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

Show the pictures people paste

A message carrying a PNG link now paints it underneath. The link stays where
it was: it is what a failed fetch, a format the decoder does not read, or a
phone with no TLS leaves the reader, and it is also how anyone opens the
picture at full size.

Fetching happens once per URL however many messages carry it, on a background
thread, into $XDG_CACHE_HOME/frq/media — where a later run finds it already
there. The file name is the URL's own last segment behind a hash of the whole
thing, so two image.png from different messages do not become one.

Rows subscribe to a single counter rather than each watching the cache, so a
fetch landing repaints the chat without every message row holding a watch on
media state.

Verified against #test: the channel's paste.png arrives, caches, and paints
under the line that linked it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T10:56:55-07:00 Browse files
56cdac3 parent: 94e59a2
modified README.md +7 -0
@@ -15,6 +15,7 @@ of an `AppState` struct.
1515 src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads
1616 src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session
1717 src/frq/store.jolt the saved sign-in, mode 600 in the config directory
18+src/frq/media.jolt image links: spot them, fetch them once, cache on disk
1819 src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG
1920 src/frq/state.jolt the ratoms every screen reads, and `apply-msg!`
2021 src/frq/app.jolt the screens
@@ -102,6 +103,8 @@ surface — that surface does not work on Android either, while the syscalls do.
102103 * Auto-joins `#test` on `irc.freeq.at`
103104 * Join channels, channel buffers with unread counts, send and receive `PRIVMSG`
104105 * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead
106+* Inline previews for PNG links, fetched once and cached under
107+ `$XDG_CACHE_HOME/frq/media`
105108 * Join/part notices, DMs bucketed under the sender's nick
106109 * Discover list, search over buffers, disconnect
107110 * Conversations listed most recently opened first
@@ -114,6 +117,10 @@ surface — that surface does not work on Android either, while the syscalls do.
114117 guest.
115118 * **Only the broker token is persisted**, and only for OAuth. An app-password
116119 sign-in is not remembered.
120+* **Previews are PNG only** — the tree backend's decoder reads no other
121+ format, and a fetch needs TLS, so the phone shows links. The link is left in
122+ place either way.
123+* **Nothing evicts the media cache.**
117124 * **No scrollback trimming, avatars, reactions, threads, or calls.**
118125 * A sent line waits up to 200ms for the reader thread to flush it.
119126 * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet.
@@ -15,6 +15,7 @@ of an `AppState` struct.
15 src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads15 src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads
16 src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session16 src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session
17 src/frq/store.jolt the saved sign-in, mode 600 in the config directory17 src/frq/store.jolt the saved sign-in, mode 600 in the config directory
18+src/frq/media.jolt image links: spot them, fetch them once, cache on disk
18 src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG19 src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG
19 src/frq/state.jolt the ratoms every screen reads, and `apply-msg!`20 src/frq/state.jolt the ratoms every screen reads, and `apply-msg!`
20 src/frq/app.jolt the screens21 src/frq/app.jolt the screens
@@ -102,6 +103,8 @@ surface — that surface does not work on Android either, while the syscalls do.
102 * Auto-joins `#test` on `irc.freeq.at`103 * Auto-joins `#test` on `irc.freeq.at`
103 * Join channels, channel buffers with unread counts, send and receive `PRIVMSG`104 * Join channels, channel buffers with unread counts, send and receive `PRIVMSG`
104 * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead105 * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead
106+* Inline previews for PNG links, fetched once and cached under
107+ `$XDG_CACHE_HOME/frq/media`
105 * Join/part notices, DMs bucketed under the sender's nick108 * Join/part notices, DMs bucketed under the sender's nick
106 * Discover list, search over buffers, disconnect109 * Discover list, search over buffers, disconnect
107 * Conversations listed most recently opened first110 * Conversations listed most recently opened first
@@ -114,6 +117,10 @@ surface — that surface does not work on Android either, while the syscalls do.
114 guest.117 guest.
115 * **Only the broker token is persisted**, and only for OAuth. An app-password118 * **Only the broker token is persisted**, and only for OAuth. An app-password
116 sign-in is not remembered.119 sign-in is not remembered.
120+* **Previews are PNG only** — the tree backend's decoder reads no other
121+ format, and a fetch needs TLS, so the phone shows links. The link is left in
122+ place either way.
123+* **Nothing evicts the media cache.**
117 * **No scrollback trimming, avatars, reactions, threads, or calls.**124 * **No scrollback trimming, avatars, reactions, threads, or calls.**
118 * A sent line waits up to 200ms for the reader thread to flush it.125 * A sent line waits up to 200ms for the reader thread to flush it.
119 * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet.126 * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet.
modified src/frq/app.jolt +11 -1
@@ -7,6 +7,7 @@
77 (:require [glimmer.ratom :as r :refer [atom]]
88 [glimmer.core :as ui]
99 [glimmer-vidya.core :as vidya]
10+ [frq.media :as media]
1011 [frq.state :as s]))
1112
1213 ;; ---------------------------------------------------------------- pieces
@@ -190,7 +191,16 @@
190191 [:dim-label {:label (:from m)}])]
191192 (if (:system? m)
192193 [:dim-label {:label (:text m)}]
193- [:label {:label (:text m)}])]))
194+ [:label {:label (:text m)}])
195+ ;; Pictures under the line that linked them. The link stays: it is what a
196+ ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
197+ [:vbox {:key :images :spacing 4}
198+ (when (seq (:images m))
199+ ;; Reading the tick is what subscribes this row to a fetch finishing.
200+ (let [_ @s/media-tick]
201+ (for [url (:images m)]
202+ (when-let [path (media/path-when-ready url)]
203+ [:image {:key url :src path :max-height 260}]))))]]))
194204
195205 (defn- message-rows [messages]
196206 (map-indexed (fn [i m] [message-row i (when (pos? i) (nth messages (dec i))) m])
@@ -7,6 +7,7 @@
7 (:require [glimmer.ratom :as r :refer [atom]]7 (:require [glimmer.ratom :as r :refer [atom]]
8 [glimmer.core :as ui]8 [glimmer.core :as ui]
9 [glimmer-vidya.core :as vidya]9 [glimmer-vidya.core :as vidya]
10+ [frq.media :as media]
10 [frq.state :as s]))11 [frq.state :as s]))
11 12
12 ;; ---------------------------------------------------------------- pieces13 ;; ---------------------------------------------------------------- pieces
@@ -190,7 +191,16 @@
190 [:dim-label {:label (:from m)}])]191 [:dim-label {:label (:from m)}])]
191 (if (:system? m)192 (if (:system? m)
192 [:dim-label {:label (:text m)}]193 [:dim-label {:label (:text m)}]
193- [:label {:label (:text m)}])]))194+ [:label {:label (:text m)}])
195+ ;; Pictures under the line that linked them. The link stays: it is what a
196+ ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
197+ [:vbox {:key :images :spacing 4}
198+ (when (seq (:images m))
199+ ;; Reading the tick is what subscribes this row to a fetch finishing.
200+ (let [_ @s/media-tick]
201+ (for [url (:images m)]
202+ (when-let [path (media/path-when-ready url)]
203+ [:image {:key url :src path :max-height 260}]))))]]))
194 204
195 (defn- message-rows [messages]205 (defn- message-rows [messages]
196 (map-indexed (fn [i m] [message-row i (when (pos? i) (nth messages (dec i))) m])206 (map-indexed (fn [i m] [message-row i (when (pos? i) (nth messages (dec i))) m])
added src/frq/media.jolt +62 -0
new file mode 100644
@@ -0,0 +1,62 @@
1+(ns frq.media
2+ "Images in messages: spot the links, fetch them once, keep them on disk.
3+
4+ The picture itself is painted by Vidya's `:image` node from a file, so all
5+ this has to do is turn a URL into a path — off the UI thread, one fetch per
6+ URL however many messages carry it, and never twice across runs."
7+ (:require [clojure.string :as str]
8+ [jolt.host :as host]
9+ [jolt.mvn-http :as http]))
10+
11+;; PNG only: it is what the tree backend decodes, and what freeq's own media
12+;; endpoint serves. A .jpg link stays a link.
13+(def ^:private image-pattern #"https?://[^\s]+\.png")
14+
15+(defn image-urls
16+ "Every image link in a message, in the order they appear."
17+ [text]
18+ (vec (distinct (re-seq image-pattern (or text "")))))
19+
20+(defn cache-dir []
21+ (let [xdg (host/getenv "XDG_CACHE_HOME")
22+ home (host/getenv "HOME")]
23+ (str (if (seq xdg) xdg (str home "/.cache")) "/frq/media")))
24+
25+(defn- cache-name
26+ "A filename for a URL: its own last segment behind a hash of the whole thing,
27+ so two `image.png` from different messages do not collide."
28+ [url]
29+ (let [h (Math/abs (hash url))
30+ tail (-> url (str/split #"/") last (str/replace #"[^A-Za-z0-9._-]" ""))]
31+ (str h "-" (subs tail (max 0 (- (count tail) 40))))))
32+
33+(defn cached-path [url] (str (cache-dir) "/" (cache-name url)))
34+
35+;; url -> :fetching | :ready | :failed
36+(defonce state (atom {}))
37+
38+(defn status [url] (get @state url))
39+
40+(defn path-when-ready [url]
41+ (when (= :ready (get @state url)) (cached-path url)))
42+
43+(defn fetch!
44+ "Ensure the image behind `url` is on disk, in the background. Returns without
45+ waiting; `path-when-ready` answers for it afterwards. `on-change` is called
46+ when the answer changes, so a UI can repaint."
47+ [url on-change]
48+ (when-not (contains? @state url)
49+ (let [path (cached-path url)]
50+ (if (host/file-exists? path)
51+ (do (swap! state assoc url :ready) (on-change))
52+ (do
53+ (swap! state assoc url :fetching)
54+ (future
55+ (let [ok (try
56+ (host/mkdirs! (cache-dir))
57+ (http/ensure-native!)
58+ (and (http/fetch url path)
59+ (host/file-exists? path))
60+ (catch Exception _ false))]
61+ (swap! state assoc url (if ok :ready :failed))
62+ (on-change))))))))
new file mode 100644
@@ -0,0 +1,62 @@
1+(ns frq.media
2+ "Images in messages: spot the links, fetch them once, keep them on disk.
3+
4+ The picture itself is painted by Vidya's `:image` node from a file, so all
5+ this has to do is turn a URL into a path — off the UI thread, one fetch per
6+ URL however many messages carry it, and never twice across runs."
7+ (:require [clojure.string :as str]
8+ [jolt.host :as host]
9+ [jolt.mvn-http :as http]))
10+
11+;; PNG only: it is what the tree backend decodes, and what freeq's own media
12+;; endpoint serves. A .jpg link stays a link.
13+(def ^:private image-pattern #"https?://[^\s]+\.png")
14+
15+(defn image-urls
16+ "Every image link in a message, in the order they appear."
17+ [text]
18+ (vec (distinct (re-seq image-pattern (or text "")))))
19+
20+(defn cache-dir []
21+ (let [xdg (host/getenv "XDG_CACHE_HOME")
22+ home (host/getenv "HOME")]
23+ (str (if (seq xdg) xdg (str home "/.cache")) "/frq/media")))
24+
25+(defn- cache-name
26+ "A filename for a URL: its own last segment behind a hash of the whole thing,
27+ so two `image.png` from different messages do not collide."
28+ [url]
29+ (let [h (Math/abs (hash url))
30+ tail (-> url (str/split #"/") last (str/replace #"[^A-Za-z0-9._-]" ""))]
31+ (str h "-" (subs tail (max 0 (- (count tail) 40))))))
32+
33+(defn cached-path [url] (str (cache-dir) "/" (cache-name url)))
34+
35+;; url -> :fetching | :ready | :failed
36+(defonce state (atom {}))
37+
38+(defn status [url] (get @state url))
39+
40+(defn path-when-ready [url]
41+ (when (= :ready (get @state url)) (cached-path url)))
42+
43+(defn fetch!
44+ "Ensure the image behind `url` is on disk, in the background. Returns without
45+ waiting; `path-when-ready` answers for it afterwards. `on-change` is called
46+ when the answer changes, so a UI can repaint."
47+ [url on-change]
48+ (when-not (contains? @state url)
49+ (let [path (cached-path url)]
50+ (if (host/file-exists? path)
51+ (do (swap! state assoc url :ready) (on-change))
52+ (do
53+ (swap! state assoc url :fetching)
54+ (future
55+ (let [ok (try
56+ (host/mkdirs! (cache-dir))
57+ (http/ensure-native!)
58+ (and (http/fetch url path)
59+ (host/file-exists? path))
60+ (catch Exception _ false))]
61+ (swap! state assoc url (if ok :ready :failed))
62+ (on-change))))))))
modified src/frq/state.jolt +11 -2
@@ -8,6 +8,7 @@
88 [glimmer.ratom :as r :refer [atom]]
99 [frq.atproto :as atproto]
1010 [frq.irc :as irc]
11+ [frq.media :as media]
1112 [frq.oauth :as oauth]
1213 [frq.store :as store]))
1314
@@ -78,17 +79,25 @@
7879 (assoc m name {:name name :messages [] :unread 0
7980 :joined? false :joining? false :accessed 0})))
8081
82+(defonce ^{:doc "Bumped whenever a fetched image becomes available, so the
83+ chat view re-renders without every message row watching the media cache."}
84+ media-tick (atom 0))
85+
8186 (defn push-message!
8287 "Append a line to a buffer, creating it if needed, and bump the unread count
83- unless that buffer is the one on screen."
88+ unless that buffer is the one on screen. Any image it links to is fetched in
89+ the background."
8490 [channel from text]
91+ (doseq [url (media/image-urls text)]
92+ (media/fetch! url #(swap! media-tick inc)))
8593 (swap! channels
8694 (fn [m]
8795 (let [m (ensure-channel m channel)
8896 viewing? (and (= :chat @screen) (= channel @current))]
8997 (-> m
9098 (update-in [channel :messages] conj
91- {:from from :text text :system? (= "*" from)})
99+ {:from from :text text :system? (= "*" from)
100+ :images (media/image-urls text)})
92101 (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))
93102
94103 (defn open-channel!
@@ -8,6 +8,7 @@
8 [glimmer.ratom :as r :refer [atom]]8 [glimmer.ratom :as r :refer [atom]]
9 [frq.atproto :as atproto]9 [frq.atproto :as atproto]
10 [frq.irc :as irc]10 [frq.irc :as irc]
11+ [frq.media :as media]
11 [frq.oauth :as oauth]12 [frq.oauth :as oauth]
12 [frq.store :as store]))13 [frq.store :as store]))
13 14
@@ -78,17 +79,25 @@
78 (assoc m name {:name name :messages [] :unread 079 (assoc m name {:name name :messages [] :unread 0
79 :joined? false :joining? false :accessed 0})))80 :joined? false :joining? false :accessed 0})))
80 81
82+(defonce ^{:doc "Bumped whenever a fetched image becomes available, so the
83+ chat view re-renders without every message row watching the media cache."}
84+ media-tick (atom 0))
85+
81 (defn push-message!86 (defn push-message!
82 "Append a line to a buffer, creating it if needed, and bump the unread count87 "Append a line to a buffer, creating it if needed, and bump the unread count
83- unless that buffer is the one on screen."88+ unless that buffer is the one on screen. Any image it links to is fetched in
89+ the background."
84 [channel from text]90 [channel from text]
91+ (doseq [url (media/image-urls text)]
92+ (media/fetch! url #(swap! media-tick inc)))
85 (swap! channels93 (swap! channels
86 (fn [m]94 (fn [m]
87 (let [m (ensure-channel m channel)95 (let [m (ensure-channel m channel)
88 viewing? (and (= :chat @screen) (= channel @current))]96 viewing? (and (= :chat @screen) (= channel @current))]
89 (-> m97 (-> m
90 (update-in [channel :messages] conj98 (update-in [channel :messages] conj
91- {:from from :text text :system? (= "*" from)})99+ {:from from :text text :system? (= "*" from)
100+ :images (media/image-urls text)})
92 (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))101 (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))
93 102
94 (defn open-channel!103 (defn open-channel!