Merge branch 'develop' into user_management

This commit is contained in:
luce 2025-09-09 11:44:47 +02:00
commit 71cf3a52f2
115 changed files with 3080 additions and 2422 deletions

View file

@ -0,0 +1,8 @@
### Release checklist
* [ ] Bump version in `package.json`
* [ ] Compile a changelog with the `tools/collect-changelog` script
* [ ] Create an MR with an announcement to pleroma.social
#### post-merge
* [ ] Tag the release on the merge commit
* [ ] Make the tag into a Gitlab Release™
* [ ] Merge `master` into `develop` (in case the fixes are already in develop, use `git merge -s ours --no-commit` and manually merge the changelogs)

View file

@ -2,6 +2,42 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## 2.9.2
### Changed
- BREAKING: due to some internal technical changes logging into AdminFE through PleromaFE is no longer possible
- User card/profile got an overhaul
- Profile editing overhaul
- Visually combined subject and content fields in post form
- Moved post form's emoji button into input field
- Minor visual changes and fixes
- Clicking on fav/rt/emoji notifications' contents expands/collapses it
- Reduced time taken processing theme by half
- Splash screen only appears if loading takes more than 2 seconds
### Added
- Mutes received an update, adding support for regex, muting based on username and expiration time.
- Mutes are now synchronized across sessions
- Support for expiring mutes and blocks (if available)
- Clicking on emoji shows bigger version of it alongside with its shortcode
- Admins also are able to copy it into a local pack
- Added support for Akkoma and IceShrimp.NET backends
- Compatibility with stricter CSP (Akkoma backend)
- Added a way to upload new packs from a URL or ZIP file via the Admin Dashboard
- Unify show/hide content buttons
- Add support for detachable scrollTop button
- Option to left-align user bio
- Cache assets and emojis with service worker
- Indicate currently active V3 theme as a body element class
- Add arithmetic blend ISS function
### Fixed
- Display counter for status action buttons when they are in the menu
- Fix bookmark button alignment in the extra actions menu
- Instance favicons are no longer stretched
- A lot more scalable UI fixes
- Emoji picker now should work fine when emoji size is increased
## 2.8.0 ## 2.8.0
### Changed ### Changed
- BREAKING: static/img/nsfw.2958239.png is now static/img/nsfw.DepQPhG0.png, which may affect people who specify exactly this path as the cover image - BREAKING: static/img/nsfw.2958239.png is now static/img/nsfw.DepQPhG0.png, which may affect people who specify exactly this path as the cover image

View file

@ -1 +0,0 @@
Display counter for status action buttons when they are on the menu

View file

@ -1 +0,0 @@
Added support for Akkoma and IceShrimp.NET backend

View file

@ -1,2 +0,0 @@
Add arithmetic blend ISS function

View file

@ -1 +0,0 @@
Add support for detachable scrollTop button

View file

@ -1 +0,0 @@
Fix bookmark button alignment in the extra actions menu

View file

@ -1 +0,0 @@
Compatibility with stricter CSP (Akkoma backend)

View file

@ -1 +0,0 @@
Synchronized mutes, advanced mute control (regexp, expiry, naming)

View file

@ -1 +0,0 @@
Fix error styling for user profiles

View file

@ -1 +0,0 @@
Cache assets and emojis with service worker

View file

@ -1 +0,0 @@
Indicate currently active V3 theme as a body element class

View file

@ -1 +0,0 @@
Support for expiring mutes and blocks (if available)

View file

@ -1 +0,0 @@
Unify show/hide content buttons

View file

@ -1 +0,0 @@
User card/profile got an overhaul

View file

@ -16,7 +16,7 @@
</head> </head>
<body> <body>
<noscript>To use Pleroma, please enable JavaScript.</noscript> <noscript>To use Pleroma, please enable JavaScript.</noscript>
<div id="splash"> <div id="splash" class="initial-hidden">
<!-- we are hiding entire graphic so no point showing credit --> <!-- we are hiding entire graphic so no point showing credit -->
<div aria-hidden="true" id="splash-credit"> <div aria-hidden="true" id="splash-credit">
Art by pipivovott Art by pipivovott

View file

@ -1,6 +1,6 @@
{ {
"name": "pleroma_fe", "name": "pleroma_fe",
"version": "2.7.1", "version": "2.9.2",
"description": "Pleroma frontend, the default frontend of Pleroma social network server", "description": "Pleroma frontend, the default frontend of Pleroma social network server",
"author": "Pleroma contributors <https://git.pleroma.social/pleroma/pleroma-fe/-/blob/develop/CONTRIBUTORS.md>", "author": "Pleroma contributors <https://git.pleroma.social/pleroma/pleroma-fe/-/blob/develop/CONTRIBUTORS.md>",
"private": false, "private": false,
@ -17,29 +17,29 @@
"lint-fix": "eslint --fix src test/unit/specs test/e2e/specs" "lint-fix": "eslint --fix src test/unit/specs test/e2e/specs"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.27.1", "@babel/runtime": "7.28.3",
"@chenfengyuan/vue-qrcode": "2.0.0", "@chenfengyuan/vue-qrcode": "2.0.0",
"@fortawesome/fontawesome-svg-core": "6.7.2", "@fortawesome/fontawesome-svg-core": "6.7.2",
"@fortawesome/free-regular-svg-icons": "6.7.2", "@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2", "@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/vue-fontawesome": "3.0.8", "@fortawesome/vue-fontawesome": "3.1.1",
"@kazvmoe-infra/pinch-zoom-element": "1.3.0", "@kazvmoe-infra/pinch-zoom-element": "1.3.0",
"@kazvmoe-infra/unicode-emoji-json": "0.4.0", "@kazvmoe-infra/unicode-emoji-json": "0.4.0",
"@ruffle-rs/ruffle": "0.1.0-nightly.2025.1.13", "@ruffle-rs/ruffle": "0.1.0-nightly.2025.6.22",
"@vuelidate/core": "2.0.3", "@vuelidate/core": "2.0.3",
"@vuelidate/validators": "2.0.4", "@vuelidate/validators": "2.0.4",
"@web3-storage/parse-link-header": "^3.1.0", "@web3-storage/parse-link-header": "^3.1.0",
"body-scroll-lock": "3.1.5", "body-scroll-lock": "3.1.5",
"chromatism": "3.0.0", "chromatism": "3.0.0",
"click-outside-vue3": "4.0.1", "click-outside-vue3": "4.0.1",
"cropperjs": "2.0.0", "cropperjs": "2.0.1",
"escape-html": "1.0.3", "escape-html": "1.0.3",
"globals": "^16.0.0", "globals": "^16.0.0",
"hash-sum": "^2.0.0", "hash-sum": "^2.0.0",
"js-cookie": "3.0.5", "js-cookie": "3.0.5",
"localforage": "1.10.0", "localforage": "1.10.0",
"parse-link-header": "2.0.0", "parse-link-header": "2.0.0",
"phoenix": "1.7.21", "phoenix": "1.8.0",
"pinia": "^3.0.0", "pinia": "^3.0.0",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"qrcode": "1.5.4", "qrcode": "1.5.4",
@ -47,53 +47,53 @@
"url": "0.11.4", "url": "0.11.4",
"utf8": "3.0.0", "utf8": "3.0.0",
"uuid": "11.1.0", "uuid": "11.1.0",
"vue": "3.5.17", "vue": "3.5.19",
"vue-i18n": "11", "vue-i18n": "11",
"vue-router": "4.5.1", "vue-router": "4.5.1",
"vue-virtual-scroller": "^2.0.0-beta.7", "vue-virtual-scroller": "^2.0.0-beta.7",
"vuex": "4.1.0" "vuex": "4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.27.1", "@babel/core": "7.28.3",
"@babel/eslint-parser": "7.27.1", "@babel/eslint-parser": "7.28.0",
"@babel/plugin-transform-runtime": "7.27.1", "@babel/plugin-transform-runtime": "7.28.3",
"@babel/preset-env": "7.27.2", "@babel/preset-env": "7.28.3",
"@babel/register": "7.27.1", "@babel/register": "7.28.3",
"@ungap/event-target": "0.2.4", "@ungap/event-target": "0.2.4",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1", "@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitest/browser": "^3.0.7", "@vitest/browser": "^3.0.7",
"@vitest/ui": "^3.0.7", "@vitest/ui": "^3.0.7",
"@vue/babel-helper-vue-jsx-merge-props": "1.4.0", "@vue/babel-helper-vue-jsx-merge-props": "1.4.0",
"@vue/babel-plugin-jsx": "1.4.0", "@vue/babel-plugin-jsx": "1.5.0",
"@vue/compiler-sfc": "3.5.17", "@vue/compiler-sfc": "3.5.19",
"@vue/test-utils": "2.4.6", "@vue/test-utils": "2.4.6",
"autoprefixer": "10.4.21", "autoprefixer": "10.4.21",
"babel-plugin-lodash": "3.3.4", "babel-plugin-lodash": "3.3.4",
"chai": "5.2.0", "chai": "5.3.2",
"chalk": "5.4.1", "chalk": "5.6.0",
"chromedriver": "135.0.4", "chromedriver": "135.0.4",
"connect-history-api-fallback": "2.0.0", "connect-history-api-fallback": "2.0.0",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"custom-event-polyfill": "1.0.7", "custom-event-polyfill": "1.0.7",
"eslint": "9.26.0", "eslint": "9.33.0",
"vue-eslint-parser": "10.1.3", "vue-eslint-parser": "10.2.0",
"eslint-config-standard": "17.1.0", "eslint-config-standard": "17.1.0",
"eslint-formatter-friendly": "7.0.0", "eslint-formatter-friendly": "7.0.0",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.32.0",
"eslint-plugin-n": "17.18.0", "eslint-plugin-n": "17.21.3",
"eslint-plugin-promise": "7.2.1", "eslint-plugin-promise": "7.2.1",
"eslint-plugin-vue": "10.1.0", "eslint-plugin-vue": "10.4.0",
"eventsource-polyfill": "0.9.6", "eventsource-polyfill": "0.9.6",
"express": "5.1.0", "express": "5.1.0",
"function-bind": "1.1.2", "function-bind": "1.1.2",
"http-proxy-middleware": "3.0.5", "http-proxy-middleware": "3.0.5",
"iso-639-1": "3.1.5", "iso-639-1": "3.1.5",
"lodash": "4.17.21", "lodash": "4.17.21",
"msw": "2.10.2", "msw": "2.10.5",
"nightwatch": "3.12.1", "nightwatch": "3.12.2",
"playwright": "1.52.0", "playwright": "1.55.0",
"postcss": "8.5.3", "postcss": "8.5.6",
"postcss-html": "^1.5.0", "postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6", "postcss-scss": "^4.0.6",
"sass": "1.89.2", "sass": "1.89.2",

View file

@ -5,15 +5,14 @@ body {
#splash { #splash {
--scale: 1; --scale: 1;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
display: grid; display: grid;
grid-template-rows: auto; grid-template-rows: auto;
grid-template-columns: auto; grid-template-columns: auto;
align-content: center; align-content: center;
align-items: center; place-items: center;
justify-content: center;
justify-items: center;
flex-direction: column; flex-direction: column;
background: #0f161e; background: #0f161e;
font-family: sans-serif; font-family: sans-serif;
@ -21,13 +20,20 @@ body {
position: absolute; position: absolute;
z-index: 9999; z-index: 9999;
font-size: calc(1vw + 1vh + 1vmin); font-size: calc(1vw + 1vh + 1vmin);
opacity: 1;
transition: opacity 500ms ease-out 2s;
}
#splash.hidden,
#splash.initial-hidden {
opacity: 0;
} }
#splash-credit { #splash-credit {
position: absolute; position: absolute;
font-size: 14px; font-size: 1em;
bottom: 16px; bottom: 1em;
right: 16px; right: 1em;
} }
#splash-container { #splash-container {

View file

@ -214,6 +214,9 @@ export default {
splashscreenRoot.addEventListener('transitionend', () => { splashscreenRoot.addEventListener('transitionend', () => {
splashscreenRoot.remove() splashscreenRoot.remove()
}) })
setTimeout(() => {
splashscreenRoot.remove() // forcibly remove it, should fix my plasma browser widget t. HJ
}, 600)
splashscreenRoot.classList.add('hidden') splashscreenRoot.classList.add('hidden')
document.querySelector('#app').classList.remove('hidden') document.querySelector('#app').classList.remove('hidden')
} }

View file

