Keep a whole search, and say what is new in it
A search here is more than the words in the box: it is the source, the channel, every filter that channel offers, and the query together. The bookmark in the search bar keeps all of it, the one in the header opens the list, and tapping a row puts the whole thing back. Each saved search remembers the ids of the newest videos it showed the last time it was opened. Once a day -- at startup, when the tab is looked at again, and on a slow heartbeat for tabs left open -- the client asks each one's first page again and counts how many of those videos it has never shown. That count is the badge, on the row and summed in the header, and it stops at 9+. Opening a saved search is viewing it, so the count goes out and the mark moves to what is on screen now. Nothing new leaves the device: the check is the same listing request the grid would have made, and everything else is localStorage. Also: handleSearch's "put this in the box without searching for it" half is now App.videos.setSearchBox, which is what applying a saved search needs. tests/smoke_saved_searches.py covers it end to end against a fixture listing whose ids the test shifts to mean "new uploads": 46 checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
@@ -472,6 +472,386 @@ App.ui = App.ui || {};
|
||||
return { bind: bind, refresh: refresh, close: close, isOpen: isOpen, shown: () => offered.slice() };
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Saved searches
|
||||
//
|
||||
// The bookmark in the search bar keeps whatever is on screen -- source,
|
||||
// channel, filters, query -- and the bookmark in the header opens the
|
||||
// list of them. Each row carries what is new in it since it was last
|
||||
// opened (App.savedSearches does the counting), and tapping a row puts
|
||||
// that whole search back.
|
||||
// ---------------------------------------------------------------------
|
||||
App.ui.savedSearches = (function() {
|
||||
let bound = false;
|
||||
// A row with its rename field open, or its "remove?" question showing.
|
||||
// Held here so a check landing mid-edit redraws around it rather than
|
||||
// through it.
|
||||
let pending = null;
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const panel = () => el('saved-panel');
|
||||
const isOpen = () => !!panel() && panel().classList.contains('open');
|
||||
|
||||
const ago = function(stamp) {
|
||||
if (!stamp) return 'never';
|
||||
const seconds = Math.max(0, Math.round((Date.now() - stamp) / 1000));
|
||||
if (seconds < 90) return 'just now';
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} min ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours} h ago`;
|
||||
const days = Math.round(hours / 24);
|
||||
return days === 1 ? 'yesterday' : `${days} days ago`;
|
||||
};
|
||||
|
||||
// Where the search points, in one line: the channel, what the filters
|
||||
// narrow it to, and the words themselves.
|
||||
const metaLine = function(entry) {
|
||||
const parts = [entry.channelName || entry.channelId];
|
||||
if (entry.optionLabels && entry.optionLabels.length) {
|
||||
parts.push(entry.optionLabels.slice(0, 3).join(' · '));
|
||||
}
|
||||
parts.push(entry.query ? `“${entry.query}”` : 'no search term');
|
||||
return parts.join(' · ');
|
||||
};
|
||||
|
||||
const stampLine = function(entry) {
|
||||
if (entry.unreachable) return 'Could not be checked — the source did not answer.';
|
||||
const checked = `Checked ${ago(entry.lastCheckedAt)}`;
|
||||
if (!entry.newCount) return `${checked} · nothing new`;
|
||||
const since = entry.lastViewedAt ? ` since you opened it ${ago(entry.lastViewedAt)}` : '';
|
||||
const label = App.savedSearches.badgeLabel(entry.newCount);
|
||||
return `${label} new${since} · ${checked.toLowerCase()}`;
|
||||
};
|
||||
|
||||
const svgIcon = function(paths, extraClass) {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
svg.setAttribute('class', 'icon-line' + (extraClass ? ' ' + extraClass : ''));
|
||||
paths.forEach((d) => {
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', d);
|
||||
svg.appendChild(path);
|
||||
});
|
||||
return svg;
|
||||
};
|
||||
|
||||
const RENAME_ICON = ['M4.5 19.5h3L19 8a2.1 2.1 0 0 0-3-3L4.5 16.5v3z'];
|
||||
const DELETE_ICON = ['M5 7.5h14', 'M9.5 7.5V5.75h5V7.5', 'M7 7.5l.8 11.2h8.4L17 7.5'];
|
||||
|
||||
const toolButton = function(act, label, paths) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'saved-tool';
|
||||
button.dataset.act = act;
|
||||
button.title = label;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.appendChild(svgIcon(paths));
|
||||
return button;
|
||||
};
|
||||
|
||||
// A row mid-rename: the name becomes a field, and leaving it (Enter or
|
||||
// blur) keeps what was typed. Escape puts the old name back -- the
|
||||
// normal way round for an edit in place.
|
||||
const renameField = function(entry, row) {
|
||||
const field = document.createElement('input');
|
||||
field.type = 'text';
|
||||
field.className = 'saved-rename';
|
||||
field.value = entry.name;
|
||||
field.maxLength = 80;
|
||||
field.setAttribute('aria-label', 'Name for this saved search');
|
||||
let settled = false;
|
||||
const commit = function(keep) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
pending = null;
|
||||
if (keep) App.savedSearches.rename(entry.id, field.value);
|
||||
else sync();
|
||||
};
|
||||
field.addEventListener('keydown', (event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter') { event.preventDefault(); commit(true); }
|
||||
else if (event.key === 'Escape') { event.preventDefault(); commit(false); }
|
||||
});
|
||||
field.addEventListener('blur', () => commit(true));
|
||||
field.addEventListener('click', (event) => event.stopPropagation());
|
||||
row.classList.add('is-renaming');
|
||||
return field;
|
||||
};
|
||||
|
||||
const confirmStrip = function(entry) {
|
||||
const strip = document.createElement('div');
|
||||
strip.className = 'saved-confirm';
|
||||
|
||||
const question = document.createElement('span');
|
||||
question.className = 'saved-confirm-text';
|
||||
question.textContent = 'Remove this saved search?';
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'saved-confirm-btn is-danger';
|
||||
remove.textContent = 'Remove';
|
||||
remove.addEventListener('click', () => {
|
||||
pending = null;
|
||||
App.savedSearches.remove(entry.id);
|
||||
});
|
||||
|
||||
const keep = document.createElement('button');
|
||||
keep.type = 'button';
|
||||
keep.className = 'saved-confirm-btn';
|
||||
keep.textContent = 'Keep';
|
||||
keep.addEventListener('click', () => { pending = null; sync(); });
|
||||
|
||||
strip.appendChild(question);
|
||||
strip.appendChild(keep);
|
||||
strip.appendChild(remove);
|
||||
return strip;
|
||||
};
|
||||
|
||||
const buildRow = function(entry, index) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'saved-row';
|
||||
row.dataset.id = entry.id;
|
||||
row.style.setProperty('--i', String(index));
|
||||
if (entry.unreachable) row.classList.add('is-unreachable');
|
||||
|
||||
const main = document.createElement('button');
|
||||
main.type = 'button';
|
||||
main.className = 'saved-row-main';
|
||||
|
||||
const count = document.createElement('span');
|
||||
const label = App.savedSearches.badgeLabel(entry.newCount);
|
||||
count.className = 'saved-count' + (label ? '' : ' is-quiet');
|
||||
// The quiet state is a mark, not a number: a row with nothing new
|
||||
// should not look like a row with something in it.
|
||||
count.textContent = label || '·';
|
||||
if (label) count.title = `${entry.newCount} new since you last opened this`;
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.className = 'saved-row-text';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'saved-row-name';
|
||||
name.textContent = entry.name;
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'saved-row-meta';
|
||||
meta.textContent = metaLine(entry);
|
||||
|
||||
const stamp = document.createElement('span');
|
||||
stamp.className = 'saved-row-stamp';
|
||||
stamp.textContent = stampLine(entry);
|
||||
|
||||
text.appendChild(name);
|
||||
text.appendChild(meta);
|
||||
text.appendChild(stamp);
|
||||
main.appendChild(count);
|
||||
main.appendChild(text);
|
||||
main.addEventListener('click', () => choose(entry.id));
|
||||
row.appendChild(main);
|
||||
|
||||
if (pending && pending.id === entry.id && pending.mode === 'rename') {
|
||||
const field = renameField(entry, row);
|
||||
name.replaceWith(field);
|
||||
main.disabled = true;
|
||||
requestAnimationFrame(() => { field.focus(); field.select(); });
|
||||
return row;
|
||||
}
|
||||
|
||||
if (pending && pending.id === entry.id && pending.mode === 'delete') {
|
||||
row.classList.add('is-confirming');
|
||||
row.appendChild(confirmStrip(entry));
|
||||
return row;
|
||||
}
|
||||
|
||||
const tools = document.createElement('div');
|
||||
tools.className = 'saved-row-tools';
|
||||
const rename = toolButton('rename', `Rename ${entry.name}`, RENAME_ICON);
|
||||
rename.addEventListener('click', () => { pending = { id: entry.id, mode: 'rename' }; render(); });
|
||||
const remove = toolButton('delete', `Remove ${entry.name}`, DELETE_ICON);
|
||||
remove.addEventListener('click', () => { pending = { id: entry.id, mode: 'delete' }; render(); });
|
||||
tools.appendChild(rename);
|
||||
tools.appendChild(remove);
|
||||
row.appendChild(tools);
|
||||
return row;
|
||||
};
|
||||
|
||||
const render = function() {
|
||||
const list = el('saved-list');
|
||||
const empty = el('saved-empty');
|
||||
const note = el('saved-head-note');
|
||||
const add = el('saved-add-btn');
|
||||
const check = el('saved-check-btn');
|
||||
if (!list) return;
|
||||
|
||||
const entries = App.savedSearches.getAll();
|
||||
list.textContent = '';
|
||||
entries.forEach((entry, index) => list.appendChild(buildRow(entry, index)));
|
||||
if (empty) empty.hidden = entries.length > 0;
|
||||
|
||||
if (note) {
|
||||
const total = App.savedSearches.totalNew();
|
||||
note.textContent = !entries.length ?
|
||||
'Keep a search once, come back to it in one tap.' :
|
||||
(total ?
|
||||
`${App.savedSearches.badgeLabel(total)} new since you last looked.` :
|
||||
'Checked once a day. Nothing new right now.');
|
||||
}
|
||||
if (check) {
|
||||
check.disabled = !entries.length || App.savedSearches.isChecking();
|
||||
check.textContent = App.savedSearches.isChecking() ? 'Checking…' : 'Check now';
|
||||
}
|
||||
if (add) {
|
||||
const match = App.savedSearches.findMatching();
|
||||
add.disabled = !!match || !App.savedSearches.current();
|
||||
add.textContent = match ? `Already saved as “${match.name}”` : 'Save the current search';
|
||||
}
|
||||
};
|
||||
|
||||
const choose = function(id) {
|
||||
close();
|
||||
App.savedSearches.apply(id);
|
||||
};
|
||||
|
||||
const open = function() {
|
||||
const box = panel();
|
||||
if (!box) return;
|
||||
pending = null;
|
||||
render();
|
||||
box.classList.add('open');
|
||||
box.setAttribute('aria-hidden', 'false');
|
||||
const trigger = el('saved-searches-btn');
|
||||
if (trigger) trigger.setAttribute('aria-expanded', 'true');
|
||||
// Opening the list is as good a moment as any to find out whether
|
||||
// anything is due -- the daily check may have been missed by a tab
|
||||
// that was never left open that long.
|
||||
App.savedSearches.check();
|
||||
const closeBtn = el('saved-close-btn');
|
||||
if (closeBtn) requestAnimationFrame(() => closeBtn.focus());
|
||||
};
|
||||
|
||||
const close = function() {
|
||||
const box = panel();
|
||||
// Where the keyboard goes next: back to the button that opened
|
||||
// this, not to the top of the page behind it.
|
||||
const returnFocus = !!(box && box.contains(document.activeElement));
|
||||
if (box) {
|
||||
box.classList.remove('open');
|
||||
box.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
pending = null;
|
||||
const trigger = el('saved-searches-btn');
|
||||
if (trigger) {
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
if (returnFocus) trigger.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// The bookmark in the search bar: lit when what is on screen is
|
||||
// already saved, and pressing it then takes it off the list again.
|
||||
const toggleCurrent = function() {
|
||||
const match = App.savedSearches.findMatching();
|
||||
if (match) {
|
||||
App.savedSearches.remove(match.id);
|
||||
return;
|
||||
}
|
||||
const saved = App.savedSearches.saveCurrent();
|
||||
if (!saved) return;
|
||||
const button = el('search-save-btn');
|
||||
if (button) {
|
||||
button.classList.remove('is-just-saved');
|
||||
// Restarted rather than left to run: saving twice in a row
|
||||
// should flash twice.
|
||||
void button.offsetWidth;
|
||||
button.classList.add('is-just-saved');
|
||||
}
|
||||
};
|
||||
|
||||
// Keeps every piece of the feature showing the same thing: the
|
||||
// bookmark's state, the header's count, and the list if it is open.
|
||||
const sync = function() {
|
||||
const button = el('search-save-btn');
|
||||
if (button) {
|
||||
const match = App.savedSearches.findMatching();
|
||||
const available = !!App.savedSearches.current();
|
||||
button.classList.toggle('is-saved', !!match);
|
||||
button.setAttribute('aria-pressed', match ? 'true' : 'false');
|
||||
button.disabled = !available;
|
||||
const title = match ? `Saved as “${match.name}” — tap to remove` : 'Save this search';
|
||||
button.title = title;
|
||||
button.setAttribute('aria-label', title);
|
||||
}
|
||||
|
||||
const badge = el('saved-searches-badge');
|
||||
const trigger = el('saved-searches-btn');
|
||||
if (badge) {
|
||||
const total = App.savedSearches.totalNew();
|
||||
const label = App.savedSearches.badgeLabel(total);
|
||||
badge.textContent = label;
|
||||
badge.hidden = !label;
|
||||
if (trigger) {
|
||||
const count = App.savedSearches.getAll().length;
|
||||
trigger.setAttribute('aria-label', label ?
|
||||
`Saved searches, ${total > App.savedSearches.NEW_CAP ? 'more than ' + App.savedSearches.NEW_CAP : total} new` :
|
||||
(count ? `Saved searches (${count})` : 'Saved searches'));
|
||||
}
|
||||
}
|
||||
|
||||
// A rename in progress is the reader's text, not ours to redraw.
|
||||
if (isOpen() && !(pending && pending.mode === 'rename')) render();
|
||||
};
|
||||
|
||||
const bind = function() {
|
||||
if (bound) return;
|
||||
const box = panel();
|
||||
const trigger = el('saved-searches-btn');
|
||||
if (!box || !trigger) return;
|
||||
bound = true;
|
||||
|
||||
trigger.addEventListener('click', () => { if (isOpen()) close(); else open(); });
|
||||
const closeBtn = el('saved-close-btn');
|
||||
if (closeBtn) closeBtn.addEventListener('click', close);
|
||||
box.addEventListener('click', (event) => { if (event.target === box) close(); });
|
||||
|
||||
const saveBtn = el('search-save-btn');
|
||||
if (saveBtn) saveBtn.addEventListener('click', toggleCurrent);
|
||||
|
||||
const add = el('saved-add-btn');
|
||||
if (add) {
|
||||
add.addEventListener('click', () => {
|
||||
const saved = App.savedSearches.saveCurrent();
|
||||
if (saved) pending = { id: saved.id, mode: 'rename' };
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
const check = el('saved-check-btn');
|
||||
if (check) {
|
||||
check.addEventListener('click', () => {
|
||||
App.savedSearches.check({ force: true });
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && isOpen()) {
|
||||
event.stopPropagation();
|
||||
close();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
bind: bind,
|
||||
sync: sync,
|
||||
open: open,
|
||||
close: close,
|
||||
isOpen: isOpen,
|
||||
toggleCurrent: toggleCurrent
|
||||
};
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Channel picker
|
||||
//
|
||||
@@ -1043,6 +1423,10 @@ App.ui = App.ui || {};
|
||||
App.videos.resetAndReload();
|
||||
};
|
||||
}
|
||||
|
||||
// Source, channel and filters are all part of what a saved search is,
|
||||
// so whenever the menu redraws, so does the bookmark.
|
||||
if (App.ui.savedSearches) App.ui.savedSearches.sync();
|
||||
};
|
||||
|
||||
App.ui.renderFilters = function(container, session) {
|
||||
@@ -1301,7 +1685,11 @@ App.ui = App.ui || {};
|
||||
// debounce the actual reload so typing doesn't wipe the grid and
|
||||
// fire a backend request on every keystroke.
|
||||
App.ui.searchHistory.bind();
|
||||
App.ui.savedSearches.bind();
|
||||
searchInput.addEventListener('input', updateClearVisibility);
|
||||
// The bookmark is about this search, so it follows the words as
|
||||
// they are typed, not just the channel and filters.
|
||||
searchInput.addEventListener('input', () => App.ui.savedSearches.sync());
|
||||
searchInput.addEventListener('input', () => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(() => {
|
||||
|
||||
Reference in New Issue
Block a user