frontend/app/stores/composer.ts

294 lines
10 KiB
TypeScript
Raw Permalink Normal View History

import type { Attachment, Status, StatusSource } from "@versia/client/schemas";
import { defineStore } from "pinia";
import type { z } from "zod";
export interface ComposerFile {
id: string;
apiId?: string;
file?: File;
alt?: string;
uploading: boolean;
updating: boolean;
}
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: ComposerFile[];
sending: boolean;
}
export type ComposerStateKey =
| "blank"
| `${NonNullable<ComposerState["relation"]>["type"]}-${string}`;
export const calculateMentionsFromReply = (
note: z.infer<typeof Status>,
): string => {
const authStore = useAuthStore();
const peopleToMention = note.mentions
.concat(note.account)
// Deduplicate mentions
.filter((men, i, a) => a.indexOf(men) === i)
// Remove self
.filter((men) => men.id !== authStore.identity?.account.id);
if (peopleToMention.length === 0) {
return "";
}
const mentions = peopleToMention.map((me) => `@${me.acct}`).join(" ");
return `${mentions} `;
};
export const useComposerStore = (key: ComposerStateKey) =>
defineStore(`composer-${key}`, {
state: (): ComposerState => ({
relation: undefined,
content: "",
rawContent: "",
sensitive: false,
contentWarning: "",
contentType: "text/html",
visibility: "public",
files: [],
sending: false,
}),
getters: {
characterCount: (state) => {
return state.rawContent.length;
},
isOverCharacterLimit(): boolean {
const authStore = useAuthStore();
const characterLimit =
authStore.identity?.instance.configuration.statuses
.max_characters ?? 0;
return this.characterCount > characterLimit;
},
/* Cannot send if content is empty or over character limit, unless media is attached */
canSend(state): boolean {
if (state.sending) {
return false;
}
if (this.isOverCharacterLimit) {
return false;
}
return this.characterCount > 0 || state.files.length > 0;
},
},
actions: {
async stateFromRelation(
relationType: "reply" | "quote" | "edit",
note: z.infer<typeof Status>,
source?: z.infer<typeof StatusSource>,
): Promise<ComposerStateKey> {
const key = `${relationType}-${note.id}` as const;
this.$patch({
relation: {
type: relationType,
note,
source,
},
content: calculateMentionsFromReply(note),
contentWarning: source?.spoiler_text || note.spoiler_text,
sensitive: note.sensitive,
files: [],
sending: false,
contentType: "text/html",
visibility: note.visibility,
});
if (relationType === "edit") {
this.content = source?.text || note.content;
this.rawContent = source?.text || "";
this.files = await Promise.all(
note.media_attachments.map(async (file) => ({
id: crypto.randomUUID(),
apiId: file.id,
alt: file.description ?? undefined,
uploading: false,
updating: false,
})),
);
}
return key;
},
async uploadFile(file: File): Promise<void> {
const index =
this.files.push({
file,
uploading: true,
updating: false,
id: crypto.randomUUID(),
}) - 1;
const authStore = useAuthStore();
return authStore.client
.uploadMedia(file)
.then((media) => {
if (!this.files[index]) {
throw new Error("File not found");
}
this.files[index].uploading = false;
this.files[index].apiId = (
media.data as z.infer<typeof Attachment>
).id;
})
.catch(() => {
this.files.splice(index, 1);
});
},
async updateFileDescription(
id: string,
description: string,
): Promise<void> {
const index = this.files.findIndex((f) => f.id === id);
if (index === -1 || !this.files[index]) {
throw new Error("File not found");
}
this.files[index].updating = true;
const authStore = useAuthStore();
try {
await authStore.client.updateMedia(
this.files[index].apiId ?? "",
{
description: description,
},
);
} finally {
if (this.files[index]) {
this.files[index].updating = false;
this.files[index].alt = description;
}
}
},
async sendEdit(): Promise<z.infer<typeof Status> | null> {
if (!this.canSend || this.relation?.type !== "edit") {
return null;
}
const authStore = useAuthStore();
this.sending = true;
try {
const { data } = await authStore.client.editStatus(
this.relation.note.id,
{
status: this.content,
content_type: this.contentType,
sensitive: this.sensitive,
spoiler_text: this.sensitive
? this.contentWarning
: undefined,
media_ids: this.files
.map((f) => f.apiId)
.filter((f) => f !== undefined),
},
);
this.sending = false;
return data;
} catch (error) {
this.sending = false;
throw error;
}
},
async send(): Promise<z.infer<typeof Status> | null> {
if (!this.canSend) {
return null;
}
const authStore = useAuthStore();
this.sending = true;
try {
const { data } = await authStore.client.postStatus(
this.content,
{
content_type: this.contentType,
sensitive: this.sensitive,
spoiler_text: this.sensitive
? this.contentWarning
: undefined,
media_ids: this.files
.map((f) => f.apiId)
.filter((f) => f !== undefined),
quote_id:
this.relation?.type === "quote"
? this.relation.note.id
: undefined,
in_reply_to_id:
this.relation?.type === "reply"
? this.relation.note.id
: undefined,
visibility: this.visibility,
},
);
this.sending = false;
return data as z.infer<typeof Status>;
} catch (error) {
this.sending = false;
throw error;
}
},
},
persist: {
serializer: {
serialize(data) {
// Delete file references before storing to avoid large storage usage
const newFiles = (data as ComposerState).files.map((f) => {
const { file, ...rest } = f;
return rest;
});
return JSON.stringify({ ...data, files: newFiles });
},
deserialize(str) {
return JSON.parse(str);
},
},
storage: {
// Store everything in "composer" key to avoid creating too many entries
getItem(key) {
return JSON.stringify(
JSON.parse(localStorage.getItem("composer") || "{}")[
key
],
);
},
setItem(key, value) {
const composer = JSON.parse(
localStorage.getItem("composer") || "{}",
);
composer[key] = JSON.parse(value);
localStorage.setItem("composer", JSON.stringify(composer));
},
},
},
});