nandi/frqpublic Fork 0
9bb81a1
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 that opens someone

`frq.profile` was deleted with the ClojureDart two commits ago and is back,
recovered from that commit and ported rather than reinvented — the rules in it
are worth keeping verbatim. A face that opens nothing is worse than no face.

Pressing a face or a name opens a panel: the picture at a size worth looking
at, the display name and handle, the DID, the bio and counts Bluesky holds,
and a link through to bsky.app. The name is a button with no chrome, because
it sits in the middle of a line and must not look like a control while still
being the thing a reader actually presses.

Three rules came across with it. `actorFor` prefers the DID from the
`account` tag over the nick, because the DID is the identity and a nick is
whatever someone chose today — and the tag is now carried on every inbound
message, which it was not before, so 50 of the 114 lines in a #test backlog
resolve to a real person. A handle-shaped nick stands in where there is no
DID, and a bare `sleek5209` is a guest with nothing to look up: the panel says
so rather than spinning at something that will never land. And `thumbnailUrl`
rewrites the CDN URL to ask for the size we paint, so a 170KB portrait is not
downloaded to draw at 24 points.

The fetch seam is gone. The Clojure has one because the two compilers
disagreed about HTTP; Nim has one client. It blocks, in the reducer, which is
the one place in this program that can afford it — the render path must not
and the socket threads have their own work, and the reader pressed a face and
is already waiting.

`truncate` had the byte-versus-rune bug for the third time in this port, and
a bio is exactly where emoji live. Runes now, with a test.

