Add in-app notitications
This commit is contained in:
parent
67d461333c
commit
8695d3e40e
10 changed files with 537 additions and 55 deletions
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue