aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js378
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css107
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js28
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();
};
});