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>
9bb81a1 parent: 98d6135 modified
flutter/lib/nim_renderer.dart +10 -0 | @@ -376,6 +376,16 @@ class _NimAppState extends State<NimApp> { | ||
| 376 | 376 | return FilledButton( |
| 377 | 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 | 389 | if (kind == 'destructive') { |
| 380 | 390 | return FilledButton( |
| 381 | 391 | 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() { | ||
| 130 | 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 | 142 | testWidgets('lays out when scrolled off the present', (tester) async { |
| 134 | 143 | core.demoUi(); |
| 135 | 144 | 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 | ||
| 70 | 70 | |
| 71 | 71 | ## What is not here |
| 72 | 72 | |
| 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. | |
| 77 | 77 | |
| 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. | |
| 80 | 80 | |
| 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 | |
| 83 | 83 | as a pane above the compose bar, the lightbox as a panel over the |
| 84 | 84 | conversation — so the 🙂 chip, the Overview toggle and clicking a picture all |
| 85 | 85 | 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 here | 71 | ## What is not here |
| 72 | 72 | ||
| 73 | -`frq.profile` and `frq.replies` were never ported and went with the | 73 | +`frq.replies` was never ported and went with the ClojureDart: asking freeq |
| 74 | -ClojureDart rather than moving: a Bluesky profile behind a nick, and asking | 74 | +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 appear | 75 | +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 by | 78 | +`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 overview | 82 | +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 the | 83 | 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 all | 84 | 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 = | ||
| 106 | 106 | if ep.len > 0: return ep |
| 107 | 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 | 119 | proc createSession*(handle, password: string): Session = |
| 110 | 120 | ## Sign in to the PDS with an app password. |
| 111 | 121 | ## |
| @@ -106,6 +106,16 @@ proc pdsFor*(did: string): string = | |||
| 106 | if ep.len > 0: return ep | 106 | 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 | ||
| 143 | 143 | |
| 144 | 144 | imagePicker*: string |
| 145 | 145 | profileViewing*: ProfileView |
| 146 | - profileTick*: int | |
| 147 | 146 | |
| 148 | 147 | # The window, polled from the host. |
| 149 | 148 | windowWidth*, windowHeight*: int |
| @@ -143,7 +143,6 @@ type | |||
| 143 | 143 | ||
| 144 | imagePicker*: string | 144 | imagePicker*: string |
| 145 | profileViewing*: ProfileView | 145 | profileViewing*: ProfileView |
| 146 | - profileTick*: int | ||
| 147 | 146 | ||
| 148 | # The window, polled from the host. | 147 | # The window, polled from the host. |
| 149 | windowWidth*, windowHeight*: int | 148 | windowWidth*, windowHeight*: int |
modified
nim/src/frq/model.nim +5 -0 | @@ -37,6 +37,11 @@ type | ||
| 37 | 37 | replyTo*: string ## the id this answers, "" for a line answering none |
| 38 | 38 | reactions*: seq[Reaction] |
| 39 | 39 | 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 | 45 | pending*: bool ## sent, not yet echoed |
| 41 | 46 | room*: string |
| 42 | 47 | ## 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 none | 37 | 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 none | 39 | 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 echoed | 45 | pending*: bool ## sent, not yet echoed |
| 41 | room*: string | 46 | room*: string |
| 42 | ## Which room this was said in. Empty on a stored message — a room | 47 | ## 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 @@ | ||
| 14 | 14 | import std/[json, options, strutils, tables] |
| 15 | 15 | import std/sets |
| 16 | 16 | import frq/[cells, model, rooms, reactions, trace, ircparse, clock, |
| 17 | - atproto, handshake, textruns, members, msgsig] | |
| 17 | + atproto, handshake, textruns, members, msgsig, profile] | |
| 18 | 18 | import frq/conn as tr |
| 19 | 19 | |
| 20 | 20 | proc split2(id: string): (string, string) = |
| @@ -328,6 +328,19 @@ proc dispatch*(event: JsonNode) = | ||
| 328 | 328 | openRoom(app.overviewReturn) |
| 329 | 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 | 344 | of "lightbox": |
| 332 | 345 | app.lightbox = Lightbox(has: true, url: arg, path: arg) |
| 333 | 346 | of "lightbox.close": app.lightbox = Lightbox() |
| @@ -420,6 +433,7 @@ proc drain*() = | ||
| 420 | 433 | # for the sender: the target is our own nick and is nobody's room. |
| 421 | 434 | let room = if target.startsWith("#"): target else: who |
| 422 | 435 | var m = Message(id: msgid, frm: who, text: p.params[^1], at: at) |
| 436 | + if p.hasAccount: m.account = p.account | |
| 423 | 437 | # The picture link out of the text, which is what draws the inline |
| 424 | 438 | # preview. This was hardcoded to "" — assigning a field its own |
| 425 | 439 | # 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/sets | 15 | 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 tr | 18 | 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: who | 434 | 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 inline | 437 | # The picture link out of the text, which is what draws the inline |
| 424 | # preview. This was hardcoded to "" — assigning a field its own | 438 | # 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 @@ | ||
| 15 | 15 | import std/[algorithm, json, strutils, tables] |
| 16 | 16 | import std/options |
| 17 | 17 | import frq/[ui, cells, model, clock, reactions, textruns, members, |
| 18 | - glyphs, emoji, rooms] | |
| 18 | + glyphs, emoji, rooms, profile] | |
| 19 | 19 | from frq/screens/connect import errorNote |
| 20 | 20 | |
| 21 | 21 | const |
| @@ -137,9 +137,14 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node = | ||
| 137 | 137 | if m.at > 0: |
| 138 | 138 | who.children.add dimLabel(clockTime(m.at)) |
| 139 | 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 | 145 | 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})) | |
| 143 | 148 | if m.at > 0: |
| 144 | 149 | row.children.add dimLabel(clockTime(m.at)) |
| 145 | 150 | if m.edited: |
| @@ -273,6 +278,51 @@ proc lightboxPane(s: State): Node = | ||
| 273 | 278 | image(s.lightbox.url, maxWidth = 640, maxHeight = 480), |
| 274 | 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 | 326 | proc chatScreen*(s: State, connected: bool): Node = |
| 277 | 327 | let room = s.currentRoom |
| 278 | 328 | let name = if room.name.len > 0: room.name else: "Chat" |
| @@ -351,6 +401,10 @@ proc chatScreen*(s: State, connected: bool): Node = | ||
| 351 | 401 | if s.overview: |
| 352 | 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 | 408 | var lightbox = vbox(%*{"key": "lightbox-pane"}) |
| 355 | 409 | if s.lightbox.has: |
| 356 | 410 | lightbox.children.add lightboxPane(s) |
| @@ -409,6 +463,7 @@ proc chatScreen*(s: State, connected: bool): Node = | ||
| 409 | 463 | n("hbox", %*{"spacing": 8, "wrap": false, "expand": true}, |
| 410 | 464 | @[messages, peoplePane]), |
| 411 | 465 | overview, |
| 466 | + profile, | |
| 412 | 467 | lightbox, |
| 413 | 468 | returnRow, |
| 414 | 469 | jump, |
| @@ -15,7 +15,7 @@ | |||
| 15 | import std/[algorithm, json, strutils, tables] | 15 | import std/[algorithm, json, strutils, tables] |
| 16 | import std/options | 16 | 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 errorNote | 19 | from frq/screens/connect import errorNote |
| 20 | 20 | ||
| 21 | const | 21 | 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.currentRoom | 327 | 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 = | ||
| 126 | 126 | if onClick.len > 0: p["onClick"] = %onClick |
| 127 | 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 | 130 | ## A profile picture, or the letter to draw where there is none. The |
| 131 | 131 | ## fallback is here rather than in the renderer because which letter is a |
| 132 | 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 | 137 | func reaction*(emoji: string, count: int, mine: bool, onClick: string): Node = |
| 136 | 138 | ## 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"] = %onClick | 126 | 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. The | 130 | ## 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 a | 131 | ## 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 why | 138 | ## 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": | ||
| 54 | 54 | e.current = "#empty" |
| 55 | 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 | 58 | # 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 | |
| 62 | 64 | |
| 63 | 65 | test "a day heading appears where the day changes, once": |
| 64 | 66 | # `at` is milliseconds. Testing it with seconds put every message on the |
| @@ -177,7 +179,8 @@ suite "the chat screen": | ||
| 177 | 179 | s.windowWidth = 1200 |
| 178 | 180 | let t = cs.chatScreen(s, true) |
| 179 | 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 | 185 | test "Jump to present only when we are not at it": |
| 183 | 186 | 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 collapsed | 58 | # 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") == 1 | 60 | + # 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") == 1 | 61 | + 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 the | 66 | # `at` is milliseconds. Testing it with seconds put every message on the |
| @@ -177,7 +179,8 @@ suite "the chat screen": | |||
| 177 | s.windowWidth = 1200 | 179 | 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 | ||