diff --git a/src/lib/app/auth.svelte.ts b/src/lib/app/auth.svelte.ts index 28e96a6d..29ffe176 100644 --- a/src/lib/app/auth.svelte.ts +++ b/src/lib/app/auth.svelte.ts @@ -1,12 +1,10 @@ import { browser } from '$app/environment' import { env } from '$env/dynamic/public' import { DEFAULT_CLIENT_TYPE, type ClientType } from '$lib/api/base' -import { client, getClient, site } from '$lib/api/client.svelte' +import { client, site } from '$lib/api/client.svelte' import type { Community, GetSiteResponse, MyUserInfo } from '$lib/api/types' import { publishedToDate } from '$lib/ui/util/date' import { toast } from 'mono-svelte' -import { writable } from 'svelte/store' -import { errorMessage } from './error' import { t } from './i18n' import { DEFAULT_INSTANCE_URL } from './instance.svelte' import { instanceToURL, moveItem } from './util.svelte' @@ -62,6 +60,9 @@ const getCookie = (key: string): string | undefined => { } class Profile { + private static readonly DONATION_CHECK_TIMEOUT = 3 * 1000 + private static readonly DONATION_REMINDER_INTERVAL = 375 * 24 * 60 * 60 * 1000 + meta = $state( getFromStorage('profileData') ?? { profiles: [ @@ -80,6 +81,7 @@ class Profile { this.meta.profiles.find((i) => i.id == this.meta.profile) ?? this.getDefaultProfile(), ) + inbox: InboxService = $state(new InboxService(this)) getDefaultProfile(): ProfileInfo { return { @@ -90,41 +92,47 @@ class Profile { } constructor() { - // cookie migration code - if ( - env.PUBLIC_MIGRATE_COOKIE && - this.meta.profiles.length == 0 && - env.PUBLIC_INSTANCE_URL - ) { - const jwt = getCookie('jwt') - if (jwt) { - ;(async () => { - const result = await this.add( - jwt, - env.PUBLIC_INSTANCE_URL ?? '', - DEFAULT_CLIENT_TYPE, - ) + this.initCookieMigrate() + this.donationPoll(Profile.DONATION_CHECK_TIMEOUT) + } - if (result) - toast({ - content: - 'Your instance migrated to Photon, and your account was transferred.', - type: 'success', - }) - })() - } - } + get current() { + return this.#current + } + set current(value) { + if (!value) return + const index = this.meta.profiles.findLastIndex((i) => i.id === value.id) + if (index != -1) this.meta.profiles[index] = value + } - setInterval( - () => { - if (profile.current.jwt) - this.checkInbox().then((res) => notifications.update(() => res)) - }, - 4 * 60 * 1000, + private async initCookieMigrate() { + if ( + !( + env.PUBLIC_MIGRATE_COOKIE && + this.meta.profiles.length == 0 && + env.PUBLIC_INSTANCE_URL + ) ) + return + + const jwt = getCookie('jwt') + if (!jwt) return + const result = await this.add( + jwt, + env.PUBLIC_INSTANCE_URL ?? '', + DEFAULT_CLIENT_TYPE, + ) + + if (result) + toast({ + content: + 'Your instance migrated frontends, and your account was transferred.', + type: 'success', + }) + } - // hacky way to check donation status - setTimeout(() => { + private donationPoll(delay: number) { + return setTimeout(() => { if ( profile.current.user?.local_user_view.local_user .last_donation_notification @@ -133,7 +141,10 @@ class Profile { profile.current.user?.local_user_view.local_user .last_donation_notification, ) - if (Date.now() - donationDate.getTime() > 365 * 24 * 60 * 60 * 1000) { + if ( + Date.now() - donationDate.getTime() > + Profile.DONATION_REMINDER_INTERVAL + ) { toast({ content: t.get('toast.lemmyDonate'), duration: 3600 * 1000, @@ -152,23 +163,13 @@ class Profile { ) } } - }, 3 * 1000) - } - - get current() { - return this.#current - } - set current(value) { - if (!value) return - const index = this.meta.profiles.findIndex((i) => i.id == value?.id) - this.meta.profiles[index] = value + }, delay) } async fetchUserData() { const startId = this.#current.id if (this.#current.jwt) { site.data = undefined - notifications.set({ applications: 0, inbox: 0, reports: 0 }) const res = await userFromJwt(this.#current.jwt, this.#current.instance) if (!res?.user) @@ -190,6 +191,7 @@ class Profile { this.#current.avatar = res?.user?.local_user_view.person.avatar this.#current.username = res?.user?.local_user_view.person.name } + this.inbox.init() } else { if (browser) { site.data = undefined @@ -202,44 +204,31 @@ class Profile { return this } - async checkInbox() { - const { user, jwt } = profile.current - if (!user || !jwt) throw new Error('checkInbox() called with invalid user') - - const notifs = await getNotificationCount(jwt, this.isMod(), this.isAdmin) - - return { - inbox: notifs.unreads, - applications: notifs.applications, - reports: notifs.reports, - } - } - async add(jwt: string, instance: string, type: ClientType) { - const user = await userFromJwt(jwt, instance) - .then((u) => u) - .catch((err) => { - toast({ content: errorMessage(err as string), type: 'error' }) + try { + const user = await userFromJwt(jwt, instance) + if (!user?.user) { + throw new Error('No user data received') + } + + const id = Math.max(...this.meta.profiles.map((p) => p.id), 0) + 1 + this.meta.profiles.unshift({ + id, + instance, + jwt, + username: user.user.local_user_view.person.name, + avatar: user.user.local_user_view.person.avatar, + client: type, }) - if (!user?.user) { + this.meta.profile = id + return user + } catch (err) { toast({ - content: "Your instance's API did not return your user data.", + content: err as string, type: 'error', }) + return null } - - const id = Math.max(...this.meta.profiles.map((p) => p.id)) + 1 - this.meta.profile = id - this.meta.profiles.unshift({ - id: id, - instance: instance, - jwt: jwt, - username: user?.user?.local_user_view.person.name, - avatar: user?.user?.local_user_view.person.avatar, - client: type, - }) - - return user } remove(id: number) { @@ -263,7 +252,7 @@ class Profile { } } - isMod(community?: Community) { + isMod(community?: Community): boolean { if (community) return ( (this.#current.user?.moderates.some( @@ -275,7 +264,7 @@ class Profile { else return (this.#current.user?.moderates.length ?? 0) > 0 } - get isAdmin() { + get isAdmin(): boolean { return ( site.data?.admins.some( (i) => i.person.id == this.#current.user?.local_user_view.person.id, @@ -283,6 +272,10 @@ class Profile { ) } + get isDefaultProfile(): boolean { + return !this.#current.jwt && this.#current.instance == DEFAULT_INSTANCE_URL + } + mainEffect = $effect.root(() => { // Sync with localStorage $effect(() => { @@ -301,22 +294,97 @@ class Profile { }) $effect(() => { - this.fetchUserData().then(() => { - if (this.current.jwt) - this.checkInbox().then((res) => notifications.update(() => res)) - }) + this.fetchUserData() }) }) } -export const profile = new Profile() +class InboxService { + private readonly POLL_INTERVAL = 4 * 60 * 1000 + #pollInterval: NodeJS.Timeout | null = null + + #profile: Profile + + notifications = $state({ + applications: 0, + inbox: 0, + reports: 0, + }) -export const notifications = writable({ - applications: 0, - inbox: 0, - reports: 0, -}) + constructor(profile: Profile) { + this.#profile = profile + } + + async init(): Promise { + this.cleanup() + + this.notifications = await this.checkInbox() + + this.#pollInterval = setInterval(async () => { + this.notifications = await this.checkInbox() + }, this.POLL_INTERVAL) + } + cleanup(): void { + if (this.#pollInterval) clearInterval(this.#pollInterval) + + this.#pollInterval = null + } + + clear(): Notifications { + this.notifications = { + applications: 0, + inbox: 0, + reports: 0, + } + return this.notifications + } + + async checkInbox(): Promise { + if (!this.#profile.current.user || !this.#profile.current.jwt) + return this.clear() + + const unreadsPromise = client() + .getUnreadCount() + .then((res) => res.mentions + res.private_messages + res.replies) + .catch(() => 0) + + const reportsPromise = this.#profile.isMod() + ? client() + .getReportCount({}) + .then( + (res) => + res.comment_reports + + res.post_reports + + (res.private_message_reports ?? 0), + ) + .catch(() => 0) + : new Promise((res) => res(0)) + + const applicationsPromise = this.#profile.isAdmin + ? client() + .getUnreadRegistrationApplicationCount() + .then((res) => res.registration_applications) + .catch(() => 0) + : new Promise((res) => res(0)) + + const [unreads, reports, applications] = await Promise.all([ + unreadsPromise, + reportsPromise, + applicationsPromise, + ]) + + return { + inbox: unreads, + reports: reports, + applications: applications, + } + } +} + +export const profile = new Profile() + +// this is all garbage legacy code, remove later async function userFromJwt( jwt: string, instance: string, @@ -358,41 +426,3 @@ function serializeUser(user: ProfileInfo): ProfileInfo { user: undefined, } } - -async function getNotificationCount(jwt: string, mod: boolean, admin: boolean) { - const unreadsPromise = getClient() - .getUnreadCount() - .then((res) => res.mentions + res.private_messages + res.replies) - .catch(() => 0) - - const reportsPromise = mod - ? getClient() - .getReportCount({}) - .then( - (res) => - res.comment_reports + - res.post_reports + - (res.private_message_reports ?? 0), - ) - .catch(() => 0) - : new Promise((res) => res(0)) - - const applicationsPromise = admin - ? getClient() - .getUnreadRegistrationApplicationCount() - .then((res) => res.registration_applications) - .catch(() => 0) - : new Promise((res) => res(0)) - - const [unreads, reports, applications] = await Promise.all([ - unreadsPromise, - reportsPromise, - applicationsPromise, - ]) - - return { - unreads: unreads, - reports: reports, - applications: applications, - } -} diff --git a/src/lib/app/i18n/en.json b/src/lib/app/i18n/en.json index bf37e690..a89a5b5e 100644 --- a/src/lib/app/i18n/en.json +++ b/src/lib/app/i18n/en.json @@ -315,12 +315,16 @@ "loadComments": "Load comments" }, "postRedirect": { - "title": "Mismatching server", - "description": "That link resolves to a post not on your account's server, {{homeInstance}}.", + "title": "Cross-server content", + "description": "That content does not originate from {{homeInstance}}. You can load it on your instance here.", "actions": { "back": "Go back", "fetch": "Locate post on {{homeInstance}}", "switch": "Switch to an account on {{destInstance}}" + }, + "automatic": { + "title": "Locating post", + "description": "That content does not originate on your account's server." } }, "inbox": { diff --git a/src/lib/ui/layout/EndPlaceholder.svelte b/src/lib/ui/layout/EndPlaceholder.svelte index bf1deaaf..2de5f84f 100644 --- a/src/lib/ui/layout/EndPlaceholder.svelte +++ b/src/lib/ui/layout/EndPlaceholder.svelte @@ -23,17 +23,12 @@ none: '', } - const alignments = { - default: '', - center: '', - } - interface Props { class?: ClassValue size?: keyof typeof sizes color?: keyof typeof colors margin?: keyof typeof margins - alignment?: keyof typeof alignments + alignment?: 'default' | 'center' border?: boolean children?: import('svelte').Snippet action?: import('svelte').Snippet diff --git a/src/lib/ui/navbar/Navbar.svelte b/src/lib/ui/navbar/Navbar.svelte index ecb41419..0766d400 100644 --- a/src/lib/ui/navbar/Navbar.svelte +++ b/src/lib/ui/navbar/Navbar.svelte @@ -1,6 +1,6 @@ -{#if $notifications.applications > 0} - - - {$notifications.applications > 99 ? '∞' : $notifications.applications} - unread applications - -{/if} + + {@const notifications = profile.inbox.notifications} + {#if notifications.applications > 0} + + + {notifications.applications > 99 ? '∞' : notifications.applications} + unread applications + + {/if} + i.type != 'private_message' && !i.read).length : 0 - notifications.update((i) => ({ - applications: i.applications, - reports: i.reports, - inbox: totalNotifs, - })) + profile.inbox.notifications.inbox = totalNotifs return { unreadOnly: new ReactiveState(unreadOnly), diff --git a/src/routes/inbox/InboxItem.svelte b/src/routes/inbox/InboxItem.svelte index 1ab25b5b..422bd502 100644 --- a/src/routes/inbox/InboxItem.svelte +++ b/src/routes/inbox/InboxItem.svelte @@ -1,6 +1,6 @@