nandi/frqpublic Fork 0
07a23ef
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

A face is asked for when somebody speaks

Faces appeared only after opening a profile by hand, which is the one
place that asked for one.

A profile is cached under the actor `actorFor` returns, and that is the
DID only once WHO has answered. Before then — and for anybody in a
replayed backlog who has since left the room, who no WHO will ever cover
— the screen looks a face up under the handle. Asking only when a DID
turned up meant the handle was never asked for, so the cache had nothing
under the name being read. Opening the profile asked under that same
name, which is why clicking worked and waiting did not.

So a message arriving asks for its sender's face, under whichever name
the screen will use. Our own line too: it has a face as much as anybody
else's. Guests and system lines are nobody to look up.

And the lookup takes both names, because which one is the actor changes
underneath the reader: the handle when the first message lands, the DID
a moment later when WHO comes back. Without the fallback the face would
appear, vanish, and return when the second fetch finished.

The cost is that somebody can be fetched twice, once under each name.
That is one request per person per run and it buys a face that arrives
when the message does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T20:21:37-07:00 Browse files
07a23ef parent: ecb440f
modified nim/src/frq/profile.nim +19 -4
@@ -218,12 +218,27 @@ proc collect*(): bool =
218218 except CatchableError: Profile(status: psFailed)
219219 result = true
220220
221-proc avatarFor*(actor: string): string =
221+proc avatarFor*(actor: string, alsoKnownAs = ""): string =
222222 ## The face to paint for this identity, or "" where there is not one yet.
223223 ## A lookup and never a fetch: this is called from the render path.
224- if actor.len == 0: return ""
225- let p = cache.getOrDefault(actor)
226- if p.status == psReady: p.avatar else: ""
224+ ##
225+ ## Two names because a person has two here, and which one the screen uses
226+ ## changes underneath them. Before WHO answers, a handle-shaped nick is its
227+ ## own actor; afterwards the actor is the DID. Without the fallback the
228+ ## face would appear on the first message, vanish the moment WHO arrived,
229+ ## and come back when the second fetch landed.
230+ if actor.len > 0:
231+ let p = cache.getOrDefault(actor)
232+ if p.status == psReady and p.avatar.len > 0: return p.avatar
233+ if alsoKnownAs.len > 0 and alsoKnownAs != actor:
234+ let q = cache.getOrDefault(alsoKnownAs)
235+ if q.status == psReady: return q.avatar
236+ ""
237+
238+proc setProfileForTest*(actor, avatar: string) =
239+ ## A profile that has arrived, without a network. For the tests that are
240+ ## about what the screen does with one.
241+ cache[actor] = Profile(status: psReady, avatar: avatar, handle: actor)
227242
228243 proc forgetProfiles*() =
229244 ## For a test that wants a known starting point.
@@ -218,12 +218,27 @@ proc collect*(): bool =
218 except CatchableError: Profile(status: psFailed)218 except CatchableError: Profile(status: psFailed)
219 result = true219 result = true
220 220
221-proc avatarFor*(actor: string): string =221+proc avatarFor*(actor: string, alsoKnownAs = ""): string =
222 ## The face to paint for this identity, or "" where there is not one yet.222 ## The face to paint for this identity, or "" where there is not one yet.
223 ## A lookup and never a fetch: this is called from the render path.223 ## A lookup and never a fetch: this is called from the render path.
224- if actor.len == 0: return ""224+ ##
225- let p = cache.getOrDefault(actor)225+ ## Two names because a person has two here, and which one the screen uses
226- if p.status == psReady: p.avatar else: ""226+ ## changes underneath them. Before WHO answers, a handle-shaped nick is its
227+ ## own actor; afterwards the actor is the DID. Without the fallback the
228+ ## face would appear on the first message, vanish the moment WHO arrived,
229+ ## and come back when the second fetch landed.
230+ if actor.len > 0:
231+ let p = cache.getOrDefault(actor)
232+ if p.status == psReady and p.avatar.len > 0: return p.avatar
233+ if alsoKnownAs.len > 0 and alsoKnownAs != actor:
234+ let q = cache.getOrDefault(alsoKnownAs)
235+ if q.status == psReady: return q.avatar
236+ ""
237+
238+proc setProfileForTest*(actor, avatar: string) =
239+ ## A profile that has arrived, without a network. For the tests that are
240+ ## about what the screen does with one.
241+ cache[actor] = Profile(status: psReady, avatar: avatar, handle: actor)
227 242
228 proc forgetProfiles*() =243 proc forgetProfiles*() =
229 ## For a test that wants a known starting point.244 ## For a test that wants a known starting point.
modified nim/src/frq/reducer.nim +22 -0
@@ -42,6 +42,10 @@ proc rememberRooms(force = false)
4242 ## Declared here because `openRoom` is above it and calls it — the file is
4343 ## ordered by what the reader does, not by what calls what.
4444
45+proc wantFace(m: Message)
46+ ## And this because `sendDraft` is: our own line wants a face as much as
47+ ## anybody's.
48+
4549 proc send(line: string) =
4650 trace("out", line)
4751 tr.send(line)
@@ -297,6 +301,7 @@ proc sendDraft() =
297301 localId: "local-" & $r.messages.len, pending: true)
298302 m.imageUrl = app.attachment.url
299303 r.messages.add m
304+ wantFace(m)
300305 app.rooms[app.current] = r.markRead
301306 app.draft = ""
302307 app.attachment = Attachment()
@@ -575,6 +580,22 @@ proc dispatch*(event: JsonNode) =
575580 # the tree the renderer gets is built after every line that had arrived when
576581 # it asked.
577582
583+proc wantFace(m: Message) =
584+ ## Ask for the face of whoever said this, by the same name the screen will
585+ ## look it up under.
586+ ##
587+ ## That last part is the whole of it. A profile is cached under the actor
588+ ## `actorFor` returns, and before WHO has answered — or for somebody in a
589+ ## replayed backlog who is no longer in the room to be answered about —
590+ ## that is the handle rather than the DID. Asking only when a DID turned up
591+ ## meant the handle was never asked for, so a face appeared only once the
592+ ## reader opened the profile by hand, which asks under the same name.
593+ if m.system or m.frm.len == 0: return
594+ let did = if m.account.len > 0: m.account
595+ else: app.dids.getOrDefault(m.frm, "")
596+ let actor = actorFor(did, m.frm)
597+ if actor.len > 0: want(actor)
598+
578599 proc note(room: string, m: Message) =
579600 app.rooms.ensureRoom(room)
580601 var r = app.rooms[room]
@@ -582,6 +603,7 @@ proc note(room: string, m: Message) =
582603 r.messages.add m
583604 r.lastActivity = nowMs()
584605 app.rooms[room] = r.recount(app.formNick)
606+ wantFace(m)
585607
586608 proc drain*() =
587609 # The browser handoff, before the socket: a sign-in that just landed should
@@ -42,6 +42,10 @@ proc rememberRooms(force = false)
42 ## Declared here because `openRoom` is above it and calls it — the file is42 ## Declared here because `openRoom` is above it and calls it — the file is
43 ## ordered by what the reader does, not by what calls what.43 ## ordered by what the reader does, not by what calls what.
44 44
45+proc wantFace(m: Message)
46+ ## And this because `sendDraft` is: our own line wants a face as much as
47+ ## anybody's.
48+
45 proc send(line: string) =49 proc send(line: string) =
46 trace("out", line)50 trace("out", line)
47 tr.send(line)51 tr.send(line)
@@ -297,6 +301,7 @@ proc sendDraft() =
297 localId: "local-" & $r.messages.len, pending: true)301 localId: "local-" & $r.messages.len, pending: true)
298 m.imageUrl = app.attachment.url302 m.imageUrl = app.attachment.url
299 r.messages.add m303 r.messages.add m
304+ wantFace(m)
300 app.rooms[app.current] = r.markRead305 app.rooms[app.current] = r.markRead
301 app.draft = ""306 app.draft = ""
302 app.attachment = Attachment()307 app.attachment = Attachment()
@@ -575,6 +580,22 @@ proc dispatch*(event: JsonNode) =
575 # the tree the renderer gets is built after every line that had arrived when580 # the tree the renderer gets is built after every line that had arrived when
576 # it asked.581 # it asked.
577 582
583+proc wantFace(m: Message) =
584+ ## Ask for the face of whoever said this, by the same name the screen will
585+ ## look it up under.
586+ ##
587+ ## That last part is the whole of it. A profile is cached under the actor
588+ ## `actorFor` returns, and before WHO has answered — or for somebody in a
589+ ## replayed backlog who is no longer in the room to be answered about —
590+ ## that is the handle rather than the DID. Asking only when a DID turned up
591+ ## meant the handle was never asked for, so a face appeared only once the
592+ ## reader opened the profile by hand, which asks under the same name.
593+ if m.system or m.frm.len == 0: return
594+ let did = if m.account.len > 0: m.account
595+ else: app.dids.getOrDefault(m.frm, "")
596+ let actor = actorFor(did, m.frm)
597+ if actor.len > 0: want(actor)
598+
578 proc note(room: string, m: Message) =599 proc note(room: string, m: Message) =
579 app.rooms.ensureRoom(room)600 app.rooms.ensureRoom(room)
580 var r = app.rooms[room]601 var r = app.rooms[room]
@@ -582,6 +603,7 @@ proc note(room: string, m: Message) =
582 r.messages.add m603 r.messages.add m
583 r.lastActivity = nowMs()604 r.lastActivity = nowMs()
584 app.rooms[room] = r.recount(app.formNick)605 app.rooms[room] = r.recount(app.formNick)
606+ wantFace(m)
585 607
586 proc drain*() =608 proc drain*() =
587 # The browser handoff, before the socket: a sign-in that just landed should609 # The browser handoff, before the socket: a sign-in that just landed should
modified nim/src/frq/screens/chat.nim +2 -1
@@ -151,7 +151,8 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
151151 # From the profile cache rather than the message: a face belongs to a
152152 # person, not to a line they said, and a profile that arrives after
153153 # their first message should appear on all of them.
154- avatar(avatarFor(senderActor), m.frm, size = faceSize, onClick = open),
154+ avatar(avatarFor(senderActor, m.frm), m.frm, size = faceSize,
155+ onClick = open),
155156 n("button", %*{"label": m.frm, "kind": "plain", "onClick": open}))
156157 if m.at > 0:
157158 row.children.add dimLabel(clockTime(m.at))
@@ -151,7 +151,8 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
151 # From the profile cache rather than the message: a face belongs to a151 # From the profile cache rather than the message: a face belongs to a
152 # person, not to a line they said, and a profile that arrives after152 # person, not to a line they said, and a profile that arrives after
153 # their first message should appear on all of them.153 # their first message should appear on all of them.
154- avatar(avatarFor(senderActor), m.frm, size = faceSize, onClick = open),154+ avatar(avatarFor(senderActor, m.frm), m.frm, size = faceSize,
155+ onClick = open),
155 n("button", %*{"label": m.frm, "kind": "plain", "onClick": open}))156 n("button", %*{"label": m.frm, "kind": "plain", "onClick": open}))
156 if m.at > 0:157 if m.at > 0:
157 row.children.add dimLabel(clockTime(m.at))158 row.children.add dimLabel(clockTime(m.at))
modified nim/tests/tsession.nim +34 -0
@@ -173,6 +173,40 @@ suite "faces":
173173 "nandi.uk H :0 did:plc:ngokl2gnmpbvuvrfckja3g7p")
174174 check avatarFor("did:plc:ngokl2gnmpbvuvrfckja3g7p") == ""
175175
176+ test "a handle-shaped nick is asked for as soon as it speaks":
177+ # Before WHO has answered, the screen looks a face up under the handle —
178+ # so that is the name it has to be asked for under. Asking only when a
179+ # DID turned up is why a face appeared only after opening the profile by
180+ # hand, which asks under the handle.
181+ say(":nandi.uk!u@freeq/plc/ngokl2gn PRIVMSG #freeq :hello")
182+ check entry("nandi.uk")[1]
183+ check entry("nandi.uk")[0].status == psLoading
184+
185+ test "and once WHO has answered, under the DID the screen then uses":
186+ say(":irc.freeq.at 352 alice #freeq ~u freeq/plc/ngokl2gn irc.freeq.at " &
187+ "nandi.uk H :0 did:plc:ngokl2gnmpbvuvrfckja3g7p",
188+ ":nandi.uk!u@freeq/plc/ngokl2gn PRIVMSG #freeq :hello")
189+ check entry("did:plc:ngokl2gnmpbvuvrfckja3g7p")[1]
190+
191+ test "a guest nick is nobody to look up":
192+ # `sleek5209` is not a handle and has no DID; there is no profile behind
193+ # it and a request for one can only fail.
194+ say(":sleek5209!u@freeq/guest PRIVMSG #freeq :hello")
195+ check not entry("sleek5209")[1]
196+
197+ test "and a system line is not somebody speaking":
198+ say(":alice!a@h JOIN #freeq")
199+ check not entry("*")[1]
200+
201+ test "the face does not blink when WHO changes which name is the actor":
202+ # The handle's profile is what is cached when the first message lands;
203+ # the actor becomes the DID a moment later. Without the fallback there is
204+ # a hole between the two.
205+ say(":nandi.uk!u@freeq/plc/ngokl2gn PRIVMSG #freeq :hello")
206+ setProfileForTest("nandi.uk", "https://cdn/face.png")
207+ check avatarFor("did:plc:ngokl2gnmpbvuvrfckja3g7p", "nandi.uk") ==
208+ "https://cdn/face.png"
209+
176210 test "an agent is never asked about":
177211 # `did:key:` has no Bluesky profile, so a request for one can only 400.
178212 say(":irc.freeq.at 352 alice #freeq ~u freeq/key/z6Mkp5we irc.freeq.at " &
@@ -173,6 +173,40 @@ suite "faces":
173 "nandi.uk H :0 did:plc:ngokl2gnmpbvuvrfckja3g7p")173 "nandi.uk H :0 did:plc:ngokl2gnmpbvuvrfckja3g7p")
174 check avatarFor("did:plc:ngokl2gnmpbvuvrfckja3g7p") == ""174 check avatarFor("did:plc:ngokl2gnmpbvuvrfckja3g7p") == ""
175 175
176+ test "a handle-shaped nick is asked for as soon as it speaks":
177+ # Before WHO has answered, the screen looks a face up under the handle —
178+ # so that is the name it has to be asked for under. Asking only when a
179+ # DID turned up is why a face appeared only after opening the profile by
180+ # hand, which asks under the handle.
181+ say(":nandi.uk!u@freeq/plc/ngokl2gn PRIVMSG #freeq :hello")
182+ check entry("nandi.uk")[1]
183+ check entry("nandi.uk")[0].status == psLoading
184+
185+ test "and once WHO has answered, under the DID the screen then uses":
186+ say(":irc.freeq.at 352 alice #freeq ~u freeq/plc/ngokl2gn irc.freeq.at " &
187+ "nandi.uk H :0 did:plc:ngokl2gnmpbvuvrfckja3g7p",
188+ ":nandi.uk!u@freeq/plc/ngokl2gn PRIVMSG #freeq :hello")
189+ check entry("did:plc:ngokl2gnmpbvuvrfckja3g7p")[1]
190+
191+ test "a guest nick is nobody to look up":
192+ # `sleek5209` is not a handle and has no DID; there is no profile behind
193+ # it and a request for one can only fail.
194+ say(":sleek5209!u@freeq/guest PRIVMSG #freeq :hello")
195+ check not entry("sleek5209")[1]
196+
197+ test "and a system line is not somebody speaking":
198+ say(":alice!a@h JOIN #freeq")
199+ check not entry("*")[1]
200+
201+ test "the face does not blink when WHO changes which name is the actor":
202+ # The handle's profile is what is cached when the first message lands;
203+ # the actor becomes the DID a moment later. Without the fallback there is
204+ # a hole between the two.
205+ say(":nandi.uk!u@freeq/plc/ngokl2gn PRIVMSG #freeq :hello")
206+ setProfileForTest("nandi.uk", "https://cdn/face.png")
207+ check avatarFor("did:plc:ngokl2gnmpbvuvrfckja3g7p", "nandi.uk") ==
208+ "https://cdn/face.png"
209+
176 test "an agent is never asked about":210 test "an agent is never asked about":
177 # `did:key:` has no Bluesky profile, so a request for one can only 400.211 # `did:key:` has no Bluesky profile, so a request for one can only 400.
178 say(":irc.freeq.at 352 alice #freeq ~u freeq/key/z6Mkp5we irc.freeq.at " &212 say(":irc.freeq.at 352 alice #freeq ~u freeq/key/z6Mkp5we irc.freeq.at " &