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
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions app/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 68 additions & 0 deletions app/src/namerules.js
Original file line number Diff line number Diff line change
@@ -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
}
65 changes: 5 additions & 60 deletions app/src/names.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/src/receptionlog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions changelog.d/2026-09-29-02-map-names-relays-like-app.json
Original file line number Diff line number Diff line change
@@ -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."
}
7 changes: 7 additions & 0 deletions web/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading