diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 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> |