Four screens, in Nim
connect, discover, settings and chats, transcribed from their ClojureDart originals rather than sketched — the same copy, the same fields, the same stable wrappers, so the two can be read side by side and a disagreement is a bug rather than an opinion. This is what the deleted spike should have been. Under them, `cells.nim`: all 44 atoms as one record. The Clojure keeps them apart because a screen there watches the individual cells it read; here the whole tree is rebuilt from the whole state on every event, so that grain buys nothing and one record is easier to reason about. The comments that record a decision came with their fields — why the attachment is held apart from the draft, why `jump-to` is cleared the frame after it is set, why the overview owes the reader a way back. `ui.nim` grew the rest of the vocabulary: separator, spacer, text, link, image, avatar, reaction, status, emoji, dialog. That is all 22 tags the real screens use, against the 12 the spike covered — the gap was most of why its chat screen was a facsimile. The terminal branches are dropped rather than translated. `conversation-row` had two layouts and one of them was for a frontend that no longer exists. One real porting bug, found by a test rather than by eye: `previewLine` truncated by byte where Clojure's `subs` truncates by character, so a preview full of emoji got cut mid-codepoint into mojibake instead of an ellipsis. It is runes now, and there is a test with a hundred emoji in it. 114 Nim tests. 1,538 lines of Nim. Still ahead: chat.cljc (1,508) and app.cljc (398), which need reactions, replies, edits, members and the emoji catalogue under them first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c69a09b parent: d333b6f added
nim/src/frq/cells.nim +193 -0 | new file mode 100644 | ||
| @@ -0,0 +1,193 @@ | ||
| 1 | +## Every piece of state the screens read. | |
| 2 | +## | |
| 3 | +## `common/frq/cells.cljc`, as one record. The Clojure holds 44 separate atoms | |
| 4 | +## because a screen there watches individual cells and re-renders on the ones | |
| 5 | +## it read; here the whole tree is rebuilt from the whole state on every | |
| 6 | +## event, so the grain that mattered there buys nothing and one record is | |
| 7 | +## easier to reason about. | |
| 8 | +## | |
| 9 | +## The comments are kept from the original wherever they record a decision, | |
| 10 | +## because they are the reason a field is shaped the way it is rather than | |
| 11 | +## documentation of what it obviously holds. | |
| 12 | + | |
| 13 | +import std/tables | |
| 14 | +import model | |
| 15 | + | |
| 16 | +type | |
| 17 | + Screen* = enum | |
| 18 | + scConnect = "connect", scChats = "chats", scChat = "chat", | |
| 19 | + scDiscover = "discover", scSettings = "settings" | |
| 20 | + | |
| 21 | + AuthMode* = enum | |
| 22 | + amGuest = "guest", amBluesky = "bluesky", amAppPassword = "app-password" | |
| 23 | + | |
| 24 | + UploadStatus* = enum | |
| 25 | + usUploading = "uploading", usReady = "ready" | |
| 26 | + | |
| 27 | + Attachment* = object | |
| 28 | + ## The picture waiting to go out with the next line. | |
| 29 | + ## | |
| 30 | + ## Held apart from the draft rather than written into it. A link pasted | |
| 31 | + ## into the entry is a line of unreadable text in the middle of whatever | |
| 32 | + ## the reader was typing, and it puts their cursor somewhere they did not | |
| 33 | + ## put it. The picture is a picture until it is sent; the draft stays | |
| 34 | + ## theirs. | |
| 35 | + ## | |
| 36 | + ## One at a time — a second paste replaces the first, which is what a | |
| 37 | + ## reader who pasted the wrong thing means by pasting the right one. | |
| 38 | + has*: bool | |
| 39 | + path*: string ## the copy on disk, which is what the preview paints | |
| 40 | + url*: string ## where freeq serves it, once the upload has landed | |
| 41 | + status*: UploadStatus | |
| 42 | + | |
| 43 | + EditTarget* = object | |
| 44 | + ## The message the draft is a rewrite of, or absent when the box is being | |
| 45 | + ## used for something new. Only the id is kept: what is being rewritten is | |
| 46 | + ## in the box, and the line on screen is the thing it will replace. | |
| 47 | + has*: bool | |
| 48 | + room*: string | |
| 49 | + id*: string | |
| 50 | + | |
| 51 | + ReplyTarget* = object | |
| 52 | + ## The message the draft is answering. Held whole rather than as an id | |
| 53 | + ## alone so the compose bar can say who is being answered without going | |
| 54 | + ## looking for them. | |
| 55 | + has*: bool | |
| 56 | + id*, frm*, text*: string | |
| 57 | + | |
| 58 | + Lightbox* = object | |
| 59 | + has*: bool | |
| 60 | + path*, url*: string | |
| 61 | + | |
| 62 | + ReactTarget* = object | |
| 63 | + ## What the emoji picker is choosing for. The picker is a panel over the | |
| 64 | + ## compose bar rather than a screen: what is being reacted to has to stay | |
| 65 | + ## in sight. | |
| 66 | + has*: bool | |
| 67 | + room*, id*: string | |
| 68 | + | |
| 69 | + ProfileView* = object | |
| 70 | + ## Who is being looked at — `actor` is the DID or handle, absent for a | |
| 71 | + ## guest. | |
| 72 | + has*: bool | |
| 73 | + nick*, actor*: string | |
| 74 | + | |
| 75 | + State* = object | |
| 76 | + screen*: Screen | |
| 77 | + status*: string | |
| 78 | + error*: string | |
| 79 | + hasError*: bool | |
| 80 | + connecting*: bool | |
| 81 | + | |
| 82 | + # The connect form. | |
| 83 | + authMode*: AuthMode | |
| 84 | + formHost*, formPort*, formNick*: string | |
| 85 | + formTls*: bool | |
| 86 | + formHandle*, formAppPassword*: string | |
| 87 | + | |
| 88 | + # The durable half of an OAuth sign-in. The web-token beside it is | |
| 89 | + # single-use, so a reconnect mints a fresh one from this rather than | |
| 90 | + # replaying the old. | |
| 91 | + brokerToken*: string | |
| 92 | + apiBearer*: string | |
| 93 | + loginUrl*: string ## shown while the browser is open | |
| 94 | + | |
| 95 | + # Rooms. | |
| 96 | + rooms*: OrderedTable[string, Room] | |
| 97 | + current*: string | |
| 98 | + joinInput*: string | |
| 99 | + search*: string | |
| 100 | + | |
| 101 | + # The compose bar and its three companions. | |
| 102 | + draft*: string | |
| 103 | + editing*: EditTarget | |
| 104 | + replyingTo*: ReplyTarget | |
| 105 | + attachment*: Attachment | |
| 106 | + | |
| 107 | + # Chat chrome. | |
| 108 | + showUsers*: bool | |
| 109 | + hideChatList*: bool | |
| 110 | + overview*: bool | |
| 111 | + atPresent*: bool | |
| 112 | + hideJoinPart*: bool | |
| 113 | + ## Comings and goings, hidden or not. A quiet room reads better with | |
| 114 | + ## them — they are how you notice someone arriving — and a busy one | |
| 115 | + ## drowns in them, so it is the reader's call. | |
| 116 | + | |
| 117 | + # Emoji picker. | |
| 118 | + emojiGroup*: string | |
| 119 | + emojiSearch*: string | |
| 120 | + reacting*: ReactTarget | |
| 121 | + reactionHover*: ReactTarget | |
| 122 | + | |
| 123 | + # Navigation within a conversation. | |
| 124 | + highlight*: string | |
| 125 | + jumpTo*: string | |
| 126 | + ## The message a "go to" is aiming at. Set for the frame that scrolls to | |
| 127 | + ## it and taken off again — a target that stayed set would pin the view | |
| 128 | + ## there and take scrolling away from the reader. | |
| 129 | + jumpTick*: int | |
| 130 | + overviewReturn*: string | |
| 131 | + ## The room the reader was in when a line in the overview took them | |
| 132 | + ## somewhere else. The strip is the one place in the app that moves you | |
| 133 | + ## without your having asked to leave where you were, so it is the one | |
| 134 | + ## place that owes you the way back. | |
| 135 | + | |
| 136 | + lightbox*: Lightbox | |
| 137 | + savedTo*: string | |
| 138 | + ## What the lightbox's Save has to say for itself: where a picture | |
| 139 | + ## landed, or "failed". Saving is a file appearing somewhere off screen, | |
| 140 | + ## so the one thing the reader needs back is where — a button that only | |
| 141 | + ## stops looking pressed has told them nothing. | |
| 142 | + savedFailed*: bool | |
| 143 | + | |
| 144 | + imagePicker*: string | |
| 145 | + profileViewing*: ProfileView | |
| 146 | + profileTick*: int | |
| 147 | + | |
| 148 | + # The window, polled from the host. | |
| 149 | + windowWidth*, windowHeight*: int | |
| 150 | + ## The height is for the pictures in the conversation: a preview sized | |
| 151 | + ## against the window is a picture on a laptop and a thumbnail on a | |
| 152 | + ## phone, where one fixed height is only ever right on one of them. | |
| 153 | + | |
| 154 | +const | |
| 155 | + defaultHost* = "irc.freeq.at" | |
| 156 | + defaultPort* = "6697" | |
| 157 | + | |
| 158 | + wideWidth* = 900 | |
| 159 | + ## Where the second pane starts paying for itself. Below this a 320pt list | |
| 160 | + ## beside a conversation leaves the messages narrower than the phone | |
| 161 | + ## layout they were written for. | |
| 162 | + | |
| 163 | + popularChannels*: seq[(string, string)] = @[ | |
| 164 | + ("#general", "General discussion"), | |
| 165 | + ("#test", "Test channel"), | |
| 166 | + ("#freeq", "freeq development & support"), | |
| 167 | + ("#dev", "Programming & technology"), | |
| 168 | + ("#music", "Music recommendations"), | |
| 169 | + ("#random", "Off-topic chat")] | |
| 170 | + | |
| 171 | +func initState*(): State = | |
| 172 | + State(screen: scConnect, | |
| 173 | + status: "Not connected", | |
| 174 | + authMode: amGuest, | |
| 175 | + formHost: defaultHost, | |
| 176 | + formPort: defaultPort, | |
| 177 | + formTls: true, | |
| 178 | + formNick: "frq-guest", | |
| 179 | + atPresent: true, | |
| 180 | + rooms: initOrderedTable[string, Room]()) | |
| 181 | + | |
| 182 | +func wide*(s: State): bool = | |
| 183 | + ## Past this the room list and the conversation are both on screen instead | |
| 184 | + ## of taking turns. | |
| 185 | + s.windowWidth >= wideWidth | |
| 186 | + | |
| 187 | +func currentRoom*(s: State): Room = | |
| 188 | + if s.current.len > 0 and s.rooms.hasKey(s.current): s.rooms[s.current] | |
| 189 | + else: initRoom("") | |
| 190 | + | |
| 191 | +var app* = initState() | |
| 192 | + ## The one mutable thing. Named `app` rather than `state` because `state` is | |
| 193 | + ## ambiguous against unittest's own inside a test module. | |
| new file mode 100644 | |||
| @@ -0,0 +1,193 @@ | |||
| 1 | +## Every piece of state the screens read. | ||
| 2 | +## | ||
| 3 | +## `common/frq/cells.cljc`, as one record. The Clojure holds 44 separate atoms | ||
| 4 | +## because a screen there watches individual cells and re-renders on the ones | ||
| 5 | +## it read; here the whole tree is rebuilt from the whole state on every | ||
| 6 | +## event, so the grain that mattered there buys nothing and one record is | ||
| 7 | +## easier to reason about. | ||
| 8 | +## | ||
| 9 | +## The comments are kept from the original wherever they record a decision, | ||
| 10 | +## because they are the reason a field is shaped the way it is rather than | ||
| 11 | +## documentation of what it obviously holds. | ||
| 12 | + | ||
| 13 | +import std/tables | ||
| 14 | +import model | ||
| 15 | + | ||
| 16 | +type | ||
| 17 | + Screen* = enum | ||
| 18 | + scConnect = "connect", scChats = "chats", scChat = "chat", | ||
| 19 | + scDiscover = "discover", scSettings = "settings" | ||
| 20 | + | ||
| 21 | + AuthMode* = enum | ||
| 22 | + amGuest = "guest", amBluesky = "bluesky", amAppPassword = "app-password" | ||
| 23 | + | ||
| 24 | + UploadStatus* = enum | ||
| 25 | + usUploading = "uploading", usReady = "ready" | ||
| 26 | + | ||
| 27 | + Attachment* = object | ||
| 28 | + ## The picture waiting to go out with the next line. | ||
| 29 | + ## | ||
| 30 | + ## Held apart from the draft rather than written into it. A link pasted | ||
| 31 | + ## into the entry is a line of unreadable text in the middle of whatever | ||
| 32 | + ## the reader was typing, and it puts their cursor somewhere they did not | ||
| 33 | + ## put it. The picture is a picture until it is sent; the draft stays | ||
| 34 | + ## theirs. | ||
| 35 | + ## | ||
| 36 | + ## One at a time — a second paste replaces the first, which is what a | ||
| 37 | + ## reader who pasted the wrong thing means by pasting the right one. | ||
| 38 | + has*: bool | ||
| 39 | + path*: string ## the copy on disk, which is what the preview paints | ||
| 40 | + url*: string ## where freeq serves it, once the upload has landed | ||
| 41 | + status*: UploadStatus | ||
| 42 | + | ||
| 43 | + EditTarget* = object | ||
| 44 | + ## The message the draft is a rewrite of, or absent when the box is being | ||
| 45 | + ## used for something new. Only the id is kept: what is being rewritten is | ||
| 46 | + ## in the box, and the line on screen is the thing it will replace. | ||
| 47 | + has*: bool | ||
| 48 | + room*: string | ||
| 49 | + id*: string | ||
| 50 | + | ||
| 51 | + ReplyTarget* = object | ||
| 52 | + ## The message the draft is answering. Held whole rather than as an id | ||
| 53 | + ## alone so the compose bar can say who is being answered without going | ||
| 54 | + ## looking for them. | ||
| 55 | + has*: bool | ||
| 56 | + id*, frm*, text*: string | ||
| 57 | + | ||
| 58 | + Lightbox* = object | ||
| 59 | + has*: bool | ||
| 60 | + path*, url*: string | ||
| 61 | + | ||
| 62 | + ReactTarget* = object | ||
| 63 | + ## What the emoji picker is choosing for. The picker is a panel over the | ||
| 64 | + ## compose bar rather than a screen: what is being reacted to has to stay | ||
| 65 | + ## in sight. | ||
| 66 | + has*: bool | ||
| 67 | + room*, id*: string | ||
| 68 | + | ||
| 69 | + ProfileView* = object | ||
| 70 | + ## Who is being looked at — `actor` is the DID or handle, absent for a | ||
| 71 | + ## guest. | ||
| 72 | + has*: bool | ||
| 73 | + nick*, actor*: string | ||
| 74 | + | ||
| 75 | + State* = object | ||
| 76 | + screen*: Screen | ||
| 77 | + status*: string | ||
| 78 | + error*: string | ||
| 79 | + hasError*: bool | ||
| 80 | + connecting*: bool | ||
| 81 | + | ||
| 82 | + # The connect form. | ||
| 83 | + authMode*: AuthMode | ||
| 84 | + formHost*, formPort*, formNick*: string | ||
| 85 | + formTls*: bool | ||
| 86 | + formHandle*, formAppPassword*: string | ||
| 87 | + | ||
| 88 | + # The durable half of an OAuth sign-in. The web-token beside it is | ||
| 89 | + # single-use, so a reconnect mints a fresh one from this rather than | ||
| 90 | + # replaying the old. | ||
| 91 | + brokerToken*: string | ||
| 92 | + apiBearer*: string | ||
| 93 | + loginUrl*: string ## shown while the browser is open | ||
| 94 | + | ||
| 95 | + # Rooms. | ||
| 96 | + rooms*: OrderedTable[string, Room] | ||
| 97 | + current*: string | ||
| 98 | + joinInput*: string | ||
| 99 | + search*: string | ||
| 100 | + | ||
| 101 | + # The compose bar and its three companions. | ||
| 102 | + draft*: string | ||
| 103 | + editing*: EditTarget | ||
| 104 | + replyingTo*: ReplyTarget | ||
| 105 | + attachment*: Attachment | ||
| 106 | + | ||
| 107 | + # Chat chrome. | ||
| 108 | + showUsers*: bool | ||
| 109 | + hideChatList*: bool | ||
| 110 | + overview*: bool | ||
| 111 | + atPresent*: bool | ||
| 112 | + hideJoinPart*: bool | ||
| 113 | + ## Comings and goings, hidden or not. A quiet room reads better with | ||
| 114 | + ## them — they are how you notice someone arriving — and a busy one | ||
| 115 | + ## drowns in them, so it is the reader's call. | ||
| 116 | + | ||
| 117 | + # Emoji picker. | ||
| 118 | + emojiGroup*: string | ||
| 119 | + emojiSearch*: string | ||
| 120 | + reacting*: ReactTarget | ||
| 121 | + reactionHover*: ReactTarget | ||
| 122 | + | ||
| 123 | + # Navigation within a conversation. | ||
| 124 | + highlight*: string | ||
| 125 | + jumpTo*: string | ||
| 126 | + ## The message a "go to" is aiming at. Set for the frame that scrolls to | ||
| 127 | + ## it and taken off again — a target that stayed set would pin the view | ||
| 128 | + ## there and take scrolling away from the reader. | ||
| 129 | + jumpTick*: int | ||
| 130 | + overviewReturn*: string | ||
| 131 | + ## The room the reader was in when a line in the overview took them | ||
| 132 | + ## somewhere else. The strip is the one place in the app that moves you | ||
| 133 | + ## without your having asked to leave where you were, so it is the one | ||
| 134 | + ## place that owes you the way back. | ||
| 135 | + | ||
| 136 | + lightbox*: Lightbox | ||
| 137 | + savedTo*: string | ||
| 138 | + ## What the lightbox's Save has to say for itself: where a picture | ||
| 139 | + ## landed, or "failed". Saving is a file appearing somewhere off screen, | ||
| 140 | + ## so the one thing the reader needs back is where — a button that only | ||
| 141 | + ## stops looking pressed has told them nothing. | ||
| 142 | + savedFailed*: bool | ||
| 143 | + | ||
| 144 | + imagePicker*: string | ||
| 145 | + profileViewing*: ProfileView | ||
| 146 | + profileTick*: int | ||
| 147 | + | ||
| 148 | + # The window, polled from the host. | ||
| 149 | + windowWidth*, windowHeight*: int | ||
| 150 | + ## The height is for the pictures in the conversation: a preview sized | ||
| 151 | + ## against the window is a picture on a laptop and a thumbnail on a | ||
| 152 | + ## phone, where one fixed height is only ever right on one of them. | ||
| 153 | + | ||
| 154 | +const | ||
| 155 | + defaultHost* = "irc.freeq.at" | ||
| 156 | + defaultPort* = "6697" | ||
| 157 | + | ||
| 158 | + wideWidth* = 900 | ||
| 159 | + ## Where the second pane starts paying for itself. Below this a 320pt list | ||
| 160 | + ## beside a conversation leaves the messages narrower than the phone | ||
| 161 | + ## layout they were written for. | ||
| 162 | + | ||
| 163 | + popularChannels*: seq[(string, string)] = @[ | ||
| 164 | + ("#general", "General discussion"), | ||
| 165 | + ("#test", "Test channel"), | ||
| 166 | + ("#freeq", "freeq development & support"), | ||
| 167 | + ("#dev", "Programming & technology"), | ||
| 168 | + ("#music", "Music recommendations"), | ||
| 169 | + ("#random", "Off-topic chat")] | ||
| 170 | + | ||
| 171 | +func initState*(): State = | ||
| 172 | + State(screen: scConnect, | ||
| 173 | + status: "Not connected", | ||
| 174 | + authMode: amGuest, | ||
| 175 | + formHost: defaultHost, | ||
| 176 | + formPort: defaultPort, | ||
| 177 | + formTls: true, | ||
| 178 | + formNick: "frq-guest", | ||
| 179 | + atPresent: true, | ||
| 180 | + rooms: initOrderedTable[string, Room]()) | ||
| 181 | + | ||
| 182 | +func wide*(s: State): bool = | ||
| 183 | + ## Past this the room list and the conversation are both on screen instead | ||
| 184 | + ## of taking turns. | ||
| 185 | + s.windowWidth >= wideWidth | ||
| 186 | + | ||
| 187 | +func currentRoom*(s: State): Room = | ||
| 188 | + if s.current.len > 0 and s.rooms.hasKey(s.current): s.rooms[s.current] | ||
| 189 | + else: initRoom("") | ||
| 190 | + | ||
| 191 | +var app* = initState() | ||
| 192 | + ## The one mutable thing. Named `app` rather than `state` because `state` is | ||
| 193 | + ## ambiguous against unittest's own inside a test module. | ||
added
nim/src/frq/screens/chats.nim +98 -0 | new file mode 100644 | ||
| @@ -0,0 +1,98 @@ | ||
| 1 | +## The conversation list. | |
| 2 | +## | |
| 3 | +## Transcribed from `common/frq/screens/chats.cljc`. The terminal branches are | |
| 4 | +## dropped rather than translated — there is no terminal frontend any more, so | |
| 5 | +## `conversation-row`'s two layouts collapse to the one a window uses. | |
| 6 | + | |
| 7 | +import std/[json, strutils, tables] | |
| 8 | +# `runeLen` and `runeSubStr` only: a plain `import std/unicode` brings a | |
| 9 | +# `title` that is ambiguous against `ui.title`, which is the one this file | |
| 10 | +# means every time it says it. | |
| 11 | +from std/unicode import runeLen, runeSubStr | |
| 12 | +import ../ui, ../cells, ../model, ../rooms | |
| 13 | +import frame, connect | |
| 14 | + | |
| 15 | +const listGutter = 16 | |
| 16 | + ## The scrollbar's room. Without it the cards sit under it and the last | |
| 17 | + ## character of a preview is behind the thumb. | |
| 18 | + | |
| 19 | +func previewLine*(text: string): string = | |
| 20 | + ## The last line of a conversation, as one line. | |
| 21 | + ## | |
| 22 | + ## A pasted shell script or a long link is a card's worth of text otherwise, | |
| 23 | + ## and the cards stop reading as a list of rooms. | |
| 24 | + var line = newStringOfCap(text.len) | |
| 25 | + var inSpace = false | |
| 26 | + for c in text: | |
| 27 | + if c in {' ', '\t', '\n', '\r'}: | |
| 28 | + if not inSpace: line.add ' ' | |
| 29 | + inSpace = true | |
| 30 | + else: | |
| 31 | + line.add c | |
| 32 | + inSpace = false | |
| 33 | + # Runes and not bytes. Clojure's `count` and `subs` are characters, and a | |
| 34 | + # byte slice at 59 can land in the middle of one — which for a preview full | |
| 35 | + # of emoji is a truncation that produces mojibake rather than an ellipsis. | |
| 36 | + if line.runeLen > 60: line.runeSubStr(0, 59) & "…" else: line | |
| 37 | + | |
| 38 | +func conversationRow(r: Room): Node = | |
| 39 | + var badges = vbox(%*{"key": "badges"}) | |
| 40 | + # A room we have not joined is one the reader can see but is not in — worth | |
| 41 | + # saying, because Open and Close both work on it either way. | |
| 42 | + let away = not dm(r.name) and not r.joined | |
| 43 | + if away or r.unread > 0: | |
| 44 | + var row = hbox(%*{"spacing": 12, "wrap": false}) | |
| 45 | + if away: | |
| 46 | + row.children.add status("not joined", live = false) | |
| 47 | + if r.unread > 0: | |
| 48 | + row.children.add label((if r.mention: "◆ @ " else: "● ") & $r.unread) | |
| 49 | + badges.children.add row | |
| 50 | + | |
| 51 | + n("vbox", %*{"key": r.name, "marginRight": listGutter}, @[ | |
| 52 | + card( | |
| 53 | + title2(r.name), | |
| 54 | + badges, | |
| 55 | + dimLabel(previewLine(r.lastPreview)), | |
| 56 | + hbox(%*{"spacing": 8, "wrap": false}, | |
| 57 | + button("Open", "room.open:" & r.name), | |
| 58 | + button("Close", "room.leave:" & r.name)))]) | |
| 59 | + | |
| 60 | +func chatsScreen*(s: State, connected: bool): Node = | |
| 61 | + let buffers = channelList(s.rooms, s.search) | |
| 62 | + | |
| 63 | + var head = vbox(%*{"key": "head", "spacing": 8, "marginRight": listGutter}, | |
| 64 | + title(if connected: "Logged in as " & s.formNick else: "Chats"), | |
| 65 | + errorNote(s)) | |
| 66 | + | |
| 67 | + # `@nick` opens a DM and `#room` joins a channel, and the button says which | |
| 68 | + # so the reader is not guessing what Enter will do. | |
| 69 | + var joinRow = hbox(%*{"spacing": 8, "align": "end"}, | |
| 70 | + button(if s.joinInput.startsWith("@"): "Message" else: "Join", "join"), | |
| 71 | + entry("join-input", s.joinInput, "#channel or @nick", "join-input.change", | |
| 72 | + onSubmit = "join")) | |
| 73 | + | |
| 74 | + var searchRow = hbox(%*{"spacing": 8, "align": "end"}) | |
| 75 | + if s.search.len > 0: | |
| 76 | + searchRow.children.add button("✕", "search.clear") | |
| 77 | + searchRow.children.add entry("search", s.search, "Search channels", | |
| 78 | + "search.change") | |
| 79 | + | |
| 80 | + head.children.add card(vbox(%*{"spacing": 8}, joinRow, searchRow)) | |
| 81 | + | |
| 82 | + var body: Node | |
| 83 | + if buffers.len > 0: | |
| 84 | + body = vbox(%*{"spacing": 8}) | |
| 85 | + for b in buffers: | |
| 86 | + body.children.add conversationRow(b) | |
| 87 | + else: | |
| 88 | + body = n("vbox", %*{"marginRight": listGutter}, @[ | |
| 89 | + card(dimLabel("No conversations yet — join a channel."))]) | |
| 90 | + | |
| 91 | + vbox(%*{"spacing": 8, "margin": 12, "fillHeight": true}, | |
| 92 | + head, | |
| 93 | + vbox(%*{"key": "list", "fillHeight": true}, | |
| 94 | + scroll(%*{"scrollKey": "chats-list", "orientation": "vertical", | |
| 95 | + "reserve": belowList(s)}, body)), | |
| 96 | + vbox(%*{"key": "foot", "spacing": 8}, | |
| 97 | + separator(), | |
| 98 | + tabBar(s))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,98 @@ | |||
| 1 | +## The conversation list. | ||
| 2 | +## | ||
| 3 | +## Transcribed from `common/frq/screens/chats.cljc`. The terminal branches are | ||
| 4 | +## dropped rather than translated — there is no terminal frontend any more, so | ||
| 5 | +## `conversation-row`'s two layouts collapse to the one a window uses. | ||
| 6 | + | ||
| 7 | +import std/[json, strutils, tables] | ||
| 8 | +# `runeLen` and `runeSubStr` only: a plain `import std/unicode` brings a | ||
| 9 | +# `title` that is ambiguous against `ui.title`, which is the one this file | ||
| 10 | +# means every time it says it. | ||
| 11 | +from std/unicode import runeLen, runeSubStr | ||
| 12 | +import ../ui, ../cells, ../model, ../rooms | ||
| 13 | +import frame, connect | ||
| 14 | + | ||
| 15 | +const listGutter = 16 | ||
| 16 | + ## The scrollbar's room. Without it the cards sit under it and the last | ||
| 17 | + ## character of a preview is behind the thumb. | ||
| 18 | + | ||
| 19 | +func previewLine*(text: string): string = | ||
| 20 | + ## The last line of a conversation, as one line. | ||
| 21 | + ## | ||
| 22 | + ## A pasted shell script or a long link is a card's worth of text otherwise, | ||
| 23 | + ## and the cards stop reading as a list of rooms. | ||
| 24 | + var line = newStringOfCap(text.len) | ||
| 25 | + var inSpace = false | ||
| 26 | + for c in text: | ||
| 27 | + if c in {' ', '\t', '\n', '\r'}: | ||
| 28 | + if not inSpace: line.add ' ' | ||
| 29 | + inSpace = true | ||
| 30 | + else: | ||
| 31 | + line.add c | ||
| 32 | + inSpace = false | ||
| 33 | + # Runes and not bytes. Clojure's `count` and `subs` are characters, and a | ||
| 34 | + # byte slice at 59 can land in the middle of one — which for a preview full | ||
| 35 | + # of emoji is a truncation that produces mojibake rather than an ellipsis. | ||
| 36 | + if line.runeLen > 60: line.runeSubStr(0, 59) & "…" else: line | ||
| 37 | + | ||
| 38 | +func conversationRow(r: Room): Node = | ||
| 39 | + var badges = vbox(%*{"key": "badges"}) | ||
| 40 | + # A room we have not joined is one the reader can see but is not in — worth | ||
| 41 | + # saying, because Open and Close both work on it either way. | ||
| 42 | + let away = not dm(r.name) and not r.joined | ||
| 43 | + if away or r.unread > 0: | ||
| 44 | + var row = hbox(%*{"spacing": 12, "wrap": false}) | ||
| 45 | + if away: | ||
| 46 | + row.children.add status("not joined", live = false) | ||
| 47 | + if r.unread > 0: | ||
| 48 | + row.children.add label((if r.mention: "◆ @ " else: "● ") & $r.unread) | ||
| 49 | + badges.children.add row | ||
| 50 | + | ||
| 51 | + n("vbox", %*{"key": r.name, "marginRight": listGutter}, @[ | ||
| 52 | + card( | ||
| 53 | + title2(r.name), | ||
| 54 | + badges, | ||
| 55 | + dimLabel(previewLine(r.lastPreview)), | ||
| 56 | + hbox(%*{"spacing": 8, "wrap": false}, | ||
| 57 | + button("Open", "room.open:" & r.name), | ||
| 58 | + button("Close", "room.leave:" & r.name)))]) | ||
| 59 | + | ||
| 60 | +func chatsScreen*(s: State, connected: bool): Node = | ||
| 61 | + let buffers = channelList(s.rooms, s.search) | ||
| 62 | + | ||
| 63 | + var head = vbox(%*{"key": "head", "spacing": 8, "marginRight": listGutter}, | ||
| 64 | + title(if connected: "Logged in as " & s.formNick else: "Chats"), | ||
| 65 | + errorNote(s)) | ||
| 66 | + | ||
| 67 | + # `@nick` opens a DM and `#room` joins a channel, and the button says which | ||
| 68 | + # so the reader is not guessing what Enter will do. | ||
| 69 | + var joinRow = hbox(%*{"spacing": 8, "align": "end"}, | ||
| 70 | + button(if s.joinInput.startsWith("@"): "Message" else: "Join", "join"), | ||
| 71 | + entry("join-input", s.joinInput, "#channel or @nick", "join-input.change", | ||
| 72 | + onSubmit = "join")) | ||
| 73 | + | ||
| 74 | + var searchRow = hbox(%*{"spacing": 8, "align": "end"}) | ||
| 75 | + if s.search.len > 0: | ||
| 76 | + searchRow.children.add button("✕", "search.clear") | ||
| 77 | + searchRow.children.add entry("search", s.search, "Search channels", | ||
| 78 | + "search.change") | ||
| 79 | + | ||
| 80 | + head.children.add card(vbox(%*{"spacing": 8}, joinRow, searchRow)) | ||
| 81 | + | ||
| 82 | + var body: Node | ||
| 83 | + if buffers.len > 0: | ||
| 84 | + body = vbox(%*{"spacing": 8}) | ||
| 85 | + for b in buffers: | ||
| 86 | + body.children.add conversationRow(b) | ||
| 87 | + else: | ||
| 88 | + body = n("vbox", %*{"marginRight": listGutter}, @[ | ||
| 89 | + card(dimLabel("No conversations yet — join a channel."))]) | ||
| 90 | + | ||
| 91 | + vbox(%*{"spacing": 8, "margin": 12, "fillHeight": true}, | ||
| 92 | + head, | ||
| 93 | + vbox(%*{"key": "list", "fillHeight": true}, | ||
| 94 | + scroll(%*{"scrollKey": "chats-list", "orientation": "vertical", | ||
| 95 | + "reserve": belowList(s)}, body)), | ||
| 96 | + vbox(%*{"key": "foot", "spacing": 8}, | ||
| 97 | + separator(), | ||
| 98 | + tabBar(s))) | ||
added
nim/src/frq/screens/connect.nim +119 -0 | new file mode 100644 | ||
| @@ -0,0 +1,119 @@ | ||
| 1 | +## The connect screen. | |
| 2 | +## | |
| 3 | +## A faithful transcription of `common/frq/screens/connect.cljc` — the same | |
| 4 | +## copy, the same fields, the same three modes — so the two can be put side by | |
| 5 | +## side and any disagreement is a bug rather than an opinion. | |
| 6 | +## | |
| 7 | +## Where the Clojure derefs a cell this reads a field, and where it puts a | |
| 8 | +## closure in `:on-click` this puts an event id. That substitution is the only | |
| 9 | +## systematic difference between the two files. | |
| 10 | + | |
| 11 | +import std/json | |
| 12 | +import ../ui, ../cells | |
| 13 | + | |
| 14 | +const transportNote* = | |
| 15 | + "TLS comes from dart:io, so :6697 works here; untick it for a plain :6667 listener." | |
| 16 | + | |
| 17 | +func errorNote*(s: State): Node = | |
| 18 | + ## Always a node, never nothing. | |
| 19 | + ## | |
| 20 | + ## A conditional child that disappears shifts every sibling after it, and a | |
| 21 | + ## renderer matching children by position would patch the header into a card | |
| 22 | + ## when an error appeared mid-screen. A stable wrapper with a stable key | |
| 23 | + ## keeps the shape of the tree fixed and only its contents changing. | |
| 24 | + result = vbox(%*{"key": "error-note", "spacing": 6}) | |
| 25 | + if s.hasError: | |
| 26 | + result.children.add card( | |
| 27 | + label("⚠ " & s.error), | |
| 28 | + button("Dismiss", "error.dismiss")) | |
| 29 | + | |
| 30 | +func modeTabs(s: State): Node = | |
| 31 | + hbox(%*{"spacing": 8}, | |
| 32 | + button("Guest", "mode.guest", | |
| 33 | + if s.authMode == amGuest: "primary" else: "default"), | |
| 34 | + button("Bluesky", "mode.bluesky", | |
| 35 | + if s.authMode == amBluesky: "primary" else: "default"), | |
| 36 | + button("App password", "mode.app-password", | |
| 37 | + if s.authMode == amAppPassword: "primary" else: "default")) | |
| 38 | + | |
| 39 | +func serverFields(s: State): Node = | |
| 40 | + ## Every entry carries a key. A renderer that keeps a text controller per | |
| 41 | + ## field needs a stable name for it, and without one the host and the port | |
| 42 | + ## shared a controller and both showed the port. | |
| 43 | + vbox(%*{"spacing": 6}, | |
| 44 | + label("Server"), | |
| 45 | + hbox(%*{"spacing": 8}, | |
| 46 | + entry("host", s.formHost, "host", "host.change", width = 220), | |
| 47 | + entry("port", s.formPort, "6697", "port.change", width = 90)), | |
| 48 | + checkbutton("TLS", s.formTls, "tls.toggle")) | |
| 49 | + | |
| 50 | +func authFields(s: State): Node = | |
| 51 | + case s.authMode | |
| 52 | + of amBluesky: | |
| 53 | + result = vbox(%*{"spacing": 6}, | |
| 54 | + title2("Sign in with Bluesky"), | |
| 55 | + dimLabel("Opens your browser for AT Protocol OAuth. freeq's broker " & | |
| 56 | + "hands back a token; no password passes through frq."), | |
| 57 | + label("Handle"), | |
| 58 | + entry("handle", s.formHandle, "alice.bsky.social", "handle.change", | |
| 59 | + width = 320)) | |
| 60 | + # Both of these are stable wrappers for the reason `errorNote` is one. | |
| 61 | + var remembered = vbox(%*{"key": "remembered", "spacing": 4}) | |
| 62 | + if s.brokerToken.len > 0: | |
| 63 | + remembered.children.add vbox(%*{"spacing": 4}, | |
| 64 | + dimLabel("Session remembered — Connect will not need the browser."), | |
| 65 | + button("Forget saved session", "session.forget")) | |
| 66 | + result.children.add remembered | |
| 67 | + | |
| 68 | + var login = vbox(%*{"key": "login-url", "spacing": 4}) | |
| 69 | + if s.loginUrl.len > 0: | |
| 70 | + login.children.add vbox(%*{"spacing": 4}, | |
| 71 | + dimLabel("If the browser did not open, visit:"), | |
| 72 | + label(s.loginUrl)) | |
| 73 | + result.children.add login | |
| 74 | + | |
| 75 | + of amAppPassword: | |
| 76 | + result = vbox(%*{"spacing": 6}, | |
| 77 | + title2("Sign in with an app password"), | |
| 78 | + dimLabel("No browser. Your app password goes to your own PDS; freeq " & | |
| 79 | + "is handed the session it mints."), | |
| 80 | + label("Handle"), | |
| 81 | + entry("handle", s.formHandle, "alice.bsky.social", "handle.change", | |
| 82 | + width = 320), | |
| 83 | + label("App password"), | |
| 84 | + entry("app-password", s.formAppPassword, "xxxx-xxxx-xxxx-xxxx", | |
| 85 | + "app-password.change", width = 320), | |
| 86 | + dimLabel("Make one at bsky.app → Settings → App Passwords.")) | |
| 87 | + | |
| 88 | + of amGuest: | |
| 89 | + result = vbox(%*{"spacing": 6}, | |
| 90 | + title2("Connect as guest"), | |
| 91 | + label("Nick"), | |
| 92 | + entry("nick", s.formNick, "your nick", "nick.change", width = 320)) | |
| 93 | + | |
| 94 | +func connectAction(s: State): Node = | |
| 95 | + if s.connecting: | |
| 96 | + # Cancel beside the spinner. A connection that never completes — a host | |
| 97 | + # that does not answer, a TLS handshake that hangs — is otherwise a | |
| 98 | + # spinner with no way out but killing the window. | |
| 99 | + hbox(%*{"spacing": 8}, | |
| 100 | + spinner(), | |
| 101 | + dimLabel(s.status), | |
| 102 | + button("Cancel", "cancel")) | |
| 103 | + else: | |
| 104 | + hbox(%*{"spacing": 8}, | |
| 105 | + button("Connect", "connect", "primary"), | |
| 106 | + dimLabel(s.status)) | |
| 107 | + | |
| 108 | +func connectScreen*(s: State): Node = | |
| 109 | + page(%*{"maxWidth": 520}, | |
| 110 | + title("frq"), | |
| 111 | + dimLabel("freeq client — guest, or your Bluesky identity."), | |
| 112 | + errorNote(s), | |
| 113 | + card( | |
| 114 | + modeTabs(s), | |
| 115 | + authFields(s), | |
| 116 | + serverFields(s), | |
| 117 | + separator(), | |
| 118 | + connectAction(s)), | |
| 119 | + dimLabel(transportNote)) | |
| new file mode 100644 | |||
| @@ -0,0 +1,119 @@ | |||
| 1 | +## The connect screen. | ||
| 2 | +## | ||
| 3 | +## A faithful transcription of `common/frq/screens/connect.cljc` — the same | ||
| 4 | +## copy, the same fields, the same three modes — so the two can be put side by | ||
| 5 | +## side and any disagreement is a bug rather than an opinion. | ||
| 6 | +## | ||
| 7 | +## Where the Clojure derefs a cell this reads a field, and where it puts a | ||
| 8 | +## closure in `:on-click` this puts an event id. That substitution is the only | ||
| 9 | +## systematic difference between the two files. | ||
| 10 | + | ||
| 11 | +import std/json | ||
| 12 | +import ../ui, ../cells | ||
| 13 | + | ||
| 14 | +const transportNote* = | ||
| 15 | + "TLS comes from dart:io, so :6697 works here; untick it for a plain :6667 listener." | ||
| 16 | + | ||
| 17 | +func errorNote*(s: State): Node = | ||
| 18 | + ## Always a node, never nothing. | ||
| 19 | + ## | ||
| 20 | + ## A conditional child that disappears shifts every sibling after it, and a | ||
| 21 | + ## renderer matching children by position would patch the header into a card | ||
| 22 | + ## when an error appeared mid-screen. A stable wrapper with a stable key | ||
| 23 | + ## keeps the shape of the tree fixed and only its contents changing. | ||
| 24 | + result = vbox(%*{"key": "error-note", "spacing": 6}) | ||
| 25 | + if s.hasError: | ||
| 26 | + result.children.add card( | ||
| 27 | + label("⚠ " & s.error), | ||
| 28 | + button("Dismiss", "error.dismiss")) | ||
| 29 | + | ||
| 30 | +func modeTabs(s: State): Node = | ||
| 31 | + hbox(%*{"spacing": 8}, | ||
| 32 | + button("Guest", "mode.guest", | ||
| 33 | + if s.authMode == amGuest: "primary" else: "default"), | ||
| 34 | + button("Bluesky", "mode.bluesky", | ||
| 35 | + if s.authMode == amBluesky: "primary" else: "default"), | ||
| 36 | + button("App password", "mode.app-password", | ||
| 37 | + if s.authMode == amAppPassword: "primary" else: "default")) | ||
| 38 | + | ||
| 39 | +func serverFields(s: State): Node = | ||
| 40 | + ## Every entry carries a key. A renderer that keeps a text controller per | ||
| 41 | + ## field needs a stable name for it, and without one the host and the port | ||
| 42 | + ## shared a controller and both showed the port. | ||
| 43 | + vbox(%*{"spacing": 6}, | ||
| 44 | + label("Server"), | ||
| 45 | + hbox(%*{"spacing": 8}, | ||
| 46 | + entry("host", s.formHost, "host", "host.change", width = 220), | ||
| 47 | + entry("port", s.formPort, "6697", "port.change", width = 90)), | ||
| 48 | + checkbutton("TLS", s.formTls, "tls.toggle")) | ||
| 49 | + | ||
| 50 | +func authFields(s: State): Node = | ||
| 51 | + case s.authMode | ||
| 52 | + of amBluesky: | ||
| 53 | + result = vbox(%*{"spacing": 6}, | ||
| 54 | + title2("Sign in with Bluesky"), | ||
| 55 | + dimLabel("Opens your browser for AT Protocol OAuth. freeq's broker " & | ||
| 56 | + "hands back a token; no password passes through frq."), | ||
| 57 | + label("Handle"), | ||
| 58 | + entry("handle", s.formHandle, "alice.bsky.social", "handle.change", | ||
| 59 | + width = 320)) | ||
| 60 | + # Both of these are stable wrappers for the reason `errorNote` is one. | ||
| 61 | + var remembered = vbox(%*{"key": "remembered", "spacing": 4}) | ||
| 62 | + if s.brokerToken.len > 0: | ||
| 63 | + remembered.children.add vbox(%*{"spacing": 4}, | ||
| 64 | + dimLabel("Session remembered — Connect will not need the browser."), | ||
| 65 | + button("Forget saved session", "session.forget")) | ||
| 66 | + result.children.add remembered | ||
| 67 | + | ||
| 68 | + var login = vbox(%*{"key": "login-url", "spacing": 4}) | ||
| 69 | + if s.loginUrl.len > 0: | ||
| 70 | + login.children.add vbox(%*{"spacing": 4}, | ||
| 71 | + dimLabel("If the browser did not open, visit:"), | ||
| 72 | + label(s.loginUrl)) | ||
| 73 | + result.children.add login | ||
| 74 | + | ||
| 75 | + of amAppPassword: | ||
| 76 | + result = vbox(%*{"spacing": 6}, | ||
| 77 | + title2("Sign in with an app password"), | ||
| 78 | + dimLabel("No browser. Your app password goes to your own PDS; freeq " & | ||
| 79 | + "is handed the session it mints."), | ||
| 80 | + label("Handle"), | ||
| 81 | + entry("handle", s.formHandle, "alice.bsky.social", "handle.change", | ||
| 82 | + width = 320), | ||
| 83 | + label("App password"), | ||
| 84 | + entry("app-password", s.formAppPassword, "xxxx-xxxx-xxxx-xxxx", | ||
| 85 | + "app-password.change", width = 320), | ||
| 86 | + dimLabel("Make one at bsky.app → Settings → App Passwords.")) | ||
| 87 | + | ||
| 88 | + of amGuest: | ||
| 89 | + result = vbox(%*{"spacing": 6}, | ||
| 90 | + title2("Connect as guest"), | ||
| 91 | + label("Nick"), | ||
| 92 | + entry("nick", s.formNick, "your nick", "nick.change", width = 320)) | ||
| 93 | + | ||
| 94 | +func connectAction(s: State): Node = | ||
| 95 | + if s.connecting: | ||
| 96 | + # Cancel beside the spinner. A connection that never completes — a host | ||
| 97 | + # that does not answer, a TLS handshake that hangs — is otherwise a | ||
| 98 | + # spinner with no way out but killing the window. | ||
| 99 | + hbox(%*{"spacing": 8}, | ||
| 100 | + spinner(), | ||
| 101 | + dimLabel(s.status), | ||
| 102 | + button("Cancel", "cancel")) | ||
| 103 | + else: | ||
| 104 | + hbox(%*{"spacing": 8}, | ||
| 105 | + button("Connect", "connect", "primary"), | ||
| 106 | + dimLabel(s.status)) | ||
| 107 | + | ||
| 108 | +func connectScreen*(s: State): Node = | ||
| 109 | + page(%*{"maxWidth": 520}, | ||
| 110 | + title("frq"), | ||
| 111 | + dimLabel("freeq client — guest, or your Bluesky identity."), | ||
| 112 | + errorNote(s), | ||
| 113 | + card( | ||
| 114 | + modeTabs(s), | ||
| 115 | + authFields(s), | ||
| 116 | + serverFields(s), | ||
| 117 | + separator(), | ||
| 118 | + connectAction(s)), | ||
| 119 | + dimLabel(transportNote)) | ||
added
nim/src/frq/screens/frame.nim +49 -0 | new file mode 100644 | ||
| @@ -0,0 +1,49 @@ | ||
| 1 | +## The tab bar, and the frame the three tabbed screens share. | |
| 2 | +## | |
| 3 | +## From `common/frq/screens/chats.cljc` and `settings.cljc`, which is where | |
| 4 | +## they live in the Clojure — pulled together here because they are one idea | |
| 5 | +## and neither screen owns them. | |
| 6 | + | |
| 7 | +import std/json | |
| 8 | +import ../ui, ../cells | |
| 9 | + | |
| 10 | +func tabBar*(s: State): Node = | |
| 11 | + hbox(%*{"spacing": 8}, | |
| 12 | + button("Chats", "screen.chats", | |
| 13 | + if s.screen == scChats: "primary" else: "default"), | |
| 14 | + button("Discover", "screen.discover", | |
| 15 | + if s.screen == scDiscover: "primary" else: "default"), | |
| 16 | + button("Settings", "screen.settings", | |
| 17 | + if s.screen == scSettings: "primary" else: "default")) | |
| 18 | + | |
| 19 | +func belowList*(s: State): int = | |
| 20 | + ## How many points the strip under the list needs, so the scroll view knows | |
| 21 | + ## what to reserve. A separator and a row of tabs. | |
| 22 | + ## | |
| 23 | + ## Counted in chrome rows rather than points in the Clojure, because a | |
| 24 | + ## terminal's row is one cell and a window's is 34. There is no terminal any | |
| 25 | + ## more, so this is the window's number. | |
| 26 | + 46 | |
| 27 | + | |
| 28 | +func tabScreen*(s: State, title0, scrollKey: string, | |
| 29 | + body: varargs[Node]): Node = | |
| 30 | + ## One of the three screens the tab bar moves between: the title at the top, | |
| 31 | + ## the tabs pinned at the bottom, and the body scrolling between them. | |
| 32 | + ## | |
| 33 | + ## The same shape for all three, so switching tabs moves nothing but the | |
| 34 | + ## middle. As pages they were centred columns of their own widths with the | |
| 35 | + ## tabs wherever the content happened to end, and every switch resized the | |
| 36 | + ## screen under the pointer. | |
| 37 | + var list = vbox(%*{"key": "list", "fillHeight": true}) | |
| 38 | + var sc = scroll(%*{"scrollKey": scrollKey, "orientation": "vertical", | |
| 39 | + "reserve": belowList(s), "spacing": 8}) | |
| 40 | + for b in body: | |
| 41 | + if not b.isNil: sc.children.add b | |
| 42 | + list.children.add sc | |
| 43 | + | |
| 44 | + vbox(%*{"spacing": 8, "margin": 12, "fillHeight": true}, | |
| 45 | + title(title0), | |
| 46 | + list, | |
| 47 | + vbox(%*{"key": "foot", "spacing": 8}, | |
| 48 | + separator(), | |
| 49 | + tabBar(s))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,49 @@ | |||
| 1 | +## The tab bar, and the frame the three tabbed screens share. | ||
| 2 | +## | ||
| 3 | +## From `common/frq/screens/chats.cljc` and `settings.cljc`, which is where | ||
| 4 | +## they live in the Clojure — pulled together here because they are one idea | ||
| 5 | +## and neither screen owns them. | ||
| 6 | + | ||
| 7 | +import std/json | ||
| 8 | +import ../ui, ../cells | ||
| 9 | + | ||
| 10 | +func tabBar*(s: State): Node = | ||
| 11 | + hbox(%*{"spacing": 8}, | ||
| 12 | + button("Chats", "screen.chats", | ||
| 13 | + if s.screen == scChats: "primary" else: "default"), | ||
| 14 | + button("Discover", "screen.discover", | ||
| 15 | + if s.screen == scDiscover: "primary" else: "default"), | ||
| 16 | + button("Settings", "screen.settings", | ||
| 17 | + if s.screen == scSettings: "primary" else: "default")) | ||
| 18 | + | ||
| 19 | +func belowList*(s: State): int = | ||
| 20 | + ## How many points the strip under the list needs, so the scroll view knows | ||
| 21 | + ## what to reserve. A separator and a row of tabs. | ||
| 22 | + ## | ||
| 23 | + ## Counted in chrome rows rather than points in the Clojure, because a | ||
| 24 | + ## terminal's row is one cell and a window's is 34. There is no terminal any | ||
| 25 | + ## more, so this is the window's number. | ||
| 26 | + 46 | ||
| 27 | + | ||
| 28 | +func tabScreen*(s: State, title0, scrollKey: string, | ||
| 29 | + body: varargs[Node]): Node = | ||
| 30 | + ## One of the three screens the tab bar moves between: the title at the top, | ||
| 31 | + ## the tabs pinned at the bottom, and the body scrolling between them. | ||
| 32 | + ## | ||
| 33 | + ## The same shape for all three, so switching tabs moves nothing but the | ||
| 34 | + ## middle. As pages they were centred columns of their own widths with the | ||
| 35 | + ## tabs wherever the content happened to end, and every switch resized the | ||
| 36 | + ## screen under the pointer. | ||
| 37 | + var list = vbox(%*{"key": "list", "fillHeight": true}) | ||
| 38 | + var sc = scroll(%*{"scrollKey": scrollKey, "orientation": "vertical", | ||
| 39 | + "reserve": belowList(s), "spacing": 8}) | ||
| 40 | + for b in body: | ||
| 41 | + if not b.isNil: sc.children.add b | ||
| 42 | + list.children.add sc | ||
| 43 | + | ||
| 44 | + vbox(%*{"spacing": 8, "margin": 12, "fillHeight": true}, | ||
| 45 | + title(title0), | ||
| 46 | + list, | ||
| 47 | + vbox(%*{"key": "foot", "spacing": 8}, | ||
| 48 | + separator(), | ||
| 49 | + tabBar(s))) | ||
added
nim/src/frq/screens/settings.nim +59 -0 | new file mode 100644 | ||
| @@ -0,0 +1,59 @@ | ||
| 1 | +## Discover and Settings — the small two. | |
| 2 | +## | |
| 3 | +## A list of rooms to join and a page of switches, transcribed from | |
| 4 | +## `common/frq/screens/settings.cljc`. | |
| 5 | + | |
| 6 | +import std/[json, tables] | |
| 7 | +import ../ui, ../cells, ../model | |
| 8 | +import frame, connect | |
| 9 | + | |
| 10 | +func discoverScreen*(s: State): Node = | |
| 11 | + var body = @[dimLabel("Popular channels on freeq."), errorNote(s)] | |
| 12 | + for (name, blurb) in popularChannels: | |
| 13 | + let joined = s.rooms.hasKey(name) and s.rooms[name].joined | |
| 14 | + body.add n("card", %*{"key": name}, @[ | |
| 15 | + title2(name), | |
| 16 | + dimLabel(blurb), | |
| 17 | + button(if joined: "Open" else: "Join", | |
| 18 | + (if joined: "room.open:" else: "room.join:") & name)]) | |
| 19 | + tabScreen(s, "Discover", "discover-list", body) | |
| 20 | + | |
| 21 | +func settingsScreen*(s: State, connected: bool, desktop: bool): Node = | |
| 22 | + var identity = vbox(%*{"key": "identity", "spacing": 6}) | |
| 23 | + if s.formHandle.len > 0: | |
| 24 | + identity.children.add label(s.formHandle) | |
| 25 | + if s.brokerToken.len > 0: | |
| 26 | + identity.children.add button("Forget Bluesky session", "session.forget", | |
| 27 | + "destructive") | |
| 28 | + else: | |
| 29 | + identity.children.add dimLabel("Guest — not signed in.") | |
| 30 | + | |
| 31 | + # Nothing to disconnect from when there is no connection — the way back to | |
| 32 | + # the connect screen is what is wanted then. | |
| 33 | + var action = vbox(%*{"key": "connection-action"}) | |
| 34 | + action.children.add( | |
| 35 | + if connected: button("Disconnect", "disconnect", "destructive") | |
| 36 | + else: button("Back to connect", "screen.connect")) | |
| 37 | + | |
| 38 | + var about = card( | |
| 39 | + title2("frq"), | |
| 40 | + dimLabel("freeq client — Nim over Flutter, on Android, Linux and the web.")) | |
| 41 | + # No Quit where there is nothing to quit: closing an app is a window's idea, | |
| 42 | + # and Android has its own way of leaving one. | |
| 43 | + if desktop: | |
| 44 | + about.children.add button("Quit", "quit") | |
| 45 | + | |
| 46 | + tabScreen(s, "Settings", "settings-list", [ | |
| 47 | + card( | |
| 48 | + title2("Connection"), | |
| 49 | + status(s.status, live = connected), | |
| 50 | + identity, | |
| 51 | + separator(), | |
| 52 | + action), | |
| 53 | + card( | |
| 54 | + title2("Messages"), | |
| 55 | + checkbutton("Hide join/part messages", s.hideJoinPart, | |
| 56 | + "join-part.toggle"), | |
| 57 | + dimLabel("Hides other people arriving, leaving and quitting. The " & | |
| 58 | + "people panel still follows who is here.")), | |
| 59 | + about]) | |
| new file mode 100644 | |||
| @@ -0,0 +1,59 @@ | |||
| 1 | +## Discover and Settings — the small two. | ||
| 2 | +## | ||
| 3 | +## A list of rooms to join and a page of switches, transcribed from | ||
| 4 | +## `common/frq/screens/settings.cljc`. | ||
| 5 | + | ||
| 6 | +import std/[json, tables] | ||
| 7 | +import ../ui, ../cells, ../model | ||
| 8 | +import frame, connect | ||
| 9 | + | ||
| 10 | +func discoverScreen*(s: State): Node = | ||
| 11 | + var body = @[dimLabel("Popular channels on freeq."), errorNote(s)] | ||
| 12 | + for (name, blurb) in popularChannels: | ||
| 13 | + let joined = s.rooms.hasKey(name) and s.rooms[name].joined | ||
| 14 | + body.add n("card", %*{"key": name}, @[ | ||
| 15 | + title2(name), | ||
| 16 | + dimLabel(blurb), | ||
| 17 | + button(if joined: "Open" else: "Join", | ||
| 18 | + (if joined: "room.open:" else: "room.join:") & name)]) | ||
| 19 | + tabScreen(s, "Discover", "discover-list", body) | ||
| 20 | + | ||
| 21 | +func settingsScreen*(s: State, connected: bool, desktop: bool): Node = | ||
| 22 | + var identity = vbox(%*{"key": "identity", "spacing": 6}) | ||
| 23 | + if s.formHandle.len > 0: | ||
| 24 | + identity.children.add label(s.formHandle) | ||
| 25 | + if s.brokerToken.len > 0: | ||
| 26 | + identity.children.add button("Forget Bluesky session", "session.forget", | ||
| 27 | + "destructive") | ||
| 28 | + else: | ||
| 29 | + identity.children.add dimLabel("Guest — not signed in.") | ||
| 30 | + | ||
| 31 | + # Nothing to disconnect from when there is no connection — the way back to | ||
| 32 | + # the connect screen is what is wanted then. | ||
| 33 | + var action = vbox(%*{"key": "connection-action"}) | ||
| 34 | + action.children.add( | ||
| 35 | + if connected: button("Disconnect", "disconnect", "destructive") | ||
| 36 | + else: button("Back to connect", "screen.connect")) | ||
| 37 | + | ||
| 38 | + var about = card( | ||
| 39 | + title2("frq"), | ||
| 40 | + dimLabel("freeq client — Nim over Flutter, on Android, Linux and the web.")) | ||
| 41 | + # No Quit where there is nothing to quit: closing an app is a window's idea, | ||
| 42 | + # and Android has its own way of leaving one. | ||
| 43 | + if desktop: | ||
| 44 | + about.children.add button("Quit", "quit") | ||
| 45 | + | ||
| 46 | + tabScreen(s, "Settings", "settings-list", [ | ||
| 47 | + card( | ||
| 48 | + title2("Connection"), | ||
| 49 | + status(s.status, live = connected), | ||
| 50 | + identity, | ||
| 51 | + separator(), | ||
| 52 | + action), | ||
| 53 | + card( | ||
| 54 | + title2("Messages"), | ||
| 55 | + checkbutton("Hide join/part messages", s.hideJoinPart, | ||
| 56 | + "join-part.toggle"), | ||
| 57 | + dimLabel("Hides other people arriving, leaving and quitting. The " & | ||
| 58 | + "people panel still follows who is here.")), | ||
| 59 | + about]) | ||
modified
nim/src/frq/ui.nim +53 -0 | @@ -90,3 +90,56 @@ func scroll*(props: JsonNode, children: varargs[Node]): Node = | ||
| 90 | 90 | ## A list that is taller than the room it has. The renderer decides how that |
| 91 | 91 | ## is done; the tree only says that it is expected. |
| 92 | 92 | n("scroll", props, @children) |
| 93 | + | |
| 94 | +# ------------------------------------------------- the rest of the vocabulary | |
| 95 | +# | |
| 96 | +# Every tag the real screens use. `frq.hiccup` interprets exactly these into | |
| 97 | +# Flutter widgets today, so a tree emitted here describes the same screen it | |
| 98 | +# describes there — which is what makes the port a transcription rather than a | |
| 99 | +# redesign. | |
| 100 | + | |
| 101 | +func separator*(): Node = n("separator") | |
| 102 | + | |
| 103 | +func spacer*(size: int): Node = n("spacer", %*{"size": size}) | |
| 104 | + | |
| 105 | +func text*(body: string): Node = | |
| 106 | + ## Prose, as opposed to a `label`: wraps, and is the thing a message is. | |
| 107 | + n("text", %*{"text": body}) | |
| 108 | + | |
| 109 | +func link*(label, url: string): Node = | |
| 110 | + n("link", %*{"label": label, "url": url}) | |
| 111 | + | |
| 112 | +func image*(src: string, maxWidth = 0, maxHeight = 0, onClick = ""): Node = | |
| 113 | + var p = %*{"src": src} | |
| 114 | + if maxWidth > 0: p["maxWidth"] = %maxWidth | |
| 115 | + if maxHeight > 0: p["maxHeight"] = %maxHeight | |
| 116 | + if onClick.len > 0: p["onClick"] = %onClick | |
| 117 | + n("image", p) | |
| 118 | + | |
| 119 | +func avatar*(url, fallback: string, size = 24): Node = | |
| 120 | + ## A profile picture, or the letter to draw where there is none. The | |
| 121 | + ## fallback is here rather than in the renderer because which letter is a | |
| 122 | + ## question about the nick, and the nick is the tree's business. | |
| 123 | + n("avatar", %*{"url": url, "fallback": fallback, "size": size}) | |
| 124 | + | |
| 125 | +func reaction*(emoji: string, count: int, mine: bool, onClick: string): Node = | |
| 126 | + ## A pill under a message. `mine` is what makes it look pressed, and is why | |
| 127 | + ## this is not just a button with a count in it. | |
| 128 | + n("reaction", %*{"emoji": emoji, "count": count, "mine": mine, | |
| 129 | + "onClick": onClick}) | |
| 130 | + | |
| 131 | +func status*(label: string, live = false): Node = | |
| 132 | + ## A connection indicator. `live` is what makes the dot beside it green, and | |
| 133 | + ## is why this is not a plain label. | |
| 134 | + n("status", %*{"label": label, "live": live}) | |
| 135 | + | |
| 136 | +func emoji*(glyph, onClick: string): Node = | |
| 137 | + n("emoji", %*{"glyph": glyph, "onClick": onClick}) | |
| 138 | + | |
| 139 | +func dialog*(title: string, props: JsonNode, children: varargs[Node]): Node = | |
| 140 | + ## A panel over the screen rather than a screen of its own. `frq.screens.app` | |
| 141 | + ## floats it where there is a pointer and draws it in place where there is | |
| 142 | + ## not; the tree says only that it is a dialog. | |
| 143 | + var p = if props.isNil: newJObject() else: props | |
| 144 | + p["title"] = %title | |
| 145 | + n("dialog", p, @children) | |
| @@ -90,3 +90,56 @@ func scroll*(props: JsonNode, children: varargs[Node]): Node = | |||
| 90 | ## A list that is taller than the room it has. The renderer decides how that | 90 | ## A list that is taller than the room it has. The renderer decides how that |
| 91 | ## is done; the tree only says that it is expected. | 91 | ## is done; the tree only says that it is expected. |
| 92 | n("scroll", props, @children) | 92 | n("scroll", props, @children) |
| 93 | + | ||
| 94 | +# ------------------------------------------------- the rest of the vocabulary | ||
| 95 | +# | ||
| 96 | +# Every tag the real screens use. `frq.hiccup` interprets exactly these into | ||
| 97 | +# Flutter widgets today, so a tree emitted here describes the same screen it | ||
| 98 | +# describes there — which is what makes the port a transcription rather than a | ||
| 99 | +# redesign. | ||
| 100 | + | ||
| 101 | +func separator*(): Node = n("separator") | ||
| 102 | + | ||
| 103 | +func spacer*(size: int): Node = n("spacer", %*{"size": size}) | ||
| 104 | + | ||
| 105 | +func text*(body: string): Node = | ||
| 106 | + ## Prose, as opposed to a `label`: wraps, and is the thing a message is. | ||
| 107 | + n("text", %*{"text": body}) | ||
| 108 | + | ||
| 109 | +func link*(label, url: string): Node = | ||
| 110 | + n("link", %*{"label": label, "url": url}) | ||
| 111 | + | ||
| 112 | +func image*(src: string, maxWidth = 0, maxHeight = 0, onClick = ""): Node = | ||
| 113 | + var p = %*{"src": src} | ||
| 114 | + if maxWidth > 0: p["maxWidth"] = %maxWidth | ||
| 115 | + if maxHeight > 0: p["maxHeight"] = %maxHeight | ||
| 116 | + if onClick.len > 0: p["onClick"] = %onClick | ||
| 117 | + n("image", p) | ||
| 118 | + | ||
| 119 | +func avatar*(url, fallback: string, size = 24): Node = | ||
| 120 | + ## A profile picture, or the letter to draw where there is none. The | ||
| 121 | + ## fallback is here rather than in the renderer because which letter is a | ||
| 122 | + ## question about the nick, and the nick is the tree's business. | ||
| 123 | + n("avatar", %*{"url": url, "fallback": fallback, "size": size}) | ||
| 124 | + | ||
| 125 | +func reaction*(emoji: string, count: int, mine: bool, onClick: string): Node = | ||
| 126 | + ## A pill under a message. `mine` is what makes it look pressed, and is why | ||
| 127 | + ## this is not just a button with a count in it. | ||
| 128 | + n("reaction", %*{"emoji": emoji, "count": count, "mine": mine, | ||
| 129 | + "onClick": onClick}) | ||
| 130 | + | ||
| 131 | +func status*(label: string, live = false): Node = | ||
| 132 | + ## A connection indicator. `live` is what makes the dot beside it green, and | ||
| 133 | + ## is why this is not a plain label. | ||
| 134 | + n("status", %*{"label": label, "live": live}) | ||
| 135 | + | ||
| 136 | +func emoji*(glyph, onClick: string): Node = | ||
| 137 | + n("emoji", %*{"glyph": glyph, "onClick": onClick}) | ||
| 138 | + | ||
| 139 | +func dialog*(title: string, props: JsonNode, children: varargs[Node]): Node = | ||
| 140 | + ## A panel over the screen rather than a screen of its own. `frq.screens.app` | ||
| 141 | + ## floats it where there is a pointer and draws it in place where there is | ||
| 142 | + ## not; the tree says only that it is a dialog. | ||
| 143 | + var p = if props.isNil: newJObject() else: props | ||
| 144 | + p["title"] = %title | ||
| 145 | + n("dialog", p, @children) | ||
added
nim/tests/tcells.nim +62 -0 | new file mode 100644 | ||
| @@ -0,0 +1,62 @@ | ||
| 1 | +## The state record, and the few questions it answers itself. | |
| 2 | + | |
| 3 | +import std/[tables, unittest] | |
| 4 | +import frq/[cells, model] | |
| 5 | + | |
| 6 | +suite "initState": | |
| 7 | + test "starts on the connect screen, not connected": | |
| 8 | + let s = initState() | |
| 9 | + check s.screen == scConnect | |
| 10 | + check s.status == "Not connected" | |
| 11 | + check not s.connecting | |
| 12 | + | |
| 13 | + test "the form is filled in with the defaults the screen shows": | |
| 14 | + let s = initState() | |
| 15 | + check s.formHost == defaultHost | |
| 16 | + check s.formPort == defaultPort | |
| 17 | + check s.formTls | |
| 18 | + check s.formNick == "frq-guest" | |
| 19 | + check s.authMode == amGuest | |
| 20 | + | |
| 21 | + test "at-present, because a fresh conversation is at its end": | |
| 22 | + check initState().atPresent | |
| 23 | + | |
| 24 | + test "nothing is attached, replied to or being edited": | |
| 25 | + let s = initState() | |
| 26 | + check not s.attachment.has | |
| 27 | + check not s.replyingTo.has | |
| 28 | + check not s.editing.has | |
| 29 | + check not s.lightbox.has | |
| 30 | + | |
| 31 | +suite "wide": | |
| 32 | + test "a phone-width window is not wide": | |
| 33 | + var s = initState(); s.windowWidth = 420 | |
| 34 | + check not s.wide | |
| 35 | + test "past wideWidth it is": | |
| 36 | + var s = initState(); s.windowWidth = wideWidth | |
| 37 | + check s.wide | |
| 38 | + test "a window that has not reported yet is not": | |
| 39 | + # windowWidth starts at 0, and guessing wide would put a 320pt list beside | |
| 40 | + # a conversation on a phone. | |
| 41 | + check not initState().wide | |
| 42 | + | |
| 43 | +suite "currentRoom": | |
| 44 | + test "the room `current` names": | |
| 45 | + var s = initState() | |
| 46 | + s.rooms["#test"] = initRoom("#test") | |
| 47 | + s.current = "#test" | |
| 48 | + check s.currentRoom.name == "#test" | |
| 49 | + | |
| 50 | + test "an empty room where nothing is current": | |
| 51 | + # A value rather than an Option: every screen that asks is about to read | |
| 52 | + # `.messages`, and an empty list is the honest answer for "no room". | |
| 53 | + check initState().currentRoom.name == "" | |
| 54 | + | |
| 55 | + test "an empty room where `current` names one that is gone": | |
| 56 | + var s = initState(); s.current = "#vanished" | |
| 57 | + check s.currentRoom.name == "" | |
| 58 | + | |
| 59 | +suite "the constants the screens share": | |
| 60 | + test "popular channels are the discover list": | |
| 61 | + check popularChannels.len == 6 | |
| 62 | + check popularChannels[0][0] == "#general" | |
| new file mode 100644 | |||
| @@ -0,0 +1,62 @@ | |||
| 1 | +## The state record, and the few questions it answers itself. | ||
| 2 | + | ||
| 3 | +import std/[tables, unittest] | ||
| 4 | +import frq/[cells, model] | ||
| 5 | + | ||
| 6 | +suite "initState": | ||
| 7 | + test "starts on the connect screen, not connected": | ||
| 8 | + let s = initState() | ||
| 9 | + check s.screen == scConnect | ||
| 10 | + check s.status == "Not connected" | ||
| 11 | + check not s.connecting | ||
| 12 | + | ||
| 13 | + test "the form is filled in with the defaults the screen shows": | ||
| 14 | + let s = initState() | ||
| 15 | + check s.formHost == defaultHost | ||
| 16 | + check s.formPort == defaultPort | ||
| 17 | + check s.formTls | ||
| 18 | + check s.formNick == "frq-guest" | ||
| 19 | + check s.authMode == amGuest | ||
| 20 | + | ||
| 21 | + test "at-present, because a fresh conversation is at its end": | ||
| 22 | + check initState().atPresent | ||
| 23 | + | ||
| 24 | + test "nothing is attached, replied to or being edited": | ||
| 25 | + let s = initState() | ||
| 26 | + check not s.attachment.has | ||
| 27 | + check not s.replyingTo.has | ||
| 28 | + check not s.editing.has | ||
| 29 | + check not s.lightbox.has | ||
| 30 | + | ||
| 31 | +suite "wide": | ||
| 32 | + test "a phone-width window is not wide": | ||
| 33 | + var s = initState(); s.windowWidth = 420 | ||
| 34 | + check not s.wide | ||
| 35 | + test "past wideWidth it is": | ||
| 36 | + var s = initState(); s.windowWidth = wideWidth | ||
| 37 | + check s.wide | ||
| 38 | + test "a window that has not reported yet is not": | ||
| 39 | + # windowWidth starts at 0, and guessing wide would put a 320pt list beside | ||
| 40 | + # a conversation on a phone. | ||
| 41 | + check not initState().wide | ||
| 42 | + | ||
| 43 | +suite "currentRoom": | ||
| 44 | + test "the room `current` names": | ||
| 45 | + var s = initState() | ||
| 46 | + s.rooms["#test"] = initRoom("#test") | ||
| 47 | + s.current = "#test" | ||
| 48 | + check s.currentRoom.name == "#test" | ||
| 49 | + | ||
| 50 | + test "an empty room where nothing is current": | ||
| 51 | + # A value rather than an Option: every screen that asks is about to read | ||
| 52 | + # `.messages`, and an empty list is the honest answer for "no room". | ||
| 53 | + check initState().currentRoom.name == "" | ||
| 54 | + | ||
| 55 | + test "an empty room where `current` names one that is gone": | ||
| 56 | + var s = initState(); s.current = "#vanished" | ||
| 57 | + check s.currentRoom.name == "" | ||
| 58 | + | ||
| 59 | +suite "the constants the screens share": | ||
| 60 | + test "popular channels are the discover list": | ||
| 61 | + check popularChannels.len == 6 | ||
| 62 | + check popularChannels[0][0] == "#general" | ||
added
nim/tests/tscreens.nim +235 -0 | new file mode 100644 | ||
| @@ -0,0 +1,235 @@ | ||
| 1 | +## The screens, as pure functions of the state. | |
| 2 | +## | |
| 3 | +## These are the tests the ClojureDart screens never had and could not easily | |
| 4 | +## have: a screen there is hiccup over cells a host installs, so exercising one | |
| 5 | +## means standing up a host. Here it is a function from a record to a tree. | |
| 6 | + | |
| 7 | +import std/[json, sequtils, strutils, tables, unicode, unittest] | |
| 8 | +import frq/[ui, cells, model] | |
| 9 | +import frq/screens/connect as cs | |
| 10 | +import frq/screens/settings as ss | |
| 11 | + | |
| 12 | +proc find*(node: Node, tag: string): seq[Node] = | |
| 13 | + if node.isNil: return | |
| 14 | + if node.tag == tag: result.add node | |
| 15 | + for c in node.children: result.add c.find(tag) | |
| 16 | + | |
| 17 | +proc labels(node: Node, tag: string): seq[string] = | |
| 18 | + node.find(tag).mapIt(it.props{"label"}.getStr()) | |
| 19 | + | |
| 20 | +proc keys(node: Node, tag: string): seq[string] = | |
| 21 | + node.find(tag).mapIt(it.props{"key"}.getStr()) | |
| 22 | + | |
| 23 | +suite "the connect screen": | |
| 24 | + setup: | |
| 25 | + var s = initState() | |
| 26 | + | |
| 27 | + test "is a page with the title and the transport note": | |
| 28 | + let t = cs.connectScreen(s) | |
| 29 | + check t.tag == "page" | |
| 30 | + check "frq" in t.labels("title") | |
| 31 | + check cs.transportNote in t.labels("dim-label") | |
| 32 | + | |
| 33 | + test "is pure — twice with no change is the same tree": | |
| 34 | + check $cs.connectScreen(s).toJson == $cs.connectScreen(s).toJson | |
| 35 | + | |
| 36 | + test "guest is the default, and shows a nick field": | |
| 37 | + let t = cs.connectScreen(s) | |
| 38 | + check "Connect as guest" in t.labels("title-2") | |
| 39 | + check "nick" in t.keys("entry") | |
| 40 | + check "handle" notin t.keys("entry") | |
| 41 | + | |
| 42 | + test "bluesky shows a handle and its own copy": | |
| 43 | + s.authMode = amBluesky | |
| 44 | + let t = cs.connectScreen(s) | |
| 45 | + check "Sign in with Bluesky" in t.labels("title-2") | |
| 46 | + check "handle" in t.keys("entry") | |
| 47 | + check "nick" notin t.keys("entry") | |
| 48 | + | |
| 49 | + test "app-password asks for both, and says where to make one": | |
| 50 | + s.authMode = amAppPassword | |
| 51 | + let t = cs.connectScreen(s) | |
| 52 | + check "handle" in t.keys("entry") | |
| 53 | + check "app-password" in t.keys("entry") | |
| 54 | + check t.labels("dim-label").anyIt("App Passwords" in it) | |
| 55 | + | |
| 56 | + test "a remembered session offers to be forgotten, and only then": | |
| 57 | + s.authMode = amBluesky | |
| 58 | + check "Forget saved session" notin cs.connectScreen(s).labels("button") | |
| 59 | + s.brokerToken = "tok" | |
| 60 | + check "Forget saved session" in cs.connectScreen(s).labels("button") | |
| 61 | + | |
| 62 | + test "the login URL is shown while the browser is open": | |
| 63 | + s.authMode = amBluesky | |
| 64 | + s.loginUrl = "https://auth.freeq.at/x" | |
| 65 | + let t = cs.connectScreen(s) | |
| 66 | + check "https://auth.freeq.at/x" in t.labels("label") | |
| 67 | + | |
| 68 | + test "the error note keeps its place whether or not there is an error": | |
| 69 | + # The bug the stable wrapper exists for: a renderer matching children by | |
| 70 | + # position would patch the header into a card when the error appeared. | |
| 71 | + let before = cs.connectScreen(s).children.mapIt(it.tag) | |
| 72 | + s.hasError = true | |
| 73 | + s.error = "nope" | |
| 74 | + check cs.connectScreen(s).children.mapIt(it.tag) == before | |
| 75 | + check "Dismiss" in cs.connectScreen(s).labels("button") | |
| 76 | + | |
| 77 | + test "the remembered and login-url wrappers hold their place too": | |
| 78 | + s.authMode = amBluesky | |
| 79 | + let bare = cs.connectScreen(s) | |
| 80 | + s.brokerToken = "tok" | |
| 81 | + s.loginUrl = "https://x" | |
| 82 | + check cs.connectScreen(s).keys("vbox").filterIt(it.len > 0) == | |
| 83 | + bare.keys("vbox").filterIt(it.len > 0) | |
| 84 | + | |
| 85 | + test "connecting swaps Connect for a spinner and a way out": | |
| 86 | + s.connecting = true | |
| 87 | + let t = cs.connectScreen(s) | |
| 88 | + check t.find("spinner").len == 1 | |
| 89 | + check "Connect" notin t.labels("button") | |
| 90 | + check "Cancel" in t.labels("button") | |
| 91 | + | |
| 92 | +suite "discover": | |
| 93 | + setup: | |
| 94 | + var s = initState() | |
| 95 | + | |
| 96 | + test "lists the popular channels with their blurbs": | |
| 97 | + let t = ss.discoverScreen(s) | |
| 98 | + check t.labels("title-2") == popularChannels.mapIt(it[0]) | |
| 99 | + check "#general" in t.keys("card") | |
| 100 | + | |
| 101 | + test "offers Join for a room we are not in and Open for one we are": | |
| 102 | + check ss.discoverScreen(s).labels("button").countIt(it == "Join") == | |
| 103 | + popularChannels.len | |
| 104 | + var r = initRoom("#test"); r.joined = true | |
| 105 | + s.rooms["#test"] = r | |
| 106 | + let t = ss.discoverScreen(s) | |
| 107 | + check "Open" in t.labels("button") | |
| 108 | + check t.labels("button").countIt(it == "Join") == popularChannels.len - 1 | |
| 109 | + | |
| 110 | + test "the tab bar is under it, with Discover selected": | |
| 111 | + let t = ss.discoverScreen(s) | |
| 112 | + s.screen = scDiscover | |
| 113 | + let sel = ss.discoverScreen(s).find("button") | |
| 114 | + .filterIt(it.props{"kind"}.getStr() == "primary") | |
| 115 | + .mapIt(it.props{"label"}.getStr()) | |
| 116 | + check "Discover" in sel | |
| 117 | + check t.find("scroll").len == 1 | |
| 118 | + | |
| 119 | +suite "settings": | |
| 120 | + setup: | |
| 121 | + var s = initState() | |
| 122 | + | |
| 123 | + test "a guest is told so": | |
| 124 | + check "Guest — not signed in." in | |
| 125 | + ss.settingsScreen(s, connected = false, desktop = true).labels("dim-label") | |
| 126 | + | |
| 127 | + test "a signed-in handle is shown, and can be forgotten": | |
| 128 | + s.formHandle = "alice.bsky.social" | |
| 129 | + s.brokerToken = "tok" | |
| 130 | + let t = ss.settingsScreen(s, connected = true, desktop = true) | |
| 131 | + check "alice.bsky.social" in t.labels("label") | |
| 132 | + check "Forget Bluesky session" in t.labels("button") | |
| 133 | + | |
| 134 | + test "Disconnect when connected, Back to connect when not": | |
| 135 | + check "Disconnect" in | |
| 136 | + ss.settingsScreen(s, connected = true, desktop = true).labels("button") | |
| 137 | + check "Back to connect" in | |
| 138 | + ss.settingsScreen(s, connected = false, desktop = true).labels("button") | |
| 139 | + | |
| 140 | + test "Quit only where there is a window to quit": | |
| 141 | + check "Quit" in | |
| 142 | + ss.settingsScreen(s, connected = false, desktop = true).labels("button") | |
| 143 | + check "Quit" notin | |
| 144 | + ss.settingsScreen(s, connected = false, desktop = false).labels("button") | |
| 145 | + | |
| 146 | + test "the join/part switch reflects the cell": | |
| 147 | + let off = ss.settingsScreen(s, false, true).find("checkbutton")[0] | |
| 148 | + check not off.props{"active"}.getBool() | |
| 149 | + s.hideJoinPart = true | |
| 150 | + let on = ss.settingsScreen(s, false, true).find("checkbutton")[0] | |
| 151 | + check on.props{"active"}.getBool() | |
| 152 | + | |
| 153 | + test "the status line is live only when connected": | |
| 154 | + check ss.settingsScreen(s, true, true).find("status")[0] | |
| 155 | + .props{"live"}.getBool() | |
| 156 | + check not ss.settingsScreen(s, false, true).find("status")[0] | |
| 157 | + .props{"live"}.getBool() | |
| 158 | + | |
| 159 | +import frq/screens/chats as ch | |
| 160 | + | |
| 161 | +suite "previewLine": | |
| 162 | + test "collapses whitespace so a card reads as one line": | |
| 163 | + check ch.previewLine("a\n b\tc") == "a b c" | |
| 164 | + test "truncates a pasted script rather than growing the card": | |
| 165 | + let long = "x".repeat(200) | |
| 166 | + check ch.previewLine(long).runeLen == 60 | |
| 167 | + check ch.previewLine(long).endsWith("…") | |
| 168 | + | |
| 169 | + test "truncates by character, not by byte": | |
| 170 | + # A byte slice at 59 lands inside a multi-byte character and produces | |
| 171 | + # mojibake where an ellipsis was wanted. The first draft did exactly that. | |
| 172 | + let emoji = "😀".repeat(100) | |
| 173 | + let got = ch.previewLine(emoji) | |
| 174 | + check got.runeLen == 60 | |
| 175 | + check got.validateUtf8 == -1 | |
| 176 | + test "leaves a short line alone": | |
| 177 | + check ch.previewLine("hello there") == "hello there" | |
| 178 | + | |
| 179 | +suite "the chats screen": | |
| 180 | + setup: | |
| 181 | + var s = initState() | |
| 182 | + var r = initRoom("#test") | |
| 183 | + r.joined = true | |
| 184 | + r.messages = @[Message(frm: "alice", text: "hello")] | |
| 185 | + s.rooms["#test"] = r | |
| 186 | + | |
| 187 | + test "an empty list says so instead of showing nothing": | |
| 188 | + var empty = initState() | |
| 189 | + check "No conversations yet — join a channel." in | |
| 190 | + ch.chatsScreen(empty, false).labels("dim-label") | |
| 191 | + | |
| 192 | + test "a room is a card with its name and last line": | |
| 193 | + let t = ch.chatsScreen(s, true) | |
| 194 | + check "#test" in t.labels("title-2") | |
| 195 | + check "alice: hello" in t.labels("dim-label") | |
| 196 | + | |
| 197 | + test "the title says who we are when connected": | |
| 198 | + check "Logged in as frq-guest" in ch.chatsScreen(s, true).labels("title") | |
| 199 | + check "Chats" in ch.chatsScreen(s, false).labels("title") | |
| 200 | + | |
| 201 | + test "an unread count shows, with a mention marked differently": | |
| 202 | + var u = s.rooms["#test"] | |
| 203 | + u.unread = 3 | |
| 204 | + s.rooms["#test"] = u | |
| 205 | + check "● 3" in ch.chatsScreen(s, true).labels("label") | |
| 206 | + u.mention = true | |
| 207 | + s.rooms["#test"] = u | |
| 208 | + check "◆ @ 3" in ch.chatsScreen(s, true).labels("label") | |
| 209 | + | |
| 210 | + test "a channel we are not in is badged, a DM never is": | |
| 211 | + var away = s.rooms["#test"] | |
| 212 | + away.joined = false | |
| 213 | + s.rooms["#test"] = away | |
| 214 | + check "not joined" in ch.chatsScreen(s, true).labels("status") | |
| 215 | + var dmr = initRoom("alice") | |
| 216 | + s.rooms["alice"] = dmr | |
| 217 | + # Still only the one badge: a DM has nothing to join. | |
| 218 | + check ch.chatsScreen(s, true).labels("status").countIt(it == "not joined") == 1 | |
| 219 | + | |
| 220 | + test "the button says Message for a nick and Join for a channel": | |
| 221 | + s.joinInput = "#room" | |
| 222 | + check "Join" in ch.chatsScreen(s, true).labels("button") | |
| 223 | + s.joinInput = "@alice" | |
| 224 | + check "Message" in ch.chatsScreen(s, true).labels("button") | |
| 225 | + | |
| 226 | + test "the search box offers a clear only when there is something to clear": | |
| 227 | + check "✕" notin ch.chatsScreen(s, true).labels("button") | |
| 228 | + s.search = "te" | |
| 229 | + check "✕" in ch.chatsScreen(s, true).labels("button") | |
| 230 | + | |
| 231 | + test "search filters the list": | |
| 232 | + s.rooms["#other"] = initRoom("#other") | |
| 233 | + s.search = "oth" | |
| 234 | + let names = ch.chatsScreen(s, true).labels("title-2") | |
| 235 | + check names == @["#other"] | |
| new file mode 100644 | |||
| @@ -0,0 +1,235 @@ | |||
| 1 | +## The screens, as pure functions of the state. | ||
| 2 | +## | ||
| 3 | +## These are the tests the ClojureDart screens never had and could not easily | ||
| 4 | +## have: a screen there is hiccup over cells a host installs, so exercising one | ||
| 5 | +## means standing up a host. Here it is a function from a record to a tree. | ||
| 6 | + | ||
| 7 | +import std/[json, sequtils, strutils, tables, unicode, unittest] | ||
| 8 | +import frq/[ui, cells, model] | ||
| 9 | +import frq/screens/connect as cs | ||
| 10 | +import frq/screens/settings as ss | ||
| 11 | + | ||
| 12 | +proc find*(node: Node, tag: string): seq[Node] = | ||
| 13 | + if node.isNil: return | ||
| 14 | + if node.tag == tag: result.add node | ||
| 15 | + for c in node.children: result.add c.find(tag) | ||
| 16 | + | ||
| 17 | +proc labels(node: Node, tag: string): seq[string] = | ||
| 18 | + node.find(tag).mapIt(it.props{"label"}.getStr()) | ||
| 19 | + | ||
| 20 | +proc keys(node: Node, tag: string): seq[string] = | ||
| 21 | + node.find(tag).mapIt(it.props{"key"}.getStr()) | ||
| 22 | + | ||
| 23 | +suite "the connect screen": | ||
| 24 | + setup: | ||
| 25 | + var s = initState() | ||
| 26 | + | ||
| 27 | + test "is a page with the title and the transport note": | ||
| 28 | + let t = cs.connectScreen(s) | ||
| 29 | + check t.tag == "page" | ||
| 30 | + check "frq" in t.labels("title") | ||
| 31 | + check cs.transportNote in t.labels("dim-label") | ||
| 32 | + | ||
| 33 | + test "is pure — twice with no change is the same tree": | ||
| 34 | + check $cs.connectScreen(s).toJson == $cs.connectScreen(s).toJson | ||
| 35 | + | ||
| 36 | + test "guest is the default, and shows a nick field": | ||
| 37 | + let t = cs.connectScreen(s) | ||
| 38 | + check "Connect as guest" in t.labels("title-2") | ||
| 39 | + check "nick" in t.keys("entry") | ||
| 40 | + check "handle" notin t.keys("entry") | ||
| 41 | + | ||
| 42 | + test "bluesky shows a handle and its own copy": | ||
| 43 | + s.authMode = amBluesky | ||
| 44 | + let t = cs.connectScreen(s) | ||
| 45 | + check "Sign in with Bluesky" in t.labels("title-2") | ||
| 46 | + check "handle" in t.keys("entry") | ||
| 47 | + check "nick" notin t.keys("entry") | ||
| 48 | + | ||
| 49 | + test "app-password asks for both, and says where to make one": | ||
| 50 | + s.authMode = amAppPassword | ||
| 51 | + let t = cs.connectScreen(s) | ||
| 52 | + check "handle" in t.keys("entry") | ||
| 53 | + check "app-password" in t.keys("entry") | ||
| 54 | + check t.labels("dim-label").anyIt("App Passwords" in it) | ||
| 55 | + | ||
| 56 | + test "a remembered session offers to be forgotten, and only then": | ||
| 57 | + s.authMode = amBluesky | ||
| 58 | + check "Forget saved session" notin cs.connectScreen(s).labels("button") | ||
| 59 | + s.brokerToken = "tok" | ||
| 60 | + check "Forget saved session" in cs.connectScreen(s).labels("button") | ||
| 61 | + | ||
| 62 | + test "the login URL is shown while the browser is open": | ||
| 63 | + s.authMode = amBluesky | ||
| 64 | + s.loginUrl = "https://auth.freeq.at/x" | ||
| 65 | + let t = cs.connectScreen(s) | ||
| 66 | + check "https://auth.freeq.at/x" in t.labels("label") | ||
| 67 | + | ||
| 68 | + test "the error note keeps its place whether or not there is an error": | ||
| 69 | + # The bug the stable wrapper exists for: a renderer matching children by | ||
| 70 | + # position would patch the header into a card when the error appeared. | ||
| 71 | + let before = cs.connectScreen(s).children.mapIt(it.tag) | ||
| 72 | + s.hasError = true | ||
| 73 | + s.error = "nope" | ||
| 74 | + check cs.connectScreen(s).children.mapIt(it.tag) == before | ||
| 75 | + check "Dismiss" in cs.connectScreen(s).labels("button") | ||
| 76 | + | ||
| 77 | + test "the remembered and login-url wrappers hold their place too": | ||
| 78 | + s.authMode = amBluesky | ||
| 79 | + let bare = cs.connectScreen(s) | ||
| 80 | + s.brokerToken = "tok" | ||
| 81 | + s.loginUrl = "https://x" | ||
| 82 | + check cs.connectScreen(s).keys("vbox").filterIt(it.len > 0) == | ||
| 83 | + bare.keys("vbox").filterIt(it.len > 0) | ||
| 84 | + | ||
| 85 | + test "connecting swaps Connect for a spinner and a way out": | ||
| 86 | + s.connecting = true | ||
| 87 | + let t = cs.connectScreen(s) | ||
| 88 | + check t.find("spinner").len == 1 | ||
| 89 | + check "Connect" notin t.labels("button") | ||
| 90 | + check "Cancel" in t.labels("button") | ||
| 91 | + | ||
| 92 | +suite "discover": | ||
| 93 | + setup: | ||
| 94 | + var s = initState() | ||
| 95 | + | ||
| 96 | + test "lists the popular channels with their blurbs": | ||
| 97 | + let t = ss.discoverScreen(s) | ||
| 98 | + check t.labels("title-2") == popularChannels.mapIt(it[0]) | ||
| 99 | + check "#general" in t.keys("card") | ||
| 100 | + | ||
| 101 | + test "offers Join for a room we are not in and Open for one we are": | ||
| 102 | + check ss.discoverScreen(s).labels("button").countIt(it == "Join") == | ||
| 103 | + popularChannels.len | ||
| 104 | + var r = initRoom("#test"); r.joined = true | ||
| 105 | + s.rooms["#test"] = r | ||
| 106 | + let t = ss.discoverScreen(s) | ||
| 107 | + check "Open" in t.labels("button") | ||
| 108 | + check t.labels("button").countIt(it == "Join") == popularChannels.len - 1 | ||
| 109 | + | ||
| 110 | + test "the tab bar is under it, with Discover selected": | ||
| 111 | + let t = ss.discoverScreen(s) | ||
| 112 | + s.screen = scDiscover | ||
| 113 | + let sel = ss.discoverScreen(s).find("button") | ||
| 114 | + .filterIt(it.props{"kind"}.getStr() == "primary") | ||
| 115 | + .mapIt(it.props{"label"}.getStr()) | ||
| 116 | + check "Discover" in sel | ||
| 117 | + check t.find("scroll").len == 1 | ||
| 118 | + | ||
| 119 | +suite "settings": | ||
| 120 | + setup: | ||
| 121 | + var s = initState() | ||
| 122 | + | ||
| 123 | + test "a guest is told so": | ||
| 124 | + check "Guest — not signed in." in | ||
| 125 | + ss.settingsScreen(s, connected = false, desktop = true).labels("dim-label") | ||
| 126 | + | ||
| 127 | + test "a signed-in handle is shown, and can be forgotten": | ||
| 128 | + s.formHandle = "alice.bsky.social" | ||
| 129 | + s.brokerToken = "tok" | ||
| 130 | + let t = ss.settingsScreen(s, connected = true, desktop = true) | ||
| 131 | + check "alice.bsky.social" in t.labels("label") | ||
| 132 | + check "Forget Bluesky session" in t.labels("button") | ||
| 133 | + | ||
| 134 | + test "Disconnect when connected, Back to connect when not": | ||
| 135 | + check "Disconnect" in | ||
| 136 | + ss.settingsScreen(s, connected = true, desktop = true).labels("button") | ||
| 137 | + check "Back to connect" in | ||
| 138 | + ss.settingsScreen(s, connected = false, desktop = true).labels("button") | ||
| 139 | + | ||
| 140 | + test "Quit only where there is a window to quit": | ||
| 141 | + check "Quit" in | ||
| 142 | + ss.settingsScreen(s, connected = false, desktop = true).labels("button") | ||
| 143 | + check "Quit" notin | ||
| 144 | + ss.settingsScreen(s, connected = false, desktop = false).labels("button") | ||
| 145 | + | ||
| 146 | + test "the join/part switch reflects the cell": | ||
| 147 | + let off = ss.settingsScreen(s, false, true).find("checkbutton")[0] | ||
| 148 | + check not off.props{"active"}.getBool() | ||
| 149 | + s.hideJoinPart = true | ||
| 150 | + let on = ss.settingsScreen(s, false, true).find("checkbutton")[0] | ||
| 151 | + check on.props{"active"}.getBool() | ||
| 152 | + | ||
| 153 | + test "the status line is live only when connected": | ||
| 154 | + check ss.settingsScreen(s, true, true).find("status")[0] | ||
| 155 | + .props{"live"}.getBool() | ||
| 156 | + check not ss.settingsScreen(s, false, true).find("status")[0] | ||
| 157 | + .props{"live"}.getBool() | ||
| 158 | + | ||
| 159 | +import frq/screens/chats as ch | ||
| 160 | + | ||
| 161 | +suite "previewLine": | ||
| 162 | + test "collapses whitespace so a card reads as one line": | ||
| 163 | + check ch.previewLine("a\n b\tc") == "a b c" | ||
| 164 | + test "truncates a pasted script rather than growing the card": | ||
| 165 | + let long = "x".repeat(200) | ||
| 166 | + check ch.previewLine(long).runeLen == 60 | ||
| 167 | + check ch.previewLine(long).endsWith("…") | ||
| 168 | + | ||
| 169 | + test "truncates by character, not by byte": | ||
| 170 | + # A byte slice at 59 lands inside a multi-byte character and produces | ||
| 171 | + # mojibake where an ellipsis was wanted. The first draft did exactly that. | ||
| 172 | + let emoji = "😀".repeat(100) | ||
| 173 | + let got = ch.previewLine(emoji) | ||
| 174 | + check got.runeLen == 60 | ||
| 175 | + check got.validateUtf8 == -1 | ||
| 176 | + test "leaves a short line alone": | ||
| 177 | + check ch.previewLine("hello there") == "hello there" | ||
| 178 | + | ||
| 179 | +suite "the chats screen": | ||
| 180 | + setup: | ||
| 181 | + var s = initState() | ||
| 182 | + var r = initRoom("#test") | ||
| 183 | + r.joined = true | ||
| 184 | + r.messages = @[Message(frm: "alice", text: "hello")] | ||
| 185 | + s.rooms["#test"] = r | ||
| 186 | + | ||
| 187 | + test "an empty list says so instead of showing nothing": | ||
| 188 | + var empty = initState() | ||
| 189 | + check "No conversations yet — join a channel." in | ||
| 190 | + ch.chatsScreen(empty, false).labels("dim-label") | ||
| 191 | + | ||
| 192 | + test "a room is a card with its name and last line": | ||
| 193 | + let t = ch.chatsScreen(s, true) | ||
| 194 | + check "#test" in t.labels("title-2") | ||
| 195 | + check "alice: hello" in t.labels("dim-label") | ||
| 196 | + | ||
| 197 | + test "the title says who we are when connected": | ||
| 198 | + check "Logged in as frq-guest" in ch.chatsScreen(s, true).labels("title") | ||
| 199 | + check "Chats" in ch.chatsScreen(s, false).labels("title") | ||
| 200 | + | ||
| 201 | + test "an unread count shows, with a mention marked differently": | ||
| 202 | + var u = s.rooms["#test"] | ||
| 203 | + u.unread = 3 | ||
| 204 | + s.rooms["#test"] = u | ||
| 205 | + check "● 3" in ch.chatsScreen(s, true).labels("label") | ||
| 206 | + u.mention = true | ||
| 207 | + s.rooms["#test"] = u | ||
| 208 | + check "◆ @ 3" in ch.chatsScreen(s, true).labels("label") | ||
| 209 | + | ||
| 210 | + test "a channel we are not in is badged, a DM never is": | ||
| 211 | + var away = s.rooms["#test"] | ||
| 212 | + away.joined = false | ||
| 213 | + s.rooms["#test"] = away | ||
| 214 | + check "not joined" in ch.chatsScreen(s, true).labels("status") | ||
| 215 | + var dmr = initRoom("alice") | ||
| 216 | + s.rooms["alice"] = dmr | ||
| 217 | + # Still only the one badge: a DM has nothing to join. | ||
| 218 | + check ch.chatsScreen(s, true).labels("status").countIt(it == "not joined") == 1 | ||
| 219 | + | ||
| 220 | + test "the button says Message for a nick and Join for a channel": | ||
| 221 | + s.joinInput = "#room" | ||
| 222 | + check "Join" in ch.chatsScreen(s, true).labels("button") | ||
| 223 | + s.joinInput = "@alice" | ||
| 224 | + check "Message" in ch.chatsScreen(s, true).labels("button") | ||
| 225 | + | ||
| 226 | + test "the search box offers a clear only when there is something to clear": | ||
| 227 | + check "✕" notin ch.chatsScreen(s, true).labels("button") | ||
| 228 | + s.search = "te" | ||
| 229 | + check "✕" in ch.chatsScreen(s, true).labels("button") | ||
| 230 | + | ||
| 231 | + test "search filters the list": | ||
| 232 | + s.rooms["#other"] = initRoom("#other") | ||
| 233 | + s.search = "oth" | ||
| 234 | + let names = ch.chatsScreen(s, true).labels("title-2") | ||
| 235 | + check names == @["#other"] | ||