From 18cf63de51e4594ee604657ecbae216b797e94c9 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Fri, 27 Jun 2025 00:28:14 +0200 Subject: [PATCH] refactor: :recycle: Rewrite composer code --- components/composer/button.vue | 18 ++ components/composer/buttons.vue | 84 ++++++ components/composer/character-counter.vue | 39 +++ components/composer/composer.ts | 240 +++++++++++++++ components/composer/composer.vue | 351 +++------------------- components/composer/content-warning.vue | 9 + components/composer/dialog.vue | 2 +- components/composer/file-preview.vue | 28 +- components/composer/files.vue | 14 +- components/composer/visibility-picker.vue | 33 ++ components/editor/content.vue | 14 + components/ui/select/SelectTrigger.vue | 8 +- 12 files changed, 492 insertions(+), 348 deletions(-) create mode 100644 components/composer/button.vue create mode 100644 components/composer/buttons.vue create mode 100644 components/composer/character-counter.vue create mode 100644 components/composer/composer.ts create mode 100644 components/composer/content-warning.vue create mode 100644 components/composer/visibility-picker.vue diff --git a/components/composer/button.vue b/components/composer/button.vue new file mode 100644 index 0000000..8f4c4d9 --- /dev/null +++ b/components/composer/button.vue @@ -0,0 +1,18 @@ + + + diff --git a/components/composer/buttons.vue b/components/composer/buttons.vue new file mode 100644 index 0000000..4f41399 --- /dev/null +++ b/components/composer/buttons.vue @@ -0,0 +1,84 @@ + + + diff --git a/components/composer/character-counter.vue b/components/composer/character-counter.vue new file mode 100644 index 0000000..6f118b3 --- /dev/null +++ b/components/composer/character-counter.vue @@ -0,0 +1,39 @@ + + + diff --git a/components/composer/composer.ts b/components/composer/composer.ts new file mode 100644 index 0000000..d9a56a0 --- /dev/null +++ b/components/composer/composer.ts @@ -0,0 +1,240 @@ +import type { ResponseError } from "@versia/client"; +import type { Attachment, Status, StatusSource } from "@versia/client/schemas"; +import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next"; +import type { FunctionalComponent } from "vue"; +import { toast } from "vue-sonner"; +import type { z } from "zod"; +import * as m from "~/paraglide/messages.js"; + +export interface ComposerState { + relation?: { + type: "reply" | "quote" | "edit"; + note: z.infer; + source?: z.infer; + }; + content: string; + rawContent: string; + sensitive: boolean; + contentWarning: string; + contentType: "text/html" | "text/plain"; + visibility: z.infer; + files: { + apiId?: string; + file: File; + alt?: string; + uploading: boolean; + updating: boolean; + }[]; + sending: boolean; + canSend: boolean; +} + +const { play } = useAudio(); +export const state = reactive({ + relation: undefined, + content: "", + rawContent: "", + sensitive: false, + contentWarning: "", + contentType: "text/html", + visibility: preferences.default_visibility.value, + files: [], + sending: false, + canSend: false, +}); + +watch( + state, + (newState) => { + const characterLimit = (identity.value as Identity).instance + .configuration.statuses.max_characters; + const characterCount = newState.rawContent.length; + + state.canSend = + characterCount > 0 + ? characterCount <= characterLimit + : newState.files.length > 0; + }, + { immediate: true }, +); + +export const visibilities: Record< + z.infer, + { + icon: FunctionalComponent; + name: string; + text: string; + } +> = { + public: { + icon: Globe, + name: m.lost_trick_dog_grace(), + text: m.last_mean_peacock_zip(), + }, + unlisted: { + icon: LockOpen, + name: m.funny_slow_jannes_walk(), + text: m.grand_strong_gibbon_race(), + }, + private: { + icon: Lock, + name: m.grassy_empty_raven_startle(), + text: m.white_teal_ostrich_yell(), + }, + direct: { + icon: AtSign, + name: m.pretty_bold_baboon_wave(), + text: m.lucky_mean_robin_link(), + }, +}; + +export const getRandomSplash = (): string => { + const splashes = useConfig().COMPOSER_SPLASHES; + + return splashes[Math.floor(Math.random() * splashes.length)] as string; +}; + +export const calculateMentionsFromReply = ( + note: z.infer, +): string => { + const peopleToMention = note.mentions + .concat(note.account) + // Deduplicate mentions + .filter((men, i, a) => a.indexOf(men) === i) + // Remove self + .filter((men) => men.id !== identity.value?.account.id); + + if (peopleToMention.length === 0) { + return ""; + } + + const mentions = peopleToMention.map((me) => `@${me.acct}`).join(" "); + + return `${mentions} `; +}; + +const fileFromUrl = (url: URL | string): Promise => { + return fetch(url).then((response) => { + if (!response.ok) { + throw new Error("Failed to fetch file"); + } + + return response.blob().then((blob) => { + const file = new File([blob], "file", { type: blob.type }); + return file; + }); + }); +}; + +export const stateFromRelation = async ( + relationType: "reply" | "quote" | "edit", + note: z.infer, + source?: z.infer, +): Promise => { + state.relation = { + type: relationType, + note, + source, + }; + state.content = note.content || calculateMentionsFromReply(note); + state.rawContent = source?.text || ""; + + if (relationType === "edit") { + state.sensitive = note.sensitive; + state.contentWarning = source?.spoiler_text || note.spoiler_text; + state.visibility = note.visibility; + state.files = await Promise.all( + note.media_attachments.map(async (file) => ({ + apiId: file.id, + alt: file.description ?? undefined, + file: await fileFromUrl(file.url), + uploading: false, + updating: false, + })), + ); + } +}; + +export const uploadFile = (file: File): Promise => { + const index = + state.files.push({ + file, + uploading: true, + updating: false, + }) - 1; + + return client.value + .uploadMedia(file) + .then((media) => { + if (!state.files[index]) { + throw new Error("File not found"); + } + + state.files[index].uploading = false; + state.files[index].apiId = ( + media.data as z.infer + ).id; + }) + .catch(() => { + state.files.splice(index, 1); + }); +}; + +export const send = async (): Promise => { + if (state.sending) { + return; + } + + state.sending = true; + + try { + if (state.relation?.type === "edit") { + const { data } = await client.value.editStatus( + state.relation.note.id, + { + status: state.content, + content_type: state.contentType, + sensitive: state.sensitive, + spoiler_text: state.sensitive + ? state.contentWarning + : undefined, + media_ids: state.files + .map((f) => f.apiId) + .filter((f) => f !== undefined), + }, + ); + + useEvent("composer:send-edit", data); + play("publish"); + useEvent("composer:close"); + } else { + const { data } = await client.value.postStatus(state.content, { + content_type: state.contentType, + sensitive: state.sensitive, + spoiler_text: state.sensitive + ? state.contentWarning + : undefined, + media_ids: state.files + .map((f) => f.apiId) + .filter((f) => f !== undefined), + quote_id: + state.relation?.type === "quote" + ? state.relation.note.id + : undefined, + in_reply_to_id: + state.relation?.type === "reply" + ? state.relation.note.id + : undefined, + visibility: state.visibility, + }); + + useEvent("composer:send", data as z.infer); + play("publish"); + useEvent("composer:close"); + } + } catch (e) { + toast.error((e as ResponseError).message); + } finally { + state.sending = false; + } +}; diff --git a/components/composer/composer.vue b/components/composer/composer.vue index 70fbcf4..5cd2907 100644 --- a/components/composer/composer.vue +++ b/components/composer/composer.vue @@ -3,358 +3,81 @@ - + - + :disabled="state.sending" :mode="state.contentType === 'text/html' ? 'rich' : 'plain'" />
- +
- - - - - - -

