Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,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",
Expand Down
69 changes: 68 additions & 1 deletion app/src/__tests__/feed.test.js
Original file line number Diff line number Diff line change
@@ -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 })

Expand Down Expand Up @@ -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'])
})
})
13 changes: 10 additions & 3 deletions app/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1150,14 +1150,21 @@ function buildTargetSheet() {
</button>
</div>
<button type="button" id="ts-clear" class="tl-clear" hidden>Clear target (showing all)</button>
<div class="tl-pinned-label">Top</div>
<ul id="ts-pinned" class="tl-list tl-pinned"></ul>
<div class="tl-pinned-label">All senders</div>
<input type="search" id="ts-search" class="tl-search" placeholder="Search name or id"
aria-label="Search senders by name or id"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
<div id="ts-browse">
<div class="tl-pinned-label">Top</div>
<ul id="ts-pinned" class="tl-list tl-pinned"></ul>
<div class="tl-pinned-label">All senders</div>
</div>
<ul id="ts-list" class="tl-list"></ul>
</div>`

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) => {
Expand Down
36 changes: 34 additions & 2 deletions app/src/feed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions app/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
52 changes: 38 additions & 14 deletions app/src/targetlist.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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()
Expand All @@ -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) {
Expand All @@ -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)))
}

Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions web/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,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",
Expand Down
Loading