Add crew admin moderation + invite actions

- useCrewModeration: fetch NIP-32 `hidden` labels authored by founder+admins for a crew; useHideCrewPost publishes a kind-1985 hidden label (L/l org.skatespots.moderation, e=post, a=crew)
- CrewDetailPage feed: moderators can hide posts via an EyeOff button + confirm dialog; hidden posts filtered from the feed for everyone, shown greyed/"Hidden from feed" to moderators (reversible/auditable)
- CrewInviteDialog: founder+admins share an invite link/message (native share + copy); founder gets a shortcut to add a member key in crew settings
- Gate all admin actions on useCrewRole().canManage; moderators = founder + delegated admins
- Add MODERATION_LABEL_NAMESPACE/HIDDEN constants
- NIP.md: document feed moderation label format (author must be founder/admin) + invite semantics

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 17:01:28 +07:00
parent 0078f75f94
commit f5483a0dbc
5 changed files with 427 additions and 10 deletions

33
NIP.md
View file

@ -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", "<hiddenPostId>"],
["a", "35006:<founder>:<crewDtag>"],
["p", "<postAuthor>"],
["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**

View file

@ -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 (
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn('gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10', triggerClassName)}
>
<UserPlus className="h-3.5 w-3.5" /> Invite
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-teal-400" /> Invite to {crewName}
</DialogTitle>
<DialogDescription>
Share this with someone you want in the crew. They'll land on the crew page to confirm.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Invite link */}
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">Invite link</label>
<div className="flex gap-2">
<Input readOnly value={crewUrl} className="font-mono text-xs" onFocus={(e) => e.target.select()} />
<Button type="button" variant="outline" size="icon" className="shrink-0" onClick={() => copy(crewUrl, 'link')}>
{copiedLink ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
{/* Invite message */}
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">Message</label>
<Textarea readOnly value={inviteMessage} rows={4} className="text-sm" />
<div className="flex gap-2">
<Button type="button" variant="outline" className="flex-1 gap-1.5" onClick={() => copy(inviteMessage, 'msg')}>
{copiedMsg ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
Copy message
</Button>
<Button type="button" className="flex-1 gap-1.5 bg-teal-500 hover:bg-teal-600 text-black" onClick={nativeShare}>
<Share2 className="h-4 w-4" /> Share
</Button>
</div>
</div>
{/* Founder-only: add member directly via edit */}
{isFounder && (
<div className="rounded-lg border border-teal-500/30 bg-teal-500/5 p-3">
<p className="mb-2 text-xs text-gray-400">
As founder you can also add a member's key directly, then they confirm.
</p>
<Link to={`/edit-crew/${founderPubkey}/${identifier}`}>
<Button type="button" variant="outline" size="sm" className="w-full gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
<Edit className="h-3.5 w-3.5" /> Add member in crew settings
</Button>
</Link>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,108 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import {
MODERATION_LABEL_NAMESPACE,
MODERATION_LABEL_HIDDEN,
} from '@/lib/types';
interface UseCrewModerationParams {
/** Crew addressable coordinate `kind:pubkey:d`. */
crewCoord: string;
/** Pubkeys allowed to moderate: the founder + delegated admins. */
moderators: string[];
enabled?: boolean;
}
/**
* Fetch the set of crew-feed post IDs hidden by the crew's moderators
* (founder + admins) via NIP-32 `hidden` labels.
*
* Only labels authored by a listed moderator are honored, so a random user
* can't hide someone else's post.
*/
export function useCrewModeration({
crewCoord,
moderators,
enabled = true,
}: UseCrewModerationParams) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['crew-moderation', crewCoord, moderators.slice().sort().join(','), getQueryKeySuffix()],
queryFn: async (): Promise<Set<string>> => {
if (moderators.length === 0) return new Set<string>();
const signal = AbortSignal.timeout(8000);
const events = await querySkateContent(
[
{
kinds: [1985],
authors: moderators,
'#a': [crewCoord],
'#l': [MODERATION_LABEL_HIDDEN],
limit: 200,
},
],
{ signal },
);
const modSet = new Set(moderators.map((m) => m.toLowerCase()));
const hidden = new Set<string>();
for (const ev of events) {
// Validate namespace + label + moderator authorship.
const hasNamespace = ev.tags.some(
([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE,
);
const hasLabel = ev.tags.some(
([n, v]) => n === 'l' && v === MODERATION_LABEL_HIDDEN,
);
const refsCrew = ev.tags.some(([n, v]) => n === 'a' && v === crewCoord);
if (!hasNamespace || !hasLabel || !refsCrew) continue;
if (!modSet.has(ev.pubkey.toLowerCase())) continue;
for (const [n, v] of ev.tags) {
if (n === 'e' && v) hidden.add(v);
}
}
return hidden;
},
enabled: enabled && moderators.length > 0 && !!crewCoord,
staleTime: 60 * 1000,
});
}
interface HidePostVars {
postId: string;
/** Author of the post being hidden (added as a `p` hint). */
postAuthor?: string;
}
/**
* Publish a NIP-32 `hidden` moderation label for a crew-feed post.
* The caller must be a crew moderator (gate on `useCrewRole().canManage`).
*/
export function useHideCrewPost(crewCoord: string) {
const { mutateAsync: publish } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ postId, postAuthor }: HidePostVars) => {
const tags: string[][] = [
['L', MODERATION_LABEL_NAMESPACE],
['l', MODERATION_LABEL_HIDDEN, MODERATION_LABEL_NAMESPACE],
['e', postId],
['a', crewCoord],
['alt', 'Crew moderation: post hidden'],
];
if (postAuthor) tags.push(['p', postAuthor]);
await publish({ kind: 1985, content: '', tags });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-moderation', crewCoord] });
},
});
}

View file

@ -78,6 +78,21 @@ export function parseEntityType(value: string | undefined | null): EntityType {
export const MEMBERSHIP_LABEL_NAMESPACE = 'org.skatespots.membership';
export const MEMBERSHIP_LABEL_VALUE = 'member';
/**
* Crew feed/chat moderation (NIP-32).
*
* A founder or delegated admin can hide a crew-feed post by publishing a kind
* 1985 label with their OWN key:
* ["L", "org.skatespots.moderation"]
* ["l", "hidden", "org.skatespots.moderation"]
* ["e", <postId>] // the moderated post
* ["a", <crewCoord>] // scopes moderation to this crew
* Clients hide the post only if the label's author is the crew founder or a
* listed admin — so moderation power can't be forged by outsiders.
*/
export const MODERATION_LABEL_NAMESPACE = 'org.skatespots.moderation';
export const MODERATION_LABEL_HIDDEN = 'hidden';
/** Extract member pubkeys (hex) claimed by a Lab profile via `p` tags. */
export function parseMemberPubkeys(event: NostrEvent): string[] {
return event.tags

View file

@ -15,8 +15,22 @@ import { CrewNip05Badge } from '@/components/CrewNip05Badge';
import { CrewChat } from '@/components/CrewChat';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { LinkedLabCard } from '@/components/CrossLinkCards';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { useCrewRole } from '@/hooks/useCrewRole';
import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration';
import { CrewInviteDialog } from '@/components/CrewInviteDialog';
import { useToast } from '@/hooks/useToast';
import { NoteContent } from '@/components/NoteContent';
import { useCrew } from '@/hooks/useCrews';
import { useCrewFeed } from '@/hooks/useCrewFeed';
@ -24,11 +38,22 @@ import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, CREW_VIBE_LABELS } from '@/lib/types';
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import NotFound from './NotFound';
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck } from 'lucide-react';
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck, EyeOff } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
function FeedItem({ event }: { event: NostrEvent }) {
function FeedItem({
event,
canModerate,
onHide,
isHiding,
}: {
event: NostrEvent;
canModerate?: boolean;
onHide?: (event: NostrEvent) => void;
isHiding?: boolean;
}) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(event.pubkey);
@ -49,6 +74,41 @@ function FeedItem({ event }: { event: NostrEvent }) {
<span className="ml-auto text-[11px] text-gray-500">
{new Date(event.created_at * 1000).toLocaleDateString()}
</span>
{canModerate && onHide && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-gray-500 hover:text-red-400"
disabled={isHiding}
aria-label="Hide this post"
title="Hide from crew feed (admin)"
>
<EyeOff className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hide this post?</AlertDialogTitle>
<AlertDialogDescription>
As a crew moderator you can hide this post from the crew feed for everyone.
The original note stays on Nostr — this only removes it from this crew's feed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onHide(event)}
className="bg-red-600 hover:bg-red-700"
>
Hide post
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<div className="whitespace-pre-wrap break-words text-sm text-gray-300">
<NoteContent event={event} className="text-sm" />
@ -61,7 +121,8 @@ export default function CrewDetailPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: crew, isLoading, error } = useCrew(identifier || '', pubkey);
const author = useAuthor(crew?.pubkey);
const { isFounder, isAdmin, role } = useCrewRole(crew);
const { isFounder, isAdmin, role, canManage } = useCrewRole(crew);
const { toast } = useToast();
useSeoMeta({
title: crew ? `${crew.name} - SkateSpots Crew` : 'Crew - SkateSpots',
@ -72,6 +133,29 @@ export default function CrewDetailPage() {
const feedAuthors = crew ? [crew.pubkey, ...crew.members] : [];
const { data: feed, isLoading: feedLoading } = useCrewFeed(feedAuthors);
// Moderation — founder + admins can hide feed posts (NIP-32 labels).
const crewCoord = crew ? `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}` : '';
const moderators = crew ? Array.from(new Set([crew.pubkey, ...crew.admins])) : [];
const { data: hiddenIds } = useCrewModeration({
crewCoord,
moderators,
enabled: !!crew,
});
const { mutateAsync: hidePost, isPending: isHiding } = useHideCrewPost(crewCoord);
const handleHidePost = async (ev: NostrEvent) => {
try {
await hidePost({ postId: ev.id, postAuthor: ev.pubkey });
toast({ title: 'Post hidden', description: 'Removed from the crew feed for everyone.' });
} catch (err) {
toast({
title: 'Failed to hide post',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
// Confirmed members (for "Zap the Crew" — only pay people who vouched in)
const { data: memberStatuses } = useCollectiveMembers({
kind: SKATE_CREW_KIND,
@ -169,6 +253,14 @@ export default function CrewDetailPage() {
</div>
</div>
<div className="flex flex-col items-end gap-2">
{canManage && (
<CrewInviteDialog
crewName={crew.name}
founderPubkey={crew.pubkey}
identifier={crew.identifier}
isFounder={isFounder}
/>
)}
{isFounder && (
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
@ -221,13 +313,35 @@ export default function CrewDetailPage() {
<Skeleton className="h-4 w-3/4" />
</div>
))
) : feed && feed.length > 0 ? (
feed.map((ev) => <FeedItem key={ev.id} event={ev} />)
) : (
<p className="text-sm text-gray-500 py-4 text-center">
No recent posts from the crew yet.
</p>
)}
) : (() => {
// Moderators still see hidden posts (greyed via the badge on hide),
// but for everyone the hidden set is filtered out.
const visible = (feed ?? []).filter(
(ev) => canManage || !hiddenIds?.has(ev.id),
);
if (visible.length === 0) {
return (
<p className="text-sm text-gray-500 py-4 text-center">
No recent posts from the crew yet.
</p>
);
}
return visible.map((ev) => (
<div key={ev.id} className={cn(hiddenIds?.has(ev.id) && 'opacity-50')}>
{hiddenIds?.has(ev.id) && (
<p className="mb-1 flex items-center gap-1 text-[11px] font-bold uppercase text-red-400">
<EyeOff className="h-3 w-3" /> Hidden from feed
</p>
)}
<FeedItem
event={ev}
canModerate={canManage && !hiddenIds?.has(ev.id)}
onHide={handleHidePost}
isHiding={isHiding}
/>
</div>
));
})()}
</CardContent>
</Card>