The chat screen, in Nim
1,508 lines of ClojureDart, transcribed. Not a facsimile this time: every line names its sender, the actions ride the sender's row, the day heading sits between the lines rather than on them, reply chips quote what they answer, reaction pills carry their count and whether they are yours, links become their own runs, and the three banners over the compose bar each keep their place whether or not they show. Under it, the pieces it needs: `reactions.nim` — the tally, kept as a seq rather than a map so the pills hold the order they are drawn in. `updateReaction` finds a line by any name a revision of it has worn, because somebody reacting to an already-rewritten message names the revision. `edits.nim` — only the sender may rewrite their own line. The server checks authorship too, and a client that believed the wire alone would let a hostile relay put words in somebody else's mouth. `textruns.nim` — the link detection, pulled out of the screen because it is the one part of it that is pure string work. Hand-rolled rather than a regex for the same reason the time-tag parser is: it runs once per message of a hundred-message backlog. A URL at the end of a sentence loses the full stop, and a closing bracket only counts as trailing when the URL did not open one — which is what keeps a wikipedia path intact. What is deliberately NOT ported is the call wall and its controls, about 110 lines. They drive `frq.actions/start-call!`, which no target has installed since the MoQ media plane went with the jolt half: dead buttons in ClojureDart and dead buttons here. The file says so where somebody will look. Two bugs the tests found, both mine and both the kind that hide: `Message.at` is MILLISECONDS — `parse-time-tag` answers in them and every clock function takes them — and I had documented and tested it as seconds. In seconds every message lands on the same day in 1970, so the day headings quietly stopped appearing and nothing else looked wrong. And `summarise` truncated by byte where Clojure's `subs` truncates by character, the same bug `previewLine` had. A reply chip quoting a line of emoji got cut mid-codepoint. 176 Nim tests, 2,078 lines of Nim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
87a28cc parent: c69a09b added
nim/src/frq/edits.nim +41 -0 | new file mode 100644 | ||
| @@ -0,0 +1,41 @@ | ||
| 1 | +## A revision, folded into the buffer it belongs to. | |
| 2 | +## | |
| 3 | +## From `common/frq/edits.cljc`. | |
| 4 | + | |
| 5 | +import std/[strutils, tables] | |
| 6 | +import model | |
| 7 | + | |
| 8 | +type | |
| 9 | + EditResult* = enum | |
| 10 | + erApplied = "applied" | |
| 11 | + erRefused = "refused" | |
| 12 | + ## Not the sender's line to rewrite. | |
| 13 | + erAbsent = "absent" | |
| 14 | + ## No line here has that id — an edit of something older than the | |
| 15 | + ## backlog we asked for. The one case the caller shows as a line of its | |
| 16 | + ## own rather than losing what it says. | |
| 17 | + | |
| 18 | +proc applyEdit*(rooms: var OrderedTable[string, Room], | |
| 19 | + room, msgid, frm, text, revision: string): EditResult = | |
| 20 | + ## Only the sender may rewrite their own line, so an edit whose nick is not | |
| 21 | + ## the one on the message is dropped. The server checks authorship too, and | |
| 22 | + ## a client that believed the wire alone would let a hostile relay put words | |
| 23 | + ## in somebody else's mouth. | |
| 24 | + ## | |
| 25 | + ## `revision` is the msgid the server gave the edit itself, which joins | |
| 26 | + ## `editIds` so a reply naming it still finds the line it belongs to. | |
| 27 | + if room.len == 0 or msgid.len == 0: return erAbsent | |
| 28 | + if not rooms.hasKey(room): return erAbsent | |
| 29 | + var r = rooms[room] | |
| 30 | + result = erAbsent | |
| 31 | + for i in 0 ..< r.messages.len: | |
| 32 | + if r.messages[i].id != msgid: continue | |
| 33 | + if r.messages[i].frm.toLowerAscii != frm.toLowerAscii: | |
| 34 | + result = erRefused | |
| 35 | + continue | |
| 36 | + r.messages[i].text = text | |
| 37 | + r.messages[i].edited = true | |
| 38 | + if revision.len > 0 and revision notin r.messages[i].editIds: | |
| 39 | + r.messages[i].editIds.add revision | |
| 40 | + result = erApplied | |
| 41 | + rooms[room] = r | |
| new file mode 100644 | |||
| @@ -0,0 +1,41 @@ | |||
| 1 | +## A revision, folded into the buffer it belongs to. | ||
| 2 | +## | ||
| 3 | +## From `common/frq/edits.cljc`. | ||
| 4 | + | ||
| 5 | +import std/[strutils, tables] | ||
| 6 | +import model | ||
| 7 | + | ||
| 8 | +type | ||
| 9 | + EditResult* = enum | ||
| 10 | + erApplied = "applied" | ||
| 11 | + erRefused = "refused" | ||
| 12 | + ## Not the sender's line to rewrite. | ||
| 13 | + erAbsent = "absent" | ||
| 14 | + ## No line here has that id — an edit of something older than the | ||
| 15 | + ## backlog we asked for. The one case the caller shows as a line of its | ||
| 16 | + ## own rather than losing what it says. | ||
| 17 | + | ||
| 18 | +proc applyEdit*(rooms: var OrderedTable[string, Room], | ||
| 19 | + room, msgid, frm, text, revision: string): EditResult = | ||
| 20 | + ## Only the sender may rewrite their own line, so an edit whose nick is not | ||
| 21 | + ## the one on the message is dropped. The server checks authorship too, and | ||
| 22 | + ## a client that believed the wire alone would let a hostile relay put words | ||
| 23 | + ## in somebody else's mouth. | ||
| 24 | + ## | ||
| 25 | + ## `revision` is the msgid the server gave the edit itself, which joins | ||
| 26 | + ## `editIds` so a reply naming it still finds the line it belongs to. | ||
| 27 | + if room.len == 0 or msgid.len == 0: return erAbsent | ||
| 28 | + if not rooms.hasKey(room): return erAbsent | ||
| 29 | + var r = rooms[room] | ||
| 30 | + result = erAbsent | ||
| 31 | + for i in 0 ..< r.messages.len: | ||
| 32 | + if r.messages[i].id != msgid: continue | ||
| 33 | + if r.messages[i].frm.toLowerAscii != frm.toLowerAscii: | ||
| 34 | + result = erRefused | ||
| 35 | + continue | ||
| 36 | + r.messages[i].text = text | ||
| 37 | + r.messages[i].edited = true | ||
| 38 | + if revision.len > 0 and revision notin r.messages[i].editIds: | ||
| 39 | + r.messages[i].editIds.add revision | ||
| 40 | + result = erApplied | ||
| 41 | + rooms[room] = r | ||
modified
nim/src/frq/model.nim +5 -1 | @@ -26,7 +26,11 @@ type | ||
| 26 | 26 | editIds*: seq[string] ## every msgid this line has worn — see `answersTo` |
| 27 | 27 | frm*: string |
| 28 | 28 | text*: string |
| 29 | - at*: int64 ## epoch seconds, 0 where the line carried no time | |
| 29 | + at*: int64 ## epoch MILLISECONDS, 0 where the line carried none | |
| 30 | + ## Milliseconds because `clock.parseTimeTag` answers | |
| 31 | + ## in them and every clock function takes them. The | |
| 32 | + ## first draft of this said seconds and the day | |
| 33 | + ## headings quietly stopped appearing. | |
| 30 | 34 | system*: bool ## a join/part/notice rather than something said |
| 31 | 35 | mention*: bool |
| 32 | 36 | edited*: bool |
| @@ -26,7 +26,11 @@ type | |||
| 26 | editIds*: seq[string] ## every msgid this line has worn — see `answersTo` | 26 | editIds*: seq[string] ## every msgid this line has worn — see `answersTo` |
| 27 | frm*: string | 27 | frm*: string |
| 28 | text*: string | 28 | text*: string |
| 29 | - at*: int64 ## epoch seconds, 0 where the line carried no time | 29 | + at*: int64 ## epoch MILLISECONDS, 0 where the line carried none |
| 30 | + ## Milliseconds because `clock.parseTimeTag` answers | ||
| 31 | + ## in them and every clock function takes them. The | ||
| 32 | + ## first draft of this said seconds and the day | ||
| 33 | + ## headings quietly stopped appearing. | ||
| 30 | system*: bool ## a join/part/notice rather than something said | 34 | system*: bool ## a join/part/notice rather than something said |
| 31 | mention*: bool | 35 | mention*: bool |
| 32 | edited*: bool | 36 | edited*: bool |
added
nim/src/frq/reactions.nim +92 -0 | new file mode 100644 | ||
| @@ -0,0 +1,92 @@ | ||
| 1 | +## Emoji pills: the tally, and folding one change into a buffer. | |
| 2 | +## | |
| 3 | +## From `common/frq/reactions.cljc`. The tally is a seq of `Reaction` rather | |
| 4 | +## than a map so the pills keep their order — a map would have thrown away | |
| 5 | +## the order they are drawn in, and the Clojure gets away with it only because | |
| 6 | +## its map happens to preserve insertion for small maps. | |
| 7 | + | |
| 8 | +import std/[strutils, tables] | |
| 9 | +import model | |
| 10 | + | |
| 11 | +func parseTally*(encoded: string): seq[Reaction] = | |
| 12 | + ## The server's tally of what is already on a message, as | |
| 13 | + ## `emoji:nick,nick;emoji:nick` — what CHATHISTORY sends so reactions | |
| 14 | + ## survive a reconnect rather than starting empty every time the app opens. | |
| 15 | + if encoded.len == 0: return | |
| 16 | + for part in encoded.split(';'): | |
| 17 | + let i = part.find(':') | |
| 18 | + if i <= 0: continue | |
| 19 | + let emoji = part[0 ..< i] | |
| 20 | + let nicks = part[i + 1 .. ^1] | |
| 21 | + if emoji.len == 0 or nicks.len == 0: continue | |
| 22 | + var r = Reaction(emoji: emoji) | |
| 23 | + for nk in nicks.split(','): | |
| 24 | + if nk.strip().len > 0: r.nicks.add nk | |
| 25 | + if r.nicks.len > 0: result.add r | |
| 26 | + | |
| 27 | +func withReaction*(reactions: seq[Reaction], emoji, nick: string, | |
| 28 | + on: bool): seq[Reaction] = | |
| 29 | + ## One nick's reaction added to or taken off a tally. An emoji nobody is | |
| 30 | + ## left on goes away with them: an empty pill is a pill that says nothing. | |
| 31 | + var found = false | |
| 32 | + for r in reactions: | |
| 33 | + if r.emoji != emoji: | |
| 34 | + result.add r | |
| 35 | + continue | |
| 36 | + found = true | |
| 37 | + var nicks: seq[string] | |
| 38 | + if on: | |
| 39 | + nicks = r.nicks | |
| 40 | + if nick notin nicks: nicks.add nick | |
| 41 | + else: | |
| 42 | + for nk in r.nicks: | |
| 43 | + if nk != nick: nicks.add nk | |
| 44 | + if nicks.len > 0: | |
| 45 | + result.add Reaction(emoji: emoji, nicks: nicks) | |
| 46 | + if on and not found: | |
| 47 | + result.add Reaction(emoji: emoji, nicks: @[nick]) | |
| 48 | + | |
| 49 | +func mine*(m: Message, emoji, nick: string): bool = | |
| 50 | + ## Whether `nick` is already on that emoji — which is what makes a second | |
| 51 | + ## press take it off rather than send the same reaction twice. | |
| 52 | + for r in m.reactions: | |
| 53 | + if r.emoji == emoji: return nick in r.nicks | |
| 54 | + false | |
| 55 | + | |
| 56 | +func countOf*(m: Message, emoji: string): int = | |
| 57 | + for r in m.reactions: | |
| 58 | + if r.emoji == emoji: return r.nicks.len | |
| 59 | + 0 | |
| 60 | + | |
| 61 | +proc updateReaction*(rooms: var OrderedTable[string, Room], | |
| 62 | + room, msgid, emoji, nick: string, on: bool) = | |
| 63 | + ## One reaction folded into the buffer it belongs to. | |
| 64 | + ## | |
| 65 | + ## The message it names may not be there — a reaction on something older | |
| 66 | + ## than the backlog we asked for — and then there is nothing to show it on, | |
| 67 | + ## so nothing happens. | |
| 68 | + ## | |
| 69 | + ## Named the way a reply names one: somebody reacting to a line that has | |
| 70 | + ## since been rewritten puts the emoji on the revision's msgid, which is a | |
| 71 | + ## name the message answers to. See `model.answersTo`. | |
| 72 | + if room.len == 0 or msgid.len == 0 or emoji.len == 0: return | |
| 73 | + if not rooms.hasKey(room): return | |
| 74 | + var r = rooms[room] | |
| 75 | + for i in 0 ..< r.messages.len: | |
| 76 | + if r.messages[i].answersTo(msgid): | |
| 77 | + r.messages[i].reactions = | |
| 78 | + r.messages[i].reactions.withReaction(emoji, nick, on) | |
| 79 | + rooms[room] = r | |
| 80 | + | |
| 81 | +func peerDid*(r: Room, me: string): string = | |
| 82 | + ## The DID of whoever this DM buffer is with, from the last thing they said. | |
| 83 | + ## | |
| 84 | + ## Empty for a channel, and for a conversation where nobody with a DID has | |
| 85 | + ## spoken — a signature over a DM needs both sides named, and there is | |
| 86 | + ## nothing to name. | |
| 87 | + if r.name.startsWith("#"): return "" | |
| 88 | + for i in countdown(r.messages.high, 0): | |
| 89 | + let m = r.messages[i] | |
| 90 | + if m.frm != me and m.frm.len > 0: | |
| 91 | + return m.frm | |
| 92 | + "" | |
| new file mode 100644 | |||
| @@ -0,0 +1,92 @@ | |||
| 1 | +## Emoji pills: the tally, and folding one change into a buffer. | ||
| 2 | +## | ||
| 3 | +## From `common/frq/reactions.cljc`. The tally is a seq of `Reaction` rather | ||
| 4 | +## than a map so the pills keep their order — a map would have thrown away | ||
| 5 | +## the order they are drawn in, and the Clojure gets away with it only because | ||
| 6 | +## its map happens to preserve insertion for small maps. | ||
| 7 | + | ||
| 8 | +import std/[strutils, tables] | ||
| 9 | +import model | ||
| 10 | + | ||
| 11 | +func parseTally*(encoded: string): seq[Reaction] = | ||
| 12 | + ## The server's tally of what is already on a message, as | ||
| 13 | + ## `emoji:nick,nick;emoji:nick` — what CHATHISTORY sends so reactions | ||
| 14 | + ## survive a reconnect rather than starting empty every time the app opens. | ||
| 15 | + if encoded.len == 0: return | ||
| 16 | + for part in encoded.split(';'): | ||
| 17 | + let i = part.find(':') | ||
| 18 | + if i <= 0: continue | ||
| 19 | + let emoji = part[0 ..< i] | ||
| 20 | + let nicks = part[i + 1 .. ^1] | ||
| 21 | + if emoji.len == 0 or nicks.len == 0: continue | ||
| 22 | + var r = Reaction(emoji: emoji) | ||
| 23 | + for nk in nicks.split(','): | ||
| 24 | + if nk.strip().len > 0: r.nicks.add nk | ||
| 25 | + if r.nicks.len > 0: result.add r | ||
| 26 | + | ||
| 27 | +func withReaction*(reactions: seq[Reaction], emoji, nick: string, | ||
| 28 | + on: bool): seq[Reaction] = | ||
| 29 | + ## One nick's reaction added to or taken off a tally. An emoji nobody is | ||
| 30 | + ## left on goes away with them: an empty pill is a pill that says nothing. | ||
| 31 | + var found = false | ||
| 32 | + for r in reactions: | ||
| 33 | + if r.emoji != emoji: | ||
| 34 | + result.add r | ||
| 35 | + continue | ||
| 36 | + found = true | ||
| 37 | + var nicks: seq[string] | ||
| 38 | + if on: | ||
| 39 | + nicks = r.nicks | ||
| 40 | + if nick notin nicks: nicks.add nick | ||
| 41 | + else: | ||
| 42 | + for nk in r.nicks: | ||
| 43 | + if nk != nick: nicks.add nk | ||
| 44 | + if nicks.len > 0: | ||
| 45 | + result.add Reaction(emoji: emoji, nicks: nicks) | ||
| 46 | + if on and not found: | ||
| 47 | + result.add Reaction(emoji: emoji, nicks: @[nick]) | ||
| 48 | + | ||
| 49 | +func mine*(m: Message, emoji, nick: string): bool = | ||
| 50 | + ## Whether `nick` is already on that emoji — which is what makes a second | ||
| 51 | + ## press take it off rather than send the same reaction twice. | ||
| 52 | + for r in m.reactions: | ||
| 53 | + if r.emoji == emoji: return nick in r.nicks | ||
| 54 | + false | ||
| 55 | + | ||
| 56 | +func countOf*(m: Message, emoji: string): int = | ||
| 57 | + for r in m.reactions: | ||
| 58 | + if r.emoji == emoji: return r.nicks.len | ||
| 59 | + 0 | ||
| 60 | + | ||
| 61 | +proc updateReaction*(rooms: var OrderedTable[string, Room], | ||
| 62 | + room, msgid, emoji, nick: string, on: bool) = | ||
| 63 | + ## One reaction folded into the buffer it belongs to. | ||
| 64 | + ## | ||
| 65 | + ## The message it names may not be there — a reaction on something older | ||
| 66 | + ## than the backlog we asked for — and then there is nothing to show it on, | ||
| 67 | + ## so nothing happens. | ||
| 68 | + ## | ||
| 69 | + ## Named the way a reply names one: somebody reacting to a line that has | ||
| 70 | + ## since been rewritten puts the emoji on the revision's msgid, which is a | ||
| 71 | + ## name the message answers to. See `model.answersTo`. | ||
| 72 | + if room.len == 0 or msgid.len == 0 or emoji.len == 0: return | ||
| 73 | + if not rooms.hasKey(room): return | ||
| 74 | + var r = rooms[room] | ||
| 75 | + for i in 0 ..< r.messages.len: | ||
| 76 | + if r.messages[i].answersTo(msgid): | ||
| 77 | + r.messages[i].reactions = | ||
| 78 | + r.messages[i].reactions.withReaction(emoji, nick, on) | ||
| 79 | + rooms[room] = r | ||
| 80 | + | ||
| 81 | +func peerDid*(r: Room, me: string): string = | ||
| 82 | + ## The DID of whoever this DM buffer is with, from the last thing they said. | ||
| 83 | + ## | ||
| 84 | + ## Empty for a channel, and for a conversation where nobody with a DID has | ||
| 85 | + ## spoken — a signature over a DM needs both sides named, and there is | ||
| 86 | + ## nothing to name. | ||
| 87 | + if r.name.startsWith("#"): return "" | ||
| 88 | + for i in countdown(r.messages.high, 0): | ||
| 89 | + let m = r.messages[i] | ||
| 90 | + if m.frm != me and m.frm.len > 0: | ||
| 91 | + return m.frm | ||
| 92 | + "" | ||
added
nim/src/frq/screens/chat.nim +309 -0 | new file mode 100644 | ||
| @@ -0,0 +1,309 @@ | ||
| 1 | +## The conversation. | |
| 2 | +## | |
| 3 | +## Transcribed from `common/frq/screens/chat.cljc`, which is the biggest | |
| 4 | +## screen and the one the port is really about. The structure is kept: a | |
| 5 | +## message is a sender's row and a body under it, the actions ride the | |
| 6 | +## sender's row, and the day heading sits between the lines rather than on | |
| 7 | +## them. | |
| 8 | +## | |
| 9 | +## What is *not* here is the call wall and its controls. They are ~110 lines | |
| 10 | +## of the original and they drive `frq.actions/start-call!`, which no target | |
| 11 | +## has installed since the MoQ media plane was retired with the jolt half — | |
| 12 | +## dead buttons in ClojureDart and dead buttons here. When Flutter's camera | |
| 13 | +## and audio plugins arrive this is the file they come back to. | |
| 14 | + | |
| 15 | +import std/[algorithm, json, strutils, tables] | |
| 16 | +from std/unicode import runeLen, runeSubStr | |
| 17 | +import std/options | |
| 18 | +import ../ui, ../cells, ../model, ../clock, ../reactions, ../textruns | |
| 19 | +from connect import errorNote | |
| 20 | + | |
| 21 | +const | |
| 22 | + faceSize = 32 | |
| 23 | + pillSize = 20 | |
| 24 | + chipGap = 4 | |
| 25 | + overviewLines* = 8 | |
| 26 | + | |
| 27 | +func summarise*(text: string, n: int): string = | |
| 28 | + ## What a reply chip quotes back. One line, cut to fit. | |
| 29 | + var line = newStringOfCap(text.len) | |
| 30 | + var inSpace = false | |
| 31 | + for c in text: | |
| 32 | + if c in {' ', '\t', '\n', '\r'}: | |
| 33 | + if not inSpace: line.add ' ' | |
| 34 | + inSpace = true | |
| 35 | + else: | |
| 36 | + line.add c | |
| 37 | + inSpace = false | |
| 38 | + line = line.strip() | |
| 39 | + # Runes, not bytes: a byte slice lands inside a multi-byte character and | |
| 40 | + # makes mojibake where an ellipsis was wanted. | |
| 41 | + if line.runeLen > n: line.runeSubStr(0, n - 1) & "…" else: line | |
| 42 | + | |
| 43 | +func actionChips(room: string, m: Message, mine: bool): Node = | |
| 44 | + ## Answering and reacting, on the sender's row above the message. | |
| 45 | + ## | |
| 46 | + ## Both are things done *to* a message rather than parts of it, so they ride | |
| 47 | + ## the sender's row against its right edge, at the size a reaction is. In | |
| 48 | + ## the line with the text they took width off every line under them and | |
| 49 | + ## wrapped a message that had the room to sit on one. | |
| 50 | + ## | |
| 51 | + ## A row laid out from the right lays its first child furthest right, so | |
| 52 | + ## reacting comes first here and this reads ✏️ then ↩️ then 🙂 on screen. | |
| 53 | + ## Only our own lines carry a pencil: the server refuses an edit of somebody | |
| 54 | + ## else's, and a chip that always fails is a chip that lies. | |
| 55 | + result = n("hbox", %*{"key": "actions", "align": "end", "spacing": chipGap}, @[ | |
| 56 | + n("reaction", %*{"key": "react", "emoji": "🙂", "size": pillSize, | |
| 57 | + "onClick": "react.open:" & rowId(m)}), | |
| 58 | + n("reaction", %*{"key": "reply", "emoji": "↩️", "size": pillSize, | |
| 59 | + "onClick": "reply.to:" & rowId(m)})]) | |
| 60 | + if mine: | |
| 61 | + result.children.add n("reaction", | |
| 62 | + %*{"key": "edit", "emoji": "✏️", "size": pillSize, | |
| 63 | + "onClick": "edit.start:" & rowId(m)}) | |
| 64 | + | |
| 65 | +func reactionRow(m: Message, me: string): Node = | |
| 66 | + ## What people have put on a message, under it. | |
| 67 | + ## | |
| 68 | + ## A pill carries its count and toggles: clicking one you are already on | |
| 69 | + ## takes yours off, which is the same gesture that put it there. `reaction` | |
| 70 | + ## rather than a button with the emoji as its label — the chip draws the | |
| 71 | + ## glyph from the Twemoji pack, in colour, where a label gets whatever the | |
| 72 | + ## text font has. | |
| 73 | + result = n("hbox", %*{"key": "pills", "spacing": chipGap}) | |
| 74 | + var emojis: seq[string] | |
| 75 | + for r in m.reactions: emojis.add r.emoji | |
| 76 | + emojis.sort() | |
| 77 | + for e in emojis: | |
| 78 | + result.children.add reaction(e, m.countOf(e), m.mine(e, me), | |
| 79 | + "react.toggle:" & rowId(m) & ":" & e) | |
| 80 | + | |
| 81 | +func replyChip(target: Message): Node = | |
| 82 | + ## A chip above a reply, quoting what it answers, and a click that goes | |
| 83 | + ## there. | |
| 84 | + n("hbox", %*{"key": "reply-chip", "spacing": 6}, @[ | |
| 85 | + dimLabel("↩ " & target.frm & ": " & summarise(target.text, 36)), | |
| 86 | + button("→", "goto:" & rowId(target))]) | |
| 87 | + | |
| 88 | +func runNodes(m: Message): Node = | |
| 89 | + ## The words, as one inline row — a paragraph. Stacking gave every link a | |
| 90 | + ## line of its own, and a plain wrapping row measures each label against the | |
| 91 | + ## row's width rather than the column's, which is what drags long URLs off | |
| 92 | + ## the left edge. | |
| 93 | + result = n("hbox", %*{"key": "runs", "wrap": true, "inline": true}) | |
| 94 | + for r in textRuns(m.text): | |
| 95 | + case r.kind | |
| 96 | + of rkText: result.children.add text(r.value) | |
| 97 | + of rkLink: result.children.add link(r.value, r.value) | |
| 98 | + | |
| 99 | +proc messageBody(s: State, m: Message, highlit: bool): Node = | |
| 100 | + ## A message without its face: the sender's line, the words, and what hangs | |
| 101 | + ## under them. | |
| 102 | + var who = vbox(%*{"key": "who"}) | |
| 103 | + if m.system: | |
| 104 | + if m.at > 0: | |
| 105 | + who.children.add dimLabel(clockTime(m.at)) | |
| 106 | + else: | |
| 107 | + var row = hbox(%*{"spacing": 6}, | |
| 108 | + avatar("", m.frm, size = faceSize), | |
| 109 | + label(m.frm)) | |
| 110 | + if m.at > 0: | |
| 111 | + row.children.add dimLabel(clockTime(m.at)) | |
| 112 | + if m.edited: | |
| 113 | + row.children.add dimLabel("(edited)") | |
| 114 | + if m.id.len > 0: | |
| 115 | + row.children.add actionChips(s.current, m, m.frm == s.formNick) | |
| 116 | + else: | |
| 117 | + # A spacer where the chips would be, so a line with no msgid is a row of | |
| 118 | + # the same shape rather than a row with a hole in it. | |
| 119 | + row.children.add spacer(0) | |
| 120 | + who.children.add row | |
| 121 | + | |
| 122 | + # The reply chip is in a wrapper that is always there, for the reason the | |
| 123 | + # error note is: a child that comes and goes renumbers the row. | |
| 124 | + var chip = vbox(%*{"key": "reply-chip"}) | |
| 125 | + if m.replyTo.len > 0: | |
| 126 | + let target = s.currentRoom.messageById(m.replyTo) | |
| 127 | + if target.isSome: | |
| 128 | + chip.children.add replyChip(target.get) | |
| 129 | + else: | |
| 130 | + # Named but not held — a reply to something older than the backlog we | |
| 131 | + # asked for. Said plainly rather than silently dropped. | |
| 132 | + chip.children.add dimLabel("↩ (an earlier message)") | |
| 133 | + | |
| 134 | + var body = vbox(%*{"key": "text", "spacing": 2, "marginTop": 4}, | |
| 135 | + chip, runNodes(m)) | |
| 136 | + | |
| 137 | + var images = vbox(%*{"key": "images", "spacing": 4}) | |
| 138 | + if m.imageUrl.len > 0: | |
| 139 | + images.children.add image(m.imageUrl, maxWidth = 320, maxHeight = 240, | |
| 140 | + onClick = "lightbox:" & m.imageUrl) | |
| 141 | + | |
| 142 | + var pills = vbox(%*{"key": "reactions-row", "marginTop": 6}) | |
| 143 | + if m.id.len > 0 and not m.system and m.reactions.len > 0: | |
| 144 | + pills.children.add reactionRow(m, s.formNick) | |
| 145 | + | |
| 146 | + # A card when the jump landed here, a plain box otherwise — the highlight is | |
| 147 | + # how a reader finds the line they were sent to. | |
| 148 | + n(if highlit: "card" else: "vbox", | |
| 149 | + %*{"key": (if highlit: "body-card" else: "body-plain"), | |
| 150 | + "spacing": 2, "margin": 0}, | |
| 151 | + @[who, body, images, pills]) | |
| 152 | + | |
| 153 | +proc messageRow(s: State, i: int, m: Message): Node = | |
| 154 | + ## One message: who said it, when, what you can do to it, and the words. | |
| 155 | + ## | |
| 156 | + ## Every line names its sender, rather than the first of a run only. A run | |
| 157 | + ## collapsed to one heading reads well until you answer the fourth line of | |
| 158 | + ## it, and then the line quoted back has no name on it; and the actions live | |
| 159 | + ## on the sender's row, which a headerless line has nowhere to put. | |
| 160 | + let rid = rowId(m) | |
| 161 | + let highlit = rid.len > 0 and rid == s.highlight | |
| 162 | + n("vbox", %*{"key": $i, "spacing": 2, "margin": 0, "marginRight": 10, | |
| 163 | + "marginTop": 10, | |
| 164 | + "scrollHere": rid.len > 0 and rid == s.jumpTo}, | |
| 165 | + @[messageBody(s, m, highlit)]) | |
| 166 | + | |
| 167 | +func daySeparator(key, label0: string): Node = | |
| 168 | + n("hbox", %*{"key": key, "spacing": 8}, @[separator(), dimLabel(label0)]) | |
| 169 | + | |
| 170 | +proc messageRows*(s: State, messages: seq[Message]): seq[Node] = | |
| 171 | + ## The messages, with a heading wherever the day changes. | |
| 172 | + ## | |
| 173 | + ## A backlog can reach back weeks, and `11:04 AM` says nothing about which | |
| 174 | + ## day it was. The heading is what makes the time above it mean something. | |
| 175 | + for i, m in messages: | |
| 176 | + if m.at > 0: | |
| 177 | + let d = day(m.at) | |
| 178 | + let prevDay = if i > 0 and messages[i - 1].at > 0: day(messages[i - 1].at) | |
| 179 | + else: "" | |
| 180 | + if d != prevDay: | |
| 181 | + result.add daySeparator("day-" & $i, dayLabel(m.at)) | |
| 182 | + result.add messageRow(s, i, m) | |
| 183 | + | |
| 184 | +proc visible(s: State, messages: seq[Message]): seq[Message] = | |
| 185 | + ## The lines this reader wants to see. Comings and goings are the room | |
| 186 | + ## talking about itself; a quiet room reads better with them and a busy one | |
| 187 | + ## drowns in them, so it is the reader's call. | |
| 188 | + for m in messages: | |
| 189 | + if s.hideJoinPart and m.system: continue | |
| 190 | + result.add m | |
| 191 | + | |
| 192 | +proc chatScreen*(s: State, connected: bool): Node = | |
| 193 | + let room = s.currentRoom | |
| 194 | + let name = if room.name.len > 0: room.name else: "Chat" | |
| 195 | + let isChannel = room.name.startsWith("#") | |
| 196 | + let showUsers = s.showUsers and isChannel | |
| 197 | + # Beside the backlog only where there is room for both. On a narrow window | |
| 198 | + # the panel is the pane, and the backlog stands down for as long as it is up. | |
| 199 | + let narrowPeople = showUsers and not s.wide | |
| 200 | + | |
| 201 | + # Wrapping, because on a phone this row asks for more than there is: ← Chats, | |
| 202 | + # the room's name, People and Overview do not fit across 360 points, and in a | |
| 203 | + # Row every one of them is a flex child sharing what there is — so Overview | |
| 204 | + # was allotted a quarter of the width and painted itself "Overvi…". | |
| 205 | + var headRow = hbox(%*{"spacing": 8, "wrap": true}) | |
| 206 | + | |
| 207 | + # Each control that comes and goes is in a wrapper of its own, so a child | |
| 208 | + # appearing does not renumber the row for the renderer. | |
| 209 | + var back = vbox(%*{"key": "back"}) | |
| 210 | + if not s.wide: | |
| 211 | + back.children.add button("← Chats", "screen.chats") | |
| 212 | + headRow.children.add back | |
| 213 | + | |
| 214 | + var fold = vbox(%*{"key": "fold"}) | |
| 215 | + if s.wide: | |
| 216 | + # One label, lit while the list is up. It used to drop to a bare "☰" with | |
| 217 | + # the list showing, which made the switch two different-looking controls in | |
| 218 | + # the same slot and left the reader guessing which state they were in. | |
| 219 | + fold.children.add button("☰ Chats", "chat-list.toggle", | |
| 220 | + if not s.hideChatList: "primary" else: "default") | |
| 221 | + headRow.children.add fold | |
| 222 | + | |
| 223 | + headRow.children.add title(name) | |
| 224 | + | |
| 225 | + var people = vbox(%*{"key": "people"}) | |
| 226 | + if isChannel: | |
| 227 | + people.children.add button("People " & $room.users.len, "users.toggle", | |
| 228 | + if s.showUsers: "primary" else: "default") | |
| 229 | + headRow.children.add people | |
| 230 | + | |
| 231 | + # Not in a conditional wrapper: the overview is about every room rather than | |
| 232 | + # this one, so it is offered in a DM and in a channel alike. | |
| 233 | + headRow.children.add n("button", | |
| 234 | + %*{"key": "overview-toggle", "label": "Overview", | |
| 235 | + "kind": (if s.overview: "primary" else: "default"), | |
| 236 | + "onClick": "overview.toggle"}) | |
| 237 | + | |
| 238 | + # The backlog. Not a page — a page scrolls everything, which would carry the | |
| 239 | + # compose bar off the bottom with the messages. | |
| 240 | + var messages = vbox(%*{"key": "messages", "fillHeight": not narrowPeople}) | |
| 241 | + if not narrowPeople: | |
| 242 | + var sc = scroll(%*{"scrollKey": "messages-" & room.name, | |
| 243 | + "orientation": "vertical", | |
| 244 | + "stickToBottom": true, | |
| 245 | + "scrollToBottom": s.jumpTick}) | |
| 246 | + let shown = visible(s, room.messages) | |
| 247 | + if shown.len > 0: | |
| 248 | + for node in messageRows(s, shown): | |
| 249 | + sc.children.add node | |
| 250 | + else: | |
| 251 | + sc.children.add dimLabel("Nothing here yet.") | |
| 252 | + messages.children.add sc | |
| 253 | + | |
| 254 | + var peoplePane = vbox(%*{"key": "people-pane"}) | |
| 255 | + if showUsers: | |
| 256 | + var panel = vbox(%*{"spacing": 4, "widthRequest": 150}, | |
| 257 | + title2("People")) | |
| 258 | + for u in room.users: | |
| 259 | + panel.children.add label(u) | |
| 260 | + peoplePane.children.add panel | |
| 261 | + | |
| 262 | + var jump = vbox(%*{"key": "jump"}) | |
| 263 | + if not s.atPresent: | |
| 264 | + jump.children.add button("↓ Jump to present", "jump.present") | |
| 265 | + | |
| 266 | + # The three banners over the compose bar, each in its own stable wrapper. | |
| 267 | + var banners = vbox(%*{"key": "banners", "spacing": 0}) | |
| 268 | + | |
| 269 | + var replying = vbox(%*{"key": "replying"}) | |
| 270 | + if s.replyingTo.has: | |
| 271 | + replying.children.add hbox(%*{"spacing": 8}, | |
| 272 | + dimLabel("↩ " & s.replyingTo.frm & ": " & summarise(s.replyingTo.text, 36)), | |
| 273 | + button("✕", "reply.cancel")) | |
| 274 | + banners.children.add replying | |
| 275 | + | |
| 276 | + var editing = vbox(%*{"key": "editing"}) | |
| 277 | + if s.editing.has: | |
| 278 | + editing.children.add hbox(%*{"spacing": 8}, | |
| 279 | + emoji("✏️", ""), | |
| 280 | + dimLabel("Editing your message"), | |
| 281 | + button("✕", "edit.cancel")) | |
| 282 | + banners.children.add editing | |
| 283 | + | |
| 284 | + var attach = vbox(%*{"key": "attachment"}) | |
| 285 | + if s.attachment.has: | |
| 286 | + attach.children.add hbox(%*{"spacing": 8}, | |
| 287 | + image(s.attachment.path, maxHeight = 64), | |
| 288 | + dimLabel(if s.attachment.status == usUploading: "Uploading…" | |
| 289 | + else: "Picture attached"), | |
| 290 | + button("✕", "attachment.clear")) | |
| 291 | + banners.children.add attach | |
| 292 | + | |
| 293 | + # The compose bar. The picture button is a tile rather than an emoji: the | |
| 294 | + # emoji was a colour photo that matched nothing else in the bar. | |
| 295 | + var compose = hbox(%*{"spacing": 8, "align": "center", "marginBottom": 12}, | |
| 296 | + image("asset:assets/insert-image.png", maxWidth = 36, maxHeight = 36, | |
| 297 | + onClick = "image.pick"), | |
| 298 | + entry("draft", s.draft, "Message " & name, "draft.change", | |
| 299 | + width = 260, onSubmit = "send"), | |
| 300 | + button("Send", "send", "primary")) | |
| 301 | + | |
| 302 | + vbox(%*{"spacing": 8, "margin": 12, "fillHeight": true}, | |
| 303 | + headRow, | |
| 304 | + errorNote(s), | |
| 305 | + hbox(%*{"spacing": 8, "wrap": false}, messages, peoplePane), | |
| 306 | + jump, | |
| 307 | + banners, | |
| 308 | + separator(), | |
| 309 | + compose) | |
| new file mode 100644 | |||
| @@ -0,0 +1,309 @@ | |||
| 1 | +## The conversation. | ||
| 2 | +## | ||
| 3 | +## Transcribed from `common/frq/screens/chat.cljc`, which is the biggest | ||
| 4 | +## screen and the one the port is really about. The structure is kept: a | ||
| 5 | +## message is a sender's row and a body under it, the actions ride the | ||
| 6 | +## sender's row, and the day heading sits between the lines rather than on | ||
| 7 | +## them. | ||
| 8 | +## | ||
| 9 | +## What is *not* here is the call wall and its controls. They are ~110 lines | ||
| 10 | +## of the original and they drive `frq.actions/start-call!`, which no target | ||
| 11 | +## has installed since the MoQ media plane was retired with the jolt half — | ||
| 12 | +## dead buttons in ClojureDart and dead buttons here. When Flutter's camera | ||
| 13 | +## and audio plugins arrive this is the file they come back to. | ||
| 14 | + | ||
| 15 | +import std/[algorithm, json, strutils, tables] | ||
| 16 | +from std/unicode import runeLen, runeSubStr | ||
| 17 | +import std/options | ||
| 18 | +import ../ui, ../cells, ../model, ../clock, ../reactions, ../textruns | ||
| 19 | +from connect import errorNote | ||
| 20 | + | ||
| 21 | +const | ||
| 22 | + faceSize = 32 | ||
| 23 | + pillSize = 20 | ||
| 24 | + chipGap = 4 | ||
| 25 | + overviewLines* = 8 | ||
| 26 | + | ||
| 27 | +func summarise*(text: string, n: int): string = | ||
| 28 | + ## What a reply chip quotes back. One line, cut to fit. | ||
| 29 | + var line = newStringOfCap(text.len) | ||
| 30 | + var inSpace = false | ||
| 31 | + for c in text: | ||
| 32 | + if c in {' ', '\t', '\n', '\r'}: | ||
| 33 | + if not inSpace: line.add ' ' | ||
| 34 | + inSpace = true | ||
| 35 | + else: | ||
| 36 | + line.add c | ||
| 37 | + inSpace = false | ||
| 38 | + line = line.strip() | ||
| 39 | + # Runes, not bytes: a byte slice lands inside a multi-byte character and | ||
| 40 | + # makes mojibake where an ellipsis was wanted. | ||
| 41 | + if line.runeLen > n: line.runeSubStr(0, n - 1) & "…" else: line | ||
| 42 | + | ||
| 43 | +func actionChips(room: string, m: Message, mine: bool): Node = | ||
| 44 | + ## Answering and reacting, on the sender's row above the message. | ||
| 45 | + ## | ||
| 46 | + ## Both are things done *to* a message rather than parts of it, so they ride | ||
| 47 | + ## the sender's row against its right edge, at the size a reaction is. In | ||
| 48 | + ## the line with the text they took width off every line under them and | ||
| 49 | + ## wrapped a message that had the room to sit on one. | ||
| 50 | + ## | ||
| 51 | + ## A row laid out from the right lays its first child furthest right, so | ||
| 52 | + ## reacting comes first here and this reads ✏️ then ↩️ then 🙂 on screen. | ||
| 53 | + ## Only our own lines carry a pencil: the server refuses an edit of somebody | ||
| 54 | + ## else's, and a chip that always fails is a chip that lies. | ||
| 55 | + result = n("hbox", %*{"key": "actions", "align": "end", "spacing": chipGap}, @[ | ||
| 56 | + n("reaction", %*{"key": "react", "emoji": "🙂", "size": pillSize, | ||
| 57 | + "onClick": "react.open:" & rowId(m)}), | ||
| 58 | + n("reaction", %*{"key": "reply", "emoji": "↩️", "size": pillSize, | ||
| 59 | + "onClick": "reply.to:" & rowId(m)})]) | ||
| 60 | + if mine: | ||
| 61 | + result.children.add n("reaction", | ||
| 62 | + %*{"key": "edit", "emoji": "✏️", "size": pillSize, | ||
| 63 | + "onClick": "edit.start:" & rowId(m)}) | ||
| 64 | + | ||
| 65 | +func reactionRow(m: Message, me: string): Node = | ||
| 66 | + ## What people have put on a message, under it. | ||
| 67 | + ## | ||
| 68 | + ## A pill carries its count and toggles: clicking one you are already on | ||
| 69 | + ## takes yours off, which is the same gesture that put it there. `reaction` | ||
| 70 | + ## rather than a button with the emoji as its label — the chip draws the | ||
| 71 | + ## glyph from the Twemoji pack, in colour, where a label gets whatever the | ||
| 72 | + ## text font has. | ||
| 73 | + result = n("hbox", %*{"key": "pills", "spacing": chipGap}) | ||
| 74 | + var emojis: seq[string] | ||
| 75 | + for r in m.reactions: emojis.add r.emoji | ||
| 76 | + emojis.sort() | ||
| 77 | + for e in emojis: | ||
| 78 | + result.children.add reaction(e, m.countOf(e), m.mine(e, me), | ||
| 79 | + "react.toggle:" & rowId(m) & ":" & e) | ||
| 80 | + | ||
| 81 | +func replyChip(target: Message): Node = | ||
| 82 | + ## A chip above a reply, quoting what it answers, and a click that goes | ||
| 83 | + ## there. | ||
| 84 | + n("hbox", %*{"key": "reply-chip", "spacing": 6}, @[ | ||
| 85 | + dimLabel("↩ " & target.frm & ": " & summarise(target.text, 36)), | ||
| 86 | + button("→", "goto:" & rowId(target))]) | ||
| 87 | + | ||
| 88 | +func runNodes(m: Message): Node = | ||
| 89 | + ## The words, as one inline row — a paragraph. Stacking gave every link a | ||
| 90 | + ## line of its own, and a plain wrapping row measures each label against the | ||
| 91 | + ## row's width rather than the column's, which is what drags long URLs off | ||
| 92 | + ## the left edge. | ||
| 93 | + result = n("hbox", %*{"key": "runs", "wrap": true, "inline": true}) | ||
| 94 | + for r in textRuns(m.text): | ||
| 95 | + case r.kind | ||
| 96 | + of rkText: result.children.add text(r.value) | ||
| 97 | + of rkLink: result.children.add link(r.value, r.value) | ||
| 98 | + | ||
| 99 | +proc messageBody(s: State, m: Message, highlit: bool): Node = | ||
| 100 | + ## A message without its face: the sender's line, the words, and what hangs | ||
| 101 | + ## under them. | ||
| 102 | + var who = vbox(%*{"key": "who"}) | ||
| 103 | + if m.system: | ||
| 104 | + if m.at > 0: | ||
| 105 | + who.children.add dimLabel(clockTime(m.at)) | ||
| 106 | + else: | ||
| 107 | + var row = hbox(%*{"spacing": 6}, | ||
| 108 | + avatar("", m.frm, size = faceSize), | ||
| 109 | + label(m.frm)) | ||
| 110 | + if m.at > 0: | ||
| 111 | + row.children.add dimLabel(clockTime(m.at)) | ||
| 112 | + if m.edited: | ||
| 113 | + row.children.add dimLabel("(edited)") | ||
| 114 | + if m.id.len > 0: | ||
| 115 | + row.children.add actionChips(s.current, m, m.frm == s.formNick) | ||
| 116 | + else: | ||
| 117 | + # A spacer where the chips would be, so a line with no msgid is a row of | ||
| 118 | + # the same shape rather than a row with a hole in it. | ||
| 119 | + row.children.add spacer(0) | ||
| 120 | + who.children.add row | ||
| 121 | + | ||
| 122 | + # The reply chip is in a wrapper that is always there, for the reason the | ||
| 123 | + # error note is: a child that comes and goes renumbers the row. | ||
| 124 | + var chip = vbox(%*{"key": "reply-chip"}) | ||
| 125 | + if m.replyTo.len > 0: | ||
| 126 | + let target = s.currentRoom.messageById(m.replyTo) | ||
| 127 | + if target.isSome: | ||
| 128 | + chip.children.add replyChip(target.get) | ||
| 129 | + else: | ||
| 130 | + # Named but not held — a reply to something older than the backlog we | ||
| 131 | + # asked for. Said plainly rather than silently dropped. | ||
| 132 | + chip.children.add dimLabel("↩ (an earlier message)") | ||
| 133 | + | ||
| 134 | + var body = vbox(%*{"key": "text", "spacing": 2, "marginTop": 4}, | ||
| 135 | + chip, runNodes(m)) | ||
| 136 | + | ||
| 137 | + var images = vbox(%*{"key": "images", "spacing": 4}) | ||
| 138 | + if m.imageUrl.len > 0: | ||
| 139 | + images.children.add image(m.imageUrl, maxWidth = 320, maxHeight = 240, | ||
| 140 | + onClick = "lightbox:" & m.imageUrl) | ||
| 141 | + | ||
| 142 | + var pills = vbox(%*{"key": "reactions-row", "marginTop": 6}) | ||
| 143 | + if m.id.len > 0 and not m.system and m.reactions.len > 0: | ||
| 144 | + pills.children.add reactionRow(m, s.formNick) | ||
| 145 | + | ||
| 146 | + # A card when the jump landed here, a plain box otherwise — the highlight is | ||
| 147 | + # how a reader finds the line they were sent to. | ||
| 148 | + n(if highlit: "card" else: "vbox", | ||
| 149 | + %*{"key": (if highlit: "body-card" else: "body-plain"), | ||
| 150 | + "spacing": 2, "margin": 0}, | ||
| 151 | + @[who, body, images, pills]) | ||
| 152 | + | ||
| 153 | +proc messageRow(s: State, i: int, m: Message): Node = | ||
| 154 | + ## One message: who said it, when, what you can do to it, and the words. | ||
| 155 | + ## | ||
| 156 | + ## Every line names its sender, rather than the first of a run only. A run | ||
| 157 | + ## collapsed to one heading reads well until you answer the fourth line of | ||
| 158 | + ## it, and then the line quoted back has no name on it; and the actions live | ||
| 159 | + ## on the sender's row, which a headerless line has nowhere to put. | ||
| 160 | + let rid = rowId(m) | ||
| 161 | + let highlit = rid.len > 0 and rid == s.highlight | ||
| 162 | + n("vbox", %*{"key": $i, "spacing": 2, "margin": 0, "marginRight": 10, | ||
| 163 | + "marginTop": 10, | ||
| 164 | + "scrollHere": rid.len > 0 and rid == s.jumpTo}, | ||
| 165 | + @[messageBody(s, m, highlit)]) | ||
| 166 | + | ||
| 167 | +func daySeparator(key, label0: string): Node = | ||
| 168 | + n("hbox", %*{"key": key, "spacing": 8}, @[separator(), dimLabel(label0)]) | ||
| 169 | + | ||
| 170 | +proc messageRows*(s: State, messages: seq[Message]): seq[Node] = | ||
| 171 | + ## The messages, with a heading wherever the day changes. | ||
| 172 | + ## | ||
| 173 | + ## A backlog can reach back weeks, and `11:04 AM` says nothing about which | ||
| 174 | + ## day it was. The heading is what makes the time above it mean something. | ||
| 175 | + for i, m in messages: | ||
| 176 | + if m.at > 0: | ||
| 177 | + let d = day(m.at) | ||
| 178 | + let prevDay = if i > 0 and messages[i - 1].at > 0: day(messages[i - 1].at) | ||
| 179 | + else: "" | ||
| 180 | + if d != prevDay: | ||
| 181 | + result.add daySeparator("day-" & $i, dayLabel(m.at)) | ||
| 182 | + result.add messageRow(s, i, m) | ||
| 183 | + | ||
| 184 | +proc visible(s: State, messages: seq[Message]): seq[Message] = | ||
| 185 | + ## The lines this reader wants to see. Comings and goings are the room | ||
| 186 | + ## talking about itself; a quiet room reads better with them and a busy one | ||
| 187 | + ## drowns in them, so it is the reader's call. | ||
| 188 | + for m in messages: | ||
| 189 | + if s.hideJoinPart and m.system: continue | ||
| 190 | + result.add m | ||
| 191 | + | ||
| 192 | +proc chatScreen*(s: State, connected: bool): Node = | ||
| 193 | + let room = s.currentRoom | ||
| 194 | + let name = if room.name.len > 0: room.name else: "Chat" | ||
| 195 | + let isChannel = room.name.startsWith("#") | ||
| 196 | + let showUsers = s.showUsers and isChannel | ||
| 197 | + # Beside the backlog only where there is room for both. On a narrow window | ||
| 198 | + # the panel is the pane, and the backlog stands down for as long as it is up. | ||
| 199 | + let narrowPeople = showUsers and not s.wide | ||
| 200 | + | ||
| 201 | + # Wrapping, because on a phone this row asks for more than there is: ← Chats, | ||
| 202 | + # the room's name, People and Overview do not fit across 360 points, and in a | ||
| 203 | + # Row every one of them is a flex child sharing what there is — so Overview | ||
| 204 | + # was allotted a quarter of the width and painted itself "Overvi…". | ||
| 205 | + var headRow = hbox(%*{"spacing": 8, "wrap": true}) | ||
| 206 | + | ||
| 207 | + # Each control that comes and goes is in a wrapper of its own, so a child | ||
| 208 | + # appearing does not renumber the row for the renderer. | ||
| 209 | + var back = vbox(%*{"key": "back"}) | ||
| 210 | + if not s.wide: | ||
| 211 | + back.children.add button("← Chats", "screen.chats") | ||
| 212 | + headRow.children.add back | ||
| 213 | + | ||
| 214 | + var fold = vbox(%*{"key": "fold"}) | ||
| 215 | + if s.wide: | ||
| 216 | + # One label, lit while the list is up. It used to drop to a bare "☰" with | ||
| 217 | + # the list showing, which made the switch two different-looking controls in | ||
| 218 | + # the same slot and left the reader guessing which state they were in. | ||
| 219 | + fold.children.add button("☰ Chats", "chat-list.toggle", | ||
| 220 | + if not s.hideChatList: "primary" else: "default") | ||
| 221 | + headRow.children.add fold | ||
| 222 | + | ||
| 223 | + headRow.children.add title(name) | ||
| 224 | + | ||
| 225 | + var people = vbox(%*{"key": "people"}) | ||
| 226 | + if isChannel: | ||
| 227 | + people.children.add button("People " & $room.users.len, "users.toggle", | ||
| 228 | + if s.showUsers: "primary" else: "default") | ||
| 229 | + headRow.children.add people | ||
| 230 | + | ||
| 231 | + # Not in a conditional wrapper: the overview is about every room rather than | ||
| 232 | + # this one, so it is offered in a DM and in a channel alike. | ||
| 233 | + headRow.children.add n("button", | ||
| 234 | + %*{"key": "overview-toggle", "label": "Overview", | ||
| 235 | + "kind": (if s.overview: "primary" else: "default"), | ||
| 236 | + "onClick": "overview.toggle"}) | ||
| 237 | + | ||
| 238 | + # The backlog. Not a page — a page scrolls everything, which would carry the | ||
| 239 | + # compose bar off the bottom with the messages. | ||
| 240 | + var messages = vbox(%*{"key": "messages", "fillHeight": not narrowPeople}) | ||
| 241 | + if not narrowPeople: | ||
| 242 | + var sc = scroll(%*{"scrollKey": "messages-" & room.name, | ||
| 243 | + "orientation": "vertical", | ||
| 244 | + "stickToBottom": true, | ||
| 245 | + "scrollToBottom": s.jumpTick}) | ||
| 246 | + let shown = visible(s, room.messages) | ||
| 247 | + if shown.len > 0: | ||
| 248 | + for node in messageRows(s, shown): | ||
| 249 | + sc.children.add node | ||
| 250 | + else: | ||
| 251 | + sc.children.add dimLabel("Nothing here yet.") | ||
| 252 | + messages.children.add sc | ||
| 253 | + | ||
| 254 | + var peoplePane = vbox(%*{"key": "people-pane"}) | ||
| 255 | + if showUsers: | ||
| 256 | + var panel = vbox(%*{"spacing": 4, "widthRequest": 150}, | ||
| 257 | + title2("People")) | ||
| 258 | + for u in room.users: | ||
| 259 | + panel.children.add label(u) | ||
| 260 | + peoplePane.children.add panel | ||
| 261 | + | ||
| 262 | + var jump = vbox(%*{"key": "jump"}) | ||
| 263 | + if not s.atPresent: | ||
| 264 | + jump.children.add button("↓ Jump to present", "jump.present") | ||
| 265 | + | ||
| 266 | + # The three banners over the compose bar, each in its own stable wrapper. | ||
| 267 | + var banners = vbox(%*{"key": "banners", "spacing": 0}) | ||
| 268 | + | ||
| 269 | + var replying = vbox(%*{"key": "replying"}) | ||
| 270 | + if s.replyingTo.has: | ||
| 271 | + replying.children.add hbox(%*{"spacing": 8}, | ||
| 272 | + dimLabel("↩ " & s.replyingTo.frm & ": " & summarise(s.replyingTo.text, 36)), | ||
| 273 | + button("✕", "reply.cancel")) | ||
| 274 | + banners.children.add replying | ||
| 275 | + | ||
| 276 | + var editing = vbox(%*{"key": "editing"}) | ||
| 277 | + if s.editing.has: | ||
| 278 | + editing.children.add hbox(%*{"spacing": 8}, | ||
| 279 | + emoji("✏️", ""), | ||
| 280 | + dimLabel("Editing your message"), | ||
| 281 | + button("✕", "edit.cancel")) | ||
| 282 | + banners.children.add editing | ||
| 283 | + | ||
| 284 | + var attach = vbox(%*{"key": "attachment"}) | ||
| 285 | + if s.attachment.has: | ||
| 286 | + attach.children.add hbox(%*{"spacing": 8}, | ||
| 287 | + image(s.attachment.path, maxHeight = 64), | ||
| 288 | + dimLabel(if s.attachment.status == usUploading: "Uploading…" | ||
| 289 | + else: "Picture attached"), | ||
| 290 | + button("✕", "attachment.clear")) | ||
| 291 | + banners.children.add attach | ||
| 292 | + | ||
| 293 | + # The compose bar. The picture button is a tile rather than an emoji: the | ||
| 294 | + # emoji was a colour photo that matched nothing else in the bar. | ||
| 295 | + var compose = hbox(%*{"spacing": 8, "align": "center", "marginBottom": 12}, | ||
| 296 | + image("asset:assets/insert-image.png", maxWidth = 36, maxHeight = 36, | ||
| 297 | + onClick = "image.pick"), | ||
| 298 | + entry("draft", s.draft, "Message " & name, "draft.change", | ||
| 299 | + width = 260, onSubmit = "send"), | ||
| 300 | + button("Send", "send", "primary")) | ||
| 301 | + | ||
| 302 | + vbox(%*{"spacing": 8, "margin": 12, "fillHeight": true}, | ||
| 303 | + headRow, | ||
| 304 | + errorNote(s), | ||
| 305 | + hbox(%*{"spacing": 8, "wrap": false}, messages, peoplePane), | ||
| 306 | + jump, | ||
| 307 | + banners, | ||
| 308 | + separator(), | ||
| 309 | + compose) | ||
added
nim/src/frq/textruns.nim +94 -0 | new file mode 100644 | ||
| @@ -0,0 +1,94 @@ | ||
| 1 | +## Message text as alternating text and link runs. | |
| 2 | +## | |
| 3 | +## From the bottom of `common/frq/screens/chat.cljc`. Pulled into a module of | |
| 4 | +## its own because it is the one part of that screen that is pure string | |
| 5 | +## work, and it is the part most worth testing on its own. | |
| 6 | +## | |
| 7 | +## Runs because a link has to be styled and clickable on its own. They are | |
| 8 | +## laid out as one inline row — a paragraph — rather than stacked: stacking | |
| 9 | +## gave every link a line of its own, and a plain wrapping row measures each | |
| 10 | +## label against the row's width rather than the column's, which is what | |
| 11 | +## drags long URLs off the left edge. | |
| 12 | + | |
| 13 | +import std/strutils | |
| 14 | + | |
| 15 | +type | |
| 16 | + RunKind* = enum rkText, rkLink | |
| 17 | + Run* = object | |
| 18 | + kind*: RunKind | |
| 19 | + value*: string | |
| 20 | + | |
| 21 | +func trimTrailingPunctuation*(url: string): string = | |
| 22 | + ## A URL at the end of a sentence would otherwise keep the sentence's | |
| 23 | + ## punctuation. A closing bracket only counts as trailing when the URL does | |
| 24 | + ## not open one itself, which is what keeps a wikipedia-style path intact. | |
| 25 | + result = url | |
| 26 | + while result.len > 0: | |
| 27 | + let c = result[^1] | |
| 28 | + if c in {'.', ',', ';', ':', '!', '?'}: | |
| 29 | + result.setLen(result.len - 1) | |
| 30 | + elif c == ')' and '(' notin result: | |
| 31 | + result.setLen(result.len - 1) | |
| 32 | + else: | |
| 33 | + break | |
| 34 | + | |
| 35 | +func urlAt(text: string, start: int): (int, int) = | |
| 36 | + ## Where the next `http://` or `https://` run begins and ends, or (-1, -1). | |
| 37 | + ## Hand-rolled rather than a regex for the reason the Clojure's time-tag | |
| 38 | + ## parser is: this runs once per message of a hundred-message backlog. | |
| 39 | + var i = start | |
| 40 | + while i < text.len: | |
| 41 | + if text[i] == 'h' and | |
| 42 | + (text.continuesWith("http://", i) or text.continuesWith("https://", i)): | |
| 43 | + var j = i | |
| 44 | + while j < text.len and text[j] notin {' ', '\t', '\n', '\r', '<', '>', '"'}: | |
| 45 | + j += 1 | |
| 46 | + return (i, j) | |
| 47 | + i += 1 | |
| 48 | + (-1, -1) | |
| 49 | + | |
| 50 | +func trimEnds(runs: seq[Run]): seq[Run] = | |
| 51 | + ## The message's leading and trailing whitespace, off the runs that carry | |
| 52 | + ## it. | |
| 53 | + ## | |
| 54 | + ## Only the two ends: every space between the runs is a space somebody typed | |
| 55 | + ## between two words, and the paragraph they now share is where it shows. | |
| 56 | + result = runs | |
| 57 | + if result.len > 0 and result[0].kind == rkText: | |
| 58 | + let v = result[0].value.strip(leading = true, trailing = false) | |
| 59 | + if v.len > 0: result[0].value = v | |
| 60 | + else: result.delete(0) | |
| 61 | + if result.len > 0 and result[^1].kind == rkText: | |
| 62 | + let v = result[^1].value.strip(leading = false, trailing = true) | |
| 63 | + if v.len > 0: result[^1].value = v | |
| 64 | + else: result.setLen(result.len - 1) | |
| 65 | + | |
| 66 | +func textRuns*(text: string): seq[Run] = | |
| 67 | + var pos = 0 | |
| 68 | + while pos < text.len: | |
| 69 | + let (at, stop) = urlAt(text, pos) | |
| 70 | + if at < 0: | |
| 71 | + if pos < text.len: | |
| 72 | + result.add Run(kind: rkText, value: text[pos .. ^1]) | |
| 73 | + break | |
| 74 | + if at > pos: | |
| 75 | + result.add Run(kind: rkText, value: text[pos ..< at]) | |
| 76 | + let url = trimTrailingPunctuation(text[at ..< stop]) | |
| 77 | + result.add Run(kind: rkLink, value: url) | |
| 78 | + # Past the trimmed URL, not the raw one: the punctuation that was trimmed | |
| 79 | + # is text and belongs in the next run. | |
| 80 | + pos = at + url.len | |
| 81 | + result = trimEnds(result) | |
| 82 | + | |
| 83 | +func firstImageUrl*(text: string): string = | |
| 84 | + ## The first picture link in a message, or "". | |
| 85 | + ## | |
| 86 | + ## PNG only, which is what the preview can draw. The link is left in the | |
| 87 | + ## text either way — a preview is an addition to the message, not a | |
| 88 | + ## replacement for what was said. | |
| 89 | + for r in textRuns(text): | |
| 90 | + if r.kind == rkLink: | |
| 91 | + let low = r.value.toLowerAscii | |
| 92 | + if low.endsWith(".png") or low.contains("/media/"): | |
| 93 | + return r.value | |
| 94 | + "" | |
| new file mode 100644 | |||
| @@ -0,0 +1,94 @@ | |||
| 1 | +## Message text as alternating text and link runs. | ||
| 2 | +## | ||
| 3 | +## From the bottom of `common/frq/screens/chat.cljc`. Pulled into a module of | ||
| 4 | +## its own because it is the one part of that screen that is pure string | ||
| 5 | +## work, and it is the part most worth testing on its own. | ||
| 6 | +## | ||
| 7 | +## Runs because a link has to be styled and clickable on its own. They are | ||
| 8 | +## laid out as one inline row — a paragraph — rather than stacked: stacking | ||
| 9 | +## gave every link a line of its own, and a plain wrapping row measures each | ||
| 10 | +## label against the row's width rather than the column's, which is what | ||
| 11 | +## drags long URLs off the left edge. | ||
| 12 | + | ||
| 13 | +import std/strutils | ||
| 14 | + | ||
| 15 | +type | ||
| 16 | + RunKind* = enum rkText, rkLink | ||
| 17 | + Run* = object | ||
| 18 | + kind*: RunKind | ||
| 19 | + value*: string | ||
| 20 | + | ||
| 21 | +func trimTrailingPunctuation*(url: string): string = | ||
| 22 | + ## A URL at the end of a sentence would otherwise keep the sentence's | ||
| 23 | + ## punctuation. A closing bracket only counts as trailing when the URL does | ||
| 24 | + ## not open one itself, which is what keeps a wikipedia-style path intact. | ||
| 25 | + result = url | ||
| 26 | + while result.len > 0: | ||
| 27 | + let c = result[^1] | ||
| 28 | + if c in {'.', ',', ';', ':', '!', '?'}: | ||
| 29 | + result.setLen(result.len - 1) | ||
| 30 | + elif c == ')' and '(' notin result: | ||
| 31 | + result.setLen(result.len - 1) | ||
| 32 | + else: | ||
| 33 | + break | ||
| 34 | + | ||
| 35 | +func urlAt(text: string, start: int): (int, int) = | ||
| 36 | + ## Where the next `http://` or `https://` run begins and ends, or (-1, -1). | ||
| 37 | + ## Hand-rolled rather than a regex for the reason the Clojure's time-tag | ||
| 38 | + ## parser is: this runs once per message of a hundred-message backlog. | ||
| 39 | + var i = start | ||
| 40 | + while i < text.len: | ||
| 41 | + if text[i] == 'h' and | ||
| 42 | + (text.continuesWith("http://", i) or text.continuesWith("https://", i)): | ||
| 43 | + var j = i | ||
| 44 | + while j < text.len and text[j] notin {' ', '\t', '\n', '\r', '<', '>', '"'}: | ||
| 45 | + j += 1 | ||
| 46 | + return (i, j) | ||
| 47 | + i += 1 | ||
| 48 | + (-1, -1) | ||
| 49 | + | ||
| 50 | +func trimEnds(runs: seq[Run]): seq[Run] = | ||
| 51 | + ## The message's leading and trailing whitespace, off the runs that carry | ||
| 52 | + ## it. | ||
| 53 | + ## | ||
| 54 | + ## Only the two ends: every space between the runs is a space somebody typed | ||
| 55 | + ## between two words, and the paragraph they now share is where it shows. | ||
| 56 | + result = runs | ||
| 57 | + if result.len > 0 and result[0].kind == rkText: | ||
| 58 | + let v = result[0].value.strip(leading = true, trailing = false) | ||
| 59 | + if v.len > 0: result[0].value = v | ||
| 60 | + else: result.delete(0) | ||
| 61 | + if result.len > 0 and result[^1].kind == rkText: | ||
| 62 | + let v = result[^1].value.strip(leading = false, trailing = true) | ||
| 63 | + if v.len > 0: result[^1].value = v | ||
| 64 | + else: result.setLen(result.len - 1) | ||
| 65 | + | ||
| 66 | +func textRuns*(text: string): seq[Run] = | ||
| 67 | + var pos = 0 | ||
| 68 | + while pos < text.len: | ||
| 69 | + let (at, stop) = urlAt(text, pos) | ||
| 70 | + if at < 0: | ||
| 71 | + if pos < text.len: | ||
| 72 | + result.add Run(kind: rkText, value: text[pos .. ^1]) | ||
| 73 | + break | ||
| 74 | + if at > pos: | ||
| 75 | + result.add Run(kind: rkText, value: text[pos ..< at]) | ||
| 76 | + let url = trimTrailingPunctuation(text[at ..< stop]) | ||
| 77 | + result.add Run(kind: rkLink, value: url) | ||
| 78 | + # Past the trimmed URL, not the raw one: the punctuation that was trimmed | ||
| 79 | + # is text and belongs in the next run. | ||
| 80 | + pos = at + url.len | ||
| 81 | + result = trimEnds(result) | ||
| 82 | + | ||
| 83 | +func firstImageUrl*(text: string): string = | ||
| 84 | + ## The first picture link in a message, or "". | ||
| 85 | + ## | ||
| 86 | + ## PNG only, which is what the preview can draw. The link is left in the | ||
| 87 | + ## text either way — a preview is an addition to the message, not a | ||
| 88 | + ## replacement for what was said. | ||
| 89 | + for r in textRuns(text): | ||
| 90 | + if r.kind == rkLink: | ||
| 91 | + let low = r.value.toLowerAscii | ||
| 92 | + if low.endsWith(".png") or low.contains("/media/"): | ||
| 93 | + return r.value | ||
| 94 | + "" | ||
added
nim/tests/tchat.nim +190 -0 | new file mode 100644 | ||
| @@ -0,0 +1,190 @@ | ||
| 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") | |
| new file mode 100644 | |||
| @@ -0,0 +1,190 @@ | |||
| 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") | ||
added
nim/tests/treactions.nim +113 -0 | new file mode 100644 | ||
| @@ -0,0 +1,113 @@ | ||
| 1 | +## Tallies, pills, and the rules about who may rewrite what. | |
| 2 | + | |
| 3 | +import std/[sequtils, tables, unittest] | |
| 4 | +import frq/[model, reactions, edits] | |
| 5 | + | |
| 6 | +suite "parseTally": | |
| 7 | + test "the server's wire form": | |
| 8 | + let t = parseTally("👍:alice,bob;🎉:carol") | |
| 9 | + check t.len == 2 | |
| 10 | + check t[0].emoji == "👍" | |
| 11 | + check t[0].nicks == @["alice", "bob"] | |
| 12 | + check t[1].nicks == @["carol"] | |
| 13 | + | |
| 14 | + test "order is kept, because it is the order the pills are drawn in": | |
| 15 | + check parseTally("a:1;b:2;c:3").mapIt(it.emoji) == @["a", "b", "c"] | |
| 16 | + | |
| 17 | + test "an empty tally is no pills": | |
| 18 | + check parseTally("").len == 0 | |
| 19 | + | |
| 20 | + test "a malformed part is skipped rather than fatal": | |
| 21 | + check parseTally("👍:alice;garbage;:bob;🎉:").mapIt(it.emoji) == @["👍"] | |
| 22 | + | |
| 23 | +suite "withReaction": | |
| 24 | + setup: | |
| 25 | + let base = @[Reaction(emoji: "👍", nicks: @["alice"])] | |
| 26 | + | |
| 27 | + test "adding a nick": | |
| 28 | + check base.withReaction("👍", "bob", true)[0].nicks == @["alice", "bob"] | |
| 29 | + | |
| 30 | + test "adding an emoji nobody had yet": | |
| 31 | + let got = base.withReaction("🎉", "bob", true) | |
| 32 | + check got.len == 2 | |
| 33 | + check got[1].emoji == "🎉" | |
| 34 | + | |
| 35 | + test "the same nick twice does not double it": | |
| 36 | + check base.withReaction("👍", "alice", true)[0].nicks == @["alice"] | |
| 37 | + | |
| 38 | + test "removing the last nick removes the pill": | |
| 39 | + # An empty pill is a pill that says nothing. | |
| 40 | + check base.withReaction("👍", "alice", false).len == 0 | |
| 41 | + | |
| 42 | + test "removing one of several leaves the rest": | |
| 43 | + let two = base.withReaction("👍", "bob", true) | |
| 44 | + check two.withReaction("👍", "alice", false)[0].nicks == @["bob"] | |
| 45 | + | |
| 46 | + test "removing a nick that is not there changes nothing": | |
| 47 | + check base.withReaction("👍", "zoe", false)[0].nicks == @["alice"] | |
| 48 | + | |
| 49 | +suite "mine": | |
| 50 | + setup: | |
| 51 | + let m = Message(reactions: @[Reaction(emoji: "👍", nicks: @["alice"])]) | |
| 52 | + test "on it": | |
| 53 | + check m.mine("👍", "alice") | |
| 54 | + test "not on it": | |
| 55 | + check not m.mine("👍", "bob") | |
| 56 | + test "an emoji with no pill at all": | |
| 57 | + check not m.mine("🎉", "alice") | |
| 58 | + | |
| 59 | +suite "updateReaction": | |
| 60 | + setup: | |
| 61 | + var rooms = initOrderedTable[string, Room]() | |
| 62 | + var r = initRoom("#test") | |
| 63 | + r.messages = @[Message(id: "1", text: "hi"), | |
| 64 | + Message(id: "2", text: "there", editIds: @["2b"])] | |
| 65 | + rooms["#test"] = r | |
| 66 | + | |
| 67 | + test "lands on the message it names": | |
| 68 | + rooms.updateReaction("#test", "1", "👍", "alice", true) | |
| 69 | + check rooms["#test"].messages[0].countOf("👍") == 1 | |
| 70 | + check rooms["#test"].messages[1].countOf("👍") == 0 | |
| 71 | + | |
| 72 | + test "finds a line by a name a revision of it wore": | |
| 73 | + # Somebody reacting to an already-rewritten line names the revision. | |
| 74 | + rooms.updateReaction("#test", "2b", "🎉", "bob", true) | |
| 75 | + check rooms["#test"].messages[1].countOf("🎉") == 1 | |
| 76 | + | |
| 77 | + test "a reaction on a line we do not hold does nothing": | |
| 78 | + rooms.updateReaction("#test", "999", "👍", "alice", true) | |
| 79 | + check rooms["#test"].messages.allIt(it.reactions.len == 0) | |
| 80 | + | |
| 81 | + test "a room we do not hold does nothing": | |
| 82 | + rooms.updateReaction("#gone", "1", "👍", "alice", true) | |
| 83 | + check rooms["#test"].messages[0].reactions.len == 0 | |
| 84 | + | |
| 85 | +suite "applyEdit": | |
| 86 | + setup: | |
| 87 | + var rooms = initOrderedTable[string, Room]() | |
| 88 | + var r = initRoom("#test") | |
| 89 | + r.messages = @[Message(id: "1", frm: "alice", text: "orignal")] | |
| 90 | + rooms["#test"] = r | |
| 91 | + | |
| 92 | + test "the sender may rewrite their own line": | |
| 93 | + check rooms.applyEdit("#test", "1", "alice", "original", "rev1") == erApplied | |
| 94 | + check rooms["#test"].messages[0].text == "original" | |
| 95 | + check rooms["#test"].messages[0].edited | |
| 96 | + | |
| 97 | + test "the revision joins the names the line answers to": | |
| 98 | + # So a reply naming the revision still finds the line it belongs to. | |
| 99 | + discard rooms.applyEdit("#test", "1", "alice", "original", "rev1") | |
| 100 | + check rooms["#test"].messages[0].answersTo("rev1") | |
| 101 | + | |
| 102 | + test "nobody else may": | |
| 103 | + # A client that believed the wire alone would let a hostile relay put | |
| 104 | + # words in somebody else's mouth. | |
| 105 | + check rooms.applyEdit("#test", "1", "mallory", "nonsense", "r") == erRefused | |
| 106 | + check rooms["#test"].messages[0].text == "orignal" | |
| 107 | + | |
| 108 | + test "case does not decide authorship": | |
| 109 | + check rooms.applyEdit("#test", "1", "ALICE", "original", "r") == erApplied | |
| 110 | + | |
| 111 | + test "an edit of a line we do not hold is absent, not applied": | |
| 112 | + check rooms.applyEdit("#test", "999", "alice", "x", "r") == erAbsent | |
| 113 | + check rooms.applyEdit("#gone", "1", "alice", "x", "r") == erAbsent | |
| new file mode 100644 | |||
| @@ -0,0 +1,113 @@ | |||
| 1 | +## Tallies, pills, and the rules about who may rewrite what. | ||
| 2 | + | ||
| 3 | +import std/[sequtils, tables, unittest] | ||
| 4 | +import frq/[model, reactions, edits] | ||
| 5 | + | ||
| 6 | +suite "parseTally": | ||
| 7 | + test "the server's wire form": | ||
| 8 | + let t = parseTally("👍:alice,bob;🎉:carol") | ||
| 9 | + check t.len == 2 | ||
| 10 | + check t[0].emoji == "👍" | ||
| 11 | + check t[0].nicks == @["alice", "bob"] | ||
| 12 | + check t[1].nicks == @["carol"] | ||
| 13 | + | ||
| 14 | + test "order is kept, because it is the order the pills are drawn in": | ||
| 15 | + check parseTally("a:1;b:2;c:3").mapIt(it.emoji) == @["a", "b", "c"] | ||
| 16 | + | ||
| 17 | + test "an empty tally is no pills": | ||
| 18 | + check parseTally("").len == 0 | ||
| 19 | + | ||
| 20 | + test "a malformed part is skipped rather than fatal": | ||
| 21 | + check parseTally("👍:alice;garbage;:bob;🎉:").mapIt(it.emoji) == @["👍"] | ||
| 22 | + | ||
| 23 | +suite "withReaction": | ||
| 24 | + setup: | ||
| 25 | + let base = @[Reaction(emoji: "👍", nicks: @["alice"])] | ||
| 26 | + | ||
| 27 | + test "adding a nick": | ||
| 28 | + check base.withReaction("👍", "bob", true)[0].nicks == @["alice", "bob"] | ||
| 29 | + | ||
| 30 | + test "adding an emoji nobody had yet": | ||
| 31 | + let got = base.withReaction("🎉", "bob", true) | ||
| 32 | + check got.len == 2 | ||
| 33 | + check got[1].emoji == "🎉" | ||
| 34 | + | ||
| 35 | + test "the same nick twice does not double it": | ||
| 36 | + check base.withReaction("👍", "alice", true)[0].nicks == @["alice"] | ||
| 37 | + | ||
| 38 | + test "removing the last nick removes the pill": | ||
| 39 | + # An empty pill is a pill that says nothing. | ||
| 40 | + check base.withReaction("👍", "alice", false).len == 0 | ||
| 41 | + | ||
| 42 | + test "removing one of several leaves the rest": | ||
| 43 | + let two = base.withReaction("👍", "bob", true) | ||
| 44 | + check two.withReaction("👍", "alice", false)[0].nicks == @["bob"] | ||
| 45 | + | ||
| 46 | + test "removing a nick that is not there changes nothing": | ||
| 47 | + check base.withReaction("👍", "zoe", false)[0].nicks == @["alice"] | ||
| 48 | + | ||
| 49 | +suite "mine": | ||
| 50 | + setup: | ||
| 51 | + let m = Message(reactions: @[Reaction(emoji: "👍", nicks: @["alice"])]) | ||
| 52 | + test "on it": | ||
| 53 | + check m.mine("👍", "alice") | ||
| 54 | + test "not on it": | ||
| 55 | + check not m.mine("👍", "bob") | ||
| 56 | + test "an emoji with no pill at all": | ||
| 57 | + check not m.mine("🎉", "alice") | ||
| 58 | + | ||
| 59 | +suite "updateReaction": | ||
| 60 | + setup: | ||
| 61 | + var rooms = initOrderedTable[string, Room]() | ||
| 62 | + var r = initRoom("#test") | ||
| 63 | + r.messages = @[Message(id: "1", text: "hi"), | ||
| 64 | + Message(id: "2", text: "there", editIds: @["2b"])] | ||
| 65 | + rooms["#test"] = r | ||
| 66 | + | ||
| 67 | + test "lands on the message it names": | ||
| 68 | + rooms.updateReaction("#test", "1", "👍", "alice", true) | ||
| 69 | + check rooms["#test"].messages[0].countOf("👍") == 1 | ||
| 70 | + check rooms["#test"].messages[1].countOf("👍") == 0 | ||
| 71 | + | ||
| 72 | + test "finds a line by a name a revision of it wore": | ||
| 73 | + # Somebody reacting to an already-rewritten line names the revision. | ||
| 74 | + rooms.updateReaction("#test", "2b", "🎉", "bob", true) | ||
| 75 | + check rooms["#test"].messages[1].countOf("🎉") == 1 | ||
| 76 | + | ||
| 77 | + test "a reaction on a line we do not hold does nothing": | ||
| 78 | + rooms.updateReaction("#test", "999", "👍", "alice", true) | ||
| 79 | + check rooms["#test"].messages.allIt(it.reactions.len == 0) | ||
| 80 | + | ||
| 81 | + test "a room we do not hold does nothing": | ||
| 82 | + rooms.updateReaction("#gone", "1", "👍", "alice", true) | ||
| 83 | + check rooms["#test"].messages[0].reactions.len == 0 | ||
| 84 | + | ||
| 85 | +suite "applyEdit": | ||
| 86 | + setup: | ||
| 87 | + var rooms = initOrderedTable[string, Room]() | ||
| 88 | + var r = initRoom("#test") | ||
| 89 | + r.messages = @[Message(id: "1", frm: "alice", text: "orignal")] | ||
| 90 | + rooms["#test"] = r | ||
| 91 | + | ||
| 92 | + test "the sender may rewrite their own line": | ||
| 93 | + check rooms.applyEdit("#test", "1", "alice", "original", "rev1") == erApplied | ||
| 94 | + check rooms["#test"].messages[0].text == "original" | ||
| 95 | + check rooms["#test"].messages[0].edited | ||
| 96 | + | ||
| 97 | + test "the revision joins the names the line answers to": | ||
| 98 | + # So a reply naming the revision still finds the line it belongs to. | ||
| 99 | + discard rooms.applyEdit("#test", "1", "alice", "original", "rev1") | ||
| 100 | + check rooms["#test"].messages[0].answersTo("rev1") | ||
| 101 | + | ||
| 102 | + test "nobody else may": | ||
| 103 | + # A client that believed the wire alone would let a hostile relay put | ||
| 104 | + # words in somebody else's mouth. | ||
| 105 | + check rooms.applyEdit("#test", "1", "mallory", "nonsense", "r") == erRefused | ||
| 106 | + check rooms["#test"].messages[0].text == "orignal" | ||
| 107 | + | ||
| 108 | + test "case does not decide authorship": | ||
| 109 | + check rooms.applyEdit("#test", "1", "ALICE", "original", "r") == erApplied | ||
| 110 | + | ||
| 111 | + test "an edit of a line we do not hold is absent, not applied": | ||
| 112 | + check rooms.applyEdit("#test", "999", "alice", "x", "r") == erAbsent | ||
| 113 | + check rooms.applyEdit("#gone", "1", "alice", "x", "r") == erAbsent | ||
added
nim/tests/ttextruns.nim +72 -0 | new file mode 100644 | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +## Link detection in message text — the part of the chat screen that is pure | |
| 2 | +## string work, and the part most worth pinning down. | |
| 3 | + | |
| 4 | +import std/[sequtils, unittest] | |
| 5 | +import frq/textruns | |
| 6 | + | |
| 7 | +proc kinds(s: string): seq[RunKind] = textRuns(s).mapIt(it.kind) | |
| 8 | +proc values(s: string): seq[string] = textRuns(s).mapIt(it.value) | |
| 9 | + | |
| 10 | +suite "textRuns": | |
| 11 | + test "plain text is one run": | |
| 12 | + check kinds("hello there") == @[rkText] | |
| 13 | + check values("hello there") == @["hello there"] | |
| 14 | + | |
| 15 | + test "a bare URL is one link": | |
| 16 | + check kinds("https://example.com") == @[rkLink] | |
| 17 | + | |
| 18 | + test "text around a link": | |
| 19 | + check kinds("see https://example.com now") == @[rkText, rkLink, rkText] | |
| 20 | + check values("see https://example.com now") == | |
| 21 | + @["see ", "https://example.com", " now"] | |
| 22 | + | |
| 23 | + test "two links in one line": | |
| 24 | + check kinds("a http://x.com b https://y.com") == | |
| 25 | + @[rkText, rkLink, rkText, rkLink] | |
| 26 | + | |
| 27 | + test "http as well as https": | |
| 28 | + check kinds("http://example.com") == @[rkLink] | |
| 29 | + | |
| 30 | + test "the message's own ends are trimmed": | |
| 31 | + check values(" hello ") == @["hello"] | |
| 32 | + | |
| 33 | + test "but spaces between words are somebody's typing": | |
| 34 | + check values("a b") == @["a b"] | |
| 35 | + | |
| 36 | + test "empty text is no runs": | |
| 37 | + check textRuns("").len == 0 | |
| 38 | + | |
| 39 | + test "whitespace-only text is no runs, not an empty one": | |
| 40 | + check textRuns(" ").len == 0 | |
| 41 | + | |
| 42 | +suite "trimTrailingPunctuation": | |
| 43 | + test "a sentence's full stop is not part of the URL": | |
| 44 | + check trimTrailingPunctuation("https://x.com.") == "https://x.com" | |
| 45 | + check trimTrailingPunctuation("https://x.com,") == "https://x.com" | |
| 46 | + check trimTrailingPunctuation("https://x.com?") == "https://x.com" | |
| 47 | + | |
| 48 | + test "several at once": | |
| 49 | + check trimTrailingPunctuation("https://x.com...") == "https://x.com" | |
| 50 | + | |
| 51 | + test "a closing bracket goes when the URL opened none": | |
| 52 | + check trimTrailingPunctuation("https://x.com)") == "https://x.com" | |
| 53 | + | |
| 54 | + test "but stays when it did — a wikipedia path keeps its brackets": | |
| 55 | + check trimTrailingPunctuation("https://en.wikipedia.org/wiki/Foo_(bar)") == | |
| 56 | + "https://en.wikipedia.org/wiki/Foo_(bar)" | |
| 57 | + | |
| 58 | + test "the trimmed punctuation comes back as text": | |
| 59 | + # Not dropped: somebody typed it. | |
| 60 | + check values("see https://x.com. ok") == @["see ", "https://x.com", ". ok"] | |
| 61 | + | |
| 62 | +suite "firstImageUrl": | |
| 63 | + test "a png link": | |
| 64 | + check firstImageUrl("look https://x.com/a.png yes") == "https://x.com/a.png" | |
| 65 | + test "a freeq media link": | |
| 66 | + check firstImageUrl("https://irc.freeq.at/api/v1/media/a/b/picture.png") == | |
| 67 | + "https://irc.freeq.at/api/v1/media/a/b/picture.png" | |
| 68 | + test "no picture at all": | |
| 69 | + check firstImageUrl("just words and https://x.com/page") == "" | |
| 70 | + test "the first one wins": | |
| 71 | + check firstImageUrl("https://a.com/1.png https://b.com/2.png") == | |
| 72 | + "https://a.com/1.png" | |
| new file mode 100644 | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | +## Link detection in message text — the part of the chat screen that is pure | ||
| 2 | +## string work, and the part most worth pinning down. | ||
| 3 | + | ||
| 4 | +import std/[sequtils, unittest] | ||
| 5 | +import frq/textruns | ||
| 6 | + | ||
| 7 | +proc kinds(s: string): seq[RunKind] = textRuns(s).mapIt(it.kind) | ||
| 8 | +proc values(s: string): seq[string] = textRuns(s).mapIt(it.value) | ||
| 9 | + | ||
| 10 | +suite "textRuns": | ||
| 11 | + test "plain text is one run": | ||
| 12 | + check kinds("hello there") == @[rkText] | ||
| 13 | + check values("hello there") == @["hello there"] | ||
| 14 | + | ||
| 15 | + test "a bare URL is one link": | ||
| 16 | + check kinds("https://example.com") == @[rkLink] | ||
| 17 | + | ||
| 18 | + test "text around a link": | ||
| 19 | + check kinds("see https://example.com now") == @[rkText, rkLink, rkText] | ||
| 20 | + check values("see https://example.com now") == | ||
| 21 | + @["see ", "https://example.com", " now"] | ||
| 22 | + | ||
| 23 | + test "two links in one line": | ||
| 24 | + check kinds("a http://x.com b https://y.com") == | ||
| 25 | + @[rkText, rkLink, rkText, rkLink] | ||
| 26 | + | ||
| 27 | + test "http as well as https": | ||
| 28 | + check kinds("http://example.com") == @[rkLink] | ||
| 29 | + | ||
| 30 | + test "the message's own ends are trimmed": | ||
| 31 | + check values(" hello ") == @["hello"] | ||
| 32 | + | ||
| 33 | + test "but spaces between words are somebody's typing": | ||
| 34 | + check values("a b") == @["a b"] | ||
| 35 | + | ||
| 36 | + test "empty text is no runs": | ||
| 37 | + check textRuns("").len == 0 | ||
| 38 | + | ||
| 39 | + test "whitespace-only text is no runs, not an empty one": | ||
| 40 | + check textRuns(" ").len == 0 | ||
| 41 | + | ||
| 42 | +suite "trimTrailingPunctuation": | ||
| 43 | + test "a sentence's full stop is not part of the URL": | ||
| 44 | + check trimTrailingPunctuation("https://x.com.") == "https://x.com" | ||
| 45 | + check trimTrailingPunctuation("https://x.com,") == "https://x.com" | ||
| 46 | + check trimTrailingPunctuation("https://x.com?") == "https://x.com" | ||
| 47 | + | ||
| 48 | + test "several at once": | ||
| 49 | + check trimTrailingPunctuation("https://x.com...") == "https://x.com" | ||
| 50 | + | ||
| 51 | + test "a closing bracket goes when the URL opened none": | ||
| 52 | + check trimTrailingPunctuation("https://x.com)") == "https://x.com" | ||
| 53 | + | ||
| 54 | + test "but stays when it did — a wikipedia path keeps its brackets": | ||
| 55 | + check trimTrailingPunctuation("https://en.wikipedia.org/wiki/Foo_(bar)") == | ||
| 56 | + "https://en.wikipedia.org/wiki/Foo_(bar)" | ||
| 57 | + | ||
| 58 | + test "the trimmed punctuation comes back as text": | ||
| 59 | + # Not dropped: somebody typed it. | ||
| 60 | + check values("see https://x.com. ok") == @["see ", "https://x.com", ". ok"] | ||
| 61 | + | ||
| 62 | +suite "firstImageUrl": | ||
| 63 | + test "a png link": | ||
| 64 | + check firstImageUrl("look https://x.com/a.png yes") == "https://x.com/a.png" | ||
| 65 | + test "a freeq media link": | ||
| 66 | + check firstImageUrl("https://irc.freeq.at/api/v1/media/a/b/picture.png") == | ||
| 67 | + "https://irc.freeq.at/api/v1/media/a/b/picture.png" | ||
| 68 | + test "no picture at all": | ||
| 69 | + check firstImageUrl("just words and https://x.com/page") == "" | ||
| 70 | + test "the first one wins": | ||
| 71 | + check firstImageUrl("https://a.com/1.png https://b.com/2.png") == | ||
| 72 | + "https://a.com/1.png" | ||