333 Nim tests and 26 layout tests. Nothing in the app is state without a
screen any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T07:17:51-07:00 Browse files
9bb81a1 parent: 98d6135
modified flutter/lib/nim_renderer.dart +10 -0
@@ -376,6 +376,16 @@ class _NimAppState extends State<NimApp> {
376376 return FilledButton(
377377 onPressed: () => _send(onClick), child: label);
378378 }
379+ // A sender's name: a way in to who someone is, but it sits in the
380+ // middle of a line and must not look like a control. Text that
381+ // takes a press, with no chrome at all.
382+ if (kind == 'plain') {
383+ return InkWell(
384+ onTap: () => _send(onClick),
385+ child: Text(n.prop('label', ''),
386+ style: _style(t.textBody, t.onBg)),
387+ );
388+ }
379389 if (kind == 'destructive') {
380390 return FilledButton(
381391 style: FilledButton.styleFrom(
@@ -376,6 +376,16 @@ class _NimAppState extends State<NimApp> {
376 return FilledButton(376 return FilledButton(
377 onPressed: () => _send(onClick), child: label);377 onPressed: () => _send(onClick), child: label);
378 }378 }
379+ // A sender's name: a way in to who someone is, but it sits in the
380+ // middle of a line and must not look like a control. Text that
381+ // takes a press, with no chrome at all.
382+ if (kind == 'plain') {
383+ return InkWell(
384+ onTap: () => _send(onClick),
385+ child: Text(n.prop('label', ''),
386+ style: _style(t.textBody, t.onBg)),
387+ );
388+ }
379 if (kind == 'destructive') {389 if (kind == 'destructive') {
380 return FilledButton(390 return FilledButton(
381 style: FilledButton.styleFrom(391 style: FilledButton.styleFrom(
modified flutter/test/nim_layout_test.dart +9 -0
@@ -130,6 +130,15 @@ void main() {
130130 expectLaidOut(tester, 'chat with the lightbox');
131131 });
132132
133+ testWidgets('lays out with a profile open', (tester) async {
134+ core.demoUi();
135+ // A guest: no identity to fetch, so the panel says so rather than
136+ // spinning — and it lays out without a network.
137+ core.dispatch('profile.open:alice:');
138+ await layOut(tester, sizes['phone']!);
139+ expectLaidOut(tester, 'chat with a guest profile');
140+ });
141+
133142 testWidgets('lays out when scrolled off the present', (tester) async {
134143 core.demoUi();
135144 core.dispatch('jump.present');
@@ -130,6 +130,15 @@ void main() {
130 expectLaidOut(tester, 'chat with the lightbox');130 expectLaidOut(tester, 'chat with the lightbox');
131 });131 });
132 132
133+ testWidgets('lays out with a profile open', (tester) async {
134+ core.demoUi();
135+ // A guest: no identity to fetch, so the panel says so rather than
136+ // spinning — and it lays out without a network.
137+ core.dispatch('profile.open:alice:');
138+ await layOut(tester, sizes['phone']!);
139+ expectLaidOut(tester, 'chat with a guest profile');
140+ });
141+
133 testWidgets('lays out when scrolled off the present', (tester) async {142 testWidgets('lays out when scrolled off the present', (tester) async {
134 core.demoUi();143 core.demoUi();
135 core.dispatch('jump.present');144 core.dispatch('jump.present');
modified nim/README.md +8 -8
@@ -70,16 +70,16 @@ just build lib # libfrqcore.so into build/nim
7070
7171 ## What is not here
7272
73-`frq.profile` and `frq.replies` were never ported and went with the
74-ClojureDart rather than moving: a Bluesky profile behind a nick, and asking
75-freeq what a collapsed msgid was. Neither had a screen in this app to appear
76-on.
73+`frq.replies` was never ported and went with the ClojureDart: asking freeq
74+what a collapsed msgid was, for a reply pointing at a line that arrived
75+already collapsed. It had no screen in this app to appear on, and the chip
76+says "an earlier message" where that happens.
7777
78-The profile card is state without a screen: `profileViewing` is moved by
79-nothing and rendered by nothing, and `frq.profile` was never ported.
78+`frq.profile` came back — recovered from the commit that deleted it and
79+ported, since a face that opens nothing is worse than no face.
8080
81-The emoji picker, the overview strip and the lightbox used to be in that list.
82-They have screens now — the picker under the message it is for, the overview
81+The emoji picker, the overview strip, the lightbox and the profile card all
82+have screens — the picker under the message it is for, the overview
8383 as a pane above the compose bar, the lightbox as a panel over the
8484 conversation — so the 🙂 chip, the Overview toggle and clicking a picture all
8585 do what they look like they do.
@@ -70,16 +70,16 @@ just build lib # libfrqcore.so into build/nim
70 70
71 ## What is not here71 ## What is not here
72 72
73-`frq.profile` and `frq.replies` were never ported and went with the73+`frq.replies` was never ported and went with the ClojureDart: asking freeq
74-ClojureDart rather than moving: a Bluesky profile behind a nick, and asking74+what a collapsed msgid was, for a reply pointing at a line that arrived
75-freeq what a collapsed msgid was. Neither had a screen in this app to appear75+already collapsed. It had no screen in this app to appear on, and the chip
76-on.76+says "an earlier message" where that happens.
77 77
78-The profile card is state without a screen: `profileViewing` is moved by78+`frq.profile` came back — recovered from the commit that deleted it and
79-nothing and rendered by nothing, and `frq.profile` was never ported.79+ported, since a face that opens nothing is worse than no face.
80 80
81-The emoji picker, the overview strip and the lightbox used to be in that list.81+The emoji picker, the overview strip, the lightbox and the profile card all
82-They have screens now — the picker under the message it is for, the overview82+have screens — the picker under the message it is for, the overview
83 as a pane above the compose bar, the lightbox as a panel over the83 as a pane above the compose bar, the lightbox as a panel over the
84 conversation — so the 🙂 chip, the Overview toggle and clicking a picture all84 conversation — so the 🙂 chip, the Overview toggle and clicking a picture all
85 do what they look like they do.85 do what they look like they do.
modified nim/src/frq/atproto.nim +10 -0
@@ -106,6 +106,16 @@ proc pdsFor*(did: string): string =
106106 if ep.len > 0: return ep
107107 raise newException(AtprotoError, "No PDS endpoint for " & did)
108108
109+proc getProfile*(actor: string): JsonNode =
110+ ## `app.bsky.actor.getProfile` for a DID or a handle.
111+ ##
112+ ## The actor goes in unescaped, as it always has: a handle is a domain name
113+ ## and a DID is `did:` and base32, and neither carries a character a query
114+ ## string would mind.
115+ getJson("https://" & directoryHost &
116+ "/xrpc/app.bsky.actor.getProfile?actor=" & actor,
117+ "Could not look up " & actor)
118+
109119 proc createSession*(handle, password: string): Session =
110120 ## Sign in to the PDS with an app password.
111121 ##
@@ -106,6 +106,16 @@ proc pdsFor*(did: string): string =
106 if ep.len > 0: return ep106 if ep.len > 0: return ep
107 raise newException(AtprotoError, "No PDS endpoint for " & did)107 raise newException(AtprotoError, "No PDS endpoint for " & did)
108 108
109+proc getProfile*(actor: string): JsonNode =
110+ ## `app.bsky.actor.getProfile` for a DID or a handle.
111+ ##
112+ ## The actor goes in unescaped, as it always has: a handle is a domain name
113+ ## and a DID is `did:` and base32, and neither carries a character a query
114+ ## string would mind.
115+ getJson("https://" & directoryHost &
116+ "/xrpc/app.bsky.actor.getProfile?actor=" & actor,
117+ "Could not look up " & actor)
118+
109 proc createSession*(handle, password: string): Session =119 proc createSession*(handle, password: string): Session =
110 ## Sign in to the PDS with an app password.120 ## Sign in to the PDS with an app password.
111 ##121 ##
modified nim/src/frq/cells.nim +0 -1
@@ -143,7 +143,6 @@ type
143143
144144 imagePicker*: string
145145 profileViewing*: ProfileView
146- profileTick*: int
147146
148147 # The window, polled from the host.
149148 windowWidth*, windowHeight*: int
@@ -143,7 +143,6 @@ type
143 143
144 imagePicker*: string144 imagePicker*: string
145 profileViewing*: ProfileView145 profileViewing*: ProfileView
146- profileTick*: int
147 146
148 # The window, polled from the host.147 # The window, polled from the host.
149 windowWidth*, windowHeight*: int148 windowWidth*, windowHeight*: int
modified nim/src/frq/model.nim +5 -0
@@ -37,6 +37,11 @@ type
3737 replyTo*: string ## the id this answers, "" for a line answering none
3838 reactions*: seq[Reaction]
3939 imageUrl*: string ## the first picture link in the text, "" for none
40+ account*: string
41+ ## The sender's DID, off the `account` tag. The only identity a client
42+ ## is given: a nick is whatever someone chose today, and the hostmask
43+ ## carries eight characters of a DID, too few to resolve.
44+ avatar*: string ## their thumbnail, once a profile has been fetched
4045 pending*: bool ## sent, not yet echoed
4146 room*: string
4247 ## Which room this was said in. Empty on a stored message — a room
@@ -37,6 +37,11 @@ type
37 replyTo*: string ## the id this answers, "" for a line answering none37 replyTo*: string ## the id this answers, "" for a line answering none
38 reactions*: seq[Reaction]38 reactions*: seq[Reaction]
39 imageUrl*: string ## the first picture link in the text, "" for none39 imageUrl*: string ## the first picture link in the text, "" for none
40+ account*: string
41+ ## The sender's DID, off the `account` tag. The only identity a client
42+ ## is given: a nick is whatever someone chose today, and the hostmask
43+ ## carries eight characters of a DID, too few to resolve.
44+ avatar*: string ## their thumbnail, once a profile has been fetched
40 pending*: bool ## sent, not yet echoed45 pending*: bool ## sent, not yet echoed
41 room*: string46 room*: string
42 ## Which room this was said in. Empty on a stored message — a room47 ## Which room this was said in. Empty on a stored message — a room
added nim/src/frq/profile.nim +125 -0
new file mode 100644
@@ -0,0 +1,125 @@
1+## Who someone is, behind the nick on a line.
2+##
3+## From `common/frq/profile.cljc`. sleek's peer profile modal, as a panel:
4+## the picture at a size worth looking at, the display name and handle, the
5+## DID, whatever bio and counts Bluesky holds, and a way through to their
6+## profile on the web.
7+##
8+## One fetch per person, kept for the run — a profile is looked at repeatedly
9+## and changes on nobody's timescale. Guests have no identity to fetch, so for
10+## them the panel is the nick and a line saying so, which is the honest answer
11+## rather than a spinner that never lands.
12+##
13+## One gesture opens it: a press. A pointer resting on a face used to open it
14+## too, which made the card something that could arrive without being asked
15+## for — crossing a column of faces on the way to the scrollbar flickered one
16+## open per row.
17+##
18+## The Clojure has a fetch seam here because the two compilers disagreed about
19+## HTTP. Nim has one client, so the seam is gone and `fetch` simply asks.
20+
21+import std/[json, re, strutils, tables]
22+from std/unicode import runeLen, runeSubStr
23+import frq/[atproto, trace]
24+
25+type
26+ ProfileStatus* = enum
27+ psLoading = "loading", psReady = "ready", psFailed = "failed"
28+
29+ Profile* = object
30+ status*: ProfileStatus
31+ did*, avatar*, handle*, displayName*, description*: string
32+ followers*, follows*, posts*: int
33+
34+var cache: Table[string, Profile]
35+
36+let handlePattern = re"^[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9][A-Za-z0-9-]*)*\.[A-Za-z]{2,}$"
37+
38+proc isHandle*(nick: string): bool =
39+ ## Whether this nick is an AT Protocol handle, and so worth a lookup.
40+ nick.len > 0 and nick.match(handlePattern)
41+
42+proc actorFor*(did, nick: string): string =
43+ ## The identity to look a profile up by, or "" when there is none.
44+ ##
45+ ## A DID from the message's `account` tag when the server sent one — it is
46+ ## the identity itself, and holds whatever the nick happens to be today.
47+ ## Otherwise the nick, but only when it is handle-shaped: freeq gives an
48+ ## authenticated user their handle by default, while `sleek5209` is a guest
49+ ## with no profile.
50+ if did.startsWith("did:"): did
51+ elif isHandle(nick): nick
52+ else: ""
53+
54+proc thumbnailUrl*(url: string): string =
55+ ## The CDN's full-size avatar URL as a 128-pixel PNG.
56+ ##
57+ ## Asking for the size we paint keeps a 170KB portrait from being downloaded
58+ ## to draw at 24 points.
59+ if url.len == 0: return ""
60+ var u = url.replace("/img/avatar/plain/", "/img/avatar_thumbnail/plain/")
61+ # Drop a trailing @jpeg/@png before adding our own.
62+ let at = u.rfind('@')
63+ if at > 0 and u[at + 1 .. ^1].allCharsInSet({'a' .. 'z'}):
64+ u = u[0 ..< at]
65+ u & "@png"
66+
67+proc parseProfile*(body: JsonNode): Profile =
68+ ## The fields the panel paints, out of an `app.bsky.actor.getProfile` body.
69+ Profile(status: psReady,
70+ did: body{"did"}.getStr(),
71+ avatar: thumbnailUrl(body{"avatar"}.getStr()),
72+ handle: body{"handle"}.getStr(),
73+ displayName: body{"displayName"}.getStr().strip(),
74+ description: body{"description"}.getStr().strip(),
75+ followers: body{"followersCount"}.getInt(),
76+ follows: body{"followsCount"}.getInt(),
77+ posts: body{"postsCount"}.getInt())
78+
79+proc entry*(actor: string): (Profile, bool) =
80+ ## What is known about this person right now, and whether anything is.
81+ if cache.hasKey(actor): (cache[actor], true)
82+ else: (Profile(), false)
83+
84+proc fetch*(actor: string) =
85+ ## Ensure this person's profile is on its way.
86+ ##
87+ ## Blocking, and called from the reducer — which is the one place in this
88+ ## program that can afford it: the render path must not, and the socket
89+ ## threads have their own work. A profile is opened by a press, so the
90+ ## reader is already waiting.
91+ if actor.len == 0 or cache.hasKey(actor): return
92+ cache[actor] = Profile(status: psLoading)
93+ try:
94+ let body = getProfile(actor)
95+ cache[actor] = if body{"did"}.getStr().len > 0: parseProfile(body)
96+ else: Profile(status: psFailed)
97+ except CatchableError as e:
98+ trace("profile", "could not fetch " & actor & ": " & e.msg)
99+ cache[actor] = Profile(status: psFailed)
100+
101+proc forgetProfiles*() =
102+ ## For a test that wants a known starting point.
103+ cache.clear()
104+
105+proc webUrl*(p: Profile): string =
106+ ## Their profile on the web, by handle where there is one and DID otherwise.
107+ let who = if p.handle.strip().len > 0: p.handle.strip() else: p.did
108+ if who.len == 0: "" else: "https://bsky.app/profile/" & who.strip(chars = {'@'})
109+
110+proc statsLine*(p: Profile): string =
111+ ## "12 followers · 34 following · 56 posts", or "" when none are known.
112+ var parts: seq[string]
113+ if p.followers > 0: parts.add $p.followers & " followers"
114+ if p.follows > 0: parts.add $p.follows & " following"
115+ if p.posts > 0: parts.add $p.posts & " posts"
116+ parts.join(" · ")
117+
118+proc truncate*(s: string, max: int): string =
119+ ## A bio cut to `max` characters, keeping its line breaks — the height of a
120+ ## multi-line bio is part of what it says.
121+ # Runes, not bytes: a bio is exactly where emoji live, and a byte slice
122+ # lands inside one and makes mojibake where an ellipsis was wanted. The
123+ # third time this has come up in this port.
124+ let t = s.strip()
125+ if t.runeLen <= max: t else: t.runeSubStr(0, max - 1) & ""
new file mode 100644
@@ -0,0 +1,125 @@
1+## Who someone is, behind the nick on a line.
2+##
3+## From `common/frq/profile.cljc`. sleek's peer profile modal, as a panel:
4+## the picture at a size worth looking at, the display name and handle, the
5+## DID, whatever bio and counts Bluesky holds, and a way through to their
6+## profile on the web.
7+##
8+## One fetch per person, kept for the run — a profile is looked at repeatedly
9+## and changes on nobody's timescale. Guests have no identity to fetch, so for
10+## them the panel is the nick and a line saying so, which is the honest answer
11+## rather than a spinner that never lands.
12+##
13+## One gesture opens it: a press. A pointer resting on a face used to open it
14+## too, which made the card something that could arrive without being asked
15+## for — crossing a column of faces on the way to the scrollbar flickered one
16+## open per row.
17+##
18+## The Clojure has a fetch seam here because the two compilers disagreed about
19+## HTTP. Nim has one client, so the seam is gone and `fetch` simply asks.
20+
21+import std/[json, re, strutils, tables]
22+from std/unicode import runeLen, runeSubStr
23+import frq/[atproto, trace]
24+
25+type
26+ ProfileStatus* = enum
27+ psLoading = "loading", psReady = "ready", psFailed = "failed"
28+
29+ Profile* = object
30+ status*: ProfileStatus
31+ did*, avatar*, handle*, displayName*, description*: string
32+ followers*, follows*, posts*: int
33+
34+var cache: Table[string, Profile]
35+
36+let handlePattern = re"^[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9][A-Za-z0-9-]*)*\.[A-Za-z]{2,}$"
37+
38+proc isHandle*(nick: string): bool =
39+ ## Whether this nick is an AT Protocol handle, and so worth a lookup.
40+ nick.len > 0 and nick.match(handlePattern)
41+
42+proc actorFor*(did, nick: string): string =
43+ ## The identity to look a profile up by, or "" when there is none.
44+ ##
45+ ## A DID from the message's `account` tag when the server sent one — it is
46+ ## the identity itself, and holds whatever the nick happens to be today.
47+ ## Otherwise the nick, but only when it is handle-shaped: freeq gives an
48+ ## authenticated user their handle by default, while `sleek5209` is a guest
49+ ## with no profile.
50+ if did.startsWith("did:"): did
51+ elif isHandle(nick): nick
52+ else: ""
53+
54+proc thumbnailUrl*(url: string): string =
55+ ## The CDN's full-size avatar URL as a 128-pixel PNG.
56+ ##
57+ ## Asking for the size we paint keeps a 170KB portrait from being downloaded
58+ ## to draw at 24 points.
59+ if url.len == 0: return ""
60+ var u = url.replace("/img/avatar/plain/", "/img/avatar_thumbnail/plain/")
61+ # Drop a trailing @jpeg/@png before adding our own.
62+ let at = u.rfind('@')
63+ if at > 0 and u[at + 1 .. ^1].allCharsInSet({'a' .. 'z'}):
64+ u = u[0 ..< at]
65+ u & "@png"
66+
67+proc parseProfile*(body: JsonNode): Profile =
68+ ## The fields the panel paints, out of an `app.bsky.actor.getProfile` body.
69+ Profile(status: psReady,
70+ did: body{"did"}.getStr(),
71+ avatar: thumbnailUrl(body{"avatar"}.getStr()),
72+ handle: body{"handle"}.getStr(),
73+ displayName: body{"displayName"}.getStr().strip(),
74+ description: body{"description"}.getStr().strip(),
75+ followers: body{"followersCount"}.getInt(),
76+ follows: body{"followsCount"}.getInt(),
77+ posts: body{"postsCount"}.getInt())
78+
79+proc entry*(actor: string): (Profile, bool) =
80+ ## What is known about this person right now, and whether anything is.
81+ if cache.hasKey(actor): (cache[actor], true)
82+ else: (Profile(), false)
83+
84+proc fetch*(actor: string) =
85+ ## Ensure this person's profile is on its way.
86+ ##
87+ ## Blocking, and called from the reducer — which is the one place in this
88+ ## program that can afford it: the render path must not, and the socket
89+ ## threads have their own work. A profile is opened by a press, so the
90+ ## reader is already waiting.
91+ if actor.len == 0 or cache.hasKey(actor): return
92+ cache[actor] = Profile(status: psLoading)
93+ try:
94+ let body = getProfile(actor)
95+ cache[actor] = if body{"did"}.getStr().len > 0: parseProfile(body)
96+ else: Profile(status: psFailed)
97+ except CatchableError as e:
98+ trace("profile", "could not fetch " & actor & ": " & e.msg)
99+ cache[actor] = Profile(status: psFailed)
100+
101+proc forgetProfiles*() =
102+ ## For a test that wants a known starting point.
103+ cache.clear()
104+
105+proc webUrl*(p: Profile): string =
106+ ## Their profile on the web, by handle where there is one and DID otherwise.
107+ let who = if p.handle.strip().len > 0: p.handle.strip() else: p.did
108+ if who.len == 0: "" else: "https://bsky.app/profile/" & who.strip(chars = {'@'})
109+
110+proc statsLine*(p: Profile): string =
111+ ## "12 followers · 34 following · 56 posts", or "" when none are known.
112+ var parts: seq[string]
113+ if p.followers > 0: parts.add $p.followers & " followers"
114+ if p.follows > 0: parts.add $p.follows & " following"
115+ if p.posts > 0: parts.add $p.posts & " posts"
116+ parts.join(" · ")
117+
118+proc truncate*(s: string, max: int): string =
119+ ## A bio cut to `max` characters, keeping its line breaks — the height of a
120+ ## multi-line bio is part of what it says.
121+ # Runes, not bytes: a bio is exactly where emoji live, and a byte slice
122+ # lands inside one and makes mojibake where an ellipsis was wanted. The
123+ # third time this has come up in this port.
124+ let t = s.strip()
125+ if t.runeLen <= max: t else: t.runeSubStr(0, max - 1) & ""
modified nim/src/frq/reducer.nim +15 -1
@@ -14,7 +14,7 @@
1414 import std/[json, options, strutils, tables]
1515 import std/sets
1616 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,
17- atproto, handshake, textruns, members, msgsig]
17+ atproto, handshake, textruns, members, msgsig, profile]
1818 import frq/conn as tr
1919
2020 proc split2(id: string): (string, string) =
@@ -328,6 +328,19 @@ proc dispatch*(event: JsonNode) =
328328 openRoom(app.overviewReturn)
329329 app.overviewReturn = ""
330330
331+ of "profile.open":
332+ # `nick:actor`, and the actor may be empty — a guest has no identity to
333+ # fetch, and the panel says so rather than spinning.
334+ let (nick, who) = split2(arg)
335+ if nick.len > 0:
336+ app.profileViewing = ProfileView(has: true, nick: nick, actor: who)
337+ # Blocking, and this is the one place that can afford it: the render
338+ # path must not, the socket threads have their own work, and the reader
339+ # pressed a face and is already waiting.
340+ if who.len > 0: fetch(who)
341+
342+ of "profile.close": app.profileViewing = ProfileView()
343+
331344 of "lightbox":
332345 app.lightbox = Lightbox(has: true, url: arg, path: arg)
333346 of "lightbox.close": app.lightbox = Lightbox()
@@ -420,6 +433,7 @@ proc drain*() =
420433 # for the sender: the target is our own nick and is nobody's room.
421434 let room = if target.startsWith("#"): target else: who
422435 var m = Message(id: msgid, frm: who, text: p.params[^1], at: at)
436+ if p.hasAccount: m.account = p.account
423437 # The picture link out of the text, which is what draws the inline
424438 # preview. This was hardcoded to "" — assigning a field its own
425439 # default — so `firstImageUrl` was ported, tested and never called,
@@ -14,7 +14,7 @@
14 import std/[json, options, strutils, tables]14 import std/[json, options, strutils, tables]
15 import std/sets15 import std/sets
16 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,16 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,
17- atproto, handshake, textruns, members, msgsig]17+ atproto, handshake, textruns, members, msgsig, profile]
18 import frq/conn as tr18 import frq/conn as tr
19 19
20 proc split2(id: string): (string, string) =20 proc split2(id: string): (string, string) =
@@ -328,6 +328,19 @@ proc dispatch*(event: JsonNode) =
328 openRoom(app.overviewReturn)328 openRoom(app.overviewReturn)
329 app.overviewReturn = ""329 app.overviewReturn = ""
330 330
331+ of "profile.open":
332+ # `nick:actor`, and the actor may be empty — a guest has no identity to
333+ # fetch, and the panel says so rather than spinning.
334+ let (nick, who) = split2(arg)
335+ if nick.len > 0:
336+ app.profileViewing = ProfileView(has: true, nick: nick, actor: who)
337+ # Blocking, and this is the one place that can afford it: the render
338+ # path must not, the socket threads have their own work, and the reader
339+ # pressed a face and is already waiting.
340+ if who.len > 0: fetch(who)
341+
342+ of "profile.close": app.profileViewing = ProfileView()
343+
331 of "lightbox":344 of "lightbox":
332 app.lightbox = Lightbox(has: true, url: arg, path: arg)345 app.lightbox = Lightbox(has: true, url: arg, path: arg)
333 of "lightbox.close": app.lightbox = Lightbox()346 of "lightbox.close": app.lightbox = Lightbox()
@@ -420,6 +433,7 @@ proc drain*() =
420 # for the sender: the target is our own nick and is nobody's room.433 # for the sender: the target is our own nick and is nobody's room.
421 let room = if target.startsWith("#"): target else: who434 let room = if target.startsWith("#"): target else: who
422 var m = Message(id: msgid, frm: who, text: p.params[^1], at: at)435 var m = Message(id: msgid, frm: who, text: p.params[^1], at: at)
436+ if p.hasAccount: m.account = p.account
423 # The picture link out of the text, which is what draws the inline437 # The picture link out of the text, which is what draws the inline
424 # preview. This was hardcoded to "" — assigning a field its own438 # preview. This was hardcoded to "" — assigning a field its own
425 # default — so `firstImageUrl` was ported, tested and never called,439 # default — so `firstImageUrl` was ported, tested and never called,
modified nim/src/frq/screens/chat.nim +58 -3
@@ -15,7 +15,7 @@
1515 import std/[algorithm, json, strutils, tables]
1616 import std/options
1717 import frq/[ui, cells, model, clock, reactions, textruns, members,
18- glyphs, emoji, rooms]
18+ glyphs, emoji, rooms, profile]
1919 from frq/screens/connect import errorNote
2020
2121 const
@@ -137,9 +137,14 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
137137 if m.at > 0:
138138 who.children.add dimLabel(clockTime(m.at))
139139 else:
140+ # A face is a way in to who someone is, so it takes the press that opens
141+ # them — and so does the name beside it, since a name is the thing a
142+ # reader is actually looking at.
143+ let senderActor = actorFor(m.account, m.frm)
144+ let open = "profile.open:" & m.frm & ":" & senderActor
140145 var row = hbox(%*{"spacing": 6},
141- avatar("", m.frm, size = faceSize),
142- label(m.frm))
146+ avatar(m.avatar, m.frm, size = faceSize, onClick = open),
147+ n("button", %*{"label": m.frm, "kind": "plain", "onClick": open}))
143148 if m.at > 0:
144149 row.children.add dimLabel(clockTime(m.at))
145150 if m.edited:
@@ -273,6 +278,51 @@ proc lightboxPane(s: State): Node =
273278 image(s.lightbox.url, maxWidth = 640, maxHeight = 480),
274279 dimLabel(s.lightbox.url))
275280
281+proc profilePane(s: State): Node =
282+ ## Who someone is, behind the nick on a line.
283+ let nick = s.profileViewing.nick
284+ let actor = s.profileViewing.actor
285+
286+ result = card(
287+ hbox(%*{"spacing": 8},
288+ title2(nick),
289+ button("Close", "profile.close")))
290+
291+ if actor.len == 0:
292+ # A guest has no identity to fetch. The honest answer, rather than a
293+ # spinner that never lands.
294+ result.children.add dimLabel(
295+ "A guest — no Bluesky identity to look up.")
296+ return
297+
298+ let (p, known) = entry(actor)
299+ if not known or p.status == psLoading:
300+ result.children.add spinner()
301+ return
302+ if p.status == psFailed:
303+ result.children.add dimLabel("Could not look " & actor & " up.")
304+ return
305+
306+ var head = hbox(%*{"spacing": 8})
307+ head.children.add avatar(p.avatar, nick, size = 64)
308+ var who = vbox(%*{"spacing": 2})
309+ if p.displayName.len > 0: who.children.add label(p.displayName)
310+ if p.handle.len > 0: who.children.add dimLabel("@" & p.handle)
311+ if p.did.len > 0: who.children.add dimLabel(p.did)
312+ head.children.add who
313+ result.children.add head
314+
315+ if p.description.len > 0:
316+ result.children.add text(truncate(p.description, 280))
317+
318+ let stats = statsLine(p)
319+ if stats.len > 0:
320+ result.children.add dimLabel(stats)
321+
322+ let url = webUrl(p)
323+ if url.len > 0:
324+ result.children.add link("Open on bsky.app", url)
325+
276326 proc chatScreen*(s: State, connected: bool): Node =
277327 let room = s.currentRoom
278328 let name = if room.name.len > 0: room.name else: "Chat"
@@ -351,6 +401,10 @@ proc chatScreen*(s: State, connected: bool): Node =
351401 if s.overview:
352402 overview.children.add overviewPane(s)
353403
404+ var profile = vbox(%*{"key": "profile-pane"})
405+ if s.profileViewing.has:
406+ profile.children.add profilePane(s)
407+
354408 var lightbox = vbox(%*{"key": "lightbox-pane"})
355409 if s.lightbox.has:
356410 lightbox.children.add lightboxPane(s)
@@ -409,6 +463,7 @@ proc chatScreen*(s: State, connected: bool): Node =
409463 n("hbox", %*{"spacing": 8, "wrap": false, "expand": true},
410464 @[messages, peoplePane]),
411465 overview,
466+ profile,
412467 lightbox,
413468 returnRow,
414469 jump,
@@ -15,7 +15,7 @@
15 import std/[algorithm, json, strutils, tables]15 import std/[algorithm, json, strutils, tables]
16 import std/options16 import std/options
17 import frq/[ui, cells, model, clock, reactions, textruns, members,17 import frq/[ui, cells, model, clock, reactions, textruns, members,
18- glyphs, emoji, rooms]18+ glyphs, emoji, rooms, profile]
19 from frq/screens/connect import errorNote19 from frq/screens/connect import errorNote
20 20
21 const21 const
@@ -137,9 +137,14 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
137 if m.at > 0:137 if m.at > 0:
138 who.children.add dimLabel(clockTime(m.at))138 who.children.add dimLabel(clockTime(m.at))
139 else:139 else:
140+ # A face is a way in to who someone is, so it takes the press that opens
141+ # them — and so does the name beside it, since a name is the thing a
142+ # reader is actually looking at.
143+ let senderActor = actorFor(m.account, m.frm)
144+ let open = "profile.open:" & m.frm & ":" & senderActor
140 var row = hbox(%*{"spacing": 6},145 var row = hbox(%*{"spacing": 6},
141- avatar("", m.frm, size = faceSize),146+ avatar(m.avatar, m.frm, size = faceSize, onClick = open),
142- label(m.frm))147+ n("button", %*{"label": m.frm, "kind": "plain", "onClick": open}))
143 if m.at > 0:148 if m.at > 0:
144 row.children.add dimLabel(clockTime(m.at))149 row.children.add dimLabel(clockTime(m.at))
145 if m.edited:150 if m.edited:
@@ -273,6 +278,51 @@ proc lightboxPane(s: State): Node =
273 image(s.lightbox.url, maxWidth = 640, maxHeight = 480),278 image(s.lightbox.url, maxWidth = 640, maxHeight = 480),
274 dimLabel(s.lightbox.url))279 dimLabel(s.lightbox.url))
275 280
281+proc profilePane(s: State): Node =
282+ ## Who someone is, behind the nick on a line.
283+ let nick = s.profileViewing.nick
284+ let actor = s.profileViewing.actor
285+
286+ result = card(
287+ hbox(%*{"spacing": 8},
288+ title2(nick),
289+ button("Close", "profile.close")))
290+
291+ if actor.len == 0:
292+ # A guest has no identity to fetch. The honest answer, rather than a
293+ # spinner that never lands.
294+ result.children.add dimLabel(
295+ "A guest — no Bluesky identity to look up.")
296+ return
297+
298+ let (p, known) = entry(actor)
299+ if not known or p.status == psLoading:
300+ result.children.add spinner()
301+ return
302+ if p.status == psFailed:
303+ result.children.add dimLabel("Could not look " & actor & " up.")
304+ return
305+
306+ var head = hbox(%*{"spacing": 8})
307+ head.children.add avatar(p.avatar, nick, size = 64)
308+ var who = vbox(%*{"spacing": 2})
309+ if p.displayName.len > 0: who.children.add label(p.displayName)
310+ if p.handle.len > 0: who.children.add dimLabel("@" & p.handle)
311+ if p.did.len > 0: who.children.add dimLabel(p.did)
312+ head.children.add who
313+ result.children.add head
314+
315+ if p.description.len > 0:
316+ result.children.add text(truncate(p.description, 280))
317+
318+ let stats = statsLine(p)
319+ if stats.len > 0:
320+ result.children.add dimLabel(stats)
321+
322+ let url = webUrl(p)
323+ if url.len > 0:
324+ result.children.add link("Open on bsky.app", url)
325+
276 proc chatScreen*(s: State, connected: bool): Node =326 proc chatScreen*(s: State, connected: bool): Node =
277 let room = s.currentRoom327 let room = s.currentRoom
278 let name = if room.name.len > 0: room.name else: "Chat"328 let name = if room.name.len > 0: room.name else: "Chat"
@@ -351,6 +401,10 @@ proc chatScreen*(s: State, connected: bool): Node =
351 if s.overview:401 if s.overview:
352 overview.children.add overviewPane(s)402 overview.children.add overviewPane(s)
353 403
404+ var profile = vbox(%*{"key": "profile-pane"})
405+ if s.profileViewing.has:
406+ profile.children.add profilePane(s)
407+
354 var lightbox = vbox(%*{"key": "lightbox-pane"})408 var lightbox = vbox(%*{"key": "lightbox-pane"})
355 if s.lightbox.has:409 if s.lightbox.has:
356 lightbox.children.add lightboxPane(s)410 lightbox.children.add lightboxPane(s)
@@ -409,6 +463,7 @@ proc chatScreen*(s: State, connected: bool): Node =
409 n("hbox", %*{"spacing": 8, "wrap": false, "expand": true},463 n("hbox", %*{"spacing": 8, "wrap": false, "expand": true},
410 @[messages, peoplePane]),464 @[messages, peoplePane]),
411 overview,465 overview,
466+ profile,
412 lightbox,467 lightbox,
413 returnRow,468 returnRow,
414 jump,469 jump,
modified nim/src/frq/ui.nim +4 -2
@@ -126,11 +126,13 @@ func image*(src: string, maxWidth = 0, maxHeight = 0, onClick = ""): Node =
126126 if onClick.len > 0: p["onClick"] = %onClick
127127 n("image", p)
128128
129-func avatar*(url, fallback: string, size = 24): Node =
129+func avatar*(url, fallback: string, size = 24, onClick = ""): Node =
130130 ## A profile picture, or the letter to draw where there is none. The
131131 ## fallback is here rather than in the renderer because which letter is a
132132 ## question about the nick, and the nick is the tree's business.
133- n("avatar", %*{"url": url, "fallback": fallback, "size": size})
133+ var p = %*{"url": url, "fallback": fallback, "size": size}
134+ if onClick.len > 0: p["onClick"] = %onClick
135+ n("avatar", p)
134136
135137 func reaction*(emoji: string, count: int, mine: bool, onClick: string): Node =
136138 ## A pill under a message. `mine` is what makes it look pressed, and is why
@@ -126,11 +126,13 @@ func image*(src: string, maxWidth = 0, maxHeight = 0, onClick = ""): Node =
126 if onClick.len > 0: p["onClick"] = %onClick126 if onClick.len > 0: p["onClick"] = %onClick
127 n("image", p)127 n("image", p)
128 128
129-func avatar*(url, fallback: string, size = 24): Node =129+func avatar*(url, fallback: string, size = 24, onClick = ""): Node =
130 ## A profile picture, or the letter to draw where there is none. The130 ## A profile picture, or the letter to draw where there is none. The
131 ## fallback is here rather than in the renderer because which letter is a131 ## fallback is here rather than in the renderer because which letter is a
132 ## question about the nick, and the nick is the tree's business.132 ## question about the nick, and the nick is the tree's business.
133- n("avatar", %*{"url": url, "fallback": fallback, "size": size})133+ var p = %*{"url": url, "fallback": fallback, "size": size}
134+ if onClick.len > 0: p["onClick"] = %onClick
135+ n("avatar", p)
134 136
135 func reaction*(emoji: string, count: int, mine: bool, onClick: string): Node =137 func reaction*(emoji: string, count: int, mine: bool, onClick: string): Node =
136 ## A pill under a message. `mine` is what makes it look pressed, and is why138 ## A pill under a message. `mine` is what makes it look pressed, and is why
modified nim/tests/tchat.nim +8 -5
@@ -54,11 +54,13 @@ suite "the chat screen":
5454 e.current = "#empty"
5555 check "Nothing here yet." in cs.chatScreen(e, true).labels("dim-label")
5656
57- test "every line names its sender":
57+ test "every line names its sender, and the name opens them":
5858 # Not the first of a run only: answering the fourth line of a collapsed
59- # run quotes back a line with no name on it.
60- check cs.chatScreen(s, true).labels("label").countIt(it == "alice") == 1
61- check cs.chatScreen(s, true).labels("label").countIt(it == "frq-guest") == 1
59+ # run quotes back a line with no name on it. The name is a button because
60+ # it is a way in to who someone is, the same as the face beside it.
61+ let names = cs.chatScreen(s, true).labels("button")
62+ check names.countIt(it == "alice") == 1
63+ check names.countIt(it == "frq-guest") == 1
6264
6365 test "a day heading appears where the day changes, once":
6466 # `at` is milliseconds. Testing it with seconds put every message on the
@@ -177,7 +179,8 @@ suite "the chat screen":
177179 s.windowWidth = 1200
178180 let t = cs.chatScreen(s, true)
179181 check "People" in t.labels("title-2")
180- check "alice" in t.labels("label")
182+ # With the mode prefix in front of the name, ops first.
183+ check "@alice" in t.labels("label")
181184
182185 test "Jump to present only when we are not at it":
183186 check "↓ Jump to present" notin cs.chatScreen(s, true).labels("button")
@@ -54,11 +54,13 @@ suite "the chat screen":
54 e.current = "#empty"54 e.current = "#empty"
55 check "Nothing here yet." in cs.chatScreen(e, true).labels("dim-label")55 check "Nothing here yet." in cs.chatScreen(e, true).labels("dim-label")
56 56
57- test "every line names its sender":57+ test "every line names its sender, and the name opens them":
58 # Not the first of a run only: answering the fourth line of a collapsed58 # Not the first of a run only: answering the fourth line of a collapsed
59- # run quotes back a line with no name on it.59+ # run quotes back a line with no name on it. The name is a button because
60- check cs.chatScreen(s, true).labels("label").countIt(it == "alice") == 160+ # it is a way in to who someone is, the same as the face beside it.
61- check cs.chatScreen(s, true).labels("label").countIt(it == "frq-guest") == 161+ let names = cs.chatScreen(s, true).labels("button")
62+ check names.countIt(it == "alice") == 1
63+ check names.countIt(it == "frq-guest") == 1
62 64
63 test "a day heading appears where the day changes, once":65 test "a day heading appears where the day changes, once":
64 # `at` is milliseconds. Testing it with seconds put every message on the66 # `at` is milliseconds. Testing it with seconds put every message on the
@@ -177,7 +179,8 @@ suite "the chat screen":
177 s.windowWidth = 1200179 s.windowWidth = 1200
178 let t = cs.chatScreen(s, true)180 let t = cs.chatScreen(s, true)
179 check "People" in t.labels("title-2")181 check "People" in t.labels("title-2")
180- check "alice" in t.labels("label")182+ # With the mode prefix in front of the name, ops first.
183+ check "@alice" in t.labels("label")
181 184
182 test "Jump to present only when we are not at it":185 test "Jump to present only when we are not at it":
183 check "↓ Jump to present" notin cs.chatScreen(s, true).labels("button")186 check "↓ Jump to present" notin cs.chatScreen(s, true).labels("button")
added nim/tests/tprofile.nim +97 -0
new file mode 100644
@@ -0,0 +1,97 @@
1+## Who someone is, behind the nick. No network: every case here is a body in
2+## and fields out, or a question about a nick.
3+
4+import std/[strutils, unicode]
5+import std/[json, unittest]
6+import frq/profile
7+
8+suite "isHandle":
9+ test "a domain-shaped nick is a handle":
10+ check isHandle("alice.bsky.social")
11+ check isHandle("nandi.uk")
12+ test "a bare nick is not":
13+ # freeq gives an authenticated user their handle by default, while
14+ # `sleek5209` is a guest with no profile.
15+ check not isHandle("sleek5209")
16+ check not isHandle("alice")
17+ test "nor is something with no TLD":
18+ check not isHandle("alice.")
19+ check not isHandle("")
20+
21+suite "actorFor":
22+ test "a DID wins, because it is the identity itself":
23+ # A nick is whatever someone chose today.
24+ check actorFor("did:plc:abc", "alice.bsky.social") == "did:plc:abc"
25+ test "a handle-shaped nick stands in where there is no DID":
26+ check actorFor("", "alice.bsky.social") == "alice.bsky.social"
27+ test "a guest has nothing to look up":
28+ check actorFor("", "sleek5209") == ""
29+
30+suite "thumbnailUrl":
31+ test "asks the CDN for the size we paint":
32+ # A 170KB portrait downloaded to draw at 24 points is the thing this
33+ # avoids.
34+ check thumbnailUrl("https://cdn.bsky.app/img/avatar/plain/did:plc:x/y@jpeg") ==
35+ "https://cdn.bsky.app/img/avatar_thumbnail/plain/did:plc:x/y@png"
36+ test "replaces an existing format suffix rather than appending":
37+ check thumbnailUrl("https://cdn/x@jpeg").endsWith("@png")
38+ check "@jpeg" notin thumbnailUrl("https://cdn/x@jpeg")
39+ test "no picture is no URL":
40+ check thumbnailUrl("") == ""
41+
42+suite "parseProfile":
43+ let body = parseJson("""{
44+ "did": "did:plc:abc", "handle": "alice.bsky.social",
45+ "displayName": " Alice ", "description": " hello ",
46+ "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:abc/p@jpeg",
47+ "followersCount": 12, "followsCount": 34, "postsCount": 56 }""")
48+
49+ test "the fields the panel paints":
50+ let p = parseProfile(body)
51+ check p.status == psReady
52+ check p.did == "did:plc:abc"
53+ check p.handle == "alice.bsky.social"
54+ check p.displayName == "Alice" # trimmed
55+ check p.description == "hello"
56+ check p.followers == 12
57+ check p.avatar.endsWith("@png")
58+
59+ test "a body with nothing in it":
60+ let p = parseProfile(parseJson("{}"))
61+ check p.did == ""
62+ check p.avatar == ""
63+
64+suite "statsLine":
65+ test "all three":
66+ let p = Profile(followers: 12, follows: 34, posts: 56)
67+ check statsLine(p) == "12 followers · 34 following · 56 posts"
68+ test "only what is known":
69+ check statsLine(Profile(posts: 5)) == "5 posts"
70+ test "none is no line at all":
71+ check statsLine(Profile()) == ""
72+
73+suite "webUrl":
74+ test "by handle where there is one":
75+ check webUrl(Profile(handle: "alice.bsky.social", did: "did:plc:x")) ==
76+ "https://bsky.app/profile/alice.bsky.social"
77+ test "by DID otherwise":
78+ check webUrl(Profile(did: "did:plc:x")) == "https://bsky.app/profile/did:plc:x"
79+ test "a leading @ is not part of a handle":
80+ check webUrl(Profile(handle: "@alice.bsky.social")) ==
81+ "https://bsky.app/profile/alice.bsky.social"
82+ test "nobody is no URL":
83+ check webUrl(Profile()) == ""
84+
85+suite "truncate":
86+ test "leaves a short bio alone, line breaks and all":
87+ # The height of a multi-line bio is part of what it says.
88+ check truncate("one\ntwo", 280) == "one\ntwo"
89+ test "cuts a long one":
90+ let got = truncate("x".repeat(400), 280)
91+ check got.runeLen == 280
92+ check got.endsWith("")
93+
94+ test "cuts by character, not by byte":
95+ let got = truncate("😀".repeat(400), 280)
96+ check got.runeLen == 280
97+ check got.validateUtf8 == -1
new file mode 100644
@@ -0,0 +1,97 @@
1+## Who someone is, behind the nick. No network: every case here is a body in
2+## and fields out, or a question about a nick.
3+
4+import std/[strutils, unicode]
5+import std/[json, unittest]
6+import frq/profile
7+
8+suite "isHandle":
9+ test "a domain-shaped nick is a handle":
10+ check isHandle("alice.bsky.social")
11+ check isHandle("nandi.uk")
12+ test "a bare nick is not":
13+ # freeq gives an authenticated user their handle by default, while
14+ # `sleek5209` is a guest with no profile.
15+ check not isHandle("sleek5209")
16+ check not isHandle("alice")
17+ test "nor is something with no TLD":
18+ check not isHandle("alice.")
19+ check not isHandle("")
20+
21+suite "actorFor":
22+ test "a DID wins, because it is the identity itself":
23+ # A nick is whatever someone chose today.
24+ check actorFor("did:plc:abc", "alice.bsky.social") == "did:plc:abc"
25+ test "a handle-shaped nick stands in where there is no DID":
26+ check actorFor("", "alice.bsky.social") == "alice.bsky.social"
27+ test "a guest has nothing to look up":
28+ check actorFor("", "sleek5209") == ""
29+
30+suite "thumbnailUrl":
31+ test "asks the CDN for the size we paint":
32+ # A 170KB portrait downloaded to draw at 24 points is the thing this
33+ # avoids.
34+ check thumbnailUrl("https://cdn.bsky.app/img/avatar/plain/did:plc:x/y@jpeg") ==
35+ "https://cdn.bsky.app/img/avatar_thumbnail/plain/did:plc:x/y@png"
36+ test "replaces an existing format suffix rather than appending":
37+ check thumbnailUrl("https://cdn/x@jpeg").endsWith("@png")
38+ check "@jpeg" notin thumbnailUrl("https://cdn/x@jpeg")
39+ test "no picture is no URL":
40+ check thumbnailUrl("") == ""
41+
42+suite "parseProfile":
43+ let body = parseJson("""{
44+ "did": "did:plc:abc", "handle": "alice.bsky.social",
45+ "displayName": " Alice ", "description": " hello ",
46+ "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:abc/p@jpeg",
47+ "followersCount": 12, "followsCount": 34, "postsCount": 56 }""")
48+
49+ test "the fields the panel paints":
50+ let p = parseProfile(body)
51+ check p.status == psReady
52+ check p.did == "did:plc:abc"
53+ check p.handle == "alice.bsky.social"
54+ check p.displayName == "Alice" # trimmed
55+ check p.description == "hello"
56+ check p.followers == 12
57+ check p.avatar.endsWith("@png")
58+
59+ test "a body with nothing in it":
60+ let p = parseProfile(parseJson("{}"))
61+ check p.did == ""
62+ check p.avatar == ""
63+
64+suite "statsLine":
65+ test "all three":
66+ let p = Profile(followers: 12, follows: 34, posts: 56)
67+ check statsLine(p) == "12 followers · 34 following · 56 posts"
68+ test "only what is known":
69+ check statsLine(Profile(posts: 5)) == "5 posts"
70+ test "none is no line at all":
71+ check statsLine(Profile()) == ""
72+
73+suite "webUrl":
74+ test "by handle where there is one":
75+ check webUrl(Profile(handle: "alice.bsky.social", did: "did:plc:x")) ==
76+ "https://bsky.app/profile/alice.bsky.social"
77+ test "by DID otherwise":
78+ check webUrl(Profile(did: "did:plc:x")) == "https://bsky.app/profile/did:plc:x"
79+ test "a leading @ is not part of a handle":
80+ check webUrl(Profile(handle: "@alice.bsky.social")) ==
81+ "https://bsky.app/profile/alice.bsky.social"
82+ test "nobody is no URL":
83+ check webUrl(Profile()) == ""
84+
85+suite "truncate":
86+ test "leaves a short bio alone, line breaks and all":
87+ # The height of a multi-line bio is part of what it says.
88+ check truncate("one\ntwo", 280) == "one\ntwo"
89+ test "cuts a long one":
90+ let got = truncate("x".repeat(400), 280)
91+ check got.runeLen == 280
92+ check got.endsWith("")
93+
94+ test "cuts by character, not by byte":
95+ let got = truncate("😀".repeat(400), 280)
96+ check got.runeLen == 280
97+ check got.validateUtf8 == -1