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>
07a23ef parent: ecb440f modified
nim/src/frq/profile.nim +19 -4 | @@ -218,12 +218,27 @@ proc collect*(): bool = | ||
| 218 | 218 | except CatchableError: Profile(status: psFailed) |
| 219 | 219 | result = true |
| 220 | 220 | |
| 221 | -proc avatarFor*(actor: string): string = | |
| 221 | +proc avatarFor*(actor: string, alsoKnownAs = ""): string = | |
| 222 | 222 | ## The face to paint for this identity, or "" where there is not one yet. |
| 223 | 223 | ## 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) | |
| 227 | 242 | |
| 228 | 243 | proc forgetProfiles*() = |
| 229 | 244 | ## 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 = true | 219 | 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) | ||
| 42 | 42 | ## Declared here because `openRoom` is above it and calls it — the file is |
| 43 | 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 | 49 | proc send(line: string) = |
| 46 | 50 | trace("out", line) |
| 47 | 51 | tr.send(line) |
| @@ -297,6 +301,7 @@ proc sendDraft() = | ||
| 297 | 301 | localId: "local-" & $r.messages.len, pending: true) |
| 298 | 302 | m.imageUrl = app.attachment.url |
| 299 | 303 | r.messages.add m |
| 304 | + wantFace(m) | |
| 300 | 305 | app.rooms[app.current] = r.markRead |
| 301 | 306 | app.draft = "" |
| 302 | 307 | app.attachment = Attachment() |
| @@ -575,6 +580,22 @@ proc dispatch*(event: JsonNode) = | ||
| 575 | 580 | # the tree the renderer gets is built after every line that had arrived when |
| 576 | 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 | 599 | proc note(room: string, m: Message) = |
| 579 | 600 | app.rooms.ensureRoom(room) |
| 580 | 601 | var r = app.rooms[room] |
| @@ -582,6 +603,7 @@ proc note(room: string, m: Message) = | ||
| 582 | 603 | r.messages.add m |
| 583 | 604 | r.lastActivity = nowMs() |
| 584 | 605 | app.rooms[room] = r.recount(app.formNick) |
| 606 | + wantFace(m) | |
| 585 | 607 | |
| 586 | 608 | proc drain*() = |
| 587 | 609 | # 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 is | 42 | ## 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.url | 302 | m.imageUrl = app.attachment.url |
| 299 | r.messages.add m | 303 | r.messages.add m |
| 304 | + wantFace(m) | ||
| 300 | app.rooms[app.current] = r.markRead | 305 | 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 when | 580 | # 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 m | 603 | 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 should | 609 | # 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 = | ||
| 151 | 151 | # From the profile cache rather than the message: a face belongs to a |
| 152 | 152 | # person, not to a line they said, and a profile that arrives after |
| 153 | 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 | 156 | n("button", %*{"label": m.frm, "kind": "plain", "onClick": open})) |
| 156 | 157 | if m.at > 0: |
| 157 | 158 | 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 a | 151 | # 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 after | 152 | # 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": | ||
| 173 | 173 | "nandi.uk H :0 did:plc:ngokl2gnmpbvuvrfckja3g7p") |
| 174 | 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 | 210 | test "an agent is never asked about": |
| 177 | 211 | # `did:key:` has no Bluesky profile, so a request for one can only 400. |
| 178 | 212 | 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 " & |