Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85805611
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
70 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 29b767b15b..74668c5017 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -1,623 +1,623 @@
-import { useId } from 'vue'
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,
}
},
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() {
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) {
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 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)
} 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)
if (newValue) {
this.$refs.suggestorPopover.showPopover()
} else {
this.$refs.suggestorPopover.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
},
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()
})
},
autoCompleteItemLabel(suggestion) {
if (suggestion.user) {
return suggestion.displayText + ' ' + suggestion.detailText
} else {
return this.maybeLocalizedEmojiName(suggestion)
}
},
},
}
export default EmojiInput
diff --git a/src/components/settings_modal/helpers/setting.js b/src/components/settings_modal/helpers/setting.js
index 8637f29e04..432d5a8fc1 100644
--- a/src/components/settings_modal/helpers/setting.js
+++ b/src/components/settings_modal/helpers/setting.js
@@ -1,435 +1,435 @@
-import { useId } from 'vue'
import { cloneDeep, get, isEqual, set } from 'lodash-es'
+import { useId } from 'vue'
import DraftButtons from './draft_buttons.vue'
import LocalSettingIndicator from './local_setting_indicator.vue'
import ModifiedIndicator from './modified_indicator.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useProfileConfigStore } from 'src/stores/profile_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
export default {
components: {
ModifiedIndicator,
DraftButtons,
LocalSettingIndicator,
},
props: {
modelValue: {
type: String,
default: null,
},
description: {
type: String,
default: null,
},
path: {
type: [String, Array],
required: false,
},
showDescription: {
type: Boolean,
required: false,
},
descriptionPathOverride: {
type: [String, Array],
required: false,
},
suggestions: {
type: [String, Array],
required: false,
},
subgroup: {
type: String,
required: false,
},
disabled: {
type: Boolean,
default: false,
},
local: {
type: Boolean,
default: false,
},
parentPath: {
type: [String, Array],
},
parentInvert: {
type: Boolean,
default: false,
},
expert: {
type: [Number, String],
default: 0,
},
source: {
type: String,
default: undefined,
},
hideDraftButtons: {
// this is for the weird backend hybrid (Boolean|String or Boolean|Number) settings
required: false,
type: Boolean,
},
hideLabel: {
type: Boolean,
},
hideDescription: {
type: Boolean,
},
swapDescriptionAndLabel: {
type: Boolean,
},
backendDescriptionPath: {
type: [String, Array],
},
overrideBackendDescription: {
type: Boolean,
},
overrideBackendDescriptionLabel: {
type: [Boolean, String],
},
draftMode: {
type: Boolean,
default: undefined,
},
timedApplyMode: {
type: Boolean,
default: false,
},
},
inject: {
defaultSource: {
default: 'default',
},
defaultDraftMode: {
default: false,
},
},
setup() {
const uniqueId = useId()
return { uniqueId }
},
data() {
return {
localDraft: null,
}
},
emits: ['update:modelValue'],
created() {
if (
this.realDraftMode &&
(this.realSource !== 'admin' || this.path == null)
) {
this.draft = cloneDeep(this.state)
}
},
computed: {
idPrefix() {
return `setting-${this.uniqueId}-`
},
draft: {
get() {
if (this.realSource === 'admin' || this.path == null) {
return get(useAdminSettingsStore().draft, this.canonPath)
} else {
return this.localDraft
}
},
set(value) {
if (this.realSource === 'admin' || this.path == null) {
useAdminSettingsStore().updateAdminDraft({
path: this.canonPath,
value,
})
} else {
this.localDraft = value
}
},
},
state() {
if (this.path == null) {
return this.modelValue
}
const value = get(this.configSource, this.canonPath)
if (value === undefined) {
return this.defaultState
} else {
return value
}
},
visibleState() {
return this.realDraftMode ? this.draft : this.state
},
realSource() {
return this.source || this.defaultSource
},
realDraftMode() {
return this.draftMode === undefined
? this.defaultDraftMode
: this.draftMode
},
backendDescription() {
return get(useAdminSettingsStore().descriptions, this.descriptionPath)
},
backendDescriptionLabel() {
if (this.realSource !== 'admin') return ''
if (
this.overrideBackendDescriptionLabel !== '' &&
typeof this.overrideBackendDescriptionLabel === 'string'
) {
return this.overrideBackendDescriptionLabel
}
if (!this.backendDescription || this.overrideBackendDescriptionLabel) {
return this.$t(
[
'admin_dash',
'temp_overrides',
...this.canonPath.map((p) => p.replaceAll('.', '_DOT_')),
'label',
].join('.'),
)
} else {
return this.swapDescriptionAndLabel
? this.backendDescription?.description
: this.backendDescription?.label
}
},
backendDescriptionDescription() {
if (this.description) return this.description
if (this.realSource !== 'admin') return ''
if (this.hideDescription) return null
if (!this.backendDescription || this.overrideBackendDescription) {
return this.$t(
[
'admin_dash',
'temp_overrides',
...this.canonPath.map((p) => p.replaceAll('.', '_DOT_')),
'description',
].join('.'),
)
} else {
return this.swapDescriptionAndLabel
? this.backendDescription?.label
: this.backendDescription?.description
}
},
backendDescriptionSuggestions() {
return this.backendDescription?.suggestions || this.suggestions
},
shouldBeDisabled() {
if (this.path == null) {
return this.disabled
}
let parentValue = null
if (this.parentPath !== undefined && this.realSource === 'admin') {
if (this.realDraftMode) {
parentValue = get(useAdminSettingsStore().draft, this.parentPath)
} else {
parentValue = get(this.configSource, this.parentPath)
}
}
return (
this.disabled ||
(parentValue !== null
? this.parentInvert
? parentValue
: !parentValue
: false)
)
},
configSource() {
switch (this.realSource) {
case 'profile':
return useProfileConfigStore().config
case 'admin':
return useAdminSettingsStore().config
default:
return useMergedConfigStore().mergedConfig
}
},
configSink() {
if (this.path == null) {
return () => {
/* no-op */
}
}
switch (this.realSource) {
case 'profile':
return (k, v) =>
useProfileConfigStore().setProfileOption({ name: k, value: v })
case 'admin':
return (k, v) =>
useAdminSettingsStore().pushAdminSetting({ path: k, value: v })
default:
return (readPath, value) => {
const writePath = `${readPath}`
if (!this.timedApplyMode) {
if (this.local) {
useLocalConfigStore().set({
path: writePath,
value,
})
} else {
useSyncConfigStore().setSimplePrefAndSave({
path: writePath,
value,
})
}
} else {
if (useInterfaceStore().temporaryChangesTimeoutId !== null) {
console.error("Can't track more than one temporary change")
return
}
const oldValue = get(this.configSource, readPath)
if (this.local) {
useLocalConfigStore().setTemporarily({ path: writePath, value })
} else {
useSyncConfigStore().setPreference({ path: writePath, value })
}
const confirm = () => {
if (this.local) {
useLocalConfigStore().set({ path: writePath, value })
} else {
useSyncConfigStore().pushSyncConfig()
}
useInterfaceStore().clearTemporaryChanges()
}
const revert = () => {
if (this.local) {
useLocalConfigStore().unsetTemporarily({
path: writePath,
value,
})
} else {
useSyncConfigStore().setPreference({
path: writePath,
value: oldValue,
})
}
useInterfaceStore().clearTemporaryChanges()
}
useInterfaceStore().setTemporaryChanges({ confirm, revert })
}
}
}
},
defaultState() {
switch (this.realSource) {
case 'profile':
return {}
default: {
return get(useMergedConfigStore().mergedConfigDefault, this.path)
}
}
},
isProfileSetting() {
return this.realSource === 'profile'
},
isLocalSetting() {
return this.local
},
isChanged() {
if (this.path == null) return false
switch (this.realSource) {
case 'profile':
case 'admin':
return false
default:
return this.state !== this.defaultState
}
},
canonPath() {
if (this.path == null) return null
return Array.isArray(this.path) ? this.path : this.path.split('.')
},
descriptionPath() {
if (this.path == null) return null
if (this.descriptionPathOverride) return this.descriptionPathOverride
const path = Array.isArray(this.path) ? this.path : this.path.split('.')
if (this.subgroup) {
return [
...path.slice(0, path.length - 1),
':subgroup,' + this.subgroup,
...path.slice(path.length - 1),
]
}
return path
},
isDirty() {
if (this.path == null) return false
if (this.realSource === 'admin' && this.canonPath.length > 3) {
return false // should not show draft buttons for "grouped" values
} else {
return this.realDraftMode && !isEqual(this.draft, this.state)
}
},
canHardReset() {
return (
this.realSource === 'admin' &&
useAdminSettingsStore().modifiedPaths?.has(this.canonPath.join(' -> '))
)
},
matchesExpertLevel() {
const settingExpertLevel = this.expert || 0
const userToggleExpert =
useMergedConfigStore().mergedConfig.expertLevel || 0
return settingExpertLevel <= userToggleExpert
},
},
methods: {
getValue(e) {
return e.target.value
},
update(e) {
if (this.realDraftMode) {
this.draft = this.getValue(e)
} else {
this.$emit('update:modelValue', this.getValue(e))
this.configSink(this.path, this.getValue(e))
}
},
commitDraft() {
if (this.realDraftMode) {
this.$emit('update:modelValue', this.draft)
this.configSink(this.path, this.draft)
}
},
reset() {
if (this.realDraftMode) {
this.draft = cloneDeep(this.state)
} else {
set(
useMergedConfigStore().mergedConfig,
this.path,
cloneDeep(this.defaultState),
)
}
},
hardReset() {
switch (this.realSource) {
case 'admin':
return useAdminSettingsStore()
.resetAdminSetting({ path: this.path })
.then(() => {
this.draft = this.state
})
default:
console.warn('Hard reset not implemented yet!')
}
},
},
}
diff --git a/src/components/settings_modal/helpers/vertical_tab_switcher.jsx b/src/components/settings_modal/helpers/vertical_tab_switcher.jsx
index 93ebcac4cc..abfd333434 100644
--- a/src/components/settings_modal/helpers/vertical_tab_switcher.jsx
+++ b/src/components/settings_modal/helpers/vertical_tab_switcher.jsx
@@ -1,243 +1,237 @@
import { mapState } from 'pinia'
import { Fragment, useId } from 'vue'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
import './vertical_tab_switcher.scss'
import { useInterfaceStore } from 'src/stores/interface.js'
const findFirstUsable = (slots) => slots.findIndex((_) => _.props)
export default {
name: 'VerticalTabSwitcher',
props: {
renderOnlyFocused: {
required: false,
type: Boolean,
default: false,
},
onSwitch: {
required: false,
type: Function,
default: undefined,
},
activeTab: {
required: false,
type: String,
default: undefined,
},
bodyScrollLock: {
required: false,
type: Boolean,
default: false,
},
},
setup() {
const uniqueId = useId()
return {
- uniqueId
+ uniqueId,
}
},
data() {
return {
active: findFirstUsable(this.slots()),
resizeHandler: null,
navSide: 'tabs',
}
},
computed: {
activeIndex() {
// In case of controlled component
if (this.activeTab) {
return this.slots().findIndex(
(slot) => slot?.props && this.activeTab === slot.props.key,
)
} else {
return this.active
}
},
idPrefix() {
return `tab-switcher-${this.uniqueId}-`
},
isActive() {
return (tabName) => {
const isWanted = (slot) =>
slot.props && slot.props['data-tab-name'] === tabName
return this.$slots.default().findIndex(isWanted) === this.activeIndex
}
},
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
beforeUpdate() {
const currentSlot = this.slots()[this.active]
if (!currentSlot.props) {
this.active = findFirstUsable(this.slots())
}
},
methods: {
clickTab(index) {
return (e) => {
e.preventDefault()
this.setTab(index)
}
},
setTab(index) {
if (typeof this.onSwitch === 'function') {
this.onSwitch.call(null, this.slots()[index].key)
}
this.active = index
this.changeNavSide('content')
},
changeNavSide(side) {
if (this.navSide !== side) {
this.navSide = side
}
},
// DO NOT put it to computed, it doesn't work (caching?)
slots() {
if (this.$slots.default()[0].type === Fragment) {
return this.$slots.default()[0].children
}
return this.$slots.default()
},
},
render() {
const tabs = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const classesTab = ['vertical-tab', 'menu-item']
if (
this.activeIndex === index &&
useInterfaceStore().layoutType !== 'mobile'
) {
classesTab.push('-active')
}
return (
<button
disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
role="tab"
id={`${this.idPrefix}tab-${index}`}
aria-selected={this.activeIndex === index}
aria-controls={`${this.idPrefix}tabpanel-${index}`}
title={props.label}
>
{!props.icon ? (
''
) : (
<FAIcon class="tab-icon" size="1x" fixed-width icon={props.icon} />
)}
<span class="text">{props.label}</span>
</button>
)
})
const contents = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const active = this.activeIndex === index
let delayRender = slot.props['delay-render']
if (delayRender && active) {
slot.props['delay-render'] = false
delayRender = false
}
const renderSlot =
!delayRender && (!this.renderOnlyFocused || active) ? slot : ''
const headerClasses = ['tab-content-label']
const header = (
<h2 class={headerClasses} aria-label={props.label}>
<button
type="button"
onClick={() => this.changeNavSide('tabs')}
title={this.$t('nav.back')}
class="button-unstyled"
>
<FAIcon size="lg" class="back-button-icon" icon="chevron-left" />
</button>
{props.label}
</h2>
)
const wrapperClasses = [
'tab-content-wrapper',
active ? '-active' : '-hidden',
]
const slotWrapperClasses = [
'tab-slot-wrapper',
active ? '-active' : '-hidden',
]
const contentClasses = ['tab-content']
if (props['full-width'] || props['full-width'] != null) {
contentClasses.push('-full-width')
wrapperClasses.push('-full-width')
slotWrapperClasses.push('-full-width')
}
if (props['full-height'] || props['full-width'] != null) {
contentClasses.push('-full-height')
wrapperClasses.push('-full-height')
slotWrapperClasses.push('-full-height')
}
return (
<div
class={wrapperClasses}
role="presentation"
id={`${this.idPrefix}tabpanel-${index}`}
>
- <div
- class="tab-mobile-header"
- role="presentation"
- >
+ <div class="tab-mobile-header" role="presentation">
{header}
</div>
<div
class={slotWrapperClasses}
role="tabpanel"
aria-label={props.label}
>
- <div
- class={contentClasses}
- role="presentation"
- >
+ <div class={contentClasses} role="presentation">
{renderSlot}
</div>
</div>
</div>
)
})
const rootClasses = ['vertical-tab-switcher']
if (useInterfaceStore().layoutType === 'mobile') {
rootClasses.push('-mobile')
}
if (this.navSide === 'tabs') {
rootClasses.push('-nav-tabs')
} else {
rootClasses.push('-nav-contents')
}
return (
<div ref="root" class={rootClasses.join(' ')}>
<div class="tabs" role="tablist" ref="nav">
{tabs}
</div>
<div
role="presentation"
class="contents"
v-body-scroll-lock={this.bodyScrollLock}
ref="contents"
>
{contents}
</div>
</div>
)
},
}
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 7139e79aa1..df5bc6386b 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,636 +1,636 @@
import { defineAsyncComponent } from 'vue'
import AvatarList from 'src/components/avatar_list/avatar_list.vue'
import EmojiReactions from 'src/components/emoji_reactions/emoji_reactions.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
-import StatusHeader from 'src/components/status_header/status_header.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
+import StatusHeader from 'src/components/status_header/status_header.vue'
import StatusPopover from 'src/components/status_popover/status_popover.vue'
+import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import UserListPopover from 'src/components/user_list_popover/user_list_popover.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
-import Timeago from 'src/components/timeago/timeago.vue'
import { muteFilterHits } from '../../services/status_parser/status_parser.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.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 { useScrobblesStore } from 'src/stores/scrobbles.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { useUsersStore } from 'src/stores/users.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleRight,
faChevronDown,
faChevronUp,
faEllipsisH,
faEnvelope,
faEye,
faEyeSlash,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faPlay,
faPlusSquare,
faReply,
faRetweet,
faSmileBeam,
faStar,
faThumbtack,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faStar,
faSmileBeam,
faEllipsisH,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight,
faPlay,
)
const Status = {
name: 'Status',
components: {
PostStatusForm,
UserAvatar,
AvatarList,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
StatusHeader,
MentionLink,
MentionsLine,
UserPopover,
UserLink,
Timeago,
Quote: defineAsyncComponent(() => import('src/components/quote/quote.vue')),
StatusActionButtons,
},
props: {
statusId: String,
statusoid: Object,
replies: Set,
focused: Boolean,
compact: Boolean,
isPreview: Boolean,
noHeading: Boolean,
inQuote: Boolean,
ignoreMute: Boolean,
threadDisplayState: String,
conversationRank: {
type: String,
default: 'linear',
},
},
emits: [
'dive',
'toggleExpanded',
'toggleThreadDisplay',
'suspendableStateChange',
'heightChange',
],
inject: {
profileUserId: {
default: null,
},
isPage: {
default: false,
},
isExpanded: {
default: false,
},
expandable: {
default: false,
},
},
provide: {
expandable: false, // for quotes
},
data() {
return {
resizeObserver: new ResizeObserver(this.updateVirtualHeight),
replying: false,
unmuted: false,
mediaPlaying: new Set(),
error: null,
headTailLinks: null,
}
},
created() {
useScrobblesStore().getLatestScrobble(this.status.user.id)
},
computed: {
headerLevel() {
- return [
- this.isPage ? 0 : 1,
- this.isExpanded ? 1 : 0,
- ].reduce((acc, item) => acc + item, 1)
+ return [this.isPage ? 0 : 1, this.isExpanded ? 1 : 0].reduce(
+ (acc, item) => acc + item,
+ 1,
+ )
},
rootClasses() {
return [
{
'-focused': this.focused,
'-conversation': !this.isPage && this.isExpanded,
},
`-conversation-rank-${this.conversationRank}`,
]
},
// Whatever we're given to work with
status() {
return this.statusoid ?? useStatusesStore().allStatuses.get(this.statusId)
},
inConversation() {
return this.isExpanded
},
inProfile() {
return this.profileUserId != null
},
// Status repeated
repeatedStatus() {
if (this.status.retweeted_status === undefined) return undefined
return useStatusesStore().allStatuses.get(this.status.retweeted_status.id)
},
// THE repeat
repeatStatus() {
if (this.isRepeat) {
return this.status
} else {
return null
}
},
mainStatus() {
if (this.isRepeat) {
return this.repeatedStatus
} else {
return this.status
}
},
repeater() {
return useUsersStore().findUser(this.status.user.id)
},
user() {
return useUsersStore().findUser(this.mainStatus.user.id)
},
isTreeView() {
return this.mergedConfig.conversationDisplay === 'tree'
},
showOtherRepliesInside() {
return this.mergedConfig.conversationOtherRepliesButton === 'inside'
},
showOtherRepliesBelow() {
return this.mergedConfig.conversationOtherRepliesButton === 'below'
},
showReasonMutedThread() {
return (
(this.mainStatus.thread_muted || this.repeatStatus?.thread_muted) &&
!this.inConversation
)
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
repeaterClass() {
return highlightClass(this.repeater)
},
userClass() {
return highlightClass(this.user)
},
isDeleted() {
return this.status.deleted
},
repeaterStyle() {
return highlightStyle(
useUserHighlightStore().get(this.repeater.screen_name),
)
},
favoritedBy() {
return useStatusesStore().favs.get(this.mainStatus.id) ?? new Set()
},
repeatedBy() {
return useStatusesStore().repeats.get(this.mainStatus.id) ?? new Set()
},
userStyle() {
if (this.noHeading) return
return highlightStyle(useUserHighlightStore().get(this.user.screen_name))
},
userProfileLink() {
return this.generateUserProfileLink(this.user.id, this.user.screen_name)
},
replyProfileLink() {
if (this.isReply) {
const user = useUsersStore().findUser(
this.mainStatus.in_reply_to_user_id,
)
// User referenced in post might not be yet present in store
// since their data is not included in status data, just the id
return user?.statusnet_profile_url
}
},
isRepeat() {
return !!this.repeatedStatus
},
repeaterName() {
return this.status.user.name || this.status.user.screen_name_ui
},
repeaterHtml() {
return this.status.user.name
},
repeaterProfileLink() {
return this.generateUserProfileLink(
this.repeater.id,
this.repeater.screen_name,
)
},
loggedIn() {
return !!this.currentUser
},
muteFilterHits() {
return muteFilterHits(
Object.values(
useSyncConfigStore().prefsStorage.simple.muteFilters || {},
),
this.status,
)
},
botStatus() {
return this.status.user.actor_type === 'Service'
},
sensitiveStatus() {
return this.status.nsfw
},
mentionsLine() {
if (!this.headTailLinks) return []
const writtenSet = new Set(
this.headTailLinks.writtenMentions.map((_) => _.url),
)
return this.mainStatus.attentions
.filter((attn) => {
// no reply user
return (
attn.id !== this.mainStatus.in_reply_to_user_id &&
// no self-replies
attn.statusnet_profile_url !==
this.mainStatus.user.statusnet_profile_url &&
// don't include if mentions is written
!writtenSet.has(attn.statusnet_profile_url)
)
})
.map((attn) => ({
url: attn.statusnet_profile_url,
content: attn.screen_name,
userId: attn.id,
}))
},
hasMentionsLine() {
return this.mentionsLine.length > 0
},
// TODO move muting logic into some store
muteReasons() {
return [
this.userIsMuted ? 'user' : null,
this.mainStatus.thread_muted ? 'thread' : null,
this.muteFilterHits.length > 0 ? 'filtered' : null,
this.muteBotStatuses && this.botStatus ? 'bot' : null,
this.muteSensitiveStatuses && this.sensitiveStatus ? 'nsfw' : null,
].filter(Boolean)
},
muteLocalized() {
if (this.muteReasons.length === 0) return null
const mainReason = () => {
switch (this.muteReasons[0]) {
case 'user':
return this.$t('status.muted_user')
case 'thread':
return this.$t('status.thread_muted')
case 'filtered':
return this.$t(
'status.muted_filters',
{
name: this.muteFilterHits[0].name,
filterMore: this.muteFilterHits.length - 1,
},
this.muteFilterHits.length,
)
case 'bot':
return this.$t('status.bot_muted')
case 'nsfw':
return this.$t('status.sensitive_muted')
}
}
if (this.muteReasons.length > 1) {
return this.$t(
'status.multi_reason_mute',
{
main: mainReason(),
numReasonsMore: this.muteReasons.length - 1,
},
this.muteReasons.length - 1,
)
} else {
return mainReason()
}
},
muted() {
if (this.ignoreMute) return false
if (this.status.user.id === this.currentUser?.id) return false
return !this.unmuted && !this.shouldNotMute && this.muteReasons.length > 0
},
userIsMuted() {
if (!this.currentUser) return false
if (this.user === this.currentUser) return false
if (this.repeater === this.currentUser) return false
const relationship = useUsersStore().relationship(this.user.id)
const relationshipRepeat = useUsersStore().relationship(this.repeater?.id)
return (
(this.status.muted && !this.status.thread_muted) ||
// Reprööt of a muted post according to BE
(this.repeatedStatus?.muted && !this.repeatedStatus.thread_muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
relationshipRepeat?.muting
)
},
shouldNotMute() {
if (this.ignoreMute) return true
if (this.focused) return true
const { reblog } = this.mainStatus
return (
((this.inProfile &&
// Don't mute user's posts on user timeline (except reblogs)
((!reblog && this.mainStatus.user.id === this.profileUserId) ||
// Same as above but also allow self-reblogs
reblog?.user.id === this.profileUserId)) ||
// Don't mute statuses in muted conversation when said conversation is opened
(this.inConversation && this.mainStatus.thread_muted)) &&
// No excuses if post has muted words
!this.muteFilterHits.length > 0
)
},
hideMutedUsers() {
return this.mergedConfig.hideMutedPosts
},
hideMutedThreads() {
return this.mergedConfig.hideMutedThreads
},
hideFilteredStatuses() {
return this.mergedConfig.hideFilteredStatuses
},
hideWordFilteredPosts() {
return this.mergedConfig.hideWordFilteredPosts
},
hideStatus() {
return (
!this.shouldNotMute &&
((this.muted && this.hideFilteredStatuses) ||
(this.userIsMuted && this.hideMutedUsers) ||
(this.status.thread_muted && this.hideMutedThreads) ||
(this.muteFilterHits.length > 0 && this.hideWordFilteredPosts) ||
this.muteFilterHits.some((x) => x.hide))
)
},
isReply() {
return !!(
this.mainStatus.in_reply_to_status_id &&
this.mainStatus.in_reply_to_user_id
)
},
replyToName() {
if (this.mainStatus.in_reply_to_screen_name) {
return this.mainStatus.in_reply_to_screen_name
} else {
const user = useUsersStore().findUser(
this.mainStatus.in_reply_to_user_id,
)
return user?.screen_name_ui
}
},
combinedFavsAndRepeatsUsers() {
return new Set([...this.favoritedBy, ...this.repeatedBy])
},
tags() {
return [...this.status.tags]
.filter((tagObj) => Object.hasOwn(tagObj, 'name'))
.map((tagObj) => tagObj.name)
.join(' ')
},
hidePostStats() {
return this.mergedConfig.hidePostStats
},
shouldDisplayFavsAndRepeats() {
return (
!this.hidePostStats &&
this.focused &&
(this.combinedFavsAndRepeatsUsers.size > 0 ||
this.mainStatus.quotes_count)
)
},
muteBotStatuses() {
return this.mergedConfig.muteBotStatuses
},
muteSensitiveStatuses() {
return this.mergedConfig.muteSensitiveStatuses
},
hideBotIndication() {
return this.mergedConfig.hideBotIndication
},
currentUser() {
return useUsersStore().currentUser
},
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
isSuspendable() {
return !this.replying && this.mediaPlaying.size === 0
},
visibilityLocalized() {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
isEdited() {
return this.mainStatus.edited_at !== null
},
editingAvailable() {
return useInstanceCapabilitiesStore().editingAvailable
},
quoteId() {
return this.mainStatus.quote_id
},
quoteUrl() {
return this.mainStatus.quote_url
},
quoteVisible() {
return this.mainStatus.quote_visible
},
quoteExpanded() {
return !this.inQuote
},
scrobblePresent() {
if (this.mergedConfig.hideScrobbles) return false
if (!this.status.user?.latestScrobble) return false
const value = this.mergedConfig.hideScrobblesAfter.match(/\d+/gs)[0]
const unit = this.mergedConfig.hideScrobblesAfter.match(/\D+/gs)[0]
let multiplier = 60 * 1000 // minutes is smallest unit
switch (unit) {
case 'm':
break
case 'h':
multiplier *= 60 // hour
break
case 'd':
multiplier *= 60 // hour
multiplier *= 24 // day
break
}
const maxAge = Number(value) * multiplier
const createdAt = Date.parse(this.status.user.latestScrobble.created_at)
const age = Date.now() - createdAt
if (age > maxAge) return false
return this.status.user.latestScrobble.artist
},
scrobble() {
return this.status.user?.latestScrobble
},
},
methods: {
visibilityIcon(visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
showError(error) {
this.error = error
},
clearError() {
this.error = undefined
},
toggleReplyForm() {
if (this.replying) {
// This emits 'close-accepted' if successful
// which in turn callse closeReply()
this.$refs.postStatusForm.requestClose()
} else {
this.replying = true
}
},
closeReplyForm() {
this.replying = false
},
toggleMute() {
this.unmuted = !this.unmuted
},
generateUserProfileLink(id, name) {
return generateProfileLink(
id,
name,
useInstanceStore().restrictedNicknames,
)
},
addMediaPlaying(id) {
this.mediaPlaying.add(id)
},
removeMediaPlaying(id) {
this.mediaPlaying.delete(id)
},
setHeadTailLinks(headTailLinks) {
this.headTailLinks = headTailLinks
},
updateVirtualHeight(e) {
const [entry] = e
this.$emit('heightChange', {
id: this.status.id,
height: entry.contentRect.height + 1,
element: this.$el,
})
},
},
mounted() {
if (this.$refs.root) {
this.resizeObserver.observe(this.$refs.root)
this.updateVirtualHeight([
{
contentRect: this.$refs.root.getBoundingClientRect(),
},
])
}
},
unmounted() {
this.resizeObserver.disconnect()
},
watch: {
hideStatus: {
handler() {
if (this.$refs.root) {
this.resizeObserver.observe(this.$refs.root)
} else {
this.resizeObserver.disconnect()
}
},
flush: 'post',
},
'mainStatus.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (this.focused && this.repeatedBy.size !== num) {
useStatusesStore().fetchRepeats(this.mainStatus.id)
}
},
'mainStatus.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (this.focused && this.favoritedBy.size !== num) {
useStatusesStore().fetchFavs(this.mainStatus.id)
}
},
isSuspendable: function (suspendable) {
this.$emit('suspendableStateChange', { id: this.status.id, suspendable })
},
},
}
export default Status
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index 9b1b9d4d2e..636b55d766 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -1,220 +1,220 @@
import { mapState } from 'pinia'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useUsersStore } from 'src/stores/users.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faFile,
faImage,
faLink,
faMusic,
faPollH,
} from '@fortawesome/free-solid-svg-icons'
library.add(faFile, faMusic, faImage, faLink, faPollH)
const StatusBody = {
name: 'StatusBody',
components: {
RichContent,
},
props: {
status: {
// Main thing
type: Object,
required: true,
},
compact: {
// Resizes emoji and minimizes vertical space used
// Primarily used for showing status in react notifications
type: Boolean,
default: false,
},
collapse: {
// replaces newlines with spaces
type: Boolean,
default: false,
},
singleLine: {
// Show entire thing (subject and content) in a single line
// Primarily used in chats
type: Boolean,
default: false,
},
inConversation: {
// Is status rendered within open conversation?
// Used to automatically expand subjects (if collapsed)
type: Boolean,
default: false,
},
ignoreSubject: {
// Pretend subject line doesn't exist. Useful for chat messages
// to indicate what post reply belongs to
type: Boolean,
default: false,
},
},
data() {
return {
postLength: this.status.text.length,
parseReadyDone: false,
showingTall: false,
showingLongSubject: false,
expandingSubject: null,
}
},
emits: ['parseReady'],
inject: {
isPage: {
default: false,
},
isExpanded: {
default: false,
},
},
computed: {
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
headerLevel() {
- return [
- this.isPage ? 0 : 1,
- this.isExpanded ? 1 : 0,
- ].reduce((acc, item) => acc + item, 1)
+ return [this.isPage ? 0 : 1, this.isExpanded ? 1 : 0].reduce(
+ (acc, item) => acc + item,
+ 1,
+ )
},
headerTag() {
return `h${this.headerLevel}`
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
// This is a bit hacky, but we want to approximate post height before rendering
// so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
// as well as approximate line count by counting characters and approximating ~80
// per line.
//
// Using max-height + overflow: auto for status components resulted in false positives
// very often with japanese characters, and it was very annoying.
hasLongSubject() {
return this.status.summary.length > 240
},
hasSubject() {
return !!this.status.summary && !this.ignoreSubject
},
// When a status has a subject and is also tall, we should only have one show more/less
// button. If the default is to collapse statuses with subjects, we just treat it like
// a status with a subject; otherwise, we just treat it like a tall status.
mightHideBecauseSubject() {
return (
!this.inConversation &&
this.hasSubject &&
this.mergedConfig.collapseMessageWithSubject
)
},
mightHideBecauseTall() {
if (this.singleLine || this.compact) return false
const lengthScore =
this.status.raw_html.split(/<p|<br/).length + this.postLength / 80
return lengthScore > 20
},
hideSubjectStatus() {
return this.mightHideBecauseSubject && !this.expandingSubject
},
hideTallStatus() {
return this.mightHideBecauseTall && !this.showingTall
},
shouldShowExpandToggle() {
return this.mightHideBecauseSubject || this.mightHideBecauseTall
},
toggleButtonClasses() {
return {
'cw-status-hider': !this.showingMore && this.mightHideBecauseSubject,
'tall-status-hider': !this.showingMore && this.mightHideBecauseTall,
'status-unhider': this.showingMore,
}
},
toggleText() {
if (this.showingMore) {
return this.mightHideBecauseSubject
? this.$t('status.hide_content')
: this.$t('general.show_less')
} else {
return this.mightHideBecauseSubject
? this.$t('status.show_content')
: this.$t('general.show_more')
}
},
shouldHide() {
return (
!this.showingMore && this.mightHideBecauseSubject && this.hasSubject
)
},
showingMore() {
return (
(this.mightHideBecauseTall && this.showingTall) ||
(this.mightHideBecauseSubject && this.expandingSubject)
)
},
attachmentTypes() {
return this.status.attachments.map((file) => file.type)
},
collapsedStatus() {
return this.status.raw_html.replaceAll(/(\n|<br\s?\/?>)/g, ' ')
},
...mapState(useMergedConfigStore, ['mergedConfig']),
},
mounted() {
this.status.attentions?.forEach((attn) => {
const { id } = attn
useUsersStore().fetchUserIfMissing({ id })
})
},
methods: {
onParseReady(event) {
if (this.parseReadyDone) return
this.parseReadyDone = true
this.$emit('parseReady', event)
const { writtenMentions, invisibleMentions } = event
writtenMentions
.filter((mention) => !mention.notifying)
.forEach((mention) => {
const { content, url } = mention
const cleanedString = content.replaceAll(/<[^>]+?>/gi, '') // remove all tags
if (!cleanedString.startsWith('@')) return
const handle = cleanedString.slice(1)
const hostRegex = /^(?:https?:\/\/)([\w-.]+)/gi
const hostMatch = hostRegex.exec(url)
if (!hostMatch) return
const [, host] = hostMatch
useUsersStore().fetchUserIfMissing({ name: `${handle}@${host}` })
})
/* This is a bit of a hack to make current tall status detector work
* with rich mentions. Invisible mentions are detected at RichContent level
* and also we generate plaintext version of mentions by stripping tags
* so here we subtract from post length by each mention that became invisible
* via MentionsLine
*/
this.postLength = invisibleMentions.reduce((acc, mention) => {
return acc - mention.textContent.length - 1
}, this.postLength)
},
toggleShowMore() {
if (this.mightHideBecauseTall) {
this.showingTall = !this.showingTall
} else if (this.mightHideBecauseSubject) {
this.expandingSubject = !this.expandingSubject
}
},
generateTagLink(tag) {
return `/tag/${tag}`
},
},
}
export default StatusBody
diff --git a/src/components/status_header/status_header.js b/src/components/status_header/status_header.js
index 32ebd3a1eb..e2a7f50c2e 100644
--- a/src/components/status_header/status_header.js
+++ b/src/components/status_header/status_header.js
@@ -1,136 +1,122 @@
import Timeago from 'src/components/timeago/timeago.vue'
import UserLink from 'src/components/user_link/user_link.vue'
-import { muteFilterHits } from '../../services/status_parser/status_parser.js'
-import {
- highlightClass,
- highlightStyle,
-} from '../../services/user_highlighter/user_highlighter.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 { useScrobblesStore } from 'src/stores/scrobbles.js'
import { useStatusesStore } from 'src/stores/statuses.js'
-import { useSyncConfigStore } from 'src/stores/sync_config.js'
-import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { useUsersStore } from 'src/stores/users.js'
-import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
-
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleRight,
faChevronDown,
faChevronUp,
faEllipsisH,
faEnvelope,
faEye,
faEyeSlash,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faPlay,
faPlusSquare,
faReply,
faRetweet,
faSmileBeam,
faStar,
faThumbtack,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faStar,
faSmileBeam,
faEllipsisH,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight,
faPlay,
)
const Status = {
name: 'Status',
components: {
Timeago,
UserLink,
},
props: {
statusId: String,
statusoid: Object,
userId: String,
userObject: Object,
replies: Set,
expandable: Boolean,
expanded: Boolean,
time: Date,
threadDisplayState: String,
unmuted: Boolean,
// for notifications
nonMention: Boolean,
},
- emits: [
- 'dive',
- 'toggleExpanded',
- 'toggleThreadDisplay',
- ],
+ emits: ['dive', 'toggleExpanded', 'toggleThreadDisplay'],
computed: {
// Whatever we're given to work with
status() {
return this.statusoid ?? useStatusesStore().allStatuses.get(this.statusId)
},
user() {
return this.userObject ?? useUsersStore().findUser(this.userId)
},
isTreeView() {
return this.mergedConfig.conversationDisplay === 'tree'
},
simpleTree() {
return !this.mergedConfig.conversationTreeAdvanced
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
inThreadForest() {
return !!this.threadDisplayState
},
threadShowing() {
return this.threadDisplayState === 'showing'
},
visibilityLocalized() {
- return this.$i18n.t('general.scope_in_timeline.' + this.status?.visibility)
+ return this.$i18n.t(
+ 'general.scope_in_timeline.' + this.status?.visibility,
+ )
},
visibilityIcon() {
switch (this.status?.visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
},
}
export default Status
diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js
index 1ffcac496b..92eb4002e7 100644
--- a/src/components/status_popover/status_popover.js
+++ b/src/components/status_popover/status_popover.js
@@ -1,64 +1,64 @@
import Popover from 'src/components/popover/popover.vue'
import { useStatusesStore } from 'src/stores/statuses.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const StatusPopover = {
name: 'StatusPopover',
props: {
statusId: String,
link: Boolean,
},
data() {
return {
error: false,
}
},
computed: {
status() {
return useStatusesStore().allStatuses.get(this.statusId)
},
statusLink() {
return this.$router.resolve({
name: 'conversation',
params: { statusId: this.statusId },
}).href
- }
+ },
},
components: {
Popover,
},
methods: {
enter() {
if (!this.status) {
if (!this.statusId) {
this.error = true
return
}
useStatusesStore()
.fetchStatus(this.statusId)
.then(() => (this.error = false))
.catch(() => (this.error = true))
}
},
navigate() {
this.$router.push({
name: 'conversation',
params: { statusId: this.statusId },
})
},
},
watch: {
status(newStatus, oldStatus) {
if (newStatus !== oldStatus) {
this.$nextTick(() => this.$refs.popover.updateStyles())
}
},
},
}
export default StatusPopover
diff --git a/src/components/tooltip/tooltip.vue b/src/components/tooltip/tooltip.vue
index 7b0818cb12..6161cd6be0 100644
--- a/src/components/tooltip/tooltip.vue
+++ b/src/components/tooltip/tooltip.vue
@@ -1,32 +1,32 @@
<template>
<Popover
trigger="hover"
tooltip
v-bind="{ 'aria-label': props.text, ...$attrs }"
>
<template #trigger>
<slot />
</template>
<template #content>
<div class="tooltip" role="tooltip" :id="idPrefix + 'content'">
{{ props.text }}
</div>
</template>
</Popover>
</template>
<script setup>
-import { useId, computed } from 'vue'
+import { computed, useId } from 'vue'
import Popover from 'src/components/popover/popover.vue'
const props = defineProps({ text: String })
const uniqueId = useId()
const idPrefix = computed(() => `tooltip-${uniqueId.value}`)
</script>
<style lang="scss">
.tooltip {
margin: 0.5em 1em;
}
</style>
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Oct 11, 5:36 PM (1 d, 17 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785895
Default Alt Text
(70 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment