-
-
-
-
-
+ {/* ββ Send a structured invite to an npub ββ */}
+
+
+
+
setInviteeInput(e.target.value)}
+ placeholder="npub1β¦ or nprofile1β¦"
+ className={cn('font-mono text-xs', invalidKey && 'border-red-500/50')}
+ autoComplete="off"
+ spellCheck={false}
+ />
+ {invalidKey && (
+
That doesn't look like a valid npub.
+ )}
-
- {/* Founder-only: add member directly via edit */}
- {isFounder && (
-
-
- As founder you can also add a member's key directly, then they confirm.
-
-
-
+
+
+
+
+
+ copy(inviteMessage, 'msg')}>
+ {copiedMsg ? : }
+ Copy message
+
+
+ Share
+
+
+
+
+ {isFounder && (
+
+
+ As founder you can also add a member's key directly, then they confirm.
+
+
+
+ Add member in crew settings
+
+
+
+ )}
+
+
);
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 06a0518..d9123af 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -60,6 +60,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { NewsletterOverlay } from '@/components/NewsletterOverlay';
+import { NotificationBell } from '@/components/NotificationBell';
interface LayoutProps {
children: React.ReactNode;
@@ -582,6 +583,8 @@ export function Layout({ children, noPadding }: LayoutProps) {
Status
)}
+ {/* Notifications (crew invites & membership requests) */}
+ {user &&
}
{/* Offline Indicator */}
{/* Language Toggle */}
@@ -624,6 +627,8 @@ export function Layout({ children, noPadding }: LayoutProps) {
+ {/* Notifications (crew invites & membership requests) */}
+ {user &&
}
{/* User menu when logged in / Login button when not */}
{user ? (
void }) {
+ const isPending = useIsPendingMember(crew);
+ const founder = useAuthor(crew.pubkey);
+ const founderName = founder.data?.metadata?.name ?? genUserName(crew.pubkey);
+ const founderPic = founder.data?.metadata?.picture;
+ const { toast } = useToast();
+ const { mutateAsync: confirm, isPending: confirming } = useConfirmMembership();
+ const [visibility, setVisibility] = useState('public');
+ const [done, setDone] = useState(false);
+
+ // Only actionable while the member is genuinely pending confirmation.
+ if (!isPending || done) return null;
+
+ const handleConfirm = async () => {
+ try {
+ await confirm({
+ kind: SKATE_CREW_KIND,
+ founderPubkey: crew.pubkey,
+ identifier: crew.identifier,
+ collectiveName: crew.name,
+ visibility,
+ });
+ setDone(true);
+ toast({ title: `You're in ${crew.name}! πΉ`, description: 'Membership confirmed.' });
+ onResolved?.();
+ } catch (err) {
+ toast({
+ title: 'Could not confirm',
+ description: err instanceof Error ? err.message : 'Please try again.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {founderName.slice(0, 2).toUpperCase()}
+
+ {founderName}
+ {' '}
+ added you to{' '}
+
+ {crew.name}
+
+ . Confirm to appear as a member.
+
+
+ {/* Visibility toggle */}
+
+ {([
+ { key: 'public', label: 'Public', icon: Globe },
+ { key: 'crew', label: 'Crew only', icon: EyeOff },
+ ] as const).map(({ key, label, icon: Icon }) => {
+ const active = visibility === key;
+ return (
+ setVisibility(key)}
+ className={cn(
+ 'flex items-center justify-center gap-1 rounded-lg border px-2 py-1.5 text-xs font-bold transition-all active:scale-95',
+ active
+ ? 'border-transparent bg-teal-500 text-black'
+ : 'border-white/10 bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white',
+ )}
+ >
+
+ {label}
+
+ );
+ })}
+
+
+
+ {confirming ? : }
+ Confirm membership
+
+
+
+
+ );
+}
+
+/**
+ * Header notification bell. Surfaces actionable crew notifications:
+ * - structured crew invites (accept / decline)
+ * - "pending confirmation" crews (someone listed you β confirm to join)
+ *
+ * Shows an unread count badge. Only renders when logged in.
+ */
+export function NotificationBell({ className }: { className?: string }) {
+ const [open, setOpen] = useState(false);
+ const { user, invites, listedCrews, isLoading } = useCrewNotifications();
+
+ // The bell only makes sense when logged in.
+ const total = useMemo(() => invites.length + listedCrews.length, [invites, listedCrews]);
+
+ if (!user) return null;
+
+ return (
+
+
+ 0 ? ` (${total} new)` : ''}`}
+ >
+
+ {total > 0 && (
+
+ {total > 9 ? '9+' : total}
+
+ )}
+
+
+
+
+
+ Notifications
+
+ {total > 0 && (
+
+ {total} new
+
+ )}
+
+
+
+
+ {isLoading && total === 0 && (
+
+ Checking for updatesβ¦
+
+ )}
+
+ {!isLoading && total === 0 && (
+
+
+
+
+
You're all caught up
+
+ Crew invites and membership requests will show up here.
+
+
+ )}
+
+ {invites.map((invite) => (
+
+ ))}
+
+ {listedCrews.map((crew) => (
+
+ ))}
+
+
+
+
+ setOpen(false)}
+ >
+ Browse all crews
+
+
+
+
+ );
+}
diff --git a/src/hooks/useCrewInvites.ts b/src/hooks/useCrewInvites.ts
new file mode 100644
index 0000000..b0b21cf
--- /dev/null
+++ b/src/hooks/useCrewInvites.ts
@@ -0,0 +1,232 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useNostr } from '@nostrify/react';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useSkateRelays } from '@/hooks/useSkateRelays';
+import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
+import {
+ CREW_INVITE_KIND,
+ SKATE_CREW_KIND,
+ crewInviteD,
+ parseCrewInvite,
+ parseAddrCoord,
+ type CrewInvite,
+ type SkateCrew,
+ type MemberVisibility,
+} from '@/lib/types';
+import type { NostrEvent } from '@nostrify/nostrify';
+
+// βββ Local dismissals βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// The invitee can't delete an invite they didn't author, so accepted/declined
+// invites are cleared from *their* notifications via a local dismissal set.
+// (The public invite event still exists on relays; it just stops nagging them.)
+
+const DISMISS_KEY = 'skatespots:dismissed-crew-invites';
+
+function readDismissed(): Set {
+ try {
+ const raw = localStorage.getItem(DISMISS_KEY);
+ return new Set(raw ? (JSON.parse(raw) as string[]) : []);
+ } catch {
+ return new Set();
+ }
+}
+
+function dismissInvite(inviteId: string) {
+ try {
+ const set = readDismissed();
+ set.add(inviteId);
+ localStorage.setItem(DISMISS_KEY, JSON.stringify([...set]));
+ } catch {
+ // ignore storage errors
+ }
+}
+
+// βββ Sending an invite ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+interface SendInviteParams {
+ crew: SkateCrew;
+ /** Invitee pubkey (hex). */
+ invitee: string;
+ role?: 'member' | 'admin';
+ note?: string;
+ /** Whether the current user is the crew founder (can also add the p-tag). */
+ isFounder: boolean;
+}
+
+/**
+ * Send a structured crew invite to a specific pubkey.
+ *
+ * The invite is a public, addressable event (kind 36229) that `p`-tags the
+ * invitee so they can query "invites addressed to me". It carries nothing
+ * sensitive β real membership stays unforgeable (founder `p` tag + invitee
+ * attestation).
+ *
+ * When the sender is the FOUNDER, we optimistically add the invitee to the
+ * crew's member (`p`) list at invite time, so the moment they accept (attest)
+ * they show as a confirmed member with no extra founder step. When an ADMIN
+ * invites, they can't edit the founder's event, so the invite alone is sent and
+ * the founder confirms the add on accept.
+ */
+export function useSendCrewInvite() {
+ const { nostr } = useNostr();
+ const { user } = useCurrentUser();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ crew, invitee, role = 'member', note = '', isFounder }: SendInviteParams) => {
+ if (!user) throw new Error('You must be logged in to invite.');
+ const crewCoord = `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`;
+ const inviteePk = invitee.toLowerCase();
+
+ if (inviteePk === user.pubkey.toLowerCase()) {
+ throw new Error("You can't invite yourself.");
+ }
+
+ // 1. Publish the invite event.
+ const inviteEvent = await user.signer.signEvent({
+ kind: CREW_INVITE_KIND,
+ content: note.slice(0, 500),
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [
+ ['d', crewInviteD(crewCoord, inviteePk)],
+ ['p', inviteePk],
+ ['a', crewCoord],
+ ['crew_name', crew.name],
+ ['role', role],
+ ['alt', `Crew invite: join ${crew.name} on SkateSpots`],
+ ],
+ });
+ await nostr.event(inviteEvent, { signal: AbortSignal.timeout(15000) });
+
+ // 2. Founder optimistically pre-lists the invitee so accept = instant confirm.
+ if (isFounder && user.pubkey.toLowerCase() === crew.pubkey.toLowerCase()) {
+ const already = crew.members.some((m) => m.toLowerCase() === inviteePk);
+ if (!already) {
+ const updated = republishCrewWithMember(crew.event, inviteePk, role);
+ const signed = await user.signer.signEvent(updated);
+ await nostr.event(signed, { signal: AbortSignal.timeout(15000) });
+ }
+ }
+
+ return inviteEvent;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['skate-crews'] });
+ queryClient.invalidateQueries({ queryKey: ['skate-crew'] });
+ queryClient.invalidateQueries({ queryKey: ['crew-invites'] });
+ },
+ });
+}
+
+/**
+ * Rebuild a crew event's tags with an added member `p` tag, preserving all
+ * other tags. Returns an unsigned event template ready to sign.
+ */
+function republishCrewWithMember(
+ crewEvent: NostrEvent,
+ memberPubkey: string,
+ role: 'member' | 'admin',
+): { kind: number; content: string; created_at: number; tags: string[][] } {
+ const tags = crewEvent.tags.filter(
+ ([n, v]) => !(n === 'p' && v?.toLowerCase() === memberPubkey.toLowerCase()),
+ );
+ if (role === 'admin') tags.push(['p', memberPubkey, '', 'admin']);
+ else tags.push(['p', memberPubkey]);
+
+ return {
+ kind: crewEvent.kind,
+ content: crewEvent.content,
+ created_at: Math.floor(Date.now() / 1000),
+ tags,
+ };
+}
+
+// βββ Reading invites addressed to me βββββββββββββββββββββββββββββββββββββββββ
+
+/** Live crew invites where the given pubkey is the invitee (excludes locally dismissed). */
+export function useMyCrewInvites(pubkey: string | undefined) {
+ const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
+
+ return useQuery({
+ queryKey: ['crew-invites', 'incoming', pubkey, getQueryKeySuffix()],
+ enabled: !!pubkey,
+ staleTime: 30_000,
+ refetchOnMount: 'always',
+ queryFn: async (): Promise => {
+ if (!pubkey) return [];
+ const signal = AbortSignal.timeout(10000);
+ const events = await querySkateContent(
+ [{ kinds: [CREW_INVITE_KIND], '#p': [pubkey], limit: 100 }],
+ { signal },
+ );
+
+ const dismissed = readDismissed();
+ const seen = new Map();
+ for (const ev of events) {
+ const inv = parseCrewInvite(ev);
+ if (!inv || inv.invitee.toLowerCase() !== pubkey.toLowerCase()) continue;
+ if (dismissed.has(inv.id)) continue;
+ // Dedup by crew coord (addressable β newest wins).
+ const prev = seen.get(inv.crewCoord);
+ if (!prev || inv.createdAt > prev.createdAt) seen.set(inv.crewCoord, inv);
+ }
+ return [...seen.values()].sort((a, b) => b.createdAt - a.createdAt);
+ },
+ });
+}
+
+// βββ Accept / decline βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+/**
+ * Accept a crew invite: publish the invitee's own NIP-32 membership attestation
+ * (kind 1985). Once the founder has listed them (founders pre-list on invite),
+ * they show as a confirmed member. The invite is dismissed locally.
+ */
+export function useAcceptCrewInvite() {
+ const { user } = useCurrentUser();
+ const queryClient = useQueryClient();
+ const { mutateAsync: confirm } = useConfirmMembership();
+
+ return useMutation({
+ mutationFn: async ({
+ invite,
+ visibility = 'public',
+ }: {
+ invite: CrewInvite;
+ visibility?: MemberVisibility;
+ }) => {
+ if (!user) throw new Error('You must be logged in.');
+ const coord = parseAddrCoord(invite.crewCoord);
+ if (!coord) throw new Error('Invalid crew reference.');
+
+ await confirm({
+ kind: coord.kind,
+ founderPubkey: coord.pubkey,
+ identifier: coord.identifier,
+ collectiveName: invite.crewName,
+ visibility,
+ });
+
+ dismissInvite(invite.id);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['crew-invites'] });
+ queryClient.invalidateQueries({ queryKey: ['collective-members'] });
+ queryClient.invalidateQueries({ queryKey: ['skate-crews'] });
+ },
+ });
+}
+
+/** Decline a crew invite β dismiss it locally so it clears from notifications. */
+export function useDeclineCrewInvite() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ invite }: { invite: CrewInvite }) => {
+ dismissInvite(invite.id);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['crew-invites'] });
+ },
+ });
+}
diff --git a/src/hooks/useCrewNotifications.ts b/src/hooks/useCrewNotifications.ts
new file mode 100644
index 0000000..3896812
--- /dev/null
+++ b/src/hooks/useCrewNotifications.ts
@@ -0,0 +1,68 @@
+import { useMemo } from 'react';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useMyCrewInvites } from '@/hooks/useCrewInvites';
+import { useCrews } from '@/hooks/useCrews';
+import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
+import { SKATE_CREW_KIND, type CrewInvite, type SkateCrew } from '@/lib/types';
+
+export interface CrewNotification {
+ /** Stable id for the notification (invite id or crew coord). */
+ id: string;
+ type: 'invite' | 'pending-confirm';
+ crew?: SkateCrew;
+ invite?: CrewInvite;
+ createdAt: number;
+}
+
+/**
+ * The logged-in user's actionable crew notifications:
+ *
+ * - `invite`: a structured crew invite addressed to them (kind 36229).
+ * - `pending-confirm`: a crew that has LISTED them as a member but which they
+ * haven't confirmed yet (they were added directly, e.g.
+ * via the founder editor before invites existed).
+ *
+ * Both resolve to the same "join this crew" action, so the notification center
+ * can render them uniformly.
+ */
+export function useCrewNotifications() {
+ const { user } = useCurrentUser();
+ const { data: invites, isLoading: invitesLoading } = useMyCrewInvites(user?.pubkey);
+ const { data: crews, isLoading: crewsLoading } = useCrews(200);
+
+ // Crews that list me as a member (candidates for "pending confirmation").
+ const listedCrews = useMemo(() => {
+ if (!user || !crews) return [];
+ return crews.filter(
+ (c) =>
+ c.pubkey.toLowerCase() !== user.pubkey.toLowerCase() &&
+ c.members.some((m) => m.toLowerCase() === user.pubkey.toLowerCase()),
+ );
+ }, [user, crews]);
+
+ return {
+ user,
+ invites: invites ?? [],
+ listedCrews,
+ isLoading: invitesLoading || crewsLoading,
+ };
+}
+
+/**
+ * Resolve whether the logged-in user has CONFIRMED membership in a listed crew.
+ * Used per-crew to decide if a "pending confirm" notification is still relevant.
+ */
+export function useIsPendingMember(crew: SkateCrew | undefined) {
+ const { user } = useCurrentUser();
+ const { data: members } = useCollectiveMembers({
+ kind: SKATE_CREW_KIND,
+ founderPubkey: crew?.pubkey ?? '',
+ identifier: crew?.identifier ?? '',
+ claimedMembers: crew?.members ?? [],
+ enabled: !!crew && (crew.members.length ?? 0) > 0,
+ });
+
+ if (!user || !crew) return false;
+ const me = members?.find((m) => m.pubkey.toLowerCase() === user.pubkey.toLowerCase());
+ return me?.status === 'pending';
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 107332a..8f6da73 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -64,6 +64,7 @@ export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to sho
export const SPOT_CHAT_KIND = 24899; // Ephemeral geohash-anchored local chat ("Spot Chat"). 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 CREW_INVITE_KIND = 36229; // Addressable crew invitation β founder/admin invites a specific npub to a crew. SkateSpots-native.
/**
* Lab provider entity type.
@@ -257,6 +258,62 @@ export function parseLabRefs(event: NostrEvent): string[] {
return refs;
}
+// βββ Crew invitations (kind 36229) βββββββββββββββββββββββββββββββββββββββββ
+//
+// A structured, addressable invite from a crew's founder/admin to a specific
+// person's pubkey. Addressable so re-inviting the same person replaces the old
+// invite (one live invite per crew+invitee). The invite itself is public and
+// carries nothing sensitive ("come join") β actual membership stays unforgeable
+// via the founder's `p` tag + the invitee's own NIP-32 attestation.
+//
+// kind: 36229
+// content: optional personal note from the inviter
+// tags:
+// ["d", ":"] // unique per crew+invitee
+// ["p", ""] // recipient β queryable by them
+// ["a", ""] // the crew (35006:pubkey:d)
+// ["crew_name", ""] // denormalized for notification UI
+// ["role", "member" | "admin"] // proposed role (default member)
+// ["alt", "Crew invite: β¦"]
+
+export interface CrewInvite {
+ id: string;
+ inviter: string; // pubkey of founder/admin who sent it
+ invitee: string; // pubkey being invited
+ crewCoord: string; // 35006:pubkey:d
+ crewName: string;
+ role: 'member' | 'admin';
+ note: string; // optional personal message
+ createdAt: number;
+ event: NostrEvent;
+}
+
+export function parseCrewInvite(event: NostrEvent): CrewInvite | null {
+ if (event.kind !== CREW_INVITE_KIND) return null;
+ const invitee = event.tags.find(([n]) => n === 'p')?.[1];
+ const crewCoord = event.tags.find(([n]) => n === 'a')?.[1];
+ if (!invitee || !crewCoord) return null;
+ const c = parseAddrCoord(crewCoord);
+ if (!c || c.kind !== SKATE_CREW_KIND) return null;
+ const role = event.tags.find(([n]) => n === 'role')?.[1] === 'admin' ? 'admin' : 'member';
+ return {
+ id: event.id,
+ inviter: event.pubkey,
+ invitee,
+ crewCoord,
+ crewName: event.tags.find(([n]) => n === 'crew_name')?.[1] ?? 'a crew',
+ role,
+ note: event.content.slice(0, 500),
+ createdAt: event.created_at,
+ event,
+ };
+}
+
+/** Build the deterministic d-tag identifier for a crew invite. */
+export function crewInviteD(crewCoord: string, inviteePubkey: string): string {
+ return `${crewCoord}:${inviteePubkey}`;
+}
+
/**
* Validate a CLINK Offer code (noffer1...) β a Nostr-native, self-custodial
* static payment code (see https://github.com/shocknet/clink, kind 21001).
diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx
index 2d05ae4..25b1b75 100644
--- a/src/pages/CrewDetailPage.tsx
+++ b/src/pages/CrewDetailPage.tsx
@@ -281,9 +281,7 @@ export default function CrewDetailPage() {
{canManage && (
)}