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
|
||||
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
|
||||
|
||||
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 { LabLinkField } from '@/components/LabLinkField';
|
||||
import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
|
||||
import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
|
||||
import { NofferField } from '@/components/NofferField';
|
||||
import {
|
||||
SKATE_CREW_KIND,
|
||||
|
|
@ -885,21 +886,14 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
|
||||
{/* Sponsor shop */}
|
||||
<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>
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
If a local shop backs your crew, paste their shop address here to show a
|
||||
“Sponsored by” banner linking to them. Use the shop's coordinate:
|
||||
<code className="text-amber-300"> 30017:pubkey:d</code>.
|
||||
If a local shop backs your crew, pick them here to show a
|
||||
“Sponsored by” banner linking to their shop.
|
||||
</p>
|
||||
<Input
|
||||
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"
|
||||
/>
|
||||
<CrewSponsorPicker value={sponsorRef} onChange={setSponsorRef} />
|
||||
</div>
|
||||
|
||||
{/* Encrypted chat coordinator (Cordn / MLS) */}
|
||||
|
|
|
|||
|
|
@ -11,67 +11,44 @@ import {
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
|
||||
import { useSendJoinRequest } from '@/hooks/useCrewInvites';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||
import { UserPlus, Loader2, Lock } from 'lucide-react';
|
||||
import { UserPlus, Loader2, Bell } from 'lucide-react';
|
||||
import type { SkateCrew } from '@/lib/types';
|
||||
|
||||
interface CrewRequestToJoinProps {
|
||||
crewName: string;
|
||||
founderPubkey: string;
|
||||
/** Delegated admins — they also receive the request so growth isn't founder-only. */
|
||||
admins?: string[];
|
||||
crew: SkateCrew;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets a skater request to join a crew via an encrypted NIP-17 DM to the
|
||||
* founder and admins. This removes the founder-push-only bottleneck: anyone can
|
||||
* knock, and any admin can then add them from the crew editor.
|
||||
*
|
||||
* Privacy: the request is a gift-wrapped DM (kinds 14/13/1059) — only the
|
||||
* recipients can read it. No public "join" event leaks the social graph.
|
||||
* Lets a skater request to join a crew by publishing a structured, notifiable
|
||||
* join-request (kind 31969) addressed to the founder + admins. Their clients
|
||||
* 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
|
||||
* an optional note.
|
||||
*/
|
||||
export function CrewRequestToJoin({ crewName, founderPubkey, admins = [] }: CrewRequestToJoinProps) {
|
||||
export function CrewRequestToJoin({ crew }: CrewRequestToJoinProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const { mutateAsync: sendMessage, isPending } = useSendDirectMessage();
|
||||
const { mutateAsync: sendJoinRequest, isPending } = useSendJoinRequest();
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
// Don't show for people already in the leadership.
|
||||
if (recipients.length === 0) return null;
|
||||
|
||||
const canDM = checkNip44(user, logins);
|
||||
|
||||
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 {
|
||||
const body =
|
||||
`🛹 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`,
|
||||
});
|
||||
await sendJoinRequest({ crew, note });
|
||||
toast({
|
||||
title: 'Request sent!',
|
||||
description: `The ${crewName} crew leadership got your encrypted message.`,
|
||||
title: 'Request sent! 🛹',
|
||||
description: `The ${crew.name} leadership will see it in their notifications.`,
|
||||
});
|
||||
setOpen(false);
|
||||
setNote('');
|
||||
|
|
@ -98,11 +75,11 @@ export function CrewRequestToJoin({ crewName, founderPubkey, admins = [] }: Crew
|
|||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<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>
|
||||
<DialogDescription className="flex items-center gap-1.5">
|
||||
<Lock className="h-3 w-3 text-teal-400" />
|
||||
Sends a private, encrypted message to the crew's founder & admins.
|
||||
<Bell className="h-3 w-3 text-teal-400" />
|
||||
Notifies the crew's founder & admins so they can add you.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { CrewInviteCard } from '@/components/CrewInviteCard';
|
||||
import { JoinRequestCard } from '@/components/JoinRequestCard';
|
||||
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
||||
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
|
|
@ -125,10 +126,13 @@ function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?
|
|||
*/
|
||||
export function NotificationBell({ className }: { className?: string }) {
|
||||
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.
|
||||
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;
|
||||
|
||||
|
|
@ -182,6 +186,10 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{joinRequests.map(({ request, crew }) => (
|
||||
<JoinRequestCard key={request.id} request={request} crew={crew} compact />
|
||||
))}
|
||||
|
||||
{invites.map((invite) => (
|
||||
<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 {
|
||||
CREW_INVITE_KIND,
|
||||
CREW_JOIN_REQUEST_KIND,
|
||||
SKATE_CREW_KIND,
|
||||
crewInviteD,
|
||||
crewJoinRequestD,
|
||||
parseCrewInvite,
|
||||
parseCrewJoinRequest,
|
||||
parseAddrCoord,
|
||||
type CrewInvite,
|
||||
type CrewJoinRequest,
|
||||
type SkateCrew,
|
||||
type MemberVisibility,
|
||||
} 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 { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useMyCrewInvites } from '@/hooks/useCrewInvites';
|
||||
import { useMyCrewInvites, useIncomingJoinRequests } from '@/hooks/useCrewInvites';
|
||||
import { useCrews } from '@/hooks/useCrews';
|
||||
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 {
|
||||
/** Stable id for the notification (invite id or crew coord). */
|
||||
|
|
@ -28,6 +28,7 @@ export interface CrewNotification {
|
|||
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);
|
||||
|
||||
// Crews that list me as a member (candidates for "pending confirmation").
|
||||
|
|
@ -40,11 +41,31 @@ export function useCrewNotifications() {
|
|||
);
|
||||
}, [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 {
|
||||
user,
|
||||
invites: invites ?? [],
|
||||
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 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.
|
||||
|
||||
/**
|
||||
* Lab provider entity type.
|
||||
|
|
@ -315,6 +316,51 @@ export function crewInviteD(crewCoord: string, inviteePubkey: string): string {
|
|||
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
|
||||
* 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
|
||||
</Badge>
|
||||
)}
|
||||
{role === 'none' && (
|
||||
<CrewRequestToJoin
|
||||
crewName={crew.name}
|
||||
founderPubkey={crew.pubkey}
|
||||
admins={crew.admins}
|
||||
/>
|
||||
)}
|
||||
{role === 'none' && <CrewRequestToJoin crew={crew} />}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue