Three buttons that did nothing now do what they say
The 🙂 chip, the Overview toggle and clicking a picture all moved state that nothing rendered. A control that lights up and does nothing is worse than an absent one, and the logic they needed was already ported and tested — `pickerEmoji`, `recentEverywhere`, the lightbox cell — with no screen to appear on. So: screens. **The picker** opens under the message it is for, which is the point of it: a reaction is cheaper than typing only if what is being reacted to stays in sight. It opens on the popular row, takes a group or a search, and closes on a pick. Capped at 120 of the 1,884, because the whole catalogue is a tree that has to be built, serialised, crossed and rebuilt on every keystroke in the search box — and a search matching nothing says so rather than showing an empty grid. **The overview** is a turn to each room rather than the newest lines outright, which is `recentEverywhere`'s whole argument: taking the newest hundred would be the strip answering about whichever room is busiest, and that is the one already on screen. Each line carries the room it was said in, since a line taken out of its own conversation no longer says that for itself. It is also the one place in the app that moves the reader without their having asked to leave where they were, so it is the one place that owes them the way back — `overviewReturn`, and a button that uses it. **The lightbox** is a panel over the conversation rather than a screen, because closing it should put the reader back exactly where they were and a screen would have to remember where that was. `Message` grew a `room` field for the overview: empty on a stored line, since a room already knows its own name, and filled in by `recentEverywhere`. 312 Nim tests and 25 layout tests, four of them new and all about the panels at a size that squeezes — a 120-emoji grid under a message on a 300-point window is exactly the shape that overflowed the last three times. Still state without a screen: the profile card. `frq.profile` was never ported and went with the ClojureDart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
98d6135 parent: 284b59c modified
flutter/test/nim_layout_test.dart +31 -0 | @@ -99,6 +99,37 @@ void main() { | ||
| 99 | 99 | expectLaidOut(tester, 'chat editing'); |
| 100 | 100 | }); |
| 101 | 101 | |
| 102 | + testWidgets('lays out with the emoji picker open', (tester) async { | |
| 103 | + // 120 emoji in a grid under a message, on a phone. | |
| 104 | + core.demoUi(); | |
| 105 | + core.dispatch('react.open:2'); | |
| 106 | + await layOut(tester, sizes['phone']!); | |
| 107 | + expectLaidOut(tester, 'chat with the picker'); | |
| 108 | + }); | |
| 109 | + | |
| 110 | + testWidgets('lays out with the picker showing a whole group', | |
| 111 | + (tester) async { | |
| 112 | + core.demoUi(); | |
| 113 | + core.dispatch('react.open:2'); | |
| 114 | + core.dispatch('emoji.group:Smileys & Emotion'); | |
| 115 | + await layOut(tester, sizes['cramped']!); | |
| 116 | + expectLaidOut(tester, 'chat with a full picker'); | |
| 117 | + }); | |
| 118 | + | |
| 119 | + testWidgets('lays out with the overview open', (tester) async { | |
| 120 | + core.demoUi(); | |
| 121 | + core.dispatch('overview.toggle'); | |
| 122 | + await layOut(tester, sizes['phone']!); | |
| 123 | + expectLaidOut(tester, 'chat with the overview'); | |
| 124 | + }); | |
| 125 | + | |
| 126 | + testWidgets('lays out with the lightbox open', (tester) async { | |
| 127 | + core.demoUi(); | |
| 128 | + core.dispatch('lightbox:https://example.com/a.png'); | |
| 129 | + await layOut(tester, sizes['phone']!); | |
| 130 | + expectLaidOut(tester, 'chat with the lightbox'); | |
| 131 | + }); | |
| 132 | + | |
| 102 | 133 | testWidgets('lays out when scrolled off the present', (tester) async { |
| 103 | 134 | core.demoUi(); |
| 104 | 135 | core.dispatch('jump.present'); |
| @@ -99,6 +99,37 @@ void main() { | |||
| 99 | expectLaidOut(tester, 'chat editing'); | 99 | expectLaidOut(tester, 'chat editing'); |
| 100 | }); | 100 | }); |
| 101 | 101 | ||
| 102 | + testWidgets('lays out with the emoji picker open', (tester) async { | ||
| 103 | + // 120 emoji in a grid under a message, on a phone. | ||
| 104 | + core.demoUi(); | ||
| 105 | + core.dispatch('react.open:2'); | ||
| 106 | + await layOut(tester, sizes['phone']!); | ||
| 107 | + expectLaidOut(tester, 'chat with the picker'); | ||
| 108 | + }); | ||
| 109 | + | ||
| 110 | + testWidgets('lays out with the picker showing a whole group', | ||
| 111 | + (tester) async { | ||
| 112 | + core.demoUi(); | ||
| 113 | + core.dispatch('react.open:2'); | ||
| 114 | + core.dispatch('emoji.group:Smileys & Emotion'); | ||
| 115 | + await layOut(tester, sizes['cramped']!); | ||
| 116 | + expectLaidOut(tester, 'chat with a full picker'); | ||
| 117 | + }); | ||
| 118 | + | ||
| 119 | + testWidgets('lays out with the overview open', (tester) async { | ||
| 120 | + core.demoUi(); | ||
| 121 | + core.dispatch('overview.toggle'); | ||
| 122 | + await layOut(tester, sizes['phone']!); | ||
| 123 | + expectLaidOut(tester, 'chat with the overview'); | ||
| 124 | + }); | ||
| 125 | + | ||
| 126 | + testWidgets('lays out with the lightbox open', (tester) async { | ||
| 127 | + core.demoUi(); | ||
| 128 | + core.dispatch('lightbox:https://example.com/a.png'); | ||
| 129 | + await layOut(tester, sizes['phone']!); | ||
| 130 | + expectLaidOut(tester, 'chat with the lightbox'); | ||
| 131 | + }); | ||
| 132 | + | ||
| 102 | testWidgets('lays out when scrolled off the present', (tester) async { | 133 | testWidgets('lays out when scrolled off the present', (tester) async { |
| 103 | core.demoUi(); | 134 | core.demoUi(); |
| 104 | core.dispatch('jump.present'); | 135 | core.dispatch('jump.present'); |
modified
nim/README.md +8 -3 | @@ -75,9 +75,14 @@ ClojureDart rather than moving: a Bluesky profile behind a nick, and asking | ||
| 75 | 75 | freeq what a collapsed msgid was. Neither had a screen in this app to appear |
| 76 | 76 | on. |
| 77 | 77 | |
| 78 | -The emoji picker, the overview strip, the lightbox and the profile card are | |
| 79 | -state without a screen: the reducer moves them and nothing renders them, so | |
| 80 | -those buttons change colour and do nothing. | |
| 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. | |
| 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 | |
| 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 | |
| 85 | +do what they look like they do. | |
| 81 | 86 | |
| 82 | 87 | Untested against a real account: nobody has watched freeq accept a SASL |
| 83 | 88 | challenge response or a signature. The shapes are checked and the curve is |
| @@ -75,9 +75,14 @@ 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 | 75 | freeq what a collapsed msgid was. Neither had a screen in this app to appear |
| 76 | on. | 76 | on. |
| 77 | 77 | ||
| 78 | -The emoji picker, the overview strip, the lightbox and the profile card are | 78 | +The profile card is state without a screen: `profileViewing` is moved by |
| 79 | -state without a screen: the reducer moves them and nothing renders them, so | 79 | +nothing and rendered by nothing, and `frq.profile` was never ported. |
| 80 | -those buttons change colour and do nothing. | 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 | ||
| 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 | ||
| 85 | +do what they look like they do. | ||
| 81 | 86 | ||
| 82 | Untested against a real account: nobody has watched freeq accept a SASL | 87 | Untested against a real account: nobody has watched freeq accept a SASL |
| 83 | challenge response or a signature. The shapes are checked and the curve is | 88 | challenge response or a signature. The shapes are checked and the curve is |
modified
nim/src/frq/glyphs.nim +7 -0 | @@ -101,6 +101,13 @@ func hasEmoji*(rs: seq[GlyphRun]): bool = | ||
| 101 | 101 | if r.kind == gkEmoji: return true |
| 102 | 102 | false |
| 103 | 103 | |
| 104 | +const pickerLimit* = 120 | |
| 105 | + ## How many of the catalogue the picker will put on screen at once. | |
| 106 | + ## | |
| 107 | + ## 1,884 emoji is a tree that has to be built, serialised, crossed and | |
| 108 | + ## rebuilt on every keystroke in the search box. The reader is scanning for | |
| 109 | + ## a face, not reading the list. | |
| 110 | + | |
| 104 | 111 | proc pickerEmoji*(search, group: string): seq[Emoji] = |
| 105 | 112 | ## What the picker is showing: the popular row, one group, or whatever the |
| 106 | 113 | ## search matches — by name, so "cat" finds the cat and the cat face, and by |
| @@ -101,6 +101,13 @@ func hasEmoji*(rs: seq[GlyphRun]): bool = | |||
| 101 | if r.kind == gkEmoji: return true | 101 | if r.kind == gkEmoji: return true |
| 102 | false | 102 | false |
| 103 | 103 | ||
| 104 | +const pickerLimit* = 120 | ||
| 105 | + ## How many of the catalogue the picker will put on screen at once. | ||
| 106 | + ## | ||
| 107 | + ## 1,884 emoji is a tree that has to be built, serialised, crossed and | ||
| 108 | + ## rebuilt on every keystroke in the search box. The reader is scanning for | ||
| 109 | + ## a face, not reading the list. | ||
| 110 | + | ||
| 104 | proc pickerEmoji*(search, group: string): seq[Emoji] = | 111 | proc pickerEmoji*(search, group: string): seq[Emoji] = |
| 105 | ## What the picker is showing: the popular row, one group, or whatever the | 112 | ## What the picker is showing: the popular row, one group, or whatever the |
| 106 | ## search matches — by name, so "cat" finds the cat and the cat face, and by | 113 | ## search matches — by name, so "cat" finds the cat and the cat face, and by |
modified
nim/src/frq/model.nim +4 -0 | @@ -38,6 +38,10 @@ type | ||
| 38 | 38 | reactions*: seq[Reaction] |
| 39 | 39 | imageUrl*: string ## the first picture link in the text, "" for none |
| 40 | 40 | pending*: bool ## sent, not yet echoed |
| 41 | + room*: string | |
| 42 | + ## Which room this was said in. Empty on a stored message — a room | |
| 43 | + ## already knows its own name — and filled in by `recentEverywhere`, | |
| 44 | + ## where a line taken out of its conversation no longer says for itself. | |
| 41 | 45 | |
| 42 | 46 | Room* = object |
| 43 | 47 | ## A buffer: a channel or a DM. Named Room rather than Channel because |
| @@ -38,6 +38,10 @@ type | |||
| 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 | pending*: bool ## sent, not yet echoed | 40 | pending*: bool ## sent, not yet echoed |
| 41 | + room*: string | ||
| 42 | + ## Which room this was said in. Empty on a stored message — a room | ||
| 43 | + ## already knows its own name — and filled in by `recentEverywhere`, | ||
| 44 | + ## where a line taken out of its conversation no longer says for itself. | ||
| 41 | 45 | ||
| 42 | Room* = object | 46 | Room* = object |
| 43 | ## A buffer: a channel or a DM. Named Room rather than Channel because | 47 | ## A buffer: a channel or a DM. Named Room rather than Channel because |
modified
nim/src/frq/reducer.nim +46 -1 | @@ -258,7 +258,35 @@ proc dispatch*(event: JsonNode) = | ||
| 258 | 258 | # -------------------------------------------------------------- reactions |
| 259 | 259 | of "react.open": |
| 260 | 260 | app.reacting = ReactTarget(has: true, room: app.current, id: arg) |
| 261 | - of "react.close": app.reacting = ReactTarget() | |
| 261 | + app.emojiSearch = "" | |
| 262 | + app.emojiGroup = "" | |
| 263 | + of "react.close": | |
| 264 | + app.reacting = ReactTarget() | |
| 265 | + # The search goes with the panel. A picker reopened on another message | |
| 266 | + # showing the last one's search is a picker that has to be cleared first. | |
| 267 | + app.emojiSearch = "" | |
| 268 | + app.emojiGroup = "" | |
| 269 | + | |
| 270 | + of "emoji.search.change": app.emojiSearch = value | |
| 271 | + of "emoji.group": app.emojiGroup = arg | |
| 272 | + | |
| 273 | + of "react.pick": | |
| 274 | + # Picking is reacting, and then the panel has done its job. | |
| 275 | + if app.reacting.has and arg.len > 0: | |
| 276 | + let mid = app.reacting.id | |
| 277 | + let m = app.currentRoom.messageById(mid) | |
| 278 | + let on = if m.isSome: not m.get.mine(arg, app.formNick) else: true | |
| 279 | + var tags = "+draft/react=" & arg & ";+draft/reply=" & mid | |
| 280 | + for k, v in mutationTags(if on: "react" else: "unreact", | |
| 281 | + app.current, mid, arg, | |
| 282 | + peerDid(app.currentRoom, app.formNick), | |
| 283 | + nowMs()): | |
| 284 | + tags.add ";" & k & "=" & v | |
| 285 | + send("@" & tags & " TAGMSG " & app.current) | |
| 286 | + app.rooms.updateReaction(app.current, mid, arg, app.formNick, on) | |
| 287 | + app.reacting = ReactTarget() | |
| 288 | + app.emojiSearch = "" | |
| 289 | + app.emojiGroup = "" | |
| 262 | 290 | |
| 263 | 291 | of "react.toggle": |
| 264 | 292 | # `id:emoji`, and the emoji may contain nothing colon-like so one more |
| @@ -283,6 +311,23 @@ proc dispatch*(event: JsonNode) = | ||
| 283 | 311 | app.jumpTo = arg |
| 284 | 312 | app.highlight = arg |
| 285 | 313 | |
| 314 | + of "overview.goto": | |
| 315 | + # The overview is the one place that moves the reader without their having | |
| 316 | + # asked to leave where they were, so it is the one place that owes them | |
| 317 | + # the way back. `room:id`. | |
| 318 | + let (room, mid) = split2(arg) | |
| 319 | + if room.len > 0 and app.rooms.hasKey(room): | |
| 320 | + app.overviewReturn = app.current | |
| 321 | + openRoom(room) | |
| 322 | + app.jumpTo = mid | |
| 323 | + app.highlight = mid | |
| 324 | + app.overview = false | |
| 325 | + | |
| 326 | + of "overview.back": | |
| 327 | + if app.overviewReturn.len > 0: | |
| 328 | + openRoom(app.overviewReturn) | |
| 329 | + app.overviewReturn = "" | |
| 330 | + | |
| 286 | 331 | of "lightbox": |
| 287 | 332 | app.lightbox = Lightbox(has: true, url: arg, path: arg) |
| 288 | 333 | of "lightbox.close": app.lightbox = Lightbox() |
| @@ -258,7 +258,35 @@ proc dispatch*(event: JsonNode) = | |||
| 258 | # -------------------------------------------------------------- reactions | 258 | # -------------------------------------------------------------- reactions |
| 259 | of "react.open": | 259 | of "react.open": |
| 260 | app.reacting = ReactTarget(has: true, room: app.current, id: arg) | 260 | app.reacting = ReactTarget(has: true, room: app.current, id: arg) |
| 261 | - of "react.close": app.reacting = ReactTarget() | 261 | + app.emojiSearch = "" |
| 262 | + app.emojiGroup = "" | ||
| 263 | + of "react.close": | ||
| 264 | + app.reacting = ReactTarget() | ||
| 265 | + # The search goes with the panel. A picker reopened on another message | ||
| 266 | + # showing the last one's search is a picker that has to be cleared first. | ||
| 267 | + app.emojiSearch = "" | ||
| 268 | + app.emojiGroup = "" | ||
| 269 | + | ||
| 270 | + of "emoji.search.change": app.emojiSearch = value | ||
| 271 | + of "emoji.group": app.emojiGroup = arg | ||
| 272 | + | ||
| 273 | + of "react.pick": | ||
| 274 | + # Picking is reacting, and then the panel has done its job. | ||
| 275 | + if app.reacting.has and arg.len > 0: | ||
| 276 | + let mid = app.reacting.id | ||
| 277 | + let m = app.currentRoom.messageById(mid) | ||
| 278 | + let on = if m.isSome: not m.get.mine(arg, app.formNick) else: true | ||
| 279 | + var tags = "+draft/react=" & arg & ";+draft/reply=" & mid | ||
| 280 | + for k, v in mutationTags(if on: "react" else: "unreact", | ||
| 281 | + app.current, mid, arg, | ||
| 282 | + peerDid(app.currentRoom, app.formNick), | ||
| 283 | + nowMs()): | ||
| 284 | + tags.add ";" & k & "=" & v | ||
| 285 | + send("@" & tags & " TAGMSG " & app.current) | ||
| 286 | + app.rooms.updateReaction(app.current, mid, arg, app.formNick, on) | ||
| 287 | + app.reacting = ReactTarget() | ||
| 288 | + app.emojiSearch = "" | ||
| 289 | + app.emojiGroup = "" | ||
| 262 | 290 | ||
| 263 | of "react.toggle": | 291 | of "react.toggle": |
| 264 | # `id:emoji`, and the emoji may contain nothing colon-like so one more | 292 | # `id:emoji`, and the emoji may contain nothing colon-like so one more |
| @@ -283,6 +311,23 @@ proc dispatch*(event: JsonNode) = | |||
| 283 | app.jumpTo = arg | 311 | app.jumpTo = arg |
| 284 | app.highlight = arg | 312 | app.highlight = arg |
| 285 | 313 | ||
| 314 | + of "overview.goto": | ||
| 315 | + # The overview is the one place that moves the reader without their having | ||
| 316 | + # asked to leave where they were, so it is the one place that owes them | ||
| 317 | + # the way back. `room:id`. | ||
| 318 | + let (room, mid) = split2(arg) | ||
| 319 | + if room.len > 0 and app.rooms.hasKey(room): | ||
| 320 | + app.overviewReturn = app.current | ||
| 321 | + openRoom(room) | ||
| 322 | + app.jumpTo = mid | ||
| 323 | + app.highlight = mid | ||
| 324 | + app.overview = false | ||
| 325 | + | ||
| 326 | + of "overview.back": | ||
| 327 | + if app.overviewReturn.len > 0: | ||
| 328 | + openRoom(app.overviewReturn) | ||
| 329 | + app.overviewReturn = "" | ||
| 330 | + | ||
| 286 | of "lightbox": | 331 | of "lightbox": |
| 287 | app.lightbox = Lightbox(has: true, url: arg, path: arg) | 332 | app.lightbox = Lightbox(has: true, url: arg, path: arg) |
| 288 | of "lightbox.close": app.lightbox = Lightbox() | 333 | of "lightbox.close": app.lightbox = Lightbox() |
modified
nim/src/frq/rooms.nim +1 -0 | @@ -132,6 +132,7 @@ func recentEverywhere*(channels: OrderedTable[string, Room], | ||
| 132 | 132 | for name, ch in channels: |
| 133 | 133 | if name == current: continue |
| 134 | 134 | var said = ch.messages.filterIt(not it.system) |
| 135 | + for i in 0 ..< said.len: said[i].room = name | |
| 135 | 136 | if said.len > overviewLimit: |
| 136 | 137 | said = said[^overviewLimit .. ^1] |
| 137 | 138 | if said.len == 0: continue |
| @@ -132,6 +132,7 @@ func recentEverywhere*(channels: OrderedTable[string, Room], | |||
| 132 | for name, ch in channels: | 132 | for name, ch in channels: |
| 133 | if name == current: continue | 133 | if name == current: continue |
| 134 | var said = ch.messages.filterIt(not it.system) | 134 | var said = ch.messages.filterIt(not it.system) |
| 135 | + for i in 0 ..< said.len: said[i].room = name | ||
| 135 | if said.len > overviewLimit: | 136 | if said.len > overviewLimit: |
| 136 | said = said[^overviewLimit .. ^1] | 137 | said = said[^overviewLimit .. ^1] |
| 137 | if said.len == 0: continue | 138 | if said.len == 0: continue |
modified
nim/src/frq/screens/chat.nim +111 -2 | @@ -14,7 +14,8 @@ | ||
| 14 | 14 | |
| 15 | 15 | import std/[algorithm, json, strutils, tables] |
| 16 | 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 | 19 | from frq/screens/connect import errorNote |
| 19 | 20 | |
| 20 | 21 | const |
| @@ -80,6 +81,54 @@ func runNodes(m: Message): Node = | ||
| 80 | 81 | of rkText: result.children.add text(r.value) |
| 81 | 82 | of rkLink: result.children.add link(r.value, r.value) |
| 82 | 83 | |
| 84 | +const pickerColumns = 8 | |
| 85 | + ## How wide the emoji grid is. Narrow enough to sit under a message on a | |
| 86 | + ## phone without the compose bar leaving the screen. | |
| 87 | + | |
| 88 | +proc emojiPicker(s: State): Node = | |
| 89 | + ## The set to choose from, under the message it is for. | |
| 90 | + ## | |
| 91 | + ## A panel over the compose bar rather than a screen: what is being reacted | |
| 92 | + ## to has to stay in sight, which is the whole reason a reaction is cheaper | |
| 93 | + ## than typing. | |
| 94 | + result = card( | |
| 95 | + hbox(%*{"spacing": 8}, | |
| 96 | + entry("emoji-search", s.emojiSearch, "Search emoji", | |
| 97 | + "emoji.search.change", width = 200), | |
| 98 | + button("✕", "react.close"))) | |
| 99 | + | |
| 100 | + # The groups, as a row of switches. Nothing selected is the popular row, | |
| 101 | + # which is what the picker opens on. | |
| 102 | + var groupRow = hbox(%*{"spacing": 4}) | |
| 103 | + groupRow.children.add button("Popular", "emoji.group:", | |
| 104 | + if s.emojiGroup.len == 0: "primary" else: "default") | |
| 105 | + for g in groups: | |
| 106 | + groupRow.children.add button(g, "emoji.group:" & g, | |
| 107 | + if s.emojiGroup == g: "primary" else: "default") | |
| 108 | + result.children.add groupRow | |
| 109 | + | |
| 110 | + # The grid. Capped, because the catalogue is 1,884 and a tree that carries | |
| 111 | + # all of them across the boundary on every keystroke is a tree nobody can | |
| 112 | + # type into. | |
| 113 | + let shown = pickerEmoji(s.emojiSearch, s.emojiGroup) | |
| 114 | + var grid = vbox(%*{"key": "grid", "spacing": 4}) | |
| 115 | + var row = hbox(%*{"spacing": 4}) | |
| 116 | + var n = 0 | |
| 117 | + for e in shown: | |
| 118 | + if n >= pickerLimit: break | |
| 119 | + row.children.add reaction(e.glyph, 0, false, | |
| 120 | + "react.pick:" & e.glyph) | |
| 121 | + n += 1 | |
| 122 | + if n mod pickerColumns == 0: | |
| 123 | + grid.children.add row | |
| 124 | + row = hbox(%*{"spacing": 4}) | |
| 125 | + if row.children.len > 0: grid.children.add row | |
| 126 | + if n == 0: | |
| 127 | + grid.children.add dimLabel("Nothing matches that.") | |
| 128 | + elif shown.len > pickerLimit: | |
| 129 | + grid.children.add dimLabel("…and " & $(shown.len - pickerLimit) & " more — keep typing.") | |
| 130 | + result.children.add grid | |
| 131 | + | |
| 83 | 132 | proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node = |
| 84 | 133 | ## A message without its face: the sender's line, the words, and what hangs |
| 85 | 134 | ## under them. |
| @@ -123,6 +172,11 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node = | ||
| 123 | 172 | images.children.add image(m.imageUrl, maxWidth = 320, maxHeight = 240, |
| 124 | 173 | onClick = "lightbox:" & m.imageUrl) |
| 125 | 174 | |
| 175 | + # The picker, under the message it is for and nowhere else. | |
| 176 | + var picker = vbox(%*{"key": "picker", "marginBottom": 4}) | |
| 177 | + if s.reacting.has and s.reacting.id == rowId(m): | |
| 178 | + picker.children.add emojiPicker(s) | |
| 179 | + | |
| 126 | 180 | var pills = vbox(%*{"key": "reactions-row", "marginTop": 6}) |
| 127 | 181 | if m.id.len > 0 and not m.system and m.reactions.len > 0: |
| 128 | 182 | pills.children.add reactionRow(m, s.formNick) |
| @@ -132,7 +186,7 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node = | ||
| 132 | 186 | n(if highlit: "card" else: "vbox", |
| 133 | 187 | %*{"key": (if highlit: "body-card" else: "body-plain"), |
| 134 | 188 | "spacing": 2, "margin": 0}, |
| 135 | - @[who, body, images, pills]) | |
| 189 | + @[who, body, picker, images, pills]) | |
| 136 | 190 | |
| 137 | 191 | proc messageRow(s: State, room: Room, i: int, m: Message): Node = |
| 138 | 192 | ## One message: who said it, when, what you can do to it, and the words. |
| @@ -182,6 +236,43 @@ proc visible(s: State, messages: seq[Message]): seq[Message] = | ||
| 182 | 236 | if s.hideJoinPart and m.system: continue |
| 183 | 237 | result.add m |
| 184 | 238 | |
| 239 | +proc overviewPane(s: State): Node = | |
| 240 | + ## What is happening in every room but this one, newest first. | |
| 241 | + ## | |
| 242 | + ## A turn to each room rather than the newest lines outright — see | |
| 243 | + ## `rooms.recentEverywhere`. Taking the newest hundred would be the strip | |
| 244 | + ## answering about whichever room is busiest, which is the one already on | |
| 245 | + ## screen. | |
| 246 | + result = card(title2("Overview")) | |
| 247 | + var body = vbox(%*{"spacing": 4}) | |
| 248 | + var n = 0 | |
| 249 | + for m in recentEverywhere(s.rooms, s.current): | |
| 250 | + if n >= overviewLines: break | |
| 251 | + n += 1 | |
| 252 | + # Each line carries the room it was said in, since that is the one thing a | |
| 253 | + # line taken out of its own conversation no longer says for itself. | |
| 254 | + body.children.add hbox(%*{"spacing": 6}, | |
| 255 | + dimLabel(m.room), | |
| 256 | + label(m.frm & ":"), | |
| 257 | + text(summarise(m.text, 48)), | |
| 258 | + button("→", "overview.goto:" & m.room & ":" & rowId(m))) | |
| 259 | + if n == 0: | |
| 260 | + body.children.add dimLabel("Nothing has happened anywhere else.") | |
| 261 | + result.children.add body | |
| 262 | + | |
| 263 | +proc lightboxPane(s: State): Node = | |
| 264 | + ## The picture being looked at, full size. | |
| 265 | + ## | |
| 266 | + ## A panel over the conversation rather than a screen of its own: closing it | |
| 267 | + ## should put the reader back exactly where they were, and a screen would | |
| 268 | + ## have to remember where that was. | |
| 269 | + card( | |
| 270 | + hbox(%*{"spacing": 8}, | |
| 271 | + title2("Picture"), | |
| 272 | + button("Close", "lightbox.close")), | |
| 273 | + image(s.lightbox.url, maxWidth = 640, maxHeight = 480), | |
| 274 | + dimLabel(s.lightbox.url)) | |
| 275 | + | |
| 185 | 276 | proc chatScreen*(s: State, connected: bool): Node = |
| 186 | 277 | let room = s.currentRoom |
| 187 | 278 | let name = if room.name.len > 0: room.name else: "Chat" |
| @@ -254,6 +345,21 @@ proc chatScreen*(s: State, connected: bool): Node = | ||
| 254 | 345 | panel.children.add label(m.prefix & m.nick) |
| 255 | 346 | peoplePane.children.add panel |
| 256 | 347 | |
| 348 | + # Both panels are in wrappers that are always there, for the reason the | |
| 349 | + # error note is: a child that comes and goes renumbers the row. | |
| 350 | + var overview = vbox(%*{"key": "overview-pane"}) | |
| 351 | + if s.overview: | |
| 352 | + overview.children.add overviewPane(s) | |
| 353 | + | |
| 354 | + var lightbox = vbox(%*{"key": "lightbox-pane"}) | |
| 355 | + if s.lightbox.has: | |
| 356 | + lightbox.children.add lightboxPane(s) | |
| 357 | + | |
| 358 | + var returnRow = vbox(%*{"key": "overview-back"}) | |
| 359 | + if s.overviewReturn.len > 0: | |
| 360 | + returnRow.children.add button("← back to " & s.overviewReturn, | |
| 361 | + "overview.back") | |
| 362 | + | |
| 257 | 363 | var jump = vbox(%*{"key": "jump"}) |
| 258 | 364 | if not s.atPresent: |
| 259 | 365 | jump.children.add button("↓ Jump to present", "jump.present") |
| @@ -302,6 +408,9 @@ proc chatScreen*(s: State, connected: bool): Node = | ||
| 302 | 408 | # row's children, which is the prop being on the wrong node. |
| 303 | 409 | n("hbox", %*{"spacing": 8, "wrap": false, "expand": true}, |
| 304 | 410 | @[messages, peoplePane]), |
| 411 | + overview, | |
| 412 | + lightbox, | |
| 413 | + returnRow, | |
| 305 | 414 | jump, |
| 306 | 415 | banners, |
| 307 | 416 | separator(), |
| @@ -14,7 +14,8 @@ | |||
| 14 | 14 | ||
| 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 | from frq/screens/connect import errorNote | 19 | from frq/screens/connect import errorNote |
| 19 | 20 | ||
| 20 | const | 21 | const |
| @@ -80,6 +81,54 @@ func runNodes(m: Message): Node = | |||
| 80 | of rkText: result.children.add text(r.value) | 81 | of rkText: result.children.add text(r.value) |
| 81 | of rkLink: result.children.add link(r.value, r.value) | 82 | of rkLink: result.children.add link(r.value, r.value) |
| 82 | 83 | ||
| 84 | +const pickerColumns = 8 | ||
| 85 | + ## How wide the emoji grid is. Narrow enough to sit under a message on a | ||
| 86 | + ## phone without the compose bar leaving the screen. | ||
| 87 | + | ||
| 88 | +proc emojiPicker(s: State): Node = | ||
| 89 | + ## The set to choose from, under the message it is for. | ||
| 90 | + ## | ||
| 91 | + ## A panel over the compose bar rather than a screen: what is being reacted | ||
| 92 | + ## to has to stay in sight, which is the whole reason a reaction is cheaper | ||
| 93 | + ## than typing. | ||
| 94 | + result = card( | ||
| 95 | + hbox(%*{"spacing": 8}, | ||
| 96 | + entry("emoji-search", s.emojiSearch, "Search emoji", | ||
| 97 | + "emoji.search.change", width = 200), | ||
| 98 | + button("✕", "react.close"))) | ||
| 99 | + | ||
| 100 | + # The groups, as a row of switches. Nothing selected is the popular row, | ||
| 101 | + # which is what the picker opens on. | ||
| 102 | + var groupRow = hbox(%*{"spacing": 4}) | ||
| 103 | + groupRow.children.add button("Popular", "emoji.group:", | ||
| 104 | + if s.emojiGroup.len == 0: "primary" else: "default") | ||
| 105 | + for g in groups: | ||
| 106 | + groupRow.children.add button(g, "emoji.group:" & g, | ||
| 107 | + if s.emojiGroup == g: "primary" else: "default") | ||
| 108 | + result.children.add groupRow | ||
| 109 | + | ||
| 110 | + # The grid. Capped, because the catalogue is 1,884 and a tree that carries | ||
| 111 | + # all of them across the boundary on every keystroke is a tree nobody can | ||
| 112 | + # type into. | ||
| 113 | + let shown = pickerEmoji(s.emojiSearch, s.emojiGroup) | ||
| 114 | + var grid = vbox(%*{"key": "grid", "spacing": 4}) | ||
| 115 | + var row = hbox(%*{"spacing": 4}) | ||
| 116 | + var n = 0 | ||
| 117 | + for e in shown: | ||
| 118 | + if n >= pickerLimit: break | ||
| 119 | + row.children.add reaction(e.glyph, 0, false, | ||
| 120 | + "react.pick:" & e.glyph) | ||
| 121 | + n += 1 | ||
| 122 | + if n mod pickerColumns == 0: | ||
| 123 | + grid.children.add row | ||
| 124 | + row = hbox(%*{"spacing": 4}) | ||
| 125 | + if row.children.len > 0: grid.children.add row | ||
| 126 | + if n == 0: | ||
| 127 | + grid.children.add dimLabel("Nothing matches that.") | ||
| 128 | + elif shown.len > pickerLimit: | ||
| 129 | + grid.children.add dimLabel("…and " & $(shown.len - pickerLimit) & " more — keep typing.") | ||
| 130 | + result.children.add grid | ||
| 131 | + | ||
| 83 | proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node = | 132 | proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node = |
| 84 | ## A message without its face: the sender's line, the words, and what hangs | 133 | ## A message without its face: the sender's line, the words, and what hangs |
| 85 | ## under them. | 134 | ## under them. |
| @@ -123,6 +172,11 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node = | |||
| 123 | images.children.add image(m.imageUrl, maxWidth = 320, maxHeight = 240, | 172 | images.children.add image(m.imageUrl, maxWidth = 320, maxHeight = 240, |
| 124 | onClick = "lightbox:" & m.imageUrl) | 173 | onClick = "lightbox:" & m.imageUrl) |
| 125 | 174 | ||
| 175 | + # The picker, under the message it is for and nowhere else. | ||
| 176 | + var picker = vbox(%*{"key": "picker", "marginBottom": 4}) | ||
| 177 | + if s.reacting.has and s.reacting.id == rowId(m): | ||
| 178 | + picker.children.add emojiPicker(s) | ||
| 179 | + | ||
| 126 | var pills = vbox(%*{"key": "reactions-row", "marginTop": 6}) | 180 | var pills = vbox(%*{"key": "reactions-row", "marginTop": 6}) |
| 127 | if m.id.len > 0 and not m.system and m.reactions.len > 0: | 181 | if m.id.len > 0 and not m.system and m.reactions.len > 0: |
| 128 | pills.children.add reactionRow(m, s.formNick) | 182 | pills.children.add reactionRow(m, s.formNick) |
| @@ -132,7 +186,7 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node = | |||
| 132 | n(if highlit: "card" else: "vbox", | 186 | n(if highlit: "card" else: "vbox", |
| 133 | %*{"key": (if highlit: "body-card" else: "body-plain"), | 187 | %*{"key": (if highlit: "body-card" else: "body-plain"), |
| 134 | "spacing": 2, "margin": 0}, | 188 | "spacing": 2, "margin": 0}, |
| 135 | - @[who, body, images, pills]) | 189 | + @[who, body, picker, images, pills]) |
| 136 | 190 | ||
| 137 | proc messageRow(s: State, room: Room, i: int, m: Message): Node = | 191 | proc messageRow(s: State, room: Room, i: int, m: Message): Node = |
| 138 | ## One message: who said it, when, what you can do to it, and the words. | 192 | ## One message: who said it, when, what you can do to it, and the words. |
| @@ -182,6 +236,43 @@ proc visible(s: State, messages: seq[Message]): seq[Message] = | |||
| 182 | if s.hideJoinPart and m.system: continue | 236 | if s.hideJoinPart and m.system: continue |
| 183 | result.add m | 237 | result.add m |
| 184 | 238 | ||
| 239 | +proc overviewPane(s: State): Node = | ||
| 240 | + ## What is happening in every room but this one, newest first. | ||
| 241 | + ## | ||
| 242 | + ## A turn to each room rather than the newest lines outright — see | ||
| 243 | + ## `rooms.recentEverywhere`. Taking the newest hundred would be the strip | ||
| 244 | + ## answering about whichever room is busiest, which is the one already on | ||
| 245 | + ## screen. | ||
| 246 | + result = card(title2("Overview")) | ||
| 247 | + var body = vbox(%*{"spacing": 4}) | ||
| 248 | + var n = 0 | ||
| 249 | + for m in recentEverywhere(s.rooms, s.current): | ||
| 250 | + if n >= overviewLines: break | ||
| 251 | + n += 1 | ||
| 252 | + # Each line carries the room it was said in, since that is the one thing a | ||
| 253 | + # line taken out of its own conversation no longer says for itself. | ||
| 254 | + body.children.add hbox(%*{"spacing": 6}, | ||
| 255 | + dimLabel(m.room), | ||
| 256 | + label(m.frm & ":"), | ||
| 257 | + text(summarise(m.text, 48)), | ||
| 258 | + button("→", "overview.goto:" & m.room & ":" & rowId(m))) | ||
| 259 | + if n == 0: | ||
| 260 | + body.children.add dimLabel("Nothing has happened anywhere else.") | ||
| 261 | + result.children.add body | ||
| 262 | + | ||
| 263 | +proc lightboxPane(s: State): Node = | ||
| 264 | + ## The picture being looked at, full size. | ||
| 265 | + ## | ||
| 266 | + ## A panel over the conversation rather than a screen of its own: closing it | ||
| 267 | + ## should put the reader back exactly where they were, and a screen would | ||
| 268 | + ## have to remember where that was. | ||
| 269 | + card( | ||
| 270 | + hbox(%*{"spacing": 8}, | ||
| 271 | + title2("Picture"), | ||
| 272 | + button("Close", "lightbox.close")), | ||
| 273 | + image(s.lightbox.url, maxWidth = 640, maxHeight = 480), | ||
| 274 | + dimLabel(s.lightbox.url)) | ||
| 275 | + | ||
| 185 | proc chatScreen*(s: State, connected: bool): Node = | 276 | proc chatScreen*(s: State, connected: bool): Node = |
| 186 | let room = s.currentRoom | 277 | let room = s.currentRoom |
| 187 | let name = if room.name.len > 0: room.name else: "Chat" | 278 | let name = if room.name.len > 0: room.name else: "Chat" |
| @@ -254,6 +345,21 @@ proc chatScreen*(s: State, connected: bool): Node = | |||
| 254 | panel.children.add label(m.prefix & m.nick) | 345 | panel.children.add label(m.prefix & m.nick) |
| 255 | peoplePane.children.add panel | 346 | peoplePane.children.add panel |
| 256 | 347 | ||
| 348 | + # Both panels are in wrappers that are always there, for the reason the | ||
| 349 | + # error note is: a child that comes and goes renumbers the row. | ||
| 350 | + var overview = vbox(%*{"key": "overview-pane"}) | ||
| 351 | + if s.overview: | ||
| 352 | + overview.children.add overviewPane(s) | ||
| 353 | + | ||
| 354 | + var lightbox = vbox(%*{"key": "lightbox-pane"}) | ||
| 355 | + if s.lightbox.has: | ||
| 356 | + lightbox.children.add lightboxPane(s) | ||
| 357 | + | ||
| 358 | + var returnRow = vbox(%*{"key": "overview-back"}) | ||
| 359 | + if s.overviewReturn.len > 0: | ||
| 360 | + returnRow.children.add button("← back to " & s.overviewReturn, | ||
| 361 | + "overview.back") | ||
| 362 | + | ||
| 257 | var jump = vbox(%*{"key": "jump"}) | 363 | var jump = vbox(%*{"key": "jump"}) |
| 258 | if not s.atPresent: | 364 | if not s.atPresent: |
| 259 | jump.children.add button("↓ Jump to present", "jump.present") | 365 | jump.children.add button("↓ Jump to present", "jump.present") |
| @@ -302,6 +408,9 @@ proc chatScreen*(s: State, connected: bool): Node = | |||
| 302 | # row's children, which is the prop being on the wrong node. | 408 | # row's children, which is the prop being on the wrong node. |
| 303 | n("hbox", %*{"spacing": 8, "wrap": false, "expand": true}, | 409 | n("hbox", %*{"spacing": 8, "wrap": false, "expand": true}, |
| 304 | @[messages, peoplePane]), | 410 | @[messages, peoplePane]), |
| 411 | + overview, | ||
| 412 | + lightbox, | ||
| 413 | + returnRow, | ||
| 305 | jump, | 414 | jump, |
| 306 | banners, | 415 | banners, |
| 307 | separator(), | 416 | separator(), |
modified
nim/tests/tchat.nim +126 -0 | @@ -188,3 +188,129 @@ suite "the chat screen": | ||
| 188 | 188 | let t = cs.chatScreen(s, true) |
| 189 | 189 | check "draft" in t.find("entry").mapIt(it.props{"key"}.getStr()) |
| 190 | 190 | check "Send" in t.labels("button") |
| 191 | + | |
| 192 | +import frq/[reducer, glyphs, emoji] | |
| 193 | + | |
| 194 | +suite "the emoji picker": | |
| 195 | + setup: | |
| 196 | + var s = withRoom() | |
| 197 | + app = s | |
| 198 | + | |
| 199 | + test "is not there until a message asks for it": | |
| 200 | + check cs.chatScreen(s, true).find("entry") | |
| 201 | + .mapIt(it.props{"key"}.getStr()).countIt(it == "emoji-search") == 0 | |
| 202 | + | |
| 203 | + test "opens under the message it is for, and nowhere else": | |
| 204 | + s.reacting = ReactTarget(has: true, room: "#test", id: "1") | |
| 205 | + let t = cs.chatScreen(s, true) | |
| 206 | + check t.find("entry").anyIt(it.props{"key"}.getStr() == "emoji-search") | |
| 207 | + # One picker, not one per message. | |
| 208 | + check t.find("entry").countIt(it.props{"key"}.getStr() == "emoji-search") == 1 | |
| 209 | + | |
| 210 | + test "opens on the popular row": | |
| 211 | + s.reacting = ReactTarget(has: true, room: "#test", id: "1") | |
| 212 | + let picks = cs.chatScreen(s, true).find("reaction") | |
| 213 | + .filterIt(it.props{"onClick"}.getStr().startsWith("react.pick:")) | |
| 214 | + check picks.len == popular.len | |
| 215 | + check picks[0].props{"emoji"}.getStr() == popular[0] | |
| 216 | + | |
| 217 | + test "a group shows that group, capped": | |
| 218 | + s.reacting = ReactTarget(has: true, room: "#test", id: "1") | |
| 219 | + s.emojiGroup = "Smileys & Emotion" | |
| 220 | + let picks = cs.chatScreen(s, true).find("reaction") | |
| 221 | + .filterIt(it.props{"onClick"}.getStr().startsWith("react.pick:")) | |
| 222 | + # Capped: the whole catalogue crossing the boundary per keystroke is a | |
| 223 | + # picker nobody can type into. | |
| 224 | + check picks.len == pickerLimit | |
| 225 | + | |
| 226 | + test "search narrows it": | |
| 227 | + s.reacting = ReactTarget(has: true, room: "#test", id: "1") | |
| 228 | + s.emojiSearch = "grinning" | |
| 229 | + let picks = cs.chatScreen(s, true).find("reaction") | |
| 230 | + .filterIt(it.props{"onClick"}.getStr().startsWith("react.pick:")) | |
| 231 | + check picks.len > 0 | |
| 232 | + check picks.len < pickerLimit | |
| 233 | + | |
| 234 | + test "a search matching nothing says so rather than showing an empty grid": | |
| 235 | + s.reacting = ReactTarget(has: true, room: "#test", id: "1") | |
| 236 | + s.emojiSearch = "zzzzznotanemoji" | |
| 237 | + check "Nothing matches that." in cs.chatScreen(s, true).labels("dim-label") | |
| 238 | + | |
| 239 | +suite "the overview": | |
| 240 | + setup: | |
| 241 | + app = withRoom() | |
| 242 | + app.rooms["#other"] = initRoom("#other") | |
| 243 | + var o = app.rooms["#other"] | |
| 244 | + o.messages = @[Message(id: "o1", frm: "zoe", text: "elsewhere", | |
| 245 | + at: 1_700_000_500_000)] | |
| 246 | + app.rooms["#other"] = o | |
| 247 | + | |
| 248 | + test "is not there until asked for": | |
| 249 | + check "Overview" notin cs.chatScreen(app, true).labels("title-2") | |
| 250 | + | |
| 251 | + test "shows lines from other rooms, naming the room": | |
| 252 | + app.overview = true | |
| 253 | + let t = cs.chatScreen(app, true) | |
| 254 | + check "Overview" in t.labels("title-2") | |
| 255 | + check "#other" in t.labels("dim-label") | |
| 256 | + check t.find("text").anyIt("elsewhere" in it.props{"text"}.getStr()) | |
| 257 | + | |
| 258 | + test "leaves out the room being read": | |
| 259 | + app.overview = true | |
| 260 | + # #test's own lines are on screen already, directly above. | |
| 261 | + check not cs.chatScreen(app, true).find("text") | |
| 262 | + .anyIt("hello" in it.props{"text"}.getStr() and | |
| 263 | + it.props{"text"}.getStr() != "hello") | |
| 264 | + | |
| 265 | + test "an empty one says so": | |
| 266 | + var lonely = withRoom() | |
| 267 | + lonely.overview = true | |
| 268 | + check "Nothing has happened anywhere else." in | |
| 269 | + cs.chatScreen(lonely, true).labels("dim-label") | |
| 270 | + | |
| 271 | + test "going somewhere from it offers the way back": | |
| 272 | + app.overview = true | |
| 273 | + dispatch(%*{"id": "overview.goto:#other:o1"}) | |
| 274 | + check app.current == "#other" | |
| 275 | + check app.overviewReturn == "#test" | |
| 276 | + check not app.overview | |
| 277 | + check cs.chatScreen(app, true).labels("button").anyIt("back to #test" in it) | |
| 278 | + | |
| 279 | + test "and the way back works": | |
| 280 | + app.overview = true | |
| 281 | + dispatch(%*{"id": "overview.goto:#other:o1"}) | |
| 282 | + dispatch(%*{"id": "overview.back"}) | |
| 283 | + check app.current == "#test" | |
| 284 | + check app.overviewReturn == "" | |
| 285 | + | |
| 286 | +suite "the lightbox": | |
| 287 | + setup: | |
| 288 | + var s = withRoom() | |
| 289 | + app = s | |
| 290 | + | |
| 291 | + test "is not there until a picture is opened": | |
| 292 | + check "Picture" notin cs.chatScreen(s, true).labels("title-2") | |
| 293 | + | |
| 294 | + test "shows the picture and a way out": | |
| 295 | + s.lightbox = Lightbox(has: true, url: "https://x.com/a.png", | |
| 296 | + path: "https://x.com/a.png") | |
| 297 | + let t = cs.chatScreen(s, true) | |
| 298 | + check "Picture" in t.labels("title-2") | |
| 299 | + check "Close" in t.labels("button") | |
| 300 | + check t.find("image").anyIt(it.props{"src"}.getStr() == "https://x.com/a.png") | |
| 301 | + | |
| 302 | + test "clicking a picture opens it": | |
| 303 | + var r = app.rooms["#test"] | |
| 304 | + r.messages[0].imageUrl = "https://x.com/a.png" | |
| 305 | + app.rooms["#test"] = r | |
| 306 | + let img = cs.chatScreen(app, true).find("image") | |
| 307 | + .filterIt(it.props{"src"}.getStr() == "https://x.com/a.png") | |
| 308 | + check img.len == 1 | |
| 309 | + dispatch(%*{"id": img[0].props{"onClick"}.getStr()}) | |
| 310 | + check app.lightbox.has | |
| 311 | + check app.lightbox.url == "https://x.com/a.png" | |
| 312 | + | |
| 313 | + test "and closing it puts it away": | |
| 314 | + dispatch(%*{"id": "lightbox:https://x.com/a.png"}) | |
| 315 | + dispatch(%*{"id": "lightbox.close"}) | |
| 316 | + check not app.lightbox.has | |
| @@ -188,3 +188,129 @@ suite "the chat screen": | |||
| 188 | let t = cs.chatScreen(s, true) | 188 | let t = cs.chatScreen(s, true) |
| 189 | check "draft" in t.find("entry").mapIt(it.props{"key"}.getStr()) | 189 | check "draft" in t.find("entry").mapIt(it.props{"key"}.getStr()) |
| 190 | check "Send" in t.labels("button") | 190 | check "Send" in t.labels("button") |
| 191 | + | ||
| 192 | +import frq/[reducer, glyphs, emoji] | ||
| 193 | + | ||
| 194 | +suite "the emoji picker": | ||
| 195 | + setup: | ||
| 196 | + var s = withRoom() | ||
| 197 | + app = s | ||
| 198 | + | ||
| 199 | + test "is not there until a message asks for it": | ||
| 200 | + check cs.chatScreen(s, true).find("entry") | ||
| 201 | + .mapIt(it.props{"key"}.getStr()).countIt(it == "emoji-search") == 0 | ||
| 202 | + | ||
| 203 | + test "opens under the message it is for, and nowhere else": | ||
| 204 | + s.reacting = ReactTarget(has: true, room: "#test", id: "1") | ||
| 205 | + let t = cs.chatScreen(s, true) | ||
| 206 | + check t.find("entry").anyIt(it.props{"key"}.getStr() == "emoji-search") | ||
| 207 | + # One picker, not one per message. | ||
| 208 | + check t.find("entry").countIt(it.props{"key"}.getStr() == "emoji-search") == 1 | ||
| 209 | + | ||
| 210 | + test "opens on the popular row": | ||
| 211 | + s.reacting = ReactTarget(has: true, room: "#test", id: "1") | ||
| 212 | + let picks = cs.chatScreen(s, true).find("reaction") | ||
| 213 | + .filterIt(it.props{"onClick"}.getStr().startsWith("react.pick:")) | ||
| 214 | + check picks.len == popular.len | ||
| 215 | + check picks[0].props{"emoji"}.getStr() == popular[0] | ||
| 216 | + | ||
| 217 | + test "a group shows that group, capped": | ||
| 218 | + s.reacting = ReactTarget(has: true, room: "#test", id: "1") | ||
| 219 | + s.emojiGroup = "Smileys & Emotion" | ||
| 220 | + let picks = cs.chatScreen(s, true).find("reaction") | ||
| 221 | + .filterIt(it.props{"onClick"}.getStr().startsWith("react.pick:")) | ||
| 222 | + # Capped: the whole catalogue crossing the boundary per keystroke is a | ||
| 223 | + # picker nobody can type into. | ||
| 224 | + check picks.len == pickerLimit | ||
| 225 | + | ||
| 226 | + test "search narrows it": | ||
| 227 | + s.reacting = ReactTarget(has: true, room: "#test", id: "1") | ||
| 228 | + s.emojiSearch = "grinning" | ||
| 229 | + let picks = cs.chatScreen(s, true).find("reaction") | ||
| 230 | + .filterIt(it.props{"onClick"}.getStr().startsWith("react.pick:")) | ||
| 231 | + check picks.len > 0 | ||
| 232 | + check picks.len < pickerLimit | ||
| 233 | + | ||
| 234 | + test "a search matching nothing says so rather than showing an empty grid": | ||
| 235 | + s.reacting = ReactTarget(has: true, room: "#test", id: "1") | ||
| 236 | + s.emojiSearch = "zzzzznotanemoji" | ||
| 237 | + check "Nothing matches that." in cs.chatScreen(s, true).labels("dim-label") | ||
| 238 | + | ||
| 239 | +suite "the overview": | ||
| 240 | + setup: | ||
| 241 | + app = withRoom() | ||
| 242 | + app.rooms["#other"] = initRoom("#other") | ||
| 243 | + var o = app.rooms["#other"] | ||
| 244 | + o.messages = @[Message(id: "o1", frm: "zoe", text: "elsewhere", | ||
| 245 | + at: 1_700_000_500_000)] | ||
| 246 | + app.rooms["#other"] = o | ||
| 247 | + | ||
| 248 | + test "is not there until asked for": | ||
| 249 | + check "Overview" notin cs.chatScreen(app, true).labels("title-2") | ||
| 250 | + | ||
| 251 | + test "shows lines from other rooms, naming the room": | ||
| 252 | + app.overview = true | ||
| 253 | + let t = cs.chatScreen(app, true) | ||
| 254 | + check "Overview" in t.labels("title-2") | ||
| 255 | + check "#other" in t.labels("dim-label") | ||
| 256 | + check t.find("text").anyIt("elsewhere" in it.props{"text"}.getStr()) | ||
| 257 | + | ||
| 258 | + test "leaves out the room being read": | ||
| 259 | + app.overview = true | ||
| 260 | + # #test's own lines are on screen already, directly above. | ||
| 261 | + check not cs.chatScreen(app, true).find("text") | ||
| 262 | + .anyIt("hello" in it.props{"text"}.getStr() and | ||
| 263 | + it.props{"text"}.getStr() != "hello") | ||
| 264 | + | ||
| 265 | + test "an empty one says so": | ||
| 266 | + var lonely = withRoom() | ||
| 267 | + lonely.overview = true | ||
| 268 | + check "Nothing has happened anywhere else." in | ||
| 269 | + cs.chatScreen(lonely, true).labels("dim-label") | ||
| 270 | + | ||
| 271 | + test "going somewhere from it offers the way back": | ||
| 272 | + app.overview = true | ||
| 273 | + dispatch(%*{"id": "overview.goto:#other:o1"}) | ||
| 274 | + check app.current == "#other" | ||
| 275 | + check app.overviewReturn == "#test" | ||
| 276 | + check not app.overview | ||
| 277 | + check cs.chatScreen(app, true).labels("button").anyIt("back to #test" in it) | ||
| 278 | + | ||
| 279 | + test "and the way back works": | ||
| 280 | + app.overview = true | ||
| 281 | + dispatch(%*{"id": "overview.goto:#other:o1"}) | ||
| 282 | + dispatch(%*{"id": "overview.back"}) | ||
| 283 | + check app.current == "#test" | ||
| 284 | + check app.overviewReturn == "" | ||
| 285 | + | ||
| 286 | +suite "the lightbox": | ||
| 287 | + setup: | ||
| 288 | + var s = withRoom() | ||
| 289 | + app = s | ||
| 290 | + | ||
| 291 | + test "is not there until a picture is opened": | ||
| 292 | + check "Picture" notin cs.chatScreen(s, true).labels("title-2") | ||
| 293 | + | ||
| 294 | + test "shows the picture and a way out": | ||
| 295 | + s.lightbox = Lightbox(has: true, url: "https://x.com/a.png", | ||
| 296 | + path: "https://x.com/a.png") | ||
| 297 | + let t = cs.chatScreen(s, true) | ||
| 298 | + check "Picture" in t.labels("title-2") | ||
| 299 | + check "Close" in t.labels("button") | ||
| 300 | + check t.find("image").anyIt(it.props{"src"}.getStr() == "https://x.com/a.png") | ||
| 301 | + | ||
| 302 | + test "clicking a picture opens it": | ||
| 303 | + var r = app.rooms["#test"] | ||
| 304 | + r.messages[0].imageUrl = "https://x.com/a.png" | ||
| 305 | + app.rooms["#test"] = r | ||
| 306 | + let img = cs.chatScreen(app, true).find("image") | ||
| 307 | + .filterIt(it.props{"src"}.getStr() == "https://x.com/a.png") | ||
| 308 | + check img.len == 1 | ||
| 309 | + dispatch(%*{"id": img[0].props{"onClick"}.getStr()}) | ||
| 310 | + check app.lightbox.has | ||
| 311 | + check app.lightbox.url == "https://x.com/a.png" | ||
| 312 | + | ||
| 313 | + test "and closing it puts it away": | ||
| 314 | + dispatch(%*{"id": "lightbox:https://x.com/a.png"}) | ||
| 315 | + dispatch(%*{"id": "lightbox.close"}) | ||
| 316 | + check not app.lightbox.has | ||