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

View file

@ -1,9 +1,7 @@
import type { Client } from "@versia/client";
import type { Account } from "@versia/client/schemas";
import type { z } from "zod";
export const useAccountFromAcct = (
client: MaybeRef<Client | null>,
acct: string,
): {
account: Ref<z.infer<typeof Account> | null>;
@ -11,13 +9,12 @@ export const useAccountFromAcct = (
} => {
const output = ref(null as z.infer<typeof Account> | null);
const isLoading = ref(true);
const authStore = useAuthStore();
ref(client)
.value?.lookupAccount(acct)
.then((res) => {
isLoading.value = false;
output.value = res.data;
});
authStore.client.lookupAccount(acct).then((res) => {
isLoading.value = false;
output.value = res.data;
});
return { account: output, isLoading };
};

View file

@ -1,16 +1,16 @@
import type { Client } from "@versia/client";
import type { Status } from "@versia/client/schemas";
import type { z } from "zod";
import { type TimelineOptions, useTimeline } from "./Timeline";
export function useAccountTimeline(
client: Client,
accountId: string,
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
) {
return useTimeline(client, {
fetchFunction: (client, opts) =>
client.getAccountStatuses(accountId, opts),
const authStore = useAuthStore();
return useTimeline({
fetchFunction: (opts) =>
authStore.client.getAccountStatuses(accountId, opts),
...options,
});
}

View file

@ -1,13 +0,0 @@
import type { CredentialApplication } from "@versia/client/schemas";
import { StorageSerializers } from "@vueuse/core";
import type { z } from "zod";
export const useAppData = () => {
return useLocalStorage<z.infer<typeof CredentialApplication> | null>(
"versia:app_data",
null,
{
serializer: StorageSerializers.object,
},
);
};

View file

@ -1,66 +1,47 @@
import type { Client } from "@versia/client";
import type { RolePermission } from "@versia/client/schemas";
import { toast } from "vue-sonner";
import * as m from "~~/paraglide/messages.js";
export const useCacheRefresh = (client: MaybeRef<Client | null>) => {
export const useCacheRefresh = () => {
const authStore = useAuthStore();
const { identity } = storeToRefs(authStore);
authStore.client.getInstance().then((res) => {
authStore.updateActiveIdentity({
instance: res.data,
});
});
// Refresh custom emojis and instance data and me on every reload
watch(
[identity, client],
async () => {
console.info("Refreshing emoji, instance and account cache");
if (identity.value) {
toValue(client)
?.verifyAccountCredentials()
identity,
async (oldIdentity, newIdentity) => {
if (newIdentity && newIdentity.id !== oldIdentity?.id) {
console.info("Refreshing emoji, instance and account cache");
authStore.client
.verifyAccountCredentials()
.then((res) => {
if (identity.value) {
identity.value.account = res.data;
}
authStore.updateActiveIdentity({
account: res.data,
});
})
.catch((err) => {
const code = err.response.status;
if (code === 401) {
// Reset tokenData
identity.value = null;
authStore.setActiveIdentity(null);
toast.error(m.fancy_this_wasp_renew(), {
description: m.real_weird_deer_stop(),
});
}
});
toValue(client)
?.getInstanceCustomEmojis()
.then((res) => {
if (identity.value) {
identity.value.emojis = res.data;
}
authStore.client.getInstanceCustomEmojis().then((res) => {
authStore.updateActiveIdentity({
emojis: res.data,
});
toValue(client)
?.getAccountRoles(identity.value.account.id)
.then((res) => {
const roles = res.data;
// Get all permissions and deduplicate
const permissions = roles
?.flatMap((r) => r.permissions)
.filter((p, i, arr) => arr.indexOf(p) === i);
if (identity.value) {
identity.value.permissions =
permissions as unknown as RolePermission[];
}
});
}
toValue(client)
?.getInstance()
.then((res) => {
if (identity.value) {
identity.value.instance = res.data;
}
});
}
},
{ flush: "sync", immediate: true },
);

View file

@ -1,33 +0,0 @@
import { Client } from "@versia/client";
import type { Token } from "@versia/client/schemas";
import { toast } from "vue-sonner";
import type { z } from "zod";
export const useClient = (
origin?: MaybeRef<URL>,
customToken: MaybeRef<z.infer<typeof Token> | null> = null,
): Ref<Client> => {
const apiHost = window.location.origin;
const domain = identity.value?.instance.domain;
return ref(
new Client(
toValue(origin) ??
(domain ? new URL(`https://${domain}`) : new URL(apiHost)),
toValue(customToken)?.access_token ??
identity.value?.tokens.access_token ??
undefined,
{
globalCatch: (error) => {
toast.error(
error.response.data.error ??
"No error message provided",
);
},
throwOnError: false,
},
),
) as Ref<Client>;
};
export const client = useClient();

View file

@ -1,7 +1,6 @@
import type { Account, Attachment, Status } from "@versia/client/schemas";
import mitt from "mitt";
import type { z } from "zod";
import type { Identity } from "./Identities";
type ApplicationEvents = {
"note:reply": z.infer<typeof Status>;
@ -23,7 +22,6 @@ type ApplicationEvents = {
"account:report": z.infer<typeof Account>;
"account:update": z.infer<typeof Account>;
"attachment:view": z.infer<typeof Attachment>;
"identity:change": Identity;
"preferences:open": undefined;
error: {
code: string;

View file

@ -1,19 +1,14 @@
import type { Client } from "@versia/client";
import type { ExtendedDescription } from "@versia/client/schemas";
import type { z } from "zod";
export const useExtendedDescription = (client: MaybeRef<Client | null>) => {
if (!ref(client).value) {
return ref(null as z.infer<typeof ExtendedDescription> | null);
}
export const useExtendedDescription = () => {
const store = useAuthStore();
const output = ref(null as z.infer<typeof ExtendedDescription> | null);
ref(client)
.value?.getInstanceExtendedDescription()
.then((res) => {
output.value = res.data;
});
store.client.getInstanceExtendedDescription().then((res) => {
output.value = res.data;
});
return output;
};

View file

@ -1,15 +1,15 @@
import type { Client } from "@versia/client";
import type { Status } from "@versia/client/schemas";
import type { z } from "zod";
import { type TimelineOptions, useTimeline } from "./Timeline";
export function useGlobalTimeline(
client: Client,
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
) {
return useTimeline(client, {
const authStore = useAuthStore();
return useTimeline({
// TODO: Implement global timeline in client sdk
fetchFunction: (client, opts) => client.getPublicTimeline(opts),
fetchFunction: (opts) => authStore.client.getPublicTimeline(opts),
...options,
});
}

View file

@ -1,14 +1,14 @@
import type { Client } from "@versia/client";
import type { Status } from "@versia/client/schemas";
import type { z } from "zod";
import { type TimelineOptions, useTimeline } from "./Timeline";
export function useHomeTimeline(
client: Client,
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
) {
return useTimeline(client, {
fetchFunction: (client, opts) => client.getHomeTimeline(opts),
const authStore = useAuthStore();
return useTimeline({
fetchFunction: (opts) => authStore.client.getHomeTimeline(opts),
...options,
});
}

View file

@ -1,104 +0,0 @@
import type {
Account,
CustomEmoji,
Instance,
RolePermission,
Token,
} from "@versia/client/schemas";
import { StorageSerializers, useLocalStorage } from "@vueuse/core";
import { ref, watch } from "vue";
import type { z } from "zod";
/**
* Represents an identity with associated tokens, account, instance, permissions, and emojis.
*/
export interface Identity {
id: string;
tokens: z.infer<typeof Token>;
account: z.infer<typeof Account>;
instance: z.infer<typeof Instance>;
permissions: RolePermission[];
emojis: z.infer<typeof CustomEmoji>[];
}
/**
* Composable to manage multiple identities.
* @returns A reactive reference to an array of identities.
*/
function useIdentities(): Ref<Identity[]> {
return useLocalStorage<Identity[]>("versia:identities", [], {
serializer: StorageSerializers.object,
});
}
export const identities = useIdentities();
const currentId = useLocalStorage<string | null>(
"versia:identities:current",
null,
);
const current = ref<Identity | null>(null);
/**
* Composable to manage the current identity.
* @returns A reactive reference to the current identity or null if not set.
*/
function useCurrentIdentity(): Ref<Identity | null> {
// Initialize current identity
function updateCurrentIdentity() {
current.value =
identities.value.find((i) => i.id === currentId.value) ?? null;
}
// Watch for changes in identities
watch(
identities,
(ids) => {
if (ids.length === 0) {
current.value = null;
currentId.value = null;
} else {
updateCurrentIdentity();
}
},
{ deep: true },
);
// Watch for changes in currentId
watch(currentId, updateCurrentIdentity);
// Watch for changes in current identity
watch(
current,
(newCurrent) => {
if (newCurrent) {
currentId.value = newCurrent.id;
const index = identities.value.findIndex(
(i) => i.id === newCurrent.id,
);
if (index !== -1) {
// Update existing identity
identities.value[index] = newCurrent;
} else {
// Add new identity
identities.value.push(newCurrent);
}
} else {
// Remove current identity
identities.value = identities.value.filter(
(i) => i.id !== currentId.value,
);
currentId.value = identities.value[0]?.id ?? null;
}
},
{ deep: true },
);
// Initial setup
updateCurrentIdentity();
return current;
}
export const identity = useCurrentIdentity();

View file

@ -2,10 +2,6 @@ import type { Client } from "@versia/client";
import type { Instance, TermsOfService } from "@versia/client/schemas";
import type { z } from "zod";
export const useInstance = () => {
return computed(() => identity.value?.instance);
};
export const useInstanceFromClient = (client: MaybeRef<Client>) => {
if (!client) {
return ref(null as z.infer<typeof Instance> | null);

View file

@ -1,14 +1,14 @@
import type { Client } from "@versia/client";
import type { Status } from "@versia/client/schemas";
import type { z } from "zod";
import { type TimelineOptions, useTimeline } from "./Timeline";
export function useLocalTimeline(
client: Client,
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
) {
return useTimeline(client, {
fetchFunction: (client, opts) => client.getLocalTimeline(opts),
const authStore = useAuthStore();
return useTimeline({
fetchFunction: (opts) => authStore.client.getLocalTimeline(opts),
...options,
});
}

View file

@ -1,21 +1,19 @@
import type { Client } from "@versia/client";
import type { Status } from "@versia/client/schemas";
import type { z } from "zod";
export const useNote = (
client: MaybeRef<Client | null>,
noteId: MaybeRef<string | null>,
) => {
if (!(toValue(client) && toValue(noteId))) {
export const useNote = (noteId: MaybeRef<string | null>) => {
if (!toValue(noteId)) {
return ref(null as z.infer<typeof Status> | null);
}
const authStore = useAuthStore();
const output = ref(null as z.infer<typeof Status> | null);
watchEffect(() => {
toValue(noteId) &&
toValue(client)
?.getStatus(toValue(noteId) as string)
authStore.client
.getStatus(toValue(noteId) as string)
.then((res) => {
output.value = res.data;
});

View file

@ -1,21 +1,15 @@
import type { Client } from "@versia/client";
import type { Context } from "@versia/client/schemas";
import type { z } from "zod";
export const useNoteContext = (
client: MaybeRef<Client | null>,
noteId: MaybeRef<string | null>,
) => {
if (!ref(client).value) {
return ref(null as z.infer<typeof Context> | null);
}
export const useNoteContext = (noteId: MaybeRef<string | null>) => {
const authStore = useAuthStore();
const output = ref(null as z.infer<typeof Context> | null);
watchEffect(() => {
if (toValue(noteId)) {
ref(client)
.value?.getStatusContext(toValue(noteId) ?? "")
authStore.client
.getStatusContext(toValue(noteId) ?? "")
.then((res) => {
output.value = res.data;
});

View file

@ -4,11 +4,12 @@ import type { z } from "zod";
import { type TimelineOptions, useTimeline } from "./Timeline";
export function useNotificationTimeline(
client: Client,
options: Partial<TimelineOptions<z.infer<typeof Notification>>> = {},
) {
return useTimeline(client, {
fetchFunction: (client, opts) => client.getNotifications(opts),
const authStore = useAuthStore();
return useTimeline({
fetchFunction: (opts) => authStore.client.getNotifications(opts),
...options,
});
}

View file

@ -1,3 +0,0 @@
export const usePermissions = () => {
return computed(() => identity.value?.permissions ?? []);
};

View file

@ -1,14 +1,14 @@
import type { Client } from "@versia/client";
import type { Status } from "@versia/client/schemas";
import type { z } from "zod";
import { type TimelineOptions, useTimeline } from "./Timeline";
export function usePublicTimeline(
client: Client,
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
) {
return useTimeline(client, {
fetchFunction: (client, opts) => client.getPublicTimeline(opts),
const authStore = useAuthStore();
return useTimeline({
fetchFunction: (opts) => authStore.client.getPublicTimeline(opts),
...options,
});
}

View file

@ -1,22 +1,19 @@
import type { Client } from "@versia/client";
import type { Relationship } from "@versia/client/schemas";
import type { z } from "zod";
export const useRelationship = (
client: MaybeRef<Client | null>,
accountId: MaybeRef<string | null>,
) => {
export const useRelationship = (accountId: MaybeRef<string | null>) => {
const relationship = ref(null as z.infer<typeof Relationship> | null);
const isLoading = ref(false);
const authStore = useAuthStore();
if (!identity.value) {
if (!authStore.isSignedIn) {
return { relationship, isLoading };
}
watchEffect(() => {
if (toValue(accountId)) {
toValue(client)
?.getRelationship(toValue(accountId) ?? "")
authStore.client
.getRelationship(toValue(accountId) ?? "")
.then((res) => {
relationship.value = res.data;
});
@ -28,14 +25,14 @@ export const useRelationship = (
if (newOutput?.following !== oldOutput?.following) {
isLoading.value = true;
if (newOutput?.following) {
toValue(client)
?.followAccount(toValue(accountId) ?? "")
authStore.client
.followAccount(toValue(accountId) ?? "")
.finally(() => {
isLoading.value = false;
});
} else {
toValue(client)
?.unfollowAccount(toValue(accountId) ?? "")
authStore.client
.unfollowAccount(toValue(accountId) ?? "")
.finally(() => {
isLoading.value = false;
});

View file

@ -1,10 +0,0 @@
import type { Instance } from "@versia/client/schemas";
import type { z } from "zod";
export const useSSOConfig = (): Ref<z.infer<
typeof Instance.shape.sso
> | null> => {
const instance = useInstance();
return computed(() => instance.value?.sso || null);
};

View file

@ -4,18 +4,20 @@ import { useIntervalFn } from "@vueuse/core";
import type { z } from "zod";
export interface TimelineOptions<T> {
fetchFunction: (client: Client, options: object) => Promise<Output<T[]>>;
fetchFunction: (options: object) => Promise<Output<T[]>>;
updateInterval?: number;
limit?: number;
}
export function useTimeline<
T extends z.infer<typeof Status> | z.infer<typeof Notification>,
>(client: Client, options: TimelineOptions<T>) {
>(options: TimelineOptions<T>) {
const items = ref<T[]>([]) as Ref<T[]>;
const isLoading = ref(false);
const hasReachedEnd = ref(false);
const error = ref<Error | null>(null);
const authStore = useAuthStore();
const { identity } = storeToRefs(authStore);
const nextMaxId = ref<string | undefined>(undefined);
const prevMinId = ref<string | undefined>(undefined);
@ -29,7 +31,7 @@ export function useTimeline<
error.value = null;
try {
const response = await options.fetchFunction(client, {
const response = await options.fetchFunction({
limit: options.limit || 20,
max_id: direction === "next" ? nextMaxId.value : undefined,
min_id: direction === "prev" ? prevMinId.value : undefined,
@ -99,6 +101,17 @@ export function useTimeline<
pause();
});
watch(identity, (newIdentity, oldIdentity) => {
if (newIdentity?.id !== oldIdentity?.id) {
// Reload timeline when identity changes
items.value = [];
nextMaxId.value = undefined;
prevMinId.value = undefined;
hasReachedEnd.value = false;
error.value = null;
}
});
return {
items,
isLoading,