| The chat screen, in Nim 87a28cc nandi 17h ago | 1 | ## The conversation screen. |
| 2 | |
| 3 | import std/[json, sequtils, strutils, tables, unicode, unittest] |
| 4 | import frq/[ui, cells, model, reactions] |
| 5 | import frq/screens/chat as cs |
| 6 | |
| 7 | proc 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 | |
| 12 | proc labels(node: Node, tag: string): seq[string] = |
| 13 | node.find(tag).mapIt(it.props{"label"}.getStr()) |
| 14 | |
| 15 | proc texts(node: Node): seq[string] = |
| 16 | node.find("text").mapIt(it.props{"text"}.getStr()) |
| 17 | |
| 18 | proc withRoom(): State = |
| 19 | result = initState() |
| 20 | var r = initRoom("#test") |
| 21 | r.joined = true |
| 22 | r.users = @["alice", "bob"] |
| 23 | 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 | |
| 30 | suite "summarise": |
| 31 | test "collapses whitespace and cuts to fit": |
| 32 | check cs.summarise("a\n b", 36) == "a b" |
| 33 | check cs.summarise("x".repeat(50), 10).runeLen == 10 |
| 34 | check cs.summarise("x".repeat(50), 10).endsWith("…") |
| 35 | test "leaves a short line alone": |
| 36 | check cs.summarise("short", 36) == "short" |
| 37 | |
| 38 | suite "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") |