nandi/frqpublic Fork 0
40f8817
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 in a Flutter conversation

A .png link on a line was a .png link and nothing else here: `:image-path`
was never installed, so `frq.screens.chat` asked where a picture was, got
nil, and drew the link it already had. The desktop has painted these for
months.

What both halves have to agree about — which links are pictures, and what
the file behind one is called — moves to `frq.media.core` under common, so a
reader on a phone and a reader at a desk see the same message. The hash
behind a cache name is our own FNV-1a over the UTF-8 bytes rather than
`hash`: the JVM's and Dart's disagree, and a name that changes with the
compiler is a cache re-filled once per backend for nothing.

The fetching cannot be shared and is not: `frq.media.dart` streams the URL
into the app's own storage over the same `HttpClient` the AT Protocol half
uses, once per URL however many messages carry it and never twice across
launches. Flutter's own image cache is memory and goes with the process,
which is every screenshot in the backlog re-fetched at every launch over the
phone's data.

Two things in the renderer come with it. `:fit` is the lightbox and now
means what it means on the desktop — every point below the row that closes
it — and an `errorBuilder` keeps a half-written cache file from taking the
whole conversation down with it during a build. And the window's size
reaches `frq.cells` from MediaQuery, after the frame rather than during it,
because that is what the shared screens size a preview against: without it
every screenshot was drawn at the 260-point fallback a terminal gets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-12T07:19:03-07:00 Browse files
40f8817 parent: 9126058
added common/frq/media/core.cljc +50 -0
new file mode 100644
@@ -0,0 +1,50 @@
1+(ns frq.media.core
2+ "What both halves agree about a picture in a message: which links are one,
3+ and what the file behind a link is called.
4+
5+ The fetching itself is not here and cannot be — jolt pulls a URL down over
6+ mvn-http and a future, Flutter over `HttpClient` and a Future, and neither
7+ is a thing `frq.io` can name for the other. What is portable is the part
8+ that has to agree: two backends that disagreed about which links are
9+ pictures would show different messages to the same reader."
10+ (:require [clojure.string :as str]
11+ [frq.io :as io]))
12+
13+;; PNG only: it is what both renderers decode, and what freeq's own media
14+;; endpoint serves. A .jpg link stays a link.
15+(def image-pattern #"https?://[^\s]+\.png")
16+
17+(defn image-urls
18+ "Every image link in a message, in the order they appear."
19+ [text]
20+ (vec (distinct (re-seq image-pattern (or text "")))))
21+
22+(defn- url-hash
23+ "A stable 32-bit hash of a URL.
24+
25+ Our own rather than `hash`, because `hash` is the host's: the JVM's and
26+ Dart's disagree, and a cache name that changes with the compiler is a cache
27+ that is re-fetched once per backend for no reason. FNV-1a over the UTF-8
28+ bytes the seam is asked for those rather than the string walked, because a
29+ string is a sequence of characters on one host and of code units on the
30+ other, and the bytes are the one spelling both agree on."
31+ [s]
32+ (reduce (fn [h b]
33+ (-> (bit-xor h b)
34+ (* 16777619)
35+ (bit-and 0xFFFFFFFF)))
36+ 2166136261
37+ (io/utf8-bytes (str s))))
38+
39+(defn cache-name
40+ "A filename for a URL: its own last segment behind a hash of the whole
41+ thing, so two `image.png` from different messages do not collide."
42+ [url]
43+ (let [h (url-hash url)
44+ tail (-> url (str/split #"/") last (str/replace #"[^A-Za-z0-9._-]" ""))]
45+ (str h "-" (subs tail (max 0 (- (count tail) 40))))))
46+
47+(defn cached-path
48+ "Where the picture behind `url` lives, under a directory the caller names."
49+ [dir url]
50+ (str dir "/" (cache-name url)))
new file mode 100644
@@ -0,0 +1,50 @@
1+(ns frq.media.core
2+ "What both halves agree about a picture in a message: which links are one,
3+ and what the file behind a link is called.
4+
5+ The fetching itself is not here and cannot be — jolt pulls a URL down over
6+ mvn-http and a future, Flutter over `HttpClient` and a Future, and neither
7+ is a thing `frq.io` can name for the other. What is portable is the part
8+ that has to agree: two backends that disagreed about which links are
9+ pictures would show different messages to the same reader."
10+ (:require [clojure.string :as str]
11+ [frq.io :as io]))
12+
13+;; PNG only: it is what both renderers decode, and what freeq's own media
14+;; endpoint serves. A .jpg link stays a link.
15+(def image-pattern #"https?://[^\s]+\.png")
16+
17+(defn image-urls
18+ "Every image link in a message, in the order they appear."
19+ [text]
20+ (vec (distinct (re-seq image-pattern (or text "")))))
21+
22+(defn- url-hash
23+ "A stable 32-bit hash of a URL.
24+
25+ Our own rather than `hash`, because `hash` is the host's: the JVM's and
26+ Dart's disagree, and a cache name that changes with the compiler is a cache
27+ that is re-fetched once per backend for no reason. FNV-1a over the UTF-8
28+ bytes the seam is asked for those rather than the string walked, because a
29+ string is a sequence of characters on one host and of code units on the
30+ other, and the bytes are the one spelling both agree on."
31+ [s]
32+ (reduce (fn [h b]
33+ (-> (bit-xor h b)
34+ (* 16777619)
35+ (bit-and 0xFFFFFFFF)))
36+ 2166136261
37+ (io/utf8-bytes (str s))))
38+
39+(defn cache-name
40+ "A filename for a URL: its own last segment behind a hash of the whole
41+ thing, so two `image.png` from different messages do not collide."
42+ [url]
43+ (let [h (url-hash url)
44+ tail (-> url (str/split #"/") last (str/replace #"[^A-Za-z0-9._-]" ""))]
45+ (str h "-" (subs tail (max 0 (- (count tail) 40))))))
46+
47+(defn cached-path
48+ "Where the picture behind `url` lives, under a directory the caller names."
49+ [dir url]
50+ (str dir "/" (cache-name url)))
modified flutter/src/frq/hiccup.cljd +21 -2
@@ -244,6 +244,9 @@
244244 p (second node)]
245245 (or (contains? #{:scroll :page} tag)
246246 (and (map? p) (:fill-height p))
247+ ;; The lightbox's one picture, which is `:fit` and means the same
248+ ;; thing: every point of the window below the row that closes it.
249+ (and (= :image tag) (map? p) (:fit p))
247250 ;; A `:page` fills for the same reason a `:scroll` does it is
248251 ;; one but it does not pass the question *up* from its
249252 ;; children, because nothing inside a scroll can take what is
@@ -665,14 +668,30 @@
665668 (let [src (or (:src p) (:path p))
666669 w (:max-width p)
667670 h (:max-height p)
671+ ;; A half-written cache file, or one deleted under us: the
672+ ;; decoder throws during the build, and an exception in a build
673+ ;; is a red screen for the whole conversation rather than a gap
674+ ;; where one picture was.
675+ oops (fn [_ _ _] (m/SizedBox .width 0.0 .height 0.0))
668676 img (cond
669677 (nil? src) (m/SizedBox .width 0.0 .height 0.0)
670678 (or (.startsWith (str src) "http://")
671679 (.startsWith (str src) "https://"))
672- (m/Image.network (str src) .fit m/BoxFit.contain)
673- :else (m/Image.file (io/File. (str src)) .fit m/BoxFit.contain))
680+ (m/Image.network (str src) .fit m/BoxFit.contain
681+ .errorBuilder oops)
682+ :else (m/Image.file (io/File. (str src)) .fit m/BoxFit.contain
683+ .errorBuilder oops))
674684 img (m/ClipRRect .borderRadius (m/BorderRadius.circular t/radius-s)
675685 .child img)
686+ ;; `:fit` is the lightbox: the picture takes what the column has
687+ ;; left, which `fills-column?` has already asked Flutter for, and
688+ ;; spreads across the width inside it. Without the SizedBox the
689+ ;; Expanded gives it the height and its own intrinsic width, and
690+ ;; a portrait screenshot is a strip up the middle.
691+ img (if (:fit p)
692+ (m/SizedBox .width double/infinity .height double/infinity
693+ .child img)
694+ img)
676695 img (if (or w h)
677696 (m/ConstrainedBox
678697 .constraints (m/BoxConstraints
@@ -244,6 +244,9 @@
244 p (second node)]244 p (second node)]
245 (or (contains? #{:scroll :page} tag)245 (or (contains? #{:scroll :page} tag)
246 (and (map? p) (:fill-height p))246 (and (map? p) (:fill-height p))
247+ ;; The lightbox's one picture, which is `:fit` and means the same
248+ ;; thing: every point of the window below the row that closes it.
249+ (and (= :image tag) (map? p) (:fit p))
247 ;; A `:page` fills for the same reason a `:scroll` does it is250 ;; A `:page` fills for the same reason a `:scroll` does it is
248 ;; one but it does not pass the question *up* from its251 ;; one but it does not pass the question *up* from its
249 ;; children, because nothing inside a scroll can take what is252 ;; children, because nothing inside a scroll can take what is
@@ -665,14 +668,30 @@
665 (let [src (or (:src p) (:path p))668 (let [src (or (:src p) (:path p))
666 w (:max-width p)669 w (:max-width p)
667 h (:max-height p)670 h (:max-height p)
671+ ;; A half-written cache file, or one deleted under us: the
672+ ;; decoder throws during the build, and an exception in a build
673+ ;; is a red screen for the whole conversation rather than a gap
674+ ;; where one picture was.
675+ oops (fn [_ _ _] (m/SizedBox .width 0.0 .height 0.0))
668 img (cond676 img (cond
669 (nil? src) (m/SizedBox .width 0.0 .height 0.0)677 (nil? src) (m/SizedBox .width 0.0 .height 0.0)
670 (or (.startsWith (str src) "http://")678 (or (.startsWith (str src) "http://")
671 (.startsWith (str src) "https://"))679 (.startsWith (str src) "https://"))
672- (m/Image.network (str src) .fit m/BoxFit.contain)680+ (m/Image.network (str src) .fit m/BoxFit.contain
673- :else (m/Image.file (io/File. (str src)) .fit m/BoxFit.contain))681+ .errorBuilder oops)
682+ :else (m/Image.file (io/File. (str src)) .fit m/BoxFit.contain
683+ .errorBuilder oops))
674 img (m/ClipRRect .borderRadius (m/BorderRadius.circular t/radius-s)684 img (m/ClipRRect .borderRadius (m/BorderRadius.circular t/radius-s)
675 .child img)685 .child img)
686+ ;; `:fit` is the lightbox: the picture takes what the column has
687+ ;; left, which `fills-column?` has already asked Flutter for, and
688+ ;; spreads across the width inside it. Without the SizedBox the
689+ ;; Expanded gives it the height and its own intrinsic width, and
690+ ;; a portrait screenshot is a strip up the middle.
691+ img (if (:fit p)
692+ (m/SizedBox .width double/infinity .height double/infinity
693+ .child img)
694+ img)
676 img (if (or w h)695 img (if (or w h)
677 (m/ConstrainedBox696 (m/ConstrainedBox
678 .constraints (m/BoxConstraints697 .constraints (m/BoxConstraints
modified flutter/src/frq/main.cljd +58 -4
@@ -30,6 +30,8 @@
3030 [frq.net.dart :as net]
3131 [frq.atproto.dart :as atproto]
3232 [frq.clock :as clock]
33+ [frq.media.core :as media-core]
34+ [frq.media.dart :as media]
3335 [frq.store :as store]
3436 [frq.actions :as actions]
3537 [frq.cells :as cells]
@@ -74,6 +76,42 @@
7476 (swap! repaint inc)
7577 nil)
7678
79+(defn- note-window-size!
80+ "Tell `frq.cells` how big the window is, from the build that knows.
81+
82+ The shared screens size a picture against it — `preview-height` is four
83+ fifths of the window and falls back to a fixed 260 while nobody has said —
84+ so without this a phone drew every screenshot as the same stamp a terminal
85+ gets. The desktop sets these from libcosmic's resize event; Flutter's
86+ equivalent is MediaQuery, which is only readable from a build.
87+
88+ After the frame, and only when it changed. A cell written during a build is
89+ a setState during a build, which Flutter refuses; and every cell here is
90+ watched, so writing the same number back on every frame would be an endless
91+ rebuild rather than a wasted one."
92+ [^m/Size size]
93+ (let [w (long (.-width size))
94+ h (long (.-height size))]
95+ (when (or (not= w @cells/window-width) (not= h @cells/window-height))
96+ (.addPostFrameCallback
97+ (.-instance m/WidgetsBinding)
98+ (fn [_]
99+ (reset! cells/window-width w)
100+ (reset! cells/window-height h)
101+ nil)))))
102+
103+(defn- images-in
104+ "The picture links in a message, fetched as they are noticed.
105+
106+ Both at once on purpose: the list is what the row draws and the fetch is
107+ what turns a link into a path, and a line whose images were recorded and
108+ never fetched is a row that waits for ever. `frq.media.dart` is the once-only
109+ half — this is called again for every replayed line on every reconnect."
110+ [text]
111+ (let [urls (media-core/image-urls text)]
112+ (doseq [url urls] (media/fetch! url bump!))
113+ urls))
114+
77115 (def ^:private history-limit
78116 "How many lines of backlog to ask a room for, as `frq.state` asks for them."
79117 100)
@@ -171,6 +209,7 @@
171209 #(-> (merge {:name name :messages [] :unread 0} %)
172210 (update :messages conj {:from who
173211 :text text
212+ :images (images-in text)
174213 :did (:account m)
175214 :id edit-of
176215 :edited? true})))))
@@ -183,6 +222,9 @@
183222 ;; nothing can be said about it.
184223 {:from who
185224 :text text
225+ ;; Any .png link on the line, and the fetch that
226+ ;; makes one a picture rather than a link.
227+ :images (images-in text)
186228 :did (:account m)
187229 ;; Who to look a profile up by: the DID the
188230 ;; server put on the line, or the nick when that
@@ -694,6 +736,17 @@
694736 ;; Their profile on the web, handed to the browser the same way the
695737 ;; sign-in link is.
696738 :open-url! (fn [url] (fio/open-url! url))
739+
740+ ;; Where a picture is on disk, once it is. A plain lookup and not the
741+ ;; desktop's reaction: glimmer wakes the one row that read it, and this
742+ ;; half repaints the tree — `images-in` ticks `repaint` when a fetch
743+ ;; lands, and Flutter's own reconciler decides what that costs.
744+ ;;
745+ ;; A path and not the URL `:avatar-path` below answers with, and the
746+ ;; difference is what is behind them: a face is a CDN URL the network
747+ ;; will serve again, a picture in a message is whatever host a stranger
748+ ;; put a link to.
749+ :image-path (fn [url] (media/path-when-ready url))
697750 :wide? (fn [] false)
698751 :desktop? (fn [] false)
699752 :mine? (fn [m] (rooms/mine? m (str @cells/form-nick)))
@@ -778,11 +831,12 @@
778831 .body
779832 m/SafeArea
780833 (f/widget
834+ :context ctx
781835 ;; One watch, not twenty. `lines` is a local atom and not a cell, so it
782836 ;; is named beside the tick; everything under `frq.cells` arrives
783837 ;; through `repaint`.
784838 :watch [tick repaint ls lines]
785- (h/render
786- ;; `frq.screens.app` decides which screen shows, the same way it does
787- ;; on the desktop. The phone was switching by hand until this moved.
788- [screens/app])))))
839+ (let [_ (note-window-size! (.-size (m/MediaQuery.of ctx)))]
840+ ;; `frq.screens.app` decides which screen shows, the same way it does
841+ ;; on the desktop. The phone was switching by hand until this moved.
842+ (h/render [screens/app]))))))
@@ -30,6 +30,8 @@
30 [frq.net.dart :as net]30 [frq.net.dart :as net]
31 [frq.atproto.dart :as atproto]31 [frq.atproto.dart :as atproto]
32 [frq.clock :as clock]32 [frq.clock :as clock]
33+ [frq.media.core :as media-core]
34+ [frq.media.dart :as media]
33 [frq.store :as store]35 [frq.store :as store]
34 [frq.actions :as actions]36 [frq.actions :as actions]
35 [frq.cells :as cells]37 [frq.cells :as cells]
@@ -74,6 +76,42 @@
74 (swap! repaint inc)76 (swap! repaint inc)
75 nil)77 nil)
76 78
79+(defn- note-window-size!
80+ "Tell `frq.cells` how big the window is, from the build that knows.
81+
82+ The shared screens size a picture against it — `preview-height` is four
83+ fifths of the window and falls back to a fixed 260 while nobody has said —
84+ so without this a phone drew every screenshot as the same stamp a terminal
85+ gets. The desktop sets these from libcosmic's resize event; Flutter's
86+ equivalent is MediaQuery, which is only readable from a build.
87+
88+ After the frame, and only when it changed. A cell written during a build is
89+ a setState during a build, which Flutter refuses; and every cell here is
90+ watched, so writing the same number back on every frame would be an endless
91+ rebuild rather than a wasted one."
92+ [^m/Size size]
93+ (let [w (long (.-width size))
94+ h (long (.-height size))]
95+ (when (or (not= w @cells/window-width) (not= h @cells/window-height))
96+ (.addPostFrameCallback
97+ (.-instance m/WidgetsBinding)
98+ (fn [_]
99+ (reset! cells/window-width w)
100+ (reset! cells/window-height h)
101+ nil)))))
102+
103+(defn- images-in
104+ "The picture links in a message, fetched as they are noticed.
105+
106+ Both at once on purpose: the list is what the row draws and the fetch is
107+ what turns a link into a path, and a line whose images were recorded and
108+ never fetched is a row that waits for ever. `frq.media.dart` is the once-only
109+ half — this is called again for every replayed line on every reconnect."
110+ [text]
111+ (let [urls (media-core/image-urls text)]
112+ (doseq [url urls] (media/fetch! url bump!))
113+ urls))
114+
77 (def ^:private history-limit115 (def ^:private history-limit
78 "How many lines of backlog to ask a room for, as `frq.state` asks for them."116 "How many lines of backlog to ask a room for, as `frq.state` asks for them."
79 100)117 100)
@@ -171,6 +209,7 @@
171 #(-> (merge {:name name :messages [] :unread 0} %)209 #(-> (merge {:name name :messages [] :unread 0} %)
172 (update :messages conj {:from who210 (update :messages conj {:from who
173 :text text211 :text text
212+ :images (images-in text)
174 :did (:account m)213 :did (:account m)
175 :id edit-of214 :id edit-of
176 :edited? true})))))215 :edited? true})))))
@@ -183,6 +222,9 @@
183 ;; nothing can be said about it.222 ;; nothing can be said about it.
184 {:from who223 {:from who
185 :text text224 :text text
225+ ;; Any .png link on the line, and the fetch that
226+ ;; makes one a picture rather than a link.
227+ :images (images-in text)
186 :did (:account m)228 :did (:account m)
187 ;; Who to look a profile up by: the DID the229 ;; Who to look a profile up by: the DID the
188 ;; server put on the line, or the nick when that230 ;; server put on the line, or the nick when that
@@ -694,6 +736,17 @@
694 ;; Their profile on the web, handed to the browser the same way the736 ;; Their profile on the web, handed to the browser the same way the
695 ;; sign-in link is.737 ;; sign-in link is.
696 :open-url! (fn [url] (fio/open-url! url))738 :open-url! (fn [url] (fio/open-url! url))
739+
740+ ;; Where a picture is on disk, once it is. A plain lookup and not the
741+ ;; desktop's reaction: glimmer wakes the one row that read it, and this
742+ ;; half repaints the tree — `images-in` ticks `repaint` when a fetch
743+ ;; lands, and Flutter's own reconciler decides what that costs.
744+ ;;
745+ ;; A path and not the URL `:avatar-path` below answers with, and the
746+ ;; difference is what is behind them: a face is a CDN URL the network
747+ ;; will serve again, a picture in a message is whatever host a stranger
748+ ;; put a link to.
749+ :image-path (fn [url] (media/path-when-ready url))
697 :wide? (fn [] false)750 :wide? (fn [] false)
698 :desktop? (fn [] false)751 :desktop? (fn [] false)
699 :mine? (fn [m] (rooms/mine? m (str @cells/form-nick)))752 :mine? (fn [m] (rooms/mine? m (str @cells/form-nick)))
@@ -778,11 +831,12 @@
778 .body831 .body
779 m/SafeArea832 m/SafeArea
780 (f/widget833 (f/widget
834+ :context ctx
781 ;; One watch, not twenty. `lines` is a local atom and not a cell, so it835 ;; One watch, not twenty. `lines` is a local atom and not a cell, so it
782 ;; is named beside the tick; everything under `frq.cells` arrives836 ;; is named beside the tick; everything under `frq.cells` arrives
783 ;; through `repaint`.837 ;; through `repaint`.
784 :watch [tick repaint ls lines]838 :watch [tick repaint ls lines]
785- (h/render839+ (let [_ (note-window-size! (.-size (m/MediaQuery.of ctx)))]
786- ;; `frq.screens.app` decides which screen shows, the same way it does840+ ;; `frq.screens.app` decides which screen shows, the same way it does
787- ;; on the desktop. The phone was switching by hand until this moved.841+ ;; on the desktop. The phone was switching by hand until this moved.
788- [screens/app])))))842+ (h/render [screens/app]))))))
added flutter/src/frq/media/dart.cljd +98 -0
new file mode 100644
@@ -0,0 +1,98 @@
1+(ns frq.media.dart
2+ "Images in messages, Flutter's half: spot the links, fetch them once, keep
3+ them on disk.
4+
5+ The mirror of `frq.media` on the desktop, and the same shape a URL goes in,
6+ a path on disk comes back once it is there, and never more than one fetch per
7+ URL however many messages carry it or however many times a row is rebuilt.
8+ `frq.media.core` under ../common is what the two halves agree about: which
9+ links are pictures, and what the file behind one is called.
10+
11+ Why a file at all, when `:image` already paints an `Image.network` from a
12+ URL and Flutter has an image cache of its own: that cache is in memory and
13+ goes with the process, so every launch re-fetched every picture in the
14+ backlog over the phone's data. And the lightbox and the composer's
15+ attachment are paths rather than URLs, so a picture on disk is the shape the
16+ rest of the app already speaks.
17+
18+ `HttpClient` and not `package:http`: it is the client `frq.atproto.dart`
19+ already uses, it is in the runtime, and what is wanted here is the byte
20+ stream rather than the string that one decodes."
21+ (:require ["dart:async" :as async]
22+ ["dart:io" :as io]
23+ [frq.io :as fio]
24+ [frq.media.core :as core]))
25+
26+(defonce ^:private client (io/HttpClient.))
27+
28+(defn cache-dir
29+ "Under the app's own storage, which is where a phone has to put this: there
30+ is no XDG cache directory on Android, and the directory path_provider hands
31+ back is the one place that is ours on both of the targets this file
32+ compiles for."
33+ []
34+ (str (fio/config-dir) "/media"))
35+
36+(defn cached-path [url] (core/cached-path (cache-dir) url))
37+
38+;; url -> :fetching | :ready | :failed
39+(defonce ^:private state (atom {}))
40+
41+(defn path-when-ready
42+ "Where the picture behind `url` is, or nil while it is not there yet. The
43+ screens already read nil as \"not here\" and leave the link standing."
44+ [url]
45+ (when (= :ready (get @state url)) (cached-path url)))
46+
47+(defn ^:async ^:private download!
48+ "One URL into one file. True when the file is on disk afterwards.
49+
50+ Anything but a 200 is a failure with nothing written: a 404 body saved under
51+ the cache path is a picture that has arrived as far as the rest of this
52+ namespace can tell, and will never be asked for again."
53+ [url path]
54+ (try
55+ (let [^io/HttpClientRequest req (await (.getUrl ^io/HttpClient client
56+ (Uri.parse (str url))))]
57+ (.set (.-headers req) "user-agent" "frq")
58+ (let [^io/HttpClientResponse resp (await (.close req))]
59+ (if (= 200 (.-statusCode resp))
60+ (let [f (io/File. path)
61+ sink (.openWrite f)]
62+ ;; Streamed into the file rather than consolidated in memory
63+ ;; first: a screenshot off a modern phone is several megabytes,
64+ ;; and there may be a screen of them.
65+ (await (.addStream sink resp))
66+ (await (.close sink))
67+ (.existsSync f))
68+ ;; Drained, or the connection is held open until the socket times
69+ ;; out `HttpClient` keeps them alive and hands them back out.
70+ (do (await (.drain resp)) false))))
71+ (catch Exception _ false)))
72+
73+(defn fetch!
74+ "Ensure the image behind `url` is on disk, in the background. Returns without
75+ waiting; `path-when-ready` answers for it afterwards. `on-change` is called
76+ when the answer changes, so the screen can repaint.
77+
78+ The state map is what makes this once-only, and it is consulted before the
79+ disk is: a message row is rebuilt whenever anything in the conversation
80+ changes, and a `statSync` per picture per rebuild is a cost with no new
81+ answer in it."
82+ [url on-change]
83+ (when-not (contains? @state url)
84+ (let [path (cached-path url)]
85+ (if (fio/file-exists? path)
86+ (do (swap! state assoc url :ready) (on-change))
87+ (do
88+ (swap! state assoc url :fetching)
89+ (fio/mkdirs! (cache-dir))
90+ (.then ^async/Future (download! url path)
91+ (fn [ok]
92+ (swap! state assoc url (if ok :ready :failed))
93+ (on-change)
94+ nil)
95+ .onError (fn [_ _]
96+ (swap! state assoc url :failed)
97+ (on-change)
98+ nil)))))))
new file mode 100644
@@ -0,0 +1,98 @@
1+(ns frq.media.dart
2+ "Images in messages, Flutter's half: spot the links, fetch them once, keep
3+ them on disk.
4+
5+ The mirror of `frq.media` on the desktop, and the same shape a URL goes in,
6+ a path on disk comes back once it is there, and never more than one fetch per
7+ URL however many messages carry it or however many times a row is rebuilt.
8+ `frq.media.core` under ../common is what the two halves agree about: which
9+ links are pictures, and what the file behind one is called.
10+
11+ Why a file at all, when `:image` already paints an `Image.network` from a
12+ URL and Flutter has an image cache of its own: that cache is in memory and
13+ goes with the process, so every launch re-fetched every picture in the
14+ backlog over the phone's data. And the lightbox and the composer's
15+ attachment are paths rather than URLs, so a picture on disk is the shape the
16+ rest of the app already speaks.
17+
18+ `HttpClient` and not `package:http`: it is the client `frq.atproto.dart`
19+ already uses, it is in the runtime, and what is wanted here is the byte
20+ stream rather than the string that one decodes."
21+ (:require ["dart:async" :as async]
22+ ["dart:io" :as io]
23+ [frq.io :as fio]
24+ [frq.media.core :as core]))
25+
26+(defonce ^:private client (io/HttpClient.))
27+
28+(defn cache-dir
29+ "Under the app's own storage, which is where a phone has to put this: there
30+ is no XDG cache directory on Android, and the directory path_provider hands
31+ back is the one place that is ours on both of the targets this file
32+ compiles for."
33+ []
34+ (str (fio/config-dir) "/media"))
35+
36+(defn cached-path [url] (core/cached-path (cache-dir) url))
37+
38+;; url -> :fetching | :ready | :failed
39+(defonce ^:private state (atom {}))
40+
41+(defn path-when-ready
42+ "Where the picture behind `url` is, or nil while it is not there yet. The
43+ screens already read nil as \"not here\" and leave the link standing."
44+ [url]
45+ (when (= :ready (get @state url)) (cached-path url)))
46+
47+(defn ^:async ^:private download!
48+ "One URL into one file. True when the file is on disk afterwards.
49+
50+ Anything but a 200 is a failure with nothing written: a 404 body saved under
51+ the cache path is a picture that has arrived as far as the rest of this
52+ namespace can tell, and will never be asked for again."
53+ [url path]
54+ (try
55+ (let [^io/HttpClientRequest req (await (.getUrl ^io/HttpClient client
56+ (Uri.parse (str url))))]
57+ (.set (.-headers req) "user-agent" "frq")
58+ (let [^io/HttpClientResponse resp (await (.close req))]
59+ (if (= 200 (.-statusCode resp))
60+ (let [f (io/File. path)
61+ sink (.openWrite f)]
62+ ;; Streamed into the file rather than consolidated in memory
63+ ;; first: a screenshot off a modern phone is several megabytes,
64+ ;; and there may be a screen of them.
65+ (await (.addStream sink resp))
66+ (await (.close sink))
67+ (.existsSync f))
68+ ;; Drained, or the connection is held open until the socket times
69+ ;; out `HttpClient` keeps them alive and hands them back out.
70+ (do (await (.drain resp)) false))))
71+ (catch Exception _ false)))
72+
73+(defn fetch!
74+ "Ensure the image behind `url` is on disk, in the background. Returns without
75+ waiting; `path-when-ready` answers for it afterwards. `on-change` is called
76+ when the answer changes, so the screen can repaint.
77+
78+ The state map is what makes this once-only, and it is consulted before the
79+ disk is: a message row is rebuilt whenever anything in the conversation
80+ changes, and a `statSync` per picture per rebuild is a cost with no new
81+ answer in it."
82+ [url on-change]
83+ (when-not (contains? @state url)
84+ (let [path (cached-path url)]
85+ (if (fio/file-exists? path)
86+ (do (swap! state assoc url :ready) (on-change))
87+ (do
88+ (swap! state assoc url :fetching)
89+ (fio/mkdirs! (cache-dir))
90+ (.then ^async/Future (download! url path)
91+ (fn [ok]
92+ (swap! state assoc url (if ok :ready :failed))
93+ (on-change)
94+ nil)
95+ .onError (fn [_ _]
96+ (swap! state assoc url :failed)
97+ (on-change)
98+ nil)))))))
modified src/frq/media.clj +9 -19
@@ -3,34 +3,24 @@
33
44 The picture itself is painted by Vidya's `:image` node from a file, so all
55 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]
6+ URL however many messages carry it, and never twice across runs.
7+
8+ The naming half of it is not here: which links are pictures and what the
9+ file behind one is called are things the Flutter half has to agree with, so
10+ they live in `frq.media.core` under ../common and this is the jolt fetching
11+ around them."
12+ (:require [frq.media.core :as core]
813 [jolt.host :as host]
914 [jolt.mvn-http :as http]))
1015
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 "")))))
16+(def image-urls core/image-urls)
1917
2018 (defn cache-dir []
2119 (let [xdg (host/getenv "XDG_CACHE_HOME")
2220 home (host/getenv "HOME")]
2321 (str (if (seq xdg) xdg (str home "/.cache")) "/frq/media")))
2422
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)))
23+(defn cached-path [url] (core/cached-path (cache-dir) url))
3424
3525 ;; url -> :fetching | :ready | :failed
3626 (defonce state (atom {}))
@@ -3,34 +3,24 @@
3 3
4 The picture itself is painted by Vidya's `:image` node from a file, so all4 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 per5 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."6+ URL however many messages carry it, and never twice across runs.
7- (:require [clojure.string :as str]7+
8+ The naming half of it is not here: which links are pictures and what the
9+ file behind one is called are things the Flutter half has to agree with, so
10+ they live in `frq.media.core` under ../common and this is the jolt fetching
11+ around them."
12+ (:require [frq.media.core :as core]
8 [jolt.host :as host]13 [jolt.host :as host]
9 [jolt.mvn-http :as http]))14 [jolt.mvn-http :as http]))
10 15
11-;; PNG only: it is what the tree backend decodes, and what freeq's own media16+(def image-urls core/image-urls)
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 17
20 (defn cache-dir []18 (defn cache-dir []
21 (let [xdg (host/getenv "XDG_CACHE_HOME")19 (let [xdg (host/getenv "XDG_CACHE_HOME")
22 home (host/getenv "HOME")]20 home (host/getenv "HOME")]
23 (str (if (seq xdg) xdg (str home "/.cache")) "/frq/media")))21 (str (if (seq xdg) xdg (str home "/.cache")) "/frq/media")))
24 22
25-(defn- cache-name23+(defn cached-path [url] (core/cached-path (cache-dir) url))
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 24
35 ;; url -> :fetching | :ready | :failed25 ;; url -> :fetching | :ready | :failed
36 (defonce state (atom {}))26 (defonce state (atom {}))