frontend/components/composer/composer.ts
Jesse Wierzbinski 18cf63de51
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 0s
Deploy to GitHub Pages / build (push) Failing after 0s
Deploy to GitHub Pages / deploy (push) Has been skipped
Docker / build (push) Failing after 0s
Mirror to Codeberg / Mirror (push) Failing after 0s
refactor: ♻️ Rewrite composer code
2025-06-27 00:28:14 +02:00

241 lines
6.8 KiB
TypeScript

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 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<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;
}
};