import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import type { ReactNode } from 'react'; import { AlertTriangle, Check, CheckCheck, ChevronDown, Copy, ImagePlus, KeyRound, Loader2, Lock, Plus, QrCode, RefreshCw, Reply, ScanLine, Search, Send, Share2, ShieldMinus, ShieldPlus, SmilePlus, Trash2, UserPlus, Users, Volume2, VolumeX, 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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; 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 { useNostrPublish } from '@/hooks/useNostrPublish'; import { useQueryClient } from '@tanstack/react-query'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useCordnNative } from '@/hooks/useCordnNative'; import { useDmMute } from '@/hooks/useDmMute'; import { useCordnRoomRead } from '@/hooks/useCordnRoomRead'; import { enqueueCordnOutbox, markCordnOutboxAttempt, readCordnOutbox, removeCordnOutbox } from '@/lib/cordnOutbox'; 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 { SKATE_CREW_KIND, type SkateCrew } from '@/lib/types'; 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'; outboxId?: string; attempts?: number; } /** * 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 { user } = useCurrentUser(); const { mutateAsync: publishEvent } = useNostrPublish(); const queryClient = useQueryClient(); const [roleBusy, setRoleBusy] = useState(null); const isCommunity = scope === 'community'; const waitingApprovalKey = isCommunity ? 'communityUi.waitingApproval' : 'cordnUi.waitingApproval'; const waitingApprovalManualKey = isCommunity ? 'communityUi.waitingApprovalManual' : 'cordnUi.waitingApprovalManual'; const noInviteKey = isCommunity ? 'communityUi.noInvite' : 'cordnUi.noInvite'; const sourceCrew = crew as SkateCrew; const canChangeRoles = !isCommunity && user?.pubkey === crew.pubkey && !!sourceCrew.event; 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 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 [searchOpen, setSearchOpen] = useState(false); const [search, setSearch] = useState(''); const [online, setOnline] = useState(() => typeof navigator === 'undefined' || navigator.onLine); const [replyTo, setReplyTo] = useState<{ id: string; content: string } | null>(null); const [reactingTo, setReactingTo] = useState(null); const readSentRef = useRef>(new Set()); 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 { isMuted, toggle: toggleMute } = useDmMute(); const { reads, markActive, markSeen } = useCordnRoomRead(user?.pubkey); const muteKey = native.groupId ? `cordn:${native.groupId}` : undefined; const muted = isMuted(muteKey); 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]); // Read receipts: when the tab is visible, acknowledge the latest inbound // message once. Control receipts are filtered out of `native.messages`. useEffect(() => { if (!active || !native.ready || !user?.pubkey || native.interactiveHintVisible) return; const lastInbound = [...native.messages] .reverse() .find((m) => m.direction === 'inbound' && m.sender !== user.pubkey); if (!lastInbound || readSentRef.current.has(lastInbound.id)) return; readSentRef.current.add(lastInbound.id); void native.markRead(lastInbound.id); }, [active, native, native.messages, native.markRead, native.ready, user?.pubkey]); useEffect(() => { if (!user?.pubkey) return; setAwaiting(readPendingJoin(user.pubkey, native.groupId)); }, [user?.pubkey, native.groupId]); const latestInboundAt = useMemo( () => native.messages.reduce( (max, message) => message.direction === 'inbound' && message.sender !== user?.pubkey ? Math.max(max, message.createdAt) : max, 0, ), [native.messages, user?.pubkey], ); useEffect(() => { if (native.groupId && latestInboundAt > 0) markActive(native.groupId, latestInboundAt); }, [native.groupId, latestInboundAt, markActive]); useEffect(() => { if (active && native.groupId && latestInboundAt > 0) markSeen(native.groupId, latestInboundAt); }, [active, native.groupId, latestInboundAt, markSeen]); useEffect(() => { const onOnline = () => setOnline(true); const onOffline = () => setOnline(false); window.addEventListener('online', onOnline); window.addEventListener('offline', onOffline); return () => { window.removeEventListener('online', onOnline); window.removeEventListener('offline', onOffline); }; }, []); // A different room must not inherit the previous room's draft/pending bubble. useEffect(() => { setPending(null); setReplyTo(null); setReactingTo(null); setSearch(''); setSearchOpen(false); }, [native.groupId]); // Restore the oldest failed send after a reload/room switch. It is retried // automatically a few times when the app is online, then stays queued for a // deliberate tap. useEffect(() => { if (!user?.pubkey || !native.groupId) return; const [next] = readCordnOutbox(user.pubkey, native.groupId); if (!next) return; setPending((current) => current ?? { text: next.text, imageUrl: next.imageUrl, state: 'failed', outboxId: next.id, attempts: next.attempts, }, ); }, [user?.pubkey, native.groupId]); const displayMessages = useMemo(() => { const query = search.trim().toLowerCase(); if (!query) return native.messages; return native.messages.filter( (message) => message.content.toLowerCase().includes(query) || (message.imageUrl ?? '').toLowerCase().includes(query), ); }, [native.messages, search]); 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. The panel records the latest inbound // timestamp; a hidden tab compares it with the local seen cursor. The first // observation seeds the cursor so old history never reads as unread. useEffect(() => { if (!user?.pubkey || !groupGid || !native.ready) { onUnreadChange?.(false); return; } if (active) { onUnreadChange?.(false); return; } const state = reads[groupGid]; if (!state) { markSeen(groupGid, latestInboundAt); onUnreadChange?.(false); return; } markActive(groupGid, latestInboundAt); onUnreadChange?.(latestInboundAt > state.seenAt); }, [ active, groupGid, native.ready, user?.pubkey, reads, latestInboundAt, markActive, markSeen, 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, outboxId?: string): Promise => { setNotice(null); const currentAttempt = outboxId ? readCordnOutbox(user?.pubkey, native.groupId).find((item) => item.id === outboxId)?.attempts : undefined; setPending({ text, imageUrl, state: 'sending', outboxId, attempts: currentAttempt, }); const sent = await sendNative(text, imageUrl); if (sent) { markSent(sent.id); if (outboxId) removeCordnOutbox(user?.pubkey, native.groupId, outboxId); const [next] = readCordnOutbox(user?.pubkey, native.groupId); setPending(next ? { text: next.text, imageUrl: next.imageUrl, state: 'failed', outboxId: next.id, attempts: next.attempts, } : null); return true; } let nextOutboxId = outboxId; let attempts = currentAttempt ?? 0; if (nextOutboxId) { markCordnOutboxAttempt(user?.pubkey, native.groupId, nextOutboxId); attempts += 1; } else { const item = enqueueCordnOutbox(user?.pubkey, native.groupId, text, imageUrl); nextOutboxId = item?.id; attempts = item?.attempts ?? 1; } setPending({ text, imageUrl, state: 'failed', outboxId: nextOutboxId, attempts, }); return false; }, [sendNative, markSent, user?.pubkey, native.groupId], ); const handleSend = useCallback(async () => { const body = draft.trim(); if (!body || native.busy) return; setDraft(''); const content = withReply(body); const ok = await deliver(content); if (ok) setReplyTo(null); }, [draft, native.busy, withReply, deliver]); const retrySend = useCallback(async () => { if (!pending || pending.state !== 'failed') return; await deliver(pending.text, pending.imageUrl, pending.outboxId); }, [pending, deliver]); const discardPending = useCallback(() => { if (pending?.outboxId) removeCordnOutbox(user?.pubkey, native.groupId, pending.outboxId); const [next] = readCordnOutbox(user?.pubkey, native.groupId); setPending(next ? { text: next.text, imageUrl: next.imageUrl, state: 'failed', outboxId: next.id, attempts: next.attempts, } : null); }, [pending, user?.pubkey, native.groupId]); // A failed send is retried a few times when the app is online, then left for // the explicit Retry button. This avoids both data loss and infinite retry. useEffect(() => { if ( !pending || pending.state !== 'failed' || !online || !native.ready || (pending.attempts ?? 0) >= 3 ) return; const timer = window.setTimeout(() => void retrySend(), 12_000); return () => window.clearTimeout(timer); }, [pending, online, native.ready, retrySend]); 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 handleApprove = useCallback(async (request: { pk: string; kp_ref: string; at: number }) => { const ok = await native.approve( request.kp_ref || request.pk, request.pk, { pk: request.pk, at: request.at }, ); if (ok) { setNotice(t('cordnUi.requestHandled')); window.setTimeout(() => setNotice(null), 2500); } }, [native, t]); 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(noInviteKey)); }, [checkNow, awaiting, t, noInviteKey, 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'; const updateAdminRole = async (memberPubkey: string, makeAdmin: boolean) => { if (!canChangeRoles || !sourceCrew.event) return; setRoleBusy(memberPubkey); try { const tags = sourceCrew.event.tags.filter( ([name, value]) => !(name === 'p' && value === memberPubkey), ); tags.push(makeAdmin ? ['p', memberPubkey, '', 'admin'] : ['p', memberPubkey]); await publishEvent({ kind: SKATE_CREW_KIND, content: sourceCrew.event.content, tags, }); await queryClient.invalidateQueries({ queryKey: ['skate-crew'] }); await queryClient.invalidateQueries({ queryKey: ['skate-crews'] }); setNotice(t(makeAdmin ? 'cordnUi.adminAdded' : 'cordnUi.adminRemoved')); } catch { setNotice(t('cordnUi.roleUpdateFailed')); } finally { setRoleBusy(null); } }; return (
{copy.title}
{native.group && ( <> {muteKey && ( )} )} {native.group && ( {t('cordnUi.memberCount', { count: native.group.members.length })} )} {native.error ? ( <> {t('cordnUi.unreachable')} ) : ( <> {status} {!native.ready && ( )} )} {native.group && ( )}

