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 <assistant@shakespeare.diy>
This commit is contained in:
parent
e122ee826b
commit
77dc4b562c
2 changed files with 252 additions and 0 deletions
244
src/components/CrewChat.tsx
Normal file
244
src/components/CrewChat.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className={cn('flex gap-2.5', isOwn && 'flex-row-reverse')}>
|
||||||
|
<Link to={`/${npub}`} className="shrink-0">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={meta?.picture} alt={name} />
|
||||||
|
<AvatarFallback className="text-xs bg-teal-900/40 text-teal-300">
|
||||||
|
{name.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Link>
|
||||||
|
<div className={cn('flex max-w-[78%] flex-col gap-0.5', isOwn && 'items-end')}>
|
||||||
|
<div className={cn('flex items-baseline gap-2', isOwn && 'flex-row-reverse')}>
|
||||||
|
<Link to={`/${npub}`} className="text-xs font-semibold hover:underline truncate max-w-[10rem]">
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-[10px] text-muted-foreground shrink-0">{timeAgo}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl px-3 py-2 text-sm break-words whitespace-pre-wrap',
|
||||||
|
isOwn ? 'bg-teal-500 text-black rounded-tr-sm' : 'bg-muted rounded-tl-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrewChat({ crewName, founderPubkey, members }: CrewChatProps) {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<Card className="bg-black/90 border-2 border-teal-500/20 flex flex-col overflow-hidden">
|
||||||
|
<CardHeader className="pb-3 border-b border-white/5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||||
|
<MessageSquare className="h-5 w-5 text-teal-400" /> Crew Chat
|
||||||
|
</CardTitle>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1 border-teal-500/40 bg-teal-500/10 text-teal-400 text-[10px] font-bold uppercase tracking-wide"
|
||||||
|
title="End-to-end encrypted — only crew members can read these messages."
|
||||||
|
>
|
||||||
|
<Lock className="h-3 w-3" />
|
||||||
|
Private
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0 flex flex-col flex-1 min-h-0">
|
||||||
|
{!user ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-10 px-6 text-center">
|
||||||
|
<Lock className="h-8 w-8 text-teal-400/40" />
|
||||||
|
<p className="text-sm text-muted-foreground">Log in to see if you can join the crew chat.</p>
|
||||||
|
</div>
|
||||||
|
) : !isMember ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-10 px-6 text-center">
|
||||||
|
<Lock className="h-8 w-8 text-teal-400/40" />
|
||||||
|
<p className="text-sm text-muted-foreground max-w-xs">
|
||||||
|
This chat is private to crew members. Ask the founder to add you to the crew to join
|
||||||
|
the conversation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex-1 overflow-y-auto px-4 py-3 space-y-3 min-h-[280px] max-h-[440px]"
|
||||||
|
>
|
||||||
|
{isLoading &&
|
||||||
|
Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex gap-2.5">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
<Skeleton className="h-8 w-40 rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!isLoading && (!messages || messages.length === 0) && (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-center gap-2 py-8">
|
||||||
|
<MessageSquare className="h-8 w-8 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm text-muted-foreground max-w-[16rem]">
|
||||||
|
No messages yet. Kick off the crew chat — plan a session, share a spot, hype a clip.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages?.map((m) => (
|
||||||
|
<CrewMessageBubble key={m.id} message={m} isOwn={m.pubkey === user.pubkey} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-white/5 p-3">
|
||||||
|
<form onSubmit={handleSend} className="flex items-end gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Message your crew…"
|
||||||
|
maxLength={2000}
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 resize-none min-h-[40px] max-h-32"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="icon"
|
||||||
|
disabled={isPending || !draft.trim()}
|
||||||
|
className="bg-teal-500 hover:bg-teal-600 text-black shrink-0"
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import { ZapButton } from '@/components/ZapButton';
|
||||||
import { ZapCrewDialog } from '@/components/ZapCrewDialog';
|
import { ZapCrewDialog } from '@/components/ZapCrewDialog';
|
||||||
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||||
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
||||||
|
import { CrewChat } from '@/components/CrewChat';
|
||||||
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||||
import { LinkedLabCard } from '@/components/CrossLinkCards';
|
import { LinkedLabCard } from '@/components/CrossLinkCards';
|
||||||
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
|
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
|
||||||
|
|
@ -216,6 +217,13 @@ export default function CrewDetailPage() {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Crew Chat — private E2E-encrypted group chat for members */}
|
||||||
|
<CrewChat
|
||||||
|
crewName={crew.name}
|
||||||
|
founderPubkey={crew.pubkey}
|
||||||
|
members={crew.members}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue