From 77dc4b562c1aafc5dd4834d1125e3bd77a9eeecd Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 15:18:39 +0700 Subject: [PATCH] Add Crew Chat: private E2E-encrypted group chat for crew members Built on the existing NIP-17 gift-wrapped group DM system (useGroupConversation + useSendDirectMessage), so the relay only ever sees encrypted blobs. - New CrewChat component: message list w/ author profiles, composer (Enter to send), auto-scroll, skeleton/empty states - Participants = founder + all listed members (deduped) - Only crew members can read/write (others see a locked "ask to join" state); warns if the signer lacks NIP-44 - "Private" lock badge makes the E2E-encrypted nature unmistakable - Surfaced as a card on the crew detail page under the Crew Feed Co-authored-by: shakespeare.diy --- src/components/CrewChat.tsx | 244 +++++++++++++++++++++++++++++++++++ src/pages/CrewDetailPage.tsx | 8 ++ 2 files changed, 252 insertions(+) create mode 100644 src/components/CrewChat.tsx diff --git a/src/components/CrewChat.tsx b/src/components/CrewChat.tsx new file mode 100644 index 0000000..89d4937 --- /dev/null +++ b/src/components/CrewChat.tsx @@ -0,0 +1,244 @@ +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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; + +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useToast } from '@/hooks/useToast'; +import { + useGroupConversation, + useSendDirectMessage, + type DirectMessage, +} from '@/hooks/useDirectMessages'; +import { genUserName } from '@/lib/genUserName'; +import { cn } from '@/lib/utils'; + +interface CrewChatProps { + crewName: string; + /** The crew founder pubkey. */ + founderPubkey: string; + /** All listed member pubkeys (hex). */ + members: string[]; +} + +/** One message bubble, resolving the author's profile. */ +function CrewMessageBubble({ + message, + isOwn, +}: { + message: DirectMessage; + isOwn: boolean; +}) { + const author = useAuthor(message.pubkey); + const meta = author.data?.metadata; + const name = meta?.name ?? genUserName(message.pubkey); + const npub = useMemo(() => nip19.npubEncode(message.pubkey), [message.pubkey]); + const timeAgo = formatDistanceToNow(new Date(message.createdAt * 1000), { addSuffix: true }); + + return ( +
+ + + + + {name.slice(0, 2).toUpperCase()} + + + +
+
+ + {name} + + {timeAgo} +
+
+ {message.content} +
+
+
+ ); +} + +export function CrewChat({ crewName, founderPubkey, members }: CrewChatProps) { + const { user } = useCurrentUser(); + const { toast } = useToast(); + const [draft, setDraft] = useState(''); + const scrollRef = useRef(null); + + // The chat participants = founder + all listed members (deduped). + const participants = useMemo( + () => Array.from(new Set([founderPubkey, ...members])), + [founderPubkey, members], + ); + + // Only crew members (founder or a listed member) can read/write the chat, + // because NIP-17 group messages are end-to-end encrypted to the participants. + const isMember = !!user && participants.includes(user.pubkey); + + const { data: messages, isLoading } = useGroupConversation(isMember ? participants : []); + const { mutateAsync: sendMessage, isPending } = useSendDirectMessage(); + + // Auto-scroll to newest message when the list grows. + const lastCount = useRef(0); + useEffect(() => { + const count = messages?.length ?? 0; + if (count !== lastCount.current) { + lastCount.current = count; + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); + } + }, [messages]); + + const canSendNip44 = !!user?.signer?.nip44; + + const handleSend = async (e: React.FormEvent) => { + e.preventDefault(); + const text = draft.trim(); + if (!text || !user) return; + + if (!canSendNip44) { + toast({ + title: 'Encryption unavailable', + description: 'Your signer does not support NIP-44 encryption. Please upgrade your extension.', + variant: 'destructive', + }); + return; + } + + // Recipients = everyone except yourself (the hook also wraps a copy to self). + const recipients = participants.filter((p) => p !== user.pubkey); + if (recipients.length === 0) { + toast({ + title: 'No one to message yet', + description: 'This crew has no other members to chat with.', + }); + return; + } + + try { + await sendMessage({ + recipients, + content: text, + subject: `${crewName} crew chat`, + }); + setDraft(''); + } catch (err) { + toast({ + title: 'Message failed', + description: err instanceof Error ? err.message : 'Could not send message.', + variant: 'destructive', + }); + } + }; + + return ( + + +
+ + Crew Chat + + + + Private + +
+

+ + Encrypted group chat for the founder & all listed crew members. +

+
+ + + {!user ? ( +
+ +

Log in to see if you can join the crew chat.

+
+ ) : !isMember ? ( +
+ +

+ This chat is private to crew members. Ask the founder to add you to the crew to join + the conversation. +

+
+ ) : ( + <> +
+ {isLoading && + Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} + {!isLoading && (!messages || messages.length === 0) && ( +
+ +

+ No messages yet. Kick off the crew chat — plan a session, share a spot, hype a clip. +

+
+ )} + {messages?.map((m) => ( + + ))} +
+ +
+
+