diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-18 16:34:12 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-18 16:34:12 +0200 |
| commit | eedbca3f0d47af39b4dd8812683e5a14ae4e48e6 (patch) | |
| tree | c99304c1f935e63a40e0546ef63ff30879d5aa9b /packages/meshbay-hub/src/meshbay_hub/static/app.js | |
| parent | c384878aa0d6ef7a33bda23887dc264b94386725 (diff) | |
| download | meshbay-eedbca3f0d47af39b4dd8812683e5a14ae4e48e6.tar.gz | |
fix: two waits with no deadline, resume positions per account, group settings tab
**Joining a group could hang.** Reported after a first attempt that never
finished and a later one that worked — the shape of a network wait with no
deadline, and there were two.
Signaling here is non-trickle: the offer is not sent until ICE gathering says
it is done. A STUN server that is slow, filtered, or resolved through a DNS that
is not answering means `icegatheringstatechange` never reaches `complete`, and
`connect()` never returns. Same shape as the fullscreen denial fixed yesterday:
a promise that never settles leaves no error to find. Gathering now has four
seconds, after which the offer goes out with what it has — host candidates are
already there, which is enough on a LAN, and giving up instead would turn a slow
STUN server into a refusal to connect.
The second: `hub:fetch` in the desktop client had no timeout, so a host that
accepts a connection and then says nothing holds the request for as long as the
OS allows. `hub:probe` had one; the handler that carries signaling did not. Now
thirty seconds — longer than the hub's own fifteen-second signaling wait, so it
cannot abort a call that was about to succeed — and it says the hub did not
answer rather than "fetch failed".
**Resume positions belonged to the machine, not the account.** Stored as
`mb:pos:<file>`, so a second account signing in on the same computer was offered
"resume where you left off" in a film it had never opened. Wrong on its own
terms, and a small disclosure of what the other person watches, since the offer
only appears for files someone has actually been through. The account is in the
key now. Positions written before this are deleted rather than re-keyed: there
is no record of whose they were, and guessing hands them to whoever signs in
next, which is the bug.
**The staggered rules in the members table.** `display: flex` on the actions
`<td>` — a flex table cell stops being a table cell, so it no longer stretches
to its row and its bottom border is drawn wherever its own content ends.
Measured: in a row whose other cells were `top 76, height 40`, that cell was
`top 77, height 30`, its rule nine pixels above the rest. It is a table cell
again, held open by a zero-width strut so the owner's row — which has no remove
button — stays as tall as the others. Every cell now shares its row's top and
bottom exactly, at 420px and 900px.
**Members became Settings.** It was a list with three unrelated forms stacked
above it, laid out with inline styles on whichever element needed them, and the
group's own controls somewhere else entirely — leaving or deleting a group sat
in the page header beside the title. Now one tab in sections: invitations,
operator pairing, your devices on this node, leaving or deleting, and the roster
last, since it is the only part with no upper bound.
One consequence worth stating: the tab bar no longer waits for the node.
Membership is hub-side, and gating it on a live connection would have made
"leave this group" unreachable exactly when a node is down — which is when
someone most wants it. Files and chat still need the node and say so.
**A download button in the viewer**, beside the close button and in the same
style, for both the video player and the file preview.
844 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 378 |
1 files changed, 242 insertions, 136 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 07fc781..c460f23 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -1925,30 +1925,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, && html`<span class="spinner"></span>${' '}`} ${statusLabel} </span> - ${group && group.is_admin && html` - <button class="admin-btn danger" style="margin-left:auto" - onClick=${async () => { - if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return; - try { - await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token }); - navigate('/'); - window.location.reload(); - } 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>`} ${needsDevice && html` @@ -1983,17 +1959,25 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, </div> </form> `} - ${status === 'connected' && html` + ${/* Not gated on the connection any more. Leaving a group, deleting it + and seeing who is in it are hub-side, and moving them into this tab + would otherwise have made them unreachable exactly when a node is + down — which is when someone is most likely to want them. Files and + chat still need the node and say so. */ group && html` <div class="group-tabs"> <button class="group-tab ${tab === 'chat' ? 'active' : ''}" onClick=${() => setTab('chat')}>${t('group.tab_chat')}</button> <button class="group-tab ${tab === 'files' ? 'active' : ''}" onClick=${() => setTab('files')}>${t('group.tab_files')}</button> - <button class="group-tab ${tab === 'members' ? 'active' : ''}" - onClick=${() => setTab('members')}>${t('group.tab_members')}</button> + <button class="group-tab ${tab === 'settings' ? 'active' : ''}" + onClick=${() => setTab('settings')}>${t('group.tab_settings')}</button> </div> - ${tab === 'files' && html` + ${tab === 'files' && status !== 'connected' && html` + <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p> + `} + + ${tab === 'files' && status === 'connected' && html` <div class="file-toolbar"> <div class="toolbar-group"> <label class="tb-btn primary"> @@ -2127,11 +2111,12 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p> `} - ${tab === 'members' && html` - <${MembersPanel} groupId=${groupId} group=${group} token=${token} + ${tab === 'settings' && html` + <${GroupSettingsPanel} groupId=${groupId} group=${group} token=${token} transportRef=${transportRef} gekRef=${gekRef} isNodeAdmin=${isNodeAdmin} userId=${userId} - operatorPaired=${operatorPaired} + operatorPaired=${operatorPaired} connected=${status === 'connected'} + onLeft=${onLeft} onPaired=${() => setOperatorPaired(true)} /> `} `} @@ -2149,14 +2134,16 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, entry=${previewEntry} transportRef=${transportRef} gekRef=${gekRef} - onClose=${() => setPreviewEntry(null)} /> + onClose=${() => setPreviewEntry(null)} + onDownload=${() => downloadFile(previewEntry)} /> `} ${videoEntry && html` <${VideoPlayer} entry=${videoEntry} transportRef=${transportRef} gekRef=${gekRef} - onClose=${() => setVideoEntry(null)} /> + onClose=${() => setVideoEntry(null)} + onDownload=${() => downloadFile(videoEntry)} /> `} </div> `; @@ -2167,7 +2154,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const TEXT_EXTS = /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i; const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i; -function FilePreview({ entry, transportRef, gekRef, onClose }) { +function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { const [phase, setPhase] = useState('loading'); const [progress, setProgress] = useState(0); const [content, setContent] = useState(null); @@ -2243,6 +2230,11 @@ function FilePreview({ entry, transportRef, gekRef, onClose }) { }}> <div class="video-top-bar"> <span class="video-title">${entry.name} (${formatSize(entry.size)})</span> + ${onDownload && html` + <button class="video-close" onClick=${onDownload} + title="${t('group.download')}"> + <${Icon} name="download" /></button> + `} <button class="video-close" onClick=${onClose} title="${t('video.close')}"> <${Icon} name="close" /></button> </div> @@ -2286,8 +2278,18 @@ function _b64ToU8(b64) { // ── Members Panel ──────────────────────────────────────────────────────── -function MembersPanel({ groupId, group, token, transportRef, gekRef, - isNodeAdmin, userId, operatorPaired, onPaired }) { +/** + * Everything about the group that is not its files or its chat. + * + * Was "Members", which was a list with three unrelated forms stacked on top of + * it and the group's own controls somewhere else entirely — leaving or deleting + * a group lived in the header, beside its title. One tab now, in sections, with + * the roster last: it is the part that grows without limit, and burying the + * controls under two hundred names is how a tab stops being usable. + */ +function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, + isNodeAdmin, userId, operatorPaired, connected, + onPaired, onLeft }) { const [members, setMembers] = useState([]); const [adminId, setAdminId] = useState(''); const [loading, setLoading] = useState(true); @@ -2460,119 +2462,181 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`; + const isOwner = Boolean(isAdmin); + return html` <div class="members-panel"> - ${isAdmin && !operatorPaired && html` - <p class="settings-hint"> - ${isNodeAdmin ? t('members.invite_needs_pairing') - : t('members.invite_ask_operator')} - </p> - `} - ${isAdmin && operatorPaired && html` - <form class="invite-form" onSubmit=${doInvite}> - <h4>${t('members.invite_title')}</h4> - ${error && html`<p class="error-msg">${error}</p>`} - ${inviteCode && html` - <div class="success-msg" style="margin-bottom:8px"> - <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p> - <p style="font-family:monospace;font-size:1.4em;letter-spacing:2px;margin:6px 0"> - ${inviteCode.code} - </p> - <p>${t('members.invite_code_hint')}</p> - </div> + ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`} + + ${/* Inviting needs the node: it is the node that wraps the group key and + issues the code, not the hub. */ isAdmin && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('members.invite_title')}</h3> + ${!connected && html` + <p class="settings-hint">${t('group.offline_title')}</p> `} - <div style="display:flex;gap:8px"> - <input type="text" placeholder="${t('members.username_placeholder')}" - value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} required /> - <button class="admin-btn" type="submit" disabled=${inviting}> - ${inviting ? '...' : t('members.invite_btn')} - </button> - </div> - </form> - `} - <table class="admin-table"> - <thead> - <tr> - <th>${t('admin.col_username')}</th> - <th>${t('members.group_role')}</th> - <th></th> - </tr> - </thead> - <tbody> - ${members.map(m => html` - <tr key=${m.user_id}> - <td>${m.username}</td> - <td> - ${m.user_id === adminId - ? html`<span class="badge" style="background:var(--accent);color:var(--accent-text)">${t('members.owner')}</span>` - : html`<span class="badge">${t('members.member')}</span>` - } - </td> - <td class="admin-actions"> - ${isAdmin && m.user_id !== adminId && html` - <button class="admin-btn danger" disabled=${removing === m.user_id} - onClick=${() => { - if (!confirm(t('members.remove_confirm', { user: m.username }))) return; - removeMember(m); - }}> - ${removing === m.user_id ? '...' : t('members.remove')} - </button> - `} - </td> - </tr> - `)} - </tbody> - </table> - ${isAdmin && members.length > 1 && html` - <p class="settings-hint">${t('members.remove_hint')}</p> + ${connected && !operatorPaired && html` + <p class="settings-hint"> + ${isNodeAdmin ? t('members.invite_needs_pairing') + : t('members.invite_ask_operator')} + </p> + `} + ${connected && operatorPaired && html` + <form onSubmit=${doInvite}> + ${inviteCode && html` + <div class="success-msg" style="margin-bottom:8px"> + <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p> + <p class="code-display">${inviteCode.code}</p> + <p>${t('members.invite_code_hint')}</p> + </div> + `} + <div class="form-row"> + <input type="text" placeholder="${t('members.username_placeholder')}" + value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} required /> + <button class="admin-btn" type="submit" disabled=${inviting}> + ${inviting ? '...' : t('members.invite_btn')} + </button> + </div> + </form> + `} + </div> `} - ${isNodeAdmin && !operatorPaired && html` - <form class="invite-form" onSubmit=${doPair}> - <h4>${t('members.pair_title')}</h4> + + ${isNodeAdmin && !operatorPaired && connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('members.pair_title')}</h3> <p class="settings-hint">${t('members.pair_hint')}</p> ${pairStatus && html` <p class=${pairStatus === 'paired' ? 'success-msg' : 'error-msg'}> ${pairStatus === 'paired' ? t('members.pair_success') : pairStatus} </p> `} - <div style="display:flex;gap:8px"> - <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace" + <form class="form-row" onSubmit=${doPair}> + <input type="text" placeholder="XXXX-XXXX" class="code-input" value=${pairCode} onInput=${e => setPairCode(e.target.value)} required /> <button class="admin-btn" type="submit" disabled=${pairing}> ${pairing ? '...' : t('members.pair_btn')} </button> - </div> - </form> + </form> + </div> `} - <div class="invite-form" style="margin-top:16px"> - <h4>${t('device.mine_title')}</h4> - <p class="settings-hint">${t('device.mine_hint')}</p> - ${deviceMsg && html`<p class="settings-hint">${deviceMsg}</p>`} - ${devices.length === 0 && html` - <p class="settings-hint">${t('device.mine_empty')}</p> - `} - ${devices.map(d => html` - <div key=${d.pk_ed25519} - style="display:flex;align-items:center;gap:8px;margin:4px 0"> - <span style="font-family:monospace">${d.pk_ed25519.slice(0, 16)}…</span> - ${d.is_this_one && html`<span class="badge">${t('device.this_one')}</span>`} - <span class="settings-hint">${d.pinned_via}${d.label ? ' · ' + d.label : ''}</span> - ${!d.is_this_one && devices.length > 1 && html` - <button class="admin-btn" onClick=${() => revokeDevice(d)}> - ${t('device.revoke')} - </button> + ${connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('device.mine_title')}</h3> + <p class="settings-hint">${t('device.mine_hint')}</p> + ${deviceMsg && html`<p class="settings-hint">${deviceMsg}</p>`} + ${devices.length === 0 + ? html`<p class="settings-hint">${t('device.mine_empty')}</p>` + : html` + <ul class="device-list"> + ${devices.map(d => html` + <li class="device-row" key=${d.pk_ed25519}> + <span class="device-key">${d.pk_ed25519.slice(0, 16)}…</span> + <span class="device-meta"> + ${d.is_this_one && html` + <span class="badge">${t('device.this_one')}</span>${' '} + `} + ${d.pinned_via}${d.label ? ' · ' + d.label : ''} + </span> + ${!d.is_this_one && devices.length > 1 && html` + <button class="admin-btn" onClick=${() => revokeDevice(d)}> + ${t('device.revoke')} + </button> + `} + </li> + `)} + </ul> `} + <form onSubmit=${approveDevice} class="settings-subform"> + <p class="settings-hint">${t('device.approve_hint')}</p> + <div class="form-row"> + <input type="text" placeholder="XXXX-XXXX" class="code-input" + value=${approveCode} onInput=${e => setApproveCode(e.target.value)} /> + <button class="admin-btn" type="submit">${t('device.approve_btn')}</button> + </div> + </form> + </div> + `} + + ${/* Before the roster, not after it: this is what someone came here to + do, and a list of two hundred names is a long way to scroll for + it. */ html` + <div class="settings-section danger-section"> + <h3 class="settings-heading">${t('members.danger_title')}</h3> + <div class="settings-row"> + <span class="settings-label"> + ${isOwner ? t('members.danger_delete_hint') + : t('members.danger_leave_hint')} + </span> + ${isOwner + ? html` + <button class="admin-btn danger" onClick=${async () => { + if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return; + try { + await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token }); + navigate('/'); + window.location.reload(); + } catch (err) { setError(err.message); } + }}>${t('group.delete_group')}</button> + ` + : html` + <button class="admin-btn danger" 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> - `)} - <form onSubmit=${approveDevice} style="margin-top:12px"> - <p class="settings-hint">${t('device.approve_hint')}</p> - <div style="display:flex;gap:8px"> - <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace" - value=${approveCode} onInput=${e => setApproveCode(e.target.value)} /> - <button class="admin-btn" type="submit">${t('device.approve_btn')}</button> - </div> - </form> + </div> + `} + + <div class="settings-section"> + <h3 class="settings-heading"> + ${t('group.tab_members')} (${members.length}) + </h3> + <table class="admin-table"> + <thead> + <tr> + <th>${t('admin.col_username')}</th> + <th>${t('members.group_role')}</th> + <th></th> + </tr> + </thead> + <tbody> + ${members.map(m => html` + <tr key=${m.user_id}> + <td>${m.username}</td> + <td> + ${m.user_id === adminId + ? html`<span class="badge badge-owner">${t('members.owner')}</span>` + : html`<span class="badge">${t('members.member')}</span>` + } + </td> + <td class="admin-actions"> + ${isAdmin && m.user_id !== adminId && html` + <button class="admin-btn danger" disabled=${removing === m.user_id} + onClick=${() => { + if (!confirm(t('members.remove_confirm', { user: m.username }))) return; + removeMember(m); + }}> + ${removing === m.user_id ? '...' : t('members.remove')} + </button> + `} + </td> + </tr> + `)} + </tbody> + </table> + ${isAdmin && members.length > 1 && html` + <p class="settings-hint">${t('members.remove_hint')}</p> + `} </div> </div> `; @@ -3018,15 +3082,28 @@ function formatClock(seconds) { } /** - * Where this browser last left off in a given file. + * Where *this account on this device* last left off in a given file. * * localStorage rather than the node: it needs no protocol, no storage anyone * else has to keep, and nothing new learns what you watch. The cost is that * the position does not follow you from the laptop to the phone. + * + * The account has to be in the key. Without it the position is per *device* — + * so a second person signing in on the same machine was offered "resume where + * you left off" in a film they had never opened, which is both wrong and a + * small disclosure of what someone else watches. Found by signing in with a + * fresh account and being offered a resume point. */ +function resumeKey(fileId) { + const auth = loadAuth(); + return auth && auth.userId ? `mb:pos:${auth.userId}:${fileId}` : null; +} + function readResumePosition(fileId) { try { - const raw = localStorage.getItem(`mb:pos:${fileId}`); + const key = resumeKey(fileId); + if (!key) return 0; + const raw = localStorage.getItem(key); const at = raw ? parseFloat(raw) : 0; return Number.isFinite(at) && at > RESUME_MIN_S ? at : 0; } catch { @@ -3036,16 +3113,40 @@ function readResumePosition(fileId) { function writeResumePosition(fileId, at, duration) { try { + const key = resumeKey(fileId); + if (!key) return; if (!Number.isFinite(at) || at < RESUME_MIN_S || (duration && at > duration * RESUME_MAX_FRACTION)) { - localStorage.removeItem(`mb:pos:${fileId}`); + localStorage.removeItem(key); return; } - localStorage.setItem(`mb:pos:${fileId}`, String(Math.floor(at))); + localStorage.setItem(key, String(Math.floor(at))); } catch { /* nothing to be done, and nothing worth failing over */ } } -function VideoPlayer({ entry, transportRef, gekRef, onClose }) { +/** + * Drop the positions written before they were scoped to an account. + * + * Re-keying them is not possible — there is no record of whose they were, and + * guessing would hand them to whoever signs in next, which is the bug. They go. + */ +function purgeUnscopedResumePositions() { + try { + const stale = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + // `mb:pos:<file>` is the old shape; `mb:pos:<user>:<file>` is current. + if (key && key.startsWith('mb:pos:') && key.split(':').length === 3) { + stale.push(key); + } + } + stale.forEach((key) => localStorage.removeItem(key)); + } catch { /* storage disabled: nothing was written either */ } +} + +purgeUnscopedResumePositions(); + +function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { const [phase, setPhase] = useState('loading'); const [error, setError] = useState(''); const videoRef = useRef(null); @@ -3676,6 +3777,11 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { }}> <div class="video-top-bar"> <span class="video-title">${entry.name} (${formatSize(entry.size)})</span> + ${onDownload && html` + <button class="video-close" onClick=${onDownload} + title="${t('group.download')}"> + <${Icon} name="download" /></button> + `} <button class="video-close" onClick=${onClose} title="${t('video.close')}"> <${Icon} name="close" /></button> </div> |