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()