From e72a07a6aee703fa8cdf55e246a31522226dabbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Mon, 24 Aug 2026 15:47:51 +0200 Subject: [PATCH 1/2] feat(app): search the target sheet by name or id prefix The sheet was browse-only, so picking a node you already know meant scrolling the full sender list to it. A field above the list filters it live: the query matches anywhere in a resolved name, and from the front of any id the row answers to, including every merged variant (#267). The matcher is a pure function in feed.js so web/ can reuse it later. Filtering runs before the lazy-paging slice, so paging walks the matches rather than filtering one page. While a query is active the pinned Top block is hidden: it ranks everything heard, not the matches. Co-Authored-By: Claude Opus 5 --- app/changelog.json | 7 ++++ app/src/__tests__/feed.test.js | 69 +++++++++++++++++++++++++++++++++- app/src/app.js | 13 +++++-- app/src/feed.js | 36 +++++++++++++++++- app/src/styles/app.css | 10 +++++ app/src/targetlist.js | 52 ++++++++++++++++++------- web/changelog.json | 7 ++++ 7 files changed, 174 insertions(+), 20 deletions(-) diff --git a/app/changelog.json b/app/changelog.json index 90e4efc9..1cc05e3c 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -1,4 +1,11 @@ [ + { + "id": "2026-08-24-target-search", + "date": "2026-08-24", + "where": "app", + "title": "Search the target list", + "body": "The target sheet has a search field. Type part of a name, or the first characters of an id as it appears on the map and in the ticker, and the list narrows to what matches while you type. Clearing the field brings the whole list back; what you already picked stays picked either way." + }, { "id": "2026-08-25-filter-a-flood-with-no-sender", "date": "2026-08-25", diff --git a/app/src/__tests__/feed.test.js b/app/src/__tests__/feed.test.js index 203187a0..e8f2e804 100644 --- a/app/src/__tests__/feed.test.js +++ b/app/src/__tests__/feed.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { relTime, senderList, topSenders, targetParts, selectedRepeaterIds, clusterKey, expandSelection, selectionKeyFor, idPrefix } from '../feed.js' +import { relTime, senderList, topSenders, targetParts, selectedRepeaterIds, clusterKey, expandSelection, selectionKeyFor, idPrefix, matchesTarget } from '../feed.js' const rec = (o) => ({ sender_kind: 'channel_name', sender_id: 'Spammer', rx_at: '2026-06-29T10:00:00Z', ...o }) @@ -438,3 +438,70 @@ describe('senderList — receptions with no sender (#454)', () => { }) }) + +// #449: the target sheet is browse-only, so picking a node you already know +// means scrolling to it. matchesTarget is the rule behind the search field: +// the name matches anywhere, an id matches only from the front. A substring +// match on an id would make "2b" find every id with those bytes anywhere in +// it, which for a 3-byte hash is most of them — the front is the only part a +// user can read off the HUD and type. +describe('matchesTarget (#449)', () => { + const row = (o) => ({ sender_id: 'a1b2c3', sender_label: null, ...o }) + + it('matches a name substring, not just its start', () => { + const r = row({ sender_label: 'NL-OAS-Walrick-RP01' }) + expect(matchesTarget(r, 'walr')).toBe(true) + expect(matchesTarget(r, 'NL-OAS')).toBe(true) + }) + it('matches case-insensitively on both sides', () => { + expect(matchesTarget(row({ sender_label: 'NL-OAS-Walrick-RP01' }), 'WALRICK')).toBe(true) + expect(matchesTarget(row({ sender_id: 'A1B2C3' }), 'a1b')).toBe(true) + }) + it('matches an id that is not the first entry in merged_ids (#267)', () => { + const r = row({ sender_id: '2beb', merged_ids: ['2beb', 'a1b2c3' + '0'.repeat(58)] }) + expect(matchesTarget(r, '2beb')).toBe(true) + expect(matchesTarget(r, 'a1b2c3')).toBe(true) + }) + it('refuses an id substring that is not a leading prefix', () => { + expect(matchesTarget(row({ sender_id: '2beb1f' }), 'eb1f')).toBe(false) + }) + it('falls back to sender_id when the row carries no merged_ids', () => { + expect(matchesTarget({ sender_id: 'c0ffee' }, 'c0ff')).toBe(true) + expect(matchesTarget({ sender_id: 'c0ffee' }, 'dead')).toBe(false) + }) + it('matches everything on an empty or whitespace-only query', () => { + const r = row({ sender_label: 'Walrick' }) + expect(matchesTarget(r, '')).toBe(true) + expect(matchesTarget(r, ' ')).toBe(true) + expect(matchesTarget(r, undefined)).toBe(true) + }) + it('matches nothing when neither the name nor an id starts with the query', () => { + expect(matchesTarget(row({ sender_label: 'Walrick' }), 'zzz')).toBe(false) + }) + it('still matches on the id when the name has not resolved', () => { + expect(matchesTarget(row({ sender_label: null, sender_id: 'a1b2c3' }), 'a1b2')).toBe(true) + }) +}) + +// The search narrows what is shown, so it has to run before the lazy-paging +// slice: filtering a page instead of paging the filtered set would hide a +// match that sorts past the first six rows. +describe('senderList — search query (#449)', () => { + const adv = (id, label, at) => ({ sender_kind: 'advert_pubkey', sender_id: pk(id), sender_label: label, rx_at: at }) + const rows = [ + adv('a1', 'Alpha', '2026-08-24T10:00:00Z'), + adv('b2', 'Bravo', '2026-08-24T10:00:01Z'), + adv('c3', 'Charlie', '2026-08-24T10:00:02Z'), + ] + + it('filters before the limit slice, so a match past the first page still shows', () => { + expect(senderList(rows, { limit: 1, query: 'charlie' }).map((r) => r.sender_label)).toEqual(['Charlie']) + }) + it('leaves the list untouched for an empty query', () => { + expect(senderList(rows, { query: '' }).map((r) => r.sender_label)).toEqual(['Alpha', 'Bravo', 'Charlie']) + expect(senderList(rows, {}).map((r) => r.sender_label)).toEqual(['Alpha', 'Bravo', 'Charlie']) + }) + it('matches on an id prefix as well as a name', () => { + expect(senderList(rows, { query: 'b2' }).map((r) => r.sender_label)).toEqual(['Bravo']) + }) +}) diff --git a/app/src/app.js b/app/src/app.js index 559a82b3..0a115c0e 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -1150,14 +1150,21 @@ function buildTargetSheet() { -
Top
- -
All senders
+ +
+
Top
+
    +
    All senders
    +
    ` state.targetList = createTargetList(el('ts-list'), { pinnedEl: el('ts-pinned'), + searchEl: el('ts-search'), + browseEl: el('ts-browse'), // Whole-row tap toggles this sender in the target set; the sheet stays open // so several can be picked in a row (#178). onSelect: (id, label, ids) => { diff --git a/app/src/feed.js b/app/src/feed.js index 9d0e4628..a01e9283 100644 --- a/app/src/feed.js +++ b/app/src/feed.js @@ -99,11 +99,43 @@ function dedupeSenders(records, ignore) { return mergePrefixGroups([...bySender.entries()]) } +// rowIds lists the lowercased ids a target row answers to. A row from +// senderList/topSenders always carries merged_ids (#267) — every prefix- +// compatible variant merged into this node — and a row built outside +// dedupeSenders falls back to its bare sender_id. +export function rowIds(rec) { + const merged = rec && rec.merged_ids + if (Array.isArray(merged) && merged.length) return merged.map((id) => String(id).toLowerCase()) + return rec && rec.sender_id != null ? [String(rec.sender_id).toLowerCase()] : [] +} + +// matchesTarget is the search rule behind the target sheet's field (#449): +// a row matches when the query appears anywhere in its resolved name, or when +// any of its ids STARTS with the query. The two halves are deliberately +// different. A name is read as words, so the middle of it is worth finding; +// an id is read from the front — it is the prefix that the HUD, the ticker +// and every popup print (idPrefix), so the front is the only part a user can +// type from memory. Matching an id substring would make a 2-byte query find +// most of the ids on screen, which is the opposite of narrowing. +// +// An empty (or whitespace-only) query matches everything, so the caller can +// pass the raw field value and get today's unfiltered list back. +export function matchesTarget(rec, query) { + const q = String(query == null ? '' : query).trim().toLowerCase() + if (!q) return true + const label = rec && rec.sender_label ? String(rec.sender_label).toLowerCase() : '' + if (label.includes(q)) return true + return rowIds(rec).some((id) => id.startsWith(q)) +} + // senderList sorts deduped senders by name so the target dropdown stays // stable while signals change. `limit` slices the same sort for lazy-loaded -// batches. -export function senderList(records, { ignore, limit = Infinity } = {}) { +// batches, and `query` narrows the set BEFORE that slice — paging the matches +// rather than filtering a page, so a match sorting past the first page is +// still reachable. +export function senderList(records, { ignore, limit = Infinity, query = '' } = {}) { return dedupeSenders(records, ignore) + .filter((r) => matchesTarget(r, query)) .sort((a, b) => String(a.sender_label || a.sender_id).localeCompare(String(b.sender_label || b.sender_id), undefined, { sensitivity: 'base' })) .slice(0, limit) diff --git a/app/src/styles/app.css b/app/src/styles/app.css index 431df5ff..a4834ac7 100644 --- a/app/src/styles/app.css +++ b/app/src/styles/app.css @@ -532,6 +532,16 @@ body.onboarding #sound-toggle, body.onboarding #nodepos-toggle { .tl-clear { width: 100%; border: 1px solid var(--ch-accent-2); border-radius: 11px; padding: 9px 14px; background: transparent; color: var(--ch-accent-2); font-weight: 600; font-size: 14px; font-family: system-ui, sans-serif; cursor: pointer; margin-bottom: 8px; } +/* Type-ahead over the sender list (#449). While a query is active the pinned + Top block and the list heading (#ts-browse) are hidden, so the field sits + directly on top of its own matches. + 16px is not a style choice: iOS Safari zooms the whole page in on a focused + input under 16px, and this one is tapped mid-hunt with the map behind it. */ +.tl-search { width: 100%; background: transparent; color: var(--ch-text); + border: 1px solid var(--ch-border); border-radius: 8px; padding: 8px 10px; + font: inherit; font-size: 16px; margin-bottom: 8px; } +.tl-search::placeholder { color: var(--ch-muted); } +.tl-empty { padding: 12px 4px; color: var(--ch-muted); font-size: 13px; } .tl-pinned-label { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--ch-muted); margin: 12px 0 2px; } .tl-list { list-style: none; margin: 0; padding: 0; overflow-y: auto; } diff --git a/app/src/targetlist.js b/app/src/targetlist.js index 1a45ffbb..6bf03706 100644 --- a/app/src/targetlist.js +++ b/app/src/targetlist.js @@ -1,16 +1,20 @@ -import { senderList, topSenders, relTime, targetParts } from './feed.js' +import { senderList, topSenders, relTime, targetParts, rowIds } from './feed.js' const PAGE_SIZE = 6 const PINNED_COUNT = 3 -// Lowercased ids a row answers to, matching how the selection set is keyed. -// A row from senderList/topSenders always carries merged_ids (#267) — every -// prefix-compatible id variant merged into this row's node — so a merged -// row is selected when any of its variants is in the current selection, and -// falls back to the bare sender_id for a row built outside dedupeSenders. -function rowIds(rec) { - if (Array.isArray(rec.merged_ids) && rec.merged_ids.length) return rec.merged_ids - return rec.sender_id != null ? [String(rec.sender_id).toLowerCase()] : [] +// rowIds (feed.js) gives the lowercased ids a row answers to, which is how the +// selection set is keyed: a merged row (#267) is selected when any of its +// variants is in the current selection. The search matcher reads the same list, +// so what you can type an id into and what a tap selects cannot drift apart. + +// A query that matches nothing says so. An empty list under a field you just +// typed into reads as "the list broke", not as "no node by that name". +function emptyRow() { + const li = document.createElement('li') + li.className = 'tl-empty' + li.textContent = 'No senders match.' + return li } function row(rec, nowMs, onSelect, selectedIds) { @@ -57,9 +61,14 @@ function row(rec, nowMs, onSelect, selectedIds) { // PINNED_COUNT rows regardless of scroll (may repeat entries from listEl). // - listEl: the full sender list, name-sorted, lazily grown as the user // scrolls instead of rendering every sender ever heard up front. +// - searchEl: the query field (#449). Its value narrows listEl live. +// - browseEl: the browse-mode chrome (the pinned Top block and the list +// heading), hidden while a query is active. Top ranks everything heard by +// recency+RSSI, which is not a ranking of the matches; showing both invites +// picking the wrong row. // selectedIds is the Set of lowercased target ids (multi-select, #178); each // row reflects membership and the whole row toggles it. -export function createTargetList(listEl, { onSelect, pinnedEl } = {}) { +export function createTargetList(listEl, { onSelect, pinnedEl, searchEl, browseEl } = {}) { let visible = PAGE_SIZE let lastRows = [] let lastIgnore = new Set() @@ -68,14 +77,17 @@ export function createTargetList(listEl, { onSelect, pinnedEl } = {}) { let _lastPinnedSig = null const selSig = (sel) => (sel ? [...sel].sort().join(',') : '') + const query = () => (searchEl ? String(searchEl.value || '').trim() : '') function render(rows, ignore, nowMs, selectedIds) { lastRows = rows lastIgnore = ignore lastSelected = selectedIds || null const selKey = selSig(lastSelected) + const q = query() + if (browseEl) browseEl.hidden = q !== '' - if (pinnedEl) { + if (pinnedEl && !q) { const pinned = topSenders(rows, { ignore, count: PINNED_COUNT, nowMs }) const pinnedSig = pinned.map((r) => (r.sender_label || r.sender_id || '') + r.rssi + r.rx_at).join('|') + '@' + selKey if (pinnedSig !== _lastPinnedSig) { @@ -84,10 +96,11 @@ export function createTargetList(listEl, { onSelect, pinnedEl } = {}) { } } - const items = senderList(rows, { ignore, limit: visible }) - const sig = items.map((r) => (r.sender_label || r.sender_id || '') + r.rssi + r.rx_at).join('|') + '#' + visible + '@' + selKey + const items = senderList(rows, { ignore, limit: visible, query: q }) + const sig = items.map((r) => (r.sender_label || r.sender_id || '') + r.rssi + r.rx_at).join('|') + '#' + visible + '@' + selKey + '?' + q if (sig === _lastSig) return _lastSig = sig + if (q && items.length === 0) { listEl.replaceChildren(emptyRow()); return } listEl.replaceChildren(...items.map((rec) => row(rec, nowMs, onSelect, lastSelected))) } @@ -98,9 +111,20 @@ export function createTargetList(listEl, { onSelect, pinnedEl } = {}) { _lastPinnedSig = null } + // Typing is a new list: page from the top of the matches rather than from + // wherever the unfiltered list had been scrolled to. + if (searchEl) { + searchEl.addEventListener('input', () => { + visible = PAGE_SIZE + _lastSig = null + listEl.scrollTop = 0 + render(lastRows, lastIgnore, Date.now(), lastSelected) + }) + } + listEl.addEventListener('scroll', () => { if (listEl.scrollTop + listEl.clientHeight < listEl.scrollHeight - 24) return - const total = senderList(lastRows, { ignore: lastIgnore }).length + const total = senderList(lastRows, { ignore: lastIgnore, query: query() }).length if (visible >= total) return visible += PAGE_SIZE _lastSig = null diff --git a/web/changelog.json b/web/changelog.json index 90e4efc9..1cc05e3c 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -1,4 +1,11 @@ [ + { + "id": "2026-08-24-target-search", + "date": "2026-08-24", + "where": "app", + "title": "Search the target list", + "body": "The target sheet has a search field. Type part of a name, or the first characters of an id as it appears on the map and in the ticker, and the list narrows to what matches while you type. Clearing the field brings the whole list back; what you already picked stays picked either way." + }, { "id": "2026-08-25-filter-a-flood-with-no-sender", "date": "2026-08-25", From 9eb8c508ec29444b25af231ec4bf2c959a8aff1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Tue, 25 Aug 2026 19:26:00 +0200 Subject: [PATCH 2/2] chore: put the target-search note back in date order parity.test.js asserts that the dates in changelog.json do not increase down the file, and this branch's note sat above one dated a day later: master gained the #497 note (2026-08-25) while this PR was open, and the rebase put its own 2026-08-24 entry on top of it. Order only. Both copies keep the same entry text, the same id, and the same date, so nobody's seen-state moves. Co-Authored-By: Claude Opus 5 --- app/changelog.json | 14 +++++++------- web/changelog.json | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/changelog.json b/app/changelog.json index 1cc05e3c..94cdab0c 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -1,11 +1,4 @@ [ - { - "id": "2026-08-24-target-search", - "date": "2026-08-24", - "where": "app", - "title": "Search the target list", - "body": "The target sheet has a search field. Type part of a name, or the first characters of an id as it appears on the map and in the ticker, and the list narrows to what matches while you type. Clearing the field brings the whole list back; what you already picked stays picked either way." - }, { "id": "2026-08-25-filter-a-flood-with-no-sender", "date": "2026-08-25", @@ -13,6 +6,13 @@ "title": "You can now filter a flood that has no sender", "body": "Some traffic arrives with nothing to identify who sent it, and until now there was no way to narrow the map to it - which is exactly the situation on a spam hunt. Two new handles: Sender unknown shows only the receptions nothing could be attributed to, and the map can group every relayed copy of one transmission together even when nobody can name the sender. The old Direct only tick is now called No path, because that is what it filters on: the path is written by the sender, so it says what the packet claims rather than how far away it was. When that tick would hide everything on screen, the map now says so instead of going blank." }, + { + "id": "2026-08-24-target-search", + "date": "2026-08-24", + "where": "app", + "title": "Search the target list", + "body": "The target sheet has a search field. Type part of a name, or the first characters of an id as it appears on the map and in the ticker, and the list narrows to what matches while you type. Clearing the field brings the whole list back; what you already picked stays picked either way." + }, { "id": "2026-08-24-render-tick-cost", "date": "2026-08-24", diff --git a/web/changelog.json b/web/changelog.json index 1cc05e3c..94cdab0c 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -1,11 +1,4 @@ [ - { - "id": "2026-08-24-target-search", - "date": "2026-08-24", - "where": "app", - "title": "Search the target list", - "body": "The target sheet has a search field. Type part of a name, or the first characters of an id as it appears on the map and in the ticker, and the list narrows to what matches while you type. Clearing the field brings the whole list back; what you already picked stays picked either way." - }, { "id": "2026-08-25-filter-a-flood-with-no-sender", "date": "2026-08-25", @@ -13,6 +6,13 @@ "title": "You can now filter a flood that has no sender", "body": "Some traffic arrives with nothing to identify who sent it, and until now there was no way to narrow the map to it - which is exactly the situation on a spam hunt. Two new handles: Sender unknown shows only the receptions nothing could be attributed to, and the map can group every relayed copy of one transmission together even when nobody can name the sender. The old Direct only tick is now called No path, because that is what it filters on: the path is written by the sender, so it says what the packet claims rather than how far away it was. When that tick would hide everything on screen, the map now says so instead of going blank." }, + { + "id": "2026-08-24-target-search", + "date": "2026-08-24", + "where": "app", + "title": "Search the target list", + "body": "The target sheet has a search field. Type part of a name, or the first characters of an id as it appears on the map and in the ticker, and the list narrows to what matches while you type. Clearing the field brings the whole list back; what you already picked stays picked either way." + }, { "id": "2026-08-24-render-tick-cost", "date": "2026-08-24",