{{ m.game_tough_seal_adore() }}

-
-
- - - - - - - -

{{ m.plane_born_koala_hope() }}

-
-
- - - - - - -

{{ m.blue_ornate_coyote_tickle() }}

-
-
- - - - - -

{{ m.top_patchy_earthworm_vent() }}

-
-
- - - - - - - -

{{ m.frail_broad_mallard_dart() }}

-
-
- + + diff --git a/components/composer/content-warning.vue b/components/composer/content-warning.vue new file mode 100644 index 0000000..06945ff --- /dev/null +++ b/components/composer/content-warning.vue @@ -0,0 +1,9 @@ + + + diff --git a/components/composer/dialog.vue b/components/composer/dialog.vue index 5b34461..e9fe844 100644 --- a/components/composer/dialog.vue +++ b/components/composer/dialog.vue @@ -79,7 +79,7 @@ const relation = ref( > {{ diff --git a/components/composer/file-preview.vue b/components/composer/file-preview.vue index 54bbf56..3b856c2 100644 --- a/components/composer/file-preview.vue +++ b/components/composer/file-preview.vue @@ -5,14 +5,9 @@ :disabled="file.uploading || file.updating" class="block bg-card text-card-foreground shadow-sm h-28 overflow-hidden rounded relative min-w-28 *:disabled:opacity-50" > - - - + {{ formatBytes(file.file.size) }} {{ file.file.name }} - - Rename + Rename - Add caption + Add caption - Remove + Remove diff --git a/components/composer/visibility-picker.vue b/components/composer/visibility-picker.vue new file mode 100644 index 0000000..12de93b --- /dev/null +++ b/components/composer/visibility-picker.vue @@ -0,0 +1,33 @@ + + + diff --git a/components/editor/content.vue b/components/editor/content.vue index 52d046e..b236f26 100644 --- a/components/editor/content.vue +++ b/components/editor/content.vue @@ -17,6 +17,7 @@ import { Emoji } from "./emoji.ts"; import suggestion from "./suggestion.ts"; const content = defineModel("content"); +const rawContent = defineModel("rawContent"); const { placeholder, disabled, @@ -27,6 +28,10 @@ const { disabled?: boolean; }>(); +const emit = defineEmits<{ + pasteFiles: [files: File[]]; +}>(); + const editor = new Editor({ extensions: [ StarterKit, @@ -49,6 +54,15 @@ const editor = new Editor({ content: content.value, onUpdate: ({ editor }) => { content.value = mode === "rich" ? editor.getHTML() : editor.getText(); + rawContent.value = editor.getText(); + }, + onPaste: (event) => { + // If pasting files, prevent the default behavior + if (event.clipboardData && event.clipboardData.files.length > 0) { + event.preventDefault(); + const files = Array.from(event.clipboardData.files); + emit("pasteFiles", files); + } }, autofocus: true, editable: !disabled, diff --git a/components/ui/select/SelectTrigger.vue b/components/ui/select/SelectTrigger.vue index 568a075..53b20e6 100644 --- a/components/ui/select/SelectTrigger.vue +++ b/components/ui/select/SelectTrigger.vue @@ -15,9 +15,11 @@ const props = withDefaults( SelectTriggerProps & { class?: HTMLAttributes["class"]; size?: "sm" | "default"; + disableSelectIcon?: boolean; + disableDefaultClasses?: boolean; } >(), - { size: "default" }, + { size: "default", disableSelectIcon: false, disableDefaultClasses: false }, ); const delegatedProps = reactiveOmit(props, "class", "size"); @@ -30,12 +32,12 @@ const forwardedProps = useForwardProps(delegatedProps); :data-size="size" v-bind="forwardedProps" :class="cn( - `border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`, + !disableDefaultClasses && `border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`, props.class, )" > - +