Add in-app notitications

This commit is contained in:
dtonon 2026-09-23 10:32:32 +02:00
parent 67d461333c
commit 8695d3e40e
10 changed files with 537 additions and 55 deletions

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { tick } from "svelte"; import { tick } from "svelte";
import { page } from "$app/state";
import { import {
chatStore, chatStore,
getChatMessage, getChatMessage,
@ -262,6 +263,23 @@
}); });
}); });
// Arriving with a #chat-… hash (a notification link): scroll to the message
// once it is loaded, and stop anchoring to the bottom so it stays in view.
let scrolledHashFor = "";
$effect(() => {
const hash = page.url.hash;
messages.length;
mobileActive;
if (!hash.startsWith("#chat-") || scrolledHashFor === hash) return;
tick().then(() => {
const el = document.getElementById(hash.slice(1));
if (!el) return;
scrolledHashFor = hash;
userScrolledUp = true;
el.scrollIntoView({ block: "center" });
});
});
$effect(() => { $effect(() => {
if (!expanded) return; if (!expanded) return;
function handleClick(e: MouseEvent) { function handleClick(e: MouseEvent) {
@ -350,7 +368,7 @@
></span> ></span>
</div> </div>
{/if} {/if}
<div> <div id="chat-{msg.id}">
<div class="mb-1 flex items-center gap-2"> <div class="mb-1 flex items-center gap-2">
<a <a
href={profilePath(msg.pubkey)} href={profilePath(msg.pubkey)}

View file

@ -7,6 +7,15 @@
import { GROUP_ID, TITLE, SEARCH_ENABLED } from "$lib/config"; import { GROUP_ID, TITLE, SEARCH_ENABLED } from "$lib/config";
import { openSearch } from "$lib/searchModal.svelte"; import { openSearch } from "$lib/searchModal.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte"; import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import UserAvatar from "$lib/components/UserAvatar.svelte";
import { notificationsStore } from "$lib/notifications.svelte";
const unread = $derived(notificationsStore.unreadCount);
const profileLabel = $derived(
unread > 0
? `Your profile, ${unread} new notification${unread === 1 ? "" : "s"}`
: "Your profile",
);
type Props = { type Props = {
mode: "simple" | "full"; mode: "simple" | "full";
@ -189,30 +198,25 @@
</button> </button>
{/if} {/if}
{#if auth.user} {#if auth.user}
<div class="mt-3 flex items-center gap-1"> <div class="mt-3 flex justify-end">
<button <button
onclick={() => { onclick={() => {
if (confirm("Log out?")) logout(); if (confirm("Log out?")) logout();
}} }}
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-700" class="rounded px-2 py-0.5 text-xs text-neutral-400 hover:bg-neutral-200 hover:text-neutral-700 dark:text-neutral-500 dark:hover:bg-neutral-700 dark:hover:text-neutral-300"
aria-label="Account options"
> >
{#if auth.user.metadata.picture} Log out
<img
src={auth.user.metadata.picture}
alt=""
class="h-7 w-7 rounded-full object-cover"
/>
{:else}
<span
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-300 text-xs font-semibold text-neutral-600 dark:bg-neutral-600 dark:text-neutral-400"
aria-hidden="true"
>
{auth.user.shortName.slice(0, 1).toUpperCase()}
</span>
{/if}
<span class="truncate font-medium">{auth.user.shortName}</span>
</button> </button>
</div>
<div class="flex items-center gap-1">
<a
href="/profile/{auth.user.npub}"
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-neutral-700 hover:bg-neutral-200 dark:text-neutral-300 dark:hover:bg-neutral-700"
aria-label={profileLabel}
>
<UserAvatar />
<span class="truncate font-medium">{auth.user.shortName}</span>
</a>
<ThemeToggle /> <ThemeToggle />
</div> </div>
{:else} {:else}

View file

@ -7,6 +7,15 @@
import { SEARCH_ENABLED } from "$lib/config"; import { SEARCH_ENABLED } from "$lib/config";
import { draftState, resumeDraft } from "$lib/draft.svelte"; import { draftState, resumeDraft } from "$lib/draft.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte"; import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import UserAvatar from "$lib/components/UserAvatar.svelte";
import { notificationsStore } from "$lib/notifications.svelte";
const unread = $derived(notificationsStore.unreadCount);
const profileLabel = $derived(
unread > 0
? `Your profile, ${unread} new notification${unread === 1 ? "" : "s"}`
: "Your profile",
);
type Props = { type Props = {
open: boolean; open: boolean;
@ -83,7 +92,7 @@
transition:fade={{ duration: 150 }} transition:fade={{ duration: 150 }}
></button> ></button>
<div <div
class="absolute inset-y-0 right-0 flex w-72 max-w-[80%] flex-col bg-white dark:bg-neutral-900 px-6 py-4 shadow-xl" class="absolute inset-y-0 right-0 flex w-72 max-w-[80%] flex-col bg-white px-6 py-4 shadow-xl dark:bg-neutral-900"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Menu" aria-label="Menu"
@ -97,7 +106,7 @@
<button <button
type="button" type="button"
onclick={onClose} onclick={onClose}
class="-mr-1 rounded p-1 text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800" class="-mr-1 rounded p-1 text-neutral-500 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800"
aria-label="Close menu" aria-label="Close menu"
> >
<svg <svg
@ -142,19 +151,25 @@
{#if mode === "full"} {#if mode === "full"}
<nav class="mt-4" aria-label="Rooms"> <nav class="mt-4" aria-label="Rooms">
<p <p
class="pb-1 font-semibold tracking-wider text-neutral-400 dark:text-neutral-500 uppercase" class="pb-1 font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
> >
Rooms Rooms
</p> </p>
{#if groupsStore.list.length === 0 && !groupsStore.loaded} {#if groupsStore.list.length === 0 && !groupsStore.loaded}
<p class="py-1.5 text-base text-neutral-400 dark:text-neutral-500">Loading rooms…</p> <p
class="py-1.5 text-base text-neutral-400 dark:text-neutral-500"
>
Loading rooms…
</p>
{/if} {/if}
{#each groupsStore.list as room} {#each groupsStore.list as room}
<a <a
href="/room/{room.id}" href="/room/{room.id}"
onclick={onClose} onclick={onClose}
class="block py-1.5 text-xl class="block py-1.5 text-xl
{activeRoom === room.id ? 'text-accent' : 'hover:text-accent text-neutral-700 dark:text-neutral-300'}" {activeRoom === room.id
? 'text-accent'
: 'hover:text-accent text-neutral-700 dark:text-neutral-300'}"
> >
{room.name} {room.name}
</a> </a>
@ -164,7 +179,7 @@
<nav class="mt-4" aria-label="Resources"> <nav class="mt-4" aria-label="Resources">
<p <p
class="pb-1 font-semibold tracking-wider text-neutral-400 dark:text-neutral-500 uppercase" class="pb-1 font-semibold tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
> >
Resources Resources
</p> </p>
@ -174,8 +189,9 @@
onclick={onClose} onclick={onClose}
aria-current={activeResource === r.slug ? "page" : undefined} aria-current={activeResource === r.slug ? "page" : undefined}
class="hover:text-accent block py-1.5 text-xl class="hover:text-accent block py-1.5 text-xl
{activeResource === r.slug ? 'text-accent' : 'text-neutral-700 dark:text-neutral-300'}" {activeResource === r.slug
>{r.title}</a ? 'text-accent'
: 'text-neutral-700 dark:text-neutral-300'}">{r.title}</a
> >
{/each} {/each}
<a <a
@ -183,11 +199,15 @@
onclick={onClose} onclick={onClose}
aria-current={contactsActive ? "page" : undefined} aria-current={contactsActive ? "page" : undefined}
class="hover:text-accent block py-1.5 text-lg class="hover:text-accent block py-1.5 text-lg
{contactsActive ? 'text-accent' : 'text-neutral-700 dark:text-neutral-300'}">Contacts</a {contactsActive
? 'text-accent'
: 'text-neutral-700 dark:text-neutral-300'}">Contacts</a
> >
</nav> </nav>
<div class="mt-4 flex flex-col gap-2 border-t border-neutral-100 dark:border-neutral-800 pt-4"> <div
class="mt-4 flex flex-col gap-2 border-t border-neutral-100 pt-4 dark:border-neutral-800"
>
{#if draftState.iconized} {#if draftState.iconized}
<button <button
type="button" type="button"
@ -199,33 +219,23 @@
{/if} {/if}
{#if auth.user} {#if auth.user}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <a
type="button" href="/profile/{auth.user.npub}"
onclick={onLogout} onclick={onClose}
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-2 text-left text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800" class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-2 text-left text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
aria-label="Log out" aria-label={profileLabel}
> >
{#if auth.user.metadata.picture} <UserAvatar size="md" />
<img
src={auth.user.metadata.picture}
alt=""
class="h-8 w-8 rounded-full object-cover"
/>
{:else}
<span
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-300 text-sm font-semibold text-neutral-600 dark:text-neutral-400 dark:bg-neutral-600"
aria-hidden="true"
>
{auth.user.shortName.slice(0, 1).toUpperCase()}
</span>
{/if}
<span class="truncate text-lg font-medium" <span class="truncate text-lg font-medium"
>{auth.user.shortName}</span >{auth.user.shortName}</span
> >
<span </a>
class="ml-auto shrink-0 text-sm text-neutral-400 dark:text-neutral-500" <button
>Log out</span type="button"
onclick={onLogout}
class="shrink-0 rounded px-2 py-2 text-sm text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
> >
Log out
</button> </button>
<ThemeToggle size="md" /> <ThemeToggle size="md" />
</div> </div>

View file

@ -1,10 +1,20 @@
<script lang="ts"> <script lang="ts">
import { groupStore } from "$lib/group.svelte"; import { groupStore } from "$lib/group.svelte";
import { auth } from "$lib/auth.svelte";
import { notificationsStore } from "$lib/notifications.svelte";
import { GROUP_ID, TITLE, MODE } from "$lib/config"; import { GROUP_ID, TITLE, MODE } from "$lib/config";
import UserAvatar from "$lib/components/UserAvatar.svelte";
type Props = { onMenuToggle: () => void }; type Props = { onMenuToggle: () => void };
let { onMenuToggle }: Props = $props(); let { onMenuToggle }: Props = $props();
const unread = $derived(notificationsStore.unreadCount);
const profileLabel = $derived(
unread > 0
? `Your profile, ${unread} new notification${unread === 1 ? "" : "s"}`
: "Your profile",
);
// PUBLIC_TITLE always wins when set. Without it, simple mode shows the room's // PUBLIC_TITLE always wins when set. Without it, simple mode shows the room's
// own name; full mode has no single room, so it falls back to GROUP_ID. // own name; full mode has no single room, so it falls back to GROUP_ID.
const name = $derived( const name = $derived(
@ -15,7 +25,7 @@
<!-- Mobile only: on desktop the logo lives at the top of the left sidebar. --> <!-- Mobile only: on desktop the logo lives at the top of the left sidebar. -->
<header <header
class="sticky top-0 z-20 flex h-16 shrink-0 items-center justify-between gap-2 bg-neutral-100 dark:bg-neutral-800 px-4 md:hidden" class="sticky top-0 z-20 flex h-16 shrink-0 items-center justify-between gap-2 bg-neutral-100 px-4 md:hidden dark:bg-neutral-800"
> >
<a <a
href="/" href="/"
@ -36,14 +46,23 @@
</div> </div>
{/if} {/if}
<span <span
class="truncate text-2xl font-medium text-neutral-900 dark:text-neutral-100 md:text-[1.7rem]" class="truncate text-2xl font-medium text-neutral-900 md:text-[1.7rem] dark:text-neutral-100"
>{name}</span >{name}</span
> >
</a> </a>
{#if auth.user}
<a
href="/profile/{auth.user.npub}"
class="ml-auto shrink-0 rounded p-1 hover:bg-neutral-200 dark:hover:bg-neutral-700"
aria-label={profileLabel}
>
<UserAvatar size="md" />
</a>
{/if}
<button <button
type="button" type="button"
onclick={onMenuToggle} onclick={onMenuToggle}
class="-mr-1 shrink-0 rounded p-1.5 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700 md:hidden" class="-mr-1 shrink-0 rounded p-1.5 text-neutral-700 hover:bg-neutral-200 md:hidden dark:text-neutral-300 dark:hover:bg-neutral-700"
aria-label="Open menu" aria-label="Open menu"
> >
<svg <svg

View file

@ -0,0 +1,167 @@
<script lang="ts">
import { untrack } from "svelte";
import { MODE } from "$lib/config";
import { getGroupName } from "$lib/group.svelte";
import { profileStore } from "$lib/profiles.svelte";
import { profilePath } from "$lib/forum/profiles";
import {
notificationsStore,
markNotificationsSeen,
type Notification,
} from "$lib/notifications.svelte";
import { summarize } from "$lib/seo";
import * as nip19 from "@nostr/tools/nip19";
const items = $derived(notificationsStore.items);
// Unread threshold frozen when the list first shows, so new items keep their
// mark while on screen even though they are counted as seen right away.
let openedAt = $state<number | null>(null);
$effect(() => {
if (!items) return;
if (openedAt === null) openedAt = untrack(() => notificationsStore.seenAt);
markNotificationsSeen();
});
function authorName(pubkey: string): string {
const p = profileStore.profiles.get(pubkey);
return (
p?.displayName ||
p?.name ||
`${(p?.npub ?? nip19.npubEncode(pubkey)).slice(0, 12)}…`
);
}
function authorPicture(pubkey: string): string | undefined {
return profileStore.profiles.get(pubkey)?.picture;
}
function verb(n: Notification): string {
switch (n.kind) {
case "reply-op":
return "replied to your discussion";
case "reply":
return "replied in";
case "mention":
return "mentioned you in";
case "chat-reply":
return "replied to you in chat";
case "chat-mention":
return "mentioned you in chat";
}
}
function href(n: Notification): string {
if (n.threadId) return `/thread/${n.threadId}#post-${n.id}`;
const room = MODE === "full" ? `/room/${n.groupId}` : "/";
return `${room}#chat-${n.id}`;
}
function formatTime(ts: number): string {
const diff = Math.floor(Date.now() / 1000) - ts;
if (diff < 60) return "now";
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
if (diff < 7 * 86400) return `${Math.floor(diff / 86400)}d`;
return new Date(ts * 1000).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
</script>
<section aria-labelledby="profile-notifications" class="mt-10">
<h2
id="profile-notifications"
class="text-accent border-b border-neutral-100 pb-2 text-[1.5rem] leading-7 dark:border-neutral-800"
>
Notifications
</h2>
{#if !items}
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">Loading…</p>
{:else if items.length === 0}
<p class="py-3 text-sm text-neutral-400 dark:text-neutral-500">
No replies or mentions yet.
</p>
{:else}
<ul class="divide-y divide-neutral-100 dark:divide-neutral-800">
{#each items as n (n.id)}
{@const unread = n.createdAt > (openedAt ?? 0)}
{@const name = authorName(n.pubkey)}
{@const picture = authorPicture(n.pubkey)}
{@const text = summarize(n.content, 160)}
<li class="flex gap-3 py-3">
<a
href={profilePath(n.pubkey)}
class="mt-0.5 shrink-0"
aria-hidden="true"
tabindex="-1"
>
{#if picture}
<img
src={picture}
alt=""
class="h-8 w-8 rounded-full object-cover"
/>
{:else}
<span
class="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-xs font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
>
{name[0].toUpperCase()}
</span>
{/if}
</a>
<div class="min-w-0 flex-1">
<a href={href(n)} class="group block">
<p
class="text-neutral-900 dark:text-neutral-100 {unread
? 'font-semibold'
: ''}"
>
{#if unread}
<span
class="bg-accent mr-1.5 inline-block h-2 w-2 rounded-full align-middle"
aria-hidden="true"
></span>
<span class="sr-only">New: </span>
{/if}
<span>{name}</span>
<span
class="font-normal text-neutral-500 dark:text-neutral-400"
>
{verb(n)}
</span>
{#if n.threadId}
<span class="group-hover:text-accent">
{n.title || "a discussion"}
</span>
{/if}
</p>
{#if text}
<p
class="mt-0.5 line-clamp-2 text-neutral-600 dark:text-neutral-400"
>
{text}
</p>
{/if}
</a>
<div
class="mt-1 flex items-center gap-1.5 text-xs text-neutral-400 dark:text-neutral-500"
>
{#if MODE === "full" && n.groupId}
<a href="/room/{n.groupId}" class="hover:text-accent truncate"
>{getGroupName(n.groupId)}</a
>
<span aria-hidden="true">·</span>
{/if}
<time datetime={new Date(n.createdAt * 1000).toISOString()}
>{formatTime(n.createdAt)}</time
>
</div>
</div>
</li>
{/each}
</ul>
{/if}
</section>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import { auth } from "$lib/auth.svelte";
import { notificationsStore } from "$lib/notifications.svelte";
type Props = { size?: "sm" | "md" };
let { size = "sm" }: Props = $props();
const box = $derived(size === "md" ? "h-8 w-8 text-sm" : "h-7 w-7 text-xs");
const count = $derived(notificationsStore.unreadCount);
const label = $derived(count > 99 ? "99+" : String(count));
</script>
<!-- Logged-in user's picture with the unread notifications count. The count is
visual only; the wrapping link carries it in its accessible name. -->
{#if auth.user}
<span class="relative inline-flex shrink-0">
{#if auth.user.metadata.picture}
<img
src={auth.user.metadata.picture}
alt=""
class="{box} rounded-full object-cover"
/>
{:else}
<span
class="{box} flex items-center justify-center rounded-full bg-neutral-300 font-semibold text-neutral-600 dark:bg-neutral-600 dark:text-neutral-400"
aria-hidden="true"
>
{auth.user.shortName.slice(0, 1).toUpperCase()}
</span>
{/if}
{#if count > 0}
<span
class="bg-accent absolute -top-1.5 -right-1.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] leading-none font-semibold text-white"
aria-hidden="true"
>
{label}
</span>
{/if}
</span>
{/if}

View file

@ -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<void> {
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<Notification[]> {
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;
}

View file

@ -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<string | null>(null);
let items = $state<Notification[] | null>(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
}
}

View file

@ -24,6 +24,10 @@
import { loadRoomAdmins } from "$lib/admins.svelte"; import { loadRoomAdmins } from "$lib/admins.svelte";
import { seedProfiles } from "$lib/profiles.svelte"; import { seedProfiles } from "$lib/profiles.svelte";
import { startChat } from "$lib/chat.svelte"; import { startChat } from "$lib/chat.svelte";
import {
startNotifications,
stopNotifications,
} from "$lib/notifications.svelte";
import { resetForumConnection } from "$lib/relay"; import { resetForumConnection } from "$lib/relay";
import { relayAccess, probeRelayAccess } from "$lib/access.svelte"; import { relayAccess, probeRelayAccess } from "$lib/access.svelte";
import { activeGroup, setActiveGroup } from "$lib/active.svelte"; import { activeGroup, setActiveGroup } from "$lib/active.svelte";
@ -93,6 +97,15 @@
seedProfiles(auth.user?.pubkey ?? null); 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 // 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 // first load, after a login (private/hidden rooms appear) or logout (they
// vanish), and after joining a members-only relay. // 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 // 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 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(() => { $effect(() => {
page.url.pathname; page.url.pathname;
mobileView = "forum"; mobileView = page.url.hash.startsWith("#chat-") ? "chat" : "forum";
}); });
</script> </script>

View file

@ -20,9 +20,12 @@
UserPostPage, UserPostPage,
} from "$lib/forum/userPosts"; } from "$lib/forum/userPosts";
import Meta from "$lib/components/Meta.svelte"; 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"; import { summarize } from "$lib/seo";
const pubkey = $derived<string | null>(page.data.pubkey ?? null); const pubkey = $derived<string | null>(page.data.pubkey ?? null);
const isOwn = $derived(!!pubkey && auth.user?.pubkey === pubkey);
$effect(() => { $effect(() => {
if (!pubkey) return; if (!pubkey) return;
@ -240,6 +243,10 @@
{/if} {/if}
</div> </div>
{#if isOwn}
<Notifications />
{/if}
<div class="mt-10 grid gap-10 md:grid-cols-2 md:gap-8"> <div class="mt-10 grid gap-10 md:grid-cols-2 md:gap-8">
<section aria-labelledby="profile-discussions"> <section aria-labelledby="profile-discussions">
<h2 <h2