import { html, render, useState, useEffect, useCallback, useRef, createContext, useContext, } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, LOCALES } from './i18n.js'; // ── Constants ──────────────────────────────────────────────────────────────── const HUB = ''; const AUTH_KEY = 'mb_auth'; const THEME_KEY = 'mb_theme'; const IDB_NAME = 'meshbay'; const IDB_VERSION = 1; const IDB_STORE = 'group_indexes'; // ── IndexedDB cache ───────────────────────────────────────────────────────── function openDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, IDB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(IDB_STORE)) { db.createObjectStore(IDB_STORE, { keyPath: 'groupId' }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function cacheGroupIndex(groupId, groupName, entries) { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readwrite'); tx.objectStore(IDB_STORE).put({ groupId, groupName, entries, cachedAt: Date.now(), }); await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; }); db.close(); } catch { /* best-effort */ } } async function getCachedGroupIndex(groupId) { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readonly'); const req = tx.objectStore(IDB_STORE).get(groupId); const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); db.close(); return result || null; } catch { return null; } } async function getAllCachedIndexes() { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readonly'); const req = tx.objectStore(IDB_STORE).getAll(); const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); db.close(); return result || []; } catch { return []; } } // ── Auth persistence ───────────────────────────────────────────────────────── let _sessionKeys = null; function loadAuth() { try { return JSON.parse(localStorage.getItem(AUTH_KEY)); } catch { return null; } } function saveAuth(auth) { if (auth) { localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); } else { localStorage.removeItem(AUTH_KEY); _sessionKeys = null; } } // ── Theme ──────────────────────────────────────────────────────────────────── function getInitialTheme() { const stored = localStorage.getItem(THEME_KEY); if (stored === 'dark' || stored === 'light' || stored === 'system') return stored; return 'system'; } function resolveTheme(pref) { if (pref === 'system') { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } return pref; } // ── Hub API ────────────────────────────────────────────────────────────────── async function hubFetch(path, { method = 'GET', body, token } = {}) { const headers = {}; if (body) headers['Content-Type'] = 'application/json'; if (token) headers['Authorization'] = `Bearer ${token}`; const opts = { method, headers }; if (body) opts.body = JSON.stringify(body); const r = await fetch(HUB + path, opts); if (!r.ok) { const err = await r.json().catch(() => ({ detail: r.statusText })); throw new Error(err.detail || r.statusText); } return r.json(); } // ── Router ─────────────────────────────────────────────────────────────────── function useRoute() { const [hash, setHash] = useState(window.location.hash.slice(1) || '/'); useEffect(() => { const onHash = () => setHash(window.location.hash.slice(1) || '/'); window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, []); return hash; } function navigate(path) { window.location.hash = path; } // ── Context ────────────────────────────────────────────────────────────────── const AuthContext = createContext(null); function useAuth() { return useContext(AuthContext); } // ── Nav ────────────────────────────────────────────────────────────────────── function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle, unreadCount }) { return html` `; } // ── Sidebar ────────────────────────────────────────────────────────────────── function Sidebar({ groups, route, menuOpen, role }) { const isStaff = role === 'moderator' || role === 'admin'; return html` `; } // ── Login Page ─────────────────────────────────────────────────────────────── function LoginPage() { const auth = useAuth(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const onSubmit = async (e) => { e.preventDefault(); if (!username || !password) return; setError(''); setLoading(true); try { await auth.login(username, password); navigate('/'); } catch (err) { setError(err.message); } finally { setLoading(false); } }; return html`

${t('login.title')}

setUsername(e.target.value)} autocomplete="username" required /> setPassword(e.target.value)} autocomplete="current-password" required /> ${error && html`
${error}
`}
`; } // ── Register Page ──────────────────────────────────────────────────────────── function RegisterPage() { const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirm, setConfirm] = useState(''); const [error, setError] = useState(''); const [success, setSuccess] = useState(false); const [loading, setLoading] = useState(false); const onSubmit = async (e) => { e.preventDefault(); if (password !== confirm) { setError(t('register.err_mismatch')); return; } if (password.length < 8) { setError(t('register.err_min_len')); return; } setError(''); setLoading(true); try { if (window.MeshBayKeys) { await window.MeshBayKeys.registerUser(username, email, password); } else { await hubFetch('/v1/users/register', { method: 'POST', body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' }, }); } setSuccess(true); } catch (err) { setError(err.message); } finally { setLoading(false); } }; if (success) { return html`

${t('register.success_title')}

