Lab collective join-requests → notification bell
Skaters can now request to join a Lab collective (studio/build team), mirroring the crew join-request flow: - New addressable kind 36387 (collective join-request), documented in NIP.md - useCollectiveJoinRequests: send / incoming / "already requested" hooks - Owner-only notification surfaced in the bell (All + Crews tabs) via CollectiveJoinRequestCard, reusing the synced dismissal set - "Request to join this collective" button on collective Lab profiles (RequestToJoinCollective), hidden for the owner and existing members Trust model preserved: only the owner can edit their own profile roster, so the card links them to the member editor — no forged membership possible. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
64b91c0ae2
commit
314c8620f1
8 changed files with 502 additions and 7 deletions
44
NIP.md
44
NIP.md
|
|
@ -2395,6 +2395,47 @@ join-request, `a`-tags the crew, and `p`-tags both the requester (role
|
|||
Clients **must** keep only endorsements whose author is a listed admin of the
|
||||
endorsed crew, and dedupe to one endorsement per admin per request.
|
||||
|
||||
#### Collective join requests (kind `36387`) — join a Lab collective
|
||||
|
||||
Lab collectives (studios, build teams, filmer houses — any Lab profile with
|
||||
`entity = collective`) accept the same "ask to join" flow as crews, but keyed to
|
||||
the collective's own coordinate. A skater publishes a public, addressable event
|
||||
(kind `36387`) that `p`-tags the collective's owner so it surfaces in their
|
||||
notification bell.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 36387,
|
||||
"content": "Been filming the local plaza scene for years — would love to roll with you.",
|
||||
"tags": [
|
||||
["d", "35003:<ownerPubkey>:<collectiveDtag>:<requesterPubkey>"],
|
||||
["a", "35003:<ownerPubkey>:<collectiveDtag>"],
|
||||
["collective_name", "Barcelona Filmers"],
|
||||
["p", "<ownerPubkey>"],
|
||||
["alt", "Collective join request: Barcelona Filmers on SkateSpots"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `d` | Collective coordinate + requester pubkey, so re-requesting **replaces** the prior request rather than piling up. |
|
||||
| `a` | The Lab collective coordinate (`<kind>:<owner>:<d>`) where `<kind>` is any Lab craft kind (35001–35009). Clients validate the referenced kind is a Lab craft kind. |
|
||||
| `collective_name` | Cached title for rendering the notification without a second fetch. |
|
||||
| `p` | The collective **owner** — queryable so the owner finds requests addressed to them: `{ kinds: [36387], "#p": ["<me>"] }`. |
|
||||
| `alt` | NIP-31 human-readable summary. |
|
||||
|
||||
**Flow (mirrors crews):**
|
||||
|
||||
1. **Request.** The skater authors the request, binding "who wants in" to their key.
|
||||
2. **Notify.** The owner's client queries `#p` and surfaces a card linking to the
|
||||
collective's member editor plus a **Dismiss** action.
|
||||
3. **Add.** Only the owner authors the collective profile, so only they add the
|
||||
requester's `p` tag; the requester then confirms with their own NIP-32
|
||||
membership attestation, exactly as with any collective member.
|
||||
4. **Dismiss.** Handled/ignored requests are cleared from the owner's bell via
|
||||
the same synced dismissal set below (keyed by event id).
|
||||
|
||||
#### Dismissed notifications (kind `30078`) — private, cross-device
|
||||
|
||||
Neither an invitee nor a moderator authored the invite / join-request they're
|
||||
|
|
@ -2402,7 +2443,8 @@ acting on, so they can't delete it — they can only **stop it nagging them**.
|
|||
That "dismissed" set is kept as a single private, addressable **NIP-78**
|
||||
application-data event (kind `30078`, `d = skatespots:crew-notif-dismissals`)
|
||||
whose `content` is a **NIP-44 payload encrypted to the user's own key**. One
|
||||
list covers both invites and join-requests (both keyed by event id).
|
||||
list covers invites, crew join-requests, and collective join-requests (all keyed
|
||||
by event id).
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
|
|||
134
src/components/CollectiveJoinRequestCard.tsx
Normal file
134
src/components/CollectiveJoinRequestCard.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
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 { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useDismissCrewNotification } from '@/hooks/useCrewDismissals';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parseAddrCoord, type CollectiveJoinRequest } from '@/lib/types';
|
||||
import { Users, X, Loader2, Hand, Settings2, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function npubSafe(pubkey: string): string {
|
||||
try {
|
||||
return nip19.npubEncode(pubkey);
|
||||
} catch {
|
||||
return pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An incoming "request to join this collective" for a Lab-collective owner.
|
||||
*
|
||||
* Trust model mirrors crews: only the owner can edit their own Lab-profile
|
||||
* event, so the card can't add the member for them. Instead it links straight
|
||||
* to the collective's editor (where the roster lives) and lets the owner
|
||||
* dismiss the request once handled. No forged membership is ever possible.
|
||||
*/
|
||||
export function CollectiveJoinRequestCard({
|
||||
request,
|
||||
onResolved,
|
||||
compact,
|
||||
}: {
|
||||
request: CollectiveJoinRequest;
|
||||
onResolved?: () => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
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: dismiss, isPending: dismissing } = useDismissCrewNotification();
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const coord = parseAddrCoord(request.collectiveCoord);
|
||||
// All Lab crafts render + edit through the /coach detail route.
|
||||
const collectiveHref = coord ? `/coach/${coord.pubkey}/${coord.identifier}` : '/lab';
|
||||
|
||||
const handleDismiss = async () => {
|
||||
try {
|
||||
await dismiss(request.id);
|
||||
setDone(true);
|
||||
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">
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
Dismissed request from {requesterName}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-sky-500/25 bg-gradient-to-br from-sky-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-sky-500/15">
|
||||
<Hand className="h-4 w-4 text-sky-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-sky-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={collectiveHref} className="font-black text-sky-300 hover:underline">
|
||||
{request.collectiveName}
|
||||
</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">
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="h-8 flex-1 gap-1.5 bg-sky-500 font-bold text-black hover:bg-sky-600"
|
||||
>
|
||||
<Link to={`${collectiveHref}?manage=members`}>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Manage roster
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleDismiss}
|
||||
disabled={dismissing}
|
||||
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>
|
||||
|
||||
<p className="mt-1.5 flex items-center gap-1 text-[11px] leading-snug text-gray-500">
|
||||
<Users className="h-3 w-3" />
|
||||
Add them from your collective’s member list, then dismiss this.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { CrewInviteCard } from '@/components/CrewInviteCard';
|
||||
import { JoinRequestCard } from '@/components/JoinRequestCard';
|
||||
import { CollectiveJoinRequestCard } from '@/components/CollectiveJoinRequestCard';
|
||||
import { ActivityNotificationCard } from '@/components/ActivityNotificationCard';
|
||||
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
||||
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
|
||||
|
|
@ -145,13 +146,14 @@ function EmptyState({ label }: { label: string }) {
|
|||
*/
|
||||
export function NotificationBell({ className }: { className?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { user, invites, listedCrews, joinRequests, isLoading: crewLoading } = useCrewNotifications();
|
||||
const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
|
||||
useCrewNotifications();
|
||||
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
||||
const { lastRead, markAllRead } = useNotificationsRead();
|
||||
|
||||
const crewTotal = useMemo(
|
||||
() => invites.length + listedCrews.length + joinRequests.length,
|
||||
[invites, listedCrews, joinRequests],
|
||||
() => invites.length + listedCrews.length + joinRequests.length + collectiveRequests.length,
|
||||
[invites, listedCrews, joinRequests, collectiveRequests],
|
||||
);
|
||||
|
||||
// Unread social items = activity newer than the last time the bell was opened.
|
||||
|
|
@ -243,6 +245,9 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
{joinRequests.map(({ request, crew, endorsements }) => (
|
||||
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
|
||||
))}
|
||||
{collectiveRequests.map((request) => (
|
||||
<CollectiveJoinRequestCard key={request.id} request={request} compact />
|
||||
))}
|
||||
{invites.map((invite) => (
|
||||
<CrewInviteCard key={invite.id} invite={invite} compact />
|
||||
))}
|
||||
|
|
@ -290,12 +295,15 @@ export function NotificationBell({ className }: { className?: string }) {
|
|||
</div>
|
||||
)}
|
||||
{!crewLoading && crewTotal === 0 && (
|
||||
<EmptyState label="Crew invites and membership requests will show up here." />
|
||||
<EmptyState label="Crew invites, collective requests and membership requests will show up here." />
|
||||
)}
|
||||
<div className="space-y-2.5">
|
||||
{joinRequests.map(({ request, crew, endorsements }) => (
|
||||
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
|
||||
))}
|
||||
{collectiveRequests.map((request) => (
|
||||
<CollectiveJoinRequestCard key={request.id} request={request} compact />
|
||||
))}
|
||||
{invites.map((invite) => (
|
||||
<CrewInviteCard key={invite.id} invite={invite} compact />
|
||||
))}
|
||||
|
|
|
|||
126
src/components/RequestToJoinCollective.tsx
Normal file
126
src/components/RequestToJoinCollective.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import {
|
||||
useSendCollectiveJoinRequest,
|
||||
useMyCollectiveJoinRequest,
|
||||
} from '@/hooks/useCollectiveJoinRequests';
|
||||
import type { LabProfile } from '@/hooks/useLabProfiles';
|
||||
import { Users, Hand, Loader2, Check, X } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* "Request to join this collective" — shown on a Lab collective's profile to
|
||||
* logged-in skaters who aren't the owner and aren't already listed. Mirrors the
|
||||
* crew join-request: it notifies the owner (who adds the member through their
|
||||
* own editor). Owners never see it on their own profile.
|
||||
*/
|
||||
export function RequestToJoinCollective({
|
||||
profile,
|
||||
accentText = 'text-teal-400',
|
||||
accentBorder = 'border-teal-500',
|
||||
}: {
|
||||
profile: LabProfile;
|
||||
accentText?: string;
|
||||
accentBorder?: string;
|
||||
}) {
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
const coord = `${profile.event.kind}:${profile.pubkey}:${profile.identifier}`;
|
||||
const { mutateAsync: send, isPending: sending } = useSendCollectiveJoinRequest();
|
||||
const { data: alreadyRequested, isLoading: checking } = useMyCollectiveJoinRequest(coord);
|
||||
|
||||
// Gate: must be logged in, not the owner, not already a listed member.
|
||||
if (!user) return null;
|
||||
const meLc = user.pubkey.toLowerCase();
|
||||
if (meLc === profile.pubkey.toLowerCase()) return null;
|
||||
if (profile.members.some((m) => m.toLowerCase() === meLc)) return null;
|
||||
|
||||
const handleSend = async () => {
|
||||
try {
|
||||
await send({ profile, note });
|
||||
setOpen(false);
|
||||
setNote('');
|
||||
toast({
|
||||
title: 'Request sent 🤝',
|
||||
description: `${profile.title} will see it in their notifications and can add you.`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Could not send request',
|
||||
description: err instanceof Error ? err.message : 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`bg-black/90 border-2 ${accentBorder}`}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Users className={`h-5 w-5 ${accentText}`} />
|
||||
<h3 className="text-lg text-white uppercase font-black">Join this collective</h3>
|
||||
</div>
|
||||
|
||||
{alreadyRequested ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2.5 text-sm text-emerald-300">
|
||||
<Check className="h-4 w-4" />
|
||||
Request sent — waiting on {profile.title}.
|
||||
</div>
|
||||
) : !open ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Ask the crew behind <span className="font-bold text-white">{profile.title}</span> to add
|
||||
you to the roster. They confirm from their side.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={checking}
|
||||
className="w-full gap-2 bg-white text-black font-black hover:bg-gray-200"
|
||||
>
|
||||
<Hand className="h-4 w-4" />
|
||||
Request to join
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value.slice(0, 500))}
|
||||
placeholder="Optional: say why you'd be a good fit…"
|
||||
rows={3}
|
||||
className="resize-none border-gray-700 bg-black/60 text-white placeholder:text-gray-600"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={sending}
|
||||
className="flex-1 gap-2 bg-white text-black font-black hover:bg-gray-200"
|
||||
>
|
||||
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Hand className="h-4 w-4" />}
|
||||
Send request
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setNote('');
|
||||
}}
|
||||
disabled={sending}
|
||||
className="gap-1.5 border-gray-700 text-gray-400 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
109
src/hooks/useCollectiveJoinRequests.ts
Normal file
109
src/hooks/useCollectiveJoinRequests.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import {
|
||||
COLLECTIVE_JOIN_REQUEST_KIND,
|
||||
collectiveJoinRequestD,
|
||||
parseCollectiveJoinRequest,
|
||||
type CollectiveJoinRequest,
|
||||
} from '@/lib/types';
|
||||
import type { LabProfile } from '@/hooks/useLabProfiles';
|
||||
|
||||
/**
|
||||
* Lab-collective join-requests — mirror of the crew join-request flow.
|
||||
*
|
||||
* Someone asks to join a Lab collective (studio / build team). The request is
|
||||
* addressable (keyed by collective + requester, so re-requesting replaces) and
|
||||
* `p`-tagged to the collective owner so it surfaces in their notification bell.
|
||||
* The owner adds the member through their normal Lab-profile editor — only they
|
||||
* can edit their own event, so there's no forged-membership risk.
|
||||
*/
|
||||
|
||||
/** Publish (or replace) a join-request to a Lab collective's owner. */
|
||||
export function useSendCollectiveJoinRequest() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ profile, note = '' }: { profile: LabProfile; note?: string }) => {
|
||||
if (!user) throw new Error('You must be logged in to request to join.');
|
||||
const coord = `${profile.event.kind}:${profile.pubkey}:${profile.identifier}`;
|
||||
|
||||
const event = await user.signer.signEvent({
|
||||
kind: COLLECTIVE_JOIN_REQUEST_KIND,
|
||||
content: note.slice(0, 500),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['d', collectiveJoinRequestD(coord, user.pubkey.toLowerCase())],
|
||||
['a', coord],
|
||||
['collective_name', profile.title],
|
||||
['p', profile.pubkey],
|
||||
['alt', `Collective join request: ${profile.title} on SkateSpots`],
|
||||
],
|
||||
});
|
||||
await nostr.event(event, { signal: AbortSignal.timeout(15000) });
|
||||
return event;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['collective-join-requests'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Incoming collective join-requests addressed to the given owner. */
|
||||
export function useIncomingCollectiveJoinRequests(pubkey: string | undefined) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['collective-join-requests', 'incoming', pubkey, getQueryKeySuffix()],
|
||||
enabled: !!pubkey,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: 'always',
|
||||
queryFn: async (): Promise<CollectiveJoinRequest[]> => {
|
||||
if (!pubkey) return [];
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
const events = await querySkateContent(
|
||||
[{ kinds: [COLLECTIVE_JOIN_REQUEST_KIND], '#p': [pubkey], limit: 100 }],
|
||||
{ signal },
|
||||
);
|
||||
const seen = new Map<string, CollectiveJoinRequest>();
|
||||
for (const ev of events) {
|
||||
const req = parseCollectiveJoinRequest(ev);
|
||||
if (!req) continue;
|
||||
if (!req.recipients.includes(pubkey.toLowerCase())) continue;
|
||||
// Dedup by (collective + requester) — newest wins.
|
||||
const key = `${req.collectiveCoord}:${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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the logged-in user already has a pending join-request to a given
|
||||
* collective coordinate (so the button can show "Requested").
|
||||
*/
|
||||
export function useMyCollectiveJoinRequest(collectiveCoord: string | undefined) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['collective-join-requests', 'mine', collectiveCoord, user?.pubkey, getQueryKeySuffix()],
|
||||
enabled: !!collectiveCoord && !!user?.pubkey,
|
||||
staleTime: 30_000,
|
||||
queryFn: async (): Promise<boolean> => {
|
||||
if (!collectiveCoord || !user?.pubkey) return false;
|
||||
const signal = AbortSignal.timeout(8000);
|
||||
const d = collectiveJoinRequestD(collectiveCoord, user.pubkey.toLowerCase());
|
||||
const events = await querySkateContent(
|
||||
[{ kinds: [COLLECTIVE_JOIN_REQUEST_KIND], authors: [user.pubkey], '#d': [d], limit: 1 }],
|
||||
{ signal },
|
||||
);
|
||||
return events.length > 0;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useMyCrewInvites, useIncomingJoinRequests, useJoinRequestEndorsements } from '@/hooks/useCrewInvites';
|
||||
import { useIncomingCollectiveJoinRequests } from '@/hooks/useCollectiveJoinRequests';
|
||||
import { useCrewDismissals } from '@/hooks/useCrewDismissals';
|
||||
import { useCrews } from '@/hooks/useCrews';
|
||||
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
|
||||
import { SKATE_CREW_KIND, type CrewEndorsement, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
|
||||
import { SKATE_CREW_KIND, type CollectiveJoinRequest, type CrewEndorsement, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
|
||||
|
||||
export interface CrewNotification {
|
||||
/** Stable id for the notification (invite id or crew coord). */
|
||||
|
|
@ -30,6 +31,8 @@ export function useCrewNotifications() {
|
|||
const { user } = useCurrentUser();
|
||||
const { data: invites, isLoading: invitesLoading } = useMyCrewInvites(user?.pubkey);
|
||||
const { data: joinRequests, isLoading: joinLoading } = useIncomingJoinRequests(user?.pubkey);
|
||||
const { data: collectiveRequestsRaw, isLoading: collectiveLoading } =
|
||||
useIncomingCollectiveJoinRequests(user?.pubkey);
|
||||
const { data: crews, isLoading: crewsLoading } = useCrews(200);
|
||||
// Cross-device-synced set of dismissed invite/request ids.
|
||||
const { data: dismissed } = useCrewDismissals();
|
||||
|
|
@ -98,12 +101,21 @@ export function useCrewNotifications() {
|
|||
[requestsWithCrew, endorsements],
|
||||
);
|
||||
|
||||
// Incoming Lab-collective join-requests, minus dismissed ones. These are
|
||||
// already p-tagged to (and filtered for) the collective owner upstream.
|
||||
const collectiveRequests = useMemo(() => {
|
||||
const list = (collectiveRequestsRaw ?? []) as CollectiveJoinRequest[];
|
||||
if (!dismissed || dismissed.size === 0) return list;
|
||||
return list.filter((req) => !dismissed.has(req.id));
|
||||
}, [collectiveRequestsRaw, dismissed]);
|
||||
|
||||
return {
|
||||
user,
|
||||
invites: visibleInvites,
|
||||
listedCrews,
|
||||
joinRequests: requestsWithEndorsements,
|
||||
isLoading: invitesLoading || joinLoading || crewsLoading,
|
||||
collectiveRequests,
|
||||
isLoading: invitesLoading || joinLoading || collectiveLoading || crewsLoading,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export const SPOT_STATUS_KIND = 2259; // Regular event - community "spot status
|
|||
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.
|
||||
export const COLLECTIVE_JOIN_REQUEST_KIND = 36387; // Addressable Lab-collective join-request — someone asks to join a Lab collective (studio/build team). Notifiable. SkateSpots-native.
|
||||
|
||||
/**
|
||||
* Lab provider entity type.
|
||||
|
|
@ -365,6 +366,58 @@ export function crewJoinRequestD(crewCoord: string, requesterPubkey: string): st
|
|||
return `${crewCoord}:${requesterPubkey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A join-request to a Lab COLLECTIVE (studio / build team / etc.) — someone
|
||||
* asks to be added to the collective's roster. Mirrors the crew join-request:
|
||||
* addressable (keyed by collective + requester so re-requesting replaces),
|
||||
* `p`-tagged to the collective owner so it surfaces as a notification. The owner
|
||||
* adds the member via their normal Lab-profile editor (only they can edit their
|
||||
* own event), so there's no forged-membership risk.
|
||||
*/
|
||||
export interface CollectiveJoinRequest {
|
||||
id: string;
|
||||
requester: string; // pubkey asking to join
|
||||
collectiveCoord: string; // e.g. 35003:pubkey:d
|
||||
collectiveName: string;
|
||||
note: string; // optional personal message
|
||||
recipients: string[]; // owner pubkey(s) addressed (from `p` tags)
|
||||
createdAt: number;
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/** Lab craft kinds that can be collectives (all of them carry entityType). */
|
||||
const COLLECTIVE_CAPABLE_KINDS = new Set<number>([
|
||||
SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND,
|
||||
SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND,
|
||||
SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND,
|
||||
]);
|
||||
|
||||
export function parseCollectiveJoinRequest(event: NostrEvent): CollectiveJoinRequest | null {
|
||||
if (event.kind !== COLLECTIVE_JOIN_REQUEST_KIND) return null;
|
||||
const collectiveCoord = event.tags.find(([n]) => n === 'a')?.[1];
|
||||
if (!collectiveCoord) return null;
|
||||
const c = parseAddrCoord(collectiveCoord);
|
||||
if (!c || !COLLECTIVE_CAPABLE_KINDS.has(c.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,
|
||||
collectiveCoord,
|
||||
collectiveName: event.tags.find(([n]) => n === 'collective_name')?.[1] ?? 'a collective',
|
||||
note: event.content.slice(0, 500),
|
||||
recipients,
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
/** Deterministic d-tag for a collective join-request: coord + requester. */
|
||||
export function collectiveJoinRequestD(collectiveCoord: string, requesterPubkey: string): string {
|
||||
return `${collectiveCoord}:${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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
||||
import { RequestToJoinCollective } from '@/components/RequestToJoinCollective';
|
||||
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||
import { VouchCard } from '@/components/VouchCard';
|
||||
import { LinkedCrewCard } from '@/components/CrossLinkCards';
|
||||
|
|
@ -619,6 +620,16 @@ export default function CoachDetailPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Request to join (collectives only) — shown to non-owner,
|
||||
non-member skaters; notifies the owner via their bell. */}
|
||||
{isCollective && (
|
||||
<RequestToJoinCollective
|
||||
profile={profile}
|
||||
accentText={accent.text}
|
||||
accentBorder={accent.border}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Linked crew (cross-link) — pass this profile's own coord so a
|
||||
mutual link shows a "Verified" badge. */}
|
||||
<LinkedCrewCard
|
||||
|
|
|
|||
Loading…
Reference in a new issue