import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import type { ReactNode } from 'react'; import { AlertTriangle, Check, ChevronDown, Copy, ExternalLink, ImagePlus, KeyRound, Loader2, Lock, Plus, QrCode, RefreshCw, Reply, ScanLine, Send, Share2, Trash2, UserPlus, Users, X, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { QRCodeCanvas } from '@/components/ui/qrcode'; import { ImageWithFallback } from '@/components/ImageWithFallback'; import { QRScannerModal } from '@/components/wallet/QRScannerModal'; import { useTranslation } from '@/contexts/language'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useCordnNative } from '@/hooks/useCordnNative'; import { shortNpub } from '@/lib/coordinator'; import { isNativePlatform } from '@/lib/platform'; import { formatDistanceToNow } from '@/lib/dateFnsI18n'; import { cn } from '@/lib/utils'; import type { CordnRoom } from '@/lib/cordn/room'; import { readCordnSeen, writeCordnSeen } from '@/lib/cordn/unread'; import { clearPendingJoin, readPendingJoin, writePendingJoin } from '@/lib/cordn/pendingJoin'; type RoomScope = 'crew' | 'community'; interface CordnNativePanelProps { crew: CordnRoom; /** Founder/admin — may create the group and approve join requests. */ canManage: boolean; /** False for bot-owned rooms: the bot creates, admins still approve. */ canCreate?: boolean; /** Founder or listed member. */ isMember: boolean; /** Switches the room-specific copy (crew vs the app-wide community room). */ scope?: RoomScope; /** Optional share/QR URL override (the community page uses `/#/community`). */ inviteUrl?: string; /** Community bot pubkey — shown with a friendly label in requests/members. */ botPubkey?: string; /** Whether the private-chat tab is the visible tab (drives seen/unread). */ active?: boolean; /** Called when the room has inbound messages newer than the last seen cursor. */ onUnreadChange?: (hasUnread: boolean) => void; /** Called with the number of pending join requests (founder/admin). */ onRequestsChange?: (count: number) => void; } type MemberRole = 'founder' | 'admin' | 'member'; interface PendingMessage { text: string; imageUrl?: string; state: 'sending' | 'failed'; } /** * The crew's **native Cordn (MLS) chat** — real end-to-end encryption between * members, routed through the crew's coordinator. See `lib/cordn/native/`. */ export function CordnNativePanel({ crew, canManage, canCreate = canManage, isMember, scope = 'crew', inviteUrl: inviteUrlProp, botPubkey, active = true, onUnreadChange, onRequestsChange, }: CordnNativePanelProps) { const { t } = useTranslation(); const isCommunity = scope === 'community'; const copy = { title: isCommunity ? t('communityUi.title') : t('cordnUi.title'), subtitle: isCommunity ? t('communityUi.subtitle') : t('cordnUi.subtitle'), notLoggedIn: isCommunity ? t('communityUi.notLoggedIn') : t('cordnUi.notLoggedIn'), noGroupAdmin: isCommunity ? t('communityUi.noGroupAdmin') : t('cordnUi.noGroupAdmin'), noGroupMember: isCommunity ? t('communityUi.noGroupMember') : t('cordnUi.noGroupMember'), create: isCommunity ? t('communityUi.create') : t('cordnUi.create'), setupDeviceBody: isCommunity ? t('communityUi.setupDeviceBody') : t('cordnUi.setupDeviceBody'), encryptedNote: isCommunity ? t('communityUi.encryptedNote') : t('cordnUi.encryptedNote'), shareInviteText: isCommunity ? t('communityUi.shareInviteText') : t('cordnUi.shareInviteText', { crew: crew.name }), qrTitle: isCommunity ? t('communityUi.qrTitle') : t('cordnUi.qrTitle'), qrHint: isCommunity ? t('communityUi.qrHint') : t('cordnUi.qrHint'), owner: isCommunity ? t('communityUi.owner') : t('cordnUi.roleFounder'), botName: t('communityUi.botName'), founderSteps: isCommunity ? [ t('communityUi.onboardingFounder1'), t('communityUi.onboardingFounder2'), t('communityUi.onboardingFounder3'), ] : [ t('cordnUi.onboardingFounder1'), t('cordnUi.onboardingFounder2'), t('cordnUi.onboardingFounder3'), ], memberSteps: isCommunity ? [ t('communityUi.onboardingMember1'), t('communityUi.onboardingMember2'), t('communityUi.onboardingMember3'), ] : [ t('cordnUi.onboardingMember1'), t('cordnUi.onboardingMember2'), t('cordnUi.onboardingMember3'), ], }; const { user } = useCurrentUser(); const native = useCordnNative({ crew, enabled: !!user && isMember, active }); // Stable callbacks (the hook's return object is recreated each render). const sendNative = native.send; const checkInviteNative = native.checkInvite; const [draft, setDraft] = useState(''); const [replyTo, setReplyTo] = useState<{ id: string; content: string } | null>(null); const [copied, setCopied] = useState(false); const [copiedLink, setCopiedLink] = useState(false); const [copiedDevice, setCopiedDevice] = useState(false); const [notice, setNotice] = useState(null); const [awaiting, setAwaiting] = useState(() => user?.pubkey ? readPendingJoin(user.pubkey, native.groupId) : false, ); const [pending, setPending] = useState(null); const [sentIds, setSentIds] = useState>(() => new Set()); const [inviteOpen, setInviteOpen] = useState(false); const [deviceLinkOpen, setDeviceLinkOpen] = useState(false); const [deviceLinkCode, setDeviceLinkCode] = useState(null); const [deviceLinkPending, setDeviceLinkPending] = useState(false); const [scannerOpen, setScannerOpen] = useState(false); const scrollRef = useRef(null); const fileRef = useRef(null); const checkingRef = useRef(false); const loadedRequestsRef = useRef(false); const { mutateAsync: uploadFile, isPending: uploading } = useUploadFile(); const inviteUrl = useMemo(() => { if (inviteUrlProp) return inviteUrlProp; // In the Android/iOS shell the page origin is `https://localhost`, which is // useless in a QR code. Share the public site there; keep the live origin on // the web (including previews). const origin = isNativePlatform() ? 'https://skate-spots.org' : window.location.origin; return `${origin}/#/crew/${crew.pubkey}/${crew.identifier}?tab=mls`; }, [crew.pubkey, crew.identifier, inviteUrlProp]); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); }, [native.messages.length, pending?.state]); useEffect(() => { if (!user?.pubkey) return; setAwaiting(readPendingJoin(user.pubkey, native.groupId)); }, [user?.pubkey, native.groupId]); useEffect(() => { if (native.group && awaiting) { if (user?.pubkey) clearPendingJoin(user.pubkey, native.groupId); setAwaiting(false); setDeviceLinkOpen(false); setDeviceLinkCode(null); setDeviceLinkPending(false); } }, [native.group, awaiting, user?.pubkey, native.groupId]); // Founders should see waiting requests without hunting for the check button. // Quiet reads keep the tab badge fresh without toggling the composer's busy // state. Amber is excluded: a background ContextVM request would pop the // signer; those users use the explicit Check requests button. const loadRequestsQuietNative = native.loadRequestsQuiet; const interactive = native.interactiveHintVisible; const groupGid = native.group?.gid; useEffect(() => { // Hidden tabs only do this for nsec/bunker. Extension/Amber users either // open the tab once (extension) or use the manual Check requests button. if (!canManage || !groupGid || !native.ready || interactive || (!native.passiveSigner && !active) || loadedRequestsRef.current) return; loadedRequestsRef.current = true; void loadRequestsQuietNative(); }, [canManage, groupGid, native.ready, interactive, native.passiveSigner, active, loadRequestsQuietNative]); useEffect(() => { if (!canManage || !groupGid || !native.ready || interactive || !native.passiveSigner) return; const id = window.setInterval(() => void loadRequestsQuietNative(), 60_000); return () => window.clearInterval(id); }, [canManage, groupGid, native.ready, interactive, native.passiveSigner, loadRequestsQuietNative]); useEffect(() => { onRequestsChange?.(canManage ? native.requests.length : 0); }, [canManage, native.requests.length, onRequestsChange]); // "New since last opened" marker. While the tab is visible we advance the // cursor; while it is hidden we only compare inbound timestamps. The first // observation seeds the cursor so a room's old history is not "unread". const messageCursor = useMemo(() => { const latest = native.messages .filter((m) => m.direction === 'inbound' && m.sender !== user?.pubkey) .reduce((max, m) => Math.max(max, m.createdAt), 0); return { latest }; }, [native.messages, user?.pubkey]); useEffect(() => { if (!user?.pubkey || !groupGid || !native.ready) { onUnreadChange?.(false); return; } const gid = groupGid; const latest = messageCursor.latest; if (active) { writeCordnSeen(user.pubkey, gid, latest); onUnreadChange?.(false); return; } const seen = readCordnSeen(user.pubkey, gid); if (seen === null) { writeCordnSeen(user.pubkey, gid, latest); onUnreadChange?.(false); } else { onUnreadChange?.(latest > seen); } }, [active, groupGid, native.ready, user?.pubkey, messageCursor.latest, onUnreadChange]); // Opening the private tab pulls anything missed while it was hidden. Silent // signers also poll in the hook; this makes tab activation immediate. const refreshNative = native.refresh; useEffect(() => { if (!active || !native.ready || !groupGid || interactive) return; void refreshNative(); }, [active, native.ready, groupGid, interactive, refreshNative]); const markSent = useCallback((id: string) => { if (!id) return; setSentIds((prev) => new Set(prev).add(id)); window.setTimeout(() => { setSentIds((prev) => { const next = new Set(prev); next.delete(id); return next; }); }, 3000); }, []); const withReply = useCallback( (text: string) => replyTo ? `> ${replyTo.content.replace(/\n/g, '\n> ')}\n\n${text}` : text, [replyTo], ); const copyMessage = useCallback(async (text: string) => { if (!text) return; try { await navigator.clipboard.writeText(text); setNotice(t('common.copied')); window.setTimeout(() => setNotice(null), 1500); } catch { // clipboard blocked } }, [t]); const deliver = useCallback( async (text: string, imageUrl?: string) => { setNotice(null); setPending({ text, imageUrl, state: 'sending' }); const sent = await sendNative(text, imageUrl); if (sent) { markSent(sent.id); setPending(null); } else { setPending((current) => (current ? { ...current, state: 'failed' } : current)); } }, [sendNative, markSent], ); const handleSend = useCallback(async () => { const body = draft.trim(); if (!body || native.busy) return; setDraft(''); const content = withReply(body); const sent = await native.send(content); if (sent) { markSent(sent.id); setReplyTo(null); setPending(null); } else { setPending({ text: content, state: 'failed' }); } }, [draft, native, withReply, markSent]); const retrySend = useCallback(async () => { if (!pending || pending.state !== 'failed') return; await deliver(pending.text, pending.imageUrl); }, [pending, deliver]); const onPickImage = useCallback( async (file: File | undefined) => { if (!file) return; try { const tags = await uploadFile(file); const url = tags[0]?.[1]; if (!url) throw new Error('upload returned no url'); const caption = draft.trim(); setDraft(''); await deliver(withReply(caption), url); } catch { setNotice(t('cordnUi.uploadFailed')); } }, [uploadFile, draft, deliver, withReply, t], ); const copyRef = useCallback(async () => { if (!native.groupRef) return; try { await navigator.clipboard.writeText(native.groupRef); setCopied(true); window.setTimeout(() => setCopied(false), 1500); } catch { // clipboard unavailable — the ref is still visible to select. } }, [native.groupRef]); const copyInvite = useCallback(async () => { try { await navigator.clipboard.writeText(inviteUrl); setCopiedLink(true); window.setTimeout(() => setCopiedLink(false), 1500); } catch { // clipboard blocked — the link is visible to select. } }, [inviteUrl]); const openDeviceLink = useCallback(async () => { setDeviceLinkOpen(true); setDeviceLinkCode(null); setDeviceLinkPending(true); setNotice(null); const code = await native.createDeviceLink(); if (!code) { setDeviceLinkPending(false); setNotice(t('cordnUi.deviceLinkFailed')); return; } setDeviceLinkCode(code); if (user?.pubkey) writePendingJoin(user.pubkey, native.groupId); setAwaiting(true); }, [native, user?.pubkey, t]); const copyDeviceLink = useCallback(async () => { if (!deviceLinkCode) return; try { await navigator.clipboard.writeText(deviceLinkCode); setCopiedDevice(true); window.setTimeout(() => setCopiedDevice(false), 1500); } catch { // clipboard blocked — the code is visible } }, [deviceLinkCode]); const addScannedDevice = useCallback(async (raw: string) => { setNotice(null); const ok = await native.addDevice(raw); setNotice(ok ? t('cordnUi.deviceLinked') : t('cordnUi.deviceLinkFailed')); }, [native, t]); const checkNow = useCallback(async () => { if (checkingRef.current) return false; checkingRef.current = true; try { return await checkInviteNative(); } finally { checkingRef.current = false; } }, [checkInviteNative]); const handleRequestJoin = useCallback(async () => { setNotice(null); const requested = await native.requestJoin(); if (!requested || !user?.pubkey) return; writePendingJoin(user.pubkey, native.groupId); setAwaiting(true); }, [native, user?.pubkey]); const handleCheckInvite = useCallback(async () => { if (native.busy) return; setNotice(null); const joined = await checkNow(); if (joined) { if (user?.pubkey) clearPendingJoin(user.pubkey, native.groupId); setAwaiting(false); return; } if (!awaiting) setNotice(t('cordnUi.noInvite')); }, [checkNow, awaiting, t, native.busy, user?.pubkey, native.groupId]); // Keep the latest checker in a ref: the poll must not restart just because a // parent re-render gave the hook callbacks new identities. const checkNowRef = useRef(checkNow); useEffect(() => { checkNowRef.current = checkNow; }, [checkNow]); // Poll the coordinator while a join request is pending. Backs off from 10s to // 30s so a founder who approves later is picked up without a manual tap. // Interactive signers (Amber) are excluded — polling would pop their signer. useEffect(() => { if (!awaiting || native.group || !native.ready || native.interactiveHintVisible || (!active && !native.passiveSigner)) return; let cancelled = false; let timer: number | undefined; let delay = 10_000; const tick = async () => { if (cancelled) return; const joined = await checkNowRef.current(); if (cancelled) return; if (joined) { setAwaiting(false); setNotice(null); return; } delay = Math.min(30_000, Math.round(delay * 1.5)); timer = window.setTimeout(() => void tick(), delay); }; timer = window.setTimeout(() => void tick(), delay); return () => { cancelled = true; if (timer !== undefined) window.clearTimeout(timer); }; }, [awaiting, native.group, native.ready, native.interactiveHintVisible, active, native.passiveSigner]); // "Join our crew chat" invite. The chat needs the person to be a crew member // first, so the link just drops them on this crew's private-chat tab with the // "Ask to join" flow ready. Uses the OS share sheet where available. const shareInvite = useCallback(async () => { try { if (navigator.share) { await navigator.share({ title: crew.name, text: copy.shareInviteText, url: inviteUrl, }); return; } await navigator.clipboard.writeText(inviteUrl); setNotice(t('cordnUi.inviteCopied')); window.setTimeout(() => setNotice(null), 2500); } catch { // share cancelled or clipboard blocked — nothing to do } }, [crew.name, copy.shareInviteText, inviteUrl, t]); if (!user) { return } text={copy.notLoggedIn} />; } if (!isMember) { return } text={t('cordnUi.membersOnly')} />; } if (!native.coordinator) { return } text={t('cordnUi.noCoordinator')} />; } const status = native.ready ? t('cordnUi.ready') : t('cordnUi.connecting'); const steps = canCreate ? copy.founderSteps : copy.memberSteps; const roleOf = (pubkey: string): MemberRole => pubkey === crew.pubkey ? 'founder' : crew.admins.includes(pubkey) ? 'admin' : 'member'; return (
{copy.title}
{native.group && ( {t('cordnUi.memberCount', { count: native.group.members.length })} )} {native.error ? ( <> {t('cordnUi.unreachable')} ) : ( <> {status} {!native.ready && ( )} )} {native.group && ( )}

{copy.subtitle}

{native.error && (

{t('cordnUi.errorUnreachable')}

{t('cordnUi.errorDetails')}

{native.error}

)} {!native.group ? (

{canCreate ? copy.noGroupAdmin : native.keyPackageReady ? copy.noGroupMember : copy.setupDeviceBody}

    {steps.map((step, index) => (
  1. {index + 1} {step}
  2. ))}

{copy.encryptedNote}

{canCreate ? ( ) : ( <> )}
{awaiting && (

{deviceLinkPending ? ( <> {t('cordnUi.waitingDevice')} ) : native.interactiveHintVisible ? ( <> {t('cordnUi.waitingApprovalManual')} ) : ( <> {t('cordnUi.waitingApproval')} )}

)} {notice &&

{notice}

} void copyRef()} />
) : ( <>
{t('cordnUi.members')} · {native.group.members.length}
{native.group.members.map((member) => ( { if (!window.confirm(t('cordnUi.removeMemberConfirm', { name: shortNpub(member.pubkey) }))) return; void native.removeMember(member.leafIndex).then((ok) => { if (!ok) setNotice(t('cordnUi.removeMemberFailed')); }); }} /> ))}
{!isCommunity && user.pubkey === crew.pubkey && ( )}
{native.messages.length === 0 && !pending ? (

{t('cordnUi.placeholder')}

) : ( native.messages.map((m) => (
{m.sender === user.pubkey ? t('cordnUi.you') : shortNpub(m.sender)} {formatDistanceToNow(new Date(m.createdAt * 1000), { addSuffix: true })} {m.direction === 'outbound' && sentIds.has(m.id) && ( {t('cordnUi.sent')} )}
{m.imageUrl && ( )} {m.content && (
{m.content}
)}
)) )} {pending && (
{t('cordnUi.you')} {pending.state === 'sending' ? t('cordnUi.sending') : t('cordnUi.sendFailed')}
{pending.imageUrl && ( )} {pending.text && (
{pending.text}
)}
{pending.state === 'failed' && (
)}
)}
{replyTo && (
{t('cordnUi.reply')}: {replyTo.content || t('cordnUi.attachImage')}
)}
{ void onPickImage(e.target.files?.[0]); e.target.value = ''; }} /> setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void handleSend(); } }} placeholder={t('cordnUi.placeholder')} className="flex-1" />
{notice &&

{notice}

} {native.interactiveHintVisible && (

{t('cordnUi.interactiveHint')}

)} void copyRef()} /> {canManage && (
{t('cordnUi.requests')}
{native.requests.length === 0 ? (

{native.group.members.length <= 1 ? t('cordnUi.inviteHint') : t('cordnUi.noRequests')}

) : ( native.requests.map((req) => ( void native.approve(req.pk)} botPubkey={botPubkey} botName={copy.botName} /> )) )}
)} )}
{copy.qrTitle} {copy.qrHint}
{inviteUrl}
{ setDeviceLinkOpen(open); if (!open) setDeviceLinkCode(null); }} > {t('cordnUi.linkDeviceTitle')} {t('cordnUi.linkDeviceHint')} {deviceLinkCode ? ( <>
{deviceLinkCode}
) : (
{t('common.loading')}
)}

