refactor(publish): extract to composables
parent
df37e7c4de
commit
0ef99f2c8e
|
@ -1,16 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { mastodon } from 'masto'
|
|
||||||
import { fileOpen } from 'browser-fs-access'
|
|
||||||
import { useDropZone } from '@vueuse/core'
|
|
||||||
import { EditorContent } from '@tiptap/vue-3'
|
import { EditorContent } from '@tiptap/vue-3'
|
||||||
|
import type { mastodon } from 'masto'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
import type { Draft } from '~/types'
|
import type { Draft } from '~/types'
|
||||||
|
|
||||||
type FileUploadError = [filename: string, message: string]
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
draftKey,
|
draftKey,
|
||||||
initial = getDefaultDraft() as never /* Bug of vue-core */,
|
initial = getDefaultDraft() as never /* Bug of vue-core */,
|
||||||
expanded: _expanded = false,
|
expanded = false,
|
||||||
placeholder,
|
placeholder,
|
||||||
dialogLabelledBy,
|
dialogLabelledBy,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
|
@ -29,11 +26,20 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
let { draft, isEmpty } = $(useDraft(draftKey, initial))
|
const draftState = useDraft(draftKey, initial)
|
||||||
|
const { draft } = $(draftState)
|
||||||
|
|
||||||
let isSending = $ref(false)
|
const {
|
||||||
let isExpanded = $ref(false)
|
isExceedingAttachmentLimit, isUploading, failedAttachments, isOverDropZone,
|
||||||
const shouldExpanded = $computed(() => _expanded || isExpanded || !isEmpty)
|
uploadAttachments, pickAttachments, setDescription, removeAttachment,
|
||||||
|
} = $(useUploadMediaAttachment($$(draft)))
|
||||||
|
|
||||||
|
let { shouldExpanded, isExpanded, isSending, isPublishDisabled, publishDraft } = $(usePublish(
|
||||||
|
{
|
||||||
|
draftState,
|
||||||
|
...$$({ expanded, isUploading, initialDraft: initial }),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
const { editor } = useTiptap({
|
const { editor } = useTiptap({
|
||||||
content: computed({
|
content: computed({
|
||||||
|
@ -55,11 +61,7 @@ const { editor } = useTiptap({
|
||||||
},
|
},
|
||||||
onPaste: handlePaste,
|
onPaste: handlePaste,
|
||||||
})
|
})
|
||||||
|
|
||||||
const characterCount = $computed(() => htmlToText(editor.value?.getHTML() || '').length)
|
const characterCount = $computed(() => htmlToText(editor.value?.getHTML() || '').length)
|
||||||
let isUploading = $ref<boolean>(false)
|
|
||||||
let isExceedingAttachmentLimit = $ref<boolean>(false)
|
|
||||||
let failed = $ref<FileUploadError[]>([])
|
|
||||||
|
|
||||||
async function handlePaste(evt: ClipboardEvent) {
|
async function handlePaste(evt: ClipboardEvent) {
|
||||||
const files = evt.clipboardData?.files
|
const files = evt.clipboardData?.files
|
||||||
|
@ -77,120 +79,21 @@ function insertCustomEmoji(image: any) {
|
||||||
editor.value?.chain().focus().insertCustomEmoji(image).run()
|
editor.value?.chain().focus().insertCustomEmoji(image).run()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickAttachments() {
|
|
||||||
const mimeTypes = currentInstance.value!.configuration.mediaAttachments.supportedMimeTypes
|
|
||||||
const files = await fileOpen({
|
|
||||||
description: 'Attachments',
|
|
||||||
multiple: true,
|
|
||||||
mimeTypes,
|
|
||||||
})
|
|
||||||
await uploadAttachments(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleSensitive() {
|
async function toggleSensitive() {
|
||||||
draft.params.sensitive = !draft.params.sensitive
|
draft.params.sensitive = !draft.params.sensitive
|
||||||
}
|
}
|
||||||
|
|
||||||
const masto = useMasto()
|
|
||||||
|
|
||||||
async function uploadAttachments(files: File[]) {
|
|
||||||
isUploading = true
|
|
||||||
failed = []
|
|
||||||
// TODO: display some kind of message if too many media are selected
|
|
||||||
// DONE
|
|
||||||
const limit = currentInstance.value!.configuration.statuses.maxMediaAttachments || 4
|
|
||||||
for (const file of files.slice(0, limit)) {
|
|
||||||
if (draft.attachments.length < limit) {
|
|
||||||
isExceedingAttachmentLimit = false
|
|
||||||
try {
|
|
||||||
const attachment = await masto.v1.mediaAttachments.create({
|
|
||||||
file,
|
|
||||||
})
|
|
||||||
draft.attachments.push(attachment)
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
// TODO: add some human-readable error message, problem is that masto api will not return response code
|
|
||||||
console.error(e)
|
|
||||||
failed = [...failed, [file.name, (e as Error).message]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
isExceedingAttachmentLimit = true
|
|
||||||
failed = [...failed, [file.name, t('state.attachments_limit_error')]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isUploading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setDescription(att: mastodon.v1.MediaAttachment, description: string) {
|
|
||||||
att.description = description
|
|
||||||
await masto.v1.mediaAttachments.update(att.id, { description: att.description })
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeAttachment(index: number) {
|
|
||||||
draft.attachments.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publish() {
|
async function publish() {
|
||||||
const payload = {
|
const status = await publishDraft()
|
||||||
...draft.params,
|
if (status)
|
||||||
status: htmlToText(draft.params.status || ''),
|
|
||||||
mediaIds: draft.attachments.map(a => a.id),
|
|
||||||
...(isGlitchEdition.value ? { 'content-type': 'text/markdown' } : {}),
|
|
||||||
} as mastodon.v1.CreateStatusParams
|
|
||||||
|
|
||||||
if (process.dev) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.info({
|
|
||||||
raw: draft.params.status,
|
|
||||||
...payload,
|
|
||||||
})
|
|
||||||
// eslint-disable-next-line no-alert
|
|
||||||
const result = confirm('[DEV] Payload logged to console, do you want to publish it?')
|
|
||||||
if (!result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
isSending = true
|
|
||||||
|
|
||||||
let status: mastodon.v1.Status
|
|
||||||
if (!draft.editingStatus)
|
|
||||||
status = await masto.v1.statuses.create(payload)
|
|
||||||
else
|
|
||||||
status = await masto.v1.statuses.update(draft.editingStatus.id, payload)
|
|
||||||
if (draft.params.inReplyToId)
|
|
||||||
navigateToStatus({ status })
|
|
||||||
|
|
||||||
draft = initial()
|
|
||||||
emit('published', status)
|
emit('published', status)
|
||||||
}
|
|
||||||
finally {
|
|
||||||
isSending = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dropZoneRef = ref<HTMLDivElement>()
|
|
||||||
|
|
||||||
async function onDrop(files: File[] | null) {
|
|
||||||
if (files)
|
|
||||||
await uploadAttachments(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
|
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
focusEditor: () => {
|
focusEditor: () => {
|
||||||
editor.value?.commands?.focus?.()
|
editor.value?.commands?.focus?.()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const isPublishDisabled = computed(() => {
|
|
||||||
if (isEmpty || isUploading || (draft.attachments.length === 0 && !draft.params.status))
|
|
||||||
return true
|
|
||||||
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -239,7 +142,7 @@ const isPublishDisabled = computed(() => {
|
||||||
{{ $t('state.uploading') }}
|
{{ $t('state.uploading') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="failed.length > 0"
|
v-else-if="failedAttachments.length > 0"
|
||||||
role="alert"
|
role="alert"
|
||||||
:aria-describedby="isExceedingAttachmentLimit ? 'upload-failed uploads-per-post' : 'upload-failed'"
|
:aria-describedby="isExceedingAttachmentLimit ? 'upload-failed uploads-per-post' : 'upload-failed'"
|
||||||
flex="~ col"
|
flex="~ col"
|
||||||
|
@ -258,7 +161,7 @@ const isPublishDisabled = computed(() => {
|
||||||
flex rounded-4 p1
|
flex rounded-4 p1
|
||||||
hover:bg-active cursor-pointer transition-100
|
hover:bg-active cursor-pointer transition-100
|
||||||
:aria-label="$t('action.clear_upload_failed')"
|
:aria-label="$t('action.clear_upload_failed')"
|
||||||
@click="failed = []"
|
@click="failedAttachments = []"
|
||||||
>
|
>
|
||||||
<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>
|
||||||
|
@ -268,7 +171,7 @@ const isPublishDisabled = computed(() => {
|
||||||
{{ $t('state.attachments_exceed_server_limit') }}
|
{{ $t('state.attachments_exceed_server_limit') }}
|
||||||
</div>
|
</div>
|
||||||
<ol ps-2 sm:ps-1>
|
<ol ps-2 sm:ps-1>
|
||||||
<li v-for="error in failed" :key="error[0]" flex="~ col sm:row" gap-y-1 sm:gap-x-2>
|
<li v-for="error in failedAttachments" :key="error[0]" flex="~ col sm:row" gap-y-1 sm:gap-x-2>
|
||||||
<strong>{{ error[1] }}:</strong>
|
<strong>{{ error[1] }}:</strong>
|
||||||
<span>{{ error[0] }}</span>
|
<span>{{ error[0] }}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { fileOpen } from 'browser-fs-access'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import type { mastodon } from 'masto'
|
||||||
|
import type { UseDraft } from './statusDrafts'
|
||||||
|
import type { Draft } from '~~/types'
|
||||||
|
|
||||||
|
export const usePublish = (options: {
|
||||||
|
draftState: UseDraft
|
||||||
|
expanded: Ref<boolean>
|
||||||
|
isUploading: Ref<boolean>
|
||||||
|
initialDraft: Ref<() => Draft>
|
||||||
|
}) => {
|
||||||
|
const { expanded, isUploading, initialDraft } = $(options)
|
||||||
|
let { draft, isEmpty } = $(options.draftState)
|
||||||
|
const masto = useMasto()
|
||||||
|
|
||||||
|
let isSending = $ref(false)
|
||||||
|
const isExpanded = $ref(false)
|
||||||
|
|
||||||
|
const shouldExpanded = $computed(() => expanded || isExpanded || !isEmpty)
|
||||||
|
const isPublishDisabled = $computed(() => {
|
||||||
|
return isEmpty || isUploading || isSending || (draft.attachments.length === 0 && !draft.params.status)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function publishDraft() {
|
||||||
|
const payload = {
|
||||||
|
...draft.params,
|
||||||
|
status: htmlToText(draft.params.status || ''),
|
||||||
|
mediaIds: draft.attachments.map(a => a.id),
|
||||||
|
...(isGlitchEdition.value ? { 'content-type': 'text/markdown' } : {}),
|
||||||
|
} as mastodon.v1.CreateStatusParams
|
||||||
|
|
||||||
|
if (process.dev) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.info({
|
||||||
|
raw: draft.params.status,
|
||||||
|
...payload,
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
const result = confirm('[DEV] Payload logged to console, do you want to publish it?')
|
||||||
|
if (!result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSending = true
|
||||||
|
|
||||||
|
let status: mastodon.v1.Status
|
||||||
|
if (!draft.editingStatus)
|
||||||
|
status = await masto.v1.statuses.create(payload)
|
||||||
|
else
|
||||||
|
status = await masto.v1.statuses.update(draft.editingStatus.id, payload)
|
||||||
|
if (draft.params.inReplyToId)
|
||||||
|
navigateToStatus({ status })
|
||||||
|
|
||||||
|
draft = initialDraft()
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
isSending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $$({
|
||||||
|
isSending,
|
||||||
|
isExpanded,
|
||||||
|
shouldExpanded,
|
||||||
|
isPublishDisabled,
|
||||||
|
|
||||||
|
publishDraft,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaAttachmentUploadError = [filename: string, message: string]
|
||||||
|
|
||||||
|
export const useUploadMediaAttachment = (draftRef: Ref<Draft>) => {
|
||||||
|
const draft = $(draftRef)
|
||||||
|
const masto = useMasto()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
let isUploading = $ref<boolean>(false)
|
||||||
|
let isExceedingAttachmentLimit = $ref<boolean>(false)
|
||||||
|
let failedAttachments = $ref<MediaAttachmentUploadError[]>([])
|
||||||
|
const dropZoneRef = ref<HTMLDivElement>()
|
||||||
|
|
||||||
|
async function uploadAttachments(files: File[]) {
|
||||||
|
isUploading = true
|
||||||
|
failedAttachments = []
|
||||||
|
// TODO: display some kind of message if too many media are selected
|
||||||
|
// DONE
|
||||||
|
const limit = currentInstance.value!.configuration.statuses.maxMediaAttachments || 4
|
||||||
|
for (const file of files.slice(0, limit)) {
|
||||||
|
if (draft.attachments.length < limit) {
|
||||||
|
isExceedingAttachmentLimit = false
|
||||||
|
try {
|
||||||
|
const attachment = await masto.v1.mediaAttachments.create({
|
||||||
|
file,
|
||||||
|
})
|
||||||
|
draft.attachments.push(attachment)
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
// TODO: add some human-readable error message, problem is that masto api will not return response code
|
||||||
|
console.error(e)
|
||||||
|
failedAttachments = [...failedAttachments, [file.name, (e as Error).message]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
isExceedingAttachmentLimit = true
|
||||||
|
failedAttachments = [...failedAttachments, [file.name, t('state.attachments_limit_error')]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isUploading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickAttachments() {
|
||||||
|
const mimeTypes = currentInstance.value!.configuration.mediaAttachments.supportedMimeTypes
|
||||||
|
const files = await fileOpen({
|
||||||
|
description: 'Attachments',
|
||||||
|
multiple: true,
|
||||||
|
mimeTypes,
|
||||||
|
})
|
||||||
|
await uploadAttachments(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDescription(att: mastodon.v1.MediaAttachment, description: string) {
|
||||||
|
att.description = description
|
||||||
|
await masto.v1.mediaAttachments.update(att.id, { description: att.description })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAttachment(index: number) {
|
||||||
|
draft.attachments.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(files: File[] | null) {
|
||||||
|
if (files)
|
||||||
|
await uploadAttachments(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
|
||||||
|
|
||||||
|
return $$({
|
||||||
|
isUploading,
|
||||||
|
isExceedingAttachmentLimit,
|
||||||
|
failedAttachments,
|
||||||
|
isOverDropZone,
|
||||||
|
|
||||||
|
uploadAttachments,
|
||||||
|
pickAttachments,
|
||||||
|
setDescription,
|
||||||
|
removeAttachment,
|
||||||
|
})
|
||||||
|
}
|
|
@ -63,3 +63,12 @@ export function getStatusInReplyToRoute(status: mastodon.v1.Status) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const navigateToStatus = ({ status, focusReply = false }: {
|
||||||
|
status: mastodon.v1.Status
|
||||||
|
focusReply?: boolean
|
||||||
|
}) =>
|
||||||
|
navigateTo({
|
||||||
|
path: getStatusRoute(status).href,
|
||||||
|
state: { focusReply },
|
||||||
|
})
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
|
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 { Draft, DraftMap } from '~/types'
|
||||||
import type { Mutable } from '~/types/utils'
|
import type { Mutable } from '~/types/utils'
|
||||||
|
@ -89,10 +90,15 @@ export const isEmptyDraft = (draft: Draft | null | undefined) => {
|
||||||
&& (params.spoilerText || '').length === 0
|
&& (params.spoilerText || '').length === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UseDraft {
|
||||||
|
draft: Ref<Draft>
|
||||||
|
isEmpty: ComputedRef<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
export function useDraft(
|
export function useDraft(
|
||||||
draftKey?: string,
|
draftKey?: string,
|
||||||
initial: () => Draft = () => getDefaultDraft({}),
|
initial: () => Draft = () => getDefaultDraft({}),
|
||||||
) {
|
): UseDraft {
|
||||||
const draft = draftKey
|
const draft = draftKey
|
||||||
? computed({
|
? computed({
|
||||||
get() {
|
get() {
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
import type { mastodon } from 'masto'
|
|
||||||
|
|
||||||
export const navigateToStatus = ({ status, focusReply = false }: { status: mastodon.v1.Status; focusReply?: boolean }) => navigateTo({ path: getStatusRoute(status).href, state: { focusReply } })
|
|
Loading…
Reference in New Issue