Add in-app notitications
This commit is contained in:
parent
67d461333c
commit
8695d3e40e
10 changed files with 537 additions and 55 deletions
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import {
|
||||
chatStore,
|
||||
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(() => {
|
||||
if (!expanded) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
|
|
@ -350,7 +368,7 @@
|
|||
></span>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<div id="chat-{msg.id}">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<a
|
||||
href={profilePath(msg.pubkey)}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@
|
|||
import { GROUP_ID, TITLE, SEARCH_ENABLED } from "$lib/config";
|
||||
import { openSearch } from "$lib/searchModal.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 = {
|
||||
mode: "simple" | "full";
|
||||
|
|
@ -189,30 +198,25 @@
|
|||
</button>
|
||||
{/if}
|
||||
{#if auth.user}
|
||||
<div class="mt-3 flex items-center gap-1">
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button
|
||||
onclick={() => {
|
||||
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"
|
||||
aria-label="Account options"
|
||||
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"
|
||||
>
|
||||
{#if auth.user.metadata.picture}
|
||||
<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>
|
||||
Log out
|
||||
</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 />
|
||||
</div>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@
|
|||
import { SEARCH_ENABLED } from "$lib/config";
|
||||
import { draftState, resumeDraft } from "$lib/draft.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 = {
|
||||
open: boolean;
|
||||
|
|
@ -83,7 +92,7 @@
|
|||
transition:fade={{ duration: 150 }}
|
||||
></button>
|
||||
<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"
|
||||
aria-modal="true"
|
||||
aria-label="Menu"
|
||||
|
|
@ -97,7 +106,7 @@
|
|||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
|
|
@ -142,19 +151,25 @@
|
|||
{#if mode === "full"}
|
||||
<nav class="mt-4" aria-label="Rooms">
|
||||
<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
|
||||
</p>
|
||||
{#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}
|
||||
{#each groupsStore.list as room}
|
||||
<a
|
||||
href="/room/{room.id}"
|
||||
onclick={onClose}
|
||||
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}
|
||||
</a>
|
||||
|
|
@ -164,7 +179,7 @@
|
|||
|
||||
<nav class="mt-4" aria-label="Resources">
|
||||
<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
|
||||
</p>
|
||||
|
|
@ -174,8 +189,9 @@
|
|||
onclick={onClose}
|
||||
aria-current={activeResource === r.slug ? "page" : undefined}
|
||||
class="hover:text-accent block py-1.5 text-xl
|
||||
{activeResource === r.slug ? 'text-accent' : 'text-neutral-700 dark:text-neutral-300'}"
|
||||
>{r.title}</a
|
||||
{activeResource === r.slug
|
||||
? 'text-accent'
|
||||
: 'text-neutral-700 dark:text-neutral-300'}">{r.title}</a
|
||||
>
|
||||
{/each}
|
||||
<a
|
||||
|
|
@ -183,11 +199,15 @@
|
|||
onclick={onClose}
|
||||
aria-current={contactsActive ? "page" : undefined}
|
||||
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>
|
||||
|
||||
<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}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -199,33 +219,23 @@
|
|||
{/if}
|
||||
{#if auth.user}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onLogout}
|
||||
<a
|
||||
href="/profile/{auth.user.npub}"
|
||||
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"
|
||||
aria-label="Log out"
|
||||
aria-label={profileLabel}
|
||||
>
|
||||
{#if auth.user.metadata.picture}
|
||||
<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}
|
||||
<UserAvatar size="md" />
|
||||
<span class="truncate text-lg font-medium"
|
||||
>{auth.user.shortName}</span
|
||||
>
|
||||
<span
|
||||
class="ml-auto shrink-0 text-sm text-neutral-400 dark:text-neutral-500"
|
||||
>Log out</span
|
||||
</a>
|
||||
<button
|
||||
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>
|
||||
<ThemeToggle size="md" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
<script lang="ts">
|
||||
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 UserAvatar from "$lib/components/UserAvatar.svelte";
|
||||
|
||||
type Props = { onMenuToggle: () => void };
|
||||
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
|
||||
// own name; full mode has no single room, so it falls back to GROUP_ID.
|
||||
const name = $derived(
|
||||
|
|
@ -15,7 +25,7 @@
|
|||
|
||||
<!-- Mobile only: on desktop the logo lives at the top of the left sidebar. -->
|
||||
<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
|
||||
href="/"
|
||||
|
|
@ -36,14 +46,23 @@
|
|||
</div>
|
||||
{/if}
|
||||
<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
|
||||
>
|
||||
</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
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
|
|
|
|||
167
src/lib/components/Notifications.svelte
Normal file
167
src/lib/components/Notifications.svelte
Normal 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>
|
||||
40
src/lib/components/UserAvatar.svelte
Normal file
40
src/lib/components/UserAvatar.svelte
Normal 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}
|
||||
91
src/lib/forum/notifications.ts
Normal file
91
src/lib/forum/notifications.ts
Normal 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;
|
||||
}
|
||||
112
src/lib/notifications.svelte.ts
Normal file
112
src/lib/notifications.svelte.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string | null>(page.data.pubkey ?? null);
|
||||
const isOwn = $derived(!!pubkey && auth.user?.pubkey === pubkey);
|
||||
|
||||
$effect(() => {
|
||||
if (!pubkey) return;
|
||||
|
|
@ -240,6 +243,10 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isOwn}
|
||||
<Notifications />
|
||||
{/if}
|
||||
|
||||
<div class="mt-10 grid gap-10 md:grid-cols-2 md:gap-8">
|
||||
<section aria-labelledby="profile-discussions">
|
||||
<h2
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue