diff --git a/frontend/css/style.css b/frontend/css/style.css
index b46319e..819330c 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -3256,7 +3256,8 @@ body.theme-light .saved-badge { box-shadow: 0 0 0 2px rgba(246, 241, 231, 0.95);
.saved-head-actions {
display: flex;
align-items: center;
- gap: 6px;
+ /* Two underlined links side by side need the space to read as two. */
+ gap: 12px;
flex-shrink: 0;
}
diff --git a/frontend/index.html b/frontend/index.html
index afdc18d..0556773 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -251,6 +251,7 @@
+
diff --git a/frontend/js/enhance.js b/frontend/js/enhance.js
index 8db7953..94f52c6 100644
--- a/frontend/js/enhance.js
+++ b/frontend/js/enhance.js
@@ -196,6 +196,14 @@ App.enhance = App.enhance || {};
hint: 'Source · channel · filters · query',
run: () => App.ui.savedSearches.toggleCurrent()
});
+ const total = App.savedSearches.totalNew();
+ if (total) {
+ out.push({
+ label: 'Mark all saved searches read',
+ hint: `${App.savedSearches.badgeLabel(total)} new`,
+ run: () => App.savedSearches.markAllRead()
+ });
+ }
saved.forEach((entry) => {
const label = App.savedSearches.badgeLabel(entry.newCount);
out.push({
diff --git a/frontend/js/savedSearches.js b/frontend/js/savedSearches.js
index 96f8589..490be2e 100644
--- a/frontend/js/savedSearches.js
+++ b/frontend/js/savedSearches.js
@@ -19,10 +19,13 @@ App.savedSearches = App.savedSearches || {};
(function() {
const KEY = App.constants.SAVED_SEARCHES_KEY;
- // How many of the newest ids are kept as the "already seen" mark. Twice
- // the probe below, so a listing that shifts by a page between visits is
- // still measured against something.
- const SEEN_KEEP = 24;
+ // How many of the newest ids are kept as the "already seen" mark. Several
+ // times the page asked for below: `perPage` is a request, not a promise --
+ // sources answer a page of 12 with 30 whenever that is how their own pages
+ // are cut -- so the mark has to be able to hold a page larger than the one
+ // that was asked for, and then some, for a listing that shifts between
+ // visits to still be measured against something.
+ const SEEN_KEEP = 60;
// One page is enough to answer "is there anything new, and roughly how
// much": the badge stops counting at 9+ anyway.
const PROBE_PER_PAGE = 12;
@@ -303,11 +306,32 @@ App.savedSearches = App.savedSearches || {};
return ask(entry.channelId, PROBE_PER_PAGE, extra);
};
- const checkOne = async function(entry) {
+ // How many of the videos the source lists now are ones the reader has not
+ // been shown -- counted only as far down the page as the mark reaches.
+ //
+ // `perPage` is a request, not a promise: a source whose own pages are 30
+ // long answers a page of 12 with 30. The reader was shown as much of that
+ // as the grid asked for, so an id further down than the mark goes has not
+ // been seen and never was new either -- it was on the same page the whole
+ // time. Counting those would put a number on the badge that no amount of
+ // looking takes off again.
+ const countNew = function(ids, seen) {
+ return ids.slice(0, seen.size).filter((id) => !seen.has(id)).length;
+ };
+
+ const askSource = async function(entry) {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
try {
- const ids = await probeIds(entry, controller.signal);
+ return await probeIds(entry, controller.signal);
+ } finally {
+ window.clearTimeout(timer);
+ }
+ };
+
+ const checkOne = async function(entry) {
+ try {
+ const ids = await askSource(entry);
const seen = new Set(entry.seenIds || []);
// A search nobody has ever opened has nothing to compare against,
// so the whole first page is its mark rather than its news.
@@ -315,15 +339,12 @@ App.savedSearches = App.savedSearches || {};
patch(entry.id, { seenIds: ids.slice(0, SEEN_KEEP), lastCheckedAt: now(), newCount: 0, unreachable: false });
return;
}
- const fresh = ids.filter((id) => !seen.has(id)).length;
- patch(entry.id, { lastCheckedAt: now(), newCount: fresh, unreachable: false });
+ patch(entry.id, { lastCheckedAt: now(), newCount: countNew(ids, seen), unreachable: false });
} catch (err) {
// A source that is down must not look like a search with nothing
// new in it, and must not reset the clock either: it is tried
// again on the next look.
patch(entry.id, { unreachable: true });
- } finally {
- window.clearTimeout(timer);
}
};
@@ -362,6 +383,62 @@ App.savedSearches = App.savedSearches || {};
App.savedSearches.isChecking = function() { return !!running; };
+ let marking = null;
+
+ // The whole list, read. For a reader who sees the badge, decides none of
+ // it is for them, and wants the slate clean rather than nine rows to open
+ // one after another.
+ //
+ // The mark is moved on to what each source lists now rather than the count
+ // merely being zeroed: a count that is only zeroed comes back on the next
+ // check, because the videos it counted are still ones nobody was shown.
+ // That is one listing request per saved search, the same one the daily
+ // check makes.
+ App.savedSearches.markAllRead = function() {
+ if (marking) return marking;
+ const entries = App.savedSearches.getAll();
+ if (!entries.length) return Promise.resolve(0);
+
+ const queue = entries.slice();
+ const worker = async function() {
+ while (queue.length) {
+ const entry = queue.shift();
+ try {
+ const ids = await askSource(entry);
+ patch(entry.id, {
+ seenIds: ids.slice(0, SEEN_KEEP),
+ lastViewedAt: now(),
+ lastCheckedAt: now(),
+ newCount: 0,
+ unreachable: false
+ });
+ } catch (err) {
+ // The source did not answer, so the mark cannot be moved
+ // forward. The count still goes out -- the reader said they
+ // are done with it -- and the row says it could not be
+ // reached, so the number coming back later is explained.
+ patch(entry.id, { newCount: 0, lastViewedAt: now(), unreachable: true });
+ }
+ changed();
+ }
+ };
+ marking = Promise.all(
+ Array.from({ length: Math.min(CHECK_CONCURRENCY, queue.length) }, worker)
+ ).then(() => {
+ marking = null;
+ changed();
+ return entries.length;
+ }).catch(() => {
+ marking = null;
+ changed();
+ return 0;
+ });
+ changed();
+ return marking;
+ };
+
+ App.savedSearches.isMarkingAllRead = function() { return !!marking; };
+
// ---- opening one -----------------------------------------------------
// Puts the whole saved search back: source, channel, filters, query. The
diff --git a/frontend/js/ui.js b/frontend/js/ui.js
index 9c91cf0..2c59836 100644
--- a/frontend/js/ui.js
+++ b/frontend/js/ui.js
@@ -683,6 +683,7 @@ App.ui = App.ui || {};
const note = el('saved-head-note');
const add = el('saved-add-btn');
const check = el('saved-check-btn');
+ const read = el('saved-read-btn');
if (!list) return;
const entries = App.savedSearches.getAll();
@@ -702,6 +703,12 @@ App.ui = App.ui || {};
check.disabled = !entries.length || App.savedSearches.isChecking();
check.textContent = App.savedSearches.isChecking() ? 'Checking…' : 'Check now';
}
+ if (read) {
+ const marking = App.savedSearches.isMarkingAllRead();
+ // Nothing to clear is not a button worth pressing.
+ read.disabled = marking || !App.savedSearches.totalNew();
+ read.textContent = marking ? 'Marking…' : 'Mark all read';
+ }
if (add) {
const match = App.savedSearches.findMatching();
add.disabled = !!match || !App.savedSearches.current();
@@ -834,6 +841,14 @@ App.ui = App.ui || {};
});
}
+ const read = el('saved-read-btn');
+ if (read) {
+ read.addEventListener('click', () => {
+ App.savedSearches.markAllRead();
+ render();
+ });
+ }
+
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && isOpen()) {
event.stopPropagation();
diff --git a/tests/smoke_saved_searches.py b/tests/smoke_saved_searches.py
index 1d799b9..a230354 100644
--- a/tests/smoke_saved_searches.py
+++ b/tests/smoke_saved_searches.py
@@ -58,6 +58,11 @@ ROWS = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row'))
stamp: row.querySelector('.saved-row-stamp').textContent
}))"""
+READ_BTN = """() => {
+ const button = document.getElementById('saved-read-btn');
+ return { disabled: button.disabled, text: button.textContent };
+}"""
+
IS_SAVED = """() => document.getElementById('search-save-btn').classList.contains('is-saved')"""
PANEL_OPEN = """() => document.getElementById('saved-panel').classList.contains('open')"""
SESSION = """() => {
@@ -83,13 +88,16 @@ def main():
# Which ids the listing hands back. Shifting this window is how the test
# says "three new videos were uploaded".
offset = {"at": 0, "fail": False}
+ # How many videos a page comes back with, whatever was asked for: real
+ # sources cut pages their own way and send what they send.
+ size = {"n": 12}
def items(query):
start = offset["at"]
return [{"id": f"vid-{i}", "title": f"{query or 'anything'} {i}",
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
"duration": 1, "isLive": False, "tags": []}
- for i in range(start, start + 12)]
+ for i in range(start, start + size["n"])]
with sync_playwright() as p:
browser = p.chromium.launch(args=[
@@ -313,6 +321,63 @@ def main():
badge["hidden"] is False and badge["text"] == "4", str(badge))
c.ok("the search survived too", len(page.evaluate(SAVED)) == 1)
+ print("\na source that sends more than the page it was asked for")
+ # sxyprn answers a page of 12 with 30. Everything past the twelfth was
+ # never shown to the reader and was never new either -- it sat on the
+ # same page all along -- so none of it may end up on the badge.
+ size["n"] = 30
+ page.evaluate("""async () => {
+ App.videos.resetGrid();
+ await App.videos.loadVideos();
+ App.savedSearches.markViewed(App.savedSearches.getAll()[0].id);
+ }""")
+ page.wait_for_timeout(800)
+ saved = page.evaluate(SAVED)
+ c.ok("the mark holds the whole page the source sent, not the one asked for",
+ len(saved[0].get("seenIds") or []) == 30, str(len(saved[0].get("seenIds") or [])))
+ page.evaluate("() => App.savedSearches.check({ force: true })")
+ page.wait_for_timeout(900)
+ saved = page.evaluate(SAVED)
+ badge = page.evaluate(BADGE)
+ c.ok("the same page, checked again, is not news",
+ saved[0].get("newCount") == 0, str(saved[0].get("newCount")))
+ c.ok("so the badge stays out instead of sticking on a number",
+ badge["hidden"] is True, str(badge))
+ c.ok("and nothing is counted anywhere else either",
+ page.evaluate("() => App.savedSearches.totalNew()") == 0)
+
+ print("\nnews on such a source still counts")
+ offset["at"] = offset["at"] + 5
+ page.evaluate("() => App.savedSearches.check({ force: true })")
+ page.wait_for_timeout(900)
+ saved = page.evaluate(SAVED)
+ c.ok("five turning up is five", saved[0].get("newCount") == 5, str(saved[0].get("newCount")))
+
+ print("\nmarking the whole list read")
+ page.click("#saved-searches-btn")
+ page.wait_for_timeout(300)
+ c.ok("the button is offered while something is new",
+ page.evaluate(READ_BTN)["disabled"] is False, str(page.evaluate(READ_BTN)))
+ asked.clear()
+ page.click("#saved-read-btn")
+ page.wait_for_timeout(1400)
+ saved = page.evaluate(SAVED)
+ badge = page.evaluate(BADGE)
+ c.ok("every count goes out", all(entry.get("newCount") == 0 for entry in saved), str(saved))
+ c.ok("and the header count with them", badge["hidden"] is True, str(badge))
+ c.ok("the mark moves on to what the source lists now, so it stays out",
+ f"vid-{offset['at']}" in (saved[0].get("seenIds") or []) and
+ f"vid-{offset['at'] - 5}" not in (saved[0].get("seenIds") or []),
+ str((saved[0].get("seenIds") or [])[:3]))
+ c.ok("which took asking the source", any(b.get("query") == "sauna" for b in asked), str(asked))
+ page.evaluate("() => App.savedSearches.check({ force: true })")
+ page.wait_for_timeout(900)
+ saved = page.evaluate(SAVED)
+ c.ok("a check straight after finds nothing new",
+ saved[0].get("newCount") == 0, str(saved[0].get("newCount")))
+ c.ok("and the button turns itself off with nothing left to clear",
+ page.evaluate(READ_BTN)["disabled"] is True, str(page.evaluate(READ_BTN)))
+
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()