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:
parent
71d3d60d09
commit
a0c59375ed
3 changed files with 97 additions and 16 deletions
26
NIP.md
26
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", "<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 & 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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue