SkateSpots/src/hooks/useCrewNotifications.ts
shakespeare.diy f4bca7b736 Admins can endorse join-requests; founder ratifies (kind 9087)
Closes the admin/founder asymmetry without breaking the trust model: only the
founder authors the crew event, so only they add members — but an admin can now
publicly *vouch* for a join-request, and the founder ratifies with one click.

Protocol (SkateSpots-native kind 9087, regular event):
- e-tags the join-request, a-tags the crew, p-tags the requester (role
  "requester") and the founder (so it hits the founder's bell).
- Clients keep only endorsements authored by a real admin of the endorsed crew
  and dedupe one-per-admin-per-request (no credibility inflation).
- New parseCrewEndorsement + CrewEndorsement type; documented in NIP.md.

Hooks:
- useEndorseJoinRequest (admin publishes an endorsement, admin-gated).
- useJoinRequestEndorsements: batch-fetch endorsements for all visible requests
  in ONE #e query, grouped per request, admin-verified per crew.
- useCrewNotifications attaches endorsements to each request entry.

UX (JoinRequestCard):
- Founder: primary becomes "Approve & add" when vouched, with a vouch strip
  (stacked admin avatars + "Vouched by Rider +1 more") as social proof.
- Admin: the old dead-end "Only the founder can add members" becomes a real
  "Vouch for them" action, then a "Vouched — waiting on the founder" state,
  plus a one-line explainer so the path isn't a mystery.
- Endorsements enrich the existing request card (no duplicate notification).

Build clean, no console errors. Not deployed.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 01:40:04 +07:00

127 lines
5 KiB
TypeScript

import { useMemo } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useMyCrewInvites, useIncomingJoinRequests, useJoinRequestEndorsements } from '@/hooks/useCrewInvites';
import { useCrewDismissals } from '@/hooks/useCrewDismissals';
import { useCrews } from '@/hooks/useCrews';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { SKATE_CREW_KIND, 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 { data: invites, isLoading: invitesLoading } = useMyCrewInvites(user?.pubkey);
const { data: joinRequests, isLoading: joinLoading } = useIncomingJoinRequests(user?.pubkey);
const { data: crews, isLoading: crewsLoading } = useCrews(200);
// 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").
const listedCrews = useMemo(() => {
if (!user || !crews) return [];
return crews.filter(
(c) =>
c.pubkey.toLowerCase() !== user.pubkey.toLowerCase() &&
c.members.some((m) => m.toLowerCase() === user.pubkey.toLowerCase()),
);
}, [user, crews]);
// 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],
);
return {
user,
invites: visibleInvites,
listedCrews,
joinRequests: requestsWithEndorsements,
isLoading: invitesLoading || joinLoading || crewsLoading,
};
}
/**
* 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';
}