// ───────────────────────────────────────────────────────── // Small helpers (kept on window so other modules can use them) // ───────────────────────────────────────────────────────── const STATE_LABEL = { disconnected: 'disconnected', need_setup: 'needs setup', ready: 'ready', bridge_offline: 'bridge offline', }; const fmtClock = (ts) => { const d = new Date(ts); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }); }; const fmtClockMs = (ts) => { const d = new Date(ts); const t = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); return t + '.' + String(d.getMilliseconds()).padStart(3, '0'); }; const sortChats = (chats) => [...chats].sort((a, b) => { const la = a.messages[a.messages.length - 1]?.ts || a.created_at || 0; const lb = b.messages[b.messages.length - 1]?.ts || b.created_at || 0; return lb - la; }); window.STATE_LABEL = STATE_LABEL; window.fmtClock = fmtClock; window.fmtClockMs = fmtClockMs; window.sortChats = sortChats; // ───────────────────────────────────────────────────────── // StatusPill // ───────────────────────────────────────────────────────── function StatusPill({ state }) { return ( {STATE_LABEL[state] || state} ); } window.StatusPill = StatusPill; // ───────────────────────────────────────────────────────── // ContactModal — handles both "new chat" and "edit name" // ───────────────────────────────────────────────────────── function ContactModal({ mode, initial, onClose, onSave }) { // In edit mode, if name == number we treat the existing name as blank. const initialName = (initial && initial.name !== initial.number) ? initial.name : ''; const [number, setNumber] = React.useState(initial?.number || ''); const [name, setName] = React.useState(initialName); const firstField = React.useRef(null); React.useEffect(() => { firstField.current?.focus(); }, []); const numberEditable = mode !== 'edit'; const normalise = (s) => { const trimmed = s.trim(); if (!trimmed) return ''; return trimmed.replace(/[\s-]/g, ''); }; const submit = () => { const n = normalise(number); if (!n) return; onSave(n, name.trim() || null); }; const isEdit = mode === 'edit'; return (
e.stopPropagation()}>

{isEdit ? 'Edit contact' : 'New chat'}

{isEdit ? 'Rename this contact, or give a previously unknown number a name.' : 'Start a conversation with a phone number. Chats also appear automatically when an SMS or call comes in.'}

