nandi/frqpublic Fork 0
98d613511f9664af4d9779a07baf26ecb2feaeea
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.

tchat.nim · 316 lines · 12.1 KBNim Blame HistoryRaw
The chat screen, in Nim 87a28cc nandi yesterday1## The conversation screen.
2
3import std/[json, sequtils, strutils, tables, unicode, unittest]
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday4import frq/[ui, cells, model, textruns]
The chat screen, in Nim 87a28cc nandi yesterday5import frq/screens/chat as cs
6
7proc find(node: Node, tag: string): seq[Node] =
8 if node.isNil: return
9 if node.tag == tag: result.add node
10 for c in node.children: result.add c.find(tag)
11
12proc labels(node: Node, tag: string): seq[string] =
13 node.find(tag).mapIt(it.props{"label"}.getStr())
14
15proc texts(node: Node): seq[string] =
16 node.find("text").mapIt(it.props{"text"}.getStr())
17
18proc withRoom(): State =
19 result = initState()
20 var r = initRoom("#test")
21 r.joined = true
Four more modules into Nim: members, glyphs, emoji, store 3975b4b nandi yesterday22 r.users = {"alice": "@", "bob": ""}.toTable
The chat screen, in Nim 87a28cc nandi yesterday23 r.messages = @[
24 Message(id: "1", frm: "alice", text: "hello", at: 1_700_000_000_000),
25 Message(id: "2", frm: "frq-guest", text: "hi back", at: 1_700_000_060_000)]
26 result.rooms["#test"] = r
27 result.current = "#test"
28 result.formNick = "frq-guest"
29
30suite "summarise":
31 test "collapses whitespace and cuts to fit":
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday32 check summarise("a\n b", 36) == "a b"
33 check summarise("x".repeat(50), 10).runeLen == 10
34 check summarise("x".repeat(50), 10).endsWith("")
The chat screen, in Nim 87a28cc nandi yesterday35 test "leaves a short line alone":
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi yesterday36 check summarise("short", 36) == "short"
The chat screen, in Nim 87a28cc nandi yesterday37
38suite "the chat screen":
39 setup:
40 var s = withRoom()
41
42 test "shows the room name and its messages":
43 let t = cs.chatScreen(s, true)
44 check "#test" in t.labels("title")
45 check "hello" in t.texts
46 check "hi back" in t.texts
47
48 test "is pure":
49 check $cs.chatScreen(s, true).toJson == $cs.chatScreen(s, true).toJson
50
51 test "an empty room says so rather than showing nothing":
52 var e = initState()
53 e.rooms["#empty"] = initRoom("#empty")
54 e.current = "#empty"
55 check "Nothing here yet." in cs.chatScreen(e, true).labels("dim-label")
56
57 test "every line names its sender":
58 # Not the first of a run only: answering the fourth line of a collapsed
59 # run quotes back a line with no name on it.
60 check cs.chatScreen(s, true).labels("label").countIt(it == "alice") == 1
61 check cs.chatScreen(s, true).labels("label").countIt(it == "frq-guest") == 1
62
63 test "a day heading appears where the day changes, once":
64 # `at` is milliseconds. Testing it with seconds put every message on the
65 # same day in 1970 and the headings quietly stopped appearing, which is
66 # how the unit mismatch in the model was found.
67 let t = cs.chatScreen(s, true)
68 # Both messages are the same day, so one heading for the pair.
69 check t.find("separator").len >= 1
70 var r = s.rooms["#test"]
71 r.messages.add Message(id: "3", frm: "a", text: "next day",
72 at: 1_700_200_000_000)
73 s.rooms["#test"] = r
74 let t2 = cs.chatScreen(s, true)
75 check t2.find("separator").len > t.find("separator").len
76
77 test "only our own lines get a pencil":
78 let chips = cs.chatScreen(s, true).find("reaction")
79 .mapIt(it.props{"emoji"}.getStr())
80 # 🙂 and ↩️ on both messages, ✏️ on ours alone.
81 check chips.countIt(it == "✏️") == 1
82 check chips.countIt(it == "🙂") == 2
83
84 test "a line with no msgid gets a spacer where the chips would be":
85 var r = s.rooms["#test"]
86 r.messages.add Message(frm: "x", text: "no id", at: 1_700_000_120_000)
87 s.rooms["#test"] = r
88 # A row of the same shape rather than a row with a hole in it.
89 check cs.chatScreen(s, true).find("spacer").len >= 1
90
91 test "reaction pills carry their count and whether they are mine":
92 var r = s.rooms["#test"]
93 r.messages[0].reactions = @[Reaction(emoji: "👍", nicks: @["frq-guest", "bob"])]
94 s.rooms["#test"] = r
95 let pills = cs.chatScreen(s, true).find("reaction")
96 .filterIt(it.props{"emoji"}.getStr() == "👍")
97 check pills.len == 1
98 check pills[0].props{"count"}.getInt() == 2
99 check pills[0].props{"mine"}.getBool()
100
101 test "links in a message become link runs":
102 var r = s.rooms["#test"]
103 r.messages[0].text = "see https://example.com now"
104 s.rooms["#test"] = r
105 check cs.chatScreen(s, true).find("link").len == 1
106
107 test "a picture becomes an image with a lightbox click":
108 var r = s.rooms["#test"]
109 r.messages[0].imageUrl = "https://x.com/a.png"
110 s.rooms["#test"] = r
111 let img = cs.chatScreen(s, true).find("image")
112 .filterIt(it.props{"src"}.getStr() == "https://x.com/a.png")
113 check img.len == 1
114 check img[0].props{"onClick"}.getStr().startsWith("lightbox:")
115
116 test "a reply quotes what it answers, and offers a way there":
117 var r = s.rooms["#test"]
118 r.messages[1].replyTo = "1"
119 s.rooms["#test"] = r
120 let t = cs.chatScreen(s, true)
121 check t.labels("dim-label").anyIt("↩ alice: hello" in it)
122 check "" in t.labels("button")
123
124 test "a reply to something we no longer hold says so":
125 var r = s.rooms["#test"]
126 r.messages[1].replyTo = "gone"
127 s.rooms["#test"] = r
128 check "↩ (an earlier message)" in cs.chatScreen(s, true).labels("dim-label")
129
130 test "hiding join/part takes the system lines out":
131 var r = s.rooms["#test"]
132 r.messages.add Message(frm: "*", text: "bob joined", system: true,
133 at: 1_700_000_120_000)
134 s.rooms["#test"] = r
135 check "bob joined" in cs.chatScreen(s, true).texts
136 s.hideJoinPart = true
137 check "bob joined" notin cs.chatScreen(s, true).texts
138
139 test "the three banners hold their place whether or not they show":
140 let bare = cs.chatScreen(s, true).find("vbox").mapIt(it.props{"key"}.getStr())
141 s.replyingTo = ReplyTarget(has: true, frm: "alice", text: "hello")
142 s.editing = EditTarget(has: true, id: "2")
143 s.attachment = Attachment(has: true, path: "/tmp/a.png", status: usUploading)
144 let full = cs.chatScreen(s, true).find("vbox").mapIt(it.props{"key"}.getStr())
145 for k in ["replying", "editing", "attachment"]:
146 check k in bare
147 check k in full
148
149 test "the banners say what they are for":
150 s.replyingTo = ReplyTarget(has: true, frm: "alice", text: "hello")
151 s.attachment = Attachment(has: true, path: "/p.png", status: usUploading)
152 let t = cs.chatScreen(s, true)
153 check t.labels("dim-label").anyIt("↩ alice: hello" in it)
154 check "Uploading…" in t.labels("dim-label")
155 s.attachment.status = usReady
156 check "Picture attached" in cs.chatScreen(s, true).labels("dim-label")
157
158 test "Back to chats on a narrow window, fold on a wide one":
159 check "← Chats" in cs.chatScreen(s, true).labels("button")
160 s.windowWidth = 1200
161 let wide = cs.chatScreen(s, true)
162 check "← Chats" notin wide.labels("button")
163 check "☰ Chats" in wide.labels("button")
164
165 test "People is offered in a channel and not in a DM":
166 check cs.chatScreen(s, true).labels("button").anyIt(it.startsWith("People"))
167 var dmState = initState()
168 var d = initRoom("alice")
169 dmState.rooms["alice"] = d
170 dmState.current = "alice"
171 check not cs.chatScreen(dmState, true).labels("button")
172 .anyIt(it.startsWith("People"))
173
174 test "the people panel lists who is here, only when asked":
175 check "People" notin cs.chatScreen(s, true).labels("title-2")
176 s.showUsers = true
177 s.windowWidth = 1200
178 let t = cs.chatScreen(s, true)
179 check "People" in t.labels("title-2")
180 check "alice" in t.labels("label")
181
182 test "Jump to present only when we are not at it":
183 check "↓ Jump to present" notin cs.chatScreen(s, true).labels("button")
184 s.atPresent = false
185 check "↓ Jump to present" in cs.chatScreen(s, true).labels("button")
186
187 test "the compose bar is always there, with a send":
188 let t = cs.chatScreen(s, true)
189 check "draft" in t.find("entry").mapIt(it.props{"key"}.getStr())
190 check "Send" in t.labels("button")
Three buttons that did nothing now do what they say 98d6135 nandi yesterday191
192import frq/[reducer, glyphs, emoji]
193
194suite "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
239suite "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
286suite "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