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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 17:08:18 +07:00
parent 71d3d60d09
commit a0c59375ed
3 changed files with 97 additions and 16 deletions

26
NIP.md
View file

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

View file

@ -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 (
<div className={cn('flex gap-2.5', isOwn && 'flex-row-reverse')}>
<div className={cn('group flex gap-2.5', isOwn && 'flex-row-reverse', isHidden && 'opacity-50')}>
<Link to={`/${npub}`} className="shrink-0">
<Avatar className="h-8 w-8">
<AvatarImage src={meta?.picture} alt={name} />
@ -60,6 +73,22 @@ function CrewMessageBubble({
{name}
</Link>
<span className="text-[10px] text-muted-foreground shrink-0">{timeAgo}</span>
{isHidden && (
<span className="flex items-center gap-0.5 text-[10px] font-bold uppercase text-red-400">
<EyeOff className="h-2.5 w-2.5" /> Hidden
</span>
)}
{canModerate && !isHidden && onHide && (
<button
type="button"
onClick={() => onHide(message)}
className="text-muted-foreground opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
aria-label="Hide this message"
title="Hide message (admin)"
>
<EyeOff className="h-3 w-3" />
</button>
)}
</div>
<div
className={cn(
@ -74,12 +103,45 @@ function CrewMessageBubble({
);
}
export function CrewChat({ crewName, founderPubkey, members }: CrewChatProps) {
export function CrewChat({
crewName,
founderPubkey,
members,
admins = [],
crewCoord = '',
canModerate = false,
}: CrewChatProps) {
const { user } = useCurrentUser();
const { toast } = useToast();
const [draft, setDraft] = useState('');
const scrollRef = useRef<HTMLDivElement>(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) {
<p className="text-xs text-muted-foreground flex items-center gap-1 pt-0.5">
<ShieldCheck className="h-3 w-3 text-teal-400 shrink-0" />
Encrypted group chat for the founder &amp; all listed crew members.
{canModerate && ' You can hide messages from the crew as a moderator.'}
</p>
</CardHeader>
@ -205,9 +268,18 @@ export function CrewChat({ crewName, founderPubkey, members }: CrewChatProps) {
</p>
</div>
)}
{messages?.map((m) => (
<CrewMessageBubble key={m.id} message={m} isOwn={m.pubkey === user.pubkey} />
))}
{messages
?.filter((m) => canModerate || !hiddenIds?.has(m.id))
.map((m) => (
<CrewMessageBubble
key={m.id}
message={m}
isOwn={m.pubkey === user.pubkey}
isHidden={hiddenIds?.has(m.id)}
canModerate={canModerate}
onHide={handleHideMessage}
/>
))}
</div>
<div className="border-t border-white/5 p-3">

View file

@ -350,6 +350,9 @@ export default function CrewDetailPage() {
crewName={crew.name}
founderPubkey={crew.pubkey}
members={crew.members}
admins={crew.admins}
crewCoord={crewCoord}
canModerate={canManage}
/>
</div>