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, EyeOff } 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 { useNostrLogin } from '@nostrify/react/login'; import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { AmberApprovalHint } from '@/components/AmberApprovalHint'; import { useAuthor } from '@/hooks/useAuthor'; import { useToast } from '@/hooks/useToast'; import { useGroupConversation, useSendDirectMessage, type DirectMessage, } from '@/hooks/useDirectMessages'; import { useCordnChat } from '@/hooks/useCordnChat'; import { useCrewChatRead } from '@/hooks/useCrewChatRead'; import { resolveCordnEndpoint } from '@/lib/cordn/endpoint'; import type { CordnMessage } from '@/lib/cordn/types'; import { genUserName } from '@/lib/genUserName'; import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration'; import { cn } from '@/lib/utils'; import { Server } from 'lucide-react'; import type { SkateCrew } from '@/lib/types'; interface CrewChatProps { crewName: string; /** The crew founder pubkey. */ 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; /** * Full crew — when present AND it has a `coordinator` configured, chat runs * over the Cordn coordinator (encrypted, coordinator-routed). Otherwise it * falls back to the built-in NIP-17 group DM. */ crew?: SkateCrew; } /** Normalise a Cordn message into the shared DirectMessage bubble shape. */ function cordnToDirect(m: CordnMessage, groupId: string): DirectMessage { return { id: m.id, pubkey: m.pubkey, content: m.content, createdAt: m.createdAt, conversationWith: groupId, }; } /** 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; 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} {isHidden && ( Hidden )} {canModerate && !isHidden && onHide && ( )}
{message.content}
); } export function CrewChat({ crewName, founderPubkey, members, admins = [], crewCoord = '', canModerate = false, crew, }: CrewChatProps) { const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const { toast } = useToast(); const [draft, setDraft] = useState(''); const scrollRef = useRef(null); // Cordn transport when the crew advertises a coordinator; else NIP-17. const cordnEndpoint = useMemo( () => (crew ? resolveCordnEndpoint(crew) : null), [crew], ); const usingCordn = !!cordnEndpoint; // 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])), [founderPubkey, members], ); // Only crew members (founder or a listed member) can read/write the chat, // because group messages are end-to-end encrypted to the participants. const isMember = !!user && participants.includes(user.pubkey); // ── NIP-17 fallback transport ── const { data: nip17Messages, isLoading: nip17Loading } = useGroupConversation( !usingCordn && isMember ? participants : [], ); const { mutateAsync: sendNip17, isPending: nip17Sending } = useSendDirectMessage(); // ── Cordn coordinator transport ── const cordn = useCordnChat({ endpoint: cordnEndpoint, roster: participants, enabled: isMember, }); // Unify whichever transport is active into one message list + send + state. const messages: DirectMessage[] = usingCordn ? cordn.messages.map((m) => cordnToDirect(m, cordnEndpoint!.groupId)) : (nip17Messages ?? []); const isLoading = usingCordn ? cordn.isLoading : nip17Loading; const isPending = usingCordn ? cordn.isSending : nip17Sending; // ── "New messages" divider ── // Snapshot the last-seen time ONCE per open so the divider stays put while the // member reads, instead of jumping as fresh messages stream in. Anything after // that timestamp (and not authored by me) is "new since your last visit". const { lastSeen, markSeen } = useCrewChatRead(crewCoord); const seenAtOpenRef = useRef(null); if (seenAtOpenRef.current === null && lastSeen !== undefined) { seenAtOpenRef.current = lastSeen; } const seenAtOpen = seenAtOpenRef.current ?? 0; // The first message id that falls after the snapshot — the divider sits above // it. Skip when there's nothing older (a brand-new reader sees no divider). const firstUnreadId = useMemo(() => { if (!user || seenAtOpen <= 0) return null; const hasOlder = messages.some((m) => m.createdAt <= seenAtOpen); if (!hasOlder) return null; const firstNew = messages.find( (m) => m.createdAt > seenAtOpen && m.pubkey !== user.pubkey, ); return firstNew?.id ?? null; }, [messages, seenAtOpen, user]); // Keep the stored "last seen" caught up to the newest message we've rendered, // so the next visit only flags genuinely newer messages. useEffect(() => { if (messages.length === 0) return; const newest = messages.reduce((max, m) => Math.max(max, m.createdAt), 0); if (newest > (lastSeen ?? 0)) markSeen(newest); }, [messages, lastSeen, markSeen]); // 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 = checkNip44(user, logins); // How many signer approvals one Send will cost — used to warn Amber users so // the repeated Approve screens don't look like a bug. Roster = founder + all // members (deduped, includes self). Cordn v2: one author-proof signature plus // one encryption per roster member. NIP-17: each target (others + self) is an // encrypt + a seal signature, i.e. two ops per member. const rosterSize = Math.max(participants.length, 1); const approvalCount = usingCordn ? rosterSize + 1 : rosterSize * 2; 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; } // Cordn transport: seal to the roster and route via the coordinator. if (usingCordn) { try { await cordn.send(text); setDraft(''); } catch (err) { toast({ title: 'Message failed', description: err instanceof Error ? err.message : 'Could not send message.', variant: 'destructive', }); } return; } // NIP-17 fallback: recipients = everyone except yourself (hook wraps self too). 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 sendNip17({ 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 {usingCordn ? ( Cordn ) : ( Private )}

{usingCordn ? 'Encrypted & routed through the crew coordinator (Cordn) — no messages hit public relays.' : 'Encrypted group chat for the founder & all listed crew members.'} {canModerate && ' You can hide messages from the crew as a moderator.'}

{!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 ?.filter((m) => canModerate || !hiddenIds?.has(m.id)) .map((m) => (
{m.id === firstUnreadId && (
New
)}
))}