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>
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 @@ | ||
| 244 | 244 | p (second node)] |
| 245 | 245 | (or (contains? #{:scroll :page} tag) |
| 246 | 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 | 250 | ;; A `:page` fills for the same reason a `:scroll` does — it is |
| 248 | 251 | ;; one — but it does not pass the question *up* from its |
| 249 | 252 | ;; children, because nothing inside a scroll can take what is |
| @@ -665,14 +668,30 @@ | ||
| 665 | 668 | (let [src (or (:src p) (:path p)) |
| 666 | 669 | w (:max-width p) |
| 667 | 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 | 676 | img (cond |
| 669 | 677 | (nil? src) (m/SizedBox .width 0.0 .height 0.0) |
| 670 | 678 | (or (.startsWith (str src) "http://") |
| 671 | 679 | (.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)) | |
| 674 | 684 | img (m/ClipRRect .borderRadius (m/BorderRadius.circular t/radius-s) |
| 675 | 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 | 695 | img (if (or w h) |
| 677 | 696 | (m/ConstrainedBox |
| 678 | 697 | .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 is | 250 | ;; A `:page` fills for the same reason a `:scroll` does — it is |
| 248 | ;; one — but it does not pass the question *up* from its | 251 | ;; one — but it does not pass the question *up* from its |
| 249 | ;; children, because nothing inside a scroll can take what is | 252 | ;; 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 (cond | 676 | 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/ConstrainedBox | 696 | (m/ConstrainedBox |
| 678 | .constraints (m/BoxConstraints | 697 | .constraints (m/BoxConstraints |
modified
flutter/src/frq/main.cljd +58 -4 | @@ -30,6 +30,8 @@ | ||
| 30 | 30 | [frq.net.dart :as net] |
| 31 | 31 | [frq.atproto.dart :as atproto] |
| 32 | 32 | [frq.clock :as clock] |
| 33 | + [frq.media.core :as media-core] | |
| 34 | + [frq.media.dart :as media] | |
| 33 | 35 | [frq.store :as store] |
| 34 | 36 | [frq.actions :as actions] |
| 35 | 37 | [frq.cells :as cells] |
| @@ -74,6 +76,42 @@ | ||
| 74 | 76 | (swap! repaint inc) |
| 75 | 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 | 115 | (def ^:private history-limit |
| 78 | 116 | "How many lines of backlog to ask a room for, as `frq.state` asks for them." |
| 79 | 117 | 100) |
| @@ -171,6 +209,7 @@ | ||
| 171 | 209 | #(-> (merge {:name name :messages [] :unread 0} %) |
| 172 | 210 | (update :messages conj {:from who |
| 173 | 211 | :text text |
| 212 | + :images (images-in text) | |
| 174 | 213 | :did (:account m) |
| 175 | 214 | :id edit-of |
| 176 | 215 | :edited? true}))))) |
| @@ -183,6 +222,9 @@ | ||
| 183 | 222 | ;; nothing can be said about it. |
| 184 | 223 | {:from who |
| 185 | 224 | :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 | 228 | :did (:account m) |
| 187 | 229 | ;; Who to look a profile up by: the DID the |
| 188 | 230 | ;; server put on the line, or the nick when that |
| @@ -694,6 +736,17 @@ | ||
| 694 | 736 | ;; Their profile on the web, handed to the browser the same way the |
| 695 | 737 | ;; sign-in link is. |
| 696 | 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 | 750 | :wide? (fn [] false) |
| 698 | 751 | :desktop? (fn [] false) |
| 699 | 752 | :mine? (fn [m] (rooms/mine? m (str @cells/form-nick))) |
| @@ -778,11 +831,12 @@ | ||
| 778 | 831 | .body |
| 779 | 832 | m/SafeArea |
| 780 | 833 | (f/widget |
| 834 | + :context ctx | |
| 781 | 835 | ;; One watch, not twenty. `lines` is a local atom and not a cell, so it |
| 782 | 836 | ;; is named beside the tick; everything under `frq.cells` arrives |
| 783 | 837 | ;; through `repaint`. |
| 784 | 838 | :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-limit | 115 | (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 who | 210 | (update :messages conj {:from who |
| 173 | :text text | 211 | :text text |
| 212 | + :images (images-in text) | ||
| 174 | :did (:account m) | 213 | :did (:account m) |
| 175 | :id edit-of | 214 | :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 who | 223 | {:from who |
| 185 | :text text | 224 | :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 the | 229 | ;; Who to look a profile up by: the DID the |
| 188 | ;; server put on the line, or the nick when that | 230 | ;; 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 the | 736 | ;; 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 | .body | 831 | .body |
| 779 | m/SafeArea | 832 | m/SafeArea |
| 780 | (f/widget | 833 | (f/widget |
| 834 | + :context ctx | ||
| 781 | ;; One watch, not twenty. `lines` is a local atom and not a cell, so it | 835 | ;; 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` arrives | 836 | ;; 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/render | 839 | + (let [_ (note-window-size! (.-size (m/MediaQuery.of ctx)))] |
| 786 | - ;; `frq.screens.app` decides which screen shows, the same way it does | 840 | + ;; `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 @@ | ||
| 3 | 3 | |
| 4 | 4 | The picture itself is painted by Vidya's `:image` node from a file, so all |
| 5 | 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] | |
| 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] | |
| 8 | 13 | [jolt.host :as host] |
| 9 | 14 | [jolt.mvn-http :as http])) |
| 10 | 15 | |
| 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) | |
| 19 | 17 | |
| 20 | 18 | (defn cache-dir [] |
| 21 | 19 | (let [xdg (host/getenv "XDG_CACHE_HOME") |
| 22 | 20 | home (host/getenv "HOME")] |
| 23 | 21 | (str (if (seq xdg) xdg (str home "/.cache")) "/frq/media"))) |
| 24 | 22 | |
| 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)) | |
| 34 | 24 | |
| 35 | 25 | ;; url -> :fetching | :ready | :failed |
| 36 | 26 | (defonce state (atom {})) |
| @@ -3,34 +3,24 @@ | |||
| 3 | 3 | ||
| 4 | The picture itself is painted by Vidya's `:image` node from a file, so all | 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 | 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." | 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 media | 16 | +(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-name | 23 | +(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 | :failed | 25 | ;; url -> :fetching | :ready | :failed |
| 36 | (defonce state (atom {})) | 26 | (defonce state (atom {})) |