Add sponsor picker + notifiable crew join-requests
Part A — Sponsor picker: - CrewSponsorPicker searches the shop directory and emits a 30017 coordinate so founders select a sponsoring shop instead of pasting a raw coordinate. - Wired into CrewForm, replacing the raw coordinate input. Part B — Notifiable join-requests (kind 31969): - New addressable join-request event p-tagged to founder + admins, making the request→approve direction symmetric with invites (surfaced in the bell, not buried in a DM inbox). - useCrewInvites: useSendJoinRequest, useIncomingJoinRequests, useApproveJoinRequest (founder adds member), useDismissJoinRequest (local). - CrewRequestToJoin now publishes the structured event. - JoinRequestCard (Add to crew / Dismiss) wired into NotificationBell and counted in the unread badge. - Documented kind 31969 in NIP.md alongside invites. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
50d8d9e614
commit
a2c34800ce
10 changed files with 615 additions and 64 deletions
47
NIP.md
47
NIP.md
|
|
@ -2245,6 +2245,53 @@ unforgeable (founder `p` tag + invitee attestation).
|
||||||
Founders also retain a shareable **link/message** invite for people not yet on
|
Founders also retain a shareable **link/message** invite for people not yet on
|
||||||
Nostr, and a shortcut to add a member key directly in crew settings.
|
Nostr, and a shortcut to add a member key directly in crew settings.
|
||||||
|
|
||||||
|
#### Join requests (kind `31969`) — the symmetric, notifiable inverse of invites
|
||||||
|
|
||||||
|
An invite flows moderator → skater. A **join request** flows the other way:
|
||||||
|
a skater asks a crew to let them in. To keep the two directions symmetric —
|
||||||
|
i.e. so a request lands in the leadership's **notification bell** with a
|
||||||
|
one-click action, rather than getting lost in a DM inbox — a request is a
|
||||||
|
public, addressable event (kind `31969`) that `p`-tags the founder **and** every
|
||||||
|
admin. It carries nothing sensitive: just an optional note.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 31969,
|
||||||
|
"content": "Local, skate the plaza most nights — would love to roll with you.",
|
||||||
|
"tags": [
|
||||||
|
["d", "35006:<founder>:<crewDtag>:<requesterPubkey>"],
|
||||||
|
["a", "35006:<founder>:<crewDtag>"],
|
||||||
|
["crew_name", "Barcelona Night Crew"],
|
||||||
|
["p", "<founderPubkey>"],
|
||||||
|
["p", "<adminPubkey>"],
|
||||||
|
["alt", "Crew join request: Barcelona Night Crew on SkateSpots"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tag | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `d` | Deterministic id `<crewCoord>:<requesterPubkey>` — re-requesting **replaces** the prior request (addressable), so a crew never accrues duplicate asks from the same person. |
|
||||||
|
| `a` | The crew coordinate (`35006:pubkey:d`) being requested. |
|
||||||
|
| `crew_name` | Readable crew name for rendering the notification without a second fetch. |
|
||||||
|
| `p` | One entry per **founder + admin**. Queryable at the relay level so each moderator finds requests addressed to them: `{ kinds: [31969], "#p": ["<me>"] }`. |
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
|
||||||
|
1. **Request.** A logged-out-of-the-crew skater publishes the request. The event
|
||||||
|
is authored by the requester (`pubkey`), so the "who wants in" claim is
|
||||||
|
cryptographically bound to them — no spoofing.
|
||||||
|
2. **Notify.** Each moderator's client queries `#p` requests, filters to crews
|
||||||
|
they actually lead/help run, and surfaces a card with **Add to crew**
|
||||||
|
(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,
|
||||||
|
handled/ignored requests are cleared from *their* bell via a local dismissal
|
||||||
|
set.
|
||||||
|
|
||||||
#### Private Crew Chat (Cordn / MLS) — the `coordinator` tag
|
#### Private Crew Chat (Cordn / MLS) — the `coordinator` tag
|
||||||
|
|
||||||
A crew's **public** identity (roster, spots, events, treasury, sponsor) lives on
|
A crew's **public** identity (roster, spots, events, treasury, sponsor) lives on
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { genUserName } from '@/lib/genUserName';
|
||||||
import { SpotPicker } from '@/components/SpotPicker';
|
import { SpotPicker } from '@/components/SpotPicker';
|
||||||
import { LabLinkField } from '@/components/LabLinkField';
|
import { LabLinkField } from '@/components/LabLinkField';
|
||||||
import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
|
import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
|
||||||
|
import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
|
||||||
import { NofferField } from '@/components/NofferField';
|
import { NofferField } from '@/components/NofferField';
|
||||||
import {
|
import {
|
||||||
SKATE_CREW_KIND,
|
SKATE_CREW_KIND,
|
||||||
|
|
@ -885,21 +886,14 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
|
|
||||||
{/* Sponsor shop */}
|
{/* Sponsor shop */}
|
||||||
<div className="space-y-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.04] p-4">
|
<div className="space-y-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.04] p-4">
|
||||||
<Label htmlFor="crew-sponsor" className="flex items-center gap-2 text-amber-400">
|
<Label className="flex items-center gap-2 text-amber-400">
|
||||||
<Store className="h-4 w-4" /> Sponsored by <span className="font-normal text-gray-500">· optional</span>
|
<Store className="h-4 w-4" /> Sponsored by <span className="font-normal text-gray-500">· optional</span>
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
If a local shop backs your crew, paste their shop address here to show a
|
If a local shop backs your crew, pick them here to show a
|
||||||
“Sponsored by” banner linking to them. Use the shop's coordinate:
|
“Sponsored by” banner linking to their shop.
|
||||||
<code className="text-amber-300"> 30017:pubkey:d</code>.
|
|
||||||
</p>
|
</p>
|
||||||
<Input
|
<CrewSponsorPicker value={sponsorRef} onChange={setSponsorRef} />
|
||||||
id="crew-sponsor"
|
|
||||||
placeholder="30017:pubkey:shop-id"
|
|
||||||
value={sponsorRef}
|
|
||||||
onChange={(e) => setSponsorRef(e.target.value)}
|
|
||||||
className="bg-gray-800 border-gray-700 font-mono text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Encrypted chat coordinator (Cordn / MLS) */}
|
{/* Encrypted chat coordinator (Cordn / MLS) */}
|
||||||
|
|
|
||||||
|
|
@ -11,67 +11,44 @@ import {
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useNostrLogin } from '@nostrify/react/login';
|
import { useSendJoinRequest } from '@/hooks/useCrewInvites';
|
||||||
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
|
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
import { UserPlus, Loader2, Bell } from 'lucide-react';
|
||||||
import { UserPlus, Loader2, Lock } from 'lucide-react';
|
import type { SkateCrew } from '@/lib/types';
|
||||||
|
|
||||||
interface CrewRequestToJoinProps {
|
interface CrewRequestToJoinProps {
|
||||||
crewName: string;
|
crew: SkateCrew;
|
||||||
founderPubkey: string;
|
|
||||||
/** Delegated admins — they also receive the request so growth isn't founder-only. */
|
|
||||||
admins?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lets a skater request to join a crew via an encrypted NIP-17 DM to the
|
* Lets a skater request to join a crew by publishing a structured, notifiable
|
||||||
* founder and admins. This removes the founder-push-only bottleneck: anyone can
|
* join-request (kind 31969) addressed to the founder + admins. Their clients
|
||||||
* knock, and any admin can then add them from the crew editor.
|
* surface it in the notification bell with a one-click "Add to crew" (founder),
|
||||||
*
|
* so requests aren't lost in a DM inbox. Nothing sensitive is published — just
|
||||||
* Privacy: the request is a gift-wrapped DM (kinds 14/13/1059) — only the
|
* an optional note.
|
||||||
* recipients can read it. No public "join" event leaks the social graph.
|
|
||||||
*/
|
*/
|
||||||
export function CrewRequestToJoin({ crewName, founderPubkey, admins = [] }: CrewRequestToJoinProps) {
|
export function CrewRequestToJoin({ crew }: CrewRequestToJoinProps) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { logins } = useNostrLogin();
|
const { mutateAsync: sendJoinRequest, isPending } = useSendJoinRequest();
|
||||||
const { mutateAsync: sendMessage, isPending } = useSendDirectMessage();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const recipients = Array.from(new Set([founderPubkey, ...admins])).filter(
|
const recipients = Array.from(new Set([crew.pubkey, ...crew.admins])).filter(
|
||||||
(pk) => pk !== user.pubkey,
|
(pk) => pk !== user.pubkey,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Don't show for people already in the leadership.
|
// Don't show for people already in the leadership.
|
||||||
if (recipients.length === 0) return null;
|
if (recipients.length === 0) return null;
|
||||||
|
|
||||||
const canDM = checkNip44(user, logins);
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!canDM) {
|
|
||||||
toast({
|
|
||||||
title: 'Encryption unavailable',
|
|
||||||
description: 'Your signer does not support NIP-44 encryption. Please upgrade your extension.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const body =
|
await sendJoinRequest({ crew, note });
|
||||||
`🛹 Request to join ${crewName}` +
|
|
||||||
(note.trim() ? `\n\n${note.trim()}` : '\n\nWould love to roll with the crew.');
|
|
||||||
await sendMessage({
|
|
||||||
recipients,
|
|
||||||
content: body,
|
|
||||||
subject: `${crewName} — join request`,
|
|
||||||
});
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Request sent!',
|
title: 'Request sent! 🛹',
|
||||||
description: `The ${crewName} crew leadership got your encrypted message.`,
|
description: `The ${crew.name} leadership will see it in their notifications.`,
|
||||||
});
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setNote('');
|
setNote('');
|
||||||
|
|
@ -98,11 +75,11 @@ export function CrewRequestToJoin({ crewName, founderPubkey, admins = [] }: Crew
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<UserPlus className="h-5 w-5 text-teal-400" /> Join {crewName}
|
<UserPlus className="h-5 w-5 text-teal-400" /> Join {crew.name}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="flex items-center gap-1.5">
|
<DialogDescription className="flex items-center gap-1.5">
|
||||||
<Lock className="h-3 w-3 text-teal-400" />
|
<Bell className="h-3 w-3 text-teal-400" />
|
||||||
Sends a private, encrypted message to the crew's founder & admins.
|
Notifies the crew's founder & admins so they can add you.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
|
||||||
168
src/components/JoinRequestCard.tsx
Normal file
168
src/components/JoinRequestCard.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
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 { 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 { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function npubSafe(pubkey: string): string {
|
||||||
|
try {
|
||||||
|
return nip19.npubEncode(pubkey);
|
||||||
|
} catch {
|
||||||
|
return pubkey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function JoinRequestCard({
|
||||||
|
request,
|
||||||
|
crew,
|
||||||
|
onResolved,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
request: CrewJoinRequest;
|
||||||
|
crew: SkateCrew;
|
||||||
|
onResolved?: () => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const requester = useAuthor(request.requester);
|
||||||
|
const requesterName = requester.data?.metadata?.name ?? genUserName(request.requester);
|
||||||
|
const requesterPic = requester.data?.metadata?.picture;
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { mutateAsync: approve, isPending: approving } = useApproveJoinRequest();
|
||||||
|
const { mutateAsync: dismiss, isPending: dismissing } = useDismissJoinRequest();
|
||||||
|
const [done, setDone] = useState<'approved' | 'dismissed' | 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 handleApprove = async () => {
|
||||||
|
try {
|
||||||
|
await approve({ request, crew });
|
||||||
|
setDone('approved');
|
||||||
|
toast({
|
||||||
|
title: `Added ${requesterName} to ${crew.name} 🛹`,
|
||||||
|
description: "They'll confirm from their side to appear as a member.",
|
||||||
|
});
|
||||||
|
onResolved?.();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Could not add member',
|
||||||
|
description: err instanceof Error ? err.message : 'Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = async () => {
|
||||||
|
try {
|
||||||
|
await dismiss({ request });
|
||||||
|
setDone('dismissed');
|
||||||
|
onResolved?.();
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Could not dismiss', variant: 'destructive' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2.5 text-sm text-gray-400">
|
||||||
|
{done === 'approved' ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4 text-emerald-400" />
|
||||||
|
Added <span className="font-bold text-white">{requesterName}</span> to {crew.name}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<X className="h-4 w-4 text-gray-500" />
|
||||||
|
Dismissed request from {requesterName}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = approving || dismissing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-emerald-500/25 bg-gradient-to-br from-emerald-500/[0.08] to-transparent p-3',
|
||||||
|
compact && 'p-2.5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-emerald-500/15">
|
||||||
|
<Hand className="h-4 w-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm leading-snug text-gray-200">
|
||||||
|
<Link
|
||||||
|
to={`/${npubSafe(request.requester)}`}
|
||||||
|
className="inline-flex items-center gap-1 font-bold text-white hover:text-emerald-300"
|
||||||
|
>
|
||||||
|
<Avatar className="h-4 w-4">
|
||||||
|
<AvatarImage src={requesterPic} alt={requesterName} />
|
||||||
|
<AvatarFallback className="text-[8px]">{requesterName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{requesterName}
|
||||||
|
</Link>{' '}
|
||||||
|
wants to join{' '}
|
||||||
|
<Link to={crewHref} className="font-black text-emerald-300 hover:underline">
|
||||||
|
{crew.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{request.note && (
|
||||||
|
<p className="mt-1 rounded-lg bg-black/30 px-2.5 py-1.5 text-xs italic text-gray-400 break-words [overflow-wrap:anywhere]">
|
||||||
|
“{request.note}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2.5 flex items-center gap-2">
|
||||||
|
{isFounder ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={busy}
|
||||||
|
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
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="flex-1 text-xs text-gray-500">
|
||||||
|
Only the founder can add members.
|
||||||
|
</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',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { CrewInviteCard } from '@/components/CrewInviteCard';
|
import { CrewInviteCard } from '@/components/CrewInviteCard';
|
||||||
|
import { JoinRequestCard } from '@/components/JoinRequestCard';
|
||||||
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
||||||
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
|
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
|
@ -125,10 +126,13 @@ function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?
|
||||||
*/
|
*/
|
||||||
export function NotificationBell({ className }: { className?: string }) {
|
export function NotificationBell({ className }: { className?: string }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { user, invites, listedCrews, isLoading } = useCrewNotifications();
|
const { user, invites, listedCrews, joinRequests, isLoading } = useCrewNotifications();
|
||||||
|
|
||||||
// The bell only makes sense when logged in.
|
// The bell only makes sense when logged in.
|
||||||
const total = useMemo(() => invites.length + listedCrews.length, [invites, listedCrews]);
|
const total = useMemo(
|
||||||
|
() => invites.length + listedCrews.length + joinRequests.length,
|
||||||
|
[invites, listedCrews, joinRequests],
|
||||||
|
);
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
|
|
@ -182,6 +186,10 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{joinRequests.map(({ request, crew }) => (
|
||||||
|
<JoinRequestCard key={request.id} request={request} crew={crew} compact />
|
||||||
|
))}
|
||||||
|
|
||||||
{invites.map((invite) => (
|
{invites.map((invite) => (
|
||||||
<CrewInviteCard key={invite.id} invite={invite} compact />
|
<CrewInviteCard key={invite.id} invite={invite} compact />
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
142
src/components/crew/CrewSponsorPicker.tsx
Normal file
142
src/components/crew/CrewSponsorPicker.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Store, Search, X, Check, Loader2, MapPin } from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { useSkateShops } from '@/hooks/useSkateShops';
|
||||||
|
import { SKATE_SHOP_KIND, parseAddrCoord, type SkateShop } from '@/lib/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a sponsoring skate shop by searching the shop directory — no raw
|
||||||
|
* coordinates. Emits the selected shop's coordinate (`30017:pubkey:d`) via
|
||||||
|
* `onChange`, or `''` when cleared.
|
||||||
|
*/
|
||||||
|
export function CrewSponsorPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (coord: string) => void;
|
||||||
|
}) {
|
||||||
|
const { data: shops, isLoading } = useSkateShops(200);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
// Resolve the currently-selected coordinate back to a shop for the preview.
|
||||||
|
const selected = useMemo(() => {
|
||||||
|
const coord = parseAddrCoord(value);
|
||||||
|
if (!coord) return null;
|
||||||
|
return shops?.find((s) => s.pubkey === coord.pubkey && s.identifier === coord.identifier) ?? null;
|
||||||
|
}, [value, shops]);
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
if (!shops) return [];
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const list = q
|
||||||
|
? shops.filter(
|
||||||
|
(s) =>
|
||||||
|
s.name.toLowerCase().includes(q) ||
|
||||||
|
s.location?.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
: shops;
|
||||||
|
return list.slice(0, 8);
|
||||||
|
}, [shops, query]);
|
||||||
|
|
||||||
|
const coordOf = (s: SkateShop) => `${SKATE_SHOP_KIND}:${s.pubkey}:${s.identifier}`;
|
||||||
|
|
||||||
|
// A shop is selected but not yet resolvable (still loading, or off-relay):
|
||||||
|
// keep the coordinate but show a neutral chip so the choice isn't lost.
|
||||||
|
const hasValueButUnresolved = !!parseAddrCoord(value) && !selected;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-amber-500/30 bg-amber-500/[0.06] p-3">
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<Avatar className="h-10 w-10 rounded-lg border border-amber-500/40">
|
||||||
|
<AvatarImage src={selected.images?.[0]} alt={selected.name} className="object-cover" />
|
||||||
|
<AvatarFallback className="rounded-lg bg-amber-500/15 text-amber-300">
|
||||||
|
<Store className="h-4 w-4" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="flex items-center gap-1 text-[10px] font-black uppercase tracking-widest text-amber-400/80">
|
||||||
|
<Check className="h-3 w-3" /> Sponsor selected
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-sm font-black text-white">{selected.name}</p>
|
||||||
|
{selected.location && (
|
||||||
|
<p className="truncate text-xs text-gray-400">{selected.location}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm text-gray-400">
|
||||||
|
{isLoading ? <Loader2 className="h-4 w-4 animate-spin text-amber-400" /> : <Store className="h-4 w-4 text-amber-400" />}
|
||||||
|
<span className="truncate">
|
||||||
|
{hasValueButUnresolved ? 'Sponsor set (shop loading…)' : 'Sponsor set'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-white/10 text-gray-400 transition-colors hover:border-red-500/40 hover:text-red-400"
|
||||||
|
aria-label="Clear sponsor"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search skate shops by name or city…"
|
||||||
|
className="bg-gray-800 border-gray-700 pl-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 px-1 py-3 text-sm text-gray-500">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading shops…
|
||||||
|
</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<p className="px-1 py-3 text-sm text-gray-500">
|
||||||
|
{query ? 'No shops match — try a different search.' : 'No shops found yet.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-56 space-y-1.5 overflow-y-auto pr-1">
|
||||||
|
{results.map((s) => (
|
||||||
|
<button
|
||||||
|
key={coordOf(s)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(coordOf(s))}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-xl border border-gray-700 bg-gray-800/60 p-2 text-left transition-all hover:border-amber-500/40 hover:bg-amber-500/[0.06] active:scale-[0.99]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar className="h-9 w-9 rounded-lg">
|
||||||
|
<AvatarImage src={s.images?.[0]} alt={s.name} className="object-cover" />
|
||||||
|
<AvatarFallback className="rounded-lg bg-amber-500/15 text-amber-300">
|
||||||
|
<Store className="h-4 w-4" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-bold text-white">{s.name}</p>
|
||||||
|
{s.location && (
|
||||||
|
<p className="flex items-center gap-1 truncate text-xs text-gray-500">
|
||||||
|
<MapPin className="h-3 w-3 shrink-0" /> {s.location}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,11 +5,15 @@ import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
|
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
|
||||||
import {
|
import {
|
||||||
CREW_INVITE_KIND,
|
CREW_INVITE_KIND,
|
||||||
|
CREW_JOIN_REQUEST_KIND,
|
||||||
SKATE_CREW_KIND,
|
SKATE_CREW_KIND,
|
||||||
crewInviteD,
|
crewInviteD,
|
||||||
|
crewJoinRequestD,
|
||||||
parseCrewInvite,
|
parseCrewInvite,
|
||||||
|
parseCrewJoinRequest,
|
||||||
parseAddrCoord,
|
parseAddrCoord,
|
||||||
type CrewInvite,
|
type CrewInvite,
|
||||||
|
type CrewJoinRequest,
|
||||||
type SkateCrew,
|
type SkateCrew,
|
||||||
type MemberVisibility,
|
type MemberVisibility,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
|
@ -230,3 +234,153 @@ export function useDeclineCrewInvite() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Join requests (notifiable) ───────────────────────────────────────────────
|
||||||
|
// A skater asks to join a crew. We publish a structured, addressable event
|
||||||
|
// (kind 31969) p-tagged to the founder + admins so it shows in THEIR notification
|
||||||
|
// bell with a one-click "Add to crew" (founder) — parallel to invites, so the
|
||||||
|
// two directions are symmetric. Handled requests are cleared via a local
|
||||||
|
// dismissal set on the moderator's device.
|
||||||
|
|
||||||
|
const JOIN_DISMISS_KEY = 'skatespots:dismissed-join-requests';
|
||||||
|
|
||||||
|
function readJoinDismissed(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(JOIN_DISMISS_KEY);
|
||||||
|
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissJoinRequest(id: string) {
|
||||||
|
try {
|
||||||
|
const set = readJoinDismissed();
|
||||||
|
set.add(id);
|
||||||
|
localStorage.setItem(JOIN_DISMISS_KEY, JSON.stringify([...set]));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish a structured join-request to a crew's founder + admins. */
|
||||||
|
export function useSendJoinRequest() {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
crew,
|
||||||
|
note = '',
|
||||||
|
}: {
|
||||||
|
crew: SkateCrew;
|
||||||
|
note?: string;
|
||||||
|
}) => {
|
||||||
|
if (!user) throw new Error('You must be logged in to request to join.');
|
||||||
|
const crewCoord = `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`;
|
||||||
|
const recipients = Array.from(new Set([crew.pubkey, ...crew.admins])).filter(
|
||||||
|
(pk) => pk.toLowerCase() !== user.pubkey.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = await user.signer.signEvent({
|
||||||
|
kind: CREW_JOIN_REQUEST_KIND,
|
||||||
|
content: note.slice(0, 500),
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['d', crewJoinRequestD(crewCoord, user.pubkey.toLowerCase())],
|
||||||
|
['a', crewCoord],
|
||||||
|
['crew_name', crew.name],
|
||||||
|
...recipients.map((pk) => ['p', pk]),
|
||||||
|
['alt', `Crew join request: ${crew.name} on SkateSpots`],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await nostr.event(event, { signal: AbortSignal.timeout(15000) });
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Incoming join-requests addressed to the given moderator (founder/admin). */
|
||||||
|
export function useIncomingJoinRequests(pubkey: string | undefined) {
|
||||||
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['crew-join-requests', 'incoming', pubkey, getQueryKeySuffix()],
|
||||||
|
enabled: !!pubkey,
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
queryFn: async (): Promise<CrewJoinRequest[]> => {
|
||||||
|
if (!pubkey) return [];
|
||||||
|
const signal = AbortSignal.timeout(10000);
|
||||||
|
const events = await querySkateContent(
|
||||||
|
[{ kinds: [CREW_JOIN_REQUEST_KIND], '#p': [pubkey], limit: 100 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
const dismissed = readJoinDismissed();
|
||||||
|
const seen = new Map<string, CrewJoinRequest>();
|
||||||
|
for (const ev of events) {
|
||||||
|
const req = parseCrewJoinRequest(ev);
|
||||||
|
if (!req) continue;
|
||||||
|
if (!req.recipients.includes(pubkey.toLowerCase())) continue;
|
||||||
|
if (dismissed.has(req.id)) continue;
|
||||||
|
// Dedup by (crew + requester) — newest wins.
|
||||||
|
const key = `${req.crewCoord}:${req.requester}`;
|
||||||
|
const prev = seen.get(key);
|
||||||
|
if (!prev || req.createdAt > prev.createdAt) seen.set(key, req);
|
||||||
|
}
|
||||||
|
return [...seen.values()].sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve a join-request: the FOUNDER adds the requester to the crew's member
|
||||||
|
* (`p`) list. The requester then confirms with their own key (standard
|
||||||
|
* attestation). Only the founder can do this (they author the crew event); an
|
||||||
|
* admin dismisses/handles out-of-band.
|
||||||
|
*/
|
||||||
|
export function useApproveJoinRequest() {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ request, crew }: { request: CrewJoinRequest; crew: SkateCrew }) => {
|
||||||
|
if (!user) throw new Error('You must be logged in.');
|
||||||
|
if (user.pubkey.toLowerCase() !== crew.pubkey.toLowerCase()) {
|
||||||
|
throw new Error('Only the crew founder can add members.');
|
||||||
|
}
|
||||||
|
const alreadyMember = crew.members.some(
|
||||||
|
(m) => m.toLowerCase() === request.requester.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (!alreadyMember) {
|
||||||
|
const updated = republishCrewWithMember(crew.event, request.requester, 'member');
|
||||||
|
const signed = await user.signer.signEvent(updated);
|
||||||
|
await nostr.event(signed, { signal: AbortSignal.timeout(15000) });
|
||||||
|
}
|
||||||
|
dismissJoinRequest(request.id);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['skate-crews'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['skate-crew'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dismiss a join-request locally (moderator handled/ignored it). */
|
||||||
|
export function useDismissJoinRequest() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ request }: { request: CrewJoinRequest }) => {
|
||||||
|
dismissJoinRequest(request.id);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useMyCrewInvites } from '@/hooks/useCrewInvites';
|
import { useMyCrewInvites, useIncomingJoinRequests } from '@/hooks/useCrewInvites';
|
||||||
import { useCrews } from '@/hooks/useCrews';
|
import { useCrews } from '@/hooks/useCrews';
|
||||||
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
|
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
|
||||||
import { SKATE_CREW_KIND, type CrewInvite, type SkateCrew } from '@/lib/types';
|
import { SKATE_CREW_KIND, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
|
||||||
|
|
||||||
export interface CrewNotification {
|
export interface CrewNotification {
|
||||||
/** Stable id for the notification (invite id or crew coord). */
|
/** Stable id for the notification (invite id or crew coord). */
|
||||||
|
|
@ -28,6 +28,7 @@ export interface CrewNotification {
|
||||||
export function useCrewNotifications() {
|
export function useCrewNotifications() {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { data: invites, isLoading: invitesLoading } = useMyCrewInvites(user?.pubkey);
|
const { data: invites, isLoading: invitesLoading } = useMyCrewInvites(user?.pubkey);
|
||||||
|
const { data: joinRequests, isLoading: joinLoading } = useIncomingJoinRequests(user?.pubkey);
|
||||||
const { data: crews, isLoading: crewsLoading } = useCrews(200);
|
const { data: crews, isLoading: crewsLoading } = useCrews(200);
|
||||||
|
|
||||||
// Crews that list me as a member (candidates for "pending confirmation").
|
// Crews that list me as a member (candidates for "pending confirmation").
|
||||||
|
|
@ -40,11 +41,31 @@ export function useCrewNotifications() {
|
||||||
);
|
);
|
||||||
}, [user, crews]);
|
}, [user, crews]);
|
||||||
|
|
||||||
|
// Resolve each incoming join-request to its crew (for approval + display).
|
||||||
|
const requestsWithCrew = useMemo(() => {
|
||||||
|
if (!user || !joinRequests?.length || !crews) return [];
|
||||||
|
return joinRequests
|
||||||
|
.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]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
invites: invites ?? [],
|
invites: invites ?? [],
|
||||||
listedCrews,
|
listedCrews,
|
||||||
isLoading: invitesLoading || crewsLoading,
|
joinRequests: requestsWithCrew,
|
||||||
|
isLoading: invitesLoading || joinLoading || crewsLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export const SPOT_CHAT_KIND = 24899; // Ephemeral geohash-anchored local chat ("
|
||||||
export const SASQUATCH_PRO_KIND = 32671; // Addressable "Sasquatch Pro" membership receipt (self-signed, references the zap payment). SkateSpots-native.
|
export const SASQUATCH_PRO_KIND = 32671; // Addressable "Sasquatch Pro" membership receipt (self-signed, references the zap payment). SkateSpots-native.
|
||||||
export const SPOT_STATUS_KIND = 2259; // Regular event - community "spot status report" (active/bust/skate-stopped/gone/closed). SkateSpots-native.
|
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_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.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lab provider entity type.
|
* Lab provider entity type.
|
||||||
|
|
@ -315,6 +316,51 @@ export function crewInviteD(crewCoord: string, inviteePubkey: string): string {
|
||||||
return `${crewCoord}:${inviteePubkey}`;
|
return `${crewCoord}:${inviteePubkey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A structured crew join-request (kind 31969) — a skater asks to join a crew.
|
||||||
|
* Public and addressable (keyed by crew + requester, so re-requesting replaces),
|
||||||
|
* `p`-tagged to the founder + admins so their clients can surface it as a
|
||||||
|
* notification with a one-click "Add to crew". Carries nothing sensitive — just
|
||||||
|
* an optional note.
|
||||||
|
*/
|
||||||
|
export interface CrewJoinRequest {
|
||||||
|
id: string;
|
||||||
|
requester: string; // pubkey asking to join
|
||||||
|
crewCoord: string; // 35006:pubkey:d
|
||||||
|
crewName: string;
|
||||||
|
note: string; // optional personal message
|
||||||
|
/** Founder + admin pubkeys this request is addressed to (from `p` tags). */
|
||||||
|
recipients: string[];
|
||||||
|
createdAt: number;
|
||||||
|
event: NostrEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCrewJoinRequest(event: NostrEvent): CrewJoinRequest | null {
|
||||||
|
if (event.kind !== CREW_JOIN_REQUEST_KIND) return null;
|
||||||
|
const crewCoord = event.tags.find(([n]) => n === 'a')?.[1];
|
||||||
|
if (!crewCoord) return null;
|
||||||
|
const c = parseAddrCoord(crewCoord);
|
||||||
|
if (!c || c.kind !== SKATE_CREW_KIND) return null;
|
||||||
|
const recipients = event.tags
|
||||||
|
.filter(([n, v]) => n === 'p' && /^[0-9a-f]{64}$/i.test(v ?? ''))
|
||||||
|
.map(([, v]) => v.toLowerCase());
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
requester: event.pubkey,
|
||||||
|
crewCoord,
|
||||||
|
crewName: event.tags.find(([n]) => n === 'crew_name')?.[1] ?? 'a crew',
|
||||||
|
note: event.content.slice(0, 500),
|
||||||
|
recipients,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
event,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic d-tag for a join-request: crew coord + requester. */
|
||||||
|
export function crewJoinRequestD(crewCoord: string, requesterPubkey: string): string {
|
||||||
|
return `${crewCoord}:${requesterPubkey}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial
|
* Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial
|
||||||
* static payment code (see https://github.com/shocknet/clink, kind 21001).
|
* static payment code (see https://github.com/shocknet/clink, kind 21001).
|
||||||
|
|
|
||||||
|
|
@ -298,13 +298,7 @@ export default function CrewDetailPage() {
|
||||||
<Users className="h-3 w-3" /> Member
|
<Users className="h-3 w-3" /> Member
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{role === 'none' && (
|
{role === 'none' && <CrewRequestToJoin crew={crew} />}
|
||||||
<CrewRequestToJoin
|
|
||||||
crewName={crew.name}
|
|
||||||
founderPubkey={crew.pubkey}
|
|
||||||
admins={crew.admins}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue