Merge pull request 'Add quoting by url / in replies' (#3488) from iamtakingiteasy/pleroma-fe:gitlab-mr-iid-2164 into develop

Reviewed-on: https://git.pleroma.social/pleroma/pleroma-fe/pulls/3488
This commit is contained in:
HJ 2026-03-26 19:22:37 +00:00
commit 8df10bd595
18 changed files with 501 additions and 110 deletions

View file

@ -0,0 +1 @@
Add quoting by URL and in replies

View file

@ -73,6 +73,7 @@
:disable-notice="true" :disable-notice="true"
:disable-lock-warning="true" :disable-lock-warning="true"
:disable-polls="true" :disable-polls="true"
:disable-quotes="true"
:disable-sensitivity-checkbox="true" :disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat" :disable-submit="errorLoadingChat || !currentChat"
:disable-preview="true" :disable-preview="true"

View file

@ -43,7 +43,12 @@ const Draft = {
} }
}, },
safeToSave() { safeToSave() {
return this.draft.status || this.draft.files?.length || this.draft.hasPoll return (
this.draft.status ||
this.draft.files?.length ||
this.draft.hasPoll ||
this.draft.hasQuote
)
}, },
postStatusFormProps() { postStatusFormProps() {
return { return {

View file

@ -4,6 +4,7 @@
v-bind="params" v-bind="params"
:post-handler="doEditStatus" :post-handler="doEditStatus"
:disable-polls="true" :disable-polls="true"
:disable-quotes="true"
:disable-visibility-selector="true" :disable-visibility-selector="true"
/> />
</template> </template>

View file

@ -15,6 +15,7 @@ import EmojiInput from '../emoji_input/emoji_input.vue'
import suggestor from '../emoji_input/suggestor.js' import suggestor from '../emoji_input/suggestor.js'
import MediaUpload from '../media_upload/media_upload.vue' import MediaUpload from '../media_upload/media_upload.vue'
import PollForm from '../poll/poll_form.vue' import PollForm from '../poll/poll_form.vue'
import QuoteForm from '../quote/quote_form.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue' import ScopeSelector from '../scope_selector/scope_selector.vue'
import Select from '../select/select.vue' import Select from '../select/select.vue'
import StatusContent from '../status_content/status_content.vue' import StatusContent from '../status_content/status_content.vue'
@ -35,6 +36,7 @@ import {
faChevronRight, faChevronRight,
faCircleNotch, faCircleNotch,
faPollH, faPollH,
faQuoteRight,
faSmileBeam, faSmileBeam,
faTimes, faTimes,
faUpload, faUpload,
@ -44,6 +46,7 @@ library.add(
faSmileBeam, faSmileBeam,
faPollH, faPollH,
faUpload, faUpload,
faQuoteRight,
faBan, faBan,
faTimes, faTimes,
faCircleNotch, faCircleNotch,
@ -105,6 +108,7 @@ const PostStatusForm = {
'disableNotice', 'disableNotice',
'disableLockWarning', 'disableLockWarning',
'disablePolls', 'disablePolls',
'disableQuotes',
'disableSensitivityCheckbox', 'disableSensitivityCheckbox',
'disableSubmit', 'disableSubmit',
'disablePreview', 'disablePreview',
@ -136,6 +140,7 @@ const PostStatusForm = {
MediaUpload, MediaUpload,
EmojiInput, EmojiInput,
PollForm, PollForm,
QuoteForm,
ScopeSelector, ScopeSelector,
Checkbox, Checkbox,
Select, Select,
@ -145,6 +150,9 @@ const PostStatusForm = {
DraftCloser, DraftCloser,
Popover, Popover,
}, },
created() {
this.initQuote()
},
mounted() { mounted() {
this.updateIdempotencyKey() this.updateIdempotencyKey()
this.resize(this.$refs.textarea) this.resize(this.$refs.textarea)
@ -203,6 +211,8 @@ const PostStatusForm = {
files: [], files: [],
poll: {}, poll: {},
hasPoll: false, hasPoll: false,
hasQuote: false,
quote: {},
mediaDescriptions: {}, mediaDescriptions: {},
visibility: scope, visibility: scope,
contentType, contentType,
@ -220,6 +230,8 @@ const PostStatusForm = {
files: this.statusFiles || [], files: this.statusFiles || [],
poll: this.statusPoll || {}, poll: this.statusPoll || {},
hasPoll: false, hasPoll: false,
hasQuote: false,
quote: {},
mediaDescriptions: this.statusMediaDescriptions || {}, mediaDescriptions: this.statusMediaDescriptions || {},
visibility: this.statusScope || scope, visibility: this.statusScope || scope,
contentType: statusContentType, contentType: statusContentType,
@ -345,12 +357,28 @@ const PostStatusForm = {
isEdit() { isEdit() {
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== '' return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
}, },
quotable() { quotingAvailable() {
if (!useInstanceCapabilitiesStore().quotingAvailable) { if (!useInstanceCapabilitiesStore().quotingAvailable) {
return false return false
} }
if (!this.replyTo) { return this.disableQuotes !== true
},
isReply() {
return this.newStatus.type === 'reply'
},
quotable() {
return this.quotingAvailable && this.replyTo
},
quoteThreadToggled() {
return this.newStatus.hasQuote && this.newStatus.quote.thread
},
defaultQuotable() {
if (
!this.quotingAvailable ||
!this.isReply ||
!this.$store.getters.mergedConfig.quoteReply
) {
return false return false
} }
@ -372,12 +400,25 @@ const PostStatusForm = {
return false return false
}, },
inReplyStatusId() {
return !this.newStatus.hasQuote ||
!this.newStatus.quote.thread ||
!this.newStatus.quote.id
? this.replyTo
: undefined
},
quoteId() {
return this.newStatus.hasQuote ? this.newStatus.quote.id : undefined
},
debouncedMaybeAutoSaveDraft() { debouncedMaybeAutoSaveDraft() {
return debounce(this.maybeAutoSaveDraft, 3000) return debounce(this.maybeAutoSaveDraft, 3000)
}, },
pollFormVisible() { pollFormVisible() {
return this.newStatus.hasPoll return this.newStatus.hasPoll
}, },
quoteFormVisible() {
return this.newStatus.hasQuote && !this.newStatus.quote.thread
},
shouldAutoSaveDraft() { shouldAutoSaveDraft() {
return this.$store.getters.mergedConfig.autoSaveDraft return this.$store.getters.mergedConfig.autoSaveDraft
}, },
@ -395,7 +436,8 @@ const PostStatusForm = {
(this.newStatus.status || (this.newStatus.status ||
this.newStatus.spoilerText || this.newStatus.spoilerText ||
this.newStatus.files?.length || this.newStatus.files?.length ||
this.newStatus.hasPoll) && this.newStatus.hasPoll ||
this.newStatus.hasQuote) &&
this.saveable this.saveable
) )
}, },
@ -406,7 +448,8 @@ const PostStatusForm = {
this.newStatus.status || this.newStatus.status ||
this.newStatus.spoilerText || this.newStatus.spoilerText ||
this.newStatus.files?.length || this.newStatus.files?.length ||
this.newStatus.hasPoll this.newStatus.hasPoll ||
this.newStatus.hasQuote
) )
) )
}, },
@ -456,11 +499,13 @@ const PostStatusForm = {
contentType: newStatus.contentType, contentType: newStatus.contentType,
poll: {}, poll: {},
hasPoll: false, hasPoll: false,
hasQuote: false,
quote: {},
mediaDescriptions: {}, mediaDescriptions: {},
quoting: false,
} }
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile() this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm() this.clearPollForm()
this.clearQuoteForm()
if (this.preserveFocus) { if (this.preserveFocus) {
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.textarea.focus() this.$refs.textarea.focus()
@ -500,9 +545,7 @@ const PostStatusForm = {
return return
} }
const poll = this.newStatus.hasPoll const poll = newStatus.hasPoll ? pollFormToMasto(newStatus.poll) : {}
? pollFormToMasto(this.newStatus.poll)
: {}
if (this.pollContentError) { if (this.pollContentError) {
this.error = this.pollContentError this.error = this.pollContentError
return return
@ -518,10 +561,6 @@ const PostStatusForm = {
return return
} }
const replyOrQuoteAttr = newStatus.quoting
? 'quoteId'
: 'inReplyToStatusId'
const postingOptions = { const postingOptions = {
status: newStatus.status, status: newStatus.status,
spoilerText: newStatus.spoilerText || null, spoilerText: newStatus.spoilerText || null,
@ -529,7 +568,8 @@ const PostStatusForm = {
sensitive: newStatus.nsfw, sensitive: newStatus.nsfw,
media: newStatus.files, media: newStatus.files,
store: this.$store, store: this.$store,
[replyOrQuoteAttr]: this.replyTo, inReplyToStatusId: this.inReplyStatusId,
quoteId: this.quoteId,
contentType: newStatus.contentType, contentType: newStatus.contentType,
poll, poll,
idempotencyKey: this.idempotencyKey, idempotencyKey: this.idempotencyKey,
@ -558,9 +598,7 @@ const PostStatusForm = {
} }
const newStatus = this.newStatus const newStatus = this.newStatus
this.previewLoading = true this.previewLoading = true
const replyOrQuoteAttr = newStatus.quoting
? 'quoteId'
: 'inReplyToStatusId'
statusPoster statusPoster
.postStatus({ .postStatus({
status: newStatus.status, status: newStatus.status,
@ -569,7 +607,8 @@ const PostStatusForm = {
sensitive: newStatus.nsfw, sensitive: newStatus.nsfw,
media: [], media: [],
store: this.$store, store: this.$store,
[replyOrQuoteAttr]: this.replyTo, inReplyToStatusId: this.inReplyStatusId,
quoteId: this.quoteId,
contentType: newStatus.contentType, contentType: newStatus.contentType,
poll: {}, poll: {},
preview: true, preview: true,
@ -813,6 +852,32 @@ const PostStatusForm = {
this.$refs.pollForm.clear() this.$refs.pollForm.clear()
} }
}, },
initQuote() {
const quote = this.newStatus.quote
if (Object.keys(quote).length > 0) {
return
}
const quotable = this.defaultQuotable
quote.id = quotable ? this.replyTo : ''
quote.url = ''
quote.thread = quotable
},
setQuoteThread(v) {
this.newStatus.hasQuote = v
this.newStatus.quote.thread = v
this.newStatus.quote.id = v ? this.replyTo : ''
},
clearQuoteForm() {
if (this.$refs.quoteForm) {
this.$refs.quoteForm.clear()
}
},
toggleQuoteForm() {
this.newStatus.hasQuote = !this.newStatus.hasQuote
},
dismissScopeNotice() { dismissScopeNotice() {
this.$store.dispatch('setOption', { this.$store.dispatch('setOption', {
name: 'hideScopeNotice', name: 'hideScopeNotice',

View file

@ -137,11 +137,17 @@
.poll-icon { .poll-icon {
order: 3; order: 3;
justify-content: center;
}
.quote-icon {
order: 4;
justify-content: right; justify-content: right;
} }
.media-upload-icon, .media-upload-icon,
.poll-icon, .poll-icon,
.quote-icon,
.emoji-icon { .emoji-icon {
font-size: 1.85em; font-size: 1.85em;
line-height: 1.1; line-height: 1.1;

View file

@ -111,24 +111,26 @@
<button <button
:id="`reply-or-quote-option-${randomSeed}-reply`" :id="`reply-or-quote-option-${randomSeed}-reply`"
class="btn button-default reply-or-quote-option" class="btn button-default reply-or-quote-option"
:class="{ toggled: !newStatus.quoting }" :class="{ toggled: !quoteThreadToggled }"
tabindex="0" tabindex="0"
role="radio" role="radio"
:disabled="quoteFormVisible"
:aria-labelledby="`reply-or-quote-option-${randomSeed}-reply`" :aria-labelledby="`reply-or-quote-option-${randomSeed}-reply`"
:aria-checked="!newStatus.quoting" :aria-checked="!newStatus.quote.thread"
@click="newStatus.quoting = false" @click="setQuoteThread(false)"
> >
{{ $t('post_status.reply_option') }} {{ $t('post_status.reply_option') }}
</button> </button>
<button <button
:id="`reply-or-quote-option-${randomSeed}-quote`" :id="`reply-or-quote-option-${randomSeed}-quote`"
class="btn button-default reply-or-quote-option" class="btn button-default reply-or-quote-option"
:class="{ toggled: newStatus.quoting }" :class="{ toggled: quoteThreadToggled }"
tabindex="0" tabindex="0"
role="radio" role="radio"
:disabled="quoteFormVisible"
:aria-labelledby="`reply-or-quote-option-${randomSeed}-quote`" :aria-labelledby="`reply-or-quote-option-${randomSeed}-quote`"
:aria-checked="newStatus.quoting" :aria-checked="newStatus.quote.thread"
@click="newStatus.quoting = true" @click="setQuoteThread(true)"
> >
{{ $t('post_status.quote_option') }} {{ $t('post_status.quote_option') }}
</button> </button>
@ -266,6 +268,13 @@
:visible="pollFormVisible" :visible="pollFormVisible"
:params="newStatus.poll" :params="newStatus.poll"
/> />
<quote-form
v-if="quotingAvailable"
ref="quoteForm"
:visible="quoteFormVisible"
:reply="isReply"
:params="newStatus.quote"
/>
<span <span
v-if="!disableDraft && shouldAutoSaveDraft" v-if="!disableDraft && shouldAutoSaveDraft"
class="auto-save-status" class="auto-save-status"
@ -296,6 +305,16 @@
> >
<FAIcon icon="poll-h" /> <FAIcon icon="poll-h" />
</button> </button>
<button
v-if="quotingAvailable"
class="quote-icon button-unstyled"
:disabled="newStatus.quote.thread"
:class="{ selected: quoteFormVisible }"
:title="$t('tool_tip.add_quote')"
@click="toggleQuoteForm"
>
<FAIcon icon="quote-right" />
</button>
</div> </div>
<div class="btn-group post-button-group"> <div class="btn-group post-button-group">
<button <button

View file

@ -0,0 +1,101 @@
import { defineAsyncComponent } from 'vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
export default {
components: {
Status: defineAsyncComponent(() => import('../status/status.vue')),
},
name: 'Quote',
props: {
visible: {
type: Boolean,
},
loading: {
type: Boolean,
},
statusId: {
type: String,
},
statusUrl: {
type: String,
},
statusVisible: {
type: Boolean,
},
initiallyExpanded: {
type: Boolean,
},
},
data() {
return {
displayQuote: this.initiallyExpanded,
fetchAttempted: false,
fetching: false,
error: null,
}
},
created() {
this.maybeFetchStatus()
},
watch: {
statusId() {
this.maybeFetchStatus()
},
},
computed: {
quotedStatus() {
return this.statusId
? this.$store.state.statuses.allStatusesObject[this.statusId]
: undefined
},
shouldDisplayQuote() {
return this.displayQuote && this.quotedStatus
},
hasVisibleQuote() {
return (
this.statusUrl &&
this.statusVisible &&
(this.showSpinner || this.quotedStatus)
)
},
hasInvisibleQuote() {
return this.statusUrl && !this.statusVisible
},
showSpinner() {
return this.loading || this.fetching
},
},
methods: {
toggleDisplayQuote() {
this.displayQuote = !this.displayQuote
this.maybeFetchStatus()
},
maybeFetchStatus() {
if (this.statusId && this.displayQuote && !this.quotedStatus) {
this.fetchStatus()
}
},
fetchStatus() {
this.fetchAttempted = true
this.fetching = true
this.$emit('loading', true)
this.$store
.dispatch('fetchStatus', this.statusId)
.then(() => {
this.displayQuote = true
})
.catch((error) => {
this.error = error
this.$emit('error', error)
})
.finally(() => {
this.fetching = false
this.$emit('loading', false)
})
},
},
}

View file

@ -0,0 +1,82 @@
<template>
<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>
<span
v-show="showSpinner"
class="loading-spinner"
>
<FAIcon
class="fa-old-padding"
spin
icon="circle-notch"
/>
</span>
<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
v-if="statusId"
:href="statusUrl"
target="_blank"
>
{{ statusUrl }}
</a>
<router-link
v-else
:to="{ name: 'search', query: { query: statusUrl } }"
>
{{ statusUrl }}
</router-link>
</bdi>
</template>
</i18n-t>
</p>
</template>
<script src="./quote.js"></script>
<style lang="scss">
.quoted-status {
margin-top: 0.5em;
border: 1px solid var(--border);
border-radius: var(--roundness);
&.-unavailable-prompt {
padding: 0.5em;
}
.display-quoted-status-button {
margin: 0.5em;
&-icon {
color: inherit;
}
}
}
</style>

View file

@ -0,0 +1,118 @@
import { debounce } from 'lodash'
import Checkbox from '../checkbox/checkbox.vue'
import Quote from './quote.vue'
import { useInstanceStore } from 'src/stores/instance.js'
export default {
components: {
Quote,
Checkbox,
},
name: 'QuoteForm',
props: {
visible: {
type: Boolean,
},
reply: {
type: Boolean,
},
params: {
type: Object,
required: true,
},
},
data() {
return {
text: this.params.url,
loading: false,
error: false,
debounceSetQuote: debounce((value) => {
this.fetchStatus(value)
}, 1000),
}
},
created() {
if (this.params.url && !this.params.id) {
this.fetchStatus(this.params.url)
} else if (this.params.id) {
this.text =
window.location.protocol +
'//' +
this.instanceHost +
'/notice/' +
this.params.id
this.params.url = this.text
}
},
computed: {
instanceHost() {
return new URL(useInstanceStore().server).host
},
noticeRegex() {
return new RegExp(
`^([^/:]*:?//|)(${window.location.host}|${this.instanceHost})/notice/(.*)$`,
)
},
quoteVisible() {
return (!!this.params.id || this.loading) && !this.error
},
},
watch: {
text(value) {
this.debounceSetQuote(value)
},
visible(value) {
if (value && this.params.url) {
this.fetchStatus(this.params.url)
}
},
},
methods: {
clear() {
this.text = this.params.url
this.loading = false
this.error = false
},
setLoading(value) {
this.loading = value
},
handleError(error) {
this.params.id = null
this.error = !!error
},
fetchStatus(value) {
this.params.url = value
this.error = false
const notice = this.noticeRegex.exec(value)
if (notice && notice.length === 4) {
this.params.id = notice[3]
} else if (value) {
this.loading = true
this.$store
.dispatch('search', {
q: value,
resolve: true,
offset: 0,
limit: 1,
type: 'statuses',
})
.then((data) => {
if (data && data.statuses && data.statuses.length === 1) {
this.params.id = data.statuses[0].id
} else {
this.handleError(true)
}
})
.catch(this.handleError)
.finally(() => {
this.loading = false
})
} else {
this.params.id = null
}
},
},
}

View file

@ -0,0 +1,55 @@
<template>
<div
v-if="visible"
class="quote-form"
>
<div class="input-container">
<input
v-model="text"
type="text"
size="1"
class="input"
:placeholder="$t('post_status.quote_url')"
>
</div>
<Quote
:status-id="params.id"
:status-url="params.url"
:status-visible="quoteVisible"
:initially-expanded="true"
:loading="loading"
@loading="setLoading"
@error="handleError"
/>
</div>
</template>
<script src="./quote_form.js"></script>
<style lang="scss">
.quote-form {
display: flex;
flex-direction: column;
padding: 0 0.5em 0.5em;
.input-container {
width: 100%;
display: flex;
flex-direction: row;
input {
width: 100%;
}
.checkbox {
white-space: pre;
display: flex;
flex-flow: row-reverse;
line-height: 2;
column-gap: 0.5em;
align-items: center;
user-select: none;
}
}
}
</style>

View file

@ -12,6 +12,7 @@ import {
import AvatarList from '../avatar_list/avatar_list.vue' import AvatarList from '../avatar_list/avatar_list.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue' import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue' import PostStatusForm from '../post_status_form/post_status_form.vue'
import Quote from '../quote/quote.vue'
import StatusContent from '../status_content/status_content.vue' import StatusContent from '../status_content/status_content.vue'
import StatusPopover from '../status_popover/status_popover.vue' import StatusPopover from '../status_popover/status_popover.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from '../timeago/timeago.vue'
@ -127,6 +128,7 @@ const Status = {
MentionsLine, MentionsLine,
UserPopover, UserPopover,
UserLink, UserLink,
Quote,
StatusActionButtons, StatusActionButtons,
}, },
props: [ props: [
@ -172,7 +174,6 @@ const Status = {
suspendable: true, suspendable: true,
error: null, error: null,
headTailLinks: null, headTailLinks: null,
displayQuote: !this.inQuote,
} }
}, },
computed: { computed: {
@ -500,19 +501,17 @@ const Status = {
editingAvailable() { editingAvailable() {
return useInstanceCapabilitiesStore().editingAvailable return useInstanceCapabilitiesStore().editingAvailable
}, },
hasVisibleQuote() { quoteId() {
return this.status.quote_url && this.status.quote_visible
},
hasInvisibleQuote() {
return this.status.quote_url && !this.status.quote_visible
},
quotedStatus() {
return this.status.quote_id return this.status.quote_id
? this.$store.state.statuses.allStatusesObject[this.status.quote_id]
: undefined
}, },
shouldDisplayQuote() { quoteUrl() {
return this.quotedStatus && this.displayQuote return this.status.quote_url
},
quoteVisible() {
return this.status.quote_visible
},
quoteExpanded() {
return !this.inQuote
}, },
scrobblePresent() { scrobblePresent() {
if (this.mergedConfig.hideScrobbles) return false if (this.mergedConfig.hideScrobbles) return false
@ -632,17 +631,6 @@ const Status = {
} }
} }
}, },
toggleDisplayQuote() {
if (this.shouldDisplayQuote) {
this.displayQuote = false
} else if (!this.quotedStatus) {
this.$store.dispatch('fetchStatus', this.status.quote_id).then(() => {
this.displayQuote = true
})
} else {
this.displayQuote = true
}
},
}, },
watch: { watch: {
highlight: function (id) { highlight: function (id) {

View file

@ -388,22 +388,4 @@
} }
} }
} }
.quoted-status {
margin-top: 0.5em;
border: 1px solid var(--border);
border-radius: var(--roundness);
&.-unavailable-prompt {
padding: 0.5em;
}
}
.display-quoted-status-button {
margin: 0.5em;
&-icon {
color: inherit;
}
}
} }

View file

@ -419,47 +419,12 @@
@parse-ready="setHeadTailLinks" @parse-ready="setHeadTailLinks"
/> />
<article <Quote
v-if="hasVisibleQuote" :status-id="quoteId"
class="quoted-status" :status-url="quoteUrl"
> :status-visible="quoteVisible"
<button :initially-expanded="quoteExpanded"
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 <div
v-if="inConversation && !isPreview && replies && replies.length" v-if="inConversation && !isPreview && replies && replies.length"

View file

@ -6,7 +6,7 @@
grid-template-columns: repeat(auto-fill, minmax(10%, 3em)); grid-template-columns: repeat(auto-fill, minmax(10%, 3em));
grid-auto-flow: row dense; grid-auto-flow: row dense;
grid-auto-rows: 1fr; grid-auto-rows: 1fr;
grid-gap: 1.25em 1em; grid-gap: 1.25em 0;
margin-top: var(--status-margin); margin-top: var(--status-margin);
} }

View file

@ -284,6 +284,7 @@
"new_status": "Post new status", "new_status": "Post new status",
"reply_option": "Reply to this status", "reply_option": "Reply to this status",
"quote_option": "Quote this status", "quote_option": "Quote this status",
"quote_url": "Link to quoted post",
"account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.", "account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked", "account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive", "attachments_sensitive": "Mark attachments as sensitive",
@ -1763,6 +1764,7 @@
"favorite": "Favorite", "favorite": "Favorite",
"unfavorite": "Unfavorite", "unfavorite": "Unfavorite",
"add_reaction": "Add Reaction", "add_reaction": "Add Reaction",
"add_quote": "Add quote",
"user_settings": "User Settings", "user_settings": "User Settings",
"accept_follow_request": "Accept follow request", "accept_follow_request": "Accept follow request",
"reject_follow_request": "Reject follow request", "reject_follow_request": "Reject follow request",

View file

@ -693,7 +693,7 @@ const fetchStatus = ({ id, credentials }) => {
if (data.ok) { if (data.ok) {
return data return data
} }
throw new Error('Error fetching timeline', data) throw new Error('Error fetching timeline', { cause: data })
}) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => parseStatus(data)) .then((data) => parseStatus(data))
@ -706,7 +706,7 @@ const fetchStatusSource = ({ id, credentials }) => {
if (data.ok) { if (data.ok) {
return data return data
} }
throw new Error('Error fetching source', data) throw new Error('Error fetching source', { cause: data })
}) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => parseSource(data)) .then((data) => parseSource(data))

View file

@ -383,7 +383,7 @@ export const parseStatus = (data) => {
} }
output.summary_raw_html = escapeHtml(data.spoiler_text) output.summary_raw_html = escapeHtml(data.spoiler_text)
output.external_url = data.url output.external_url = data.uri || data.url
output.poll = data.poll output.poll = data.poll
if (output.poll) { if (output.poll) {
output.poll.options = (output.poll.options || []).map((field) => ({ output.poll.options = (output.poll.options || []).map((field) => ({