${t('register.success_msg')}

${t('register.go_login')}
`; } return html`

${t('register.title')}

setUsername(e.target.value)} autocomplete="username" required /> setEmail(e.target.value)} autocomplete="email" required /> setPassword(e.target.value)} autocomplete="new-password" required minlength="8" /> setConfirm(e.target.value)} autocomplete="new-password" required /> ${error && html`
${error}
`}
`; } // ── Home Page ──────────────────────────────────────────────────────────────── function NotificationFeed({ notifications, onMarkRead }) { if (!notifications.length) return null; return html`

${t('notif.title')}

${notifications.map(n => html`
{ if (!n.read) onMarkRead(n.id); if (n.link) navigate(n.link); }}> ${n.kind} ${n.title} ${new Date(n.created_at).toLocaleDateString()}
`)}
`; } function HomePage({ groups, notifications, onMarkRead }) { if (groups.length === 0) { return html`

${t('home.welcome')}

<${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} />

${t('home.no_groups')} ${' '}${t('home.browse_prefix')}${t('home.browse_link')}${t('home.browse_suffix')}

`; } return html`

${t('home.my_groups')}

<${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} />
${groups.map(g => html`

${g.name}

${g.visibility} ${' '} ${g.join_policy} ${g.is_admin && html`${' '}admin`}
`)}
`; } // ── Explore Page ───────────────────────────────────────────────────────────── function ExplorePage({ token, myGroupIds }) { const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [joining, setJoining] = useState(null); const doSearch = useCallback((q) => { setLoading(true); const url = q ? `/v1/groups?q=${encodeURIComponent(q)}` : '/v1/groups'; hubFetch(url, { token }) .then(data => setGroups(data.groups || [])) .catch(() => {}) .finally(() => setLoading(false)); }, [token]); useEffect(() => { doSearch(''); }, [token]); const onSearch = useCallback((e) => { const q = e.target.value; setSearch(q); doSearch(q); }, [doSearch]); const joinGroup = useCallback(async (gid) => { setJoining(gid); try { await hubFetch(`/v1/groups/${gid}/join`, { method: 'POST', token }); window.location.reload(); } catch (err) { alert(err.message); } finally { setJoining(null); } }, [token]); const isMember = (gid) => myGroupIds && myGroupIds.includes(gid); return html`

${t('explore.title')}

${t('explore.create_group')}
${loading ? html`

${t('explore.loading')}

` : groups.length === 0 ? html`

${t('explore.empty')}

` : html`
${groups.map(g => html`

${g.name}

${g.join_policy} ${g.source && g.source !== 'local' && html` ${' '}${g.source} `} ${' '} ${isMember(g.id) ? html`${t('explore.member')}` : g.join_policy === 'open' && html` ` }
`)}
` }
`; } // ── Create Group Page ──────────────────────────────────────────────────────── function CreateGroupPage({ token, onCreated }) { const [name, setName] = useState(''); const [visibility, setVisibility] = useState('private'); const [joinPolicy, setJoinPolicy] = useState('invite'); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const onSubmit = async (e) => { e.preventDefault(); if (!name.trim()) return; setLoading(true); setError(''); try { const data = await hubFetch('/v1/groups', { method: 'POST', token, body: { name: name.trim(), visibility, join_policy: joinPolicy }, }); if (window.MeshBayCrypto && window.MeshBayKeys && _sessionKeys) { const gek = window.MeshBayCrypto.generateGEK(); const skXB64 = _sessionKeys.skXB64; const skXRaw = Uint8Array.from(atob(skXB64), c => c.charCodeAt(0)); const skX = await crypto.subtle.importKey( 'pkcs8', skXRaw, { name: 'X25519' }, true, ['deriveBits']); const pkXRaw = new Uint8Array( await crypto.subtle.exportKey('raw', (await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits'])).publicKey)); const meResp = await hubFetch('/v1/users/me', { token }); const pubkeys = await hubFetch(`/v1/users/${meResp.username}/pubkeys`); const pkXBytes = Uint8Array.from(atob(pubkeys.pk_x25519), c => c.charCodeAt(0)); const bundle = await window.MeshBayCrypto.wrapGEK(gek, pkXBytes); await hubFetch(`/v1/groups/${data.group_id}/members/${meResp.username}/gek`, { method: 'POST', token, body: bundle, }); } if (onCreated) onCreated(); navigate('/'); } catch (err) { setError(err.message); } finally { setLoading(false); } }; return html`

${t('create_group.title')}

