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>
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 | ||