summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-05 13:13:54 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-05 13:13:54 +0200
commit92b7afcb186d7aaf475939d58a0385f408425eeb (patch)
treea0283cdda0e91cf1af2a1ff7b1a0a896d3a4d1f4
parent97fa4cf78a826050b9d1df0b484a7794861cda80 (diff)
downloadmeshbay-92b7afcb186d7aaf475939d58a0385f408425eeb.tar.gz
feat(hub): say "N groups unreachable" on Search for a few seconds, not for goodHEAD0.18main
The line sat above the results for as long as the page was open. It is now said once a cross-group pass is over, for five seconds, in the same passing note as "Link copied" — moved out of copy-link.js into note.js (`say(text, ms)`), one note at a time for the whole page. The `.search-unreachable` rule goes with the line it styled. The copy-link probe now also checks in Chrome that the note goes by itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/copy-link.js21
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/note.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css10
-rw-r--r--packages/meshbay-hub/tests/harness/copy_link_probe.py8
-rw-r--r--packages/meshbay-hub/tests/test_copy_link.py5
-rw-r--r--packages/meshbay-hub/tests/test_search_unreachable_note.py33
7 files changed, 86 insertions, 34 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/copy-link.js b/packages/meshbay-hub/src/meshbay_hub/static/copy-link.js
index ad6c3be..4cc3f30 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/copy-link.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/copy-link.js
@@ -1,6 +1,7 @@
import { t } from './i18n.js';
import * as platform from './platform.js';
import { groupLinkRoute } from './group-link.js';
+import { say } from './note.js';
/**
* "Copy link": the `#/name@owner/path` address of a file or folder
@@ -41,27 +42,9 @@ async function _write(text) {
return ok;
}
-let _note = null;
-let _noteTimer = null;
-
-// Said once and gone, in the playlists' note (style.css `.playlist-note`).
-// Appended to the body rather than rendered by a view, because the views that
-// copy include modals and a context menu that has already closed.
-function _say(text) {
- if (!_note) {
- _note = document.createElement('div');
- _note.className = 'playlist-note copy-link-note';
- _note.setAttribute('role', 'status');
- }
- _note.textContent = text;
- document.body.appendChild(_note);
- clearTimeout(_noteTimer);
- _noteTimer = setTimeout(() => { if (_note) _note.remove(); }, 2500);
-}
-
/** Copy `url`, and say whether it worked. The link itself is shown if not. */
export async function copyLink(url) {
if (!url) return;
const ok = await _write(url);
- _say(ok ? t('link.copied') : t('link.copy_failed', { url }));
+ say(ok ? t('link.copied') : t('link.copy_failed', { url }));
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/note.js b/packages/meshbay-hub/src/meshbay_hub/static/note.js
new file mode 100644
index 0000000..f55c307
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/note.js
@@ -0,0 +1,24 @@
+/**
+ * A line said once and gone, in the playlists' note (style.css
+ * `.playlist-note`): "Link copied", "2 groups unreachable". Not a dialog —
+ * nothing here is anyone's to dismiss.
+ *
+ * Appended to the body rather than rendered by a view, because the views that
+ * speak include modals and a context menu that has already closed. One note
+ * at a time: a second replaces the first and restarts the clock.
+ */
+
+let _note = null;
+let _timer = null;
+
+export function say(text, ms = 2500) {
+ if (!_note) {
+ _note = document.createElement('div');
+ _note.className = 'playlist-note page-note';
+ _note.setAttribute('role', 'status');
+ }
+ _note.textContent = text;
+ document.body.appendChild(_note);
+ clearTimeout(_timer);
+ _timer = setTimeout(() => { if (_note) _note.remove(); }, ms);
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
index 1ede1f6..ae8eac7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -14,6 +14,7 @@ import { PhotosApp, groupPhotoAlbums } from './photos-app.js';
import { VideoPlayer } from './video-player.js';
import { transfers } from './transfers.js';
import { copyLink, entryPath, groupItemLink } from './copy-link.js';
+import { say } from './note.js';
import { mergeUnitEntries } from './source-merge.js';
import { useStickyBand } from './sticky.js';
import { ConnectionPool, MAX_IN_FLIGHT, MAX_POOL_SIZE } from './connection-pool.js';
@@ -22,6 +23,8 @@ const DEBOUNCE_MS = 200;
const SEARCH_VIDEO_ROOT = '__search__';
const SEARCH_AUDIO_ROOT = '__search__';
const SEARCH_PHOTO_ROOTS = ['__search_photos__'];
+// How long "N groups unreachable" stays up once a search pass is over.
+const UNREACHABLE_NOTE_MS = 5000;
// -- Index fetching -----------------------------------------------------------
@@ -313,13 +316,20 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
setProgress({ done: 0, total: groups.length, unreachable: [] });
if (!poolRef.current) return;
- await fetchAllIndexes(
+ const { unreachable } = await fetchAllIndexes(
poolRef.current, groups, token, username, userId,
(p) => { if (!cancelled) setProgress(p); },
(results) => { if (!cancelled) setIndexedGroups(new Map(results)); },
);
- if (!cancelled) setFetching(false);
+ if (cancelled) return;
+ setFetching(false);
+ // Said once the pass is over, for a few seconds, rather than left above
+ // the results: by then nothing more can be done about it here, and a
+ // line that stays is one more thing between the reader and the results.
+ if (unreachable.length) {
+ say(t('search.unreachable', { n: unreachable.length }), UNREACHABLE_NOTE_MS);
+ }
})();
return () => { cancelled = true; };
@@ -715,11 +725,6 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
</div>
`}
- ${progress.unreachable.length > 0 && html`
- <p class="search-unreachable">
- ${t('search.unreachable', { n: progress.unreachable.length })}
- </p>
- `}
${viewMode === 'files' && hasResults && html`
<${FilesPanel}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index bde1c61..21d92df 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -156,10 +156,6 @@ a:hover { text-decoration: underline; }
height: 100%; background: var(--accent);
transition: width 0.3s;
}
-.search-unreachable {
- font-size: 0.8em; color: var(--text-dim); margin-bottom: 8px;
-}
-
.view-toggle {
display: flex; gap: 0; flex-shrink: 0; margin-left: auto;
}
@@ -5613,9 +5609,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
overflow: hidden;
text-overflow: ellipsis;
}
-/* The same note for "Copy link" (copy-link.js). When the clipboard refused, it
- carries the link itself, which must be readable whole rather than cut. */
-.copy-link-note.playlist-note {
+/* The same note, said from anywhere (note.js). It can carry a link the
+ clipboard refused, which must be readable whole rather than cut. */
+.page-note.playlist-note {
white-space: normal;
overflow-wrap: anywhere;
border-radius: 12px;
diff --git a/packages/meshbay-hub/tests/harness/copy_link_probe.py b/packages/meshbay-hub/tests/harness/copy_link_probe.py
index 50d269d..564d277 100644
--- a/packages/meshbay-hub/tests/harness/copy_link_probe.py
+++ b/packages/meshbay-hub/tests/harness/copy_link_probe.py
@@ -69,7 +69,7 @@ const pick = async (label) => {
await wait(50);
return true;
};
-const note = () => (document.querySelector('.copy-link-note') || {}).textContent || '';
+const note = () => (document.querySelector('.page-note') || {}).textContent || '';
const closeMenus = async () => {
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
await frame();
@@ -177,6 +177,12 @@ const rowNamed = (name) => [...document.querySelectorAll('tr.file-row')]
cases.push({ case: 'photos', tile: Boolean(tile), labels: tileLabels, copied: fromTile,
lightbox_button: Boolean(lb), lightbox_copied: COPIES.at(-1) || null });
+ // Said, then gone (note.js): still up just after, not 2.5 s later.
+ const upAfterCopy = Boolean(document.querySelector('.page-note'));
+ await wait(2700);
+ cases.push({ case: 'note goes', up_after_copy: upAfterCopy,
+ up_later: Boolean(document.querySelector('.page-note')) });
+
parent.postMessage({ cases, logs: LOGS, origin: location.origin }, '*');
} catch (err) {
parent.postMessage({ error: String(err && (err.stack || err)), logs: LOGS }, '*');
diff --git a/packages/meshbay-hub/tests/test_copy_link.py b/packages/meshbay-hub/tests/test_copy_link.py
index 7115d04..22e56e7 100644
--- a/packages/meshbay-hub/tests/test_copy_link.py
+++ b/packages/meshbay-hub/tests/test_copy_link.py
@@ -96,3 +96,8 @@ def test_the_link_is_the_hubs_not_the_pages():
source = (STATIC / "copy-link.js").read_text(encoding="utf-8")
assert "platform.hubOrigin() + '/#' + groupLinkRoute(" in source
assert "location.origin" not in source
+
+
+def test_the_note_goes_by_itself(cases):
+ c = cases["note goes"]
+ assert c["up_after_copy"] is True and c["up_later"] is False
diff --git a/packages/meshbay-hub/tests/test_search_unreachable_note.py b/packages/meshbay-hub/tests/test_search_unreachable_note.py
new file mode 100644
index 0000000..41ed25c
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_search_unreachable_note.py
@@ -0,0 +1,33 @@
+"""
+"N groups unreachable" on the cross-group Search page is said once a pass is
+over, for a few seconds, in the same passing note as "Link copied" (note.js) —
+not left above the results for as long as the page is open.
+
+Source-level, as the other Search wiring checks are: what is held is where the
+line is said and that nothing renders it in the page any more.
+"""
+
+import re
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+
+def test_said_once_the_pass_is_over_and_not_rendered():
+ source = (STATIC / "search-page.js").read_text(encoding="utf-8")
+ assert "import { say } from './note.js';" in source
+ after = source[source.index("const { unreachable } = await fetchAllIndexes("):]
+ after = after[:after.index("})();")]
+ assert re.search(r"if \(cancelled\) return;\s*setFetching\(false\);", after)
+ assert "say(t('search.unreachable', { n: unreachable.length }), UNREACHABLE_NOTE_MS)" in after
+ assert "search-unreachable" not in source
+ ms = int(re.search(r"const UNREACHABLE_NOTE_MS = (\d+);", source).group(1))
+ assert 2000 <= ms <= 8000
+
+
+def test_one_note_for_the_whole_page():
+ note = (STATIC / "note.js").read_text(encoding="utf-8")
+ assert "export function say(text, ms = 2500)" in note
+ assert "clearTimeout(_timer)" in note
+ copy = (STATIC / "copy-link.js").read_text(encoding="utf-8")
+ assert "import { say } from './note.js';" in copy and "_say" not in copy