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>
56cdac3 parent: 94e59a2 modified
README.md +7 -0 | @@ -15,6 +15,7 @@ of an `AppState` struct. | ||
| 15 | 15 | src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads |
| 16 | 16 | src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session |
| 17 | 17 | 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 | 19 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| 19 | 20 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` |
| 20 | 21 | src/frq/app.jolt the screens |
| @@ -102,6 +103,8 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 102 | 103 | * Auto-joins `#test` on `irc.freeq.at` |
| 103 | 104 | * Join channels, channel buffers with unread counts, send and receive `PRIVMSG` |
| 104 | 105 | * 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 | 108 | * Join/part notices, DMs bucketed under the sender's nick |
| 106 | 109 | * Discover list, search over buffers, disconnect |
| 107 | 110 | * Conversations listed most recently opened first |
| @@ -114,6 +117,10 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 114 | 117 | guest. |
| 115 | 118 | * **Only the broker token is persisted**, and only for OAuth. An app-password |
| 116 | 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 | 124 | * **No scrollback trimming, avatars, reactions, threads, or calls.** |
| 118 | 125 | * A sent line waits up to 200ms for the reader thread to flush it. |
| 119 | 126 | * 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 payloads | 15 | src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads |
| 16 | src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session | 16 | 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 directory | 17 | 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, PRIVMSG | 19 | 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 screens | 21 | 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 instead | 105 | * 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 nick | 108 | * Join/part notices, DMs bucketed under the sender's nick |
| 106 | * Discover list, search over buffers, disconnect | 109 | * Discover list, search over buffers, disconnect |
| 107 | * Conversations listed most recently opened first | 110 | * 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-password | 118 | * **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 @@ | ||
| 7 | 7 | (:require [glimmer.ratom :as r :refer [atom]] |
| 8 | 8 | [glimmer.core :as ui] |
| 9 | 9 | [glimmer-vidya.core :as vidya] |
| 10 | + [frq.media :as media] | |
| 10 | 11 | [frq.state :as s])) |
| 11 | 12 | |
| 12 | 13 | ;; ---------------------------------------------------------------- pieces |
| @@ -190,7 +191,16 @@ | ||
| 190 | 191 | [:dim-label {:label (:from m)}])] |
| 191 | 192 | (if (:system? m) |
| 192 | 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 | 205 | (defn- message-rows [messages] |
| 196 | 206 | (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 | ;; ---------------------------------------------------------------- pieces | 13 | ;; ---------------------------------------------------------------- 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 @@ | ||
| 8 | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | 9 | [frq.atproto :as atproto] |
| 10 | 10 | [frq.irc :as irc] |
| 11 | + [frq.media :as media] | |
| 11 | 12 | [frq.oauth :as oauth] |
| 12 | 13 | [frq.store :as store])) |
| 13 | 14 | |
| @@ -78,17 +79,25 @@ | ||
| 78 | 79 | (assoc m name {:name name :messages [] :unread 0 |
| 79 | 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 | 86 | (defn push-message! |
| 82 | 87 | "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 | 90 | [channel from text] |
| 91 | + (doseq [url (media/image-urls text)] | |
| 92 | + (media/fetch! url #(swap! media-tick inc))) | |
| 85 | 93 | (swap! channels |
| 86 | 94 | (fn [m] |
| 87 | 95 | (let [m (ensure-channel m channel) |
| 88 | 96 | viewing? (and (= :chat @screen) (= channel @current))] |
| 89 | 97 | (-> m |
| 90 | 98 | (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 | 101 | (update-in [channel :unread] (if viewing? (constantly 0) inc))))))) |
| 93 | 102 | |
| 94 | 103 | (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 0 | 79 | (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 count | 87 | "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! channels | 93 | (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 | (-> m | 97 | (-> m |
| 90 | (update-in [channel :messages] conj | 98 | (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! |