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('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 (

{founderName.slice(0, 2).toUpperCase()} {founderName} {' '} added you to{' '} {crew.name} . Confirm to appear as a member.

{/* Visibility toggle */}
{([ { 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 ( ); })}
); } function EmptyState({ label }: { label: string }) { return (

You're all caught up

{label}

); } /** * 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 (

Notifications

All {badgeTotal > 0 && {badgeTotal}} Social Crews {crewTotal > 0 && {crewTotal}} {/* ─── ALL ─────────────────────────────────────────────────── */} {isLoading && badgeTotal === 0 && (
Checking for updates…
)} {!isLoading && crewTotal === 0 && activity.length === 0 && ( )}
{joinRequests.map(({ request, crew, endorsements }) => ( ))} {collectiveRequests.map((request) => ( ))} {invites.map((invite) => ( ))} {listedCrews.map((crew) => ( ))} {activity.map((n) => ( lastRead} onNavigate={() => setOpen(false)} /> ))}
{/* ─── SOCIAL ──────────────────────────────────────────────── */} {activityLoading && activity.length === 0 && (
Checking for updates…
)} {!activityLoading && activity.length === 0 && ( )}
{activity.map((n) => ( lastRead} onNavigate={() => setOpen(false)} /> ))}
{/* ─── CREWS ───────────────────────────────────────────────── */} {crewLoading && crewTotal === 0 && (
Checking for updates…
)} {!crewLoading && crewTotal === 0 && ( )}
{joinRequests.map(({ request, crew, endorsements }) => ( ))} {collectiveRequests.map((request) => ( ))} {invites.map((invite) => ( ))} {listedCrews.map((crew) => ( ))}
); }