${error && html`

${error}

`}
setName(e.target.value)} required />
`; } // ── Helpers ────────────────────────────────────────────────────────────────── const FILE_ICONS = { video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}', document: '\u{1F4C4}', archive: '\u{1F4E6}', other: '\u{1F4CE}', }; function formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } function formatDate(ts) { return new Date(ts * 1000).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); } // ── Group Page ────────────────────────────────────────────────────────────── const CHUNK_SIZE = 1024 * 1024; const PIPELINE_WINDOW = 8; async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, writable) { const results = writable ? null : new Array(totalChunks); let nextSend = 0, nextRecv = 0; const inflight = new Array(totalChunks); const fire = () => { while (nextSend < totalChunks && nextSend - nextRecv < PIPELINE_WINDOW) { inflight[nextSend] = transport.fetchChunk(fileId, nextSend); nextSend++; } }; fire(); while (nextRecv < totalChunks) { const chunkMsg = await inflight[nextRecv]; let plaintext; if (gekKey && chunkMsg.ct) { plaintext = await window.MeshBayCrypto.decryptChunkBin( gekKey, fileId, nextRecv, chunkMsg.nonce, chunkMsg.ct); } else if (gekKey && chunkMsg.ct_b64) { plaintext = await window.MeshBayCrypto.decryptChunk( gekKey, fileId, nextRecv, chunkMsg.nonce_b64, chunkMsg.ct_b64); } else { plaintext = _b64ToU8(chunkMsg.ct_b64 || chunkMsg.data_b64); } if (writable) { await writable.write(plaintext); } else { results[nextRecv] = plaintext; } nextRecv++; fire(); if (onChunk) onChunk(plaintext.byteLength, nextRecv, totalChunks); } return results; } function GroupPage({ groupId, group, token, username }) { const [status, setStatus] = useState('idle'); const [entries, setEntries] = useState([]); const [cached, setCached] = useState(false); const [error, setError] = useState(''); const [sortKey, setSortKey] = useState('name'); const [sortAsc, setSortAsc] = useState(true); const [filter, setFilter] = useState(''); const [currentPath, setCurrentPath] = useState(''); const [dlState, setDlState] = useState(null); const [videoEntry, setVideoEntry] = useState(null); const [tab, setTab] = useState('files'); const [uploading, setUploading] = useState(false); const transportRef = useRef(null); const gekRef = useRef(null); useEffect(() => { let cancelled = false; getCachedGroupIndex(groupId).then(hit => { if (hit && !cancelled) { setEntries(hit.entries || []); setCached(true); } }); const connect = async () => { setStatus('discovering'); setError(''); gekRef.current = null; try { const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); if (cancelled) return; if (!nodesData.nodes || nodesData.nodes.length === 0) { setStatus('offline'); return; } setStatus('connecting'); const nodeId = nodesData.nodes[0].node_id; const transport = new window.MeshBayTransport('', token); transportRef.current = transport; await transport.connect(nodeId, token, groupId); if (cancelled) return; setStatus('fetching'); const indexMsg = await transport.fetchIndex(); if (cancelled) return; const freshEntries = indexMsg.entries || []; setEntries(freshEntries); setCached(false); setStatus('connected'); cacheGroupIndex(groupId, group ? group.name : groupId, freshEntries); } catch (err) { if (!cancelled) { setError(err.message); setStatus('error'); } } }; if (token && window.MeshBayTransport) { connect(); } else if (!window.MeshBayTransport) { setStatus('error'); setError(t('group.err_transport')); } return () => { cancelled = true; if (transportRef.current) { transportRef.current.close(); transportRef.current = null; } }; }, [groupId, token]); const downloadFile = useCallback(async (entry) => { const transport = transportRef.current; if (!transport || !transport.connected) return; setDlState({ fileId: entry.id, name: entry.name, progress: 0, total: entry.size }); try { if (!gekRef.current && window.MeshBayCrypto) { const gekB64 = await transport.fetchGEK(); gekRef.current = await window.MeshBayCrypto.importGEK(gekB64); } const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); let downloaded = 0; const onProgress = (bytes) => { downloaded += bytes; setDlState(prev => ({ ...prev, progress: downloaded })); }; if (window.showSaveFilePicker) { const handle = await window.showSaveFilePicker({ suggestedName: entry.name }); const writable = await handle.createWritable(); try { await pipelinedDownload( transport, gekRef.current, entry.id, totalChunks, onProgress, writable); await writable.close(); } catch (err) { await writable.abort(); throw err; } } else { const chunks = await pipelinedDownload( transport, gekRef.current, entry.id, totalChunks, onProgress); const blob = new Blob(chunks); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = entry.name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } setDlState(null); } catch (err) { setDlState(null); if (err.name === 'AbortError') return; setError(t('group.dl_failed', { err: err.message })); } }, []); const uploadFile = useCallback(async (e) => { const file = e.target.files?.[0]; if (!file) return; e.target.value = ''; const transport = transportRef.current; if (!transport || !transport.connected) return; setUploading(true); setError(''); try { const totalChunks = Math.ceil(file.size / CHUNK_SIZE); for (let i = 0; i < totalChunks; i++) { const slice = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE); const buf = new Uint8Array(await slice.arrayBuffer()); await transport.uploadChunk(file.name, i, totalChunks, buf); } const indexMsg = await transport.fetchIndex(); setEntries(indexMsg.entries || []); } catch (err) { setError(err.message); } finally { setUploading(false); } }, []); const toggleSort = useCallback((key) => { setSortAsc(prev => sortKey === key ? !prev : true); setSortKey(key); }, [sortKey]); const dirs = new Set(); const filteredEntries = entries.filter(e => { const ePath = e.path || ''; if (ePath === currentPath) { return !filter || e.name.toLowerCase().includes(filter.toLowerCase()); } if (!currentPath && ePath) { dirs.add(ePath.split('/')[0]); } else if (currentPath && ePath.startsWith(currentPath + '/')) { const rest = ePath.slice(currentPath.length + 1); dirs.add(rest.split('/')[0]); } return false; }); const sorted = [...filteredEntries].sort((a, b) => { let cmp = 0; if (sortKey === 'name') cmp = a.name.localeCompare(b.name); else if (sortKey === 'size') cmp = a.size - b.size; else if (sortKey === 'type') cmp = a.type.localeCompare(b.type); else if (sortKey === 'date') cmp = a.added_at - b.added_at; return sortAsc ? cmp : -cmp; }); const subdirs = [...dirs].sort(); const baseLabel = { idle: t('status.idle'), discovering: t('status.discovering'), connecting: t('status.connecting'), fetching: t('status.fetching'), connected: t('status.files', { n: entries.length }), offline: t('status.offline'), error: t('status.error'), }[status] || status; const statusLabel = (cached && status !== 'connected') ? `${baseLabel} (${t('status.cached', { n: entries.length })})` : baseLabel; const statusClass = status === 'connected' ? 'status-ok' : status === 'error' || status === 'offline' ? 'status-err' : 'status-busy'; const breadcrumbs = currentPath ? currentPath.split('/') : []; return html`

