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

221
app/stores/auth.ts Normal file
View file

@ -0,0 +1,221 @@
import { Client } from "@versia/client";
import type {
Account,
CredentialApplication,
CustomEmoji,
Instance,
RolePermission,
Token,
} from "@versia/client/schemas";
import { defineStore } from "pinia";
import { toast } from "vue-sonner";
import type { z } from "zod";
import pkg from "~~/package.json";
/**
* Represents an identity with associated tokens, account, instance, permissions, and emojis.
*/
export interface Identity {
id: string;
token: z.infer<typeof Token>;
account: z.infer<typeof Account>;
instance: z.infer<typeof Instance>;
emojis: z.infer<typeof CustomEmoji>[];
}
interface AuthStoreState {
identities: Identity[];
activeIdentityId: string | null;
applications: {
[domain: string]: z.infer<typeof CredentialApplication>;
};
}
export const useAuthStore = defineStore("auth", {
state: (): AuthStoreState => ({
identities: [],
activeIdentityId: null,
applications: {},
}),
getters: {
identity(state): Identity | null {
return state.activeIdentityId
? state.identities.find(
(id) => id.id === state.activeIdentityId,
) || null
: null;
},
emojis(): z.infer<typeof CustomEmoji>[] {
return this.identity?.emojis || [];
},
instance(): z.infer<typeof Instance> | null {
return this.identity?.instance || null;
},
account(): z.infer<typeof Account> | null {
return this.identity?.account || null;
},
application(): z.infer<typeof CredentialApplication> | null {
if (!this.identity) {
return null;
}
return this.applications[this.identity.instance.domain] || null;
},
token(): z.infer<typeof Token> | null {
return this.identity?.token || null;
},
permissions(): RolePermission[] {
const roles = this.account?.roles ?? [];
return roles
.flatMap((r) => r.permissions)
.filter((p, i, arr) => arr.indexOf(p) === i);
},
isSignedIn(state): boolean {
return state.activeIdentityId !== null;
},
client(): Client {
const apiHost = window.location.origin;
const domain = this.identity?.instance.domain;
return new Client(
domain ? new URL(`https://${domain}`) : new URL(apiHost),
this.identity?.token.access_token ?? undefined,
{
globalCatch: (error) => {
toast.error(
error.response?.data.error ??
"No error message provided",
);
},
throwOnError: false,
},
);
},
},
actions: {
setActiveIdentity(id: string | null) {
this.activeIdentityId = id;
},
updateActiveIdentity(data: Partial<Identity>) {
if (this.activeIdentityId) {
this.$patch({
identities: this.identities.map((id) =>
id.id === this.activeIdentityId
? { ...id, ...data }
: id,
),
});
}
},
async createApp(
origin: URL,
): Promise<z.infer<typeof CredentialApplication>> {
const redirectUri = new URL(
`/callback?${new URLSearchParams({ domain: origin.host }).toString()}`,
useRequestURL().origin,
);
const client = new Client(origin);
const output = await client.createApp("Versia-FE", {
scopes: ["read", "write", "follow", "push"],
redirect_uris: redirectUri.href,
// @ts-expect-error Package.json types are missing this field
website: pkg.homepage ?? undefined,
});
this.applications[origin.host] = output.data;
return output.data;
},
async startSignIn(origin: URL) {
const client = new Client(origin);
const appData =
this.applications[origin.host] ??
(await this.createApp(origin));
const url = await client.generateAuthUrl(
appData.client_id,
appData.client_secret,
{
scopes: ["read", "write", "follow", "push"],
redirect_uri: appData.redirect_uris[0],
},
);
window.location.href = url;
},
async finishSignIn(code: string, origin: URL): Promise<void> {
const appData = this.applications[origin.host];
if (!appData) {
toast.error(`No application data found for ${origin.host}`);
return;
}
const client = new Client(origin);
const token = await client.fetchAccessToken(
appData.client_id,
appData.client_secret,
code,
appData.redirect_uris[0],
);
const authClient = new Client(origin, token.data.access_token);
const [account, instance, emojis] = await Promise.all([
authClient.verifyAccountCredentials(),
authClient.getInstance(),
authClient.getInstanceCustomEmojis(),
]);
if (
!this.identities.find((i) => i.account.id === account.data.id)
) {
const newIdentity: Identity = {
id: crypto.randomUUID(),
token: token.data,
account: account.data,
instance: instance.data,
emojis: emojis.data,
};
this.identities.push(newIdentity);
this.activeIdentityId = newIdentity.id;
} else {
this.activeIdentityId = this.identities.find(
(i) => i.account.id === account.data.id,
)?.id as string;
}
},
async signOut(identityId?: string) {
const id = identityId ?? this.activeIdentityId;
const identity = this.identities.find((i) => i.id === id);
if (!identity) {
return;
}
const appData = this.applications[identity.instance.domain];
if (!appData) {
return;
}
await this.client.revokeToken(
appData.client_id,
appData.client_secret,
identity.token.access_token,
);
if (this.activeIdentityId === id) {
this.activeIdentityId = null;
}
this.identities = this.identities.filter((i) => i.id !== id);
},
},
persist: {
storage: localStorage,
},
});

260
app/stores/composer.ts Normal file
View file

@ -0,0 +1,260 @@
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 || "";
console.log(note.media_attachments);
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;
}
},
},
});