mirror of
https://github.com/versia-pub/frontend.git
synced 2026-03-13 03:29:16 +01:00
refactor: ♻️ Rewrite state system to use Pinia for composer and auth
This commit is contained in:
parent
a6db9e059d
commit
b510782a30
80 changed files with 999 additions and 1011 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
35
app/components/composer/visibilities.ts
Normal file
35
app/components/composer/visibilities.ts
Normal 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(),
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue