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
221
app/stores/auth.ts
Normal file
221
app/stores/auth.ts
Normal 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
260
app/stores/composer.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue