SkateSpots/src/components/NotificationBell.tsx
shakespeare.diy 314c8620f1 Lab collective join-requests → notification bell
Skaters can now request to join a Lab collective (studio/build team), mirroring
the crew join-request flow:

- New addressable kind 36387 (collective join-request), documented in NIP.md
- useCollectiveJoinRequests: send / incoming / "already requested" hooks
- Owner-only notification surfaced in the bell (All + Crews tabs) via
  CollectiveJoinRequestCard, reusing the synced dismissal set
- "Request to join this collective" button on collective Lab profiles
  (RequestToJoinCollective), hidden for the owner and existing members

Trust model preserved: only the owner can edit their own profile roster, so the
card links them to the member editor — no forged membership possible.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 15:41:03 +07:00

332 lines
15 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
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 { CollectiveJoinRequestCard } from '@/components/CollectiveJoinRequestCard';
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';
import { genUserName } from '@/lib/genUserName';
import { SKATE_CREW_KIND, type MemberVisibility, type SkateCrew } from '@/lib/types';
import { cn } from '@/lib/utils';
/**
* A pending-confirm crew: this user has been LISTED in a crew's member list but
* hasn't published their own attestation yet. Rendered like an invite so they
* can confirm (with a visibility choice) right from the bell.
*/
function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?: () => void }) {
const isPending = useIsPendingMember(crew);
const founder = useAuthor(crew.pubkey);
const founderName = founder.data?.metadata?.name ?? genUserName(crew.pubkey);
const founderPic = founder.data?.metadata?.picture;
const { toast } = useToast();
const { mutateAsync: confirm, isPending: confirming } = useConfirmMembership();
const [visibility, setVisibility] = useState<MemberVisibility>('public');
const [done, setDone] = useState(false);
// Only actionable while the member is genuinely pending confirmation.
if (!isPending || done) return null;
const handleConfirm = async () => {
try {
await confirm({
kind: SKATE_CREW_KIND,
founderPubkey: crew.pubkey,
identifier: crew.identifier,
collectiveName: crew.name,
visibility,
});
setDone(true);
toast({ title: `You're in ${crew.name}! 🛹`, description: 'Membership confirmed.' });
onResolved?.();
} catch (err) {
toast({
title: 'Could not confirm',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
return (
<div className="rounded-xl border border-teal-500/25 bg-gradient-to-br from-teal-500/[0.08] to-transparent p-3">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-teal-500/15">
<Users className="h-4 w-4 text-teal-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-gray-200">
<span className="inline-flex items-center gap-1 font-bold text-white">
<Avatar className="h-4 w-4">
<AvatarImage src={founderPic} alt={founderName} />
<AvatarFallback className="text-[8px]">{founderName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
{founderName}
</span>{' '}
added you to{' '}
<Link to={`/crew/${crew.pubkey}/${crew.identifier}`} className="font-black text-teal-300 hover:underline">
{crew.name}
</Link>
. Confirm to appear as a member.
</p>
{/* Visibility toggle */}
<div className="mt-2 grid grid-cols-2 gap-1.5">
{([
{ key: 'public', label: 'Public', icon: Globe },
{ key: 'crew', label: 'Crew only', icon: EyeOff },
] as const).map(({ key, label, icon: Icon }) => {
const active = visibility === key;
return (
<button
key={key}
type="button"
onClick={() => setVisibility(key)}
className={cn(
'flex items-center justify-center gap-1 rounded-lg border px-2 py-1.5 text-xs font-bold transition-all active:scale-95',
active
? 'border-transparent bg-teal-500 text-black'
: 'border-white/10 bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white',
)}
>
<Icon className="h-3 w-3" />
{label}
</button>
);
})}
</div>
<Button
size="sm"
onClick={handleConfirm}
disabled={confirming}
className="mt-2.5 h-8 w-full gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
>
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
Confirm membership
</Button>
</div>
</div>
</div>
);
}
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 — the user's whole activity center, not just crews:
*
* 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, collectiveRequests, isLoading: crewLoading } =
useCrewNotifications();
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
const { lastRead, markAllRead } = useNotificationsRead();
const crewTotal = useMemo(
() => invites.length + listedCrews.length + joinRequests.length + collectiveRequests.length,
[invites, listedCrews, joinRequests, collectiveRequests],
);
// 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn('relative text-gray-400 hover:text-teal-400 hover:bg-teal-500/10', className)}
title="Notifications"
aria-label={`Notifications${badgeTotal > 0 ? ` (${badgeTotal} new)` : ''}`}
>
<Bell className="h-5 w-5" />
{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">
{badgeTotal > 9 ? '9+' : badgeTotal}
</span>
)}
</Button>
</PopoverTrigger>
<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>
<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>
<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>
<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>
)}
{!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 />
))}
{collectiveRequests.map((request) => (
<CollectiveJoinRequestCard key={request.id} request={request} 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>
{/* ─── 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>
{/* ─── 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, collective requests 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 />
))}
{collectiveRequests.map((request) => (
<CollectiveJoinRequestCard key={request.id} request={request} 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
asChild
variant="ghost"
size="sm"
className="w-full text-xs font-bold text-gray-400 hover:text-teal-400"
onClick={() => setOpen(false)}
>
<Link to="/crews">Browse all crews</Link>
</Button>
</div>
</PopoverContent>
</Popover>
);
}