import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useMyCrewInvites, useIncomingJoinRequests, useJoinRequestEndorsements } from '@/hooks/useCrewInvites'; import { useIncomingCollectiveJoinRequests } from '@/hooks/useCollectiveJoinRequests'; import { useCrewDismissals } from '@/hooks/useCrewDismissals'; import { useCrews, useCrewsByMember } from '@/hooks/useCrews'; import { useCollectiveMembers } from '@/hooks/useCollectiveMembers'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { SKATE_CREW_KIND, MEMBERSHIP_LABEL_NAMESPACE, MEMBERSHIP_LABEL_VALUE, type CollectiveJoinRequest, type CrewEndorsement, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types'; export interface CrewNotification { /** Stable id for the notification (invite id or crew coord). */ id: string; type: 'invite' | 'pending-confirm'; crew?: SkateCrew; invite?: CrewInvite; createdAt: number; } /** * The logged-in user's actionable crew notifications: * * - `invite`: a structured crew invite addressed to them (kind 36229). * - `pending-confirm`: a crew that has LISTED them as a member but which they * haven't confirmed yet (they were added directly, e.g. * via the founder editor before invites existed). * * Both resolve to the same "join this crew" action, so the notification center * can render them uniformly. */ export function useCrewNotifications() { const { user } = useCurrentUser(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { data: invites, isLoading: invitesLoading } = useMyCrewInvites(user?.pubkey); const { data: joinRequests, isLoading: joinLoading } = useIncomingJoinRequests(user?.pubkey); const { data: collectiveRequestsRaw, isLoading: collectiveLoading } = useIncomingCollectiveJoinRequests(user?.pubkey); const { data: memberCrews, isLoading: memberCrewsLoading } = useCrewsByMember(user?.pubkey); // Only admins with an actual incoming request need the full crew directory // to resolve which crew it belongs to. Everyone else skips that 200-crew read. const { data: crews, isLoading: crewsLoading } = useCrews(200, { enabled: !!joinRequests?.length, }); // Cross-device-synced set of dismissed invite/request ids. const { data: dismissed } = useCrewDismissals(); // Invites addressed to me, minus anything I've dismissed (accepted/declined), // synced across all my devices. const visibleInvites = useMemo(() => { const list = invites ?? []; if (!dismissed || dismissed.size === 0) return list; return list.filter((inv) => !dismissed.has(inv.id)); }, [invites, dismissed]); // Crews that list me as a member (candidates for "pending confirmation"). // One query resolves which crews I've ALREADY confirmed, so a confirmed member // never gets a phantom "confirm to join" alert when the crew is edited. While // it loads we return none (better a beat's delay than a false notification). const { data: confirmedCoords, isLoading: membershipLoading } = useQuery>({ queryKey: ['my-membership-labels', user?.pubkey, getQueryKeySuffix()], enabled: !!user?.pubkey, staleTime: 60_000, queryFn: async () => { if (!user?.pubkey) return new Set(); const signal = AbortSignal.timeout(8000); const events = await querySkateContent( [{ kinds: [1985], authors: [user.pubkey], '#l': [MEMBERSHIP_LABEL_VALUE], limit: 500 }], { signal }, ); const coords = new Set(); for (const ev of events) { const hasNamespace = ev.tags.some(([n, v]) => n === 'L' && v === MEMBERSHIP_LABEL_NAMESPACE); const hasLabel = ev.tags.some(([n, v]) => n === 'l' && v === MEMBERSHIP_LABEL_VALUE); if (!hasNamespace || !hasLabel) continue; for (const [n, v] of ev.tags) if (n === 'a' && v) coords.add(v); } return coords; }, }); const listedCrews = useMemo(() => { if (!user || !memberCrews || !confirmedCoords) return []; return memberCrews.filter( (c) => c.pubkey.toLowerCase() !== user.pubkey.toLowerCase() && c.members.some((m) => m.toLowerCase() === user.pubkey.toLowerCase()) && !confirmedCoords.has(`${SKATE_CREW_KIND}:${c.pubkey}:${c.identifier}`), ); }, [user, memberCrews, confirmedCoords]); // Resolve each incoming join-request to its crew (for approval + display). const requestsWithCrew = useMemo(() => { if (!user || !joinRequests?.length || !crews) return []; return joinRequests // Drop dismissed requests (synced across the moderator's devices). .filter((req) => !dismissed?.has(req.id)) .map((req) => { const crew = crews.find( (c) => `${SKATE_CREW_KIND}:${c.pubkey}:${c.identifier}` === req.crewCoord, ); return crew ? { request: req, crew } : null; }) .filter((x): x is { request: CrewJoinRequest; crew: SkateCrew } => !!x) // Only show requests for crews I actually lead or help run. .filter( ({ crew }) => crew.pubkey.toLowerCase() === user.pubkey.toLowerCase() || crew.admins.some((a) => a.toLowerCase() === user.pubkey.toLowerCase()), ); }, [user, joinRequests, crews, dismissed]); // Batch-fetch admin endorsements for the visible requests (one query), keyed // per crew so only real admins of THAT crew can vouch. const requestIds = useMemo( () => requestsWithCrew.map(({ request }) => request.id), [requestsWithCrew], ); const adminsByCrewCoord = useMemo(() => { const map = new Map>(); for (const { request, crew } of requestsWithCrew) { map.set(request.crewCoord, new Set(crew.admins.map((a) => a.toLowerCase()))); } return map; }, [requestsWithCrew]); const { data: endorsements } = useJoinRequestEndorsements(requestIds, adminsByCrewCoord); // Attach each request's endorsements for the card to render. const requestsWithEndorsements = useMemo( () => requestsWithCrew.map((entry) => ({ ...entry, endorsements: (endorsements?.get(entry.request.id) ?? []) as CrewEndorsement[], })), [requestsWithCrew, endorsements], ); // Incoming Lab-collective join-requests, minus dismissed ones. These are // already p-tagged to (and filtered for) the collective owner upstream. const collectiveRequests = useMemo(() => { const list = (collectiveRequestsRaw ?? []) as CollectiveJoinRequest[]; if (!dismissed || dismissed.size === 0) return list; return list.filter((req) => !dismissed.has(req.id)); }, [collectiveRequestsRaw, dismissed]); return { user, invites: visibleInvites, listedCrews, joinRequests: requestsWithEndorsements, collectiveRequests, isLoading: invitesLoading || joinLoading || collectiveLoading || memberCrewsLoading || crewsLoading || membershipLoading, }; } /** * Resolve whether the logged-in user has CONFIRMED membership in a listed crew. * Used per-crew to decide if a "pending confirm" notification is still relevant. */ export function useIsPendingMember(crew: SkateCrew | undefined) { const { user } = useCurrentUser(); const { data: members } = useCollectiveMembers({ kind: SKATE_CREW_KIND, founderPubkey: crew?.pubkey ?? '', identifier: crew?.identifier ?? '', claimedMembers: crew?.members ?? [], enabled: !!crew && (crew.members.length ?? 0) > 0, }); if (!user || !crew) return false; const me = members?.find((m) => m.pubkey.toLowerCase() === user.pubkey.toLowerCase()); return me?.status === 'pending'; }