diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
13 files changed, 408 insertions, 145 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> 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(); }; }); |