nandi/frqpublic Fork
⑂ b7df73e
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

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

Download ZIP

Add separate direct messages tab

nandi committed 2026-09-22T10:11:34-07:00 Browse files
b7df73e parent: 31683ce
modified flutter/test/nim_layout_test.dart +2 -2
@@ -168,7 +168,7 @@ void main() {
168168 });
169169
170170 group('discover and settings', () {
171- for (final screen in ['discover', 'settings']) {
171+ for (final screen in ['dms', 'discover', 'settings']) {
172172 for (final entry in sizes.entries) {
173173 testWidgets('$screen lays out at ${entry.key}', (tester) async {
174174 core.demoUi();
@@ -194,7 +194,7 @@ void main() {
194194 await tester.pump();
195195 }
196196
197- for (final screen in ['chat', 'chats', 'discover', 'settings']) {
197+ for (final screen in ['chat', 'chats', 'dms', 'discover', 'settings']) {
198198 // The platform has to be said out loud. A widget test runs as Android
199199 // by default, where a SingleChildScrollView *is* primary and attaches
200200 // to the very controller the scrollbar is looking at — so this passed
@@ -168,7 +168,7 @@ void main() {
168 });168 });
169 169
170 group('discover and settings', () {170 group('discover and settings', () {
171- for (final screen in ['discover', 'settings']) {171+ for (final screen in ['dms', 'discover', 'settings']) {
172 for (final entry in sizes.entries) {172 for (final entry in sizes.entries) {
173 testWidgets('$screen lays out at ${entry.key}', (tester) async {173 testWidgets('$screen lays out at ${entry.key}', (tester) async {
174 core.demoUi();174 core.demoUi();
@@ -194,7 +194,7 @@ void main() {
194 await tester.pump();194 await tester.pump();
195 }195 }
196 196
197- for (final screen in ['chat', 'chats', 'discover', 'settings']) {197+ for (final screen in ['chat', 'chats', 'dms', 'discover', 'settings']) {
198 // The platform has to be said out loud. A widget test runs as Android198 // The platform has to be said out loud. A widget test runs as Android
199 // by default, where a SingleChildScrollView *is* primary and attaches199 // by default, where a SingleChildScrollView *is* primary and attaches
200 // to the very controller the scrollbar is looking at — so this passed200 // to the very controller the scrollbar is looking at — so this passed
modified nim/src/frq/cells.nim +1 -1
@@ -16,7 +16,7 @@ import frq/[model]
1616 type
1717 Screen* = enum
1818 scConnect = "connect", scChats = "chats", scChat = "chat",
19- scDiscover = "discover", scSettings = "settings"
19+ scDms = "dms", scDiscover = "discover", scSettings = "settings"
2020
2121 AuthMode* = enum
2222 amGuest = "guest", amBluesky = "bluesky", amAppPassword = "app-password"
@@ -16,7 +16,7 @@ import frq/[model]
16 type16 type
17 Screen* = enum17 Screen* = enum
18 scConnect = "connect", scChats = "chats", scChat = "chat",18 scConnect = "connect", scChats = "chats", scChat = "chat",
19- scDiscover = "discover", scSettings = "settings"19+ scDms = "dms", scDiscover = "discover", scSettings = "settings"
20 20
21 AuthMode* = enum21 AuthMode* = enum
22 amGuest = "guest", amBluesky = "bluesky", amAppPassword = "app-password"22 amGuest = "guest", amBluesky = "bluesky", amAppPassword = "app-password"
modified nim/src/frq/reducer.nim +1 -0
@@ -496,6 +496,7 @@ proc dispatch*(event: JsonNode) =
496496 # ------------------------------------------------------------- navigation
497497 of "screen.connect": app.screen = scConnect
498498 of "screen.chats": app.screen = scChats
499+ of "screen.dms": app.screen = scDms
499500 of "screen.discover": app.screen = scDiscover
500501 of "screen.settings": app.screen = scSettings
501502
@@ -496,6 +496,7 @@ proc dispatch*(event: JsonNode) =
496 # ------------------------------------------------------------- navigation496 # ------------------------------------------------------------- navigation
497 of "screen.connect": app.screen = scConnect497 of "screen.connect": app.screen = scConnect
498 of "screen.chats": app.screen = scChats498 of "screen.chats": app.screen = scChats
499+ of "screen.dms": app.screen = scDms
499 of "screen.discover": app.screen = scDiscover500 of "screen.discover": app.screen = scDiscover
500 of "screen.settings": app.screen = scSettings501 of "screen.settings": app.screen = scSettings
501 502
modified nim/src/frq/screens/chats.nim +21 -8
@@ -4,7 +4,7 @@
44 ## dropped rather than translated — there is no terminal frontend any more, so
55 ## `conversation-row`'s two layouts collapse to the one a window uses.
66
7-import std/[json, strutils]
7+import std/[json, sequtils, strutils]
88 import frq/[ui, cells, model, rooms, textruns]
99 import frq/screens/[frame, connect]
1010
@@ -42,24 +42,30 @@ func conversationRow(r: Room): Node =
4242 button("Open", "room.open:" & r.name),
4343 button("Close", "room.leave:" & r.name)))])
4444
45-func chatsScreen*(s: State, connected: bool): Node =
46- let buffers = channelList(s.rooms, s.search)
45+func conversationListScreen(s: State, connected, direct: bool): Node =
46+ ## The two kinds of conversation have their own tabs, but share the cards
47+ ## and controls. A DM is identified by its name rather than by a second
48+ ## collection in state, so it remains visible after reconnecting like rooms.
49+ let buffers = channelList(s.rooms, s.search).filterIt(dm(it.name) == direct)
50+ let section = if direct: "DMs" else: "Chats"
4751
4852 var head = vbox(%*{"key": "head", "spacing": 8, "marginRight": listGutter},
49- title(if connected: "Logged in as " & s.formNick else: "Chats"),
53+ title(if connected: "Logged in as " & s.formNick else: section),
5054 errorNote(s))
5155
5256 # `@nick` opens a DM and `#room` joins a channel, and the button says which
5357 # so the reader is not guessing what Enter will do.
5458 var joinRow = hbox(%*{"spacing": 8, "align": "end"},
55- button(if s.joinInput.startsWith("@"): "Message" else: "Join", "join"),
56- entry("join-input", s.joinInput, "#channel or @nick", "join-input.change",
59+ button(if direct or s.joinInput.startsWith("@"): "Message" else: "Join", "join"),
60+ entry("join-input", s.joinInput,
61+ if direct: "@nick" else: "#channel or @nick", "join-input.change",
5762 onSubmit = "join", verbatim = true))
5863
5964 var searchRow = hbox(%*{"spacing": 8, "align": "end"})
6065 if s.search.len > 0:
6166 searchRow.children.add button("✕", "search.clear")
62- searchRow.children.add entry("search", s.search, "Search channels",
67+ searchRow.children.add entry("search", s.search,
68+ if direct: "Search direct messages" else: "Search channels",
6369 "search.change")
6470
6571 head.children.add card(vbox(%*{"spacing": 8}, joinRow, searchRow))
@@ -71,7 +77,8 @@ func chatsScreen*(s: State, connected: bool): Node =
7177 body.children.add conversationRow(b)
7278 else:
7379 body = n("vbox", %*{"marginRight": listGutter}, @[
74- card(dimLabel("No conversations yet — join a channel."))])
80+ card(dimLabel(if direct: "No direct messages yet — message someone."
81+ else: "No conversations yet — join a channel."))])
7582
7683 vbox(%*{"spacing": 8, "margin": 12, "expand": true},
7784 head,
@@ -80,3 +87,9 @@ func chatsScreen*(s: State, connected: bool): Node =
8087 vbox(%*{"key": "foot", "spacing": 8},
8188 separator(),
8289 tabBar(s)))
90+
91+func chatsScreen*(s: State, connected: bool): Node =
92+ conversationListScreen(s, connected, direct = false)
93+
94+func dmsScreen*(s: State, connected: bool): Node =
95+ conversationListScreen(s, connected, direct = true)
@@ -4,7 +4,7 @@
4 ## dropped rather than translated — there is no terminal frontend any more, so4 ## dropped rather than translated — there is no terminal frontend any more, so
5 ## `conversation-row`'s two layouts collapse to the one a window uses.5 ## `conversation-row`'s two layouts collapse to the one a window uses.
6 6
7-import std/[json, strutils]7+import std/[json, sequtils, strutils]
8 import frq/[ui, cells, model, rooms, textruns]8 import frq/[ui, cells, model, rooms, textruns]
9 import frq/screens/[frame, connect]9 import frq/screens/[frame, connect]
10 10
@@ -42,24 +42,30 @@ func conversationRow(r: Room): Node =
42 button("Open", "room.open:" & r.name),42 button("Open", "room.open:" & r.name),
43 button("Close", "room.leave:" & r.name)))])43 button("Close", "room.leave:" & r.name)))])
44 44
45-func chatsScreen*(s: State, connected: bool): Node =45+func conversationListScreen(s: State, connected, direct: bool): Node =
46- let buffers = channelList(s.rooms, s.search)46+ ## The two kinds of conversation have their own tabs, but share the cards
47+ ## and controls. A DM is identified by its name rather than by a second
48+ ## collection in state, so it remains visible after reconnecting like rooms.
49+ let buffers = channelList(s.rooms, s.search).filterIt(dm(it.name) == direct)
50+ let section = if direct: "DMs" else: "Chats"
47 51
48 var head = vbox(%*{"key": "head", "spacing": 8, "marginRight": listGutter},52 var head = vbox(%*{"key": "head", "spacing": 8, "marginRight": listGutter},
49- title(if connected: "Logged in as " & s.formNick else: "Chats"),53+ title(if connected: "Logged in as " & s.formNick else: section),
50 errorNote(s))54 errorNote(s))
51 55
52 # `@nick` opens a DM and `#room` joins a channel, and the button says which56 # `@nick` opens a DM and `#room` joins a channel, and the button says which
53 # so the reader is not guessing what Enter will do.57 # so the reader is not guessing what Enter will do.
54 var joinRow = hbox(%*{"spacing": 8, "align": "end"},58 var joinRow = hbox(%*{"spacing": 8, "align": "end"},
55- button(if s.joinInput.startsWith("@"): "Message" else: "Join", "join"),59+ button(if direct or s.joinInput.startsWith("@"): "Message" else: "Join", "join"),
56- entry("join-input", s.joinInput, "#channel or @nick", "join-input.change",60+ entry("join-input", s.joinInput,
61+ if direct: "@nick" else: "#channel or @nick", "join-input.change",
57 onSubmit = "join", verbatim = true))62 onSubmit = "join", verbatim = true))
58 63
59 var searchRow = hbox(%*{"spacing": 8, "align": "end"})64 var searchRow = hbox(%*{"spacing": 8, "align": "end"})
60 if s.search.len > 0:65 if s.search.len > 0:
61 searchRow.children.add button("✕", "search.clear")66 searchRow.children.add button("✕", "search.clear")
62- searchRow.children.add entry("search", s.search, "Search channels",67+ searchRow.children.add entry("search", s.search,
68+ if direct: "Search direct messages" else: "Search channels",
63 "search.change")69 "search.change")
64 70
65 head.children.add card(vbox(%*{"spacing": 8}, joinRow, searchRow))71 head.children.add card(vbox(%*{"spacing": 8}, joinRow, searchRow))
@@ -71,7 +77,8 @@ func chatsScreen*(s: State, connected: bool): Node =
71 body.children.add conversationRow(b)77 body.children.add conversationRow(b)
72 else:78 else:
73 body = n("vbox", %*{"marginRight": listGutter}, @[79 body = n("vbox", %*{"marginRight": listGutter}, @[
74- card(dimLabel("No conversations yet — join a channel."))])80+ card(dimLabel(if direct: "No direct messages yet — message someone."
81+ else: "No conversations yet — join a channel."))])
75 82
76 vbox(%*{"spacing": 8, "margin": 12, "expand": true},83 vbox(%*{"spacing": 8, "margin": 12, "expand": true},
77 head,84 head,
@@ -80,3 +87,9 @@ func chatsScreen*(s: State, connected: bool): Node =
80 vbox(%*{"key": "foot", "spacing": 8},87 vbox(%*{"key": "foot", "spacing": 8},
81 separator(),88 separator(),
82 tabBar(s)))89 tabBar(s)))
90+
91+func chatsScreen*(s: State, connected: bool): Node =
92+ conversationListScreen(s, connected, direct = false)
93+
94+func dmsScreen*(s: State, connected: bool): Node =
95+ conversationListScreen(s, connected, direct = true)
modified nim/src/frq/screens/frame.nim +3 -1
@@ -8,7 +8,7 @@ import std/json
88 import frq/[ui, cells]
99
1010 func tabBar*(s: State): Node =
11- ## The three screens, and the way between them.
11+ ## The four screens, and the way between them.
1212 ##
1313 ## Lit for a conversation as well as for the list, because a conversation is
1414 ## what the Chats tab leads to — a bar with nothing lit on it reads as a bar
@@ -16,6 +16,8 @@ func tabBar*(s: State): Node =
1616 hbox(%*{"spacing": 8},
1717 button("Chats", "screen.chats",
1818 if s.screen in {scChats, scChat}: "primary" else: "default"),
19+ button("DMs", "screen.dms",
20+ if s.screen == scDms: "primary" else: "default"),
1921 button("Discover", "screen.discover",
2022 if s.screen == scDiscover: "primary" else: "default"),
2123 button("Settings", "screen.settings",
@@ -8,7 +8,7 @@ import std/json
8 import frq/[ui, cells]8 import frq/[ui, cells]
9 9
10 func tabBar*(s: State): Node =10 func tabBar*(s: State): Node =
11- ## The three screens, and the way between them.11+ ## The four screens, and the way between them.
12 ##12 ##
13 ## Lit for a conversation as well as for the list, because a conversation is13 ## Lit for a conversation as well as for the list, because a conversation is
14 ## what the Chats tab leads to — a bar with nothing lit on it reads as a bar14 ## what the Chats tab leads to — a bar with nothing lit on it reads as a bar
@@ -16,6 +16,8 @@ func tabBar*(s: State): Node =
16 hbox(%*{"spacing": 8},16 hbox(%*{"spacing": 8},
17 button("Chats", "screen.chats",17 button("Chats", "screen.chats",
18 if s.screen in {scChats, scChat}: "primary" else: "default"),18 if s.screen in {scChats, scChat}: "primary" else: "default"),
19+ button("DMs", "screen.dms",
20+ if s.screen == scDms: "primary" else: "default"),
19 button("Discover", "screen.discover",21 button("Discover", "screen.discover",
20 if s.screen == scDiscover: "primary" else: "default"),22 if s.screen == scDiscover: "primary" else: "default"),
21 button("Settings", "screen.settings",23 button("Settings", "screen.settings",
modified nim/src/frq_core.nim +1 -0
@@ -172,6 +172,7 @@ proc currentTree(): string =
172172 case app.screen
173173 of scChat: scChatScreen.chatScreen(app, connected)
174174 of scChats: scChatsScreen.chatsScreen(app, connected)
175+ of scDms: scChatsScreen.dmsScreen(app, connected)
175176 of scDiscover: scSettingsScreen.discoverScreen(app)
176177 of scSettings: scSettingsScreen.settingsScreen(app, connected, true)
177178 of scConnect: scConnectScreen.connectScreen(app)
@@ -172,6 +172,7 @@ proc currentTree(): string =
172 case app.screen172 case app.screen
173 of scChat: scChatScreen.chatScreen(app, connected)173 of scChat: scChatScreen.chatScreen(app, connected)
174 of scChats: scChatsScreen.chatsScreen(app, connected)174 of scChats: scChatsScreen.chatsScreen(app, connected)
175+ of scDms: scChatsScreen.dmsScreen(app, connected)
175 of scDiscover: scSettingsScreen.discoverScreen(app)176 of scDiscover: scSettingsScreen.discoverScreen(app)
176 of scSettings: scSettingsScreen.settingsScreen(app, connected, true)177 of scSettings: scSettingsScreen.settingsScreen(app, connected, true)
177 of scConnect: scConnectScreen.connectScreen(app)178 of scConnect: scConnectScreen.connectScreen(app)
modified nim/tests/tscreens.nim +24 -4
@@ -9,6 +9,7 @@ import frq/[ui, cells, model]
99 import frq/screens/connect as cs
1010 import frq/screens/settings as ss
1111 import frq/screens/chat as cht
12+import frq/screens/chats as ch
1213 import frq/[rooms]
1314
1415 proc find*(node: Node, tag: string): seq[Node] =
@@ -139,6 +140,26 @@ suite "discover":
139140 check "Discover" in sel
140141 check t.find("scroll").len == 1
141142
143+suite "direct messages":
144+ test "shows only direct-message buffers":
145+ var s = initState()
146+ var room = initRoom("#test"); room.joined = true
147+ var direct = initRoom("alice")
148+ s.rooms[room.name] = room
149+ s.rooms[direct.name] = direct
150+ let t = ch.dmsScreen(s, true)
151+ check "alice" in t.labels("title-2")
152+ check "#test" notin t.labels("title-2")
153+ check "DMs" in t.labels("button")
154+
155+ test "the DMs tab is selected":
156+ var s = initState()
157+ s.screen = scDms
158+ let selected = ch.dmsScreen(s, false).find("button")
159+ .filterIt(it.props{"kind"}.getStr() == "primary")
160+ .mapIt(it.props{"label"}.getStr())
161+ check "DMs" in selected
162+
142163 suite "settings":
143164 setup:
144165 var s = initState()
@@ -179,8 +200,6 @@ suite "settings":
179200 check not ss.settingsScreen(s, false, true).find("status")[0]
180201 .props{"live"}.getBool()
181202
182-import frq/screens/chats as ch
183-
184203 suite "previewLine":
185204 test "collapses whitespace so a card reads as one line":
186205 check ch.previewLine("a\n b\tc") == "a b c"
@@ -300,11 +319,12 @@ suite "the chat screen's panes":
300319 check t.find("scroll").anyIt(
301320 it.props{"scrollKey"}.getStr() == "messages-#test")
302321
303- test "the way to Discover and Settings is on the screen":
322+ test "the way to DMs, Discover and Settings is on the screen":
304323 # It was not: the chat screen never carried the tab bar, and on a wide
305324 # window there is no back button either — the room list is a strip. So
306- # from a conversation there was no way to either of them at all.
325+ # from a conversation there was no way to any of them at all.
307326 let t = cht.chatScreen(s, true)
327+ check "DMs" in t.labels("button")
308328 check "Discover" in t.labels("button")
309329 check "Settings" in t.labels("button")
310330
@@ -9,6 +9,7 @@ import frq/[ui, cells, model]
9 import frq/screens/connect as cs9 import frq/screens/connect as cs
10 import frq/screens/settings as ss10 import frq/screens/settings as ss
11 import frq/screens/chat as cht11 import frq/screens/chat as cht
12+import frq/screens/chats as ch
12 import frq/[rooms]13 import frq/[rooms]
13 14
14 proc find*(node: Node, tag: string): seq[Node] =15 proc find*(node: Node, tag: string): seq[Node] =
@@ -139,6 +140,26 @@ suite "discover":
139 check "Discover" in sel140 check "Discover" in sel
140 check t.find("scroll").len == 1141 check t.find("scroll").len == 1
141 142
143+suite "direct messages":
144+ test "shows only direct-message buffers":
145+ var s = initState()
146+ var room = initRoom("#test"); room.joined = true
147+ var direct = initRoom("alice")
148+ s.rooms[room.name] = room
149+ s.rooms[direct.name] = direct
150+ let t = ch.dmsScreen(s, true)
151+ check "alice" in t.labels("title-2")
152+ check "#test" notin t.labels("title-2")
153+ check "DMs" in t.labels("button")
154+
155+ test "the DMs tab is selected":
156+ var s = initState()
157+ s.screen = scDms
158+ let selected = ch.dmsScreen(s, false).find("button")
159+ .filterIt(it.props{"kind"}.getStr() == "primary")
160+ .mapIt(it.props{"label"}.getStr())
161+ check "DMs" in selected
162+
142 suite "settings":163 suite "settings":
143 setup:164 setup:
144 var s = initState()165 var s = initState()
@@ -179,8 +200,6 @@ suite "settings":
179 check not ss.settingsScreen(s, false, true).find("status")[0]200 check not ss.settingsScreen(s, false, true).find("status")[0]
180 .props{"live"}.getBool()201 .props{"live"}.getBool()
181 202
182-import frq/screens/chats as ch
183-
184 suite "previewLine":203 suite "previewLine":
185 test "collapses whitespace so a card reads as one line":204 test "collapses whitespace so a card reads as one line":
186 check ch.previewLine("a\n b\tc") == "a b c"205 check ch.previewLine("a\n b\tc") == "a b c"
@@ -300,11 +319,12 @@ suite "the chat screen's panes":
300 check t.find("scroll").anyIt(319 check t.find("scroll").anyIt(
301 it.props{"scrollKey"}.getStr() == "messages-#test")320 it.props{"scrollKey"}.getStr() == "messages-#test")
302 321
303- test "the way to Discover and Settings is on the screen":322+ test "the way to DMs, Discover and Settings is on the screen":
304 # It was not: the chat screen never carried the tab bar, and on a wide323 # It was not: the chat screen never carried the tab bar, and on a wide
305 # window there is no back button either — the room list is a strip. So324 # window there is no back button either — the room list is a strip. So
306- # from a conversation there was no way to either of them at all.325+ # from a conversation there was no way to any of them at all.
307 let t = cht.chatScreen(s, true)326 let t = cht.chatScreen(s, true)
327+ check "DMs" in t.labels("button")
308 check "Discover" in t.labels("button")328 check "Discover" in t.labels("button")
309 check "Settings" in t.labels("button")329 check "Settings" in t.labels("button")
310 330