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">
|
<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)}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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 { 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue