From a0c59375ed93c974527b6aa48436ee3023bc0c13 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 17:08:18 +0700 Subject: [PATCH] Add crew chat moderation (client-side hide for E2EE group DMs) - CrewChat: founder+admins can hide chat messages via a per-message hide button; reuses useCrewModeration/useHideCrewPost with the rumor (kind-14) message id - Hidden messages filtered from chat for everyone; moderators see them greyed + "Hidden" (transparent/reversible) - Since NIP-17 messages are E2EE and can't be deleted from relays, moderation is enforced client-side via public NIP-32 labels (label reveals only the hidden id, never content) - Wire admins/crewCoord/canModerate from CrewDetailPage; header hint for moderators - NIP.md: expand moderation section to cover both feed (kind-1 note id) and chat (kind-14 rumor id) with the E2EE client-side caveat Co-authored-by: shakespeare.diy --- NIP.md | 26 ++++++----- src/components/CrewChat.tsx | 84 +++++++++++++++++++++++++++++++++--- src/pages/CrewDetailPage.tsx | 3 ++ 3 files changed, 97 insertions(+), 16 deletions(-) diff --git a/NIP.md b/NIP.md index f94b3b0..666bedf 100644 --- a/NIP.md +++ b/NIP.md @@ -1784,10 +1784,10 @@ 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) +#### 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**: +Moderators (founder + admins) can hide an item from the **crew feed** *or* the +**crew chat** by publishing a kind `1985` label with their **own key**: ```json { @@ -1795,19 +1795,25 @@ publishing a kind `1985` label with their **own key**: "tags": [ ["L", "org.skatespots.moderation"], ["l", "hidden", "org.skatespots.moderation"], - ["e", ""], + ["e", ""], ["a", "35006::"], - ["p", ""], + ["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. +- **Feed** (public kind-1 notes): `e` is the note id. Hiding removes it from + *this crew's feed only*; the note remains on Nostr. +- **Chat** (NIP-17 E2EE group DMs): `e` is the rumor (kind-14) message id. + Because chat is end-to-end encrypted, messages **cannot be deleted** from + relays — moderation is enforced **client-side**: every member's client hides a + message carried by a valid moderator label. The label is public and reveals + only that *a* message id was hidden, never its content. + +A client honors a label only when its **author is the crew founder or a listed +admin**, so outsiders can't forge moderation. Moderators still see hidden items +(greyed, marked "Hidden") so the action is transparent and reversible. #### Invites diff --git a/src/components/CrewChat.tsx b/src/components/CrewChat.tsx index 89d4937..31ab37f 100644 --- a/src/components/CrewChat.tsx +++ b/src/components/CrewChat.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { formatDistanceToNow } from 'date-fns'; -import { Lock, Send, Loader2, ShieldCheck, MessageSquare } from 'lucide-react'; +import { Lock, Send, Loader2, ShieldCheck, MessageSquare, EyeOff } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -20,6 +20,7 @@ import { type DirectMessage, } from '@/hooks/useDirectMessages'; import { genUserName } from '@/lib/genUserName'; +import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration'; import { cn } from '@/lib/utils'; interface CrewChatProps { @@ -28,15 +29,27 @@ interface CrewChatProps { founderPubkey: string; /** All listed member pubkeys (hex). */ members: string[]; + /** Delegated admin pubkeys (subset of members). */ + admins?: string[]; + /** Crew addressable coordinate `kind:pubkey:d` (for moderation labels). */ + crewCoord?: string; + /** Whether the viewer can moderate (founder or admin). */ + canModerate?: boolean; } /** One message bubble, resolving the author's profile. */ function CrewMessageBubble({ message, isOwn, + isHidden, + canModerate, + onHide, }: { message: DirectMessage; isOwn: boolean; + isHidden?: boolean; + canModerate?: boolean; + onHide?: (message: DirectMessage) => void; }) { const author = useAuthor(message.pubkey); const meta = author.data?.metadata; @@ -45,7 +58,7 @@ function CrewMessageBubble({ const timeAgo = formatDistanceToNow(new Date(message.createdAt * 1000), { addSuffix: true }); return ( -
+
@@ -60,6 +73,22 @@ function CrewMessageBubble({ {name} {timeAgo} + {isHidden && ( + + Hidden + + )} + {canModerate && !isHidden && onHide && ( + + )}
(null); + // Moderation — founder + admins can hide chat messages (client-side, since + // NIP-17 messages are E2EE and can't be deleted from relays). + const moderators = useMemo( + () => Array.from(new Set([founderPubkey, ...admins])), + [founderPubkey, admins], + ); + const { data: hiddenIds } = useCrewModeration({ + crewCoord, + moderators, + enabled: !!crewCoord && moderators.length > 0, + }); + const { mutateAsync: hideMessage } = useHideCrewPost(crewCoord); + + const handleHideMessage = async (m: DirectMessage) => { + try { + await hideMessage({ postId: m.id, postAuthor: m.pubkey }); + toast({ title: 'Message hidden', description: 'Hidden from the crew chat for everyone.' }); + } catch (err) { + toast({ + title: 'Failed to hide message', + description: err instanceof Error ? err.message : 'Please try again.', + variant: 'destructive', + }); + } + }; + // The chat participants = founder + all listed members (deduped). const participants = useMemo( () => Array.from(new Set([founderPubkey, ...members])), @@ -164,6 +226,7 @@ export function CrewChat({ crewName, founderPubkey, members }: CrewChatProps) {

Encrypted group chat for the founder & all listed crew members. + {canModerate && ' You can hide messages from the crew as a moderator.'}

@@ -205,9 +268,18 @@ export function CrewChat({ crewName, founderPubkey, members }: CrewChatProps) {

)} - {messages?.map((m) => ( - - ))} + {messages + ?.filter((m) => canModerate || !hiddenIds?.has(m.id)) + .map((m) => ( + + ))}
diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index c2015a2..48d0842 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -350,6 +350,9 @@ export default function CrewDetailPage() { crewName={crew.name} founderPubkey={crew.pubkey} members={crew.members} + admins={crew.admins} + crewCoord={crewCoord} + canModerate={canManage} />