diff --git a/src/lib/components/ChatSidebar.svelte b/src/lib/components/ChatSidebar.svelte index 430e772..d3babfc 100644 --- a/src/lib/components/ChatSidebar.svelte +++ b/src/lib/components/ChatSidebar.svelte @@ -1,5 +1,6 @@ + +
+

+ Notifications +

+ {#if !items} +

Loading…

+ {:else if items.length === 0} +

+ No replies or mentions yet. +

+ {:else} + + {/if} +
diff --git a/src/lib/components/UserAvatar.svelte b/src/lib/components/UserAvatar.svelte new file mode 100644 index 0000000..2d2ff7d --- /dev/null +++ b/src/lib/components/UserAvatar.svelte @@ -0,0 +1,40 @@ + + + +{#if auth.user} + + {#if auth.user.metadata.picture} + + {:else} + + {/if} + {#if count > 0} + + {/if} + +{/if} diff --git a/src/lib/forum/notifications.ts b/src/lib/forum/notifications.ts new file mode 100644 index 0000000..02cca1d --- /dev/null +++ b/src/lib/forum/notifications.ts @@ -0,0 +1,91 @@ +import type { Event } from "@nostr/tools/core"; +import { extractMentionPubkeys } from "$lib/mentions"; +import { tag, type Query } from "./query"; + +export type NotificationKind = + | "reply-op" // reply to a discussion the user started + | "reply" // reply in a discussion the user took part in + | "mention" // mentioned in a discussion reply + | "chat-reply" // chat reply to the user's message + | "chat-mention"; // mentioned in chat + +export type Notification = { + id: string; + kind: NotificationKind; + pubkey: string; // author + createdAt: number; + content: string; + groupId: string; + threadId?: string; // discussion replies only + title?: string; // thread title, filled by fetchThreadTitles +}; + +export const NOTIFICATIONS_LIMIT = 50; + +// Classify an event that p-tags the user. Own events and anything that is +// neither a discussion reply nor a chat message yield null. +export function parseNotification(e: Event, me: string): Notification | null { + if (e.pubkey === me) return null; + const mentioned = extractMentionPubkeys(e.content).includes(me); + const base = { + id: e.id, + pubkey: e.pubkey, + createdAt: e.created_at, + content: e.content, + groupId: tag(e, "h") ?? "", + }; + if (e.kind === 1111) { + const threadId = tag(e, "E"); + if (!threadId) return null; + const kind: NotificationKind = mentioned + ? "mention" + : tag(e, "P") === me + ? "reply-op" + : "reply"; + return { ...base, kind, threadId }; + } + if (e.kind === 9) { + const q = e.tags.find((t) => t[0] === "q"); + return { ...base, kind: q?.[3] === me ? "chat-reply" : "chat-mention" }; + } + return null; +} + +// Fill thread titles for discussion replies, in place. +export async function fetchThreadTitles( + q: Query, + items: Notification[], +): Promise { + const ids = [ + ...new Set(items.flatMap((n) => (n.threadId ? [n.threadId] : []))), + ]; + if (ids.length === 0) return; + const roots = await q({ kinds: [11], ids }); + const titles = new Map( + roots.map((e) => [e.id, tag(e, "title") ?? "(untitled)"]), + ); + for (const n of items) { + if (n.threadId) n.title = titles.get(n.threadId) ?? ""; + } +} + +// Latest replies and mentions addressed to the user, newest first. Without +// `groupId` every group the relay serves the caller counts. +export async function fetchNotifications( + q: Query, + me: string, + groupId?: string, +): Promise { + const events = await q({ + kinds: [1111, 9], + "#p": [me], + ...(groupId ? { "#h": [groupId] } : {}), + limit: NOTIFICATIONS_LIMIT, + }); + const items = events + .map((e) => parseNotification(e, me)) + .filter((n): n is Notification => n !== null) + .sort((a, b) => b.createdAt - a.createdAt); + await fetchThreadTitles(q, items); + return items; +} diff --git a/src/lib/notifications.svelte.ts b/src/lib/notifications.svelte.ts new file mode 100644 index 0000000..b50923b --- /dev/null +++ b/src/lib/notifications.svelte.ts @@ -0,0 +1,112 @@ +import { GROUP_ID, MODE } from "$lib/config"; +import { queryForum, subscribeForum } from "$lib/relay"; +import { ensureProfile } from "$lib/profiles.svelte"; +import { + fetchNotifications, + fetchThreadTitles, + parseNotification, + type Notification, +} from "$lib/forum/notifications"; + +export type { Notification }; + +const SEEN_KEY = "notifications_seen"; + +let pubkey = $state(null); +let items = $state(null); +let seenAt = $state(0); +let liveSub: { close(): void } | null = null; +let req = 0; // supersedes an in-flight load on login/logout + +function readSeen(pk: string): number { + try { + return Number(localStorage.getItem(`${SEEN_KEY}_${pk}`)) || 0; + } catch { + return 0; + } +} + +export const notificationsStore = { + get pubkey() { + return pubkey; + }, + get items() { + return items; + }, + get seenAt() { + return seenAt; + }, + get unreadCount() { + return items?.filter((n) => n.createdAt > seenAt).length ?? 0; + }, +}; + +function ingest(n: Notification) { + if (!items || items.some((x) => x.id === n.id)) return; + items = [n, ...items]; + ensureProfile(n.pubkey); +} + +// (Re)start for the logged-in user: one authenticated read of the latest +// replies/mentions, then a live subscription that prepends new ones. +export async function startNotifications(pk: string) { + if (pk === pubkey) return; + stopNotifications(); + pubkey = pk; + seenAt = readSeen(pk); + const current = ++req; + const scope = MODE === "simple" ? GROUP_ID : undefined; + + let result: Notification[] = []; + try { + result = await fetchNotifications(queryForum, pk, scope); + } catch (e) { + console.error("[notifications] load failed", e); + } + if (current !== req) return; + items = result; + for (const n of result) ensureProfile(n.pubkey); + + liveSub = subscribeForum( + { + kinds: [1111, 9], + "#p": [pk], + ...(scope ? { "#h": [scope] } : {}), + since: Math.floor(Date.now() / 1000), + }, + { + onevent: async (ev) => { + const n = parseNotification(ev, pk); + if (!n) return; + if (n.threadId) { + try { + await fetchThreadTitles(queryForum, [n]); + } catch { + // Shown without a title + } + } + if (current === req) ingest(n); + }, + }, + ); +} + +export function stopNotifications() { + req++; + liveSub?.close(); + liveSub = null; + pubkey = null; + items = null; + seenAt = 0; +} + +// Everything loaded so far counts as read. +export function markNotificationsSeen() { + if (!pubkey) return; + seenAt = Math.floor(Date.now() / 1000); + try { + localStorage.setItem(`${SEEN_KEY}_${pubkey}`, String(seenAt)); + } catch { + // Private mode: unread state lasts for the session only + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 04a36c2..5eb4227 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -24,6 +24,10 @@ import { loadRoomAdmins } from "$lib/admins.svelte"; import { seedProfiles } from "$lib/profiles.svelte"; import { startChat } from "$lib/chat.svelte"; + import { + startNotifications, + stopNotifications, + } from "$lib/notifications.svelte"; import { resetForumConnection } from "$lib/relay"; import { relayAccess, probeRelayAccess } from "$lib/access.svelte"; import { activeGroup, setActiveGroup } from "$lib/active.svelte"; @@ -93,6 +97,15 @@ seedProfiles(auth.user?.pubkey ?? null); } + // Replies/mentions follow the logged-in user. Keyed on the user rather than + // on loadForum: a restored session resolves its profile after the probe. + $effect(() => { + const pk = auth.user?.pubkey ?? null; + if (relayAccess.state !== "open") return; + if (pk) startNotifications(pk); + else stopNotifications(); + }); + // Each completed probe that finds the relay open reloads the room views: on // first load, after a login (private/hidden rooms appear) or logout (they // vanish), and after joining a members-only relay. @@ -171,9 +184,10 @@ // On mobile a route change should always land on the forum pane, so opening // a thread or room from the menu never leaves the user stranded on chat. + // A chat message link (#chat-…, e.g. from a notification) opens the chat pane. $effect(() => { page.url.pathname; - mobileView = "forum"; + mobileView = page.url.hash.startsWith("#chat-") ? "chat" : "forum"; }); diff --git a/src/routes/profile/[id]/+page.svelte b/src/routes/profile/[id]/+page.svelte index cb0a0bd..0f0bf35 100644 --- a/src/routes/profile/[id]/+page.svelte +++ b/src/routes/profile/[id]/+page.svelte @@ -20,9 +20,12 @@ UserPostPage, } from "$lib/forum/userPosts"; import Meta from "$lib/components/Meta.svelte"; + import Notifications from "$lib/components/Notifications.svelte"; + import { auth } from "$lib/auth.svelte"; import { summarize } from "$lib/seo"; const pubkey = $derived(page.data.pubkey ?? null); + const isOwn = $derived(!!pubkey && auth.user?.pubkey === pubkey); $effect(() => { if (!pubkey) return; @@ -240,6 +243,10 @@ {/if} + {#if isOwn} + + {/if} +