Merge branch 'profile-edit' into shigusegubu-themes3

This commit is contained in:
Henry Jameson 2025-08-05 00:27:58 +03:00
commit 23d53e9fd0
27 changed files with 1192 additions and 866 deletions

View file

@ -0,0 +1 @@
Profile editing change overhaul

View file

@ -782,12 +782,6 @@ option {
color: var(--text);
}
.visibility-notice {
padding: 0.5em;
border: 1px solid var(--textFaint);
border-radius: var(--roundness);
}
.notice-dismissible {
padding-right: 4rem;
position: relative;

View file

@ -139,9 +139,9 @@
</confirm-modal>
<UserTimedFilterModal
v-if="blockExpirationSupported"
ref="timedBlockDialog"
:is-mute="false"
:user="user"
ref="timedBlockDialog"
/>
</teleport>
</div>

View file

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

View file

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

View file

@ -10,14 +10,6 @@ library.add(
const ImageCropper = {
props: {
trigger: {
type: [String, window.Element],
required: true
},
submitHandler: {
type: Function,
required: true
},
mimes: {
type: String,
default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon'
@ -30,6 +22,9 @@ const ImageCropper = {
},
cancelButtonLabel: {
type: String
},
aspectRatio: {
type: Number
}
},
data () {
@ -39,17 +34,7 @@ const ImageCropper = {
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'],
methods: {
destroy () {
this.$refs.input.value = ''
@ -65,20 +50,15 @@ const ImageCropper = {
} else {
cropperPromise = Promise.resolve()
}
cropperPromise.then(canvas => {
this.submitHandler(canvas, this.file)
.then(() => this.destroy())
.finally(() => {
this.$emit('submit', { canvas, file: this.file })
this.submitting = false
})
})
},
pickImage () {
this.$refs.input.click()
},
getTriggerDOM () {
return typeof this.trigger === 'object' ? this.trigger : document.querySelector(this.trigger)
},
readFile () {
const fileInput = this.$refs.input
if (fileInput.files != null && fileInput.files[0] != null) {
@ -117,23 +97,11 @@ const ImageCropper = {
}
},
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
const fileInput = this.$refs.input
fileInput.addEventListener('change', this.readFile)
},
beforeUnmount: function () {
// remove the event listeners
const trigger = this.getTriggerDOM()
if (trigger) {
trigger.removeEventListener('click', this.pickImage)
}
const fileInput = this.$refs.input
fileInput.removeEventListener('change', this.readFile)
}

View file

@ -1,13 +1,14 @@
<template>
<div class="image-cropper">
<div v-if="dataUrl">
<div class="image">
<cropper-canvas
ref="cropperCanvas"
background
class="image-cropper-canvas"
height="25em"
height="100%"
>
<cropper-image
v-if="dataUrl"
ref="cropperImage"
:src="dataUrl"
alt="Picture"
@ -22,8 +23,8 @@
/>
<cropper-selection
ref="cropperSelection"
initial-coverage="1"
aspect-ratio="1"
initial-coverage="0.9"
:aspect-ratio="aspectRatio"
movable
resizable
@change="onCropperSelectionChange"
@ -47,41 +48,13 @@
<cropper-handle action="sw-resize" />
</cropper-selection>
</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>
<input
ref="input"
type="file"
class="input image-cropper-img-input"
:accept="mimes"
>
/>
</div>
</template>
@ -89,13 +62,15 @@
<style lang="scss">
.image-cropper {
&-img-input {
display: none;
display: flex;
flex-direction: column;
&-canvas, .image {
height: 100%;
}
&-canvas {
height: 25em;
width: 25em;
& &-img-input {
display: none;
}
&-buttons-wrapper {

View file

@ -2,6 +2,7 @@
<div class="interface-language-switcher">
<label>
{{ promptText }}
<ProfileSettingIndicator :is-profile="profile" />
</label>
<ul class="setting-list">
<li
@ -46,12 +47,15 @@
<script>
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: {
// eslint-disable-next-line vue/no-reserved-component-names
Select
Select,
ProfileSettingIndicator
},
props: {
promptText: {
@ -62,11 +66,12 @@ export default {
type: [Array, String],
required: true
},
setLanguage: {
type: Function,
required: true
profile: {
type: Boolean,
default: false
}
},
emits: ['update'],
computed: {
languages () {
return localeService.languages
@ -77,7 +82,7 @@ export default {
return Array.isArray(this.language) ? this.language : [this.language]
},
set: function (val) {
this.setLanguage(val)
this.$emit('update', val)
}
}
},

View file

@ -16,6 +16,7 @@ import {
} from 'src/services/theme_data/css_utils.js'
import { deserialize } from 'src/services/theme_data/iss_deserializer.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 ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
@ -72,7 +73,10 @@ const AppearanceTab = {
key: mode,
value: mode,
label: this.$t(`settings.style.themes3.hacks.underlay_override_mode_${mode}`)
}))
})),
backgroundUploading: false,
background: null,
backgroundPreview: null,
}
},
components: {
@ -420,7 +424,55 @@ const AppearanceTab = {
].join(''))
sheet.ready = true
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;
}
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 {
height: 15em;
overflow: hidden auto;

View file

@ -151,6 +151,49 @@
</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">
<h2>{{ $t('settings.scale_and_layout') }}</h2>
<div class="alert neutral theme-notice">

View file

@ -1,3 +1,5 @@
import { mapState } from 'vuex'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
@ -8,8 +10,8 @@ import InterfaceLanguageSwitcher from 'src/components/interface_language_switche
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 localeService from 'src/services/locale/locale.service.js'
import { clearCache, cacheKey, emojiCacheKey } from 'src/services/sw/sw.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@ -75,7 +77,6 @@ const GeneralTab = {
UnitSetting,
InterfaceLanguageSwitcher,
ScopeSelector,
ProfileSettingIndicator,
Select
},
computed: {
@ -97,7 +98,10 @@ const GeneralTab = {
},
instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable },
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
...SharedComputedObject()
...SharedComputedObject(),
...mapState({
blockExpirationSupported: state => state.instance.blockExpiration,
})
},
methods: {
changeDefaultScope (value) {
@ -117,7 +121,19 @@ const GeneralTab = {
},
clearEmojiCache () {
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

@ -7,7 +7,15 @@
<interface-language-switcher
:prompt-text="$t('settings.interfaceLanguage')"
:language="language"
:set-language="val => language = val"
@update="val => language = val"
/>
</li>
<li>
<interface-language-switcher
:prompt-text="$t('settings.email_language')"
:language="emailLanguage"
:profile="true"
@update="val => { emailLanguage = val; updateProfile() }"
/>
</li>
<li v-if="instanceSpecificPanelPresent">

View file

@ -1,18 +1,16 @@
import unescape from 'lodash/unescape'
import merge from 'lodash/merge'
import UserCard from 'src/components/user_card/user_card.vue'
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 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 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'
@ -21,7 +19,6 @@ import {
faPlus,
faCircleNotch
} from '@fortawesome/free-solid-svg-icons'
import { useInterfaceStore } from 'src/stores/interface'
library.add(
faTimes,
@ -32,35 +29,19 @@ library.add(
const ProfileTab = {
data () {
return {
newName: this.$store.state.users.currentUser.name_unescaped,
newBio: unescape(this.$store.state.users.currentUser.description),
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 || ['']
locked: this.$store.state.users.currentUser.locked,
}
},
components: {
UserCard,
ScopeSelector,
ImageCropper,
EmojiInput,
Autosuggest,
ProgressButton,
Checkbox,
BooleanSetting,
InterfaceLanguageSwitcher,
ProfileSettingIndicator,
Select
},
computed: {
@ -88,193 +69,38 @@ const ProfileTab = {
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: {
changeVis (visibility) {
this.newDefaultScope = visibility
},
updateProfile () {
const params = {
note: this.newBio,
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)
locked: this.locked
}
this.$store.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
this.newFields.splice(user.fields.length)
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
changeVis (visibility) {
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
}
const reader = new FileReader()
reader.onload = ({ target }) => {
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'
this.displayUploadError(error)
})
},
propsToNative (props) {
return propsToNative(props)
}
},
watch: {
locked () {
this.updateProfile()
}
}
}

View file

@ -3,131 +3,12 @@
margin: 0;
}
.visibility-tray {
padding-top: 5px;
}
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 {
color: white;
}
}
.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,20 @@
<template>
<div class="profile-tab">
<div class="setting-item">
<h2>{{ $t('settings.name_bio') }}</h2>
<p>{{ $t('settings.name') }}</p>
<EmojiInput
v-model="newName"
enable-emoji-picker
:suggest="emojiSuggestor"
<UserCard
:user-id="user.id"
:editable="true"
:switcher="false"
rounded="top"
>
<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 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>
</UserCard>
<div class="setting-item">
<h2>{{ $t('settings.account_privacy') }}</h2>
<ul class="setting-list">
<li>
<BooleanSetting
source="profile"
path="locked"
>
<Checkbox v-model="locked">
{{ $t('settings.lock_account_description') }}
</BooleanSetting>
</Checkbox>
<ProfileSettingIndicator :is-profile="true" />
</li>
<li>
<BooleanSetting

View file

@ -17,7 +17,8 @@ const UserAvatar = {
props: [
'user',
'compact',
'showActorTypeIndicator'
'showActorTypeIndicator',
'url'
],
data () {
return {

View file

@ -8,7 +8,7 @@
class="avatar"
:alt="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"
:class="{ '-compact': compact, '-better-shadow': betterShadow }"
/>

View file

@ -1,3 +1,6 @@
import merge from 'lodash/merge'
import unescape from 'lodash/unescape'
import ColorInput from 'src/components/color_input/color_input.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
@ -10,11 +13,19 @@ import Select from '../select/select.vue'
import UserLink from '../user_link/user_link.vue'
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 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 suggestor from 'src/components/emoji_input/suggestor.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters } from 'vuex'
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 {
faBell,
@ -24,13 +35,16 @@ import {
faEdit,
faTimes,
faExpandAlt,
faBirthdayCake
faBirthdayCake,
faSave,
faClockRotateLeft
} from '@fortawesome/free-solid-svg-icons'
import { useMediaViewerStore } from '../../stores/media_viewer'
import { useInterfaceStore } from '../../stores/interface'
library.add(
faSave,
faRss,
faBell,
faSearchPlus,
@ -38,11 +52,13 @@ library.add(
faEdit,
faTimes,
faExpandAlt,
faBirthdayCake
faBirthdayCake,
faClockRotateLeft
)
export default {
props: [
'editable',
'userId',
'switcher',
'selected',
@ -54,7 +70,9 @@ export default {
'hasNoteEditor'
],
components: {
DialogModal,
UserAvatar,
Checkbox,
RemoteFollow,
ModerationTools,
AccountActions,
@ -65,22 +83,58 @@ export default {
UserLink,
UserNote,
UserTimedFilterModal,
ColorInput
ColorInput,
EmojiInput,
ImageCropper
},
data () {
const user = this.$store.getters.findUser(this.userId)
return {
followRequestInProgress: false,
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 () {
this.$store.dispatch('fetchUserRelationship', this.user.id)
},
computed: {
groupActorAvailable () {
return this.$store.state.instance.groupActorAvailable
},
availableActorTypes () {
return this.groupActorAvailable ? ['Person', 'Service', 'Group'] : ['Person', 'Service']
},
user () {
return this.$store.getters.findUser(this.userId)
},
role () {
return this.user.role
},
relationship () {
return this.$store.getters.relationship(this.userId)
},
@ -96,7 +150,7 @@ export default {
return {
backgroundImage: [
'linear-gradient(to bottom, var(--profileTint), var(--profileTint))',
`url(${this.user.cover_photo})`
`url(${this.bannerImgSrc})`
].join(', ')
}
},
@ -114,6 +168,13 @@ export default {
const days = Math.ceil((new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000))
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: {
get () {
const data = this.$store.getters.mergedConfig.highlight[this.user.screen_name]
@ -139,6 +200,7 @@ export default {
}
},
visibleRole () {
if (!this.newShowRole) { return }
const rights = this.user.rights
if (!rights) { return }
const validRole = rights.admin || rights.moderator
@ -184,6 +246,60 @@ export default {
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' })
},
// Editable stuff
avatarImgSrc () {
const currentUrl = this.user.profile_image_url_original || this.defaultAvatar
const newUrl = this.newAvatar === '' ? this.defaultAvatar : this.newAvatar
return (this.newAvatar === null) ? currentUrl : newUrl
},
bannerImgSrc () {
const currentUrl = this.user.cover_photo || this.defaultBanner
const newUrl = this.newBanner === '' ? 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)
},
isDefaultBackground () {
return !(this.$store.state.users.currentUser.background_image)
},
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'])
},
methods: {
@ -238,6 +354,130 @@ export default {
e.preventDefault()
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 = ''
this.newAvatarFile = ''
} else {
this.newBanner = ''
this.newBannerFile = ''
}
this.editImage = false
},
addField () {
if (this.newFields.length < this.maxFields) {
this.newFields.push({ name: '', value: '' })
return true
}
return false
},
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.showBirthday,
}
if (this.actorType) {
params.actor_type = this.actorType
}
if (this.newAvatarFile !== null) {
params.avatar = this.newAvatarFile
}
if (this.newBannerFile !== null) {
params.header = this.newBannerFile
}
if (this.emailLanguage) {
params.language = localeService.internalToBackendLocaleMulti(this.emailLanguage)
}
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

@ -2,11 +2,61 @@
position: relative;
z-index: 1;
// editing headers
h4 {
line-height: 2;
display: flex;
padding: 0 1.0em;
span {
flex: 1;
}
}
.input.bio {
height: auto; // override settings default textarea size
}
.user-card-inner {
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 {
text-align: center;
margin: 0 0.6em;
&.input {
margin: 0 1em;
textarea {
text-align: inherit;
}
}
&, * {
line-height: 1.5;
}
&.-justify-left {
text-align: start;
}
@ -80,22 +130,6 @@
z-index: -2;
}
&-bio {
text-align: center;
color: var(--lightText);
display: block;
line-height: 1.3;
padding: 0.6em;
margin: 0 0.6em;
img {
object-fit: contain;
vertical-align: middle;
max-width: 100%;
max-height: 400px;
}
}
&.-rounded-t {
border-top-left-radius: var(--roundness);
border-top-right-radius: var(--roundness);
@ -129,7 +163,7 @@
position: relative;
margin: 0.6em;
margin-bottom: 0;
text-align: right;
text-align: left;
.user-identity {
position: relative;
@ -179,6 +213,12 @@
padding: 0.6em;
margin: -0.6em;
&.save-profile-button,
&.reset-profile-button,
&.edit-banner-button {
width: auto;
}
&:hover .icon {
color: var(--textFaint);
}
@ -199,7 +239,6 @@
inset: -0.6em;
left: -0.6em;
right: -1.2em;
background-color: rgb(0 0 0 / 30%);
display: flex;
justify-content: center;
align-items: center;
@ -209,11 +248,21 @@
svg {
color: #fff;
margin: 0.5em;
}
}
&:hover &.-overlay {
opacity: 1;
background-color: rgb(0 0 0 / 30%);
}
}
.user-info-avatar.-editable {
.-overlay {
opacity: 1;
place-items: start end;
justify-content: end;
}
}
@ -238,6 +287,39 @@
--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 {
display: flex;
flex-direction: column;
@ -246,8 +328,6 @@
// these two normalize position and height when custom emoji are used
line-height: 2;
margin-bottom: -0.2em;
font-weight: 600;
font-size: 110%;
font-size: calc(max(110%, 4cqw));
}
@ -258,6 +338,7 @@
white-space: normal;
font-weight: 500;
font-size: 0.9em;
font-size: calc(max(90%, 2.5cqw));
line-height: 1.5;
}
@ -281,13 +362,6 @@
}
}
.user-name {
text-overflow: ellipsis;
overflow: hidden;
margin-right: 1em;
font-size: 1.1em;
}
.highlighter {
margin: 5em;
align-items: baseline;
@ -336,10 +410,13 @@
.user-interactions {
position: relative;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7.5em, 20%));
grid-gap: 0.6em;
max-width: 98vw;
display: flex;
flex-wrap: wrap;
gap: 0.6em;
> * {
flex: 0 0 10%;
}
.popover-trigger-button, .moderation-tools-button {
width: 100%;
@ -399,38 +476,76 @@
.user-profile-fields {
margin: 0 0.5em;
display: flex;
flex-direction: column;
--emoji-size: 1.8em;
img {
object-fit: contain;
vertical-align: middle;
max-width: 100%;
max-height: 400px;
&.emoji {
width: 18px;
height: 18px;
}
}
.user-profile-field-add,
.user-profile-field {
display: flex;
align-items: center;
margin: 0.25em;
border: 1px solid var(--border);
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-value {
.user-profile-field-value,
.user-profile-field-add {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
box-sizing: border-box;
line-height: 2em;
display: inline-flex;
&.-edit {
padding: 0;
input {
font-weight: inherit;
}
}
}
.user-profile-field-name {
flex: 0 1 50%;
font-weight: 600;
text-align: right;
justify-content: end;
color: var(--lightText);
min-width: 9em;
border-right: 1px solid var(--border);
@ -446,3 +561,63 @@
}
}
}
.edit-image {
.panel-body {
text-align: center;
}
.current-avatar {
object-fit: contain;
}
.image-container {
display: flex;
margin: 0 1em 0.5em;
gap: 0.5em;
.new-image {
display: flex;
flex-direction: column;
}
.cropper {
flex: 1;
}
> * {
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;
}
}
}

View file

@ -3,7 +3,10 @@
class="user-card"
:class="classes"
>
<div :class="onClose ? '' : 'panel-heading -flexible-height'" class="user-card-inner">
<div
:class="onClose ? '' : 'panel-heading -flexible-height'"
class="user-card-inner"
>
<div class="user-info">
<div class="user-identity">
<div
@ -24,6 +27,23 @@
/>
</div>
</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
v-else-if="typeof avatarAction === 'function'"
class="user-info-avatar"
@ -39,9 +59,25 @@
</router-link>
<div class="user-summary">
<div class="top-line">
<div class="other-actions">
<div
class="other-actions"
>
<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('user_card.change_banner')"
/>
</button>
<button
v-else-if="!editable && !isOtherUser && user.is_local"
class="button-unstyled edit-profile-button"
@click.stop="openProfileTab"
>
@ -90,16 +126,45 @@
/>
</button>
</div>
<div class="name-wrapper">
<router-link
v-if="!editable || !editingName"
:to="userProfileLink(user)"
class="user-name"
>
<RichContent
:title="user.name"
:html="user.name"
:emoji="user.emoji"
:title="editable ? newName : user.name_unescaped"
:html="editable ? newName : user.name_unescaped"
: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"
@click="editingName = !editingName"
:title="$t('settings.toggle_edit')"
>
<FAIcon
class="icon"
icon="pencil"
/>
</button>
</div>
</div>
<div class="bottom-line">
<user-link
@ -163,9 +228,10 @@
</div>
</div>
<div
v-if="loggedIn && isOtherUser"
v-if="loggedIn"
class="user-interactions"
>
<template v-if="isOtherUser">
<div class="btn-group">
<FollowButton
:relationship="relationship"
@ -229,6 +295,35 @@
class="moderation-menu"
:user="user"
/>
</template>
<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>
<div
v-if="!loggedIn && user.is_local"
@ -238,7 +333,45 @@
</div>
</div>
</div>
<div class="personal-marks" v-if="loggedIn && isOtherUser && (hasNote || !hideBio) && !mergedConfig.userCardHidePersonalMarks">
<template v-if="editable">
<h4>{{ $t('settings.user_preferences') }}</h4>
<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>
</template>
<div
v-if="!editable && loggedIn && isOtherUser && (hasNote || !hideBio) && !mergedConfig.userCardHidePersonalMarks"
class="personal-marks"
>
<UserNote
v-if="hasNote || (hasNoteEditor && supportsNote)"
:user="user"
@ -272,60 +405,184 @@
<!-- id's need to be unique, otherwise vue confuses which user-card checkbox belongs to -->
<ColorInput
v-if="userHighlightType !== 'disabled'"
class="highlighter-color"
v-model="userHighlightColor"
class="highlighter-color"
:show-optional-checkbox="false"
name="'userHighlightColorTx'+user.id"
:unstyled="true"
/>
</div>
</div>
<h4 v-if="editable">
<span>
{{ $t('settings.bio') }}
</span>
<button
class="button-default"
@click="editingBio = !editingBio"
>
{{ $t('settings.toggle_edit') }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="pencil"
/>
</button>
</h4>
<template v-if="!editable || !editingBio">
<RichContent
v-if="!hideBio"
class="user-card-bio"
:class="{ '-justify-left': mergedConfig.userCardLeftJustify }"
:html="user.description_html"
:emoji="user.emoji"
: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"
/>
</template>
</EmojiInput>
</template>
<h4 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>
</h4>
<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 user.fields_html"
v-for="(field, index) in (editable ? newFields : user.fields_html)"
:key="index"
class="user-profile-field"
>
<dt
:title="user.fields_text[index].name"
:title="field.name"
class="user-profile-field-name"
>
<RichContent
:html="field.name"
:emoji="user.emoji"
:emoji="editable ? emoji : user.emoji"
/>
</dt>
<dd
:title="user.fields_text[index].value"
:title="field.value"
class="user-profile-field-value"
>
<RichContent
:html="field.value"
:emoji="user.emoji"
:emoji="editable ? emoji : user.emoji"
/>
</dd>
</dl>
</div>
<div class="user-extras" v-if="!hideBio">
</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" />
<span class="label">
{{ $t("settings.profile_fields.add_field") }}
</span>
</button>
</div>
</template>
<div
class="user-extras"
v-if="!hideBio"
>
<span
v-if="!mergedConfig.hideUserStats"
v-if="!editable && !mergedConfig.hideUserStats"
class="user-stats"
>
<dl
v-if="!mergedConfig.hideUserStats && !hideBio"
class="user-count"
@click.prevent="setProfileView('statuses')"
v-if="!mergedConfig.hideUserStats && !hideBio"
>
<dd>{{ user.statuses_count }}</dd>
{{ ' ' }}
@ -356,21 +613,106 @@
<dt>{{ $t('user_card.followers') }}</dt>
</dl>
</span>
<div class="birthday" v-if="!hideBio && !!user.birthday">
<template v-if="!hideBio">
<div
v-if="user.birthday && !editable"
class="birthday"
>
<FAIcon
class="fa-old-padding"
icon="birthday-cake"
/>
{{ $t('user_card.birthday', { birthday: formattedBirthday }) }}
</div>
<div
v-else-if="editable"
class="birthday"
>
<div>
<Checkbox v-model="showBirthday">
{{ $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>
<teleport to="#modal">
<UserTimedFilterModal
ref="timedMuteDialog"
:user="user"
:is-mute="true"
ref="timedMuteDialog"
/>
</teleport>
<teleport to="#modal">
<DialogModal
v-if="editImage"
class="edit-image"
>
<template #header>
{{ editImage === 'avatar' ? $t('settings.change_avatar') : $t('settings.change_banner') }}
</template>
<div class="image-container">
<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="() => this.$refs.cropper.pickImage()"
>
{{ $t('settings.upload_picture') }}
</button>
<p class="visibility-notice">
{{ editImage === 'avatar' ? $t('settings.avatar_size_instruction') : $t('settings.banner_size_instruction' )}}
</p>
<template #footer>
<button
class="button-default btn"
type="button"
@click="editImage = false"
>
{{ this.$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="this.$refs.cropper.submit(false)"
>
{{ $t('image_cropper.save_without_cropping') }}
</button>
<button
class="button-default btn"
type="button"
@click="this.$refs.cropper.submit(true)"
>
{{ $t('image_cropper.save') }}
</button>
</template>
</DialogModal>
</teleport>
</div>
</template>

View file

@ -8,14 +8,14 @@
v-model="localNote"
class="input note-text"
:class="{ unstyled: !editing }"
@focus="startEditing"
@blur="finalizeEditing"
rows="1"
:placeholder="$t('user_card.note_blank_click')"
@focus="startEditing"
@blur="finalizeEditing"
/>
<span
class="overlay"
v-if="frozen"
class="overlay"
>
<PanelLoading />
</span>

View file

@ -29,7 +29,6 @@
}
.user-info {
.Avatar {
width: 5em;
width: calc(min(5em, 20cqw));

View file

@ -387,13 +387,18 @@
"actor_type": "This account is:",
"actor_type_description": "Marking your account as a group will make it automatically repeat statuses that mention it.",
"actor_type_Person": "a normal user",
"actor_type_person_proper": "a person",
"actor_type_Service": "a bot",
"actor_type_Group": "a group",
"mobile_center_dialog": "Vertically center dialogs on mobile",
"app_name": "App name",
"expert_mode": "Show advanced",
"save": "Save changes",
"reset": "Reset changes",
"security": "Security",
"toggle_edit": "Toggle edit",
"change_banner": "Change banner",
"change_avatar": "Change avatar",
"setting_changed": "Setting is different from default",
"setting_server_side": "This setting is tied to your profile and affects all sessions and clients",
"enter_current_password_to_confirm": "Enter your current password to confirm your identity",
@ -488,6 +493,7 @@
"avatarRadius": "Avatars",
"background": "Background",
"bio": "Bio",
"user_preferences": "Profile settings",
"email_language": "Language for receiving emails from the server",
"block_export": "Block export",
"block_export_button": "Export your blocks to a csv file",
@ -563,7 +569,8 @@
"move_account_error": "Error moving account: {error}",
"discoverable": "Allow discovery of this account in search results and other services",
"domain_mutes": "Domains",
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.",
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels. Recommended aspect ratio is 1:1",
"banner_size_instruction": "The recommended minimum size for banner images is 450x150 pixels. Recommended aspect ratio is 3:1",
"pad_emoji": "Pad emoji with spaces when adding from picker",
"autocomplete_select_first": "Automatically select the first candidate when autocomplete results are available",
"unsaved_post_action": "When you try to close an unsaved posting form",
@ -722,8 +729,10 @@
"minimal_scopes_mode": "Minimize post scope selection options",
"set_new_avatar": "Set new avatar",
"set_new_profile_background": "Set new profile background",
"set_new_background": "Set new background",
"set_new_profile_banner": "Set new profile banner",
"reset_avatar": "Reset avatar",
"reset_banner": "Reset banner",
"reset_profile_background": "Reset profile background",
"reset_profile_banner": "Reset profile banner",
"reset_avatar_confirm": "Do you really want to reset the avatar?",
@ -776,6 +785,7 @@
"tooltipRadius": "Tooltips/alerts",
"type_domains_to_mute": "Search domains to mute",
"upload_a_photo": "Upload a photo",
"upload_picture": "Upload picture",
"user_settings": "User Settings",
"values": {
"false": "no",

View file

@ -139,6 +139,7 @@ const defaultState = {
// Nasty stuff
customEmoji: [],
rawCustomEmoji: [],
customEmojiFetched: false,
emoji: {},
emojiFetched: false,

View file

@ -215,12 +215,26 @@ const updateProfileImages = ({ credentials, avatar = null, avatarName = null, ba
}
const updateProfile = ({ credentials, params }) => {
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
const formData = new FormData();
for(const name in params) {
if (name === 'fields_attributes') {
params[name].forEach((param, i) => {
formData.append(name + `[${i}][name]`, param.name)
formData.append(name + `[${i}][value]`, param.value)
})
} else {
formData.append(name, params[name]);
}
}
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
payload: params,
credentials
}).then((data) => parseUser(data))
body: formData
})
.then((data) => data.json())
.then((data) => parseUser(data))
}
// Params needed:
@ -323,7 +337,7 @@ const unmuteConversation = ({ id, credentials }) => {
const blockUser = ({ id, expiresIn, credentials }) => {
const payload = {}
if (expiresIn) {
payload.expires_in = expiresIn
payload.duration = expiresIn
}
return promisedRequest({

View file

@ -18,8 +18,7 @@ const mastoApiNotificationTypes = new Set([
'move',
'poll',
'pleroma:emoji_reaction',
'pleroma:report',
'test'
'pleroma:report'
])
const fetchAndUpdate = ({ store, credentials, older = false, since }) => {