${group ? group.name : t('group.default_name')}

${statusLabel}
${error && html`
${error}
`} ${dlState && html`
${dlState.name}
${formatSize(dlState.progress)} / ${formatSize(dlState.total)}
`} ${status === 'connected' && html`
${tab === 'files' && html`
setFilter(e.target.value)} />
${subdirs.map(d => html` setCurrentPath(currentPath ? currentPath + '/' + d : d)}> `)} ${sorted.map(e => html` `)} ${sorted.length === 0 && subdirs.length === 0 && html` `}
toggleSort('name')}> ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''} toggleSort('size')}> ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''} toggleSort('type')}> ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''} toggleSort('date')}> ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''}
\u{1F4C1} ${d}/
${FILE_ICONS[e.type] || FILE_ICONS.other} ${e.name} ${formatSize(e.size)} ${e.type} ${formatDate(e.added_at)} ${e.type === 'video' && html` `}
${filter ? t('group.empty_filter') : t('group.empty_dir')}
`} ${tab === 'chat' && html` <${ChatPanel} transportRef=${transportRef} username=${username} /> `} ${tab === 'members' && html` <${MembersPanel} groupId=${groupId} group=${group} token=${token} /> `} `} ${status === 'offline' && html`

${t('group.offline_title')} ${' '}${t('group.offline_hint')}

`} ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`

${statusLabel}

`} ${videoEntry && html` <${VideoPlayer} entry=${videoEntry} transportRef=${transportRef} gekRef=${gekRef} onClose=${() => setVideoEntry(null)} /> `}
`; } function _b64ToU8(b64) { const bin = atob(b64); const arr = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); return arr; } // ── Members Panel ──────────────────────────────────────────────────────── function MembersPanel({ groupId, group, token }) { const [members, setMembers] = useState([]); const [adminId, setAdminId] = useState(''); const [loading, setLoading] = useState(true); const [inviteUser, setInviteUser] = useState(''); const [inviting, setInviting] = useState(false); const [error, setError] = useState(''); const loadMembers = useCallback(() => { setLoading(true); hubFetch(`/v1/groups/${groupId}/members`, { token }) .then(data => { setMembers(data.members || []); setAdminId(data.admin_id || ''); }) .catch(() => {}) .finally(() => setLoading(false)); }, [groupId, token]); useEffect(() => { loadMembers(); }, [loadMembers]); const isAdmin = group && group.is_admin; const doInvite = useCallback(async (e) => { e.preventDefault(); if (!inviteUser.trim()) return; setInviting(true); setError(''); try { const pubkeys = await hubFetch(`/v1/users/${inviteUser.trim()}/pubkeys`); const pkXBytes = Uint8Array.from(atob(pubkeys.pk_x25519), c => c.charCodeAt(0)); const gekB64 = await (async () => { const transport = window._activeTransport; if (transport && transport.connected) { return await transport.fetchGEK(); } const bundleResp = await hubFetch(`/v1/groups/${groupId}/gek`, { token }); if (!_sessionKeys) throw new Error('No session keys — log in via browser registration'); const skXB64 = _sessionKeys.skXB64; const skXRaw = Uint8Array.from(atob(skXB64), c => c.charCodeAt(0)); const meResp = await hubFetch('/v1/users/me', { token }); const myPubkeys = await hubFetch(`/v1/users/${meResp.username}/pubkeys`); const myPkX = Uint8Array.from(atob(myPubkeys.pk_x25519), c => c.charCodeAt(0)); const rawGek = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw, myPkX); return btoa(String.fromCharCode(...rawGek)); })(); const gekBytes = Uint8Array.from(atob(gekB64), c => c.charCodeAt(0)); const bundle = await window.MeshBayCrypto.wrapGEK(gekBytes, pkXBytes); await hubFetch(`/v1/groups/${groupId}/members/${inviteUser.trim()}/gek`, { method: 'POST', token, body: bundle, }); setInviteUser(''); loadMembers(); } catch (err) { setError(err.message); } finally { setInviting(false); } }, [groupId, token, inviteUser, loadMembers]); if (loading) return html`

