feat: support quote post (#3443)

This commit is contained in:
TAKAHASHI Shuuji 2025-12-26 02:06:32 +09:00 committed by GitHub
parent 4d13eb9e92
commit 0198a2bf43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 488 additions and 69 deletions

View file

@ -71,7 +71,6 @@ async function removeList() {
if (confirmDelete.choice === 'confirm') {
await nextTick()
try {
// @ts-expect-error this method should not take any argument, but it expects 1-2 arguments since masto.js v7.7.0 (potential issue)
await client.v1.lists.$select(list.value.id).remove()
emit('listRemoved', list.value.id)
}

View file

@ -117,6 +117,10 @@ const shortcutItemGroups = computed<ShortcutItemGroup[]>(() => [
description: t('magic_keys.groups.actions.boost'),
shortcut: { keys: ['b'], isSequence: false },
},
{
description: t('magic_keys.groups.actions.quote'),
shortcut: { keys: ['q'], isSequence: false },
},
],
},
{

View file

@ -6,11 +6,11 @@ import {
isConfirmDialogOpen,
isEditHistoryDialogOpen,
isErrorDialogOpen,
isFavouritedBoostedByDialogOpen,
isKeyboardShortcutsDialogOpen,
isMediaPreviewOpen,
isPreviewHelpOpen,
isPublishDialogOpen,
isReactedByDialogOpen,
isReportDialogOpen,
isSigninDialogOpen,
} from '~/composables/dialog'
@ -48,8 +48,8 @@ function handleConfirmChoice(choice: ConfirmDialogChoice) {
isConfirmDialogOpen.value = false
}
function handleFavouritedBoostedByClose() {
isFavouritedBoostedByDialogOpen.value = false
function handleReactedByClose() {
isReactedByDialogOpen.value = false
}
</script>
@ -95,11 +95,11 @@ function handleFavouritedBoostedByClose() {
<ModalError v-if="errorDialogData" v-bind="errorDialogData" />
</ModalDialog>
<ModalDialog
v-model="isFavouritedBoostedByDialogOpen"
v-model="isReactedByDialogOpen"
max-w-180
@close="handleFavouritedBoostedByClose"
@close="handleReactedByClose"
>
<StatusFavouritedBoostedBy />
<StatusReactedBy />
</ModalDialog>
<ModalDialog v-model="isKeyboardShortcutsDialogOpen" max-w-full sm:max-w-140 md:max-w-170 lg:max-w-220 md:min-w-160>
<MagickeysKeyboardShortcuts @close="closeKeyboardShortcuts()" />

View file

@ -0,0 +1,42 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
import { statusQuoteApprovalPolicies } from '~/composables/masto/icons'
defineProps<{
editing?: boolean
}>()
const modelValue = defineModel<string | null | undefined>({
required: true,
})
const currentQuoteApprovalPolicy = computed(() =>
statusQuoteApprovalPolicies.find(v => v.value === modelValue.value) || statusQuoteApprovalPolicies[0],
)
function chooseQuoteApprovalPolicy(quoteApprovalPolicy: mastodon.rest.v1.QuoteApprovalPolicy) {
modelValue.value = quoteApprovalPolicy
}
</script>
<template>
<div flex items-center>
<div i-ri:double-quotes-l me--2 />
<CommonTooltip placement="top" :content="editing ? $t(`quote_approval_policy.${currentQuoteApprovalPolicy.value}`) : $t('tooltip.change_quote_approval_policy')">
<CommonDropdown placement="bottom">
<slot :quote-approval-policy="currentQuoteApprovalPolicy" />
<template #popper>
<CommonDropdownItem
v-for="quoteApprovalPolicy in statusQuoteApprovalPolicies"
:key="quoteApprovalPolicy.value"
:icon="quoteApprovalPolicy.icon"
:text="$t(`quote_approval_policy.${quoteApprovalPolicy.value}`)"
:description="$t(`quote_approval_policy.${quoteApprovalPolicy.value}_desc`)"
:checked="quoteApprovalPolicy.value === modelValue"
@click="chooseQuoteApprovalPolicy(quoteApprovalPolicy.value)"
/>
</template>
</CommonDropdown>
</CommonTooltip>
</div>
</template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
const { draftKey, draftItemIndex } = defineProps<{
draftKey: string
draftKey: DraftKey
draftItemIndex: number
}>()

View file

@ -1,4 +1,6 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
defineProps<{
editing?: boolean
}>()
@ -11,7 +13,7 @@ const currentVisibility = computed(() =>
statusVisibilities.find(v => v.value === modelValue.value) || statusVisibilities[0],
)
function chooseVisibility(visibility: string) {
function chooseVisibility(visibility: mastodon.v1.StatusVisibility) {
modelValue.value = visibility
}
</script>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { DraftItem } from '#shared/types'
import type { DraftItem, DraftKey } from '#shared/types'
import type { mastodon } from 'masto'
import { EditorContent } from '@tiptap/vue-3'
import { useNow } from '@vueuse/core'
@ -13,7 +13,7 @@ const {
placeholder,
initial = getDefaultDraftItem,
} = defineProps<{
draftKey: string
draftKey: DraftKey
draftItemIndex: number
initial?: () => DraftItem
threadComposer?: ReturnType<typeof useThreadComposer>
@ -251,6 +251,28 @@ const postLanguageDisplay = computed(() => languagesNameList.find(i => i.code ==
const isDM = computed(() => draft.value.params.visibility === 'direct')
const hasQuote = computed(() => !!draft.value.params.quotedStatusId)
const quotedStatus = ref<mastodon.v1.Status | null>(null)
const quoteFetchError = ref<string | null>(null)
watchEffect(async () => {
if (hasQuote.value) {
try {
quotedStatus.value = await fetchStatus(draft.value.params.quotedStatusId!)
}
catch (err) {
console.error(err)
quoteFetchError.value = (err as Error).message
}
}
})
function removeQuote() {
draft.value.params.quotedStatusId = undefined
draft.value.params.quoteApprovalPolicy = undefined
quotedStatus.value = null
quoteFetchError.value = null
}
async function handlePaste(evt: ClipboardEvent) {
const files = evt.clipboardData?.files
if (!files || files.length === 0)
@ -552,6 +574,33 @@ const detectLanguage = useDebounceFn(async () => {
}}</span>
</div>
</form>
<template v-if="hasQuote">
<div flex justify-end mt-2>
<button
text-sm px-2 py-1 rounded-3 hover:bg-gray-300
flex="~ gap1" items-center
:aria-label="$t('action.remove_quote')"
@click="removeQuote"
>
<div i-ri:close-line />
{{ $t('action.remove_quote') }}
</button>
</div>
<blockquote v-if="quotedStatus" b="~ base 1" rounded-lg overflow-hidden my-3>
<StatusCard
:status="quotedStatus"
:actions="false"
:is-nested="true"
/>
</blockquote>
<div v-else-if="quoteFetchError" text-danger b="base 1" rounded-lg hover:bg-active my-3 p-3>
{{ $t('error.quote_fetch_error') }} ({{ quoteFetchError }})
</div>
<StatusCardSkeleton v-else b="base 1" rounded-lg hover:bg-active my-3 />
</template>
<!-- toolbar -->
<div v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="end" max-w-full border="t base">
<PublishEmojiPicker @select="insertEmoji" @select-custom="insertCustomEmoji">
<button btn-action-icon :title="$t('tooltip.emojis')" :aria-label="$t('tooltip.add_emojis')">
@ -685,6 +734,18 @@ const detectLanguage = useDebounceFn(async () => {
</template>
</PublishVisibilityPicker>
<PublishQuoteApprovalPicker v-if="hasQuote" v-model="draft.params.quoteApprovalPolicy" :editing="!!draft.editingStatus">
<template #default="{ quoteApprovalPolicy }">
<button
:disabled="!!draft.editingStatus" :aria-label="$t('tooltip.change_content_visibility')"
btn-action-icon :class="{ 'w-12': !draft.editingStatus }"
>
<div :class="quoteApprovalPolicy.icon" />
<div v-if="!draft.editingStatus" i-ri:arrow-down-s-line text-sm text-secondary me--1 />
</button>
</template>
</PublishQuoteApprovalPicker>
<PublishThreadTools :draft-item-index="draftItemIndex" :draft-key="draftKey" />
<CommonTooltip

View file

@ -6,16 +6,25 @@ const route = useRoute()
const { formatNumber } = useHumanReadableNumber()
const timeAgoOptions = useTimeAgoOptions()
const draftKey = ref('home')
const draftKey = ref<DraftKey>('home')
const draftKeys = computed(() => Object.keys(currentUserDrafts.value))
const draftKeys = computed<DraftKey[]>(() => Object.keys(currentUserDrafts.value) as DraftKey[])
const nonEmptyDrafts = computed(() => draftKeys.value
.filter(i => i !== draftKey.value && !isEmptyDraft(currentUserDrafts.value[i]))
.map(i => [i, currentUserDrafts.value[i]] as const),
)
watchEffect(() => {
draftKey.value = route.query.draft?.toString() || 'home'
const quotedStatusId = route.query.quote?.toString()
if (quotedStatusId) {
draftKey.value = 'quote'
currentUserDrafts.value[draftKey.value] = [getDefaultDraftItem({ quotedStatusId })]
}
else {
const key = route.query.draft?.toString() || 'home'
if (isDraftKey(key))
draftKey.value = key
}
})
onDeactivated(() => {

View file

@ -7,7 +7,7 @@ const {
initial = getDefaultDraftItem,
expanded = false,
} = defineProps<{
draftKey: string
draftKey: DraftKey
initial?: () => DraftItem
placeholder?: string
inReplyToId?: string

View file

@ -16,9 +16,11 @@ const {
status,
isLoading,
canReblog,
canQuote,
toggleBookmark,
toggleFavourite,
toggleReblog,
composeWithQuote,
} = useStatusActions({ status: props.status })
function reply() {
@ -73,6 +75,27 @@ function reply() {
</StatusActionButton>
</div>
<div flex-1>
<StatusActionButton
:content="$t('action.quote')"
:text="!getPreferences(userSettings, 'hideQuoteCount') && status.quotesCount ? status.quotesCount : ''"
color="text-purple" hover="text-purple" elk-group-hover="bg-purple/10"
icon="i-ri:double-quotes-l"
active-icon="i-ri:double-quotes-l"
inactive-icon="i-ri:double-quotes-l"
:disabled="!canQuote"
:command="command"
@click="composeWithQuote()"
>
<template v-if="status.quotesCount && !getPreferences(userSettings, 'hideQuoteCount')" #text>
<CommonLocalizedNumber
keypath="action.quote_count"
:count="status.quotesCount"
/>
</template>
</StatusActionButton>
</div>
<div flex-1>
<StatusActionButton
:content="$t(status.favourited ? 'action.favourited' : 'action.favourite')"

View file

@ -126,8 +126,8 @@ async function editStatus() {
emit('afterEdit')
}
function showFavoritedAndBoostedBy() {
openFavoridedBoostedByDialog(status.value.id)
function showReactedBy() {
openReactedByDialog(status.value.id)
}
</script>
@ -194,10 +194,10 @@ function showFavoritedAndBoostedBy() {
<CommonDropdownItem
is="button"
:text="$t('menu.show_favourited_and_boosted_by')"
:text="$t('menu.show_reacted_by')"
icon="i-ri:hearts-line"
:command="command"
@click="showFavoritedAndBoostedBy()"
@click="showReactedBy()"
/>
<CommonDropdownItem

View file

@ -5,10 +5,12 @@ const {
status,
newer,
withAction = true,
isNested = false,
} = defineProps<{
status: mastodon.v1.Status | mastodon.v1.StatusEdit
newer?: mastodon.v1.Status
withAction?: boolean
isNested?: boolean
}>()
const { translation } = await useTranslation(status, getLanguageCode())
@ -38,6 +40,7 @@ const vnode = computed(() => {
<component :is="vnode" v-if="vnode" />
</span>
<div v-else />
<StatusQuote :status="status" :is-nested="isNested" />
<template v-if="translation.visible">
<div my2 h-px border="b base" bg-base />
<ContentRich v-if="translation.success" class="line-compact" :content="translation.text" :emojis="status.emojis" />

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { actions = true, older, newer, hasOlder, hasNewer, main, account, ...props } = defineProps<{
const { actions = true, isNested = false, older, newer, hasOlder, hasNewer, main, account, ...props } = defineProps<{
status: mastodon.v1.Status
followedTag?: string | null
actions?: boolean
@ -9,6 +9,7 @@ const { actions = true, older, newer, hasOlder, hasNewer, main, account, ...prop
hover?: boolean
inNotification?: boolean
isPreview?: boolean
isNested?: boolean
// If we know the prev and next status in the timeline, we can simplify the card
older?: mastodon.v1.Status
@ -211,6 +212,7 @@ const forceShow = ref(false)
:context="context"
:is-preview="isPreview"
:in-notification="inNotification"
:is-nested="isNested"
mb2 :class="{ 'mt-2 mb1': isDM }"
/>
<StatusActions v-if="actions !== false" v-show="!getPreferences(userSettings, 'zenMode')" :status="status" />

View file

@ -7,6 +7,7 @@ const { status, context } = defineProps<{
context?: mastodon.v2.FilterContext | 'details'
isPreview?: boolean
inNotification?: boolean
isNested: boolean
}>()
const isDM = computed(() => status.visibility === 'direct')
@ -41,7 +42,7 @@ const allowEmbeddedMedia = computed(() => status.card?.html && embeddedMediaPref
'ms--3.5 mt--1 ms--1': isDM && context !== 'details',
}"
>
<StatusBody v-if="(!isFiltered && isSensitiveNonSpoiler) || hideAllMedia" :status="status" :newer="newer" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
<StatusBody v-if="(!isFiltered && isSensitiveNonSpoiler) || hideAllMedia" :status="status" :newer="newer" :with-action="!isDetails" :is-nested="isNested" :class="isDetails ? 'text-xl' : ''" />
<StatusSpoiler :enabled="hasSpoilerOrSensitiveMedia || isFiltered" :filter="isFiltered" :sensitive-non-spoiler="isSensitiveNonSpoiler || hideAllMedia" :is-d-m="isDM">
<template v-if="spoilerTextPresent" #spoiler>
<p>
@ -51,7 +52,7 @@ const allowEmbeddedMedia = computed(() => status.card?.html && embeddedMediaPref
<template v-else-if="filterPhrase" #spoiler>
<p>{{ `${$t('status.filter_hidden_phrase')}: ${filterPhrase}` }}</p>
</template>
<StatusBody v-if="!(isSensitiveNonSpoiler || hideAllMedia)" :status="status" :newer="newer" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
<StatusBody v-if="!(isSensitiveNonSpoiler || hideAllMedia)" :status="status" :newer="newer" :with-action="!isDetails" :is-nested="isNested" :class="isDetails ? 'text-xl' : ''" />
<StatusTranslation :status="status" />
<StatusPoll v-if="status.poll" :status="status" />
<StatusMedia
@ -60,7 +61,7 @@ const allowEmbeddedMedia = computed(() => status.card?.html && embeddedMediaPref
:is-preview="isPreview"
/>
<StatusPreviewCard
v-if="status.card && !allowEmbeddedMedia"
v-if="status.card && !allowEmbeddedMedia && !isNested"
:card="status.card"
:small-picture-only="status.mediaAttachments?.length > 0"
/>

View file

@ -1,11 +1,12 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const { actions = true, ...props } = defineProps<{
const { actions = true, isNested = false, ...props } = defineProps<{
status: mastodon.v1.Status
newer?: mastodon.v1.Status
command?: boolean
actions?: boolean
isNested?: boolean
}>()
defineEmits<{
@ -35,7 +36,7 @@ useHydratedHead({
<AccountInfo :account="status.account" />
</AccountHoverWrapper>
</NuxtLink>
<StatusContent :status="status" :newer="newer" context="details" />
<StatusContent :status="status" :newer="newer" context="details" :is-nested="isNested" />
<div flex="~ gap-1" items-center text-secondary text-sm>
<div flex>
<div>{{ createdAt }}</div>

View file

@ -0,0 +1,134 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const {
status,
isNested = false,
} = defineProps<{
status: mastodon.v1.Status | mastodon.v1.StatusEdit
isNested?: boolean
}>()
function isQuoteType(quote: mastodon.v1.Status['quote']): quote is mastodon.v1.Quote | mastodon.v1.ShallowQuote {
return !!quote
}
function isShallowQuoteType(quote: mastodon.v1.Quote | mastodon.v1.ShallowQuote): quote is mastodon.v1.ShallowQuote {
return 'quotedStatusId' in quote
}
const quoteState = computed<mastodon.v1.QuoteState | null>(() => {
if (!isQuoteType(status.quote)) {
return null
}
return status.quote.state
})
const shallowQuotedStatus = ref<mastodon.v1.Status | null>(null)
watchEffect(async () => {
if (!isQuoteType(status.quote) || !isShallowQuoteType(status.quote) || quoteState.value === 'deleted' || !status.quote.quotedStatusId) {
shallowQuotedStatus.value = null
return
}
shallowQuotedStatus.value = await fetchStatus(status.quote.quotedStatusId)
})
const quotedStatus = computed(() => {
if (!isQuoteType(status.quote)) {
return null
}
if (isShallowQuoteType(status.quote)) {
if (!status.quote.quotedStatusId) {
return null
}
return shallowQuotedStatus.value
}
return status.quote.quotedStatus
})
</script>
<template>
<template v-if="quotedStatus">
<template v-if="isNested && quoteState">
<div
v-if="quoteState === 'pending'"
flex b="~ 1" rounded-lg bg-card mt-3 p-3
>
Post pending for approval by author
</div>
<div
v-else-if="quoteState === 'revoked'"
flex b="~ 1" rounded-lg bg-card mt-3 p-3
>
Post removed by author
</div>
<div
v-else-if="quoteState === 'blocked_account'"
flex b="~ 1" rounded-lg bg-card mt-3 p-3
>
Post by blocked author
</div>
<div
v-else-if="quoteState === 'blocked_domain'"
flex b="~ 1" rounded-lg bg-card mt-3 p-3
>
Post from blocked server
</div>
<div
v-else-if="quoteState === 'muted_account'"
flex b="~ 1" rounded-lg bg-card mt-3 p-3
>
Post by muted author
</div>
<div
v-else-if="quoteState === 'deleted' || quoteState === 'rejected' || quoteState === 'unauthorized'"
flex b="~ 1" rounded-lg bg-card mt-3 p-3
>
Post is unavailable
</div>
<div
v-else-if="quoteState === 'accepted'"
flex b="~ 1" rounded-lg bg-card mt-3 p-3
>
Post by
<AccountInlineInfo :account="quotedStatus.account" :link="false" mx-1 />
</div>
</template>
<template v-else>
<div
v-if="quoteState === 'pending'"
flex b="~ 1" rounded-lg bg-card mt-3 p-3
>
Post pending for approval by author
</div>
<div
v-else-if="quoteState === 'revoked'"
flex b="~ 1" rounded-lg bg-card mt-3 p-3
>
Post removed by author
</div>
<div
v-else-if="quoteState === 'deleted' || quoteState === 'rejected' || quoteState === 'unauthorized'"
flex b="~ 1" rounded-lg bg-card mt-3 p-3
>
Post is unavailable
</div>
<blockquote
v-else-if="quoteState === 'accepted'"
:cite="quotedStatus.uri"
>
<StatusCard
:status="quotedStatus"
:actions="false"
:is-nested="true"
b="base 1" rounded-lg hover:bg-active my-3
/>
</blockquote>
</template>
</template>
</template>
<style scoped>
* {
cursor: pointer;
}
</style>

View file

@ -1,12 +1,20 @@
<script setup lang="ts">
import { favouritedBoostedByStatusId } from '~/composables/dialog'
import { reactedByStatusId } from '~/composables/dialog'
const type = ref<'favourited-by' | 'boosted-by'>('favourited-by')
const type = ref<'favourited-by' | 'boosted-by' | 'quoted-by'>('favourited-by')
const { client } = useMasto()
function load() {
return client.value.v1.statuses.$select(favouritedBoostedByStatusId.value!)[type.value === 'favourited-by' ? 'favouritedBy' : 'rebloggedBy'].list()
if (type.value !== 'quoted-by') {
const accounts = client.value.v1.statuses.$select(reactedByStatusId.value!)[type.value === 'favourited-by' ? 'favouritedBy' : 'rebloggedBy'].list()
return accounts
}
else {
const quotes = client.value.v1.statuses.$select(reactedByStatusId.value!).quotes.list()
// @ts-expect-error waiting for masto.js v7.9.0 release (quotes)
return quotes.map(quote => quote.account)
}
}
const paginator = computed(() => load())
@ -19,6 +27,10 @@ function showRebloggedBy() {
type.value = 'boosted-by'
}
function showQuotedBy() {
type.value = 'quoted-by'
}
const { t } = useI18n()
const tabs = [
{
@ -31,6 +43,11 @@ const tabs = [
display: t('status.boosted_by'),
onClick: showRebloggedBy,
},
{
name: 'quoted-by',
display: t('status.quoted_by'),
onClick: showQuotedBy,
},
]
</script>

View file

@ -1,4 +1,4 @@
import type { ConfirmDialogChoice, ConfirmDialogOptions, DraftItem, ErrorDialogData } from '#shared/types'
import type { ConfirmDialogChoice, ConfirmDialogOptions, DraftItem, DraftKey, ErrorDialogData } from '#shared/types'
import type { mastodon } from 'masto'
export const confirmDialogChoice = ref<ConfirmDialogChoice>()
@ -9,7 +9,7 @@ export const mediaPreviewList = ref<mastodon.v1.MediaAttachment[]>([])
export const mediaPreviewIndex = ref(0)
export const statusEdit = ref<mastodon.v1.StatusEdit>()
export const dialogDraftKey = ref<string>()
export const dialogDraftKey = ref<DraftKey>()
export const reportAccount = ref<mastodon.v1.Account>()
export const reportStatus = ref<mastodon.v1.Status>()
@ -25,12 +25,12 @@ export const isPreviewHelpOpen = ref(false)
export const isCommandPanelOpen = ref(false)
export const isConfirmDialogOpen = ref(false)
export const isErrorDialogOpen = ref(false)
export const isFavouritedBoostedByDialogOpen = ref(false)
export const isReactedByDialogOpen = ref(false)
export const isReportDialogOpen = ref(false)
export const lastPublishDialogStatus = ref<mastodon.v1.Status | null>(null)
export const favouritedBoostedByStatusId = ref<string | null>(null)
export const reactedByStatusId = ref<string | null>(null)
export function openSigninDialog() {
isSigninDialogOpen.value = true
@ -46,7 +46,7 @@ export async function openConfirmDialog(label: ConfirmDialogOptions | string): P
return confirmDialogChoice.value!
}
export async function openPublishDialog(draftKey = 'dialog', draft?: DraftItem, overwrite = false): Promise<void> {
export async function openPublishDialog(draftKey: DraftKey = 'dialog', draft?: DraftItem, overwrite = false): Promise<void> {
dialogDraftKey.value = draftKey
if (draft) {
@ -69,9 +69,9 @@ export async function openPublishDialog(draftKey = 'dialog', draft?: DraftItem,
await until(isPublishDialogOpen).toBe(false)
}
export async function openFavoridedBoostedByDialog(statusId: string) {
isFavouritedBoostedByDialogOpen.value = true
favouritedBoostedByStatusId.value = statusId
export async function openReactedByDialog(statusId: string) {
isReactedByDialogOpen.value = true
reactedByStatusId.value = statusId
}
function restoreMediaPreviewFromState() {

View file

@ -1,4 +1,6 @@
// @unocss-include
import type { mastodon } from 'masto'
export const accountFieldIcons: Record<string, string> = Object.fromEntries(Object.entries({
Alipay: 'i-ri:alipay-line',
Bilibili: 'i-ri:bilibili-line',
@ -65,7 +67,10 @@ export function getAccountFieldIcon(value: string) {
return accountFieldIconsLowercase[name] || undefined
}
export const statusVisibilities = [
export const statusVisibilities: {
value: mastodon.v1.StatusVisibility
icon: `i-ri:${string}`
}[] = [
{
value: 'public',
icon: 'i-ri:global-line',
@ -83,3 +88,21 @@ export const statusVisibilities = [
icon: 'i-ri:at-line',
},
] as const
export const statusQuoteApprovalPolicies: {
value: mastodon.rest.v1.QuoteApprovalPolicy
icon: `i-ri:${string}`
}[] = [
{
value: 'public',
icon: 'i-ri:global-line',
},
{
value: 'followers',
icon: 'i-ri:group-line',
},
{
value: 'nobody',
icon: 'i-ri:lock-line',
},
] as const

View file

@ -100,6 +100,8 @@ export function usePublish(options: {
language: draftItem.value.params.language || preferredLanguage.value,
poll,
scheduledAt,
quotedStatusId: draftItem.value.params.quotedStatusId,
quoteApprovalPolicy: draftItem.value.params.quoteApprovalPolicy,
...(isGlitchEdition.value ? { 'content-type': 'text/markdown' } : {}),
} as mastodon.rest.v1.CreateScheduledStatusParams

View file

@ -1,7 +1,7 @@
import type { mastodon } from 'masto'
type Action = 'reblogged' | 'favourited' | 'bookmarked' | 'pinned' | 'muted'
type CountField = 'reblogsCount' | 'favouritesCount'
type CountField = 'reblogsCount' | 'favouritesCount' | 'quotesCount'
export interface StatusActionsProps {
status: mastodon.v1.Status
@ -54,6 +54,12 @@ export function useStatusActions(props: StatusActionsProps) {
status.value[countField] += status.value[action] ? 1 : -1
}
const toggleFavourite = () => toggleStatusAction(
'favourited',
() => client.value.v1.statuses.$select(status.value.id)[status.value.favourited ? 'unfavourite' : 'favourite'](),
'favouritesCount',
)
const canReblog = computed(() =>
status.value.visibility !== 'direct'
&& (status.value.visibility !== 'private' || status.value.account.id === currentUser.value?.account.id),
@ -61,7 +67,6 @@ export function useStatusActions(props: StatusActionsProps) {
const toggleReblog = () => toggleStatusAction(
'reblogged',
// @ts-expect-error this method should not take any argument, but it expects 1-2 arguments since masto.js v7.7.0 (potential issue)
() => client.value.v1.statuses.$select(status.value.id)[status.value.reblogged ? 'unreblog' : 'reblog']().then((res) => {
if (status.value.reblogged)
// returns the original status
@ -71,11 +76,14 @@ export function useStatusActions(props: StatusActionsProps) {
'reblogsCount',
)
const toggleFavourite = () => toggleStatusAction(
'favourited',
() => client.value.v1.statuses.$select(status.value.id)[status.value.favourited ? 'unfavourite' : 'favourite'](),
'favouritesCount',
)
const canQuote = computed(() => {
if (status.value.visibility === 'private' || status.value.visibility === 'direct')
return false
return status.value.quoteApproval?.currentUser === 'automatic' || status.value.quoteApproval?.currentUser === 'manual'
})
const composeWithQuote = () => navigateTo(`/compose?quote=${status.value.id}`)
const toggleBookmark = () => toggleStatusAction(
'bookmarked',
@ -95,11 +103,13 @@ export function useStatusActions(props: StatusActionsProps) {
return {
status,
isLoading,
canQuote,
canReblog,
toggleMute,
toggleReblog,
toggleFavourite,
toggleBookmark,
togglePin,
composeWithQuote,
}
}

View file

@ -1,19 +1,14 @@
import type { DraftItem, DraftMap } from '#shared/types'
import type { DraftItem, DraftKey, DraftMap } from '#shared/types'
import type { Mutable } from '#shared/types/utils'
import type { mastodon } from 'masto'
import type { ComputedRef, Ref } from 'vue'
import { STORAGE_KEY_DRAFTS } from '~/constants'
export const currentUserDrafts = (import.meta.server || process.test)
? computed<DraftMap>(() => ({}))
: useUserLocalStorage<DraftMap>(STORAGE_KEY_DRAFTS, () => ({}))
? computed<DraftMap>(() => ({ home: [], dialog: [], intent: [], quote: [] }))
: useUserLocalStorage<DraftMap>(STORAGE_KEY_DRAFTS, () => ({ home: [], dialog: [], intent: [], quote: [] }))
export const builtinDraftKeys = [
'dialog',
'home',
]
const ALL_VISIBILITY = ['public', 'unlisted', 'private', 'direct'] as const
const ALL_VISIBILITY: readonly mastodon.v1.StatusVisibility[] = ['public', 'unlisted', 'private', 'direct'] as const
function getDefaultVisibility(currentVisibility: mastodon.v1.StatusVisibility) {
// The default privacy only should be taken into account if it makes
@ -25,6 +20,18 @@ function getDefaultVisibility(currentVisibility: mastodon.v1.StatusVisibility) {
: preferredVisibility
}
const ALL_QUOTE_APPROVAL_POLICY: readonly mastodon.rest.v1.QuoteApprovalPolicy[] = ['public', 'followers', 'nobody'] as const
function getDefaultQuoteApprovalPolicy(currentQuoteApprovalPolicy: mastodon.rest.v1.QuoteApprovalPolicy) {
// The default quote policy only should be taken into account if it makes
// the quote permission more restricted
const preferredQuoteApprovalPolicy = currentUser.value?.account.source.quotePolicy || 'public'
return ALL_QUOTE_APPROVAL_POLICY.indexOf(currentQuoteApprovalPolicy)
> ALL_QUOTE_APPROVAL_POLICY.indexOf(preferredQuoteApprovalPolicy)
? currentQuoteApprovalPolicy
: preferredQuoteApprovalPolicy
}
export function getDefaultDraftItem(options: Partial<Mutable<mastodon.rest.v1.CreateScheduledStatusParams> & Omit<DraftItem, 'params'>> = {}): DraftItem {
const {
attachments = [],
@ -38,6 +45,8 @@ export function getDefaultDraftItem(options: Partial<Mutable<mastodon.rest.v1.Cr
mentions,
poll,
scheduledAt,
quotedStatusId,
quoteApprovalPolicy,
} = options
return {
@ -48,6 +57,8 @@ export function getDefaultDraftItem(options: Partial<Mutable<mastodon.rest.v1.Cr
poll,
scheduledAt,
inReplyToId,
quotedStatusId,
quoteApprovalPolicy: getDefaultQuoteApprovalPolicy(quoteApprovalPolicy || 'public'),
visibility: getDefaultVisibility(visibility || 'public'),
sensitive: sensitive ?? false,
spoilerText: spoilerText || '',
@ -99,7 +110,7 @@ function getAccountsToMention(status: mastodon.v1.Status) {
export function getReplyDraft(status: mastodon.v1.Status) {
const accountsToMention = getAccountsToMention(status)
return {
key: `reply-${status.id}`,
key: `reply-${status.id}` satisfies DraftKey,
draft: () => {
return getDefaultDraftItem({
initialText: '',
@ -127,9 +138,11 @@ export function isEmptyDraft(drafts: Array<DraftItem> | DraftItem | null | undef
const { params, attachments } = draft
const status = params.status ?? ''
const text = htmlToText(status).trim().replace(/^(@\S+\s?)+/, '').replaceAll(/```/g, '').trim()
const hasQuote = !!params.quotedStatusId
return (text.length > 0)
|| (attachments.length > 0)
|| hasQuote
})
return !anyDraftHasContent
@ -141,7 +154,7 @@ export interface UseDraft {
}
export function useDraft(
draftKey: string,
draftKey: DraftKey,
initial: () => DraftItem = () => getDefaultDraftItem({}),
): UseDraft {
const draftItems = computed({
@ -184,11 +197,24 @@ export function privateMentionUser(account: mastodon.v1.Account) {
}))
}
export const builtinDraftKeys = [
'home',
'dialog',
'intent',
'quote',
]
export function clearEmptyDrafts() {
for (const key in currentUserDrafts.value) {
if (builtinDraftKeys.includes(key) && !isEmptyDraft(currentUserDrafts.value[key]))
continue
if (isEmptyDraft(currentUserDrafts.value[key]))
delete currentUserDrafts.value[key]
if (isDraftKey(key)) {
if (builtinDraftKeys.includes(key) && !isEmptyDraft(currentUserDrafts.value[key]))
continue
if (isEmptyDraft(currentUserDrafts.value[key]))
delete currentUserDrafts.value[key]
}
}
}
export function isDraftKey(key: string): key is DraftKey {
return builtinDraftKeys.includes(key) || key.startsWith('reply-')
}

View file

@ -13,6 +13,7 @@ export interface PreferencesSettings {
hideAltIndicatorOnPosts: boolean
hideGifIndicatorOnPosts: boolean
hideBoostCount: boolean
hideQuoteCount: boolean
hideReplyCount: boolean
hideFavoriteCount: boolean
hideFollowerCount: boolean
@ -70,6 +71,7 @@ export const DEFAULT__PREFERENCES_SETTINGS: PreferencesSettings = {
hideAltIndicatorOnPosts: false,
hideGifIndicatorOnPosts: false,
hideBoostCount: false,
hideQuoteCount: false,
hideReplyCount: false,
hideFavoriteCount: false,
hideFollowerCount: false,

View file

@ -1,9 +1,9 @@
import type { DraftItem } from '#shared/types'
import type { DraftItem, DraftKey } from '#shared/types'
import type { mastodon } from 'masto'
const maxThreadLength = 99
export function useThreadComposer(draftKey: string, initial?: () => DraftItem) {
export function useThreadComposer(draftKey: DraftKey, initial?: () => DraftItem) {
const { draftItems } = useDraft(draftKey, initial)
/**

View file

@ -12,6 +12,7 @@ onMounted(async () => {
spoilerText: route.query.spoiler_text as string,
visibility: route.query.visibility as mastodon.v1.StatusVisibility,
language: route.query.language as string,
quotedStatusId: route.query.quote as string,
}), true)
// TODO: need a better idea 👀
await router.replace('/home')

View file

@ -102,6 +102,12 @@ const userSettings = useUserSettings()
>
{{ $t('settings.preferences.hide_boost_count') }}
</SettingsToggleItem>
<SettingsToggleItem
:checked="getPreferences(userSettings, 'hideQuoteCount')"
@click="togglePreferences('hideQuoteCount')"
>
{{ $t('settings.preferences.hide_quote_count') }}
</SettingsToggleItem>
<SettingsToggleItem
:checked="getPreferences(userSettings, 'hideFavoriteCount')"
@click="togglePreferences('hideFavoriteCount')"

View file

@ -71,6 +71,15 @@ export default defineNuxtPlugin(({ $scrollToTop }) => {
}
whenever(logicAnd(isAuthenticated, notUsingInput, keys.b), toggleBoostActiveStatus)
const composeWithQuote = () => {
const quotedStatusId = document.querySelector<HTMLElement>('[aria-roledescription=status-details]')
?.getAttribute('id')
?.replace('status-', '')
if (quotedStatusId)
navigateTo(`/compose?quote=${quotedStatusId}`)
}
whenever(logicAnd(isAuthenticated, notUsingInput, keys.q), composeWithQuote)
const showNewItems = () => {
// TODO: find a better solution than clicking buttons...
document

View file

@ -78,6 +78,9 @@
"prev": "Prev",
"publish": "Publish",
"publish_thread": "Publish thread",
"quote": "Quote",
"quote_count": "{0}",
"remove_quote": "Remove quote",
"reply": "Reply",
"reply_count": "{0}",
"reset": "Reset",
@ -189,6 +192,7 @@
"account_not_found": "Account {0} not found",
"explore_list_empty": "Nothing is trending right now. Check back later!",
"file_size_cannot_exceed_n_mb": "File size cannot exceed {0}MB",
"quote_fetch_error": "Could not fetch the quoted post",
"sign_in_error": "Cannot connect to the server.",
"status_not_found": "Post not found",
"unsupported_file_format": "Unsupported file format"
@ -240,6 +244,7 @@
"command_mode": "Command mode",
"compose": "Compose",
"favourite": "Favorite",
"quote": "Quote",
"search": "Search",
"show_new_items": "Show new items",
"title": "Actions"
@ -289,6 +294,7 @@
"share_account": "Share {0}",
"share_post": "Share this post",
"show_favourited_and_boosted_by": "Show who favorited and boosted",
"show_reacted_by": "Show who reacted",
"show_reblogs": "Show boosts from {0}",
"show_untranslated": "Show untranslated",
"toggle_theme": {
@ -402,6 +408,14 @@
}
}
},
"quote_approval_policy": {
"followers": "Followers only",
"followers_desc": "Your followers can quote",
"nobody": "Nobody",
"nobody_desc": "Only you can quote",
"public": "Public",
"public_desc": "Anyone can quote"
},
"report": {
"additional_comments": "Additional comments",
"another_server": "The user you're reporting is from another server",
@ -557,6 +571,7 @@
"hide_follower_count": "Hide following/follower count",
"hide_gif_indi_on_posts": "Hide gif indicator on posts",
"hide_news": "Hide news",
"hide_quote_count": "Hide quote count",
"hide_reply_count": "Hide reply count",
"hide_tag_hover_card": "Hide tag hover card",
"hide_translation": "Hide translation",
@ -650,6 +665,7 @@
"finished": "finished {0}",
"update": "Update poll"
},
"quoted_by": "Quoted By",
"replying_to": "Replying to {0}",
"show_full_thread": "Show Full thread",
"someone": "someone",
@ -742,6 +758,7 @@
"add_thread_item": "Add item to thread",
"change_content_visibility": "Change content visibility",
"change_language": "Change language",
"change_quote_approval_policy": "Change quote approval policy",
"emoji": "Emoji",
"explore_links_intro": "These news stories are being talked about by people on this and other servers of the decentralized network right now.",
"explore_posts_intro": "These posts from this and other servers in the decentralized network are gaining traction on this server right now.",

View file

@ -77,11 +77,15 @@
"prev": "前へ",
"publish": "投稿",
"publish_thread": "スレッドを投稿",
"quote": "引用",
"quote_count": "{0}",
"remove_quote": "引用を削除",
"reply": "返信",
"reply_count": "{0}",
"reset": "リセット",
"save": "保存する",
"save_changes": "変更を保存",
"schedule": "予約",
"sign_in": "サインイン",
"sign_in_to": "{0}にサインイン",
"switch_account": "アカウントの切り替え",
@ -187,6 +191,7 @@
"account_not_found": "アカウント {0} が見つかりません",
"explore_list_empty": "現在トレンドには何もありません。またあとで!",
"file_size_cannot_exceed_n_mb": "{0}MBを超えるファイルはアップロードできません",
"quote_fetch_error": "引用投稿が取得できませんでした",
"sign_in_error": "サーバーに接続できません。",
"status_not_found": "投稿が見つかりません",
"unsupported_file_format": "対応していないファイル形式です"
@ -238,6 +243,7 @@
"command_mode": "コマンドモード",
"compose": "投稿",
"favourite": "お気に入り",
"quote": "引用",
"search": "検索",
"show_new_items": "新しいアイテムを表示",
"title": "アクション"
@ -400,6 +406,14 @@
}
}
},
"quote_approval_policy": {
"followers": "フォロワーのみ",
"followers_desc": "フォロワーの引用を許可",
"nobody": "自分のみ",
"nobody_desc": "自分自身の引用のみ許可",
"public": "誰でも",
"public_desc": "すべての人に引用を許可"
},
"report": {
"additional_comments": "追加コメント",
"another_server": "報告しようとしているユーザーは他のサーバーのユーザーです",
@ -555,6 +569,7 @@
"hide_follower_count": "フォロワーの数を隠す",
"hide_gif_indi_on_posts": "投稿上のGIF表示を隠す",
"hide_news": "ニュースを隠す",
"hide_quote_count": "引用数を隠す",
"hide_reply_count": "リプライの数を隠す",
"hide_tag_hover_card": "タグのホバーカードを隠す",
"hide_translation": "翻訳を隠す",
@ -617,6 +632,9 @@
"publish_failed": "投稿に失敗しました",
"publishing": "投稿中",
"save_failed": "保存に失敗しました",
"schedule_failed": "投稿の予約に失敗しました",
"schedule_time_invalid": "予約時刻は少なくとも5分後でなければなりません。{0}以降に設定してください。",
"scheduling": "予約投稿中",
"upload_failed": "アップロードに失敗しました",
"uploading": "更新中..."
},
@ -645,6 +663,7 @@
"finished": "{0}に完了",
"update": "投票を更新"
},
"quoted_by": "引用したユーザー",
"replying_to": "{0}さんへの返信",
"show_full_thread": "スレッド全体を表示",
"someone": "誰か",
@ -737,6 +756,7 @@
"add_thread_item": "投稿をスレッドに追加",
"change_content_visibility": "公開範囲を変更",
"change_language": "言語を変更",
"change_quote_approval_policy": "引用許可ポリシーを変更",
"emoji": "絵文字",
"explore_links_intro": "これらのニュース記事は、分散型ネットワーク上のこのサーバーや他のサーバー上で現在話題になっています。",
"explore_posts_intro": "これらの投稿は、分散型ネットワーク上のこのサーバーや他のサーバー上にあり、現在このサーバー上で注目を集めています。",
@ -745,6 +765,8 @@
"pick_an_icon": "アイコンの選択",
"publish_failed": "投稿の再公開時にエディタ上部のエラーメッセージを閉じる",
"remove_thread_item": "投稿をスレッドから削除",
"schedule_failed": "投稿の予約投稿時にエディタ上部のエラーメッセージを閉じる",
"schedule_post": "予約投稿",
"start_thread": "スレッドを開始",
"toggle_bold": "太字",
"toggle_code_block": "コードブロック",

View file

@ -94,7 +94,7 @@
"js-yaml": "^4.1.0",
"lru-cache": "^11.0.0",
"magic-string": "^0.30.19",
"masto": "^7.8.0",
"masto": "^7.10.0",
"mocked-exports": "^0.1.1",
"node-emoji": "^2.1.3",
"nuxt": "^4.1.2",

View file

@ -198,8 +198,8 @@ importers:
specifier: ^0.30.19
version: 0.30.19
masto:
specifier: ^7.8.0
version: 7.8.0
specifier: ^7.10.0
version: 7.10.0
mocked-exports:
specifier: ^0.1.1
version: 0.1.1
@ -6932,8 +6932,8 @@ packages:
marky@1.3.0:
resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
masto@7.8.0:
resolution: {integrity: sha512-ZU0zQHzyV6I4hTOUv0PBvmLQDvtp+EtOm3/2hwei+CNcfA9E+XcBafThe1gSzNDBAzUbEg/O8fwGLTZV5PJNfQ==}
masto@7.10.0:
resolution: {integrity: sha512-BTaivOeHkb87oND2Dp/QzRP5kWJnB5khH9xSsfS0N8WzEAziuKV4DjZAHdbh2SNemqwikQeYtMPBsl52Q9sreQ==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
@ -17586,7 +17586,7 @@ snapshots:
marky@1.3.0: {}
masto@7.8.0:
masto@7.10.0:
dependencies:
change-case: 5.4.4
events-to-async: 2.0.2

View file

@ -50,10 +50,13 @@ export interface DraftItem {
attachments: mastodon.v1.MediaAttachment[]
lastUpdated: number
mentions?: string[]
quotedStatusId?: mastodon.v1.Status['id']
}
export type DraftMap = Record<string, Array<DraftItem>
// For backward compatibility we need to support single draft items
export type DraftKey = 'home' | 'dialog' | 'intent' | 'quote' | `reply-${string}` | `edit-${string}`
export type DraftMap = Record<DraftKey, DraftItem[]
// For backward compatibility to support single draft item before introducing thread
| DraftItem>
export interface ConfirmDialogOptions {