feat: Implement proper login and logout using UI

This commit is contained in:
Jesse Wierzbinski 2024-04-26 18:50:30 -10:00
parent e0c41bb9b5
commit 3c8093a3d2
No known key found for this signature in database
23 changed files with 273 additions and 193 deletions

27
app.vue
View file

@ -13,6 +13,33 @@ useServerSeoMeta({
// Use SSR-safe IDs for Headless UI // Use SSR-safe IDs for Headless UI
provideHeadlessUseId(() => useId()); provideHeadlessUseId(() => useId());
const code = useRequestURL().searchParams.get("code");
if (code) {
const client = useMegalodon();
const appData = useAppData();
const tokenData = useTokenData();
if (appData.value) {
client.value
?.fetchAccessToken(
appData.value.client_id,
appData.value.client_secret,
code,
new URL("/", useRequestURL().origin).toString(),
)
.then((res) => {
tokenData.value = res;
// Remove code from URL
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
});
}
}
</script> </script>
<style> <style>

View file

@ -1,6 +1,9 @@
<template> <template>
<button v-bind="$props" type="button" <button v-bind="$props" type="button" :disabled="loading"
class="rounded-md duration-200 hover:shadow disabled:opacity-70 disabled:cursor-not-allowed px-3 py-2 text-sm font-semibold text-white shadow-sm"> :class="['rounded-md duration-200 relative hover:shadow disabled:opacity-70 content-none disabled:cursor-not-allowed px-3 py-2 text-sm font-semibold text-white shadow-sm', loading && '[&>*]:invisible']">
<div v-if="loading" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 !visible">
<Icon name="tabler:loader-2" class="animate-spin w-5 h-5" />
</div>
<slot /> <slot />
</button> </button>
</template> </template>
@ -10,7 +13,11 @@ import type { ButtonHTMLAttributes } from "vue";
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes {} interface Props extends /* @vue-ignore */ ButtonHTMLAttributes {}
defineProps<Props>(); defineProps<
Props & {
loading?: boolean;
}
>();
</script> </script>
<style></style> <style></style>

View file

@ -1,5 +1,5 @@
<template> <template>
<ButtonsBase class="bg-white/10 hover:bg-white/20"> <ButtonsBase class="bg-white/10 hover:bg-white/20" :loading="loading">
<slot /> <slot />
</ButtonsBase> </ButtonsBase>
</template> </template>
@ -9,7 +9,11 @@ import type { ButtonHTMLAttributes } from "vue";
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes {} interface Props extends /* @vue-ignore */ ButtonHTMLAttributes {}
defineProps<Props>(); defineProps<
Props & {
loading?: boolean;
}
>();
</script> </script>
<style></style> <style></style>

View file

@ -21,14 +21,17 @@
<div class="flex flex-col gap-3 mt-auto"> <div class="flex flex-col gap-3 mt-auto">
<h3 class="font-semibold text-gray-300 text-xs uppercase opacity-0 group-hover:opacity-100 duration-200"> <h3 class="font-semibold text-gray-300 text-xs uppercase opacity-0 group-hover:opacity-100 duration-200">
Account</h3> Account</h3>
<NuxtLink href="/about/apps"> <ButtonsBase v-if="tokenData" @click="signOut().finally(() => loadingAuth = false)" :loading="loadingAuth"
<ButtonsBase class="flex flex-row text-left items-center justify-start gap-3 text-lg hover:ring-1 ring-white/10 overflow-hidden h-12 w-full duration-200">
<Icon name="tabler:logout" class="shrink-0 text-2xl" />
<span class="pr-28 line-clamp-1">Sign Out</span>
</ButtonsBase>
<ButtonsBase v-else @click="signIn().finally(() => loadingAuth = false)" :loading="loadingAuth"
class="flex flex-row text-left items-center justify-start gap-3 text-lg hover:ring-1 ring-white/10 overflow-hidden h-12 w-full duration-200"> class="flex flex-row text-left items-center justify-start gap-3 text-lg hover:ring-1 ring-white/10 overflow-hidden h-12 w-full duration-200">
<Icon name="tabler:login" class="shrink-0 text-2xl" /> <Icon name="tabler:login" class="shrink-0 text-2xl" />
<span class="pr-28 line-clamp-1">Sign In</span> <span class="pr-28 line-clamp-1">Sign In</span>
</ButtonsBase> </ButtonsBase>
</NuxtLink> <NuxtLink href="/register" v-if="!tokenData">
<NuxtLink href="/register">
<ButtonsBase <ButtonsBase
class="flex flex-row text-left items-center justify-start gap-3 text-lg hover:ring-1 ring-white/10 overflow-hidden h-12 w-full duration-200"> class="flex flex-row text-left items-center justify-start gap-3 text-lg hover:ring-1 ring-white/10 overflow-hidden h-12 w-full duration-200">
<Icon name="tabler:certificate" class="shrink-0 text-2xl" /> <Icon name="tabler:certificate" class="shrink-0 text-2xl" />
@ -52,4 +55,63 @@ const timelines = ref([
icon: "tabler:home", icon: "tabler:home",
}, },
]); ]);
const loadingAuth = ref(false);
const appData = useAppData();
const tokenData = useTokenData();
const client = useMegalodon();
const signIn = async () => {
loadingAuth.value = true;
const output = await client.value?.createApp("Lysand", {
scopes: ["read", "write", "follow", "push"],
redirect_uris: new URL("/", useRequestURL().origin).toString(),
website: useBaseUrl().value,
});
if (!output) {
alert("Failed to create app");
return;
}
appData.value = output;
const url = await client.value?.generateAuthUrl(
output.client_id,
output.client_secret,
{
scope: ["read", "write", "follow", "push"],
redirect_uri: new URL("/", useRequestURL().origin).toString(),
},
);
if (!url) {
alert("Failed to generate auth URL");
return;
}
window.location.href = url;
};
const signOut = async () => {
loadingAuth.value = true;
if (!appData.value || !tokenData.value) {
console.error("No app or token data to sign out");
return;
}
// Don't do anything on error, as Lysand doesn't implement the revoke endpoint yet
await client.value
?.revokeToken(
appData.value.client_id,
tokenData.value.access_token,
tokenData.value.access_token,
)
.catch(() => {});
tokenData.value = null;
};
</script> </script>

