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>
127 lines
5 KiB
TypeScript
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';
|
|
}
|