diff options
Diffstat (limited to 'packages')
18 files changed, 838 insertions, 153 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 8e11f1f..6228189 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -252,6 +252,10 @@ function publicKeyB64(privateKey) { // full-screen is the whole list. const GRANTED_PERMISSIONS = new Set(['fullscreen']); +// No hub call is still going to be answered after this. The hub's longest is +// signaling a WebRTC offer, which gives up at fifteen seconds of its own. +const HUB_FETCH_TIMEOUT_MS = 30000; + // ── Window ────────────────────────────────────────────────────────────────── let mainWindow = null; @@ -414,8 +418,18 @@ function registerBridge() { method: (init && init.method) || 'GET', headers: (init && init.headers) || {}, body: (init && init.body) || undefined, + // Node's fetch waits as long as the OS lets it, which for a host that + // accepts a connection and then says nothing is minutes. The hub's own + // longest call is signaling, which gives up at fifteen seconds, so + // anything past this is not an answer that is still coming. + signal: AbortSignal.timeout(HUB_FETCH_TIMEOUT_MS), }); } catch (e) { + if (e && (e.name === 'TimeoutError' || e.name === 'AbortError')) { + throw new Error( + `${target.origin} accepted the connection but did not answer within ` + + `${Math.round(HUB_FETCH_TIMEOUT_MS / 1000)}s.`); + } // Node's fetch says "fetch failed" for everything from a refused // connection to a TLS mismatch, which tells a person nothing at all. throw new Error(describeUnreachable(target.origin, e)); 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> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index e099edb..d222bba 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -77,6 +77,10 @@ export default { 'group.tab_files': 'Dateien', 'group.tab_chat': 'Chat', 'group.tab_members': 'Mitglieder', + 'group.tab_settings': "Einstellungen", + 'members.danger_title': "Gefahrenbereich", + 'members.danger_leave_hint': "Sie verlieren den Zugriff auf die Dateien und den Chat dieser Gruppe.", + 'members.danger_delete_hint': "Die Gruppe verschwindet für alle Mitglieder. Das lässt sich nicht rückgängig machen.", 'group.filter': 'Dateien filtern …', 'group.col_name': 'Name', 'group.col_size': 'Größe', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index a621084..33e8f5e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -78,6 +78,10 @@ export default { 'group.tab_files': 'Files', 'group.tab_chat': 'Chat', 'group.tab_members': 'Members', + 'group.tab_settings': "Settings", + 'members.danger_title': "Danger zone", + 'members.danger_leave_hint': "You will lose access to this group's files and chat.", + 'members.danger_delete_hint': "This removes the group for every member. It cannot be undone.", 'group.filter': 'Filter files...', 'group.col_name': 'Name', 'group.col_size': 'Size', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 5fb4950..81b2c97 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -75,6 +75,10 @@ export default { 'group.tab_files': 'Archivos', 'group.tab_chat': 'Chat', 'group.tab_members': 'Miembros', + 'group.tab_settings': "Ajustes", + 'members.danger_title': "Zona de riesgo", + 'members.danger_leave_hint': "Perderá el acceso a los archivos y al chat de este grupo.", + 'members.danger_delete_hint': "El grupo desaparece para todos sus miembros. No se puede deshacer.", 'group.filter': 'Filtrar archivos...', 'group.col_name': 'Nombre', 'group.col_size': 'Tamaño', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index c9480b6..e44b03b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -76,6 +76,10 @@ export default { 'group.tab_files': 'Fichiers', 'group.tab_chat': 'Discussion', 'group.tab_members': 'Membres', + 'group.tab_settings': "Paramètres", + 'members.danger_title': "Zone sensible", + 'members.danger_leave_hint': "Vous perdrez l’accès aux fichiers et à la discussion de ce groupe.", + 'members.danger_delete_hint': "Le groupe disparaît pour tous ses membres. C’est irréversible.", 'group.filter': 'Filtrer les fichiers...', 'group.col_name': 'Nom', 'group.col_size': 'Taille', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index ac7c06c..206c749 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -76,6 +76,10 @@ export default { 'group.tab_files': 'File', 'group.tab_chat': 'Chat', 'group.tab_members': 'Membri', + 'group.tab_settings': "Impostazioni", + 'members.danger_title': "Zona critica", + 'members.danger_leave_hint': "Perderai l’accesso ai file e alla chat di questo gruppo.", + 'members.danger_delete_hint': "Il gruppo scompare per tutti i membri. Non è reversibile.", 'group.filter': 'Filtra i file...', 'group.col_name': 'Nome', 'group.col_size': 'Dimensione', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index b5d60ca..b4ccfaf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -74,6 +74,10 @@ export default { 'group.tab_files': 'ファイル', 'group.tab_chat': 'チャット', 'group.tab_members': 'メンバー', + 'group.tab_settings': "設定", + 'members.danger_title': "取り扱い注意", + 'members.danger_leave_hint': "このグループのファイルとチャットにアクセスできなくなります。", + 'members.danger_delete_hint': "グループはすべてのメンバーから消えます。元に戻せません。", 'group.filter': 'ファイルを絞り込み…', 'group.col_name': '名前', 'group.col_size': 'サイズ', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 304c90a..ac75f26 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -77,6 +77,10 @@ export default { 'group.tab_files': 'Bestanden', 'group.tab_chat': 'Chat', 'group.tab_members': 'Leden', + 'group.tab_settings': "Instellingen", + 'members.danger_title': "Gevarenzone", + 'members.danger_leave_hint': "U verliest de toegang tot de bestanden en de chat van deze groep.", + 'members.danger_delete_hint': "De groep verdwijnt voor alle leden. Dit kan niet ongedaan worden gemaakt.", 'group.filter': 'Bestanden filteren...', 'group.col_name': 'Naam', 'group.col_size': 'Grootte', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 98f36ce..fe9c18d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -81,6 +81,10 @@ export default { 'group.tab_files': 'Pliki', 'group.tab_chat': 'Czat', 'group.tab_members': 'Członkowie', + 'group.tab_settings': "Ustawienia", + 'members.danger_title': "Strefa ryzyka", + 'members.danger_leave_hint': "Utracisz dostęp do plików i czatu tej grupy.", + 'members.danger_delete_hint': "Grupa zniknie dla wszystkich członków. Tego nie można cofnąć.", 'group.filter': 'Filtruj pliki...', 'group.col_name': 'Nazwa', 'group.col_size': 'Rozmiar', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 5db5cd5..270af8e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -77,6 +77,10 @@ export default { 'group.tab_files': 'Arquivos', 'group.tab_chat': 'Conversa', 'group.tab_members': 'Membros', + 'group.tab_settings': "Configurações", + 'members.danger_title': "Zona de risco", + 'members.danger_leave_hint': "Você perderá o acesso aos arquivos e ao chat deste grupo.", + 'members.danger_delete_hint': "O grupo desaparece para todos os membros. Não há como desfazer.", 'group.filter': 'Filtrar arquivos...', 'group.col_name': 'Nome', 'group.col_size': 'Tamanho', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index b5f16fb..6e19df9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -74,6 +74,10 @@ export default { 'group.tab_files': '文件', 'group.tab_chat': '聊天', 'group.tab_members': '成员', + 'group.tab_settings': "设置", + 'members.danger_title': "危险区域", + 'members.danger_leave_hint': "您将无法再访问该群组的文件和聊天。", + 'members.danger_delete_hint': "该群组将对所有成员消失,且无法恢复。", 'group.filter': '筛选文件…', 'group.col_name': '名称', 'group.col_size': '大小', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 828e104..cb56321 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -940,6 +940,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } display: flex; align-items: center; justify-content: space-between; + gap: 12px; + flex-wrap: wrap; padding: 8px 0; } .settings-row + .settings-row { @@ -989,6 +991,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } display: flex; align-items: center; justify-content: space-between; + gap: 8px; padding: 12px 20px; z-index: 210; } @@ -1000,7 +1003,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - max-width: calc(100% - 60px); + flex: 1; + min-width: 0; } .video-close { @@ -1242,16 +1246,103 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } font-style: italic; } -/* A row whose actions cell is empty — the group owner, who cannot be removed, - or your own account in the admin table — used to sit lower than the rest: - `display: flex` makes the cell the height of its button, and nothing when - there is no button. Reserving the height keeps every row aligned without - rendering a disabled button nobody can use. */ +/* Two things this cell has to do: hold a row of buttons, and stay as tall as + the rest of its row even when it is empty — the group owner cannot be + removed, and neither can your own account in the admin table. + + `display: flex` did the second one and broke the first. **A flex <td> stops + being a table cell**: it no longer stretches to the height of its row, so + its bottom border is drawn wherever its own content ends. Measured, in a row + whose other cells were `top 76, height 40`, the actions cell came out + `top 77, height 30` — its rule nine pixels above the rest, which is the + staggered lines across the members table. + + So the cell goes back to being a table cell, and an empty one is held open + by a zero-width strut the height of a button. Reported as "un décalage sur + les lignes du tableau listant les membres". */ .admin-actions { + white-space: nowrap; +} +.admin-actions::before { + content: ''; + display: inline-block; + width: 0; + height: 30px; + vertical-align: middle; +} +.admin-actions > * { vertical-align: middle; } +.admin-actions > * + * { margin-left: 6px; } + +/* ── Group settings sections ────────────────────────────────────────────── + The group tab was three forms and a table, each laid out with inline styles + on the element that needed it, which is why no two of them lined up. These + are the pieces they all wanted. */ + +/* An input and its button. Wraps rather than overflowing on a phone, and the + input takes the slack so the button keeps its size. */ +.form-row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} +.form-row input { + flex: 1; + min-width: 160px; +} + +/* A pairing or invitation code: typed one character at a time, off a screen or + a piece of paper, so it is spaced and unambiguous. */ +.code-input, +.code-display { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.code-display { + font-size: 1.4em; + letter-spacing: 2px; + margin: 6px 0; +} + +.settings-subform { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--border); +} + +.device-list { + list-style: none; + margin: 10px 0 0; + padding: 0; +} +.device-row { display: flex; - gap: 6px; align-items: center; - min-height: 30px; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} +.device-row:last-child { border-bottom: none; } +.device-key { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85em; +} +.device-meta { + color: var(--text-secondary); + font-size: 0.85em; + flex: 1; + min-width: 0; +} + +/* Leaving or deleting a group. Marked, because the two buttons in this tab + that cannot be undone should not look like the ones that can. */ +.danger-section { + border-color: color-mix(in srgb, var(--error) 40%, var(--border)); +} +.danger-section .settings-heading { color: var(--error); } + +.badge-owner { + background: var(--accent); + color: var(--accent-text); } .admin-btn { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 1a63e23..853ae75 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -44,6 +44,11 @@ const UPLOAD_BUFFER_HIGH = 1024 * 1024; // Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a // slow link and small enough that nothing accumulates. +// How long to collect ICE candidates before sending the offer anyway. Long +// enough for a STUN round trip on a slow link, short enough that a STUN server +// that never answers costs a pause rather than the whole attempt. +const ICE_GATHER_TIMEOUT_MS = 4000; + const STREAM_CREDITS = 24; function _aborted() { @@ -155,10 +160,31 @@ class MeshBayTransport { const offer = await this._pc.createOffer(); await this._pc.setLocalDescription(offer); + // Wait for candidates, but not indefinitely. + // + // This is non-trickle signaling: the offer carries its candidates, so the + // SDP is only sent once gathering is done. When gathering *never* finishes + // — a STUN server that is slow, filtered, or being resolved through a DNS + // that is not answering — this promise never settles, and joining a group + // hangs with no error and nothing on screen. Reported after exactly that, + // and it succeeded on a later attempt, which is the shape of a network + // wait rather than a refusal. + // + // Past the deadline the offer goes out with whatever has been gathered. + // Host candidates are already there, which is enough on a LAN — the case + // this project cares most about — and the reflexive ones normally arrive + // in well under a second when STUN is reachable at all. A partial offer + // that usually connects beats a promise that never returns. await new Promise((resolve) => { if (this._pc.iceGatheringState === 'complete') return resolve(); + const done = () => { clearTimeout(timer); resolve(); }; + const timer = setTimeout(() => { + console.warn('[MeshBay] ICE gathering did not finish in', + ICE_GATHER_TIMEOUT_MS, 'ms — offering what we have'); + done(); + }, ICE_GATHER_TIMEOUT_MS); this._pc.onicegatheringstatechange = () => { - if (this._pc.iceGatheringState === 'complete') resolve(); + if (this._pc.iceGatheringState === 'complete') done(); }; }); diff --git a/packages/meshbay-hub/tests/test_connect_never_hangs.py b/packages/meshbay-hub/tests/test_connect_never_hangs.py new file mode 100644 index 0000000..d6f1369 --- /dev/null +++ b/packages/meshbay-hub/tests/test_connect_never_hangs.py @@ -0,0 +1,91 @@ +""" +Joining a group must fail, or succeed — never wait forever. + +Reported: a first attempt to connect hung with nothing on screen, and the same +account connected a few minutes later. That is the shape of a network wait with +no deadline, not of a refusal, and there were two of them. + +**ICE gathering.** Signaling here is non-trickle — the offer carries its +candidates, so it is not sent until 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: a promise that never settles +produces no error to find. + +**The hub call in the desktop client.** Node's `fetch` has no default 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 a deadline; `hub:fetch`, which carries +signaling, did not. +""" + +import re +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +TRANSPORT = STATIC / "transport.js" +MAIN = (Path(__file__).resolve().parents[2] / "meshbay-client" / "src" / "main.js") + +pytestmark = pytest.mark.skipif(not TRANSPORT.exists(), + reason="SPA sources unavailable") + + +def _gathering_block() -> str: + """The wait on ICE gathering, and only it.""" + source = TRANSPORT.read_text(encoding="utf-8") + start = source.index("iceGatheringState") + return source[max(0, start - 900):start + 700] + + +def test_ice_gathering_has_a_deadline(): + block = _gathering_block() + assert "setTimeout" in block, ( + "the wait on ICE gathering can never end, and connect() with it") + assert "ICE_GATHER_TIMEOUT_MS" in block + + +def test_the_deadline_is_long_enough_for_a_stun_round_trip(): + """Cutting gathering off too early drops the reflexive candidate and breaks + every connection that is not on the same network.""" + source = TRANSPORT.read_text(encoding="utf-8") + match = re.search(r"const ICE_GATHER_TIMEOUT_MS = (\d+);", source) + assert match, "the constant is gone or was renamed" + assert 2000 <= int(match.group(1)) <= 10000 + + +def test_a_timed_out_gathering_still_sends_the_offer(): + """Host candidates are already gathered, which is enough on a LAN. Giving + up instead would turn a slow STUN server into a refusal to connect.""" + source = TRANSPORT.read_text(encoding="utf-8") + block = _gathering_block() + # The deadline resolves the promise; it does not reject it. + assert "reject" not in block.split("setTimeout", 1)[1][:300] + # And the offer is still posted afterwards. + assert "webrtc/offer" in source[source.index("iceGatheringState"):] + + +@pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present") +def test_every_hub_call_from_the_client_has_a_deadline(): + source = MAIN.read_text(encoding="utf-8") + block = source.split("ipcMain.handle('hub:fetch'", 1)[1].split("ipcMain.handle", 1)[0] + assert "AbortSignal.timeout" in block, ( + "a hub that accepts the connection and says nothing holds this for " + "as long as the OS allows") + + +@pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present") +def test_the_deadline_outlasts_the_hubs_own_longest_call(): + """Signaling waits fifteen seconds for a node to answer an offer. A client + deadline under that would abort calls that were about to succeed.""" + source = MAIN.read_text(encoding="utf-8") + match = re.search(r"const HUB_FETCH_TIMEOUT_MS = (\d+);", source) + assert match, "the constant is gone or was renamed" + assert int(match.group(1)) > 15000 + + +@pytest.mark.skipif(not MAIN.exists(), reason="the desktop client is not present") +def test_a_timeout_says_so_rather_than_saying_fetch_failed(): + source = MAIN.read_text(encoding="utf-8") + block = source.split("ipcMain.handle('hub:fetch'", 1)[1].split("ipcMain.handle", 1)[0] + assert "TimeoutError" in block and "did not answer" in block diff --git a/packages/meshbay-hub/tests/test_resume_position.py b/packages/meshbay-hub/tests/test_resume_position.py new file mode 100644 index 0000000..ef239c5 --- /dev/null +++ b/packages/meshbay-hub/tests/test_resume_position.py @@ -0,0 +1,158 @@ +""" +"Resume where you left off" belongs to an account, not to a machine. + +The position was stored as `mb:pos:<file>` in localStorage — per *device*. Sign +in with a second account on the same computer and the player offered to resume a +film that account had never opened. Wrong on its own terms, and a small +disclosure of what the other person watches: the offer only appears for files +someone has actually been through. + +The functions are lifted out of `app.js` and run for real, rather than having +their source inspected, because the thing worth holding is the behaviour of two +accounts sharing one storage — which no assertion about the source text says. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +APP = STATIC / "app.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not APP.exists(), + reason="node or the SPA sources are not available") + +WANTED = ("resumeKey", "readResumePosition", "writeResumePosition", + "purgeUnscopedResumePositions") + + +def _extract() -> str: + """The real source of the functions under test, and nothing else. + + `app.js` imports preact and cannot be loaded outside a browser, so the + declarations are sliced out by brace matching. A rename breaks this loudly, + which is the intent — a silently skipped test is worse than a failing one. + """ + source = APP.read_text(encoding="utf-8") + out = [ + f"const RESUME_MIN_S = {_const(source, 'RESUME_MIN_S')};", + f"const RESUME_MAX_FRACTION = {_const(source, 'RESUME_MAX_FRACTION')};", + ] + for name in WANTED: + start = source.index(f"function {name}(") + depth, i = 0, source.index("{", start) + while True: + if source[i] == "{": + depth += 1 + elif source[i] == "}": + depth -= 1 + if depth == 0: + break + i += 1 + out.append(source[start:i + 1]) + return "\n".join(out) + + +def _const(source: str, name: str) -> str: + match = re.search(rf"^const {name} = ([^;]+);", source, re.M) + assert match, f"{name} is gone or was renamed" + return match.group(1) + + +def _run(body: str, tmp_path: Path): + script = tmp_path / "case.mjs" + script.write_text( + "const store = new Map();\n" + "globalThis.localStorage = {\n" + " get length() { return store.size; },\n" + " key: i => Array.from(store.keys())[i] ?? null,\n" + " getItem: k => (store.has(k) ? store.get(k) : null),\n" + " setItem: (k, v) => store.set(k, String(v)),\n" + " removeItem: k => store.delete(k),\n" + "};\n" + # Whoever is signed in, swapped by the cases below. + "let AUTH = null;\n" + "function loadAuth() { return AUTH; }\n" + f"{_extract()}\n" + "const out = [];\n" + "const say = (...a) => out.push(...a);\n" + "const keys = () => Array.from(store.keys()).sort();\n" + f"{body}\n" + "console.log(JSON.stringify(out));\n", + encoding="utf-8") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── The bug ───────────────────────────────────────────────────────────────── + +def test_a_second_account_is_not_offered_the_firsts_position(tmp_path): + """The report: a brand-new account was offered a resume point.""" + assert _run(""" +AUTH = { userId: 'alice' }; +writeResumePosition('film1', 600, 7200); +say(readResumePosition('film1')); + +AUTH = { userId: 'bob' }; +say(readResumePosition('film1')); +""", tmp_path) == [600, 0] + + +def test_each_account_keeps_its_own_place_in_the_same_film(tmp_path): + """Two people watching one film on one machine is the ordinary case, and + neither should move the other's bookmark.""" + assert _run(""" +AUTH = { userId: 'alice' }; writeResumePosition('film1', 600, 7200); +AUTH = { userId: 'bob' }; writeResumePosition('film1', 1800, 7200); +AUTH = { userId: 'alice' }; say(readResumePosition('film1')); +AUTH = { userId: 'bob' }; say(readResumePosition('film1')); +""", tmp_path) == [600, 1800] + + +def test_nothing_is_written_when_nobody_is_signed_in(tmp_path): + assert _run(""" +AUTH = null; +writeResumePosition('film1', 600, 7200); +say(keys().length, readResumePosition('film1')); +""", tmp_path) == [0, 0] + + +def test_positions_written_before_the_fix_are_dropped(tmp_path): + """ + They cannot be re-keyed: there is no record of whose they were, and guessing + hands them to whoever signs in next, which is the bug itself. + """ + assert _run(""" +localStorage.setItem('mb:pos:film1', '600'); // the old shape +localStorage.setItem('mb:pos:alice:film2', '900'); // the new one +localStorage.setItem('mb_auth', '{}'); // nothing to do with this +purgeUnscopedResumePositions(); +say(...keys()); +""", tmp_path) == ["mb:pos:alice:film2", "mb_auth"] + + +# ── What must still hold ──────────────────────────────────────────────────── + +def test_a_glance_at_the_opening_is_not_a_bookmark(tmp_path): + assert _run(""" +AUTH = { userId: 'alice' }; +writeResumePosition('film1', 12, 7200); +say(readResumePosition('film1')); +""", tmp_path) == [0] + + +def test_a_film_watched_to_the_end_stops_offering_to_resume(tmp_path): + """And the earlier bookmark goes with it, rather than sitting there + offering the last thirty seconds forever.""" + assert _run(""" +AUTH = { userId: 'alice' }; +writeResumePosition('film1', 3600, 7200); +writeResumePosition('film1', 7150, 7200); +say(readResumePosition('film1'), keys().length); +""", tmp_path) == [0, 0] diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py index 9d02d42..1556cb7 100644 --- a/packages/meshbay-hub/tests/test_spa_ordering.py +++ b/packages/meshbay-hub/tests/test_spa_ordering.py @@ -125,24 +125,67 @@ def _component(name: str) -> str: return source[start:end if end != -1 else len(source)] -def test_members_panel_renders_what_it_owns(): - panel = _component("MembersPanel") +def test_the_group_settings_panel_renders_what_it_owns(): + panel = _component("GroupSettingsPanel") assert "members.map(" in panel, "the member list is not rendered" assert "onSubmit=${doInvite}" in panel, "the invite form is not rendered" assert "onSubmit=${doPair}" in panel, "the pairing form is not rendered" + assert "device.mine_title" in panel, "the devices section is not rendered" -def test_the_invite_form_comes_before_the_list(): - panel = _component("MembersPanel") - assert panel.index("onSubmit=${doInvite}") < panel.index("members.map("), \ - "the invite form belongs above the member list" +def test_the_roster_comes_last(): + """ + It is the only part of this tab with no upper bound. Two hundred members + would put every form and every control below the fold, which is what the + order is for — asked for in those terms. + """ + panel = _component("GroupSettingsPanel") + listing = panel.index("members.map(") + for name, marker in (("the invite form", "onSubmit=${doInvite}"), + ("the pairing form", "onSubmit=${doPair}"), + ("the devices section", "device.mine_title"), + ("leaving and deleting", "members.danger_title")): + assert panel.index(marker) < listing, f"{name} belongs above the roster" + + +def test_leaving_a_group_lives_with_the_group_settings(): + """It used to sit in the page header beside the group's name, which is + neither where it belongs nor where anyone looked for it.""" + panel = _component("GroupSettingsPanel") + assert "group.leave_confirm" in panel and "group.delete_group_confirm" in panel + page = _component("GroupPage") + assert "group.leave_confirm" not in page, "still in the header as well" + + +def test_leaving_does_not_require_the_node_to_be_up(): + """ + Moving these into a tab that only rendered on a live connection would have + made them unreachable exactly when a node is down — which is when someone + most wants to leave. Membership is hub-side; the tab bar does not wait for + the node. + """ + page = _component("GroupPage") + tabs = page[page.index("group-tabs"):] + tabs = tabs[:tabs.index("</div>")] + before = page[:page.index("group-tabs")] + guard = before[before.rindex("${"):] + assert "status === 'connected'" not in guard, ( + "the tab bar is gated on the connection, so a group on an offline node " + "cannot be left") + + +def test_the_node_dependent_sections_say_when_the_node_is_down(): + """The other half of that: inviting needs the node to wrap the group key, + so it must explain itself rather than silently doing nothing.""" + panel = _component("GroupSettingsPanel") + assert "connected &&" in panel and "!connected &&" in panel -def test_admin_page_does_not_borrow_the_members_panel_state(): +def test_admin_page_does_not_borrow_the_group_settings_state(): admin = _component("AdminPage") for name in ("doInvite", "adminId", "inviteCode", "setInviteUser"): assert name not in admin, \ - f"AdminPage references {name}, which only exists in MembersPanel" + f"AdminPage references {name}, which only exists in GroupSettingsPanel" # ── Upload pipelining ─────────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/tests/test_table_rows_measured.py b/packages/meshbay-hub/tests/test_table_rows_measured.py new file mode 100644 index 0000000..60ec878 --- /dev/null +++ b/packages/meshbay-hub/tests/test_table_rows_measured.py @@ -0,0 +1,116 @@ +""" +Every cell in a row ends where its row ends. + +Reported as "un décalage sur les lignes du tableau listant les membres": the +horizontal rules between rows came out staggered rather than straight. + +The cause was `display: flex` on the actions `<td>`. **A flex table cell stops +being a table cell** — it no longer stretches to the height of its row, so its +`border-bottom` is drawn wherever its own content happens to end. Measured +before the fix, in a row whose other cells were `top 76, height 40`, the actions +cell was `top 77, height 30`: its rule nine pixels above the rest. + +Nothing in the stylesheet says this. `min-height: 30px` was already there, added +for a related symptom, and reads as though it settles the question. Only the +rectangles show it does not — which is what this file is for. +""" + +import json +import subprocess +from pathlib import Path + +import shutil + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "layout_probe.py" +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" + +pytestmark = pytest.mark.skipif( + shutil.which("google-chrome") is None or not (STATIC / "style.css").exists(), + reason="Chrome or the SPA stylesheet is not available") + +WIDTHS = [420, 900] + +# The members table as `MembersTab` renders it: the owner's row carries no +# button, which is the row that used to break. Two ordinary rows after it, so a +# rule between two *equal* rows can be told from a rule against the odd one. +TABLE = """ +<div class="main"><div class="settings-section"> +<table class="admin-table"> + <thead><tr><th>User</th><th>Role</th><th></th></tr></thead> + <tbody> + <tr><td>grenet</td> + <td><span class="badge">Owner</span></td> + <td class="admin-actions"></td></tr> + <tr><td>toto</td> + <td><span class="badge">Member</span></td> + <td class="admin-actions"><button class="admin-btn danger">Remove</button></td></tr> + <tr><td>alice</td> + <td><span class="badge">Member</span></td> + <td class="admin-actions"><button class="admin-btn danger">Remove</button></td></tr> + </tbody> +</table> +</div></div> +""" + +ROWS = (1, 2, 3) +SELECTORS = [f"tbody tr:nth-child({r}) td:nth-child({c})" + for r in ROWS for c in (1, 2, 3)] + + +@pytest.fixture(scope="module") +def measured(tmp_path_factory): + fragment = tmp_path_factory.mktemp("table") / "fragment.html" + fragment.write_text(TABLE) + proc = subprocess.run( + ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS), + str(fragment), *SELECTORS], + capture_output=True, text=True, timeout=180) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + out = json.loads(proc.stdout) + assert "error" not in out, f"no measurement: {out}" + return out + + +def _cells(measured, width: int, row: int) -> list[dict]: + boxes = measured[str(width)]["boxes"] + return [boxes[f"tbody tr:nth-child({row}) td:nth-child({c})"] for c in (1, 2, 3)] + + +@pytest.mark.parametrize("width", WIDTHS) +@pytest.mark.parametrize("row", ROWS) +def test_the_rule_under_a_row_is_one_straight_line(measured, width, row): + bottoms = [c["top"] + c["height"] for c in _cells(measured, width, row)] + assert max(bottoms) - min(bottoms) <= 1, ( + f"row {row} at {width}px ends at {bottoms} — the border under the " + f"actions cell is drawn {max(bottoms) - min(bottoms)}px off the others") + + +@pytest.mark.parametrize("width", WIDTHS) +@pytest.mark.parametrize("row", ROWS) +def test_every_cell_in_a_row_starts_at_the_same_height(measured, width, row): + tops = [c["top"] for c in _cells(measured, width, row)] + assert max(tops) - min(tops) <= 1, f"row {row} at {width}px starts at {tops}" + + +@pytest.mark.parametrize("width", WIDTHS) +def test_the_row_without_a_button_is_as_tall_as_the_others(measured, width): + """The owner cannot be removed, so that row has an empty actions cell. It + still has to be a row, not a thin one that reads as a rendering fault.""" + heights = [_cells(measured, width, r)[0]["height"] for r in ROWS] + assert max(heights) - min(heights) <= 1, ( + f"row heights at {width}px are {heights}") + + +@pytest.mark.parametrize("width", WIDTHS) +def test_the_rows_are_stacked_with_no_gap_or_overlap(measured, width): + """A cell that does not fill its row leaves the next one starting early or + late; consecutive rows meeting exactly is what says the table is intact.""" + for row in ROWS[:-1]: + below = _cells(measured, width, row + 1)[0]["top"] + for cell in _cells(measured, width, row): + end = cell["top"] + cell["height"] + assert abs(below - end) <= 1, ( + f"at {width}px a cell of row {row} ends at {end} while row " + f"{row + 1} starts at {below}") |