Polish notifications: full activity center (social + crews), not just crews
- New useActivityNotifications: single combined #p query classifies reactions, reposts, zaps, mentions and replies referencing the user; parses zap sats - New useNotificationsRead: per-account last-read timestamp for unread badge - New ActivityNotificationCard: actor avatar + type badge, verb, snippet, links to the actor's profile - Rewrite NotificationBell into a tabbed activity center (All / Social / Crews) with unread tracking, mark-all-read, and the existing crew invites/requests Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
35915557bb
commit
c34f194d8f
4 changed files with 495 additions and 52 deletions
116
src/components/ActivityNotificationCard.tsx
Normal file
116
src/components/ActivityNotificationCard.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Heart, Repeat2, Zap, AtSign, MessageCircle } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
activityVerb,
|
||||
type ActivityNotification,
|
||||
type ActivityType,
|
||||
} from '@/hooks/useActivityNotifications';
|
||||
|
||||
const TYPE_META: Record<
|
||||
ActivityType,
|
||||
{ icon: typeof Heart; color: string; ring: string }
|
||||
> = {
|
||||
reaction: { icon: Heart, color: 'text-pink-400', ring: 'bg-pink-500/15' },
|
||||
repost: { icon: Repeat2, color: 'text-green-400', ring: 'bg-green-500/15' },
|
||||
zap: { icon: Zap, color: 'text-yellow-400', ring: 'bg-yellow-500/15' },
|
||||
mention: { icon: AtSign, color: 'text-sky-400', ring: 'bg-sky-500/15' },
|
||||
reply: { icon: MessageCircle, color: 'text-teal-400', ring: 'bg-teal-500/15' },
|
||||
};
|
||||
|
||||
/**
|
||||
* A single "social" activity notification (reaction / repost / zap / mention /
|
||||
* reply). Links to the actor's profile and — when there's a target event — to
|
||||
* that note so the user can jump straight to the context.
|
||||
*/
|
||||
export function ActivityNotificationCard({
|
||||
notification,
|
||||
unread,
|
||||
onNavigate,
|
||||
}: {
|
||||
notification: ActivityNotification;
|
||||
unread?: boolean;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const author = useAuthor(notification.pubkey);
|
||||
const name = author.data?.metadata?.name ?? genUserName(notification.pubkey);
|
||||
const pic = author.data?.metadata?.picture;
|
||||
|
||||
const meta = TYPE_META[notification.type];
|
||||
const Icon = meta.icon;
|
||||
|
||||
const npub = (() => {
|
||||
try {
|
||||
return nip19.npubEncode(notification.pubkey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const timeAgo = formatDistanceToNow(new Date(notification.createdAt * 1000), {
|
||||
addSuffix: true,
|
||||
});
|
||||
|
||||
const snippet =
|
||||
(notification.type === 'reply' || notification.type === 'mention') && notification.content
|
||||
? notification.content.slice(0, 120)
|
||||
: null;
|
||||
|
||||
const body = (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-xl border p-3 transition-colors',
|
||||
unread
|
||||
? 'border-teal-500/25 bg-teal-500/[0.06]'
|
||||
: 'border-white/10 bg-white/[0.02]',
|
||||
)}
|
||||
>
|
||||
{/* Actor avatar with a type badge */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={pic} alt={name} />
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full ring-2 ring-background',
|
||||
meta.ring,
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-3 w-3', meta.color)} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm leading-snug text-gray-200">
|
||||
<span className="font-bold text-white">{name}</span>{' '}
|
||||
<span className="text-gray-400">{activityVerb(notification)}</span>
|
||||
</p>
|
||||
{snippet && (
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-gray-500">"{snippet}"</p>
|
||||
)}
|
||||
<p className="mt-0.5 text-[11px] text-gray-600">{timeAgo}</p>
|
||||
</div>
|
||||
|
||||
{unread && (
|
||||
<span className="mt-1 h-2 w-2 flex-shrink-0 rounded-full bg-teal-400" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Link to the actor's profile (the always-valid in-app destination).
|
||||
if (!npub) return body;
|
||||
|
||||
return (
|
||||
<Link to={`/${npub}`} onClick={onNavigate} className="block">
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,17 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Bell, Users, Check, Loader2, EyeOff, Globe } from 'lucide-react';
|
||||
import { Bell, Users, Check, Loader2, EyeOff, Globe, CheckCheck, Heart } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { CrewInviteCard } from '@/components/CrewInviteCard';
|
||||
import { JoinRequestCard } from '@/components/JoinRequestCard';
|
||||
import { ActivityNotificationCard } from '@/components/ActivityNotificationCard';
|
||||
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
||||
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
|
||||
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
|
||||
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
|
@ -117,23 +121,55 @@ function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?
|
|||
);
|
||||
}
|
||||
|
||||
function EmptyState({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5">
|
||||
<Bell className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-400">You're all caught up</p>
|
||||
<p className="max-w-[220px] text-xs text-gray-600">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Header notification bell. Surfaces actionable crew notifications:
|
||||
* - structured crew invites (accept / decline)
|
||||
* - "pending confirmation" crews (someone listed you — confirm to join)
|
||||
* Header notification bell — the user's whole activity center, not just crews:
|
||||
*
|
||||
* Shows an unread count badge. Only renders when logged in.
|
||||
* SOCIAL: reactions, reposts, zaps, mentions and replies that reference them.
|
||||
* CREWS: structured invites, "pending confirmation" crews, and (for crew
|
||||
* leaders) incoming join-requests to approve.
|
||||
*
|
||||
* Unread state is tracked by a per-account "last read" timestamp; opening the
|
||||
* bell (or hitting "mark all read") clears the badge. Only renders when logged in.
|
||||
*/
|
||||
export function NotificationBell({ className }: { className?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { user, invites, listedCrews, joinRequests, isLoading } = useCrewNotifications();
|
||||
const { user, invites, listedCrews, joinRequests, isLoading: crewLoading } = useCrewNotifications();
|
||||
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
||||
const { lastRead, markAllRead } = useNotificationsRead();
|
||||
|
||||
// The bell only makes sense when logged in.
|
||||
const total = useMemo(
|
||||
const crewTotal = useMemo(
|
||||
() => invites.length + listedCrews.length + joinRequests.length,
|
||||
[invites, listedCrews, joinRequests],
|
||||
);
|
||||
|
||||
// Unread social items = activity newer than the last time the bell was opened.
|
||||
const unreadActivity = useMemo(
|
||||
() => activity.filter((n) => n.createdAt > lastRead).length,
|
||||
[activity, lastRead],
|
||||
);
|
||||
|
||||
// Crew items are always "actionable" so they always count toward the badge.
|
||||
const badgeTotal = crewTotal + unreadActivity;
|
||||
|
||||
// Opening the bell marks everything as read.
|
||||
useEffect(() => {
|
||||
if (open) markAllRead();
|
||||
}, [open, markAllRead]);
|
||||
|
||||
const isLoading = crewLoading || activityLoading;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -144,67 +180,132 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
size="icon"
|
||||
className={cn('relative text-gray-400 hover:text-teal-400 hover:bg-teal-500/10', className)}
|
||||
title="Notifications"
|
||||
aria-label={`Notifications${total > 0 ? ` (${total} new)` : ''}`}
|
||||
aria-label={`Notifications${badgeTotal > 0 ? ` (${badgeTotal} new)` : ''}`}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{total > 0 && (
|
||||
{badgeTotal > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex min-w-[18px] items-center justify-center rounded-full bg-teal-500 px-1 text-[10px] font-black leading-[18px] text-black">
|
||||
{total > 9 ? '9+' : total}
|
||||
{badgeTotal > 9 ? '9+' : badgeTotal}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[360px] max-w-[calc(100vw-1.5rem)] p-0" sideOffset={8}>
|
||||
<PopoverContent align="end" className="w-[380px] max-w-[calc(100vw-1.5rem)] p-0" sideOffset={8}>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-white">
|
||||
<Bell className="h-4 w-4 text-teal-400" /> Notifications
|
||||
</h3>
|
||||
{total > 0 && (
|
||||
<span className="rounded-full bg-teal-500/20 px-2 py-0.5 text-[10px] font-black uppercase text-teal-300">
|
||||
{total} new
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllRead}
|
||||
className="flex items-center gap-1 text-[10px] font-black uppercase tracking-wide text-gray-500 transition-colors hover:text-teal-400"
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5" /> Mark read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[70vh]">
|
||||
<div className="space-y-2.5 p-3">
|
||||
{isLoading && total === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultValue="all" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 rounded-none border-b border-white/10 bg-transparent p-0">
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
||||
>
|
||||
All
|
||||
{badgeTotal > 0 && <span className="ml-1 text-teal-400">{badgeTotal}</span>}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="social"
|
||||
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
||||
>
|
||||
<Heart className="mr-1 h-3 w-3" /> Social
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="crews"
|
||||
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
||||
>
|
||||
<Users className="mr-1 h-3 w-3" /> Crews
|
||||
{crewTotal > 0 && <span className="ml-1 text-teal-400">{crewTotal}</span>}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{!isLoading && total === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5">
|
||||
<Bell className="h-5 w-5 text-gray-600" />
|
||||
<ScrollArea className="max-h-[65vh]">
|
||||
{/* ─── ALL ─────────────────────────────────────────────────── */}
|
||||
<TabsContent value="all" className="m-0 p-3">
|
||||
{isLoading && badgeTotal === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-400">You're all caught up</p>
|
||||
<p className="max-w-[220px] text-xs text-gray-600">
|
||||
Crew invites and membership requests will show up here.
|
||||
</p>
|
||||
)}
|
||||
{!isLoading && crewTotal === 0 && activity.length === 0 && (
|
||||
<EmptyState label="Zaps, reactions, replies and crew activity will show up here." />
|
||||
)}
|
||||
<div className="space-y-2.5">
|
||||
{joinRequests.map(({ request, crew, endorsements }) => (
|
||||
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
|
||||
))}
|
||||
{invites.map((invite) => (
|
||||
<CrewInviteCard key={invite.id} invite={invite} compact />
|
||||
))}
|
||||
{listedCrews.map((crew) => (
|
||||
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
|
||||
))}
|
||||
{activity.map((n) => (
|
||||
<ActivityNotificationCard
|
||||
key={n.id}
|
||||
notification={n}
|
||||
unread={n.createdAt > lastRead}
|
||||
onNavigate={() => setOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{joinRequests.map(({ request, crew, endorsements }) => (
|
||||
<JoinRequestCard
|
||||
key={request.id}
|
||||
request={request}
|
||||
crew={crew}
|
||||
endorsements={endorsements}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
{/* ─── SOCIAL ──────────────────────────────────────────────── */}
|
||||
<TabsContent value="social" className="m-0 p-3">
|
||||
{activityLoading && activity.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
||||
</div>
|
||||
)}
|
||||
{!activityLoading && activity.length === 0 && (
|
||||
<EmptyState label="When people zap, react, repost, mention or reply to you, it lands here." />
|
||||
)}
|
||||
<div className="space-y-2.5">
|
||||
{activity.map((n) => (
|
||||
<ActivityNotificationCard
|
||||
key={n.id}
|
||||
notification={n}
|
||||
unread={n.createdAt > lastRead}
|
||||
onNavigate={() => setOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{invites.map((invite) => (
|
||||
<CrewInviteCard key={invite.id} invite={invite} compact />
|
||||
))}
|
||||
|
||||
{listedCrews.map((crew) => (
|
||||
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{/* ─── CREWS ───────────────────────────────────────────────── */}
|
||||
<TabsContent value="crews" className="m-0 p-3">
|
||||
{crewLoading && crewTotal === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
||||
</div>
|
||||
)}
|
||||
{!crewLoading && crewTotal === 0 && (
|
||||
<EmptyState label="Crew invites and membership requests will show up here." />
|
||||
)}
|
||||
<div className="space-y-2.5">
|
||||
{joinRequests.map(({ request, crew, endorsements }) => (
|
||||
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
|
||||
))}
|
||||
{invites.map((invite) => (
|
||||
<CrewInviteCard key={invite.id} invite={invite} compact />
|
||||
))}
|
||||
{listedCrews.map((crew) => (
|
||||
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
|
||||
<div className="border-t border-white/10 p-2">
|
||||
<Button
|
||||
|
|
|
|||
205
src/hooks/useActivityNotifications.ts
Normal file
205
src/hooks/useActivityNotifications.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
/**
|
||||
* General, app-wide activity notifications for the logged-in user.
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* This complements the crew-specific notifications (invites / join-requests)
|
||||
* with the "social" notifications any Nostr user expects: reactions, reposts,
|
||||
* zaps, mentions and replies/comments that reference THEM.
|
||||
*
|
||||
* How it works: we do ONE combined query for events that tag the user's pubkey
|
||||
* (`#p`), across the relevant kinds, then classify them in JS. This keeps relay
|
||||
* load minimal (a single filter) per the project's query-efficiency rules.
|
||||
*/
|
||||
|
||||
export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply';
|
||||
|
||||
export interface ActivityNotification {
|
||||
id: string;
|
||||
type: ActivityType;
|
||||
/** Who triggered it (reactor / reposter / zapper / author). */
|
||||
pubkey: string;
|
||||
createdAt: number;
|
||||
/** The raw event, for rendering extra context (reaction emoji, comment text). */
|
||||
event: NostrEvent;
|
||||
/** For reactions: the emoji/content ("+", "🛹", …). */
|
||||
content?: string;
|
||||
/** The event id this activity points at (their content), if any. */
|
||||
targetEventId?: string;
|
||||
/** Sats amount for zaps, parsed from the bolt11/description when available. */
|
||||
amountSats?: number;
|
||||
}
|
||||
|
||||
const REACTION_KIND = 7;
|
||||
const REPOST_KINDS = [6, 16];
|
||||
const ZAP_RECEIPT_KIND = 9735;
|
||||
const TEXT_NOTE_KIND = 1;
|
||||
const COMMENT_KIND = 1111;
|
||||
|
||||
/** Parse the sats amount out of a zap receipt (9735) if we can. */
|
||||
function parseZapAmount(event: NostrEvent): number | undefined {
|
||||
// Zap receipts embed the original zap request in a "description" tag; that
|
||||
// request usually carries an "amount" tag in millisats.
|
||||
try {
|
||||
const desc = event.tags.find(([t]) => t === 'description')?.[1];
|
||||
if (desc) {
|
||||
const req = JSON.parse(desc) as { tags?: string[][] };
|
||||
const amountMsat = req.tags?.find((t) => t[0] === 'amount')?.[1];
|
||||
if (amountMsat) {
|
||||
const msat = parseInt(amountMsat, 10);
|
||||
if (Number.isFinite(msat) && msat > 0) return Math.round(msat / 1000);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed description
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The pubkey that actually SENT a zap (the zapper), from the receipt. */
|
||||
function zapSenderPubkey(event: NostrEvent): string {
|
||||
// Prefer the "P" (uppercase) tag = sender pubkey (NIP-57), then the embedded
|
||||
// request's pubkey, then fall back to the receipt author (the LN service).
|
||||
const upperP = event.tags.find(([t]) => t === 'P')?.[1];
|
||||
if (upperP) return upperP;
|
||||
try {
|
||||
const desc = event.tags.find(([t]) => t === 'description')?.[1];
|
||||
if (desc) {
|
||||
const req = JSON.parse(desc) as { pubkey?: string };
|
||||
if (req.pubkey) return req.pubkey;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return event.pubkey;
|
||||
}
|
||||
|
||||
export function useActivityNotifications() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['activity-notifications', user?.pubkey],
|
||||
enabled: !!user?.pubkey,
|
||||
// Refresh periodically so the bell stays fresh without hammering relays.
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 90_000,
|
||||
queryFn: async (c) => {
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(4000)]);
|
||||
const pubkey = user!.pubkey;
|
||||
|
||||
// Single combined query: everything that tags me.
|
||||
const events = await nostr.query(
|
||||
[
|
||||
{
|
||||
kinds: [
|
||||
REACTION_KIND,
|
||||
...REPOST_KINDS,
|
||||
ZAP_RECEIPT_KIND,
|
||||
TEXT_NOTE_KIND,
|
||||
COMMENT_KIND,
|
||||
],
|
||||
'#p': [pubkey],
|
||||
limit: 100,
|
||||
},
|
||||
],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const out: ActivityNotification[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
// Never notify the user about their own actions.
|
||||
if (event.pubkey === pubkey && event.kind !== ZAP_RECEIPT_KIND) continue;
|
||||
|
||||
const targetEventId = event.tags.find(([t]) => t === 'e')?.[1];
|
||||
|
||||
if (event.kind === REACTION_KIND) {
|
||||
out.push({
|
||||
id: event.id,
|
||||
type: 'reaction',
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
content: event.content || '+',
|
||||
targetEventId,
|
||||
});
|
||||
} else if (REPOST_KINDS.includes(event.kind)) {
|
||||
out.push({
|
||||
id: event.id,
|
||||
type: 'repost',
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
targetEventId,
|
||||
});
|
||||
} else if (event.kind === ZAP_RECEIPT_KIND) {
|
||||
const sender = zapSenderPubkey(event);
|
||||
if (sender === pubkey) continue; // don't notify on self-zaps
|
||||
out.push({
|
||||
id: event.id,
|
||||
type: 'zap',
|
||||
pubkey: sender,
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
targetEventId,
|
||||
amountSats: parseZapAmount(event),
|
||||
});
|
||||
} else if (event.kind === TEXT_NOTE_KIND) {
|
||||
// A kind-1 that p-tags me: either a reply to my note or a mention.
|
||||
const isReply = event.tags.some(([t]) => t === 'e');
|
||||
out.push({
|
||||
id: event.id,
|
||||
type: isReply ? 'reply' : 'mention',
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
content: event.content,
|
||||
targetEventId,
|
||||
});
|
||||
} else if (event.kind === COMMENT_KIND) {
|
||||
out.push({
|
||||
id: event.id,
|
||||
type: 'reply',
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
content: event.content,
|
||||
targetEventId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Newest first.
|
||||
out.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return out;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
notifications: query.data ?? [],
|
||||
isLoading: query.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
/** Human-readable verb for each activity type. */
|
||||
export function activityVerb(n: ActivityNotification): string {
|
||||
switch (n.type) {
|
||||
case 'reaction':
|
||||
return n.content && n.content !== '+' ? `reacted ${n.content}` : 'liked your post';
|
||||
case 'repost':
|
||||
return 'reposted you';
|
||||
case 'zap':
|
||||
return n.amountSats ? `zapped you ${n.amountSats.toLocaleString()} sats` : 'zapped you';
|
||||
case 'mention':
|
||||
return 'mentioned you';
|
||||
case 'reply':
|
||||
return 'replied to you';
|
||||
default:
|
||||
return 'interacted with you';
|
||||
}
|
||||
}
|
||||
21
src/hooks/useNotificationsRead.ts
Normal file
21
src/hooks/useNotificationsRead.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
/**
|
||||
* Tracks the timestamp (unix seconds) at which the logged-in user last opened
|
||||
* their notification center. Anything newer than this is considered "unread"
|
||||
* and contributes to the bell's badge count. Stored per-pubkey in localStorage
|
||||
* so switching accounts keeps independent read state.
|
||||
*/
|
||||
export function useNotificationsRead() {
|
||||
const { user } = useCurrentUser();
|
||||
const key = user ? `notifications-last-read:${user.pubkey}` : 'notifications-last-read:anon';
|
||||
const [lastRead, setLastRead] = useLocalStorage<number>(key, 0);
|
||||
|
||||
const markAllRead = useCallback(() => {
|
||||
setLastRead(Math.floor(Date.now() / 1000));
|
||||
}, [setLastRead]);
|
||||
|
||||
return { lastRead, markAllRead };
|
||||
}
|
||||
Loading…
Reference in a new issue