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

@ -32,7 +32,7 @@
<TriangleAlert class="!size-5" />
</Toggle>
</ComposerButton>
<CharacterCounter class="ml-auto" :max="(identity as Identity).instance.configuration.statuses.max_characters" :current="rawContent.length" />
<CharacterCounter class="ml-auto" :max="authStore.instance?.configuration.statuses.max_characters ?? 0" :current="rawContent.length" />
<Button type="submit" size="lg" :disabled="sending || !canSend" @click="emit('submit')">
<Loader v-if="sending" class="!size-5 animate-spin" />
{{
@ -57,7 +57,7 @@ import { Button } from "../ui/button";
import { Toggle } from "../ui/toggle";
import ComposerButton from "./button.vue";
import CharacterCounter from "./character-counter.vue";
import { type ComposerState, visibilities } from "./composer";
import { visibilities } from "./visibilities";
import VisibilityPicker from "./visibility-picker.vue";
const { relation, sending, canSend, rawContent } = defineProps<{
@ -66,6 +66,7 @@ const { relation, sending, canSend, rawContent } = defineProps<{
canSend: boolean;
rawContent: string;
}>();
const authStore = useAuthStore();
const contentType = defineModel<ComposerState["contentType"]>("contentType", {
required: true,

View file

@ -1,240 +0,0 @@
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?.instance.configuration.statuses.max_characters ?? 0;
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;
}
};

View file

@ -3,19 +3,19 @@
<Note :note="relation.note" :hide-actions="true" :small-layout="true" />
</div>
<ContentWarning v-if="state.sensitive" v-model="state.contentWarning" />
<ContentWarning v-if="store.sensitive" v-model="store.contentWarning" />
<EditorContent @paste-files="uploadFiles" v-model:content="state.content" v-model:raw-content="state.rawContent" :placeholder="getRandomSplash()"
<EditorContent @paste-files="uploadFiles" v-model:content="store.content" v-model:raw-content="store.rawContent" :placeholder="getRandomSplash()"
class="[&>.tiptap]:!border-none [&>.tiptap]:!ring-0 [&>.tiptap]:!outline-none [&>.tiptap]:rounded-none p-0 [&>.tiptap]:max-h-[50dvh] [&>.tiptap]:overflow-y-auto [&>.tiptap]:min-h-48 [&>.tiptap]:!ring-offset-0 [&>.tiptap]:h-full"
:disabled="state.sending" :mode="state.contentType === 'text/html' ? 'rich' : 'plain'" />
:disabled="store.sending" :mode="store.contentType === 'text/html' ? 'rich' : 'plain'" />
<div class="w-full flex flex-row gap-2 overflow-x-auto *:shrink-0 pb-2">
<input type="file" ref="fileInput" @change="uploadFileFromEvent" class="hidden" multiple />
<Files v-model:files="state.files" />
<Files v-model:files="store.files" :composer-key="composerKey" />
</div>
<DialogFooter class="items-center flex-row overflow-x-auto">
<ComposerButtons @submit="send" @pick-file="fileInput?.click()" v-model:content-type="state.contentType" v-model:sensitive="state.sensitive" v-model:visibility="state.visibility" :relation="state.relation" :sending="state.sending" :can-send="state.canSend" :raw-content="state.rawContent" />
<ComposerButtons @submit="send" @pick-file="fileInput?.click()" v-model:content-type="store.contentType" v-model:sensitive="store.sensitive" v-model:visibility="store.visibility" :relation="store.relation" :sending="store.sending" :can-send="store.canSend" :raw-content="store.rawContent" />
</DialogFooter>
</template>
@ -24,37 +24,53 @@ import Note from "~/components/notes/note.vue";
import EditorContent from "../editor/content.vue";
import { DialogFooter } from "../ui/dialog";
import ComposerButtons from "./buttons.vue";
import {
type ComposerState,
getRandomSplash,
send,
state,
stateFromRelation,
uploadFile,
} from "./composer";
import ContentWarning from "./content-warning.vue";
import Files from "./files.vue";
const props = defineProps<{
relation?: ComposerState["relation"];
}>();
const { Control_Enter, Command_Enter } = useMagicKeys();
const { play } = useAudio();
const fileInput = useTemplateRef<HTMLInputElement>("fileInput");
const composerKey = props.relation
? (`${props.relation.type}-${props.relation.note.id}` as const)
: "blank";
const store = useComposerStore(composerKey)();
watch([Control_Enter, Command_Enter], () => {
if (state.sending || !preferences.ctrl_enter_send.value) {
if (store.sending || !preferences.ctrl_enter_send.value) {
return;
}
send();
});
const props = defineProps<{
relation?: ComposerState["relation"];
}>();
const getRandomSplash = (): string => {
const splashes = useConfig().COMPOSER_SPLASHES;
return splashes[Math.floor(Math.random() * splashes.length)] as string;
};
const send = async () => {
const result =
store.relation?.type === "edit"
? await store.sendEdit()
: await store.send();
if (result) {
play("publish");
store.$reset();
useEvent("composer:close");
}
};
watch(
props,
async (props) => {
if (props.relation) {
await stateFromRelation(
store.stateFromRelation(
props.relation.type,
props.relation.note,
props.relation.source,
@ -69,7 +85,7 @@ const uploadFileFromEvent = (e: Event) => {
const files = Array.from(target.files ?? []);
for (const file of files) {
uploadFile(file);
store.uploadFile(file);
}
target.value = "";
@ -77,7 +93,7 @@ const uploadFileFromEvent = (e: Event) => {
const uploadFiles = (files: File[]) => {
for (const file of files) {
uploadFile(file);
store.uploadFile(file);
}
};
</script>

View file

@ -11,8 +11,10 @@ import {
import * as m from "~~/paraglide/messages.js";
import Composer from "./composer.vue";
const authStore = useAuthStore();
useListen("composer:open", () => {
if (identity.value) {
if (authStore.isSignedIn) {
open.value = true;
}
});
@ -21,7 +23,7 @@ useListen("composer:edit", async (note) => {
const id = toast.loading(m.wise_late_fireant_walk(), {
duration: 0,
});
const { data: source } = await client.value.getStatusSource(note.id);
const { data: source } = await authStore.client.getStatusSource(note.id);
relation.value = {
type: "edit",
note,

View file

@ -5,22 +5,19 @@
:disabled="file.uploading || file.updating"
class="block bg-card text-card-foreground shadow-sm h-28 overflow-hidden rounded relative min-w-28 *:disabled:opacity-50"
>
<img :src="createObjectURL(file.file)" class="object-contain h-28 w-full" :alt="file.alt" />
<img v-if="file.file?.type.startsWith('image/')" :src="createObjectURL(file.file)" class="object-contain h-28 w-full" :alt="file.alt" />
<FileIcon v-else class="size-6 m-auto text-muted-foreground" />
<Badge
v-if="!(file.uploading || file.updating)"
v-if="file.file && !(file.uploading || file.updating)"
class="absolute bottom-1 right-1"
variant="default"
>{{ formatBytes(file.file.size) }}</Badge
>
<Spinner v-else class="absolute bottom-1 right-1 size-8 p-1.5" />
<Spinner v-else-if="file.file" class="absolute bottom-1 right-1 size-8 p-1.5" />
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-48">
<DropdownMenuLabel>{{ file.file.name }}</DropdownMenuLabel>
<DropdownMenuLabel v-if="file.file">{{ file.file.name }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem @click="editName">
<TextCursorInput />
Rename
</DropdownMenuItem>
<DropdownMenuItem @click="editCaption">
<Captions />
Add caption
@ -35,7 +32,7 @@
</template>
<script lang="ts" setup>
import { Captions, Delete, TextCursorInput } from "lucide-vue-next";
import { Captions, Delete, FileIcon } from "lucide-vue-next";
import Spinner from "~/components/graphics/spinner.vue";
import { confirmModalService } from "~/components/modals/composable.ts";
import { Badge } from "~/components/ui/badge";
@ -47,45 +44,22 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import type { ComposerState } from "./composer";
import type { ComposerStateKey } from "~/stores/composer";
const file = defineModel<ComposerState["files"][number]>("file", {
const { composerKey } = defineProps<{
composerKey: ComposerStateKey;
}>();
const file = defineModel<ComposerFile>("file", {
required: true,
});
const composerStore = useComposerStore(composerKey)();
const emit = defineEmits<{
remove: [];
}>();
const editName = async () => {
const result = await confirmModalService.confirm({
title: "Enter a new name",
defaultValue: file.value.file.name,
confirmText: "Edit",
inputType: "text",
});
if (result.confirmed) {
file.value.updating = true;
file.value.file = new File(
[file.value.file],
result.value ?? file.value.file.name,
{
type: file.value.file.type,
lastModified: file.value.file.lastModified,
},
);
try {
await client.value.updateMedia(file.value.apiId ?? "", {
file: file.value.file,
});
} finally {
file.value.updating = false;
}
}
};
const editCaption = async () => {
const result = await confirmModalService.confirm({
title: "Enter a caption",
@ -97,16 +71,10 @@ const editCaption = async () => {
});
if (result.confirmed) {
file.value.updating = true;
file.value.alt = result.value;
try {
await client.value.updateMedia(file.value.apiId ?? "", {
description: file.value.alt,
});
} finally {
file.value.updating = false;
}
await composerStore.updateFileDescription(
file.value.id,
result.value ?? "",
);
}
};

View file

@ -1,13 +1,17 @@
<template>
<FilePreview v-for="(file, index) in files" :key="file.apiId" :file="file" @update:file="files[index] = $event"
<FilePreview v-for="(file, index) in files" :key="file.apiId" :file="file" @update:file="files[index] = $event" :composer-key="composerKey"
@remove="files.splice(index, 1)" />
</template>
<script lang="ts" setup>
import type { ComposerState } from "./composer";
import type { ComposerStateKey } from "~/stores/composer";
import FilePreview from "./file-preview.vue";
const files = defineModel<ComposerState["files"]>("files", {
const { composerKey } = defineProps<{
composerKey: ComposerStateKey;
}>();
const files = defineModel<ComposerFile[]>("files", {
required: true,
});
</script>

View file

@ -0,0 +1,35 @@
import type { Status } from "@versia/client/schemas";
import { AtSign, Globe, Lock, LockOpen } from "lucide-vue-next";
import type { FunctionalComponent } from "vue";
import type { z } from "zod";
import * as m from "~~/paraglide/messages.js";
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(),
},
};

View file

@ -18,14 +18,13 @@
</template>
<script lang="ts" setup>
import { Button } from "~/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "~/components/ui/select";
import { type ComposerState, visibilities } from "./composer";
import { visibilities } from "./visibilities";
const visibility = defineModel<ComposerState["visibility"]>("visibility", {
required: true,

View file

@ -68,9 +68,10 @@ watch(active, (value) => {
</script>
<template>
<BubbleMenu :editor="editor" class="bg-popover rounded-md">
<BubbleMenu :editor="editor">
<ToggleGroup type="multiple"
v-model="active"
class="bg-popover rounded-md"
>
<ToggleGroupItem value="bold">
<BoldIcon />

View file

@ -29,6 +29,7 @@ const {
mode?: "rich" | "plain";
disabled?: boolean;
}>();
const authStore = useAuthStore();
const emit = defineEmits<{
pasteFiles: [files: File[]];
@ -53,13 +54,13 @@ const editor = new Editor({
}),
Emoji.configure({
emojis: emojis.concat(
identity.value?.emojis.map((emoji) => ({
authStore.emojis.map((emoji) => ({
name: emoji.shortcode,
shortcodes: [emoji.shortcode],
group: emoji.category ?? undefined,
tags: [],
fallbackImage: emoji.url,
})) || [],
})),
),
HTMLAttributes: {
class: "emoji not-prose",

View file

@ -26,14 +26,13 @@ const { items, command } = defineProps<{
items: string[];
command: (value: { name: string }) => void;
}>();
const authStore = useAuthStore();
const selectedIndex = ref(0);
const emojis = computed(() => {
return items
.map((item) => {
return identity.value?.emojis.find(
(emoji) => emoji.shortcode === item,
);
return authStore.emojis.find((emoji) => emoji.shortcode === item);
})
.filter((emoji) => emoji !== undefined);
});

View file

@ -14,6 +14,8 @@ export type UserData = {
value: z.infer<typeof Account>;
};
const authStore = useAuthStore();
const updatePosition = (editor: Editor, element: HTMLElement): void => {
const virtualElement = {
getBoundingClientRect: () =>
@ -42,7 +44,9 @@ export const mentionSuggestion = {
return [];
}
const users = await client.value.searchAccount(query, { limit: 20 });
const users = await authStore.client.searchAccount(query, {
limit: 20,
});
return go(
query,
@ -122,7 +126,7 @@ export const emojiSuggestion = {
return [];
}
const emojis = (identity.value as Identity).emojis;
const emojis = authStore.emojis;
return go(
query,

View file

@ -17,12 +17,23 @@
<script lang="ts" setup>
import { LogIn } from "lucide-vue-next";
import { toast } from "vue-sonner";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { Button } from "~/components/ui/button";
import * as m from "~~/paraglide/messages.js";
const appData = useAppData();
const signInAction = async () => signIn(appData, await askForInstance());
const authStore = useAuthStore();
const signInAction = async () => {
const instance = await askForInstance();
const id = toast.loading(m.level_due_ox_greet());
try {
await authStore.startSignIn(instance);
} catch (e) {
toast.dismiss(id);
}
};
</script>
<style></style>

View file

@ -2,7 +2,7 @@
<Tabs v-model:model-value="current">
<TabsList>
<TabsTrigger v-for="timeline in timelines.filter(
i => i.requiresLogin ? !!identity : true,
i => i.requiresLogin ? authStore.isSignedIn : true,
)" :key="timeline.value" :value="timeline.value" :as="NuxtLink" :href="timeline.url">
{{ timeline.name }}
</TabsTrigger>
@ -47,12 +47,12 @@ const timelines = [
},
];
const { beforeEach } = useRouter();
const { path } = useRoute();
const authStore = useAuthStore();
const current = computed(() => {
if (path === "/") {
return identity.value ? "home" : "public";
return authStore.isSignedIn ? "home" : "public";
}
const timeline = timelines.find((i) => i.url === path);

View file

@ -1,17 +1,17 @@
<template>
<div class="flex flex-row w-full max-w-sm items-stretch justify-between">
<ActionButton :icon="Reply" @click="emit('reply')" :title="m.drab_tense_turtle_comfort()" :disabled="!identity">
<ActionButton :icon="Reply" @click="emit('reply')" :title="m.drab_tense_turtle_comfort()" :disabled="!authStore.isSignedIn">
{{ numberFormat(replyCount) }}
</ActionButton>
<ActionButton :icon="Heart" @click="liked ? unlike() : like()" :title="liked ? m.vexed_fluffy_clownfish_dance() : m.royal_close_samuel_scold()" :disabled="!identity" :class="liked && '*:fill-red-600 *:text-red-600'">
<ActionButton :icon="Heart" @click="liked ? unlike() : like()" :title="liked ? m.vexed_fluffy_clownfish_dance() : m.royal_close_samuel_scold()" :disabled="!authStore.isSignedIn" :class="liked && '*:fill-red-600 *:text-red-600'">
{{ numberFormat(likeCount) }}
</ActionButton>
<ActionButton :icon="Repeat" @click="reblogged ? unreblog() : reblog()" :title="reblogged ? m.lime_neat_ox_stab() : m.aware_helpful_marlin_drop()" :disabled="!identity" :class="reblogged && '*:text-green-600'">
<ActionButton :icon="Repeat" @click="reblogged ? unreblog() : reblog()" :title="reblogged ? m.lime_neat_ox_stab() : m.aware_helpful_marlin_drop()" :disabled="!authStore.isSignedIn" :class="reblogged && '*:text-green-600'">
{{ numberFormat(reblogCount) }}
</ActionButton>
<ActionButton :icon="Quote" @click="emit('quote')" :title="m.true_shy_jackal_drip()" :disabled="!identity" />
<ActionButton :icon="Quote" @click="emit('quote')" :title="m.true_shy_jackal_drip()" :disabled="!authStore.isSignedIn" />
<Picker @pick="react">
<ActionButton :icon="Smile" :title="m.bald_cool_kangaroo_jump()" :disabled="!identity" />
<ActionButton :icon="Smile" :title="m.bald_cool_kangaroo_jump()" :disabled="!authStore.isSignedIn" />
</Picker>
<Menu :api-note-string="apiNoteString" :url="url" :remote-url="remoteUrl" :is-remote="isRemote" :author-id="authorId" @edit="emit('edit')" :note-id="noteId" @delete="emit('delete')">
<ActionButton :icon="Ellipsis" :title="m.busy_merry_cowfish_absorb()" />
@ -54,6 +54,7 @@ const emit = defineEmits<{
react: [];
}>();
const { play } = useAudio();
const authStore = useAuthStore();
const like = async () => {
if (preferences.confirm_actions.value.includes("like")) {
@ -71,7 +72,7 @@ const like = async () => {
play("like");
const id = toast.loading(m.slimy_candid_tiger_read());
const { data } = await client.value.favouriteStatus(noteId);
const { data } = await authStore.client.favouriteStatus(noteId);
toast.dismiss(id);
toast.success(m.mealy_slow_buzzard_commend());
useEvent("note:edit", data);
@ -92,7 +93,7 @@ const unlike = async () => {
}
const id = toast.loading(m.busy_active_leopard_strive());
const { data } = await client.value.unfavouriteStatus(noteId);
const { data } = await authStore.client.unfavouriteStatus(noteId);
toast.dismiss(id);
toast.success(m.fresh_direct_bear_affirm());
useEvent("note:edit", data);
@ -113,7 +114,7 @@ const reblog = async () => {
}
const id = toast.loading(m.late_sunny_cobra_scold());
const { data } = await client.value.reblogStatus(noteId);
const { data } = await authStore.client.reblogStatus(noteId);
toast.dismiss(id);
toast.success(m.weird_moving_hawk_lift());
useEvent(
@ -137,7 +138,7 @@ const unreblog = async () => {
}
const id = toast.loading(m.white_sharp_gorilla_embrace());
const { data } = await client.value.unreblogStatus(noteId);
const { data } = await authStore.client.unreblogStatus(noteId);
toast.dismiss(id);
toast.success(m.royal_polite_moose_catch());
useEvent("note:edit", data);
@ -149,7 +150,7 @@ const react = async (emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji) => {
? (emoji as UnicodeEmoji).unicode
: `:${(emoji as z.infer<typeof CustomEmoji>).shortcode}:`;
const { data } = await client.value.createEmojiReaction(noteId, text);
const { data } = await authStore.client.createEmojiReaction(noteId, text);
toast.dismiss(id);
toast.success(m.main_least_turtle_fall());

View file

@ -37,8 +37,8 @@ const emit = defineEmits<{
}>();
const { copy } = useClipboard();
const loggedIn = !!identity.value;
const authorIsMe = loggedIn && authorId === identity.value?.account.id;
const authStore = useAuthStore();
const authorIsMe = authStore.isSignedIn && authorId === authStore.account?.id;
const copyText = (text: string) => {
copy(text);
@ -47,7 +47,7 @@ const copyText = (text: string) => {
const blockUser = async (userId: string) => {
const id = toast.loading(m.top_cute_bison_nudge());
await client.value.blockAccount(userId);
await authStore.client.blockAccount(userId);
toast.dismiss(id);
toast.success(m.main_weary_racoon_peek());
@ -68,7 +68,7 @@ const _delete = async () => {
}
const id = toast.loading(m.new_funny_fox_boil());
await client.value.deleteStatus(noteId);
await authStore.client.deleteStatus(noteId);
toast.dismiss(id);
toast.success(m.green_tasty_bumblebee_beam());
@ -122,8 +122,8 @@ const _delete = async () => {
{{ m.tense_quick_cod_favor() }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="loggedIn && !authorIsMe" />
<DropdownMenuGroup v-if="loggedIn && !authorIsMe">
<DropdownMenuSeparator v-if="authStore.isSignedIn && !authorIsMe" />
<DropdownMenuGroup v-if="authStore.isSignedIn && !authorIsMe">
<DropdownMenuItem as="button" :disabled="true">
<Flag />
{{ m.great_few_jaguar_rise() }}

View file

@ -52,6 +52,7 @@ const emit = defineEmits<{
pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
}>();
const authStore = useAuthStore();
const open = ref(false);
const selectedEmoji = ref<z.infer<typeof CustomEmoji> | UnicodeEmoji | null>(
null,
@ -59,12 +60,10 @@ const selectedEmoji = ref<z.infer<typeof CustomEmoji> | UnicodeEmoji | null>(
const emojiContainer = useTemplateRef<HTMLDivElement>("emojiContainer");
const filter = ref("");
const customEmojis = computed(() => identity.value?.emojis ?? []);
const customEmojiCategories = computed(() => {
const categories: Record<string, z.infer<typeof CustomEmoji>[]> = {};
for (const emoji of customEmojis.value) {
for (const emoji of authStore.emojis) {
const categoryName = emoji.category || "Uncategorized";
if (!categories[categoryName]) {

View file

@ -53,6 +53,8 @@ const { reaction, emoji, statusId } = defineProps<{
emoji?: z.infer<typeof CustomEmoji>;
}>();
const authStore = useAuthStore();
const formatNumber = (number: number) =>
new Intl.NumberFormat(getLocale(), {
notation: "compact",
@ -63,12 +65,13 @@ const formatNumber = (number: number) =>
const accounts = ref<z.infer<typeof Account>[] | null>(null);
const refreshReactions = async () => {
const { data } = await client.value.getStatusReactions(statusId);
const { data } = await authStore.client.getStatusReactions(statusId);
const accountIds =
data.find((r) => r.name === reaction.name)?.account_ids.slice(0, 10) ??
[];
const { data: accountsData } = await client.value.getAccounts(accountIds);
const { data: accountsData } =
await authStore.client.getAccounts(accountIds);
accounts.value = accountsData;
};
@ -76,7 +79,7 @@ const refreshReactions = async () => {
const react = async () => {
const id = toast.loading(m.gray_stale_antelope_roam());
const { data } = await client.value.createEmojiReaction(
const { data } = await authStore.client.createEmojiReaction(
statusId,
reaction.name,
);
@ -89,7 +92,7 @@ const react = async () => {
const unreact = async () => {
const id = toast.loading(m.many_weary_bat_intend());
const { data } = await client.value.deleteEmojiReaction(
const { data } = await authStore.client.deleteEmojiReaction(
statusId,
reaction.name,
);

View file

@ -25,5 +25,5 @@ const { note } = defineProps<{
note: z.infer<typeof Status>;
}>();
const parent = useNote(client, note.in_reply_to_id);
const parent = useNote(note.in_reply_to_id);
</script>

View file

@ -45,7 +45,8 @@ const { follower } = defineProps<{
const loading = ref(true);
const followerUrl = `/@${follower.acct}`;
const [username, domain] = follower.acct.split("@");
const { relationship } = useRelationship(client, follower.id);
const { relationship } = useRelationship(follower.id);
const authStore = useAuthStore();
// TODO: Add "followed" notification
watch(relationship, () => {
@ -58,7 +59,7 @@ const accept = async () => {
const id = toast.loading(m.cool_slimy_coyote_affirm());
loading.value = true;
const { data } = await client.value.acceptFollowRequest(follower.id);
const { data } = await authStore.client.acceptFollowRequest(follower.id);
toast.dismiss(id);
toast.success(m.busy_awful_mouse_jump());
@ -70,7 +71,7 @@ const reject = async () => {
const id = toast.loading(m.front_sunny_penguin_flip());
loading.value = true;
const { data } = await client.value.rejectFollowRequest(follower.id);
const { data } = await authStore.client.rejectFollowRequest(follower.id);
toast.dismiss(id);
toast.success(m.green_flat_mayfly_trust());

View file

@ -61,7 +61,7 @@ for (const name of [
}
const issuerRedirectUrl = (issuerId: string) => {
const url = new URL("/oauth/sso", client.value.url);
const url = new URL("/oauth/sso", useRequestURL().origin);
for (const name of [
"redirect_uri",

View file

@ -25,13 +25,13 @@ const copy = (data: string) => {
toast.success("Copied to clipboard");
};
const appData = useAppData();
const authStore = useAuthStore();
const data: [string, string | VNode][] = [
["User ID", identity.value?.account.id ?? ""],
["Instance domain", identity.value?.instance.domain ?? ""],
["Instance version", identity.value?.instance.versia_version ?? ""],
["Client ID", appData.value?.client_id ?? ""],
["User ID", authStore.account?.id ?? ""],
["Instance domain", authStore.instance?.domain ?? ""],
["Instance version", authStore.instance?.versia_version ?? ""],
["Client ID", authStore.application?.client_id ?? ""],
[
"Client secret",
<Button
@ -39,7 +39,7 @@ const data: [string, string | VNode][] = [
class="font-sans"
size="sm"
// @ts-expect-error missing onClick types
onClick={() => copy(appData.value?.client_secret ?? "")}
onClick={() => copy(authStore.application?.client_secret ?? "")}
>
Click to copy
</Button>,
@ -51,7 +51,7 @@ const data: [string, string | VNode][] = [
class="font-sans"
size="sm"
// @ts-expect-error missing onClick types
onClick={() => copy(identity.value?.tokens.access_token ?? "")}
onClick={() => copy(authStore.token?.access_token ?? "")}
>
Click to copy
</Button>,

View file

@ -62,22 +62,11 @@ const categories = Object.fromEntries(
}),
);
const { account: author1 } = useAccountFromAcct(
client,
"jessew@vs.cpluspatch.com",
);
const { account: author2 } = useAccountFromAcct(
client,
"aprl@social.lysand.org",
);
const { account: author3 } = useAccountFromAcct(
client,
"lina@social.lysand.org",
);
const { account: author4 } = useAccountFromAcct(client, "nyx@v.everypizza.im");
const { account: author1 } = useAccountFromAcct("jessew@vs.cpluspatch.com");
const { account: author2 } = useAccountFromAcct("aprl@social.lysand.org");
const { account: author3 } = useAccountFromAcct("lina@social.lysand.org");
const { account: author4 } = useAccountFromAcct("nyx@v.everypizza.im");
const authStore = useAuthStore();
const open = ref(false);
@ -87,14 +76,14 @@ useListen("preferences:open", () => {
</script>
<template>
<Dialog v-model:open="open" v-if="identity">
<Dialog v-model:open="open" v-if="authStore.isSignedIn">
<DialogContent class="md:max-w-5xl w-full h-full p-0 md:max-h-[70dvh] overflow-hidden">
<Tabs class="md:grid-cols-[auto_minmax(0,1fr)] !grid gap-2 *:p-4 overflow-hidden *:overflow-y-auto *:h-full" orientation="vertical"
:default-value="pages[0]">
<DialogHeader class="gap-6 grid grid-rows-[auto_minmax(0,1fr)] border-b md:border-b-0 md:border-r min-w-60 text-left">
<div class="grid gap-3 items-center grid-cols-[auto_minmax(0,1fr)]">
<Avatar :name="identity.account.display_name || identity.account.username"
:src="identity.account.avatar" />
<Avatar :name="authStore.account!.display_name || authStore.account!.username"
:src="authStore.account!.avatar" />
<DialogTitle>Preferences</DialogTitle>
</div>
<DialogDescription class="sr-only">

View file

@ -30,14 +30,14 @@ const { emojis } = defineProps<{
emojis: z.infer<typeof CustomEmoji>[];
}>();
const permissions = usePermissions();
const authStore = useAuthStore();
const canEdit =
(!emojis.some((e) => e.global) &&
permissions.value.includes(RolePermission.ManageOwnEmojis)) ||
permissions.value.includes(RolePermission.ManageEmojis);
authStore.permissions.includes(RolePermission.ManageOwnEmojis)) ||
authStore.permissions.includes(RolePermission.ManageEmojis);
const deleteAll = async () => {
if (!identity.value) {
if (!authStore.isSignedIn) {
return;
}
@ -57,14 +57,16 @@ const deleteAll = async () => {
);
try {
await Promise.all(
emojis.map((emoji) => client.value.deleteEmoji(emoji.id)),
emojis.map((emoji) => authStore.client.deleteEmoji(emoji.id)),
);
toast.dismiss(id);
toast.success("Emojis deleted");
identity.value.emojis = identity.value.emojis.filter(
(e) => !emojis.some((emoji) => e.id === emoji.id),
);
authStore.updateActiveIdentity({
emojis: authStore.emojis.filter(
(e) => !emojis.some((emoji) => e.id === emoji.id),
),
});
} catch {
toast.dismiss(id);
}

View file

@ -42,14 +42,10 @@ const { emoji } = defineProps<{
emoji: z.infer<typeof CustomEmoji>;
}>();
const permissions = usePermissions();
const canEdit =
(!emoji.global &&
permissions.value.includes(RolePermission.ManageOwnEmojis)) ||
permissions.value.includes(RolePermission.ManageEmojis);
const authStore = useAuthStore();
const editName = async () => {
if (!identity.value) {
if (!authStore.isSignedIn) {
return;
}
@ -63,16 +59,18 @@ const editName = async () => {
if (result.confirmed) {
const id = toast.loading(m.teary_tame_gull_bless());
try {
const { data } = await client.value.updateEmoji(emoji.id, {
const { data } = await authStore.client.updateEmoji(emoji.id, {
shortcode: result.value,
});
toast.dismiss(id);
toast.success(m.gaudy_lime_bison_adore());
identity.value.emojis = identity.value.emojis.map((e) =>
e.id === emoji.id ? data : e,
);
authStore.updateActiveIdentity({
emojis: authStore.emojis.map((e) =>
e.id === emoji.id ? data : e,
),
});
} catch {
toast.dismiss(id);
}
@ -80,7 +78,7 @@ const editName = async () => {
};
const _delete = async () => {
if (!identity.value) {
if (!authStore.isSignedIn) {
return;
}
@ -93,13 +91,13 @@ const _delete = async () => {
if (confirmed) {
const id = toast.loading(m.weary_away_liger_zip());
try {
await client.value.deleteEmoji(emoji.id);
await authStore.client.deleteEmoji(emoji.id);
toast.dismiss(id);
toast.success(m.crisp_whole_canary_tear());
identity.value.emojis = identity.value.emojis.filter(
(e) => e.id !== emoji.id,
);
authStore.updateActiveIdentity({
emojis: authStore.emojis.filter((e) => e.id !== emoji.id),
});
} catch {
toast.dismiss(id);
}

View file

@ -1,6 +1,6 @@
<template>
<div v-if="emojis.length > 0" class="grow">
<Table :emojis="emojis" :can-upload="canUpload" />
<div v-if="authStore.emojis.length > 0" class="grow">
<Table :emojis="authStore.emojis" :can-upload="canUpload" />
</div>
</template>
@ -8,12 +8,10 @@
import { RolePermission } from "@versia/client/schemas";
import Table from "./table.vue";
const permissions = usePermissions();
const authStore = useAuthStore();
const canUpload = computed(
() =>
permissions.value.includes(RolePermission.ManageOwnEmojis) ||
permissions.value.includes(RolePermission.ManageEmojis),
authStore.permissions.includes(RolePermission.ManageOwnEmojis) ||
authStore.permissions.includes(RolePermission.ManageEmojis),
);
const emojis = computed(() => identity.value?.emojis ?? []);
</script>

View file

@ -189,8 +189,10 @@ import { Textarea } from "~/components/ui/textarea";
import * as m from "~~/paraglide/messages.js";
const open = ref(false);
const permissions = usePermissions();
const hasEmojiAdmin = permissions.value.includes(RolePermission.ManageEmojis);
const authStore = useAuthStore();
const hasEmojiAdmin = authStore.permissions.includes(
RolePermission.ManageEmojis,
);
const createObjectURL = URL.createObjectURL;
const formSchema = toTypedSchema(
@ -202,11 +204,11 @@ const formSchema = toTypedSchema(
.refine(
(v) =>
v.size <=
(identity.value?.instance.configuration.emojis
(authStore.instance?.configuration.emojis
.emoji_size_limit ?? Number.POSITIVE_INFINITY),
m.orange_weird_parakeet_hug({
count:
identity.value?.instance.configuration.emojis
authStore.instance?.configuration.emojis
.emoji_size_limit ?? Number.POSITIVE_INFINITY,
}),
),
@ -214,11 +216,11 @@ const formSchema = toTypedSchema(
.string()
.min(1)
.max(
identity.value?.instance.configuration.emojis
authStore.instance?.configuration.emojis
.max_shortcode_characters ?? Number.POSITIVE_INFINITY,
m.solid_inclusive_owl_hug({
count:
identity.value?.instance.configuration.emojis
authStore.instance?.configuration.emojis
.max_shortcode_characters ??
Number.POSITIVE_INFINITY,
}),
@ -237,11 +239,11 @@ const formSchema = toTypedSchema(
alt: z
.string()
.max(
identity.value?.instance.configuration.emojis
authStore.instance?.configuration.emojis
.max_description_characters ?? Number.POSITIVE_INFINITY,
m.key_ago_hound_emerge({
count:
identity.value?.instance.configuration.emojis
authStore.instance?.configuration.emojis
.max_description_characters ??
Number.POSITIVE_INFINITY,
}),
@ -254,14 +256,14 @@ const { isSubmitting, handleSubmit, values, setFieldValue } = useForm({
});
const submit = handleSubmit(async (values) => {
if (!identity.value) {
if (!authStore.isSignedIn) {
return;
}
const id = toast.loading(m.factual_gray_mouse_believe());
try {
const { data } = await client.value.uploadEmoji(
const { data } = await authStore.client.uploadEmoji(
values.shortcode,
values.image,
{
@ -274,7 +276,10 @@ const submit = handleSubmit(async (values) => {
toast.dismiss(id);
toast.success(m.cool_trite_gull_quiz());
identity.value.emojis = [...identity.value.emojis, data];
authStore.updateActiveIdentity({
emojis: [...authStore.emojis, data],
});
open.value = false;
} catch {
toast.dismiss(id);

View file

@ -1,10 +1,11 @@
import { toTypedSchema } from "@vee-validate/zod";
import type { Instance } from "@versia/client/schemas";
import { z } from "zod";
import * as m from "~~/paraglide/messages.js";
const characterRegex = new RegExp(/^[a-z0-9_-]+$/);
export const formSchema = (identity: Identity) =>
export const formSchema = (instance: z.infer<typeof Instance>) =>
toTypedSchema(
z.strictObject({
banner: z
@ -12,11 +13,10 @@ export const formSchema = (identity: Identity) =>
.refine(
(v) =>
v.size <=
(identity.instance.configuration.accounts
.header_limit ?? Number.POSITIVE_INFINITY),
(instance.configuration.accounts.header_limit ??
Number.POSITIVE_INFINITY),
m.civil_icy_ant_mend({
size: identity.instance.configuration.accounts
.header_limit,
size: instance.configuration.accounts.header_limit,
}),
)
.optional(),
@ -25,11 +25,10 @@ export const formSchema = (identity: Identity) =>
.refine(
(v) =>
v.size <=
(identity.instance.configuration.accounts
.avatar_limit ?? Number.POSITIVE_INFINITY),
(instance.configuration.accounts.avatar_limit ??
Number.POSITIVE_INFINITY),
m.zippy_caring_raven_edit({
size: identity.instance.configuration.accounts
.avatar_limit,
size: instance.configuration.accounts.avatar_limit,
}),
)
.or(z.string().url())
@ -37,22 +36,15 @@ export const formSchema = (identity: Identity) =>
name: z
.string()
.max(
identity.instance.configuration.accounts
.max_displayname_characters,
instance.configuration.accounts.max_displayname_characters,
),
username: z
.string()
.regex(characterRegex, m.still_upper_otter_dine())
.max(
identity.instance.configuration.accounts
.max_username_characters,
),
.max(instance.configuration.accounts.max_username_characters),
bio: z
.string()
.max(
identity.instance.configuration.accounts
.max_note_characters,
),
.max(instance.configuration.accounts.max_note_characters),
bot: z.boolean().default(false),
locked: z.boolean().default(false),
discoverable: z.boolean().default(true),

View file

@ -1,5 +1,5 @@
<template>
<form v-if="identity" class="grid gap-6" @submit="save">
<form class="grid gap-6" @submit="save">
<Transition name="slide-up">
<Alert v-if="dirty" layout="button" class="absolute bottom-2 z-10 inset-x-2 w-[calc(100%-1rem)]">
<SaveOff class="size-4" />
@ -19,7 +19,7 @@
<FormField v-slot="{ setValue }" name="avatar">
<TextInput :title="m.safe_icy_bulldog_quell()">
<ImageUploader v-model:image="identity.account.avatar" @submit-file="(file) => setValue(file)"
<ImageUploader v-model:image="authStore.account!.avatar" @submit-file="(file) => setValue(file)"
@submit-url="(url) => setValue(url)" />
</TextInput>
</FormField>
@ -85,25 +85,25 @@ import ImageUploader from "./profile/image-uploader.vue";
const dirty = computed(() => form.meta.value.dirty);
const submitting = ref(false);
const authStore = useAuthStore();
if (!identity.value) {
throw new Error("Identity not found.");
if (!(authStore.instance && authStore.account)) {
throw new Error("Not signed in.");
}
const account = computed(() => identity.value?.account as Identity["account"]);
const schema = formSchema(identity.value);
const schema = formSchema(authStore.instance);
const form = useForm({
validationSchema: schema,
initialValues: {
bio: account.value.source?.note ?? "",
bot: account.value.bot ?? false,
locked: account.value.locked ?? false,
discoverable: account.value.discoverable ?? true,
username: account.value.username,
name: account.value.display_name,
bio: authStore.account.source?.note ?? "",
bot: authStore.account.bot ?? false,
locked: authStore.account.locked ?? false,
discoverable: authStore.account.discoverable ?? true,
username: authStore.account.username,
name: authStore.account.display_name,
fields:
account.value.source?.fields.map((f) => ({
authStore.account.source?.fields.map((f) => ({
name: f.name,
value: f.value,
})) ?? [],
@ -111,7 +111,7 @@ const form = useForm({
});
const save = form.handleSubmit(async (values) => {
if (submitting.value) {
if (submitting.value || !authStore.account) {
return;
}
@ -120,25 +120,29 @@ const save = form.handleSubmit(async (values) => {
const changedData = {
display_name:
values.name === account.value.display_name
values.name === authStore.account.display_name
? undefined
: values.name,
username:
values.username === account.value.username
values.username === authStore.account.username
? undefined
: values.username,
note:
values.bio === account.value.source?.note ? undefined : values.bio,
bot: values.bot === account.value.bot ? undefined : values.bot,
values.bio === authStore.account.source?.note
? undefined
: values.bio,
bot: values.bot === authStore.account.bot ? undefined : values.bot,
locked:
values.locked === account.value.locked ? undefined : values.locked,
values.locked === authStore.account.locked
? undefined
: values.locked,
discoverable:
values.discoverable === account.value.discoverable
values.discoverable === authStore.account.discoverable
? undefined
: values.discoverable,
// Can't compare two arrays directly in JS, so we need to check if all fields are the same
fields_attributes: values.fields.every((field) =>
account.value.source?.fields?.some(
authStore.account?.source?.fields?.some(
(f) => f.name === field.name && f.value === field.value,
),
)
@ -157,7 +161,7 @@ const save = form.handleSubmit(async (values) => {
}
try {
const { data } = await client.value.updateCredentials(
const { data } = await authStore.client.updateCredentials(
Object.fromEntries(
Object.entries(changedData).filter(([, v]) => v !== undefined),
),
@ -166,9 +170,9 @@ const save = form.handleSubmit(async (values) => {
toast.dismiss(id);
toast.success(m.spry_honest_kestrel_arrive());
if (identity.value) {
identity.value.account = data;
}
authStore.updateActiveIdentity({
account: data,
});
form.resetForm({
values: {

View file

@ -33,8 +33,8 @@
{{ m.active_trite_lark_inspire() }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="isLoggedIn && !isMe" />
<DropdownMenuGroup v-if="isLoggedIn && !isMe">
<DropdownMenuSeparator v-if="authStore.isSignedIn && !isMe" />
<DropdownMenuGroup v-if="authStore.isSignedIn && !isMe">
<DropdownMenuItem as="button" @click="muteUser(account.id)">
<VolumeX />
{{ m.spare_wild_mole_intend() }}
@ -51,8 +51,8 @@
{{ m.slow_chunky_chipmunk_hush() }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator v-if="isLoggedIn && !isMe" />
<DropdownMenuGroup v-if="isLoggedIn && !isMe">
<DropdownMenuSeparator v-if="authStore.isSignedIn && !isMe" />
<DropdownMenuGroup v-if="authStore.isSignedIn && !isMe">
<DropdownMenuItem as="button" :disabled="true">
<Flag />
{{ m.great_few_jaguar_rise() }}
@ -91,8 +91,8 @@ const { account } = defineProps<{
account: z.infer<typeof Account>;
}>();
const isMe = identity.value?.account.id === account.id;
const isLoggedIn = !!identity.value;
const authStore = useAuthStore();
const isMe = authStore.account?.id === account.id;
const { copy } = useClipboard();
const copyText = (text: string) => {
@ -105,7 +105,7 @@ const isRemote = account.acct.includes("@");
const muteUser = async (userId: string) => {
const id = toast.loading(m.ornate_tidy_coyote_grow());
await client.value.muteAccount(userId);
await authStore.client.muteAccount(userId);
toast.dismiss(id);
toast.success("User muted");
@ -113,7 +113,7 @@ const muteUser = async (userId: string) => {
const blockUser = async (userId: string) => {
const id = toast.loading(m.empty_smug_raven_bloom());
await client.value.blockAccount(userId);
await authStore.client.blockAccount(userId);
toast.dismiss(id);
toast.success("User blocked");
@ -121,7 +121,7 @@ const blockUser = async (userId: string) => {
const refresh = async () => {
const id = toast.loading(m.real_every_macaw_wish());
await client.value.refetchAccount(account.id);
await authStore.client.refetchAccount(account.id);
toast.dismiss(id);
toast.success(m.many_cool_fox_love());

View file

@ -33,15 +33,14 @@ import ProfileBadge from "./profile-badge.vue";
const { account } = defineProps<{
account: z.infer<typeof Account>;
}>();
const authStore = useAuthStore();
const config = useConfig();
const roles = account.roles.filter((r) => r.visible);
// Get user handle in username@instance format
const handle = account.acct.includes("@")
? account.acct
: `${account.acct}@${
identity.value?.instance.domain ?? window.location.host
}`;
: `${account.acct}@${authStore.instance?.domain ?? window.location.host}`;
const isDeveloper = config.DEVELOPER_HANDLES.includes(handle);
</script>

View file

@ -1,5 +1,5 @@
<template>
<Button variant="secondary" :disabled="isLoading || relationship?.requested" v-if="!isMe && identity"
<Button variant="secondary" :disabled="isLoading || relationship?.requested" v-if="!isMe && authStore.isSignedIn"
@click="relationship?.following ? unfollow() : follow()">
<Loader v-if="isLoading" class="animate-spin" />
<span v-else>
@ -27,8 +27,9 @@ const { account } = defineProps<{
account: z.infer<typeof Account>;
}>();
const { relationship, isLoading } = useRelationship(client, account.id);
const isMe = identity.value?.account.id === account.id;
const { relationship, isLoading } = useRelationship(account.id);
const authStore = useAuthStore();
const isMe = authStore.account?.id === account.id;
const follow = async () => {
if (preferences.confirm_actions.value.includes("follow")) {
@ -47,7 +48,7 @@ const follow = async () => {
}
const id = toast.loading(m.quick_basic_peacock_bubble());
const { data } = await client.value.followAccount(account.id);
const { data } = await authStore.client.followAccount(account.id);
toast.dismiss(id);
relationship.value = data;
@ -71,7 +72,7 @@ const unfollow = async () => {
}
const id = toast.loading(m.big_safe_guppy_mix());
const { data } = await client.value.unfollowAccount(account.id);
const { data } = await authStore.client.unfollowAccount(account.id);
toast.dismiss(id);
relationship.value = data;

View file

@ -10,15 +10,15 @@
Manage your accounts and settings.
</DialogDescription>
</DialogHeader>
<div v-if="identities.length > 0" class="grid gap-4 py-2">
<div v-for="identity of identities" :key="identity.account.id"
<div v-if="authStore.identities.length > 0" class="grid gap-4 py-2">
<div v-for="identity of authStore.identities" :key="identity.account.id"
class="grid grid-cols-[1fr_auto] has-[>[data-switch]]:grid-cols-[1fr_auto_auto] gap-2">
<TinyCard :account="identity.account" :domain="identity.instance.domain" naked />
<Button data-switch v-if="currentIdentity?.id !== identity.id"
@click="switchAccount(identity.account.id)" variant="outline">
<Button data-switch v-if="authStore.identity?.id !== identity.id"
@click="authStore.setActiveIdentity(identity.id)" variant="outline">
Switch
</Button>
<Button @click="signOut(appData, identity)" variant="outline" size="icon"
<Button @click="signOutAction(identity.id)" variant="outline" size="icon"
:title="m.sharp_big_mallard_reap()">
<LogOut />
</Button>
@ -47,7 +47,6 @@
import { LogIn, LogOut, UserPlus } from "lucide-vue-next";
import { toast } from "vue-sonner";
import { NuxtLink } from "#components";
import { identity as currentIdentity } from "#imports";
import TinyCard from "~/components/profiles/tiny-card.vue";
import { Button } from "~/components/ui/button";
import {
@ -61,31 +60,25 @@ import {
} from "~/components/ui/dialog";
import * as m from "~~/paraglide/messages.js";
const appData = useAppData();
const authStore = useAuthStore();
const signInAction = async () => {
const instance = await askForInstance();
const signInAction = async () => signIn(appData, await askForInstance());
const id = toast.loading(m.level_due_ox_greet());
const switchAccount = async (userId: string) => {
if (userId === currentIdentity.value?.account.id) {
return await navigateTo(`/@${currentIdentity.value.account.username}`);
}
const id = toast.loading("Switching account...");
const identityToSwitch = identities.value.find(
(i) => i.account.id === userId,
);
if (!identityToSwitch) {
try {
await authStore.startSignIn(instance);
} catch (e) {
console.error(e);
toast.dismiss(id);
toast.error("No identity to switch to");
return;
}
};
currentIdentity.value = identityToSwitch;
const signOutAction = async (identityId: string) => {
const id = toast.loading("Signing out...");
await authStore.signOut(identityId);
toast.dismiss(id);
toast.success("Switched account");
window.location.href = "/";
toast.success("Signed out");
};
</script>

View file

@ -18,6 +18,7 @@ import * as m from "~~/paraglide/messages.js";
import AccountManager from "../account/account-manager.vue";
const { $pwa } = useNuxtApp();
const authStore = useAuthStore();
</script>
<template>
@ -25,8 +26,8 @@ const { $pwa } = useNuxtApp();
<SidebarMenu class="gap-3">
<SidebarMenuItem>
<AccountManager>
<SidebarMenuButton v-if="identity" size="lg">
<TinyCard :account="identity.account" :domain="identity.instance.domain" naked />
<SidebarMenuButton v-if="authStore.account && authStore.instance" size="lg">
<TinyCard :account="authStore.account" :domain="authStore.instance.domain" naked />
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
<SidebarMenuButton v-else>
@ -37,14 +38,14 @@ const { $pwa } = useNuxtApp();
</AccountManager>
</SidebarMenuItem>
<SidebarMenuItem class="flex flex-col gap-2">
<Button v-if="identity" variant="default" size="lg" class="w-full group-data-[collapsible=icon]:px-4"
<Button v-if="authStore.isSignedIn" variant="default" size="lg" class="w-full group-data-[collapsible=icon]:px-4"
@click="useEvent('composer:open')">
<Pen />
<span class="group-data-[collapsible=icon]:hidden">
{{ m.salty_aloof_turkey_nudge() }}
</span>
</Button>
<Button v-if="identity" size="lg" variant="secondary" @click="useEvent('preferences:open')">
<Button v-if="authStore.isSignedIn" size="lg" variant="secondary" @click="useEvent('preferences:open')">
<Cog />
Preferences
</Button>

View file

@ -6,7 +6,7 @@ import {
SidebarMenuItem,
} from "~/components/ui/sidebar";
const instance = useInstance();
const authStore = useAuthStore();
</script>
<template>
@ -14,7 +14,7 @@ const instance = useInstance();
<SidebarMenu>
<SidebarMenuItem>
<NuxtLink href="/">
<InstanceSmallCard v-if="instance" :instance="instance" />
<InstanceSmallCard v-if="authStore.instance" :instance="authStore.instance" />
</NuxtLink>
</SidebarMenuItem>
</SidebarMenu>

View file

@ -9,7 +9,7 @@
<NavItems
:items="
sidebarConfig.other.filter((i) =>
i.requiresLogin ? !!identity : true
i.requiresLogin ? authStore.isSignedIn : true
)
"
/>
@ -33,4 +33,6 @@ import * as m from "~~/paraglide/messages.js";
import FooterActions from "./footer/footer-actions.vue";
import InstanceHeader from "./instance/instance-header.vue";
import NavItems from "./navigation/nav-items.vue";
const authStore = useAuthStore();
</script>

View file

@ -10,6 +10,7 @@ const showTimelines = computed(
["/", "/home", "/local", "/public", "/global"].includes(route.path) &&
isMd.value,
);
const authStore = useAuthStore();
</script>
<template>
@ -23,5 +24,5 @@ const showTimelines = computed(
</header>
<slot />
</main>
<RightSidebar v-if="identity" v-show="preferences.display_notifications_sidebar" />
<RightSidebar v-if="authStore.isSignedIn" v-show="preferences.display_notifications_sidebar" />
</template>

View file

@ -21,7 +21,7 @@ const {
loadPrev,
removeItem,
updateItem,
} = useAccountTimeline(client.value, props.id);
} = useAccountTimeline(props.id);
useListen("note:delete", ({ id }) => {
removeItem(id);

View file

@ -18,7 +18,7 @@ const {
loadPrev,
removeItem,
updateItem,
} = useGlobalTimeline(client.value);
} = useGlobalTimeline();
useListen("note:delete", ({ id }) => {
removeItem(id);

View file

@ -4,9 +4,9 @@
:update-item="updateItem" />
</template>
<script lang="ts" setup>import { useHomeTimeline } from "~/composables/HomeTimeline";
<script lang="ts" setup>
import { useHomeTimeline } from "~/composables/HomeTimeline";
import Timeline from "./timeline.vue";
import type { z } from "zod";
const {
error,
@ -17,7 +17,7 @@ const {
loadPrev,
removeItem,
updateItem,
} = useHomeTimeline(client.value);
} = useHomeTimeline();
useListen("note:delete", ({ id }) => {
removeItem(id);

View file

@ -18,7 +18,7 @@ const {
loadPrev,
removeItem,
updateItem,
} = useLocalTimeline(client.value);
} = useLocalTimeline();
useListen("note:delete", ({ id }) => {
removeItem(id);

View file

@ -17,5 +17,5 @@ const {
loadPrev,
removeItem,
updateItem,
} = useNotificationTimeline(client.value);
} = useNotificationTimeline();
</script>

View file

@ -17,7 +17,7 @@ const {
loadPrev,
removeItem,
updateItem,
} = usePublicTimeline(client.value);
} = usePublicTimeline();
useListen("note:delete", ({ id }) => {
removeItem(id);