nandi/frqpublic Fork 0
98d6135
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-19T06:43:27-07:00 Browse files
98d6135 parent: 284b59c
modified flutter/test/nim_layout_test.dart +31 -0
@@ -99,6 +99,37 @@ void main() {
9999 expectLaidOut(tester, 'chat editing');
100100 });
101101
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+
102133 testWidgets('lays out when scrolled off the present', (tester) async {
103134 core.demoUi();
104135 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
7575 freeq what a collapsed msgid was. Neither had a screen in this app to appear
7676 on.
7777
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.
8186
8287 Untested against a real account: nobody has watched freeq accept a SASL
8388 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 appear75 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 are78+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, so79+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 SASL87 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 is88 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 =
101101 if r.kind == gkEmoji: return true
102102 false
103103
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+
104111 proc pickerEmoji*(search, group: string): seq[Emoji] =
105112 ## What the picker is showing: the popular row, one group, or whatever the
106113 ## 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 true101 if r.kind == gkEmoji: return true
102 false102 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 the112 ## 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 by113 ## 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
3838 reactions*: seq[Reaction]
3939 imageUrl*: string ## the first picture link in the text, "" for none
4040 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.
4145
4246 Room* = object
4347 ## 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 none39 imageUrl*: string ## the first picture link in the text, "" for none
40 pending*: bool ## sent, not yet echoed40 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* = object46 Room* = object
43 ## A buffer: a channel or a DM. Named Room rather than Channel because47 ## 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) =
258258 # -------------------------------------------------------------- reactions
259259 of "react.open":
260260 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 = ""
262290
263291 of "react.toggle":
264292 # `id:emoji`, and the emoji may contain nothing colon-like so one more
@@ -283,6 +311,23 @@ proc dispatch*(event: JsonNode) =
283311 app.jumpTo = arg
284312 app.highlight = arg
285313
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+
286331 of "lightbox":
287332 app.lightbox = Lightbox(has: true, url: arg, path: arg)
288333 of "lightbox.close": app.lightbox = Lightbox()
@@ -258,7 +258,35 @@ proc dispatch*(event: JsonNode) =
258 # -------------------------------------------------------------- reactions258 # -------------------------------------------------------------- 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 more292 # `id:emoji`, and the emoji may contain nothing colon-like so one more
@@ -283,6 +311,23 @@ proc dispatch*(event: JsonNode) =
283 app.jumpTo = arg311 app.jumpTo = arg
284 app.highlight = arg312 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],
132132 for name, ch in channels:
133133 if name == current: continue
134134 var said = ch.messages.filterIt(not it.system)
135+ for i in 0 ..< said.len: said[i].room = name
135136 if said.len > overviewLimit:
136137 said = said[^overviewLimit .. ^1]
137138 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: continue133 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: continue138 if said.len == 0: continue
modified nim/src/frq/screens/chat.nim +111 -2
@@ -14,7 +14,8 @@
1414
1515 import std/[algorithm, json, strutils, tables]
1616 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]
1819 from frq/screens/connect import errorNote
1920
2021 const
@@ -80,6 +81,54 @@ func runNodes(m: Message): Node =
8081 of rkText: result.children.add text(r.value)
8182 of rkLink: result.children.add link(r.value, r.value)
8283
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+
83132 proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
84133 ## A message without its face: the sender's line, the words, and what hangs
85134 ## under them.
@@ -123,6 +172,11 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
123172 images.children.add image(m.imageUrl, maxWidth = 320, maxHeight = 240,
124173 onClick = "lightbox:" & m.imageUrl)
125174
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+
126180 var pills = vbox(%*{"key": "reactions-row", "marginTop": 6})
127181 if m.id.len > 0 and not m.system and m.reactions.len > 0:
128182 pills.children.add reactionRow(m, s.formNick)
@@ -132,7 +186,7 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
132186 n(if highlit: "card" else: "vbox",
133187 %*{"key": (if highlit: "body-card" else: "body-plain"),
134188 "spacing": 2, "margin": 0},
135- @[who, body, images, pills])
189+ @[who, body, picker, images, pills])
136190
137191 proc messageRow(s: State, room: Room, i: int, m: Message): Node =
138192 ## 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] =
182236 if s.hideJoinPart and m.system: continue
183237 result.add m
184238
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+
185276 proc chatScreen*(s: State, connected: bool): Node =
186277 let room = s.currentRoom
187278 let name = if room.name.len > 0: room.name else: "Chat"
@@ -254,6 +345,21 @@ proc chatScreen*(s: State, connected: bool): Node =
254345 panel.children.add label(m.prefix & m.nick)
255346 peoplePane.children.add panel
256347
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+
257363 var jump = vbox(%*{"key": "jump"})
258364 if not s.atPresent:
259365 jump.children.add button("↓ Jump to present", "jump.present")
@@ -302,6 +408,9 @@ proc chatScreen*(s: State, connected: bool): Node =
302408 # row's children, which is the prop being on the wrong node.
303409 n("hbox", %*{"spacing": 8, "wrap": false, "expand": true},
304410 @[messages, peoplePane]),
411+ overview,
412+ lightbox,
413+ returnRow,
305414 jump,
306415 banners,
307416 separator(),
@@ -14,7 +14,8 @@
14 14
15 import std/[algorithm, json, strutils, tables]15 import std/[algorithm, json, strutils, tables]
16 import std/options16 import std/options
17-import frq/[ui, cells, model, clock, reactions, textruns, members]17+import frq/[ui, cells, model, clock, reactions, textruns, members,
18+ glyphs, emoji, rooms]
18 from frq/screens/connect import errorNote19 from frq/screens/connect import errorNote
19 20
20 const21 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 hangs133 ## 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: continue236 if s.hideJoinPart and m.system: continue
183 result.add m237 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.currentRoom277 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 panel346 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":
188188 let t = cs.chatScreen(s, true)
189189 check "draft" in t.find("entry").mapIt(it.props{"key"}.getStr())
190190 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