179 lines
7.6 KiB
TypeScript
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';
|
|
}
|