diff --git a/AGENTS.md b/AGENTS.md
index e0c77960..d6f5e03e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -603,9 +603,13 @@ reception came from are different acts. The second is one rule on both surfaces,
without an advertised position is no candidate, since reach cannot place it; one pubkey listed
twice is one node.
- **Surfaces:** the reach stars and the node-position layer in the app and on the map, and in the
- app also the arrow (#660) and the sender names. The node-position layer draws registry nodes
- only, so there the rule pairs (rule 1) or refuses (rule 3); a rule-2 estimate gets no marker of
- its own. On the map, the ticker and the point popup keep their resolver names, a separate issue.
+ app also the arrow (#660). The sender names follow it on both: in the app everywhere a sender
+ is named, on the map in the ticker and the point popup (#663). How a name is printed is
+ `namerules.js` (copied whole). The map's names take their registry slice around the rows they
+ name, not around the view (`web/rowattribution.js`), since the ticker's lines are the latest
+ wherever they were heard; the target picker keeps its own rule (above). The node-position layer
+ draws registry nodes only, so there the rule pairs (rule 1) or refuses (rule 3); a rule-2
+ estimate gets no marker of its own.
A ● hub has no name on either surface since #632 (`docs/2026-09-21-node-glyphs-in-gl.md`): it
is a GL feature keyed by its star's id, and the tooltip that carried `starLabel` went with the
marker. Should a hub get a name surface again, a 1-byte id reads `#` plus the id, never a bare
diff --git a/app/changelog.json b/app/changelog.json
index f839c43e..e6a98383 100644
--- a/app/changelog.json
+++ b/app/changelog.json
@@ -13,6 +13,13 @@
"title": "Quieter send sounds",
"body": "The pops for Discover and trace-pings are a third as loud and shorter, so a weak reception is heard over them."
},
+ {
+ "id": "2026-09-29-map-names-relays-like-app",
+ "date": "2026-09-29",
+ "where": "map",
+ "title": "The map names a relay like the app",
+ "body": "A relay's name in the ticker and the popup is marked ~ as a guess. Members get the name of the one known node in reach."
+ },
{
"id": "2026-09-27-hud-bottom-space",
"date": "2026-09-27",
diff --git a/app/src/namerules.js b/app/src/namerules.js
new file mode 100644
index 00000000..0433f934
--- /dev/null
+++ b/app/src/namerules.js
@@ -0,0 +1,68 @@
+// How a sender's name is printed (#452, #661, #663). Pure: no resolver, no
+// cache, no config. Where a name comes from differs per surface (the app asks
+// the registries of the companion's SF, the map asks the server's resolve
+// proxy), how it reads does not.
+// Copied whole between app/src/ and web/ (parity.test.js), since neither deploy
+// path can ship a file outside its own directory (#238). No imports.
+
+// A sender id of one byte (2 hex) is a 256-way collision space, so the id
+// itself is never a name, and meshpacket.js carries it as its OWN sender_label
+// for the two kinds below. A surface that prints that label unguarded shows
+// "77" exactly as it would show a resolved short name. Marked with # instead,
+// the house style hudsender.js set, and kept out of the resolver by the 4-hex
+// floor. A name for it comes only from its attribution by reach (#661).
+const HASH_ID_KINDS = ['direct_hash', 'path_hash']
+export function isHashIdKind(kind) { return HASH_ID_KINDS.includes(kind) }
+
+// A name resolved for a short prefix is a guess about who was heard: a 2- or
+// 3-byte id is one in 65,536 or 16 million per registry, and a relay hash is
+// the forwarder's, not a node id. The name stays (it is usually right, and
+// the field reads by it) and wears GUESS_MARK on every surface, so nothing
+// presents it as a resolved identity (#452). An advert's own name on its
+// full key, a channel sender's name and an 8-byte discover prefix are not
+// guesses; a 1-byte hash carries no resolved name (isHashIdKind), only the
+// name of the node it is placed on by reach (displayName).
+export const GUESS_MARK = '~'
+const GUESS_MAX_HEX = 6
+export function isGuessedName(rec) {
+ if (!rec || !rec.sender_label) return false
+ if (rec.sender_kind === 'channel_name' || isHashIdKind(rec.sender_kind)) return false
+ const id = typeof rec.sender_id === 'string' ? rec.sender_id : ''
+ return /^[0-9a-f]+$/i.test(id) && id.length <= GUESS_MAX_HEX
+}
+
+// nameParts: the name as a surface should print it, split into the guess mark
+// and the name itself ({ mark: '', name: '' } when there is none), so the HUD
+// can mute the mark and not the name (#618). displayName joins the two.
+//
+// A relay, path or direct hash of 1 to 3 bytes is named by its attribution by
+// reach first (#661, attribution.js), which the surface puts on the row as
+// _attr:
+// node the one registry node in reach: that node's name, marked, since
+// the node is still a guess about who relayed; none if it has none
+// collision two or more in reach: no name, whatever the resolver said
+// estimate none in reach: the resolver's name as before, unless the
+// registry holds a positioned node with that prefix out of reach
+// (prefixKnown), which is evidence the name is that node's
+// A 1-byte hash (isHashIdKind) is named only by a placement: meshpacket.js
+// carries the hash as its own label, which is no name. Any other row without
+// _attr (a kind the rule does not cover, or not worked out yet) reads by its
+// label, as before.
+const NO_NAME = Object.freeze({ mark: '', name: '' })
+export function nameParts(rec) {
+ if (!rec) return NO_NAME
+ const attr = rec._attr
+ if (attr && attr.rule === 'node') return attr.node && attr.node.name ? { mark: GUESS_MARK, name: String(attr.node.name) } : NO_NAME
+ if (attr && attr.rule === 'collision') return NO_NAME
+ if (isHashIdKind(rec.sender_kind)) return NO_NAME
+ if (attr && attr.rule === 'estimate' && attr.prefixKnown) return NO_NAME
+ if (!rec.sender_label) return NO_NAME
+ return { mark: isGuessedName(rec) ? GUESS_MARK : '', name: String(rec.sender_label) }
+}
+
+// displayName: the name as one string, marked when guessed; '' when there is
+// none, so callers fall back to the id as before.
+export function displayName(rec) {
+ const { mark, name } = nameParts(rec)
+ return mark + name
+}
diff --git a/app/src/names.js b/app/src/names.js
index e5b2dfa9..4a1908bb 100644
--- a/app/src/names.js
+++ b/app/src/names.js
@@ -37,7 +37,7 @@ export function isFullPubkey(id) { return typeof id === 'string' && FULL_PUBKEY.
// cached as ''). 1-byte hashes (2 hex) stay excluded: the resolver answers per
// id without knowing where it was heard, and one byte is shared by too many
// nodes for that answer to mean anything. A 1-byte hash is named only by its
-// attribution by reach (#661, displayName). Mirrors the analysis website's
+// attribution by reach (#661, namerules.js). Mirrors the analysis website's
// gate (web/names.js) so a relayed advert heard by the hunter shows the same
// repeater name the map does.
const RESOLVABLE = /^[0-9a-f]{4,64}$/i;
@@ -53,14 +53,10 @@ export function resolvableKey(rec) {
return isResolvableId(rec.sender_id) ? rec.sender_id.toLowerCase() : null;
}
-// A sender id of one byte (2 hex) is a 256-way collision space, so the id
-// itself is never a name, and meshpacket.js carries it as its OWN sender_label
-// for the two kinds below. A surface that prints that label unguarded shows
-// "77" exactly as it would show a resolved short name. Marked with # instead,
-// the house style hudsender.js set, and kept out of the resolver by the 4-hex
-// floor. A name for it comes only from its attribution by reach (#661).
-const HASH_ID_KINDS = ['direct_hash', 'path_hash']
-export function isHashIdKind(kind) { return HASH_ID_KINDS.includes(kind) }
+// How a name is printed (the guess mark, a hash id, a name by reach) is
+// namerules.js, one file on both surfaces (#663). Re-exported, so the app's
+// modules keep reading it from here.
+export { isHashIdKind, GUESS_MARK, isGuessedName, nameParts, displayName } from './namerules.js';
// cachedName returns a previously-resolved name ('' = resolved-but-unknown) for
// a key, or undefined when it has not been resolved yet. Synchronous — safe to
@@ -104,57 +100,6 @@ export function consensusName(names) {
return { name: '', refused: distinct.length > 1 };
}
-// A name resolved for a short prefix is a guess about who was heard: a 2- or
-// 3-byte id is one in 65,536 or 16 million per registry, and a relay hash is
-// the forwarder's, not a node id. The name stays (it is usually right, and
-// the field reads by it) and wears GUESS_MARK on every surface, so nothing
-// presents it as a resolved identity (#452). An advert's own name on its
-// full key, a channel sender's name and an 8-byte discover prefix are not
-// guesses; a 1-byte hash carries no resolved name (isHashIdKind), only the
-// name of the node it is placed on by reach (displayName).
-export const GUESS_MARK = '~';
-const GUESS_MAX_HEX = 6;
-export function isGuessedName(rec) {
- if (!rec || !rec.sender_label) return false;
- if (rec.sender_kind === 'channel_name' || isHashIdKind(rec.sender_kind)) return false;
- const id = typeof rec.sender_id === 'string' ? rec.sender_id : '';
- return /^[0-9a-f]+$/i.test(id) && id.length <= GUESS_MAX_HEX;
-}
-// nameParts: the name as a surface should print it, split into the guess mark
-// and the name itself ({ mark: '', name: '' } when there is none), so the HUD
-// can mute the mark and not the name (#618). displayName joins the two.
-//
-// A relay, path or direct hash of 1 to 3 bytes is named by its attribution by
-// reach first (#661, attribution.js), which the app puts on the row as _attr:
-// node the one registry node in reach: that node's name, marked, since
-// the node is still a guess about who relayed; none if it has none
-// collision two or more in reach: no name, whatever the resolver said
-// estimate none in reach: the resolver's name as before, unless the
-// registry holds a positioned node with that prefix out of reach
-// (prefixKnown), which is evidence the name is that node's
-// A 1-byte hash (isHashIdKind) is named only by a placement: meshpacket.js
-// carries the hash as its own label, which is no name. Any other row without
-// _attr (a kind the rule does not cover, or not worked out yet) reads by its
-// label, as before.
-const NO_NAME = Object.freeze({ mark: '', name: '' });
-export function nameParts(rec) {
- if (!rec) return NO_NAME;
- const attr = rec._attr;
- if (attr && attr.rule === 'node') return attr.node && attr.node.name ? { mark: GUESS_MARK, name: String(attr.node.name) } : NO_NAME;
- if (attr && attr.rule === 'collision') return NO_NAME;
- if (isHashIdKind(rec.sender_kind)) return NO_NAME;
- if (attr && attr.rule === 'estimate' && attr.prefixKnown) return NO_NAME;
- if (!rec.sender_label) return NO_NAME;
- return { mark: isGuessedName(rec) ? GUESS_MARK : '', name: String(rec.sender_label) };
-}
-
-// displayName: the name as one string, marked when guessed; '' when there is
-// none, so callers fall back to the id as before.
-export function displayName(rec) {
- const { mark, name } = nameParts(rec);
- return mark + name;
-}
-
// resolveName resolves a heard key (2-3 byte prefix, 8-byte prefix or full
// pubkey) to a name. Every resolver of the companion's SF is asked at once
// (resolversFor) and the answers go through consensusName: a name only when
diff --git a/app/src/receptionlog.js b/app/src/receptionlog.js
index 3468015c..76b49ebf 100644
--- a/app/src/receptionlog.js
+++ b/app/src/receptionlog.js
@@ -344,8 +344,8 @@ export function senderText(r) {
// printed twice. A hash id is its # mark and nothing else, until it is placed
// on a named node by reach (#661): then the # id stands beside that name. A
// label that is the id is no name either: meshpacket.js gives a channel_name
-// sender its decrypted name as both. For a row without an attribution this is
-// the rule of web/receptionticker.js; the map's ticker does not attribute.
+// sender its decrypted name as both. web/receptionticker.js carries the same
+// rule since #663, pinned by web/parity.test.js.
export function senderCell(r) {
const name = senderText(r)
if (isHashIdKind(r.sender_kind) && r.sender_id) {
diff --git a/changelog.d/2026-09-29-02-map-names-relays-like-app.json b/changelog.d/2026-09-29-02-map-names-relays-like-app.json
new file mode 100644
index 00000000..96ae1271
--- /dev/null
+++ b/changelog.d/2026-09-29-02-map-names-relays-like-app.json
@@ -0,0 +1,7 @@
+{
+ "id": "2026-09-29-map-names-relays-like-app",
+ "date": "2026-09-29",
+ "where": "map",
+ "title": "The map names a relay like the app",
+ "body": "A relay's name in the ticker and the popup is marked ~ as a guess. Members get the name of the one known node in reach."
+}
diff --git a/web/changelog.json b/web/changelog.json
index f839c43e..e6a98383 100644
--- a/web/changelog.json
+++ b/web/changelog.json
@@ -13,6 +13,13 @@
"title": "Quieter send sounds",
"body": "The pops for Discover and trace-pings are a third as loud and shorter, so a weak reception is heard over them."
},
+ {
+ "id": "2026-09-29-map-names-relays-like-app",
+ "date": "2026-09-29",
+ "where": "map",
+ "title": "The map names a relay like the app",
+ "body": "A relay's name in the ticker and the popup is marked ~ as a guess. Members get the name of the one known node in reach."
+ },
{
"id": "2026-09-27-hud-bottom-space",
"date": "2026-09-27",
diff --git a/web/e2e/relaynames.spec.js b/web/e2e/relaynames.spec.js
new file mode 100644
index 00000000..4fa534d9
--- /dev/null
+++ b/web/e2e/relaynames.spec.js
@@ -0,0 +1,142 @@
+import { test, expect } from './fixtures.js'
+
+// #663: the ticker and the point popup name a relay the way the app does. A
+// name on a 1 to 3-byte id is a guess and wears ~ (#452), and the registry
+// node within reach decides the name first (#661).
+
+const RELAY = {
+ lat: 51, lon: 4, rssi: -90, snr: -8, sender_id: 'a1b2', sender_kind: 'relay', sender_label: 'repeater-3',
+ hunter_pubkey: 'h1', hunter_name: 'Hunter 1', channel_name: '', packet_type: 'TextMessage', hops: 2,
+ rx_at: new Date(Date.now() - 5000).toISOString(),
+}
+const NOORD = { pubkey: 'a1b2' + '0'.repeat(60), name: 'Mast Noord', lat: 51.001, lon: 4.001 }
+const ZUID = { pubkey: 'a1b2' + 'f'.repeat(60), name: 'Mast Zuid', lat: 50.999, lon: 3.999 }
+
+async function setup(page, { role, points, nodes = [] }) {
+ await page.route('**/api/auth/me', (r) => (role === 'guest'
+ ? r.fulfill({ status: 401, json: { error: 'unauthorised' } })
+ : r.fulfill({ json: { role, username: 'm' } })))
+ await page.route('**/api/heatmap*', (r) => r.fulfill({ json: { features: [] } }))
+ await page.route('**/api/hunters*', (r) => r.fulfill({ json: { hunters: [] } }))
+ await page.route('**/api/points*', (r) => r.fulfill({ json: { points } }))
+ const asked = []
+ await page.route('**/api/nodes/positions*', (r) => {
+ asked.push(new URL(r.request().url()).searchParams.get('bbox'))
+ return role === 'guest' ? r.fulfill({ status: 403, json: { error: 'forbidden' } }) : r.fulfill({ json: { nodes } })
+ })
+ return asked
+}
+
+async function openPointPopup(page) {
+ await expect(async () => {
+ const box = await page.locator('#map').boundingBox()
+ await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2)
+ await expect(page.locator('.maplibregl-popup')).toBeVisible({ timeout: 1000 })
+ }).toPass()
+}
+
+test('a guest reads a relay name with the guess mark, and the registry is not asked', async ({ page }) => {
+ const asked = await setup(page, { role: 'guest', points: [RELAY] })
+ await page.goto('/?mode=points')
+ const line = page.locator('#rx-log .rx-ln')
+ await expect(line).toHaveCount(1, { timeout: 10000 })
+ await expect(line.locator('.rx-sn')).toContainText('~repeater-3')
+ await expect(line.locator('.rx-id')).toHaveText('a1b2')
+ expect(asked).toEqual([])
+})
+
+test('a member reads the name of the one registry node in reach, with the node layer off', async ({ page }) => {
+ await setup(page, { role: 'member', points: [RELAY], nodes: [NOORD] })
+ await page.goto('/?mode=points')
+ await expect(page.locator('#nodepos-toggle')).toHaveAttribute('aria-pressed', 'false')
+ const line = page.locator('#rx-log .rx-ln')
+ await expect(line).toHaveCount(1, { timeout: 10000 })
+ await expect(line.locator('.rx-sn')).toContainText('~Mast Noord')
+ await expect(line.locator('.rx-sn')).not.toContainText('repeater-3')
+ await openPointPopup(page)
+ await expect(page.locator('.maplibregl-popup-content')).toContainText('sender ~Mast Noord')
+})
+
+test('two registry nodes in reach leave the id and no name', async ({ page }) => {
+ await setup(page, { role: 'member', points: [RELAY], nodes: [NOORD, ZUID] })
+ await page.goto('/?mode=points')
+ const line = page.locator('#rx-log .rx-ln')
+ await expect(line).toHaveCount(1, { timeout: 10000 })
+ await expect(line.locator('.rx-sn')).toContainText('a1b2')
+ await expect(line.locator('.rx-sn')).not.toContainText('repeater-3')
+ await expect(line.locator('.rx-sn')).not.toContainText('Mast')
+})
+
+test('a 1-byte id placed on a node reads by that name, with its # id beside it', async ({ page }) => {
+ const hash = { ...RELAY, sender_id: 'a1', sender_kind: 'path_hash', sender_label: 'a1' }
+ await setup(page, { role: 'member', points: [hash], nodes: [NOORD] })
+ await page.goto('/?mode=points')
+ const line = page.locator('#rx-log .rx-ln')
+ await expect(line).toHaveCount(1, { timeout: 10000 })
+ await expect(line.locator('.rx-sn')).toContainText('~Mast Noord')
+ await expect(line.locator('.rx-id')).toHaveText('#a1')
+ // The popup's id line reads the id as the ticker does.
+ await openPointPopup(page)
+ await expect(page.locator('.maplibregl-popup-content .pp-id')).toHaveText('#a1')
+})
+
+test('the registry is asked around the ticker lines, not around the view', async ({ page }) => {
+ // The ticker's page holds a line heard 2 degrees north of the one point the
+ // map frames, so the view cannot hold both and the slice has to.
+ const far = { ...RELAY, lat: 53, lon: 4, rx_at: new Date(Date.now() - 1000).toISOString() }
+ const asked = await setup(page, { role: 'member', points: [RELAY], nodes: [] })
+ await page.route('**/api/points*', (r) => {
+ const q = new URL(r.request().url()).searchParams
+ const ticker = q.get('limit') === '200' && q.get('offset') === '0' && !q.get('bbox')
+ return r.fulfill({ json: { points: ticker ? [RELAY, far] : [RELAY] } })
+ })
+ await page.goto('/?mode=points')
+ await expect(page.locator('#rx-log .rx-ln')).toHaveCount(2, { timeout: 10000 })
+ const view = await page.evaluate(() => window.__mapBounds())
+ expect(view.north, 'the view holds the far line, so this measures nothing').toBeLessThan(53)
+ await expect.poll(() => asked.some((bbox) => {
+ const [south, , north] = String(bbox).split(',').map(Number)
+ return south < 51 && north > 53
+ })).toBe(true)
+})
+
+// The lines drawn before the registry answered are named again in place when
+// it does (receptionticker.js rebuild): same rows, so no rebuild, and the
+// reader keeps where they scrolled to. A deterministic path: the first slice
+// holds no node, and a minute later the registry names one.
+test('a line drawn before the registry named its relay is renamed in place, where the reader scrolled', async ({ page }) => {
+ await page.clock.install()
+ const rows = Array.from({ length: 40 }, (_, i) => ({ ...RELAY, rx_at: new Date(Date.now() - 5000 - i * 1000).toISOString() }))
+ let nodes = []
+ await setup(page, { role: 'member', points: rows })
+ await page.route('**/api/nodes/positions*', (r) => r.fulfill({ json: { nodes } }))
+ await page.goto('/?mode=points')
+ const lines = page.locator('#rx-log .rx-ln')
+ await expect(lines).toHaveCount(40, { timeout: 10000 })
+ await expect(lines.first().locator('.rx-sn')).toContainText('~repeater-3')
+ const list = page.locator('#rx-log .rx-list')
+ await list.evaluate((el) => { el.scrollTop = Math.floor(el.scrollHeight / 3) })
+ const scrolled = await list.evaluate((el) => el.scrollTop)
+ expect(scrolled, 'the list has to scroll for this to measure anything').toBeGreaterThan(0)
+ nodes = [NOORD]
+ // Past the slice's minute, and past the next poll.
+ await page.clock.fastForward(66_000)
+ await expect(lines.first().locator('.rx-sn')).toContainText('~Mast Noord')
+ await expect(lines.last().locator('.rx-sn')).toContainText('~Mast Noord')
+ expect(await list.evaluate((el) => el.scrollTop)).toBe(scrolled)
+})
+
+// A tap that lands before the registry answered opens the popup by the label;
+// when the answer lands, the open popup names the node it places the relay on.
+test('a popup opened before the registry answered is named again when it does', async ({ page }) => {
+ await setup(page, { role: 'member', points: [RELAY] })
+ let release
+ const answered = new Promise((r) => { release = r })
+ await page.route('**/api/nodes/positions*', async (r) => { await answered; return r.fulfill({ json: { nodes: [NOORD] } }) })
+ await page.goto('/?mode=points')
+ await expect(page.locator('#rx-log .rx-ln')).toHaveCount(1, { timeout: 10000 })
+ await openPointPopup(page)
+ await expect(page.locator('.maplibregl-popup-content')).toContainText('sender ~repeater-3')
+ release()
+ await expect(page.locator('.maplibregl-popup-content')).toContainText('sender ~Mast Noord')
+})
diff --git a/web/map.js b/web/map.js
index 9cb6a87b..908b5e86 100644
--- a/web/map.js
+++ b/web/map.js
@@ -4,9 +4,10 @@ import { leafletZoom, mapZoomFromLeaflet, zoomParam, pointFeatures, hexFeatures,
import { coverageStars, coverageFeatures, assignHues, isRepeaterHearing, selectionDim, starKey, starSelected } from './coverage.js'
import { advertFeature, dotFeature, fc as glyphFc, NODE_GLYPH_SOURCE, NODE_DOT_SOURCE } from './nodeglyphs.js'
import { registryIndex, attributeReception, REACH_CAP_KM } from './attribution.js'
+import { createRowAttributor } from './rowattribution.js'
import { EXAGGERATION_STEPS, DEFAULT_EXAGGERATION } from './terrain.js'
import { API_BASE } from './config.js'
-import { resolveName, cachedName, isFullPubkey, isResolvableId, senderName, resolvableKey } from './names.js'
+import { resolveName, cachedName, isFullPubkey, isResolvableId, senderName, resolvableKey, isHashIdKind } from './names.js'
import { loadSeenRole, saveSeenRole, roleRose, roleNotice } from './rolechange.js'
import { locate, toLocatePoints } from './locate.js'
import { groupSenderPointsForNodes, nodesInView, padBounds, circleRing, nodeRows } from './nodelayer.js'
@@ -325,10 +326,19 @@ const ignoreSuffix = () => (ignored.size ? ` · ${ignored.size} ignored` : '')
// the Locate and Ignore buttons are handled by the delegated document
// listeners further down, so nothing here needs a reference to the popup.
let currentPoints = []
+// The rows a name is printed on carry their attribution by reach (#663), so
+// the popup and the ticker name a relay the way the app does. One attributor
+// each: the points are the view's, the ticker's lines are the latest wherever
+// they were heard, and each keeps the registry slice that covers its own rows.
+// Below member the registry is refused, so nothing is asked.
+const mayReadRegistry = () => roleKnown && canSeeObserverPoints(currentRole)
+const pointAttribution = createRowAttributor({ fetchRegistry: (b) => fetchNodeRegistry(b), allowed: mayReadRegistry })
+const tickerAttribution = createRowAttributor({ fetchRegistry: (b) => fetchNodeRegistry(b), allowed: mayReadRegistry })
function pointPopupHtml(pt) {
const role = pt.sender_role ? ` · ${esc(pt.sender_role)}` : ''
const sid = pt.sender_id || ''
- const idLine = sid ? ` ${esc(sid)}` : ''
+ // A relay or direct hash reads '#' and the id, as the ticker has it.
+ const idLine = sid ? ` ${esc(isHashIdKind(pt.sender_kind) ? '#' + sid : sid)}` : ''
const locBtn = (sid && canSeeLocate(currentRole)) ? ` ` : ''
// Ignoring is per person and needs no role: it only ever removes rows from
// the asker's own view. Same wording as the app's popup (huntmap.js).
@@ -338,10 +348,14 @@ function pointPopupHtml(pt) {
// Reception ticker two-way sync (#224): clicking a point scrolls the ticker
// to the matching line, keyed by receptionKey since /api/points rows carry no
// stable id.
+// A tap before the registry answered opens by the label; the open popup is
+// named again when the answer lands.
+let pointsAttributed = Promise.resolve()
const onPointClick = (props) => {
const pt = currentPoints[props.i]
if (!pt) return
- wm.openPopup([pt.lon, pt.lat], pointPopupHtml(pt))
+ const popup = wm.openPopup([pt.lon, pt.lat], pointPopupHtml(pt))
+ if (pt._attr === undefined) pointsAttributed.then(() => { if (popup.isOpen()) popup.setHTML(pointPopupHtml(pt)) })
if (rxTicker) rxTicker.focusRecord(receptionKey(pt))
}
// The pillar carries the index of the reception it stands for (#595), so a
@@ -373,6 +387,10 @@ async function drawPoints() {
// until the new ones are here. The array is kept for the click handler,
// which gets a feature index back rather than a marker object.
currentPoints = points
+ // Not awaited: the popup reads _attr when a point is tapped, and the draw
+ // does not wait for a registry. A tap that lands first reads by the label
+ // until this settles (onPointClick).
+ pointsAttributed = pointAttribution.attribute(points).catch(() => {})
// A selection dims what is not part of it (#624), on a fresh draw as well as
// on a tap, or a pan would bring the whole map back to full strength.
const { dimFor } = selectionDimmer()
@@ -543,6 +561,8 @@ function announceRoleRise(role) {
function applyRole(me) {
currentRole = me.role || 'guest'
roleKnown = true
+ // The registry is the role's to read or not (#663).
+ pointAttribution.reset(); tickerAttribution.reset()
announceRoleRise(currentRole)
const notice = document.getElementById('guest-notice')
const msg = guestNotice(currentRole)
@@ -785,7 +805,12 @@ async function fetchTickerPage(mode) {
// truncated travels with the rows: QueryPoints fetches one past the limit to
// detect it precisely, and the ticker's header needs it to say whether more
// receptions exist behind this page (#638).
- return { points: d.points || [], truncated: !!d.truncated }
+ // Attributed before the lines render, so a line never prints a name first
+ // and drops it a moment later. A registry that does not answer leaves the
+ // rows as a guest's (rowattribution.js).
+ const points = d.points || []
+ await tickerAttribution.attribute(points).catch(() => {})
+ return { points, truncated: !!d.truncated }
}
// Snap the map to the selected hunter(s) (#195).
diff --git a/web/namerules.js b/web/namerules.js
new file mode 100644
index 00000000..0433f934
--- /dev/null
+++ b/web/namerules.js
@@ -0,0 +1,68 @@
+// How a sender's name is printed (#452, #661, #663). Pure: no resolver, no
+// cache, no config. Where a name comes from differs per surface (the app asks
+// the registries of the companion's SF, the map asks the server's resolve
+// proxy), how it reads does not.
+// Copied whole between app/src/ and web/ (parity.test.js), since neither deploy
+// path can ship a file outside its own directory (#238). No imports.
+
+// A sender id of one byte (2 hex) is a 256-way collision space, so the id
+// itself is never a name, and meshpacket.js carries it as its OWN sender_label
+// for the two kinds below. A surface that prints that label unguarded shows
+// "77" exactly as it would show a resolved short name. Marked with # instead,
+// the house style hudsender.js set, and kept out of the resolver by the 4-hex
+// floor. A name for it comes only from its attribution by reach (#661).
+const HASH_ID_KINDS = ['direct_hash', 'path_hash']
+export function isHashIdKind(kind) { return HASH_ID_KINDS.includes(kind) }
+
+// A name resolved for a short prefix is a guess about who was heard: a 2- or
+// 3-byte id is one in 65,536 or 16 million per registry, and a relay hash is
+// the forwarder's, not a node id. The name stays (it is usually right, and
+// the field reads by it) and wears GUESS_MARK on every surface, so nothing
+// presents it as a resolved identity (#452). An advert's own name on its
+// full key, a channel sender's name and an 8-byte discover prefix are not
+// guesses; a 1-byte hash carries no resolved name (isHashIdKind), only the
+// name of the node it is placed on by reach (displayName).
+export const GUESS_MARK = '~'
+const GUESS_MAX_HEX = 6
+export function isGuessedName(rec) {
+ if (!rec || !rec.sender_label) return false
+ if (rec.sender_kind === 'channel_name' || isHashIdKind(rec.sender_kind)) return false
+ const id = typeof rec.sender_id === 'string' ? rec.sender_id : ''
+ return /^[0-9a-f]+$/i.test(id) && id.length <= GUESS_MAX_HEX
+}
+
+// nameParts: the name as a surface should print it, split into the guess mark
+// and the name itself ({ mark: '', name: '' } when there is none), so the HUD
+// can mute the mark and not the name (#618). displayName joins the two.
+//
+// A relay, path or direct hash of 1 to 3 bytes is named by its attribution by
+// reach first (#661, attribution.js), which the surface puts on the row as
+// _attr:
+// node the one registry node in reach: that node's name, marked, since
+// the node is still a guess about who relayed; none if it has none
+// collision two or more in reach: no name, whatever the resolver said
+// estimate none in reach: the resolver's name as before, unless the
+// registry holds a positioned node with that prefix out of reach
+// (prefixKnown), which is evidence the name is that node's
+// A 1-byte hash (isHashIdKind) is named only by a placement: meshpacket.js
+// carries the hash as its own label, which is no name. Any other row without
+// _attr (a kind the rule does not cover, or not worked out yet) reads by its
+// label, as before.
+const NO_NAME = Object.freeze({ mark: '', name: '' })
+export function nameParts(rec) {
+ if (!rec) return NO_NAME
+ const attr = rec._attr
+ if (attr && attr.rule === 'node') return attr.node && attr.node.name ? { mark: GUESS_MARK, name: String(attr.node.name) } : NO_NAME
+ if (attr && attr.rule === 'collision') return NO_NAME
+ if (isHashIdKind(rec.sender_kind)) return NO_NAME
+ if (attr && attr.rule === 'estimate' && attr.prefixKnown) return NO_NAME
+ if (!rec.sender_label) return NO_NAME
+ return { mark: isGuessedName(rec) ? GUESS_MARK : '', name: String(rec.sender_label) }
+}
+
+// displayName: the name as one string, marked when guessed; '' when there is
+// none, so callers fall back to the id as before.
+export function displayName(rec) {
+ const { mark, name } = nameParts(rec)
+ return mark + name
+}
diff --git a/web/names.js b/web/names.js
index 7fdbf1ae..5008d568 100644
--- a/web/names.js
+++ b/web/names.js
@@ -34,13 +34,10 @@ export function resolvableKey(rec) {
return isResolvableId(rec.sender_id) ? rec.sender_id.toLowerCase() : null
}
-// A sender id of one byte (2 hex) is a 256-way collision space, so it is never
-// a name, and meshpacket.js carries it as its OWN sender_label for the two
-// kinds below. A surface that prints that label unguarded shows "77" exactly
-// as it would show a resolved short name. Marked with # instead, the house
-// style hudsender.js set, and kept out of the resolver by the 4-hex floor.
-const HASH_ID_KINDS = ['direct_hash', 'path_hash']
-export function isHashIdKind(kind) { return HASH_ID_KINDS.includes(kind) }
+// How a name is printed (the guess mark, a hash id, a name by reach) is
+// namerules.js, one file on both surfaces (#663).
+import { isHashIdKind, displayName } from './namerules.js'
+export { isHashIdKind, GUESS_MARK, isGuessedName, nameParts, displayName } from './namerules.js'
// cachedName: resolved name ('' = resolved-but-unknown) or undefined if not yet
// looked up. Synchronous — use it while rendering.
@@ -82,14 +79,20 @@ export async function resolveName(key) {
return name
}
-// senderName picks the best label for a point: an existing server label wins
-// (fill-only — advert broadcast names), then a cached resolved name for a
-// resolvable id (full pubkey or discover prefix), then the raw id.
+// senderName is the name a point or a ticker line prints, the app's
+// senderText (receptionlog.js) with the map's one difference: a row without a
+// label takes the name the resolve proxy cached for its id. After that the
+// rule is namerules.js: a name on a short prefix wears the guess mark (#452),
+// the attribution by reach decides a relay's name first (#661, _attr), and a
+// 1-byte hash is # and its id until it is placed on a named node. Without a
+// name the id stands, as before.
export function senderName(pt) {
- if (pt.sender_label) return pt.sender_label
- if (isResolvableId(pt.sender_id)) {
+ if (!pt) return '—'
+ let rec = pt
+ if (!pt.sender_label && isResolvableId(pt.sender_id)) {
const hit = cachedName(pt.sender_id)
- if (hit) return hit
+ if (hit) rec = { ...pt, sender_label: hit }
}
- return pt.sender_id || '—'
+ if (isHashIdKind(pt.sender_kind) && pt.sender_id) return displayName(rec) || '#' + String(pt.sender_id)
+ return displayName(rec) || pt.sender_id || '—'
}
diff --git a/web/names.test.js b/web/names.test.js
index 416afac9..e94a5819 100644
--- a/web/names.test.js
+++ b/web/names.test.js
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { resolveName, cachedName, cachedPosition, _resetNameCache } from './names.js'
+import { resolveName, cachedName, cachedPosition, _resetNameCache, senderName } from './names.js'
beforeEach(() => { if (_resetNameCache) _resetNameCache() })
@@ -44,3 +44,33 @@ describe('cachedPosition', () => {
expect(cachedPosition('abcd')).toBeNull()
})
})
+
+// #663: a name the resolver cache fills in reads by the same rule as a label
+// the server sent.
+describe('senderName (#663)', () => {
+ beforeEach(() => { _resetNameCache() })
+ const answer = (name) => vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ name }) })))
+ it('marks a cached name on a 2-byte relay id as a guess', async () => {
+ answer('repeater-3')
+ await resolveName('a1b2')
+ expect(senderName({ sender_kind: 'relay', sender_id: 'a1b2', sender_label: '' })).toBe('~repeater-3')
+ })
+ it('leaves a cached name on a full key unmarked', async () => {
+ answer('Node Zuid')
+ const key = 'ab'.repeat(32)
+ await resolveName(key)
+ expect(senderName({ sender_kind: 'advert_pubkey', sender_id: key, sender_label: '' })).toBe('Node Zuid')
+ })
+ it('drops a cached name when the attribution refuses one', async () => {
+ answer('repeater-3')
+ await resolveName('a1b2')
+ expect(senderName({ sender_kind: 'relay', sender_id: 'a1b2', sender_label: '', _attr: { rule: 'collision', count: 2 } })).toBe('a1b2')
+ })
+ it('prints a 1-byte id as # and its id, never as a name', () => {
+ expect(senderName({ sender_kind: 'path_hash', sender_id: '77', sender_label: '77' })).toBe('#77')
+ })
+ it('falls back to the id, then to a dash', () => {
+ expect(senderName({ sender_kind: 'relay', sender_id: 'a1b2f3c4d5e6', sender_label: '' })).toBe('a1b2f3c4d5e6')
+ expect(senderName({ sender_kind: 'relay', sender_id: '', sender_label: '' })).toBe('—')
+ })
+})
diff --git a/web/parity.test.js b/web/parity.test.js
index b74f6bb6..08b66908 100644
--- a/web/parity.test.js
+++ b/web/parity.test.js
@@ -785,6 +785,15 @@ describe('receptions ticker CSS parity (#322)', () => {
{ sender_kind: 'channel_name', sender_id: 'Spammer', sender_label: 'Spammer' },
{ sender_kind: 'path_hash', sender_id: '77', sender_label: '77' },
{ sender_kind: 'direct_hash', sender_id: '4a', sender_label: 'Repeater-Zuid' },
+ // #663: a name on a short prefix wears the guess mark, and the
+ // attribution by reach decides a relay's name first, on both surfaces.
+ { sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3' },
+ { sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', _attr: { rule: 'node', node: { pubkey: 'a1b2' + '0'.repeat(60), name: 'Mast Noord' } } },
+ { sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', _attr: { rule: 'collision', count: 2 } },
+ { sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', _attr: { rule: 'estimate', prefixKnown: true } },
+ { sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', _attr: { rule: 'estimate', prefixKnown: false } },
+ { sender_kind: 'path_hash', sender_id: '77', sender_label: '77', _attr: { rule: 'node', node: { pubkey: '77' + '0'.repeat(62), name: 'Mast Noord' } } },
+ { sender_kind: 'path_hash', sender_id: '77', sender_label: '77', _attr: { rule: 'node', node: { pubkey: '77' + '0'.repeat(62), name: '' } } },
]
for (const s of senders) {
expect(webTicker.senderCell(s), JSON.stringify(s)).toEqual(appTicker.senderCell(s))
@@ -1361,7 +1370,9 @@ describe('files copied whole from the app (#595)', () => {
// enough for it and because it catches the case a behaviour test cannot: a
// comment on one side explaining a rule the other side no longer follows.
describe('the filter modules are one file on both surfaces (#564)', () => {
- for (const name of ['chiprow.js', 'barfilters.js', 'basemapswap.js']) {
+ // namerules.js since #663: how a sender's name is printed, the guess mark
+ // included, is one rule on both surfaces.
+ for (const name of ['chiprow.js', 'barfilters.js', 'basemapswap.js', 'namerules.js']) {
it(`${name} is identical in app/src`, () => {
const web = readFileSync(new URL(`./${name}`, import.meta.url), 'utf8')
const app = readFileSync(new URL(`../app/src/${name}`, import.meta.url), 'utf8')
diff --git a/web/receptionticker.js b/web/receptionticker.js
index 6549aa1e..5ce2d27d 100644
--- a/web/receptionticker.js
+++ b/web/receptionticker.js
@@ -347,19 +347,22 @@ export function rxLineHeight(raw) {
}
export const CAP = 200 // recent-window cap, mirrors app's; reused by map.js's fetch limit
-// senderCell, the app's rule for a row without an attribution
-// (app/src/receptionlog.js, #451): once a name has resolved the id stands
-// beside it in its own column, cut to the same six characters the target
-// picker uses; a line without a name keeps the id in the name cell and the
-// column empty, and a hash id is its # mark only. The map's ticker does not
-// attribute by reach, so it has no placed hash id to name.
+// senderCell is the app's rule (app/src/receptionlog.js, #451, #663): once a
+// name has resolved the id stands beside it in its own column, cut to the same
+// six characters the target picker uses; a line without a name keeps the id in
+// the name cell and the column empty. A hash id is its # mark and nothing
+// else, until it is placed on a named node by reach (#661): then the # id
+// stands beside that name. map.js puts the attribution on the row as _attr
+// (rowattribution.js).
const ID_PREFIX_HEX_CHARS = 6
export function senderCell(pt) {
- const id = pt.sender_id ? String(pt.sender_id) : ''
- if (isHashIdKind(pt.sender_kind) && id) return { id: '', name: '#' + id }
const name = senderName(pt)
- const resolved = !!id && name !== id
- return { id: resolved ? id.slice(0, ID_PREFIX_HEX_CHARS) : '', name }
+ if (isHashIdKind(pt.sender_kind) && pt.sender_id) {
+ const hashId = '#' + String(pt.sender_id)
+ return { id: name === hashId ? '' : hashId, name }
+ }
+ const resolved = !!pt.sender_id && name !== String(pt.sender_id)
+ return { id: resolved ? String(pt.sender_id).slice(0, ID_PREFIX_HEX_CHARS) : '', name }
}
// createReceptionTicker builds the log inside `rootId` and owns its own
@@ -479,10 +482,20 @@ export function createReceptionTicker(rootId, { fetchFiltered, fetchAll, shouldP
// Same rows, but they aged: refresh only the relative-time cells. Without
// this a quiet mesh freezes every row at the age it first rendered, which
// reads as "just received".
+ // The sender cells too (#663): a fetch returns new row objects, and what
+ // names them can have changed since these rows were drawn: the role
+ // became known, so the registry answered, or a resolver name landed.
+ // Written in place, so the reader stays where they scrolled to.
const els = list.children
for (let i = 0; i < els.length && i < view.length; i++) {
const tm = els[i].children[1]
if (tm) tm.textContent = relTime(view[i].rx_at, nowMs)
+ const cell = senderCell(view[i])
+ const idEl = els[i].children[3]
+ const snEl = els[i].children[4]
+ if (idEl && idEl.textContent !== cell.id) idEl.textContent = cell.id
+ const text = snEl && snEl.firstChild
+ if (text && text.nodeType === 3 && text.nodeValue !== cell.name + ' ') text.nodeValue = cell.name + ' '
}
paint(); return
}
diff --git a/web/receptionticker.test.js b/web/receptionticker.test.js
index 4fb16483..56860e5d 100644
--- a/web/receptionticker.test.js
+++ b/web/receptionticker.test.js
@@ -370,3 +370,25 @@ describe('senderCell — the id stays beside the name it resolved to', () => {
expect(senderCell({ sender_kind: 'path_hash', sender_id: '77', sender_label: '77' })).toEqual({ id: '', name: '#77' })
})
})
+
+// #663: the map's ticker names a relay the way the app's does.
+describe('senderCell, a relay named by the guess mark and by reach (#663)', () => {
+ const node = (name) => ({ rule: 'node', node: { pubkey: 'a1b2' + '0'.repeat(60), name } })
+ it('marks a name on a 2 or 3-byte id as a guess', () => {
+ expect(senderCell({ sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3' })).toEqual({ id: 'a1b2', name: '~repeater-3' })
+ expect(senderCell({ sender_kind: 'relay', sender_id: 'a1b2f3', sender_label: 'repeater-3' })).toEqual({ id: 'a1b2f3', name: '~repeater-3' })
+ })
+ it('leaves a name on a longer id unmarked', () => {
+ expect(senderCell({ sender_kind: 'discover', sender_id: 'a1b2f3c4d5e6f7a8', sender_label: 'repeater-3' }).name).toBe('repeater-3')
+ })
+ it('takes the name of the one registry node in reach, marked', () => {
+ expect(senderCell({ sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', _attr: node('Mast Noord') })).toEqual({ id: 'a1b2', name: '~Mast Noord' })
+ })
+ it('names a 1-byte id by its placement, with the # id beside it', () => {
+ expect(senderCell({ sender_kind: 'path_hash', sender_id: '77', sender_label: '77', _attr: node('Mast Noord') })).toEqual({ id: '#77', name: '~Mast Noord' })
+ })
+ it('prints no name on a collision, nor when the registry knows the prefix out of reach', () => {
+ expect(senderCell({ sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', _attr: { rule: 'collision', count: 2 } })).toEqual({ id: '', name: 'a1b2' })
+ expect(senderCell({ sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', _attr: { rule: 'estimate', prefixKnown: true } })).toEqual({ id: '', name: 'a1b2' })
+ })
+})
diff --git a/web/rowattribution.js b/web/rowattribution.js
new file mode 100644
index 00000000..610c7e68
--- /dev/null
+++ b/web/rowattribution.js
@@ -0,0 +1,81 @@
+// Attribution by reach for the rows a name is printed on (#663): the ticker's
+// lines and the points behind a popup. attribution.js holds the rule; this
+// fetches the registry slice it needs and puts the outcome on each row as
+// _attr, which is where namerules.js reads it.
+//
+// The slice is the box around the rows, not around the view. The ticker shows
+// the latest receptions wherever they were heard, and a row outside the view
+// with no candidates fetched reads as "none in reach": it would print a name
+// where the app prints none. The node-position layer keeps its own slice of
+// the view, since it draws the nodes in view.
+import { registryIndex, attributeReception, attributableId } from './attribution.js'
+
+const isCoord = (v) => typeof v === 'number' && Number.isFinite(v)
+
+// rowsBounds: the box around the rows that carry a position, or null. The
+// caller's fetch pads it by the reach (fetchNodeRegistry, padBounds).
+export function rowsBounds(rows) {
+ let b = null
+ for (const r of rows || []) {
+ if (!r || !isCoord(r.lat) || !isCoord(r.lon)) continue
+ if (!b) b = { south: r.lat, west: r.lon, north: r.lat, east: r.lon }
+ else {
+ b.south = Math.min(b.south, r.lat); b.north = Math.max(b.north, r.lat)
+ b.west = Math.min(b.west, r.lon); b.east = Math.max(b.east, r.lon)
+ }
+ }
+ return b
+}
+
+const covers = (outer, inner) => outer.south <= inner.south && outer.north >= inner.north
+ && outer.west <= inner.west && outer.east >= inner.east
+const union = (a, b) => ({ south: Math.min(a.south, b.south), west: Math.min(a.west, b.west), north: Math.max(a.north, b.north), east: Math.max(a.east, b.east) })
+
+// A slice is reused while it covers the rows and is under a minute old: the
+// ticker asks every 5 s while live, and the server's own registry cache is
+// older than that anyway.
+export const SLICE_TTL_MS = 60000
+// After an answer that is not a whole registry, the rows read without one
+// for this long rather than asking again on every poll of an outage.
+export const RETRY_MS = 15000
+
+// createRowAttributor({ fetchRegistry, allowed, now }).
+// fetchRegistry(bounds) answers { status, nodes, truncated } (map.js's
+// fetchNodeRegistry). allowed() says whether this account may read the
+// registry: below member the server refuses, so nothing is asked and every
+// row reads by rule 2, the resolver's name with its guess mark.
+//
+// An answer that is not a whole registry counts as none. Refused or failed has
+// no nodes. Truncated has some, and a missing node is a missed candidate: a
+// collision would read as a placement. Neither is kept, and the next ask is
+// RETRY_MS later.
+//
+// A fresh slice that does not cover the rows grows to the box around both:
+// the ticker's "all" stand attributes two pages a poll, and a slice of the
+// last box alone would be fetched twice a poll where neither covers the other.
+export function createRowAttributor({ fetchRegistry, allowed = () => true, now = () => Date.now() } = {}) {
+ let slice = null // { bounds, at, index }
+ let failedAt = null
+ async function indexFor(rows) {
+ if (!allowed()) return null
+ const bounds = rowsBounds(rows.filter((r) => attributableId(r) != null))
+ if (!bounds) return null
+ const fresh = slice && now() - slice.at < SLICE_TTL_MS
+ if (fresh && covers(slice.bounds, bounds)) return slice.index
+ if (failedAt != null && now() - failedAt < RETRY_MS) return null
+ const want = fresh ? union(slice.bounds, bounds) : bounds
+ const answer = await fetchRegistry(want)
+ if (!answer || answer.status !== 'ok' || answer.truncated) { failedAt = now(); return null }
+ failedAt = null
+ slice = { bounds: want, at: now(), index: registryIndex(answer.nodes) }
+ return slice.index
+ }
+ return {
+ async attribute(rows) {
+ const index = await indexFor(rows || [])
+ for (const r of rows || []) r._attr = attributeReception(r, { index })
+ return rows
+ },
+ reset() { slice = null; failedAt = null },
+ }
+}
diff --git a/web/rowattribution.test.js b/web/rowattribution.test.js
new file mode 100644
index 00000000..86dea430
--- /dev/null
+++ b/web/rowattribution.test.js
@@ -0,0 +1,105 @@
+import { describe, it, expect, vi } from 'vitest'
+import { rowsBounds, createRowAttributor, RETRY_MS } from './rowattribution.js'
+
+const NODE = { pubkey: 'a1b2' + '0'.repeat(60), name: 'Mast Noord', lat: 51.0, lon: 4.0 }
+const ROW = { sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', lat: 51.01, lon: 4.01, rssi: -90 }
+const ok = (nodes) => ({ status: 'ok', nodes, truncated: false })
+
+describe('rowsBounds', () => {
+ it('is the box around the rows that have a position', () => {
+ expect(rowsBounds([{ lat: 51, lon: 4 }, { lat: 52, lon: 3 }, { lat: null, lon: null }]))
+ .toEqual({ south: 51, west: 3, north: 52, east: 4 })
+ })
+ it('is null without a positioned row', () => {
+ expect(rowsBounds([])).toBe(null)
+ expect(rowsBounds([{ lat: null, lon: 4 }])).toBe(null)
+ })
+})
+
+describe('createRowAttributor (#663)', () => {
+ it('places a relay id on the one registry node in reach', async () => {
+ const a = createRowAttributor({ fetchRegistry: async () => ok([NODE]), allowed: () => true })
+ const rows = [{ ...ROW }]
+ await a.attribute(rows)
+ expect(rows[0]._attr).toEqual({ rule: 'node', node: NODE })
+ })
+ it('asks for the box around the rows, not around the view', async () => {
+ const fetchRegistry = vi.fn(async () => ok([]))
+ const a = createRowAttributor({ fetchRegistry, allowed: () => true })
+ await a.attribute([{ ...ROW }, { ...ROW, lat: 52.5, lon: 5.5 }])
+ expect(fetchRegistry).toHaveBeenCalledWith({ south: 51.01, west: 4.01, north: 52.5, east: 5.5 })
+ })
+ it('asks nothing for a guest, and leaves the resolver name its guess mark', async () => {
+ const fetchRegistry = vi.fn(async () => ok([NODE]))
+ const a = createRowAttributor({ fetchRegistry, allowed: () => false })
+ const rows = [{ ...ROW }]
+ await a.attribute(rows)
+ expect(fetchRegistry).not.toHaveBeenCalled()
+ expect(rows[0]._attr).toEqual({ rule: 'estimate', prefixKnown: false })
+ })
+ it('asks nothing when no row carries an id the rule covers', async () => {
+ const fetchRegistry = vi.fn(async () => ok([NODE]))
+ const a = createRowAttributor({ fetchRegistry, allowed: () => true })
+ const rows = [{ sender_kind: 'advert_pubkey', sender_id: 'ab'.repeat(32), lat: 51, lon: 4, rssi: -90 }]
+ await a.attribute(rows)
+ expect(fetchRegistry).not.toHaveBeenCalled()
+ expect(rows[0]._attr).toBe(null)
+ })
+ it('reuses a slice that still covers the rows, until it is a minute old', async () => {
+ let now = 1000
+ const fetchRegistry = vi.fn(async () => ok([NODE]))
+ const a = createRowAttributor({ fetchRegistry, allowed: () => true, now: () => now })
+ await a.attribute([{ ...ROW }, { ...ROW, lat: 51.2, lon: 4.2 }])
+ await a.attribute([{ ...ROW, lat: 51.1, lon: 4.1 }])
+ expect(fetchRegistry).toHaveBeenCalledTimes(1)
+ await a.attribute([{ ...ROW, lat: 53, lon: 4.1 }]) // outside the slice
+ expect(fetchRegistry).toHaveBeenCalledTimes(2)
+ now += 61000
+ await a.attribute([{ ...ROW, lat: 53, lon: 4.1 }]) // covered, but stale
+ expect(fetchRegistry).toHaveBeenCalledTimes(3)
+ })
+ it('treats a refused, failed or truncated registry as no registry', async () => {
+ for (const answer of [{ status: 'forbidden', nodes: [] }, { status: 'server_unreachable', nodes: [] }, { status: 'ok', nodes: [NODE], truncated: true }]) {
+ const a = createRowAttributor({ fetchRegistry: async () => answer, allowed: () => true })
+ const rows = [{ ...ROW }]
+ await a.attribute(rows)
+ expect(rows[0]._attr, JSON.stringify(answer)).toEqual({ rule: 'estimate', prefixKnown: false })
+ }
+ })
+ // A registry that fails is asked again, but not on every 5 s poll of an
+ // outage: after a failure the rows read without it for RETRY_MS.
+ it('asks again after a failed answer, once RETRY_MS has passed', async () => {
+ let now = 1000
+ const fetchRegistry = vi.fn().mockResolvedValueOnce({ status: 'server_unreachable', nodes: [] }).mockResolvedValue(ok([NODE]))
+ const a = createRowAttributor({ fetchRegistry, allowed: () => true, now: () => now })
+ await a.attribute([{ ...ROW }])
+ now += 5000
+ await a.attribute([{ ...ROW }])
+ expect(fetchRegistry).toHaveBeenCalledTimes(1)
+ now += RETRY_MS
+ const rows = [{ ...ROW }]
+ await a.attribute(rows)
+ expect(fetchRegistry).toHaveBeenCalledTimes(2)
+ expect(rows[0]._attr.rule).toBe('node')
+ })
+ // The ticker's "all" stand attributes the filtered page and then the all
+ // page on every poll. Where neither box covers the other, a slice of the
+ // last box only would be fetched twice a poll, for ever.
+ it('keeps what it fetched for two alternating boxes, so it asks once more and then not again', async () => {
+ const fetchRegistry = vi.fn(async () => ok([NODE]))
+ const a = createRowAttributor({ fetchRegistry, allowed: () => true, now: () => 1000 })
+ const here = [{ ...ROW }], there = [{ ...ROW, lat: 52.5, lon: 5.5 }]
+ for (let poll = 0; poll < 4; poll++) { await a.attribute(here.map((r) => ({ ...r }))); await a.attribute(there.map((r) => ({ ...r }))) }
+ expect(fetchRegistry).toHaveBeenCalledTimes(2)
+ // The second ask is the box around both.
+ expect(fetchRegistry.mock.calls[1][0]).toEqual({ south: 51.01, west: 4.01, north: 52.5, east: 5.5 })
+ })
+ it('forgets its slice on reset, which is what a change of role calls', async () => {
+ const fetchRegistry = vi.fn(async () => ok([NODE]))
+ const a = createRowAttributor({ fetchRegistry, allowed: () => true })
+ await a.attribute([{ ...ROW }])
+ a.reset()
+ await a.attribute([{ ...ROW }])
+ expect(fetchRegistry).toHaveBeenCalledTimes(2)
+ })
+})