{t('cordnUi.linkDeviceNote')}

setScannerOpen(false)} onResult={(result) => void addScannedDevice(result.value)} />
); } function AdvancedPanel({ groupRef, coordinatorPubkey, keyPackageReady, copied, onCopyRef, }: { groupRef: string | null; coordinatorPubkey: string; keyPackageReady: boolean; copied: boolean; onCopyRef: () => void; }) { const { t } = useTranslation(); const [copiedCordn, setCopiedCordn] = useState(false); const cordnUrl = groupRef ? `https://cordn.net/chat/${groupRef}` : null; const copyCordn = async () => { if (!cordnUrl) return; try { await navigator.clipboard.writeText(cordnUrl); setCopiedCordn(true); window.setTimeout(() => setCopiedCordn(false), 1500); } catch { // clipboard blocked — the link is visible to select. } }; return (
{t('cordnUi.advanced')}
{t('cordnUi.coordinator')} {shortNpub(coordinatorPubkey)}
{t('cordnUi.keyPackage')} {keyPackageReady ? t('cordnUi.keyPackageReady') : t('cordnUi.keyPackageMissing')}
{groupRef && ( <>
{t('cordnUi.groupRef')} {groupRef}
{' '} {t('socialUi.crewCoordinator.openCordnGroup')}
)}
); } function PersonLabel({ pubkey, isSelf, botPubkey, botName, }: { pubkey: string; isSelf?: boolean; botPubkey?: string; botName?: string; }) { const { t } = useTranslation(); const { data } = useAuthor(pubkey); const isBot = !!botPubkey && pubkey === botPubkey; const name = isBot ? (botName ?? shortNpub(pubkey)) : data?.metadata?.display_name || data?.metadata?.name || shortNpub(pubkey); const picture = isBot ? undefined : data?.metadata?.picture; const initials = name.replace(/^@/, '').slice(0, 2).toUpperCase(); return (
{initials} {name} {isSelf && ({t('cordnUi.you')})}
); } function MemberRow({ pubkey, role, isSelf, founderLabel, botPubkey, botName, canRemove, onRemove, }: { pubkey: string; role: MemberRole; isSelf: boolean; founderLabel: string; botPubkey?: string; botName?: string; canRemove?: boolean; onRemove?: () => void; }) { const { t } = useTranslation(); const label = role === 'founder' ? founderLabel : role === 'admin' ? t('cordnUi.roleAdmin') : t('cordnUi.roleMember'); return (
{label} {canRemove && onRemove && ( )}
); } function RequestRow({ pubkey, disabled, onApprove, botPubkey, botName, }: { pubkey: string; disabled: boolean; onApprove: () => void; botPubkey?: string; botName?: string; }) { const { t } = useTranslation(); return (
); } function Notice({ icon, text }: { icon: ReactNode; text: string }) { return ( {icon} {text} ); }