diff --git a/NIP.md b/NIP.md index 9b1e795..f94b3b0 100644 --- a/NIP.md +++ b/NIP.md @@ -1784,6 +1784,39 @@ also a confirmed member is treated as a plain (pending) member until it attests. > model (like GitHub orgs, Discord, and NIP-72 communities): the founder owns > the record, admins wield delegated powers with their own keys. +#### Feed Moderation (NIP-32 `hidden` labels) + +Moderators (founder + admins) can hide a post from the **crew feed** by +publishing a kind `1985` label with their **own key**: + +```json +{ + "kind": 1985, + "tags": [ + ["L", "org.skatespots.moderation"], + ["l", "hidden", "org.skatespots.moderation"], + ["e", ""], + ["a", "35006::"], + ["p", ""], + ["alt", "Crew moderation: post hidden"] + ] +} +``` + +A client hides a post only when a matching label's **author is the crew founder +or a listed admin** — so outsiders can't forge moderation. This hides the post +from *this crew's feed only*; the underlying note remains on Nostr. Moderators +still see hidden posts (greyed, marked "Hidden from feed") so the action is +reversible/auditable. + +#### Invites + +Because only the founder can write the crew's `p` (member) list, an admin cannot +silently add members. Every moderator can **invite** — share a link/message to +the crew page where the invitee, once listed by the founder, confirms membership +with their own key (the standard bilateral attestation above). Founders get an +extra shortcut to add a member key directly in crew settings. + ### NIP-05 Verification The optional `nip05` tag is a crew handle on **any domain the crew controls** diff --git a/src/components/CrewInviteDialog.tsx b/src/components/CrewInviteDialog.tsx new file mode 100644 index 0000000..28c9c8f --- /dev/null +++ b/src/components/CrewInviteDialog.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { UserPlus, Copy, Check, Share2, Edit } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { useToast } from '@/hooks/useToast'; +import { cn } from '@/lib/utils'; + +interface CrewInviteDialogProps { + crewName: string; + founderPubkey: string; + identifier: string; + /** Whether the viewer is the founder (gets the extra "add member" edit link). */ + isFounder: boolean; + triggerClassName?: string; +} + +/** + * Invite flow for crew moderators (founder + admins). + * + * On Nostr, only the founder can write the crew's member (`p`) list, so an admin + * can't silently add someone. What every moderator CAN do is invite: share a + * link/message that brings the invitee to the crew page, where — once the + * founder lists them — they confirm membership with their own key. + * + * Founders additionally get a direct "add member" shortcut to the edit form. + */ +export function CrewInviteDialog({ + crewName, + founderPubkey, + identifier, + isFounder, + triggerClassName, +}: CrewInviteDialogProps) { + const { toast } = useToast(); + const [copiedLink, setCopiedLink] = useState(false); + const [copiedMsg, setCopiedMsg] = useState(false); + + const crewUrl = `${window.location.origin}/crew/${founderPubkey}/${identifier}`; + const inviteMessage = + `Yo — join the ${crewName} crew on SkateSpots 🛹\n${crewUrl}\n\n` + + `Log in with your Nostr key, then confirm your membership on the crew page.`; + + const copy = async (text: string, which: 'link' | 'msg') => { + try { + await navigator.clipboard.writeText(text); + if (which === 'link') { + setCopiedLink(true); + setTimeout(() => setCopiedLink(false), 2000); + } else { + setCopiedMsg(true); + setTimeout(() => setCopiedMsg(false), 2000); + } + toast({ title: 'Copied!' }); + } catch { + toast({ title: 'Copy failed', variant: 'destructive' }); + } + }; + + const nativeShare = async () => { + if (navigator.share) { + try { + await navigator.share({ title: `Join ${crewName}`, text: inviteMessage, url: crewUrl }); + } catch { + /* cancelled */ + } + } else { + copy(inviteMessage, 'msg'); + } + }; + + return ( + + + + + + + + Invite to {crewName} + + + Share this with someone you want in the crew. They'll land on the crew page to confirm. + + + +
+ {/* Invite link */} +
+ +
+ e.target.select()} /> + +
+
+ + {/* Invite message */} +
+ +