nandi/frqpublic Fork 0
d333b6f
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 data model, in Nim

Restoring the UI tree I deleted on a misreading — the objection was that a
43-line chat screen standing in for 1,518 was a bad facsimile, not that screens
belong in ClojureDart. They do not; everything is going to Nim. `ui.nim` and
the Flutter renderer come back as they were, and the transport work stands
either way.

What is new is the layer the screens actually need, ported faithfully rather
than sketched:

`model.nim` is what a message and a room are. The naming rules are the fiddly
part and are transcribed with their reasoning: `rowId` prefers the server's
msgid and falls back to the local one a line wears until the echo comes back,
and `answersTo` matches any name a line has ever had — because a reply to an
already-rewritten line names the revision, and both are the same message.

`rooms.nim` is the list, the overview and the read marker. Unread is derived
from the marker and never counted, which is the only thing that survives what
the server does: a JOIN replays the backlog and CHATHISTORY replays it again,
and a counter would tick twice for every line. `recentEverywhere` keeps the
round-robin, so a room that said one thing all day sits beside the room that
said a hundred instead of being buried by it.

`clock.nim` is Hinnant's algorithms, checked against a round trip over every
day from 1901 to 2052. One thing got simpler in the move rather than merely
moving: the zone was four platform guesses behind `frq.io` because neither
compiler had a zone database, and Nim has one.

`Channel` became `Room`, because Nim's `system.Channel` is the thread-safe
queue conn.nim already uses and a type shadowing it would be a bad afternoon.

Two of my own tests were wrong and the port was right, which is worth saying
because it is the argument for porting with tests at all. The clock's expected
epoch was twelve days out — hand arithmetic against an implementation that had
already round-tripped fifty thousand days. And the marker test appended a
backlog twice and expected zero unread, which is a state `seenMessage` exists
to make impossible and which the Clojure answers three to as well; a test for
an unreachable state says nothing about the reachable ones.

