Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
93 KB
Referenced Files
None
Subscribers
None
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 74668c5017..b3d780b4f4 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -1,623 +1,635 @@
import { take } from 'lodash-es'
import { useId } from 'vue'
import Popover from 'src/components/popover/popover.vue'
import ScreenReaderNotice from 'src/components/screen_reader_notice/screen_reader_notice.vue'
import { ensureFinalFallback } from '../../i18n/languages.js'
import Completion from '../../services/completion/completion.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faSmileBeam } from '@fortawesome/free-regular-svg-icons'
library.add(faSmileBeam)
/**
* EmojiInput - augmented inputs for emoji and autocomplete support in inputs
* without having to give up the comfort of <input/> and <textarea/> elements
*
* Intended usage is:
* <EmojiInput v-model="something">
* <input v-model="something"/>
* </EmojiInput>
*
* Works only with <input> and <textarea>. Intended to use with only one nested
* input. It will find first input or textarea and work with that, multiple
* nested children not tested. You HAVE TO duplicate v-model for both
* <emoji-input> and <input>/<textarea> otherwise it will not work.
*
* Be prepared for CSS troubles though because it still wraps component in a div
* while TRYING to make it look like nothing happened, but it could break stuff.
*/
const EmojiInput = {
emits: ['update:modelValue', 'shown'],
props: {
suggest: {
/**
* suggest: function (input: String) => Suggestion[]
*
* Function that takes input string which takes string (textAtCaret)
* and returns an array of Suggestions
*
* Suggestion is an object containing following properties:
* displayText: string. Main display text, what actual suggestion
* represents (user's screen name/emoji shortcode)
* replacement: string. Text that should replace the textAtCaret
* detailText: string, optional. Subtitle text, providing additional info
* if present (user's nickname)
* imageUrl: string, optional. Image to display alongside with suggestion,
* currently if no image is provided, replacement will be used (for
* unicode emojis)
*
* TODO: make it asynchronous when adding proper server-provided user
* suggestions
*
* For commonly used suggestors (emoji, users, both) use suggestor.js
*/
required: true,
type: Function,
},
modelValue: {
/**
* Used for v-model
*/
required: true,
type: String,
},
enableEmojiPicker: {
/**
* Enables emoji picker support, this implies that custom emoji are supported
*/
required: false,
type: Boolean,
default: false,
},
hideEmojiButton: {
/**
* intended to use with external picker trigger, i.e. you have a button outside
* input that will open up the picker, see triggerShowPicker()
*/
required: false,
type: Boolean,
default: false,
},
enableStickerPicker: {
/**
* Enables sticker picker support, only makes sense when enableEmojiPicker=true
*/
required: false,
type: Boolean,
default: false,
},
placement: {
/**
* Forces the panel to take a specific position relative to the input element.
* The 'auto' placement chooses either bottom or top depending on which has the available space (when both have available space, bottom is preferred).
*/
required: false,
type: String, // 'auto', 'top', 'bottom'
default: 'auto',
},
newlineOnCtrlEnter: {
required: false,
type: Boolean,
default: false,
},
},
setup() {
const uniqueId = useId()
return { uniqueId }
},
data() {
return {
input: undefined,
caretEl: undefined,
highlighted: -1,
caret: 0,
focused: false,
blurTimeout: null,
temporarilyHideSuggestions: false,
disableClickOutside: false,
suggestions: [],
overlayStyle: {},
pickerShown: false,
+ suggestShown: false,
}
},
components: {
Popover,
EmojiPicker,
UnicodeDomainIndicator,
ScreenReaderNotice,
},
computed: {
padEmoji() {
return useMergedConfigStore().mergedConfig.padEmoji
},
defaultCandidateIndex() {
return useMergedConfigStore().mergedConfig.autocompleteSelect ? 0 : -1
},
preText() {
return this.modelValue.slice(0, this.caret)
},
postText() {
return this.modelValue.slice(this.caret)
},
- showSuggestions() {
+ shouldShowSuggestions() {
return (
this.focused &&
this.suggestions &&
this.suggestions.length > 0 &&
!this.pickerShown &&
!this.temporarilyHideSuggestions
)
},
textAtCaret() {
return this.wordAtCaret?.word
},
wordAtCaret() {
if (this.modelValue && this.caret) {
const word =
Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
return word
}
},
languages() {
return ensureFinalFallback(
useMergedConfigStore().mergedConfig.interfaceLanguage,
)
},
maybeLocalizedEmojiNamesAndKeywords() {
return (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(Boolean),
keywords: keywords.filter(Boolean),
}
}
},
maybeLocalizedEmojiName() {
return (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
}
},
ariaOwns() {
- if (this.showSuggestions) {
+ if (this.suggestShown) {
return this.suggestionListId
} else if (this.pickerShown) {
return this.emojiPickerId
} else {
return undefined
}
},
suggestionListId() {
return `suggestions-${this.uniqueId}`
},
emojiPickerId() {
return `emoji-picker-${this.uniqueId}`
},
suggestionItemId() {
return (index) => `suggestion-item-${index}-${this.uniqueId}`
},
},
mounted() {
- const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs
+ const { root, hiddenOverlayCaret, suggest, picker } = this.$refs
const input =
root.querySelector('.emoji-input > input') ||
root.querySelector('.emoji-input > textarea')
if (!input) return
this.input = input
this.caretEl = hiddenOverlayCaret
- if (suggestorPopover.setAnchorEl) {
- suggestorPopover.setAnchorEl(this.caretEl) // unit test compat
- this.$refs.picker.setAnchorEl(this.caretEl)
+ if (this.caretEl) {
+ suggest.setAnchorEl(this.caretEl) // unit test compat
+ picker.setAnchorEl(this.caretEl)
} else {
console.warn('setAnchorEl not found, are we in a unit test?')
}
const style = getComputedStyle(this.input)
this.overlayStyle.padding = style.padding
this.overlayStyle.border = style.border
this.overlayStyle.margin = style.margin
this.overlayStyle.lineHeight = style.lineHeight
this.overlayStyle.fontFamily = style.fontFamily
this.overlayStyle.fontSize = style.fontSize
this.overlayStyle.wordWrap = style.wordWrap
this.overlayStyle.whiteSpace = style.whiteSpace
input.addEventListener('blur', this.onBlur)
input.addEventListener('focus', this.onFocus)
input.addEventListener('paste', this.onPaste)
input.addEventListener('keyup', this.onKeyUp)
input.addEventListener('keydown', this.onKeyDown)
input.addEventListener('click', this.onClickInput)
input.addEventListener('transitionend', this.onTransition)
input.addEventListener('input', this.onInput)
input.addEventListener('scroll', this.onInputScroll)
},
unmounted() {
const { input } = this
if (input) {
input.removeEventListener('blur', this.onBlur)
input.removeEventListener('focus', this.onFocus)
input.removeEventListener('paste', this.onPaste)
input.removeEventListener('keyup', this.onKeyUp)
input.removeEventListener('keydown', this.onKeyDown)
input.removeEventListener('click', this.onClickInput)
input.removeEventListener('transitionend', this.onTransition)
input.removeEventListener('input', this.onInput)
input.removeEventListener('scroll', this.onInputScroll)
}
},
watch: {
- showSuggestions: function (newValue) {
- this.$emit('shown', newValue)
+ shouldShowSuggestions: function (newValue) {
+ this.$emit('suggestionsShown', newValue)
if (newValue) {
- this.$refs.suggestorPopover.showPopover()
+ this.$refs.suggest.showPopover()
} else {
- this.$refs.suggestorPopover.hidePopover()
+ this.$refs.suggest.hidePopover()
}
},
textAtCaret: async function (newWord) {
if (newWord === undefined) return
const firstchar = newWord.charAt(0)
if (newWord === firstchar) {
this.suggestions = []
return
}
const matchedSuggestions = await this.suggest(
newWord,
this.maybeLocalizedEmojiNamesAndKeywords,
)
// Async: cancel if textAtCaret has changed during wait
if (this.textAtCaret !== newWord || matchedSuggestions.length <= 0) {
this.suggestions = []
return
}
this.suggestions = take(matchedSuggestions, 5).map(
({ imageUrl, ...rest }) => ({
...rest,
img: imageUrl || '',
}),
)
this.highlighted = this.defaultCandidateIndex
this.$refs.screenReaderNotice.announce(
this.$t(
'tool_tip.autocomplete_available',
{ number: this.suggestions.length },
this.suggestions.length,
),
)
},
},
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()
this.scrollIntoView()
})
// This temporarily disables "click outside" handler
// since external trigger also means click originates
// from outside, thus preventing picker from opening
this.disableClickOutside = true
setTimeout(() => {
this.disableClickOutside = false
}, 0)
},
togglePicker() {
this.input.focus()
if (!this.pickerShown) {
this.scrollIntoView()
this.$refs.picker.showPicker()
} else {
this.$refs.picker.hidePicker()
}
},
replace(replacement) {
const newValue = Completion.replaceWord(
this.modelValue,
this.wordAtCaret,
replacement,
)
this.$emit('update:modelValue', newValue)
this.caret = 0
},
insert({ insertion, keepOpen, surroundingSpace = true }) {
const before = this.modelValue.substring(0, this.caret) || ''
const after = this.modelValue.substring(this.caret) || ''
/* Using a bit more smart approach to padding emojis with spaces:
* - put a space before cursor if there isn't one already, unless we
* are at the beginning of post or in spam mode
* - put a space after emoji if there isn't one already unless we are
* in spam mode
*
* The idea is that when you put a cursor somewhere in between sentence
* inserting just ' :emoji: ' will add more spaces to post which might
* break the flow/spacing, as well as the case where user ends sentence
* with a space before adding emoji.
*
* Spam mode is intended for creating multi-part emojis and overall spamming
* them, masto seem to be rendering :emoji::emoji: correctly now so why not
*/
const isSpaceRegex = /\s/
const spaceBefore =
surroundingSpace &&
!isSpaceRegex.exec(before.slice(-1)) &&
before.length &&
this.padEmoji > 0
? ' '
: ''
const spaceAfter =
surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji
? ' '
: ''
const newValue = [before, spaceBefore, insertion, spaceAfter, after].join(
'',
)
this.$emit('update:modelValue', newValue)
const position =
this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) {
this.input.focus()
}
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
// Set selection right after the replacement instead of the very end
this.input.setSelectionRange(position, position)
this.caret = position
})
},
replaceText(e, suggestion) {
const len = this.suggestions.length || 0
if (this.textAtCaret.length === 1) {
return
}
if (len > 0 || suggestion) {
const chosenSuggestion =
suggestion || this.suggestions[this.highlighted]
const replacement = chosenSuggestion.replacement
const newValue = Completion.replaceWord(
this.modelValue,
this.wordAtCaret,
replacement,
)
this.$emit('update:modelValue', newValue)
this.highlighted = 0
const position = this.wordAtCaret.start + replacement.length
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
this.input.focus()
// Set selection right after the replacement instead of the very end
this.input.setSelectionRange(position, position)
this.caret = position
})
e.preventDefault()
}
},
cycleBackward(e) {
const len = this.suggestions.length || 0
this.highlighted -= 1
if (this.highlighted === -1) {
this.input.focus()
} else if (this.highlighted < -1) {
this.highlighted = len - 1
}
if (len > 0) {
e.preventDefault()
}
},
cycleForward(e) {
const len = this.suggestions.length || 0
this.highlighted += 1
if (this.highlighted >= len) {
this.highlighted = -1
this.input.focus()
}
if (len > 0) {
e.preventDefault()
}
},
scrollIntoView() {
const rootRef = this.$refs.picker.$el
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const scrollerRef =
this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') ||
window
const currentScroll =
scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop
const scrollerHeight =
scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// We check where the bottom border of root element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const rootBottomBorder =
rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder)
// could also check top delta but there's no case for it
const targetScroll = currentScroll + bottomDelta
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
this.$nextTick(() => {
const { offsetHeight } = this.input
const { picker } = this.$refs
const pickerBottom = picker.$el.getBoundingClientRect().bottom
if (pickerBottom > window.innerHeight) {
picker.$el.style.top = 'auto'
picker.$el.style.bottom = offsetHeight + 'px'
}
})
},
onPickerShown() {
this.pickerShown = true
},
onPickerClosed() {
this.pickerShown = false
},
+ onSuggestShown() {
+ this.suggestShown = true
+ },
+ onSuggestClosed() {
+ this.suggestShown = false
+ },
+ focusInput() {
+ this.$refs.picker.hidePicker()
+ this.$refs.suggest.hidePopover()
+ this.input.focus()
+ },
onBlur(e) {
// Clicking on any suggestion removes focus from autocomplete,
// preventing click handler ever executing.
this.blurTimeout = setTimeout(() => {
this.focused = false
this.setCaret(e)
}, 200)
},
onClick(e, suggestion) {
this.replaceText(e, suggestion)
},
onFocus(e) {
if (this.blurTimeout) {
clearTimeout(this.blurTimeout)
this.blurTimeout = null
}
this.focused = true
this.setCaret(e)
this.temporarilyHideSuggestions = false
},
onKeyUp(e) {
const { key } = e
this.setCaret(e)
// Setting hider in keyUp to prevent suggestions from blinking
// when moving away from suggested spot
if (key === 'Escape') {
this.temporarilyHideSuggestions = true
} else {
this.temporarilyHideSuggestions = false
}
},
onPaste(e) {
this.setCaret(e)
},
onKeyDown(e) {
this.postingKeyboardControl(e)
this.suggestionsKeyboardControl(e)
},
postingKeyboardControl(e) {
const { ctrlKey, key } = e
if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
this.insert({ insertion: '\n', surroundingSpace: false })
// Ensure only one new line is added on macos
e.stopPropagation()
e.preventDefault()
// Scroll the input element to the position of the cursor
this.$nextTick(() => {
this.input.blur()
this.input.focus()
})
return
}
},
suggestionsKeyboardControl(e) {
// Disable suggestions hotkeys if suggestions are hidden
if (this.temporarilyHideSuggestions) {
return
}
// TODO Probably add optional keyboard controls for emoji picker?
const { ctrlKey, shiftKey, key } = e
if (key === 'Tab') {
if (shiftKey) {
this.cycleBackward(e)
} else {
this.cycleForward(e)
}
} else if (key === 'ArrowUp') {
this.cycleBackward(e)
} else if (key === 'ArrowDown') {
this.cycleForward(e)
} else if (key === 'Enter' && !ctrlKey) {
this.replaceText(e)
} else if (key === 'Escape') {
// Escape hides suggestions, if suggestions are hidden it
// de-focuses the element (i.e. default browser behavior)
this.input.focus()
}
},
onInput(e) {
this.setCaret(e)
this.$emit('update:modelValue', e.target.value)
},
onStickerUploaded(e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed(e) {
this.$emit('sticker-upload-Failed', e)
},
setCaret({ target: { selectionStart } }) {
this.caret = selectionStart
this.$nextTick(() => {
- this.$refs.suggestorPopover?.updateStyles()
+ this.$refs.suggest?.updateStyles()
})
},
autoCompleteItemLabel(suggestion) {
if (suggestion.user) {
return suggestion.displayText + ' ' + suggestion.detailText
} else {
return this.maybeLocalizedEmojiName(suggestion)
}
},
},
}
export default EmojiInput
diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue
index 436a70b650..92f84b5821 100644
--- a/src/components/emoji_input/emoji_input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -1,236 +1,243 @@
<template>
<div
ref="root"
class="input emoji-input"
:class="{ '-with-picker': !hideEmojiButton, '-textarea': input?.tagName === 'TEXTAREA' }"
>
<slot
:id="'textbox-' + uniqueId"
:aria-owns="ariaOwns"
aria-autocomplete="list"
:aria-activedescendant="(!showSuggestions || highlighted === -1) ? undefined : suggestionItemId(highlighted)"
aria-haspopup="menu"
/>
<!-- TODO: make the 'x' disappear if at the end maybe? -->
<div
ref="hiddenOverlay"
class="hidden-overlay"
:style="overlayStyle"
:aria-hidden="true"
>
<span>{{ preText }}</span>
<span
ref="hiddenOverlayCaret"
class="caret"
>
x
<Popover
- ref="suggestorPopover"
+ ref="suggest"
class="autocomplete-panel"
:aria-label="$t('general.suggestions')"
placement="bottom"
- :hide-trigger="true"
+ hide-trigger
+ @show="onSuggestShown"
+ @close="onSuggestClosed"
+ @forward-tab-content-focus="focusInput"
+ @reverse-tab-content-focus="focusInput"
>
<template #content>
<div
:id="suggestionListId"
ref="panel-body"
class="autocomplete-panel-body"
role="menu"
>
<div
v-for="(suggestion, index) in suggestions"
:id="suggestionItemId(index)"
:key="index"
class="menu-item autocomplete-item"
role="menuitemradio"
:class="{ '-active': index === highlighted }"
:aria-label="autoCompleteItemLabel(suggestion)"
:aria-checked="index === highlighted"
@click.stop.prevent="onClick($event, suggestion)"
>
<span class="image">
<img
v-if="suggestion.img"
:src="suggestion.img"
:alt="suggestion.user ? $t('user_card.avatar') : $t('emoji.emoji')"
:title="suggestion.user ? $t('user_card.avatar') : $t('emoji.emoji')"
>
<span v-else>{{ suggestion.replacement }}</span>
</span>
<div class="label">
<span
v-if="suggestion.user"
class="displayText"
>
{{ suggestion.displayText }}<UnicodeDomainIndicator
:user="suggestion.user"
:at="false"
/>
</span>
<span
v-if="!suggestion.user"
class="displayText"
>
{{ maybeLocalizedEmojiName(suggestion) }}
</span>
<span class="detailText">{{ suggestion.detailText }}</span>
</div>
</div>
</div>
</template>
</Popover>
<EmojiPicker
v-if="enableEmojiPicker"
:id="emojiPickerId"
ref="picker"
:enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel"
+ hide-trigger
@emoji="insert"
@sticker-uploaded="onStickerUploaded"
@sticker-upload-failed="onStickerUploadFailed"
@show="onPickerShown"
@close="onPickerClosed"
+ @forward-tab-content-focus="focusInput"
+ @reverse-tab-content-focus="focusInput"
/>
</span>
<span>{{ postText }}</span>
</div>
<screen-reader-notice
ref="screenReaderNotice"
aria-live="assertive"
/>
<template v-if="enableEmojiPicker">
<button
v-if="!hideEmojiButton"
class="button-unstyled emoji-picker-icon"
type="button"
:title="$t('emoji.add_emoji')"
@click.prevent="togglePicker"
>
<FAIcon :icon="['far', 'face-smile-beam']" />
</button>
</template>
</div>
</template>
<script src="./emoji_input.js"></script>
<style lang="scss">
.input.emoji-input {
padding: 0;
display: flex;
flex-direction: column;
position: relative;
.emoji-picker-icon {
position: absolute;
bottom: 0;
right: 0;
height: 100%;
padding: 0 0.2em;
font-size: 1.3em;
cursor: pointer;
line-height: 1.2em;
&:hover i {
color: var(--text);
}
}
&.-textarea {
.emoji-picker-icon {
height: auto;
padding: 0.2em;
}
}
input,
textarea {
flex: 1 0 auto;
color: inherit;
/* stylelint-disable-next-line declaration-no-important */
background: none !important;
box-shadow: none;
border: none;
outline: none;
}
&.-with-picker {
textarea,
input {
padding-right: 2.4em;
}
}
.hidden-overlay {
opacity: 0;
pointer-events: none;
position: absolute;
inset: 0;
overflow: hidden;
/* DEBUG STUFF */
color: red;
/* set opacity to non-zero to see the overlay */
.caret {
width: 0;
margin-right: calc(-1ch - 1px);
border: 1px solid red;
.popover-trigger-button {
height: 100%;
position: absolute;
}
}
}
}
.autocomplete {
&-panel {
position: absolute;
}
&-item.menu-item {
display: flex;
padding-top: 0;
padding-bottom: 0;
.image {
width: calc(var(--__line-height) + var(--__vertical-gap) * 2);
height: calc(var(--__line-height) + var(--__vertical-gap) * 2);
line-height: var(--__line-height);
text-align: center;
margin-right: var(--__horizontal-gap);
img {
width: calc(var(--__line-height) + var(--__vertical-gap) * 2);
height: calc(var(--__line-height) + var(--__vertical-gap) * 2);
object-fit: contain;
}
span {
font-size: var(--__line-height);
line-height: var(--__line-height);
}
}
.label {
display: flex;
flex-direction: column;
justify-content: center;
margin: 0 0.1em 0 0.2em;
.displayText {
line-height: 1.5;
}
.detailText {
font-size: 0.6em;
line-height: 0.6em;
}
}
}
}
</style>
diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js
index 99fbc742a9..e3191174f0 100644
--- a/src/components/emoji_picker/emoji_picker.js
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -1,446 +1,451 @@
import { chunk, debounce, trim } from 'lodash-es'
import { defineAsyncComponent } from 'vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Popover from 'src/components/popover/popover.vue'
import { ensureFinalFallback } from '../../i18n/languages.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBasketballBall,
faBoxOpen,
faBus,
faCode,
faFlag,
faIceCream,
faLightbulb,
faPaw,
faSmile,
faSmileBeam,
faStickyNote,
faUser,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faBoxOpen,
faStickyNote,
faSmileBeam,
faSmile,
faUser,
faPaw,
faIceCream,
faBus,
faBasketballBall,
faLightbulb,
faCode,
faFlag,
)
const UNICODE_EMOJI_GROUP_ICON = {
'smileys-and-emotion': 'smile',
'people-and-body': 'user',
'animals-and-nature': 'paw',
'food-and-drink': 'ice-cream',
'travel-and-places': 'bus',
activities: 'basketball-ball',
objects: 'lightbulb',
symbols: 'code',
flags: 'flag',
}
const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => {
const res = [emoji.displayText, nameLocalizer(emoji)]
if (emoji.annotations) {
languages.forEach((lang) => {
const keywords = emoji.annotations[lang]?.keywords || []
const name = emoji.annotations[lang]?.name
res.push(...keywords.concat([name]).filter(Boolean))
})
}
return res
}
const filterByKeyword = (
list,
keyword = '',
languages = [],
nameLocalizer = () => '',
) => {
if (keyword === '') return list
const keywordLowercase = keyword.toLowerCase()
const orderedEmojiList = []
for (const emoji of list) {
const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer)
.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()
}
const getOffset = (elem) => {
const style = elem.style.transform
const res = /translateY\((\d+)px\)/.exec(style)
if (!res) {
return 0
}
return Number.parseInt(res[1], 10)
}
const toHeaderId = (id) => {
return id.replace(/^row-\d+-/, '')
}
const EmojiPicker = {
props: {
enableStickerPicker: {
- required: false,
type: Boolean,
default: true,
},
- hideCustomEmoji: {
- required: false,
- type: Boolean,
- default: false,
- },
+ hideCustomEmoji: Boolean,
+ hideTrigger: Boolean,
},
inject: {
popoversZLayer: {
default: '',
},
},
data() {
return {
keyword: '',
activeGroup: 'custom',
showingStickers: false,
groupsScrolledClass: 'scrolled-top',
keepOpen: false,
customEmojiTimeout: null,
hideCustomEmojiInPicker: false,
// Lazy-load only after the first time `showing` becomes true.
contentLoaded: false,
popoverShown: false,
groupRefs: {},
emojiRefs: {},
filteredEmojiGroups: [],
emojiSize: 0,
width: 0,
}
},
+ emits: [
+ 'emojiPickerShown',
+ 'stickerUploaded',
+ 'stickerUploadFailed',
+ 'emoji',
+ 'forwardTabContentFocus',
+ 'reverseTabContentFocus',
+ ],
components: {
StickerPicker: defineAsyncComponent(
() => import('src/components/sticker_picker/sticker_picker.vue'),
),
Checkbox,
-
Popover,
},
methods: {
groupScroll(e) {
e.currentTarget.scrollLeft += e.deltaY + e.deltaX
},
updateEmojiSize() {
const css = window.getComputedStyle(this.$refs.popover.$el)
const fontSize = css.getPropertyValue('font-size') || '1rem'
const emojiSize = css.getPropertyValue('--emojiSize') || '2.2rem'
const fontSizeUnit = fontSize.replace(/[0-9,.]+/, '').trim()
const fontSizeValue = Number(fontSize.replace(/[^0-9,.]+/, ''))
const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '').trim()
const emojiSizeValue = Number(emojiSize.replace(/[^0-9,.]+/, ''))
let fontSizeMultiplier
if (fontSizeUnit.endsWith('em')) {
fontSizeMultiplier = fontSizeValue
} else {
fontSizeMultiplier = fontSizeValue / 14
}
let emojiSizeReal
if (emojiSizeUnit.endsWith('em')) {
emojiSizeReal = emojiSizeValue * fontSizeMultiplier * 14
} else {
emojiSizeReal = emojiSizeValue
}
const fullEmojiSize = emojiSizeReal + 2 * 0.2 * fontSizeMultiplier * 14
this.emojiSize = fullEmojiSize
},
togglePicker() {
if (this.popoverShown) {
this.hidePicker()
} else {
this.showPicker()
}
},
showPicker() {
this.$refs.popover.showPopover()
- this.$nextTick(() => {
- this.onShowing()
- })
},
hidePicker() {
this.$refs.popover.hidePopover()
},
setAnchorEl(el) {
this.$refs.popover.setAnchorEl(el)
},
setGroupRef(name) {
return (el) => {
this.groupRefs[name] = el
}
},
onPopoverShown() {
+ this.$emit('emojiPickerShown', true)
this.popoverShown = true
+ this.$nextTick(() => {
+ this.onShowing()
+ })
},
onPopoverClosed() {
+ this.$emit('emojiPickerShown', false)
this.popoverShown = false
},
onStickerUploaded(e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed(e) {
- this.$emit('sticker-upload-failed', e)
+ this.$emit('stickerUploadFailed', e)
},
onEmoji(emoji) {
const value = emoji.imageUrl
? `:${emoji.displayText}:`
: emoji.replacement
if (!this.keepOpen) {
this.$refs.popover.hidePopover()
}
this.$emit('emoji', {
insertion: value,
insertionUrl: emoji.imageUrl,
keepOpen: this.keepOpen,
})
},
onScroll(startIndex, endIndex, visibleStartIndex, visibleEndIndex) {
const target = this.$refs['emoji-groups'].$el
this.scrolledGroup(target, visibleStartIndex, visibleEndIndex)
},
scrolledGroup(target, start, end) {
const top = target.scrollTop + 5
this.$nextTick(() => {
this.emojiItems.slice(start, end + 1).forEach((group) => {
const headerId = toHeaderId(group.id)
const ref = this.groupRefs['group-' + group.id]
if (!ref) {
return
}
const elem = ref.$el.parentElement
if (!elem) {
return
}
if (elem && getOffset(elem) <= top) {
this.activeGroup = headerId
}
})
this.scrollHeader()
})
},
scrollHeader() {
// Scroll the active tab's header into view
const headerRef = this.groupRefs['group-header-' + this.activeGroup]
const left = headerRef.offsetLeft
const right = left + headerRef.offsetWidth
const headerCont = this.$refs.header
const currentScroll = headerCont.scrollLeft
const currentScrollRight = currentScroll + headerCont.clientWidth
const setScroll = (s) => {
headerCont.scrollLeft = s
}
const margin = 7 // .emoji-tabs-item: padding
if (left - margin < currentScroll) {
setScroll(left - margin)
} else if (right + margin > currentScrollRight) {
setScroll(right + margin - headerCont.clientWidth)
}
},
highlight(groupId) {
this.setShowStickers(false)
const indexInList = this.emojiItems.findIndex((k) => k.id === groupId)
this.$refs['emoji-groups'].scrollToItem(indexInList)
},
updateScrolledClass(target) {
if (target.scrollTop <= 5) {
this.groupsScrolledClass = 'scrolled-top'
} else if (target.scrollTop >= target.scrollTopMax - 5) {
this.groupsScrolledClass = 'scrolled-bottom'
} else {
this.groupsScrolledClass = 'scrolled-middle'
}
},
toggleStickers() {
this.showingStickers = !this.showingStickers
},
setShowStickers(value) {
this.showingStickers = value
},
filterByKeyword(list, keyword) {
return filterByKeyword(
list,
keyword,
this.languages,
this.maybeLocalizedEmojiName,
)
},
onShowing() {
const oldContentLoaded = this.contentLoaded
this.updateEmojiSize()
this.recalculateItemPerRow()
this.$nextTick(() => {
this.$refs.search.focus()
})
this.contentLoaded = true
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
if (!oldContentLoaded) {
this.$nextTick(() => {
if (this.defaultGroup) {
this.highlight(this.defaultGroup)
}
})
}
},
getFilteredEmojiGroups() {
return this.allEmojiGroups
.map((group) => ({
...group,
emojis: this.filterByKeyword(group.emojis, trim(this.keyword)),
}))
.filter((group) => group.emojis.length > 0)
},
recalculateItemPerRow() {
this.$nextTick(() => {
if (!this.$refs['emoji-groups']) {
return
}
this.width = this.$refs['emoji-groups'].$el.clientWidth
})
},
},
watch: {
keyword() {
this.onScroll()
this.debouncedHandleKeywordChange()
},
allCustomGroups() {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
},
},
computed: {
minItemSize() {
return this.emojiSize
},
// used to watch it
fontSize() {
this.$nextTick(() => {
this.updateEmojiSize()
})
return useMergedConfigStore().mergedConfig.fontSize
},
emojiHeight() {
return this.emojiSize
},
itemPerRow() {
return this.width ? Math.floor(this.width / this.emojiSize) : 6
},
activeGroupView() {
return this.showingStickers ? '' : this.activeGroup
},
stickersAvailable() {
if (useEmojiStore().stickers) {
return useEmojiStore().stickers.length > 0
}
return false
},
allCustomGroups() {
if (this.hideCustomEmoji || this.hideCustomEmojiInPicker) {
return {}
}
const emojis = useEmojiStore().groupedCustomEmojis
if (emojis.unpacked) {
emojis.unpacked.text = this.$t('emoji.unpacked')
}
return emojis
},
defaultGroup() {
return Object.keys(this.allCustomGroups)[0]
},
unicodeEmojiGroups() {
return useEmojiStore().standardEmojiGroupList.map((group) => ({
id: `standard-${group.id}`,
text: this.$t(`emoji.unicode_groups.${group.id}`),
icon: UNICODE_EMOJI_GROUP_ICON[group.id],
emojis: group.emojis,
}))
},
allEmojiGroups() {
return Object.entries(this.allCustomGroups)
.map(([, v]) => v)
.concat(this.unicodeEmojiGroups)
},
stickerPickerEnabled() {
return (useEmojiStore().stickers || []).length !== 0
},
debouncedHandleKeywordChange() {
return debounce(() => {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
}, 500)
},
emojiItems() {
return this.filteredEmojiGroups.flatMap((group) =>
chunk(group.emojis, this.itemPerRow).map((items, index) => ({
...group,
id: index === 0 ? group.id : `row-${index}-${group.id}`,
emojis: items,
isFirstRow: index === 0,
})),
)
},
languages() {
return ensureFinalFallback(
useMergedConfigStore().mergedConfig.interfaceLanguage,
)
},
maybeLocalizedEmojiName() {
return (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
}
},
isInModal() {
return this.popoversZLayer === 'modals'
},
},
}
export default EmojiPicker
diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue
index 9d1d962766..feb05eae45 100644
--- a/src/components/emoji_picker/emoji_picker.vue
+++ b/src/components/emoji_picker/emoji_picker.vue
@@ -1,187 +1,192 @@
<template>
<Popover
ref="popover"
trigger="click"
popover-class="emoji-picker popover-default"
- :hide-trigger="true"
+ :hide-trigger="hideTrigger"
placement="bottom"
+ :aria-label="$t('emoji.picker')"
@show="onPopoverShown"
@close="onPopoverClosed"
- :aria-label="$t('emoji.picker')"
+ @forward-tab-content-focus="$emit('forwardTabContentFocus')"
+ @reverse-tab-content-focus="$emit('reverseTabContentFocus')"
v-bind="$attrs"
>
+ <template #trigger>
+ <slot />
+ </template>
<template #content>
<div
class="heading"
role="presentation"
>
<div
class="emoji-search"
role="presentation"
>
<input
ref="search"
role="search"
v-model="keyword"
type="text"
class="input form-control"
:placeholder="$t('emoji.search_emoji')"
:title="$t('emoji.search_emoji')"
@input="$event.target.composing = false"
>
</div>
<!--
Body scroll lock needs to be on every scrollable element on safari iOS.
Here we tell it to enable scrolling for this element.
See https://github.com/willmcpo/body-scroll-lock#vanilla-js
-->
<span
ref="header"
v-body-scroll-lock="isInModal"
class="emoji-tabs"
role="tablist"
@wheel.prevent="groupScroll"
>
<button
v-for="group in filteredEmojiGroups"
:ref="setGroupRef('group-header-' + group.id)"
:key="group.id"
class="button-unstyled emoji-tabs-item"
:class="{
toggled: activeGroupView === group.id
}"
:title="group.text"
role="tab"
:aria-selected="activeGroupView === group.id"
@click.prevent="highlight(group.id)"
>
<span
v-if="group.image"
class="emoji-picker-header-image"
>
<StillImage
:alt="group.text"
:src="group.image"
/>
</span>
<FAIcon
v-else
:icon="group.icon"
fixed-width
/>
</button>
<button
v-if="stickerPickerEnabled"
class="button-unstyled stickers-tab-icon"
:class="{toggled: showingStickers}"
:title="$t('emoji.stickers')"
@click.prevent="toggleStickers"
:aria-selected="showingStickers"
role="tab"
>
<FAIcon
icon="sticky-note"
fixed-width
/>
</button>
</span>
</div>
<div
v-if="contentLoaded"
class="content"
>
<div
class="emoji-content"
:class="{hidden: showingStickers}"
>
<!-- Enables scrolling for this element on safari iOS. See comments for header. -->
<DynamicScroller
ref="emoji-groups"
v-body-scroll-lock="isInModal"
class="emoji-groups"
:class="groupsScrolledClass"
:min-item-size="minItemSize"
:buffer="minItemSize"
:items="emojiItems"
:emit-update="true"
@update="onScroll"
@visible="recalculateItemPerRow"
@resize="recalculateItemPerRow"
role="tabpanel"
>
<template #default="{ item: group, index, active }">
<DynamicScrollerItem
:ref="setGroupRef('group-' + group.id)"
:item="group"
:active="active"
:data-index="index"
:size-dependencies="[group.emojis.length]"
>
<div
class="emoji-group"
:class="{ ['first-row']: group.isFirstRow }"
:style="{ '--__amount': itemPerRow }"
>
<h1
v-if="group.isFirstRow"
class="emoji-group-title"
>
{{ group.text }}
</h1>
<button
v-for="emoji in group.emojis"
:key="group.id + emoji.displayText"
:title="maybeLocalizedEmojiName(emoji)"
class="button-unstyled emoji-item"
@click.stop.prevent="onEmoji(emoji)"
>
<span
v-if="!emoji.imageUrl"
class="emoji-picker-emoji -unicode"
>{{ emoji.replacement }}</span>
<StillImage
v-else
class="emoji-picker-emoji -custom"
loading="lazy"
:alt="maybeLocalizedEmojiName(emoji)"
:src="emoji.imageUrl"
:data-emoji-name="group.id + emoji.displayText"
/>
</button>
</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
<div class="keep-open">
<Checkbox v-model="keepOpen">
{{ $t('emoji.keep_open') }}
</Checkbox>
</div>
<div
v-if="!hideCustomEmoji"
class="hide-custom-emoji"
>
<Checkbox
v-model="hideCustomEmojiInPicker"
@change="onShowing"
>
{{ $t('emoji.hide_custom_emoji') }}
</Checkbox>
</div>
</div>
<div
v-if="showingStickers"
class="stickers-content"
>
<sticker-picker
@uploaded="onStickerUploaded"
@upload-failed="onStickerUploadFailed"
/>
</div>
</div>
</template>
</Popover>
</template>
<script src="./emoji_picker.js"></script>
<style lang="scss" src="./emoji_picker.scss"></style>
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js
index 36d1c45b6c..8aa4d81360 100644
--- a/src/components/popover/popover.js
+++ b/src/components/popover/popover.js
@@ -1,531 +1,540 @@
import { useId } from 'vue'
const Popover = {
name: 'Popover',
inheritAttrs: false,
props: {
// Action to trigger popover: either 'hover' or 'click'
trigger: String,
// Accessible label
label: String,
// 'top', 'bottom', 'left', 'right'
placement: String,
// Takes object with properties 'x' and 'y', values of these can be
// 'container' for using offsetParent as boundaries for either axis
// or 'viewport'
boundTo: Object,
// Takes a selector to use as a replacement for the parent container
// for getting boundaries for x an y axis
boundToSelector: String,
// Takes a top/bottom/left/right object, how much space to leave
// between boundary and popover element
margin: Object,
// Takes a x/y object and tells how many pixels to offset from
// anchor point on either axis
offset: Object,
// Replaces the classes you may want for the popover container.
// Use 'popover-default' in addition to get the default popover
// styles with your custom class.
popoverClass: String,
// If true, subtract padding when calculating position for the popover,
// use it when popover offset looks to be different on top vs bottom.
removePadding: Boolean,
// self-explanatory (i hope)
disabled: Boolean,
// Instead of putting popover next to anchor, overlay popover's center on top of anchor's center
overlayCenters: Boolean,
// What selector (witin popover!) to use for determining center of popover
overlayCentersSelector: String,
// Lets hover popover stay when clicking inside of it
stayOnClick: Boolean,
// Use styled button (to avoid nested buttons)
normalButton: Boolean,
// Use <a> instead of <button>
link: {
type: Boolean,
default: false,
},
// Whether to hide the trigger totally
hideTrigger: {
type: Boolean,
default: false,
},
// Used to set or unset aria-haspopup (attribute not set for tooltips)
tooltip: Boolean,
},
+ emits: [
+ 'show',
+ 'close',
+ 'forwardTabContentFocus',
+ 'reverseTabContentFocus',
+ ],
inject: {
// override popover z layer
popoversZLayer: {
default: '',
},
},
setup() {
const uniqueId = useId()
return { uniqueId }
},
data() {
return {
// lockReEntry is a flag that is set when mouse cursor is leaving the popover's content
// so that if mouse goes back into popover it won't be re-shown again to prevent annoyance
// with popovers refusing to be hidden when user wants to interact with something in below popover
anchorEl: null,
// There's an issue where having teleport enabled by default causes things just...
// not render at all, i.e. main post status form and its emoji inputs
teleport: false,
lockReEntry: false,
hidden: true,
nextFocus: null,
redirectFocus: false,
styles: {},
oldSize: { width: 0, height: 0 },
scrollable: null,
// used to avoid blinking if hovered onto popover
graceTimeout: null,
parentPopover: null,
disableClickOutside: false,
childrenShown: new Set(),
}
},
computed: {
idPrefix() {
return `popover-${this.uniqueId}-`
},
allTriggerAttrs() {
if (process.env.NODE_ENV === 'development') {
if ('aria-hidden' in this.$attrs) {
throw new Error(
'Do not use aria-hidden in attrs. Instead set hideTrigger to true',
)
}
}
const allAttrs = {
...this.$attrs,
}
const {
'aria-hidden': _unused0,
'aria-label': _unused1,
...rest
} = allAttrs
const attrs = { ...rest }
if (this.hideTrigger) {
attrs['aria-hidden'] = true
- attrs.tabindex = 0
+ attrs.tabindex = -1
}
return attrs
},
ariaHasPopup() {
if (this.tooltip || this.disabled) {
return undefined
} else {
return this.$attrs['aria-haspopup'] ?? 'menu'
}
},
triggerClass() {
if (this.link) {
return ''
} else if (this.normalButton) {
return 'button-default btn'
} else {
return 'button-unstyled'
}
},
},
methods: {
setAnchorEl(el) {
this.anchorEl = el
this.updateStyles()
},
containerBoundingClientRect() {
const container = this.boundToSelector
? this.$el.closest(this.boundToSelector)
: this.$el.offsetParent
return container.getBoundingClientRect()
},
updateStyles() {
if (this.hidden) {
this.styles = {}
return
}
// Popover will be anchored around this element, trigger ref is the container, so
// its children are what are inside the slot. Expect only one v-slot:trigger.
const anchorEl =
this.anchorEl || this.$refs.trigger?.children[0] || this.$refs.trigger
// SVGs don't have offsetWidth/Height, use fallback
const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
const anchorScreenBox = anchorEl.getBoundingClientRect()
const anchorStyle = getComputedStyle(anchorEl)
const topPadding = Number.parseFloat(anchorStyle.paddingTop)
const bottomPadding = Number.parseFloat(anchorStyle.paddingBottom)
const rightPadding = Number.parseFloat(anchorStyle.paddingRight)
const leftPadding = Number.parseFloat(anchorStyle.paddingLeft)
// Screen position of the origin point for popover = center of the anchor
const origin = {
x: anchorScreenBox.left + anchorWidth * 0.5,
y: anchorScreenBox.top + anchorHeight * 0.5,
}
const content = this.$refs.content
const overlayCenter = this.overlayCenters
? this.$refs.content.querySelector(this.overlayCentersSelector)
: null
// Minor optimization, don't call a slow reflow call if we don't have to
const parentScreenBox =
(this.boundTo?.x === 'container' || this.boundTo?.y === 'container') &&
this.containerBoundingClientRect()
const margin = this.margin || {}
// What are the screen bounds for the popover? Viewport vs container
// when using viewport, using default margin values to dodge the navbar
const xBounds =
this.boundTo?.x === 'container'
? {
min: parentScreenBox.left + (margin.left || 0),
max: parentScreenBox.right - (margin.right || 0),
}
: {
min: 0 + (margin.left || 10),
max: window.innerWidth - (margin.right || 10),
}
const yBounds =
this.boundTo?.y === 'container'
? {
min: parentScreenBox.top + (margin.top || 0),
max: parentScreenBox.bottom - (margin.bottom || 0),
}
: {
min: 0 + (margin.top || 50),
max: window.innerHeight - (margin.bottom || 5),
}
let horizOffset = 0
let vertOffset = 0
if (overlayCenter) {
const box = content.getBoundingClientRect()
const overlayCenterScreenBox = overlayCenter.getBoundingClientRect()
const leftInnerOffset = overlayCenterScreenBox.left - box.left
const topInnerOffset = overlayCenterScreenBox.top - box.top
horizOffset = -leftInnerOffset - overlayCenter.offsetWidth * 0.5
vertOffset = -topInnerOffset - overlayCenter.offsetHeight * 0.5
} else {
horizOffset = content.offsetWidth * -0.5
vertOffset = content.offsetHeight * -0.5
}
const leftBorder = origin.x + horizOffset
const rightBorder = leftBorder + content.offsetWidth
const topBorder = origin.y + vertOffset
const bottomBorder = topBorder + content.offsetHeight
// If overflowing from left, move it so that it doesn't
if (leftBorder < xBounds.min) {
horizOffset += xBounds.min - leftBorder
}
// If overflowing from right, move it so that it doesn't
if (rightBorder > xBounds.max) {
horizOffset -= rightBorder - xBounds.max
}
// If overflowing from top, move it so that it doesn't
if (topBorder < yBounds.min) {
vertOffset += yBounds.min - topBorder
}
// If overflowing from bottom, move it so that it doesn't
if (bottomBorder > yBounds.max) {
vertOffset -= bottomBorder - yBounds.max
}
let translateX = 0
let translateY = 0
if (overlayCenter) {
translateX = origin.x + horizOffset
translateY = origin.y + vertOffset
} else if (this.placement !== 'right' && this.placement !== 'left') {
// Default to whatever user wished with placement prop
let usingTop = this.placement !== 'bottom'
// Handle special cases, first force to displaying on top if there's no space on bottom,
// regardless of what placement value was. Then check if there's no space on top, and
// force to bottom, again regardless of what placement value was.
const topBoundary =
origin.y - anchorHeight * 0.5 + (this.removePadding ? topPadding : 0)
const bottomBoundary =
origin.y +
anchorHeight * 0.5 -
(this.removePadding ? bottomPadding : 0)
if (bottomBoundary + content.offsetHeight > yBounds.max) usingTop = true
if (topBoundary - content.offsetHeight < yBounds.min) usingTop = false
const yOffset = this.offset?.y || 0
translateY = usingTop
? topBoundary - yOffset - content.offsetHeight
: bottomBoundary + yOffset
const xOffset = this.offset?.x || 0
translateX = origin.x + horizOffset + xOffset
} else {
// Default to whatever user wished with placement prop
let usingLeft = this.placement !== 'right'
// Handle special cases, first force to displaying on left if there's no space on right,
// regardless of what placement value was. Then check if there's no space on right, and
// force to left, again regardless of what placement value was.
const leftBoundary =
origin.x - anchorWidth * 0.5 + (this.removePadding ? leftPadding : 0)
const rightBoundary =
origin.x + anchorWidth * 0.5 - (this.removePadding ? rightPadding : 0)
if (rightBoundary + content.offsetWidth > xBounds.max) usingLeft = true
if (leftBoundary - content.offsetWidth < xBounds.min) usingLeft = false
const xOffset = this.offset?.x || 0
translateX = usingLeft
? leftBoundary - xOffset - content.offsetWidth
: rightBoundary + xOffset
const yOffset = this.offset?.y || 0
translateY = origin.y + vertOffset + yOffset
}
this.styles = {
left: `${Math.round(translateX)}px`,
top: `${Math.round(translateY)}px`,
}
if (this.popoversZLayer) {
this.styles['--ZI_popover_override'] =
`var(--ZI_${this.popoversZLayer}_popovers)`
}
if (parentScreenBox) {
this.styles.maxWidth = `${Math.round(parentScreenBox.width)}px`
}
},
showPopover() {
if (this.disabled) return
this.disableClickOutside = true
setTimeout(() => {
this.disableClickOutside = false
}, 0)
const wasHidden = this.hidden
this.hidden = false
this.parentPopover?.onChildPopoverState(this, true)
if (this.trigger === 'click' || this.stayOnClick) {
document.addEventListener('click', this.onClickOutside)
}
this.scrollable.addEventListener('scroll', this.onScroll)
this.scrollable.addEventListener('resize', this.onResize)
// My assumption is that upon showing popover initially has different size
// as its contents are getting populating, so logic uses those incorrect
// sizes as basis
setTimeout(() => {
if (wasHidden) this.$emit('show')
this.updateStyles()
}, 1)
},
hidePopover() {
if (this.disabled) return
if (!this.hidden) this.$emit('close')
this.redirectFocus = false
this.nextFocus = null
this.hidden = true
this.parentPopover?.onChildPopoverState(this, false)
if (this.trigger === 'click') {
document.removeEventListener('click', this.onClickOutside)
}
this.scrollable?.removeEventListener('scroll', this.onScroll)
this.scrollable?.removeEventListener('resize', this.onResize)
},
resizePopover() {
setTimeout(() => {
this.updateStyles()
}, 1)
},
onMouseenter() {
if (this.trigger === 'hover') {
this.lockReEntry = false
clearTimeout(this.graceTimeout)
this.graceTimeout = null
this.showPopover()
}
},
onMouseleave() {
if (this.trigger === 'hover' && this.childrenShown.size === 0) {
this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
}
},
onMouseenterContent() {
if (this.trigger === 'hover' && !this.lockReEntry) {
this.lockReEntry = true
clearTimeout(this.graceTimeout)
this.graceTimeout = null
this.showPopover()
}
},
onMouseleaveContent() {
if (this.trigger === 'hover' && this.childrenShown.size === 0) {
this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
}
},
// # Focus management.
//
// Since our content is far away in separate #modals or #popovers container
// the native tabindex flow is broken, but we still want user to be able to
// focus popover contents. To do so we do a bit of focus management.
//
// We do a little focus trapping and redirecting. First, we must establish
// our order. Opening a popover (including auto-open by hover/focus) and
// hitting tab should select content's first focusable element. Shift-tab
// away from popover trigger should hide popover and continue on as normal.
// While inside content, shift-tabbing from first focusable element should
// focus trigger again, and tabbing beyond last focusable element should
// focus element next in line relative to trigger.
//
// We use two tricks here:
// 1. we add two bogus ".focus-trap" elements before and after popover
// contents. Those are aria-hidden="1" and empty spans so they don't
// interfere with the UI or accessibility. We use them to detect when
// user tab-navigated beyond first and last focusable elements and act
// accordingly
// 2. when we open up a popover we listen for "Tab" keypress and engage
// "redirectFocus", so that when trigger loses focus (immideately after)
// we a) focus content's first focusable element and b) save reference
// to the next intended focus target.
//
// There is a blind spot in this approach. If user opened a popover and
// navigated in it with a mouse we never get to know the "next intended
// focus target" so focus order ends up being broken.
//
// There's probably a better way to figure out this "next target" but for
// now this will do.
onFocus(e) {
if (this.trigger === 'hover') {
this.showPopover()
}
},
onBlur(e) {
if (this.redirectFocus) {
const firstFocusable = this.$refs.content
?.querySelector('*:not(.focus-trap)')
?.querySelector('a[href], button, [tabindex="0"], input')
if (firstFocusable) {
firstFocusable?.focus()
this.nextFocus = e.relatedTarget
} else {
this.hidePopover()
}
}
this.redirectFocus = false
},
onTab(e) {
this.redirectFocus = true
},
onForwardTabContentFocus() {
this.nextFocus?.focus()
this.hidePopover()
+ this.$emit('forwardTabContentFocus')
},
onReverseTabContentFocus() {
- this.$refs.trigger.focus()
+ const triggerer = this.anchorEl ?? this.$refs.trigger
+ triggerer.focus()
+ this.$emit('reverseTabContentFocus')
},
onClick(e) {
if (this.trigger === 'click') {
if (e.ctrlKey) return
if (e.shiftKey) return
if (e.metaKey) return
if (e.button > 0) return
e.preventDefault()
if (this.hidden) {
this.showPopover()
} else {
this.hidePopover()
}
}
},
onClickOutside(e) {
if (this.disableClickOutside) return
if (this.hidden) return
if (this.$refs.content?.contains(e.target)) return
if (this.$el.contains(e.target)) return
if (this.childrenShown.size > 0) return
this.hidePopover()
if (this.parentPopover) this.parentPopover.onClickOutside(e)
},
onScroll() {
this.updateStyles()
},
onResize() {
const content = this.$refs.content
if (!content) return
if (
this.oldSize.width !== content.offsetWidth ||
this.oldSize.height !== content.offsetHeight
) {
this.updateStyles()
this.oldSize = {
width: content.offsetWidth,
height: content.offsetHeight,
}
}
},
onChildPopoverState(childRef, state) {
if (state) {
this.childrenShown.add(childRef)
} else {
this.childrenShown.delete(childRef)
}
},
},
updated() {
// Monitor changes to content size, update styles only when content sizes have changed,
// that should be the only time we need to move the popover box if we don't care about scroll
// or resize
this.onResize()
},
mounted() {
this.teleport = true
let scrollable =
this.$refs.trigger.closest('.column.-scrollable') ||
this.$refs.trigger.closest('.mobile-notifications')
if (!scrollable) scrollable = window
this.scrollable = scrollable
let parent = this.$parent
while (parent && parent.$.type.name !== 'Popover') {
parent = parent.$parent
}
this.parentPopover = parent
},
beforeUnmount() {
this.hidePopover()
},
}
export default Popover
diff --git a/src/components/status_action_buttons/action_button.js b/src/components/status_action_buttons/action_button.js
index 3ea502afff..d075c947cd 100644
--- a/src/components/status_action_buttons/action_button.js
+++ b/src/components/status_action_buttons/action_button.js
@@ -1,177 +1,164 @@
import Popover from 'src/components/popover/popover.vue'
import StatusBookmarkFolderMenu from 'src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue'
-import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
-import { useStatusesStore } from 'src/stores/statuses.js'
import { useUsersStore } from 'src/stores/users.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBookmark as faBookmarkRegular,
faFaceSmileBeam,
faStar as faStarRegular,
} from '@fortawesome/free-regular-svg-icons'
import {
faBookmark,
faCheck,
faChevronDown,
faChevronRight,
faComments,
faExternalLinkAlt,
faEye,
faEyeSlash,
faHistory,
faList,
faMinus,
faPencil,
faPlus,
faReply,
faRetweet,
faShareAlt,
faStar,
faThumbtack,
faTimes,
faWrench,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faPlus,
faMinus,
faCheck,
faTimes,
faWrench,
faChevronRight,
faChevronDown,
faReply,
faRetweet,
faStar,
faStarRegular,
faFaceSmileBeam,
faBookmark,
faBookmarkRegular,
faEyeSlash,
faEye,
faThumbtack,
faPencil,
faShareAlt,
faComments,
faList,
faExternalLinkAlt,
faHistory,
)
export default {
props: {
button: Object,
status: Object,
extra: Boolean,
funcArg: Object,
getClass: Function,
getComponent: Function,
doAction: Function,
outerClose: Function,
defaultButtonStyle: Boolean,
hideLabel: Boolean,
popoverTrigger: Boolean,
},
components: {
StatusBookmarkFolderMenu,
- EmojiPicker,
Popover,
},
data: () => ({
animationState: false,
}),
computed: {
buttonClass() {
return [
this.button.name + '-button',
{
'-with-extra': this.button.name === 'bookmark',
'-extra': this.extra,
'-quick': !this.extra,
},
]
},
userIsMuted() {
return useUsersStore().relationship(this.status.user.id).muting
},
threadIsMuted() {
return this.status.thread_muted
},
hideCustomEmoji() {
return !useInstanceCapabilitiesStore()
.pleromaCustomEmojiReactionsAvailable
},
hidePostStats() {
return useMergedConfigStore().mergedConfig.hidePostStats
},
buttonInnerClass() {
const buttonStyleClass = this.defaultButtonStyle
? 'button-default'
: 'button-unstyled'
return [
this.button.name + '-button',
{
'main-button': this.extra,
[buttonStyleClass]: !this.extra,
'-active': this.button.active?.(this.funcArg),
disabled: this.button.interactive
? !this.button.interactive(this.funcArg)
: false,
},
]
},
remoteInteractionLink() {
return useInstanceStore().getRemoteInteractionLink({
statusId: this.status.id,
})
},
+ isInteractive() {
+ return this.functionWrap('interactive', true)(this.funcArg)
+ },
+ href() {
+ if (this.getComponent(this.button) !== 'a') return undefined
+ return this.functionWrap('link', remoteInteractionLink)(this.funcArg) ?? remoteInteractionLink
+ }
},
methods: {
- addReaction(event) {
- const emoji = event.insertion
- const existingReaction = this.status.emoji_reactions.find(
- (r) => r.name === emoji,
- )
- if (existingReaction?.me) {
- useStatusesStore().unreactWithEmoji(this.status.id, emoji)
- } else {
- useStatusesStore().reactWithEmoji(this.status.id, emoji)
- }
- },
- onShowEmojiPicker() {
- this.$emit('emojiPickerShown', true)
- },
- onHideEmojiPicker() {
- this.$emit('emojiPickerShown', false)
+ functionWrap(property, fallback) {
+ const isFunction = typeof this.button[property] === 'function'
+
+ return isFunction
+ ? this.button[property]
+ : () => this.button[property] ?? fallback
},
doActionWrap(
button,
close = () => {
/* no-op */
},
) {
- if (
- this.button.interactive ? !this.button.interactive(this.funcArg) : false
- )
- return
- if (button.name === 'emoji') {
- this.$refs.picker.togglePicker()
- } else {
- this.animationState = true
- this.getComponent(button) === 'button' && this.doAction(button)
- setTimeout(() => {
- this.animationState = false
- }, 500)
- if (!this.button.dropdown) close()
- }
+ if (!this.isInteractive) return
+ this.animationState = true
+ this.getComponent(button) === 'button' && this.doAction(button)
+ setTimeout(() => {
+ this.animationState = false
+ }, 500)
+ if (!this.button.dropdown) close()
},
},
}
diff --git a/src/components/status_action_buttons/action_button.vue b/src/components/status_action_buttons/action_button.vue
index eb19503daf..33cd321393 100644
--- a/src/components/status_action_buttons/action_button.vue
+++ b/src/components/status_action_buttons/action_button.vue
@@ -1,113 +1,102 @@
<template>
<div
class="action-button"
:class="buttonClass"
role="presentation"
>
<component
:is="popoverTrigger ? 'span' : getComponent(button)"
class="action-button-inner"
:class="buttonInnerClass"
:role="popoverTrigger ? 'presentation' : 'button'"
:type="popoverTrigger ? undefined : 'button'"
placement="bottom"
:title="popoverTrigger ? undefined : $t(button.label(funcArg))"
target="_blank"
:tabindex="popoverTrigger ? undefined : 0"
- :disabled="button.interactive ? !button.interactive(funcArg) : false"
- :href="getComponent(button) == 'a' ? button.link?.(funcArg) || remoteInteractionLink : undefined"
+ :disabled="!isInteractive"
+ :href="href"
@click="popoverTrigger ? undefined : doActionWrap(button, outerClose)"
>
<FALayers role="presentation">
<FAIcon
class="fa-scale-110"
:icon="button.icon(funcArg)"
:spin="!extra && getComponent(button) == 'button' && button.animated?.() && animationState"
:style="{ '--fa-animation-duration': '750ms' }"
fixed-width
/>
<template v-if="!buttonClass.disabled && (!button.interactive || button?.interactive(funcArg)) && button.toggleable?.(funcArg) && button.active">
<FAIcon
v-if="button.active(funcArg) && button.activeIndicator?.() !== null"
class="active-marker"
transform="shrink-6 up-9 left-12"
:icon="button.activeIndicator?.(funcArg) || 'check'"
/>
<FAIcon
v-if="!button.active(funcArg)"
class="focus-marker"
transform="shrink-6 up-9 left-12"
:icon="button.openIndicator?.(funcArg) || 'plus'"
/>
<FAIcon
v-else
class="focus-marker"
transform="shrink-6 up-9 left-12"
:icon="button.closeIndicator?.(funcArg) || 'minus'"
/>
</template>
</FALayers>
<span
v-if="extra"
class="action-label"
>
{{ $t(button.label(funcArg)) }}
</span>
<FAIcon
v-if="button.dropdown?.()"
class="chevron-icon"
:icon="extra ? 'chevron-right' : 'chevron-down'"
fixed-width
/>
</component>
<span
v-if="!hidePostStats && button.counter?.(funcArg) > 0 && !hideLabel"
class="action-counter"
:aria-label="$t(`status.${button.counterLabel()}`, [button.counter?.(funcArg)])"
>
{{ button.counter?.(funcArg) }}
</span>
<span
v-if="!extra && button.name === 'bookmark'"
class="separator"
/>
<Popover
v-if="button.name === 'bookmark'"
class="extra-button"
:trigger="extra ? 'hover' : 'click'"
:placement="extra ? 'right' : 'bottom'"
:offset="extra ? { x: 10 } : { y: 10 }"
:aria-label="$t('nav.bookmark_folders')"
>
<template #trigger>
<FAIcon
class="chevron-icon"
:icon="extra ? 'chevron-right' : 'chevron-down'"
fixed-width
/>
</template>
<template #content="{close}">
<StatusBookmarkFolderMenu
v-if="button.name === 'bookmark'"
:status="status"
@close="() => { close(); outerClose?.() }"
/>
</template>
</Popover>
-
- <EmojiPicker
- v-if="button.name === 'emoji'"
- ref="picker"
- :enable-sticker-picker="false"
- :hide-custom-emoji="hideCustomEmoji"
- class="emoji-picker-panel"
- @emoji="addReaction"
- @show="onShowEmojiPicker"
- @close="onHideEmojiPicker"
- />
</div>
</template>
<script src="./action_button.js" />
<style lang="scss" src="./action_button.scss" />
diff --git a/src/components/status_action_buttons/action_button_container.js b/src/components/status_action_buttons/action_button_container.js
index fce80d9f0f..47edbf1518 100644
--- a/src/components/status_action_buttons/action_button_container.js
+++ b/src/components/status_action_buttons/action_button_container.js
@@ -1,151 +1,175 @@
import { defineAsyncComponent } from 'vue'
import Popover from 'src/components/popover/popover.vue'
import ActionButton from './action_button.vue'
+import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useUsersStore } from 'src/stores/users.js'
import genRandomSeed from 'src/services/random_seed/random_seed.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEnvelope,
faEye,
faEyeSlash,
faFolderTree,
faGlobe,
faLock,
faLockOpen,
faUser,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faUser,
faGlobe,
faFolderTree,
faEye,
faEyeSlash,
faLock,
faLockOpen,
faEnvelope,
)
export default {
components: {
ActionButton,
Popover,
MuteConfirm: defineAsyncComponent(
() => import('src/components/confirm_modal/mute_confirm.vue'),
),
UserTimedFilterModal: defineAsyncComponent(
() =>
import(
'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
),
),
+ EmojiPicker,
+ },
+ props: {
+ button: {
+ type: Object,
+ required: true,
+ },
+ status: {
+ type: Object,
+ required: true,
+ },
+ defaultButton: Boolean,
+ hideLabel: Boolean,
},
- props: ['button', 'status', 'defaultButton', 'hideLabel'],
emits: ['emojiPickerShown'],
mounted() {
if (this.button.name === 'mute') {
useUsersStore().fetchDomainMutes()
}
},
data() {
return {
randomSeed: genRandomSeed(),
}
},
computed: {
buttonClass() {
return [
this.button.name + '-button',
{
'-with-extra': this.button.name === 'bookmark',
'-extra': this.extra,
'-quick': !this.extra,
},
]
},
user() {
return this.status.user
},
userIsMuted() {
return useUsersStore().relationship(this.user.id).muting
},
conversationIsMuted() {
return this.status.thread_muted
},
domain() {
return this.user.fqn.split('@')[1]
},
domainIsMuted() {
return new Set(useUsersStore().currentUser.domainMutes).has(this.domain)
},
availableScopes() {
return ['private', 'unlisted', 'direct', 'public'].filter((scope) => {
return scope !== this.status.visibility
})
},
},
methods: {
+ addReaction(event) {
+ const emoji = event.insertion
+ const existingReaction = this.status.emoji_reactions.find(
+ (r) => r.name === emoji,
+ )
+ if (existingReaction?.me) {
+ useStatusesStore().unreactWithEmoji(this.status.id, emoji)
+ } else {
+ useStatusesStore().reactWithEmoji(this.status.id, emoji)
+ }
+ },
visibilityIcon(visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
unmuteUser() {
return useUsersStore().unmuteUser(this.user.id)
},
unmuteConversation() {
return useStatusesStore().unmuteConversation(this.status.id)
},
unmuteDomain() {
return useUsersStore().unmuteDomain(this.domain)
},
toggleUserMute() {
if (this.userIsMuted) {
this.unmuteUser()
} else {
this.$refs.confirmUser.optionallyPrompt()
}
},
setScope(visibility) {
return useAdminSettingsStore().changeStatusScope({
id: this.status.id,
visibility,
})
},
setSensitive(sensitive) {
useAdminSettingsStore().changeStatusScope({
id: this.status.id,
sensitive,
})
},
toggleConversationMute() {
if (this.conversationIsMuted) {
this.unmuteConversation()
} else {
this.$refs.confirmConversation.optionallyPrompt()
}
},
toggleDomainMute() {
if (this.domainIsMuted) {
this.unmuteDomain()
} else {
this.$refs.confirmDomain.optionallyPrompt()
}
},
},
}
diff --git a/src/components/status_action_buttons/action_button_container.vue b/src/components/status_action_buttons/action_button_container.vue
index b4477508e2..210c979145 100644
--- a/src/components/status_action_buttons/action_button_container.vue
+++ b/src/components/status_action_buttons/action_button_container.vue
@@ -1,163 +1,180 @@
<template>
<div role="presentation">
+ <EmojiPicker
+ v-if="button.name === 'emoji'"
+ ref="picker"
+ :enable-sticker-picker="false"
+ :hide-custom-emoji="hideCustomEmoji"
+ class="emoji-picker-panel"
+ @emoji="addReaction"
+ @show="onShowEmojiPicker"
+ @close="onHideEmojiPicker"
+ @emoji-picker-shown="e => $emit('emojiPickerShown', e)"
+ >
+ <ActionButton
+ :button="button" :status="status"
+ :hide-label="hideLabel"
+ popover-trigger
+ v-bind.prop="$attrs"
+ />
+ </EmojiPicker>
<Popover
- v-if="button.dropdown?.()"
+ v-else-if="button.dropdown?.()"
:trigger="$attrs.extra ? 'hover' : 'click'"
:offset="{ y: 5 }"
:placement="$attrs.extra ? 'right' : 'bottom'"
:title="$t(button.label(funcArg))"
>
<template #trigger>
<ActionButton
:button="button"
:status="status"
:hide-label="hideLabel"
popover-trigger
v-bind.prop="$attrs"
/>
</template>
<template #content>
<div
v-if="button.name === 'changeScope'"
:id="`popup-menu-scope-${randomSeed}`"
class="dropdown-menu"
role="menu"
>
<div
v-for="visibility in availableScopes"
:key="visibility"
class="menu-item dropdown-item extra-action -icon"
>
<button
class="main-button"
@click="() => setScope(visibility)"
>
<FAIcon
:icon="visibilityIcon(visibility)"
fixed-width
/>
{{ $t('general.scope_in_timeline.' + visibility) }}
</button>
</div>
<div
v-if="status.nsfw"
class="menu-item dropdown-item extra-action -icon"
>
<button
class="main-button"
@click="() => setSensitive(false)"
>
<FAIcon
icon="eye"
fixed-width
/>
{{ $t('status.mark_as_non-sensitive') }}
</button>
</div>
<div
v-else
class="menu-item dropdown-item extra-action -icon"
>
<button
class="main-button"
@click="() => setSensitive(true)"
>
<FAIcon
icon="eye-slash"
fixed-width
/>
{{ $t('status.mark_as_sensitive') }}
</button>
</div>
</div>
<div
v-if="button.name === 'mute'"
:id="`popup-menu-${randomSeed}`"
class="dropdown-menu"
role="menu"
>
<div class="menu-item dropdown-item extra-action -icon">
<button
class="main-button"
@click="toggleUserMute"
>
<FAIcon
icon="user"
fixed-width
/>
<template v-if="userIsMuted">
{{ $t('status.unmute_user') }}
</template>
<template v-else>
{{ $t('status.mute_user') }}
</template>
</button>
</div>
<div class="menu-item dropdown-item extra-action -icon">
<button
class="main-button"
@click="toggleConversationMute"
>
<FAIcon
icon="folder-tree"
fixed-width
/>
<template v-if="conversationIsMuted">
{{ $t('status.unmute_conversation') }}
</template>
<template v-else>
{{ $t('status.mute_conversation') }}
</template>
</button>
</div>
<div class="menu-item dropdown-item extra-action -icon">
<button
class="main-button"
@click="toggleDomainMute"
>
<FAIcon
icon="globe"
fixed-width
/>
<template v-if="domainIsMuted">
{{ $t('status.unmute_domain') }}
</template>
<template v-else>
{{ $t('status.mute_domain') }}
</template>
</button>
</div>
</div>
</template>
</Popover>
<ActionButton
v-else
:button="button"
:status="status"
:hide-label="hideLabel"
v-bind="$attrs"
- @emoji-picker-shown="e => $emit('emojiPickerShown', e)"
/>
<teleport to="#modal">
<MuteConfirm
ref="confirmConversation"
type="conversation"
:status="status"
:user="user"
/>
<MuteConfirm
ref="confirmDomain"
type="domain"
:status="status"
:user="user"
/>
<UserTimedFilterModal
ref="confirmUser"
:is-mute="true"
:user="user"
/>
</teleport>
</div>
</template>
<script src="./action_button_container.js" />
diff --git a/src/components/status_action_buttons/buttons_definitions.js b/src/components/status_action_buttons/buttons_definitions.js
index b60ee4cd8f..d60cf7553a 100644
--- a/src/components/status_action_buttons/buttons_definitions.js
+++ b/src/components/status_action_buttons/buttons_definitions.js
@@ -1,341 +1,340 @@
import { useEditStatusStore } from 'src/stores/editStatus.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useReportsStore } from 'src/stores/reports.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useStatusHistoryStore } from 'src/stores/statusHistory.js'
const PRIVATE_SCOPES = new Set(['private', 'direct'])
const PUBLIC_SCOPES = new Set(['public', 'unlisted'])
export const BUTTONS = [
{
// =========
// REPLY
// =========
name: 'reply',
label: 'tool_tip.reply',
icon: 'reply',
active: ({ replying }) => replying,
counter: ({ status }) => status.replies_count,
counterLabel: 'replies_counter',
anon: true,
anonLink: true,
toggleable: true,
closeIndicator: 'times',
activeIndicator: null,
action({ emit }) {
emit('toggleReplying')
return Promise.resolve()
},
},
{
// =========
// REPEAT
// =========
name: 'retweet',
label: ({ status }) =>
status.repeated ? 'tool_tip.unrepeat' : 'tool_tip.repeat',
icon({ status, loggedIn, currentUser }) {
if (
loggedIn &&
status.user.id !== currentUser.id &&
PRIVATE_SCOPES.has(status.visibility)
) {
return 'lock'
}
return 'retweet'
},
animated: true,
active: ({ status }) => status.repeated,
counter: ({ status }) => status.repeat_num,
counterLabel: 'repeats_counter',
anonLink: true,
interactive: ({ status, currentUser }) =>
!!currentUser &&
(currentUser.id === status.user.id ||
!PRIVATE_SCOPES.has(status.visibility)),
toggleable: true,
confirm: ({ status }) =>
!status.repeated && useMergedConfigStore().mergedConfig.modalOnRepeat,
confirmStrings: {
title: 'status.repeat_confirm_title',
body: 'status.repeat_confirm',
confirm: 'status.repeat_confirm_accept_button',
cancel: 'status.repeat_confirm_cancel_button',
},
action({ status }) {
if (!status.repeated) {
return useStatusesStore().retweet(status.id)
} else {
return useStatusesStore().unretweet(status.id)
}
},
},
{
// =========
// FAVORITE
// =========
name: 'favorite',
label: ({ status }) =>
status.favorited ? 'tool_tip.unfavorite' : 'tool_tip.favorite',
icon: ({ status }) =>
status.favorited ? ['fas', 'star'] : ['far', 'star'],
animated: true,
active: ({ status }) => status.favorited,
counter: ({ status }) => status.fave_num,
counterLabel: 'favorites_counter',
anonLink: true,
toggleable: true,
action({ status }) {
if (!status.favorited) {
return useStatusesStore().favorite(status.id)
} else {
return useStatusesStore().unfavorite(status.id)
}
},
},
{
// =========
// EMOJI REACTIONS
// =========
name: 'emoji',
label: 'tool_tip.add_reaction',
icon: ['far', 'face-smile-beam'],
- interactive: () => true,
active: ({ emojiPickerShown }) => emojiPickerShown,
toggleable: true,
anonLink: true,
},
{
// =========
// MUTE
// =========
name: 'mute',
icon: 'eye-slash',
label: 'status.mute_ellipsis',
if: ({ loggedIn }) => loggedIn,
toggleable: false,
dropdown: true,
action({ status, emit }) {
/* prevent hiding */
},
},
{
// =========
// PIN STATUS
// =========
name: 'pin',
icon: 'thumbtack',
label: ({ status }) => (status.pinned ? 'status.unpin' : 'status.pin'),
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
status.user.id === currentUser.id &&
PUBLIC_SCOPES.has(status.visibility)
)
},
action({ status }) {
if (status.pinned) {
return useStatusesStore().unpinStatus(status.id)
} else {
return useStatusesStore().pinStatus(status.id)
}
},
},
{
// =========
// BOOKMARK
// =========
name: 'bookmark',
icon: ({ status }) =>
status.bookmarked ? ['fas', 'bookmark'] : ['far', 'bookmark'],
toggleable: true,
active: ({ status }) => status.bookmarked,
label: ({ status }) =>
status.bookmarked ? 'status.unbookmark' : 'status.bookmark',
if: ({ loggedIn }) => loggedIn,
action({ status }) {
if (status.bookmarked) {
return useStatusesStore().unbookmark(status.id)
} else {
return useStatusesStore().bookmark(status.id)
}
},
},
{
// =========
// EDIT HISTORY
// =========
name: 'editHistory',
icon: 'history',
label: 'status.status_history',
if({ status }) {
return (
useInstanceCapabilitiesStore().editingAvailable &&
status.edited_at !== null
)
},
action({ status }) {
useStatusHistoryStore().openModal(status.id)
return Promise.resolve()
},
},
{
// =========
// EDIT
// =========
name: 'edit',
icon: 'pen',
label: 'status.edit',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
useInstanceCapabilitiesStore().editingAvailable &&
status.user.id === currentUser.id
)
},
action({ status }) {
return useStatusesStore()
.fetchStatusSource(status.id)
.then((data) =>
useEditStatusStore().openEditStatusModal({
statusId: status.id,
statusSubject: data.spoiler_text,
statusText: data.text,
statusIsSensitive: status.nsfw,
statusPoll: status.poll,
statusFiles: [...status.attachments],
statusVisibility: status.visibility,
statusContentType: data.content_type,
}),
)
},
},
{
// =========
// OPEN IN CHAT VIEW
// =========
name: 'chat_view',
icon: 'comments',
label: 'status.open_in_chat_view',
if({ chatView }) {
return !chatView
},
action({ router, status }) {
router.push({ name: 'conversation2', params: { statusId: status.id } })
},
},
{
// =========
// OPEN IN THREAD VIEW
// =========
name: 'thread_view',
icon: 'list',
label: 'status.open_in_thread_view',
if({ chatView }) {
return chatView
},
action({ router, status }) {
router.push({ name: 'conversation', params: { statusId: status.id } })
},
},
{
// =========
// DELETE
// =========
name: 'delete',
icon: 'times',
label: 'status.delete',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
(status.user.id === currentUser.id ||
currentUser.privileges.has('messages_delete'))
)
},
confirm: () => useMergedConfigStore().mergedConfig.modalOnDelete,
confirmStrings: {
title: 'status.delete_confirm_title',
body: 'status.delete_confirm',
confirm: 'status.delete_confirm_accept_button',
cancel: 'status.delete_confirm_cancel_button',
},
action({ status }) {
return useStatusesStore().deleteStatus(status.id)
},
},
{
// =========
// CHANGE SCOPE
// =========
name: 'changeScope',
icon: 'eye',
label: 'status.admin_change_scope',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
(status.user.id === currentUser.id ||
currentUser.privileges.has('messages_delete'))
)
},
toggleable: false,
dropdown: true,
action({ status, emit }) {
/* prevent hiding */
},
},
{
// =========
// SHARE/COPY
// =========
name: 'share',
icon: 'share-alt',
label: 'status.copy_link',
action({ status, router }) {
navigator.clipboard.writeText(
[
useInstanceStore().server,
router.resolve({
name: 'conversation',
params: { statusId: status.id },
}).href,
].join(''),
)
return Promise.resolve()
},
},
{
// =========
// EXTERNAL
// =========
name: 'external',
icon: 'external-link-alt',
label: 'status.external_source',
link: ({ status }) => status.external_url,
},
{
// =========
// REPORT
// =========
name: 'report',
icon: 'flag',
label: 'user_card.report',
if: ({ loggedIn }) => loggedIn,
action({ status }) {
useReportsStore().openUserReportingModal({
userId: status.user.id,
statusIds: [status.id],
})
return Promise.resolve()
},
},
].map((button) => {
return Object.fromEntries(
Object.entries(button).map(([k, v]) => [
k,
typeof v === 'function' || k === 'name' ? v : () => v,
]),
)
})

File Metadata

Mime Type
text/x-diff
Expires
Sat, Oct 31, 8:43 PM (18 h, 49 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1801770
Default Alt Text
(93 KB)

Event Timeline