diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 770 |
1 files changed, 550 insertions, 220 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 6a5a13f..7968284 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -1,5 +1,5 @@ import { - html, render, useState, useEffect, useCallback, useRef, + html, render, useState, useEffect, useLayoutEffect, useCallback, useRef, createContext, useContext, } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js'; @@ -245,6 +245,16 @@ const ICON_PATHS = { globe: ['M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18', 'M3.4 9.2h17.2M3.4 14.8h17.2', 'M12 3c-2.6 2.4-4 5.6-4 9s1.4 6.6 4 9c2.6-2.4 4-5.6 4-9s-1.4-6.6-4-9'], + archive: ['M3 7.5h18v3H3z', 'M4.5 10.5V19a1.5 1.5 0 0 0 1.5 1.5h12a1.5 1.5 0 0 0 1.5-1.5v-8.5', + 'M10 14h4'], + play: ['M8 5.5v13l11-6.5z'], + eye: ['M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12', + 'M12 14.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5'], + trash: ['M4 7h16', 'M10 11v6M14 11v6', + 'M6 7l1 12.5A1.5 1.5 0 0 0 8.5 21h7a1.5 1.5 0 0 0 1.5-1.5L18 7', + 'M9.5 7V5a1.5 1.5 0 0 1 1.5-1.5h2A1.5 1.5 0 0 1 14.5 5v2'], + user: ['M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8', + 'M4.5 20a7.5 7.5 0 0 1 15 0'], gear: ['M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6', 'M19.2 14.4a1.7 1.7 0 0 0 .3 1.9 2 2 0 1 1-2.8 2.8 1.7 1.7 0 0 0-2.9 1.2 2 2 0 0 1-4 0 1.7 1.7 0 0 0-2.9-1.2 2 2 0 1 1-2.8-2.8 1.7 1.7 0 0 0-1.2-2.9 2 2 0 0 1 0-4 1.7 1.7 0 0 0 1.2-2.9 2 2 0 1 1 2.8-2.8 1.7 1.7 0 0 0 2.9-1.2 2 2 0 0 1 4 0 1.7 1.7 0 0 0 2.9 1.2 2 2 0 1 1 2.8 2.8 1.7 1.7 0 0 0 1.2 2.9 2 2 0 0 1 0 4 1.7 1.7 0 0 0-1.5 1.1z'], sun: ['M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6', @@ -335,6 +345,9 @@ function UserMenu({ user, theme, onThemeChange, onLogout }) { && html`<${Icon} name="check" cls="umi-check" />`} </button> `)} + <button class="user-menu-item" onClick=${() => { setOpen(false); navigate('/profile'); }}> + <${Icon} name="user" cls="umi-icon" /> ${t('usermenu.profile')} + </button> <button class="user-menu-item" onClick=${() => { setOpen(false); navigate('/settings'); }}> <${Icon} name="gear" cls="umi-icon" /> ${t('usermenu.settings')} </button> @@ -473,7 +486,7 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount } // ── Sidebar ────────────────────────────────────────────────────────────────── -function Sidebar({ groups, route, menuOpen, role }) { +function Sidebar({ groups, presence, route, menuOpen, role }) { const isStaff = role === 'moderator' || role === 'admin'; return html` <aside class="sidebar ${menuOpen ? 'open' : ''}"> @@ -497,13 +510,25 @@ function Sidebar({ groups, route, menuOpen, role }) { <div class="sidebar-heading">${t('sidebar.my_groups')}</div> ${groups.length === 0 ? html`<div class="sidebar-empty">${t('sidebar.no_groups')}</div>` - : groups.map(g => html` - <a key=${g.id} - class="sidebar-item ${route === '/group/' + g.id ? 'active' : ''}" - href="#/group/${g.id}"> - ${g.name} - </a> - `) + : groups.map(g => { + // Three states, each backed by something. `node_online` comes from + // the hub's signaling registry and rides on the group list itself, + // so there is no poll and no timer; a connection this browser tried + // and failed overrides it, because that is the fact the reader + // actually cares about. Anything else is "not known yet". + const state = presence[g.id] ?? (g.node_online === true ? 'online' + : g.node_online === false ? 'offline' : 'unknown'); + return html` + <a key=${g.id} + class="sidebar-item ${route === '/group/' + g.id ? 'active' : ''}" + href="#/group/${g.id}"> + <span class="presence presence-${state}" + title="${t('presence.' + state)}" + aria-label="${t('presence.' + state)}"></span> + <span class="sidebar-item-name">${g.name}</span> + </a> + `; + }) } </div> </aside> @@ -842,21 +867,20 @@ function CreateGroupPage({ token, onCreated }) { }; return html` - <div class="create-group-page"> - <div class="create-group-card"> - <div class="create-group-header"> - <h2>${t('create_group.title')}</h2> - <p class="create-group-hint">${t('create_group.hint')}</p> - </div> - ${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`} - <form onSubmit=${onSubmit}> + <div> + <h2>${t('create_group.title')}</h2> + <p class="page-message">${t('create_group.hint')}</p> + ${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`} + + <form onSubmit=${onSubmit}> + <div class="settings-section"> <div class="form-field"> <label class="form-label">${t('create_group.name')}</label> <input type="text" placeholder="${t('create_group.name_placeholder')}" value=${name} onInput=${e => setName(e.target.value)} required autofocus /> </div> - <div class="form-field"> + <div class="form-field" style="margin-bottom:0"> <label class="form-label">${t('create_group.description')}</label> <textarea class="form-textarea" rows="3" maxlength="512" placeholder="${t('create_group.description_hint')}" @@ -864,48 +888,66 @@ function CreateGroupPage({ token, onCreated }) { onInput=${e => setDescription(e.target.value)} /> <div class="form-char-count">${description.length}/512</div> </div> + </div> - <div class="form-field"> - <label class="form-label">${t('create_group.visibility')}</label> - <div class="option-cards"> - <button type="button" class="option-card ${visibility === 'private' ? 'selected' : ''}" - onClick=${() => setVisibility('private')}> - <${Icon} name="lock" cls="option-icon" /> - <span class="option-title">${t('create_group.private')}</span> - <span class="option-desc">${t('create_group.private_desc')}</span> - </button> - <button type="button" class="option-card ${visibility === 'public' ? 'selected' : ''}" - onClick=${() => setVisibility('public')}> - <${Icon} name="globe" cls="option-icon" /> - <span class="option-title">${t('create_group.public')}</span> - <span class="option-desc">${t('create_group.public_desc')}</span> - </button> - </div> + <div class="settings-section"> + <h3 class="settings-heading">${t('create_group.visibility')}</h3> + <div class="choice-list"> + <label class="choice ${visibility === 'private' ? 'selected' : ''}"> + <input type="radio" name="visibility" checked=${visibility === 'private'} + onChange=${() => { setVisibility('private'); setJoinPolicy('invite'); }} /> + <${Icon} name="lock" cls="choice-icon" /> + <span class="choice-text"> + <span class="choice-title">${t('create_group.private')}</span> + <span class="choice-desc">${t('create_group.private_desc')}</span> + </span> + </label> + <label class="choice ${visibility === 'public' ? 'selected' : ''}"> + <input type="radio" name="visibility" checked=${visibility === 'public'} + onChange=${() => { setVisibility('public'); setJoinPolicy('open'); }} /> + <${Icon} name="globe" cls="choice-icon" /> + <span class="choice-text"> + <span class="choice-title">${t('create_group.public')}</span> + <span class="choice-desc">${t('create_group.public_desc')}</span> + </span> + </label> </div> - <div class="form-field"> - <label class="form-label">${t('create_group.join_policy')}</label> - <div class="option-cards"> - <button type="button" class="option-card ${joinPolicy === 'invite' ? 'selected' : ''}" - onClick=${() => setJoinPolicy('invite')}> - <${Icon} name="envelope" cls="option-icon" /> - <span class="option-title">${t('create_group.invite')}</span> - <span class="option-desc">${t('create_group.invite_desc')}</span> - </button> - <button type="button" class="option-card ${joinPolicy === 'open' ? 'selected' : ''}" - onClick=${() => setJoinPolicy('open')}> - <${Icon} name="door" cls="option-icon" /> - <span class="option-title">${t('create_group.open')}</span> - <span class="option-desc">${t('create_group.open_desc')}</span> - </button> - </div> - </div> + ${visibility === 'public' + ? html`<p class="settings-hint" style="margin-top:12px"> + ${t('create_group.public_is_open')} + </p>` + : html` + <h3 class="settings-heading" style="margin-top:20px"> + ${t('create_group.join_policy')} + </h3> + <div class="choice-list"> + <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}"> + <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'} + onChange=${() => setJoinPolicy('invite')} /> + <${Icon} name="envelope" cls="choice-icon" /> + <span class="choice-text"> + <span class="choice-title">${t('create_group.invite')}</span> + <span class="choice-desc">${t('create_group.invite_desc')}</span> + </span> + </label> + <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}"> + <input type="radio" name="join_policy" checked=${joinPolicy === 'open'} + onChange=${() => setJoinPolicy('open')} /> + <${Icon} name="door" cls="choice-icon" /> + <span class="choice-text"> + <span class="choice-title">${t('create_group.open')}</span> + <span class="choice-desc">${t('create_group.open_desc')}</span> + </span> + </label> + </div> + `} + </div> - <button class="create-group-submit" type="submit" disabled=${loading}> - ${loading ? t('create_group.creating') : t('create_group.submit')} - </button> - </form> - </div> + <button class="btn-primary" type="submit" disabled=${loading}> + ${loading ? t('create_group.creating') : t('create_group.submit')} + </button> + </form> </div> `; } @@ -1042,14 +1084,13 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk } function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, - onJoined, onGroupUpdated }) { + onJoined, onGroupUpdated, onPresence, onLeft }) { const [status, setStatus] = useState('idle'); const [entries, setEntries] = useState([]); const [error, setError] = useState(''); const [selecting, setSelecting] = useState(false); const [selected, setSelected] = useState(() => new Set()); - const [actionsOpen, setActionsOpen] = useState(false); const [editingDesc, setEditingDesc] = useState(false); const [descDraft, setDescDraft] = useState(''); const [savingDesc, setSavingDesc] = useState(false); @@ -1090,13 +1131,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, setRetryKey(k => k + 1); }, [codeInput]); - useEffect(() => { - if (!actionsOpen) return; - const close = () => setActionsOpen(false); - document.addEventListener('click', close); - return () => document.removeEventListener('click', close); - }, [actionsOpen]); - // One place that takes an index from the node and puts it everywhere it has to // go. Deleting a file used to refresh the table and leave the cache alone, so // the search page went on offering a file that no longer existed until the @@ -1127,6 +1161,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, if (cancelled) return; if (!nodesData.nodes || nodesData.nodes.length === 0) { setStatus('offline'); + if (onPresence) onPresence(groupId, 'offline'); return; } @@ -1179,6 +1214,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, if (cancelled) return; applyIndex(indexMsg); setStatus('connected'); + // First-hand evidence, and the strongest available: this browser spoke + // to the node. It outranks whatever the hub said in the group list. + if (onPresence) onPresence(groupId, 'online'); } catch (err) { if (cancelled) return; @@ -1198,6 +1236,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, if (err.reason === 'code_required') setNeedsCode(true); setError(err.message); setStatus('error'); + // A refusal means the node answered, so it is up; only a failure to + // reach it at all is evidence of absence. + if (onPresence) { + onPresence(groupId, err.reason ? 'online' : 'offline'); + } } }; @@ -1508,7 +1551,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const deletableFiles = selectedFiles.filter( e => isNodeAdmin || (userId && e.uploader_id === userId)); const run = (fn) => { - setActionsOpen(false); setSelecting(false); setSelected(new Set()); Promise.resolve().then(fn).catch(err => { @@ -1516,41 +1558,60 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, }); }; + // Icon only, with the name in the tooltip: these sit in a toolbar that is + // already narrow, and every one of them is a verb the icon carries on its + // own. `title` gives the hover text and `aria-label` the accessible name — + // an icon button with neither is unusable with a screen reader. + // + // Every action is rendered as soon as Select is on, and the ones that do not + // apply are disabled rather than absent. Buttons appearing and vanishing as + // the selection changed made the bar jump about and gave no clue that an + // action existed at all before something was ticked. + const action = (icon, label, onClick, opts = {}) => html` + <button class="tb-icon-btn ${opts.danger ? 'danger' : ''}" + title=${label} aria-label=${label} + disabled=${!!opts.disabled} onClick=${onClick}> + <${Icon} name=${icon} /> + </button> + `; + + const canPlay = !!(onlyFile && onlyFile.type === 'video'); + const canView = !!(onlyFile && onlyFile.type !== 'video' && canPreview(onlyFile)); + const deletableCount = deletableFiles.length + + (operatorPaired ? selectedDirs.length : 0); + // The operator can always delete; anyone else only ever sees the button if + // something here is theirs to remove. Hiding it from an uploader would take + // away a right the protocol grants them (draft-v5 §5.1), not just a control. + const mayEverDelete = isNodeAdmin + || (userId && entries.some(e => e.uploader_id === userId)); + const actionItems = html` - ${onlyFile && onlyFile.type === 'video' && html` - <button onClick=${() => run(() => setVideoEntry(onlyFile))}> - <span class="fmi">${'\u{25B6}'}</span> ${t('group.play')} - </button> - `} - ${onlyFile && onlyFile.type !== 'video' && canPreview(onlyFile) && html` - <button onClick=${() => run(() => setPreviewEntry(onlyFile))}> - <span class="fmi">${'\u{1F441}'}</span> ${t('group.view')} - </button> - `} - ${selectedFiles.length > 0 && html` - <button onClick=${() => run(async () => { + ${action('play', t('group.play'), + () => run(() => setVideoEntry(onlyFile)), { disabled: !canPlay })} + ${action('eye', t('group.view'), + () => run(() => setPreviewEntry(onlyFile)), { disabled: !canView })} + ${action('download', + selectedFiles.length + ? t('group.download_n', { n: selectedFiles.length }) + : t('group.download'), + () => run(async () => { // Awaited one at a time, and each returns as soon as its transfer is // registered — so the transfers still run together. Firing them without // awaiting meant every file asked the browser for a save dialog at // once, and a browser allows one: the rest were rejected and only the // first file ever downloaded. for (const e of selectedFiles) await downloadFile(e); - })}> - <span class="fmi">${'\u{2B07}'}</span> - ${t('group.download_n', { n: selectedFiles.length })} - </button> - `} - ${selectedDirs.length > 0 && html` - <button onClick=${() => run(async () => { + }), { disabled: selectedFiles.length === 0 })} + ${action('archive', + selectedDirs.length + ? t('group.download_zip_n', { n: selectedDirs.length }) + : t('group.download_zip_n', { n: 0 }), + () => run(async () => { for (const d of selectedDirs) await downloadDirectory(d); - })}> - <span class="fmi">${'\u{2B07}'}</span> - ${t('group.download_zip_n', { n: selectedDirs.length })} - </button> - `} - ${status === 'connected' && (deletableFiles.length > 0 - || (operatorPaired && selectedDirs.length > 0)) && html` - <button class="danger" onClick=${() => { + }), { disabled: selectedDirs.length === 0 })} + ${mayEverDelete && action('trash', + deletableCount ? t('group.delete_n', { n: deletableCount }) : t('group.delete'), + () => { const names = [...deletableFiles.map(e => e.name), ...(operatorPaired ? selectedDirs : [])]; if (!confirm(t('group.delete_n_confirm', { n: names.length, @@ -1559,12 +1620,8 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, for (const e of deletableFiles) deleteFile(e); if (operatorPaired) for (const d of selectedDirs) deleteDirectory(d); }); - }}> - <span class="fmi">${'\u{1F5D1}'}</span> - ${t('group.delete_n', { n: deletableFiles.length - + (operatorPaired ? selectedDirs.length : 0) })} - </button> - `} + }, + { danger: true, disabled: status !== 'connected' || deletableCount === 0 })} `; return html` @@ -1620,6 +1677,19 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, } catch (err) { setError(err.message); } }}>${t('group.delete_group')}</button> `} + ${group && !group.is_admin && html` + <button class="admin-btn danger" style="margin-left:auto" + onClick=${async () => { + if (!confirm(t('group.leave_confirm', { name: group.name }))) return; + try { + await hubFetch('/v1/groups/' + groupId + '/leave', + { method: 'POST', token }); + // Dropped from the list here rather than reloading: a reload + // would tear down the WebRTC connections other groups hold. + if (onLeft) onLeft(groupId); + } catch (err) { setError(err.message); } + }}>${t('group.leave')}</button> + `} </div> ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`} ${needsCode && html` @@ -1679,21 +1749,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, onClick=${() => { setSelecting(v => !v); setSelected(new Set()); - setActionsOpen(false); }}> <${Icon} name=${selecting ? 'check' : 'checkbox'} /> ${selecting ? t('group.select_done') : t('group.select')} </button> - ${selecting && html` - <div class="actions-wrap"> - <button class="tb-btn" disabled=${selected.size === 0} - onClick=${(ev) => { ev.stopPropagation(); setActionsOpen(o => !o); }}> - <${Icon} name="dots" /> - ${t('group.actions', { n: selected.size })} - </button> - ${actionsOpen && html`<div class="file-menu">${actionItems}</div>`} - </div> - `} + ${selecting && html`<div class="tb-actions">${actionItems}</div>`} </div> </div> <table class="file-table"> @@ -2198,9 +2258,11 @@ function linkify(text) { function formatTime(ts) { const d = new Date(ts * 1000); const now = new Date(); - const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + // getLocale() rather than the browser default: the user may have picked a + // language here that differs from the one their OS reports. + const time = d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' }); if (d.toDateString() === now.toDateString()) return time; - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + time; + return d.toLocaleDateString(getLocale(), { month: 'short', day: 'numeric' }) + ' ' + time; } function _parsePayload(raw) { @@ -2210,6 +2272,37 @@ function _parsePayload(raw) { return null; } +// How much history a group opens with, and how much each "older" click adds. +const CHAT_PAGE = 100; +const CHAT_OLDER_PAGE = 50; + +// Breathing room under the panel, and the floor below which shrinking it stops +// helping — past that the page may scroll after all, which beats a chat two +// lines tall. +const CHAT_BOTTOM_GAP = 16; +const CHAT_MIN_HEIGHT = 240; + +function _sameDay(a, b) { + const da = new Date(a * 1000), db = new Date(b * 1000); + return da.getFullYear() === db.getFullYear() + && da.getMonth() === db.getMonth() + && da.getDate() === db.getDate(); +} + +/** "Today" / "Yesterday" / a written date, in the reader's language. */ +function _dayLabel(ts) { + const d = new Date(ts * 1000); + const now = new Date(); + if (_sameDay(ts, now.getTime() / 1000)) return t('chat.today'); + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (_sameDay(ts, yesterday.getTime() / 1000)) return t('chat.yesterday'); + return d.toLocaleDateString(getLocale(), { + weekday: 'long', day: 'numeric', month: 'long', + year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', + }); +} + function ChatImage({ filename, entries, transportRef, gekRef }) { const [blobUrl, setBlobUrl] = useState(null); const [loading, setLoading] = useState(true); @@ -2251,12 +2344,20 @@ function ChatImage({ filename, entries, transportRef, gekRef }) { function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, onPreview }) { const [messages, setMessages] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [atBottom, setAtBottom] = useState(true); + const [unreadFrom, setUnreadFrom] = useState(null); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); const [attaching, setAttaching] = useState(false); const listRef = useRef(null); - const bottomRef = useRef(null); + const panelRef = useRef(null); const loadedRef = useRef(false); + // Set just before older messages are prepended; read once, after the DOM has + // them but before the browser paints. + const anchorRef = useRef(null); + const atBottomRef = useRef(true); useEffect(() => { const transport = transportRef.current; @@ -2264,29 +2365,123 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on if (!loadedRef.current) { loadedRef.current = true; - transport.fetchChatHistory(0, 200) - .then(msgs => setMessages(msgs)) + // The newest page. This used to be fetchChatHistory(0, 200), which paged + // forwards from the very first message ever sent, so a busy group opened + // on its oldest screen and the recent conversation was unreachable. + transport.fetchChatHistory({ limit: CHAT_PAGE }) + .then(({ messages: msgs, hasMore: more }) => { + setMessages(msgs); + setHasMore(more); + }) .catch(() => {}); } transport.onChat = (msg) => { + // A live message has no row id until it is re-read from the node, so it + // gets a local one. Keys have to be stable and unique or prepending a + // page makes Preact reuse the wrong bubbles. Computed once and reused + // below: the unread marker points at a message by id, so generating a + // second one there would point it at nothing. + const id = msg.id + || `live-${Date.now()}-${Math.random().toString(36).slice(2)}`; setMessages(prev => [...prev, { + id, sender_id: msg.sender_id, sender_name: msg.sender_name || '', payload: msg.payload, timestamp: msg.timestamp || Date.now() / 1000, thread_id: msg.thread_id, }]); + // Somebody wrote while you were reading further up: mark where you were + // rather than yanking the view down. + if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id); }; return () => { transport.onChat = null; }; }, [transportRef.current?.connected]); - useEffect(() => { - if (bottomRef.current) { - bottomRef.current.scrollIntoView({ behavior: 'smooth' }); + const loadOlder = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected || loadingOlder || !messages.length) return; + setLoadingOlder(true); + const list = listRef.current; + // Keeping the reading position means restoring the distance from the + // *bottom*, not scrollTop: everything above the viewport just grew. + anchorRef.current = list ? list.scrollHeight - list.scrollTop : null; + try { + const { messages: older, hasMore: more } = + await transport.fetchChatHistory({ before: messages[0].id, limit: CHAT_OLDER_PAGE }); + setMessages(prev => [...older, ...prev]); + setHasMore(more); + } catch { + anchorRef.current = null; + } finally { + setLoadingOlder(false); + } + }, [messages, loadingOlder]); + + useLayoutEffect(() => { + const list = listRef.current; + if (!list) return; + if (anchorRef.current !== null) { + list.scrollTop = list.scrollHeight - anchorRef.current; + anchorRef.current = null; + return; } - }, [messages.length]); + // Only follow the conversation if the reader was already at the bottom. + // Scrolling unconditionally fought every attempt to read back through it. + // + // scrollTop rather than bottomRef.scrollIntoView: the sentinel has no + // height, so aligning it to the bottom of the viewport leaves the list's + // own padding below it and the bar stops just short of the end. + if (atBottomRef.current) list.scrollTop = list.scrollHeight; + }, [messages]); + + // The panel was `calc(100vh - 220px)`: a guess at how much sits above it. On a + // phone the group header — title, description, edit link, delete button, tabs + // — is closer to 430px, so the panel ran past the fold and the composer ended + // up off screen with the whole page scrolling to reach it. + // + // Measured instead, from the panel's own position in the document, so the + // header can be any height. `visualViewport` rather than innerHeight where it + // exists: on Android the on-screen keyboard shrinks the visual viewport + // without changing innerHeight, and the composer would go back under it. + useLayoutEffect(() => { + const el = panelRef.current; + if (!el) return; + const fit = () => { + const vh = window.visualViewport?.height || window.innerHeight; + // Document-relative, so a page that happens to be scrolled does not skew + // the result — the answer must be the same either way. + const top = el.getBoundingClientRect().top + window.scrollY; + el.style.height = `${Math.max(CHAT_MIN_HEIGHT, vh - top - CHAT_BOTTOM_GAP)}px`; + }; + fit(); + window.addEventListener('resize', fit); + window.addEventListener('orientationchange', fit); + window.visualViewport?.addEventListener('resize', fit); + return () => { + window.removeEventListener('resize', fit); + window.removeEventListener('orientationchange', fit); + window.visualViewport?.removeEventListener('resize', fit); + }; + }, []); + + const onScroll = useCallback((e) => { + const el = e.target; + const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + atBottomRef.current = bottom; + setAtBottom(bottom); + if (bottom) setUnreadFrom(null); + }, []); + + const jumpToBottom = useCallback(() => { + atBottomRef.current = true; + setAtBottom(true); + setUnreadFrom(null); + const list = listRef.current; + if (list) list.scrollTo({ top: list.scrollHeight, behavior: 'smooth' }); + }, []); const sendMessage = useCallback(async () => { const text = input.trim(); @@ -2299,18 +2494,20 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on try { await transport.sendChat(text, 0, null, username); setMessages(prev => [...prev, { + id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, sender_id: username, sender_name: username, payload: text, timestamp: Date.now() / 1000, thread_id: null, }]); + jumpToBottom(); } catch { setInput(text); } finally { setSending(false); } - }, [input, username]); + }, [input, username, jumpToBottom]); const attachFile = useCallback(async (e) => { const file = e.target.files?.[0]; @@ -2334,15 +2531,17 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on }); await transport.sendChat(structured, 0, null, username); setMessages(prev => [...prev, { + id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, sender_id: username, sender_name: username, payload: structured, timestamp: Date.now() / 1000, thread_id: null, }]); + jumpToBottom(); } catch (err) { alert(err.message); } finally { setAttaching(false); } - }, [username, onRefreshIndex]); + }, [username, onRefreshIndex, jumpToBottom]); const onKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -2352,20 +2551,44 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on }, [sendMessage]); return html` - <div class="chat-panel"> - <div class="chat-messages" ref=${listRef}> + <div class="chat-panel" ref=${panelRef}> + <div class="chat-messages" ref=${listRef} onScroll=${onScroll}> + ${hasMore && html` + <div class="chat-older-row"> + <button class="chat-older-btn" onClick=${loadOlder} disabled=${loadingOlder}> + ${loadingOlder + ? html`<span class="spinner"></span>` + : html`<${Icon} name="chevron" cls="chat-older-icon" />`} + ${' '}${t('chat.load_older', { n: CHAT_OLDER_PAGE })} + </button> + </div> + `} + ${!hasMore && messages.length > 0 && html` + <div class="chat-start">${t('chat.start_of_history')}</div> + `} ${messages.length === 0 && html` <div class="chat-empty">${t('chat.empty')}</div> `} ${messages.map((m, i) => { const isOwn = m.sender_name === username || m.sender_id === username; const displayName = m.sender_name || '?'; + const prev = messages[i - 1]; const showSender = !isOwn && (i === 0 || - (messages[i - 1].sender_name || messages[i - 1].sender_id) !== (m.sender_name || m.sender_id)); + (prev.sender_name || prev.sender_id) !== (m.sender_name || m.sender_id)); + // A conversation read over several days is unreadable without them. + const daySep = i === 0 || !_sameDay(prev.timestamp, m.timestamp) + ? _dayLabel(m.timestamp) : null; const parsed = _parsePayload(m.payload); const att = parsed && parsed.attachment; return html` - <div key=${i} class="chat-msg ${isOwn ? 'chat-msg-own' : ''}"> + ${daySep && html` + <div class="chat-day" key=${'d' + m.id}><span>${daySep}</span></div> + `} + ${unreadFrom && unreadFrom === m.id && html` + <div class="chat-unread" key=${'u' + m.id}><span>${t('chat.unread')}</span></div> + `} + <div key=${m.id} class="chat-msg ${isOwn ? 'chat-msg-own' : ''} + ${showSender || daySep ? '' : 'chat-msg-tight'}"> ${showSender && html` <div class="chat-sender">${displayName}</div> `} @@ -2396,8 +2619,13 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on </div> `; })} - <div ref=${bottomRef} /> </div> + ${!atBottom && messages.length > 0 && html` + <button class="chat-jump ${unreadFrom ? 'unread' : ''}" onClick=${jumpToBottom}> + <${Icon} name="chevron" cls="chat-jump-icon" /> + ${' '}${unreadFrom ? t('chat.jump_new') : t('chat.jump_latest')} + </button> + `} <div class="chat-input-row"> <label class="chat-attach" title="${t('chat.attach')}"> ${attaching ? html`<span class="spinner"></span>` @@ -2493,6 +2721,16 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { useEffect(() => { let cancelled = false; + // Reset here, not in the teardown of the run before: switching video while + // an append was in flight left `appendingRef` true, and flushQueue bails + // out on it. The new SourceBuffer then never appended anything, so no + // `updateend` ever cleared the flag, no credit went back to the node, and + // the player sat on "buffering" for good. `endedRef` surviving is the same + // shape of bug — the next stream would call endOfStream() the first time + // its queue ran dry and truncate the film. + appendingRef.current = false; + endedRef.current = false; + queueRef.current = []; const transport = transportRef.current; if (!transport || !transport.connected) { setError(t('video.err_transport')); @@ -2511,8 +2749,17 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { }; const startStream = async () => { + transport.onStreamError = (msg) => { + if (cancelled) return; + // Say what the node said. Sitting on "buffering" with the reason + // already delivered is the worst of both. + setError(msg.detail || t('video.err_transport')); + setPhase('error'); + }; + transport.onStreamInit = (msg) => { if (cancelled) return; + if (msg.file_id && msg.file_id !== entry.id) return; const mime = `video/mp4; codecs="${msg.codec}"`; if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) { @@ -2558,6 +2805,11 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { transport.onStreamData = async (msg) => { if (cancelled) return; + // Late segments from the stream we just left. The DataChannel is + // ordered, so they arrive before the new stream's first segment and + // would otherwise be decrypted against the wrong file — which fails, + // loudly, in the console, for something that is simply not ours. + if (msg.file_id && msg.file_id !== entry.id) return; try { const plaintext = await window.MeshBayCrypto.decryptChunkBin( gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct); @@ -2568,8 +2820,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { } }; - transport.onStreamEnd = () => { + transport.onStreamEnd = (msg) => { if (cancelled) return; + // The end of the previous film is not the end of this one. + if (msg && msg.file_id && msg.file_id !== entry.id) return; endedRef.current = true; flushQueue(); }; @@ -2577,12 +2831,35 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { transport.requestStream(entry.id); }; + // Closing the tab, or backgrounding it on a phone, never runs a React + // cleanup — so the node hears nothing and keeps transcoding. `pagehide` + // fires in both cases and is the one event mobile browsers honour on the + // way out; `visibilitychange` covers switching apps. The node stops the + // stream by itself when the connection drops, but that costs a round of + // detection, and this message is a single datagram already in flight. + const leave = (why) => { + const t = transportRef.current; + console.log('[MeshBay] stopStream:', why); + if (t && t.connected) t.stopStream(); + }; + const onPageHide = () => leave('pagehide'); + // NOT wired to stopStream. Android fires visibilitychange when a video goes + // fullscreen, so cutting the stream here killed the film the moment it was + // watched properly. Logged only, until that is confirmed or ruled out. + const onVisibility = () => { + console.log('[MeshBay] visibilitychange:', document.visibilityState); + }; + window.addEventListener('pagehide', onPageHide); + document.addEventListener('visibilitychange', onVisibility); + startStream().catch(err => { if (!cancelled) { setError(err.message); setPhase('error'); } }); return () => { cancelled = true; + window.removeEventListener('pagehide', onPageHide); + document.removeEventListener('visibilitychange', onVisibility); if (videoRef.current) { videoRef.current.removeEventListener('seeking', onSeeking); } @@ -2593,6 +2870,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { transport.onStreamInit = null; transport.onStreamData = null; transport.onStreamEnd = null; + transport.onStreamError = null; } // The queue can hold several megabytes of decrypted video. queueRef.current = []; @@ -2774,11 +3052,14 @@ function SearchPage() { const THEME_OPTIONS = ['light', 'dark', 'system']; -function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) { - const [locale, setLoc] = useState(getLocale); - // Comes from the hub with the group list, so it is the same on every device. - const [muted, setMuted] = useState( - () => Object.fromEntries((groups || []).map(g => [g.id, !!g.muted]))); +// ── Profile Page ──────────────────────────────────────────────────────────── +// +// Split out of Settings: these four are about *you* — who the account is, the +// node you operate, the node identities this browser has pinned, and closing +// the account. Settings is about how the application behaves. Mixing them put +// an irreversible button two scrolls under a theme picker. + +function ProfilePage({ user, onLogout }) { const [nodeKey, setNodeKey] = useState(''); const [currentNodeKey, setCurrentNodeKey] = useState(null); const [nodeKeyStatus, setNodeKeyStatus] = useState(''); @@ -2824,46 +3105,6 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) { .catch(() => {}); }, [user.username, user.token]); - const onLocaleChange = useCallback((e) => { - const code = e.target.value; - setLocale(code); - setLoc(code); - window.location.reload(); - }, []); - - const onThemeSelect = useCallback((e) => { - onThemeChange(e.target.value); - }, [onThemeChange]); - - const toggleMute = useCallback(async (gid) => { - // Server-side: this used to write to localStorage, which nothing read, so - // muting a group had no effect on anything. The hub now declines to create - // the notification at all. - const next = !muted[gid]; - setMuted(prev => ({ ...prev, [gid]: next })); - try { - await hubFetch(`/v1/groups/${gid}/mute`, { - method: 'POST', token: user.token, body: { muted: next }, - }); - } catch (err) { - setMuted(prev => ({ ...prev, [gid]: !next })); - } - }, [muted, user.token]); - - const [dlMode, setDlMode] = useState(() => downloads.getMode()); - const [dlDir, setDlDir] = useState(null); - - useEffect(() => { downloads.savedDirectory().then(setDlDir); }, []); - - const pickFolder = useCallback(async () => { - try { - const handle = await downloads.chooseDirectory(); - setDlDir(handle); - } catch (err) { - if (err.name !== 'AbortError') setNodeKeyStatus(err.message); - } - }, []); - const submitNodeKey = useCallback(async () => { const key = nodeKey.trim(); if (!key) return; @@ -2886,49 +3127,7 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) { return html` <div> - <h2>${t('settings.title')}</h2> - - <div class="settings-section"> - <h3 class="settings-heading">${t('settings.downloads')}</h3> - ${!downloads.SUPPORTED - ? html`<p class="settings-hint">${t('settings.dl_unsupported')}</p>` - : html` - <label class="settings-choice"> - <input type="radio" name="dlmode" checked=${dlMode === 'auto'} - onChange=${() => { downloads.setMode('auto'); setDlMode('auto'); }} /> - <span> - <strong>${t('settings.dl_auto')}</strong> - <span class="settings-hint">${t('settings.dl_auto_hint')}</span> - </span> - </label> - <label class="settings-choice"> - <input type="radio" name="dlmode" checked=${dlMode === 'ask'} - onChange=${() => { downloads.setMode('ask'); setDlMode('ask'); }} /> - <span> - <strong>${t('settings.dl_ask')}</strong> - <span class="settings-hint">${t('settings.dl_ask_hint')}</span> - </span> - </label> - <div class="settings-row" style="margin-top:10px"> - <span class="settings-label"> - ${dlDir ? t('settings.dl_folder', { name: dlDir.name }) - : t('settings.dl_no_folder')} - </span> - <span> - <button class="admin-btn" onClick=${pickFolder}> - ${dlDir ? t('settings.dl_change') : t('settings.dl_choose')} - </button> - ${dlDir && html` - <button class="btn-secondary" onClick=${async () => { - await downloads.forgetDirectory(); - setDlDir(null); - }}>${t('settings.dl_forget')}</button> - `} - </span> - </div> - <p class="settings-hint">${t('settings.dl_path_note')}</p> - `} - </div> + <h2>${t('profile.title')}</h2> <div class="settings-section"> <h3 class="settings-heading">${t('settings.profile')}</h3> @@ -2962,7 +3161,7 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) { </button> </div> ${nodeKeyStatus && html` - <p style="margin-top:6px;font-size:0.85em;color:${nodeKeyStatus === t('settings.node_key_success') ? 'var(--green, #22c55e)' : 'var(--red, #ef4444)'}"> + <p style="margin-top:6px;font-size:0.85em;color:${nodeKeyStatus === t('settings.node_key_success') ? 'var(--success)' : 'var(--error)'}"> ${nodeKeyStatus} </p> `} @@ -3006,6 +3205,113 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) { `} </div> + </div> + `; +} + +function SettingsPage({ user, theme, onThemeChange, groups }) { + const [locale, setLoc] = useState(getLocale); + // Comes from the hub with the group list, so it is the same on every device. + const [muted, setMuted] = useState( + () => Object.fromEntries((groups || []).map(g => [g.id, !!g.muted]))); + const onLocaleChange = useCallback((e) => { + const code = e.target.value; + setLocale(code); + setLoc(code); + window.location.reload(); + }, []); + + const onThemeSelect = useCallback((e) => { + onThemeChange(e.target.value); + }, [onThemeChange]); + + const toggleMute = useCallback(async (gid) => { + // Server-side: this used to write to localStorage, which nothing read, so + // muting a group had no effect on anything. The hub now declines to create + // the notification at all. + const next = !muted[gid]; + setMuted(prev => ({ ...prev, [gid]: next })); + try { + await hubFetch(`/v1/groups/${gid}/mute`, { + method: 'POST', token: user.token, body: { muted: next }, + }); + } catch (err) { + setMuted(prev => ({ ...prev, [gid]: !next })); + } + }, [muted, user.token]); + + const [dlMode, setDlMode] = useState(() => downloads.getMode()); + const [dlDir, setDlDir] = useState(null); + const [dlError, setDlError] = useState(''); + // Read from the hub rather than written here: the two constants that used to + // sit in this markup said 0.1.0 and MNP 0.1 long after both had moved on. + const [hubInfo, setHubInfo] = useState(null); + + useEffect(() => { + hubFetch('/v1/hub/version').then(setHubInfo).catch(() => {}); + }, []); + + useEffect(() => { downloads.savedDirectory().then(setDlDir); }, []); + + const pickFolder = useCallback(async () => { + try { + const handle = await downloads.chooseDirectory(); + setDlDir(handle); + } catch (err) { + // Reported where the folder controls are. This used to be written into + // the node-key status, two sections away, where nobody was looking. + if (err.name !== 'AbortError') setDlError(err.message); + } + }, []); + + + return html` + <div> + <h2>${t('settings.title')}</h2> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.downloads')}</h3> + ${!downloads.SUPPORTED + ? html`<p class="settings-hint">${t('settings.dl_unsupported')}</p>` + : html` + <label class="settings-choice"> + <input type="radio" name="dlmode" checked=${dlMode === 'auto'} + onChange=${() => { downloads.setMode('auto'); setDlMode('auto'); }} /> + <span> + <strong>${t('settings.dl_auto')}</strong> + <span class="settings-hint">${t('settings.dl_auto_hint')}</span> + </span> + </label> + <label class="settings-choice"> + <input type="radio" name="dlmode" checked=${dlMode === 'ask'} + onChange=${() => { downloads.setMode('ask'); setDlMode('ask'); }} /> + <span> + <strong>${t('settings.dl_ask')}</strong> + <span class="settings-hint">${t('settings.dl_ask_hint')}</span> + </span> + </label> + <div class="settings-row" style="margin-top:10px"> + <span class="settings-label"> + ${dlDir ? t('settings.dl_folder', { name: dlDir.name }) + : t('settings.dl_no_folder')} + </span> + <span> + <button class="admin-btn" onClick=${pickFolder}> + ${dlDir ? t('settings.dl_change') : t('settings.dl_choose')} + </button> + ${dlDir && html` + <button class="btn-secondary" onClick=${async () => { + await downloads.forgetDirectory(); + setDlDir(null); + }}>${t('settings.dl_forget')}</button> + `} + </span> + </div> + ${dlError && html`<p class="error-msg">${dlError}</p>`} + <p class="settings-hint">${t('settings.dl_path_note')}</p> + `} + </div> + <div class="settings-section"> <h3 class="settings-heading">${t('settings.appearance')}</h3> <div class="settings-row"> @@ -3046,11 +3352,13 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) { <h3 class="settings-heading">${t('settings.about')}</h3> <div class="settings-row"> <span class="settings-label">${t('settings.version')}</span> - <span class="settings-value">0.1.0</span> + <span class="settings-value">${hubInfo ? hubInfo.hub : '—'}</span> </div> <div class="settings-row"> <span class="settings-label">${t('settings.protocol')}</span> - <span class="settings-value">MNP 0.1 / MHP 0.1</span> + <span class="settings-value"> + ${hubInfo ? `MNP ${hubInfo.mnp} / MHP ${hubInfo.mhp}` : '—'} + </span> </div> </div> </div> @@ -3490,6 +3798,24 @@ function App() { setGroups(prev => prev.map(g => (g.id === gid ? { ...g, ...patch } : g))); }, []); + // What this browser saw for itself, which beats what the hub reported. Held + // for the session only: it is a cache of observations, not a source of truth, + // and a reload should go back to asking. + const [presence, setPresence] = useState({}); + const notePresence = useCallback((gid, state) => { + setPresence(prev => (prev[gid] === state ? prev : { ...prev, [gid]: state })); + }, []); + + const handleLeftGroup = useCallback((gid) => { + setGroups(prev => prev.filter(g => g.id !== gid)); + setPresence(prev => { + const next = { ...prev }; + delete next[gid]; + return next; + }); + navigate('/'); + }, []); + const markRead = useCallback((id) => { if (!user) return; // Drop it here and now. Waiting for the round trip leaves it on screen while @@ -3602,7 +3928,8 @@ function App() { groupId=${groupId} group=${group} token=${user.token} username=${user.username} userId=${user.userId} onRefreshAuth=${refreshAuth} onJoined=${dismissGroupNotifications} - onGroupUpdated=${updateGroup} />`; + onGroupUpdated=${updateGroup} onPresence=${notePresence} + onLeft=${handleLeftGroup} />`; } else if (route === '/admin') { page = (user.role === 'moderator' || user.role === 'admin') ? html`<${AdminPage} token=${user.token} />` @@ -3610,7 +3937,9 @@ function App() { onMarkRead=${markRead} onPurge=${purgeNotifications} />`; } else if (route === '/settings') { page = html`<${SettingsPage} user=${user} theme=${theme} - onThemeChange=${setTheme} groups=${groups} onLogout=${authCtx.logout} />`; + onThemeChange=${setTheme} groups=${groups} />`; + } else if (route === '/profile') { + page = html`<${ProfilePage} user=${user} onLogout=${authCtx.logout} />`; } else { page = html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} onPurge=${purgeNotifications} />`; @@ -3628,6 +3957,7 @@ function App() { <div class="layout"> ${user && html`<${Sidebar} groups=${groups} + presence=${presence} route=${route} menuOpen=${menuOpen} role=${user.role} />`} |