import { dateLocaleCode } from '@/lib/dateFnsI18n'; import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { Bell, Users, Check, Loader2, EyeOff, Globe, CheckCheck, Heart, Radio, ArrowRight, Handshake } 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 { TradeNotificationCard } from '@/components/TradeNotificationCard'; import { ImageWithFallback } from '@/components/ImageWithFallback'; import { EmptyState as SharedEmptyState } from '@/components/EmptyState'; import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications'; import { useActivityNotifications } from '@/hooks/useActivityNotifications'; import { useTradeNotifications } from '@/hooks/useTradeNotifications'; import { useNotificationsRead } from '@/hooks/useNotificationsRead'; import { useNewsletterIssues } from '@/hooks/useNewsletterIssues'; import { useNewsletterRead } from '@/hooks/useNewsletterRead'; import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription'; import { useConfirmMembership } from '@/hooks/useMembershipAttestation'; import { useAuthor } from '@/hooks/useAuthor'; import { useTranslation } from '@/contexts/language'; import { useToast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; import { NEWSLETTER_KIND, SKATE_CREW_KIND, type MemberVisibility, type SkateCrew, type SkateNewsletterIssue } 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 { t } = useTranslation(); 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: t('socialUi.bell.youAreIn', { crew: crew.name }), description: t('socialUi.bell.membershipConfirmed') }); onResolved?.(); } catch (err) { toast({ title: t('socialUi.bell.couldNotConfirm'), description: err instanceof Error ? err.message : t('common.tryAgain'), variant: 'destructive', }); } }; return (

{founderName.slice(0, 2).toUpperCase()} {founderName} {' '} {t('socialUi.bell.addedYouTo')}{' '} {crew.name} . {t('socialUi.bell.confirmToAppear')}

{/* Visibility toggle */}
{([ { key: 'public', label: t('socialUi.confirmMembership.public'), icon: Globe }, { key: 'crew', label: t('socialUi.confirmMembership.crewOnly'), icon: EyeOff }, ] as const).map(({ key, label, icon: Icon }) => { const active = visibility === key; return ( ); })}
); } /** A newsletter issue row in the bell's News tab. */ function NewsletterItem({ issue, unread, onNavigate, }: { issue: SkateNewsletterIssue; unread: boolean; onNavigate: () => void; }) { const { t } = useTranslation(); const naddr = nip19.naddrEncode({ kind: NEWSLETTER_KIND, pubkey: issue.pubkey, identifier: issue.identifier, }); const date = new Date(issue.publishedAt * 1000).toLocaleDateString(dateLocaleCode(), { month: 'short', day: 'numeric', }); return (
} />

{issue.title}

{unread && }
{issue.summary &&

{issue.summary}

}

{date} · {t('socialUi.bell.read')}

); } function EmptyState({ label }: { label: string }) { const { t } = useTranslation(); return ( ); } /** * 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 { t } = useTranslation(); const [open, setOpen] = useState(false); const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } = useCrewNotifications(); const { notifications: activity, isLoading: activityLoading } = useActivityNotifications(); const { lastRead, seenAt, markAllRead } = useNotificationsRead(); // Buying/selling steps: an offer on your listing, a counter it's your turn to // answer, or a request to confirm a completed trade. const { notifications: tradeNotifs, isLoading: tradeLoading } = useTradeNotifications(user?.pubkey); // Newsletter mailbox — only nags subscribers, but always browsable in the tab. const { data: newsletterIssues = [], isLoading: newsletterLoading } = useNewsletterIssues(); const { lastRead: newsletterLastRead, markNewsletterRead } = useNewsletterRead(); const { isSubscribed } = useNewsletterSubscription(); // Total actionable crew items — these always RENDER in the Crews/All tabs // until the user acts on them (accept/decline/confirm). const crewTotal = useMemo( () => invites.length + listedCrews.length + joinRequests.length + collectiveRequests.length, [invites, listedCrews, joinRequests, collectiveRequests], ); // Crew items that count toward the BADGE = ones the user hasn't acknowledged // yet (newer than the last "Mark read"/open). This lets "Mark read" clear the // badge while the actionable cards stay visible in their tab. const unreadCrew = useMemo(() => { const ts = (n: { createdAt?: number }) => n.createdAt ?? 0; let count = 0; for (const inv of invites) if (ts(inv) > seenAt) count++; for (const crew of listedCrews) if (ts(crew) > seenAt) count++; for (const { request } of joinRequests) if (ts(request) > seenAt) count++; for (const req of collectiveRequests) if (ts(req) > seenAt) count++; return count; }, [invites, listedCrews, joinRequests, collectiveRequests, seenAt]); // 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], ); // Trades are actionable like crew items — they count until acknowledged. const unreadTrades = useMemo( () => tradeNotifs.filter((t) => t.createdAt > seenAt).length, [tradeNotifs, seenAt], ); // Unread newsletter issues (only counts for subscribers). const unreadNewsletter = useMemo( () => isSubscribed ? newsletterIssues.filter((i) => i.publishedAt > newsletterLastRead).length : 0, [isSubscribed, newsletterIssues, newsletterLastRead], ); // Badge = only UNACKNOWLEDGED items, so "Mark read" actually clears it. const badgeTotal = unreadCrew + unreadActivity + unreadNewsletter + unreadTrades; // Opening the bell marks social + crew as read (and newsletter, since the // News tab is right there). Newsletter read state also clears on the /newsletter page. useEffect(() => { if (open) { markAllRead(); markNewsletterRead(); } }, [open, markAllRead, markNewsletterRead]); const isLoading = crewLoading || activityLoading || tradeLoading; if (!user) return null; return (

{t('socialUi.bell.notifications')} {badgeTotal > 0 && ( {badgeTotal > 99 ? '99+' : badgeTotal} )}

{badgeTotal > 0 ? ( ) : ( {t('socialUi.bell.allClear')} )}
{t('socialUi.bell.tabAll')} {badgeTotal > 0 && {badgeTotal}} {t('socialUi.bell.tabTrades')} {tradeNotifs.length > 0 && {tradeNotifs.length}} {t('socialUi.bell.tabSocial')} {t('socialUi.bell.tabNews')} {unreadNewsletter > 0 && {unreadNewsletter}} {t('socialUi.bell.tabCrews')} {crewTotal > 0 && {crewTotal}} {/* ─── ALL ─────────────────────────────────────────────────── */} {isLoading && badgeTotal === 0 && (
{t('socialUi.bell.checkingUpdates')}
)} {!isLoading && crewTotal === 0 && activity.length === 0 && newsletterIssues.length === 0 && tradeNotifs.length === 0 && ( )}
{/* Buying/selling steps first — they're the most time-sensitive. */} {tradeNotifs.map((n) => ( seenAt} onNavigate={() => setOpen(false)} /> ))} {joinRequests.map(({ request, crew, endorsements }) => ( ))} {collectiveRequests.map((request) => ( ))} {invites.map((invite) => ( ))} {listedCrews.map((crew) => ( ))} {/* Latest newsletter drop surfaces in All when unread (subscribers). */} {isSubscribed && newsletterIssues[0] && newsletterIssues[0].publishedAt > newsletterLastRead && ( setOpen(false)} /> )} {activity.map((n) => ( lastRead} onNavigate={() => setOpen(false)} /> ))}
{/* ─── TRADES (offers, counter-offers, confirmations) ──────── */} {tradeLoading && tradeNotifs.length === 0 && (
{t('socialUi.bell.checkingTrades')}
)} {!tradeLoading && tradeNotifs.length === 0 && ( )}
{tradeNotifs.map((n) => ( seenAt} onNavigate={() => setOpen(false)} /> ))}
{/* ─── SOCIAL ──────────────────────────────────────────────── */} {activityLoading && activity.length === 0 && (
{t('socialUi.bell.checkingUpdates')}
)} {!activityLoading && activity.length === 0 && ( )}
{activity.map((n) => ( lastRead} onNavigate={() => setOpen(false)} /> ))}
{/* ─── NEWS (newsletter mailbox) ───────────────────────────── */} {newsletterLoading && newsletterIssues.length === 0 && (
{t('socialUi.bell.loadingDrops')}
)} {!newsletterLoading && newsletterIssues.length === 0 && ( )} {!isSubscribed && newsletterIssues.length > 0 && ( setOpen(false)} className="mb-2.5 flex items-center gap-2 rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/[0.06] p-3 text-xs font-bold text-[#ccff00] transition-colors hover:bg-[#ccff00]/10" > {t('socialUi.bell.subscribeBanner')} )}
{newsletterIssues.map((issue) => ( newsletterLastRead} onNavigate={() => setOpen(false)} /> ))}
{/* ─── CREWS ───────────────────────────────────────────────── */} {crewLoading && crewTotal === 0 && (
{t('socialUi.bell.checkingUpdates')}
)} {!crewLoading && crewTotal === 0 && ( )}
{joinRequests.map(({ request, crew, endorsements }) => ( ))} {collectiveRequests.map((request) => ( ))} {invites.map((invite) => ( ))} {listedCrews.map((crew) => ( ))}
); }