mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
Compare commits
2 commits
a6db9e059d
...
e055e2bc8f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e055e2bc8f | ||
|
|
b510782a30 |
39
app/app.vue
39
app/app.vue
|
|
@ -24,14 +24,10 @@ import { TooltipProvider } from "./components/ui/tooltip";
|
||||||
//import "~/styles/mcdonalds.css";
|
//import "~/styles/mcdonalds.css";
|
||||||
|
|
||||||
const lang = useLanguage();
|
const lang = useLanguage();
|
||||||
|
const authStore = useAuthStore();
|
||||||
overwriteGetLocale(() => lang.value);
|
overwriteGetLocale(() => lang.value);
|
||||||
|
|
||||||
const code = useRequestURL().searchParams.get("code");
|
const description = useExtendedDescription();
|
||||||
const origin = useRequestURL().searchParams.get("origin");
|
|
||||||
const appData = useAppData();
|
|
||||||
const instance = useInstance();
|
|
||||||
const description = useExtendedDescription(client);
|
|
||||||
const route = useRoute();
|
|
||||||
|
|
||||||
// Theme switcher
|
// Theme switcher
|
||||||
const colorMode = useColorMode();
|
const colorMode = useColorMode();
|
||||||
|
|
@ -62,13 +58,13 @@ useSeoMeta({
|
||||||
titleTemplate: (titleChunk) => {
|
titleTemplate: (titleChunk) => {
|
||||||
return titleChunk ? `${titleChunk} · Versia` : "Versia";
|
return titleChunk ? `${titleChunk} · Versia` : "Versia";
|
||||||
},
|
},
|
||||||
title: computed(() => instance.value?.title ?? ""),
|
title: computed(() => authStore.instance?.title ?? ""),
|
||||||
ogImage: computed(() => instance.value?.banner?.url),
|
ogImage: computed(() => authStore.instance?.banner?.url),
|
||||||
twitterTitle: computed(() => instance.value?.title ?? ""),
|
twitterTitle: computed(() => authStore.instance?.title ?? ""),
|
||||||
twitterDescription: computed(() =>
|
twitterDescription: computed(() =>
|
||||||
convert(description.value?.content ?? ""),
|
convert(description.value?.content ?? ""),
|
||||||
),
|
),
|
||||||
twitterImage: computed(() => instance.value?.banner?.url),
|
twitterImage: computed(() => authStore.instance?.banner?.url),
|
||||||
description: computed(() => convert(description.value?.content ?? "")),
|
description: computed(() => convert(description.value?.content ?? "")),
|
||||||
ogDescription: computed(() => convert(description.value?.content ?? "")),
|
ogDescription: computed(() => convert(description.value?.content ?? "")),
|
||||||
ogSiteName: "Versia",
|
ogSiteName: "Versia",
|
||||||
|
|
@ -82,28 +78,7 @@ useHead({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (code && origin && appData.value && route.path !== "/oauth/code") {
|
useCacheRefresh();
|
||||||
const newOrigin = new URL(
|
|
||||||
URL.canParse(origin) ? origin : `https://${origin}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
signInWithCode(code, appData.value, newOrigin);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (origin && !code) {
|
|
||||||
const newOrigin = new URL(
|
|
||||||
URL.canParse(origin) ? origin : `https://${origin}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
signIn(appData, newOrigin);
|
|
||||||
}
|
|
||||||
|
|
||||||
useListen("identity:change", (newIdentity) => {
|
|
||||||
identity.value = newIdentity;
|
|
||||||
window.location.pathname = "/";
|
|
||||||
});
|
|
||||||
|
|
||||||
useCacheRefresh(client);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
<TriangleAlert class="!size-5" />
|
<TriangleAlert class="!size-5" />
|
||||||
</Toggle>
|
</Toggle>
|
||||||
</ComposerButton>
|
</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')">
|
<Button type="submit" size="lg" :disabled="sending || !canSend" @click="emit('submit')">
|
||||||
<Loader v-if="sending" class="!size-5 animate-spin" />
|
<Loader v-if="sending" class="!size-5 animate-spin" />
|
||||||
{{
|
{{
|
||||||
|
|
@ -57,7 +57,7 @@ import { Button } from "../ui/button";
|
||||||
import { Toggle } from "../ui/toggle";
|
import { Toggle } from "../ui/toggle";
|
||||||
import ComposerButton from "./button.vue";
|
import ComposerButton from "./button.vue";
|
||||||
import CharacterCounter from "./character-counter.vue";
|
import CharacterCounter from "./character-counter.vue";
|
||||||
import { type ComposerState, visibilities } from "./composer";
|
import { visibilities } from "./visibilities";
|
||||||
import VisibilityPicker from "./visibility-picker.vue";
|
import VisibilityPicker from "./visibility-picker.vue";
|
||||||
|
|
||||||
const { relation, sending, canSend, rawContent } = defineProps<{
|
const { relation, sending, canSend, rawContent } = defineProps<{
|
||||||
|
|
@ -66,6 +66,7 @@ const { relation, sending, canSend, rawContent } = defineProps<{
|
||||||
canSend: boolean;
|
canSend: boolean;
|
||||||
rawContent: string;
|
rawContent: string;
|
||||||
}>();
|
}>();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const contentType = defineModel<ComposerState["contentType"]>("contentType", {
|
const contentType = defineModel<ComposerState["contentType"]>("contentType", {
|
||||||
required: true,
|
required: true,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -3,19 +3,19 @@
|
||||||
<Note :note="relation.note" :hide-actions="true" :small-layout="true" />
|
<Note :note="relation.note" :hide-actions="true" :small-layout="true" />
|
||||||
</div>
|
</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"
|
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">
|
<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 />
|
<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>
|
</div>
|
||||||
|
|
||||||
<DialogFooter class="items-center flex-row overflow-x-auto">
|
<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>
|
</DialogFooter>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -24,37 +24,53 @@ import Note from "~/components/notes/note.vue";
|
||||||
import EditorContent from "../editor/content.vue";
|
import EditorContent from "../editor/content.vue";
|
||||||
import { DialogFooter } from "../ui/dialog";
|
import { DialogFooter } from "../ui/dialog";
|
||||||
import ComposerButtons from "./buttons.vue";
|
import ComposerButtons from "./buttons.vue";
|
||||||
import {
|
|
||||||
type ComposerState,
|
|
||||||
getRandomSplash,
|
|
||||||
send,
|
|
||||||
state,
|
|
||||||
stateFromRelation,
|
|
||||||
uploadFile,
|
|
||||||
} from "./composer";
|
|
||||||
import ContentWarning from "./content-warning.vue";
|
import ContentWarning from "./content-warning.vue";
|
||||||
import Files from "./files.vue";
|
import Files from "./files.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
relation?: ComposerState["relation"];
|
||||||
|
}>();
|
||||||
|
|
||||||
const { Control_Enter, Command_Enter } = useMagicKeys();
|
const { Control_Enter, Command_Enter } = useMagicKeys();
|
||||||
|
const { play } = useAudio();
|
||||||
const fileInput = useTemplateRef<HTMLInputElement>("fileInput");
|
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], () => {
|
watch([Control_Enter, Command_Enter], () => {
|
||||||
if (state.sending || !preferences.ctrl_enter_send.value) {
|
if (store.sending || !preferences.ctrl_enter_send.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
send();
|
send();
|
||||||
});
|
});
|
||||||
|
|
||||||
const props = defineProps<{
|
const getRandomSplash = (): string => {
|
||||||
relation?: ComposerState["relation"];
|
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(
|
watch(
|
||||||
props,
|
props,
|
||||||
async (props) => {
|
async (props) => {
|
||||||
if (props.relation) {
|
if (props.relation && !store.relation) {
|
||||||
await stateFromRelation(
|
store.stateFromRelation(
|
||||||
props.relation.type,
|
props.relation.type,
|
||||||
props.relation.note,
|
props.relation.note,
|
||||||
props.relation.source,
|
props.relation.source,
|
||||||
|
|
@ -69,7 +85,7 @@ const uploadFileFromEvent = (e: Event) => {
|
||||||
const files = Array.from(target.files ?? []);
|
const files = Array.from(target.files ?? []);
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
uploadFile(file);
|
store.uploadFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
target.value = "";
|
target.value = "";
|
||||||
|
|
@ -77,7 +93,7 @@ const uploadFileFromEvent = (e: Event) => {
|
||||||
|
|
||||||
const uploadFiles = (files: File[]) => {
|
const uploadFiles = (files: File[]) => {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
uploadFile(file);
|
store.uploadFile(file);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,10 @@ import {
|
||||||
import * as m from "~~/paraglide/messages.js";
|
import * as m from "~~/paraglide/messages.js";
|
||||||
import Composer from "./composer.vue";
|
import Composer from "./composer.vue";
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
useListen("composer:open", () => {
|
useListen("composer:open", () => {
|
||||||
if (identity.value) {
|
if (authStore.isSignedIn) {
|
||||||
open.value = true;
|
open.value = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -21,7 +23,7 @@ useListen("composer:edit", async (note) => {
|
||||||
const id = toast.loading(m.wise_late_fireant_walk(), {
|
const id = toast.loading(m.wise_late_fireant_walk(), {
|
||||||
duration: 0,
|
duration: 0,
|
||||||
});
|
});
|
||||||
const { data: source } = await client.value.getStatusSource(note.id);
|
const { data: source } = await authStore.client.getStatusSource(note.id);
|
||||||
relation.value = {
|
relation.value = {
|
||||||
type: "edit",
|
type: "edit",
|
||||||
note,
|
note,
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,19 @@
|
||||||
:disabled="file.uploading || file.updating"
|
: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"
|
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
|
<Badge
|
||||||
v-if="!(file.uploading || file.updating)"
|
v-if="file.file && !(file.uploading || file.updating)"
|
||||||
class="absolute bottom-1 right-1"
|
class="absolute bottom-1 right-1"
|
||||||
variant="default"
|
variant="default"
|
||||||
>{{ formatBytes(file.file.size) }}</Badge
|
>{{ 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>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent class="min-w-48">
|
<DropdownMenuContent class="min-w-48">
|
||||||
<DropdownMenuLabel>{{ file.file.name }}</DropdownMenuLabel>
|
<DropdownMenuLabel v-if="file.file">{{ file.file.name }}</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem @click="editName">
|
|
||||||
<TextCursorInput />
|
|
||||||
Rename
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem @click="editCaption">
|
<DropdownMenuItem @click="editCaption">
|
||||||
<Captions />
|
<Captions />
|
||||||
Add caption
|
Add caption
|
||||||
|
|
@ -35,7 +32,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 Spinner from "~/components/graphics/spinner.vue";
|
||||||
import { confirmModalService } from "~/components/modals/composable.ts";
|
import { confirmModalService } from "~/components/modals/composable.ts";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
|
@ -47,45 +44,22 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "~/components/ui/dropdown-menu";
|
} 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,
|
required: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const composerStore = useComposerStore(composerKey)();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
remove: [];
|
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 editCaption = async () => {
|
||||||
const result = await confirmModalService.confirm({
|
const result = await confirmModalService.confirm({
|
||||||
title: "Enter a caption",
|
title: "Enter a caption",
|
||||||
|
|
@ -97,16 +71,10 @@ const editCaption = async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.confirmed) {
|
if (result.confirmed) {
|
||||||
file.value.updating = true;
|
await composerStore.updateFileDescription(
|
||||||
file.value.alt = result.value;
|
file.value.id,
|
||||||
|
result.value ?? "",
|
||||||
try {
|
);
|
||||||
await client.value.updateMedia(file.value.apiId ?? "", {
|
|
||||||
description: file.value.alt,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
file.value.updating = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
<template>
|
<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)" />
|
@remove="files.splice(index, 1)" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ComposerState } from "./composer";
|
import type { ComposerStateKey } from "~/stores/composer";
|
||||||
import FilePreview from "./file-preview.vue";
|
import FilePreview from "./file-preview.vue";
|
||||||
|
|
||||||
const files = defineModel<ComposerState["files"]>("files", {
|
const { composerKey } = defineProps<{
|
||||||
|
composerKey: ComposerStateKey;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const files = defineModel<ComposerFile[]>("files", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
35
app/components/composer/visibilities.ts
Normal file
35
app/components/composer/visibilities.ts
Normal 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(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -18,14 +18,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { type ComposerState, visibilities } from "./composer";
|
import { visibilities } from "./visibilities";
|
||||||
|
|
||||||
const visibility = defineModel<ComposerState["visibility"]>("visibility", {
|
const visibility = defineModel<ComposerState["visibility"]>("visibility", {
|
||||||
required: true,
|
required: true,
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,10 @@ watch(active, (value) => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BubbleMenu :editor="editor" class="bg-popover rounded-md">
|
<BubbleMenu :editor="editor">
|
||||||
<ToggleGroup type="multiple"
|
<ToggleGroup type="multiple"
|
||||||
v-model="active"
|
v-model="active"
|
||||||
|
class="bg-popover rounded-md"
|
||||||
>
|
>
|
||||||
<ToggleGroupItem value="bold">
|
<ToggleGroupItem value="bold">
|
||||||
<BoldIcon />
|
<BoldIcon />
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ const {
|
||||||
mode?: "rich" | "plain";
|
mode?: "rich" | "plain";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
pasteFiles: [files: File[]];
|
pasteFiles: [files: File[]];
|
||||||
|
|
@ -53,13 +54,13 @@ const editor = new Editor({
|
||||||
}),
|
}),
|
||||||
Emoji.configure({
|
Emoji.configure({
|
||||||
emojis: emojis.concat(
|
emojis: emojis.concat(
|
||||||
identity.value?.emojis.map((emoji) => ({
|
authStore.emojis.map((emoji) => ({
|
||||||
name: emoji.shortcode,
|
name: emoji.shortcode,
|
||||||
shortcodes: [emoji.shortcode],
|
shortcodes: [emoji.shortcode],
|
||||||
group: emoji.category ?? undefined,
|
group: emoji.category ?? undefined,
|
||||||
tags: [],
|
tags: [],
|
||||||
fallbackImage: emoji.url,
|
fallbackImage: emoji.url,
|
||||||
})) || [],
|
})),
|
||||||
),
|
),
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "emoji not-prose",
|
class: "emoji not-prose",
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,13 @@ const { items, command } = defineProps<{
|
||||||
items: string[];
|
items: string[];
|
||||||
command: (value: { name: string }) => void;
|
command: (value: { name: string }) => void;
|
||||||
}>();
|
}>();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const selectedIndex = ref(0);
|
const selectedIndex = ref(0);
|
||||||
const emojis = computed(() => {
|
const emojis = computed(() => {
|
||||||
return items
|
return items
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
return identity.value?.emojis.find(
|
return authStore.emojis.find((emoji) => emoji.shortcode === item);
|
||||||
(emoji) => emoji.shortcode === item,
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.filter((emoji) => emoji !== undefined);
|
.filter((emoji) => emoji !== undefined);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ export type UserData = {
|
||||||
value: z.infer<typeof Account>;
|
value: z.infer<typeof Account>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const updatePosition = (editor: Editor, element: HTMLElement): void => {
|
const updatePosition = (editor: Editor, element: HTMLElement): void => {
|
||||||
const virtualElement = {
|
const virtualElement = {
|
||||||
getBoundingClientRect: () =>
|
getBoundingClientRect: () =>
|
||||||
|
|
@ -42,7 +44,9 @@ export const mentionSuggestion = {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = await client.value.searchAccount(query, { limit: 20 });
|
const users = await authStore.client.searchAccount(query, {
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
return go(
|
return go(
|
||||||
query,
|
query,
|
||||||
|
|
@ -122,7 +126,7 @@ export const emojiSuggestion = {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const emojis = (identity.value as Identity).emojis;
|
const emojis = authStore.emojis;
|
||||||
|
|
||||||
return go(
|
return go(
|
||||||
query,
|
query,
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,23 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { LogIn } from "lucide-vue-next";
|
import { LogIn } from "lucide-vue-next";
|
||||||
|
import { toast } from "vue-sonner";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import * as m from "~~/paraglide/messages.js";
|
import * as m from "~~/paraglide/messages.js";
|
||||||
|
|
||||||
const appData = useAppData();
|
const authStore = useAuthStore();
|
||||||
const signInAction = async () => signIn(appData, await askForInstance());
|
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>
|
</script>
|
||||||
|
|
||||||
<style></style>
|
<style></style>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<Tabs v-model:model-value="current">
|
<Tabs v-model:model-value="current">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger v-for="timeline in timelines.filter(
|
<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">
|
)" :key="timeline.value" :value="timeline.value" :as="NuxtLink" :href="timeline.url">
|
||||||
{{ timeline.name }}
|
{{ timeline.name }}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -47,12 +47,12 @@ const timelines = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { beforeEach } = useRouter();
|
|
||||||
const { path } = useRoute();
|
const { path } = useRoute();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const current = computed(() => {
|
const current = computed(() => {
|
||||||
if (path === "/") {
|
if (path === "/") {
|
||||||
return identity.value ? "home" : "public";
|
return authStore.isSignedIn ? "home" : "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeline = timelines.find((i) => i.url === path);
|
const timeline = timelines.find((i) => i.url === path);
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-row w-full max-w-sm items-stretch justify-between">
|
<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) }}
|
{{ numberFormat(replyCount) }}
|
||||||
</ActionButton>
|
</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) }}
|
{{ numberFormat(likeCount) }}
|
||||||
</ActionButton>
|
</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) }}
|
{{ numberFormat(reblogCount) }}
|
||||||
</ActionButton>
|
</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">
|
<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>
|
</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')">
|
<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()" />
|
<ActionButton :icon="Ellipsis" :title="m.busy_merry_cowfish_absorb()" />
|
||||||
|
|
@ -54,6 +54,7 @@ const emit = defineEmits<{
|
||||||
react: [];
|
react: [];
|
||||||
}>();
|
}>();
|
||||||
const { play } = useAudio();
|
const { play } = useAudio();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const like = async () => {
|
const like = async () => {
|
||||||
if (preferences.confirm_actions.value.includes("like")) {
|
if (preferences.confirm_actions.value.includes("like")) {
|
||||||
|
|
@ -71,7 +72,7 @@ const like = async () => {
|
||||||
|
|
||||||
play("like");
|
play("like");
|
||||||
const id = toast.loading(m.slimy_candid_tiger_read());
|
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.dismiss(id);
|
||||||
toast.success(m.mealy_slow_buzzard_commend());
|
toast.success(m.mealy_slow_buzzard_commend());
|
||||||
useEvent("note:edit", data);
|
useEvent("note:edit", data);
|
||||||
|
|
@ -92,7 +93,7 @@ const unlike = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = toast.loading(m.busy_active_leopard_strive());
|
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.dismiss(id);
|
||||||
toast.success(m.fresh_direct_bear_affirm());
|
toast.success(m.fresh_direct_bear_affirm());
|
||||||
useEvent("note:edit", data);
|
useEvent("note:edit", data);
|
||||||
|
|
@ -113,7 +114,7 @@ const reblog = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = toast.loading(m.late_sunny_cobra_scold());
|
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.dismiss(id);
|
||||||
toast.success(m.weird_moving_hawk_lift());
|
toast.success(m.weird_moving_hawk_lift());
|
||||||
useEvent(
|
useEvent(
|
||||||
|
|
@ -137,7 +138,7 @@ const unreblog = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = toast.loading(m.white_sharp_gorilla_embrace());
|
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.dismiss(id);
|
||||||
toast.success(m.royal_polite_moose_catch());
|
toast.success(m.royal_polite_moose_catch());
|
||||||
useEvent("note:edit", data);
|
useEvent("note:edit", data);
|
||||||
|
|
@ -149,7 +150,7 @@ const react = async (emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji) => {
|
||||||
? (emoji as UnicodeEmoji).unicode
|
? (emoji as UnicodeEmoji).unicode
|
||||||
: `:${(emoji as z.infer<typeof CustomEmoji>).shortcode}:`;
|
: `:${(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.dismiss(id);
|
||||||
toast.success(m.main_least_turtle_fall());
|
toast.success(m.main_least_turtle_fall());
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,8 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { copy } = useClipboard();
|
const { copy } = useClipboard();
|
||||||
const loggedIn = !!identity.value;
|
const authStore = useAuthStore();
|
||||||
const authorIsMe = loggedIn && authorId === identity.value?.account.id;
|
const authorIsMe = authStore.isSignedIn && authorId === authStore.account?.id;
|
||||||
|
|
||||||
const copyText = (text: string) => {
|
const copyText = (text: string) => {
|
||||||
copy(text);
|
copy(text);
|
||||||
|
|
@ -47,7 +47,7 @@ const copyText = (text: string) => {
|
||||||
|
|
||||||
const blockUser = async (userId: string) => {
|
const blockUser = async (userId: string) => {
|
||||||
const id = toast.loading(m.top_cute_bison_nudge());
|
const id = toast.loading(m.top_cute_bison_nudge());
|
||||||
await client.value.blockAccount(userId);
|
await authStore.client.blockAccount(userId);
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
|
|
||||||
toast.success(m.main_weary_racoon_peek());
|
toast.success(m.main_weary_racoon_peek());
|
||||||
|
|
@ -68,7 +68,7 @@ const _delete = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = toast.loading(m.new_funny_fox_boil());
|
const id = toast.loading(m.new_funny_fox_boil());
|
||||||
await client.value.deleteStatus(noteId);
|
await authStore.client.deleteStatus(noteId);
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
|
|
||||||
toast.success(m.green_tasty_bumblebee_beam());
|
toast.success(m.green_tasty_bumblebee_beam());
|
||||||
|
|
@ -122,8 +122,8 @@ const _delete = async () => {
|
||||||
{{ m.tense_quick_cod_favor() }}
|
{{ m.tense_quick_cod_favor() }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator v-if="loggedIn && !authorIsMe" />
|
<DropdownMenuSeparator v-if="authStore.isSignedIn && !authorIsMe" />
|
||||||
<DropdownMenuGroup v-if="loggedIn && !authorIsMe">
|
<DropdownMenuGroup v-if="authStore.isSignedIn && !authorIsMe">
|
||||||
<DropdownMenuItem as="button" :disabled="true">
|
<DropdownMenuItem as="button" :disabled="true">
|
||||||
<Flag />
|
<Flag />
|
||||||
{{ m.great_few_jaguar_rise() }}
|
{{ m.great_few_jaguar_rise() }}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ const emit = defineEmits<{
|
||||||
pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
|
pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const selectedEmoji = ref<z.infer<typeof CustomEmoji> | UnicodeEmoji | null>(
|
const selectedEmoji = ref<z.infer<typeof CustomEmoji> | UnicodeEmoji | null>(
|
||||||
null,
|
null,
|
||||||
|
|
@ -59,12 +60,10 @@ const selectedEmoji = ref<z.infer<typeof CustomEmoji> | UnicodeEmoji | null>(
|
||||||
const emojiContainer = useTemplateRef<HTMLDivElement>("emojiContainer");
|
const emojiContainer = useTemplateRef<HTMLDivElement>("emojiContainer");
|
||||||
const filter = ref("");
|
const filter = ref("");
|
||||||
|
|
||||||
const customEmojis = computed(() => identity.value?.emojis ?? []);
|
|
||||||
|
|
||||||
const customEmojiCategories = computed(() => {
|
const customEmojiCategories = computed(() => {
|
||||||
const categories: Record<string, z.infer<typeof CustomEmoji>[]> = {};
|
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";
|
const categoryName = emoji.category || "Uncategorized";
|
||||||
|
|
||||||
if (!categories[categoryName]) {
|
if (!categories[categoryName]) {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ const { reaction, emoji, statusId } = defineProps<{
|
||||||
emoji?: z.infer<typeof CustomEmoji>;
|
emoji?: z.infer<typeof CustomEmoji>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const formatNumber = (number: number) =>
|
const formatNumber = (number: number) =>
|
||||||
new Intl.NumberFormat(getLocale(), {
|
new Intl.NumberFormat(getLocale(), {
|
||||||
notation: "compact",
|
notation: "compact",
|
||||||
|
|
@ -63,12 +65,13 @@ const formatNumber = (number: number) =>
|
||||||
const accounts = ref<z.infer<typeof Account>[] | null>(null);
|
const accounts = ref<z.infer<typeof Account>[] | null>(null);
|
||||||
|
|
||||||
const refreshReactions = async () => {
|
const refreshReactions = async () => {
|
||||||
const { data } = await client.value.getStatusReactions(statusId);
|
const { data } = await authStore.client.getStatusReactions(statusId);
|
||||||
const accountIds =
|
const accountIds =
|
||||||
data.find((r) => r.name === reaction.name)?.account_ids.slice(0, 10) ??
|
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;
|
accounts.value = accountsData;
|
||||||
};
|
};
|
||||||
|
|
@ -76,7 +79,7 @@ const refreshReactions = async () => {
|
||||||
const react = async () => {
|
const react = async () => {
|
||||||
const id = toast.loading(m.gray_stale_antelope_roam());
|
const id = toast.loading(m.gray_stale_antelope_roam());
|
||||||
|
|
||||||
const { data } = await client.value.createEmojiReaction(
|
const { data } = await authStore.client.createEmojiReaction(
|
||||||
statusId,
|
statusId,
|
||||||
reaction.name,
|
reaction.name,
|
||||||
);
|
);
|
||||||
|
|
@ -89,7 +92,7 @@ const react = async () => {
|
||||||
const unreact = async () => {
|
const unreact = async () => {
|
||||||
const id = toast.loading(m.many_weary_bat_intend());
|
const id = toast.loading(m.many_weary_bat_intend());
|
||||||
|
|
||||||
const { data } = await client.value.deleteEmojiReaction(
|
const { data } = await authStore.client.deleteEmojiReaction(
|
||||||
statusId,
|
statusId,
|
||||||
reaction.name,
|
reaction.name,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -25,5 +25,5 @@ const { note } = defineProps<{
|
||||||
note: z.infer<typeof Status>;
|
note: z.infer<typeof Status>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const parent = useNote(client, note.in_reply_to_id);
|
const parent = useNote(note.in_reply_to_id);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@ const { follower } = defineProps<{
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const followerUrl = `/@${follower.acct}`;
|
const followerUrl = `/@${follower.acct}`;
|
||||||
const [username, domain] = follower.acct.split("@");
|
const [username, domain] = follower.acct.split("@");
|
||||||
const { relationship } = useRelationship(client, follower.id);
|
const { relationship } = useRelationship(follower.id);
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
// TODO: Add "followed" notification
|
// TODO: Add "followed" notification
|
||||||
watch(relationship, () => {
|
watch(relationship, () => {
|
||||||
|
|
@ -58,7 +59,7 @@ const accept = async () => {
|
||||||
const id = toast.loading(m.cool_slimy_coyote_affirm());
|
const id = toast.loading(m.cool_slimy_coyote_affirm());
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
const { data } = await client.value.acceptFollowRequest(follower.id);
|
const { data } = await authStore.client.acceptFollowRequest(follower.id);
|
||||||
|
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
toast.success(m.busy_awful_mouse_jump());
|
toast.success(m.busy_awful_mouse_jump());
|
||||||
|
|
@ -70,7 +71,7 @@ const reject = async () => {
|
||||||
const id = toast.loading(m.front_sunny_penguin_flip());
|
const id = toast.loading(m.front_sunny_penguin_flip());
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
const { data } = await client.value.rejectFollowRequest(follower.id);
|
const { data } = await authStore.client.rejectFollowRequest(follower.id);
|
||||||
|
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
toast.success(m.green_flat_mayfly_trust());
|
toast.success(m.green_flat_mayfly_trust());
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ for (const name of [
|
||||||
}
|
}
|
||||||
|
|
||||||
const issuerRedirectUrl = (issuerId: string) => {
|
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 [
|
for (const name of [
|
||||||
"redirect_uri",
|
"redirect_uri",
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,13 @@ const copy = (data: string) => {
|
||||||
toast.success("Copied to clipboard");
|
toast.success("Copied to clipboard");
|
||||||
};
|
};
|
||||||
|
|
||||||
const appData = useAppData();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const data: [string, string | VNode][] = [
|
const data: [string, string | VNode][] = [
|
||||||
["User ID", identity.value?.account.id ?? ""],
|
["User ID", authStore.account?.id ?? ""],
|
||||||
["Instance domain", identity.value?.instance.domain ?? ""],
|
["Instance domain", authStore.instance?.domain ?? ""],
|
||||||
["Instance version", identity.value?.instance.versia_version ?? ""],
|
["Instance version", authStore.instance?.versia_version ?? ""],
|
||||||
["Client ID", appData.value?.client_id ?? ""],
|
["Client ID", authStore.application?.client_id ?? ""],
|
||||||
[
|
[
|
||||||
"Client secret",
|
"Client secret",
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -39,7 +39,7 @@ const data: [string, string | VNode][] = [
|
||||||
class="font-sans"
|
class="font-sans"
|
||||||
size="sm"
|
size="sm"
|
||||||
// @ts-expect-error missing onClick types
|
// @ts-expect-error missing onClick types
|
||||||
onClick={() => copy(appData.value?.client_secret ?? "")}
|
onClick={() => copy(authStore.application?.client_secret ?? "")}
|
||||||
>
|
>
|
||||||
Click to copy
|
Click to copy
|
||||||
</Button>,
|
</Button>,
|
||||||
|
|
@ -51,7 +51,7 @@ const data: [string, string | VNode][] = [
|
||||||
class="font-sans"
|
class="font-sans"
|
||||||
size="sm"
|
size="sm"
|
||||||
// @ts-expect-error missing onClick types
|
// @ts-expect-error missing onClick types
|
||||||
onClick={() => copy(identity.value?.tokens.access_token ?? "")}
|
onClick={() => copy(authStore.token?.access_token ?? "")}
|
||||||
>
|
>
|
||||||
Click to copy
|
Click to copy
|
||||||
</Button>,
|
</Button>,
|
||||||
|
|
|
||||||
|
|
@ -62,22 +62,11 @@ const categories = Object.fromEntries(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { account: author1 } = useAccountFromAcct(
|
const { account: author1 } = useAccountFromAcct("jessew@vs.cpluspatch.com");
|
||||||
client,
|
const { account: author2 } = useAccountFromAcct("aprl@social.lysand.org");
|
||||||
"jessew@vs.cpluspatch.com",
|
const { account: author3 } = useAccountFromAcct("lina@social.lysand.org");
|
||||||
);
|
const { account: author4 } = useAccountFromAcct("nyx@v.everypizza.im");
|
||||||
|
const authStore = useAuthStore();
|
||||||
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 open = ref(false);
|
const open = ref(false);
|
||||||
|
|
||||||
|
|
@ -87,14 +76,14 @@ useListen("preferences:open", () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<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"
|
<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]">
|
: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">
|
<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)]">
|
<div class="grid gap-3 items-center grid-cols-[auto_minmax(0,1fr)]">
|
||||||
<Avatar :name="identity.account.display_name || identity.account.username"
|
<Avatar :name="authStore.account!.display_name || authStore.account!.username"
|
||||||
:src="identity.account.avatar" />
|
:src="authStore.account!.avatar" />
|
||||||
<DialogTitle>Preferences</DialogTitle>
|
<DialogTitle>Preferences</DialogTitle>
|
||||||
</div>
|
</div>
|
||||||
<DialogDescription class="sr-only">
|
<DialogDescription class="sr-only">
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,14 @@ const { emojis } = defineProps<{
|
||||||
emojis: z.infer<typeof CustomEmoji>[];
|
emojis: z.infer<typeof CustomEmoji>[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const permissions = usePermissions();
|
const authStore = useAuthStore();
|
||||||
const canEdit =
|
const canEdit =
|
||||||
(!emojis.some((e) => e.global) &&
|
(!emojis.some((e) => e.global) &&
|
||||||
permissions.value.includes(RolePermission.ManageOwnEmojis)) ||
|
authStore.permissions.includes(RolePermission.ManageOwnEmojis)) ||
|
||||||
permissions.value.includes(RolePermission.ManageEmojis);
|
authStore.permissions.includes(RolePermission.ManageEmojis);
|
||||||
|
|
||||||
const deleteAll = async () => {
|
const deleteAll = async () => {
|
||||||
if (!identity.value) {
|
if (!authStore.isSignedIn) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,14 +57,16 @@ const deleteAll = async () => {
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
emojis.map((emoji) => client.value.deleteEmoji(emoji.id)),
|
emojis.map((emoji) => authStore.client.deleteEmoji(emoji.id)),
|
||||||
);
|
);
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
toast.success("Emojis deleted");
|
toast.success("Emojis deleted");
|
||||||
|
|
||||||
identity.value.emojis = identity.value.emojis.filter(
|
authStore.updateActiveIdentity({
|
||||||
|
emojis: authStore.emojis.filter(
|
||||||
(e) => !emojis.some((emoji) => e.id === emoji.id),
|
(e) => !emojis.some((emoji) => e.id === emoji.id),
|
||||||
);
|
),
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,14 +42,10 @@ const { emoji } = defineProps<{
|
||||||
emoji: z.infer<typeof CustomEmoji>;
|
emoji: z.infer<typeof CustomEmoji>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const permissions = usePermissions();
|
const authStore = useAuthStore();
|
||||||
const canEdit =
|
|
||||||
(!emoji.global &&
|
|
||||||
permissions.value.includes(RolePermission.ManageOwnEmojis)) ||
|
|
||||||
permissions.value.includes(RolePermission.ManageEmojis);
|
|
||||||
|
|
||||||
const editName = async () => {
|
const editName = async () => {
|
||||||
if (!identity.value) {
|
if (!authStore.isSignedIn) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,16 +59,18 @@ const editName = async () => {
|
||||||
if (result.confirmed) {
|
if (result.confirmed) {
|
||||||
const id = toast.loading(m.teary_tame_gull_bless());
|
const id = toast.loading(m.teary_tame_gull_bless());
|
||||||
try {
|
try {
|
||||||
const { data } = await client.value.updateEmoji(emoji.id, {
|
const { data } = await authStore.client.updateEmoji(emoji.id, {
|
||||||
shortcode: result.value,
|
shortcode: result.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
toast.success(m.gaudy_lime_bison_adore());
|
toast.success(m.gaudy_lime_bison_adore());
|
||||||
|
|
||||||
identity.value.emojis = identity.value.emojis.map((e) =>
|
authStore.updateActiveIdentity({
|
||||||
|
emojis: authStore.emojis.map((e) =>
|
||||||
e.id === emoji.id ? data : e,
|
e.id === emoji.id ? data : e,
|
||||||
);
|
),
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +78,7 @@ const editName = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const _delete = async () => {
|
const _delete = async () => {
|
||||||
if (!identity.value) {
|
if (!authStore.isSignedIn) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,13 +91,13 @@ const _delete = async () => {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
const id = toast.loading(m.weary_away_liger_zip());
|
const id = toast.loading(m.weary_away_liger_zip());
|
||||||
try {
|
try {
|
||||||
await client.value.deleteEmoji(emoji.id);
|
await authStore.client.deleteEmoji(emoji.id);
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
toast.success(m.crisp_whole_canary_tear());
|
toast.success(m.crisp_whole_canary_tear());
|
||||||
|
|
||||||
identity.value.emojis = identity.value.emojis.filter(
|
authStore.updateActiveIdentity({
|
||||||
(e) => e.id !== emoji.id,
|
emojis: authStore.emojis.filter((e) => e.id !== emoji.id),
|
||||||
);
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="emojis.length > 0" class="grow">
|
<div v-if="authStore.emojis.length > 0" class="grow">
|
||||||
<Table :emojis="emojis" :can-upload="canUpload" />
|
<Table :emojis="authStore.emojis" :can-upload="canUpload" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -8,12 +8,10 @@
|
||||||
import { RolePermission } from "@versia/client/schemas";
|
import { RolePermission } from "@versia/client/schemas";
|
||||||
import Table from "./table.vue";
|
import Table from "./table.vue";
|
||||||
|
|
||||||
const permissions = usePermissions();
|
const authStore = useAuthStore();
|
||||||
const canUpload = computed(
|
const canUpload = computed(
|
||||||
() =>
|
() =>
|
||||||
permissions.value.includes(RolePermission.ManageOwnEmojis) ||
|
authStore.permissions.includes(RolePermission.ManageOwnEmojis) ||
|
||||||
permissions.value.includes(RolePermission.ManageEmojis),
|
authStore.permissions.includes(RolePermission.ManageEmojis),
|
||||||
);
|
);
|
||||||
|
|
||||||
const emojis = computed(() => identity.value?.emojis ?? []);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -189,8 +189,10 @@ import { Textarea } from "~/components/ui/textarea";
|
||||||
import * as m from "~~/paraglide/messages.js";
|
import * as m from "~~/paraglide/messages.js";
|
||||||
|
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const permissions = usePermissions();
|
const authStore = useAuthStore();
|
||||||
const hasEmojiAdmin = permissions.value.includes(RolePermission.ManageEmojis);
|
const hasEmojiAdmin = authStore.permissions.includes(
|
||||||
|
RolePermission.ManageEmojis,
|
||||||
|
);
|
||||||
const createObjectURL = URL.createObjectURL;
|
const createObjectURL = URL.createObjectURL;
|
||||||
|
|
||||||
const formSchema = toTypedSchema(
|
const formSchema = toTypedSchema(
|
||||||
|
|
@ -202,11 +204,11 @@ const formSchema = toTypedSchema(
|
||||||
.refine(
|
.refine(
|
||||||
(v) =>
|
(v) =>
|
||||||
v.size <=
|
v.size <=
|
||||||
(identity.value?.instance.configuration.emojis
|
(authStore.instance?.configuration.emojis
|
||||||
.emoji_size_limit ?? Number.POSITIVE_INFINITY),
|
.emoji_size_limit ?? Number.POSITIVE_INFINITY),
|
||||||
m.orange_weird_parakeet_hug({
|
m.orange_weird_parakeet_hug({
|
||||||
count:
|
count:
|
||||||
identity.value?.instance.configuration.emojis
|
authStore.instance?.configuration.emojis
|
||||||
.emoji_size_limit ?? Number.POSITIVE_INFINITY,
|
.emoji_size_limit ?? Number.POSITIVE_INFINITY,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
@ -214,11 +216,11 @@ const formSchema = toTypedSchema(
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(
|
.max(
|
||||||
identity.value?.instance.configuration.emojis
|
authStore.instance?.configuration.emojis
|
||||||
.max_shortcode_characters ?? Number.POSITIVE_INFINITY,
|
.max_shortcode_characters ?? Number.POSITIVE_INFINITY,
|
||||||
m.solid_inclusive_owl_hug({
|
m.solid_inclusive_owl_hug({
|
||||||
count:
|
count:
|
||||||
identity.value?.instance.configuration.emojis
|
authStore.instance?.configuration.emojis
|
||||||
.max_shortcode_characters ??
|
.max_shortcode_characters ??
|
||||||
Number.POSITIVE_INFINITY,
|
Number.POSITIVE_INFINITY,
|
||||||
}),
|
}),
|
||||||
|
|
@ -237,11 +239,11 @@ const formSchema = toTypedSchema(
|
||||||
alt: z
|
alt: z
|
||||||
.string()
|
.string()
|
||||||
.max(
|
.max(
|
||||||
identity.value?.instance.configuration.emojis
|
authStore.instance?.configuration.emojis
|
||||||
.max_description_characters ?? Number.POSITIVE_INFINITY,
|
.max_description_characters ?? Number.POSITIVE_INFINITY,
|
||||||
m.key_ago_hound_emerge({
|
m.key_ago_hound_emerge({
|
||||||
count:
|
count:
|
||||||
identity.value?.instance.configuration.emojis
|
authStore.instance?.configuration.emojis
|
||||||
.max_description_characters ??
|
.max_description_characters ??
|
||||||
Number.POSITIVE_INFINITY,
|
Number.POSITIVE_INFINITY,
|
||||||
}),
|
}),
|
||||||
|
|
@ -254,14 +256,14 @@ const { isSubmitting, handleSubmit, values, setFieldValue } = useForm({
|
||||||
});
|
});
|
||||||
|
|
||||||
const submit = handleSubmit(async (values) => {
|
const submit = handleSubmit(async (values) => {
|
||||||
if (!identity.value) {
|
if (!authStore.isSignedIn) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = toast.loading(m.factual_gray_mouse_believe());
|
const id = toast.loading(m.factual_gray_mouse_believe());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await client.value.uploadEmoji(
|
const { data } = await authStore.client.uploadEmoji(
|
||||||
values.shortcode,
|
values.shortcode,
|
||||||
values.image,
|
values.image,
|
||||||
{
|
{
|
||||||
|
|
@ -274,7 +276,10 @@ const submit = handleSubmit(async (values) => {
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
toast.success(m.cool_trite_gull_quiz());
|
toast.success(m.cool_trite_gull_quiz());
|
||||||
|
|
||||||
identity.value.emojis = [...identity.value.emojis, data];
|
authStore.updateActiveIdentity({
|
||||||
|
emojis: [...authStore.emojis, data],
|
||||||
|
});
|
||||||
|
|
||||||
open.value = false;
|
open.value = false;
|
||||||
} catch {
|
} catch {
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { toTypedSchema } from "@vee-validate/zod";
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
|
import type { Instance } from "@versia/client/schemas";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import * as m from "~~/paraglide/messages.js";
|
import * as m from "~~/paraglide/messages.js";
|
||||||
|
|
||||||
const characterRegex = new RegExp(/^[a-z0-9_-]+$/);
|
const characterRegex = new RegExp(/^[a-z0-9_-]+$/);
|
||||||
|
|
||||||
export const formSchema = (identity: Identity) =>
|
export const formSchema = (instance: z.infer<typeof Instance>) =>
|
||||||
toTypedSchema(
|
toTypedSchema(
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
banner: z
|
banner: z
|
||||||
|
|
@ -12,11 +13,10 @@ export const formSchema = (identity: Identity) =>
|
||||||
.refine(
|
.refine(
|
||||||
(v) =>
|
(v) =>
|
||||||
v.size <=
|
v.size <=
|
||||||
(identity.instance.configuration.accounts
|
(instance.configuration.accounts.header_limit ??
|
||||||
.header_limit ?? Number.POSITIVE_INFINITY),
|
Number.POSITIVE_INFINITY),
|
||||||
m.civil_icy_ant_mend({
|
m.civil_icy_ant_mend({
|
||||||
size: identity.instance.configuration.accounts
|
size: instance.configuration.accounts.header_limit,
|
||||||
.header_limit,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
@ -25,11 +25,10 @@ export const formSchema = (identity: Identity) =>
|
||||||
.refine(
|
.refine(
|
||||||
(v) =>
|
(v) =>
|
||||||
v.size <=
|
v.size <=
|
||||||
(identity.instance.configuration.accounts
|
(instance.configuration.accounts.avatar_limit ??
|
||||||
.avatar_limit ?? Number.POSITIVE_INFINITY),
|
Number.POSITIVE_INFINITY),
|
||||||
m.zippy_caring_raven_edit({
|
m.zippy_caring_raven_edit({
|
||||||
size: identity.instance.configuration.accounts
|
size: instance.configuration.accounts.avatar_limit,
|
||||||
.avatar_limit,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.or(z.string().url())
|
.or(z.string().url())
|
||||||
|
|
@ -37,22 +36,15 @@ export const formSchema = (identity: Identity) =>
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
.max(
|
.max(
|
||||||
identity.instance.configuration.accounts
|
instance.configuration.accounts.max_displayname_characters,
|
||||||
.max_displayname_characters,
|
|
||||||
),
|
),
|
||||||
username: z
|
username: z
|
||||||
.string()
|
.string()
|
||||||
.regex(characterRegex, m.still_upper_otter_dine())
|
.regex(characterRegex, m.still_upper_otter_dine())
|
||||||
.max(
|
.max(instance.configuration.accounts.max_username_characters),
|
||||||
identity.instance.configuration.accounts
|
|
||||||
.max_username_characters,
|
|
||||||
),
|
|
||||||
bio: z
|
bio: z
|
||||||
.string()
|
.string()
|
||||||
.max(
|
.max(instance.configuration.accounts.max_note_characters),
|
||||||
identity.instance.configuration.accounts
|
|
||||||
.max_note_characters,
|
|
||||||
),
|
|
||||||
bot: z.boolean().default(false),
|
bot: z.boolean().default(false),
|
||||||
locked: z.boolean().default(false),
|
locked: z.boolean().default(false),
|
||||||
discoverable: z.boolean().default(true),
|
discoverable: z.boolean().default(true),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<form v-if="identity" class="grid gap-6" @submit="save">
|
<form class="grid gap-6" @submit="save">
|
||||||
<Transition name="slide-up">
|
<Transition name="slide-up">
|
||||||
<Alert v-if="dirty" layout="button" class="absolute bottom-2 z-10 inset-x-2 w-[calc(100%-1rem)]">
|
<Alert v-if="dirty" layout="button" class="absolute bottom-2 z-10 inset-x-2 w-[calc(100%-1rem)]">
|
||||||
<SaveOff class="size-4" />
|
<SaveOff class="size-4" />
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
|
|
||||||
<FormField v-slot="{ setValue }" name="avatar">
|
<FormField v-slot="{ setValue }" name="avatar">
|
||||||
<TextInput :title="m.safe_icy_bulldog_quell()">
|
<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)" />
|
@submit-url="(url) => setValue(url)" />
|
||||||
</TextInput>
|
</TextInput>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
@ -85,25 +85,25 @@ import ImageUploader from "./profile/image-uploader.vue";
|
||||||
|
|
||||||
const dirty = computed(() => form.meta.value.dirty);
|
const dirty = computed(() => form.meta.value.dirty);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
if (!identity.value) {
|
if (!(authStore.instance && authStore.account)) {
|
||||||
throw new Error("Identity not found.");
|
throw new Error("Not signed in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = computed(() => identity.value?.account as Identity["account"]);
|
const schema = formSchema(authStore.instance);
|
||||||
const schema = formSchema(identity.value);
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
validationSchema: schema,
|
validationSchema: schema,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
bio: account.value.source?.note ?? "",
|
bio: authStore.account.source?.note ?? "",
|
||||||
bot: account.value.bot ?? false,
|
bot: authStore.account.bot ?? false,
|
||||||
locked: account.value.locked ?? false,
|
locked: authStore.account.locked ?? false,
|
||||||
discoverable: account.value.discoverable ?? true,
|
discoverable: authStore.account.discoverable ?? true,
|
||||||
username: account.value.username,
|
username: authStore.account.username,
|
||||||
name: account.value.display_name,
|
name: authStore.account.display_name,
|
||||||
fields:
|
fields:
|
||||||
account.value.source?.fields.map((f) => ({
|
authStore.account.source?.fields.map((f) => ({
|
||||||
name: f.name,
|
name: f.name,
|
||||||
value: f.value,
|
value: f.value,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
|
|
@ -111,7 +111,7 @@ const form = useForm({
|
||||||
});
|
});
|
||||||
|
|
||||||
const save = form.handleSubmit(async (values) => {
|
const save = form.handleSubmit(async (values) => {
|
||||||
if (submitting.value) {
|
if (submitting.value || !authStore.account) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,25 +120,29 @@ const save = form.handleSubmit(async (values) => {
|
||||||
|
|
||||||
const changedData = {
|
const changedData = {
|
||||||
display_name:
|
display_name:
|
||||||
values.name === account.value.display_name
|
values.name === authStore.account.display_name
|
||||||
? undefined
|
? undefined
|
||||||
: values.name,
|
: values.name,
|
||||||
username:
|
username:
|
||||||
values.username === account.value.username
|
values.username === authStore.account.username
|
||||||
? undefined
|
? undefined
|
||||||
: values.username,
|
: values.username,
|
||||||
note:
|
note:
|
||||||
values.bio === account.value.source?.note ? undefined : values.bio,
|
values.bio === authStore.account.source?.note
|
||||||
bot: values.bot === account.value.bot ? undefined : values.bot,
|
? undefined
|
||||||
|
: values.bio,
|
||||||
|
bot: values.bot === authStore.account.bot ? undefined : values.bot,
|
||||||
locked:
|
locked:
|
||||||
values.locked === account.value.locked ? undefined : values.locked,
|
values.locked === authStore.account.locked
|
||||||
|
? undefined
|
||||||
|
: values.locked,
|
||||||
discoverable:
|
discoverable:
|
||||||
values.discoverable === account.value.discoverable
|
values.discoverable === authStore.account.discoverable
|
||||||
? undefined
|
? undefined
|
||||||
: values.discoverable,
|
: values.discoverable,
|
||||||
// Can't compare two arrays directly in JS, so we need to check if all fields are the same
|
// 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) =>
|
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,
|
(f) => f.name === field.name && f.value === field.value,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -157,7 +161,7 @@ const save = form.handleSubmit(async (values) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await client.value.updateCredentials(
|
const { data } = await authStore.client.updateCredentials(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(changedData).filter(([, v]) => v !== undefined),
|
Object.entries(changedData).filter(([, v]) => v !== undefined),
|
||||||
),
|
),
|
||||||
|
|
@ -166,9 +170,9 @@ const save = form.handleSubmit(async (values) => {
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
toast.success(m.spry_honest_kestrel_arrive());
|
toast.success(m.spry_honest_kestrel_arrive());
|
||||||
|
|
||||||
if (identity.value) {
|
authStore.updateActiveIdentity({
|
||||||
identity.value.account = data;
|
account: data,
|
||||||
}
|
});
|
||||||
|
|
||||||
form.resetForm({
|
form.resetForm({
|
||||||
values: {
|
values: {
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,8 @@
|
||||||
{{ m.active_trite_lark_inspire() }}
|
{{ m.active_trite_lark_inspire() }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator v-if="isLoggedIn && !isMe" />
|
<DropdownMenuSeparator v-if="authStore.isSignedIn && !isMe" />
|
||||||
<DropdownMenuGroup v-if="isLoggedIn && !isMe">
|
<DropdownMenuGroup v-if="authStore.isSignedIn && !isMe">
|
||||||
<DropdownMenuItem as="button" @click="muteUser(account.id)">
|
<DropdownMenuItem as="button" @click="muteUser(account.id)">
|
||||||
<VolumeX />
|
<VolumeX />
|
||||||
{{ m.spare_wild_mole_intend() }}
|
{{ m.spare_wild_mole_intend() }}
|
||||||
|
|
@ -51,8 +51,8 @@
|
||||||
{{ m.slow_chunky_chipmunk_hush() }}
|
{{ m.slow_chunky_chipmunk_hush() }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator v-if="isLoggedIn && !isMe" />
|
<DropdownMenuSeparator v-if="authStore.isSignedIn && !isMe" />
|
||||||
<DropdownMenuGroup v-if="isLoggedIn && !isMe">
|
<DropdownMenuGroup v-if="authStore.isSignedIn && !isMe">
|
||||||
<DropdownMenuItem as="button" :disabled="true">
|
<DropdownMenuItem as="button" :disabled="true">
|
||||||
<Flag />
|
<Flag />
|
||||||
{{ m.great_few_jaguar_rise() }}
|
{{ m.great_few_jaguar_rise() }}
|
||||||
|
|
@ -91,8 +91,8 @@ const { account } = defineProps<{
|
||||||
account: z.infer<typeof Account>;
|
account: z.infer<typeof Account>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isMe = identity.value?.account.id === account.id;
|
const authStore = useAuthStore();
|
||||||
const isLoggedIn = !!identity.value;
|
const isMe = authStore.account?.id === account.id;
|
||||||
|
|
||||||
const { copy } = useClipboard();
|
const { copy } = useClipboard();
|
||||||
const copyText = (text: string) => {
|
const copyText = (text: string) => {
|
||||||
|
|
@ -105,7 +105,7 @@ const isRemote = account.acct.includes("@");
|
||||||
|
|
||||||
const muteUser = async (userId: string) => {
|
const muteUser = async (userId: string) => {
|
||||||
const id = toast.loading(m.ornate_tidy_coyote_grow());
|
const id = toast.loading(m.ornate_tidy_coyote_grow());
|
||||||
await client.value.muteAccount(userId);
|
await authStore.client.muteAccount(userId);
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
|
|
||||||
toast.success("User muted");
|
toast.success("User muted");
|
||||||
|
|
@ -113,7 +113,7 @@ const muteUser = async (userId: string) => {
|
||||||
|
|
||||||
const blockUser = async (userId: string) => {
|
const blockUser = async (userId: string) => {
|
||||||
const id = toast.loading(m.empty_smug_raven_bloom());
|
const id = toast.loading(m.empty_smug_raven_bloom());
|
||||||
await client.value.blockAccount(userId);
|
await authStore.client.blockAccount(userId);
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
|
|
||||||
toast.success("User blocked");
|
toast.success("User blocked");
|
||||||
|
|
@ -121,7 +121,7 @@ const blockUser = async (userId: string) => {
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
const id = toast.loading(m.real_every_macaw_wish());
|
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.dismiss(id);
|
||||||
|
|
||||||
toast.success(m.many_cool_fox_love());
|
toast.success(m.many_cool_fox_love());
|
||||||
|
|
|
||||||
|
|
@ -33,15 +33,14 @@ import ProfileBadge from "./profile-badge.vue";
|
||||||
const { account } = defineProps<{
|
const { account } = defineProps<{
|
||||||
account: z.infer<typeof Account>;
|
account: z.infer<typeof Account>;
|
||||||
}>();
|
}>();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const roles = account.roles.filter((r) => r.visible);
|
const roles = account.roles.filter((r) => r.visible);
|
||||||
// Get user handle in username@instance format
|
// Get user handle in username@instance format
|
||||||
const handle = account.acct.includes("@")
|
const handle = account.acct.includes("@")
|
||||||
? account.acct
|
? account.acct
|
||||||
: `${account.acct}@${
|
: `${account.acct}@${authStore.instance?.domain ?? window.location.host}`;
|
||||||
identity.value?.instance.domain ?? window.location.host
|
|
||||||
}`;
|
|
||||||
const isDeveloper = config.DEVELOPER_HANDLES.includes(handle);
|
const isDeveloper = config.DEVELOPER_HANDLES.includes(handle);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<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()">
|
@click="relationship?.following ? unfollow() : follow()">
|
||||||
<Loader v-if="isLoading" class="animate-spin" />
|
<Loader v-if="isLoading" class="animate-spin" />
|
||||||
<span v-else>
|
<span v-else>
|
||||||
|
|
@ -27,8 +27,9 @@ const { account } = defineProps<{
|
||||||
account: z.infer<typeof Account>;
|
account: z.infer<typeof Account>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { relationship, isLoading } = useRelationship(client, account.id);
|
const { relationship, isLoading } = useRelationship(account.id);
|
||||||
const isMe = identity.value?.account.id === account.id;
|
const authStore = useAuthStore();
|
||||||
|
const isMe = authStore.account?.id === account.id;
|
||||||
|
|
||||||
const follow = async () => {
|
const follow = async () => {
|
||||||
if (preferences.confirm_actions.value.includes("follow")) {
|
if (preferences.confirm_actions.value.includes("follow")) {
|
||||||
|
|
@ -47,7 +48,7 @@ const follow = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = toast.loading(m.quick_basic_peacock_bubble());
|
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);
|
toast.dismiss(id);
|
||||||
|
|
||||||
relationship.value = data;
|
relationship.value = data;
|
||||||
|
|
@ -71,7 +72,7 @@ const unfollow = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = toast.loading(m.big_safe_guppy_mix());
|
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);
|
toast.dismiss(id);
|
||||||
|
|
||||||
relationship.value = data;
|
relationship.value = data;
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,15 @@
|
||||||
Manage your accounts and settings.
|
Manage your accounts and settings.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div v-if="identities.length > 0" class="grid gap-4 py-2">
|
<div v-if="authStore.identities.length > 0" class="grid gap-4 py-2">
|
||||||
<div v-for="identity of identities" :key="identity.account.id"
|
<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">
|
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 />
|
<TinyCard :account="identity.account" :domain="identity.instance.domain" naked />
|
||||||
<Button data-switch v-if="currentIdentity?.id !== identity.id"
|
<Button data-switch v-if="authStore.identity?.id !== identity.id"
|
||||||
@click="switchAccount(identity.account.id)" variant="outline">
|
@click="authStore.setActiveIdentity(identity.id)" variant="outline">
|
||||||
Switch
|
Switch
|
||||||
</Button>
|
</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()">
|
:title="m.sharp_big_mallard_reap()">
|
||||||
<LogOut />
|
<LogOut />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -47,7 +47,6 @@
|
||||||
import { LogIn, LogOut, UserPlus } from "lucide-vue-next";
|
import { LogIn, LogOut, UserPlus } from "lucide-vue-next";
|
||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
import { NuxtLink } from "#components";
|
import { NuxtLink } from "#components";
|
||||||
import { identity as currentIdentity } from "#imports";
|
|
||||||
import TinyCard from "~/components/profiles/tiny-card.vue";
|
import TinyCard from "~/components/profiles/tiny-card.vue";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -61,31 +60,25 @@ import {
|
||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/dialog";
|
||||||
import * as m from "~~/paraglide/messages.js";
|
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) => {
|
try {
|
||||||
if (userId === currentIdentity.value?.account.id) {
|
await authStore.startSignIn(instance);
|
||||||
return await navigateTo(`/@${currentIdentity.value.account.username}`);
|
} catch (e) {
|
||||||
}
|
console.error(e);
|
||||||
|
|
||||||
const id = toast.loading("Switching account...");
|
|
||||||
|
|
||||||
const identityToSwitch = identities.value.find(
|
|
||||||
(i) => i.account.id === userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!identityToSwitch) {
|
|
||||||
toast.dismiss(id);
|
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.dismiss(id);
|
||||||
toast.success("Switched account");
|
toast.success("Signed out");
|
||||||
|
|
||||||
window.location.href = "/";
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import * as m from "~~/paraglide/messages.js";
|
||||||
import AccountManager from "../account/account-manager.vue";
|
import AccountManager from "../account/account-manager.vue";
|
||||||
|
|
||||||
const { $pwa } = useNuxtApp();
|
const { $pwa } = useNuxtApp();
|
||||||
|
const authStore = useAuthStore();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -25,8 +26,8 @@ const { $pwa } = useNuxtApp();
|
||||||
<SidebarMenu class="gap-3">
|
<SidebarMenu class="gap-3">
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<AccountManager>
|
<AccountManager>
|
||||||
<SidebarMenuButton v-if="identity" size="lg">
|
<SidebarMenuButton v-if="authStore.account && authStore.instance" size="lg">
|
||||||
<TinyCard :account="identity.account" :domain="identity.instance.domain" naked />
|
<TinyCard :account="authStore.account" :domain="authStore.instance.domain" naked />
|
||||||
<ChevronsUpDown class="ml-auto size-4" />
|
<ChevronsUpDown class="ml-auto size-4" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
<SidebarMenuButton v-else>
|
<SidebarMenuButton v-else>
|
||||||
|
|
@ -37,14 +38,14 @@ const { $pwa } = useNuxtApp();
|
||||||
</AccountManager>
|
</AccountManager>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem class="flex flex-col gap-2">
|
<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')">
|
@click="useEvent('composer:open')">
|
||||||
<Pen />
|
<Pen />
|
||||||
<span class="group-data-[collapsible=icon]:hidden">
|
<span class="group-data-[collapsible=icon]:hidden">
|
||||||
{{ m.salty_aloof_turkey_nudge() }}
|
{{ m.salty_aloof_turkey_nudge() }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</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 />
|
<Cog />
|
||||||
Preferences
|
Preferences
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "~/components/ui/sidebar";
|
} from "~/components/ui/sidebar";
|
||||||
|
|
||||||
const instance = useInstance();
|
const authStore = useAuthStore();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -14,7 +14,7 @@ const instance = useInstance();
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<NuxtLink href="/">
|
<NuxtLink href="/">
|
||||||
<InstanceSmallCard v-if="instance" :instance="instance" />
|
<InstanceSmallCard v-if="authStore.instance" :instance="authStore.instance" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<NavItems
|
<NavItems
|
||||||
:items="
|
:items="
|
||||||
sidebarConfig.other.filter((i) =>
|
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 FooterActions from "./footer/footer-actions.vue";
|
||||||
import InstanceHeader from "./instance/instance-header.vue";
|
import InstanceHeader from "./instance/instance-header.vue";
|
||||||
import NavItems from "./navigation/nav-items.vue";
|
import NavItems from "./navigation/nav-items.vue";
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const showTimelines = computed(
|
||||||
["/", "/home", "/local", "/public", "/global"].includes(route.path) &&
|
["/", "/home", "/local", "/public", "/global"].includes(route.path) &&
|
||||||
isMd.value,
|
isMd.value,
|
||||||
);
|
);
|
||||||
|
const authStore = useAuthStore();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -23,5 +24,5 @@ const showTimelines = computed(
|
||||||
</header>
|
</header>
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
<RightSidebar v-if="identity" v-show="preferences.display_notifications_sidebar" />
|
<RightSidebar v-if="authStore.isSignedIn" v-show="preferences.display_notifications_sidebar" />
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const {
|
||||||
loadPrev,
|
loadPrev,
|
||||||
removeItem,
|
removeItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
} = useAccountTimeline(client.value, props.id);
|
} = useAccountTimeline(props.id);
|
||||||
|
|
||||||
useListen("note:delete", ({ id }) => {
|
useListen("note:delete", ({ id }) => {
|
||||||
removeItem(id);
|
removeItem(id);
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const {
|
||||||
loadPrev,
|
loadPrev,
|
||||||
removeItem,
|
removeItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
} = useGlobalTimeline(client.value);
|
} = useGlobalTimeline();
|
||||||
|
|
||||||
useListen("note:delete", ({ id }) => {
|
useListen("note:delete", ({ id }) => {
|
||||||
removeItem(id);
|
removeItem(id);
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
:update-item="updateItem" />
|
:update-item="updateItem" />
|
||||||
</template>
|
</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 Timeline from "./timeline.vue";
|
||||||
import type { z } from "zod";
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
error,
|
error,
|
||||||
|
|
@ -17,7 +17,7 @@ const {
|
||||||
loadPrev,
|
loadPrev,
|
||||||
removeItem,
|
removeItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
} = useHomeTimeline(client.value);
|
} = useHomeTimeline();
|
||||||
|
|
||||||
useListen("note:delete", ({ id }) => {
|
useListen("note:delete", ({ id }) => {
|
||||||
removeItem(id);
|
removeItem(id);
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const {
|
||||||
loadPrev,
|
loadPrev,
|
||||||
removeItem,
|
removeItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
} = useLocalTimeline(client.value);
|
} = useLocalTimeline();
|
||||||
|
|
||||||
useListen("note:delete", ({ id }) => {
|
useListen("note:delete", ({ id }) => {
|
||||||
removeItem(id);
|
removeItem(id);
|
||||||
|
|
|
||||||
|
|
@ -17,5 +17,5 @@ const {
|
||||||
loadPrev,
|
loadPrev,
|
||||||
removeItem,
|
removeItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
} = useNotificationTimeline(client.value);
|
} = useNotificationTimeline();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ const {
|
||||||
loadPrev,
|
loadPrev,
|
||||||
removeItem,
|
removeItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
} = usePublicTimeline(client.value);
|
} = usePublicTimeline();
|
||||||
|
|
||||||
useListen("note:delete", ({ id }) => {
|
useListen("note:delete", ({ id }) => {
|
||||||
removeItem(id);
|
removeItem(id);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import type { Client } from "@versia/client";
|
|
||||||
import type { Account } from "@versia/client/schemas";
|
import type { Account } from "@versia/client/schemas";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
export const useAccountFromAcct = (
|
export const useAccountFromAcct = (
|
||||||
client: MaybeRef<Client | null>,
|
|
||||||
acct: string,
|
acct: string,
|
||||||
): {
|
): {
|
||||||
account: Ref<z.infer<typeof Account> | null>;
|
account: Ref<z.infer<typeof Account> | null>;
|
||||||
|
|
@ -11,10 +9,9 @@ export const useAccountFromAcct = (
|
||||||
} => {
|
} => {
|
||||||
const output = ref(null as z.infer<typeof Account> | null);
|
const output = ref(null as z.infer<typeof Account> | null);
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
ref(client)
|
authStore.client.lookupAccount(acct).then((res) => {
|
||||||
.value?.lookupAccount(acct)
|
|
||||||
.then((res) => {
|
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
output.value = res.data;
|
output.value = res.data;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import type { Client } from "@versia/client";
|
|
||||||
import type { Status } from "@versia/client/schemas";
|
import type { Status } from "@versia/client/schemas";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { type TimelineOptions, useTimeline } from "./Timeline";
|
import { type TimelineOptions, useTimeline } from "./Timeline";
|
||||||
|
|
||||||
export function useAccountTimeline(
|
export function useAccountTimeline(
|
||||||
client: Client,
|
|
||||||
accountId: string,
|
accountId: string,
|
||||||
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
|
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
|
||||||
) {
|
) {
|
||||||
return useTimeline(client, {
|
const authStore = useAuthStore();
|
||||||
fetchFunction: (client, opts) =>
|
|
||||||
client.getAccountStatuses(accountId, opts),
|
return useTimeline({
|
||||||
|
fetchFunction: (opts) =>
|
||||||
|
authStore.client.getAccountStatuses(accountId, opts),
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,66 +1,47 @@
|
||||||
import type { Client } from "@versia/client";
|
|
||||||
import type { RolePermission } from "@versia/client/schemas";
|
|
||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
import * as m from "~~/paraglide/messages.js";
|
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
|
// Refresh custom emojis and instance data and me on every reload
|
||||||
watch(
|
watch(
|
||||||
[identity, client],
|
identity,
|
||||||
async () => {
|
async (oldIdentity, newIdentity) => {
|
||||||
|
if (newIdentity && newIdentity.id !== oldIdentity?.id) {
|
||||||
console.info("Refreshing emoji, instance and account cache");
|
console.info("Refreshing emoji, instance and account cache");
|
||||||
if (identity.value) {
|
authStore.client
|
||||||
toValue(client)
|
.verifyAccountCredentials()
|
||||||
?.verifyAccountCredentials()
|
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (identity.value) {
|
authStore.updateActiveIdentity({
|
||||||
identity.value.account = res.data;
|
account: res.data,
|
||||||
}
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const code = err.response.status;
|
const code = err.response.status;
|
||||||
|
|
||||||
if (code === 401) {
|
if (code === 401) {
|
||||||
// Reset tokenData
|
// Reset tokenData
|
||||||
identity.value = null;
|
authStore.setActiveIdentity(null);
|
||||||
toast.error(m.fancy_this_wasp_renew(), {
|
toast.error(m.fancy_this_wasp_renew(), {
|
||||||
description: m.real_weird_deer_stop(),
|
description: m.real_weird_deer_stop(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
toValue(client)
|
authStore.client.getInstanceCustomEmojis().then((res) => {
|
||||||
?.getInstanceCustomEmojis()
|
authStore.updateActiveIdentity({
|
||||||
.then((res) => {
|
emojis: res.data,
|
||||||
if (identity.value) {
|
|
||||||
identity.value.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 },
|
{ flush: "sync", immediate: true },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import type { Account, Attachment, Status } from "@versia/client/schemas";
|
import type { Account, Attachment, Status } from "@versia/client/schemas";
|
||||||
import mitt from "mitt";
|
import mitt from "mitt";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import type { Identity } from "./Identities";
|
|
||||||
|
|
||||||
type ApplicationEvents = {
|
type ApplicationEvents = {
|
||||||
"note:reply": z.infer<typeof Status>;
|
"note:reply": z.infer<typeof Status>;
|
||||||
|
|
@ -23,7 +22,6 @@ type ApplicationEvents = {
|
||||||
"account:report": z.infer<typeof Account>;
|
"account:report": z.infer<typeof Account>;
|
||||||
"account:update": z.infer<typeof Account>;
|
"account:update": z.infer<typeof Account>;
|
||||||
"attachment:view": z.infer<typeof Attachment>;
|
"attachment:view": z.infer<typeof Attachment>;
|
||||||
"identity:change": Identity;
|
|
||||||
"preferences:open": undefined;
|
"preferences:open": undefined;
|
||||||
error: {
|
error: {
|
||||||
code: string;
|
code: string;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
import type { Client } from "@versia/client";
|
|
||||||
import type { ExtendedDescription } from "@versia/client/schemas";
|
import type { ExtendedDescription } from "@versia/client/schemas";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
export const useExtendedDescription = (client: MaybeRef<Client | null>) => {
|
export const useExtendedDescription = () => {
|
||||||
if (!ref(client).value) {
|
const store = useAuthStore();
|
||||||
return ref(null as z.infer<typeof ExtendedDescription> | null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = ref(null as z.infer<typeof ExtendedDescription> | null);
|
const output = ref(null as z.infer<typeof ExtendedDescription> | null);
|
||||||
|
|
||||||
ref(client)
|
store.client.getInstanceExtendedDescription().then((res) => {
|
||||||
.value?.getInstanceExtendedDescription()
|
|
||||||
.then((res) => {
|
|
||||||
output.value = res.data;
|
output.value = res.data;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import type { Client } from "@versia/client";
|
|
||||||
import type { Status } from "@versia/client/schemas";
|
import type { Status } from "@versia/client/schemas";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { type TimelineOptions, useTimeline } from "./Timeline";
|
import { type TimelineOptions, useTimeline } from "./Timeline";
|
||||||
|
|
||||||
export function useGlobalTimeline(
|
export function useGlobalTimeline(
|
||||||
client: Client,
|
|
||||||
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
|
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
|
||||||
) {
|
) {
|
||||||
return useTimeline(client, {
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
return useTimeline({
|
||||||
// TODO: Implement global timeline in client sdk
|
// TODO: Implement global timeline in client sdk
|
||||||
fetchFunction: (client, opts) => client.getPublicTimeline(opts),
|
fetchFunction: (opts) => authStore.client.getPublicTimeline(opts),
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import type { Client } from "@versia/client";
|
|
||||||
import type { Status } from "@versia/client/schemas";
|
import type { Status } from "@versia/client/schemas";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { type TimelineOptions, useTimeline } from "./Timeline";
|
import { type TimelineOptions, useTimeline } from "./Timeline";
|
||||||
|
|
||||||
export function useHomeTimeline(
|
export function useHomeTimeline(
|
||||||
client: Client,
|
|
||||||
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
|
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
|
||||||
) {
|
) {
|
||||||
return useTimeline(client, {
|
const authStore = useAuthStore();
|
||||||
fetchFunction: (client, opts) => client.getHomeTimeline(opts),
|
|
||||||
|
return useTimeline({
|
||||||
|
fetchFunction: (opts) => authStore.client.getHomeTimeline(opts),
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -2,10 +2,6 @@ import type { Client } from "@versia/client";
|
||||||
import type { Instance, TermsOfService } from "@versia/client/schemas";
|
import type { Instance, TermsOfService } from "@versia/client/schemas";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
export const useInstance = () => {
|
|
||||||
return computed(() => identity.value?.instance);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useInstanceFromClient = (client: MaybeRef<Client>) => {
|
export const useInstanceFromClient = (client: MaybeRef<Client>) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return ref(null as z.infer<typeof Instance> | null);
|
return ref(null as z.infer<typeof Instance> | null);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import type { Client } from "@versia/client";
|
|
||||||
import type { Status } from "@versia/client/schemas";
|
import type { Status } from "@versia/client/schemas";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { type TimelineOptions, useTimeline } from "./Timeline";
|
import { type TimelineOptions, useTimeline } from "./Timeline";
|
||||||
|
|
||||||
export function useLocalTimeline(
|
export function useLocalTimeline(
|
||||||
client: Client,
|
|
||||||
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
|
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
|
||||||
) {
|
) {
|
||||||
return useTimeline(client, {
|
const authStore = useAuthStore();
|
||||||
fetchFunction: (client, opts) => client.getLocalTimeline(opts),
|
|
||||||
|
return useTimeline({
|
||||||
|
fetchFunction: (opts) => authStore.client.getLocalTimeline(opts),
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,19 @@
|
||||||
import type { Client } from "@versia/client";
|
|
||||||
import type { Status } from "@versia/client/schemas";
|
import type { Status } from "@versia/client/schemas";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
export const useNote = (
|
export const useNote = (noteId: MaybeRef<string | null>) => {
|
||||||
client: MaybeRef<Client | null>,
|
if (!toValue(noteId)) {
|
||||||
noteId: MaybeRef<string | null>,
|
|
||||||
) => {
|
|
||||||
if (!(toValue(client) && toValue(noteId))) {
|
|
||||||
return ref(null as z.infer<typeof Status> | null);
|
return ref(null as z.infer<typeof Status> | null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const output = ref(null as z.infer<typeof Status> | null);
|
const output = ref(null as z.infer<typeof Status> | null);
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
toValue(noteId) &&
|
toValue(noteId) &&
|
||||||
toValue(client)
|
authStore.client
|
||||||
?.getStatus(toValue(noteId) as string)
|
.getStatus(toValue(noteId) as string)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
output.value = res.data;
|
output.value = res.data;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,15 @@
|
||||||
import type { Client } from "@versia/client";
|
|
||||||
import type { Context } from "@versia/client/schemas";
|
import type { Context } from "@versia/client/schemas";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
export const useNoteContext = (
|
export const useNoteContext = (noteId: MaybeRef<string | null>) => {
|
||||||
client: MaybeRef<Client | null>,
|
const authStore = useAuthStore();
|
||||||
noteId: MaybeRef<string | null>,
|
|
||||||
) => {
|
|
||||||
if (!ref(client).value) {
|
|
||||||
return ref(null as z.infer<typeof Context> | null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = ref(null as z.infer<typeof Context> | null);
|
const output = ref(null as z.infer<typeof Context> | null);
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (toValue(noteId)) {
|
if (toValue(noteId)) {
|
||||||
ref(client)
|
authStore.client
|
||||||
.value?.getStatusContext(toValue(noteId) ?? "")
|
.getStatusContext(toValue(noteId) ?? "")
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
output.value = res.data;
|
output.value = res.data;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,12 @@ import type { z } from "zod";
|
||||||
import { type TimelineOptions, useTimeline } from "./Timeline";
|
import { type TimelineOptions, useTimeline } from "./Timeline";
|
||||||
|
|
||||||
export function useNotificationTimeline(
|
export function useNotificationTimeline(
|
||||||
client: Client,
|
|
||||||
options: Partial<TimelineOptions<z.infer<typeof Notification>>> = {},
|
options: Partial<TimelineOptions<z.infer<typeof Notification>>> = {},
|
||||||
) {
|
) {
|
||||||
return useTimeline(client, {
|
const authStore = useAuthStore();
|
||||||
fetchFunction: (client, opts) => client.getNotifications(opts),
|
|
||||||
|
return useTimeline({
|
||||||
|
fetchFunction: (opts) => authStore.client.getNotifications(opts),
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export const usePermissions = () => {
|
|
||||||
return computed(() => identity.value?.permissions ?? []);
|
|
||||||
};
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import type { Client } from "@versia/client";
|
|
||||||
import type { Status } from "@versia/client/schemas";
|
import type { Status } from "@versia/client/schemas";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { type TimelineOptions, useTimeline } from "./Timeline";
|
import { type TimelineOptions, useTimeline } from "./Timeline";
|
||||||
|
|
||||||
export function usePublicTimeline(
|
export function usePublicTimeline(
|
||||||
client: Client,
|
|
||||||
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
|
options: Partial<TimelineOptions<z.infer<typeof Status>>> = {},
|
||||||
) {
|
) {
|
||||||
return useTimeline(client, {
|
const authStore = useAuthStore();
|
||||||
fetchFunction: (client, opts) => client.getPublicTimeline(opts),
|
|
||||||
|
return useTimeline({
|
||||||
|
fetchFunction: (opts) => authStore.client.getPublicTimeline(opts),
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,19 @@
|
||||||
import type { Client } from "@versia/client";
|
|
||||||
import type { Relationship } from "@versia/client/schemas";
|
import type { Relationship } from "@versia/client/schemas";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
export const useRelationship = (
|
export const useRelationship = (accountId: MaybeRef<string | null>) => {
|
||||||
client: MaybeRef<Client | null>,
|
|
||||||
accountId: MaybeRef<string | null>,
|
|
||||||
) => {
|
|
||||||
const relationship = ref(null as z.infer<typeof Relationship> | null);
|
const relationship = ref(null as z.infer<typeof Relationship> | null);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
if (!identity.value) {
|
if (!authStore.isSignedIn) {
|
||||||
return { relationship, isLoading };
|
return { relationship, isLoading };
|
||||||
}
|
}
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (toValue(accountId)) {
|
if (toValue(accountId)) {
|
||||||
toValue(client)
|
authStore.client
|
||||||
?.getRelationship(toValue(accountId) ?? "")
|
.getRelationship(toValue(accountId) ?? "")
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
relationship.value = res.data;
|
relationship.value = res.data;
|
||||||
});
|
});
|
||||||
|
|
@ -28,14 +25,14 @@ export const useRelationship = (
|
||||||
if (newOutput?.following !== oldOutput?.following) {
|
if (newOutput?.following !== oldOutput?.following) {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
if (newOutput?.following) {
|
if (newOutput?.following) {
|
||||||
toValue(client)
|
authStore.client
|
||||||
?.followAccount(toValue(accountId) ?? "")
|
.followAccount(toValue(accountId) ?? "")
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toValue(client)
|
authStore.client
|
||||||
?.unfollowAccount(toValue(accountId) ?? "")
|
.unfollowAccount(toValue(accountId) ?? "")
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
};
|
|
||||||
|
|
@ -4,18 +4,20 @@ import { useIntervalFn } from "@vueuse/core";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
export interface TimelineOptions<T> {
|
export interface TimelineOptions<T> {
|
||||||
fetchFunction: (client: Client, options: object) => Promise<Output<T[]>>;
|
fetchFunction: (options: object) => Promise<Output<T[]>>;
|
||||||
updateInterval?: number;
|
updateInterval?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTimeline<
|
export function useTimeline<
|
||||||
T extends z.infer<typeof Status> | z.infer<typeof Notification>,
|
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 items = ref<T[]>([]) as Ref<T[]>;
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const hasReachedEnd = ref(false);
|
const hasReachedEnd = ref(false);
|
||||||
const error = ref<Error | null>(null);
|
const error = ref<Error | null>(null);
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const { identity } = storeToRefs(authStore);
|
||||||
|
|
||||||
const nextMaxId = ref<string | undefined>(undefined);
|
const nextMaxId = ref<string | undefined>(undefined);
|
||||||
const prevMinId = ref<string | undefined>(undefined);
|
const prevMinId = ref<string | undefined>(undefined);
|
||||||
|
|
@ -29,7 +31,7 @@ export function useTimeline<
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await options.fetchFunction(client, {
|
const response = await options.fetchFunction({
|
||||||
limit: options.limit || 20,
|
limit: options.limit || 20,
|
||||||
max_id: direction === "next" ? nextMaxId.value : undefined,
|
max_id: direction === "next" ? nextMaxId.value : undefined,
|
||||||
min_id: direction === "prev" ? prevMinId.value : undefined,
|
min_id: direction === "prev" ? prevMinId.value : undefined,
|
||||||
|
|
@ -99,6 +101,17 @@ export function useTimeline<
|
||||||
pause();
|
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 {
|
return {
|
||||||
items,
|
items,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
<template>
|
<template>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar>
|
<AppSidebar>
|
||||||
<slot v-if="!route.meta.requiresAuth || identity" />
|
<slot v-if="!route.meta.requiresAuth || authStore.isSignedIn" />
|
||||||
<div class="mx-auto max-w-4xl p-4" v-else>
|
<div class="mx-auto max-w-4xl p-4" v-else>
|
||||||
<AuthRequired />
|
<AuthRequired />
|
||||||
</div>
|
</div>
|
||||||
</AppSidebar>
|
</AppSidebar>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
<MobileNavbar v-if="identity" />
|
<MobileNavbar v-if="authStore.isSignedIn" />
|
||||||
<Preferences />
|
<Preferences />
|
||||||
<ComposerDialog v-if="identity" />
|
<ComposerDialog v-if="authStore.isSignedIn" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
@ -23,6 +23,7 @@ import { SidebarProvider } from "~/components/ui/sidebar";
|
||||||
const colorMode = useColorMode();
|
const colorMode = useColorMode();
|
||||||
const { n, d } = useMagicKeys();
|
const { n, d } = useMagicKeys();
|
||||||
const activeElement = useActiveElement();
|
const activeElement = useActiveElement();
|
||||||
|
const authStore = useAuthStore();
|
||||||
const notUsingInput = computed(
|
const notUsingInput = computed(
|
||||||
() =>
|
() =>
|
||||||
activeElement.value?.tagName !== "INPUT" &&
|
activeElement.value?.tagName !== "INPUT" &&
|
||||||
|
|
|
||||||
11
app/layouts/auth.vue
Normal file
11
app/layouts/auth.vue
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex h-svh items-center justify-center px-6 py-12 lg:px-8 bg-center bg-no-repeat bg-cover" :style="{
|
||||||
|
backgroundImage: 'url(/images/banner.webp)',
|
||||||
|
}">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<slot />
|
<slot />
|
||||||
<ComposerDialog v-if="identity" />
|
<ComposerDialog v-if="authStore.isSignedIn" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import ComposerDialog from "~/components/composer/dialog.vue";
|
import ComposerDialog from "~/components/composer/dialog.vue";
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,9 @@ const element = ref<HTMLElement | null>(null);
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const uuid = route.params.uuid as string;
|
const uuid = route.params.uuid as string;
|
||||||
|
|
||||||
const note = useNote(client, uuid);
|
const note = useNote(uuid);
|
||||||
const noteId = computed(() => note.value?.id ?? null);
|
const noteId = computed(() => note.value?.id ?? null);
|
||||||
const context = useNoteContext(client, noteId);
|
const context = useNoteContext(noteId);
|
||||||
const loaded = computed(() => note.value !== null && context.value !== null);
|
const loaded = computed(() => note.value !== null && context.value !== null);
|
||||||
|
|
||||||
// If ancestors changes, scroll down so that the initial note stays at the same place
|
// If ancestors changes, scroll down so that the initial note stays at the same place
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ definePageMeta({
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { account, isLoading } = useAccountFromAcct(client, username);
|
const { account, isLoading } = useAccountFromAcct(username);
|
||||||
const accountId = computed(() => account.value?.id ?? undefined);
|
const accountId = computed(() => account.value?.id ?? undefined);
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
|
|
|
||||||
43
app/pages/callback.vue
Normal file
43
app/pages/callback.vue
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<template>
|
||||||
|
<Card v-if="code && domain" class="w-full max-w-md *:w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Signing in...</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
You will be redirected shortly.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card v-else class="w-full max-w-md *:w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Error</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Missing code or domain in the callback URL.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
|
||||||
|
// This page handles the OAuth callback and signs the user in
|
||||||
|
definePageMeta({
|
||||||
|
layout: "auth",
|
||||||
|
});
|
||||||
|
|
||||||
|
const code = useRequestURL().searchParams.get("code");
|
||||||
|
const domain = useRequestURL().searchParams.get("domain");
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
if (code && domain) {
|
||||||
|
const newOrigin = new URL(`https://${domain}`);
|
||||||
|
|
||||||
|
await authStore.finishSignIn(code, newOrigin);
|
||||||
|
await navigateTo("/");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<TimelineScroller>
|
<TimelineScroller>
|
||||||
<Home v-if="identity" />
|
<Home v-if="authStore.isSignedIn" />
|
||||||
<Public v-else />
|
<Public v-else />
|
||||||
</TimelineScroller>
|
</TimelineScroller>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -11,8 +11,9 @@ import Public from "~/components/timelines/public.vue";
|
||||||
import TimelineScroller from "~/components/timelines/timeline-scroller.vue";
|
import TimelineScroller from "~/components/timelines/timeline-scroller.vue";
|
||||||
import * as m from "~~/paraglide/messages.js";
|
import * as m from "~~/paraglide/messages.js";
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
useHead({
|
useHead({
|
||||||
title: identity.value
|
title: authStore.isSignedIn
|
||||||
? m.bland_chunky_sparrow_propel()
|
? m.bland_chunky_sparrow_propel()
|
||||||
: m.lost_trick_dog_grace(),
|
: m.lost_trick_dog_grace(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
|
||||||
class="flex h-svh items-center justify-center px-6 py-12 lg:px-8 bg-center bg-no-repeat bg-cover"
|
|
||||||
:style="{
|
|
||||||
backgroundImage: 'url(/images/banner.webp)',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Card v-if="params.success" class="w-full max-w-md *:w-full">
|
<Card v-if="params.success" class="w-full max-w-md *:w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{{ m.late_mean_capybara_fade() }}</CardTitle>
|
<CardTitle>{{ m.late_mean_capybara_fade() }}</CardTitle>
|
||||||
|
|
@ -103,7 +97,6 @@
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
@ -135,8 +128,12 @@ import * as m from "~~/paraglide/messages.js";
|
||||||
useHead({
|
useHead({
|
||||||
title: m.arable_arable_herring_lead(),
|
title: m.arable_arable_herring_lead(),
|
||||||
});
|
});
|
||||||
|
definePageMeta({
|
||||||
|
layout: "auth",
|
||||||
|
});
|
||||||
|
|
||||||
identity.value = null;
|
const authStore = useAuthStore();
|
||||||
|
authStore.setActiveIdentity(null);
|
||||||
|
|
||||||
const formSchema = toTypedSchema(
|
const formSchema = toTypedSchema(
|
||||||
z
|
z
|
||||||
|
|
|
||||||
|
|
@ -160,15 +160,17 @@ const schema = toTypedSchema(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const instance = useInstanceFromClient(new Client(client.value.url));
|
const client = new Client(new URL(useRequestURL().origin));
|
||||||
|
const instance = useInstanceFromClient(client);
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
validationSchema: schema,
|
validationSchema: schema,
|
||||||
});
|
});
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit((values) => {
|
const handleSubmit = form.handleSubmit((values) => {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
ref(client)
|
authStore.client
|
||||||
.value?.registerAccount(
|
.registerAccount(
|
||||||
values.username,
|
values.username,
|
||||||
values.email,
|
values.email,
|
||||||
values.password,
|
values.password,
|
||||||
|
|
|
||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
293
app/stores/composer.ts
Normal file
293
app/stores/composer.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
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 || "";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
persist: {
|
||||||
|
serializer: {
|
||||||
|
serialize(data) {
|
||||||
|
// Delete file references before storing to avoid large storage usage
|
||||||
|
const newFiles = (data as ComposerState).files.map((f) => {
|
||||||
|
const { file, ...rest } = f;
|
||||||
|
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
|
return JSON.stringify({ ...data, files: newFiles });
|
||||||
|
},
|
||||||
|
deserialize(str) {
|
||||||
|
return JSON.parse(str);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
// Store everything in "composer" key to avoid creating too many entries
|
||||||
|
getItem(key) {
|
||||||
|
return JSON.stringify(
|
||||||
|
JSON.parse(localStorage.getItem("composer") || "{}")[
|
||||||
|
key
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
setItem(key, value) {
|
||||||
|
const composer = JSON.parse(
|
||||||
|
localStorage.getItem("composer") || "{}",
|
||||||
|
);
|
||||||
|
composer[key] = JSON.parse(value);
|
||||||
|
localStorage.setItem("composer", JSON.stringify(composer));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
import type { CredentialApplication } from "@versia/client/schemas";
|
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
import { toast } from "vue-sonner";
|
|
||||||
import type { z } from "zod";
|
|
||||||
import { confirmModalService } from "~/components/modals/composable";
|
import { confirmModalService } from "~/components/modals/composable";
|
||||||
import pkg from "~~/package.json";
|
|
||||||
import * as m from "~~/paraglide/messages.js";
|
import * as m from "~~/paraglide/messages.js";
|
||||||
|
|
||||||
const getRedirectUri = () => new URL("/", useRequestURL().origin);
|
|
||||||
|
|
||||||
export const askForInstance = async (): Promise<URL> => {
|
export const askForInstance = async (): Promise<URL> => {
|
||||||
const { confirmed, value } = await confirmModalService.confirm({
|
const { confirmed, value } = await confirmModalService.confirm({
|
||||||
title: m.sharp_alive_anteater_fade(),
|
title: m.sharp_alive_anteater_fade(),
|
||||||
|
|
@ -21,136 +14,3 @@ export const askForInstance = async (): Promise<URL> => {
|
||||||
|
|
||||||
throw new Error("No instance provided");
|
throw new Error("No instance provided");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const signIn = async (
|
|
||||||
appData: Ref<z.infer<typeof CredentialApplication> | null>,
|
|
||||||
origin: URL,
|
|
||||||
) => {
|
|
||||||
const id = toast.loading(m.level_due_ox_greet());
|
|
||||||
|
|
||||||
const client = useClient(origin);
|
|
||||||
|
|
||||||
const redirectUri = getRedirectUri();
|
|
||||||
|
|
||||||
redirectUri.searchParams.append("origin", origin.toString());
|
|
||||||
|
|
||||||
const output = await client.value.createApp("Versia-FE", {
|
|
||||||
scopes: ["read", "write", "follow", "push"],
|
|
||||||
redirect_uris: redirectUri.toString(),
|
|
||||||
// @ts-expect-error Package.json types are missing this field
|
|
||||||
website: pkg.homepage ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!output?.data) {
|
|
||||||
toast.dismiss(id);
|
|
||||||
toast.error(m.silly_sour_fireant_fear());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
appData.value = output.data;
|
|
||||||
|
|
||||||
const url = await client.value.generateAuthUrl(
|
|
||||||
output.data.client_id,
|
|
||||||
output.data.client_secret,
|
|
||||||
{
|
|
||||||
scopes: ["read", "write", "follow", "push"],
|
|
||||||
redirect_uri: redirectUri.toString(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
toast.dismiss(id);
|
|
||||||
toast.error(m.candid_frail_lion_value());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add "instance_switch_uri" parameter to URL
|
|
||||||
const toRedirect = new URL(url);
|
|
||||||
|
|
||||||
toRedirect.searchParams.append("instance_switch_uri", useRequestURL().href);
|
|
||||||
|
|
||||||
window.location.href = toRedirect.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const signInWithCode = (
|
|
||||||
code: string,
|
|
||||||
appData: z.infer<typeof CredentialApplication>,
|
|
||||||
origin: URL,
|
|
||||||
) => {
|
|
||||||
const client = useClient(origin);
|
|
||||||
const redirectUri = getRedirectUri();
|
|
||||||
|
|
||||||
redirectUri.searchParams.append("origin", origin.toString());
|
|
||||||
|
|
||||||
client.value
|
|
||||||
?.fetchAccessToken(
|
|
||||||
appData.client_id,
|
|
||||||
appData.client_secret,
|
|
||||||
code,
|
|
||||||
redirectUri.toString(),
|
|
||||||
)
|
|
||||||
.then(async (res) => {
|
|
||||||
const tempClient = useClient(origin, res.data).value;
|
|
||||||
|
|
||||||
const [accountOutput, instanceOutput] = await Promise.all([
|
|
||||||
tempClient.verifyAccountCredentials(),
|
|
||||||
tempClient.getInstance(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Get account data
|
|
||||||
if (
|
|
||||||
!identities.value.find(
|
|
||||||
(i) => i.account.id === accountOutput.data.id,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
identity.value = {
|
|
||||||
id: nanoid(),
|
|
||||||
tokens: res.data,
|
|
||||||
account: accountOutput.data,
|
|
||||||
instance: instanceOutput.data,
|
|
||||||
permissions: [],
|
|
||||||
emojis: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove code from URL
|
|
||||||
window.history.replaceState(
|
|
||||||
{},
|
|
||||||
document.title,
|
|
||||||
window.location.pathname,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Redirect to home
|
|
||||||
window.location.pathname = "/";
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const signOut = async (
|
|
||||||
appData: z.infer<typeof CredentialApplication> | null,
|
|
||||||
identityToRevoke: Identity,
|
|
||||||
) => {
|
|
||||||
const id = toast.loading("Signing out...");
|
|
||||||
|
|
||||||
if (!appData) {
|
|
||||||
toast.dismiss(id);
|
|
||||||
toast.error("No app or identity data to sign out");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't do anything on error, as Versia Server doesn't implement the revoke endpoint yet
|
|
||||||
await client.value
|
|
||||||
?.revokeToken(
|
|
||||||
appData.client_id,
|
|
||||||
identityToRevoke.tokens.access_token,
|
|
||||||
identityToRevoke.tokens.access_token,
|
|
||||||
)
|
|
||||||
.catch(() => {
|
|
||||||
// Do nothing
|
|
||||||
});
|
|
||||||
|
|
||||||
identities.value = identities.value.filter(
|
|
||||||
(i) => i.id !== identityToRevoke.id,
|
|
||||||
);
|
|
||||||
toast.dismiss(id);
|
|
||||||
toast.success("Signed out");
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
export const wrapUrl = (path: string) => {
|
export const wrapUrl = (path: string) => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
return new URL(
|
return new URL(
|
||||||
path,
|
path,
|
||||||
identity.value
|
authStore.instance
|
||||||
? `https://${identity.value.instance.domain}`
|
? `https://${authStore.instance.domain}`
|
||||||
: window.location.origin,
|
: window.location.origin,
|
||||||
).toString();
|
).toString();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
11
bun.lock
11
bun.lock
|
|
@ -7,6 +7,7 @@
|
||||||
"@floating-ui/dom": "^1.7.4",
|
"@floating-ui/dom": "^1.7.4",
|
||||||
"@nuxt/fonts": "^0.11.4",
|
"@nuxt/fonts": "^0.11.4",
|
||||||
"@nuxtjs/color-mode": "3.5.2",
|
"@nuxtjs/color-mode": "3.5.2",
|
||||||
|
"@pinia/nuxt": "^0.11.2",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.1.12",
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
|
@ -43,6 +44,8 @@
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"nuxt": "^4.0.3",
|
"nuxt": "^4.0.3",
|
||||||
"nuxt-security": "^2.4.0",
|
"nuxt-security": "^2.4.0",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"pinia-plugin-persistedstate": "^4.5.0",
|
||||||
"reka-ui": "^2.4.1",
|
"reka-ui": "^2.4.1",
|
||||||
"shadcn-nuxt": "2.2.0",
|
"shadcn-nuxt": "2.2.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|
@ -599,6 +602,8 @@
|
||||||
|
|
||||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
|
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
|
||||||
|
|
||||||
|
"@pinia/nuxt": ["@pinia/nuxt@0.11.2", "", { "dependencies": { "@nuxt/kit": "^3.9.0" }, "peerDependencies": { "pinia": "^3.0.3" } }, "sha512-CgvSWpbktxxWBV7ModhAcsExsQZqpPq6vMYEe9DexmmY6959ev8ukL4iFhr/qov2Nb9cQAWd7niFDnaWkN+FHg=="],
|
||||||
|
|
||||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||||
|
|
||||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||||
|
|
@ -1167,6 +1172,8 @@
|
||||||
|
|
||||||
"dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="],
|
"dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="],
|
||||||
|
|
||||||
|
"deep-pick-omit": ["deep-pick-omit@1.2.1", "", {}, "sha512-2J6Kc/m3irCeqVG42T+SaUMesaK7oGWaedGnQQK/+O0gYc+2SP5bKh/KKTE7d7SJ+GCA9UUE1GRzh6oDe0EnGw=="],
|
||||||
|
|
||||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|
||||||
"default-browser": ["default-browser@5.2.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg=="],
|
"default-browser": ["default-browser@5.2.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg=="],
|
||||||
|
|
@ -1909,6 +1916,10 @@
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"pinia": ["pinia@3.0.3", "", { "dependencies": { "@vue/devtools-api": "^7.7.2" }, "peerDependencies": { "typescript": ">=4.4.4", "vue": "^2.7.0 || ^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA=="],
|
||||||
|
|
||||||
|
"pinia-plugin-persistedstate": ["pinia-plugin-persistedstate@4.5.0", "", { "dependencies": { "deep-pick-omit": "^1.2.1", "defu": "^6.1.4", "destr": "^2.0.5" }, "peerDependencies": { "@nuxt/kit": ">=3.0.0", "@pinia/nuxt": ">=0.10.0", "pinia": ">=3.0.0" }, "optionalPeers": ["@nuxt/kit", "@pinia/nuxt", "pinia"] }, "sha512-QTkP1xJVyCdr2I2p3AKUZM84/e+IS+HktRxKGAIuDzkyaKKV48mQcYkJFVVDuvTxlI5j6X3oZObpqoVB8JnWpw=="],
|
||||||
|
|
||||||
"pkcs7": ["pkcs7@1.0.4", "", { "dependencies": { "@babel/runtime": "^7.5.5" }, "bin": { "pkcs7": "bin/cli.js" } }, "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ=="],
|
"pkcs7": ["pkcs7@1.0.4", "", { "dependencies": { "@babel/runtime": "^7.5.5" }, "bin": { "pkcs7": "bin/cli.js" } }, "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ=="],
|
||||||
|
|
||||||
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ export default defineNuxtConfig({
|
||||||
"@vite-pwa/nuxt",
|
"@vite-pwa/nuxt",
|
||||||
"shadcn-nuxt",
|
"shadcn-nuxt",
|
||||||
"@nuxtjs/color-mode",
|
"@nuxtjs/color-mode",
|
||||||
|
"@pinia/nuxt",
|
||||||
|
"pinia-plugin-persistedstate/nuxt",
|
||||||
],
|
],
|
||||||
ssr: false,
|
ssr: false,
|
||||||
components: {
|
components: {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
"@floating-ui/dom": "^1.7.4",
|
"@floating-ui/dom": "^1.7.4",
|
||||||
"@nuxt/fonts": "^0.11.4",
|
"@nuxt/fonts": "^0.11.4",
|
||||||
"@nuxtjs/color-mode": "3.5.2",
|
"@nuxtjs/color-mode": "3.5.2",
|
||||||
|
"@pinia/nuxt": "^0.11.2",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.1.12",
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
|
@ -72,6 +73,8 @@
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"nuxt": "^4.0.3",
|
"nuxt": "^4.0.3",
|
||||||
"nuxt-security": "^2.4.0",
|
"nuxt-security": "^2.4.0",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"pinia-plugin-persistedstate": "^4.5.0",
|
||||||
"reka-ui": "^2.4.1",
|
"reka-ui": "^2.4.1",
|
||||||
"shadcn-nuxt": "2.2.0",
|
"shadcn-nuxt": "2.2.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue