Merge branch 'pleroma-akkoma-emoji-port' into 'develop'

Custom emoji reaction support

See merge request pleroma/pleroma-fe!1792
This commit is contained in:
HJ 2023-03-20 20:31:20 +00:00
commit fa532b1f00
18 changed files with 201 additions and 194 deletions

View File

@ -253,6 +253,7 @@ const getNodeInfo = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') }) store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'shoutAvailable', value: features.includes('chat') }) store.dispatch('setInstanceOption', { name: 'shoutAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') }) store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
store.dispatch('setInstanceOption', { name: 'pleromaCustomEmojiReactionsAvailable', value: features.includes('pleroma_custom_emoji_reactions') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') }) store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') })

View File

@ -98,6 +98,11 @@ const EmojiPicker = {
required: false, required: false,
type: Boolean, type: Boolean,
default: false default: false
},
hideCustomEmoji: {
required: false,
type: Boolean,
default: false
} }
}, },
data () { data () {
@ -280,6 +285,9 @@ const EmojiPicker = {
return 0 return 0
}, },
allCustomGroups () { allCustomGroups () {
if (this.hideCustomEmoji) {
return {}
}
const emojis = this.$store.getters.groupedCustomEmojis const emojis = this.$store.getters.groupedCustomEmojis
if (emojis.unpacked) { if (emojis.unpacked) {
emojis.unpacked.text = this.$t('emoji.unpacked') emojis.unpacked.text = this.$t('emoji.unpacked')

View File

@ -2,7 +2,7 @@
<div class="EmojiReactions"> <div class="EmojiReactions">
<UserListPopover <UserListPopover
v-for="(reaction) in emojiReactions" v-for="(reaction) in emojiReactions"
:key="reaction.name" :key="reaction.url || reaction.name"
:users="accountsForEmoji[reaction.name]" :users="accountsForEmoji[reaction.name]"
> >
<button <button
@ -11,7 +11,21 @@
@click="emojiOnClick(reaction.name, $event)" @click="emojiOnClick(reaction.name, $event)"
@mouseenter="fetchEmojiReactionsByIfMissing()" @mouseenter="fetchEmojiReactionsByIfMissing()"
> >
<span class="reaction-emoji">{{ reaction.name }}</span> <span
class="reaction-emoji"
>
<img
v-if="reaction.url"
:src="reaction.url"
:title="reaction.name"
class="reaction-emoji-content"
width="1em"
>
<span
v-else
class="reaction-emoji reaction-emoji-content"
>{{ reaction.name }}</span>
</span>
<span>{{ reaction.count }}</span> <span>{{ reaction.count }}</span>
</button> </button>
</UserListPopover> </UserListPopover>
@ -35,6 +49,8 @@
margin-top: 0.25em; margin-top: 0.25em;
flex-wrap: wrap; flex-wrap: wrap;
--emoji-size: calc(1.25em * var(--emojiReactionsScale, 1));
.emoji-reaction { .emoji-reaction {
padding: 0 0.5em; padding: 0 0.5em;
margin-right: 0.5em; margin-right: 0.5em;
@ -45,8 +61,24 @@
box-sizing: border-box; box-sizing: border-box;
.reaction-emoji { .reaction-emoji {
width: 1.25em; width: var(--emoji-size);
height: var(--emoji-size);
margin-right: 0.25em; margin-right: 0.25em;
line-height: var(--emoji-size);
display: flex;
justify-content: center;
align-items: center;
}
.reaction-emoji-content {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
line-height: inherit;
overflow: hidden;
font-size: calc(var(--emoji-size) * 0.8);
margin: 0;
} }
&:focus { &:focus {

View File

@ -121,7 +121,16 @@
scope="global" scope="global"
keypath="notifications.reacted_with" keypath="notifications.reacted_with"
> >
<span class="emoji-reaction-emoji">{{ notification.emoji }}</span> <img
v-if="notification.emoji_url"
class="emoji-reaction-emoji emoji-reaction-emoji-image"
:src="notification.emoji_url"
:name="notification.emoji"
>
<span
v-else
class="emoji-reaction-emoji"
>{{ notification.emoji }}</span>
</i18n-t> </i18n-t>
</small> </small>
</span> </span>
@ -153,9 +162,9 @@
</router-link> </router-link>
<button <button
class="button-unstyled expand-icon" class="button-unstyled expand-icon"
@click.prevent="toggleStatusExpanded"
:title="$t('tool_tip.toggle_expand')"
:aria-expanded="statusExpanded" :aria-expanded="statusExpanded"
:title="$t('tool_tip.toggle_expand')"
@click.prevent="toggleStatusExpanded"
> >
<FAIcon <FAIcon
class="fa-scale-110" class="fa-scale-110"

View File

@ -129,6 +129,13 @@
.emoji-reaction-emoji { .emoji-reaction-emoji {
font-size: 1.3em; font-size: 1.3em;
max-width: 1.25em;
height: 1.25em;
width: auto;
}
.emoji-reaction-emoji-image {
vertical-align: middle;
} }
.notification-details { .notification-details {

View File

@ -1,9 +1,8 @@
import Popover from '../popover/popover.vue' import Popover from '../popover/popover.vue'
import { ensureFinalFallback } from '../../i18n/languages.js' import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons' import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faSmileBeam } from '@fortawesome/free-regular-svg-icons' import { faSmileBeam } from '@fortawesome/free-regular-svg-icons'
import { trim } from 'lodash'
library.add( library.add(
faPlus, faPlus,
@ -20,105 +19,34 @@ const ReactButton = {
} }
}, },
components: { components: {
Popover Popover,
EmojiPicker
}, },
methods: { methods: {
addReaction (event, emoji, close) { addReaction (event) {
const emoji = event.insertion
const existingReaction = this.status.emoji_reactions.find(r => r.name === emoji) const existingReaction = this.status.emoji_reactions.find(r => r.name === emoji)
if (existingReaction && existingReaction.me) { if (existingReaction && existingReaction.me) {
this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji }) this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
} else { } else {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji }) this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
} }
close() },
show () {
if (!this.expanded) {
this.$refs.picker.showPicker()
}
}, },
onShow () { onShow () {
this.expanded = true this.expanded = true
this.focusInput()
}, },
onClose () { onClose () {
this.expanded = false this.expanded = false
},
focusInput () {
this.$nextTick(() => {
const input = document.querySelector('.reaction-picker-filter > input')
if (input) input.focus()
})
},
// Vaguely adjusted copypaste from emoji_input and emoji_picker!
maybeLocalizedEmojiNamesAndKeywords (emoji) {
const names = [emoji.displayText]
const keywords = []
if (emoji.displayTextI18n) {
names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args))
}
if (emoji.annotations) {
this.languages.forEach(lang => {
names.push(emoji.annotations[lang]?.name)
keywords.push(...(emoji.annotations[lang]?.keywords || []))
})
}
return {
names: names.filter(k => k),
keywords: keywords.filter(k => k)
}
},
maybeLocalizedEmojiName (emoji) {
if (!emoji.annotations) {
return emoji.displayText
}
if (emoji.displayTextI18n) {
return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
}
for (const lang of this.languages) {
if (emoji.annotations[lang]?.name) {
return emoji.annotations[lang].name
}
}
return emoji.displayText
} }
}, },
computed: { computed: {
commonEmojis () { hideCustomEmoji () {
const hardcodedSet = new Set(['👍', '😠', '👀', '😂', '🔥']) return !this.$store.state.instance.pleromaChatMessagesAvailable
return this.$store.getters.standardEmojiList.filter(emoji => hardcodedSet.has(emoji.replacement))
},
languages () {
return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
},
emojis () {
if (this.filterWord !== '') {
const keywordLowercase = trim(this.filterWord.toLowerCase())
const orderedEmojiList = []
for (const emoji of this.$store.getters.standardEmojiList) {
const indices = this.maybeLocalizedEmojiNamesAndKeywords(emoji)
.keywords
.map(k => k.toLowerCase().indexOf(keywordLowercase))
.filter(k => k > -1)
const indexOfKeyword = indices.length ? Math.min(...indices) : -1
if (indexOfKeyword > -1) {
if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
orderedEmojiList[indexOfKeyword] = []
}
orderedEmojiList[indexOfKeyword].push(emoji)
}
}
return orderedEmojiList.flat()
}
return this.$store.getters.standardEmojiList || []
},
mergedConfig () {
return this.$store.getters.mergedConfig
} }
} }
} }

View File

@ -1,51 +1,18 @@
<template> <template>
<Popover <span class="ReactButton">
trigger="click" <EmojiPicker
class="ReactButton" ref="picker"
placement="top" :enable-sticker-picker="enableStickerPicker"
:offset="{ y: 5 }" :hide-custom-emoji="hideCustomEmoji"
:bound-to="{ x: 'container' }" class="emoji-picker-panel"
remove-padding @emoji="addReaction"
popover-class="ReactButton popover-default"
@show="onShow" @show="onShow"
@close="onClose" @close="onClose"
> />
<template #content="{close}">
<div class="reaction-picker-filter">
<input
v-model="filterWord"
size="1"
:placeholder="$t('emoji.search_emoji')"
@input="$event.target.composing = false"
>
</div>
<div class="reaction-picker">
<span
v-for="emoji in commonEmojis"
:key="emoji.replacement"
class="emoji-button"
:title="maybeLocalizedEmojiName(emoji)"
@click="addReaction($event, emoji.replacement, close)"
>
{{ emoji.replacement }}
</span>
<div class="reaction-picker-divider" />
<span
v-for="(emoji, key) in emojis"
:key="key"
class="emoji-button"
:title="maybeLocalizedEmojiName(emoji)"
@click="addReaction($event, emoji.replacement, close)"
>
{{ emoji.replacement }}
</span>
<div class="reaction-bottom-fader" />
</div>
</template>
<template #trigger>
<span <span
class="button-unstyled popover-trigger" class="button-unstyled popover-trigger"
:title="$t('tool_tip.add_reaction')" :title="$t('tool_tip.add_reaction')"
@click.stop.prevent="show"
> >
<FALayers> <FALayers>
<FAIcon <FAIcon
@ -66,8 +33,7 @@
/> />
</FALayers> </FALayers>
</span> </span>
</template> </span>
</Popover>
</template> </template>
<script src="./react_button.js"></script> <script src="./react_button.js"></script>
@ -135,11 +101,6 @@
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
}
.popover-trigger-button {
/* override of popover internal stuff */
width: auto;
@include unfocused-style { @include unfocused-style {
.focus-marker { .focus-marker {

View File

@ -0,0 +1,16 @@
<template>
<NumberSetting
v-bind="$attrs"
>
<slot />
</NumberSetting>
</template>
<script>
import NumberSetting from './number_setting.vue'
export default {
components: {
NumberSetting
}
}
</script>

View File

@ -1,27 +1,17 @@
<template> <template>
<span <NumberSetting
v-if="matchesExpertLevel" v-bind="$attrs"
class="IntegerSetting" truncate="1"
> >
<label :for="path">
<slot /> <slot />
</label> </NumberSetting>
<input
:id="path"
class="number-input"
type="number"
step="1"
:disabled="disabled"
:min="min || 0"
:value="state"
@change="update"
>
{{ ' ' }}
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
</span>
</template> </template>
<script src="./integer_setting.js"></script> <script>
import NumberSetting from './number_setting.vue'
export default {
components: {
NumberSetting
}
}
</script>

View File

@ -8,6 +8,8 @@ export default {
path: String, path: String,
disabled: Boolean, disabled: Boolean,
min: Number, min: Number,
step: Number,
truncate: Number,
expert: [Number, String] expert: [Number, String]
}, },
computed: { computed: {
@ -15,8 +17,11 @@ export default {
const [firstSegment, ...rest] = this.path.split('.') const [firstSegment, ...rest] = this.path.split('.')
return [firstSegment + 'DefaultValue', ...rest].join('.') return [firstSegment + 'DefaultValue', ...rest].join('.')
}, },
parent () {
return this.$parent.$parent
},
state () { state () {
const value = get(this.$parent, this.path) const value = get(this.parent, this.path)
if (value === undefined) { if (value === undefined) {
return this.defaultState return this.defaultState
} else { } else {
@ -24,21 +29,28 @@ export default {
} }
}, },
defaultState () { defaultState () {
return get(this.$parent, this.pathDefault) return get(this.parent, this.pathDefault)
}, },
isChanged () { isChanged () {
return this.state !== this.defaultState return this.state !== this.defaultState
}, },
matchesExpertLevel () { matchesExpertLevel () {
return (this.expert || 0) <= this.$parent.expertLevel return (this.expert || 0) <= this.parent.expertLevel
} }
}, },
methods: { methods: {
truncateValue (value) {
if (!this.truncate) {
return value
}
return Math.trunc(value / this.truncate) * this.truncate
},
update (e) { update (e) {
set(this.$parent, this.path, parseInt(e.target.value)) set(this.parent, this.path, this.truncateValue(parseFloat(e.target.value)))
}, },
reset () { reset () {
set(this.$parent, this.path, this.defaultState) set(this.parent, this.path, this.defaultState)
} }
} }
} }

View File

@ -0,0 +1,27 @@
<template>
<span
v-if="matchesExpertLevel"
class="NumberSetting"
>
<label :for="path">
<slot />
</label>
<input
:id="path"
class="number-input"
type="number"
:step="step || 1"
:disabled="disabled"
:min="min || 0"
:value="state"
@change="update"
>
{{ ' ' }}
<ModifiedIndicator
:changed="isChanged"
:onclick="reset"
/>
</span>
</template>
<script src="./number_setting.js"></script>

View File

@ -2,6 +2,7 @@ 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'
import IntegerSetting from '../helpers/integer_setting.vue' import IntegerSetting from '../helpers/integer_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import SizeSetting, { defaultHorizontalUnits } from '../helpers/size_setting.vue' import SizeSetting, { defaultHorizontalUnits } from '../helpers/size_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'
@ -62,6 +63,7 @@ const GeneralTab = {
BooleanSetting, BooleanSetting,
ChoiceSetting, ChoiceSetting,
IntegerSetting, IntegerSetting,
FloatSetting,
SizeSetting, SizeSetting,
InterfaceLanguageSwitcher, InterfaceLanguageSwitcher,
ScopeSelector, ScopeSelector,

View File

@ -271,6 +271,15 @@
{{ $t('settings.no_rich_text_description') }} {{ $t('settings.no_rich_text_description') }}
</BooleanSetting> </BooleanSetting>
</li> </li>
<li>
<FloatSetting
v-if="user"
path="emojiReactionsScale"
expert="1"
>
{{ $t('settings.emoji_reactions_scale') }}
</FloatSetting>
</li>
<h3>{{ $t('settings.attachments') }}</h3> <h3>{{ $t('settings.attachments') }}</h3>
<li> <li>
<BooleanSetting <BooleanSetting

View File

@ -467,6 +467,7 @@
"pad_emoji": "Pad emoji with spaces when adding from picker", "pad_emoji": "Pad emoji with spaces when adding from picker",
"autocomplete_select_first": "Automatically select the first candidate when autocomplete results are available", "autocomplete_select_first": "Automatically select the first candidate when autocomplete results are available",
"emoji_reactions_on_timeline": "Show emoji reactions on timeline", "emoji_reactions_on_timeline": "Show emoji reactions on timeline",
"emoji_reactions_scale": "Reactions scale factor",
"export_theme": "Save preset", "export_theme": "Save preset",
"filtering": "Filtering", "filtering": "Filtering",
"wordfilter": "Wordfilter", "wordfilter": "Wordfilter",

View File

@ -97,6 +97,7 @@ export const defaultState = {
sidebarColumnWidth: '25rem', sidebarColumnWidth: '25rem',
contentColumnWidth: '45rem', contentColumnWidth: '45rem',
notifsColumnWidth: '25rem', notifsColumnWidth: '25rem',
emojiReactionsScale: 1.0,
navbarColumnStretch: false, navbarColumnStretch: false,
greentext: undefined, // instance default greentext: undefined, // instance default
useAtIcon: undefined, // instance default useAtIcon: undefined, // instance default
@ -185,6 +186,7 @@ const config = {
case 'sidebarColumnWidth': case 'sidebarColumnWidth':
case 'contentColumnWidth': case 'contentColumnWidth':
case 'notifsColumnWidth': case 'notifsColumnWidth':
case 'emojiReactionsScale':
applyConfig(state) applyConfig(state)
break break
case 'customTheme': case 'customTheme':

View File

@ -123,6 +123,7 @@ const defaultState = {
// Feature-set, apparently, not everything here is reported... // Feature-set, apparently, not everything here is reported...
shoutAvailable: false, shoutAvailable: false,
pleromaChatMessagesAvailable: false, pleromaChatMessagesAvailable: false,
pleromaCustomEmojiReactionsAvailable: false,
gopherAvailable: false, gopherAvailable: false,
mediaProxyAvailable: false, mediaProxyAvailable: false,
suggestionsEnabled: false, suggestionsEnabled: false,

View File

@ -441,6 +441,7 @@ export const parseNotification = (data) => {
: parseUser(data.target) : parseUser(data.target)
output.from_profile = parseUser(data.account) output.from_profile = parseUser(data.account)
output.emoji = data.emoji output.emoji = data.emoji
output.emoji_url = data.emoji_url
if (data.report) { if (data.report) {
output.report = data.report output.report = data.report
output.report.content = data.report.content output.report.content = data.report.content

View File

@ -21,8 +21,8 @@ export const applyTheme = (input) => {
body.classList.remove('hidden') body.classList.remove('hidden')
} }
const configColumns = ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth }) => const configColumns = ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth, emojiReactionsScale }) =>
({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth }) ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth, emojiReactionsScale })
const defaultConfigColumns = configColumns(defaultState) const defaultConfigColumns = configColumns(defaultState)