aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js378
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>