{copy.subtitle}

{native.group && native.lastCheckedAt && (

{t('cordnUi.lastChecked')} {formatDistanceToNow(new Date(native.lastCheckedAt), { addSuffix: true })}

)} {!isCommunity && (
{t('cordnUi.compareTitle')}

{t('cordnUi.compareChat')}

{t('cordnUi.comparePrivate')}

)}
{isCommunity && native.group && botPubkey && !native.group.members.some((member) => member.pubkey === botPubkey) && (
{t('communityUi.botMissing')}
)} {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) => { // Members see the setup move visibly: device key prepared → // request sent → waiting for an admin. Founders start at step 1. const stepDone = !canCreate && index === 0 && (awaiting || native.keyPackageReady); const stepWaiting = !canCreate && index === 1 && awaiting; return (
  1. {stepDone ? : stepWaiting ? : index + 1} {step}
  2. ); })}

{copy.encryptedNote}

{/* Amber needs one foreground approval for the device key and one for the join request. Show the two translated step labels so the second prompt is expected, not a mystery. */} {native.interactiveHintVisible && !canCreate && (

Amber {t('cordnUi.setupDevice')} {t('cordnUi.requestJoin')}

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

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

)} {awaiting && !deviceLinkPending && ( )} {isCommunity && (

{t('communityUi.secondDeviceHint')}

)} {notice &&

{notice}

} void copyRef()} />
) : ( <>
{t('cordnUi.members')} · {native.group.members.length}
{native.group.members.map((member) => ( void updateAdminRole(member.pubkey, roleOf(member.pubkey) !== 'admin')} canRemove={canManage && member.pubkey !== user.pubkey && member.pubkey !== crew.pubkey} onRemove={() => { void native.removeMember(member.leafIndex).then((ok) => { if (!ok) setNotice(t('cordnUi.removeMemberFailed')); }); }} /> ))}
{!isCommunity && user.pubkey === crew.pubkey && ( )}
{searchOpen && (
setSearch(event.target.value)} placeholder={t('common.search')} aria-label={t('common.search')} className="h-8 border-white/10 bg-black/40 text-xs" autoFocus /> {search && ( )}
)}
{displayMessages.length === 0 && !pending ? (

{search.trim() ? t('common.noResults') : t('cordnUi.placeholder')}

) : ( displayMessages.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.direction === 'outbound' && (native.readBy[m.id]?.length ?? 0) > 0 && ( {t('cordnUi.readBy', { count: native.readBy[m.id].length })} )} {m.direction === 'outbound' && ( )}
{m.imageUrl && ( )} {m.content && (
{m.content}
)}
{reactingTo === m.id && (
{['👍', '❤️', '🔥', '😂'].map((emoji) => ( ))}
)} {Object.values(native.reactions[m.id] ?? {}).some((pks) => pks.length > 0) && (
{Object.entries(native.reactions[m.id] ?? {}) .filter(([, pks]) => pks.length > 0) .map(([emoji, pks]) => ( ))}
)}
)) )} {pending && (
{t('cordnUi.you')} {pending.state === 'sending' ? t('cordnUi.sending') : t('cordnUi.sendFailed')} {pending.state === 'failed' && pending.outboxId && ( · {t('cordnUi.queued')} )}
{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 handleApprove(req)} 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, lastCheckedAt, copied, onCopyRef, }: { groupRef: string | null; coordinatorPubkey: string; keyPackageReady: boolean; lastCheckedAt: number | null; 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')}
{t('cordnUi.lastChecked')} {lastCheckedAt ? formatDistanceToNow(new Date(lastCheckedAt), { addSuffix: true }) : '—'}
{groupRef && ( <>
{t('cordnUi.groupRef')} {groupRef}
{t('cordnUi.cordnWebCaveat')}
)}
); } 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, canChangeRole, roleBusy, onToggleAdmin, }: { pubkey: string; role: MemberRole; isSelf: boolean; founderLabel: string; botPubkey?: string; botName?: string; canRemove?: boolean; onRemove?: () => void; canChangeRole?: boolean; roleBusy?: boolean; onToggleAdmin?: () => void; }) { const { t } = useTranslation(); const label = role === 'founder' ? founderLabel : role === 'admin' ? t('cordnUi.roleAdmin') : t('cordnUi.roleMember'); return (
{label} {canChangeRole && onToggleAdmin && ( )} {canRemove && onRemove && ( {t('common.remove')} {t('cordnUi.removeMemberConfirm', { name: shortNpub(pubkey) })} {t('common.cancel')} {t('common.remove')} )}
); } function RequestRow({ pubkey, disabled, onApprove, botPubkey, botName, }: { pubkey: string; disabled: boolean; onApprove: () => void; botPubkey?: string; botName?: string; }) { const { t } = useTranslation(); return (
{t('cordnUi.approveTitle')} {t('cordnUi.approveDesc', { name: shortNpub(pubkey) })} {t('common.cancel')} {t('cordnUi.approve')}
); } function Notice({ icon, text }: { icon: ReactNode; text: string }) { return ( {icon} {text} ); }