refactor: ♻️ Rewrite state system to use Pinia for composer and auth

This commit is contained in:
Jesse Wierzbinski 2025-08-28 07:41:51 +02:00
parent a6db9e059d
commit b510782a30
No known key found for this signature in database
80 changed files with 999 additions and 1011 deletions

View file

@ -32,7 +32,7 @@
<TriangleAlert class="!size-5" />
</Toggle>
</ComposerButton>
<CharacterCounter class="ml-auto" :max="(identity as Identity).instance.configuration.statuses.max_characters" :current="rawContent.length" />
<CharacterCounter class="ml-auto" :max="authStore.instance?.configuration.statuses.max_characters ?? 0" :current="rawContent.length" />
<Button type="submit" size="lg" :disabled="sending || !canSend" @click="emit('submit')">
<Loader v-if="sending" class="!size-5 animate-spin" />
{{
@ -57,7 +57,7 @@ import { Button } from "../ui/button";
import { Toggle } from "../ui/toggle";
import ComposerButton from "./button.vue";
import CharacterCounter from "./character-counter.vue";
import { type ComposerState, visibilities } from "./composer";
import { visibilities } from "./visibilities";
import VisibilityPicker from "./visibility-picker.vue";
const { relation, sending, canSend, rawContent } = defineProps<{
@ -66,6 +66,7 @@ const { relation, sending, canSend, rawContent } = defineProps<{
canSend: boolean;
rawContent: string;
}>();
const authStore = useAuthStore();
const contentType = defineModel<ComposerState["contentType"]>("contentType", {
required: true,

View file

@ -1,240 +0,0 @@
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<typeof Status>;
source?: z.infer<typeof StatusSource>;
};
content: string;
rawContent: string;
sensitive: boolean;
contentWarning: string;
contentType: "text/html" | "text/plain";
visibility: z.infer<typeof Status.shape.visibility>;
files: {
apiId?: string;
file: File;
alt?: string;
uploading: boolean;
updating: boolean;
}[];
sending: boolean;
canSend: boolean;
}
const { play } = useAudio();
export const state = reactive<ComposerState>({
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?.instance.configuration.statuses.max_characters ?? 0;
const characterCount = newState.rawContent.length;
state.canSend =
characterCount > 0
? characterCount <= characterLimit
: newState.files.length > 0;
},
{ immediate: true },
);
export const visibilities: Record<
z.infer<typeof Status.shape.visibility>,
{
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<typeof Status>,
): 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<File> => {
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<typeof Status>,
source?: z.infer<typeof StatusSource>,
): Promise<void> => {
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<void> => {
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<typeof Attachment>
).id;
})
.catch(() => {
state.files.splice(index, 1);
});
};
export const send = async (): Promise<void> => {
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<typeof Status>);
play("publish");
useEvent("composer:close");
}
} catch (e) {
toast.error((e as ResponseError).message);
} finally {
state.sending = false;
}
};

View file