setNumber(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') submit(); }} disabled={!numberEditable} style={!numberEditable ? { opacity: 0.7, cursor: 'not-allowed' } : null} /> setName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') submit(); }} />
); } window.ContactModal = ContactModal; // ───────────────────────────────────────────────────────── // Sidebar // ───────────────────────────────────────────────────────── function Sidebar({ chats, selectedId, onSelect, onDeleteChat, tab, onTab, unreadSettings, bridgeState, onNewChat, dark, onToggleDark, ownNumber }) { const [q, setQ] = React.useState(''); const [ctxMenu, setCtxMenu] = React.useState(null); // { chat, x, y } | null const filtered = React.useMemo(() => { if (!q.trim()) return chats; const s = q.toLowerCase(); return chats.filter(c => c.name.toLowerCase().includes(s) || c.number.toLowerCase().includes(s) || c.messages.some(m => (m.body || '').toLowerCase().includes(s)) ); }, [q, chats]); // Close the right-click / long-press menu on any outside click or touch, // scroll, resize, or Esc. React.useEffect(() => { if (!ctxMenu) return; const close = () => setCtxMenu(null); const onKey = (e) => { if (e.key === 'Escape') close(); }; document.addEventListener('mousedown', close); document.addEventListener('touchstart', close); document.addEventListener('keydown', onKey); window.addEventListener('resize', close); window.addEventListener('scroll', close, true); return () => { document.removeEventListener('mousedown', close); document.removeEventListener('touchstart', close); document.removeEventListener('keydown', onKey); window.removeEventListener('resize', close); window.removeEventListener('scroll', close, true); }; }, [ctxMenu]); const openCtxMenu = (chat, e) => { e.preventDefault(); // Clamp so the menu can't fall off the right edge of the rail. const menuW = 180, menuH = 44; const x = Math.min(e.clientX, window.innerWidth - menuW - 8); const y = Math.min(e.clientY, window.innerHeight - menuH - 8); setCtxMenu({ chat, x, y }); }; const confirmDelete = () => { if (!ctxMenu) return; const chat = ctxMenu.chat; setCtxMenu(null); const label = chat.name && chat.name !== chat.number ? `${chat.name} (${chat.number})` : chat.number; if (window.confirm(`Delete ${label}?\n\nAll messages with this contact will be removed.`)) { onDeleteChat?.(chat); } }; return ( ); } window.Sidebar = Sidebar; function ChatListRow({ chat, active, onClick, onContextMenu }) { const last = chat.messages[chat.messages.length - 1]; const preview = previewOf(last); // Long-press → open the same context menu on touch devices. // 500ms hold with <10px drift; the synthetic tap that follows is swallowed. const longPressTimer = React.useRef(null); const touchStart = React.useRef(null); const longPressed = React.useRef(false); const cancelLongPress = () => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; } }; const onTouchStart = (e) => { if (e.touches.length !== 1) return; const t = e.touches[0]; touchStart.current = { x: t.clientX, y: t.clientY }; longPressed.current = false; cancelLongPress(); longPressTimer.current = setTimeout(() => { longPressed.current = true; if (navigator.vibrate) { try { navigator.vibrate(15); } catch (_) {} } onContextMenu?.({ preventDefault: () => {}, clientX: t.clientX, clientY: t.clientY, }); }, 500); }; const onTouchMove = (e) => { if (!touchStart.current || !e.touches.length) return; const t = e.touches[0]; const dx = t.clientX - touchStart.current.x; const dy = t.clientY - touchStart.current.y; if (Math.hypot(dx, dy) > 10) cancelLongPress(); }; const onTouchEnd = () => cancelLongPress(); const handleClick = (e) => { // Swallow the tap that follows a long-press so we don't also open the chat. if (longPressed.current) { e.preventDefault(); longPressed.current = false; return; } onClick?.(e); }; return (
{chat.initials}
{chat.name} {chat.is_short_code ? ( BIZ ) : null}
{preview}
{last ? fmtClock(last.ts) : ''}
); } function previewOf(m) { if (!m) return No messages yet; if (m.kind === 'call_in') return <>Missed call · auto-replied; if (m.kind === 'sms_out') return <>You: {m.body}; return m.body; } // ───────────────────────────────────────────────────────── // Conversation // ───────────────────────────────────────────────────────── function Conversation({ chat, onSend, bridgeReady, onEditContact }) { const [draft, setDraft] = React.useState(''); const [menuOpen, setMenuOpen] = React.useState(false); const bodyRef = React.useRef(null); const taRef = React.useRef(null); const menuRef = React.useRef(null); React.useEffect(() => { setMenuOpen(false); }, [chat?.number]); React.useEffect(() => { if (!menuOpen) return; const onDocClick = (e) => { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false); }; const onEsc = (e) => { if (e.key === 'Escape') setMenuOpen(false); }; document.addEventListener('mousedown', onDocClick); document.addEventListener('keydown', onEsc); return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onEsc); }; }, [menuOpen]); React.useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }, [chat?.number, chat?.messages.length]); React.useEffect(() => { setDraft(''); }, [chat?.number]); React.useEffect(() => { const ta = taRef.current; if (!ta) return; ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 120) + 'px'; }, [draft]); if (!chat) { return (

Pick a conversation

Texts go out through your modem on the other side of the world. Replies and calls land here.

); } const submit = () => { const v = draft.trim(); if (!v) return; onSend(chat.number, v); setDraft(''); }; const handleKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } }; const grouped = groupByDay(chat.messages); return ( <>
{chat.initials}
{chat.name}
{chat.number}{chat.is_short_code ? ' · short code' : ''}
calls auto-reject
{menuOpen && (
)}
{grouped.length === 0 && (
No messages yet — say hi.
)} {grouped.map((grp, gi) => (
{grp.label}
{grp.items.map(m => )}
))}