feat: add threaded drafts & posts (#2715)
Co-authored-by: Sebastian Di Luzio <sebastian.di-luzio@iu.org> Co-authored-by: Emanuel Pina <contacto@emanuelpina.pt> Co-authored-by: lazzzis <lazzzis@outlook.com> Co-authored-by: Joaquín Sánchez <userquin@gmail.com> Co-authored-by: TAKAHASHI Shuuji <shuuji3@gmail.com> Co-authored-by: Francesco <129339155+katullo11@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: patak-dev <matias.capeletto@gmail.com>
This commit is contained in:
parent
0538f97ada
commit
1234fb2dd1
|
@ -67,7 +67,7 @@ function handleFavouritedBoostedByClose() {
|
||||||
@close="handlePublishClose"
|
@close="handlePublishClose"
|
||||||
>
|
>
|
||||||
<!-- This `w-0` style is used to avoid overflow problems in flex layouts,so don't remove it unless you know what you're doing -->
|
<!-- This `w-0` style is used to avoid overflow problems in flex layouts,so don't remove it unless you know what you're doing -->
|
||||||
<PublishWidget
|
<PublishWidgetList
|
||||||
v-if="dialogDraftKey"
|
v-if="dialogDraftKey"
|
||||||
:draft-key="dialogDraftKey" expanded flex-1 w-0
|
:draft-key="dialogDraftKey" expanded flex-1 w-0
|
||||||
@published="handlePublished"
|
@published="handlePublished"
|
||||||
|
|
45
components/publish/PublishThreadTools.vue
Normal file
45
components/publish/PublishThreadTools.vue
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
draftKey: string
|
||||||
|
draftItemIndex: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { threadIsActive, addThreadItem, threadItems, removeThreadItem } = useThreadComposer(props.draftKey)
|
||||||
|
|
||||||
|
const isRemovableItem = computed(() => threadIsActive.value && props.draftItemIndex < threadItems.value.length - 1)
|
||||||
|
|
||||||
|
function addOrRemoveItem() {
|
||||||
|
if (isRemovableItem.value)
|
||||||
|
removeThreadItem(props.draftItemIndex)
|
||||||
|
|
||||||
|
else
|
||||||
|
addThreadItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
if (!isRemovableItem.value && props.draftItemIndex === 0)
|
||||||
|
return t('tooltip.start_thread')
|
||||||
|
|
||||||
|
return isRemovableItem.value ? t('tooltip.remove_thread_item') : t('tooltip.add_thread_item')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div flex flex-row rounded-3 :class="{ 'bg-border': threadIsActive }">
|
||||||
|
<div
|
||||||
|
v-if="threadIsActive" dir="ltr" pointer-events-none pe-1 pt-2 pl-2 text-sm tabular-nums text-secondary flex
|
||||||
|
gap="0.5"
|
||||||
|
>
|
||||||
|
{{ draftItemIndex + 1 }}<span text-secondary-light>/</span><span text-secondary-light>{{ threadItems.length
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<CommonTooltip placement="top" :content="label">
|
||||||
|
<button btn-action-icon :aria-label="label" @click="addOrRemoveItem">
|
||||||
|
<div v-if="isRemovableItem" i-ri:chat-delete-line />
|
||||||
|
<div v-else i-ri:chat-new-line />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -2,17 +2,19 @@
|
||||||
import { EditorContent } from '@tiptap/vue-3'
|
import { EditorContent } from '@tiptap/vue-3'
|
||||||
import stringLength from 'string-length'
|
import stringLength from 'string-length'
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
import type { Draft } from '~/types'
|
import type { DraftItem } from '~/types'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
draftKey,
|
draftKey,
|
||||||
initial = getDefaultDraft,
|
draftItemIndex,
|
||||||
expanded = false,
|
expanded = false,
|
||||||
placeholder,
|
placeholder,
|
||||||
dialogLabelledBy,
|
dialogLabelledBy,
|
||||||
|
initial = getDefaultDraftItem,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
draftKey?: string
|
draftKey: string
|
||||||
initial?: () => Draft
|
draftItemIndex: number
|
||||||
|
initial?: () => DraftItem
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
inReplyToId?: string
|
inReplyToId?: string
|
||||||
inReplyToVisibility?: mastodon.v1.StatusVisibility
|
inReplyToVisibility?: mastodon.v1.StatusVisibility
|
||||||
|
@ -26,8 +28,17 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const draftState = useDraft(draftKey, initial)
|
const { threadItems, threadIsActive, publishThread } = useThreadComposer(draftKey)
|
||||||
const { draft } = draftState
|
|
||||||
|
const draft = computed({
|
||||||
|
get: () => threadItems.value[draftItemIndex],
|
||||||
|
set: (updatedDraft: DraftItem) => {
|
||||||
|
threadItems.value[draftItemIndex] = updatedDraft
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const isFinalItemOfThread = computed(() => draftItemIndex === threadItems.value.length - 1)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isExceedingAttachmentLimit,
|
isExceedingAttachmentLimit,
|
||||||
|
@ -43,8 +54,8 @@ const {
|
||||||
|
|
||||||
const { shouldExpanded, isExpanded, isSending, isPublishDisabled, publishDraft, failedMessages, preferredLanguage, publishSpoilerText } = usePublish(
|
const { shouldExpanded, isExpanded, isSending, isPublishDisabled, publishDraft, failedMessages, preferredLanguage, publishSpoilerText } = usePublish(
|
||||||
{
|
{
|
||||||
draftState,
|
draftItem: draft,
|
||||||
...{ expanded: toRef(() => expanded), isUploading, initialDraft: toRef(() => initial) },
|
...{ expanded: toRef(() => expanded), isUploading, initialDraft: initial, isPartOfThread: false },
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -181,9 +192,13 @@ async function toggleSensitive() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publish() {
|
async function publish() {
|
||||||
const status = await publishDraft()
|
const publishResult = await (threadIsActive.value ? publishThread() : publishDraft())
|
||||||
if (status)
|
if (publishResult) {
|
||||||
emit('published', status)
|
if (Array.isArray(publishResult))
|
||||||
|
failedMessages.value = publishResult
|
||||||
|
else
|
||||||
|
emit('published', publishResult)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useWebShareTarget(async ({ data: { data, action } }: any) => {
|
useWebShareTarget(async ({ data: { data, action } }: any) => {
|
||||||
|
@ -215,10 +230,6 @@ function stopQuestionMarkPropagation(e: KeyboardEvent) {
|
||||||
if (e.key === '?')
|
if (e.key === '?')
|
||||||
e.stopImmediatePropagation()
|
e.stopImmediatePropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
onDeactivated(() => {
|
|
||||||
clearEmptyDrafts()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -228,31 +239,36 @@ onDeactivated(() => {
|
||||||
{{ $t('state.editing') }}
|
{{ $t('state.editing') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div flex gap-3 flex-1>
|
<div flex gap-3 flex-1>
|
||||||
|
<div>
|
||||||
<NuxtLink self-start :to="getAccountRoute(currentUser.account)">
|
<NuxtLink self-start :to="getAccountRoute(currentUser.account)">
|
||||||
<AccountBigAvatar :account="currentUser.account" square />
|
<AccountBigAvatar :account="currentUser.account" square />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<div v-if="!isFinalItemOfThread" w-full h-full flex mt--3px justify-center>
|
||||||
|
<div w-1px border="x base" mb-6 />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div w-full>
|
||||||
|
<div flex gap-3 flex-1>
|
||||||
<!-- This `w-0` style is used to avoid overflow problems in flex layouts,so don't remove it unless you know what you're doing -->
|
<!-- This `w-0` style is used to avoid overflow problems in flex layouts,so don't remove it unless you know what you're doing -->
|
||||||
<div
|
<div
|
||||||
ref="dropZoneRef"
|
ref="dropZoneRef" flex w-0 flex-col gap-3 flex-1 border="2 dashed transparent"
|
||||||
flex w-0 flex-col gap-3 flex-1
|
|
||||||
border="2 dashed transparent"
|
|
||||||
:class="[isSending ? 'pointer-events-none' : '', isOverDropZone ? '!border-primary' : '']"
|
:class="[isSending ? 'pointer-events-none' : '', isOverDropZone ? '!border-primary' : '']"
|
||||||
>
|
>
|
||||||
<ContentMentionGroup v-if="draft.mentions?.length && shouldExpanded" replying>
|
<ContentMentionGroup v-if="draft.mentions?.length && shouldExpanded" replying>
|
||||||
<button v-for="m, i of draft.mentions" :key="m" text-primary hover:color-red @click="draft.mentions?.splice(i, 1)">
|
<button
|
||||||
|
v-for="m, i of draft.mentions" :key="m" text-primary hover:color-red
|
||||||
|
@click="draft.mentions?.splice(i, 1)"
|
||||||
|
>
|
||||||
{{ accountToShortHandle(m) }}
|
{{ accountToShortHandle(m) }}
|
||||||
</button>
|
</button>
|
||||||
</ContentMentionGroup>
|
</ContentMentionGroup>
|
||||||
|
|
||||||
<div v-if="draft.params.sensitive">
|
<div v-if="draft.params.sensitive">
|
||||||
<input
|
<input
|
||||||
v-model="publishSpoilerText"
|
v-model="publishSpoilerText" type="text" :placeholder="$t('placeholder.content_warning')" p2
|
||||||
type="text"
|
border-rounded w-full bg-transparent outline-none border="~ base"
|
||||||
:placeholder="$t('placeholder.content_warning')"
|
|
||||||
p2 border-rounded w-full bg-transparent
|
|
||||||
outline-none border="~ base"
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -264,8 +280,8 @@ onDeactivated(() => {
|
||||||
</div>
|
</div>
|
||||||
<CommonTooltip placement="bottom" :content="$t('action.clear_publish_failed')">
|
<CommonTooltip placement="bottom" :content="$t('action.clear_publish_failed')">
|
||||||
<button
|
<button
|
||||||
flex rounded-4 p1 hover:bg-active cursor-pointer transition-100 :aria-label="$t('action.clear_publish_failed')"
|
flex rounded-4 p1 hover:bg-active cursor-pointer transition-100
|
||||||
@click="failedMessages = []"
|
:aria-label="$t('action.clear_publish_failed')" @click="failedMessages = []"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
|
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
|
||||||
</button>
|
</button>
|
||||||
|
@ -281,8 +297,7 @@ onDeactivated(() => {
|
||||||
|
|
||||||
<div relative flex-1 flex flex-col min-h-30>
|
<div relative flex-1 flex flex-col min-h-30>
|
||||||
<EditorContent
|
<EditorContent
|
||||||
:editor="editor"
|
:editor="editor" flex max-w-full
|
||||||
flex max-w-full
|
|
||||||
:class="{
|
:class="{
|
||||||
'md:max-h-[calc(100vh-200px)] sm:max-h-[calc(100vh-400px)] max-h-35 of-y-auto overscroll-contain': shouldExpanded,
|
'md:max-h-[calc(100vh-200px)] sm:max-h-[calc(100vh-400px)] max-h-35 of-y-auto overscroll-contain': shouldExpanded,
|
||||||
'py2 px3.5 bg-dm rounded-4 me--1 ms--1 mt--1': isDM,
|
'py2 px3.5 bg-dm rounded-4 me--1 ms--1 mt--1': isDM,
|
||||||
|
@ -329,34 +344,22 @@ onDeactivated(() => {
|
||||||
|
|
||||||
<div v-if="draft.attachments.length" flex="~ col gap-2" overflow-auto>
|
<div v-if="draft.attachments.length" flex="~ col gap-2" overflow-auto>
|
||||||
<PublishAttachment
|
<PublishAttachment
|
||||||
v-for="(att, idx) in draft.attachments" :key="att.id"
|
v-for="(att, idx) in draft.attachments" :key="att.id" :attachment="att"
|
||||||
:attachment="att"
|
|
||||||
:dialog-labelled-by="dialogLabelledBy ?? (draft.editingStatus ? 'state-editing' : undefined)"
|
:dialog-labelled-by="dialogLabelledBy ?? (draft.editingStatus ? 'state-editing' : undefined)"
|
||||||
@remove="removeAttachment(idx)"
|
@remove="removeAttachment(idx)" @set-description="setDescription(att, $event)"
|
||||||
@set-description="setDescription(att, $event)"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div flex gap-4>
|
|
||||||
<div w-12 h-full sm:block hidden />
|
|
||||||
<div flex="~ col 1" max-w-full>
|
<div flex="~ col 1" max-w-full>
|
||||||
<form v-if="isExpanded && draft.params.poll" my-4 flex="~ 1 col" gap-3 m="s--1">
|
<form v-if="isExpanded && draft.params.poll" my-4 flex="~ 1 col" gap-3 m="s--1">
|
||||||
<div
|
<div v-for="(option, index) in draft.params.poll.options" :key="index" flex="~ row" gap-3>
|
||||||
v-for="(option, index) in draft.params.poll.options"
|
|
||||||
:key="index"
|
|
||||||
flex="~ row"
|
|
||||||
gap-3
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
:value="option"
|
:value="option" bg-base border="~ base" flex-1 h10 pe-4 rounded-2 w-full flex="~ row" items-center
|
||||||
bg-base
|
relative focus-within:box-shadow-outline gap-3 px-4 py-2
|
||||||
border="~ base" flex-1 h10 pe-4 rounded-2 w-full flex="~ row"
|
|
||||||
items-center relative focus-within:box-shadow-outline gap-3
|
|
||||||
px-4 py-2
|
|
||||||
:placeholder="$t('polls.option_placeholder', { current: index + 1, max: currentInstance?.configuration?.polls.maxOptions })"
|
:placeholder="$t('polls.option_placeholder', { current: index + 1, max: currentInstance?.configuration?.polls.maxOptions })"
|
||||||
class="option-input"
|
class="option-input" @input="editPollOptionDraft($event, index)"
|
||||||
@input="editPollOptionDraft($event, index)"
|
|
||||||
>
|
>
|
||||||
<CommonTooltip placement="top" :content="$t('polls.remove_option')" class="delete-button">
|
<CommonTooltip placement="top" :content="$t('polls.remove_option')" class="delete-button">
|
||||||
<button
|
<button
|
||||||
|
@ -368,28 +371,23 @@ onDeactivated(() => {
|
||||||
</button>
|
</button>
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
<span
|
<span
|
||||||
v-if="currentInstance?.configuration?.polls.maxCharactersPerOption"
|
v-if="currentInstance?.configuration?.polls.maxCharactersPerOption" class="char-limit-radial"
|
||||||
class="char-limit-radial"
|
aspect-ratio-1 h-10
|
||||||
aspect-ratio-1
|
|
||||||
h-10
|
|
||||||
:style="{ background: `radial-gradient(closest-side, rgba(var(--rgb-bg-base)) 79%, transparent 80% 100%), conic-gradient(${draft.params.poll!.options[index].length / currentInstance?.configuration?.polls.maxCharactersPerOption > 1 ? 'var(--c-danger)' : 'var(--c-primary)'} ${draft.params.poll!.options[index].length / currentInstance?.configuration?.polls.maxCharactersPerOption * 100}%, var(--c-primary-fade) 0)` }"
|
:style="{ background: `radial-gradient(closest-side, rgba(var(--rgb-bg-base)) 79%, transparent 80% 100%), conic-gradient(${draft.params.poll!.options[index].length / currentInstance?.configuration?.polls.maxCharactersPerOption > 1 ? 'var(--c-danger)' : 'var(--c-primary)'} ${draft.params.poll!.options[index].length / currentInstance?.configuration?.polls.maxCharactersPerOption * 100}%, var(--c-primary-fade) 0)` }"
|
||||||
>{{ draft.params.poll!.options[index].length }}</span>
|
>{{
|
||||||
|
draft.params.poll!.options[index].length }}</span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div
|
<div v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="end" max-w-full border="t base">
|
||||||
v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="end" max-w-full
|
<PublishEmojiPicker @select="insertEmoji" @select-custom="insertCustomEmoji">
|
||||||
border="t base"
|
|
||||||
>
|
|
||||||
<PublishEmojiPicker
|
|
||||||
@select="insertEmoji"
|
|
||||||
@select-custom="insertCustomEmoji"
|
|
||||||
>
|
|
||||||
<button btn-action-icon :title="$t('tooltip.emojis')" :aria-label="$t('tooltip.add_emojis')">
|
<button btn-action-icon :title="$t('tooltip.emojis')" :aria-label="$t('tooltip.add_emojis')">
|
||||||
<div i-ri:emotion-line />
|
<div i-ri:emotion-line />
|
||||||
</button>
|
</button>
|
||||||
</PublishEmojiPicker>
|
</PublishEmojiPicker>
|
||||||
|
|
||||||
<CommonTooltip v-if="draft.params.poll === undefined" placement="top" :content="$t('tooltip.add_media')">
|
<CommonTooltip
|
||||||
|
v-if="draft.params.poll === undefined" placement="top" :content="$t('tooltip.add_media')"
|
||||||
|
>
|
||||||
<button btn-action-icon :aria-label="$t('tooltip.add_media')" @click="pickAttachments">
|
<button btn-action-icon :aria-label="$t('tooltip.add_media')" @click="pickAttachments">
|
||||||
<div i-ri:image-add-line />
|
<div i-ri:image-add-line />
|
||||||
</button>
|
</button>
|
||||||
|
@ -397,13 +395,19 @@ onDeactivated(() => {
|
||||||
|
|
||||||
<template v-if="draft.attachments.length === 0">
|
<template v-if="draft.attachments.length === 0">
|
||||||
<CommonTooltip v-if="!draft.params.poll" placement="top" :content="$t('polls.create')">
|
<CommonTooltip v-if="!draft.params.poll" placement="top" :content="$t('polls.create')">
|
||||||
<button btn-action-icon :aria-label="$t('polls.create')" @click="draft.params.poll = { options: [''], expiresIn: expiresInOptions[expiresInDefaultOptionIndex].seconds }">
|
<button
|
||||||
|
btn-action-icon :aria-label="$t('polls.create')"
|
||||||
|
@click="draft.params.poll = { options: [''], expiresIn: expiresInOptions[expiresInDefaultOptionIndex].seconds }"
|
||||||
|
>
|
||||||
<div i-ri:chat-poll-line />
|
<div i-ri:chat-poll-line />
|
||||||
</button>
|
</button>
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
<div v-else rounded-full b-1 border-dark flex="~ row" gap-1>
|
<div v-else rounded-full b-1 border-dark flex="~ row" gap-1>
|
||||||
<CommonTooltip placement="top" :content="$t('polls.cancel')">
|
<CommonTooltip placement="top" :content="$t('polls.cancel')">
|
||||||
<button btn-action-icon b-r border-dark :aria-label="$t('polls.cancel')" @click="draft.params.poll = undefined">
|
<button
|
||||||
|
btn-action-icon b-r border-dark :aria-label="$t('polls.cancel')"
|
||||||
|
@click="draft.params.poll = undefined"
|
||||||
|
>
|
||||||
<div i-ri:close-line />
|
<div i-ri:close-line />
|
||||||
</button>
|
</button>
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
|
@ -416,8 +420,19 @@ onDeactivated(() => {
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
<template #popper>
|
<template #popper>
|
||||||
<div flex="~ col" gap-1 p-2>
|
<div flex="~ col" gap-1 p-2>
|
||||||
<CommonCheckbox v-model="draft.params.poll.multiple" :label="draft.params.poll.multiple ? $t('polls.disallow_multiple') : $t('polls.allow_multiple')" px-2 gap-3 h-9 flex justify-center hover:bg-active rounded-full icon-checked="i-ri:checkbox-multiple-blank-line" icon-unchecked="i-ri:checkbox-blank-circle-line" />
|
<CommonCheckbox
|
||||||
<CommonCheckbox v-model="draft.params.poll.hideTotals" :label="draft.params.poll.hideTotals ? $t('polls.show_votes') : $t('polls.hide_votes')" px-2 gap-3 h-9 flex justify-center hover:bg-active rounded-full icon-checked="i-ri:eye-close-line" icon-unchecked="i-ri:eye-line" />
|
v-model="draft.params.poll.multiple"
|
||||||
|
:label="draft.params.poll.multiple ? $t('polls.disallow_multiple') : $t('polls.allow_multiple')"
|
||||||
|
px-2 gap-3 h-9 flex justify-center hover:bg-active rounded-full
|
||||||
|
icon-checked="i-ri:checkbox-multiple-blank-line"
|
||||||
|
icon-unchecked="i-ri:checkbox-blank-circle-line"
|
||||||
|
/>
|
||||||
|
<CommonCheckbox
|
||||||
|
v-model="draft.params.poll.hideTotals"
|
||||||
|
:label="draft.params.poll.hideTotals ? $t('polls.show_votes') : $t('polls.hide_votes')" px-2 gap-3
|
||||||
|
h-9 flex justify-center hover:bg-active rounded-full icon-checked="i-ri:eye-close-line"
|
||||||
|
icon-unchecked="i-ri:eye-line"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</CommonDropdown>
|
</CommonDropdown>
|
||||||
|
@ -430,10 +445,8 @@ onDeactivated(() => {
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
<template #popper>
|
<template #popper>
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
v-for="expiresInOption in expiresInOptions"
|
v-for="expiresInOption in expiresInOptions" :key="expiresInOption.seconds"
|
||||||
:key="expiresInOption.seconds"
|
:text="expiresInOption.label" :checked="draft.params.poll!.expiresIn === expiresInOption.seconds"
|
||||||
:text="expiresInOption.label"
|
|
||||||
:checked="draft.params.poll!.expiresIn === expiresInOption.seconds"
|
|
||||||
@click="draft.params.poll!.expiresIn = expiresInOption.seconds"
|
@click="draft.params.poll!.expiresIn = expiresInOption.seconds"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
@ -470,16 +483,25 @@ onDeactivated(() => {
|
||||||
|
|
||||||
<PublishVisibilityPicker v-model="draft.params.visibility" :editing="!!draft.editingStatus">
|
<PublishVisibilityPicker v-model="draft.params.visibility" :editing="!!draft.editingStatus">
|
||||||
<template #default="{ visibility }">
|
<template #default="{ visibility }">
|
||||||
<button :disabled="!!draft.editingStatus" :aria-label="$t('tooltip.change_content_visibility')" btn-action-icon :class="{ 'w-12': !draft.editingStatus }">
|
<button
|
||||||
|
:disabled="!!draft.editingStatus" :aria-label="$t('tooltip.change_content_visibility')"
|
||||||
|
btn-action-icon :class="{ 'w-12': !draft.editingStatus }"
|
||||||
|
>
|
||||||
<div :class="visibility.icon" />
|
<div :class="visibility.icon" />
|
||||||
<div v-if="!draft.editingStatus" i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
<div v-if="!draft.editingStatus" i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</PublishVisibilityPicker>
|
</PublishVisibilityPicker>
|
||||||
|
|
||||||
<CommonTooltip v-if="failedMessages.length > 0" id="publish-failed-tooltip" placement="top" :content="$t('tooltip.publish_failed')">
|
<PublishThreadTools :draft-item-index="draftItemIndex" :draft-key="draftKey" />
|
||||||
|
|
||||||
|
<CommonTooltip
|
||||||
|
v-if="failedMessages.length > 0" id="publish-failed-tooltip" placement="top"
|
||||||
|
:content="$t('tooltip.publish_failed')"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
btn-danger rounded-3 text-sm w-full flex="~ gap1" items-center md:w-fit aria-describedby="publish-failed-tooltip"
|
btn-danger rounded-3 text-sm w-full flex="~ gap1" items-center md:w-fit
|
||||||
|
aria-describedby="publish-failed-tooltip"
|
||||||
>
|
>
|
||||||
<span block>
|
<span block>
|
||||||
<div block i-carbon:face-dizzy-filled />
|
<div block i-carbon:face-dizzy-filled />
|
||||||
|
@ -488,13 +510,14 @@ onDeactivated(() => {
|
||||||
</button>
|
</button>
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
|
|
||||||
<CommonTooltip v-else id="publish-tooltip" placement="top" :content="$t('tooltip.add_publishable_content')" :disabled="!(isPublishDisabled || isExceedingCharacterLimit)">
|
<CommonTooltip
|
||||||
|
v-else id="publish-tooltip" placement="top" :content="$t('tooltip.add_publishable_content')"
|
||||||
|
:disabled="!(isPublishDisabled || isExceedingCharacterLimit)"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
btn-solid rounded-3 text-sm w-full flex="~ gap1" items-center
|
v-if="!threadIsActive || isFinalItemOfThread"
|
||||||
md:w-fit
|
btn-solid rounded-3 text-sm w-full flex="~ gap1" items-center md:w-fit class="publish-button"
|
||||||
class="publish-button"
|
:aria-disabled="isPublishDisabled || isExceedingCharacterLimit" aria-describedby="publish-tooltip"
|
||||||
:aria-disabled="isPublishDisabled || isExceedingCharacterLimit"
|
|
||||||
aria-describedby="publish-tooltip"
|
|
||||||
@click="publish"
|
@click="publish"
|
||||||
>
|
>
|
||||||
<span v-if="isSending" block animate-spin preserve-3d>
|
<span v-if="isSending" block animate-spin preserve-3d>
|
||||||
|
@ -503,39 +526,47 @@ onDeactivated(() => {
|
||||||
<span v-if="failedMessages.length" block>
|
<span v-if="failedMessages.length" block>
|
||||||
<div block i-carbon:face-dizzy-filled />
|
<div block i-carbon:face-dizzy-filled />
|
||||||
</span>
|
</span>
|
||||||
|
<template v-if="threadIsActive">
|
||||||
|
<span>{{ $t('action.publish_thread') }} </span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<span v-if="draft.editingStatus">{{ $t('action.save_changes') }}</span>
|
<span v-if="draft.editingStatus">{{ $t('action.save_changes') }}</span>
|
||||||
<span v-else-if="draft.params.inReplyToId">{{ $t('action.reply') }}</span>
|
<span v-else-if="draft.params.inReplyToId">{{ $t('action.reply') }}</span>
|
||||||
<span v-else>{{ !isSending ? $t('action.publish') : $t('state.publishing') }}</span>
|
<span v-else>{{ !isSending ? $t('action.publish') : $t('state.publishing') }}</span>
|
||||||
|
</template>
|
||||||
</button>
|
</button>
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.publish-button[aria-disabled=true] {
|
.publish-button[aria-disabled=true] {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
background-color: var(--c-bg-btn-disabled);
|
background-color: var(--c-bg-btn-disabled);
|
||||||
color: var(--c-text-btn-disabled);
|
color: var(--c-text-btn-disabled);
|
||||||
}
|
}
|
||||||
.publish-button[aria-disabled=true]:hover {
|
|
||||||
|
.publish-button[aria-disabled=true]:hover {
|
||||||
background-color: var(--c-bg-btn-disabled);
|
background-color: var(--c-bg-btn-disabled);
|
||||||
color: var(--c-text-btn-disabled);
|
color: var(--c-text-btn-disabled);
|
||||||
}
|
}
|
||||||
.option-input:focus + .delete-button {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-input:not(:focus) + .delete-button + .char-limit-radial {
|
.option-input:focus+.delete-button {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.char-limit-radial {
|
.option-input:not(:focus)+.delete-button+.char-limit-radial {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-limit-radial {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatTimeAgo } from '@vueuse/core'
|
import { formatTimeAgo } from '@vueuse/core'
|
||||||
|
import type { DraftItem } from '~/types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { formatNumber } = useHumanReadableNumber()
|
const { formatNumber } = useHumanReadableNumber()
|
||||||
|
@ -20,35 +21,39 @@ watchEffect(() => {
|
||||||
onDeactivated(() => {
|
onDeactivated(() => {
|
||||||
clearEmptyDrafts()
|
clearEmptyDrafts()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function firstDraftItemOf(drafts: DraftItem | Array<DraftItem>): DraftItem {
|
||||||
|
if (Array.isArray(drafts))
|
||||||
|
return drafts[0]
|
||||||
|
return drafts
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div flex="~ col" pt-6 h-screen>
|
<div flex="~ col" pb-6>
|
||||||
<div inline-flex justify-end h-8>
|
<div inline-flex justify-end h-8>
|
||||||
<VDropdown v-if="nonEmptyDrafts.length" placement="bottom-end">
|
<VDropdown v-if="nonEmptyDrafts.length" placement="bottom-end">
|
||||||
<button btn-text flex="inline center">
|
<button btn-text flex="inline center">
|
||||||
{{ $t('compose.drafts', nonEmptyDrafts.length, { named: { v: formatNumber(nonEmptyDrafts.length) } }) }} <div aria-hidden="true" i-ri:arrow-down-s-line />
|
{{ $t('compose.drafts', nonEmptyDrafts.length, { named: { v: formatNumber(nonEmptyDrafts.length) } }) }} 
|
||||||
|
<div aria-hidden="true" i-ri:arrow-down-s-line />
|
||||||
</button>
|
</button>
|
||||||
<template #popper="{ hide }">
|
<template #popper="{ hide }">
|
||||||
<div flex="~ col">
|
<div flex="~ col">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="[key, draft] of nonEmptyDrafts" :key="key"
|
v-for="[key, drafts] of nonEmptyDrafts" :key="key" border="b base" text-left py2 px4
|
||||||
border="b base" text-left py2 px4 hover:bg-active
|
hover:bg-active :replace="true" :to="`/compose?draft=${encodeURIComponent(key)}`" @click="hide()"
|
||||||
:replace="true"
|
|
||||||
:to="`/compose?draft=${encodeURIComponent(key)}`"
|
|
||||||
@click="hide()"
|
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div flex="~ gap-1" items-center>
|
<div flex="~ gap-1" items-center>
|
||||||
<i18n-t keypath="compose.draft_title">
|
<i18n-t keypath="compose.draft_title">
|
||||||
<code>{{ key }}</code>
|
<code>{{ key }}</code>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
<span v-if="draft.lastUpdated" text-secondary text-sm>
|
<span v-if="firstDraftItemOf(drafts).lastUpdated" text-secondary text-sm>
|
||||||
· {{ formatTimeAgo(new Date(draft.lastUpdated), timeAgoOptions) }}
|
· {{ formatTimeAgo(new Date(firstDraftItemOf(drafts).lastUpdated), timeAgoOptions) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div text-secondary>
|
<div text-secondary>
|
||||||
{{ htmlToText(draft.params.status).slice(0, 50) }}
|
{{ htmlToText(firstDraftItemOf(drafts).params.status).slice(0, 50) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
@ -57,7 +62,7 @@ onDeactivated(() => {
|
||||||
</VDropdown>
|
</VDropdown>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<PublishWidget :key="draftKey" expanded class="min-h-100!" :draft-key="draftKey" />
|
<PublishWidgetList expanded class="min-h-100!" :draft-key="draftKey" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
49
components/publish/PublishWidgetList.vue
Normal file
49
components/publish/PublishWidgetList.vue
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { mastodon } from 'masto'
|
||||||
|
import type { DraftItem } from '~/types'
|
||||||
|
|
||||||
|
const {
|
||||||
|
draftKey,
|
||||||
|
initial = getDefaultDraftItem,
|
||||||
|
expanded = false,
|
||||||
|
placeholder,
|
||||||
|
dialogLabelledBy,
|
||||||
|
inReplyToId,
|
||||||
|
inReplyToVisibility,
|
||||||
|
} = defineProps<{
|
||||||
|
draftKey: string
|
||||||
|
initial?: () => DraftItem
|
||||||
|
placeholder?: string
|
||||||
|
inReplyToId?: string
|
||||||
|
inReplyToVisibility?: mastodon.v1.StatusVisibility
|
||||||
|
expanded?: boolean
|
||||||
|
dialogLabelledBy?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const threadItems = computed(() =>
|
||||||
|
useThreadComposer(draftKey, initial).threadItems.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
clearEmptyDrafts()
|
||||||
|
})
|
||||||
|
|
||||||
|
function isFirstItem(index: number) {
|
||||||
|
return index === 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<template v-if="isHydrated && currentUser">
|
||||||
|
<PublishWidget
|
||||||
|
v-for="(_, index) in threadItems" :key="`${draftKey}-${index}`"
|
||||||
|
:draft-key="draftKey"
|
||||||
|
:draft-item-index="index"
|
||||||
|
:expanded="isFirstItem(index) ? expanded : true"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:dialog-labelled-by="dialogLabelledBy"
|
||||||
|
:in-reply-to-id="isFirstItem(index) ? inReplyToId : undefined"
|
||||||
|
:in-reply-to-visibility="inReplyToVisibility"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
|
@ -10,7 +10,8 @@ function reorderAndFilter(items: mastodon.v1.Status[]) {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<PublishWidget draft-key="home" border="b base" />
|
<PublishWidgetList draft-key="home" />
|
||||||
|
<div h="1px" w-auto bg-border mb-3 />
|
||||||
<TimelinePaginator v-bind="{ paginator, stream }" :preprocess="reorderAndFilter" context="home" />
|
<TimelinePaginator v-bind="{ paginator, stream }" :preprocess="reorderAndFilter" context="home" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
import type { ConfirmDialogChoice, ConfirmDialogOptions, Draft, ErrorDialogData } from '~/types'
|
import type { ConfirmDialogChoice, ConfirmDialogOptions, DraftItem, ErrorDialogData } from '~/types'
|
||||||
import { STORAGE_KEY_FIRST_VISIT } from '~/constants'
|
import { STORAGE_KEY_FIRST_VISIT } from '~/constants'
|
||||||
|
|
||||||
export const confirmDialogChoice = ref<ConfirmDialogChoice>()
|
export const confirmDialogChoice = ref<ConfirmDialogChoice>()
|
||||||
|
@ -49,7 +49,7 @@ export async function openConfirmDialog(label: ConfirmDialogOptions | string): P
|
||||||
return confirmDialogChoice.value!
|
return confirmDialogChoice.value!
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openPublishDialog(draftKey = 'dialog', draft?: Draft, overwrite = false): Promise<void> {
|
export async function openPublishDialog(draftKey = 'dialog', draft?: DraftItem, overwrite = false): Promise<void> {
|
||||||
dialogDraftKey.value = draftKey
|
dialogDraftKey.value = draftKey
|
||||||
|
|
||||||
if (draft) {
|
if (draft) {
|
||||||
|
@ -65,7 +65,7 @@ export async function openPublishDialog(draftKey = 'dialog', draft?: Draft, over
|
||||||
}
|
}
|
||||||
|
|
||||||
if (overwrite || !currentUserDrafts.value[draftKey])
|
if (overwrite || !currentUserDrafts.value[draftKey])
|
||||||
currentUserDrafts.value[draftKey] = draft
|
currentUserDrafts.value[draftKey] = [draft]
|
||||||
}
|
}
|
||||||
isPublishDialogOpen.value = true
|
isPublishDialogOpen.value = true
|
||||||
|
|
||||||
|
|
|
@ -1,16 +1,19 @@
|
||||||
import { fileOpen } from 'browser-fs-access'
|
import { fileOpen } from 'browser-fs-access'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
import type { UseDraft } from './statusDrafts'
|
import type { DraftItem } from '~~/types'
|
||||||
import type { Draft } from '~~/types'
|
|
||||||
|
|
||||||
export function usePublish(options: {
|
export function usePublish(options: {
|
||||||
draftState: UseDraft
|
draftItem: Ref<DraftItem>
|
||||||
expanded: Ref<boolean>
|
expanded: Ref<boolean>
|
||||||
isUploading: Ref<boolean>
|
isUploading: Ref<boolean>
|
||||||
initialDraft: Ref<() => Draft>
|
isPartOfThread: boolean
|
||||||
|
initialDraft: () => DraftItem
|
||||||
}) {
|
}) {
|
||||||
const { draft, isEmpty } = options.draftState
|
const { draftItem } = options
|
||||||
|
|
||||||
|
const isEmpty = computed(() => isEmptyDraft([draftItem.value]))
|
||||||
|
|
||||||
const { client } = useMasto()
|
const { client } = useMasto()
|
||||||
const settings = useUserSettings()
|
const settings = useUserSettings()
|
||||||
|
|
||||||
|
@ -22,18 +25,18 @@ export function usePublish(options: {
|
||||||
|
|
||||||
const publishSpoilerText = computed({
|
const publishSpoilerText = computed({
|
||||||
get() {
|
get() {
|
||||||
return draft.value.params.sensitive ? draft.value.params.spoilerText : ''
|
return draftItem.value.params.sensitive ? draftItem.value.params.spoilerText : ''
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
if (!draft.value.params.sensitive)
|
if (!draftItem.value.params.sensitive)
|
||||||
return
|
return
|
||||||
draft.value.params.spoilerText = val
|
draftItem.value.params.spoilerText = val
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const shouldExpanded = computed(() => options.expanded.value || isExpanded.value || !isEmpty.value)
|
const shouldExpanded = computed(() => options.expanded.value || isExpanded.value || !isEmpty.value)
|
||||||
const isPublishDisabled = computed(() => {
|
const isPublishDisabled = computed(() => {
|
||||||
const { params, attachments } = draft.value
|
const { params, attachments } = draftItem.value
|
||||||
const firstEmptyInputIndex = params.poll?.options.findIndex(option => option.trim().length === 0)
|
const firstEmptyInputIndex = params.poll?.options.findIndex(option => option.trim().length === 0)
|
||||||
return isEmpty.value
|
return isEmpty.value
|
||||||
|| options.isUploading.value
|
|| options.isUploading.value
|
||||||
|
@ -54,7 +57,7 @@ export function usePublish(options: {
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(draft, () => {
|
watch(draftItem, () => {
|
||||||
if (failedMessages.value.length > 0)
|
if (failedMessages.value.length > 0)
|
||||||
failedMessages.value.length = 0
|
failedMessages.value.length = 0
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
@ -63,14 +66,14 @@ export function usePublish(options: {
|
||||||
if (isPublishDisabled.value)
|
if (isPublishDisabled.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
let content = htmlToText(draft.value.params.status || '')
|
let content = htmlToText(draftItem.value.params.status || '')
|
||||||
if (draft.value.mentions?.length)
|
if (draftItem.value.mentions?.length)
|
||||||
content = `${draft.value.mentions.map(i => `@${i}`).join(' ')} ${content}`
|
content = `${draftItem.value.mentions.map(i => `@${i}`).join(' ')} ${content}`
|
||||||
|
|
||||||
let poll
|
let poll
|
||||||
|
|
||||||
if (draft.value.params.poll) {
|
if (draftItem.value.params.poll) {
|
||||||
let options = draft.value.params.poll.options
|
let options = draftItem.value.params.poll.options
|
||||||
|
|
||||||
if (currentInstance.value?.configuration !== undefined
|
if (currentInstance.value?.configuration !== undefined
|
||||||
&& (
|
&& (
|
||||||
|
@ -80,15 +83,15 @@ export function usePublish(options: {
|
||||||
)
|
)
|
||||||
options = options.slice(0, options.length - 1)
|
options = options.slice(0, options.length - 1)
|
||||||
|
|
||||||
poll = { ...draft.value.params.poll, options }
|
poll = { ...draftItem.value.params.poll, options }
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...draft.value.params,
|
...draftItem.value.params,
|
||||||
spoilerText: publishSpoilerText.value,
|
spoilerText: publishSpoilerText.value,
|
||||||
status: content,
|
status: content,
|
||||||
mediaIds: draft.value.attachments.map(a => a.id),
|
mediaIds: draftItem.value.attachments.map(a => a.id),
|
||||||
language: draft.value.params.language || preferredLanguage.value,
|
language: draftItem.value.params.language || preferredLanguage.value,
|
||||||
poll,
|
poll,
|
||||||
...(isGlitchEdition.value ? { 'content-type': 'text/markdown' } : {}),
|
...(isGlitchEdition.value ? { 'content-type': 'text/markdown' } : {}),
|
||||||
} as mastodon.rest.v1.CreateStatusParams
|
} as mastodon.rest.v1.CreateStatusParams
|
||||||
|
@ -96,7 +99,7 @@ export function usePublish(options: {
|
||||||
if (import.meta.dev) {
|
if (import.meta.dev) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.info({
|
console.info({
|
||||||
raw: draft.value.params.status,
|
raw: draftItem.value.params.status,
|
||||||
...payload,
|
...payload,
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line no-alert
|
// eslint-disable-next-line no-alert
|
||||||
|
@ -109,23 +112,23 @@ export function usePublish(options: {
|
||||||
isSending.value = true
|
isSending.value = true
|
||||||
|
|
||||||
let status: mastodon.v1.Status
|
let status: mastodon.v1.Status
|
||||||
if (!draft.value.editingStatus) {
|
if (!draftItem.value.editingStatus) {
|
||||||
status = await client.value.v1.statuses.create(payload)
|
status = await client.value.v1.statuses.create(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
else {
|
else {
|
||||||
status = await client.value.v1.statuses.$select(draft.value.editingStatus.id).update({
|
status = await client.value.v1.statuses.$select(draftItem.value.editingStatus.id).update({
|
||||||
...payload,
|
...payload,
|
||||||
mediaAttributes: draft.value.attachments.map(media => ({
|
mediaAttributes: draftItem.value.attachments.map(media => ({
|
||||||
id: media.id,
|
id: media.id,
|
||||||
description: media.description,
|
description: media.description,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (draft.value.params.inReplyToId)
|
if (draftItem.value.params.inReplyToId && !options.isPartOfThread)
|
||||||
navigateToStatus({ status })
|
navigateToStatus({ status })
|
||||||
|
|
||||||
draft.value = options.initialDraft.value()
|
draftItem.value = options.initialDraft()
|
||||||
|
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
@ -152,7 +155,7 @@ export function usePublish(options: {
|
||||||
|
|
||||||
export type MediaAttachmentUploadError = [filename: string, message: string]
|
export type MediaAttachmentUploadError = [filename: string, message: string]
|
||||||
|
|
||||||
export function useUploadMediaAttachment(draft: Ref<Draft>) {
|
export function useUploadMediaAttachment(draft: Ref<DraftItem>) {
|
||||||
const { client } = useMasto()
|
const { client } = useMasto()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { formatFileSize } = useFileSizeFormatter()
|
const { formatFileSize } = useFileSizeFormatter()
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
import type { ComputedRef, Ref } from 'vue'
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
import { STORAGE_KEY_DRAFTS } from '~/constants'
|
import { STORAGE_KEY_DRAFTS } from '~/constants'
|
||||||
import type { Draft, DraftMap } from '~/types'
|
import type { DraftItem, DraftMap } from '~/types'
|
||||||
import type { Mutable } from '~/types/utils'
|
import type { Mutable } from '~/types/utils'
|
||||||
|
|
||||||
export const currentUserDrafts = (import.meta.server || process.test)
|
export const currentUserDrafts = (import.meta.server || process.test)
|
||||||
|
@ -25,7 +25,7 @@ function getDefaultVisibility(currentVisibility: mastodon.v1.StatusVisibility) {
|
||||||
: preferredVisibility
|
: preferredVisibility
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultDraft(options: Partial<Mutable<mastodon.rest.v1.CreateStatusParams> & Omit<Draft, 'params'>> = {}): Draft {
|
export function getDefaultDraftItem(options: Partial<Mutable<mastodon.rest.v1.CreateStatusParams> & Omit<DraftItem, 'params'>> = {}): DraftItem {
|
||||||
const {
|
const {
|
||||||
attachments = [],
|
attachments = [],
|
||||||
initialText = '',
|
initialText = '',
|
||||||
|
@ -56,7 +56,7 @@ export function getDefaultDraft(options: Partial<Mutable<mastodon.rest.v1.Create
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDraftFromStatus(status: mastodon.v1.Status): Promise<Draft> {
|
export async function getDraftFromStatus(status: mastodon.v1.Status): Promise<DraftItem> {
|
||||||
const info = {
|
const info = {
|
||||||
status: await convertMastodonHTML(status.content),
|
status: await convertMastodonHTML(status.content),
|
||||||
visibility: status.visibility,
|
visibility: status.visibility,
|
||||||
|
@ -67,7 +67,7 @@ export async function getDraftFromStatus(status: mastodon.v1.Status): Promise<Dr
|
||||||
inReplyToId: status.inReplyToId,
|
inReplyToId: status.inReplyToId,
|
||||||
}
|
}
|
||||||
|
|
||||||
return getDefaultDraft((status.mediaAttachments !== undefined && status.mediaAttachments.length > 0)
|
return getDefaultDraftItem((status.mediaAttachments !== undefined && status.mediaAttachments.length > 0)
|
||||||
? { ...info, mediaIds: status.mediaAttachments.map(att => att.id) }
|
? { ...info, mediaIds: status.mediaAttachments.map(att => att.id) }
|
||||||
: {
|
: {
|
||||||
...info,
|
...info,
|
||||||
|
@ -99,7 +99,7 @@ export function getReplyDraft(status: mastodon.v1.Status) {
|
||||||
return {
|
return {
|
||||||
key: `reply-${status.id}`,
|
key: `reply-${status.id}`,
|
||||||
draft: () => {
|
draft: () => {
|
||||||
return getDefaultDraft({
|
return getDefaultDraftItem({
|
||||||
initialText: '',
|
initialText: '',
|
||||||
inReplyToId: status!.id,
|
inReplyToId: status!.id,
|
||||||
sensitive: status.sensitive,
|
sensitive: status.sensitive,
|
||||||
|
@ -112,40 +112,51 @@ export function getReplyDraft(status: mastodon.v1.Status) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isEmptyDraft(draft: Draft | null | undefined) {
|
export function isEmptyDraft(drafts: Array<DraftItem> | DraftItem | null | undefined) {
|
||||||
if (!draft)
|
if (!drafts)
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
const draftsArray: Array<DraftItem> = Array.isArray(drafts) ? drafts : [drafts]
|
||||||
|
|
||||||
|
if (draftsArray.length === 0)
|
||||||
|
return true
|
||||||
|
|
||||||
|
const anyDraftHasContent = draftsArray.some((draft) => {
|
||||||
const { params, attachments } = draft
|
const { params, attachments } = draft
|
||||||
const status = params.status || ''
|
const status = params.status ?? ''
|
||||||
const text = htmlToText(status).trim().replace(/^(@\S+\s?)+/, '').replaceAll(/```/g, '').trim()
|
const text = htmlToText(status).trim().replace(/^(@\S+\s?)+/, '').replaceAll(/```/g, '').trim()
|
||||||
|
|
||||||
return (text.length === 0)
|
return (text.length > 0)
|
||||||
&& attachments.length === 0
|
|| (attachments.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
return !anyDraftHasContent
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseDraft {
|
export interface UseDraft {
|
||||||
draft: Ref<Draft>
|
draftItems: Ref<Array<DraftItem>>
|
||||||
isEmpty: ComputedRef<boolean>
|
isEmpty: ComputedRef<boolean> | Ref<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDraft(
|
export function useDraft(
|
||||||
draftKey?: string,
|
draftKey: string,
|
||||||
initial: () => Draft = () => getDefaultDraft({}),
|
initial: () => DraftItem = () => getDefaultDraftItem({}),
|
||||||
): UseDraft {
|
): UseDraft {
|
||||||
const draft = draftKey
|
const draftItems = computed({
|
||||||
? computed({
|
|
||||||
get() {
|
get() {
|
||||||
if (!currentUserDrafts.value[draftKey])
|
if (!currentUserDrafts.value[draftKey])
|
||||||
currentUserDrafts.value[draftKey] = initial()
|
currentUserDrafts.value[draftKey] = [initial()]
|
||||||
return currentUserDrafts.value[draftKey]
|
const drafts = currentUserDrafts.value[draftKey]
|
||||||
|
if (Array.isArray(drafts))
|
||||||
|
return drafts
|
||||||
|
return [drafts]
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
currentUserDrafts.value[draftKey] = val
|
currentUserDrafts.value[draftKey] = val
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: ref(initial())
|
|
||||||
|
|
||||||
const isEmpty = computed(() => isEmptyDraft(draft.value))
|
const isEmpty = computed(() => isEmptyDraft(draftItems.value))
|
||||||
|
|
||||||
onUnmounted(async () => {
|
onUnmounted(async () => {
|
||||||
// Remove draft if it's empty
|
// Remove draft if it's empty
|
||||||
|
@ -155,17 +166,17 @@ export function useDraft(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return { draft, isEmpty }
|
return { draftItems, isEmpty }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mentionUser(account: mastodon.v1.Account) {
|
export function mentionUser(account: mastodon.v1.Account) {
|
||||||
openPublishDialog('dialog', getDefaultDraft({
|
openPublishDialog('dialog', getDefaultDraftItem({
|
||||||
status: `@${account.acct} `,
|
status: `@${account.acct} `,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function directMessageUser(account: mastodon.v1.Account) {
|
export function directMessageUser(account: mastodon.v1.Account) {
|
||||||
openPublishDialog('dialog', getDefaultDraft({
|
openPublishDialog('dialog', getDefaultDraftItem({
|
||||||
status: `@${account.acct} `,
|
status: `@${account.acct} `,
|
||||||
visibility: 'direct',
|
visibility: 'direct',
|
||||||
}))
|
}))
|
||||||
|
@ -175,7 +186,7 @@ export function clearEmptyDrafts() {
|
||||||
for (const key in currentUserDrafts.value) {
|
for (const key in currentUserDrafts.value) {
|
||||||
if (builtinDraftKeys.includes(key) && !isEmptyDraft(currentUserDrafts.value[key]))
|
if (builtinDraftKeys.includes(key) && !isEmptyDraft(currentUserDrafts.value[key]))
|
||||||
continue
|
continue
|
||||||
if (!currentUserDrafts.value[key].params || isEmptyDraft(currentUserDrafts.value[key]))
|
if (isEmptyDraft(currentUserDrafts.value[key]))
|
||||||
delete currentUserDrafts.value[key]
|
delete currentUserDrafts.value[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
94
composables/thread.ts
Normal file
94
composables/thread.ts
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import type { mastodon } from 'masto'
|
||||||
|
import type { DraftItem } from '~/types'
|
||||||
|
|
||||||
|
const maxThreadLength = 99
|
||||||
|
|
||||||
|
export function useThreadComposer(draftKey: string, initial?: () => DraftItem) {
|
||||||
|
const { draftItems } = useDraft(draftKey, initial)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the thread is active (has more than one item)
|
||||||
|
*/
|
||||||
|
const threadIsActive = computed<boolean>(() => draftItems.value.length > 1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an item to the thread
|
||||||
|
*/
|
||||||
|
function addThreadItem() {
|
||||||
|
if (draftItems.value.length >= maxThreadLength) {
|
||||||
|
// TODO handle with error message that tells the user what's wrong
|
||||||
|
// For now just fail silently without breaking anything
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastItem = draftItems.value[draftItems.value.length - 1]
|
||||||
|
draftItems.value.push(getDefaultDraftItem({
|
||||||
|
language: lastItem.params.language,
|
||||||
|
sensitive: lastItem.params.sensitive,
|
||||||
|
spoilerText: lastItem.params.spoilerText,
|
||||||
|
visibility: lastItem.params.visibility,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param index index of the draft to remove from the thread
|
||||||
|
*/
|
||||||
|
function removeThreadItem(index: number) {
|
||||||
|
draftItems.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish all items in the thread in order
|
||||||
|
*/
|
||||||
|
async function publishThread() {
|
||||||
|
const allFailedMessages: Array<string> = []
|
||||||
|
const isAReplyThread = Boolean(draftItems.value[0].params.inReplyToId)
|
||||||
|
|
||||||
|
let lastPublishedStatus: mastodon.v1.Status | null = null
|
||||||
|
let amountPublished = 0
|
||||||
|
for (const draftItem of draftItems.value) {
|
||||||
|
if (lastPublishedStatus)
|
||||||
|
draftItem.params.inReplyToId = lastPublishedStatus.id
|
||||||
|
|
||||||
|
const { publishDraft, failedMessages } = usePublish({
|
||||||
|
draftItem: ref(draftItem),
|
||||||
|
expanded: computed(() => true),
|
||||||
|
isUploading: ref(false),
|
||||||
|
initialDraft: () => draftItem,
|
||||||
|
isPartOfThread: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = await publishDraft()
|
||||||
|
if (status) {
|
||||||
|
lastPublishedStatus = status
|
||||||
|
amountPublished++
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
allFailedMessages.push(...failedMessages.value)
|
||||||
|
// Stop publishing if one fails
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove all published items from the thread
|
||||||
|
draftItems.value.splice(0, amountPublished)
|
||||||
|
|
||||||
|
// If we have errors, return them
|
||||||
|
if (allFailedMessages.length > 0)
|
||||||
|
return allFailedMessages
|
||||||
|
|
||||||
|
// If the thread was a reply and all items were published, jump to it
|
||||||
|
if (isAReplyThread && lastPublishedStatus && draftItems.value.length === 0)
|
||||||
|
navigateToStatus({ status: lastPublishedStatus })
|
||||||
|
|
||||||
|
return lastPublishedStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
threadItems: draftItems,
|
||||||
|
threadIsActive,
|
||||||
|
addThreadItem,
|
||||||
|
removeThreadItem,
|
||||||
|
publishThread,
|
||||||
|
}
|
||||||
|
}
|
|
@ -66,6 +66,7 @@
|
||||||
"next": "Nächster",
|
"next": "Nächster",
|
||||||
"prev": "Vorheriger",
|
"prev": "Vorheriger",
|
||||||
"publish": "Veröffentlichen",
|
"publish": "Veröffentlichen",
|
||||||
|
"publish_thread": "Thread veröffentlichen",
|
||||||
"reply": "Antworten",
|
"reply": "Antworten",
|
||||||
"reply_count": "{0}",
|
"reply_count": "{0}",
|
||||||
"reset": "Zurücksetzen",
|
"reset": "Zurücksetzen",
|
||||||
|
@ -668,6 +669,7 @@
|
||||||
"add_emojis": "Emojis hinzufügen",
|
"add_emojis": "Emojis hinzufügen",
|
||||||
"add_media": "Bilder, ein Video oder eine Audiodatei hinzufügen",
|
"add_media": "Bilder, ein Video oder eine Audiodatei hinzufügen",
|
||||||
"add_publishable_content": "Füge Inhalte zum Veröffentlichen hinzu",
|
"add_publishable_content": "Füge Inhalte zum Veröffentlichen hinzu",
|
||||||
|
"add_thread_item": "Weiteres Element zum Thread hinzufügen",
|
||||||
"change_content_visibility": "Sichtbarkeit von Inhalten ändern",
|
"change_content_visibility": "Sichtbarkeit von Inhalten ändern",
|
||||||
"change_language": "Sprache ändern",
|
"change_language": "Sprache ändern",
|
||||||
"emoji": "Emoji",
|
"emoji": "Emoji",
|
||||||
|
@ -677,6 +679,8 @@
|
||||||
"open_editor_tools": "Bearbeitungswerkzeuge",
|
"open_editor_tools": "Bearbeitungswerkzeuge",
|
||||||
"pick_an_icon": "Wähle ein Symbol",
|
"pick_an_icon": "Wähle ein Symbol",
|
||||||
"publish_failed": "Schließe fehlgeschlagene Nachrichten oben im Editor, um Beiträge erneut zu veröffentlichen",
|
"publish_failed": "Schließe fehlgeschlagene Nachrichten oben im Editor, um Beiträge erneut zu veröffentlichen",
|
||||||
|
"remove_thread_item": "Element aus dem Thread entfernen",
|
||||||
|
"start_thread": "Thread starten",
|
||||||
"toggle_bold": "Fett darstellen umschalten",
|
"toggle_bold": "Fett darstellen umschalten",
|
||||||
"toggle_code_block": "Codeblock umschalten",
|
"toggle_code_block": "Codeblock umschalten",
|
||||||
"toggle_italic": "Kursiv umschalten"
|
"toggle_italic": "Kursiv umschalten"
|
||||||
|
|
|
@ -74,6 +74,7 @@
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"prev": "Prev",
|
"prev": "Prev",
|
||||||
"publish": "Publish",
|
"publish": "Publish",
|
||||||
|
"publish_thread": "Publish thread",
|
||||||
"reply": "Reply",
|
"reply": "Reply",
|
||||||
"reply_count": "{0}",
|
"reply_count": "{0}",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
|
@ -718,6 +719,7 @@
|
||||||
"add_emojis": "Add emojis",
|
"add_emojis": "Add emojis",
|
||||||
"add_media": "Add images, a video or an audio file",
|
"add_media": "Add images, a video or an audio file",
|
||||||
"add_publishable_content": "Add content to publish",
|
"add_publishable_content": "Add content to publish",
|
||||||
|
"add_thread_item": "Add item to thread",
|
||||||
"change_content_visibility": "Change content visibility",
|
"change_content_visibility": "Change content visibility",
|
||||||
"change_language": "Change language",
|
"change_language": "Change language",
|
||||||
"emoji": "Emoji",
|
"emoji": "Emoji",
|
||||||
|
@ -727,6 +729,8 @@
|
||||||
"open_editor_tools": "Editor tools",
|
"open_editor_tools": "Editor tools",
|
||||||
"pick_an_icon": "Pick an icon",
|
"pick_an_icon": "Pick an icon",
|
||||||
"publish_failed": "Close failed messages at the top of editor to republish posts",
|
"publish_failed": "Close failed messages at the top of editor to republish posts",
|
||||||
|
"remove_thread_item": "Remove item from thread",
|
||||||
|
"start_thread": "Start thread",
|
||||||
"toggle_bold": "Toggle bold",
|
"toggle_bold": "Toggle bold",
|
||||||
"toggle_code_block": "Toggle code block",
|
"toggle_code_block": "Toggle code block",
|
||||||
"toggle_italic": "Toggle italic"
|
"toggle_italic": "Toggle italic"
|
||||||
|
|
|
@ -85,7 +85,7 @@ onReactivated(() => {
|
||||||
style="scroll-margin-top: 60px"
|
style="scroll-margin-top: 60px"
|
||||||
@refetch-status="refreshStatus()"
|
@refetch-status="refreshStatus()"
|
||||||
/>
|
/>
|
||||||
<PublishWidget
|
<PublishWidgetList
|
||||||
v-if="currentUser"
|
v-if="currentUser"
|
||||||
ref="publishWidget"
|
ref="publishWidget"
|
||||||
border="y base"
|
border="y base"
|
||||||
|
|
|
@ -11,5 +11,13 @@ useHydratedHead({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<MainContent>
|
||||||
|
<template #title>
|
||||||
|
<NuxtLink to="/compose" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
||||||
|
<div i-ri:quill-pen-line />
|
||||||
|
<span>{{ $t('nav.compose') }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
<PublishWidgetFull />
|
<PublishWidgetFull />
|
||||||
|
</MainContent>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -6,7 +6,7 @@ const route = useRoute()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// TODO: login check
|
// TODO: login check
|
||||||
await openPublishDialog('intent', getDefaultDraft({
|
await openPublishDialog('intent', getDefaultDraftItem({
|
||||||
status: route.query.text as string,
|
status: route.query.text as string,
|
||||||
sensitive: route.query.sensitive === 'true' || route.query.sensitive === null,
|
sensitive: route.query.sensitive === 'true' || route.query.sensitive === null,
|
||||||
spoilerText: route.query.spoiler_text as string,
|
spoilerText: route.query.spoiler_text as string,
|
||||||
|
|
|
@ -31,7 +31,7 @@ export default defineNuxtPlugin(({ $scrollToTop }) => {
|
||||||
// TODO: bugfix -> create PR for vueuse, reset `current` ref on window focus|blur
|
// TODO: bugfix -> create PR for vueuse, reset `current` ref on window focus|blur
|
||||||
if (!current.has('shift') && !current.has('meta') && !current.has('control') && !current.has('alt')) {
|
if (!current.has('shift') && !current.has('meta') && !current.has('control') && !current.has('alt')) {
|
||||||
// TODO: is this the correct way of using openPublishDialog()?
|
// TODO: is this the correct way of using openPublishDialog()?
|
||||||
openPublishDialog('dialog', getDefaultDraft())
|
openPublishDialog('dialog', getDefaultDraftItem())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
whenever(logicAnd(isAuthenticated, notUsingInput, keys.c), defaultPublishDialog)
|
whenever(logicAnd(isAuthenticated, notUsingInput, keys.c), defaultPublishDialog)
|
||||||
|
|
|
@ -45,7 +45,7 @@ export type NotificationSlot = GroupedNotifications | GroupedLikeNotifications |
|
||||||
|
|
||||||
export type TranslateFn = ReturnType<typeof useI18n>['t']
|
export type TranslateFn = ReturnType<typeof useI18n>['t']
|
||||||
|
|
||||||
export interface Draft {
|
export interface DraftItem {
|
||||||
editingStatus?: mastodon.v1.Status
|
editingStatus?: mastodon.v1.Status
|
||||||
initialText?: string
|
initialText?: string
|
||||||
params: MarkNonNullable<Mutable<Omit<mastodon.rest.v1.CreateStatusParams, 'poll'>>, 'status' | 'language' | 'sensitive' | 'spoilerText' | 'visibility'> & { poll: Mutable<mastodon.rest.v1.CreateStatusParams['poll']> }
|
params: MarkNonNullable<Mutable<Omit<mastodon.rest.v1.CreateStatusParams, 'poll'>>, 'status' | 'language' | 'sensitive' | 'spoilerText' | 'visibility'> & { poll: Mutable<mastodon.rest.v1.CreateStatusParams['poll']> }
|
||||||
|
@ -54,7 +54,9 @@ export interface Draft {
|
||||||
mentions?: string[]
|
mentions?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DraftMap = Record<string, Draft>
|
export type DraftMap = Record<string, Array<DraftItem>
|
||||||
|
// For backward compatibility we need to support single draft items
|
||||||
|
| DraftItem>
|
||||||
|
|
||||||
export interface ConfirmDialogOptions {
|
export interface ConfirmDialogOptions {
|
||||||
title: string
|
title: string
|
||||||
|
|
Loading…
Reference in a new issue