72 Nim tests. 967 lines of Nim against 12,869 of Clojure — the model and the
clock are in, the screens are not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-18T20:43:13-07:00 Browse files
d333b6f parent: 1bb3f77
added flutter/lib/nim_renderer.dart +251 -0
new file mode 100644
@@ -0,0 +1,251 @@
1+/// The renderer: a Nim widget tree, walked into Flutter widgets.
2+///
3+/// This is the Dart half of the spike's claim. It knows the tag vocabulary
4+/// and nothing else — no screens, no state, no idea what "connect" means. Nim
5+/// decides what the screen is; this decides what a `vbox` looks like.
6+///
7+/// The measure of whether the split is honest is how boring this file is. If
8+/// a feature ever needs a change here AND in Nim, the boundary is in the
9+/// wrong place.
10+import 'dart:async';
11+
12+import 'package:flutter/material.dart';
13+import 'package:frq_core/frq_core.dart' as core;
14+
15+/// Rebuilds from Nim on every event. One `setState` per dispatch, and the
16+/// whole tree is rebuilt — which is what Flutter does anyway, and is why the
17+/// Nim side does not need a reconciler of its own.
18+class NimApp extends StatefulWidget {
19+ const NimApp({super.key});
20+ @override
21+ State<NimApp> createState() => _NimAppState();
22+}
23+
24+class _NimAppState extends State<NimApp> {
25+ late core.UiNode _tree = core.render();
26+ Timer? _poll;
27+
28+ @override
29+ void initState() {
30+ super.initState();
31+ // Polling, because the socket lives on a Nim thread and there is no
32+ // callback into Dart. A Dart callback invoked from a foreign thread has to
33+ // be marshalled onto the main isolate — NativeCallable, ports, a whole
34+ // mechanism — and at 70µs a render a 100ms timer does the same job for
35+ // nothing. It is also why `render` is allowed to be impure.
36+ _poll = Timer.periodic(const Duration(milliseconds: 100), (_) {
37+ final t = core.poll();
38+ // Only when it actually differs: a setState per tick would rebuild the
39+ // whole tree ten times a second for a screen nobody is touching.
40+ if (t.toString() != _tree.toString()) {
41+ setState(() => _tree = t);
42+ }
43+ });
44+ }
45+
46+ // One controller per keyed entry, kept across rebuilds.
47+ //
48+ // This is the whole reason `:key` is on every entry in both the Clojure and
49+ // the Nim: a controller identified by position instead of name meant the
50+ // host field and the port field shared one and both showed the port. The
51+ // comment survives three languages now.
52+ final _controllers = <String, TextEditingController>{};
53+
54+ // One focus node per keyed entry, for the same reason as the controllers.
55+ // Without it, sending with Enter drops focus and the next line is typed
56+ // into nothing — the field is rebuilt from a fresh tree every time.
57+ final _focus = <String, FocusNode>{};
58+
59+ void _send(String id, [String value = '']) {
60+ setState(() => _tree = core.dispatch(id, value));
61+ // Enter in the compose box clears the draft in Nim and rebuilds the
62+ // field; putting focus back is what makes a second line typeable.
63+ if (id == 'send') _focus['draft']?.requestFocus();
64+ }
65+
66+ @override
67+ void dispose() {
68+ _poll?.cancel();
69+ for (final c in _controllers.values) {
70+ c.dispose();
71+ }
72+ for (final f in _focus.values) {
73+ f.dispose();
74+ }
75+ super.dispose();
76+ }
77+
78+ @override
79+ Widget build(BuildContext context) => MaterialApp(
80+ title: 'frq',
81+ theme: ThemeData.dark(useMaterial3: true),
82+ home: Scaffold(
83+ body: SafeArea(child: SingleChildScrollView(child: _build(_tree))),
84+ ),
85+ );
86+
87+ Widget _build(core.UiNode n) {
88+ final kids = n.children.map(_build).toList();
89+
90+ switch (n.tag) {
91+ case 'page':
92+ return Center(
93+ child: ConstrainedBox(
94+ constraints:
95+ BoxConstraints(maxWidth: n.prop('maxWidth', 520).toDouble()),
96+ child: Padding(
97+ padding: const EdgeInsets.all(24),
98+ child: Column(
99+ crossAxisAlignment: CrossAxisAlignment.start, children: kids),
100+ ),
101+ ),
102+ );
103+
104+ case 'vbox':
105+ return Column(
106+ crossAxisAlignment: CrossAxisAlignment.start,
107+ children: _spaced(kids, n.prop('spacing', 0), vertical: true),
108+ );
109+
110+ case 'hbox':
111+ // Wrap and not Row, and this was a bug before it was a decision: the
112+ // three mode buttons are wider than the 520-point page, and a Row
113+ // answers that with a RenderFlex overflow rather than a second line.
114+ // A `:hbox` in the screens means "these go together across", not "these
115+ // fit"; the tree has no idea how wide the window is and should not.
116+ final gap = n.prop('spacing', 0).toDouble();
117+ return Wrap(
118+ spacing: gap,
119+ runSpacing: gap,
120+ crossAxisAlignment: WrapCrossAlignment.center,
121+ children: kids,
122+ );
123+
124+ case 'scroll':
125+ return SizedBox(
126+ height: n.prop('height', 300).toDouble(),
127+ child: Scrollbar(
128+ child: SingleChildScrollView(
129+ reverse: true,
130+ child: Column(
131+ crossAxisAlignment: CrossAxisAlignment.start,
132+ children: kids),
133+ ),
134+ ),
135+ );
136+
137+ case 'card':
138+ return Card(
139+ margin: const EdgeInsets.symmetric(vertical: 8),
140+ child: Padding(
141+ padding: const EdgeInsets.all(16),
142+ child: Column(
143+ crossAxisAlignment: CrossAxisAlignment.start, children: kids),
144+ ),
145+ );
146+
147+ case 'title':
148+ return Text(n.prop('label', ''),
149+ style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold));
150+
151+ case 'title-2':
152+ return Padding(
153+ padding: const EdgeInsets.only(top: 8, bottom: 4),
154+ child: Text(n.prop('label', ''),
155+ style:
156+ const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
157+ );
158+
159+ case 'label':
160+ return Text(n.prop('label', ''));
161+
162+ case 'dim-label':
163+ return Opacity(
164+ opacity: 0.7,
165+ child: Text(n.prop('label', ''),
166+ style: const TextStyle(fontSize: 12)));
167+
168+ case 'spinner':
169+ return const SizedBox(
170+ width: 16,
171+ height: 16,
172+ child: CircularProgressIndicator(strokeWidth: 2));
173+
174+ case 'button':
175+ final onClick = n.prop('onClick', '');
176+ final label = Text(n.prop('label', ''));
177+ // No padding of its own: spacing belongs to the container, which is
178+ // the only thing that knows whether this is in a row or a column.
179+ return n.prop('kind', 'default') == 'primary'
180+ ? FilledButton(onPressed: () => _send(onClick), child: label)
181+ : OutlinedButton(onPressed: () => _send(onClick), child: label);
182+
183+ case 'checkbutton':
184+ return Row(mainAxisSize: MainAxisSize.min, children: [
185+ Checkbox(
186+ value: n.prop('active', false),
187+ onChanged: (_) => _send(n.prop('onToggled', '')),
188+ ),
189+ Text(n.prop('label', '')),
190+ ]);
191+
192+ case 'entry':
193+ final key = n.prop('key', '');
194+ final text = n.prop('text', '');
195+ final c = _controllers.putIfAbsent(
196+ key, () => TextEditingController(text: text));
197+ // Only when it actually differs: assigning unconditionally moves the
198+ // caret to the end on every keystroke, which is the classic way to
199+ // make a controlled text field unusable.
200+ if (c.text != text) {
201+ c.value = c.value.copyWith(
202+ text: text,
203+ selection: TextSelection.collapsed(offset: text.length),
204+ );
205+ }
206+ final field = TextField(
207+ controller: c,
208+ focusNode: _focus.putIfAbsent(key, FocusNode.new),
209+ decoration: InputDecoration(
210+ hintText: n.prop('placeholder', ''),
211+ isDense: true,
212+ border: const OutlineInputBorder(),
213+ ),
214+ onChanged: (v) => _send(n.prop('onChange', ''), v),
215+ onSubmitted: (_) {
216+ final submit = n.prop('onSubmit', '');
217+ if (submit.isNotEmpty) _send(submit);
218+ },
219+ );
220+ final w = n.prop('widthRequest', 0);
221+ // A width request is a minimum in the screens' vocabulary, but here it
222+ // has to be a maximum too: an unconstrained TextField inside a Wrap
223+ // has no width at all to take.
224+ return w > 0 ? SizedBox(width: w.toDouble(), child: field) : field;
225+
226+ default:
227+ // An unknown tag paints as itself rather than crashing or vanishing.
228+ // Nim can add one and see it before this file has heard of it, which
229+ // is the behaviour that makes the boundary pleasant to work across.
230+ return Container(
231+ padding: const EdgeInsets.all(4),
232+ color: Colors.orange.withValues(alpha: 0.3),
233+ child: Text('?${n.tag}'),
234+ );
235+ }
236+ }
237+
238+ List<Widget> _spaced(List<Widget> kids, num gap, {required bool vertical}) {
239+ if (gap <= 0 || kids.length < 2) return kids;
240+ final out = <Widget>[];
241+ for (var i = 0; i < kids.length; i++) {
242+ if (i > 0) {
243+ out.add(vertical
244+ ? SizedBox(height: gap.toDouble())
245+ : SizedBox(width: gap.toDouble()));
246+ }
247+ out.add(kids[i]);
248+ }
249+ return out;
250+ }
251+}
new file mode 100644
@@ -0,0 +1,251 @@
1+/// The renderer: a Nim widget tree, walked into Flutter widgets.
2+///
3+/// This is the Dart half of the spike's claim. It knows the tag vocabulary
4+/// and nothing else — no screens, no state, no idea what "connect" means. Nim
5+/// decides what the screen is; this decides what a `vbox` looks like.
6+///
7+/// The measure of whether the split is honest is how boring this file is. If
8+/// a feature ever needs a change here AND in Nim, the boundary is in the
9+/// wrong place.
10+import 'dart:async';
11+
12+import 'package:flutter/material.dart';
13+import 'package:frq_core/frq_core.dart' as core;
14+
15+/// Rebuilds from Nim on every event. One `setState` per dispatch, and the
16+/// whole tree is rebuilt — which is what Flutter does anyway, and is why the
17+/// Nim side does not need a reconciler of its own.
18+class NimApp extends StatefulWidget {
19+ const NimApp({super.key});
20+ @override
21+ State<NimApp> createState() => _NimAppState();
22+}
23+
24+class _NimAppState extends State<NimApp> {
25+ late core.UiNode _tree = core.render();
26+ Timer? _poll;
27+
28+ @override
29+ void initState() {
30+ super.initState();
31+ // Polling, because the socket lives on a Nim thread and there is no
32+ // callback into Dart. A Dart callback invoked from a foreign thread has to
33+ // be marshalled onto the main isolate — NativeCallable, ports, a whole
34+ // mechanism — and at 70µs a render a 100ms timer does the same job for
35+ // nothing. It is also why `render` is allowed to be impure.
36+ _poll = Timer.periodic(const Duration(milliseconds: 100), (_) {
37+ final t = core.poll();
38+ // Only when it actually differs: a setState per tick would rebuild the
39+ // whole tree ten times a second for a screen nobody is touching.
40+ if (t.toString() != _tree.toString()) {
41+ setState(() => _tree = t);
42+ }
43+ });
44+ }
45+
46+ // One controller per keyed entry, kept across rebuilds.
47+ //
48+ // This is the whole reason `:key` is on every entry in both the Clojure and
49+ // the Nim: a controller identified by position instead of name meant the
50+ // host field and the port field shared one and both showed the port. The
51+ // comment survives three languages now.
52+ final _controllers = <String, TextEditingController>{};
53+
54+ // One focus node per keyed entry, for the same reason as the controllers.
55+ // Without it, sending with Enter drops focus and the next line is typed
56+ // into nothing — the field is rebuilt from a fresh tree every time.
57+ final _focus = <String, FocusNode>{};
58+
59+ void _send(String id, [String value = '']) {
60+ setState(() => _tree = core.dispatch(id, value));
61+ // Enter in the compose box clears the draft in Nim and rebuilds the
62+ // field; putting focus back is what makes a second line typeable.
63+ if (id == 'send') _focus['draft']?.requestFocus();
64+ }
65+
66+ @override
67+ void dispose() {
68+ _poll?.cancel();
69+ for (final c in _controllers.values) {
70+ c.dispose();
71+ }
72+ for (final f in _focus.values) {
73+ f.dispose();
74+ }
75+ super.dispose();
76+ }
77+
78+ @override
79+ Widget build(BuildContext context) => MaterialApp(
80+ title: 'frq',
81+ theme: ThemeData.dark(useMaterial3: true),
82+ home: Scaffold(
83+ body: SafeArea(child: SingleChildScrollView(child: _build(_tree))),
84+ ),
85+ );
86+
87+ Widget _build(core.UiNode n) {
88+ final kids = n.children.map(_build).toList();
89+
90+ switch (n.tag) {
91+ case 'page':
92+ return Center(
93+ child: ConstrainedBox(
94+ constraints:
95+ BoxConstraints(maxWidth: n.prop('maxWidth', 520).toDouble()),
96+ child: Padding(
97+ padding: const EdgeInsets.all(24),
98+ child: Column(
99+ crossAxisAlignment: CrossAxisAlignment.start, children: kids),
100+ ),
101+ ),
102+ );
103+
104+ case 'vbox':
105+ return Column(
106+ crossAxisAlignment: CrossAxisAlignment.start,
107+ children: _spaced(kids, n.prop('spacing', 0), vertical: true),
108+ );
109+
110+ case 'hbox':
111+ // Wrap and not Row, and this was a bug before it was a decision: the
112+ // three mode buttons are wider than the 520-point page, and a Row
113+ // answers that with a RenderFlex overflow rather than a second line.
114+ // A `:hbox` in the screens means "these go together across", not "these
115+ // fit"; the tree has no idea how wide the window is and should not.
116+ final gap = n.prop('spacing', 0).toDouble();
117+ return Wrap(
118+ spacing: gap,
119+ runSpacing: gap,
120+ crossAxisAlignment: WrapCrossAlignment.center,
121+ children: kids,
122+ );
123+
124+ case 'scroll':
125+ return SizedBox(
126+ height: n.prop('height', 300).toDouble(),
127+ child: Scrollbar(
128+ child: SingleChildScrollView(
129+ reverse: true,
130+ child: Column(
131+ crossAxisAlignment: CrossAxisAlignment.start,
132+ children: kids),
133+ ),
134+ ),
135+ );
136+
137+ case 'card':
138+ return Card(
139+ margin: const EdgeInsets.symmetric(vertical: 8),
140+ child: Padding(
141+ padding: const EdgeInsets.all(16),
142+ child: Column(
143+ crossAxisAlignment: CrossAxisAlignment.start, children: kids),
144+ ),
145+ );
146+
147+ case 'title':
148+ return Text(n.prop('label', ''),
149+ style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold));
150+
151+ case 'title-2':
152+ return Padding(
153+ padding: const EdgeInsets.only(top: 8, bottom: 4),
154+ child: Text(n.prop('label', ''),
155+ style:
156+ const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
157+ );
158+
159+ case 'label':
160+ return Text(n.prop('label', ''));
161+
162+ case 'dim-label':
163+ return Opacity(
164+ opacity: 0.7,
165+ child: Text(n.prop('label', ''),
166+ style: const TextStyle(fontSize: 12)));
167+
168+ case 'spinner':
169+ return const SizedBox(
170+ width: 16,
171+ height: 16,
172+ child: CircularProgressIndicator(strokeWidth: 2));
173+
174+ case 'button':
175+ final onClick = n.prop('onClick', '');
176+ final label = Text(n.prop('label', ''));
177+ // No padding of its own: spacing belongs to the container, which is
178+ // the only thing that knows whether this is in a row or a column.
179+ return n.prop('kind', 'default') == 'primary'
180+ ? FilledButton(onPressed: () => _send(onClick), child: label)
181+ : OutlinedButton(onPressed: () => _send(onClick), child: label);
182+
183+ case 'checkbutton':
184+ return Row(mainAxisSize: MainAxisSize.min, children: [
185+ Checkbox(
186+ value: n.prop('active', false),
187+ onChanged: (_) => _send(n.prop('onToggled', '')),
188+ ),
189+ Text(n.prop('label', '')),
190+ ]);
191+
192+ case 'entry':
193+ final key = n.prop('key', '');
194+ final text = n.prop('text', '');
195+ final c = _controllers.putIfAbsent(
196+ key, () => TextEditingController(text: text));
197+ // Only when it actually differs: assigning unconditionally moves the
198+ // caret to the end on every keystroke, which is the classic way to
199+ // make a controlled text field unusable.
200+ if (c.text != text) {
201+ c.value = c.value.copyWith(
202+ text: text,
203+ selection: TextSelection.collapsed(offset: text.length),
204+ );
205+ }
206+ final field = TextField(
207+ controller: c,
208+ focusNode: _focus.putIfAbsent(key, FocusNode.new),
209+ decoration: InputDecoration(
210+ hintText: n.prop('placeholder', ''),
211+ isDense: true,
212+ border: const OutlineInputBorder(),
213+ ),
214+ onChanged: (v) => _send(n.prop('onChange', ''), v),
215+ onSubmitted: (_) {
216+ final submit = n.prop('onSubmit', '');
217+ if (submit.isNotEmpty) _send(submit);
218+ },
219+ );
220+ final w = n.prop('widthRequest', 0);
221+ // A width request is a minimum in the screens' vocabulary, but here it
222+ // has to be a maximum too: an unconstrained TextField inside a Wrap
223+ // has no width at all to take.
224+ return w > 0 ? SizedBox(width: w.toDouble(), child: field) : field;
225+
226+ default:
227+ // An unknown tag paints as itself rather than crashing or vanishing.
228+ // Nim can add one and see it before this file has heard of it, which
229+ // is the behaviour that makes the boundary pleasant to work across.
230+ return Container(
231+ padding: const EdgeInsets.all(4),
232+ color: Colors.orange.withValues(alpha: 0.3),
233+ child: Text('?${n.tag}'),
234+ );
235+ }
236+ }
237+
238+ List<Widget> _spaced(List<Widget> kids, num gap, {required bool vertical}) {
239+ if (gap <= 0 || kids.length < 2) return kids;
240+ final out = <Widget>[];
241+ for (var i = 0; i < kids.length; i++) {
242+ if (i > 0) {
243+ out.add(vertical
244+ ? SizedBox(height: gap.toDouble())
245+ : SizedBox(width: gap.toDouble()));
246+ }
247+ out.add(kids[i]);
248+ }
249+ return out;
250+ }
251+}
added nim/src/frq/clock.nim +119 -0
new file mode 100644
@@ -0,0 +1,119 @@
1+## Server time, in the reader's own zone.
2+##
3+## Transcribed from `common/frq/clock.cljc`, arithmetic and all. The date
4+## conversions are Howard Hinnant's algorithms rather than a call into a date
5+## library, which is what let the Clojure version be shared between two
6+## compilers and is just as useful here: eleven lines with no dependency and
7+## no zone database.
8+##
9+## The zone itself is the one thing that is not arithmetic. `localOffsetSeconds`
10+## asks the host, because finding it is four platform-specific guesses on Linux
11+## and one property read on Android — and an offset rather than a zone name
12+## because the offset moves twice a year, and a backlog read in November
13+## carries messages from August.
14+
15+import std/[strutils, times]
16+
17+func floorDiv*(a, b: int64): int64 =
18+ ## Rounds down where `div` rounds toward zero, which for a day number before
19+ ## 1970 is a different day.
20+ let q = a div b
21+ let r = a mod b
22+ if r == 0 or (r < 0) == (b < 0): q else: q - 1
23+
24+func floorMod*(a, b: int64): int64 = a - b * floorDiv(a, b)
25+
26+func daysFromCivil*(y0, m, d: int64): int64 =
27+ ## Hinnant's algorithm: a civil date to a day number since the epoch.
28+ let y = if m <= 2: y0 - 1 else: y0
29+ let era = floorDiv(if y >= 0: y else: y - 399, 400)
30+ let yoe = y - era * 400
31+ let doy = (153 * (m + (if m > 2: -3 else: 9)) + 2) div 5 + d - 1
32+ let doe = yoe * 365 + yoe div 4 - yoe div 100 + doy
33+ era * 146097 + doe - 719468
34+
35+func civilFromDays*(days: int64): (int64, int64, int64) =
36+ ## The same, the other way round.
37+ let z = days + 719468
38+ let era = floorDiv(z, 146097)
39+ let doe = z - era * 146097
40+ let yoe = (doe - doe div 1460 + doe div 36524 - doe div 146096) div 365
41+ let y = yoe + era * 400
42+ let doy = doe - (365 * yoe + yoe div 4 - yoe div 100)
43+ let mp = (5 * doy + 2) div 153
44+ let d = doy - (153 * mp + 2) div 5 + 1
45+ let m = mp + (if mp < 10: 3 else: -9)
46+ ((if m <= 2: y + 1 else: y), m, d)
47+
48+proc nowMs*(): int64 = getTime().toUnix * 1000 + getTime().nanosecond div 1_000_000
49+
50+proc localOffsetSeconds*(epochSecs: int64): int64 =
51+ ## How far the reader's zone is from UTC at this instant, DST included.
52+ ##
53+ ## Nim has a zone database where the ClojureDart version had to ask the host
54+ ## through `frq.io` — so this is the one function that got simpler in the
55+ ## move rather than merely moving.
56+ let t = fromUnix(epochSecs)
57+ t.local.utcOffset.int64 * -1
58+
59+func parseTimeTag*(tags: string): (int64, bool) =
60+ ## The `time=` value of an IRCv3 tag string as epoch milliseconds.
61+ ##
62+ ## Fixed format, always UTC: `2026-08-30T07:05:09.000Z`. Read by hand rather
63+ ## than through a parser, since this runs once per message of a
64+ ## hundred-message backlog.
65+ let i = tags.find("time=")
66+ if i < 0: return (0'i64, false)
67+ let s = tags[i + 5 .. ^1]
68+ if s.len < 19: return (0'i64, false)
69+ template digits(a, b: int): bool =
70+ (block:
71+ var ok = true
72+ for k in a .. b:
73+ if k >= s.len or s[k] notin {'0' .. '9'}: ok = false
74+ ok)
75+ if not (digits(0, 3) and s[4] == '-' and digits(5, 6) and s[7] == '-' and
76+ digits(8, 9) and s[10] == 'T' and digits(11, 12) and s[13] == ':' and
77+ digits(14, 15) and s[16] == ':' and digits(17, 18)):
78+ return (0'i64, false)
79+ try:
80+ let y = s[0 .. 3].parseInt.int64
81+ let mo = s[5 .. 6].parseInt.int64
82+ let d = s[8 .. 9].parseInt.int64
83+ let h = s[11 .. 12].parseInt.int64
84+ let mi = s[14 .. 15].parseInt.int64
85+ let sec = s[17 .. 18].parseInt.int64
86+ let days = daysFromCivil(y, mo, d)
87+ (1000'i64 * (days * 86400 + h * 3600 + mi * 60 + sec), true)
88+ except ValueError:
89+ (0'i64, false)
90+
91+func pad2(n: int64): string =
92+ if n < 10: "0" & $n else: $n
93+
94+proc localParts*(ms: int64): (string, int64, string) =
95+ ## `(date, hour, minute)` in the reader's zone, the hour on a 24-clock.
96+ var secs = ms div 1000
97+ secs += localOffsetSeconds(secs)
98+ let days = floorDiv(secs, 86400)
99+ let sod = floorMod(secs, 86400)
100+ let (y, m, d) = civilFromDays(days)
101+ ($y & "-" & pad2(m) & "-" & pad2(d), sod div 3600, pad2((sod mod 3600) div 60))
102+
103+proc clockTime*(ms: int64): string =
104+ ## A twelve-hour time: `9:05 AM`, `12:30 PM`.
105+ let (_, hour, minute) = localParts(ms)
106+ let display = if hour == 0: 12'i64 elif hour > 12: hour - 12 else: hour
107+ $display & ":" & minute & " " & (if hour < 12: "AM" else: "PM")
108+
109+proc day*(ms: int64): string = localParts(ms)[0]
110+
111+proc dayLabel*(ms: int64): string =
112+ ## The heading for a day's messages: today and yesterday by name, anything
113+ ## older by date.
114+ let d = day(ms)
115+ let today = day(nowMs())
116+ let yesterday = day(nowMs() - 86_400_000)
117+ if d == today: "Today"
118+ elif d == yesterday: "Yesterday"
119+ else: d
new file mode 100644
@@ -0,0 +1,119 @@
1+## Server time, in the reader's own zone.
2+##
3+## Transcribed from `common/frq/clock.cljc`, arithmetic and all. The date
4+## conversions are Howard Hinnant's algorithms rather than a call into a date
5+## library, which is what let the Clojure version be shared between two
6+## compilers and is just as useful here: eleven lines with no dependency and
7+## no zone database.
8+##
9+## The zone itself is the one thing that is not arithmetic. `localOffsetSeconds`
10+## asks the host, because finding it is four platform-specific guesses on Linux
11+## and one property read on Android — and an offset rather than a zone name
12+## because the offset moves twice a year, and a backlog read in November
13+## carries messages from August.
14+
15+import std/[strutils, times]
16+
17+func floorDiv*(a, b: int64): int64 =
18+ ## Rounds down where `div` rounds toward zero, which for a day number before
19+ ## 1970 is a different day.
20+ let q = a div b
21+ let r = a mod b
22+ if r == 0 or (r < 0) == (b < 0): q else: q - 1
23+
24+func floorMod*(a, b: int64): int64 = a - b * floorDiv(a, b)
25+
26+func daysFromCivil*(y0, m, d: int64): int64 =
27+ ## Hinnant's algorithm: a civil date to a day number since the epoch.
28+ let y = if m <= 2: y0 - 1 else: y0
29+ let era = floorDiv(if y >= 0: y else: y - 399, 400)
30+ let yoe = y - era * 400
31+ let doy = (153 * (m + (if m > 2: -3 else: 9)) + 2) div 5 + d - 1
32+ let doe = yoe * 365 + yoe div 4 - yoe div 100 + doy
33+ era * 146097 + doe - 719468
34+
35+func civilFromDays*(days: int64): (int64, int64, int64) =
36+ ## The same, the other way round.
37+ let z = days + 719468
38+ let era = floorDiv(z, 146097)
39+ let doe = z - era * 146097
40+ let yoe = (doe - doe div 1460 + doe div 36524 - doe div 146096) div 365
41+ let y = yoe + era * 400
42+ let doy = doe - (365 * yoe + yoe div 4 - yoe div 100)
43+ let mp = (5 * doy + 2) div 153
44+ let d = doy - (153 * mp + 2) div 5 + 1
45+ let m = mp + (if mp < 10: 3 else: -9)
46+ ((if m <= 2: y + 1 else: y), m, d)
47+
48+proc nowMs*(): int64 = getTime().toUnix * 1000 + getTime().nanosecond div 1_000_000
49+
50+proc localOffsetSeconds*(epochSecs: int64): int64 =
51+ ## How far the reader's zone is from UTC at this instant, DST included.
52+ ##
53+ ## Nim has a zone database where the ClojureDart version had to ask the host
54+ ## through `frq.io` — so this is the one function that got simpler in the
55+ ## move rather than merely moving.
56+ let t = fromUnix(epochSecs)
57+ t.local.utcOffset.int64 * -1
58+
59+func parseTimeTag*(tags: string): (int64, bool) =
60+ ## The `time=` value of an IRCv3 tag string as epoch milliseconds.
61+ ##
62+ ## Fixed format, always UTC: `2026-08-30T07:05:09.000Z`. Read by hand rather
63+ ## than through a parser, since this runs once per message of a
64+ ## hundred-message backlog.
65+ let i = tags.find("time=")
66+ if i < 0: return (0'i64, false)
67+ let s = tags[i + 5 .. ^1]
68+ if s.len < 19: return (0'i64, false)
69+ template digits(a, b: int): bool =
70+ (block:
71+ var ok = true
72+ for k in a .. b:
73+ if k >= s.len or s[k] notin {'0' .. '9'}: ok = false
74+ ok)
75+ if not (digits(0, 3) and s[4] == '-' and digits(5, 6) and s[7] == '-' and
76+ digits(8, 9) and s[10] == 'T' and digits(11, 12) and s[13] == ':' and
77+ digits(14, 15) and s[16] == ':' and digits(17, 18)):
78+ return (0'i64, false)
79+ try:
80+ let y = s[0 .. 3].parseInt.int64
81+ let mo = s[5 .. 6].parseInt.int64
82+ let d = s[8 .. 9].parseInt.int64
83+ let h = s[11 .. 12].parseInt.int64
84+ let mi = s[14 .. 15].parseInt.int64
85+ let sec = s[17 .. 18].parseInt.int64
86+ let days = daysFromCivil(y, mo, d)
87+ (1000'i64 * (days * 86400 + h * 3600 + mi * 60 + sec), true)
88+ except ValueError:
89+ (0'i64, false)
90+
91+func pad2(n: int64): string =
92+ if n < 10: "0" & $n else: $n
93+
94+proc localParts*(ms: int64): (string, int64, string) =
95+ ## `(date, hour, minute)` in the reader's zone, the hour on a 24-clock.
96+ var secs = ms div 1000
97+ secs += localOffsetSeconds(secs)
98+ let days = floorDiv(secs, 86400)
99+ let sod = floorMod(secs, 86400)
100+ let (y, m, d) = civilFromDays(days)
101+ ($y & "-" & pad2(m) & "-" & pad2(d), sod div 3600, pad2((sod mod 3600) div 60))
102+
103+proc clockTime*(ms: int64): string =
104+ ## A twelve-hour time: `9:05 AM`, `12:30 PM`.
105+ let (_, hour, minute) = localParts(ms)
106+ let display = if hour == 0: 12'i64 elif hour > 12: hour - 12 else: hour
107+ $display & ":" & minute & " " & (if hour < 12: "AM" else: "PM")
108+
109+proc day*(ms: int64): string = localParts(ms)[0]
110+
111+proc dayLabel*(ms: int64): string =
112+ ## The heading for a day's messages: today and yesterday by name, anything
113+ ## older by date.
114+ let d = day(ms)
115+ let today = day(nowMs())
116+ let yesterday = day(nowMs() - 86_400_000)
117+ if d == today: "Today"
118+ elif d == yesterday: "Yesterday"
119+ else: d
added nim/src/frq/model.nim +109 -0
new file mode 100644
@@ -0,0 +1,109 @@
1+## What a message and a room are.
2+##
3+## The types the rest of the port hangs off, transcribed from the shapes
4+## `frq.cells` holds and `frq.rooms` reads. Kept as one module because they
5+## are one idea: a room is a list of messages and the bookkeeping about how
6+## far it has been read.
7+##
8+## Fields that are `Option` in spirit are spelled as a value plus a `has`
9+## flag rather than `Option[T]`, for one reason: these cross the FFI as JSON,
10+## and a missing key and a null key are the same thing on the other side. An
11+## Option would have to be unwrapped at every boundary anyway.
12+
13+import std/[options, strutils, tables]
14+
15+type
16+ Reaction* = object
17+ ## An emoji and who put it there. The nicks are a set in the Clojure; a
18+ ## seq here, kept ordered, because the order is what the pills are drawn
19+ ## in and a set would have thrown it away.
20+ emoji*: string
21+ nicks*: seq[string]
22+
23+ Message* = object
24+ id*: string ## the server's msgid, "" until it echoes back
25+ localId*: string ## what this client called it before then
26+ editIds*: seq[string] ## every msgid this line has worn — see `answersTo`
27+ frm*: string
28+ text*: string
29+ at*: int64 ## epoch seconds, 0 where the line carried no time
30+ system*: bool ## a join/part/notice rather than something said
31+ mention*: bool
32+ edited*: bool
33+ replyTo*: string ## the id this answers, "" for a line answering none
34+ reactions*: seq[Reaction]
35+ imageUrl*: string ## the first picture link in the text, "" for none
36+ pending*: bool ## sent, not yet echoed
37+
38+ Room* = object
39+ ## A buffer: a channel or a DM. Named Room rather than Channel because
40+ ## Nim's `system.Channel` is the thread-safe queue `conn.nim` uses, and a
41+ ## type that shadows it here would be a confusing thing to debug.
42+ name*: string
43+ messages*: seq[Message]
44+ unread*: int
45+ mention*: bool
46+ joined*: bool
47+ joining*: bool
48+ users*: seq[string]
49+ topic*: string
50+ accessed*: int64 ## when this reader last opened it
51+ lastActivity*: int64
52+ lastReadId*: string
53+ lastReadAt*: int64
54+ peerDid*: string ## for a DM, who the other side is
55+
56+func initMessage*(frm, text: string): Message =
57+ Message(frm: frm, text: text)
58+
59+func initRoom*(name: string): Room =
60+ Room(name: name)
61+
62+# ------------------------------------------------------------------- naming
63+
64+func dm*(name: string): bool =
65+ ## Whether a buffer is a conversation with a person rather than a room.
66+ ## Every channel name starts with `#`; what does not is somebody's nick.
67+ name.len > 0 and not name.startsWith("#")
68+
69+func rowId*(m: Message): string =
70+ ## What this client calls a line: the server's name for it where there is
71+ ## one, and the name it was given here where there is not.
72+ ##
73+ ## freeq tags a message with a msgid and that is a line's identity
74+ ## everywhere it matters — a reply points at one, an edit rewrites one, a
75+ ## reaction lands on one. But not every line arrives with one: a replayed
76+ ## backlog can come with no tags at all, and a line this client has just
77+ ## sent has none until the server echoes it back. Those lines are not
78+ ## nameless to the reader — they are on the screen — so they get a local id
79+ ## made out of what they are. It is never sent.
80+ if m.id.len > 0: m.id else: m.localId
81+
82+func answersTo*(m: Message, id: string): bool =
83+ ## Whether `id` names this line — by any of the names it has had.
84+ ##
85+ ## `rowId` is what this client calls a line; this is what everybody else may
86+ ## call it. A message keeps the id it was born with through every revision,
87+ ## but the server gives each revision a msgid of its own, and anyone
88+ ## replying to a line already rewritten answers the wording in front of them
89+ ## — so the reply names the revision rather than the original. Both are this
90+ ## message, so both find it.
91+ ##
92+ ## The local name counts too: a line just sent has no msgid until the echo,
93+ ## and its own reply chip points at the local id until then.
94+ if id.len == 0: return false
95+ id == m.id or id == m.localId or id in m.editIds
96+
97+func messageById*(ch: Room, id: string): Option[Message] =
98+ ## The message `id` names, if this buffer still holds it.
99+ for m in ch.messages:
100+ if m.answersTo(id): return some(m)
101+ none(Message)
102+
103+func indexById*(ch: Room, id: string): int =
104+ ## Where it is, or -1. Separate from `messageById` because scrolling wants
105+ ## the position and reading wants the value, and returning a copy to find an
106+ ## index would be the wrong way round.
107+ for i, m in ch.messages:
108+ if m.answersTo(id): return i
109+ -1
new file mode 100644
@@ -0,0 +1,109 @@
1+## What a message and a room are.
2+##
3+## The types the rest of the port hangs off, transcribed from the shapes
4+## `frq.cells` holds and `frq.rooms` reads. Kept as one module because they
5+## are one idea: a room is a list of messages and the bookkeeping about how
6+## far it has been read.
7+##
8+## Fields that are `Option` in spirit are spelled as a value plus a `has`
9+## flag rather than `Option[T]`, for one reason: these cross the FFI as JSON,
10+## and a missing key and a null key are the same thing on the other side. An
11+## Option would have to be unwrapped at every boundary anyway.
12+
13+import std/[options, strutils, tables]
14+
15+type
16+ Reaction* = object
17+ ## An emoji and who put it there. The nicks are a set in the Clojure; a
18+ ## seq here, kept ordered, because the order is what the pills are drawn
19+ ## in and a set would have thrown it away.
20+ emoji*: string
21+ nicks*: seq[string]
22+
23+ Message* = object
24+ id*: string ## the server's msgid, "" until it echoes back
25+ localId*: string ## what this client called it before then
26+ editIds*: seq[string] ## every msgid this line has worn — see `answersTo`
27+ frm*: string
28+ text*: string
29+ at*: int64 ## epoch seconds, 0 where the line carried no time
30+ system*: bool ## a join/part/notice rather than something said
31+ mention*: bool
32+ edited*: bool
33+ replyTo*: string ## the id this answers, "" for a line answering none
34+ reactions*: seq[Reaction]
35+ imageUrl*: string ## the first picture link in the text, "" for none
36+ pending*: bool ## sent, not yet echoed
37+
38+ Room* = object
39+ ## A buffer: a channel or a DM. Named Room rather than Channel because
40+ ## Nim's `system.Channel` is the thread-safe queue `conn.nim` uses, and a
41+ ## type that shadows it here would be a confusing thing to debug.
42+ name*: string
43+ messages*: seq[Message]
44+ unread*: int
45+ mention*: bool
46+ joined*: bool
47+ joining*: bool
48+ users*: seq[string]
49+ topic*: string
50+ accessed*: int64 ## when this reader last opened it
51+ lastActivity*: int64
52+ lastReadId*: string
53+ lastReadAt*: int64
54+ peerDid*: string ## for a DM, who the other side is
55+
56+func initMessage*(frm, text: string): Message =
57+ Message(frm: frm, text: text)
58+
59+func initRoom*(name: string): Room =
60+ Room(name: name)
61+
62+# ------------------------------------------------------------------- naming
63+
64+func dm*(name: string): bool =
65+ ## Whether a buffer is a conversation with a person rather than a room.
66+ ## Every channel name starts with `#`; what does not is somebody's nick.
67+ name.len > 0 and not name.startsWith("#")
68+
69+func rowId*(m: Message): string =
70+ ## What this client calls a line: the server's name for it where there is
71+ ## one, and the name it was given here where there is not.
72+ ##
73+ ## freeq tags a message with a msgid and that is a line's identity
74+ ## everywhere it matters — a reply points at one, an edit rewrites one, a
75+ ## reaction lands on one. But not every line arrives with one: a replayed
76+ ## backlog can come with no tags at all, and a line this client has just
77+ ## sent has none until the server echoes it back. Those lines are not
78+ ## nameless to the reader — they are on the screen — so they get a local id
79+ ## made out of what they are. It is never sent.
80+ if m.id.len > 0: m.id else: m.localId
81+
82+func answersTo*(m: Message, id: string): bool =
83+ ## Whether `id` names this line — by any of the names it has had.
84+ ##
85+ ## `rowId` is what this client calls a line; this is what everybody else may
86+ ## call it. A message keeps the id it was born with through every revision,
87+ ## but the server gives each revision a msgid of its own, and anyone
88+ ## replying to a line already rewritten answers the wording in front of them
89+ ## — so the reply names the revision rather than the original. Both are this
90+ ## message, so both find it.
91+ ##
92+ ## The local name counts too: a line just sent has no msgid until the echo,
93+ ## and its own reply chip points at the local id until then.
94+ if id.len == 0: return false
95+ id == m.id or id == m.localId or id in m.editIds
96+
97+func messageById*(ch: Room, id: string): Option[Message] =
98+ ## The message `id` names, if this buffer still holds it.
99+ for m in ch.messages:
100+ if m.answersTo(id): return some(m)
101+ none(Message)
102+
103+func indexById*(ch: Room, id: string): int =
104+ ## Where it is, or -1. Separate from `messageById` because scrolling wants
105+ ## the position and reading wants the value, and returning a copy to find an
106+ ## index would be the wrong way round.
107+ for i, m in ch.messages:
108+ if m.answersTo(id): return i
109+ -1
added nim/src/frq/rooms.nim +203 -0
new file mode 100644
@@ -0,0 +1,203 @@
1+## The conversation list, and the read marker under it.
2+##
3+## Transcribed from `common/frq/rooms.cljc`. Pure functions over the state —
4+## everything here is a question about rooms rather than a change to one, with
5+## the two exceptions (`markRead`, `ensureRoom`) that are named for being
6+## changes.
7+##
8+## The marker is the part to read carefully. Unread is *derived* from it and
9+## never counted, because a count cannot survive what the server does: a JOIN
10+## replays the backlog and CHATHISTORY replays it again, and every line would
11+## tick a counter a second time. Against a marker a replayed line is simply
12+## older than it and counts for nothing.
13+
14+import std/[algorithm, sequtils, strutils, tables]
15+import model, clock
16+
17+const
18+ overviewLimit* = 100
19+ ## How many lines the overview strip holds in all.
20+
21+ freshRoomGraceMs* = 60_000'i64
22+ ## How far back a room nobody has seen before counts as already read.
23+ ##
24+ ## A room joined for the first time replays its whole history, and none of
25+ ## that is news — the reader was not away for it, they were not here. So a
26+ ## new buffer starts caught up rather than at the beginning, or joining a
27+ ## busy channel announces a hundred unread posts from before you arrived.
28+ ##
29+ ## A minute ago rather than this instant, because a live line is stamped
30+ ## by the server and this by our clock: the two disagree by whatever the
31+ ## skew is, and a live message stamped a few seconds behind us would land
32+ ## under the marker and never be counted. A minute is more skew than there
33+ ## will be and far less than the age of any backlog. What it costs is that
34+ ## a message sent in the minute before you joined counts as unread, which
35+ ## is the harmless direction.
36+
37+func lastPreview*(ch: Room): string =
38+ if ch.messages.len == 0: "No messages yet"
39+ else:
40+ let m = ch.messages[^1]
41+ m.frm & ": " & m.text
42+
43+func channelList*(channels: OrderedTable[string, Room], search: string): seq[Room] =
44+ ## Buffers most recently opened first, filtered by the search box.
45+ ##
46+ ## A conversation list is read from the top, and the one you were just in is
47+ ## the one you are most likely to want again. Buffers never opened — a DM
48+ ## that arrived, a channel someone mentioned — sort under those by name
49+ ## rather than jumping the queue.
50+ let q = search.strip().toLowerAscii
51+ for _, ch in channels:
52+ if q.len == 0 or ch.name.toLowerAscii.contains(q):
53+ result.add ch
54+ result.sort(proc (a, b: Room): int =
55+ # Descending by `accessed`, then ascending by name — the juxt in the
56+ # Clojure, which negates the first key and leaves the second alone.
57+ if a.accessed != b.accessed:
58+ cmp(b.accessed, a.accessed)
59+ else:
60+ cmp(a.name, b.name))
61+
62+func mine*(m: Message, me: string): bool =
63+ ## Whether we are the one who said this.
64+ ##
65+ ## Nick against nick, which is what the server itself falls back to for an
66+ ## account with no DID — and an edit it would refuse is one not worth
67+ ## offering. A system line is nobody's to rewrite.
68+ (not m.system) and m.frm.len > 0 and
69+ m.frm.toLowerAscii == me.toLowerAscii
70+
71+func seenMessage*(msgs: seq[Message], id, frm, text, me: string): bool =
72+ ## Whether this buffer already holds the line that has just arrived.
73+ ##
74+ ## The server hands the same message over more than once: a JOIN replays the
75+ ## backlog, CHATHISTORY replays it again, and a line can have arrived live
76+ ## before either. The msgid survives every revision, so holding the copy we
77+ ## have is what keeps a rejoin from doubling the buffer.
78+ ##
79+ ## Sometimes a line is replayed with no tags at all — no msgid to know it by
80+ ## and no time to place it. That line has no identity, so left alone it
81+ ## arrives new on every rejoin, appended again and stamped now, which is a
82+ ## room that can never be finished reading. What it does have is a sender
83+ ## and words, which for an untagged line is identity enough. The cost is
84+ ## that the same person saying the same thing twice — both untagged — shows
85+ ## once. Ours and the system's are left out of it: a second "ok" from this
86+ ## client, or a second "alice joined", is a real event rather than a replay.
87+ if id.len > 0:
88+ for m in msgs:
89+ if m.id == id: return true
90+ false
91+ else:
92+ if frm == "*" or frm.toLowerAscii == me.toLowerAscii:
93+ return false
94+ for m in msgs:
95+ if m.id.len == 0 and m.frm == frm and m.text == text: return true
96+ false
97+
98+func roundRobin(colls: seq[seq[Message]]): seq[Message] =
99+ ## The colls' firsts, then their seconds, and so on until they are spent.
100+ ##
101+ ## This is how the overview stays about every room while still being a fixed
102+ ## number of lines. Taking the newest hundred outright would be the strip
103+ ## answering about whichever room is busiest — which is the one you can
104+ ## already see. A turn each means a room that said one thing all day is in
105+ ## the first handful, beside the room that has said a hundred.
106+ var live = colls.filterIt(it.len > 0)
107+ var i = 0
108+ while live.len > 0:
109+ var next: seq[seq[Message]]
110+ for c in live:
111+ if i < c.len:
112+ result.add c[i]
113+ next.add c
114+ if next.len == 0: break
115+ live = next
116+ i += 1
117+
118+func recentEverywhere*(channels: OrderedTable[string, Room],
119+ current: string): seq[Message] =
120+ ## The newest lines from every buffer at once, newest first, and at most
121+ ## `overviewLimit` of them — a turn to each room until they run out.
122+ ##
123+ ## Bounded per room before anything else, so the cost is the number of rooms
124+ ## rather than the length of their backlogs: a channel with a week of
125+ ## history must not make this the most expensive thing on the screen.
126+ ##
127+ ## Joins, parts and the system's own chatter are left out — they are the
128+ ## noise this strip would drown in. So is the room being read: it is on the
129+ ## screen already, in full, directly above, and what the strip is for is the
130+ ## rooms you are not looking at.
131+ var colls: seq[seq[Message]]
132+ for name, ch in channels:
133+ if name == current: continue
134+ var said = ch.messages.filterIt(not it.system)
135+ if said.len > overviewLimit:
136+ said = said[^overviewLimit .. ^1]
137+ if said.len == 0: continue
138+ colls.add said.reversed # newest first, the order a turn needs
139+ result = roundRobin(colls)
140+ if result.len > overviewLimit:
141+ result = result[0 ..< overviewLimit]
142+ # Newest at the top, the other way round from a conversation and right for
143+ # the same reason a conversation is the way it is: what you came to the
144+ # strip for is what has just happened. The turn-taking above is about which
145+ # lines are in it, not where they sit.
146+ result.sort(proc (a, b: Message): int = cmp(b.at, a.at))
147+
148+func afterMarker*(ch: Room): seq[Message] =
149+ ## The messages the reader has not seen: everything after the read marker.
150+ ##
151+ ## By id where the marked message is still held, and by time otherwise. The
152+ ## id is the exact answer — a msgid survives every revision, so it names the
153+ ## same line however often the server replays it — and the timestamp is what
154+ ## answers when the marked line has fallen off the end of the buffer or was
155+ ## never in this run's copy of it.
156+ if ch.lastReadId.len > 0:
157+ for i, m in ch.messages:
158+ if m.id == ch.lastReadId:
159+ return if i + 1 <= ch.messages.high: ch.messages[i + 1 .. ^1] else: @[]
160+ ch.messages.filterIt(it.at > ch.lastReadAt)
161+
162+func mentionsMe*(m: Message, me: string): bool =
163+ ## Whether a line is addressed at the reader by name. Our own lines do not
164+ ## count — saying your own nick is not being called.
165+ let me = me.strip()
166+ me.len > 0 and m.frm != me and m.text.toLowerAscii.contains(me.toLowerAscii)
167+
168+func recount*(ch: Room, me: string): Room =
169+ ## Answer what the marker says: how many lines are unseen, and whether any
170+ ## of them names the reader.
171+ ##
172+ ## Joins, parts and "Joined #room" are the room talking about itself, not
173+ ## somebody talking in it. They arrive stamped now, so counted, every room
174+ ## you are a member of sits at one unread from the moment it opens, saying
175+ ## only that you joined it. The marker still moves past them: they are read,
176+ ## they are just never what made a room worth looking at.
177+ result = ch
178+ let fresh = ch.afterMarker.filterIt(not it.system)
179+ result.unread = fresh.len
180+ result.mention = fresh.anyIt(it.mentionsMe(me))
181+
182+func markRead*(ch: Room): Room =
183+ ## Move the marker to the newest line this buffer holds. Both halves: the id
184+ ## for as long as that line is here, and its time for after it is gone.
185+ ##
186+ ## The time only ever goes forward. A backlog can arrive after the reader
187+ ## has already read past it, and taking the last line's time unconditionally
188+ ## would walk the marker backwards and re-unread what was read.
189+ result = ch
190+ result.unread = 0
191+ result.mention = false
192+ if ch.messages.len > 0:
193+ let newest = ch.messages[^1]
194+ result.lastReadId = newest.id
195+ result.lastReadAt = max(ch.lastReadAt, newest.at)
196+
197+proc ensureRoom*(channels: var OrderedTable[string, Room], name: string) =
198+ ## Make sure a buffer exists, started caught up rather than at the
199+ ## beginning — see `freshRoomGraceMs`.
200+ if channels.hasKey(name): return
201+ var ch = initRoom(name)
202+ ch.lastReadAt = max(0'i64, nowMs() - freshRoomGraceMs)
203+ channels[name] = ch
new file mode 100644
@@ -0,0 +1,203 @@
1+## The conversation list, and the read marker under it.
2+##
3+## Transcribed from `common/frq/rooms.cljc`. Pure functions over the state —
4+## everything here is a question about rooms rather than a change to one, with
5+## the two exceptions (`markRead`, `ensureRoom`) that are named for being
6+## changes.
7+##
8+## The marker is the part to read carefully. Unread is *derived* from it and
9+## never counted, because a count cannot survive what the server does: a JOIN
10+## replays the backlog and CHATHISTORY replays it again, and every line would
11+## tick a counter a second time. Against a marker a replayed line is simply
12+## older than it and counts for nothing.
13+
14+import std/[algorithm, sequtils, strutils, tables]
15+import model, clock
16+
17+const
18+ overviewLimit* = 100
19+ ## How many lines the overview strip holds in all.
20+
21+ freshRoomGraceMs* = 60_000'i64
22+ ## How far back a room nobody has seen before counts as already read.
23+ ##
24+ ## A room joined for the first time replays its whole history, and none of
25+ ## that is news — the reader was not away for it, they were not here. So a
26+ ## new buffer starts caught up rather than at the beginning, or joining a
27+ ## busy channel announces a hundred unread posts from before you arrived.
28+ ##
29+ ## A minute ago rather than this instant, because a live line is stamped
30+ ## by the server and this by our clock: the two disagree by whatever the
31+ ## skew is, and a live message stamped a few seconds behind us would land
32+ ## under the marker and never be counted. A minute is more skew than there
33+ ## will be and far less than the age of any backlog. What it costs is that
34+ ## a message sent in the minute before you joined counts as unread, which
35+ ## is the harmless direction.
36+
37+func lastPreview*(ch: Room): string =
38+ if ch.messages.len == 0: "No messages yet"
39+ else:
40+ let m = ch.messages[^1]
41+ m.frm & ": " & m.text
42+
43+func channelList*(channels: OrderedTable[string, Room], search: string): seq[Room] =
44+ ## Buffers most recently opened first, filtered by the search box.
45+ ##
46+ ## A conversation list is read from the top, and the one you were just in is
47+ ## the one you are most likely to want again. Buffers never opened — a DM
48+ ## that arrived, a channel someone mentioned — sort under those by name
49+ ## rather than jumping the queue.
50+ let q = search.strip().toLowerAscii
51+ for _, ch in channels:
52+ if q.len == 0 or ch.name.toLowerAscii.contains(q):
53+ result.add ch
54+ result.sort(proc (a, b: Room): int =
55+ # Descending by `accessed`, then ascending by name — the juxt in the
56+ # Clojure, which negates the first key and leaves the second alone.
57+ if a.accessed != b.accessed:
58+ cmp(b.accessed, a.accessed)
59+ else:
60+ cmp(a.name, b.name))
61+
62+func mine*(m: Message, me: string): bool =
63+ ## Whether we are the one who said this.
64+ ##
65+ ## Nick against nick, which is what the server itself falls back to for an
66+ ## account with no DID — and an edit it would refuse is one not worth
67+ ## offering. A system line is nobody's to rewrite.
68+ (not m.system) and m.frm.len > 0 and
69+ m.frm.toLowerAscii == me.toLowerAscii
70+
71+func seenMessage*(msgs: seq[Message], id, frm, text, me: string): bool =
72+ ## Whether this buffer already holds the line that has just arrived.
73+ ##
74+ ## The server hands the same message over more than once: a JOIN replays the
75+ ## backlog, CHATHISTORY replays it again, and a line can have arrived live
76+ ## before either. The msgid survives every revision, so holding the copy we
77+ ## have is what keeps a rejoin from doubling the buffer.
78+ ##
79+ ## Sometimes a line is replayed with no tags at all — no msgid to know it by
80+ ## and no time to place it. That line has no identity, so left alone it
81+ ## arrives new on every rejoin, appended again and stamped now, which is a
82+ ## room that can never be finished reading. What it does have is a sender
83+ ## and words, which for an untagged line is identity enough. The cost is
84+ ## that the same person saying the same thing twice — both untagged — shows
85+ ## once. Ours and the system's are left out of it: a second "ok" from this
86+ ## client, or a second "alice joined", is a real event rather than a replay.
87+ if id.len > 0:
88+ for m in msgs:
89+ if m.id == id: return true
90+ false
91+ else:
92+ if frm == "*" or frm.toLowerAscii == me.toLowerAscii:
93+ return false
94+ for m in msgs:
95+ if m.id.len == 0 and m.frm == frm and m.text == text: return true
96+ false
97+
98+func roundRobin(colls: seq[seq[Message]]): seq[Message] =
99+ ## The colls' firsts, then their seconds, and so on until they are spent.
100+ ##
101+ ## This is how the overview stays about every room while still being a fixed
102+ ## number of lines. Taking the newest hundred outright would be the strip
103+ ## answering about whichever room is busiest — which is the one you can
104+ ## already see. A turn each means a room that said one thing all day is in
105+ ## the first handful, beside the room that has said a hundred.
106+ var live = colls.filterIt(it.len > 0)
107+ var i = 0
108+ while live.len > 0:
109+ var next: seq[seq[Message]]
110+ for c in live:
111+ if i < c.len:
112+ result.add c[i]
113+ next.add c
114+ if next.len == 0: break
115+ live = next
116+ i += 1
117+
118+func recentEverywhere*(channels: OrderedTable[string, Room],
119+ current: string): seq[Message] =
120+ ## The newest lines from every buffer at once, newest first, and at most
121+ ## `overviewLimit` of them — a turn to each room until they run out.
122+ ##
123+ ## Bounded per room before anything else, so the cost is the number of rooms
124+ ## rather than the length of their backlogs: a channel with a week of
125+ ## history must not make this the most expensive thing on the screen.
126+ ##
127+ ## Joins, parts and the system's own chatter are left out — they are the
128+ ## noise this strip would drown in. So is the room being read: it is on the
129+ ## screen already, in full, directly above, and what the strip is for is the
130+ ## rooms you are not looking at.
131+ var colls: seq[seq[Message]]
132+ for name, ch in channels:
133+ if name == current: continue
134+ var said = ch.messages.filterIt(not it.system)
135+ if said.len > overviewLimit:
136+ said = said[^overviewLimit .. ^1]
137+ if said.len == 0: continue
138+ colls.add said.reversed # newest first, the order a turn needs
139+ result = roundRobin(colls)
140+ if result.len > overviewLimit:
141+ result = result[0 ..< overviewLimit]
142+ # Newest at the top, the other way round from a conversation and right for
143+ # the same reason a conversation is the way it is: what you came to the
144+ # strip for is what has just happened. The turn-taking above is about which
145+ # lines are in it, not where they sit.
146+ result.sort(proc (a, b: Message): int = cmp(b.at, a.at))
147+
148+func afterMarker*(ch: Room): seq[Message] =
149+ ## The messages the reader has not seen: everything after the read marker.
150+ ##
151+ ## By id where the marked message is still held, and by time otherwise. The
152+ ## id is the exact answer — a msgid survives every revision, so it names the
153+ ## same line however often the server replays it — and the timestamp is what
154+ ## answers when the marked line has fallen off the end of the buffer or was
155+ ## never in this run's copy of it.
156+ if ch.lastReadId.len > 0:
157+ for i, m in ch.messages:
158+ if m.id == ch.lastReadId:
159+ return if i + 1 <= ch.messages.high: ch.messages[i + 1 .. ^1] else: @[]
160+ ch.messages.filterIt(it.at > ch.lastReadAt)
161+
162+func mentionsMe*(m: Message, me: string): bool =
163+ ## Whether a line is addressed at the reader by name. Our own lines do not
164+ ## count — saying your own nick is not being called.
165+ let me = me.strip()
166+ me.len > 0 and m.frm != me and m.text.toLowerAscii.contains(me.toLowerAscii)
167+
168+func recount*(ch: Room, me: string): Room =
169+ ## Answer what the marker says: how many lines are unseen, and whether any
170+ ## of them names the reader.
171+ ##
172+ ## Joins, parts and "Joined #room" are the room talking about itself, not
173+ ## somebody talking in it. They arrive stamped now, so counted, every room
174+ ## you are a member of sits at one unread from the moment it opens, saying
175+ ## only that you joined it. The marker still moves past them: they are read,
176+ ## they are just never what made a room worth looking at.
177+ result = ch
178+ let fresh = ch.afterMarker.filterIt(not it.system)
179+ result.unread = fresh.len
180+ result.mention = fresh.anyIt(it.mentionsMe(me))
181+
182+func markRead*(ch: Room): Room =
183+ ## Move the marker to the newest line this buffer holds. Both halves: the id
184+ ## for as long as that line is here, and its time for after it is gone.
185+ ##
186+ ## The time only ever goes forward. A backlog can arrive after the reader
187+ ## has already read past it, and taking the last line's time unconditionally
188+ ## would walk the marker backwards and re-unread what was read.
189+ result = ch
190+ result.unread = 0
191+ result.mention = false
192+ if ch.messages.len > 0:
193+ let newest = ch.messages[^1]
194+ result.lastReadId = newest.id
195+ result.lastReadAt = max(ch.lastReadAt, newest.at)
196+
197+proc ensureRoom*(channels: var OrderedTable[string, Room], name: string) =
198+ ## Make sure a buffer exists, started caught up rather than at the
199+ ## beginning — see `freshRoomGraceMs`.
200+ if channels.hasKey(name): return
201+ var ch = initRoom(name)
202+ ch.lastReadAt = max(0'i64, nowMs() - freshRoomGraceMs)
203+ channels[name] = ch
added nim/src/frq/ui.nim +92 -0
new file mode 100644
@@ -0,0 +1,92 @@
1+## The widget tree Nim hands Dart, and the little DSL for building one.
2+##
3+## The shape is the hiccup the ClojureDart screens already produce — a tag, a
4+## props table, children — because the vocabulary is the part worth keeping.
5+## `frq.hiccup` interprets exactly these tags into Flutter widgets today, so a
6+## tree emitted here and a tree emitted there describe the same screen, and
7+## the renderer on the Dart side is the same idea rewritten rather than a new
8+## one invented.
9+##
10+## The one real difference is callbacks. In Clojure a prop holds a closure;
11+## across a C ABI it cannot, so `onClick` holds an **event id** instead — an
12+## opaque string Dart sends back to `dispatch`. That is what turns this from a
13+## rendering trick into an architecture: Nim owns the state, Dart owns the
14+## pixels, and the only things crossing are a tree going out and an event id
15+## coming back.
16+
17+import std/json
18+
19+type
20+ Node* = ref object
21+ ## A widget. `props` is deliberately untyped-ish — a JsonNode — because
22+ ## the tags disagree about what they take and a variant per tag would be
23+ ## a second place to edit every time one gains a property.
24+ tag*: string
25+ props*: JsonNode
26+ children*: seq[Node]
27+
28+func n*(tag: string, props: JsonNode = nil, children: seq[Node] = @[]): Node =
29+ ## The constructor everything uses. `n"vbox"` reads closely enough to
30+ ## `[:vbox ...]` that a screen transcribed from the Clojure stays legible
31+ ## beside it.
32+ Node(tag: tag, props: if props.isNil: newJObject() else: props, children: children)
33+
34+func toJson*(node: Node): JsonNode =
35+ if node.isNil: return newJNull()
36+ result = newJObject()
37+ result["tag"] = %node.tag
38+ result["props"] = node.props
39+ if node.children.len > 0:
40+ var kids = newJArray()
41+ for c in node.children:
42+ if not c.isNil:
43+ kids.add c.toJson
44+ result["children"] = kids
45+
46+# --------------------------------------------------------------- shorthands
47+#
48+# Props are written as `%*{...}` at the call sites, which is Nim's JSON
49+# literal. It is noisier than Clojure's map but it is checked: a typo in a key
50+# is still a typo, but a typo in the *shape* — a string where a number goes —
51+# fails at the boundary rather than three layers into Flutter.
52+
53+func vbox*(props: JsonNode, children: varargs[Node]): Node =
54+ n("vbox", props, @children)
55+func hbox*(props: JsonNode, children: varargs[Node]): Node =
56+ n("hbox", props, @children)
57+func card*(children: varargs[Node]): Node =
58+ n("card", newJObject(), @children)
59+func page*(props: JsonNode, children: varargs[Node]): Node =
60+ n("page", props, @children)
61+
62+func label*(text: string): Node = n("label", %*{"label": text})
63+func dimLabel*(text: string): Node = n("dim-label", %*{"label": text})
64+func title*(text: string): Node = n("title", %*{"label": text})
65+func title2*(text: string): Node = n("title-2", %*{"label": text})
66+func spinner*(): Node = n("spinner")
67+
68+func button*(text: string, onClick: string, kind = "default"): Node =
69+ ## `onClick` is an event id, not a closure. See the module comment.
70+ n("button", %*{"label": text, "kind": kind, "onClick": onClick})
71+
72+func entry*(key, text, placeholder, onChange: string, width = 0,
73+ onSubmit = ""): Node =
74+ ## Every entry carries a key, and for the reason the Clojure's comment
75+ ## gives: a renderer that keeps a text controller per field needs a stable
76+ ## name for it, and without one the host and the port shared a controller
77+ ## and both showed the port.
78+ var p = %*{"key": key, "text": text, "placeholder": placeholder,
79+ "onChange": onChange}
80+ if width > 0: p["widthRequest"] = %width
81+ # Enter, where the field has something to do with it. A compose box that
82+ # only sends on a button click is one nobody can type into at speed.
83+ if onSubmit.len > 0: p["onSubmit"] = %onSubmit
84+ n("entry", p)
85+
86+func checkbutton*(text: string, active: bool, onToggled: string): Node =
87+ n("checkbutton", %*{"label": text, "active": active, "onToggled": onToggled})
88+
89+func scroll*(props: JsonNode, children: varargs[Node]): Node =
90+ ## A list that is taller than the room it has. The renderer decides how that
91+ ## is done; the tree only says that it is expected.
92+ n("scroll", props, @children)
new file mode 100644
@@ -0,0 +1,92 @@
1+## The widget tree Nim hands Dart, and the little DSL for building one.
2+##
3+## The shape is the hiccup the ClojureDart screens already produce — a tag, a
4+## props table, children — because the vocabulary is the part worth keeping.
5+## `frq.hiccup` interprets exactly these tags into Flutter widgets today, so a
6+## tree emitted here and a tree emitted there describe the same screen, and
7+## the renderer on the Dart side is the same idea rewritten rather than a new
8+## one invented.
9+##
10+## The one real difference is callbacks. In Clojure a prop holds a closure;
11+## across a C ABI it cannot, so `onClick` holds an **event id** instead — an
12+## opaque string Dart sends back to `dispatch`. That is what turns this from a
13+## rendering trick into an architecture: Nim owns the state, Dart owns the
14+## pixels, and the only things crossing are a tree going out and an event id
15+## coming back.
16+
17+import std/json
18+
19+type
20+ Node* = ref object
21+ ## A widget. `props` is deliberately untyped-ish — a JsonNode — because
22+ ## the tags disagree about what they take and a variant per tag would be
23+ ## a second place to edit every time one gains a property.
24+ tag*: string
25+ props*: JsonNode
26+ children*: seq[Node]
27+
28+func n*(tag: string, props: JsonNode = nil, children: seq[Node] = @[]): Node =
29+ ## The constructor everything uses. `n"vbox"` reads closely enough to
30+ ## `[:vbox ...]` that a screen transcribed from the Clojure stays legible
31+ ## beside it.
32+ Node(tag: tag, props: if props.isNil: newJObject() else: props, children: children)
33+
34+func toJson*(node: Node): JsonNode =
35+ if node.isNil: return newJNull()
36+ result = newJObject()
37+ result["tag"] = %node.tag
38+ result["props"] = node.props
39+ if node.children.len > 0:
40+ var kids = newJArray()
41+ for c in node.children:
42+ if not c.isNil:
43+ kids.add c.toJson
44+ result["children"] = kids
45+
46+# --------------------------------------------------------------- shorthands
47+#
48+# Props are written as `%*{...}` at the call sites, which is Nim's JSON
49+# literal. It is noisier than Clojure's map but it is checked: a typo in a key
50+# is still a typo, but a typo in the *shape* — a string where a number goes —
51+# fails at the boundary rather than three layers into Flutter.
52+
53+func vbox*(props: JsonNode, children: varargs[Node]): Node =
54+ n("vbox", props, @children)
55+func hbox*(props: JsonNode, children: varargs[Node]): Node =
56+ n("hbox", props, @children)
57+func card*(children: varargs[Node]): Node =
58+ n("card", newJObject(), @children)
59+func page*(props: JsonNode, children: varargs[Node]): Node =
60+ n("page", props, @children)
61+
62+func label*(text: string): Node = n("label", %*{"label": text})
63+func dimLabel*(text: string): Node = n("dim-label", %*{"label": text})
64+func title*(text: string): Node = n("title", %*{"label": text})
65+func title2*(text: string): Node = n("title-2", %*{"label": text})
66+func spinner*(): Node = n("spinner")
67+
68+func button*(text: string, onClick: string, kind = "default"): Node =
69+ ## `onClick` is an event id, not a closure. See the module comment.
70+ n("button", %*{"label": text, "kind": kind, "onClick": onClick})
71+
72+func entry*(key, text, placeholder, onChange: string, width = 0,
73+ onSubmit = ""): Node =
74+ ## Every entry carries a key, and for the reason the Clojure's comment
75+ ## gives: a renderer that keeps a text controller per field needs a stable
76+ ## name for it, and without one the host and the port shared a controller
77+ ## and both showed the port.
78+ var p = %*{"key": key, "text": text, "placeholder": placeholder,
79+ "onChange": onChange}
80+ if width > 0: p["widthRequest"] = %width
81+ # Enter, where the field has something to do with it. A compose box that
82+ # only sends on a button click is one nobody can type into at speed.
83+ if onSubmit.len > 0: p["onSubmit"] = %onSubmit
84+ n("entry", p)
85+
86+func checkbutton*(text: string, active: bool, onToggled: string): Node =
87+ n("checkbutton", %*{"label": text, "active": active, "onToggled": onToggled})
88+
89+func scroll*(props: JsonNode, children: varargs[Node]): Node =
90+ ## A list that is taller than the room it has. The renderer decides how that
91+ ## is done; the tree only says that it is expected.
92+ n("scroll", props, @children)
added nim/tests/tclock.nim +57 -0
new file mode 100644
@@ -0,0 +1,57 @@
1+## The date arithmetic, against the cases that are easy to get wrong.
2+
3+import std/unittest
4+import frq/clock
5+
6+suite "the civil-date round trip":
7+ test "the epoch":
8+ check daysFromCivil(1970, 1, 1) == 0
9+ check civilFromDays(0) == (1970'i64, 1'i64, 1'i64)
10+
11+ test "round-trips every day from 1901 to 2052":
12+ # The same range the Clojure was checked against java.time.LocalDate over.
13+ var d = daysFromCivil(1901, 1, 1)
14+ let last = daysFromCivil(2052, 12, 31)
15+ while d <= last:
16+ let (y, m, dd) = civilFromDays(d)
17+ check daysFromCivil(y, m, dd) == d
18+ d += 1
19+
20+ test "leap days exist and non-leap ones do not":
21+ check civilFromDays(daysFromCivil(2024, 2, 29)) == (2024'i64, 2'i64, 29'i64)
22+ # 2100 is not a leap year; Feb 29 there rolls into March.
23+ check civilFromDays(daysFromCivil(2100, 2, 29)) == (2100'i64, 3'i64, 1'i64)
24+
25+ test "before the epoch, where rounding toward zero would be a day out":
26+ check civilFromDays(daysFromCivil(1969, 12, 31)) == (1969'i64, 12'i64, 31'i64)
27+ check daysFromCivil(1969, 12, 31) == -1
28+
29+suite "floorDiv":
30+ test "rounds down rather than toward zero":
31+ check floorDiv(-1, 86400) == -1
32+ check floorDiv(7, 2) == 3
33+ check floorDiv(-7, 2) == -4
34+ test "floorMod is never negative for a positive divisor":
35+ check floorMod(-1, 86400) == 86399
36+
37+suite "parseTimeTag":
38+ # The expected values are python's `datetime(...).timestamp()`, not arithmetic
39+ # done by hand — the first draft of this test was twelve days out and the
40+ # implementation was right.
41+ test "a real tag":
42+ let (ms, ok) = parseTimeTag("time=2026-08-30T07:05:09.000Z")
43+ check ok
44+ check ms == 1788073509000'i64
45+
46+ test "finds it among other tags":
47+ let (ms, ok) = parseTimeTag("account=alice;time=2026-08-30T07:05:09.000Z;msgid=x")
48+ check ok
49+ check ms == 1788073509000'i64
50+
51+ test "no tag at all":
52+ check not parseTimeTag("account=alice")[1]
53+ check not parseTimeTag("")[1]
54+
55+ test "a malformed value is refused rather than guessed at":
56+ check not parseTimeTag("time=not-a-time")[1]
57+ check not parseTimeTag("time=2026-08-30")[1]
new file mode 100644
@@ -0,0 +1,57 @@
1+## The date arithmetic, against the cases that are easy to get wrong.
2+
3+import std/unittest
4+import frq/clock
5+
6+suite "the civil-date round trip":
7+ test "the epoch":
8+ check daysFromCivil(1970, 1, 1) == 0
9+ check civilFromDays(0) == (1970'i64, 1'i64, 1'i64)
10+
11+ test "round-trips every day from 1901 to 2052":
12+ # The same range the Clojure was checked against java.time.LocalDate over.
13+ var d = daysFromCivil(1901, 1, 1)
14+ let last = daysFromCivil(2052, 12, 31)
15+ while d <= last:
16+ let (y, m, dd) = civilFromDays(d)
17+ check daysFromCivil(y, m, dd) == d
18+ d += 1
19+
20+ test "leap days exist and non-leap ones do not":
21+ check civilFromDays(daysFromCivil(2024, 2, 29)) == (2024'i64, 2'i64, 29'i64)
22+ # 2100 is not a leap year; Feb 29 there rolls into March.
23+ check civilFromDays(daysFromCivil(2100, 2, 29)) == (2100'i64, 3'i64, 1'i64)
24+
25+ test "before the epoch, where rounding toward zero would be a day out":
26+ check civilFromDays(daysFromCivil(1969, 12, 31)) == (1969'i64, 12'i64, 31'i64)
27+ check daysFromCivil(1969, 12, 31) == -1
28+
29+suite "floorDiv":
30+ test "rounds down rather than toward zero":
31+ check floorDiv(-1, 86400) == -1
32+ check floorDiv(7, 2) == 3
33+ check floorDiv(-7, 2) == -4
34+ test "floorMod is never negative for a positive divisor":
35+ check floorMod(-1, 86400) == 86399
36+
37+suite "parseTimeTag":
38+ # The expected values are python's `datetime(...).timestamp()`, not arithmetic
39+ # done by hand — the first draft of this test was twelve days out and the
40+ # implementation was right.
41+ test "a real tag":
42+ let (ms, ok) = parseTimeTag("time=2026-08-30T07:05:09.000Z")
43+ check ok
44+ check ms == 1788073509000'i64
45+
46+ test "finds it among other tags":
47+ let (ms, ok) = parseTimeTag("account=alice;time=2026-08-30T07:05:09.000Z;msgid=x")
48+ check ok
49+ check ms == 1788073509000'i64
50+
51+ test "no tag at all":
52+ check not parseTimeTag("account=alice")[1]
53+ check not parseTimeTag("")[1]
54+
55+ test "a malformed value is refused rather than guessed at":
56+ check not parseTimeTag("time=not-a-time")[1]
57+ check not parseTimeTag("time=2026-08-30")[1]
added nim/tests/tmodel.nim +55 -0
new file mode 100644
@@ -0,0 +1,55 @@
1+## The naming rules, which are the fiddly part of the model.
2+
3+import std/[options, unittest]
4+import frq/model
5+
6+suite "dm":
7+ test "a channel is not a DM":
8+ check not dm("#test")
9+ test "a nick is":
10+ check dm("alice")
11+ test "an empty name is neither":
12+ check not dm("")
13+
14+suite "rowId":
15+ test "the server's name wins":
16+ check rowId(Message(id: "srv", localId: "loc")) == "srv"
17+ test "the local name stands in until there is one":
18+ check rowId(Message(localId: "loc")) == "loc"
19+ test "a line with neither has no name":
20+ check rowId(Message()) == ""
21+
22+suite "answersTo":
23+ setup:
24+ let m = Message(id: "b", localId: "a", editIds: @["c", "d"])
25+
26+ test "the current msgid":
27+ check m.answersTo("b")
28+ test "the local id, for a line not yet echoed":
29+ check m.answersTo("a")
30+ test "any msgid a revision of it has worn":
31+ check m.answersTo("c")
32+ check m.answersTo("d")
33+ test "not somebody else's":
34+ check not m.answersTo("z")
35+ test "an empty id names nothing":
36+ # Otherwise every line with no msgid answers to every lookup that has
37+ # none either, and a reply chip points at an arbitrary message.
38+ check not m.answersTo("")
39+ check not Message().answersTo("")
40+
41+suite "messageById":
42+ setup:
43+ var ch = initRoom("#test")
44+ ch.messages = @[Message(id: "1", text: "one"),
45+ Message(id: "2", text: "two", editIds: @["2b"])]
46+
47+ test "finds by msgid":
48+ check ch.messageById("1").get.text == "one"
49+ test "finds a revision by the name it wore":
50+ check ch.messageById("2b").get.text == "two"
51+ test "answers with nothing for a line it does not hold":
52+ check ch.messageById("9").isNone
53+ test "indexById agrees with it":
54+ check ch.indexById("2") == 1
55+ check ch.indexById("9") == -1
new file mode 100644
@@ -0,0 +1,55 @@
1+## The naming rules, which are the fiddly part of the model.
2+
3+import std/[options, unittest]
4+import frq/model
5+
6+suite "dm":
7+ test "a channel is not a DM":
8+ check not dm("#test")
9+ test "a nick is":
10+ check dm("alice")
11+ test "an empty name is neither":
12+ check not dm("")
13+
14+suite "rowId":
15+ test "the server's name wins":
16+ check rowId(Message(id: "srv", localId: "loc")) == "srv"
17+ test "the local name stands in until there is one":
18+ check rowId(Message(localId: "loc")) == "loc"
19+ test "a line with neither has no name":
20+ check rowId(Message()) == ""
21+
22+suite "answersTo":
23+ setup:
24+ let m = Message(id: "b", localId: "a", editIds: @["c", "d"])
25+
26+ test "the current msgid":
27+ check m.answersTo("b")
28+ test "the local id, for a line not yet echoed":
29+ check m.answersTo("a")
30+ test "any msgid a revision of it has worn":
31+ check m.answersTo("c")
32+ check m.answersTo("d")
33+ test "not somebody else's":
34+ check not m.answersTo("z")
35+ test "an empty id names nothing":
36+ # Otherwise every line with no msgid answers to every lookup that has
37+ # none either, and a reply chip points at an arbitrary message.
38+ check not m.answersTo("")
39+ check not Message().answersTo("")
40+
41+suite "messageById":
42+ setup:
43+ var ch = initRoom("#test")
44+ ch.messages = @[Message(id: "1", text: "one"),
45+ Message(id: "2", text: "two", editIds: @["2b"])]
46+
47+ test "finds by msgid":
48+ check ch.messageById("1").get.text == "one"
49+ test "finds a revision by the name it wore":
50+ check ch.messageById("2b").get.text == "two"
51+ test "answers with nothing for a line it does not hold":
52+ check ch.messageById("9").isNone
53+ test "indexById agrees with it":
54+ check ch.indexById("2") == 1
55+ check ch.indexById("9") == -1
added nim/tests/trooms.nim +148 -0
new file mode 100644
@@ -0,0 +1,148 @@
1+## The list, the overview and the read marker.
2+import std/algorithm
3+import std/strutils
4+##
5+## The marker rules are the ones worth the most here: unread is derived rather
6+## than counted precisely so a replayed backlog cannot inflate it, and these
7+## are the cases that prove it.
8+
9+import std/[sequtils, tables, unittest]
10+import frq/[model, rooms]
11+
12+proc msg(frm, text: string, at: int64 = 0, id = "", system = false): Message =
13+ Message(frm: frm, text: text, at: at, id: id, system: system)
14+
15+suite "channelList":
16+ setup:
17+ var chans = initOrderedTable[string, Room]()
18+ for (n, acc) in [("#alpha", 0'i64), ("#beta", 5'i64), ("#gamma", 9'i64)]:
19+ var c = initRoom(n); c.accessed = acc; chans[n] = c
20+
21+ test "most recently opened first":
22+ check channelList(chans, "").mapIt(it.name) == @["#gamma", "#beta", "#alpha"]
23+
24+ test "never-opened buffers sort under those, by name":
25+ chans["#aaa"] = initRoom("#aaa")
26+ chans["#zzz"] = initRoom("#zzz")
27+ let names = channelList(chans, "").mapIt(it.name)
28+ check names == @["#gamma", "#beta", "#aaa", "#alpha", "#zzz"]
29+
30+ test "the search box filters, case-insensitively":
31+ check channelList(chans, "BET").mapIt(it.name) == @["#beta"]
32+ test "a blank search filters nothing":
33+ check channelList(chans, " ").len == 3
34+
35+suite "seenMessage":
36+ test "a msgid we already hold is a replay":
37+ let msgs = @[msg("a", "hi", id = "1")]
38+ check seenMessage(msgs, "1", "a", "hi", "me")
39+ check not seenMessage(msgs, "2", "a", "hi", "me")
40+
41+ test "an untagged line is known by its sender and words":
42+ # A replayed line with no tags has no identity, so left alone it arrives
43+ # new on every rejoin and the room can never be finished reading.
44+ let msgs = @[msg("alice", "hello")]
45+ check seenMessage(msgs, "", "alice", "hello", "me")
46+ check not seenMessage(msgs, "", "alice", "different", "me")
47+
48+ test "our own untagged line is never a replay":
49+ # A second "ok" from this client is a real event.
50+ let msgs = @[msg("me", "ok")]
51+ check not seenMessage(msgs, "", "me", "ok", "me")
52+
53+ test "nor is the system's":
54+ let msgs = @[msg("*", "alice joined")]
55+ check not seenMessage(msgs, "", "*", "alice joined", "me")
56+
57+suite "the read marker":
58+ setup:
59+ var ch = initRoom("#test")
60+ ch.messages = @[msg("a", "one", at = 100, id = "1"),
61+ msg("b", "two", at = 200, id = "2"),
62+ msg("c", "three", at = 300, id = "3")]
63+
64+ test "everything after the marked id is unread":
65+ ch.lastReadId = "1"
66+ check ch.afterMarker.mapIt(it.text) == @["two", "three"]
67+
68+ test "by time when the marked line is no longer held":
69+ ch.lastReadId = "gone"
70+ ch.lastReadAt = 150
71+ check ch.afterMarker.mapIt(it.text) == @["two", "three"]
72+
73+ test "a replayed backlog cannot inflate the count":
74+ # The whole reason unread is derived rather than counted. Two guards act
75+ # together and this checks the pair, because either alone is not enough:
76+ #
77+ # seenMessage keeps the replayed line out of the buffer, and
78+ # afterMarker means a line that IS older than the marker counts nothing.
79+ #
80+ # The first draft of this test appended the backlog twice and expected
81+ # zero, which is a state seenMessage exists to make impossible — and the
82+ # Clojure answers three to it as well. A test for an unreachable state
83+ # tells you nothing about the reachable ones.
84+ let marked = ch.markRead
85+ check marked.recount("me").unread == 0
86+ for m in ch.messages:
87+ check seenMessage(marked.messages, m.id, m.frm, m.text, "me")
88+
89+ test "a line older than the marker counts for nothing":
90+ ch.lastReadId = ""
91+ ch.lastReadAt = 250
92+ check ch.afterMarker.mapIt(it.text) == @["three"]
93+
94+ test "markRead never walks the marker backwards":
95+ ch.lastReadAt = 500 # read past a backlog that then arrived
96+ let marked = ch.markRead
97+ check marked.lastReadAt == 500
98+
99+ test "system lines are read but never make a room worth looking at":
100+ ch.messages.add msg("*", "you joined", at = 400, system = true)
101+ check ch.recount("me").unread == 4 - 1 # the three said lines only...
102+ ch.lastReadId = "3"
103+ check ch.recount("me").unread == 0
104+
105+ test "a mention is noticed, and only somebody else's":
106+ ch.messages = @[msg("alice", "hey me, look", at = 100),
107+ msg("me", "me me me", at = 200)]
108+ let r = ch.recount("me")
109+ check r.unread == 2
110+ check r.mention
111+
112+ test "no mention where the reader is not named":
113+ ch.messages = @[msg("alice", "nothing here", at = 100)]
114+ check not ch.recount("me").mention
115+
116+suite "recentEverywhere":
117+ setup:
118+ var chans = initOrderedTable[string, Room]()
119+ for n in ["#a", "#b"]:
120+ var c = initRoom(n)
121+ for i in 1 .. 3:
122+ c.messages.add msg("u", n & $i, at = i.int64 * 10)
123+ chans[n] = c
124+
125+ test "the room being read is left out":
126+ let names = recentEverywhere(chans, "#a").mapIt(it.text)
127+ check names.allIt(it.startsWith("#b"))
128+
129+ test "system lines are left out":
130+ var c = initRoom("#c")
131+ c.messages = @[msg("*", "joined", at = 99, system = true)]
132+ chans["#c"] = c
133+ check recentEverywhere(chans, "").allIt(it.text != "joined")
134+
135+ test "newest first":
136+ let ats = recentEverywhere(chans, "").mapIt(it.at)
137+ check ats == ats.sorted(SortOrder.Descending)
138+
139+ test "a turn each, so a busy room cannot crowd a quiet one out":
140+ # The reason for round-robin: taking the newest N outright would answer
141+ # about whichever room is busiest, which is the one you can already see.
142+ var busy = initRoom("#busy")
143+ for i in 1 .. 200:
144+ busy.messages.add msg("u", "spam" & $i, at = i.int64)
145+ chans["#busy"] = busy
146+ let got = recentEverywhere(chans, "")
147+ check got.anyIt(it.text.startsWith("#a"))
148+ check got.len <= overviewLimit
new file mode 100644
@@ -0,0 +1,148 @@
1+## The list, the overview and the read marker.
2+import std/algorithm
3+import std/strutils
4+##
5+## The marker rules are the ones worth the most here: unread is derived rather
6+## than counted precisely so a replayed backlog cannot inflate it, and these
7+## are the cases that prove it.
8+
9+import std/[sequtils, tables, unittest]
10+import frq/[model, rooms]
11+
12+proc msg(frm, text: string, at: int64 = 0, id = "", system = false): Message =
13+ Message(frm: frm, text: text, at: at, id: id, system: system)
14+
15+suite "channelList":
16+ setup:
17+ var chans = initOrderedTable[string, Room]()
18+ for (n, acc) in [("#alpha", 0'i64), ("#beta", 5'i64), ("#gamma", 9'i64)]:
19+ var c = initRoom(n); c.accessed = acc; chans[n] = c
20+
21+ test "most recently opened first":
22+ check channelList(chans, "").mapIt(it.name) == @["#gamma", "#beta", "#alpha"]
23+
24+ test "never-opened buffers sort under those, by name":
25+ chans["#aaa"] = initRoom("#aaa")
26+ chans["#zzz"] = initRoom("#zzz")
27+ let names = channelList(chans, "").mapIt(it.name)
28+ check names == @["#gamma", "#beta", "#aaa", "#alpha", "#zzz"]
29+
30+ test "the search box filters, case-insensitively":
31+ check channelList(chans, "BET").mapIt(it.name) == @["#beta"]
32+ test "a blank search filters nothing":
33+ check channelList(chans, " ").len == 3
34+
35+suite "seenMessage":
36+ test "a msgid we already hold is a replay":
37+ let msgs = @[msg("a", "hi", id = "1")]
38+ check seenMessage(msgs, "1", "a", "hi", "me")
39+ check not seenMessage(msgs, "2", "a", "hi", "me")
40+
41+ test "an untagged line is known by its sender and words":
42+ # A replayed line with no tags has no identity, so left alone it arrives
43+ # new on every rejoin and the room can never be finished reading.
44+ let msgs = @[msg("alice", "hello")]
45+ check seenMessage(msgs, "", "alice", "hello", "me")
46+ check not seenMessage(msgs, "", "alice", "different", "me")
47+
48+ test "our own untagged line is never a replay":
49+ # A second "ok" from this client is a real event.
50+ let msgs = @[msg("me", "ok")]
51+ check not seenMessage(msgs, "", "me", "ok", "me")
52+
53+ test "nor is the system's":
54+ let msgs = @[msg("*", "alice joined")]
55+ check not seenMessage(msgs, "", "*", "alice joined", "me")
56+
57+suite "the read marker":
58+ setup:
59+ var ch = initRoom("#test")
60+ ch.messages = @[msg("a", "one", at = 100, id = "1"),
61+ msg("b", "two", at = 200, id = "2"),
62+ msg("c", "three", at = 300, id = "3")]
63+
64+ test "everything after the marked id is unread":
65+ ch.lastReadId = "1"
66+ check ch.afterMarker.mapIt(it.text) == @["two", "three"]
67+
68+ test "by time when the marked line is no longer held":
69+ ch.lastReadId = "gone"
70+ ch.lastReadAt = 150
71+ check ch.afterMarker.mapIt(it.text) == @["two", "three"]
72+
73+ test "a replayed backlog cannot inflate the count":
74+ # The whole reason unread is derived rather than counted. Two guards act
75+ # together and this checks the pair, because either alone is not enough:
76+ #
77+ # seenMessage keeps the replayed line out of the buffer, and
78+ # afterMarker means a line that IS older than the marker counts nothing.
79+ #
80+ # The first draft of this test appended the backlog twice and expected
81+ # zero, which is a state seenMessage exists to make impossible — and the
82+ # Clojure answers three to it as well. A test for an unreachable state
83+ # tells you nothing about the reachable ones.
84+ let marked = ch.markRead
85+ check marked.recount("me").unread == 0
86+ for m in ch.messages:
87+ check seenMessage(marked.messages, m.id, m.frm, m.text, "me")
88+
89+ test "a line older than the marker counts for nothing":
90+ ch.lastReadId = ""
91+ ch.lastReadAt = 250
92+ check ch.afterMarker.mapIt(it.text) == @["three"]
93+
94+ test "markRead never walks the marker backwards":
95+ ch.lastReadAt = 500 # read past a backlog that then arrived
96+ let marked = ch.markRead
97+ check marked.lastReadAt == 500
98+
99+ test "system lines are read but never make a room worth looking at":
100+ ch.messages.add msg("*", "you joined", at = 400, system = true)
101+ check ch.recount("me").unread == 4 - 1 # the three said lines only...
102+ ch.lastReadId = "3"
103+ check ch.recount("me").unread == 0
104+
105+ test "a mention is noticed, and only somebody else's":
106+ ch.messages = @[msg("alice", "hey me, look", at = 100),
107+ msg("me", "me me me", at = 200)]
108+ let r = ch.recount("me")
109+ check r.unread == 2
110+ check r.mention
111+
112+ test "no mention where the reader is not named":
113+ ch.messages = @[msg("alice", "nothing here", at = 100)]
114+ check not ch.recount("me").mention
115+
116+suite "recentEverywhere":
117+ setup:
118+ var chans = initOrderedTable[string, Room]()
119+ for n in ["#a", "#b"]:
120+ var c = initRoom(n)
121+ for i in 1 .. 3:
122+ c.messages.add msg("u", n & $i, at = i.int64 * 10)
123+ chans[n] = c
124+
125+ test "the room being read is left out":
126+ let names = recentEverywhere(chans, "#a").mapIt(it.text)
127+ check names.allIt(it.startsWith("#b"))
128+
129+ test "system lines are left out":
130+ var c = initRoom("#c")
131+ c.messages = @[msg("*", "joined", at = 99, system = true)]
132+ chans["#c"] = c
133+ check recentEverywhere(chans, "").allIt(it.text != "joined")
134+
135+ test "newest first":
136+ let ats = recentEverywhere(chans, "").mapIt(it.at)
137+ check ats == ats.sorted(SortOrder.Descending)
138+
139+ test "a turn each, so a busy room cannot crowd a quiet one out":
140+ # The reason for round-robin: taking the newest N outright would answer
141+ # about whichever room is busiest, which is the one you can already see.
142+ var busy = initRoom("#busy")
143+ for i in 1 .. 200:
144+ busy.messages.add msg("u", "spam" & $i, at = i.int64)
145+ chans["#busy"] = busy
146+ let got = recentEverywhere(chans, "")
147+ check got.anyIt(it.text.startsWith("#a"))
148+ check got.len <= overviewLimit