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>
This commit is contained in:
parent
0f2215f46c
commit
f4bca7b736
6 changed files with 323 additions and 26 deletions
42
NIP.md
42
NIP.md
|
|
@ -2287,12 +2287,48 @@ admin. It carries nothing sensitive: just an optional note.
|
|||
(founder only) / **Dismiss**.
|
||||
3. **Approve.** Only the **founder** can add the requester's `p` tag to the crew
|
||||
event (they author it); the requester then confirms with their own NIP-32
|
||||
attestation, exactly as with an invite. Admins can surface and dismiss but
|
||||
cannot add.
|
||||
4. **Dismiss.** Because a moderator can't delete an event they didn't author,
|
||||
attestation, exactly as with an invite.
|
||||
4. **Endorse (admins).** An admin can't edit the roster, but can publicly
|
||||
**vouch** for a request (kind `9087`, below). The founder sees the
|
||||
endorsement(s) on the request card as social proof and ratifies with one
|
||||
click — closing the admin/founder asymmetry without changing who owns the
|
||||
crew identity.
|
||||
5. **Dismiss.** Because a moderator can't delete an event they didn't author,
|
||||
handled/ignored requests are cleared from *their* bell via the synced
|
||||
dismissal set (below).
|
||||
|
||||
#### Admin endorsements (kind `9087`) — propose, founder confirms
|
||||
|
||||
Because the founder is the sole author of the crew event, an admin approving a
|
||||
member directly is impossible by design. Instead an admin publishes a small
|
||||
**endorsement** — a public "I vouch for this rider" signal that `e`-tags the
|
||||
join-request, `a`-tags the crew, and `p`-tags both the requester (role
|
||||
`requester`) and the founder (so it lands in the founder's bell).
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 9087,
|
||||
"content": "Solid — skates the plaza with us most nights.",
|
||||
"tags": [
|
||||
["e", "<joinRequestEventId>"],
|
||||
["a", "35006:<founder>:<crewDtag>"],
|
||||
["p", "<requesterPubkey>", "", "requester"],
|
||||
["p", "<founderPubkey>"],
|
||||
["alt", "Crew endorsement: vouching for a new member of Barcelona Night Crew"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `e` | The join-request (kind `31969`) event id being endorsed. Queryable so a client fetches all endorsements for a set of requests in one `#e` filter. |
|
||||
| `a` | The crew coordinate — used to verify the endorser is actually an **admin of that crew** (endorsements from non-admins are ignored, so nobody can inflate a request's credibility). |
|
||||
| `p` (requester) | The rider being vouched for, marked with the `requester` role. |
|
||||
| `p` (founder) | Surfaces the endorsement in the founder's notification bell. |
|
||||
|
||||
Clients **must** keep only endorsements whose author is a listed admin of the
|
||||
endorsed crew, and dedupe to one endorsement per admin per request.
|
||||
|
||||
#### Dismissed notifications (kind `30078`) — private, cross-device
|
||||
|
||||
Neither an invitee nor a moderator authored the invite / join-request they're
|
||||
|
|
|
|||
|
|
@ -5,11 +5,15 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useApproveJoinRequest, useDismissJoinRequest } from '@/hooks/useCrewInvites';
|
||||
import {
|
||||
useApproveJoinRequest,
|
||||
useDismissJoinRequest,
|
||||
useEndorseJoinRequest,
|
||||
} from '@/hooks/useCrewInvites';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parseAddrCoord, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
|
||||
import { UserPlus, Check, X, Loader2, Hand } from 'lucide-react';
|
||||
import { parseAddrCoord, type CrewEndorsement, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
|
||||
import { UserPlus, Check, X, Loader2, Hand, ThumbsUp, Clock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function npubSafe(pubkey: string): string {
|
||||
|
|
@ -20,20 +24,49 @@ function npubSafe(pubkey: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
/** Tiny avatar for an endorsing admin (used in the vouch strip). */
|
||||
function EndorserAvatar({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const name = author.data?.metadata?.name ?? genUserName(pubkey);
|
||||
return (
|
||||
<Avatar className="h-5 w-5 ring-2 ring-gray-900">
|
||||
<AvatarImage src={author.data?.metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="text-[8px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
/** Readable "Vouched by Rider, Mia +1" line for the endorsement strip. */
|
||||
function EndorserNames({ pubkeys }: { pubkeys: string[] }) {
|
||||
const first = useAuthor(pubkeys[0]);
|
||||
const firstName = first.data?.metadata?.name ?? genUserName(pubkeys[0]);
|
||||
const extra = pubkeys.length - 1;
|
||||
return (
|
||||
<span className="font-bold text-emerald-300">
|
||||
{firstName}
|
||||
{extra > 0 && <span className="font-normal text-emerald-400/70"> +{extra} more</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single incoming join-request card, shown to a crew's founder/admins. The
|
||||
* FOUNDER gets a one-click "Add to crew" (publishes the updated crew member
|
||||
* list); admins can only dismiss (they can't edit the founder's event). Either
|
||||
* way the request can be cleared locally so it stops nagging.
|
||||
* A single incoming join-request card for a crew's founder / admins.
|
||||
*
|
||||
* Trust model: only the founder authors the crew event, so only they can *add*
|
||||
* a member. Admins therefore **endorse** ("vouch for") a request; the founder
|
||||
* sees those endorsements as social proof and ratifies with one click. This
|
||||
* closes the admin/founder asymmetry without breaking who-can-edit-the-roster.
|
||||
*/
|
||||
export function JoinRequestCard({
|
||||
request,
|
||||
crew,
|
||||
endorsements = [],
|
||||
onResolved,
|
||||
compact,
|
||||
}: {
|
||||
request: CrewJoinRequest;
|
||||
crew: SkateCrew;
|
||||
endorsements?: CrewEndorsement[];
|
||||
onResolved?: () => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
|
|
@ -44,11 +77,18 @@ export function JoinRequestCard({
|
|||
const { toast } = useToast();
|
||||
const { mutateAsync: approve, isPending: approving } = useApproveJoinRequest();
|
||||
const { mutateAsync: dismiss, isPending: dismissing } = useDismissJoinRequest();
|
||||
const [done, setDone] = useState<'approved' | 'dismissed' | null>(null);
|
||||
const { mutateAsync: endorse, isPending: endorsing } = useEndorseJoinRequest();
|
||||
const [done, setDone] = useState<'approved' | 'dismissed' | 'endorsed' | null>(null);
|
||||
|
||||
const coord = parseAddrCoord(request.crewCoord);
|
||||
const crewHref = coord ? `/crew/${coord.pubkey}/${coord.identifier}` : '/crews';
|
||||
const isFounder = !!user && user.pubkey.toLowerCase() === crew.pubkey.toLowerCase();
|
||||
const meLc = user?.pubkey.toLowerCase();
|
||||
const isFounder = !!meLc && meLc === crew.pubkey.toLowerCase();
|
||||
const isAdmin = !!meLc && crew.admins.some((a) => a.toLowerCase() === meLc);
|
||||
|
||||
const endorserPubkeys = endorsements.map((e) => e.endorser);
|
||||
const iEndorsed = !!meLc && endorserPubkeys.some((p) => p.toLowerCase() === meLc);
|
||||
const hasEndorsements = endorserPubkeys.length > 0;
|
||||
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
|
|
@ -68,6 +108,24 @@ export function JoinRequestCard({
|
|||
}
|
||||
};
|
||||
|
||||
const handleEndorse = async () => {
|
||||
try {
|
||||
await endorse({ request, crew });
|
||||
setDone('endorsed');
|
||||
toast({
|
||||
title: `You vouched for ${requesterName} 🤝`,
|
||||
description: `The founder of ${crew.name} will see your endorsement and can add them.`,
|
||||
});
|
||||
onResolved?.();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Could not endorse',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = async () => {
|
||||
try {
|
||||
await dismiss({ request });
|
||||
|
|
@ -86,6 +144,11 @@ export function JoinRequestCard({
|
|||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
Added <span className="font-bold text-white">{requesterName}</span> to {crew.name}
|
||||
</>
|
||||
) : done === 'endorsed' ? (
|
||||
<>
|
||||
<ThumbsUp className="h-4 w-4 text-emerald-400" />
|
||||
You vouched for <span className="font-bold text-white">{requesterName}</span> — founder will confirm
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
|
|
@ -96,7 +159,7 @@ export function JoinRequestCard({
|
|||
);
|
||||
}
|
||||
|
||||
const busy = approving || dismissing;
|
||||
const busy = approving || dismissing || endorsing;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -131,6 +194,21 @@ export function JoinRequestCard({
|
|||
“{request.note}”
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Vouch strip — admins' endorsements as social proof for the founder. */}
|
||||
{hasEndorsements && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-lg border border-emerald-500/20 bg-emerald-500/[0.06] px-2.5 py-1.5">
|
||||
<div className="flex -space-x-1.5">
|
||||
{endorserPubkeys.slice(0, 3).map((pk) => (
|
||||
<EndorserAvatar key={pk} pubkey={pk} />
|
||||
))}
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate text-[11px] text-emerald-200/90">
|
||||
Vouched by <EndorserNames pubkeys={endorserPubkeys} />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-2">
|
||||
{isFounder ? (
|
||||
<Button
|
||||
|
|
@ -140,27 +218,45 @@ export function JoinRequestCard({
|
|||
className="h-8 flex-1 gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-600"
|
||||
>
|
||||
{approving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <UserPlus className="h-3.5 w-3.5" />}
|
||||
Add to crew
|
||||
{hasEndorsements ? 'Approve & add' : 'Add to crew'}
|
||||
</Button>
|
||||
) : isAdmin ? (
|
||||
iEndorsed ? (
|
||||
<span className="flex flex-1 items-center gap-1.5 text-xs font-bold text-emerald-400/90">
|
||||
<Clock className="h-3.5 w-3.5" /> Vouched — waiting on the founder
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleEndorse}
|
||||
disabled={busy}
|
||||
className="h-8 flex-1 gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-600"
|
||||
>
|
||||
{endorsing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ThumbsUp className="h-3.5 w-3.5" />}
|
||||
Vouch for them
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<span className="flex-1 text-xs text-gray-500">
|
||||
Only the founder can add members.
|
||||
</span>
|
||||
<span className="flex-1 text-xs text-gray-500">Crew leadership only.</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleDismiss}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
'h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white',
|
||||
!isFounder && 'flex-1',
|
||||
)}
|
||||
className="h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Micro-help so the admin path isn't a mystery. */}
|
||||
{isAdmin && !iEndorsed && (
|
||||
<p className="mt-1.5 text-[11px] leading-snug text-gray-500">
|
||||
Admins can’t add members directly — vouching tells the founder you back this rider.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -186,8 +186,14 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{joinRequests.map(({ request, crew }) => (
|
||||
<JoinRequestCard key={request.id} request={request} crew={crew} compact />
|
||||
{joinRequests.map(({ request, crew, endorsements }) => (
|
||||
<JoinRequestCard
|
||||
key={request.id}
|
||||
request={request}
|
||||
crew={crew}
|
||||
endorsements={endorsements}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
|
||||
{invites.map((invite) => (
|
||||
|
|
|
|||
|
|
@ -7,14 +7,17 @@ import { useDismissCrewNotification } from '@/hooks/useCrewDismissals';
|
|||
import {
|
||||
CREW_INVITE_KIND,
|
||||
CREW_JOIN_REQUEST_KIND,
|
||||
CREW_ENDORSEMENT_KIND,
|
||||
SKATE_CREW_KIND,
|
||||
crewInviteD,
|
||||
crewJoinRequestD,
|
||||
parseCrewInvite,
|
||||
parseCrewJoinRequest,
|
||||
parseCrewEndorsement,
|
||||
parseAddrCoord,
|
||||
type CrewInvite,
|
||||
type CrewJoinRequest,
|
||||
type CrewEndorsement,
|
||||
type SkateCrew,
|
||||
type MemberVisibility,
|
||||
} from '@/lib/types';
|
||||
|
|
@ -350,3 +353,93 @@ export function useDismissJoinRequest() {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Admin endorsements (admin proposes → founder confirms) ────────────────────
|
||||
// Only the founder can edit the roster, so an admin can't add a member. Instead
|
||||
// an admin publicly *vouches* for a request (kind 9087, p-tagging the founder).
|
||||
// The founder sees the endorsement(s) on the request card and ratifies with one
|
||||
// click — closing the admin/founder asymmetry without breaking the trust model.
|
||||
|
||||
/** An admin endorses ("vouches for") a join-request so the founder can ratify. */
|
||||
export function useEndorseJoinRequest() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
request,
|
||||
crew,
|
||||
note = '',
|
||||
}: {
|
||||
request: CrewJoinRequest;
|
||||
crew: SkateCrew;
|
||||
note?: string;
|
||||
}) => {
|
||||
if (!user) throw new Error('You must be logged in.');
|
||||
const isAdmin = crew.admins.some((a) => a.toLowerCase() === user.pubkey.toLowerCase());
|
||||
if (!isAdmin) throw new Error('Only a crew admin can endorse a request.');
|
||||
|
||||
const event = await user.signer.signEvent({
|
||||
kind: CREW_ENDORSEMENT_KIND,
|
||||
content: note.slice(0, 300),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['e', request.id], // the request being endorsed
|
||||
['a', request.crewCoord], // the crew
|
||||
['p', request.requester, '', 'requester'], // who's being vouched for
|
||||
['p', crew.pubkey], // surface to the founder's bell
|
||||
['alt', `Crew endorsement: vouching for a new member of ${crew.name}`],
|
||||
],
|
||||
});
|
||||
await nostr.event(event, { signal: AbortSignal.timeout(15000) });
|
||||
return event;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['crew-endorsements'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch endorsements for a set of join-request ids in ONE query, grouped by
|
||||
* request id. Only endorsements from actual crew admins are kept (an outsider
|
||||
* can't inflate a request's credibility).
|
||||
*/
|
||||
export function useJoinRequestEndorsements(
|
||||
requestIds: string[],
|
||||
adminsByCrewCoord: Map<string, Set<string>>,
|
||||
) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const ids = [...requestIds].sort();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['crew-endorsements', ids, getQueryKeySuffix()],
|
||||
enabled: ids.length > 0,
|
||||
staleTime: 30_000,
|
||||
queryFn: async (): Promise<Map<string, CrewEndorsement[]>> => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
const events = await querySkateContent(
|
||||
[{ kinds: [CREW_ENDORSEMENT_KIND], '#e': ids, limit: 200 }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const byRequest = new Map<string, CrewEndorsement[]>();
|
||||
const seen = new Set<string>(); // dedupe one endorser per request
|
||||
for (const ev of events) {
|
||||
const end = parseCrewEndorsement(ev);
|
||||
if (!end || !ids.includes(end.requestId)) continue;
|
||||
// Endorser must be an admin of the endorsed crew.
|
||||
const admins = adminsByCrewCoord.get(end.crewCoord);
|
||||
if (!admins || !admins.has(end.endorser.toLowerCase())) continue;
|
||||
const key = `${end.requestId}:${end.endorser.toLowerCase()}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const arr = byRequest.get(end.requestId) ?? [];
|
||||
arr.push(end);
|
||||
byRequest.set(end.requestId, arr);
|
||||
}
|
||||
return byRequest;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useMyCrewInvites, useIncomingJoinRequests } from '@/hooks/useCrewInvites';
|
||||
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 CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
|
||||
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). */
|
||||
|
|
@ -73,11 +73,36 @@ export function useCrewNotifications() {
|
|||
);
|
||||
}, [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: requestsWithCrew,
|
||||
joinRequests: requestsWithEndorsements,
|
||||
isLoading: invitesLoading || joinLoading || crewsLoading,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export const SASQUATCH_PRO_KIND = 32671; // Addressable "Sasquatch Pro" membersh
|
|||
export const SPOT_STATUS_KIND = 2259; // Regular event - community "spot status report" (active/bust/skate-stopped/gone/closed). SkateSpots-native.
|
||||
export const CREW_INVITE_KIND = 36229; // Addressable crew invitation — founder/admin invites a specific npub to a crew. SkateSpots-native.
|
||||
export const CREW_JOIN_REQUEST_KIND = 31969; // Addressable crew join-request — a skater asks to join a crew (notifiable). SkateSpots-native.
|
||||
export const CREW_ENDORSEMENT_KIND = 9087; // Regular event — an admin endorses ("vouches for") a join-request; the founder ratifies. SkateSpots-native.
|
||||
|
||||
/**
|
||||
* Lab provider entity type.
|
||||
|
|
@ -361,6 +362,46 @@ export function crewJoinRequestD(crewCoord: string, requesterPubkey: string): st
|
|||
return `${crewCoord}:${requesterPubkey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* An admin's endorsement of a join-request (kind 9087). Since only the founder
|
||||
* can edit the crew roster, an admin can't add a member directly — but they can
|
||||
* publicly *vouch* for a request. The founder sees these endorsements on the
|
||||
* request card and ratifies with one click. Public + `p`-tagged to the founder
|
||||
* so it surfaces in their bell as a stronger signal.
|
||||
*/
|
||||
export interface CrewEndorsement {
|
||||
id: string;
|
||||
endorser: string; // admin pubkey who vouched
|
||||
requester: string; // pubkey being vouched for
|
||||
requestId: string; // the join-request event id being endorsed
|
||||
crewCoord: string; // 35006:pubkey:d
|
||||
note: string; // optional "why"
|
||||
createdAt: number;
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
export function parseCrewEndorsement(event: NostrEvent): CrewEndorsement | null {
|
||||
if (event.kind !== CREW_ENDORSEMENT_KIND) return null;
|
||||
const requestId = event.tags.find(([n]) => n === 'e')?.[1];
|
||||
const crewCoord = event.tags.find(([n]) => n === 'a')?.[1];
|
||||
const requester = event.tags.find(([n, , , role]) => n === 'p' && role === 'requester')?.[1]
|
||||
?? event.tags.find(([n]) => n === 'p')?.[1];
|
||||
if (!requestId || !crewCoord || !requester) return null;
|
||||
const c = parseAddrCoord(crewCoord);
|
||||
if (!c || c.kind !== SKATE_CREW_KIND) return null;
|
||||
if (!/^[0-9a-f]{64}$/i.test(requester)) return null;
|
||||
return {
|
||||
id: event.id,
|
||||
endorser: event.pubkey,
|
||||
requester: requester.toLowerCase(),
|
||||
requestId,
|
||||
crewCoord,
|
||||
note: event.content.slice(0, 300),
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial
|
||||
* static payment code (see https://github.com/shocknet/clink, kind 21001).
|
||||
|
|
|
|||
Loading…
Reference in a new issue