@ -3,19 +3,19 @@
<Note :note="relation.note" :hide-actions="true" :small-layout="true" />
</div>
<ContentWarning v-if="state.sensitive" v-model="state.contentWarning" />
<ContentWarning v-if="store.sensitive" v-model="store.contentWarning" />
<EditorContent @paste-files="uploadFiles" v-model:content="state.content" v-model:raw-content="state.rawContent" :placeholder="getRandomSplash()"
<EditorContent @paste-files="uploadFiles" v-model:content="store.content" v-model:raw-content="store.rawContent" :placeholder="getRandomSplash()"
class="[&>.tiptap]:!border-none [&>.tiptap]:!ring-0 [&>.tiptap]:!outline-none [&>.tiptap]:rounded-none p-0 [&>.tiptap]:max-h-[50dvh] [&>.tiptap]:overflow-y-auto [&>.tiptap]:min-h-48 [&>.tiptap]:!ring-offset-0 [&>.tiptap]:h-full"
:disabled="state.sending" :mode="state.contentType === 'text/html' ? 'rich' : 'plain'" />
:disabled="store.sending" :mode="store.contentType === 'text/html' ? 'rich' : 'plain'" />
<div class="w-full flex flex-row gap-2 overflow-x-auto *:shrink-0 pb-2">
<input type="file" ref="fileInput" @change="uploadFileFromEvent" class="hidden" multiple />
<Files v-model:files="state.files" />
<Files v-model:files="store.files" :composer-key="composerKey" />
</div>
<DialogFooter class="items-center flex-row overflow-x-auto">
<ComposerButtons @submit="send" @pick-file="fileInput?.click()" v-model:content-type="state.contentType" v-model:sensitive="state.sensitive" v-model:visibility="state.visibility" :relation="state.relation" :sending="state.sending" :can-send="state.canSend" :raw-content="state.rawContent" />
<ComposerButtons @submit="send" @pick-file="fileInput?.click()" v-model:content-type="store.contentType" v-model:sensitive="store.sensitive" v-model:visibility="store.visibility" :relation="store.relation" :sending="store.sending" :can-send="store.canSend" :raw-content="store.rawContent" />
</DialogFooter>
</template>
@ -24,37 +24,53 @@ import Note from "~/components/notes/note.vue";
import EditorContent from "../editor/content.vue";
import { DialogFooter } from "../ui/dialog";
import ComposerButtons from "./buttons.vue";
import {
type ComposerState,
getRandomSplash,
send,
state,
stateFromRelation,
uploadFile,
} from "./composer";
import ContentWarning from "./content-warning.vue";
import Files from "./files.vue";
const props = defineProps<{
relation?: ComposerState["relation"];
}>();
const { Control_Enter, Command_Enter } = useMagicKeys();
const { play } = useAudio();
const fileInput = useTemplateRef<HTMLInputElement>("fileInput");
const composerKey = props.relation
? (`${props.relation.type}-${props.relation.note.id}` as const)
: "blank";
const store = useComposerStore(composerKey)();
watch([Control_Enter, Command_Enter], () => {
if (state.sending || !preferences.ctrl_enter_send.value) {
if (store.sending || !preferences.ctrl_enter_send.value) {
return;
}
send();
});
const props = defineProps<{
relation?: ComposerState["relation"];
}>();
const getRandomSplash = (): string => {
const splashes = useConfig().COMPOSER_SPLASHES;
return splashes[Math.floor(Math.random() * splashes.length)] as string;
};
const send = async () => {
const result =
store.relation?.type === "edit"
? await store.sendEdit()
: await store.send();
if (result) {
play("publish");
store.$reset();
useEvent("composer:close");
}
};
watch(
props,
async (props) => {
if (props.relation) {
await stateFromRelation(
store.stateFromRelation(
props.relation.type,
props.relation.note,
props.relation.source,
@ -69,7 +85,7 @@ const uploadFileFromEvent = (e: Event) => {
const files = Array.from(target.files ?? []);
for (const file of files) {
uploadFile(file);
store.uploadFile(file);
}
target.value = "";
@ -77,7 +93,7 @@ const uploadFileFromEvent = (e: Event) => {
const uploadFiles = (files: File[]) => {
for (const file of files) {
uploadFile(file);
store.uploadFile(file);
}
};
</script>

View file

@ -11,8 +11,10 @@ import {
import * as m from "~~/paraglide/messages.js";
import Composer from "./composer.vue";
const authStore = useAuthStore();
useListen("composer:open", () => {
if (identity.value) {
if (authStore.isSignedIn) {
open.value = true;
}
});
@ -21,7 +23,7 @@ useListen("composer:edit", async (note) => {
const id = toast.loading(m.wise_late_fireant_walk(), {
duration: 0,
});
const { data: source } = await client.value.getStatusSource(note.id);
const { data: source } = await authStore.client.getStatusSource(note.id);
relation.value = {
type: "edit",
note,

View file

@ -5,22 +5,19 @@
: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"
>
<img :src="createObjectURL(file.file)" class="object-contain h-28 w-full" :alt="file.alt" />
<img v-if="file.file?.type.startsWith('image/')" :src="createObjectURL(file.file)" class="object-contain h-28 w-full" :alt="file.alt" />
<FileIcon v-else class="size-6 m-auto text-muted-foreground" />
<Badge
v-if="!(file.uploading || file.updating)"
v-if="file.file && !(file.uploading || file.updating)"
class="absolute bottom-1 right-1"
variant="default"
>{{ formatBytes(file.file.size) }}</Badge
>
<Spinner v-else class="absolute bottom-1 right-1 size-8 p-1.5" />
<Spinner v-else-if="file.file" class="absolute bottom-1 right-1 size-8 p-1.5" />
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-48">
<DropdownMenuLabel>{{ file.file.name }}</DropdownMenuLabel>
<DropdownMenuLabel v-if="file.file">{{ file.file.name }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem @click="editName">
<TextCursorInput />
Rename
</DropdownMenuItem>
<DropdownMenuItem @click="editCaption">
<Captions />
Add caption
@ -35,7 +32,7 @@
</template>
<script lang="ts" setup>
import { Captions, Delete, TextCursorInput } from "lucide-vue-next";
import { Captions, Delete, FileIcon } from "lucide-vue-next";
import Spinner from "~/components/graphics/spinner.vue";
import { confirmModalService } from "~/components/modals/composable.ts";
import { Badge } from "~/components/ui/badge";
@ -47,45 +44,22 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import type { ComposerState } from "./composer";
import type { ComposerStateKey } from "~/stores/composer";
const file = defineModel<ComposerState["files"][number]>("file", {
const { composerKey } = defineProps<{
composerKey: ComposerStateKey;
}>();
const file = defineModel<ComposerFile>("file", {
required: true,
});
const composerStore = useComposerStore(composerKey)();
const emit = defineEmits<{
remove: [];
}>();
const editName = async () => {
const result = await confirmModalService.confirm({
title: "Enter a new name",
defaultValue: file.value.file.name,
confirmText: "Edit",
inputType: "text",
});
if (result.confirmed) {
file.value.updating = true;
file.value.file = new File(
[file.value.file],
result.value ?? file.value.file.name,
{
type: file.value.file.type,
lastModified: file.value.file.lastModified,
},
);
try {
await client.value.updateMedia(file.value.apiId ?? "", {
file: file.value.file,
});
} finally {
file.value.updating = false;
}
}
};
const editCaption = async () => {
const result = await confirmModalService.confirm({
title: "Enter a caption",
@ -97,16 +71,10 @@ const editCaption = async () => {
});
if (result.confirmed) {
file.value.updating = true;
file.value.alt = result.value;
try {
await client.value.updateMedia(file.value.apiId ?? "", {
description: file.value.alt,
});
} finally {
file.value.updating = false;
}
await composerStore.updateFileDescription(
file.value.id,
result.value ?? "",
);
}
};

View file

@ -1,13 +1,17 @@
<template>
<FilePreview v-for="(file, index) in files" :key="file.apiId" :file="file" @update:file="files[index] = $event"
<FilePreview v-for="(file, index) in files" :key="file.apiId" :file="file" @update:file="files[index] = $event" :composer-key="composerKey"
@remove="files.splice(index, 1)" />
</template>
<script lang="ts" setup>
import type { ComposerState } from "./composer";
import type { ComposerStateKey } from "~/stores/composer";
import FilePreview from "./file-preview.vue";
const files = defineModel<ComposerState["files"]>("files", {
const { composerKey } = defineProps<{
composerKey: ComposerStateKey;
}>();
const files = defineModel<ComposerFile[]>("files", {
required: true,
});
</script>

View file

@ -0,0 +1,35 @@
import type { Status } from "@versia/client/schemas";
import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next";
import type { FunctionalComponent } from "vue";
import type { z } from "zod";
import * as m from "~~/paraglide/messages.js";
export const visibilities: Record<
z.infer<typeof Status.shape.visibility>,
{
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(),
},
};

View file

@ -18,14 +18,13 @@
</template>
<script lang="ts" setup>
import { Button } from "~/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "~/components/ui/select";
import { type ComposerState, visibilities } from "./composer";
import { visibilities } from "./visibilities";
const visibility = defineModel<ComposerState["visibility"]>("visibility", {
required: true,