Page MenuHomePhorge

No OneTemporary

Size
98 KB
Referenced Files
None
Subscribers
None
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 1c290b9004..52d578b1e9 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,334 +1,334 @@
<template>
<div
v-if="!hideStatus"
:style="hiddenStyle"
class="Conversation"
:class="{ '-expanded' : isExpanded, 'panel' : isExpanded }"
>
<div
v-if="isExpanded"
class="panel-heading conversation-heading -sticky"
>
<h1 class="title">
{{ $t('timeline.conversation') }}
</h1>
<button
v-if="collapsable"
class="button-unstyled -link"
@click.prevent="toggleExpanded"
>
{{ $t('timeline.collapse') }}
</button>
<QuickFilterSettings
v-if="!collapsable"
:conversation="true"
class="rightside-button"
/>
<QuickViewSettings
v-if="!collapsable"
:conversation="true"
class="rightside-button"
/>
</div>
<div
v-if="isPage && !status"
class="conversation-body"
:class="{ 'panel-body': isExpanded }"
>
<p v-if="!loadStatusError">
<FAIcon
spin
icon="circle-notch"
/>
{{ $t('status.loading') }}
</p>
<p v-else>
{{ $t('status.load_error', { error: loadStatusError }) }}
</p>
</div>
<div
v-else
class="conversation-body"
:class="{ 'panel-body': isExpanded }"
>
<div
v-if="isTreeView"
class="thread-body"
>
<div
v-if="shouldShowAllConversationButton"
class="conversation-dive-to-top-level-box"
>
<i18n-t
keypath="status.show_all_conversation_with_icon"
tag="button"
class="button-unstyled -link"
scope="global"
@click.prevent="diveToTopLevel"
>
<template #icon>
<FAIcon
icon="angle-double-left"
/>
</template>
<template #text>
<span>
- {{ $tc('status.show_all_conversation', otherTopLevelCount, { numStatus: otherTopLevelCount }) }}
+ {{ $t('status.show_all_conversation', { numStatus: otherTopLevelCount }, otherTopLevelCount) }}
</span>
</template>
</i18n-t>
</div>
<div
v-if="shouldShowAncestors"
class="thread-ancestors"
>
<article
v-for="status in ancestorsOf(diveRoot)"
:key="status.id"
class="thread-ancestor"
:class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors}"
>
<status
ref="statusComponent"
:inline-expanded="collapsable && isExpanded"
:statusoid="status"
:expandable="!isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="focused(status.id)"
:in-conversation="isExpanded"
:highlight="getHighlight()"
:replies="getReplies(status.id)"
:in-profile="inProfile"
:profile-user-id="profileUserId"
class="conversation-status status-fadein panel-body"
:simple-tree="treeViewIsSimple"
:toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:show-other-replies-as-button="showOtherRepliesButtonInsideStatus"
:dive="() => diveIntoStatus(status.id)"
:controlled-showing-tall="statusContentProperties[status.id].showingTall"
:controlled-expanding-subject="statusContentProperties[status.id].expandingSubject"
:controlled-showing-long-subject="statusContentProperties[status.id].showingLongSubject"
:controlled-replying="statusContentProperties[status.id].replying"
:controlled-media-playing="statusContentProperties[status.id].mediaPlaying"
:controlled-toggle-showing-tall="() => toggleStatusContentProperty(status.id, 'showingTall')"
:controlled-toggle-expanding-subject="() => toggleStatusContentProperty(status.id, 'expandingSubject')"
:controlled-toggle-showing-long-subject="() => toggleStatusContentProperty(status.id, 'showingLongSubject')"
:controlled-toggle-replying="() => toggleStatusContentProperty(status.id, 'replying')"
:controlled-set-media-playing="(newVal) => toggleStatusContentProperty(status.id, 'mediaPlaying', newVal)"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
/>
<div
v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1"
class="thread-ancestor-dive-box"
>
<div
class="thread-ancestor-dive-box-inner"
>
<i18n-t
tag="button"
scope="global"
keypath="status.ancestor_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="diveIntoStatus(status.id)"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
- {{ $tc('status.ancestor_follow', getReplies(status.id).length - 1, { numReplies: getReplies(status.id).length - 1 }) }}
+ {{ $t('status.ancestor_follow', { numReplies: getReplies(status.id, getReplies(status.id).length - 1).length - 1 }) }}
</span>
</template>
</i18n-t>
</div>
</div>
</article>
</div>
<thread-tree
v-for="status in showingTopLevel"
:key="status.id"
ref="statusComponent"
:depth="0"
:status="status"
:in-profile="inProfile"
:conversation="conversation"
:collapsable="collapsable"
:is-expanded="isExpanded"
:pinned-status-ids-object="pinnedStatusIdsObject"
:profile-user-id="profileUserId"
:focused="focused"
:get-replies="getReplies"
:highlight="maybeHighlight"
:set-highlight="setHighlight"
:toggle-expanded="toggleExpanded"
:simple="treeViewIsSimple"
:toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:status-content-properties="statusContentProperties"
:set-status-content-property="setStatusContentProperty"
:toggle-status-content-property="toggleStatusContentProperty"
:dive="canDive ? diveIntoStatus : undefined"
/>
</div>
<div
v-if="isLinearView"
class="thread-body"
>
<article>
<status
v-for="status in conversation"
:key="status.id"
ref="statusComponent"
:inline-expanded="collapsable && isExpanded"
:statusoid="status"
:expandable="!isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="focused(status.id)"
:in-conversation="isExpanded"
:highlight="getHighlight()"
:replies="getReplies(status.id)"
:in-profile="inProfile"
:profile-user-id="profileUserId"
class="conversation-status status-fadein panel-body"
:toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:status-content-properties="statusContentProperties"
:set-status-content-property="setStatusContentProperty"
:toggle-status-content-property="toggleStatusContentProperty"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
/>
</article>
</div>
</div>
</div>
<div
v-else
class="Conversation -hidden"
:style="hiddenStyle"
/>
</template>
<script src="./conversation.js"></script>
<style lang="scss">
.Conversation {
z-index: 1;
&.-hidden {
background: var(--__panel-background);
backdrop-filter: var(--__panel-backdrop-filter);
}
.conversation-dive-to-top-level-box {
padding: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
display: flex;
align-items: stretch;
flex-direction: column;
}
.thread-ancestors {
margin-left: var(--status-margin);
border-left: 2px solid var(--border);
}
.thread-ancestor.-faded .RichContent {
/* stylelint-disable declaration-no-important */
--text: var(--textFaint) !important;
--link: var(--linkFaint) !important;
--funtextGreentext: var(--funtextGreentextFaint) !important;
--funtextCyantext: var(--funtextCyantextFaint) !important;
/* stylelint-enable declaration-no-important */
}
.thread-ancestor-dive-box {
padding-left: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
&,
&-inner {
display: flex;
align-items: stretch;
flex-direction: column;
}
}
.thread-ancestor-dive-box-inner {
padding: var(--status-margin);
}
.conversation-status {
border-bottom: 1px solid var(--border);
border-radius: 0;
}
.thread-ancestor-has-other-replies .conversation-status,
&:last-child:not(.-expanded) .conversation-status,
&.-expanded .conversation-status:last-child,
.thread-ancestor:last-child .conversation-status,
.thread-ancestor:last-child .thread-ancestor-dive-box,
&.-expanded .thread-tree .conversation-status {
border-bottom: none;
}
.thread-ancestors + .thread-tree > .conversation-status {
border-top: 1px solid var(--border);
}
/* expanded conversation in timeline */
&.status-fadein.-expanded .thread-body {
border-left: 4px solid var(--cRed);
border-radius: var(--roundness);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom: 1px solid var(--border);
}
&.-expanded.status-fadein {
--___margin: calc(var(--status-margin) / 2);
background: var(--background);
margin: var(--___margin);
&::before {
z-index: -1;
content: "";
display: block;
position: absolute;
top: calc(var(--___margin) * -1);
bottom: calc(var(--___margin) * -1);
left: calc(var(--___margin) * -1);
right: calc(var(--___margin) * -1);
background: var(--background);
backdrop-filter: var(--__panel-backdrop-filter);
}
}
}
</style>
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 9baf63f226..09d44b0bdb 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -1,573 +1,576 @@
import Completion from '../../services/completion/completion.js'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import Popover from 'src/components/popover/popover.vue'
import ScreenReaderNotice from 'src/components/screen_reader_notice/screen_reader_notice.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { ensureFinalFallback } from '../../i18n/languages.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
}
},
data () {
return {
randomSeed: genRandomSeed(),
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 this.$store.getters.mergedConfig.padEmoji
},
defaultCandidateIndex () {
return this.$store.getters.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(this.$store.getters.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(k => k),
keywords: keywords.filter(k => k)
}
}
},
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
}
},
onInputScroll () {
this.$refs.hiddenOverlay.scrollTo({
top: this.input.scrollTop,
left: this.input.scrollLeft
})
},
suggestionListId () {
return `suggestions-${this.randomSeed}`
},
suggestionItemId () {
return (index) => `suggestion-item-${index}-${this.randomSeed}`
}
},
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
this.resize()
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, oldValue) {
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.$tc('tool_tip.autocomplete_available',
- this.suggestions.length,
- { number: this.suggestions.length }))
+ this.$t(
+ 'tool_tip.autocomplete_available',
+ { number: this.suggestions.length },
+ this.suggestions.length
+ )
+ )
}
},
methods: {
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()
this.$refs.picker.startEmojiLoad()
} 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) {
const { ctrlKey, shiftKey, 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()
})
}
// Disable suggestions hotkeys if suggestions are hidden
if (!this.temporarilyHideSuggestions) {
if (key === 'Tab') {
if (shiftKey) {
this.cycleBackward(e)
} else {
this.cycleForward(e)
}
}
if (key === 'ArrowUp') {
this.cycleBackward(e)
} else if (key === 'ArrowDown') {
this.cycleForward(e)
}
if (key === 'Enter') {
if (!ctrlKey) {
this.replaceText(e)
}
}
}
// Probably add optional keyboard controls for emoji picker?
// Escape hides suggestions, if suggestions are hidden it
// de-focuses the element (i.e. default browser behavior)
if (key === 'Escape') {
if (!this.temporarilyHideSuggestions) {
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()
})
},
resize () {
},
autoCompleteItemLabel (suggestion) {
if (suggestion.user) {
return suggestion.displayText + ' ' + suggestion.detailText
} else {
return this.maybeLocalizedEmojiName(suggestion)
}
}
}
}
export default EmojiInput
diff --git a/src/components/emoji_reactions/emoji_reactions.js b/src/components/emoji_reactions/emoji_reactions.js
index 77f8ecbb2e..861fbe542d 100644
--- a/src/components/emoji_reactions/emoji_reactions.js
+++ b/src/components/emoji_reactions/emoji_reactions.js
@@ -1,101 +1,101 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faPlus,
faMinus,
faCheck
} from '@fortawesome/free-solid-svg-icons'
library.add(
faPlus,
faMinus,
faCheck
)
const EMOJI_REACTION_COUNT_CUTOFF = 12
const EmojiReactions = {
name: 'EmojiReactions',
components: {
UserAvatar,
UserListPopover,
StillImage
},
props: ['status'],
data: () => ({
showAll: false
}),
computed: {
tooManyReactions () {
return this.status.emoji_reactions.length > EMOJI_REACTION_COUNT_CUTOFF
},
emojiReactions () {
return this.showAll
? this.status.emoji_reactions
: this.status.emoji_reactions.slice(0, EMOJI_REACTION_COUNT_CUTOFF)
},
showMoreString () {
return `+${this.status.emoji_reactions.length - EMOJI_REACTION_COUNT_CUTOFF}`
},
accountsForEmoji () {
return this.status.emoji_reactions.reduce((acc, reaction) => {
acc[reaction.name] = reaction.accounts || []
return acc
}, {})
},
loggedIn () {
return !!this.$store.state.users.currentUser
},
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
},
methods: {
toggleShowAll () {
this.showAll = !this.showAll
},
reactedWith (emoji) {
return this.status.emoji_reactions.find(r => r.name === emoji).me
},
async fetchEmojiReactionsByIfMissing () {
const hasNoAccounts = this.status.emoji_reactions.find(r => !r.accounts)
if (hasNoAccounts) {
return await this.$store.dispatch('fetchEmojiReactionsBy', this.status.id)
}
},
reactWith (emoji) {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
},
unreact (emoji) {
this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
},
async emojiOnClick (emoji, event) {
if (!this.loggedIn) return
await this.fetchEmojiReactionsByIfMissing()
if (this.reactedWith(emoji)) {
this.unreact(emoji)
} else {
this.reactWith(emoji)
}
},
counterTriggerAttrs (reaction) {
return {
class: [
'btn',
'button-default',
'emoji-reaction-count-button',
{
'-picked-reaction': this.reactedWith(reaction.name),
toggled: this.reactedWith(reaction.name)
}
],
- 'aria-label': this.$tc('status.reaction_count_label', reaction.count, { num: reaction.count })
+ 'aria-label': this.$t('status.reaction_count_label', { num: reaction.count }, reaction.count)
}
}
}
}
export default EmojiReactions
diff --git a/src/components/extra_notifications/extra_notifications.vue b/src/components/extra_notifications/extra_notifications.vue
index ffbb43eb36..5728dc98f8 100644
--- a/src/components/extra_notifications/extra_notifications.vue
+++ b/src/components/extra_notifications/extra_notifications.vue
@@ -1,111 +1,111 @@
<template>
<div class="ExtraNotifications">
<div
v-if="shouldShowChats"
class="notification unseen"
>
<div class="notification-overlay" />
<router-link
class="button-unstyled -link extra-notification"
:to="{ name: 'chats', params: { username: currentUser.screen_name } }"
>
<FAIcon
fixed-width
class="fa-scale-110 icon"
icon="comments"
/>
- {{ $tc('notifications.unread_chats', unreadChatCount, { num: unreadChatCount }) }}
+ {{ $t('notifications.unread_chats', { num: unreadChatCount }, unreadChatCount) }}
</router-link>
</div>
<div
v-if="shouldShowAnnouncements"
class="notification unseen"
>
<div class="notification-overlay" />
<router-link
class="button-unstyled -link extra-notification"
:to="{ name: 'announcements' }"
>
<FAIcon
fixed-width
class="fa-scale-110 icon"
icon="bullhorn"
/>
- {{ $tc('notifications.unread_announcements', unreadAnnouncementCount, { num: unreadAnnouncementCount }) }}
+ {{ $t('notifications.unread_announcements', { num: unreadAnnouncementCount }, unreadAnnouncementCount) }}
</router-link>
</div>
<div
v-if="shouldShowFollowRequests"
class="notification unseen"
>
<div class="notification-overlay" />
<router-link
class="button-unstyled -link extra-notification"
:to="{ name: 'friend-requests' }"
>
<FAIcon
fixed-width
class="fa-scale-110 icon"
icon="user-plus"
/>
- {{ $tc('notifications.unread_follow_requests', followRequestCount, { num: followRequestCount }) }}
+ {{ $t('notifications.unread_follow_requests', { num: followRequestCount }, followRequestCount) }}
</router-link>
</div>
<i18n-t
v-if="shouldShowCustomizationTip"
tag="span"
class="notification tip extra-notification"
keypath="notifications.configuration_tip"
scope="global"
>
<template #theSettings>
<button
class="button-unstyled -link"
@click="openNotificationSettings"
>
{{ $t('notifications.configuration_tip_settings') }}
</button>
</template>
<template #dismiss>
<button
class="button-unstyled -link"
@click="dismissConfigurationTip"
>
{{ $t('notifications.configuration_tip_dismiss') }}
</button>
</template>
</i18n-t>
</div>
</template>
<script src="./extra_notifications.js" />
<style lang="scss">
.ExtraNotifications {
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
.notification {
width: 100%;
border-bottom: 1px solid;
border-color: var(--border);
display: flex;
flex-direction: column;
align-items: stretch;
}
.extra-notification {
padding: 1em;
}
.icon {
margin-right: 0.5em;
}
.tip {
display: inline;
}
}
</style>
diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue
index f7d5ef7eab..e6a0c1617f 100644
--- a/src/components/interface_language_switcher/interface_language_switcher.vue
+++ b/src/components/interface_language_switcher/interface_language_switcher.vue
@@ -1,112 +1,112 @@
<template>
<div class="interface-language-switcher">
<label>
{{ promptText }}
</label>
<ul class="setting-list">
<li
v-for="index of controlledLanguage.keys()"
:key="index"
>
<label>
- {{ index === 0 ? $t('settings.primary_language') : $tc('settings.fallback_language', index, { index }) }}
+ {{ index === 0 ? $t('settings.primary_language') : $t('settings.fallback_language', { index }, index) }}
<Select
class="language-select"
:model-value="controlledLanguage[index]"
@update:modelValue="val => setLanguageAt(index, val)"
>
<option
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
>
{{ lang.name }}
</option>
</Select>
</label>
<button
v-if="controlledLanguage.length > 1 && index !== 0"
class="button-default btn"
@click="() => removeLanguageAt(index)"
>
{{ $t('settings.remove_language') }}
</button>
</li>
<li>
<button
class="button-default btn"
@click="addLanguage"
>
{{ $t('settings.add_language') }}
</button>
</li>
</ul>
</div>
</template>
<script>
import localeService from '../../services/locale/locale.service.js'
import Select from '../select/select.vue'
export default {
components: {
// eslint-disable-next-line vue/no-reserved-component-names
Select
},
props: {
promptText: {
type: String,
required: true
},
language: {
type: [Array, String],
required: true
},
setLanguage: {
type: Function,
required: true
}
},
computed: {
languages () {
return localeService.languages
},
controlledLanguage: {
get: function () {
return Array.isArray(this.language) ? this.language : [this.language]
},
set: function (val) {
this.setLanguage(val)
}
}
},
methods: {
getLanguageName (code) {
return localeService.getLanguageName(code)
},
addLanguage () {
this.controlledLanguage = [...this.controlledLanguage, '']
},
setLanguageAt (index, val) {
const lang = [...this.controlledLanguage]
lang[index] = val
this.controlledLanguage = lang
},
removeLanguageAt (index) {
const lang = [...this.controlledLanguage]
lang.splice(index, 1)
this.controlledLanguage = lang
}
}
}
</script>
<style lang="scss">
.interface-language-switcher {
.language-select {
margin-right: 1em;
}
}
</style>
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index eb28f8be1f..39e4d8eb68 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -1,293 +1,293 @@
<template>
<Modal
v-if="showing"
class="media-modal-view"
@backdropClicked="hideIfNotSwiped"
>
<SwipeClick
v-if="type === 'image'"
ref="swipeClick"
class="modal-image-container"
:direction="swipeDirection"
:threshold="swipeThreshold"
:disable-click-threshold="swipeDisableClickThreshold"
@preview-requested="handleSwipePreview"
@swipe-finished="handleSwipeEnd"
@swipeless-clicked="hide"
>
<PinchZoom
ref="pinchZoom"
class="modal-image-container-inner"
selector=".modal-image"
reach-min-scale-strategy="reset"
stop-propagate-handled="stop-propgate-handled"
:allow-pan-min-scale="pinchZoomMinScale"
:min-scale="pinchZoomMinScale"
:reset-to-min-scale-limit="pinchZoomScaleResetLimit"
>
<img
:class="{ loading }"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
@load="onImageLoaded"
>
</PinchZoom>
</SwipeClick>
<VideoAttachment
v-if="type === 'video'"
class="modal-image"
:attachment="currentMedia"
:controls="true"
/>
<audio
v-if="type === 'audio'"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
controls
/>
<Flash
v-if="type === 'flash'"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
/>
<button
v-if="canNavigate"
:title="$t('media_modal.previous')"
class="modal-view-button modal-view-button-arrow modal-view-button-arrow--prev"
@click.stop.prevent="goPrev"
>
<FAIcon
class="button-icon arrow-icon"
icon="chevron-left"
/>
</button>
<button
v-if="canNavigate"
:title="$t('media_modal.next')"
class="modal-view-button modal-view-button-arrow modal-view-button-arrow--next"
@click.stop.prevent="goNext"
>
<FAIcon
class="button-icon arrow-icon"
icon="chevron-right"
/>
</button>
<button
class="modal-view-button modal-view-button-hide"
:title="$t('media_modal.hide')"
@click.stop.prevent="hide"
>
<FAIcon
class="button-icon"
icon="times"
/>
</button>
<span
v-if="description"
class="description"
>
{{ description }}
</span>
<span
class="counter"
>
- {{ $tc('media_modal.counter', currentIndex + 1, { current: currentIndex + 1, total: media.length }) }}
+ {{ $t('media_modal.counter', { current: currentIndex + 1, total: media.length }, currentIndex + 1) }}
</span>
<span
v-if="loading"
class="loading-spinner"
>
<FAIcon
spin
icon="circle-notch"
size="5x"
/>
</span>
</Modal>
</template>
<script src="./media_modal.js"></script>
<style lang="scss">
$modal-view-button-icon-height: 3em;
$modal-view-button-icon-half-height: calc(#{$modal-view-button-icon-height} / 2);
$modal-view-button-icon-width: 3em;
$modal-view-button-icon-margin: 0.5em;
.media-modal-view {
@keyframes media-fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-image-container {
display: flex;
overflow: hidden;
align-items: center;
flex-direction: column;
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
flex-grow: 1;
justify-content: center;
&-inner {
width: 100%;
height: 100%;
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
}
.description,
.counter {
/* Hardcoded since background is also hardcoded */
color: white;
margin-top: 1em;
text-shadow: 0 0 10px black, 0 0 10px black;
padding: 0.2em 2em;
}
.description {
flex: 0 0 auto;
overflow-y: auto;
min-height: 1em;
max-width: 500px;
max-height: 9.5em;
word-break: break-all;
}
.modal-image {
max-width: 100%;
max-height: 100%;
image-orientation: from-image; // NOTE: only FF supports this
animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
&.loading {
opacity: 0.5;
}
}
.loading-spinner {
width: 100%;
height: 100%;
position: absolute;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
svg {
color: white;
}
}
.modal-view-button {
border: 0;
padding: 0;
opacity: 0;
box-shadow: none;
background: none;
appearance: none;
overflow: visible;
cursor: pointer;
transition: opacity 333ms cubic-bezier(0.4, 0, 0.22, 1);
height: $modal-view-button-icon-height;
width: $modal-view-button-icon-width;
.button-icon {
position: absolute;
height: $modal-view-button-icon-height;
width: $modal-view-button-icon-width;
font-size: 1rem;
line-height: $modal-view-button-icon-height;
color: #fff;
text-align: center;
background-color: rgb(0 0 0 / 30%);
}
}
.modal-view-button-arrow {
position: absolute;
display: block;
top: 50%;
margin-top: $modal-view-button-icon-half-height;
width: $modal-view-button-icon-width;
height: $modal-view-button-icon-height;
.arrow-icon {
position: absolute;
top: 0;
line-height: $modal-view-button-icon-height;
color: #fff;
text-align: center;
background-color: rgb(0 0 0 / 30%);
}
&--prev {
left: 0;
.arrow-icon {
left: $modal-view-button-icon-margin;
}
}
&--next {
right: 0;
.arrow-icon {
right: $modal-view-button-icon-margin;
}
}
}
.modal-view-button-hide {
position: absolute;
top: 0;
right: 0;
.button-icon {
top: $modal-view-button-icon-margin;
right: $modal-view-button-icon-margin;
}
}
}
.modal-view.media-modal-view {
z-index: var(--ZI_media_modal);
flex-direction: column;
.modal-view-button-arrow,
.modal-view-button-hide {
opacity: 0.75;
&:focus,
&:hover {
outline: none;
box-shadow: none;
}
&:hover {
opacity: 1;
}
}
overflow: hidden;
}
</style>
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
index e12f3e61b2..faaba99218 100644
--- a/src/components/poll/poll.vue
+++ b/src/components/poll/poll.vue
@@ -1,188 +1,188 @@
<template>
<div
class="poll"
:class="containerClass"
>
<div
:role="showResults ? 'section' : (poll.multiple ? 'group' : 'radiogroup')"
>
<div
v-for="(option, index) in options"
:key="index"
class="poll-option"
>
<div
v-if="showResults"
:title="resultTitle(option)"
class="option-result"
>
<div class="option-result-label">
<span class="result-percentage">
{{ percentageForOption(option.votes_count) }}%
</span>
<RichContent
:html="option.title_html"
:handle-links="false"
:emoji="emoji"
/>
</div>
<div
class="result-fill"
:style="{ 'width': `${percentageForOption(option.votes_count)}%` }"
/>
</div>
<div
v-else
tabindex="0"
:role="poll.multiple ? 'checkbox' : 'radio'"
:aria-labelledby="`option-vote-${randomSeed}-${index}`"
:aria-checked="choices[index]"
class="input unstyled"
@click="activateOption(index)"
>
<!-- TODO: USE CHECKBOX -->
<input
v-if="poll.multiple"
type="checkbox"
class="input -checkbox poll-checkbox"
:disabled="loading"
:value="index"
>
<input
v-else
type="radio"
:disabled="loading"
:value="index"
class="input -radio"
>
<label class="option-vote">
<RichContent
:id="`option-vote-${randomSeed}-${index}`"
:html="option.title_html"
:handle-links="false"
:emoji="emoji"
/>
</label>
</div>
</div>
</div>
<div class="footer faint">
<button
v-if="!showResults"
class="btn button-default poll-vote-button"
type="button"
:disabled="isDisabled"
@click="vote"
>
{{ $t('polls.vote') }}
</button>
<span
v-if="poll.pleroma?.non_anonymous"
:title="$t('polls.non_anonymous_title')"
>
{{ $t('polls.non_anonymous') }}
&nbsp;·&nbsp;
</span>
<div class="total">
<template v-if="typeof poll.voters_count === 'number'">
- {{ $tc("polls.people_voted_count", poll.voters_count, { count: poll.voters_count }) }}
+ {{ $t("polls.people_voted_count", { count: poll.voters_count }, poll.voters_count) }}
</template>
<template v-else>
- {{ $tc("polls.votes_count", poll.votes_count, { count: poll.votes_count }) }}
+ {{ $t("polls.votes_count", { count: poll.votes_count }, poll.votes_count) }}
</template>
<span v-if="expiresAt !== null">
&nbsp;·&nbsp;
</span>
</div>
<span v-if="expiresAt !== null">
<i18n-t
scope="global"
:keypath="expired ? 'polls.expired' : 'polls.expires_in'"
>
<Timeago
:time="expiresAt"
:auto-update="60"
:now-threshold="0"
/>
</i18n-t>
</span>
</div>
</div>
</template>
<script src="./poll.js"></script>
<style lang="scss">
.poll {
.votes {
display: flex;
flex-direction: column;
margin: 0 0 0.5em;
}
.poll-option {
margin: 0.75em 0.5em;
.input {
line-height: inherit;
}
}
.option-result {
height: 100%;
display: flex;
flex-direction: row;
position: relative;
color: var(--textLight);
}
.option-result-label {
display: flex;
align-items: center;
padding: 0.1em 0.25em;
z-index: 1;
word-break: break-word;
}
.result-percentage {
width: 3.5em;
flex-shrink: 0;
}
.result-fill {
height: 100%;
position: absolute;
border-radius: var(--roundness);
top: 0;
left: 0;
transition: width 0.5s;
}
.option-vote {
display: flex;
align-items: center;
}
input {
width: 3.5em;
}
.footer {
display: flex;
align-items: center;
}
&.loading * {
cursor: progress;
}
.poll-vote-button {
padding: 0 0.5em;
margin-right: 0.5em;
}
.poll-checkbox {
display: none;
}
}
</style>
diff --git a/src/components/poll/poll_form.vue b/src/components/poll/poll_form.vue
index 4eb9d594bc..e5ca9cefe3 100644
--- a/src/components/poll/poll_form.vue
+++ b/src/components/poll/poll_form.vue
@@ -1,157 +1,157 @@
<template>
<div
v-if="visible"
class="poll-form"
>
<div
v-for="(option, index) in options"
:key="index"
class="poll-option"
>
<div class="input-container">
<input
:id="`poll-${index}`"
v-model="options[index]"
size="1"
class="input poll-option-input"
type="text"
:placeholder="$t('polls.option')"
:maxlength="maxLength"
@change="updatePollToParent"
@keydown.enter.stop.prevent="nextOption(index)"
>
</div>
<button
v-if="options.length > 2"
class="delete-option button-unstyled -hover-highlight"
@click="deleteOption(index)"
>
<FAIcon icon="times" />
</button>
</div>
<button
v-if="options.length < maxOptions"
class="add-option faint button-unstyled -hover-highlight"
@click="addOption"
>
<FAIcon
icon="plus"
size="sm"
/>
{{ $t("polls.add_option") }}
</button>
<div class="poll-type-expiry">
<div
class="poll-type"
:title="$t('polls.type')"
>
<Select
v-model="pollType"
class="poll-type-select"
unstyled="true"
@change="updatePollToParent"
>
<option value="single">
{{ $t('polls.single_choice') }}
</option>
<option value="multiple">
{{ $t('polls.multiple_choices') }}
</option>
</Select>
</div>
<div
class="poll-expiry"
:title="$t('polls.expiry')"
>
<input
v-model="expiryAmount"
type="number"
class="input expiry-amount hide-number-spinner"
:min="minExpirationInCurrentUnit"
:max="maxExpirationInCurrentUnit"
@change="expiryAmountChange"
>
{{ ' ' }}
<Select
v-model="expiryUnit"
unstyled="true"
class="expiry-unit"
@change="expiryAmountChange"
>
<option
v-for="unit in expiryUnits"
:key="unit"
:value="unit"
>
- {{ $tc(`time.unit.${unit}_short`, expiryAmount, ['']) }}
+ {{ $t(`time.unit.${unit}_short`, [''], expiryAmount) }}
</option>
</Select>
</div>
</div>
</div>
</template>
<script src="./poll_form.js"></script>
<style lang="scss">
.poll-form {
display: flex;
flex-direction: column;
padding: 0 0.5em 0.5em;
.add-option {
align-self: flex-start;
padding-top: 0.25em;
padding-left: 0.1em;
}
.poll-option {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.25em;
}
.input-container {
width: 100%;
input {
// Hack: dodge the floating X icon
padding-right: 2.5em;
width: 100%;
}
}
.delete-option {
// Hack: Move the icon over the input box
width: 1.5em;
margin-left: -1.5em;
z-index: 1;
}
.poll-type-expiry {
margin-top: 0.5em;
display: flex;
width: 100%;
}
.poll-type {
margin-right: 0.75em;
flex: 1 1 60%;
.poll-type-select {
padding-right: 0.75em;
}
}
.poll-expiry {
display: flex;
.expiry-amount {
width: 3em;
text-align: right;
}
}
}
</style>
diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue
index cb0209ec73..4bd7c728c1 100644
--- a/src/components/registration/registration.vue
+++ b/src/components/registration/registration.vue
@@ -1,422 +1,422 @@
<template>
<div class="settings panel panel-default">
<div class="panel-heading">
<h1 class="title">
{{ $t('registration.registration') }}
</h1>
</div>
<div
v-if="!hasSignUpNotice"
class="panel-body"
>
<form
class="registration-form"
@submit.prevent="submit(user)"
>
<div class="container">
<div class="text-fields">
<div
class="form-group"
:class="{ 'form-group--error': v$.user.username.$error }"
>
<label
class="form--label"
for="sign-up-username"
>{{ $t('login.username') }}</label>
<input
id="sign-up-username"
v-model.trim="v$.user.username.$model"
:disabled="isPending"
class="input form-control"
:aria-required="true"
:placeholder="$t('registration.username_placeholder')"
>
</div>
<div
v-if="v$.user.username.$dirty"
class="form-error"
>
<ul>
<li v-if="!v$.user.username.required">
<span>{{ $t('registration.validations.username_required') }}</span>
</li>
</ul>
</div>
<div
class="form-group"
:class="{ 'form-group--error': v$.user.fullname.$error }"
>
<label
class="form--label"
for="sign-up-fullname"
>{{ $t('registration.fullname') }}</label>
<input
id="sign-up-fullname"
v-model.trim="v$.user.fullname.$model"
:disabled="isPending"
class="input form-control"
:aria-required="true"
:placeholder="$t('registration.fullname_placeholder')"
>
</div>
<div
v-if="v$.user.fullname.$dirty"
class="form-error"
>
<ul>
<li v-if="!v$.user.fullname.required">
<span>{{ $t('registration.validations.fullname_required') }}</span>
</li>
</ul>
</div>
<div
class="form-group"
:class="{ 'form-group--error': v$.user.email.$error }"
>
<label
class="form--label"
for="email"
>{{ accountActivationRequired ? $t('registration.email') : $t('registration.email_optional') }}</label>
<input
id="email"
v-model="v$.user.email.$model"
:disabled="isPending"
class="input form-control"
type="email"
:aria-required="accountActivationRequired"
>
</div>
<div
v-if="v$.user.email.$dirty"
class="form-error"
>
<ul>
<li v-if="!v$.user.email.required">
<span>{{ $t('registration.validations.email_required') }}</span>
</li>
</ul>
</div>
<div class="form-group">
<label
class="form--label"
for="bio"
>{{ $t('registration.bio_optional') }}</label>
<textarea
id="bio"
v-model="user.bio"
:disabled="isPending"
class="input form-control"
:placeholder="bioPlaceholder"
/>
</div>
<div
class="form-group"
:class="{ 'form-group--error': v$.user.password.$error }"
>
<label
class="form--label"
for="sign-up-password"
>{{ $t('login.password') }}</label>
<input
id="sign-up-password"
v-model="user.password"
:disabled="isPending"
class="input form-control"
type="password"
:aria-required="true"
>
</div>
<div
v-if="v$.user.password.$dirty"
class="form-error"
>
<ul>
<li v-if="!v$.user.password.required">
<span>{{ $t('registration.validations.password_required') }}</span>
</li>
</ul>
</div>
<div
class="form-group"
:class="{ 'form-group--error': v$.user.confirm.$error }"
>
<label
class="form--label"
for="sign-up-password-confirmation"
>{{ $t('registration.password_confirm') }}</label>
<input
id="sign-up-password-confirmation"
v-model="user.confirm"
:disabled="isPending"
class="input form-control"
type="password"
:aria-required="true"
>
</div>
<div
v-if="v$.user.confirm.$dirty"
class="form-error"
>
<ul>
<li v-if="v$.user.confirm.required.$invalid">
<span>{{ $t('registration.validations.password_confirmation_required') }}</span>
</li>
<li v-if="v$.user.confirm.sameAs.$invalid">
<span>{{ $t('registration.validations.password_confirmation_match') }}</span>
</li>
</ul>
</div>
<div
class="form-group"
:class="{ 'form-group--error': v$.user.birthday.$error }"
>
<label
class="form--label"
for="sign-up-birthday"
>
{{ birthdayRequired ? $t('registration.birthday') : $t('registration.birthday_optional') }}
</label>
<input
id="sign-up-birthday"
v-model="user.birthday"
:disabled="isPending"
class="input form-control"
type="date"
:max="birthdayRequired ? birthdayMinAttr : undefined"
:aria-required="birthdayRequired"
>
</div>
<div
v-if="v$.user.birthday.$dirty"
class="form-error"
>
<ul>
<li v-if="v$.user.birthday.required.$invalid">
<span>{{ $t('registration.validations.birthday_required') }}</span>
</li>
<li v-if="v$.user.birthday.maxValue.$invalid">
- <span>{{ $tc('registration.validations.birthday_min_age', { date: birthdayMinFormatted }) }}</span>
+ <span>{{ $t('registration.validations.birthday_min_age', { date: birthdayMinFormatted }) }}</span>
</li>
</ul>
</div>
<div
class="form-group"
:class="{ 'form-group--error': v$.user.language.$error }"
>
<interface-language-switcher
for="email-language"
:prompt-text="$t('registration.email_language')"
:language="v$.user.language.$model"
:set-language="val => v$.user.language.$model = val"
@click.stop.prevent
/>
</div>
<div
v-if="accountApprovalRequired"
class="form-group"
>
<label
class="form--label"
for="reason"
>{{ $t('registration.reason') }}</label>
<textarea
id="reason"
v-model="user.reason"
:disabled="isPending"
class="input form-control"
:placeholder="reasonPlaceholder"
/>
</div>
<div
v-if="captcha.type != 'none'"
id="captcha-group"
class="form-group"
>
<label
class="form--label"
for="captcha-label"
>{{ $t('registration.captcha') }}</label>
<template v-if="['kocaptcha', 'native'].includes(captcha.type)">
<img
:src="captcha.url"
@click="setCaptcha"
>
<sub>{{ $t('registration.new_captcha') }}</sub>
<input
id="captcha-answer"
v-model="captcha.solution"
:disabled="isPending"
class="input form-control"
type="text"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
>
</template>
</div>
<div
v-if="token"
class="form-group"
>
<label for="token">{{ $t('registration.token') }}</label>
<input
id="token"
v-model="token"
disabled="true"
class="input form-control"
type="text"
>
</div>
<div class="form-group">
<button
:disabled="isPending"
type="submit"
class="btn button-default"
>
{{ $t('registration.register') }}
</button>
</div>
</div>
<!-- eslint-disable vue/no-v-html -->
<div
class="terms-of-service"
v-html="termsOfService"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
<div
v-if="serverValidationErrors.length"
class="form-group"
>
<div class="alert error">
<span
v-for="error in serverValidationErrors"
:key="error"
>{{ error }}</span>
</div>
</div>
</form>
</div>
<div v-else>
<p class="registration-notice">
{{ signUpNotice.message }}
</p>
</div>
</div>
</template>
<script src="./registration.js"></script>
<style lang="scss">
.registration-form {
display: flex;
flex-direction: column;
margin: 0.6em;
.container {
display: flex;
flex-direction: row;
> * {
min-width: 0;
}
}
.terms-of-service {
flex: 0 1 50%;
margin: 0.8em;
}
.text-fields {
margin-top: 0.6em;
flex: 1 0;
display: flex;
flex-direction: column;
}
textarea {
min-height: 100px;
resize: vertical;
}
.form-group {
display: flex;
flex-direction: column;
padding: 0.3em 0;
line-height: 2;
margin-bottom: 1em;
}
.form-group--error {
animation-name: shakeError;
animation-duration: 0.6s;
animation-timing-function: ease-in-out;
}
.form-group--error .form--label {
color: var(--cRed);
}
.form-error {
margin-top: -0.7em;
text-align: left;
span {
font-size: 0.85em;
}
}
.form-error ul {
list-style: none;
padding: 0 0 0 5px;
margin-top: 0;
li::before {
content: "• ";
}
}
form textarea {
line-height: 16px;
resize: vertical;
}
.captcha {
max-width: 350px;
margin-bottom: 0.4em;
}
.btn {
margin-top: 0.6em;
height: 2em;
}
.error {
text-align: center;
}
}
.registration-notice {
margin: 0.6em;
}
@media all and (max-width: 800px) {
.registration-form .container {
flex-direction: column-reverse;
}
}
</style>
diff --git a/src/components/settings_modal/tabs/data_import_export_tab.vue b/src/components/settings_modal/tabs/data_import_export_tab.vue
index 48356c9b56..eb3c864252 100644
--- a/src/components/settings_modal/tabs/data_import_export_tab.vue
+++ b/src/components/settings_modal/tabs/data_import_export_tab.vue
@@ -1,131 +1,131 @@
<template>
<div
:label="$t('settings.data_import_export_tab')"
>
<div class="setting-item">
<h2>{{ $t('settings.follow_import') }}</h2>
<p>{{ $t('settings.import_followers_from_a_csv_file') }}</p>
<Importer
:submit-handler="importFollows"
:success-message="$t('settings.follows_imported')"
:error-message="$t('settings.follow_import_error')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.follow_export') }}</h2>
<Exporter
:get-content="getFollowsContent"
filename="friends.csv"
:export-button-label="$t('settings.follow_export_button')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.block_import') }}</h2>
<p>{{ $t('settings.import_blocks_from_a_csv_file') }}</p>
<Importer
:submit-handler="importBlocks"
:success-message="$t('settings.blocks_imported')"
:error-message="$t('settings.block_import_error')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.block_export') }}</h2>
<Exporter
:get-content="getBlocksContent"
filename="blocks.csv"
:export-button-label="$t('settings.block_export_button')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.mute_import') }}</h2>
<p>{{ $t('settings.import_mutes_from_a_csv_file') }}</p>
<Importer
:submit-handler="importMutes"
:success-message="$t('settings.mutes_imported')"
:error-message="$t('settings.mute_import_error')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.mute_export') }}</h2>
<Exporter
:get-content="getMutesContent"
filename="mutes.csv"
:export-button-label="$t('settings.mute_export_button')"
/>
</div>
<div class="setting-item">
<h2>{{ $t('settings.account_backup') }}</h2>
<p>{{ $t('settings.account_backup_description') }}</p>
<table>
<thead>
<tr>
<th>{{ $t('settings.account_backup_table_head') }}</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="backup in backups"
:key="backup.id"
>
<td>{{ backup.inserted_at }}</td>
<td class="actions">
<a
v-if="backup.processed"
target="_blank"
:href="backup.url"
>
{{ $t('settings.download_backup') }}
</a>
<span
v-else-if="backup.state === 'running'"
>
- {{ $tc('settings.backup_running', backup.processed_number, { number: backup.processed_number }) }}
+ {{ $t('settings.backup_running', { number: backup.processed_number }, backup.processed_number) }}
</span>
<span
v-else-if="backup.state === 'failed'"
>
{{ $t('settings.backup_failed') }}
</span>
<span
v-else
>
{{ $t('settings.backup_not_ready') }}
</span>
</td>
</tr>
</tbody>
</table>
<div
v-if="listBackupsError"
class="alert error"
>
{{ $t('settings.list_backups_error', { error }) }}
<button
:title="$t('settings.hide_list_backups_error_action')"
@click="listBackupsError = false"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
/>
</button>
</div>
<button
class="btn button-default"
@click="addBackup"
>
{{ $t('settings.add_backup') }}
</button>
<p v-if="addedBackup">
{{ $t('settings.added_backup') }}
</p>
<template v-if="addBackupError !== false">
<p>{{ $t('settings.add_backup_error', { error: addBackupError }) }}</p>
</template>
</div>
</div>
</template>
<script src="./data_import_export_tab.js"></script>
<!-- <style lang="scss" src="./profile.scss"></style> -->
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index be286191c8..e95a899c68 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,616 +1,616 @@
<template>
<div
v-if="!hideStatus"
ref="root"
class="Status"
:class="[{ '-focused': isFocused }, { '-conversation': inlineExpanded }]"
>
<div
v-if="error"
class="alert error"
>
{{ error }}
<span
class="fa-scale-110 fa-old-padding"
@click="clearError"
>
<FAIcon icon="times" />
</span>
</div>
<template v-if="muted && !isPreview">
<div class="status-container muted">
<small class="status-username">
<FAIcon
v-if="muted && retweet"
class="fa-scale-110 fa-old-padding repeat-icon"
icon="retweet"
/>
<user-link
:user="status.user"
:at="false"
/>
</small>
<small class="mute-reason">
{{ muteLocalized }}
</small>
<button
class="unmute button-unstyled"
@click.prevent="toggleMute"
>
<FAIcon
icon="eye-slash"
class="fa-scale-110 fa-old-padding"
/>
</button>
</div>
</template>
<template v-else>
<div
v-if="showPinned"
class="pin"
>
<FAIcon
icon="thumbtack"
class="faint"
/>
<span class="faint">{{ $t('status.pinned') }}</span>
</div>
<div
v-if="retweet && !noHeading && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]"
class="status-container repeat-info"
>
<UserAvatar
v-if="retweet"
class="left-side repeater-avatar"
:show-actor-type-indicator="showActorTypeIndicator"
:better-shadow="betterShadow"
:user="statusoid.user"
/>
<div class="right-side faint">
<bdi
class="status-username repeater-name"
:title="retweeter"
>
<router-link
v-if="retweeterHtml"
:to="retweeterProfileLink"
>
<RichContent
:html="retweeterHtml"
:emoji="retweeterUser.emoji"
/>
</router-link>
<router-link
v-else
:to="retweeterProfileLink"
>{{ retweeter }}</router-link>
</bdi>
{{ ' ' }}
<FAIcon
icon="retweet"
class="repeat-icon"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</div>
</div>
<div
v-if="!deleted"
:class="[userClass, { highlighted: userStyle, '-repeat': retweet && !inConversation }]"
:style="[ userStyle ]"
class="status-container"
:data-tags="tags"
>
<div
v-if="!noHeading"
class="left-side"
>
<a
:href="$router.resolve(userProfileLink).href"
@click.prevent
>
<UserPopover
:user-id="status.user.id"
:overlay-centers="true"
>
<UserAvatar
class="post-avatar"
:show-actor-type-indicator="showActorTypeIndicator"
:compact="compact"
:better-shadow="betterShadow"
:user="status.user"
/>
</UserPopover>
</a>
</div>
<div class="right-side">
<div
v-if="!noHeading"
class="status-heading"
>
<div class="heading-name-row">
<div class="heading-left">
<h4
v-if="status.user.name_html"
class="status-username"
:title="status.user.name"
>
<RichContent
:html="status.user.name"
:emoji="status.user.emoji"
/>
</h4>
<h4
v-else
class="status-username"
:title="status.user.name"
>
{{ status.user.name }}
</h4>
<user-link
class="account-name"
:title="status.user.screen_name_ui"
:user="status.user"
:at="false"
/>
<img
v-if="!!(status.user && status.user.favicon)"
class="status-favicon"
:src="status.user.favicon"
>
</div>
<span class="heading-right">
<router-link
class="timeago faint"
:to="{ name: 'conversation', params: { id: status.id } }"
>
<Timeago
:time="status.created_at"
:auto-update="60"
/>
</router-link>
<span
v-if="status.visibility"
class="visibility-icon"
:title="visibilityLocalized"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="visibilityIcon(status.visibility)"
/>
</span>
<button
v-if="expandable && !isPreview"
class="button-unstyled"
:title="$t('status.expand')"
@click.prevent="toggleExpanded"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="plus-square"
/>
</button>
<button
v-if="unmuted"
class="button-unstyled"
@click.prevent="toggleMute"
>
<FAIcon
fixed-width
icon="eye-slash"
class="fa-scale-110"
/>
</button>
<button
v-if="inThreadForest && replies && replies.length && !simpleTree"
class="button-unstyled"
:title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')"
:aria-expanded="threadShowing ? 'true' : 'false'"
@click.prevent="toggleThreadDisplay"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="threadShowing ? 'chevron-up' : 'chevron-down'"
/>
</button>
<button
v-if="dive && !simpleTree"
class="button-unstyled"
:title="$t('status.show_only_conversation_under_this')"
@click.prevent="dive"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="'angle-double-right'"
/>
</button>
</span>
</div>
<div
v-if="scrobblePresent"
class="status-rich-presence"
>
<a
v-if="scrobble.externalLink"
:href="scrobble.externalLink"
target="_blank"
>
{{ scrobble.artist }} — {{ scrobble.title }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="play"
/>
<span class="status-rich-presence-time">
<Timeago
template-key="time.in_past"
:time="scrobble.created_at"
:auto-update="60"
/>
</span>
</a>
<span v-if="!scrobble.externalLink">
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="music"
/>
{{ scrobble.artist }} — {{ scrobble.title }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="play"
/>
<span class="status-rich-presence-time">
<Timeago
template-key="time.in_past"
:time="scrobble.created_at"
:auto-update="60"
/>
</span>
</span>
</div>
<div
v-if="isReply || hasMentionsLine"
class="heading-reply-row"
>
<span
v-if="isReply"
class="glued-label reply-glued-label"
>
<i18n-t
keypath="status.reply_to_with_arg"
scope="global"
>
<template #replyToWithIcon>
<StatusPopover
v-if="!isPreview"
:status-id="status.parent_visible && status.in_reply_to_status_id"
class="reply-to-popover"
style="min-width: 0"
:class="{ '-strikethrough': !status.parent_visible }"
>
<button
class="button-unstyled reply-to"
:aria-label="$t('tool_tip.reply')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<i18n-t
keypath="status.reply_to_with_icon"
scope="global"
>
<template #icon>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="reply"
flip="horizontal"
/>
</template>
<template #replyTo>
<span
class="reply-to-text"
>
{{ $t('status.reply_to') }}
</span>
</template>
</i18n-t>
</button>
</StatusPopover>
<span
v-else
class="reply-to-no-popover"
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
</template>
<template #user>
<MentionLink
:content="replyToName"
:url="replyProfileLink"
:user-id="status.in_reply_to_user_id"
:user-screen-name="status.in_reply_to_screen_name"
/>
</template>
</i18n-t>
</span>
<!-- This little wrapper is made for sole purpose of "gluing" -->
<!-- "Mentions" label to the first mention -->
<span
v-if="hasMentionsLine"
class="glued-label"
>
<span
class="mentions"
:aria-label="$t('tool_tip.mentions')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<span
class="mentions-text"
>
{{ $t('status.mentions') }}
</span>
</span>
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(0, 1)"
class="mentions-line-first"
/>
</span>
{{ ' ' }}
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(1)"
class="mentions-line"
/>
</div>
<div
v-if="isEdited && editingAvailable && !isPreview"
class="heading-edited-row"
>
<i18n-t
scope="global"
keypath="status.edited_at"
tag="span"
>
<template #time>
<Timeago
template-key="time.in_past"
:time="status.edited_at"
:auto-update="60"
:long-format="true"
/>
</template>
</i18n-t>
</div>
</div>
<StatusContent
ref="content"
:status="status"
:no-heading="noHeading"
:highlight="highlight"
:focused="isFocused"
:controlled-showing-tall="controlledShowingTall"
:controlled-expanding-subject="controlledExpandingSubject"
:controlled-showing-long-subject="controlledShowingLongSubject"
:controlled-toggle-showing-tall="controlledToggleShowingTall"
:controlled-toggle-expanding-subject="controlledToggleExpandingSubject"
:controlled-toggle-showing-long-subject="controlledToggleShowingLongSubject"
@mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)"
@parseReady="setHeadTailLinks"
/>
<article
v-if="hasVisibleQuote"
class="quoted-status"
>
<button
class="button-unstyled -link display-quoted-status-button"
:aria-expanded="shouldDisplayQuote"
@click="toggleDisplayQuote"
>
{{ shouldDisplayQuote ? $t('status.hide_quote') : $t('status.display_quote') }}
<FAIcon
class="display-quoted-status-button-icon"
:icon="shouldDisplayQuote ? 'chevron-up' : 'chevron-down'"
/>
</button>
<Status
v-if="shouldDisplayQuote"
:statusoid="quotedStatus"
:in-quote="true"
/>
</article>
<p
v-else-if="hasInvisibleQuote"
class="quoted-status -unavailable-prompt"
>
<i18n-t
scope="global"
keypath="status.invisible_quote"
>
<template #link>
<bdi>
<a
:href="status.quote_url"
target="_blank"
>
{{ status.quote_url }}
</a>
</bdi>
</template>
</i18n-t>
</p>
<div
v-if="inConversation && !isPreview && replies && replies.length"
class="replies"
>
<button
v-if="showOtherRepliesAsButton && replies.length > 1"
class="button-unstyled -link"
- :title="$tc('status.ancestor_follow', replies.length - 1, { numReplies: replies.length - 1 })"
+ :title="$t('status.ancestor_follow', { numReplies: replies.length - 1 }, replies.length - 1)"
@click.prevent="dive"
>
- {{ $tc('status.replies_list_with_others', replies.length - 1, { numReplies: replies.length - 1 }) }}
+ {{ $t('status.replies_list_with_others', { numReplies: replies.length - 1 }, replies.length - 1) }}
</button>
<span
v-else
class="faint"
>
{{ $t('status.replies_list') }}
</span>
<StatusPopover
v-for="reply in replies"
:key="reply.id"
:status-id="reply.id"
>
<button
class="button-unstyled -link reply-link"
@click.prevent="gotoOriginal(reply.id)"
>
{{ reply.name }}
</button>
</StatusPopover>
</div>
<transition name="fade">
<div
v-if="shouldDisplayFavsAndRepeats"
class="favs-repeated-users"
>
<div class="stats">
<UserListPopover
v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0"
:users="statusFromGlobalRepository.rebloggedBy"
>
<div class="stat-count">
<a class="stat-title">{{ $t('status.repeats') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.rebloggedBy.length }}
</div>
</div>
</UserListPopover>
<UserListPopover
v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0"
:users="statusFromGlobalRepository.favoritedBy"
>
<div
class="stat-count"
>
<a class="stat-title">{{ $t('status.favorites') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.favoritedBy.length }}
</div>
</div>
</UserListPopover>
<router-link
v-if="statusFromGlobalRepository.quotes_count > 0"
:to="{ name: 'quotes', params: { id: status.id } }"
>
<div
class="stat-count"
>
<a class="stat-title">{{ $t('status.quotes') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.quotes_count }}
</div>
</div>
</router-link>
<div class="avatar-row">
<AvatarList :users="combinedFavsAndRepeatsUsers" />
</div>
</div>
</div>
</transition>
<EmojiReactions
v-if="(mergedConfig.emojiReactionsOnTimeline || isFocused) && (!noHeading && !isPreview)"
:status="status"
/>
<div
v-if="!noHeading && !isPreview"
class="status-actions"
>
<reply-button
:replying="replying"
:status="status"
@toggle="toggleReplying"
/>
<retweet-button
:visibility="status.visibility"
:logged-in="loggedIn"
:status="status"
@click="$emit('interacted')"
/>
<favorite-button
:logged-in="loggedIn"
:status="status"
@click="$emit('interacted')"
/>
<ReactButton
v-if="loggedIn"
:status="status"
@click="$emit('interacted')"
/>
<extra-buttons
:status="status"
@onError="showError"
@onSuccess="clearError"
/>
</div>
</div>
</div>
<div
v-else
class="gravestone"
>
<div class="left-side">
<UserAvatar
class="post-avatar"
:compact="compact"
:show-actor-type-indicator="showActorTypeIndicator"
/>
</div>
<div class="right-side">
<div class="deleted-text">
{{ $t('status.status_deleted') }}
</div>
<reply-button
v-if="replying"
:replying="replying"
:status="status"
@toggle="toggleReplying"
/>
</div>
</div>
<div
v-if="replying"
class="status-container reply-form"
>
<PostStatusForm
class="reply-body"
:reply-to="status.id"
:attentions="status.attentions"
:replied-user="status.user"
:copy-message-scope="status.visibility"
:subject="replySubject"
@posted="toggleReplying"
/>
</div>
</template>
</div>
</template>
<script src="./status.js"></script>
<style src="./status.scss" lang="scss"></style>
diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue
index 971b7001e0..472cc103e3 100644
--- a/src/components/thread_tree/thread_tree.vue
+++ b/src/components/thread_tree/thread_tree.vue
@@ -1,135 +1,135 @@
<template>
<article class="thread-tree">
<status
:key="status.id"
ref="statusComponent"
:inline-expanded="collapsable && isExpanded"
:statusoid="status"
:expandable="!isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="focused(status.id)"
:in-conversation="isExpanded"
:highlight="highlight"
:replies="getReplies(status.id)"
:in-profile="inProfile"
:profile-user-id="profileUserId"
class="conversation-status conversation-status-treeview status-fadein panel-body"
:simple-tree="simple"
:controlled-thread-display-status="threadDisplayStatus[status.id]"
:controlled-toggle-thread-display="() => toggleThreadDisplay(status.id)"
:controlled-showing-tall="currentProp.showingTall"
:controlled-expanding-subject="currentProp.expandingSubject"
:controlled-showing-long-subject="currentProp.showingLongSubject"
:controlled-replying="currentProp.replying"
:controlled-media-playing="currentProp.mediaPlaying"
:controlled-toggle-showing-tall="() => toggleCurrentProp('showingTall')"
:controlled-toggle-expanding-subject="() => toggleCurrentProp('expandingSubject')"
:controlled-toggle-showing-long-subject="() => toggleCurrentProp('showingLongSubject')"
:controlled-toggle-replying="() => toggleCurrentProp('replying')"
:controlled-set-media-playing="(newVal) => setCurrentProp('mediaPlaying', newVal)"
:dive="dive ? () => dive(status.id) : undefined"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
/>
<div
v-if="currentReplies.length && threadShowing"
class="thread-tree-replies"
>
<thread-tree
v-for="replyStatus in currentReplies"
:key="replyStatus.id"
ref="childComponent"
:depth="depth + 1"
:status="replyStatus"
:in-profile="inProfile"
:conversation="conversation"
:collapsable="collapsable"
:is-expanded="isExpanded"
:pinned-status-ids-object="pinnedStatusIdsObject"
:profile-user-id="profileUserId"
:focused="focused"
:get-replies="getReplies"
:highlight="highlight"
:set-highlight="setHighlight"
:toggle-expanded="toggleExpanded"
:simple="simple"
:toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:status-content-properties="statusContentProperties"
:set-status-content-property="setStatusContentProperty"
:toggle-status-content-property="toggleStatusContentProperty"
:dive="dive"
/>
</div>
<div
v-if="currentReplies.length && !threadShowing"
class="thread-tree-replies thread-tree-replies-hidden"
>
<i18n-t
v-if="simple"
scope="global"
tag="button"
keypath="status.thread_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="dive(status.id)"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
- {{ $tc('status.thread_follow', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id] }) }}
+ {{ $t('status.thread_follow', { numStatus: totalReplyCount[status.id] }, totalReplyCount[status.id]) }}
</span>
</template>
</i18n-t>
<i18n-t
v-else
scope="global"
tag="button"
keypath="status.thread_show_full_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="showThreadRecursively(status.id)"
>
<template #icon>
<FAIcon
icon="angle-double-down"
/>
</template>
<template #text>
<span>
- {{ $tc('status.thread_show_full', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id], depth: totalReplyDepth[status.id] }) }}
+ {{ $t('status.thread_show_full', { numStatus: totalReplyCount[status.id], depth: totalReplyDepth[status.id] }, totalReplyCount[status.id]) }}
</span>
</template>
</i18n-t>
</div>
</article>
</template>
<script src="./thread_tree.js"></script>
<style lang="scss">
.thread-tree-replies {
margin-left: var(--status-margin);
border-left: 2px solid var(--border);
}
.thread-tree-replies-hidden {
padding: var(--status-margin);
/* Make the button stretch along the whole row */
display: flex;
align-items: stretch;
flex-direction: column;
}
</style>

File Metadata

Mime Type
text/x-diff
Expires
Thu, Jun 4, 6:26 PM (1 d, 4 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1539115
Default Alt Text
(98 KB)

Event Timeline