SkateSpots/src/hooks/useCrewNotifications.ts

179 lines
7.6 KiB
TypeScript

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<Set<string>>({
queryKey: ['my-membership-labels', user?.pubkey, getQueryKeySuffix()],
enabled: !!user?.pubkey,
staleTime: 60_000,
queryFn: async () => {
if (!user?.pubkey) return new Set<string>();
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<string>();
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<string, Set<string>>();
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';
}