View file

@ -55,7 +55,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { Status } from '~/types/mastodon/status'; import type { Status } from "~/types/mastodon/status";
const props = defineProps<{ const props = defineProps<{
note?: Status; note?: Status;

View file

@ -91,12 +91,23 @@ const props = defineProps<{
// Handle reblogs // Handle reblogs
const note = computed(() => props.note?.reblog ?? props.note); const note = computed(() => props.note?.reblog ?? props.note);
const noteClosed = ref(note.value?.sensitive || !!note.value?.spoiler_text || false); const noteClosed = ref(
note.value?.sensitive || !!note.value?.spoiler_text || false,
);
const { copy } = useClipboard(); const { copy } = useClipboard();
const client = useMegalodon(); const client = useMegalodon();
const mentions = await useResolveMentions(note.value?.mentions ?? [], client); const mentions = await useResolveMentions(
const eventualReblogAccountName = props.note?.reblog ? (useParsedContent(props.note?.account.display_name, props.note?.account.emojis, mentions.value)).value : null; note.value?.mentions ?? [],
client.value,
);
const eventualReblogAccountName = props.note?.reblog
? useParsedContent(
props.note?.account.display_name,
props.note?.account.emojis,
mentions.value,
).value
: null;
const content = const content =
note.value && process.client note.value && process.client
? useParsedContent( ? useParsedContent(

View file

@ -20,48 +20,52 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { Notification } from '~/types/mastodon/notification'; import type { Notification } from "~/types/mastodon/notification";
const props = defineProps<{ const props = defineProps<{
notification?: Notification; notification?: Notification;
}>(); }>();
const accountName = useParsedContent(props.notification?.account?.display_name ?? '', props.notification?.account?.emojis ?? [], []); const accountName = useParsedContent(
props.notification?.account?.display_name ?? "",
props.notification?.account?.emojis ?? [],
[],
);
const text = computed(() => { const text = computed(() => {
if (!props.notification) return ''; if (!props.notification) return "";
switch (props.notification.type) { switch (props.notification.type) {
case 'mention': case "mention":
return 'mentioned you'; return "mentioned you";
case 'reblog': case "reblog":
return 'reblogged your note'; return "reblogged your note";
case 'favourite': case "favourite":
return 'liked your note'; return "liked your note";
case 'follow': case "follow":
return 'followed you'; return "followed you";
case 'follow_request': case "follow_request":
return 'requested to follow you'; return "requested to follow you";
default: default:
console.error('Unknown notification type', props.notification.type) console.error("Unknown notification type", props.notification.type);
return ''; return "";
} }
}); });
const icon = computed(() => { const icon = computed(() => {
if (!props.notification) return ''; if (!props.notification) return "";
switch (props.notification.type) { switch (props.notification.type) {
case 'mention': case "mention":
return 'tabler:at'; return "tabler:at";
case 'reblog': case "reblog":
return 'tabler:repeat'; return "tabler:repeat";
case 'favourite': case "favourite":
return 'tabler:heart'; return "tabler:heart";
case 'follow': case "follow":
return 'tabler:plus'; return "tabler:plus";
case 'follow_request': case "follow_request":
return 'tabler:plus'; return "tabler:plus";
default: default:
return ''; return "";
} }
}); });
</script> </script>

View file

@ -115,27 +115,25 @@ watch(
skeleton, skeleton,
async () => { async () => {
if (skeleton.value) return; if (skeleton.value) return;
parsedNote.value = ( parsedNote.value =
useParsedContent( useParsedContent(
props.account?.note ?? "", props.account?.note ?? "",
props.account?.emojis ?? [], props.account?.emojis ?? [],
[], [],
)
).value ?? ""; ).value ?? "";
parsedFields.value = props.account?.fields.map((field) => ({ parsedFields.value =
name: ( props.account?.fields.map((field) => ({
name:
useParsedContent( useParsedContent(
field.name, field.name,
props.account?.emojis ?? [], props.account?.emojis ?? [],
[], [],
)
).value ?? "", ).value ?? "",
value: ( value:
useParsedContent( useParsedContent(
field.value, field.value,
props.account?.emojis ?? [], props.account?.emojis ?? [],
[], [],
)
).value ?? "", ).value ?? "",
})) ?? []; })) ?? [];
}, },

View file

@ -14,15 +14,15 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
const access_token = useLocalStorage("lysand:access_token", null); const tokenData = useTokenData();
const client = useMegalodon(access_token); const client = useMegalodon(tokenData);
const isLoading = ref(true); const isLoading = ref(true);
const timelineParameters = ref({}); const timelineParameters = ref({});
const hasReachedEnd = ref(false); const hasReachedEnd = ref(false);
const { timeline, loadNext, loadPrev } = useNotificationTimeline( const { timeline, loadNext, loadPrev } = useNotificationTimeline(
client, client.value,
timelineParameters, timelineParameters,
); );
const skeleton = ref<HTMLSpanElement | null>(null); const skeleton = ref<HTMLSpanElement | null>(null);

View file

@ -21,7 +21,7 @@ const isLoading = ref(true);
const timelineParameters = ref({}); const timelineParameters = ref({});
const hasReachedEnd = ref(false); const hasReachedEnd = ref(false);
const { timeline, loadNext, loadPrev } = usePublicTimeline( const { timeline, loadNext, loadPrev } = usePublicTimeline(
client, client.value,
timelineParameters, timelineParameters,
); );
const skeleton = ref<HTMLSpanElement | null>(null); const skeleton = ref<HTMLSpanElement | null>(null);

View file

@ -0,0 +1,8 @@
import { StorageSerializers } from "@vueuse/core";
import type { OAuth } from "megalodon";
export const useTokenData = () => {
return useLocalStorage<OAuth.TokenData | null>("lysand:token_data", null, {
serializer: StorageSerializers.object,
});
};

View file

@ -1,14 +1,19 @@
import type { Mastodon } from "megalodon"; import type { Mastodon } from "megalodon";
import type { Account } from "~/types/mastodon/account"; import type { Account } from "~/types/mastodon/account";
export const useAccount = (client: Mastodon | null, accountId: string) => { export const useAccount = (
client: MaybeRef<Mastodon | null>,
accountId: string,
) => {
if (!client) { if (!client) {
return ref(null as Account | null); return ref(null as Account | null);
} }
const output = ref(null as Account | null); const output = ref(null as Account | null);
client.getAccount(accountId).then((res) => { ref(client)
.value?.getAccount(accountId)
.then((res) => {
output.value = res.data; output.value = res.data;
}); });

View file

@ -1,13 +1,19 @@
import type { Mastodon } from "megalodon"; import type { Mastodon } from "megalodon";
import type { Account } from "~/types/mastodon/account";
export const useAccountSearch = async (client: Mastodon | null, q: string) => { export const useAccountSearch = (
if (!client) { client: MaybeRef<Mastodon | null>,
return null; q: string,
} ): Ref<Account[] | null> => {
const output = ref(null as Account[] | null);
return ( ref(client)
await client.searchAccount(q, { .value?.searchAccount(q, {
resolve: true, resolve: true,
}) })
).data; .then((res) => {
output.value = res.data;
});
return output;
}; };

View file

@ -29,72 +29,4 @@ export const useAccountTimeline = (
}), }),
options, options,
); );
/* if (!client) {
return {
timeline: ref([]),
loadNext: async () => {},
loadPrev: async () => {},
};
}
const fetchedNotes = ref<Status[]>([]);
const fetchedNoteIds = new Set<string>();
let nextMaxId: string | undefined = undefined;
let prevMinId: string | undefined = undefined;
const loadNext = async () => {
const response = await client.getAccountStatuses(ref(id).value ?? "", {
only_media: false,
...ref(options).value,
max_id: nextMaxId,
limit: useConfig().NOTES_PER_PAGE,
});
const newNotes = response.data.filter(
(note) => !fetchedNoteIds.has(note.id),
);
if (newNotes.length > 0) {
fetchedNotes.value = [...fetchedNotes.value, ...newNotes];
nextMaxId = newNotes[newNotes.length - 1].id;
for (const note of newNotes) {
fetchedNoteIds.add(note.id);
}
} else {
nextMaxId = undefined;
}
};
const loadPrev = async () => {
const response = await client.getAccountStatuses(ref(id).value ?? "", {
only_media: false,
...ref(options).value,
min_id: prevMinId,
limit: useConfig().NOTES_PER_PAGE,
});
const newNotes = response.data.filter(
(note) => !fetchedNoteIds.has(note.id),
);
if (newNotes.length > 0) {
fetchedNotes.value = [...newNotes, ...fetchedNotes.value];
prevMinId = newNotes[0].id;
for (const note of newNotes) {
fetchedNoteIds.add(note.id);
}
} else {
prevMinId = undefined;
}
};
watch(
[() => ref(id).value, () => ref(options).value],
async ([id, { max_id, min_id }]) => {
nextMaxId = max_id;
prevMinId = min_id;
id && (await loadNext());
},
{ immediate: true },
);
return { timeline: fetchedNotes, loadNext, loadPrev }; */
}; };

8
composables/AppData.ts Normal file
View file

@ -0,0 +1,8 @@
import { StorageSerializers } from "@vueuse/core";
import type { OAuth } from "megalodon";
export const useAppData = () => {
return useLocalStorage<OAuth.AppData | null>("lysand:app_data", null, {
serializer: StorageSerializers.object,
});
};

View file

@ -5,14 +5,16 @@ type ExtendedDescription = {
content: string; content: string;
}; };
export const useExtendedDescription = (client: Mastodon | null) => { export const useExtendedDescription = (client: MaybeRef<Mastodon | null>) => {
if (!client) { if (!ref(client).value) {
return ref(null as ExtendedDescription | null); return ref(null as ExtendedDescription | null);
} }
const output = ref(null as ExtendedDescription | null); const output = ref(null as ExtendedDescription | null);
client.client.get("/api/v1/instance/extended_description").then((res) => { ref(client)
.value?.client.get("/api/v1/instance/extended_description")
.then((res) => {
output.value = res.data; output.value = res.data;
}); });

View file

@ -6,14 +6,16 @@ type InstanceWithExtra = Instance & {
lysand_version?: string; lysand_version?: string;
}; };
export const useInstance = (client: Mastodon | null) => { export const useInstance = (client: MaybeRef<Mastodon | null>) => {
if (!client) { if (!ref(client).value) {
return ref(null as InstanceWithExtra | null); return ref(null as InstanceWithExtra | null);
} }
const output = ref(null as InstanceWithExtra | null); const output = ref(null as InstanceWithExtra | null);
client.getInstance().then((res) => { ref(client)
.value?.getInstance()
.then((res) => {
output.value = res.data; output.value = res.data;
}); });

View file

@ -1,16 +1,18 @@
import { Mastodon } from "megalodon"; import { Mastodon, type OAuth } from "megalodon";
export const useMegalodon = ( export const useMegalodon = (
accessToken?: MaybeRef<string | null | undefined>, tokenData?: MaybeRef<OAuth.TokenData | null>,
disableOnServer = false, disableOnServer = false,
) => { ): Ref<Mastodon | null> => {
if (disableOnServer && process.server) { if (disableOnServer && process.server) {
return null; return ref(null);
} }
const baseUrl = useBaseUrl().value; return computed(
() =>
const client = new Mastodon(baseUrl, ref(accessToken).value); new Mastodon(
useBaseUrl().value,
return client; ref(tokenData).value?.access_token,
),
);
}; };

View file

@ -1,14 +1,16 @@
import type { Mastodon } from "megalodon"; import type { Mastodon } from "megalodon";
import type { Status } from "~/types/mastodon/status"; import type { Status } from "~/types/mastodon/status";
export const useNote = (client: Mastodon | null, noteId: string) => { export const useNote = (client: MaybeRef<Mastodon | null>, noteId: string) => {
if (!client) { if (!ref(client).value) {
return ref(null as Status | null); return ref(null as Status | null);
} }
const output = ref(null as Status | null); const output = ref(null as Status | null);
client.getStatus(noteId).then((res) => { ref(client)
.value?.getStatus(noteId)
.then((res) => {
output.value = res.data; output.value = res.data;
}); });

View file

@ -5,7 +5,7 @@
<aside <aside
class="max-w-md max-h-dvh overflow-y-auto w-full bg-dark-900 ring-1 ring-white/10 hidden lg:flex flex-col gap-10"> class="max-w-md max-h-dvh overflow-y-auto w-full bg-dark-900 ring-1 ring-white/10 hidden lg:flex flex-col gap-10">
<ClientOnly> <ClientOnly>
<div class="grow p-10" v-if="!accessToken"> <div class="grow p-10" v-if="!tokenData">
<button type="button" <button type="button"
class="relative block h-full w-full rounded-lg border-2 border-dashed border-dark-300 p-12 text-center"> class="relative block h-full w-full rounded-lg border-2 border-dashed border-dark-300 p-12 text-center">
<Icon name="tabler:notification" class="mx-auto h-12 w-12 text-gray-400" /> <Icon name="tabler:notification" class="mx-auto h-12 w-12 text-gray-400" />
@ -58,8 +58,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { convert } from "html-to-text"; import { convert } from "html-to-text";
const accessToken = useLocalStorage("lysand:access_token", ""); const tokenData = useTokenData();
const client = useMegalodon(accessToken); const client = useMegalodon(tokenData);
const instance = useInstance(client); const instance = useInstance(client);
const description = useExtendedDescription(client); const description = useExtendedDescription(client);

View file

@ -38,14 +38,13 @@ const route = useRoute();
const client = useMegalodon(undefined, true); const client = useMegalodon(undefined, true);
const username = (route.params.username as string).replace("@", ""); const username = (route.params.username as string).replace("@", "");
const account: Ref<Account | null> = ref(null); const accounts = useAccountSearch(client, username);
const account = computed<Account | null>(
() => accounts.value?.find((account) => account.acct === username) ?? null,
);
const accountId = computed(() => account.value?.id ?? null); const accountId = computed(() => account.value?.id ?? null);
onMounted(async () => { onMounted(async () => {
const accounts = await useAccountSearch(client, username);
account.value =
(await accounts?.find((account) => account.acct === username)) ?? null;
useIntersectionObserver(skeleton, async (entries) => { useIntersectionObserver(skeleton, async (entries) => {
if ( if (
entries[0].isIntersecting && entries[0].isIntersecting &&
@ -70,7 +69,7 @@ const isLoadingTimeline = ref(true);
const timelineParameters = ref({}); const timelineParameters = ref({});
const hasReachedEnd = ref(false); const hasReachedEnd = ref(false);
const { timeline, loadNext, loadPrev } = useAccountTimeline( const { timeline, loadNext, loadPrev } = useAccountTimeline(
client, client.value,
accountId, accountId,
timelineParameters, timelineParameters,
); );

View file

@ -23,7 +23,7 @@ const isLoading = ref(true);
const timelineParameters = ref({}); const timelineParameters = ref({});
const hasReachedEnd = ref(false); const hasReachedEnd = ref(false);
const { timeline, loadNext, loadPrev } = useLocalTimeline( const { timeline, loadNext, loadPrev } = useLocalTimeline(
client, client.value,
timelineParameters, timelineParameters,
); );
const skeleton = ref<HTMLSpanElement | null>(null); const skeleton = ref<HTMLSpanElement | null>(null);

View file

@ -133,7 +133,8 @@ const register = (result: {
reason: string; reason: string;
}) => { }) => {
isLoading.value = true; isLoading.value = true;
client?.registerAccount( ref(client)
.value?.registerAccount(
result.username, result.username,
result.email, result.email,
result.password, result.password,