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

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

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>
nandi committed 2026-09-18T21:39:04-07:00 Browse files
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
2626 editIds*: seq[string] ## every msgid this line has worn — see `answersTo`
2727 frm*: string
2828 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.
3034 system*: bool ## a join/part/notice rather than something said
3135 mention*: bool
3236 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*: string27 frm*: string
28 text*: string28 text*: string
29- at*: int64 ## epoch seconds, 0 where the line carried no time29+ 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 said34 system*: bool ## a join/part/notice rather than something said
31 mention*: bool35 mention*: bool
32 edited*: bool36 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"