nandi/frqpublic Fork 0
0742154
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.

Quiet the chat down

Four kinds of noise, in what the screen actually shows:

A run of messages from one person repeated the nick on every line, so a busy
channel read as a list of headers with text between them. The sender now
appears once per run.

"You joined #freeq" appeared twice, because a reconnect joins every channel
again and the buffer it announces into already has the backlog in it. It is
announced only into an empty buffer now, and reads "Joined #freeq".

A join refused with 473 raised the global banner, which then sat over an
unrelated channel saying nothing about which one it meant. The reason goes to
the refused channel's own buffer; the banner is left for what stops the whole
app — a failed connection, a refused sign-in.

Server lines are dim rather than shouting in body text, a conversation row puts
membership beside the name instead of in the button row, and Settings offers
"Back to connect" rather than a Disconnect button with nothing to disconnect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T10:22:27-07:00 Browse files
0742154 parent: 4aa71e7
modified src/frq/app.jolt +38 -13
@@ -132,15 +132,16 @@
132132 (let [name (:name buffer)
133133 unread (:unread buffer)]
134134 [:card {:key name}
135+ ;; Name, membership and unread count on one line they describe the same
136+ ;; thing, and the row below is for the button alone.
135137 [:hbox {:spacing 12}
136138 [:title-2 {:label name}]
139+ [:status {:label (if (:joined? buffer) "joined" "not joined")
140+ :live (boolean (:joined? buffer))}]
137141 [:vbox {:key :unread}
138142 (when (pos? unread) [:label {:label (str "" unread)}])]]
139143 [:dim-label {:label (s/last-preview buffer)}]
140- [:hbox {:spacing 8}
141- [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}]
142- [:status {:label (if (:joined? buffer) "joined" "not joined")
143- :live (boolean (:joined? buffer))}]]]))
144+ [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}]]))
144145
145146 (defn chats-screen []
146147 (let [buffers (s/channel-list)]
@@ -169,13 +170,31 @@
169170
170171 ;; ---------------------------------------------------------------- chat
171172
172-(defn message-row [i m]
173- ;; Sender above the text, not beside it. A wrapping label in a horizontal row
174- ;; lays out against the row's width rather than the column's, so one long URL
175- ;; drags every line that follows it off the left edge.
176- [:vbox {:key i :spacing 2}
177- [:dim-label {:label (:from m)}]
178- [:label {:label (:text m)}]])
173+(defn message-row
174+ "One message. `prev` is the message above it, which decides whether this one
175+ repeats the sender.
176+
177+ Sender above the text, not beside it: a wrapping label in a horizontal row
178+ lays out against the row's width rather than the column's, so one long URL
179+ drags every line that follows it off the left edge."
180+ [i prev m]
181+ (let [same-sender? (and prev
182+ (not (:system? m))
183+ (not (:system? prev))
184+ (= (:from prev) (:from m)))]
185+ [:vbox {:key i :spacing 2 :margin 0}
186+ ;; A run from one person reads as one block; repeating the nick on every
187+ ;; line is what made a busy channel look like a list of headers.
188+ [:vbox {:key :who}
189+ (when-not (or same-sender? (:system? m))
190+ [:dim-label {:label (:from m)}])]
191+ (if (:system? m)
192+ [:dim-label {:label (:text m)}]
193+ [:label {:label (:text m)}])]))
194+
195+(defn- message-rows [messages]
196+ (map-indexed (fn [i m] [message-row i (when (pos? i) (nth messages (dec i))) m])
197+ messages))
179198
180199 (defn chat-screen []
181200 (let [name @s/current
@@ -192,7 +211,7 @@
192211 ;; list fills whatever the window has and no more.
193212 [:scroll {:orientation :vertical :reserve 72 :stick-to-bottom true}
194213 (if (seq (:messages buffer))
195- (map-indexed message-row (:messages buffer))
214+ (message-rows (:messages buffer))
196215 [:dim-label {:label "Nothing here yet."}])]
197216 [:separator {}]
198217 [:hbox {:spacing 8}
@@ -243,7 +262,13 @@
243262 :on-click s/forget-session!}])]]
244263 [:dim-label {:label "Guest — not signed in."}])
245264 [:separator {}]
246- [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]]
265+ ;; Nothing to disconnect from when there is no connection the way back to
266+ ;; the connect screen is what is wanted then.
267+ [:vbox {:key :connection-action}
268+ (if (s/connected?)
269+ [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]
270+ [:button {:label "Back to connect"
271+ :on-click #(reset! s/screen :connect)}])]]
247272 [:card {}
248273 [:title-2 {:label "frq"}]
249274 [:dim-label {:label "freeq client in jolt — glimmer components on the Vidya/egui backend."}]
@@ -132,15 +132,16 @@
132 (let [name (:name buffer)132 (let [name (:name buffer)
133 unread (:unread buffer)]133 unread (:unread buffer)]
134 [:card {:key name}134 [:card {:key name}
135+ ;; Name, membership and unread count on one line they describe the same
136+ ;; thing, and the row below is for the button alone.
135 [:hbox {:spacing 12}137 [:hbox {:spacing 12}
136 [:title-2 {:label name}]138 [:title-2 {:label name}]
139+ [:status {:label (if (:joined? buffer) "joined" "not joined")
140+ :live (boolean (:joined? buffer))}]
137 [:vbox {:key :unread}141 [:vbox {:key :unread}
138 (when (pos? unread) [:label {:label (str "" unread)}])]]142 (when (pos? unread) [:label {:label (str "" unread)}])]]
139 [:dim-label {:label (s/last-preview buffer)}]143 [:dim-label {:label (s/last-preview buffer)}]
140- [:hbox {:spacing 8}144+ [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}]]))
141- [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}]
142- [:status {:label (if (:joined? buffer) "joined" "not joined")
143- :live (boolean (:joined? buffer))}]]]))
144 145
145 (defn chats-screen []146 (defn chats-screen []
146 (let [buffers (s/channel-list)]147 (let [buffers (s/channel-list)]
@@ -169,13 +170,31 @@
169 170
170 ;; ---------------------------------------------------------------- chat171 ;; ---------------------------------------------------------------- chat
171 172
172-(defn message-row [i m]173+(defn message-row
173- ;; Sender above the text, not beside it. A wrapping label in a horizontal row174+ "One message. `prev` is the message above it, which decides whether this one
174- ;; lays out against the row's width rather than the column's, so one long URL175+ repeats the sender.
175- ;; drags every line that follows it off the left edge.176+
176- [:vbox {:key i :spacing 2}177+ Sender above the text, not beside it: a wrapping label in a horizontal row
177- [:dim-label {:label (:from m)}]178+ lays out against the row's width rather than the column's, so one long URL
178- [:label {:label (:text m)}]])179+ drags every line that follows it off the left edge."
180+ [i prev m]
181+ (let [same-sender? (and prev
182+ (not (:system? m))
183+ (not (:system? prev))
184+ (= (:from prev) (:from m)))]
185+ [:vbox {:key i :spacing 2 :margin 0}
186+ ;; A run from one person reads as one block; repeating the nick on every
187+ ;; line is what made a busy channel look like a list of headers.
188+ [:vbox {:key :who}
189+ (when-not (or same-sender? (:system? m))
190+ [:dim-label {:label (:from m)}])]
191+ (if (:system? m)
192+ [:dim-label {:label (:text m)}]
193+ [:label {:label (:text m)}])]))
194+
195+(defn- message-rows [messages]
196+ (map-indexed (fn [i m] [message-row i (when (pos? i) (nth messages (dec i))) m])
197+ messages))
179 198
180 (defn chat-screen []199 (defn chat-screen []
181 (let [name @s/current200 (let [name @s/current
@@ -192,7 +211,7 @@
192 ;; list fills whatever the window has and no more.211 ;; list fills whatever the window has and no more.
193 [:scroll {:orientation :vertical :reserve 72 :stick-to-bottom true}212 [:scroll {:orientation :vertical :reserve 72 :stick-to-bottom true}
194 (if (seq (:messages buffer))213 (if (seq (:messages buffer))
195- (map-indexed message-row (:messages buffer))214+ (message-rows (:messages buffer))
196 [:dim-label {:label "Nothing here yet."}])]215 [:dim-label {:label "Nothing here yet."}])]
197 [:separator {}]216 [:separator {}]
198 [:hbox {:spacing 8}217 [:hbox {:spacing 8}
@@ -243,7 +262,13 @@
243 :on-click s/forget-session!}])]]262 :on-click s/forget-session!}])]]
244 [:dim-label {:label "Guest — not signed in."}])263 [:dim-label {:label "Guest — not signed in."}])
245 [:separator {}]264 [:separator {}]
246- [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]]265+ ;; Nothing to disconnect from when there is no connection the way back to
266+ ;; the connect screen is what is wanted then.
267+ [:vbox {:key :connection-action}
268+ (if (s/connected?)
269+ [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]
270+ [:button {:label "Back to connect"
271+ :on-click #(reset! s/screen :connect)}])]]
247 [:card {}272 [:card {}
248 [:title-2 {:label "frq"}]273 [:title-2 {:label "frq"}]
249 [:dim-label {:label "freeq client in jolt — glimmer components on the Vidya/egui backend."}]274 [:dim-label {:label "freeq client in jolt — glimmer components on the Vidya/egui backend."}]
modified src/frq/irc.jolt +96 -24
@@ -17,6 +17,7 @@
1717 (:require [clojure.string :as str]
1818 [frq.atproto :as atproto]
1919 [jolt.ffi :as ffi]
20+ [jolt.host :as host]
2021 [jolt.mvn-http :as tls]
2122 [jolt.socket :as socket]))
2223
@@ -28,6 +29,20 @@
2829 ;; otherwise, and nothing here handles signals the process simply goes.
2930 (def ^:private no-signal @#'socket/msg-nosignal)
3031
32+;; jolt's TLS sockets carry a 30-second receive timeout, so a quiet connection
33+;; reads nothing without being closed. These decide how long that is allowed to
34+;; go on: past `idle-ping-secs` we ask the server whether it is still there,
35+;; and past `idle-dead-secs` with no answer we conclude it is not.
36+(def ^:private idle-ping-secs 45)
37+(def ^:private idle-dead-secs 90)
38+
39+;; How long a TLS read waits before giving the thread back. It is also how long
40+;; an outgoing line can sit in the outbox, so it wants to be short: the reader
41+;; owns the connection, and this is how often it looks at what there is to send.
42+(def ^:private tls-poll-ms 200)
43+
44+(defn- secs-since [t] (quot (- (host/mono-nanos) t) 1000000000))
45+
3146 ;; ---------------------------------------------------------------- parsing
3247
3348 (defn parse-line
@@ -77,7 +92,8 @@
7792 (recur (+ sent n))))))))
7893
7994 (defn- write!
80- "Bytes out, whichever transport this is."
95+ "Bytes out, whichever transport this is. TLS callers go through the outbox
96+ instead see `send-line!`."
8197 [conn text]
8298 (if (= :tls (:kind conn))
8399 (tls/tls-write (:tls conn) (.getBytes text))
@@ -94,38 +110,92 @@
94110 (when (and n (pos? n)) (String. (ffi/read-bytes buf n))))))
95111
96112 (defn send-line!
97- "Send a raw IRC line. Safe from any thread; writes are serialised by the lock."
113+ "Send a raw IRC line. Safe from any thread.
114+
115+ On TLS the line is queued rather than written: OpenSSL is driven here through
116+ a pair of memory BIOs, and a write issued while the reader thread is parked
117+ inside SSL_read is simply lost the call reports success and the server
118+ never sees the line. So the reader thread owns the connection in both
119+ directions and drains this queue between reads. A raw socket has no such
120+ problem, and writes straight through."
98121 [conn line]
99- (locking (:lock conn)
100- (write! conn (str line "\r\n"))))
122+ (let [text (str line "\r\n")]
123+ (if (= :tls (:kind conn))
124+ (locking (:lock conn) (swap! (:outbox conn) conj text))
125+ (locking (:lock conn) (write! conn text)))))
126+
127+(defn- flush-outbox!
128+ "Write whatever has been queued. Only ever called on the reader thread."
129+ [conn]
130+ (let [pending (locking (:lock conn)
131+ (let [q @(:outbox conn)]
132+ (reset! (:outbox conn) [])
133+ q))]
134+ (doseq [text pending]
135+ (when (System/getenv "FRQ_TRACE")
136+ (binding [*out* *err*] (println "frq/irc: >>" (str/trimr text))))
137+ (try (write! conn text)
138+ (catch Exception e
139+ (binding [*out* *err*] (println "frq/irc: write failed:" (or (ex-message e) (str e))))
140+ ;; Put it back: a write that failed for a transient reason is
141+ ;; worth another turn of the loop.
142+ (locking (:lock conn) (swap! (:outbox conn) conj text)))))))
101143
102144 (defn- reader-loop!
103- "Read bytes until the socket closes, splitting on CRLF and dispatching each
104- complete line. PING is answered here so a dropped UI never times the link
105- out; everything else goes to `on-msg`."
145+ "Read until the connection ends, splitting on CRLF and dispatching each
146+ complete line. PING is answered here so a busy UI never times the link out;
147+ everything else goes to `on-msg`.
148+
149+ Nothing to read is not the end of the connection. On TLS it usually means the
150+ 30-second receive timeout elapsed on a quiet channel reading that as EOF is
151+ what used to leave the app connected in appearance only: sends went nowhere
152+ while the buffer still filled in with what the user typed. So a quiet stretch
153+ gets a PING, and only silence after that counts as gone."
106154 [conn on-msg]
107- (let []
108- (loop [acc ""]
109- (let [chunk (read-chunk! conn)]
110- (if (nil? chunk)
111- (on-msg {:command "*DISCONNECTED*" :params []})
112- (let [acc (str acc chunk)
113- lines (str/split acc #"\r?\n" -1)
114- complete (butlast lines)]
115- (doseq [line complete :when (seq (str/trim line))]
116- (let [msg (parse-line line)]
117- (when (= "PING" (:command msg))
118- (send-line! conn (str "PONG :" (first (:params msg)))))
119- (on-msg (assoc msg :raw line))))
120- (recur (last lines))))))))
155+ (loop [acc "" last-data (host/mono-nanos) pinged? false]
156+ (flush-outbox! conn)
157+ (let [chunk (read-chunk! conn)]
158+ (cond
159+ ;; A plain socket has no timeout, so nothing to read really is the end.
160+ (and (nil? chunk) (not= :tls (:kind conn)))
161+ (on-msg {:command "*DISCONNECTED*" :params []})
162+
163+ (nil? chunk)
164+ (let [idle (secs-since last-data)]
165+ (cond
166+ (and pinged? (> idle idle-dead-secs))
167+ (on-msg {:command "*DISCONNECTED*" :params []})
168+
169+ (and (not pinged?) (> idle idle-ping-secs))
170+ (do (try (send-line! conn "PING :frq") (catch Exception _ nil))
171+ (recur acc last-data true))
172+
173+ :else (recur acc last-data pinged?)))
174+
175+ :else
176+ (let [acc (str acc chunk)
177+ lines (str/split acc #"\r?\n" -1)
178+ complete (butlast lines)]
179+ (doseq [line complete :when (seq (str/trim line))]
180+ (when (System/getenv "FRQ_TRACE")
181+ (binding [*out* *err*] (println "frq/irc: <<" (subs line 0 (min 100 (count line))))))
182+ (let [msg (parse-line line)]
183+ (when (= "PING" (:command msg))
184+ (send-line! conn (str "PONG :" (first (:params msg)))))
185+ (on-msg (assoc msg :raw line))))
186+ (recur (last lines) (host/mono-nanos) false))))))
121187
122188 (defn- open
123189 "Dial `host`:`port`, over TLS unless `tls?` is false."
124190 [host port tls? nick]
125191 (if tls?
126192 (do (tls/ensure-native!)
127- {:kind :tls :tls (tls/tls-connect host (int port))
128- :lock (Object.) :nick nick})
193+ (let [t (tls/tls-connect host (int port))]
194+ ;; Without a short timeout the reader parks for 30 seconds at a time,
195+ ;; which is 30 seconds of nothing being sent.
196+ (try (#'tls/set-timeouts! (:sock t) tls-poll-ms) (catch Exception _ nil))
197+ {:kind :tls :tls t :outbox (atom [])
198+ :lock (Object.) :nick nick}))
129199 (let [ip (#'socket/ip->str (socket/resolve-host host))
130200 fd (socket/c-socket af-inet sock-stream 0)]
131201 (when (neg? fd) (throw (ex-info "socket() failed" {:host host})))
@@ -210,7 +280,9 @@
210280 (send-line! conn (str "PRIVMSG " target " :" text)))
211281
212282 (defn close! [conn]
213- (try (send-line! conn "QUIT :frq") (catch Exception _ nil))
283+ ;; Written straight out rather than queued: the reader may already be gone,
284+ ;; and there is nothing left to lose if this one is.
285+ (try (locking (:lock conn) (write! conn "QUIT :frq\r\n")) (catch Exception _ nil))
214286 (try (if (= :tls (:kind conn))
215287 (tls/tls-close (:tls conn))
216288 (do (socket/c-close (:fd conn))
@@ -17,6 +17,7 @@
17 (:require [clojure.string :as str]17 (:require [clojure.string :as str]
18 [frq.atproto :as atproto]18 [frq.atproto :as atproto]
19 [jolt.ffi :as ffi]19 [jolt.ffi :as ffi]
20+ [jolt.host :as host]
20 [jolt.mvn-http :as tls]21 [jolt.mvn-http :as tls]
21 [jolt.socket :as socket]))22 [jolt.socket :as socket]))
22 23
@@ -28,6 +29,20 @@
28 ;; otherwise, and nothing here handles signals the process simply goes.29 ;; otherwise, and nothing here handles signals the process simply goes.
29 (def ^:private no-signal @#'socket/msg-nosignal)30 (def ^:private no-signal @#'socket/msg-nosignal)
30 31
32+;; jolt's TLS sockets carry a 30-second receive timeout, so a quiet connection
33+;; reads nothing without being closed. These decide how long that is allowed to
34+;; go on: past `idle-ping-secs` we ask the server whether it is still there,
35+;; and past `idle-dead-secs` with no answer we conclude it is not.
36+(def ^:private idle-ping-secs 45)
37+(def ^:private idle-dead-secs 90)
38+
39+;; How long a TLS read waits before giving the thread back. It is also how long
40+;; an outgoing line can sit in the outbox, so it wants to be short: the reader
41+;; owns the connection, and this is how often it looks at what there is to send.
42+(def ^:private tls-poll-ms 200)
43+
44+(defn- secs-since [t] (quot (- (host/mono-nanos) t) 1000000000))
45+
31 ;; ---------------------------------------------------------------- parsing46 ;; ---------------------------------------------------------------- parsing
32 47
33 (defn parse-line48 (defn parse-line
@@ -77,7 +92,8 @@
77 (recur (+ sent n))))))))92 (recur (+ sent n))))))))
78 93
79 (defn- write!94 (defn- write!
80- "Bytes out, whichever transport this is."95+ "Bytes out, whichever transport this is. TLS callers go through the outbox
96+ instead see `send-line!`."
81 [conn text]97 [conn text]
82 (if (= :tls (:kind conn))98 (if (= :tls (:kind conn))
83 (tls/tls-write (:tls conn) (.getBytes text))99 (tls/tls-write (:tls conn) (.getBytes text))
@@ -94,38 +110,92 @@
94 (when (and n (pos? n)) (String. (ffi/read-bytes buf n))))))110 (when (and n (pos? n)) (String. (ffi/read-bytes buf n))))))
95 111
96 (defn send-line!112 (defn send-line!
97- "Send a raw IRC line. Safe from any thread; writes are serialised by the lock."113+ "Send a raw IRC line. Safe from any thread.
114+
115+ On TLS the line is queued rather than written: OpenSSL is driven here through
116+ a pair of memory BIOs, and a write issued while the reader thread is parked
117+ inside SSL_read is simply lost the call reports success and the server
118+ never sees the line. So the reader thread owns the connection in both
119+ directions and drains this queue between reads. A raw socket has no such
120+ problem, and writes straight through."
98 [conn line]121 [conn line]
99- (locking (:lock conn)122+ (let [text (str line "\r\n")]
100- (write! conn (str line "\r\n"))))123+ (if (= :tls (:kind conn))
124+ (locking (:lock conn) (swap! (:outbox conn) conj text))
125+ (locking (:lock conn) (write! conn text)))))
126+
127+(defn- flush-outbox!
128+ "Write whatever has been queued. Only ever called on the reader thread."
129+ [conn]
130+ (let [pending (locking (:lock conn)
131+ (let [q @(:outbox conn)]
132+ (reset! (:outbox conn) [])
133+ q))]
134+ (doseq [text pending]
135+ (when (System/getenv "FRQ_TRACE")
136+ (binding [*out* *err*] (println "frq/irc: >>" (str/trimr text))))
137+ (try (write! conn text)
138+ (catch Exception e
139+ (binding [*out* *err*] (println "frq/irc: write failed:" (or (ex-message e) (str e))))
140+ ;; Put it back: a write that failed for a transient reason is
141+ ;; worth another turn of the loop.
142+ (locking (:lock conn) (swap! (:outbox conn) conj text)))))))
101 143
102 (defn- reader-loop!144 (defn- reader-loop!
103- "Read bytes until the socket closes, splitting on CRLF and dispatching each145+ "Read until the connection ends, splitting on CRLF and dispatching each
104- complete line. PING is answered here so a dropped UI never times the link146+ complete line. PING is answered here so a busy UI never times the link out;
105- out; everything else goes to `on-msg`."147+ everything else goes to `on-msg`.
148+
149+ Nothing to read is not the end of the connection. On TLS it usually means the
150+ 30-second receive timeout elapsed on a quiet channel reading that as EOF is
151+ what used to leave the app connected in appearance only: sends went nowhere
152+ while the buffer still filled in with what the user typed. So a quiet stretch
153+ gets a PING, and only silence after that counts as gone."
106 [conn on-msg]154 [conn on-msg]
107- (let []155+ (loop [acc "" last-data (host/mono-nanos) pinged? false]
108- (loop [acc ""]156+ (flush-outbox! conn)
109- (let [chunk (read-chunk! conn)]157+ (let [chunk (read-chunk! conn)]
110- (if (nil? chunk)158+ (cond
111- (on-msg {:command "*DISCONNECTED*" :params []})159+ ;; A plain socket has no timeout, so nothing to read really is the end.
112- (let [acc (str acc chunk)160+ (and (nil? chunk) (not= :tls (:kind conn)))
113- lines (str/split acc #"\r?\n" -1)161+ (on-msg {:command "*DISCONNECTED*" :params []})
114- complete (butlast lines)]162+
115- (doseq [line complete :when (seq (str/trim line))]163+ (nil? chunk)
116- (let [msg (parse-line line)]164+ (let [idle (secs-since last-data)]
117- (when (= "PING" (:command msg))165+ (cond
118- (send-line! conn (str "PONG :" (first (:params msg)))))166+ (and pinged? (> idle idle-dead-secs))
119- (on-msg (assoc msg :raw line))))167+ (on-msg {:command "*DISCONNECTED*" :params []})
120- (recur (last lines))))))))168+
169+ (and (not pinged?) (> idle idle-ping-secs))
170+ (do (try (send-line! conn "PING :frq") (catch Exception _ nil))
171+ (recur acc last-data true))
172+
173+ :else (recur acc last-data pinged?)))
174+
175+ :else
176+ (let [acc (str acc chunk)
177+ lines (str/split acc #"\r?\n" -1)
178+ complete (butlast lines)]
179+ (doseq [line complete :when (seq (str/trim line))]
180+ (when (System/getenv "FRQ_TRACE")
181+ (binding [*out* *err*] (println "frq/irc: <<" (subs line 0 (min 100 (count line))))))
182+ (let [msg (parse-line line)]
183+ (when (= "PING" (:command msg))
184+ (send-line! conn (str "PONG :" (first (:params msg)))))
185+ (on-msg (assoc msg :raw line))))
186+ (recur (last lines) (host/mono-nanos) false))))))
121 187
122 (defn- open188 (defn- open
123 "Dial `host`:`port`, over TLS unless `tls?` is false."189 "Dial `host`:`port`, over TLS unless `tls?` is false."
124 [host port tls? nick]190 [host port tls? nick]
125 (if tls?191 (if tls?
126 (do (tls/ensure-native!)192 (do (tls/ensure-native!)
127- {:kind :tls :tls (tls/tls-connect host (int port))193+ (let [t (tls/tls-connect host (int port))]
128- :lock (Object.) :nick nick})194+ ;; Without a short timeout the reader parks for 30 seconds at a time,
195+ ;; which is 30 seconds of nothing being sent.
196+ (try (#'tls/set-timeouts! (:sock t) tls-poll-ms) (catch Exception _ nil))
197+ {:kind :tls :tls t :outbox (atom [])
198+ :lock (Object.) :nick nick}))
129 (let [ip (#'socket/ip->str (socket/resolve-host host))199 (let [ip (#'socket/ip->str (socket/resolve-host host))
130 fd (socket/c-socket af-inet sock-stream 0)]200 fd (socket/c-socket af-inet sock-stream 0)]
131 (when (neg? fd) (throw (ex-info "socket() failed" {:host host})))201 (when (neg? fd) (throw (ex-info "socket() failed" {:host host})))
@@ -210,7 +280,9 @@
210 (send-line! conn (str "PRIVMSG " target " :" text)))280 (send-line! conn (str "PRIVMSG " target " :" text)))
211 281
212 (defn close! [conn]282 (defn close! [conn]
213- (try (send-line! conn "QUIT :frq") (catch Exception _ nil))283+ ;; Written straight out rather than queued: the reader may already be gone,
284+ ;; and there is nothing left to lose if this one is.
285+ (try (locking (:lock conn) (write! conn "QUIT :frq\r\n")) (catch Exception _ nil))
214 (try (if (= :tls (:kind conn))286 (try (if (= :tls (:kind conn))
215 (tls/tls-close (:tls conn))287 (tls/tls-close (:tls conn))
216 (do (socket/c-close (:fd conn))288 (do (socket/c-close (:fd conn))
modified src/frq/state.jolt +15 -6
@@ -80,7 +80,8 @@
8080 (let [m (ensure-channel m channel)
8181 viewing? (and (= :chat @screen) (= channel @current))]
8282 (-> m
83- (update-in [channel :messages] conj {:from from :text text})
83+ (update-in [channel :messages] conj
84+ {:from from :text text :system? (= "*" from)})
8485 (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))
8586
8687 (defn open-channel!
@@ -122,10 +123,14 @@
122123 (push-message! buffer from text))
123124 "JOIN" (let [ch (first params)]
124125 (if (= from @form-nick)
125- (do (swap! channels #(-> (ensure-channel % ch)
126- (assoc-in [ch :joined?] true)
127- (assoc-in [ch :joining?] false)))
128- (push-message! ch "*" (str "You joined " ch)))
126+ (let [fresh? (empty? (get-in @channels [ch :messages]))]
127+ (swap! channels #(-> (ensure-channel % ch)
128+ (assoc-in [ch :joined?] true)
129+ (assoc-in [ch :joining?] false)))
130+ ;; Only on the way in to an empty buffer. A reconnect joins
131+ ;; every channel again, and saying so on top of the backlog
132+ ;; already there is just a second line of noise.
133+ (when fresh? (push-message! ch "*" (str "Joined " ch))))
129134 (push-message! ch "*" (str from " joined"))))
130135 "PART" (let [ch (first params)]
131136 (if (= from @form-nick)
@@ -146,7 +151,11 @@
146151 (assoc-in [ch :joined?] false)
147152 (assoc-in [ch :joining?] false)))
148153 (push-message! ch "*" (str "Could not join " ch "" why)))
149- (reset! error (str "Cannot join: " why)))
154+ ;; Deliberately not the global banner: it outlives the screen it was
155+ ;; about, and the reason is in the channel's own buffer where it
156+ ;; belongs. The banner is for what stops the whole app a failed
157+ ;; connection or a refused sign-in.
158+ (when-not ch (reset! error (str "Cannot join: " why))))
150159 "903" (reset! status (str "Signed in as " (:handle @session)))
151160 ("904" "905" "906") (do (reset! session nil)
152161 ;; The broker token may still be good but a
@@ -80,7 +80,8 @@
80 (let [m (ensure-channel m channel)80 (let [m (ensure-channel m channel)
81 viewing? (and (= :chat @screen) (= channel @current))]81 viewing? (and (= :chat @screen) (= channel @current))]
82 (-> m82 (-> m
83- (update-in [channel :messages] conj {:from from :text text})83+ (update-in [channel :messages] conj
84+ {:from from :text text :system? (= "*" from)})
84 (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))85 (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))
85 86
86 (defn open-channel!87 (defn open-channel!
@@ -122,10 +123,14 @@
122 (push-message! buffer from text))123 (push-message! buffer from text))
123 "JOIN" (let [ch (first params)]124 "JOIN" (let [ch (first params)]
124 (if (= from @form-nick)125 (if (= from @form-nick)
125- (do (swap! channels #(-> (ensure-channel % ch)126+ (let [fresh? (empty? (get-in @channels [ch :messages]))]
126- (assoc-in [ch :joined?] true)127+ (swap! channels #(-> (ensure-channel % ch)
127- (assoc-in [ch :joining?] false)))128+ (assoc-in [ch :joined?] true)
128- (push-message! ch "*" (str "You joined " ch)))129+ (assoc-in [ch :joining?] false)))
130+ ;; Only on the way in to an empty buffer. A reconnect joins
131+ ;; every channel again, and saying so on top of the backlog
132+ ;; already there is just a second line of noise.
133+ (when fresh? (push-message! ch "*" (str "Joined " ch))))
129 (push-message! ch "*" (str from " joined"))))134 (push-message! ch "*" (str from " joined"))))
130 "PART" (let [ch (first params)]135 "PART" (let [ch (first params)]
131 (if (= from @form-nick)136 (if (= from @form-nick)
@@ -146,7 +151,11 @@
146 (assoc-in [ch :joined?] false)151 (assoc-in [ch :joined?] false)
147 (assoc-in [ch :joining?] false)))152 (assoc-in [ch :joining?] false)))
148 (push-message! ch "*" (str "Could not join " ch "" why)))153 (push-message! ch "*" (str "Could not join " ch "" why)))
149- (reset! error (str "Cannot join: " why)))154+ ;; Deliberately not the global banner: it outlives the screen it was
155+ ;; about, and the reason is in the channel's own buffer where it
156+ ;; belongs. The banner is for what stops the whole app a failed
157+ ;; connection or a refused sign-in.
158+ (when-not ch (reset! error (str "Cannot join: " why))))
150 "903" (reset! status (str "Signed in as " (:handle @session)))159 "903" (reset! status (str "Signed in as " (:handle @session)))
151 ("904" "905" "906") (do (reset! session nil)160 ("904" "905" "906") (do (reset! session nil)
152 ;; The broker token may still be good but a161 ;; The broker token may still be good but a