diff options
Diffstat (limited to 'packages/meshbay-hub/src')
4 files changed, 227 insertions, 117 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js index 585558f..ae6cd6f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -349,9 +349,13 @@ async function _writeOrStall(writable, bytes, at) { } } +// "Transport closed" is not here: that is close(), somebody ending the +// connection on purpose. "DataChannel closed" is the channel going away under +// it, which is what a reconnect is for. function _isRetryableTransportError(err) { return err.name === 'TransportLostError' || err.message === 'Response timeout' + || err.message === 'DataChannel closed' || (err.message || '').startsWith('DataChannel not open'); } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index bc22cbd..674779b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -501,7 +501,15 @@ export function NodePage({ groups, token, username }) { setSavingSettings(true); setActionMsg(''); try { - await nodeOp('setNodeSettings', { settings: editSettings }); + // What was changed, and nothing else. Sending the whole form stored every + // setting the operator had not touched as a choice, in the roster and in + // node.toml, so no later default ever reached that node again: one + // saved stream cap kept connection_processes at 1 for good. + const changed = Object.fromEntries(Object.entries(editSettings).filter( + ([k, v]) => JSON.stringify(v) !== JSON.stringify((nodeSettings || {})[k]))); + if (Object.keys(changed).length) { + await nodeOp('setNodeSettings', { settings: changed }); + } setNodeSettings({ ...editSettings }); setActionMsg(t('node.settings_saved')); } catch (err) { @@ -509,7 +517,7 @@ export function NodePage({ groups, token, username }) { } finally { setSavingSettings(false); } - }, [editSettings]); + }, [editSettings, nodeSettings]); const saveStun = useCallback(async () => { if (!editStun) return; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 627f2ca..de4f2ae 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -101,6 +101,14 @@ foreign highlight. Unrelated to real hover/focus — safe to kill outright. */ *, *::before, *::after { -webkit-tap-highlight-color: transparent; } +/* Every :hover rule in this file sits inside @media (hover: hover). A tap on a + touchscreen leaves the element in :hover until something else is tapped, + since no pointer ever moves away, so a tapped button kept its hover colour + (the music player's next/prev, the pager arrows, the nav hamburger: found + live on Android Chrome and in the app, 2026-08-26 and again 2026-10-16). + Fixing it button by button only moved the bug to the next one; scoping + them all is what test_hover_scoped.py holds. Real hover is unaffected. */ + body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg-base); @@ -110,7 +118,7 @@ body { } a { color: var(--accent); text-decoration: none; } -a:hover { text-decoration: underline; } +@media (hover: hover) { a:hover { text-decoration: underline; } } /* ── Cross-group search page ──────────────────────────────────────────────── */ @@ -141,7 +149,7 @@ a:hover { text-decoration: underline; } background: none; border: none; padding: 2px; cursor: pointer; color: var(--text-dim); display: flex; align-items: center; } -.search-bar-clear:hover { color: var(--text); } +@media (hover: hover) { .search-bar-clear:hover { color: var(--text); } } .search-bar-clear .icon { width: 14px; height: 14px; } .search-progress { @@ -182,7 +190,7 @@ a:hover { text-decoration: underline; } display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; cursor: pointer; } -.search-media-row:hover { background: var(--bg-raised); } +@media (hover: hover) { .search-media-row:hover { background: var(--bg-raised); } } .search-media-icon { flex-shrink: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; @@ -244,7 +252,7 @@ a:hover { text-decoration: underline; } font-size: 1.375em; text-decoration: none; } -.nav-brand:hover { text-decoration: none; } +@media (hover: hover) { .nav-brand:hover { text-decoration: none; } } /* The M is a picture standing in for a letter, so it is sized in `em` and follows the wordmark rather than being pinned to a pixel height. @@ -304,7 +312,7 @@ a:hover { text-decoration: underline; } font-size: 0.85em; transition: background 0.12s; } -.user-menu-trigger:hover { background: rgba(255, 255, 255, 0.15); } +@media (hover: hover) { .user-menu-trigger:hover { background: rgba(255, 255, 255, 0.15); } } .user-menu-avatar { width: 28px; height: 28px; @@ -368,7 +376,7 @@ a:hover { text-decoration: underline; } color: var(--text); font-size: 0.85em; cursor: pointer; text-align: left; } -.user-menu-item:hover { background: var(--bg-raised); } +@media (hover: hover) { .user-menu-item:hover { background: var(--bg-raised); } } .user-menu-logout { color: var(--error); } .umi-icon { width: 17px; height: 17px; flex-shrink: 0; color: var(--text-secondary); } @@ -387,7 +395,9 @@ a:hover { text-decoration: underline; } cursor: pointer; text-decoration: none; } -.nav-btn:hover { background: rgba(255, 255, 255, 0.18); text-decoration: none; } +@media (hover: hover) { + .nav-btn:hover { background: rgba(255, 255, 255, 0.18); text-decoration: none; } +} /* ── Layout ───────────────────────────────────────────────────────────────── */ @@ -426,7 +436,7 @@ a:hover { text-decoration: underline; } color: var(--text-dim); font-size: 0.75em; } -.sidebar-legal:hover { color: var(--accent); } +@media (hover: hover) { .sidebar-legal:hover { color: var(--accent); } } .register-legal { margin: 12px 0 0; @@ -477,7 +487,9 @@ a:hover { text-decoration: underline; } color: var(--text-dim); } .sidebar-item.active .sidebar-owner { color: inherit; opacity: 0.75; } -.sidebar-item:hover { background: var(--sidebar-hover); text-decoration: none; } +@media (hover: hover) { + .sidebar-item:hover { background: var(--sidebar-hover); text-decoration: none; } +} .sidebar-item.active { background: var(--accent); color: var(--accent-text); } .sidebar-empty { @@ -936,9 +948,9 @@ a:hover { text-decoration: underline; } font-size: 0.95em; font-weight: 600; } -.welcome-cta:hover { background: #15803d; text-decoration: none; } +@media (hover: hover) { .welcome-cta:hover { background: #15803d; text-decoration: none; } } .welcome-legal { color: var(--text-secondary); font-size: 0.9em; } -.welcome-legal:hover { color: var(--accent); } +@media (hover: hover) { .welcome-legal:hover { color: var(--accent); } } @media (max-width: 1000px) { .welcome { flex-direction: column; align-items: center; gap: 36px; } @@ -995,7 +1007,7 @@ input:focus { outline: none; border-color: var(--border-focus); } font-size: 1.15em; cursor: pointer; } -.pw-toggle:hover { background: none; color: var(--text); } +@media (hover: hover) { .pw-toggle:hover { background: none; color: var(--text); } } button { padding: 10px 20px; @@ -1007,7 +1019,7 @@ button { cursor: pointer; transition: background 0.15s; } -button:hover { background: var(--accent-hover); } +@media (hover: hover) { button:hover { background: var(--accent-hover); } } button:disabled { opacity: 0.5; cursor: not-allowed; } /* ── Alerts ───────────────────────────────────────────────────────────────── */ @@ -1066,10 +1078,12 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } color: var(--text); display: block; } -.group-card:hover { - border-color: var(--accent); - box-shadow: var(--shadow-lg); - text-decoration: none; +@media (hover: hover) { + .group-card:hover { + border-color: var(--accent); + box-shadow: var(--shadow-lg); + text-decoration: none; + } } .group-card h3 { font-size: 1em; margin-bottom: 4px; } @@ -1123,7 +1137,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } align-items: center; justify-content: center; } -.group-tab:hover { color: var(--text); background: none; } +@media (hover: hover) { .group-tab:hover { color: var(--text); background: none; } } .group-tab.active { color: var(--accent); border-bottom-color: var(--accent); @@ -1141,7 +1155,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } align-items: center; margin-left: auto; } -.group-mute-btn:hover { color: var(--text); } +@media (hover: hover) { .group-mute-btn:hover { color: var(--text); } } .group-mute-btn .icon { width: 20px; height: 20px; } /* ── Chat panel ──────────────────────────────────────────────────────────── */ @@ -1236,7 +1250,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } text-decoration: none; overflow: hidden; } -.chat-link-preview:hover { background: var(--bg-surface); } +@media (hover: hover) { .chat-link-preview:hover { background: var(--bg-surface); } } .clp-img { width: 72px; height: 72px; @@ -1356,9 +1370,11 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } cursor: pointer; transition: border-color 0.12s, color 0.12s, background 0.12s; } -.tb-btn:hover { border-color: var(--accent); color: var(--accent); } +@media (hover: hover) { .tb-btn:hover { border-color: var(--accent); color: var(--accent); } } .tb-btn:disabled { opacity: 0.45; cursor: not-allowed; } -.tb-btn:disabled:hover { border-color: var(--border); color: var(--text); } +@media (hover: hover) { + .tb-btn:disabled:hover { border-color: var(--border); color: var(--text); } +} .tb-btn .icon { width: 15px; height: 15px; } /* Square, for a toolbar button whose name is in its tooltip rather than beside it. The horizontal padding goes; the height does not, so it lines up @@ -1381,7 +1397,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } border-color: var(--accent); color: var(--accent-text); } -.tb-btn.primary:hover { background: var(--accent-hover); color: var(--accent-text); } +@media (hover: hover) { + .tb-btn.primary:hover { background: var(--accent-hover); color: var(--accent-text); } +} .tb-btn.active { background: color-mix(in srgb, var(--accent) 12%, var(--bg-surface)); border-color: var(--accent); @@ -1434,7 +1452,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } padding: 2px 4px; border-radius: 4px; } -.crumb:hover { background: var(--bg-raised); text-decoration: none; } +@media (hover: hover) { .crumb:hover { background: var(--bg-raised); text-decoration: none; } } .crumb-sep { color: var(--text-dim); } .crumb-refresh { background: none; @@ -1442,7 +1460,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } font: inherit; color: var(--text-secondary); } -.crumb-refresh:hover:not(:disabled) { color: var(--text); } +@media (hover: hover) { .crumb-refresh:hover:not(:disabled) { color: var(--text); } } .crumb-refresh:disabled { cursor: default; } .crumb-refresh.spinning .icon { animation: spin 0.8s linear infinite; } @@ -1477,7 +1495,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } } .file-table th.sortable { cursor: pointer; } -.file-table th.sortable:hover { color: var(--text); } +@media (hover: hover) { .file-table th.sortable:hover { color: var(--text); } } .file-row td { padding: 8px 12px; @@ -1485,7 +1503,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } vertical-align: middle; } -.file-row:hover { background: var(--bg-raised); } +@media (hover: hover) { .file-row:hover { background: var(--bg-raised); } } .dir-row { cursor: pointer; } .dir-row td { font-weight: 500; } @@ -1522,7 +1540,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } color: var(--text-dim); cursor: pointer; } -.file-loc:hover { color: var(--accent); text-decoration: underline; } +@media (hover: hover) { .file-loc:hover { color: var(--accent); text-decoration: underline; } } .file-size { white-space: nowrap; color: var(--text-secondary); } .file-type { color: var(--text-dim); } .file-date { white-space: nowrap; color: var(--text-dim); } @@ -1573,7 +1591,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } cursor: pointer; line-height: 1; } -.file-menu-btn:hover { background: var(--bg-raised); border-color: var(--accent); color: var(--accent); } +@media (hover: hover) { + .file-menu-btn:hover { background: var(--bg-raised); border-color: var(--accent); color: var(--accent); } +} .file-menu { position: absolute; @@ -1600,7 +1620,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } cursor: pointer; border-radius: 0; } -.file-menu button:hover { background: var(--bg-raised); } +@media (hover: hover) { .file-menu button:hover { background: var(--bg-raised); } } /* ── File link (clickable name) ─────────────────────────────────────────── */ @@ -1609,7 +1629,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } cursor: pointer; text-decoration: none; } -.file-link:hover { text-decoration: underline; } +@media (hover: hover) { .file-link:hover { text-decoration: underline; } } /* ── File preview overlay ───────────────────────────────────────────────── */ @@ -1665,7 +1685,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } flex-shrink: 0; color: var(--text-secondary); } -.chat-attach:hover { background: var(--bg-raised); color: var(--accent); } +@media (hover: hover) { .chat-attach:hover { background: var(--bg-raised); color: var(--accent); } } .chat-attachment { display: flex; @@ -1692,7 +1712,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .file-menu button { display: flex; align-items: center; gap: 8px; } .file-menu button.danger { color: var(--error); } -.file-menu button.danger:hover { background: var(--error-bg); } +@media (hover: hover) { .file-menu button.danger:hover { background: var(--error-bg); } } .fmi { font-size: 0.9em; width: 16px; text-align: center; flex-shrink: 0; } /* ── Download button + progress ──────────────────────────────────────────── */ @@ -1711,7 +1731,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } justify-content: center; cursor: pointer; } -.dl-btn:hover { background: var(--bg-raised); border-color: var(--accent); } +@media (hover: hover) { + .dl-btn:hover { background: var(--bg-raised); border-color: var(--accent); } +} .dl-btn:disabled { opacity: 0.3; cursor: not-allowed; } @@ -1858,7 +1880,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } background: var(--accent); color: var(--accent-text); font-family: inherit; font-size: 0.83em; cursor: pointer; } -.app-save:hover:not(:disabled) { background: var(--accent-hover); } +@media (hover: hover) { .app-save:hover:not(:disabled) { background: var(--accent-hover); } } /* No transition on the enabled/disabled swap. It animates a colour change that coincides with a text-colour change, which reads as a flicker — and it made the state genuinely hard to observe: a measurement taken during it reports a @@ -2044,10 +2066,10 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } justify-content: center; flex-shrink: 0; } -.video-close:hover { background: rgba(255, 255, 255, 0.25); } +@media (hover: hover) { .video-close:hover { background: rgba(255, 255, 255, 0.25); } } .video-close.dl-active { background: rgba(34, 197, 94, 0.25); pointer-events: none; } .video-close.cast-active { background: rgba(59, 130, 246, 0.35); } -.video-close.cast-active:hover { background: rgba(59, 130, 246, 0.5); } +@media (hover: hover) { .video-close.cast-active:hover { background: rgba(59, 130, 246, 0.5); } } .cast-url-input { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 4px; color: #e2e8f0; font-size: 0.75em; font-family: inherit; @@ -2058,9 +2080,11 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .cast-wrapper { display: inline-block; position: relative; } .video-close:disabled { opacity: 0.4; cursor: default; } .video-close.cast-btn.casting { background: rgba(59, 130, 246, 0.35); } -.video-close.cast-btn.casting:hover { background: rgba(59, 130, 246, 0.5); } +@media (hover: hover) { + .video-close.cast-btn.casting:hover { background: rgba(59, 130, 246, 0.5); } +} .cast-picker-item.cast-menu-current { color: #94a3b8; cursor: default; } -.cast-picker-item.cast-menu-current:hover { background: none; } +@media (hover: hover) { .cast-picker-item.cast-menu-current:hover { background: none; } } /* The cast button in a page's toolbar (cast-session.js): the same panel as any other menu there, hung under the button rather than at a point. */ @@ -2069,7 +2093,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } /* From the music bar, at the bottom of the window: the menu opens upward. */ .cast-menu-up { top: auto !important; bottom: calc(100% + 4px); } .ctx-menu-item.cast-menu-current { color: var(--text-dim); cursor: default; } -.ctx-menu-item.cast-menu-current:hover { background: none; } +@media (hover: hover) { .ctx-menu-item.cast-menu-current:hover { background: none; } } .ctx-menu-item svg { width: 16px; height: 16px; flex-shrink: 0; } .photo-album-actions { display: flex; align-items: center; gap: 4px; } .cast-picker { @@ -2084,12 +2108,12 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } color: #e2e8f0; font-size: 0.82em; cursor: pointer; text-align: left; font-family: inherit; } -.cast-picker-item:hover { background: rgba(255, 255, 255, 0.09); } +@media (hover: hover) { .cast-picker-item:hover { background: rgba(255, 255, 255, 0.09); } } .cast-picker-item svg { width: 16px; height: 16px; flex-shrink: 0; } .cast-picker-scanning { color: #94a3b8; cursor: default; } -.cast-picker-scanning:hover { background: none; } +@media (hover: hover) { .cast-picker-scanning:hover { background: none; } } .cast-picker-empty { color: #64748b; cursor: default; font-style: italic; } -.cast-picker-empty:hover { background: none; } +@media (hover: hover) { .cast-picker-empty:hover { background: none; } } .cast-picker-sep { height: 1px; margin: 4px 10px; background: rgba(255,255,255,0.1); } .cast-status-label { color: #94a3b8; font-size: 0.78em; display: flex; align-items: center; @@ -2270,7 +2294,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } color: #e2e8f0; font-size: 44px; } -.video-remote-skip:hover:not(:disabled) { background: rgba(148, 163, 184, 0.12); } +@media (hover: hover) { + .video-remote-skip:hover:not(:disabled) { background: rgba(148, 163, 184, 0.12); } +} .video-remote-skip .icon { stroke-width: 1.4; } .video-remote-skip-n { position: absolute; @@ -2292,7 +2318,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } font-size: 34px; box-shadow: 0 8px 24px rgba(35, 177, 240, 0.35); } -.video-remote-main:hover:not(:disabled) { background: #4cc3f5; } +@media (hover: hover) { .video-remote-main:hover:not(:disabled) { background: #4cc3f5; } } .video-remote-main .icon { stroke-width: 2.6; } /* The triangle is a closed path: filled, it reads as play at a glance. */ .video-remote-main.is-paused .icon path { fill: currentColor; } @@ -2311,7 +2337,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } font-size: 0.9rem; cursor: pointer; } -.video-remote-stop:hover { border-color: #fca5a5; color: #fecaca; } +@media (hover: hover) { .video-remote-stop:hover { border-color: #fca5a5; color: #fecaca; } } /* Where the film was picked up again. Sits over the picture rather than pushing it down, and clears itself once the viewer is watching. */ @@ -2407,7 +2433,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } cursor: pointer; margin-right: 4px; } -.play-btn:hover { background: var(--bg-raised); border-color: var(--success); } +@media (hover: hover) { + .play-btn:hover { background: var(--bg-raised); border-color: var(--success); } +} .play-btn:disabled { opacity: 0.3; cursor: not-allowed; } /* ── Admin panel ─────────────────────────────────────────────────────────── */ @@ -2431,7 +2459,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } margin-bottom: -2px; transition: color 0.15s, border-color 0.15s; } -.admin-tab:hover { color: var(--text); } +@media (hover: hover) { .admin-tab:hover { color: var(--text); } } .admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); @@ -2532,7 +2560,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } word-break: break-word; min-width: 0; } -.admin-table tr:hover { background: var(--bg-raised); } +@media (hover: hover) { .admin-table tr:hover { background: var(--bg-raised); } } .admin-table .admin-empty { text-align: center; padding: 24px; @@ -2642,9 +2670,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } cursor: pointer; white-space: nowrap; } -.admin-btn:hover { border-color: var(--accent); color: var(--accent); } +@media (hover: hover) { .admin-btn:hover { border-color: var(--accent); color: var(--accent); } } .admin-btn.danger { color: var(--error); } -.admin-btn.danger:hover { border-color: var(--error); } +@media (hover: hover) { .admin-btn.danger:hover { border-color: var(--error); } } .admin-btn:disabled { opacity: 0.4; cursor: not-allowed; } /* Used in four places and never defined, so it rendered as a bare browser @@ -2659,7 +2687,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } cursor: pointer; white-space: nowrap; } -.btn-secondary:hover { border-color: var(--border); color: var(--text); } +@media (hover: hover) { .btn-secondary:hover { border-color: var(--border); color: var(--text); } } .btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; } /* The recovery screen's "e-mail it to myself": a mailto: link, shaped like the Copy button beside it. */ @@ -2683,7 +2711,7 @@ a.recovery-mail { font-size: 0.8em; cursor: pointer; } -.link-btn:hover { color: var(--accent); } +@media (hover: hover) { .link-btn:hover { color: var(--accent); } } /* The header is a flex row, so a textarea in it collapses to its content width unless told otherwise. */ @@ -2799,7 +2827,7 @@ a.transfer-name { padding: 0 2px; display: flex; } -.transfer-cancel:hover { color: var(--error); } +@media (hover: hover) { .transfer-cancel:hover { color: var(--error); } } /* Same shape as cancel, and beside it: pausing and cancelling are the two things a person does to a transfer, and one of them is not destructive. */ .transfer-pause { @@ -2810,7 +2838,7 @@ a.transfer-name { padding: 0 2px; display: flex; } -.transfer-pause:hover { color: var(--accent); } +@media (hover: hover) { .transfer-pause:hover { color: var(--accent); } } /* Not a button: there is nothing to click. Dimmer than the cancel beside it, and it carries its explanation in a tooltip rather than in the row, which would be four lines of prose in a panel that has none. */ @@ -3136,11 +3164,16 @@ a.transfer-name { border-radius: 6px; opacity: 0.75; } -.nav-tray:hover, .nav-tray:focus-visible { opacity: 1; background: rgba(255, 255, 255, 0.1); } +@media (hover: hover) { + .nav-tray:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.1); + } +} /* ── Notification bell ───────────────────────────────────────────────────── */ @@ -3160,8 +3193,8 @@ a.transfer-name { padding: 4px; border-radius: 6px; } -.nav-search:hover, .nav-search[aria-current="page"] { background: rgba(255, 255, 255, 0.1); } +@media (hover: hover) { .nav-search:hover { background: rgba(255, 255, 255, 0.1); } } .notif-badge { position: absolute; top: -6px; @@ -3199,8 +3232,10 @@ a.transfer-name { cursor: pointer; border: 1px solid var(--border); } -.notif-item:hover { - background: var(--bg-raised); +@media (hover: hover) { + .notif-item:hover { + background: var(--bg-raised); + } } .notif-unread { /* `--primary` is not defined here either; it is `--accent`. */ @@ -3296,7 +3331,7 @@ a.transfer-name { cursor: pointer; font-size: 0.9em; } -.btn-danger:hover { filter: brightness(1.1); } +@media (hover: hover) { .btn-danger:hover { filter: brightness(1.1); } } .btn-danger:disabled { opacity: 0.5; cursor: not-allowed; } .notif-feed h3 { display: flex; align-items: center; justify-content: space-between; } @@ -3379,7 +3414,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } font-size: 0.8em; cursor: pointer; } -.chat-older-btn:hover:not(:disabled) { background: var(--bg-surface); color: var(--text); } +@media (hover: hover) { + .chat-older-btn:hover:not(:disabled) { background: var(--bg-surface); color: var(--text); } +} .chat-older-btn:disabled { opacity: 0.6; cursor: default; } .chat-older-icon { width: 13px; height: 13px; transform: rotate(180deg); } @@ -3435,7 +3472,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } cursor: pointer; box-shadow: var(--shadow-lg); } -.chat-jump:hover { color: var(--text); } +@media (hover: hover) { .chat-jump:hover { color: var(--text); } } .chat-jump.unread { background: var(--accent); color: var(--accent-text); @@ -3464,10 +3501,12 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } cursor: pointer; transition: background 0.12s, color 0.12s, border-color 0.12s; } -.tb-icon-btn:hover:not(:disabled) { - background: var(--bg-raised); - color: var(--text); - border-color: var(--accent); +@media (hover: hover) { + .tb-icon-btn:hover:not(:disabled) { + background: var(--bg-raised); + color: var(--text); + border-color: var(--accent); + } } /* Present but inert: the bar shows what exists here, and greys out what this selection cannot do. `not-allowed` rather than `default` so the reason is @@ -3478,10 +3517,12 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } } .tb-icon-btn .icon { width: 16px; height: 16px; } .tb-icon-btn.danger { color: var(--error); } -.tb-icon-btn.danger:hover:not(:disabled) { - background: var(--error-bg); - border-color: var(--error); - color: var(--error); +@media (hover: hover) { + .tb-icon-btn.danger:hover:not(:disabled) { + background: var(--error-bg); + border-color: var(--error); + color: var(--error); + } } @@ -3505,7 +3546,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } cursor: pointer; transition: border-color 0.12s, background 0.12s; } -.choice:hover { border-color: var(--accent); } +@media (hover: hover) { .choice:hover { border-color: var(--accent); } } .choice.selected { border-color: var(--accent); background: var(--bg-raised); } .choice:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; } @@ -3538,7 +3579,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } font-weight: 600; cursor: pointer; } -.btn-primary:hover:not(:disabled) { background: var(--accent-hover); } +@media (hover: hover) { .btn-primary:hover:not(:disabled) { background: var(--accent-hover); } } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } /* A root whose volume is absent. Its files stay listed — they are frozen, not @@ -3627,9 +3668,13 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } line-height: 1; vertical-align: middle; } .sdt-action-btn + .sdt-action-btn { margin-left: 6px; } -.sdt-action-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text); } +@media (hover: hover) { + .sdt-action-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text); } +} .sdt-action-btn:disabled { opacity: 0.3; cursor: default; } -.sdt-action-danger:hover:not(:disabled) { color: var(--error); border-color: var(--error); } +@media (hover: hover) { + .sdt-action-danger:hover:not(:disabled) { color: var(--error); border-color: var(--error); } +} .sdt-action-btn .icon { width: 1em; height: 1em; display: block; } .sdt-dir-name { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; } .sdt-dir-name .icon { width: 16px; height: 16px; flex-shrink: 0; } @@ -3640,7 +3685,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } padding: 2px 6px; cursor: pointer; font-size: 0.85em; color: var(--text-dim); line-height: 1; } -.root-eject-btn:hover { background: var(--bg-raised); } +@media (hover: hover) { .root-eject-btn:hover { background: var(--bg-raised); } } .root-eject-btn { margin-left: 8px; } /* An ejected root in the Files table: still listed, deliberately — its files are frozen, not gone — but not somewhere you can walk into. */ @@ -4187,7 +4232,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } transition: background 0.12s, color 0.12s; } .tb-typefilter button + button { border-left: 1px solid var(--border); } -.tb-typefilter button:hover { color: var(--accent); } +@media (hover: hover) { .tb-typefilter button:hover { color: var(--accent); } } .tb-typefilter button.active { background: var(--accent); color: var(--accent-text); @@ -4213,7 +4258,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } border: 1px solid var(--border); transition: border-color 0.12s, transform 0.12s; } -.video-card:hover { border-color: var(--accent); transform: translateY(-2px); } +@media (hover: hover) { + .video-card:hover { border-color: var(--accent); transform: translateY(-2px); } +} .video-poster { width: 100%; @@ -4321,7 +4368,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } } .video-detail .video-title { color: var(--text); } .video-detail .video-close { background: var(--bg-surface); color: var(--text); } -.video-detail .video-close:hover { background: var(--border); } +@media (hover: hover) { .video-detail .video-close:hover { background: var(--border); } } .video-detail-body { padding: 16px 20px; @@ -4386,7 +4433,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } font: inherit; cursor: pointer; } -.video-overview-toggle:hover { text-decoration: underline; } +@media (hover: hover) { .video-overview-toggle:hover { text-decoration: underline; } } .video-overview-toggle.standalone { float: none; display: inline-block; @@ -4464,7 +4511,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } text-align: left; margin-bottom: 4px; } -.video-episode-row:hover { border-color: var(--accent); color: var(--accent); } +@media (hover: hover) { + .video-episode-row:hover { border-color: var(--accent); color: var(--accent); } +} /* The episode last started from this modal, so the one to watch next is the row under it. A border and a tint, not a colour change of the text alone, so it does not read as the hover state left behind. */ @@ -4508,7 +4557,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } border-radius: 6px; cursor: pointer; } -.video-flat-row:hover { background: var(--bg-raised); } +@media (hover: hover) { .video-flat-row:hover { background: var(--bg-raised); } } .video-flat-thumb-slot { width: 64px; height: 40px; flex-shrink: 0; } .video-flat-thumb { @@ -4560,7 +4609,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } cursor: pointer; text-align: left; } -.video-season-trigger:hover { border-color: var(--accent); color: var(--accent); } +@media (hover: hover) { + .video-season-trigger:hover { border-color: var(--accent); color: var(--accent); } +} .video-season-current { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .video-season-caret { width: 14px; height: 14px; flex-shrink: 0; color: var(--text-dim); } @@ -4597,7 +4648,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } text-align: left; cursor: pointer; } -.video-season-option:hover { background: var(--bg-raised); } +@media (hover: hover) { .video-season-option:hover { background: var(--bg-raised); } } .video-season-option.active { color: var(--accent); } .video-season-option-count { color: var(--text-dim); font-size: 0.85em; flex-shrink: 0; } @@ -4634,7 +4685,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } text-align: left; cursor: pointer; } -.video-search-result:hover:not(:disabled) { border-color: var(--accent); background: var(--bg-raised); } +@media (hover: hover) { + .video-search-result:hover:not(:disabled) { border-color: var(--accent); background: var(--bg-raised); } +} .video-search-result:disabled { opacity: 0.5; cursor: not-allowed; } .video-search-result-thumb { width: 46px; height: 68px; flex-shrink: 0; } .video-search-result-poster { @@ -4681,7 +4734,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } border: 1px solid var(--border); transition: border-color 0.12s, transform 0.12s; } -.music-card:hover { border-color: var(--accent); transform: translateY(-2px); } +@media (hover: hover) { + .music-card:hover { border-color: var(--accent); transform: translateY(-2px); } +} .music-cover { width: 100%; @@ -4740,7 +4795,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } } .music-detail .video-title { color: var(--text); } .music-detail .video-close { background: var(--bg-surface); color: var(--text); } -.music-detail .video-close:hover { background: var(--border); } +@media (hover: hover) { .music-detail .video-close:hover { background: var(--border); } } .music-queue-now-playing { padding: 10px 20px; @@ -4814,7 +4869,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } text-align: left; cursor: pointer; } -.music-track-row:hover { background: var(--bg-raised); border-color: var(--border); } +@media (hover: hover) { + .music-track-row:hover { background: var(--bg-raised); border-color: var(--border); } +} .music-track-row.active { border-color: var(--accent); color: var(--accent); } .music-track-no { width: 24px; flex-shrink: 0; color: var(--text-dim); text-align: right; } .music-track-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -4955,7 +5012,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } color: var(--text-dim); cursor: pointer; } -.index-dock-hide:hover { background: var(--bg-surface); } +@media (hover: hover) { .index-dock-hide:hover { background: var(--bg-surface); } } .index-dock-hide .icon { width: 16px; height: 16px; } @media (max-width: 640px) { .index-dock-head { flex-direction: column; gap: 2px; } @@ -5009,12 +5066,6 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } cursor: pointer; position: relative; } -/* (hover: hover) — a tap on a touchscreen fires a synthetic hover that - never gets a "pointer left" to end it, so next/prev/shuffle stayed - highlighted as if still being pressed until some other element was - tapped (found live on mobile Chrome, 2026-08-26; not reproducible on - desktop, which actually has a mouse to move away with). Scoped to - devices that can genuinely hover — real hover behaviour is unaffected. */ @media (hover: hover) { .music-player-btn:hover { background: var(--bg-surface); color: var(--text); } } @@ -5140,7 +5191,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } align-items: center; justify-content: center; } -.photo-icon-btn:hover { color: var(--text); background: var(--bg-surface); } +@media (hover: hover) { + .photo-icon-btn:hover { color: var(--text); background: var(--bg-surface); } +} .photo-icon-btn-icon { width: 22px; height: 22px; } /* .tb-btn's own chevron is drawn pointing down; rotated here to read as @@ -5165,7 +5218,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } border: 1px solid var(--border); transition: border-color 0.12s, transform 0.12s; } -.photo-album-card:hover { border-color: var(--accent); transform: translateY(-2px); } +@media (hover: hover) { + .photo-album-card:hover { border-color: var(--accent); transform: translateY(-2px); } +} .photo-album-cover { width: 100%; @@ -5205,7 +5260,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } background: var(--bg-raised); border: 1px solid var(--border); } -.photo-tile:hover { border-color: var(--accent); } +@media (hover: hover) { .photo-tile:hover { border-color: var(--accent); } } .photo-tile { position: relative; } /* A video among the photos: a play mark and its length over the thumbnail. */ .photo-tile-video { @@ -5227,7 +5282,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } border: 0; background: rgba(0, 0, 0, 0.6); color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; } -.photo-lightbox-play:hover { background: var(--accent); } +@media (hover: hover) { .photo-lightbox-play:hover { background: var(--accent); } } .photo-lightbox-play .icon { width: 28px; height: 28px; } .photo-tile-thumb { width: 100%; @@ -5282,7 +5337,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } align-items: center; justify-content: center; } -.photo-nav:hover { background: rgba(255, 255, 255, 0.22); } +@media (hover: hover) { .photo-nav:hover { background: rgba(255, 255, 255, 0.22); } } .photo-nav:disabled { opacity: 0.3; cursor: default; } .photo-nav-icon { width: 20px; height: 20px; } .photo-nav-prev-icon { transform: rotate(90deg); } @@ -5342,10 +5397,12 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } padding: 5px; border-radius: 4px; } -.photo-zoom-controls .photo-icon-btn:hover { background: rgba(255, 255, 255, 0.15); } +@media (hover: hover) { + .photo-zoom-controls .photo-icon-btn:hover { background: rgba(255, 255, 255, 0.15); } +} .photo-zoom-controls .photo-icon-btn.active { background: rgba(255, 255, 255, 0.18); } .photo-zoom-controls .photo-icon-btn:disabled { opacity: 0.35; cursor: default; } -.photo-zoom-controls .photo-icon-btn:disabled:hover { background: none; } +@media (hover: hover) { .photo-zoom-controls .photo-icon-btn:disabled:hover { background: none; } } .photo-zoom-controls .photo-icon-btn-icon { width: 18px; height: 18px; } .photo-icon-btn-text { font-size: 0.72em; font-weight: 600; padding: 5px 8px; } .photo-zoom-percent { @@ -5625,7 +5682,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } text-align: left; cursor: pointer; } -.ctx-menu-item:hover:not(:disabled) { background: var(--sidebar-hover); } +@media (hover: hover) { .ctx-menu-item:hover:not(:disabled) { background: var(--sidebar-hover); } } .ctx-menu-item:disabled { color: var(--text-dim); cursor: default; } .ctx-menu-item.danger { color: var(--error); } @@ -5673,10 +5730,12 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } transition: opacity 0.12s, background 0.12s; } .ctx-dots svg { width: 18px; height: 18px; } -.ctx-dots:hover { background: var(--sidebar-hover); color: var(--text); } +@media (hover: hover) { .ctx-dots:hover { background: var(--sidebar-hover); color: var(--text); } } .ctx-dots:focus-visible { opacity: 1; outline: 2px solid var(--border-focus); } -.music-card:hover .ctx-dots, -.music-track-row:hover .ctx-dots { opacity: 1; } +@media (hover: hover) { + .music-card:hover .ctx-dots, + .music-track-row:hover .ctx-dots { opacity: 1; } +} @media (hover: none), (pointer: coarse) { .ctx-dots { opacity: 1; } @@ -5691,7 +5750,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } color: #fff; backdrop-filter: blur(2px); } -.music-card-dots:hover { background: rgba(0, 0, 0, 0.75); color: #fff; } +@media (hover: hover) { .music-card-dots:hover { background: rgba(0, 0, 0, 0.75); color: #fff; } } /* A flat folder row expands on click; its menu button sits in the same row and must not take the whole width with it. */ diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index a07f431..15c7b09 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -447,6 +447,16 @@ const LEASE_WATCHDOG_MS = 60000; // one sender: 1 connection 5 to 6 MB/s, 4 about 19, 8 between 27 and 34, and // 16 no more than that (the node's sender is one Python process). const DOWNLOAD_LANES = 8; + +// What a connection sends while it is opening its session, before the node +// knows who is on it: the node answers anything else with "Handshake +// required" (webrtc/dispatch.py), and the same list is what it accepts there. +// While a reconnect runs these are the only messages that go out; everything +// else waits for the session (see `_sendAndWait`). +const SESSION_OPENING = new Set([ + 'handshake', 'handshake_response', 'keypair_bundle_fetch', 'gek_bundle_fetch', + 'join_request', 'device_add_request', 'device_hello', +]); // A lane left unused this long is closed: each one is a peer connection the // node holds open. const LANE_IDLE_MS = 30000; @@ -594,6 +604,10 @@ class MeshBayTransport { this._lanesTimer = 0; this._laneRetryAt = 0; this._lane = false; + // True from the end of connect() — the node has authenticated this side — + // until the connection is lost. Not `_connected`, which is true from the + // moment the channel opens, a whole handshake earlier. + this._sessionUp = false; // Set once close() runs — stops the automatic reconnect from firing on a // connection the caller tore down on purpose (leaving the group, page // unload), which would otherwise race back in right as everything else @@ -810,6 +824,7 @@ class MeshBayTransport { // reconnect that never got as far as announcing a device still looked, to // the composer, exactly like one that had. this._setDevicePk('', 'new connection'); + this._sessionUp = false; this._pc = new RTCPeerConnection({ iceServers: await iceServers() }); this._channel = this._pc.createDataChannel('mnp', { ordered: true }); @@ -836,15 +851,23 @@ class MeshBayTransport { channelReady.catch(() => { /* the awaiter below reports it */ }); this._channel.onmessage = (event) => this._onMessage(event.data); + const channel = this._channel; this._channel.onclose = (ev) => { - console.warn('[MeshBay] DataChannel closed', this._channel?.readyState, ev); + console.warn('[MeshBay] DataChannel closed', channel.readyState, ev); trace('channel_close', { - readyState: this._channel?.readyState, + readyState: channel.readyState, pc: this._pc?.connectionState, ice: this._pc?.iceConnectionState, }); - this._connected = false; if (channelReject) channelReject(new Error('DataChannel closed')); + // A reconnect closes the old channel before it opens the next, and the + // event comes when it comes: from a channel that is no longer this + // connection's, it is news about nothing. Acted on, it marked the new + // session closed and failed whatever it had in flight, its own + // handshake included. + if (channel !== this._channel) return; + this._connected = false; + this._sessionUp = false; for (const [, p] of this._pending) p.reject(new Error('DataChannel closed')); this._pending.clear(); }; @@ -876,6 +899,7 @@ class MeshBayTransport { // sit out its own timeout instead of failing fast. if (pc.connectionState === 'failed' && pc === this._pc && !this._closed) { this._connected = false; + this._sessionUp = false; this._reconnect(); const err = new Error('WebRTC connection lost'); err.name = 'TransportLostError'; @@ -1303,6 +1327,7 @@ class MeshBayTransport { console.warn('[MeshBay] device_hello failed — chat will not work:', e); }); + this._sessionUp = true; return ack; } @@ -1757,6 +1782,7 @@ class MeshBayTransport { if (this._channel) this._channel.close(); if (this._pc) this._pc.close(); this._connected = false; + this._sessionUp = false; for (const [, p] of this._pending) p.reject(new Error('Transport closed')); this._pending.clear(); } @@ -1823,12 +1849,25 @@ class MeshBayTransport { async _sendAndWait(obj, timeoutMs = 30000) { // A reconnect already in flight (see _reconnectLoop) means the channel - // this would send on is the one just declared dead. `_inReconnectAttempt` - // excludes the handshake connect() itself makes while reconnecting — that - // call runs *inside* this same _reconnectPromise, which cannot resolve - // until it returns, so waiting on it here would just be waiting on - // itself for the full 6s, on every step of the handshake, every time. - if (!this._inReconnectAttempt) await this.waitForReconnect(6000); + // this would send on is the one just declared dead. The handshake that + // connect() sends while reconnecting runs *inside* that same + // _reconnectPromise, which cannot resolve until it returns, so waiting on + // it here would be waiting on itself — those messages go straight out. + // + // Only those. This used to let *every* message through while the attempt + // ran, so a download's chunk asked for again in that window went out on a + // channel that had opened and not finished its handshake: the node + // answered "Handshake required", which no retry treats as a dropped + // connection, and a download was failed by the reconnect meant to save it. + const opening = SESSION_OPENING.has(obj.type); + if (!(opening && this._inReconnectAttempt)) await this.waitForReconnect(6000); + if (!opening && !this._sessionUp) { + // No session to send it on: what the caller sees on a dropped connection, + // and what the download path waits out (`_fetchChunkResilient`). + const err = new Error('WebRTC connection lost'); + err.name = 'TransportLostError'; + throw err; + } return new Promise((resolve, reject) => { const id = this._seqId++; const timeout = setTimeout(() => { |