@ -21,7 +21,7 @@
} }
html { html {
font-size: var(--textSize, 14px); font-size: var(--textSize, 1rem);
--navbar-height: var(--navbarSize, 3.5rem); --navbar-height: var(--navbarSize, 3.5rem);
--emoji-size: var(--emojiSize, 32px); --emoji-size: var(--emojiSize, 32px);
@ -382,6 +382,10 @@ nav {
font-family: sans-serif; font-family: sans-serif;
font-family: var(--font); font-family: var(--font);
&.-transparent {
backdrop-filter: blur(0.125em) contrast(60%);
}
&::-moz-focus-inner { &::-moz-focus-inner {
border: none; border: none;
} }
@ -741,17 +745,15 @@ option {
} }
&.-dot { &.-dot {
min-height: 8px; min-height: 0.6em;
max-height: 8px; max-height: 0.6em;
min-width: 8px; min-width: 0.6em;
max-width: 8px; max-width: 0.6em;
padding: 0; left: calc(50% + 0.5em);
top: calc(50% - 1em);
line-height: 0; line-height: 0;
font-size: 0; padding: 0;
left: calc(50% - 4px); margin: 0;
top: calc(50% - 4px);
margin-left: 6px;
margin-top: -6px;
} }
&.-counter { &.-counter {
@ -782,12 +784,6 @@ option {
color: var(--text); color: var(--text);
} }
.visibility-notice {
padding: 0.5em;
border: 1px solid var(--textFaint);
border-radius: var(--roundness);
}
.notice-dismissible { .notice-dismissible {
padding-right: 4rem; padding-right: 4rem;
position: relative; position: relative;
@ -935,12 +931,7 @@ option {
#splash { #splash {
pointer-events: none; pointer-events: none;
transition: opacity 0.5s; // transition: opacity 0.5s;
opacity: 1;
&.hidden {
opacity: 0;
}
#status { #status {
&.css-ok { &.css-ok {

View file

@ -38,6 +38,9 @@ const AccountActions = {
hideConfirmRemoveUserFromFollowers () { hideConfirmRemoveUserFromFollowers () {
this.showingConfirmRemoveFollower = false this.showingConfirmRemoveFollower = false
}, },
hideConfirmBlock () {
this.showingConfirmBlock = false
},
showRepeats () { showRepeats () {
this.$store.dispatch('showReblogs', this.user.id) this.$store.dispatch('showReblogs', this.user.id)
}, },
@ -56,7 +59,7 @@ const AccountActions = {
} }
}, },
doBlockUser () { doBlockUser () {
this.$store.dispatch('blockUser', this.user.id) this.$store.dispatch('blockUser', { id: this.user.id })
this.hideConfirmBlock() this.hideConfirmBlock()
}, },
unblockUser () { unblockUser () {

View file

@ -107,9 +107,9 @@
.play-icon { .play-icon {
position: absolute; position: absolute;
font-size: 64px; font-size: 4.5em;
top: calc(50% - 32px); top: calc(50% - 2.25rem);
left: calc(50% - 32px); left: calc(50% - 2.25rem);
color: rgb(255 255 255 / 75%); color: rgb(255 255 255 / 75%);
text-shadow: 0 0 2px rgb(0 0 0 / 40%); text-shadow: 0 0 2px rgb(0 0 0 / 40%);

View file

@ -1,27 +0,0 @@
export default {
name: 'Attachment',
selector: '.Attachment',
notEditable: true,
validInnerComponents: [
'Border',
'Button',
'Input'
],
defaultRules: [
{
directives: {
roundness: 3
}
},
{
component: 'Button',
parent: {
component: 'Attachment'
},
directives: {
background: '#FFFFFF',
opacity: 0.5
}
}
]
}

View file

@ -23,7 +23,7 @@
> >
<button <button
v-if="remove" v-if="remove"
class="button-default attachment-button" class="button-default attachment-button -transparent"
@click.prevent="onRemove" @click.prevent="onRemove"
> >
<FAIcon icon="trash-alt" /> <FAIcon icon="trash-alt" />
@ -81,7 +81,7 @@
> >
<button <button
v-if="type === 'flash' && flashLoaded" v-if="type === 'flash' && flashLoaded"
class="button-default attachment-button" class="button-default attachment-button -transparent"
:title="$t('status.attachment_stop_flash')" :title="$t('status.attachment_stop_flash')"
@click.prevent="stopFlash" @click.prevent="stopFlash"
> >
@ -89,7 +89,7 @@
</button> </button>
<button <button
v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'" v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'"
class="button-default attachment-button" class="button-default attachment-button -transparent"
:title="$t('status.show_attachment_description')" :title="$t('status.show_attachment_description')"
@click.prevent="toggleDescription" @click.prevent="toggleDescription"
> >
@ -97,7 +97,7 @@
</button> </button>
<button <button
v-if="!useModal && type !== 'unknown'" v-if="!useModal && type !== 'unknown'"
class="button-default attachment-button" class="button-default attachment-button -transparent"
:title="$t('status.show_attachment_in_modal')" :title="$t('status.show_attachment_in_modal')"
@click.prevent="openModalForce" @click.prevent="openModalForce"
> >
@ -105,7 +105,7 @@
</button> </button>
<button <button
v-if="nsfw && hideNsfwLocal" v-if="nsfw && hideNsfwLocal"
class="button-default attachment-button" class="button-default attachment-button -transparent"
:title="$t('status.hide_attachment')" :title="$t('status.hide_attachment')"
@click.prevent="toggleHidden" @click.prevent="toggleHidden"
> >
@ -113,7 +113,7 @@
</button> </button>
<button <button
v-if="shiftUp" v-if="shiftUp"
class="button-default attachment-button" class="button-default attachment-button -transparent"
:title="$t('status.move_up')" :title="$t('status.move_up')"
@click.prevent="onShiftUp" @click.prevent="onShiftUp"
> >
@ -121,7 +121,7 @@
</button> </button>
<button <button
v-if="shiftDn" v-if="shiftDn"
class="button-default attachment-button" class="button-default attachment-button -transparent"
:title="$t('status.move_down')" :title="$t('status.move_down')"
@click.prevent="onShiftDn" @click.prevent="onShiftDn"
> >
@ -129,7 +129,7 @@
</button> </button>
<button <button
v-if="remove" v-if="remove"
class="button-default attachment-button" class="button-default attachment-button -transparent"
:title="$t('status.remove_attachment')" :title="$t('status.remove_attachment')"
@click.prevent="onRemove" @click.prevent="onRemove"
> >

View file

@ -37,7 +37,7 @@ const BlockCard = {
if (this.blockExpirationSupported) { if (this.blockExpirationSupported) {
this.$refs.timedBlockDialog.optionallyPrompt() this.$refs.timedBlockDialog.optionallyPrompt()
} else { } else {
this.$store.dispatch('blockUser', this.user.id) this.$store.dispatch('blockUser', { id: this.user.id })
} }
} }
} }

View file

@ -10,7 +10,7 @@ export default {
// normal: '' // normal state is implicitly added, it is always included // normal: '' // normal state is implicitly added, it is always included
toggled: '.toggled', toggled: '.toggled',
focused: ':focus-within', focused: ':focus-within',
pressed: ':focus:active', pressed: ':active',
hover: ':is(:hover, :focus-visible):not(:disabled)', hover: ':is(:hover, :focus-visible):not(:disabled)',
disabled: ':disabled' disabled: ':disabled'
}, },
@ -18,7 +18,8 @@ export default {
variants: { variants: {
// Variants save on computation time since adding new variant just adds one more "set". // Variants save on computation time since adding new variant just adds one more "set".
// normal: '', // you can override normal variant, it will be appenended to the main class // normal: '', // you can override normal variant, it will be appenended to the main class
danger: '.danger' danger: '.-danger',
transparent: '.-transparent'
// Overall the compuation difficulty is N*((1/6)M^3+M) where M is number of distinct states and N is number of variants. // Overall the compuation difficulty is N*((1/6)M^3+M) where M is number of distinct states and N is number of variants.
// This (currently) is further multipled by number of places where component can exist. // This (currently) is further multipled by number of places where component can exist.
}, },
@ -51,6 +52,38 @@ export default {
roundness: 3 roundness: 3
} }
}, },
{
variant: 'danger',
directives: {
background: '--cRed'
}
},
{
variant: 'transparent',
directives: {
opacity: 0.5
}
},
{
component: 'Text',
parent: {
component: 'Button',
variant: 'transparent'
},
directives: {
textColor: '--text'
}
},
{
component: 'Icon',
parent: {
component: 'Button',
variant: 'transparent'
},
directives: {
textColor: '--text'
}
},
{ {
state: ['hover'], state: ['hover'],
directives: { directives: {
@ -96,6 +129,13 @@ export default {
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'] shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel']
} }
}, },
{
state: ['toggled', 'hover', 'focused'],
directives: {
background: '--accent,-24.2',
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel']
}
},
{ {
state: ['toggled', 'disabled'], state: ['toggled', 'disabled'],
directives: { directives: {

View file

@ -8,9 +8,6 @@ export default {
'Text', 'Text',
'Icon', 'Icon',
'Border', 'Border',
'Button',
'RichContent',
'Attachment',
'PollGraph' 'PollGraph'
], ],
defaultRules: [ defaultRules: [

View file

@ -19,6 +19,7 @@
:title="'@'+(user && user.screen_name_ui)" :title="'@'+(user && user.screen_name_ui)"
:html="htmlTitle" :html="htmlTitle"
:emoji="user.emoji || []" :emoji="user.emoji || []"
:is-local="user.is_local"
/> />
</div> </div>
</template> </template>

View file

@ -45,10 +45,10 @@
inset: 0; inset: 0;
justify-content: center; justify-content: center;
place-items: center center; place-items: center center;
overflow: auto;
} }
.dialog-modal.panel { .dialog-modal.panel {
max-height: 80vh;
max-width: 90vw; max-width: 90vw;
z-index: 2001; z-index: 2001;
cursor: default; cursor: default;

View file

@ -205,12 +205,6 @@ const EmojiInput = {
return emoji.displayText return emoji.displayText
} }
}, },
onInputScroll () {
this.$refs.hiddenOverlay.scrollTo({
top: this.input.scrollTop,
left: this.input.scrollLeft
})
},
suggestionListId () { suggestionListId () {
return `suggestions-${this.randomSeed}` return `suggestions-${this.randomSeed}`
}, },
@ -239,7 +233,6 @@ const EmojiInput = {
this.overlayStyle.fontSize = style.fontSize this.overlayStyle.fontSize = style.fontSize
this.overlayStyle.wordWrap = style.wordWrap this.overlayStyle.wordWrap = style.wordWrap
this.overlayStyle.whiteSpace = style.whiteSpace this.overlayStyle.whiteSpace = style.whiteSpace
this.resize()
input.addEventListener('blur', this.onBlur) input.addEventListener('blur', this.onBlur)
input.addEventListener('focus', this.onFocus) input.addEventListener('focus', this.onFocus)
input.addEventListener('paste', this.onPaste) input.addEventListener('paste', this.onPaste)
@ -302,6 +295,13 @@ const EmojiInput = {
} }
}, },
methods: { methods: {
onInputScroll (e) {
this.$refs.hiddenOverlay.scrollTo({
top: this.input.scrollTop,
left: this.input.scrollLeft
})
this.setCaret(e)
},
triggerShowPicker () { triggerShowPicker () {
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.picker.showPicker() this.$refs.picker.showPicker()
@ -561,8 +561,6 @@ const EmojiInput = {
this.$refs.suggestorPopover.updateStyles() this.$refs.suggestorPopover.updateStyles()
}) })
}, },
resize () {
},
autoCompleteItemLabel (suggestion) { autoCompleteItemLabel (suggestion) {
if (suggestion.user) { if (suggestion.user) {
return suggestion.displayText + ' ' + suggestion.detailText return suggestion.displayText + ' ' + suggestion.detailText

View file

@ -123,7 +123,7 @@
margin: 0.2em 0.25em; margin: 0.2em 0.25em;
font-size: 1.3em; font-size: 1.3em;
cursor: pointer; cursor: pointer;
line-height: 24px; line-height: 1.2em;
&:hover i { &:hover i {
color: var(--text); color: var(--text);
@ -133,7 +133,7 @@
.emoji-picker-panel { .emoji-picker-panel {
position: absolute; position: absolute;
z-index: 20; z-index: 20;
margin-top: 2px; margin-top: 0.2em;
&.hide { &.hide {
display: none; display: none;
@ -152,7 +152,7 @@
} }
&.with-picker input { &.with-picker input {
padding-right: 30px; padding-right: 2em;
} }
.hidden-overlay { .hidden-overlay {
@ -215,8 +215,8 @@
} }
.detailText { .detailText {
font-size: 9px; font-size: 0.6em;
line-height: 9px; line-height: 0.6em;
} }
} }
} }

View file

@ -140,13 +140,13 @@ const EmojiPicker = {
}, },
updateEmojiSize () { updateEmojiSize () {
const css = window.getComputedStyle(this.$refs.popover.$el) const css = window.getComputedStyle(this.$refs.popover.$el)
const fontSize = css.getPropertyValue('font-size') || '14px' const fontSize = css.getPropertyValue('font-size') || '1rem'
const emojiSize = css.getPropertyValue('--emojiSize') || '2.2rem' const emojiSize = css.getPropertyValue('--emojiSize') || '2.2rem'
const fontSizeUnit = fontSize.replace(/[0-9,.]+/, '') const fontSizeUnit = fontSize.replace(/[0-9,.]+/, '').trim()
const fontSizeValue = Number(fontSize.replace(/[^0-9,.]+/, '')) const fontSizeValue = Number(fontSize.replace(/[^0-9,.]+/, ''))
const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '') const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '').trim()
const emojiSizeValue = Number(emojiSize.replace(/[^0-9,.]+/, '')) const emojiSizeValue = Number(emojiSize.replace(/[^0-9,.]+/, ''))
let fontSizeMultiplier let fontSizeMultiplier

View file

@ -129,7 +129,7 @@
.gallery-item { .gallery-item {
margin: 0; margin: 0;
height: 200px; height: 15em;
} }
} }
} }

View file

@ -10,46 +10,26 @@ library.add(
const ImageCropper = { const ImageCropper = {
props: { props: {
trigger: { // Mime-types to accept, i.e. which filetypes to accept (.gif, .png, etc.)
type: [String, window.Element],
required: true
},
submitHandler: {
type: Function,
required: true
},
mimes: { mimes: {
type: String, type: String,
default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon' default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon'
}, },
saveButtonLabel: { // Fixed aspect-ratio for selection box
type: String aspectRatio: {
}, type: Number
saveWithoutCroppingButtonlabel: {
type: String
},
cancelButtonLabel: {
type: String
} }
}, },
data () { data () {
return { return {
dataUrl: undefined, dataUrl: undefined,
filename: undefined, filename: undefined
submitting: false
}
},
computed: {
saveText () {
return this.saveButtonLabel || this.$t('image_cropper.save')
},
saveWithoutCroppingText () {
return this.saveWithoutCroppingButtonlabel || this.$t('image_cropper.save_without_cropping')
},
cancelText () {
return this.cancelButtonLabel || this.$t('image_cropper.cancel')
} }
}, },
emits: [
'submit', // cropping complete or uncropped image returned
'close', // cropper is closed
],
methods: { methods: {
destroy () { destroy () {
this.$refs.input.value = '' this.$refs.input.value = ''
@ -57,28 +37,20 @@ const ImageCropper = {
this.$emit('close') this.$emit('close')
}, },
submit (cropping = true) { submit (cropping = true) {
this.submitting = true
let cropperPromise let cropperPromise
if (cropping) { if (cropping) {
cropperPromise = this.$refs.cropperSelection.$toCanvas() cropperPromise = this.$refs.cropperSelection.$toCanvas()
} else { } else {
cropperPromise = Promise.resolve() cropperPromise = Promise.resolve()
} }
cropperPromise.then(canvas => { cropperPromise.then(canvas => {
this.submitHandler(canvas, this.file) this.$emit('submit', { canvas, file: this.file })
.then(() => this.destroy())
.finally(() => {
this.submitting = false
})
}) })
}, },
pickImage () { pickImage () {
this.$refs.input.click() this.$refs.input.click()
}, },
getTriggerDOM () {
return typeof this.trigger === 'object' ? this.trigger : document.querySelector(this.trigger)
},
readFile () { readFile () {
const fileInput = this.$refs.input const fileInput = this.$refs.input
if (fileInput.files != null && fileInput.files[0] != null) { if (fileInput.files != null && fileInput.files[0] != null) {
@ -117,23 +89,11 @@ const ImageCropper = {
} }
}, },
mounted () { mounted () {
// listen for click event on trigger
const trigger = this.getTriggerDOM()
if (!trigger) {
this.$emit('error', 'No image make trigger found.', 'user')
} else {
trigger.addEventListener('click', this.pickImage)
}
// listen for input file changes // listen for input file changes
const fileInput = this.$refs.input const fileInput = this.$refs.input
fileInput.addEventListener('change', this.readFile) fileInput.addEventListener('change', this.readFile)
}, },
beforeUnmount: function () { beforeUnmount: function () {
// remove the event listeners
const trigger = this.getTriggerDOM()
if (trigger) {
trigger.removeEventListener('click', this.pickImage)
}
const fileInput = this.$refs.input const fileInput = this.$refs.input
fileInput.removeEventListener('change', this.readFile) fileInput.removeEventListener('change', this.readFile)
} }

View file

@ -1,13 +1,14 @@
<template> <template>
<div class="image-cropper"> <div class="image-cropper">
<div v-if="dataUrl"> <div class="image">
<cropper-canvas <cropper-canvas
ref="cropperCanvas" ref="cropperCanvas"
background background
class="image-cropper-canvas" class="image-cropper-canvas"
height="25em" height="100%"
> >
<cropper-image <cropper-image
v-if="dataUrl"
ref="cropperImage" ref="cropperImage"
:src="dataUrl" :src="dataUrl"
alt="Picture" alt="Picture"
@ -22,8 +23,8 @@
/> />
<cropper-selection <cropper-selection
ref="cropperSelection" ref="cropperSelection"
initial-coverage="1" initial-coverage="0.9"
aspect-ratio="1" :aspect-ratio="aspectRatio"
movable movable
resizable resizable
@change="onCropperSelectionChange" @change="onCropperSelectionChange"
@ -47,34 +48,6 @@
<cropper-handle action="sw-resize" /> <cropper-handle action="sw-resize" />
</cropper-selection> </cropper-selection>
</cropper-canvas> </cropper-canvas>
<div class="image-cropper-buttons-wrapper">
<button
class="button-default btn"
type="button"
:disabled="submitting"
@click="submit()"
v-text="saveText"
/>
<button
class="button-default btn"
type="button"
:disabled="submitting"
@click="destroy"
v-text="cancelText"
/>
<button
class="button-default btn"
type="button"
:disabled="submitting"
@click="submit(false)"
v-text="saveWithoutCroppingText"
/>
<FAIcon
v-if="submitting"
spin
icon="circle-notch"
/>
</div>
</div> </div>
<input <input
ref="input" ref="input"
@ -89,13 +62,15 @@
<style lang="scss"> <style lang="scss">
.image-cropper { .image-cropper {
&-img-input { display: flex;
display: none; flex-direction: column;
&-canvas, .image {
height: 100%;
} }
&-canvas { & &-img-input {
height: 25em; display: none;
width: 25em;
} }
&-buttons-wrapper { &-buttons-wrapper {

View file

@ -0,0 +1,58 @@
import localeService from '../../services/locale/locale.service.js'
import Select from '../select/select.vue'
import ProfileSettingIndicator from 'src/components/settings_modal/helpers/profile_setting_indicator.vue'
export default {
components: {
Select,
ProfileSettingIndicator
},
props: {
// List of languages (or just one language)
modelValue: {
type: [Array, String],
required: true
},
// Is this setting stored in user profile (true) or elsewhere (false)
// Doesn't affect storage, just shows an icon if true
profile: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue'],
computed: {
languages () {
return localeService.languages
},
controlledLanguage: {
get: function () {
return Array.isArray(this.modelValue) ? this.modelValue : [this.modelValue]
},
set: function (val) {
this.$emit('update:modelValue', val)
}
}
},
methods: {
getLanguageName (code) {
return localeService.getLanguageName(code)
},
addLanguage () {
this.controlledLanguage = [...this.controlledLanguage, '']
},
setLanguageAt (index, val) {
const lang = [...this.controlledLanguage]
lang[index] = val
this.controlledLanguage = lang
},
removeLanguageAt (index) {
const lang = [...this.controlledLanguage]
lang.splice(index, 1)
this.controlledLanguage = lang
}
}
}

View file

@ -1,7 +1,8 @@
<template> <template>
<div class="interface-language-switcher"> <div class="interface-language-switcher">
<label> <label>
{{ promptText }} <slot />
<ProfileSettingIndicator :is-profile="profile" />
</label> </label>
<ul class="setting-list"> <ul class="setting-list">
<li <li
@ -44,64 +45,7 @@
</div> </div>
</template> </template>
<script> <script src="./interface_language_switcher.js"></script>
import localeService from '../../services/locale/locale.service.js'
import Select from '../select/select.vue'
export default {
components: {
// eslint-disable-next-line vue/no-reserved-component-names
Select
},
props: {
promptText: {
type: String,
required: true
},
language: {
type: [Array, String],
required: true
},
setLanguage: {
type: Function,
required: true
}
},
computed: {
languages () {
return localeService.languages
},
controlledLanguage: {
get: function () {
return Array.isArray(this.language) ? this.language : [this.language]
},
set: function (val) {
this.setLanguage(val)
}
}
},
methods: {
getLanguageName (code) {
return localeService.getLanguageName(code)
},
addLanguage () {
this.controlledLanguage = [...this.controlledLanguage, '']
},
setLanguageAt (index, val) {
const lang = [...this.controlledLanguage]
lang[index] = val
this.controlledLanguage = lang
},
removeLanguageAt (index) {
const lang = [...this.controlledLanguage]
lang.splice(index, 1)
this.controlledLanguage = lang
}
}
}
</script>
<style lang="scss"> <style lang="scss">
.interface-language-switcher { .interface-language-switcher {

View file

@ -1,48 +0,0 @@
export default {
name: 'ListItem',
selector: '.list-item',
states: {
active: '.-active',
hover: ':is(:hover, :focus-visible, :has(:focus-visible)):not(.-non-interactive)'
},
validInnerComponents: [
'Text',
'Link',
'Icon',
'Border',
'Button',
'ButtonUnstyled',
'RichContent',
'Input',
'Avatar'
],
defaultRules: [
{
directives: {
background: '--bg',
opacity: 0
}
},
{
state: ['active'],
directives: {
background: '--inheritedBackground, 10',
opacity: 1
}
},
{
state: ['hover'],
directives: {
background: '--inheritedBackground, 10',
opacity: 1
}
},
{
state: ['hover', 'active'],
directives: {
background: '--inheritedBackground, 20',
opacity: 1
}
}
]
}

View file

@ -168,7 +168,7 @@ $modal-view-button-icon-margin: 0.5em;
flex: 0 0 auto; flex: 0 0 auto;
overflow-y: auto; overflow-y: auto;
min-height: 1em; min-height: 1em;
max-width: 500px; max-width: 35.8em;
max-height: 9.5em; max-height: 9.5em;
overflow-wrap: break-word; overflow-wrap: break-word;
text-wrap: pretty; text-wrap: pretty;

View file

@ -7,7 +7,6 @@
& .new, & .new,
& .original { & .original {
display: inline; display: inline;
border-radius: 2px;
} }
.mention-avatar { .mention-avatar {

View file

@ -4,11 +4,7 @@ export default {
validInnerComponents: [ validInnerComponents: [
'Text', 'Text',
'Icon', 'Icon',
'Input', 'Border'
'Border',
'ButtonUnstyled',
'Badge',
'Avatar'
], ],
states: { states: {
hover: ':is(:hover, :focus-visible, :has(:focus-visible)):not(.disabled)', hover: ':is(:hover, :focus-visible, :has(:focus-visible)):not(.disabled)',

View file

@ -2,18 +2,7 @@ export default {
name: 'MobileDrawer', name: 'MobileDrawer',
selector: '.mobile-drawer', selector: '.mobile-drawer',
validInnerComponents: [ validInnerComponents: [
'Text', 'MenuItem'
'Link',
'Icon',
'Border',
'Button',
'ButtonUnstyled',
'Input',
'PanelHeader',
'MenuItem',
'Notification',
'Alert',
'UserCard'
], ],
defaultRules: [ defaultRules: [
{ {
@ -21,21 +10,6 @@ export default {
background: '--bg', background: '--bg',
backgroundNoCssColor: 'yes' backgroundNoCssColor: 'yes'
} }
},
{
component: 'PanelHeader',
parent: { component: 'MobileDrawer' },
directives: {
background: '--fg',
shadow: [{
x: 0,
y: 0,
blur: 4,
spread: 0,
color: '#000000',
alpha: 0.6
}]
}
} }
] ]
} }

View file

@ -4,13 +4,13 @@
table { table {
width: 100%; width: 100%;
text-align: left; text-align: left;
padding-left: 10px; padding-left: 0.5em;
padding-bottom: 20px; padding-bottom: 1.1em;
th, th,
td { td {
width: 180px; width: 11em;
max-width: 360px; max-width: 25em;
overflow: hidden; overflow: hidden;
vertical-align: text-top; vertical-align: text-top;
} }

View file

@ -42,6 +42,7 @@ library.add(
const Notification = { const Notification = {
data () { data () {
return { return {
selecting: false,
statusExpanded: false, statusExpanded: false,
unmuted: false, unmuted: false,
showingApproveConfirmDialog: false, showingApproveConfirmDialog: false,
@ -62,10 +63,35 @@ const Notification = {
UserLink, UserLink,
ConfirmModal ConfirmModal
}, },
mounted () {
document.addEventListener('selectionchange', this.onContentSelect)
},
unmounted () {
document.removeEventListener('selectionchange', this.onContentSelect)
},
methods: { methods: {
toggleStatusExpanded () { toggleStatusExpanded () {
if (!this.expandable) return
this.statusExpanded = !this.statusExpanded this.statusExpanded = !this.statusExpanded
}, },
onContentSelect () {
const { isCollapsed, anchorNode, offsetNode } = document.getSelection()
if (isCollapsed) {
this.selecting = false
return
}
const within = this.$refs.root.contains(anchorNode) || this.$refs.root.contains(offsetNode)
if (within) {
this.selecting = true
} else {
this.selecting = false
}
},
onContentClick (e) {
if (!this.selecting && !e.target.closest('a') && !e.target.closest('button')) {
this.toggleStatusExpanded()
}
},
generateUserProfileLink (user) { generateUserProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}, },
@ -136,6 +162,9 @@ const Notification = {
const user = this.notification.from_profile const user = this.notification.from_profile
return highlightStyle(highlight[user.screen_name]) return highlightStyle(highlight[user.screen_name])
}, },
expandable () {
return (new Set(['like', 'pleroma:emoji_reaction', 'repeat'])).has(this.notification.type)
},
user () { user () {
return this.$store.getters.findUser(this.notification.from_profile.id) return this.$store.getters.findUser(this.notification.from_profile.id)
}, },

View file

@ -1,10 +1,15 @@
// TODO Copypaste from Status, should unify it somehow // TODO Copypaste from Status, should unify it somehow
.Notification { .Notification {
border-bottom: 1px solid; border-bottom: 1px solid;
border-color: var(--border); border-color: var(--border);
overflow-wrap: break-word; overflow-wrap: break-word;
text-wrap: pretty; text-wrap: pretty;
.status-content {
cursor: pointer;
}
&.Status { &.Status {
/* stylelint-disable-next-line declaration-no-important */ /* stylelint-disable-next-line declaration-no-important */
background-color: transparent !important; background-color: transparent !important;

View file

@ -6,12 +6,7 @@ export default {
'Link', 'Link',
'Icon', 'Icon',
'Border', 'Border',
'Button',
'ButtonUnstyled',
'RichContent',
'Input',
'Avatar', 'Avatar',
'Attachment',
'PollGraph' 'PollGraph'
], ],
defaultRules: [] defaultRules: []

View file

@ -1,6 +1,7 @@
<template> <template>
<article <article
v-if="notification.type === 'mention' || notification.type === 'status'" v-if="notification.type === 'mention' || notification.type === 'status'"
ref="root"
> >
<Status <Status
class="Notification" class="Notification"
@ -9,9 +10,17 @@
@interacted="interacted" @interacted="interacted"
/> />
</article> </article>
<article v-else> <article
v-else
ref="root"
class="NotificationParent"
:class="{ '-expandable': expandable }"
>
<div <div
v-if="needMute && !unmuted" v-if="needMute && !unmuted"
:id="'notif-' +notification.id"
:aria-expanded="statusExpanded"
:aria-controls="'notif-' +notification.id"
class="Notification container -muted" class="Notification container -muted"
> >
<small> <small>
@ -62,6 +71,7 @@
:title="'@'+notification.from_profile.screen_name_ui" :title="'@'+notification.from_profile.screen_name_ui"
:html="notification.from_profile.name_html" :html="notification.from_profile.name_html"
:emoji="notification.from_profile.emoji" :emoji="notification.from_profile.emoji"
:is-local="notification.from_profile.is_local"
/> />
</bdi> </bdi>
<!-- eslint-enable vue/no-v-html --> <!-- eslint-enable vue/no-v-html -->
@ -245,8 +255,11 @@
/> />
<template v-else> <template v-else>
<StatusContent <StatusContent
class="status-content"
:compact="!statusExpanded" :compact="!statusExpanded"
:status="notification.status" :status="notification.status"
:collapse="!statusExpanded"
@click="onContentClick"
/> />
</template> </template>
</div> </div>

View file

@ -6,29 +6,17 @@ export default {
'Link', 'Link',
'Icon', 'Icon',
'Border', 'Border',
'Button',
'ButtonUnstyled',
'Input',
'PanelHeader', 'PanelHeader',
'MenuItem',
'Post', 'Post',
'Notification', 'Notification',
'Alert', 'MenuItem'
'UserCard',
'Chat',
'Attachment',
'Tab',
'ListItem'
], ],
validInnerComponentsLite: [ validInnerComponentsLite: [
'Text', 'Text',
'Link', 'Link',
'Icon', 'Icon',
'Border', 'Border',
'Button', 'PanelHeader'
'Input',
'PanelHeader',
'Alert'
], ],
defaultRules: [ defaultRules: [
{ {

View file

@ -7,9 +7,7 @@ export default {
'Icon', 'Icon',
'Button', 'Button',
'ButtonUnstyled', 'ButtonUnstyled',
'Badge', 'Alert'
'Alert',
'Avatar'
], ],
defaultRules: [ defaultRules: [
{ {

View file

@ -6,16 +6,7 @@ export default {
modal: '.modal' modal: '.modal'
}, },
validInnerComponents: [ validInnerComponents: [
'Text', 'MenuItem'
'Link',
'Icon',
'Border',
'Button',
'ButtonUnstyled',
'Input',
'MenuItem',
'Post',
'UserCard'
], ],
defaultRules: [ defaultRules: [
{ {

View file

@ -726,10 +726,6 @@ const PostStatusForm = {
scrollerRef.scrollTop = targetScroll scrollerRef.scrollTop = targetScroll
} }
}, },
showEmojiPicker () {
this.$refs.textarea.focus()
this.$refs['emoji-input'].triggerShowPicker()
},
clearError () { clearError () {
this.error = null this.error = null
}, },

View file

@ -41,10 +41,12 @@
.form-bottom-left { .form-bottom-left {
display: flex; display: flex;
flex: 1; gap: 1.5em;
padding-right: 7px;
margin-right: 7px; button {
max-width: 10em; padding: 0.5em;
margin: -0.5em;
}
} }
.preview-heading { .preview-heading {
@ -102,10 +104,16 @@
.visibility-tray { .visibility-tray {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding-top: 5px; padding-top: 0.5em;
align-items: baseline; align-items: baseline;
} }
.visibility-notice {
border: 1px solid var(--border);
border-radius: var(--roundness);
padding: 0.5em 1em
}
.visibility-notice.edit-warning { .visibility-notice.edit-warning {
> :first-child { > :first-child {
margin-top: 0; margin-top: 0;
@ -193,6 +201,10 @@
line-height: 1.85; line-height: 1.85;
} }
.inputs-wrapper {
padding: 0;
}
.input.form-post-body { .input.form-post-body {
// TODO: make a resizable textarea component? // TODO: make a resizable textarea component?
box-sizing: content-box; // needed for easier computation of dynamic size box-sizing: content-box; // needed for easier computation of dynamic size
@ -200,6 +212,7 @@
transition: min-height 200ms 100ms; transition: min-height 200ms 100ms;
// stock padding + 1 line of text (for counter) // stock padding + 1 line of text (for counter)
padding-bottom: calc(var(--_padding) + var(--post-line-height) * 1em); padding-bottom: calc(var(--_padding) + var(--post-line-height) * 1em);
padding-right: 1.5em;
// two lines of text // two lines of text
height: calc(var(--post-line-height) * 1em); height: calc(var(--post-line-height) * 1em);
min-height: calc(var(--post-line-height) * 1em); min-height: calc(var(--post-line-height) * 1em);
@ -216,6 +229,10 @@
position: relative; position: relative;
} }
.subject-input {
border-bottom: 1px solid var(--border);
}
.character-counter { .character-counter {
position: absolute; position: absolute;
bottom: 0; bottom: 0;

View file

@ -156,67 +156,68 @@
class="preview-status" class="preview-status"
/> />
</div> </div>
<EmojiInput <div class="input inputs-wrapper">
v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)" <EmojiInput
v-model="newStatus.spoilerText" v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
enable-emoji-picker v-model="newStatus.spoilerText"
:suggest="emojiSuggestor" enable-emoji-picker
class="input form-control" :suggest="emojiSuggestor"
> class="input form-control subject-input unstyled"
<template #default="inputProps"> >
<input <template #default="inputProps">
v-model="newStatus.spoilerText" <input
type="text" v-model="newStatus.spoilerText"
:placeholder="$t('post_status.content_warning')" type="text"
:disabled="posting && !optimisticPosting" :placeholder="$t('post_status.content_warning')"
v-bind="propsToNative(inputProps)" :disabled="posting && !optimisticPosting"
size="1" v-bind="propsToNative(inputProps)"
class="input form-post-subject" size="1"
> class="input form-post-subject unstyled"
</template> >
</EmojiInput> </template>
<EmojiInput </EmojiInput>
ref="emoji-input" <EmojiInput
v-model="newStatus.status" ref="emoji-input"
:suggest="emojiUserSuggestor" v-model="newStatus.status"
:placement="emojiPickerPlacement" :suggest="emojiUserSuggestor"
class="input form-control main-input" :placement="emojiPickerPlacement"
enable-sticker-picker class="input form-control main-input unstyled"
enable-emoji-picker enable-sticker-picker
hide-emoji-button enable-emoji-picker
:newline-on-ctrl-enter="submitOnEnter" :newline-on-ctrl-enter="submitOnEnter"
@input="onEmojiInputInput" @input="onEmojiInputInput"
@sticker-uploaded="addMediaFile" @sticker-uploaded="addMediaFile"
@sticker-upload-failed="uploadFailed" @sticker-upload-failed="uploadFailed"
@shown="handleEmojiInputShow" @shown="handleEmojiInputShow"
> >
<template #default="inputProps"> <template #default="inputProps">
<textarea <textarea
ref="textarea" ref="textarea"
v-model="newStatus.status" v-model="newStatus.status"
:placeholder="placeholder || $t('post_status.default')" :placeholder="placeholder || $t('post_status.default')"
rows="1" rows="1"
cols="1" cols="1"
:disabled="posting && !optimisticPosting" :disabled="posting && !optimisticPosting"
class="input form-post-body" class="input form-post-body"
:class="{ 'scrollable-form': !!maxHeight }" :class="{ 'scrollable-form': !!maxHeight }"
v-bind="propsToNative(inputProps)" v-bind="propsToNative(inputProps)"
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)" @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
@keydown.meta.enter="postStatus($event, newStatus)" @keydown.meta.enter="postStatus($event, newStatus)"
@keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)" @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
@input="resize" @input="resize"
@compositionupdate="resize" @compositionupdate="resize"
@paste="paste" @paste="paste"
/> />
<p <p
v-if="hasStatusLengthLimit" v-if="hasStatusLengthLimit"
class="character-counter faint" class="character-counter faint"
:class="{ error: isOverLengthLimit }" :class="{ error: isOverLengthLimit }"
> >
{{ charactersLeft }} {{ charactersLeft }}
</p> </p>
</template> </template>
</EmojiInput> </EmojiInput>
</div>
<div <div
v-if="!disableScopeSelector" v-if="!disableScopeSelector"
class="visibility-tray" class="visibility-tray"
@ -236,8 +237,9 @@
> >
<Select <Select
v-model="newStatus.contentType" v-model="newStatus.contentType"
class="input form-control" class="input form-control unstyled"
:attrs="{ 'aria-label': $t('post_status.content_type_selection') }" :attrs="{ 'aria-label': $t('post_status.content_type_selection') }"
unstyled="true"
> >
<option <option
v-for="postFormat in postFormats" v-for="postFormat in postFormats"
@ -285,13 +287,6 @@
@upload-failed="uploadFailed" @upload-failed="uploadFailed"
@all-uploaded="finishedUploadingFiles" @all-uploaded="finishedUploadingFiles"
/> />
<button
class="emoji-icon button-unstyled"
:title="$t('emoji.add_emoji')"
@click="showEmojiPicker"
>
<FAIcon icon="smile-beam" />
</button>
<button <button
v-if="pollsAvailable" v-if="pollsAvailable"
class="poll-icon button-unstyled" class="poll-icon button-unstyled"

View file

@ -2,7 +2,7 @@ import { unescape, flattenDeep } from 'lodash'
import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js' import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js' import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js' import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
import StillImage from 'src/components/still-image/still-image.vue' import StillImageEmojiPopover from 'src/components/still-image/still-image-emoji-popover.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue' import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.js' import { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.js'
import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue' import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
@ -86,6 +86,24 @@ export default {
required: false, required: false,
type: Boolean, type: Boolean,
default: false default: false
},
// Collapse newlines
collapse: {
required: false,
type: Boolean,
default: false
},
/* Content comes from current instance
*
* This is used for emoji stealing popover.
* By default we assume it is, so that steal
* emoji button isn't shown where it probably
* should not be.
*/
isLocal: {
required: false,
type: Boolean,
default: true
} }
}, },
// NEVER EVER TOUCH DATA INSIDE RENDER // NEVER EVER TOUCH DATA INSIDE RENDER
@ -162,11 +180,14 @@ export default {
item, item,
this.emoji, this.emoji,
({ shortcode, url }) => { ({ shortcode, url }) => {
return <StillImage return <StillImageEmojiPopover
class="emoji img" class="emoji img"
src={url} src={url}
title={`:${shortcode}:`} title={`:${shortcode}:`}
alt={`:${shortcode}:`} alt={`:${shortcode}:`}
shortcode={shortcode}
isLocal={this.isLocal}
/> />
} }
)] )]
@ -281,11 +302,20 @@ export default {
const pass1 = convertHtmlToTree(html).map(processItem) const pass1 = convertHtmlToTree(html).map(processItem)
const pass2 = [...pass1].reverse().map(processItemReverse).reverse() const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
// DO NOT USE SLOTS they cause a re-render feedback loop here. // DO NOT USE SLOTS they cause a re-render feedback loop here.
// slots updated -> rerender -> emit -> update up the tree -> rerender -> ... // slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
// at least until vue3? // at least until vue3?
const result = <span class={['RichContent', this.faint ? '-faint' : '']}> const result =
{ pass2 } <span class={['RichContent', this.faint ? '-faint' : '']}>
{
this.collapse
? pass2.map(x => {
if (!Array.isArray(x)) return x.replace(/\n/g, ' ')
return x.map(y => y.type === 'br' ? ' ' : y)
})
: pass2
}
</span> </span>
const event = { const event = {

View file

@ -2,6 +2,7 @@
font-family: var(--font); font-family: var(--font);
&.-faint { &.-faint {
color: var(--text);
/* stylelint-disable declaration-no-important */ /* stylelint-disable declaration-no-important */
--text: var(--textFaint) !important; --text: var(--textFaint) !important;
--link: var(--linkFaint) !important; --link: var(--linkFaint) !important;
@ -63,6 +64,11 @@
.img { .img {
display: inline-block; display: inline-block;
// fix vertical alignment of stealable emoji
button {
display: inline-flex;
}
} }
.emoji { .emoji {

View file

@ -1,20 +0,0 @@
export default {
name: 'RichContent',
selector: '.RichContent',
notEditable: true,
transparent: true,
validInnerComponents: [
'Text',
'FunText',
'Link'
],
defaultRules: [
{
directives: {
'--font': 'generic | inherit',
'--monoFont': 'generic | monospace',
textNoCssColor: 'yes'
}
}
]
}

View file

@ -32,7 +32,10 @@ const EmojiTab = {
newPackName: '', newPackName: '',
deleteModalVisible: false, deleteModalVisible: false,
remotePackInstance: '', remotePackInstance: '',
remotePackDownloadAs: '' remotePackDownloadAs: '',
remotePackURL: '',
remotePackFile: null
} }
}, },
@ -142,9 +145,9 @@ const EmojiTab = {
}) })
}, },
updatePackFiles (newFiles) { updatePackFiles (newFiles, packName) {
this.pack.files = newFiles this.knownPacks[packName].files = newFiles
this.sortPackFiles(this.packName) this.sortPackFiles(packName)
}, },
loadPacksPaginated (listFunction) { loadPacksPaginated (listFunction) {
@ -220,7 +223,7 @@ const EmojiTab = {
.then(data => data.json()) .then(data => data.json())
.then(resp => { .then(resp => {
if (resp === 'ok') { if (resp === 'ok') {
this.$refs.dlPackPopover.hidePopover() this.$refs.downloadPackPopover.hidePopover()
return this.refreshPackList() return this.refreshPackList()
} else { } else {
@ -232,6 +235,47 @@ const EmojiTab = {
this.remotePackDownloadAs = '' this.remotePackDownloadAs = ''
}) })
}, },
downloadRemoteURLPack () {
this.$store.state.api.backendInteractor.downloadRemoteEmojiPackZIP({
url: this.remotePackURL, packName: this.newPackName
})
.then(data => data.json())
.then(resp => {
if (resp === 'ok') {
this.$refs.additionalRemotePopover.hidePopover()
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
}).then(() => {
this.packName = this.newPackName
this.newPackName = ''
this.remotePackURL = ''
})
},
downloadRemoteFilePack () {
this.$store.state.api.backendInteractor.downloadRemoteEmojiPackZIP({
file: this.remotePackFile[0], packName: this.newPackName
})
.then(data => data.json())
.then(resp => {
if (resp === 'ok') {
this.$refs.additionalRemotePopover.hidePopover()
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
}).then(() => {
this.packName = this.newPackName
this.newPackName = ''
this.remotePackURL = ''
})
},
displayError (msg) { displayError (msg) {
useInterfaceStore().pushGlobalNotice({ useInterfaceStore().pushGlobalNotice({
messageKey: 'admin_dash.emoji.error', messageKey: 'admin_dash.emoji.error',

View file

@ -62,6 +62,64 @@
</template> </template>
</Popover> </Popover>
</button> </button>
<button
class="button button-default emoji-panel-additional-actions"
@click="$refs.additionalRemotePopover.showPopover"
>
<FAIcon
icon="chevron-down"
/>
<Popover
ref="additionalRemotePopover"
popover-class="emoji-tab-edit-popover popover-default"
trigger="click"
placement="bottom"
bound-to-selector=".emoji-tab"
:bound-to="{ x: 'container' }"
:offset="{ y: 5 }"
>
<template #content>
<div class="emoji-tab-popover-input">
<h3>{{ $t('admin_dash.emoji.new_pack_name') }}</h3>
<input
v-model="newPackName"
:placeholder="$t('admin_dash.emoji.new_pack_name')"
class="input"
>
<h3>Import pack from URL</h3>
<input
v-model="remotePackURL"
class="input"
placeholder="Pack .zip URL"
>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
:disabled="newPackName.trim() === '' || remotePackURL.trim() === ''"
@click="downloadRemoteURLPack"
>
Import
</button>
<h3>Import pack from a file</h3>
<input
type="file"
accept="application/zip"
class="emoji-tab-popover-file input"
@change="remotePackFile = $event.target.files"
>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
:disabled="newPackName.trim() === '' || remotePackFile === null || remotePackFile.length === 0"
@click="downloadRemoteFilePack"
>
Import
</button>
</div>
</template>
</Popover>
</button>
</li> </li>
<h3>{{ $t('admin_dash.emoji.emoji_packs') }}</h3> <h3>{{ $t('admin_dash.emoji.emoji_packs') }}</h3>
@ -240,12 +298,12 @@
v-if="pack.remote !== undefined" v-if="pack.remote !== undefined"
class="button button-default btn" class="button button-default btn"
type="button" type="button"
@click="$refs.dlPackPopover.showPopover" @click="$refs.downloadPackPopover.showPopover"
> >
{{ $t('admin_dash.emoji.download_pack') }} {{ $t('admin_dash.emoji.download_pack') }}
<Popover <Popover
ref="dlPackPopover" ref="downloadPackPopover"
trigger="click" trigger="click"
placement="bottom" placement="bottom"
bound-to-selector=".emoji-tab" bound-to-selector=".emoji-tab"
@ -329,11 +387,12 @@
ref="emojiPopovers" ref="emojiPopovers"
:key="shortcode" :key="shortcode"
placement="top" placement="top"
:title="$t('admin_dash.emoji.editing', [shortcode])" :title="$t(`admin_dash.emoji.${pack.remote === undefined ? 'editing' : 'copying'}`, [shortcode])"
:disabled="pack.remote !== undefined"
:shortcode="shortcode" :shortcode="shortcode"
:file="file" :file="file"
:pack-name="packName" :pack-name="packName"
:remote="pack.remote"
:known-local-packs="knownLocalPacks"
@update-pack-files="updatePackFiles" @update-pack-files="updatePackFiles"
@display-error="displayError" @display-error="displayError"
> >

View file

@ -7,7 +7,6 @@
popover-class="emoji-tab-edit-popover popover-default" popover-class="emoji-tab-edit-popover popover-default"
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
:offset="{ y: 5 }" :offset="{ y: 5 }"
:disabled="disabled"
:class="{'emoji-unsaved': isEdited}" :class="{'emoji-unsaved': isEdited}"
> >
<template #trigger> <template #trigger>
@ -30,15 +29,27 @@
<div <div
v-if="newUpload" v-if="newUpload"
class="emoji-tab-popover-input" class="emoji-tab-popover-new-upload"
> >
<input <h4>{{ $t('admin_dash.emoji.emoji_source') }}</h4>
type="file"
accept="image/*" <div class="emoji-tab-popover-input">
class="emoji-tab-popover-file input" <input
@change="uploadFile = $event.target.files" type="file"
> accept="image/*"
class="emoji-tab-popover-file input"
@change="uploadFile = $event.target.files"
>
</div>
<div class="emoji-tab-popover-input ">
<input
v-model="uploadURL"
:placeholder="$t('admin_dash.emoji.upload_url')"
class="emoji-data-input input"
>
</div>
</div> </div>
<div> <div>
<div class="emoji-tab-popover-input"> <div class="emoji-tab-popover-input">
<label> <label>
@ -63,16 +74,50 @@
</label> </label>
</div> </div>
<div
v-if="remote !== undefined"
class="emoji-tab-popover-input"
>
<label>
{{ $t('admin_dash.emoji.copy_to') }}
<SelectComponent
v-model="copyToPack"
class="form-control"
>
<option
value=""
disabled
hidden
>
{{ $t('admin_dash.emoji.emoji_pack') }}
</option>
<option
v-for="(pack, listPackName) in knownLocalPacks"
:key="listPackName"
:label="listPackName"
>
{{ listPackName }}
</option>
</SelectComponent>
</label>
</div>
<!--
For local emojis, disable the button if nothing was edited.
For remote emojis, also disable it if a local pack is not selected.
Remote emojis are processed by the same function that uploads new ones, as that is effectively what it does
-->
<button <button
class="button button-default btn" class="button button-default btn"
type="button" type="button"
:disabled="newUpload ? uploadFile.length == 0 : !isEdited" :disabled="saveButtonDisabled"
@click="newUpload ? uploadEmoji() : saveEditedEmoji()" @click="(newUpload || remote !== undefined) ? uploadEmoji() : saveEditedEmoji()"
> >
{{ $t('admin_dash.emoji.save') }} {{ $t('admin_dash.emoji.save') }}
</button> </button>
<template v-if="!newUpload"> <template v-if="!newUpload && remote === undefined">
<button <button
class="button button-default btn emoji-tab-popover-button" class="button button-default btn emoji-tab-popover-button"
type="button" type="button"
@ -107,19 +152,16 @@
import Popover from 'components/popover/popover.vue' import Popover from 'components/popover/popover.vue'
import ConfirmModal from 'components/confirm_modal/confirm_modal.vue' import ConfirmModal from 'components/confirm_modal/confirm_modal.vue'
import StillImage from 'components/still-image/still-image.vue' import StillImage from 'components/still-image/still-image.vue'
import SelectComponent from 'components/select/select.vue'
export default { export default {
components: { Popover, ConfirmModal, StillImage }, components: { Popover, ConfirmModal, StillImage, SelectComponent },
inject: ['emojiAddr'], inject: ['emojiAddr'],
props: { props: {
placement: { placement: {
type: String, type: String,
required: true required: true
}, },
disabled: {
type: Boolean,
default: false
},
newUpload: Boolean, newUpload: Boolean,
@ -140,21 +182,35 @@ export default {
type: String, type: String,
// Only exists when this is not a new upload // Only exists when this is not a new upload
default: '' default: ''
},
// Only exists for emojis from remote packs
remote: {
type: Object,
default: undefined
},
knownLocalPacks: {
type: Object,
default: undefined
} }
}, },
emits: ['updatePackFiles', 'displayError'], emits: ['updatePackFiles', 'displayError'],
data () { data () {
return { return {
uploadFile: [], uploadFile: [],
uploadURL: '',
editedShortcode: this.shortcode, editedShortcode: this.shortcode,
editedFile: this.file, editedFile: this.file,
deleteModalVisible: false deleteModalVisible: false,
copyToPack: ''
} }
}, },
computed: { computed: {
emojiPreview () { emojiPreview () {
if (this.newUpload && this.uploadFile.length > 0) { if (this.newUpload && this.uploadFile.length > 0) {
return URL.createObjectURL(this.uploadFile[0]) return URL.createObjectURL(this.uploadFile[0])
} else if (this.newUpload && this.uploadURL !== '') {
return this.uploadURL
} else if (!this.newUpload) { } else if (!this.newUpload) {
return this.emojiAddr(this.file) return this.emojiAddr(this.file)
} }
@ -163,6 +219,12 @@ export default {
}, },
isEdited () { isEdited () {
return !this.newUpload && (this.editedShortcode !== this.shortcode || this.editedFile !== this.file) return !this.newUpload && (this.editedShortcode !== this.shortcode || this.editedFile !== this.file)
},
saveButtonDisabled() {
if (this.remote === undefined)
return this.newUpload ? (this.uploadURL === "" && this.uploadFile.length == 0) : !this.isEdited
else
return this.copyToPack === ""
} }
}, },
methods: { methods: {
@ -181,9 +243,12 @@ export default {
}).then(resp => this.$emit('updatePackFiles', resp)) }).then(resp => this.$emit('updatePackFiles', resp))
}, },
uploadEmoji () { uploadEmoji () {
let packName = this.remote === undefined ? this.packName : this.copyToPack
this.$store.state.api.backendInteractor.addNewEmojiFile({ this.$store.state.api.backendInteractor.addNewEmojiFile({
packName: this.packName, packName: packName,
file: this.uploadFile[0], file: this.remote === undefined
? (this.uploadURL !== "" ? this.uploadURL : this.uploadFile[0])
: this.emojiAddr(this.file),
shortcode: this.editedShortcode, shortcode: this.editedShortcode,
filename: this.editedFile filename: this.editedFile
}).then(resp => resp.json()).then(resp => { }).then(resp => resp.json()).then(resp => {
@ -192,7 +257,7 @@ export default {
return return
} }
this.$emit('updatePackFiles', resp) this.$emit('updatePackFiles', resp, packName)
this.$refs.emojiPopover.hidePopover() this.$refs.emojiPopover.hidePopover()
this.editedFile = '' this.editedFile = ''
@ -215,7 +280,7 @@ export default {
return return
} }
this.$emit('updatePackFiles', resp) this.$emit('updatePackFiles', resp, this.packName)
}) })
} }
} }
@ -228,9 +293,22 @@ export default {
padding-right: 0.5em; padding-right: 0.5em;
padding-bottom: 0.5em; padding-bottom: 0.5em;
.emoji-tab-popover-new-upload {
margin-bottom: 2em;
}
.emoji { .emoji {
width: 32px; width: 2.3em;
height: 32px; height: 2.3em;
}
.Select {
display: inline-block;
}
h4 {
margin-bottom: 1em;
margin-top: 1em;
} }
} }
</style> </style>

View file

@ -16,6 +16,7 @@ import {
} from 'src/services/theme_data/css_utils.js' } from 'src/services/theme_data/css_utils.js'
import { deserialize } from 'src/services/theme_data/iss_deserializer.js' import { deserialize } from 'src/services/theme_data/iss_deserializer.js'
import { createStyleSheet, adoptStyleSheets } from 'src/services/style_setter/style_setter.js' import { createStyleSheet, adoptStyleSheets } from 'src/services/style_setter/style_setter.js'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
import SharedComputedObject from '../helpers/shared_computed_object.js' import SharedComputedObject from '../helpers/shared_computed_object.js'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue' import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
@ -72,7 +73,10 @@ const AppearanceTab = {
key: mode, key: mode,
value: mode, value: mode,
label: this.$t(`settings.style.themes3.hacks.underlay_override_mode_${mode}`) label: this.$t(`settings.style.themes3.hacks.underlay_override_mode_${mode}`)
})) })),
backgroundUploading: false,
background: null,
backgroundPreview: null,
} }
}, },
components: { components: {
@ -187,6 +191,9 @@ const AppearanceTab = {
} }
}, },
computed: { computed: {
isDefaultBackground () {
return !(this.$store.state.users.currentUser.background_image)
},
switchInProgress () { switchInProgress () {
return useInterfaceStore().themeChangeInProgress return useInterfaceStore().themeChangeInProgress
}, },
@ -420,7 +427,55 @@ const AppearanceTab = {
].join('')) ].join(''))
sheet.ready = true sheet.ready = true
adoptStyleSheets() adoptStyleSheets()
} },
uploadFile (slot, e) {
const file = e.target.files[0]
if (!file) { return }
if (file.size > this.$store.state.instance[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [
this.$t('upload.error.file_too_big', {
filesize: filesize.num,
filesizeunit: filesize.unit,
allowedsize: allowedsize.num,
allowedsizeunit: allowedsize.unit
})
],
level: 'error'
})
return
}
const reader = new FileReader()
reader.onload = ({ target }) => {
const img = target.result
this[slot + 'Preview'] = img
this[slot] = file
}
reader.readAsDataURL(file)
},
resetBackground () {
const confirmed = window.confirm(this.$t('settings.reset_background_confirm'))
if (confirmed) {
this.submitBackground('')
}
},
submitBackground (background) {
if (!this.backgroundPreview && background !== '') { return }
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateProfileImages({ background })
.then((data) => {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
})
.catch(this.displayUploadError)
.finally(() => { this.backgroundUploading = false })
},
} }
} }

View file

@ -24,6 +24,46 @@
margin: 0.5em 0; margin: 0.5em 0;
} }
input[type="file"] {
padding: 0.25em;
height: auto;
}
.banner-background-preview {
max-width: 100%;
width: 300px;
position: relative;
img {
width: 100%;
}
}
.reset-button {
position: absolute;
top: 0.2em;
right: 0.2em;
border-radius: var(--roundness);
background-color: rgb(0 0 0 / 60%);
opacity: 0.7;
width: 1.5em;
height: 1.5em;
text-align: center;
line-height: 1.5em;
font-size: 1.5em;
cursor: pointer;
&:hover {
opacity: 1;
}
svg {
color: white;
}
}
.palettes-container { .palettes-container {
height: 15em; height: 15em;
overflow: hidden auto; overflow: hidden auto;

View file

@ -151,6 +151,49 @@
</div> </div>
</div> </div>
</div> </div>
<div class="setting-item">
<h2>{{ $t('settings.background') }}</h2>
<div class="banner-background-preview">
<img :src="user.background_image">
<button
v-if="!isDefaultBackground"
class="button-unstyled reset-button"
:title="$t('settings.reset_profile_background')"
@click="resetBackground"
>
<FAIcon
icon="times"
type="button"
/>
</button>
</div>
<p>{{ $t('settings.set_new_background') }}</p>
<img
v-if="backgroundPreview"
class="banner-background-preview"
:src="backgroundPreview"
>
<div>
<input
type="file"
class="input"
@change="uploadFile('background', $event)"
>
</div>
<FAIcon
v-if="backgroundUploading"
class="uploading"
spin
icon="circle-notch"
/>
<button
v-else-if="backgroundPreview"
class="btn button-default"
@click="submitBackground(background)"
>
{{ $t('settings.save') }}
</button>
</div>
<div class="setting-item"> <div class="setting-item">
<h2>{{ $t('settings.scale_and_layout') }}</h2> <h2>{{ $t('settings.scale_and_layout') }}</h2>
<div class="alert neutral theme-notice"> <div class="alert neutral theme-notice">

View file

@ -1,3 +1,5 @@
import { mapState } from 'vuex'
import BooleanSetting from '../helpers/boolean_setting.vue' import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue' import ChoiceSetting from '../helpers/choice_setting.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue' import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
@ -6,10 +8,11 @@ import FloatSetting from '../helpers/float_setting.vue'
import UnitSetting from '../helpers/unit_setting.vue' import UnitSetting from '../helpers/unit_setting.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue' import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import Select from 'src/components/select/select.vue' import Select from 'src/components/select/select.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue' import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import localeService from 'src/services/locale/locale.service.js'
import { clearCache, cacheKey, emojiCacheKey } from 'src/services/sw/sw.js' import { clearCache, cacheKey, emojiCacheKey } from 'src/services/sw/sw.js'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -64,7 +67,8 @@ const GeneralTab = {
// Chrome-likes // Chrome-likes
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') || Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') ||
// Future spec, still not supported in Nightly 63 as of 08/2018 // Future spec, still not supported in Nightly 63 as of 08/2018
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks') Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks'),
emailLanguage: this.$store.state.users.currentUser.language || ['']
} }
}, },
components: { components: {
@ -74,8 +78,8 @@ const GeneralTab = {
FloatSetting, FloatSetting,
UnitSetting, UnitSetting,
InterfaceLanguageSwitcher, InterfaceLanguageSwitcher,
ScopeSelector,
ProfileSettingIndicator, ProfileSettingIndicator,
ScopeSelector,
Select Select
}, },
computed: { computed: {
@ -97,7 +101,10 @@ const GeneralTab = {
}, },
instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable }, instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable },
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel }, instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
...SharedComputedObject() ...SharedComputedObject(),
...mapState({
blockExpirationSupported: state => state.instance.blockExpiration,
})
}, },
methods: { methods: {
changeDefaultScope (value) { changeDefaultScope (value) {
@ -117,7 +124,19 @@ const GeneralTab = {
}, },
clearEmojiCache () { clearEmojiCache () {
this.clearCache(emojiCacheKey) this.clearCache(emojiCacheKey)
} },
updateProfile () {
const params = {
language: localeService.internalToBackendLocaleMulti(this.emailLanguage)
}
this.$store.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
} }
} }

View file

@ -5,10 +5,20 @@
<ul class="setting-list"> <ul class="setting-list">
<li> <li>
<interface-language-switcher <interface-language-switcher
:prompt-text="$t('settings.interfaceLanguage')" v-model="language"
:language="language" @update="val => language = val"
:set-language="val => language = val" >
/> {{ $t('settings.interfaceLanguage') }}
</interface-language-switcher>
</li>
<li>
<interface-language-switcher
v-model="emailLanguage"
:profile="true"
@update:model-value="updateProfile()"
>
{{ $t('settings.email_language') }}
</interface-language-switcher>
</li> </li>
<li v-if="instanceSpecificPanelPresent"> <li v-if="instanceSpecificPanelPresent">
<BooleanSetting path="hideISP"> <BooleanSetting path="hideISP">

View file

@ -1,19 +1,8 @@
import unescape from 'lodash/unescape' import UserCard from 'src/components/user_card/user_card.vue'
import merge from 'lodash/merge'
import ImageCropper from 'src/components/image_cropper/image_cropper.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue' import Checkbox from 'src/components/checkbox/checkbox.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import Select from 'src/components/select/select.vue'
import BooleanSetting from '../helpers/boolean_setting.vue' import BooleanSetting from '../helpers/boolean_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js' import SharedComputedObject from '../helpers/shared_computed_object.js'
import localeService from 'src/services/locale/locale.service.js' import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import { propsToNative } from 'src/services/attributes_helper/attributes_helper.service.js'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -21,7 +10,6 @@ import {
faPlus, faPlus,
faCircleNotch faCircleNotch
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { useInterfaceStore } from 'src/stores/interface'
library.add( library.add(
faTimes, faTimes,
@ -32,248 +20,42 @@ library.add(
const ProfileTab = { const ProfileTab = {
data () { data () {
return { return {
newName: this.$store.state.users.currentUser.name_unescaped, // Whether user is locked or not
newBio: unescape(this.$store.state.users.currentUser.description), locked: this.$store.state.users.currentUser.locked,
newLocked: this.$store.state.users.currentUser.locked,
newBirthday: this.$store.state.users.currentUser.birthday,
showBirthday: this.$store.state.users.currentUser.show_birthday,
newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
bot: this.$store.state.users.currentUser.bot,
actorType: this.$store.state.users.currentUser.actor_type,
pickAvatarBtnVisible: true,
bannerUploading: false,
backgroundUploading: false,
banner: null,
bannerPreview: null,
background: null,
backgroundPreview: null,
emailLanguage: this.$store.state.users.currentUser.language || ['']
} }
}, },
components: { components: {
ScopeSelector, UserCard,
ImageCropper,
EmojiInput,
Autosuggest,
ProgressButton,
Checkbox, Checkbox,
BooleanSetting, BooleanSetting,
InterfaceLanguageSwitcher, ProfileSettingIndicator
Select
}, },
computed: { computed: {
user () { user () {
return this.$store.state.users.currentUser return this.$store.state.users.currentUser
}, },
...SharedComputedObject(), ...SharedComputedObject()
emojiUserSuggestor () {
return suggestor({
emoji: [
...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
],
store: this.$store
})
},
emojiSuggestor () {
return suggestor({
emoji: [
...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
]
})
},
userSuggestor () {
return suggestor({ store: this.$store })
},
fieldsLimits () {
return this.$store.state.instance.fieldsLimits
},
maxFields () {
return this.fieldsLimits ? this.fieldsLimits.maxFields : 0
},
defaultAvatar () {
return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar
},
defaultBanner () {
return this.$store.state.instance.server + this.$store.state.instance.defaultBanner
},
isDefaultAvatar () {
const baseAvatar = this.$store.state.instance.defaultAvatar
return !(this.$store.state.users.currentUser.profile_image_url) ||
this.$store.state.users.currentUser.profile_image_url.includes(baseAvatar)
},
isDefaultBanner () {
const baseBanner = this.$store.state.instance.defaultBanner
return !(this.$store.state.users.currentUser.cover_photo) ||
this.$store.state.users.currentUser.cover_photo.includes(baseBanner)
},
isDefaultBackground () {
return !(this.$store.state.users.currentUser.background_image)
},
avatarImgSrc () {
const src = this.$store.state.users.currentUser.profile_image_url_original
return (!src) ? this.defaultAvatar : src
},
bannerImgSrc () {
const src = this.$store.state.users.currentUser.cover_photo
return (!src) ? this.defaultBanner : src
},
groupActorAvailable () {
return this.$store.state.instance.groupActorAvailable
},
availableActorTypes () {
return this.groupActorAvailable ? ['Person', 'Service', 'Group'] : ['Person', 'Service']
}
}, },
methods: { methods: {
updateProfile () { updateProfile () {
const params = { const params = {
note: this.newBio, locked: this.locked
locked: this.newLocked,
// Backend notation.
display_name: this.newName,
fields_attributes: this.newFields.filter(el => el != null),
actor_type: this.actorType,
show_role: this.showRole,
birthday: this.newBirthday || '',
show_birthday: this.showBirthday
}
if (this.emailLanguage) {
params.language = localeService.internalToBackendLocaleMulti(this.emailLanguage)
} }
this.$store.state.api.backendInteractor this.$store.state.api.backendInteractor
.updateProfile({ params }) .updateProfile({ params })
.then((user) => { .then((user) => {
this.newFields.splice(user.fields.length)
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user]) this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user) this.$store.commit('setCurrentUser', user)
}) })
}, .catch((error) => {
changeVis (visibility) { this.displayUploadError(error)
this.newDefaultScope = visibility
},
addField () {
if (this.newFields.length < this.maxFields) {
this.newFields.push({ name: '', value: '' })
return true
}
return false
},
deleteField (index) {
this.newFields.splice(index, 1)
},
uploadFile (slot, e) {
const file = e.target.files[0]
if (!file) { return }
if (file.size > this.$store.state.instance[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [
this.$t('upload.error.file_too_big', {
filesize: filesize.num,
filesizeunit: filesize.unit,
allowedsize: allowedsize.num,
allowedsizeunit: allowedsize.unit
})
],
level: 'error'
}) })
return }
} },
watch: {
const reader = new FileReader() locked () {
reader.onload = ({ target }) => { this.updateProfile()
const img = target.result
this[slot + 'Preview'] = img
this[slot] = file
}
reader.readAsDataURL(file)
},
resetAvatar () {
const confirmed = window.confirm(this.$t('settings.reset_avatar_confirm'))
if (confirmed) {
this.submitAvatar(undefined, '')
}
},
resetBanner () {
const confirmed = window.confirm(this.$t('settings.reset_banner_confirm'))
if (confirmed) {
this.submitBanner('')
}
},
resetBackground () {
const confirmed = window.confirm(this.$t('settings.reset_background_confirm'))
if (confirmed) {
this.submitBackground('')
}
},
submitAvatar (canvas, file) {
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar, avatarName) {
that.$store.state.api.backendInteractor.updateProfileImages({ avatar, avatarName })
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
resolve()
})
.catch((error) => {
that.displayUploadError(error)
reject(error)
})
}
if (canvas) {
canvas.toBlob((data) => updateAvatar(data, file.name), file.type)
} else {
updateAvatar(file, file.name)
}
})
},
submitBanner (banner) {
if (!this.bannerPreview && banner !== '') { return }
this.bannerUploading = true
this.$store.state.api.backendInteractor.updateProfileImages({ banner })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.bannerPreview = null
})
.catch(this.displayUploadError)
.finally(() => { this.bannerUploading = false })
},
submitBackground (background) {
if (!this.backgroundPreview && background !== '') { return }
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateProfileImages({ background })
.then((data) => {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
})
.catch(this.displayUploadError)
.finally(() => { this.backgroundUploading = false })
},
displayUploadError (error) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [error.message],
level: 'error'
})
},
propsToNative (props) {
return propsToNative(props)
} }
} }
} }

View file

@ -1,133 +1,15 @@
.profile-tab { .profile-tab {
.bio { // overriding global for better look
.setting-item.profile-edit {
margin: 0; margin: 0;
}
.visibility-tray { h2 {
padding-top: 5px; margin-left: 1rem;
}
input[type="file"] {
padding: 5px;
height: auto;
}
.banner-background-preview {
max-width: 100%;
width: 300px;
position: relative;
img {
width: 100%;
}
}
.uploading {
font-size: 1.5em;
margin: 0.25em;
}
.name-changer {
width: 100%;
}
.current-avatar-container {
position: relative;
width: 150px;
height: 150px;
}
.current-avatar {
display: block;
width: 100%;
height: 100%;
border-radius: var(--roundness);
}
.reset-button {
position: absolute;
top: 0.2em;
right: 0.2em;
border-radius: var(--roundness);
background-color: rgb(0 0 0 / 60%);
opacity: 0.7;
width: 1.5em;
height: 1.5em;
text-align: center;
line-height: 1.5em;
font-size: 1.5em;
cursor: pointer;
&:hover {
opacity: 1;
} }
svg { .user-card {
color: white; padding: 1.2em;
overflow: hidden;
} }
} }
.oauth-tokens {
width: 100%;
th {
text-align: left;
}
.actions {
text-align: right;
}
}
&-usersearch-wrapper {
padding: 1em;
}
&-bulk-actions {
text-align: right;
padding: 0 1em;
min-height: 2em;
button {
width: 10em;
}
}
&-domain-mute-form {
padding: 1em;
display: flex;
flex-direction: column;
button {
align-self: flex-end;
margin-top: 1em;
width: 10em;
}
}
.setting-subitem {
margin-left: 1.75em;
}
.profile-fields {
display: flex;
& > .emoji-input {
flex: 1 1 auto;
margin: 0 0.2em 0.5em;
min-width: 0;
}
.delete-field {
width: 20px;
align-self: center;
margin: 0 0.2em 0.5em;
padding: 0 0.5em;
}
}
.birthday-input {
display: block;
margin-bottom: 1em;
}
} }

View file

@ -1,283 +1,21 @@
<template> <template>
<div class="profile-tab"> <div class="profile-tab">
<div class="setting-item"> <div class="setting-item profile-edit">
<h2>{{ $t('settings.name_bio') }}</h2> <h2>{{ $t('settings.account_profile_edit') }}</h2>
<p>{{ $t('settings.name') }}</p> <UserCard
<EmojiInput :user-id="user.id"
v-model="newName" :editable="true"
enable-emoji-picker :switcher="false"
:suggest="emojiSuggestor"
>
<template #default="inputProps">
<input
id="username"
v-model="newName"
class="input name-changer"
v-bind="propsToNative(inputProps)"
>
</template>
</EmojiInput>
<p>{{ $t('settings.bio') }}</p>
<EmojiInput
v-model="newBio"
enable-emoji-picker
:suggest="emojiUserSuggestor"
>
<template #default="inputProps">
<textarea
v-model="newBio"
class="input bio resize-height"
v-bind="propsToNative(inputProps)"
/>
</template>
</EmojiInput>
<p v-if="role === 'admin' || role === 'moderator'">
<Checkbox v-model="showRole">
<template v-if="role === 'admin'">
{{ $t('settings.show_admin_badge') }}
</template>
<template v-if="role === 'moderator'">
{{ $t('settings.show_moderator_badge') }}
</template>
</Checkbox>
</p>
<div>
<p>{{ $t('settings.birthday.label') }}</p>
<input
id="birthday"
v-model="newBirthday"
type="date"
class="input birthday-input"
>
<Checkbox v-model="showBirthday">
{{ $t('settings.birthday.show_birthday') }}
</Checkbox>
</div>
<div v-if="maxFields > 0">
<p>{{ $t('settings.profile_fields.label') }}</p>
<div
v-for="(_, i) in newFields"
:key="i"
class="profile-fields"
>
<EmojiInput
v-model="newFields[i].name"
enable-emoji-picker
hide-emoji-button
:suggest="userSuggestor"
>
<template #default="inputProps">
<input
v-model="newFields[i].name"
:placeholder="$t('settings.profile_fields.name')"
v-bind="propsToNative(inputProps)"
class="input"
>
</template>
</EmojiInput>
<EmojiInput
v-model="newFields[i].value"
enable-emoji-picker
hide-emoji-button
:suggest="userSuggestor"
>
<template #default="inputProps">
<input
v-model="newFields[i].value"
:placeholder="$t('settings.profile_fields.value')"
v-bind="propsToNative(inputProps)"
class="input"
>
</template>
</EmojiInput>
<button
class="delete-field button-unstyled -hover-highlight"
@click="deleteField(i)"
>
<!-- TODO something is wrong with v-show here -->
<FAIcon
v-if="newFields.length > 1"
icon="times"
/>
</button>
</div>
<button
v-if="newFields.length < maxFields"
class="add-field faint button-unstyled -hover-highlight"
@click="addField"
>
<FAIcon icon="plus" />
{{ $t("settings.profile_fields.add_field") }}
</button>
</div>
<p>
<label>
{{ $t('settings.actor_type') }}
<Select v-model="actorType">
<option
v-for="option in availableActorTypes"
:key="option"
:value="option"
>
{{ $t('settings.actor_type_' + option) }}
</option>
</Select>
</label>
</p>
<div v-if="groupActorAvailable">
<small>
{{ $t('settings.actor_type_description') }}
</small>
</div>
<p>
<interface-language-switcher
:prompt-text="$t('settings.email_language')"
:language="emailLanguage"
:set-language="val => emailLanguage = val"
/>
</p>
<button
:disabled="newName && newName.length === 0"
class="btn button-default"
@click="updateProfile"
>
{{ $t('settings.save') }}
</button>
</div>
<div class="setting-item">
<h2>{{ $t('settings.avatar') }}</h2>
<p class="visibility-notice">
{{ $t('settings.avatar_size_instruction') }}
</p>
<div class="current-avatar-container">
<img
:src="user.profile_image_url_original"
class="current-avatar"
>
<button
v-if="!isDefaultAvatar && pickAvatarBtnVisible"
:title="$t('settings.reset_avatar')"
class="button-unstyled reset-button"
@click="resetAvatar"
>
<FAIcon
icon="times"
type="button"
/>
</button>
</div>
<p>{{ $t('settings.set_new_avatar') }}</p>
<button
v-show="pickAvatarBtnVisible"
id="pick-avatar"
class="button-default btn"
type="button"
>
{{ $t('settings.upload_a_photo') }}
</button>
<image-cropper
trigger="#pick-avatar"
:submit-handler="submitAvatar"
@open="pickAvatarBtnVisible=false"
@close="pickAvatarBtnVisible=true"
/> />
</div> </div>
<div class="setting-item">
<h2>{{ $t('settings.profile_banner') }}</h2>
<div class="banner-background-preview">
<img :src="user.cover_photo">
<button
v-if="!isDefaultBanner"
class="button-unstyled reset-button"
:title="$t('settings.reset_profile_banner')"
@click="resetBanner"
>
<FAIcon
icon="times"
type="button"
/>
</button>
</div>
<p>{{ $t('settings.set_new_profile_banner') }}</p>
<img
v-if="bannerPreview"
class="banner-background-preview"
:src="bannerPreview"
>
<div>
<input
type="file"
class="input"
@change="uploadFile('banner', $event)"
>
</div>
<FAIcon
v-if="bannerUploading"
class="uploading"
spin
icon="circle-notch"
/>
<button
v-else-if="bannerPreview"
class="btn button-default"
@click="submitBanner(banner)"
>
{{ $t('settings.save') }}
</button>
</div>
<div class="setting-item">
<h2>{{ $t('settings.profile_background') }}</h2>
<div class="banner-background-preview">
<img :src="user.background_image">
<button
v-if="!isDefaultBackground"
class="button-unstyled reset-button"
:title="$t('settings.reset_profile_background')"
@click="resetBackground"
>
<FAIcon
icon="times"
type="button"
/>
</button>
</div>
<p>{{ $t('settings.set_new_profile_background') }}</p>
<img
v-if="backgroundPreview"
class="banner-background-preview"
:src="backgroundPreview"
>
<div>
<input
type="file"
class="input"
@change="uploadFile('background', $event)"
>
</div>
<FAIcon
v-if="backgroundUploading"
class="uploading"
spin
icon="circle-notch"
/>
<button
v-else-if="backgroundPreview"
class="btn button-default"
@click="submitBackground(background)"
>
{{ $t('settings.save') }}
</button>
</div>
<div class="setting-item"> <div class="setting-item">
<h2>{{ $t('settings.account_privacy') }}</h2> <h2>{{ $t('settings.account_privacy') }}</h2>
<ul class="setting-list"> <ul class="setting-list">
<li> <li>
<BooleanSetting <Checkbox v-model="locked">
source="profile"
path="locked"
>
{{ $t('settings.lock_account_description') }} {{ $t('settings.lock_account_description') }}
</BooleanSetting> </Checkbox>
<ProfileSettingIndicator :is-profile="true" />
</li> </li>
<li> <li>
<BooleanSetting <BooleanSetting

View file

@ -159,10 +159,16 @@
.qr-code { .qr-code {
flex: 1; flex: 1;
padding-right: 10px; padding-right: 0.7em;
}
.verify {
flex: 1;
}
.error {
margin: 0.3em 0 0;
} }
.verify { flex: 1; }
.error { margin: 4px 0 0; }
.confirm-otp-actions { .confirm-otp-actions {
button { button {

View file

@ -21,7 +21,10 @@
</div> </div>
<div class="panel-body theme-preview-content"> <div class="panel-body theme-preview-content">
<div class="post"> <div class="post">
<div class="avatar still-image"> <div
class="avatar still-image"
aria-hidden="true"
>
( ͡° ͜ʖ ͡°) ( ͡° ͜ʖ ͡°)
</div> </div>
<div class="content"> <div class="content">
@ -71,7 +74,10 @@
</div> </div>
<div class="after-post"> <div class="after-post">
<div class="avatar-alt"> <div
class="avatar-alt"
aria-hidden="true"
>
:^) :^)
</div> </div>
<div class="content"> <div class="content">
@ -149,7 +155,7 @@ export default {
background-position: 50% 50%; background-position: 50% 50%;
.theme-preview-content { .theme-preview-content {
padding: 20px; padding: 1.5em;
} }
.dummy { .dummy {
@ -203,19 +209,21 @@ export default {
.avatar-alt { .avatar-alt {
flex: 0 auto; flex: 0 auto;
margin-left: 28px; margin-left: 2em;
font-size: 12px; font-size: 0.85em;
min-width: 20px; min-width: 2.2rem;
min-height: 20px; min-height: 2.2rem;
line-height: 20px; line-height: 1.5em;
align-content: center;
} }
.avatar { .avatar {
flex: 0 auto; flex: 0 auto;
width: 48px; width: 3.5em;
height: 48px; height: 3.5em;
font-size: 14px; font-size: 1rem;
line-height: 48px; line-height: 3.5em;
justify-content: center;
} }
.actions { .actions {
@ -241,7 +249,7 @@ export default {
.underlay-preview { .underlay-preview {
position: absolute; position: absolute;
inset: 0 10px; inset: 0 0.9em;
} }
} }
</style> </style>

View file

@ -107,7 +107,7 @@
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
width: 100%; width: 100%;
min-height: 30px; min-height: 2.1em;
margin-bottom: 1em; margin-bottom: 1em;
p { p {
@ -212,7 +212,7 @@
.theme-color-cl, .theme-color-cl,
.theme-radius-in, .theme-radius-in,
.theme-color-in { .theme-color-in {
margin-left: 4px; margin-left: 0.3em;
} }
.theme-radius-in { .theme-radius-in {

View file

@ -122,8 +122,8 @@
.shout-avatar { .shout-avatar {
img { img {
height: 24px; height: 0.6em;
width: 24px; width: 0.6em;
border-radius: var(--roundness); border-radius: var(--roundness);
margin-right: 0.5em; margin-right: 0.5em;
margin-top: 0.25em; margin-top: 0.25em;

View file

@ -412,6 +412,10 @@
display: flex; display: flex;
padding: 0; padding: 0;
margin: 0; margin: 0;
.user-info {
margin: 1em;
}
} }
.side-drawer ul { .side-drawer ul {

View file

@ -9,23 +9,9 @@ export default {
'Link', 'Link',
'Icon', 'Icon',
'Border', 'Border',
'Button',
'ButtonUnstyled',
'RichContent',
'Input',
'Avatar', 'Avatar',
'Attachment',
'PollGraph' 'PollGraph'
], ],
validInnerComponentsLite: [
'Text',
'Link',
'Icon',
'Border',
'ButtonUnstyled',
'RichContent',
'Avatar'
],
defaultRules: [ defaultRules: [
{ {
directives: { directives: {

View file

@ -76,9 +76,10 @@
} }
.status-favicon { .status-favicon {
height: 18px; height: 1.2em;
width: 18px; width: 1.2em;
margin-right: 0.4em; margin-right: 0.4em;
object-fit: contain;
} }
.status-heading { .status-heading {
@ -100,7 +101,8 @@
} }
.account-name { .account-name {
min-width: 1.6em; display: inline-block;
min-width: 1em;
margin-right: 0.4em; margin-right: 0.4em;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -116,10 +118,11 @@
.heading-right { .heading-right {
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
align-self: baseline;
.button-unstyled { .button-unstyled {
padding: 5px; padding: 0.2em;
margin: -5px; margin: -0.2em;
} }
.svg-inline--fa { .svg-inline--fa {
@ -232,9 +235,9 @@
.repeater-avatar { .repeater-avatar {
border-radius: var(--roundness); border-radius: var(--roundness);
margin-left: 28px; margin-left: 2em; // 3.5 (poster avatar size) - 1.5 (repeater avatar size)
width: 20px; width: 1.5em;
height: 20px; height: 1.5em;
} }
.repeater-name { .repeater-name {
@ -242,8 +245,8 @@
margin-right: 0; margin-right: 0;
.emoji { .emoji {
width: 14px; width: 1em;
height: 14px; height: 1em;
vertical-align: middle; vertical-align: middle;
object-fit: contain; object-fit: contain;
} }

View file

@ -79,6 +79,7 @@
<RichContent <RichContent
:html="retweeterHtml" :html="retweeterHtml"
:emoji="retweeterUser.emoji" :emoji="retweeterUser.emoji"
:is-local="retweeterUser.is_local"
/> />
</router-link> </router-link>
<router-link <router-link
@ -139,6 +140,7 @@
<RichContent <RichContent
:html="status.user.name" :html="status.user.name"
:emoji="status.user.emoji" :emoji="status.user.emoji"
:is-local="status.user.is_local"
/> />
</h4> </h4>
<h4 <h4

View file

@ -26,8 +26,8 @@
align-self: stretch; align-self: stretch;
width: 1px; width: 1px;
background-color: var(--icon); background-color: var(--icon);
margin-left: 1em; margin-left: 0.75em;
margin-right: 0.5em; margin-right: 0.125em;
} }
&.-pin { &.-pin {

View file

@ -18,10 +18,11 @@ library.add(
faPollH faPollH
) )
const StatusContent = { const StatusBody = {
name: 'StatusContent', name: 'StatusBody',
props: [ props: [
'compact', 'compact',
'collapse', // replaces newlines with spaces
'status', 'status',
'focused', 'focused',
'noHeading', 'noHeading',
@ -40,6 +41,7 @@ const StatusContent = {
parseReadyDone: false parseReadyDone: false
} }
}, },
emits: ['parseReady'],
computed: { computed: {
localCollapseSubjectDefault () { localCollapseSubjectDefault () {
return this.mergedConfig.collapseMessageWithSubject return this.mergedConfig.collapseMessageWithSubject
@ -95,6 +97,9 @@ const StatusContent = {
attachmentTypes () { attachmentTypes () {
return this.status.attachments.map(file => fileType.fileType(file.mimetype)) return this.status.attachments.map(file => fileType.fileType(file.mimetype))
}, },
collapsedStatus () {
return this.status.raw_html.replace(/(\n|<br\s?\/?>)/g, ' ')
},
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig'])
}, },
components: { components: {
@ -145,4 +150,4 @@ const StatusContent = {
} }
} }
export default StatusContent export default StatusBody

View file

@ -14,6 +14,7 @@
:faint="compact" :faint="compact"
:html="status.summary_raw_html" :html="status.summary_raw_html"
:emoji="status.emojis" :emoji="status.emojis"
:is-local="status.isLocal"
/> />
<button <button
v-show="longSubject && showingLongSubject" v-show="longSubject && showingLongSubject"
@ -39,11 +40,13 @@
:class="{ '-single-line': singleLine }" :class="{ '-single-line': singleLine }"
class="text media-body" class="text media-body"
:html="status.raw_html" :html="status.raw_html"
:collapse="collapse"
:emoji="status.emojis" :emoji="status.emojis"
:handle-links="true" :handle-links="true"
:faint="compact" :faint="compact"
:greentext="mergedConfig.greentext" :greentext="mergedConfig.greentext"
:attentions="status.attentions" :attentions="status.attentions"
:is-local="status.is_local"
@parse-ready="onParseReady" @parse-ready="onParseReady"
/> />
<div <div
@ -57,32 +60,6 @@
@click.prevent="toggleShowMore" @click.prevent="toggleShowMore"
> >
{{ toggleText }} {{ toggleText }}
<template v-if="!showingMore">
<FAIcon
v-if="attachmentTypes.includes('image')"
icon="image"
/>
<FAIcon
v-if="attachmentTypes.includes('video')"
icon="video"
/>
<FAIcon
v-if="attachmentTypes.includes('audio')"
icon="music"
/>
<FAIcon
v-if="attachmentTypes.includes('unknown')"
icon="file"
/>
<FAIcon
v-if="status.poll && status.poll.options"
icon="poll-h"
/>
<FAIcon
v-if="status.card"
icon="link"
/>
</template>
</button> </button>
</div> </div>
</div> </div>

View file

@ -53,6 +53,7 @@ const StatusContent = {
props: [ props: [
'status', 'status',
'compact', 'compact',
'collapse',
'focused', 'focused',
'noHeading', 'noHeading',
'fullContent', 'fullContent',
@ -64,6 +65,7 @@ const StatusContent = {
'controlledShowingLongSubject', 'controlledShowingLongSubject',
'controlledToggleShowingLongSubject' 'controlledToggleShowingLongSubject'
], ],
emits: ['parseReady', 'mediaplay', 'mediapause'],
data () { data () {
return { return {
uncontrolledShowingTall: this.fullContent || (this.inConversation && this.focused), uncontrolledShowingTall: this.fullContent || (this.inConversation && this.focused),

View file

@ -14,6 +14,7 @@
:toggle-showing-tall="toggleShowingTall" :toggle-showing-tall="toggleShowingTall"
:toggle-expanding-subject="toggleExpandingSubject" :toggle-expanding-subject="toggleExpandingSubject"
:toggle-showing-long-subject="toggleShowingLongSubject" :toggle-showing-long-subject="toggleShowingLongSubject"
:collapse="collapse"
@parse-ready="$emit('parseReady', $event)" @parse-ready="$emit('parseReady', $event)"
> >
<div v-if="status.poll && status.poll.options && !compact"> <div v-if="status.poll && status.poll.options && !compact">
@ -23,7 +24,10 @@
/> />
</div> </div>
<div v-else-if="status.poll && status.poll.options && compact"> <div
v-else-if="status.poll && status.poll.options && compact"
class="poll-icon"
>
<FAIcon <FAIcon
icon="poll-h" icon="poll-h"
size="2x" size="2x"
@ -62,5 +66,9 @@
.StatusContent { .StatusContent {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
.poll-icon {
margin: 0.5em;
}
} }
</style> </style>

View file

@ -0,0 +1,189 @@
<template>
<Popover
ref="emojiPopover"
trigger="click"
placement="top"
:bound-to="{ x: 'container' }"
:offset="{ y: 10 }"
@show="fetchEmojiPacksIfAdmin"
>
<template #trigger>
<StillImage v-bind="$attrs" />
</template>
<template #content>
<div class="emoji-popover">
<h3>{{ $attrs.title }}</h3>
<div class="emoji-popover-centered">
<StillImage
class="emoji"
v-bind="$attrs"
/>
</div>
<div v-if="isUserAdmin && !isLocal">
<button
class="button button-default btn emoji-popover-button"
type="button"
:disabled="packName == ''"
@click="copyToLocalPack"
>
{{ $t('admin_dash.emoji.copy_to_pack') }}
</button>
<SelectComponent
v-model="packName"
>
<option
value=""
disabled
hidden
>
{{ $t('admin_dash.emoji.emoji_pack') }}
</option>
<option
v-for="(pack, listPackName) in knownLocalPacks"
:key="listPackName"
:label="listPackName"
>
{{ listPackName }}
</option>
</SelectComponent>
</div>
</div>
</template>
</Popover>
</template>
<script>
import { assign } from 'lodash'
import StillImage from './still-image.vue'
import Popover from 'components/popover/popover.vue'
import SelectComponent from 'components/select/select.vue'
import { useInterfaceStore } from 'src/stores/interface'
export default {
components: { StillImage, Popover, SelectComponent },
props: {
shortcode: {
type: String,
required: true
},
isLocal: {
type: Boolean,
required: true
}
},
data() {
return {
knownLocalPacks: { },
packName: ""
}
},
computed: {
isUserAdmin () {
return this.$store.state.users.currentUser.rights.admin
}
},
methods: {
displayError (msg) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'admin_dash.emoji.error',
messageArgs: [msg],
level: 'error'
})
},
copyToLocalPack() {
this.$store.state.api.backendInteractor.addNewEmojiFile({
packName: this.packName,
file: this.$attrs.src,
shortcode: this.shortcode,
filename: ""
}).then(resp => resp.json()).then(resp => {
if (resp.error !== undefined) {
this.displayError(resp.error)
return
}
useInterfaceStore().pushGlobalNotice({
messageKey: 'admin_dash.emoji.copied_successfully',
messageArgs: [this.shortcode, this.packName],
level: 'success'
})
this.$refs.emojiPopover.hidePopover()
this.packName = ''
})
},
// Copied from emoji_tab.js
loadPacksPaginated (listFunction) {
const pageSize = 25
const allPacks = {}
return listFunction({ instance: this.$store.state.instance.server, page: 1, pageSize: 0 })
.then(data => data.json())
.then(data => {
if (data.error !== undefined) { return Promise.reject(data.error) }
let resultingPromise = Promise.resolve({})
for (let i = 0; i < Math.ceil(data.count / pageSize); i++) {
resultingPromise = resultingPromise.then(() => listFunction({ instance: this.$store.state.instance.server, page: i, pageSize })
).then(data => data.json()).then(pageData => {
if (pageData.error !== undefined) { return Promise.reject(pageData.error) }
assign(allPacks, pageData.packs)
})
}
return resultingPromise
})
.then(() => allPacks)
.catch(data => {
this.displayError(data)
})
},
fetchEmojiPacksIfAdmin() {
if (!this.isUserAdmin) return
this.loadPacksPaginated(this.$store.state.api.backendInteractor.listEmojiPacks)
.then(allPacks => {
// Sort by key
const sorted = Object.keys(allPacks).sort().reduce((acc, key) => {
if (key.length === 0) return acc
acc[key] = allPacks[key]
return acc
}, {})
this.knownLocalPacks = sorted
})
}
}
}
</script>
<style>
.emoji-popover {
margin: 0 0.5em 0.5em;
text-align: center;
.emoji {
width: 4.6em;
height: 4.6em;
}
.emoji-popover-centered {
display: flex;
align-items: center;
justify-content: center;
}
.emoji-popover-button {
width: 100%;
margin-top: 0.5em;
}
.Select {
width: 100%;
}
}
</style>

View file

@ -202,9 +202,9 @@
} }
img { img {
max-height: 26px; max-height: 1.9em;
vertical-align: top; vertical-align: top;
margin-top: -5px; margin-top: -0.3em;
} }
} }

View file

@ -5,6 +5,7 @@ export default {
'Link', 'Link',
'Text', 'Text',
'Icon', 'Icon',
// Optimization: don't put heavy components unless needed
'Button', 'Button',
'ButtonUnstyled', 'ButtonUnstyled',
'Input', 'Input',

View file

@ -14,11 +14,31 @@ library.add(
) )
const UserAvatar = { const UserAvatar = {
props: [ props: {
'user', // User object to show avatar of
'compact', user: {
'showActorTypeIndicator' required: true,
], type: Object
},
// Use less space and use alternative roundness
compact: {
required: false,
type: Boolean,
default: false
},
// Show small icon indicating if account is a bot or group
showActorTypeIndicator : {
required: false,
type: Boolean,
default: false
},
// Override avatar image URL, useful for profile editing
url: {
required: false,
type: String,
default: null
}
},
data () { data () {
return { return {
showPlaceholder: false, showPlaceholder: false,

View file

@ -8,7 +8,7 @@
class="avatar" class="avatar"
:alt="user.screen_name_ui" :alt="user.screen_name_ui"
:title="user.screen_name_ui" :title="user.screen_name_ui"
:src="imgSrc(user.profile_image_url_original)" :src="url ? url : imgSrc(user.profile_image_url_original)"
:image-load-error="imageLoadError" :image-load-error="imageLoadError"
:class="{ '-compact': compact, '-better-shadow': betterShadow }" :class="{ '-compact': compact, '-better-shadow': betterShadow }"
/> />
@ -40,12 +40,12 @@
display: inline-block; display: inline-block;
position: relative; position: relative;
width: 48px; width: 3.5em;
height: 48px; height: 3.5em;
&.-compact { &.-compact {
width: 32px; width: 2.2em;
height: 32px; height: 2.2em;
border-radius: var(--roundness); border-radius: var(--roundness);
} }

View file

@ -1,3 +1,7 @@
import merge from 'lodash/merge'
import isEqual from 'lodash/isEqual'
import unescape from 'lodash/unescape'
import ColorInput from 'src/components/color_input/color_input.vue' import ColorInput from 'src/components/color_input/color_input.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue' import RemoteFollow from '../remote_follow/remote_follow.vue'
@ -10,11 +14,19 @@ import Select from '../select/select.vue'
import UserLink from '../user_link/user_link.vue' import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx' import RichContent from 'src/components/rich_content/rich_content.jsx'
import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue' import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import DialogModal from 'src/components/dialog_modal/dialog_modal.vue'
import ImageCropper from 'src/components/image_cropper/image_cropper.vue'
import localeService from 'src/services/locale/locale.service.js' import localeService from 'src/services/locale/locale.service.js'
import suggestor from 'src/components/emoji_input/suggestor.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { usePostStatusStore } from 'src/stores/post_status' import { usePostStatusStore } from 'src/stores/post_status'
import { propsToNative } from 'src/services/attributes_helper/attributes_helper.service.js'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faBell, faBell,
@ -24,13 +36,16 @@ import {
faEdit, faEdit,
faTimes, faTimes,
faExpandAlt, faExpandAlt,
faBirthdayCake faBirthdayCake,
faSave,
faClockRotateLeft
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { useMediaViewerStore } from '../../stores/media_viewer' import { useMediaViewerStore } from '../../stores/media_viewer'
import { useInterfaceStore } from '../../stores/interface' import { useInterfaceStore } from '../../stores/interface'
library.add( library.add(
faSave,
faRss, faRss,
faBell, faBell,
faSearchPlus, faSearchPlus,
@ -38,23 +53,58 @@ library.add(
faEdit, faEdit,
faTimes, faTimes,
faExpandAlt, faExpandAlt,
faBirthdayCake faBirthdayCake,
faClockRotateLeft
) )
export default { export default {
props: [ props: {
'userId', // Enables all the options for profile editing, used in settings -> profile tab
'switcher', editable: {
'selected', required: false,
'hideBio', default: false,
'rounded', type: Boolean
'bordered', },
'avatarAction', // default - open profile, 'zoom' - zoom, function - call function // ID of user to show data of
'onClose', userId: {
'hasNoteEditor' required: true,
], type: String
},
// Use a compact layout that hides bio, stats etc.
hideBio: {
required: false,
default: false,
type: Boolean
},
// default - open profile, 'zoom' - zoom, function - call function
avatarAction: {
required: false,
type: String,
default: 'default'
},
// Show note editor if supported
hasNoteEditor: {
required: false,
type: Boolean,
default: false
},
// Show close icon (for popovers)
showClose: {
required: false,
type: Boolean,
default: false
},
// Show close icon (for popovers)
showExpand: {
required: false,
type: Boolean,
default: false
}
},
components: { components: {
DialogModal,
UserAvatar, UserAvatar,
Checkbox,
RemoteFollow, RemoteFollow,
ModerationTools, ModerationTools,
AccountActions, AccountActions,
@ -65,41 +115,77 @@ export default {
UserLink, UserLink,
UserNote, UserNote,
UserTimedFilterModal, UserTimedFilterModal,
ColorInput ColorInput,
EmojiInput,
ImageCropper
}, },
data () { data () {
const user = this.$store.getters.findUser(this.userId)
return { return {
followRequestInProgress: false, followRequestInProgress: false,
muteExpiryAmount: 0, muteExpiryAmount: 0,
muteExpiryUnit: 'minutes' muteExpiryUnit: 'minutes',
// Editable stuff
editImage: false,
newName: user.name_unescaped,
editingName: false,
newBio: unescape(user.description),
editingBio: false,
newAvatar: null,
newAvatarFile: null,
newBanner: null,
newBannerFile: null,
newActorType: user.actor_type,
newBirthday: user.birthday,
newShowBirthday: user.show_birthday,
newShowRole: user.show_role,
newFields: user.fields?.map(field => ({ name: field.name, value: field.value })),
editingFields: false,
} }
}, },
created () { created () {
this.$store.dispatch('fetchUserRelationship', this.user.id) this.$store.dispatch('fetchUserRelationship', this.user.id)
}, },
computed: { computed: {
somethingToSave () {
if (this.newName !== this.user.name_unescaped) return true
if (this.newBio !== unescape(this.user.description)) return true
if (this.newAvatar !== null) return true
if (this.newBanner !== null) return true
if (this.newActorType !== this.user.actor_type) return true
if (this.newBirthday !== this.user.birthday) return true
if (this.newShowBirthday !== this.user.show_birthday) return true
if (this.newShowRole !== this.user.show_role) return true
if (!isEqual(
this.newFields,
this.user.fields?.map(field => ({ name: field.name, value: field.value }))
)) return true
return false
},
groupActorAvailable () {
return this.$store.state.instance.groupActorAvailable
},
availableActorTypes () {
return this.groupActorAvailable ? ['Person', 'Service', 'Group'] : ['Person', 'Service']
},
user () { user () {
return this.$store.getters.findUser(this.userId) return this.$store.getters.findUser(this.userId)
}, },
role () {
return this.user.role
},
relationship () { relationship () {
return this.$store.getters.relationship(this.userId) return this.$store.getters.relationship(this.userId)
}, },
classes () {
return [{
'-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius
'-rounded': this.rounded === true, // set border-radius for all sides
'-bordered': this.bordered === true, // set border for all sides
'-popover': !!this.onClose // set popover rounding
}]
},
style () {
return {
backgroundImage: [
'linear-gradient(to bottom, var(--profileTint), var(--profileTint))',
`url(${this.user.cover_photo})`
].join(', ')
}
},
isOtherUser () { isOtherUser () {
return this.user.id !== this.$store.state.users.currentUser.id return this.user.id !== this.$store.state.users.currentUser.id
}, },
@ -114,6 +200,13 @@ export default {
const days = Math.ceil((new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000)) const days = Math.ceil((new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000))
return Math.round(this.user.statuses_count / days) return Math.round(this.user.statuses_count / days)
}, },
emoji () {
return this.$store.state.instance.customEmoji.map(e => ({
shortcode: e.displayText,
static_url: e.imageUrl,
url: e.imageUrl
}))
},
userHighlightType: { userHighlightType: {
get () { get () {
const data = this.$store.getters.mergedConfig.highlight[this.user.screen_name] const data = this.$store.getters.mergedConfig.highlight[this.user.screen_name]
@ -139,6 +232,7 @@ export default {
} }
}, },
visibleRole () { visibleRole () {
if (!this.newShowRole) { return }
const rights = this.user.rights const rights = this.user.rights
if (!rights) { return } if (!rights) { return }
const validRole = rights.admin || rights.moderator const validRole = rights.admin || rights.moderator
@ -184,6 +278,59 @@ export default {
const browserLocale = localeService.internalToBrowserLocale(this.$i18n.locale) const browserLocale = localeService.internalToBrowserLocale(this.$i18n.locale)
return this.user.birthday && new Date(Date.parse(this.user.birthday)).toLocaleDateString(browserLocale, { timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' }) return this.user.birthday && new Date(Date.parse(this.user.birthday)).toLocaleDateString(browserLocale, { timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' })
}, },
// Editable stuff
avatarImgSrc () {
const currentUrl = this.user.profile_image_url_original || this.defaultAvatar
if (!this.editable) return currentUrl
const newUrl = this.newAvatar === null ? this.defaultAvatar : this.newAvatar
return (this.newAvatar === null) ? currentUrl : newUrl
},
bannerImgSrc () {
const currentUrl = this.user.cover_photo || this.defaultBanner
if (!this.editable) return currentUrl
const newUrl = this.newBanner === null ? this.defaultBanner : this.newBanner
return (this.newBanner === null) ? currentUrl : newUrl
},
defaultAvatar () {
return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar
},
defaultBanner () {
return this.$store.state.instance.server + this.$store.state.instance.defaultBanner
},
isDefaultAvatar () {
const baseAvatar = this.$store.state.instance.defaultAvatar
return !(this.$store.state.users.currentUser.profile_image_url) ||
this.$store.state.users.currentUser.profile_image_url.includes(baseAvatar)
},
isDefaultBanner () {
const baseBanner = this.$store.state.instance.defaultBanner
return !(this.$store.state.users.currentUser.cover_photo) ||
this.$store.state.users.currentUser.cover_photo.includes(baseBanner)
},
fieldsLimits () {
return this.$store.state.instance.fieldsLimits
},
maxFields () {
return this.fieldsLimits ? this.fieldsLimits.maxFields : 0
},
emojiUserSuggestor () {
return suggestor({
emoji: [
...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
],
store: this.$store
})
},
emojiSuggestor () {
return suggestor({
emoji: [
...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
]
})
},
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig'])
}, },
methods: { methods: {
@ -199,12 +346,6 @@ export default {
unsubscribeUser () { unsubscribeUser () {
return this.$store.dispatch('unsubscribeUser', this.user.id) return this.$store.dispatch('unsubscribeUser', this.user.id)
}, },
setProfileView (v) {
if (this.switcher) {
const store = this.$store
store.commit('setProfileView', { v })
}
},
linkClicked ({ target }) { linkClicked ({ target }) {
if (target.tagName === 'SPAN') { if (target.tagName === 'SPAN') {
target = target.parentNode target = target.parentNode
@ -238,6 +379,124 @@ export default {
e.preventDefault() e.preventDefault()
this.onAvatarClick() this.onAvatarClick()
} }
},
// Editable stuff
changeAvatar () {
this.editImage = 'avatar'
},
changeBanner () {
this.editImage = 'banner'
},
submitImage ({ canvas, file }) {
if (canvas) {
return canvas.toBlob((data) => this.submitImage({ canvas: null, file: data }))
}
const reader = new window.FileReader()
reader.onload = (e) => {
const dataUrl = e.target.result
if (this.editImage === 'avatar') {
this.newAvatar = dataUrl
this.newAvatarFile = file
} else {
this.newBanner = dataUrl
this.newBannerFile = file
}
this.editImage = false
}
reader.readAsDataURL(file)
},
resetImage () {
if (this.editImage === 'avatar') {
this.newAvatar = null
this.newAvatarFile = null
} else {
this.newBanner = null
this.newBannerFile = null
}
this.editImage = false
},
addField () {
if (this.newFields.length < this.maxFields) {
this.newFields.push({ name: '', value: '' })
}
},
deleteField (index) {
this.newFields.splice(index, 1)
},
propsToNative (props) {
return propsToNative(props)
},
cancelImageText () {
return
},
resetState () {
const user = this.$store.state.users.currentUser
this.newName = user.name_unescaped
this.newBio = unescape(user.description)
this.newAvatar = null
this.newAvatarFile = null
this.newBanner = null
this.newBannerFile = null
this.newActorType = user.actor_type
this.newBirthday = user.birthday
this.newShowBirthday = user.show_birthday
this.newShowRole = user.show_role
this.newFields = user.fields.map(field => ({ name: field.name, value: field.value }))
},
updateProfile () {
const params = {
note: this.newBio,
// Backend notation.
display_name: this.newName,
fields_attributes: this.newFields.filter(el => el != null),
show_role: !!this.newShowRole,
birthday: this.newBirthday || '',
show_birthday: !!this.newShowBirthday,
}
if (this.actorType) {
params.actor_type = this.actorType
}
if (this.newAvatarFile !== null) {
params.avatar = this.newAvatarFile
}
if (this.newBannerFile !== null) {
params.header = this.newBannerFile
}
this.$store.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
this.newFields.splice(this.newFields.length)
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.resetState()
})
.catch((error) => {
this.displayUploadError(error)
})
},
displayUploadError (error) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [error.message],
level: 'error'
})
} }
} }
} }

View file

@ -1,12 +1,71 @@
.user-card { .user-card {
position: relative; position: relative;
z-index: 1; z-index: 1;
overflow: hidden;
border-top-left-radius: calc(var(--roundness) - 1px);
border-top-right-radius: calc(var(--roundness) - 1px);
// editing headers
h4 {
line-height: 2;
display: flex;
}
h3 {
padding-left: 0.6em;
margin-bottom: 0;
.button-default {
font-size: 1rem;
line-height: 2;
padding: 0 0.6em;
}
}
.input.bio {
height: auto; // override settings default textarea size
}
.user-card-inner { .user-card-inner {
padding-bottom: 0; padding-bottom: 0;
} }
&-setting,
&-bio {
color: var(--lightText);
display: block;
line-height: 1.3;
padding: 0 0.6em;
img {
object-fit: contain;
vertical-align: middle;
max-width: 100%;
max-height: 400px;
}
}
.user-card-setting {
margin-left: 0.6em;
margin-right: 0.6em;
}
.user-card-bio { .user-card-bio {
text-align: center;
margin: 0 0.6em;
&.input {
margin: 0 1em;
textarea {
text-align: inherit;
}
}
&, * {
line-height: 1.5;
}
&.-justify-left { &.-justify-left {
text-align: start; text-align: start;
} }
@ -18,17 +77,6 @@
--_still-image-label-visibility: hidden; --_still-image-label-visibility: hidden;
} }
.panel-heading {
text-align: center;
box-shadow: none;
background: transparent;
backdrop-filter: none;
flex-direction: column;
align-items: stretch;
// create new stacking context
position: relative;
}
.personal-marks { .personal-marks {
margin: 0.6em; margin: 0.6em;
padding: 0.6em; padding: 0.6em;
@ -63,73 +111,53 @@
} }
} }
.background-image { .header-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
right: -1.2em; right: -1.2em;
left: -1.2em; left: -1.2em;
top: -1.4em; top: -1.4em;
padding: 0;
mask: linear-gradient(to top, transparent 0, white 5em) bottom no-repeat; mask: linear-gradient(to top, transparent 0, white 5em) bottom no-repeat;
background-size: cover;
border-top-left-radius: calc(var(--__roundnessTop, --panelRadius) - 1px);
border-top-right-radius: calc(var(--__roundnessTop, --panelRadius) - 1px);
border-bottom-left-radius: calc(var(--__roundnessBottom, --panelRadius) - 1px);
border-bottom-right-radius: calc(var(--__roundnessBottom, --panelRadius) - 1px);
background-color: var(--profileBg);
z-index: -2;
} }
&-bio { .banner-overlay,
text-align: center; .banner-image {
color: var(--lightText); position: absolute;
display: block; inset: 0;
line-height: 1.3; padding: 0;
padding: 0.6em; border-top-left-radius: calc(var(--roundness) - 1px);
margin: 0 0.6em; border-top-right-radius: calc(var(--roundness) - 1px);
}
.banner-image {
z-index: -2;
img { img {
object-fit: contain; object-fit: cover;
vertical-align: middle; height: 100%;
max-width: 100%; width: 100%;
max-height: 400px;
} }
} }
&.-rounded-t { .banner-overlay {
border-top-left-radius: var(--roundness); background-color: var(--profileTint);
border-top-right-radius: var(--roundness); opacity: 0.5;
pointer-events: none; // let user copy bg url
--__roundnessTop: var(--roundness); z-index: -1;
--__roundnessBottom: 0;
} }
&.-rounded { .bottom-buttons {
border-radius: var(--roundness); display: flex;
gap: 0.5em;
--__roundnessTop: var(--roundness);
--__roundnessBottom: var(--roundness);
}
&.-popover {
border-radius: var(--roundness);
--__roundnessTop: var(--roundness);
--__roundnessBottom: var(--roundness);
}
&.-bordered {
border-width: 1px;
border-style: solid;
border-color: var(--border);
} }
} }
.user-info { .user-info {
position: relative; position: relative;
margin: 0.6em; text-align: left;
margin-bottom: 0; display: flex;
text-align: right; flex-direction: column;
gap: 1em;
.user-identity { .user-identity {
position: relative; position: relative;
@ -137,7 +165,6 @@
min-height: 6em; min-height: 6em;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
margin-bottom: 1em;
container: user-card / inline-size; container: user-card / inline-size;
> * { > * {
@ -179,6 +206,10 @@
padding: 0.6em; padding: 0.6em;
margin: -0.6em; margin: -0.6em;
&.edit-banner-button {
width: auto;
}
&:hover .icon { &:hover .icon {
color: var(--textFaint); color: var(--textFaint);
} }
@ -199,7 +230,6 @@
inset: -0.6em; inset: -0.6em;
left: -0.6em; left: -0.6em;
right: -1.2em; right: -1.2em;
background-color: rgb(0 0 0 / 30%);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -209,11 +239,21 @@
svg { svg {
color: #fff; color: #fff;
margin: 0.5em;
} }
} }
&:hover &.-overlay { &:hover &.-overlay {
opacity: 1; opacity: 1;
background-color: rgb(0 0 0 / 30%);
}
}
.user-info-avatar.-editable {
.-overlay {
opacity: 1;
place-items: start end;
justify-content: end;
} }
} }
@ -230,6 +270,13 @@
// big one // big one
z-index: 1; z-index: 1;
line-height: 2em; line-height: 2em;
filter: drop-shadow(0 0 0.5em var(--profileTint))
drop-shadow(0 0 0.2em var(--profileTint));
.alert {
text-shadow: none;
}
--emoji-size: 1.7em; --emoji-size: 1.7em;
@ -238,6 +285,39 @@
--link: var(--text) !important; --link: var(--text) !important;
} }
.name-wrapper {
display: flex;
align-items: baseline;
.edit-button {
width: 3em;
text-align: center;
&:hover .icon {
color: var(--textFaint);
}
&:not(:hover) .icon {
color: var(--lightText);
}
}
.input,
.user-name {
flex: 1;
font-weight: 600;
line-height: 2;
margin-right: 1em;
text-overflow: ellipsis;
overflow: hidden;
}
.input {
margin: 0 -0.5em;
padding: 0 0.5em;
}
}
.top-line { .top-line {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -246,8 +326,6 @@
// these two normalize position and height when custom emoji are used // these two normalize position and height when custom emoji are used
line-height: 2; line-height: 2;
margin-bottom: -0.2em; margin-bottom: -0.2em;
font-weight: 600;
font-size: 110%;
font-size: calc(max(110%, 4cqw)); font-size: calc(max(110%, 4cqw));
} }
@ -258,7 +336,9 @@
white-space: normal; white-space: normal;
font-weight: 500; font-weight: 500;
font-size: 0.9em; font-size: 0.9em;
font-size: calc(max(90%, 2.5cqw));
line-height: 1.5; line-height: 1.5;
margin-top: 0.5em;
} }
.lock-icon { .lock-icon {
@ -281,65 +361,15 @@
} }
} }
.user-name {
text-overflow: ellipsis;
overflow: hidden;
margin-right: 1em;
font-size: 1.1em;
}
.highlighter {
margin: 5em;
align-items: baseline;
line-height: 22px;
flex-wrap: wrap;
.following {
flex: 1 0 auto;
margin: 0;
margin-bottom: 0.25em;
text-align: left;
}
.highlighter {
flex: 0 1 auto;
display: flex;
flex-wrap: wrap;
margin-right: -0.5em;
align-self: start;
.userHighlightCl {
padding: 2px 10px;
flex: 1 0 auto;
}
.userHighlightSel {
padding-top: 0;
padding-bottom: 0;
flex: 1 0 auto;
}
.userHighlightText {
width: 70px;
flex: 1 0 auto;
}
.userHighlightCl,
.userHighlightText,
.userHighlightSel {
vertical-align: top;
margin-right: 0.5em;
margin-bottom: 0.25em;
}
}
}
.user-interactions { .user-interactions {
position: relative; position: relative;
display: grid; display: flex;
grid-template-columns: repeat(auto-fit, minmax(7.5em, 20%)); flex-wrap: wrap;
grid-gap: 0.6em; gap: 0.6em;
max-width: 98vw;
> * {
flex: 0 0 8em;
}
.popover-trigger-button, .moderation-tools-button { .popover-trigger-button, .moderation-tools-button {
width: 100%; width: 100%;
@ -399,38 +429,70 @@
.user-profile-fields { .user-profile-fields {
margin: 0 0.5em; margin: 0 0.5em;
padding: 0 0.5em;
display: flex;
flex-direction: column;
img { --emoji-size: 1.8em;
object-fit: contain;
vertical-align: middle;
max-width: 100%;
max-height: 400px;
&.emoji {
width: 18px;
height: 18px;
}
}
.user-profile-field-add,
.user-profile-field { .user-profile-field {
display: flex; display: flex;
align-items: center;
margin: 0.25em; margin: 0.25em;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--roundness); border-radius: var(--roundness);
line-height: 2em;
.label {
margin-left: 0.5em;
}
}
.user-profile-field-add {
justify-content: center;
margin: 0.25em;
}
.user-profile-field {
/* stylelint-disable no-descending-specificity */
// input is a generic class
.input {
text-align: inherit;
flex: 1;
}
/* stylelint-enable no-descending-specificity */
.delete-field {
display: inline-block;
text-align: center;
width: 2em;
}
.user-profile-field-name, .user-profile-field-name,
.user-profile-field-value { .user-profile-field-value,
.user-profile-field-add {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
line-height: 2em; display: inline-flex;
&.-edit {
padding: 0;
input {
font-weight: inherit;
}
}
} }
.user-profile-field-name { .user-profile-field-name {
flex: 0 1 50%; flex: 0 1 50%;
font-weight: 600; font-weight: 600;
text-align: right; text-align: right;
justify-content: end;
color: var(--lightText); color: var(--lightText);
min-width: 9em; min-width: 9em;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
@ -446,3 +508,88 @@
} }
} }
} }
.edit-image {
.panel-body {
text-align: center;
display: flex;
flex-direction: column;
}
.current-avatar {
object-fit: contain;
}
.image-container {
display: flex;
align-self: center;
margin: 0 0 1em;
max-height: 30em;
max-width: 100%;
flex: 1 0 20em;
aspect-ratio: 1;
gap: 0.5em;
&.-banner {
aspect-ratio: 3;
max-width: 100%;
}
.new-image {
display: flex;
flex-direction: column;
}
.cropper {
flex: 1;
overflow-x: auto;
}
> * {
flex: 1 0 10em;
max-height: 100%;
aspect-ratio: 1;
}
.separator {
min-width: 1.1em;
font-size: 500%;
align-self: center;
flex: 0 1 5em;
aspect-ratio: unset;
}
}
&.-banner {
.images-container {
grid-template-rows: 20em 5em 20em;
grid-template-columns: 1fr;
> * {
flex: 1 0 10em;
width: 100%;
aspect-ratio: 3;
}
}
.separator {
min-height: 1.1em;
font-size: 500%;
justify-self: center;
flex: 0 1 5em;
aspect-ratio: unset;
}
}
#modal.-mobile & {
#pick-image {
height: 3em;
}
.image-container {
&.-banner {
max-height: 10em;
}
}
}
}

View file

@ -2,40 +2,10 @@ export default {
name: 'UserCard', name: 'UserCard',
selector: '.user-card', selector: '.user-card',
notEditable: true, notEditable: true,
validInnerComponents: [
'Text',
'Link',
'Icon',
'Button',
'ButtonUnstyled',
'Input',
'RichContent',
'Alert'
],
defaultRules: [ defaultRules: [
{ {
directives: { directives: {
background: '--bg', '--profileTint': 'color | $alpha(--background 1)'
opacity: 0,
roundness: 3,
shadow: [{
x: 1,
y: 1,
blur: 4,
spread: 0,
color: '#000000',
alpha: 0.6
}],
'--profileTint': 'color | $alpha(--background 0.5)'
}
},
{
parent: {
component: 'UserCard'
},
component: 'RichContent',
directives: {
opacity: 0
} }
} }
] ]

View file

@ -1,19 +1,20 @@
<template> <template>
<div <div class="user-card">
class="user-card" <div class="user-card-inner">
:class="classes"
>
<div
:class="onClose ? '' : 'panel-heading -flexible-height'"
class="user-card-inner"
>
<div class="user-info"> <div class="user-info">
<div class="user-identity"> <div class="user-identity">
<div <div class="header-overlay">
:class="{ 'hide-bio': hideBio }" <div class="banner-image">
:style="style" <img
class="background-image" :src="bannerImgSrc"
/> :class="{ 'hide-bio': hideBio }"
>
</div>
<div
class="banner-overlay"
:class="{ 'hide-bio': hideBio }"
/>
</div>
<a <a
v-if="avatarAction === 'zoom'" v-if="avatarAction === 'zoom'"
class="user-info-avatar -link" class="user-info-avatar -link"
@ -27,6 +28,23 @@
/> />
</div> </div>
</a> </a>
<button
v-else-if="editable"
class="user-info-avatar button-unstyled -link"
:class="{ '-editable': editable }"
@click="changeAvatar"
>
<UserAvatar
:user="user"
:url="avatarImgSrc"
/>
<div class="user-info-avatar -link -overlay">
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="pencil"
/>
</div>
</button>
<UserAvatar <UserAvatar
v-else-if="typeof avatarAction === 'function'" v-else-if="typeof avatarAction === 'function'"
class="user-info-avatar" class="user-info-avatar"
@ -42,9 +60,25 @@
</router-link> </router-link>
<div class="user-summary"> <div class="user-summary">
<div class="top-line"> <div class="top-line">
<div class="other-actions"> <div
class="other-actions"
>
<button <button
v-if="!isOtherUser && user.is_local" v-if="editable"
:disabled="newName && newName.length === 0"
class="btn button-unstyled edit-banner-button"
@click="changeBanner"
>
{{ $t('settings.change_banner') }}
<FAIcon
fixed-width
class="icon"
icon="pencil"
:title="$t('settings.change_banner')"
/>
</button>
<button
v-else-if="!editable && !isOtherUser && user.is_local"
class="button-unstyled edit-profile-button" class="button-unstyled edit-profile-button"
@click.stop="openProfileTab" @click.stop="openProfileTab"
> >
@ -72,10 +106,10 @@
:relationship="relationship" :relationship="relationship"
/> />
<router-link <router-link
v-if="onClose" v-if="showExpand"
:to="userProfileLink(user)" :to="userProfileLink(user)"
class="button-unstyled external-link-button" class="button-unstyled external-link-button"
@click="onClose" @click="$emit('close')"
> >
<FAIcon <FAIcon
class="icon" class="icon"
@ -83,9 +117,9 @@
/> />
</router-link> </router-link>
<button <button
v-if="onClose" v-if="showClose"
class="button-unstyled external-link-button" class="button-unstyled external-link-button"
@click="onClose" @click="$emit('close')"
> >
<FAIcon <FAIcon
class="icon" class="icon"
@ -93,19 +127,48 @@
/> />
</button> </button>
</div> </div>
<router-link <div class="name-wrapper">
:to="userProfileLink(user)" <router-link
class="user-name" v-if="!editable || !editingName"
> :to="userProfileLink(user)"
<RichContent class="user-name"
:title="user.name" >
:html="user.name" <RichContent
:emoji="user.emoji" :title="editable ? newName : user.name_unescaped"
/> :html="editable ? newName : user.name_unescaped"
</router-link> :emoji="editable ? emoji : user.emoji"
/>
</router-link>
<EmojiInput
v-else-if="editingName"
v-model="newName"
enable-emoji-picker
:suggest="emojiSuggestor"
>
<template #default="inputProps">
<input
id="username"
v-model="newName"
class="input name-changer"
v-bind="propsToNative(inputProps)"
>
</template>
</EmojiInput>
<button
v-if="editable"
class="button-unstyled edit-button"
:title="$t('settings.toggle_edit')"
@click="editingName = !editingName"
>
<FAIcon
class="icon"
icon="pencil"
/>
</button>
</div>
</div> </div>
<div class="bottom-line"> <div class="bottom-line">
<user-link <UserLink
class="user-screen-name" class="user-screen-name"
:user="user" :user="user"
/> />
@ -242,7 +305,7 @@
</div> </div>
</div> </div>
<div <div
v-if="loggedIn && isOtherUser && (hasNote || !hideBio) && !mergedConfig.userCardHidePersonalMarks" v-if="!editable && loggedIn && isOtherUser && (hasNote || !hideBio) && !mergedConfig.userCardHidePersonalMarks"
class="personal-marks" class="personal-marks"
> >
<UserNote <UserNote
@ -286,96 +349,300 @@
/> />
</div> </div>
</div> </div>
<RichContent <h3 v-if="editable">
v-if="!hideBio" <span>
class="user-card-bio" {{ $t('settings.bio') }}
:class="{ '-justify-left': mergedConfig.userCardLeftJustify }" </span>
:html="user.description_html" {{ ' ' }}
:emoji="user.emoji" <button
:handle-links="true" class="button-default"
/> @click="editingBio = !editingBio"
<div
v-if="!hideBio && user.fields_html && user.fields_html.length > 0"
class="user-profile-fields"
>
<dl
v-for="(field, index) in user.fields_html"
:key="index"
class="user-profile-field"
> >
<dt {{ $t('settings.toggle_edit') }}
:title="user.fields_text[index].name" <FAIcon
class="user-profile-field-name" class="fa-scale-110 fa-old-padding"
> icon="pencil"
<RichContent />
:html="field.name" </button>
:emoji="user.emoji" </h3>
<template v-if="!editable || !editingBio">
<RichContent
v-if="!hideBio"
class="user-card-bio"
:class="{ '-justify-left': mergedConfig.userCardLeftJustify }"
:html="editable ? newBio.replace(/\n/g, '<br>') : user.description_html"
:emoji="editable ? emoji : user.emoji"
:handle-links="true"
/>
</template>
<template v-else-if="editingBio">
<EmojiInput
v-model="newBio"
enable-emoji-picker
class="user-card-bio"
:class="{ '-justify-left': mergedConfig.userCardLeftJustify }"
:suggest="emojiUserSuggestor"
>
<template #default="inputProps">
<textarea
v-model="newBio"
class="input bio resize-height"
v-bind="propsToNative(inputProps)"
:rows="newBio.split(/\n/g).length"
/> />
</dt> </template>
<dd </EmojiInput>
:title="user.fields_text[index].value" </template>
class="user-profile-field-value" <h3 v-if="editable">
<span>
{{ $t('settings.profile_fields.label') }}
</span>
{{ ' ' }}
<button
class="button-default"
@click="editingFields = !editingFields"
>
{{ $t('settings.toggle_edit') }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="pencil"
/>
</button>
</h3>
<template v-if="!editable || !editingFields">
<div
v-if="!hideBio && user.fields_html && user.fields_html.length > 0"
class="user-profile-fields"
>
<dl
v-for="(field, index) in (editable ? newFields : user.fields_html)"
:key="index"
class="user-profile-field"
> >
<RichContent <dt
:html="field.value" :title="field.name"
:emoji="user.emoji" class="user-profile-field-name"
>
<RichContent
:html="field.name"
:emoji="editable ? emoji : user.emoji"
/>
</dt>
<dd
:title="field.value"
class="user-profile-field-value"
>
<RichContent
:html="field.value"
:emoji="editable ? emoji : user.emoji"
/>
</dd>
</dl>
</div>
</template>
<template v-else-if="editingFields">
<div
v-if="maxFields > 0"
class="user-profile-fields"
>
<dl
v-for="(_, i) in newFields"
:key="i"
class="user-profile-field"
>
<dt
class="user-profile-field-name -edit"
>
<EmojiInput
v-model="newFields[i].name"
enable-emoji-picker
:suggest="emojiSuggestor"
>
<template #default="inputProps">
<input
v-model="newFields[i].name"
:placeholder="$t('settings.profile_fields.name')"
v-bind="propsToNative(inputProps)"
class="input"
>
</template>
</EmojiInput>
</dt>
<dd
class="user-profile-field-value -edit"
>
<EmojiInput
v-model="newFields[i].value"
enable-emoji-picker
:suggest="emojiSuggestor"
>
<template #default="inputProps">
<input
v-model="newFields[i].value"
:placeholder="$t('settings.profile_fields.value')"
v-bind="propsToNative(inputProps)"
class="input input"
>
</template>
</EmojiInput>
<button
class="delete-field button-default -hover-highlight"
@click="deleteField(i)"
>
<!-- TODO something is wrong with v-show here -->
<FAIcon
v-if="newFields.length > 1"
icon="times"
/>
</button>
</dd>
</dl>
<button
v-if="newFields.length < maxFields"
class="user-profile-field-add add-field button-default -hover-highlight"
@click="addField"
>
<FAIcon
icon="plus"
class="icon"
/> />
</dd> <span class="label">
</dl> {{ $t("settings.profile_fields.add_field") }}
</div> </span>
</button>
</div>
</template>
<div <div
v-if="!hideBio" v-if="!hideBio"
class="user-extras" class="user-extras"
> >
<span <span
v-if="!mergedConfig.hideUserStats" v-if="!editable && !mergedConfig.hideUserStats"
class="user-stats" class="user-stats"
> >
<dl <dl
v-if="!mergedConfig.hideUserStats && !hideBio" v-if="!mergedConfig.hideUserStats && !hideBio"
class="user-count" class="user-count"
@click.prevent="setProfileView('statuses')"
> >
<dd>{{ user.statuses_count }}</dd> <dd>{{ user.statuses_count }}</dd>
{{ ' ' }} {{ ' ' }}
<dt>{{ $t('user_card.statuses') }}</dt> <dt>{{ $t('user_card.statuses') }}</dt>
</dl> </dl>
<dl <dl class="user-count">
class="user-count"
@click.prevent="setProfileView('statuses')"
>
<dd>{{ dailyAvg }}</dd> <dd>{{ dailyAvg }}</dd>
{{ ' ' }} {{ ' ' }}
<dt>{{ $t('user_card.statuses_per_day') }}</dt> <dt>{{ $t('user_card.statuses_per_day') }}</dt>
</dl> </dl>
<dl <dl class="user-count">
class="user-count"
@click.prevent="setProfileView('friends')"
>
<dd>{{ hideFollowsCount ? $t('user_card.hidden') : user.friends_count }}</dd> <dd>{{ hideFollowsCount ? $t('user_card.hidden') : user.friends_count }}</dd>
{{ ' ' }} {{ ' ' }}
<dt>{{ $t('user_card.followees') }}</dt> <dt>{{ $t('user_card.followees') }}</dt>
</dl> </dl>
<dl <dl class="user-count">
class="user-count"
@click.prevent="setProfileView('followers')"
>
<dd>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</dd> <dd>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</dd>
{{ ' ' }} {{ ' ' }}
<dt>{{ $t('user_card.followers') }}</dt> <dt>{{ $t('user_card.followers') }}</dt>
</dl> </dl>
</span> </span>
<div <template v-if="!hideBio">
v-if="!hideBio && !!user.birthday" <div
class="birthday" v-if="user.birthday && !editable"
> class="birthday"
<FAIcon >
class="fa-old-padding" <FAIcon
icon="birthday-cake" class="fa-old-padding"
/> icon="birthday-cake"
{{ $t('user_card.birthday', { birthday: formattedBirthday }) }} />
</div> {{ $t('user_card.birthday', { birthday: formattedBirthday }) }}
</div>
<div
v-else-if="editable"
class="birthday"
>
<div>
<Checkbox v-model="newShowBirthday">
{{ $t('settings.birthday.show_birthday') }}
</Checkbox>
</div>
<FAIcon
class="fa-old-padding"
icon="birthday-cake"
/>
{{ $t('settings.birthday.label') }}
<input
id="birthday"
v-model="newBirthday"
type="date"
class="input birthday-input"
>
</div>
</template>
</div> </div>
<template v-if="editable">
<h3>{{ $t('settings.profile_other') }}</h3>
<p
v-if="role === 'admin' || role === 'moderator'"
class="user-card-setting"
>
<Checkbox v-model="newShowRole">
<template v-if="role === 'admin'">
{{ $t('settings.show_admin_badge') }}
</template>
<template v-if="role === 'moderator'">
{{ $t('settings.show_moderator_badge') }}
</template>
</Checkbox>
</p>
<p class="user-card-setting">
<label>
{{ $t('settings.actor_type') }}
<Select v-model="newActorType">
<option
v-for="option in availableActorTypes"
:key="option"
:value="option"
>
{{ $t('settings.actor_type_' + (option === 'Person' ? 'person_proper' : option)) }}
</option>
</Select>
<div v-if="groupActorAvailable">
<small>
{{ $t('settings.actor_type_description') }}
</small>
</div>
</label>
</p>
<div class="bottom-buttons">
<button
v-if="editable"
:disabled="!somethingToSave"
class="btn button-default reset-profile-button"
@click="resetState"
>
{{ $t('settings.reset') }}
<FAIcon
fixed-width
class="icon"
icon="clock-rotate-left"
:title="$t('user_card.edit_profile')"
/>
</button>
<button
v-if="editable"
:disabled="!somethingToSave"
class="btn button-default save-profile-button"
@click="updateProfile"
>
{{ $t('settings.save') }}
<FAIcon
fixed-width
class="icon"
icon="save"
:title="$t('user_card.edit_profile')"
/>
</button>
</div>
</template>
<teleport to="#modal"> <teleport to="#modal">
<UserTimedFilterModal <UserTimedFilterModal
ref="timedMuteDialog" ref="timedMuteDialog"
@ -383,6 +650,68 @@
:is-mute="true" :is-mute="true"
/> />
</teleport> </teleport>
<teleport to="#modal">
<DialogModal
v-if="editImage"
class="edit-image"
>
<template #header>
{{ editImage === 'avatar' ? $t('settings.change_avatar') : $t('settings.change_banner') }}
</template>
<p>
{{ editImage === 'avatar' ? $t('settings.avatar_size_instruction') : $t('settings.banner_size_instruction' ) }}
</p>
<div
class="image-container"
:class="{ '-banner': editImage === 'banner' }"
>
<image-cropper
ref="cropper"
class="cropper"
:aspect-ratio="editImage === 'avatar' ? 1 : 3"
@submit="submitImage"
/>
</div>
<button
id="pick-image"
class="button-default btn"
type="button"
@click="() => $refs.cropper.pickImage()"
>
{{ $t('settings.select_picture') }}
</button>
<template #footer>
<button
class="button-default btn"
type="button"
@click="editImage = false"
>
{{ $t('image_cropper.cancel') }}
</button>
<button
:title="editImage === 'avatar' ? $t('settings.reset_avatar') : $t('settings.reset_banner')"
class="button-default btn reset-button"
@click="resetImage"
>
{{ editImage === 'avatar' ? $t('settings.reset_avatar') : $t('settings.reset_banner' ) }}
</button>
<button
class="button-default btn"
type="button"
@click="$refs.cropper.submit(false)"
>
{{ $t('image_cropper.save_without_cropping') }}
</button>
<button
class="button-default btn"
type="button"
@click="$refs.cropper.submit(true)"
>
{{ $t('image_cropper.save') }}
</button>
</template>
</DialogModal>
</teleport>
</div> </div>
</template> </template>

View file

@ -8,7 +8,6 @@
<UserCard <UserCard
:user-id="user.id" :user-id="user.id"
:hide-bio="true" :hide-bio="true"
rounded="top"
/> />
<PostStatusForm /> <PostStatusForm />
</div> </div>
@ -29,6 +28,8 @@
} }
.user-info { .user-info {
margin: 0.6em 0.6em 0;
.Avatar { .Avatar {
width: 5em; width: 5em;
width: calc(min(5em, 20cqw)); width: calc(min(5em, 20cqw));
@ -37,6 +38,12 @@
} }
} }
.post-status-form {
form {
margin-top: 0;
}
}
.signed-in { .signed-in {
z-index: 10; z-index: 10;
} }

View file

@ -12,10 +12,12 @@
<template #content="{close}"> <template #content="{close}">
<UserCard <UserCard
class="user-popover" class="user-popover"
:show-close="true"
:show-expand="true"
:user-id="userId" :user-id="userId"
:hide-bio="true" :hide-bio="true"
:avatar-action="userPopoverAvatarAction == 'close' ? close : userPopoverAvatarAction" :avatar-action="userPopoverAvatarAction == 'close' ? close : userPopoverAvatarAction"
:on-close="close" @close="close"
/> />
</template> </template>
</Popover> </Popover>
@ -27,7 +29,12 @@
/* popover styles load on-demand, so we need to override */ /* popover styles load on-demand, so we need to override */
/* stylelint-disable block-no-empty */ /* stylelint-disable block-no-empty */
.user-popover { .user-popover {
margin-bottom: 0.6em; margin: 0;
.user-info {
width: 100%;
margin: 1.2em;
}
.user-identity { .user-identity {
aspect-ratio: unset; aspect-ratio: unset;
@ -40,7 +47,6 @@
&.popover { &.popover {
overflow: hidden; overflow: hidden;
padding: 0.6em;
} }
} }
/* stylelint-enable block-no-empty */ /* stylelint-enable block-no-empty */

Some files were not shown because too many files have changed in this diff Show more