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) => (
+
+ ))}
+
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx
index 45c5b7b..995d303 100644
--- a/src/pages/CrewDetailPage.tsx
+++ b/src/pages/CrewDetailPage.tsx
@@ -11,6 +11,7 @@ import { ZapButton } from '@/components/ZapButton';
import { ZapCrewDialog } from '@/components/ZapCrewDialog';
import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CollectiveMembers } from '@/components/CollectiveMembers';
+import { CrewChat } from '@/components/CrewChat';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { LinkedLabCard } from '@/components/CrossLinkCards';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
@@ -216,6 +217,13 @@ export default function CrewDetailPage() {
)}
+
+ {/* Crew Chat — private E2E-encrypted group chat for members */}
+
{/* Sidebar */}