nandi/frqpublic Fork 0
5693bd54d43c2a63f33ffe43d67bc4909e271eb3
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 · 319 lines · 12.3 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
A face that opens someone 9bb81a1 nandi yesterday57 test "every line names its sender, and the name opens them":
The chat screen, in Nim 87a28cc nandi yesterday58 # Not the first of a run only: answering the fourth line of a collapsed
A face that opens someone 9bb81a1 nandi yesterday59 # run quotes back a line with no name on it. The name is a button because
60 # it is a way in to who someone is, the same as the face beside it.
61 let names = cs.chatScreen(s, true).labels("button")
62 check names.countIt(it == "alice") == 1
63 check names.countIt(it == "frq-guest") == 1
The chat screen, in Nim 87a28cc nandi yesterday64
65 test "a day heading appears where the day changes, once":
66 # `at` is milliseconds. Testing it with seconds put every message on the
67 # same day in 1970 and the headings quietly stopped appearing, which is
68 # how the unit mismatch in the model was found.
69 let t = cs.chatScreen(s, true)
70 # Both messages are the same day, so one heading for the pair.
71 check t.find("separator").len >= 1
72 var r = s.rooms["#test"]
73 r.messages.add Message(id: "3", frm: "a", text: "next day",
74 at: 1_700_200_000_000)
75 s.rooms["#test"] = r
76 let t2 = cs.chatScreen(s, true)
77 check t2.find("separator").len > t.find("separator").len
78
79 test "only our own lines get a pencil":
80 let chips = cs.chatScreen(s, true).find("reaction")
81 .mapIt(it.props{"emoji"}.getStr())
82 # 🙂 and ↩️ on both messages, ✏️ on ours alone.
83 check chips.countIt(it == "✏️") == 1
84 check chips.countIt(it == "🙂") == 2
85
86 test "a line with no msgid gets a spacer where the chips would be":
87 var r = s.rooms["#test"]
88 r.messages.add Message(frm: "x", text: "no id", at: 1_700_000_120_000)
89 s.rooms["#test"] = r
90 # A row of the same shape rather than a row with a hole in it.
91 check cs.chatScreen(s, true).find("spacer").len >= 1
92
93 test "reaction pills carry their count and whether they are mine":
94 var r = s.rooms["#test"]
95 r.messages[0].reactions = @[Reaction(emoji: "👍", nicks: @["frq-guest", "bob"])]
96 s.rooms["#test"] = r
97 let pills = cs.chatScreen(s, true).find("reaction")
98 .filterIt(it.props{"emoji"}.getStr() == "👍")
99 check pills.len == 1
100 check pills[0].props{"count"}.getInt() == 2
101 check pills[0].props{"mine"}.getBool()
102
103 test "links in a message become link runs":
104 var r = s.rooms["#test"]
105 r.messages[0].text = "see https://example.com now"
106 s.rooms["#test"] = r
107 check cs.chatScreen(s, true).find("link").len == 1
108
109 test "a picture becomes an image with a lightbox click":
110 var r = s.rooms["#test"]
111 r.messages[0].imageUrl = "https://x.com/a.png"
112 s.rooms["#test"] = r
113 let img = cs.chatScreen(s, true).find("image")
114 .filterIt(it.props{"src"}.getStr() == "https://x.com/a.png")
115 check img.len == 1
116 check img[0].props{"onClick"}.getStr().startsWith("lightbox:")
117
118 test "a reply quotes what it answers, and offers a way there":
119 var r = s.rooms["#test"]
120 r.messages[1].replyTo = "1"
121 s.rooms["#test"] = r
122 let t = cs.chatScreen(s, true)
123 check t.labels("dim-label").anyIt("↩ alice: hello" in it)
124 check "" in t.labels("button")
125
126 test "a reply to something we no longer hold says so":
127 var r = s.rooms["#test"]
128 r.messages[1].replyTo = "gone"
129 s.rooms["#test"] = r
130 check "↩ (an earlier message)" in cs.chatScreen(s, true).labels("dim-label")
131
132 test "hiding join/part takes the system lines out":
133 var r = s.rooms["#test"]
134 r.messages.add Message(frm: "*", text: "bob joined", system: true,
135 at: 1_700_000_120_000)
136 s.rooms["#test"] = r
137 check "bob joined" in cs.chatScreen(s, true).texts
138 s.hideJoinPart = true
139 check "bob joined" notin cs.chatScreen(s, true).texts
140
141 test "the three banners hold their place whether or not they show":
142 let bare = cs.chatScreen(s, true).find("vbox").mapIt(it.props{"key"}.getStr())
143 s.replyingTo = ReplyTarget(has: true, frm: "alice", text: "hello")
144 s.editing = EditTarget(has: true, id: "2")
145 s.attachment = Attachment(has: true, path: "/tmp/a.png", status: usUploading)
146 let full = cs.chatScreen(s, true).find("vbox").mapIt(it.props{"key"}.getStr())
147 for k in ["replying", "editing", "attachment"]:
148 check k in bare
149 check k in full
150
151 test "the banners say what they are for":
152 s.replyingTo = ReplyTarget(has: true, frm: "alice", text: "hello")
153 s.attachment = Attachment(has: true, path: "/p.png", status: usUploading)
154 let t = cs.chatScreen(s, true)
155 check t.labels("dim-label").anyIt("↩ alice: hello" in it)
156 check "Uploading…" in t.labels("dim-label")
157 s.attachment.status = usReady
158 check "Picture attached" in cs.chatScreen(s, true).labels("dim-label")
159
160 test "Back to chats on a narrow window, fold on a wide one":
161 check "← Chats" in cs.chatScreen(s, true).labels("button")
162 s.windowWidth = 1200
163 let wide = cs.chatScreen(s, true)
164 check "← Chats" notin wide.labels("button")
165 check "☰ Chats" in wide.labels("button")
166
167 test "People is offered in a channel and not in a DM":
168 check cs.chatScreen(s, true).labels("button").anyIt(it.startsWith("People"))
169 var dmState = initState()
170 var d = initRoom("alice")
171 dmState.rooms["alice"] = d
172 dmState.current = "alice"
173 check not cs.chatScreen(dmState, true).labels("button")
174 .anyIt(it.startsWith("People"))
175
176 test "the people panel lists who is here, only when asked":
177 check "People" notin cs.chatScreen(s, true).labels("title-2")
178 s.showUsers = true
179 s.windowWidth = 1200
180 let t = cs.chatScreen(s, true)
181 check "People" in t.labels("title-2")
A face that opens someone 9bb81a1 nandi yesterday182 # With the mode prefix in front of the name, ops first.
183 check "@alice" in t.labels("label")
The chat screen, in Nim 87a28cc nandi yesterday184
185 test "Jump to present only when we are not at it":
186 check "↓ Jump to present" notin cs.chatScreen(s, true).labels("button")
187 s.atPresent = false
188 check "↓ Jump to present" in cs.chatScreen(s, true).labels("button")
189
190 test "the compose bar is always there, with a send":
191 let t = cs.chatScreen(s, true)
192 check "draft" in t.find("entry").mapIt(it.props{"key"}.getStr())
193 check "Send" in t.labels("button")
Three buttons that did nothing now do what they say 98d6135 nandi yesterday194
195import frq/[reducer, glyphs, emoji]
196
197suite "the emoji picker":
198 setup:
199 var s = withRoom()
200 app = s
201
202 test "is not there until a message asks for it":
203 check cs.chatScreen(s, true).find("entry")
204 .mapIt(it.props{"key"}.getStr()).countIt(it == "emoji-search") == 0
205
206 test "opens under the message it is for, and nowhere else":
207 s.reacting = ReactTarget(has: true, room: "#test", id: "1")
208 let t = cs.chatScreen(s, true)
209 check t.find("entry").anyIt(it.props{"key"}.getStr() == "emoji-search")
210 # One picker, not one per message.
211 check t.find("entry").countIt(it.props{"key"}.getStr() == "emoji-search") == 1
212
213 test "opens on the popular row":
214 s.reacting = ReactTarget(has: true, room: "#test", id: "1")
215 let picks = cs.chatScreen(s, true).find("reaction")
216 .filterIt(it.props{"onClick"}.getStr().startsWith("react.pick:"))
217 check picks.len == popular.len
218 check picks[0].props{"emoji"}.getStr() == popular[0]
219
220 test "a group shows that group, capped":
221 s.reacting = ReactTarget(has: true, room: "#test", id: "1")
222 s.emojiGroup = "Smileys & Emotion"
223 let picks = cs.chatScreen(s, true).find("reaction")
224 .filterIt(it.props{"onClick"}.getStr().startsWith("react.pick:"))
225 # Capped: the whole catalogue crossing the boundary per keystroke is a
226 # picker nobody can type into.
227 check picks.len == pickerLimit
228
229 test "search narrows it":
230 s.reacting = ReactTarget(has: true, room: "#test", id: "1")
231 s.emojiSearch = "grinning"
232 let picks = cs.chatScreen(s, true).find("reaction")
233 .filterIt(it.props{"onClick"}.getStr().startsWith("react.pick:"))
234 check picks.len > 0
235 check picks.len < pickerLimit
236
237 test "a search matching nothing says so rather than showing an empty grid":
238 s.reacting = ReactTarget(has: true, room: "#test", id: "1")
239 s.emojiSearch = "zzzzznotanemoji"
240 check "Nothing matches that." in cs.chatScreen(s, true).labels("dim-label")
241
242suite "the overview":
243 setup:
244 app = withRoom()
245 app.rooms["#other"] = initRoom("#other")
246 var o = app.rooms["#other"]
247 o.messages = @[Message(id: "o1", frm: "zoe", text: "elsewhere",
248 at: 1_700_000_500_000)]
249 app.rooms["#other"] = o
250
251 test "is not there until asked for":
252 check "Overview" notin cs.chatScreen(app, true).labels("title-2")
253
254 test "shows lines from other rooms, naming the room":
255 app.overview = true
256 let t = cs.chatScreen(app, true)
257 check "Overview" in t.labels("title-2")
258 check "#other" in t.labels("dim-label")
259 check t.find("text").anyIt("elsewhere" in it.props{"text"}.getStr())
260
261 test "leaves out the room being read":
262 app.overview = true
263 # #test's own lines are on screen already, directly above.
264 check not cs.chatScreen(app, true).find("text")
265 .anyIt("hello" in it.props{"text"}.getStr() and
266 it.props{"text"}.getStr() != "hello")
267
268 test "an empty one says so":
269 var lonely = withRoom()
270 lonely.overview = true
271 check "Nothing has happened anywhere else." in
272 cs.chatScreen(lonely, true).labels("dim-label")
273
274 test "going somewhere from it offers the way back":
275 app.overview = true
276 dispatch(%*{"id": "overview.goto:#other:o1"})
277 check app.current == "#other"
278 check app.overviewReturn == "#test"
279 check not app.overview
280 check cs.chatScreen(app, true).labels("button").anyIt("back to #test" in it)
281
282 test "and the way back works":
283 app.overview = true
284 dispatch(%*{"id": "overview.goto:#other:o1"})
285 dispatch(%*{"id": "overview.back"})
286 check app.current == "#test"
287 check app.overviewReturn == ""
288
289suite "the lightbox":
290 setup:
291 var s = withRoom()
292 app = s
293
294 test "is not there until a picture is opened":
295 check "Picture" notin cs.chatScreen(s, true).labels("title-2")
296
297 test "shows the picture and a way out":
298 s.lightbox = Lightbox(has: true, url: "https://x.com/a.png",
299 path: "https://x.com/a.png")
300 let t = cs.chatScreen(s, true)
301 check "Picture" in t.labels("title-2")
302 check "Close" in t.labels("button")
303 check t.find("image").anyIt(it.props{"src"}.getStr() == "https://x.com/a.png")
304
305 test "clicking a picture opens it":
306 var r = app.rooms["#test"]
307 r.messages[0].imageUrl = "https://x.com/a.png"
308 app.rooms["#test"] = r
309 let img = cs.chatScreen(app, true).find("image")
310 .filterIt(it.props{"src"}.getStr() == "https://x.com/a.png")
311 check img.len == 1
312 dispatch(%*{"id": img[0].props{"onClick"}.getStr()})
313 check app.lightbox.has
314 check app.lightbox.url == "https://x.com/a.png"
315
316 test "and closing it puts it away":
317 dispatch(%*{"id": "lightbox:https://x.com/a.png"})
318 dispatch(%*{"id": "lightbox.close"})
319 check not app.lightbox.has