${t('explore.loading')}

`; return html`
${members.map(m => html` `)}
${t('admin.col_username')} ${t('members.col_role')}
${m.username} ${m.user_id === adminId ? t('members.admin') : t('members.member')}
${isAdmin && html`

${t('members.invite_title')}

${error && html`

${error}

`}
setInviteUser(e.target.value)} required />
`}
`; } // ── Chat Panel ────────────────────────────────────────────────────────── function formatTime(ts) { const d = new Date(ts * 1000); const now = new Date(); const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); if (d.toDateString() === now.toDateString()) return time; return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + time; } function ChatPanel({ transportRef, username }) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); const listRef = useRef(null); const bottomRef = useRef(null); const loadedRef = useRef(false); useEffect(() => { const transport = transportRef.current; if (!transport || !transport.connected) return; if (!loadedRef.current) { loadedRef.current = true; transport.fetchChatHistory(0, 200) .then(msgs => setMessages(msgs)) .catch(() => {}); } transport.onChat = (msg) => { setMessages(prev => [...prev, { sender_id: msg.sender_id, payload: msg.payload, timestamp: msg.timestamp || Date.now() / 1000, thread_id: msg.thread_id, }]); }; return () => { transport.onChat = null; }; }, [transportRef.current?.connected]); useEffect(() => { if (bottomRef.current) { bottomRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [messages.length]); const sendMessage = useCallback(async () => { const text = input.trim(); if (!text) return; const transport = transportRef.current; if (!transport || !transport.connected) return; setSending(true); setInput(''); try { await transport.sendChat(text, 0, null); setMessages(prev => [...prev, { sender_id: username, payload: text, timestamp: Date.now() / 1000, thread_id: null, }]); } catch { setInput(text); } finally { setSending(false); } }, [input, username]); const onKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }, [sendMessage]); return html`
${messages.length === 0 && html`
${t('chat.empty')}
`} ${messages.map((m, i) => { const isOwn = m.sender_id === username; const showSender = !isOwn && (i === 0 || messages[i - 1].sender_id !== m.sender_id); return html`
${showSender && html`
${m.sender_id}
`}
${m.payload} ${formatTime(m.timestamp)}
`; })}