A link in a message is a thing you can follow again
Two bugs, either of which alone was enough to kill every link, and which together made each other hard to see. The tap never arrived. The app-wide SelectionArea takes it before a `TextSpan.recognizer` under it sees one, and links in a message became spans the day they stopped being InkWells -- the change that let a URL break inside a sentence instead of taking a line of its own. So the selection goes and the recognizer stays. `SelectableText.rich` was tried in between and is worse: it does not dispatch to span recognizers either. Copying a line out of a chat is a real loss and the trade is recorded where the widget used to be; getting both back needs a paragraph that hit-tests its own runs, which is work in `frq.hiccup` and not a widget you wrap the tree in. And `open-url!` never opened anything. `(.open html/window u "_blank")` compiled to no call at all: `window.open(` appears zero times in main.dart.js, so the function logged nothing, answered true, and did nothing -- which also meant the `some?` check around it was always true and could not fall back. An anchor the page clicks for itself is what `url_launcher_web` builds for the same job, and it survives compilation; verified by grepping the bundle for `a.click()`, which is the only honest test available for interop that vanishes silently. Both confirmed by clicking a real link in #test with the console open: nothing at all before, `open-url! <url>` and `clicked an anchor` after. The logging stays for now. It is two lines, and the whole problem was that a dead link and a refused navigation look identical from outside. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8d6f6cb parent: ec5b47d modified
flutter/src/frq/hiccup.cljd +11 -0 | @@ -827,6 +827,17 @@ | ||
| 827 | 827 | ;; The check is not just the prop: a sentence with a button |
| 828 | 828 | ;; in it is not a sentence, and a caller that asks for one |
| 829 | 829 | ;; anyway gets the Wrap it would have got before. |
| 830 | + ;; `Text.rich` with the runs' own recognizers. Two things | |
| 831 | + ;; were tried above this line and both swallowed the tap: | |
| 832 | + ;; the app-wide `SelectionArea` takes it before a | |
| 833 | + ;; `TextSpan.recognizer` sees it, and `SelectableText.rich` | |
| 834 | + ;; does not dispatch to span recognizers at all. Verified by | |
| 835 | + ;; clicking a real link in #test with the console open -- | |
| 836 | + ;; `open-url!` logged nothing in either case, which is what | |
| 837 | + ;; a gesture that never arrives looks like. | |
| 838 | + ;; | |
| 839 | + ;; So the recognizer stays and the SelectionArea goes; see | |
| 840 | + ;; `frq.main`. | |
| 830 | 841 | (and (true? (:inline p)) (inline? (body node))) |
| 831 | 842 | (m/Text.rich (m/TextSpan .children (inline-spans ctx (body node))) |
| 832 | 843 | .softWrap true) |
| @@ -827,6 +827,17 @@ | |||
| 827 | ;; The check is not just the prop: a sentence with a button | 827 | ;; The check is not just the prop: a sentence with a button |
| 828 | ;; in it is not a sentence, and a caller that asks for one | 828 | ;; in it is not a sentence, and a caller that asks for one |
| 829 | ;; anyway gets the Wrap it would have got before. | 829 | ;; anyway gets the Wrap it would have got before. |
| 830 | + ;; `Text.rich` with the runs' own recognizers. Two things | ||
| 831 | + ;; were tried above this line and both swallowed the tap: | ||
| 832 | + ;; the app-wide `SelectionArea` takes it before a | ||
| 833 | + ;; `TextSpan.recognizer` sees it, and `SelectableText.rich` | ||
| 834 | + ;; does not dispatch to span recognizers at all. Verified by | ||
| 835 | + ;; clicking a real link in #test with the console open -- | ||
| 836 | + ;; `open-url!` logged nothing in either case, which is what | ||
| 837 | + ;; a gesture that never arrives looks like. | ||
| 838 | + ;; | ||
| 839 | + ;; So the recognizer stays and the SelectionArea goes; see | ||
| 840 | + ;; `frq.main`. | ||
| 830 | (and (true? (:inline p)) (inline? (body node))) | 841 | (and (true? (:inline p)) (inline? (body node))) |
| 831 | (m/Text.rich (m/TextSpan .children (inline-spans ctx (body node))) | 842 | (m/Text.rich (m/TextSpan .children (inline-spans ctx (body node))) |
| 832 | .softWrap true) | 843 | .softWrap true) |
modified
flutter/src/frq/io/web.cljd +37 -3 | @@ -175,10 +175,44 @@ | ||
| 175 | 175 | ;; simply another tab here. Popup blockers can refuse it, which is why the |
| 176 | 176 | ;; seam lets this answer false — the OAuth screen shows the URL to open by |
| 177 | 177 | ;; hand. |
| 178 | + ;; An anchor the page clicks for itself, and NOT `window.open`. | |
| 179 | + ;; | |
| 180 | + ;; `(.open html/window u "_blank")` compiled to nothing: dart2js emitted a | |
| 181 | + ;; folded constant where the call should have been, so `window.open(` | |
| 182 | + ;; appears zero times in `main.dart.js` and this function logged success | |
| 183 | + ;; and returned true without opening anything. That is also why the | |
| 184 | + ;; `some?` check around it could not help -- with the call gone it was | |
| 185 | + ;; always true. Verified by grepping the built bundle, which is the only | |
| 186 | + ;; honest test available for an interop call that silently disappears. | |
| 187 | + ;; | |
| 188 | + ;; An anchor is what `url_launcher_web` builds for the same job, and it | |
| 189 | + ;; survives compilation because `createElement`/`click` are ordinary | |
| 190 | + ;; dart:html members of the kind already used all over this file. | |
| 191 | + ;; `noopener` because a tab opened with a handle back to us is the one | |
| 192 | + ;; thing a link in someone else's message should not get. | |
| 178 | 193 | :open-url! (fn [url] |
| 179 | - (try | |
| 180 | - (some? (.open html/window (str url) "_blank")) | |
| 181 | - (catch Object _ false))) | |
| 194 | + (let [u (str url) | |
| 195 | + log (.-console html/window)] | |
| 196 | + (.log log (str "open-url! " u)) | |
| 197 | + (try | |
| 198 | + (let [doc (.-document html/window) | |
| 199 | + a (.createElement doc "a")] | |
| 200 | + (.setAttribute a "href" u) | |
| 201 | + (.setAttribute a "target" "_blank") | |
| 202 | + (.setAttribute a "rel" "noopener noreferrer") | |
| 203 | + (.append (.-body doc) a) | |
| 204 | + (.click a) | |
| 205 | + (.remove a) | |
| 206 | + (.log log "open-url! clicked an anchor") | |
| 207 | + true) | |
| 208 | + (catch Object e | |
| 209 | + ;; Whatever went wrong, opening the link at | |
| 210 | + ;; all beats a link that does nothing, so | |
| 211 | + ;; this tab goes there. The session is in | |
| 212 | + ;; localStorage and survives the trip. | |
| 213 | + (.log log (str "open-url! anchor failed " e "; same tab")) | |
| 214 | + (try (.assign (.-location html/window) u) true | |
| 215 | + (catch Object _ false)))))) | |
| 182 | 216 | :config-dir (fn [] root) |
| 183 | 217 | :file-exists? (fn [p] (some? (slurp* p))) |
| 184 | 218 | ;; A directory exists here exactly when something is under it. There is no |
| @@ -175,10 +175,44 @@ | |||
| 175 | ;; simply another tab here. Popup blockers can refuse it, which is why the | 175 | ;; simply another tab here. Popup blockers can refuse it, which is why the |
| 176 | ;; seam lets this answer false — the OAuth screen shows the URL to open by | 176 | ;; seam lets this answer false — the OAuth screen shows the URL to open by |
| 177 | ;; hand. | 177 | ;; hand. |
| 178 | + ;; An anchor the page clicks for itself, and NOT `window.open`. | ||
| 179 | + ;; | ||
| 180 | + ;; `(.open html/window u "_blank")` compiled to nothing: dart2js emitted a | ||
| 181 | + ;; folded constant where the call should have been, so `window.open(` | ||
| 182 | + ;; appears zero times in `main.dart.js` and this function logged success | ||
| 183 | + ;; and returned true without opening anything. That is also why the | ||
| 184 | + ;; `some?` check around it could not help -- with the call gone it was | ||
| 185 | + ;; always true. Verified by grepping the built bundle, which is the only | ||
| 186 | + ;; honest test available for an interop call that silently disappears. | ||
| 187 | + ;; | ||
| 188 | + ;; An anchor is what `url_launcher_web` builds for the same job, and it | ||
| 189 | + ;; survives compilation because `createElement`/`click` are ordinary | ||
| 190 | + ;; dart:html members of the kind already used all over this file. | ||
| 191 | + ;; `noopener` because a tab opened with a handle back to us is the one | ||
| 192 | + ;; thing a link in someone else's message should not get. | ||
| 178 | :open-url! (fn [url] | 193 | :open-url! (fn [url] |
| 179 | - (try | 194 | + (let [u (str url) |
| 180 | - (some? (.open html/window (str url) "_blank")) | 195 | + log (.-console html/window)] |
| 181 | - (catch Object _ false))) | 196 | + (.log log (str "open-url! " u)) |
| 197 | + (try | ||
| 198 | + (let [doc (.-document html/window) | ||
| 199 | + a (.createElement doc "a")] | ||
| 200 | + (.setAttribute a "href" u) | ||
| 201 | + (.setAttribute a "target" "_blank") | ||
| 202 | + (.setAttribute a "rel" "noopener noreferrer") | ||
| 203 | + (.append (.-body doc) a) | ||
| 204 | + (.click a) | ||
| 205 | + (.remove a) | ||
| 206 | + (.log log "open-url! clicked an anchor") | ||
| 207 | + true) | ||
| 208 | + (catch Object e | ||
| 209 | + ;; Whatever went wrong, opening the link at | ||
| 210 | + ;; all beats a link that does nothing, so | ||
| 211 | + ;; this tab goes there. The session is in | ||
| 212 | + ;; localStorage and survives the trip. | ||
| 213 | + (.log log (str "open-url! anchor failed " e "; same tab")) | ||
| 214 | + (try (.assign (.-location html/window) u) true | ||
| 215 | + (catch Object _ false)))))) | ||
| 182 | :config-dir (fn [] root) | 216 | :config-dir (fn [] root) |
| 183 | :file-exists? (fn [p] (some? (slurp* p))) | 217 | :file-exists? (fn [p] (some? (slurp* p))) |
| 184 | ;; A directory exists here exactly when something is under it. There is no | 218 | ;; A directory exists here exactly when something is under it. There is no |
modified
flutter/src/frq/main.cljd +15 -10 | @@ -1455,17 +1455,22 @@ | ||
| 1455 | 1455 | (m/Scaffold) |
| 1456 | 1456 | .body |
| 1457 | 1457 | m/SafeArea |
| 1458 | - ;; Text you can select, which on the web is not the default: a browser | |
| 1459 | - ;; renders Flutter to a canvas, so the ordinary drag-over-text a page | |
| 1460 | - ;; gives for free is not there unless something asks for it. One | |
| 1461 | - ;; SelectionArea over the whole tree is that ask, and it covers every | |
| 1462 | - ;; Text under it rather than needing them changed. | |
| 1458 | + ;; No SelectionArea here, and that is a trade rather than an oversight. | |
| 1463 | 1459 | ;; |
| 1464 | - ;; Above `render-root` rather than inside `frq.hiccup`, so it is one | |
| 1465 | - ;; widget and not one per label — and for all three targets rather than | |
| 1466 | - ;; the web alone, because copying a line out of a chat is a thing a | |
| 1467 | - ;; desktop window should do too. | |
| 1468 | - m/SelectionArea | |
| 1460 | + ;; It used to wrap the whole tree so text could be dragged over and | |
| 1461 | + ;; copied, which a canvas does not give a browser for free. But a | |
| 1462 | + ;; SelectionArea takes the tap before a `TextSpan.recognizer` under it | |
| 1463 | + ;; ever sees one, and since links in a message became spans rather than | |
| 1464 | + ;; InkWells — so that a URL breaks inside a sentence instead of taking a | |
| 1465 | + ;; line of its own — that meant every link in every message was dead on | |
| 1466 | + ;; all three targets. `SelectableText.rich` was tried in its place and is | |
| 1467 | + ;; worse: it does not dispatch to span recognizers either, and it is not | |
| 1468 | + ;; one widget for the tree but one per paragraph. | |
| 1469 | + ;; | |
| 1470 | + ;; A link you can follow beats a line you can copy, so the selection is | |
| 1471 | + ;; what goes. Getting both back means a paragraph that hit-tests its own | |
| 1472 | + ;; runs — a tap mapped to a `TextPosition` and looked up — which is real | |
| 1473 | + ;; work in `frq.hiccup` and not a widget you wrap the tree in. | |
| 1469 | 1474 | (f/widget |
| 1470 | 1475 | :context ctx |
| 1471 | 1476 | ;; One watch, not twenty. `lines` is a local atom and not a cell, so it |
| @@ -1455,17 +1455,22 @@ | |||
| 1455 | (m/Scaffold) | 1455 | (m/Scaffold) |
| 1456 | .body | 1456 | .body |
| 1457 | m/SafeArea | 1457 | m/SafeArea |
| 1458 | - ;; Text you can select, which on the web is not the default: a browser | 1458 | + ;; No SelectionArea here, and that is a trade rather than an oversight. |
| 1459 | - ;; renders Flutter to a canvas, so the ordinary drag-over-text a page | ||
| 1460 | - ;; gives for free is not there unless something asks for it. One | ||
| 1461 | - ;; SelectionArea over the whole tree is that ask, and it covers every | ||
| 1462 | - ;; Text under it rather than needing them changed. | ||
| 1463 | ;; | 1459 | ;; |
| 1464 | - ;; Above `render-root` rather than inside `frq.hiccup`, so it is one | 1460 | + ;; It used to wrap the whole tree so text could be dragged over and |
| 1465 | - ;; widget and not one per label — and for all three targets rather than | 1461 | + ;; copied, which a canvas does not give a browser for free. But a |
| 1466 | - ;; the web alone, because copying a line out of a chat is a thing a | 1462 | + ;; SelectionArea takes the tap before a `TextSpan.recognizer` under it |
| 1467 | - ;; desktop window should do too. | 1463 | + ;; ever sees one, and since links in a message became spans rather than |
| 1468 | - m/SelectionArea | 1464 | + ;; InkWells — so that a URL breaks inside a sentence instead of taking a |
| 1465 | + ;; line of its own — that meant every link in every message was dead on | ||
| 1466 | + ;; all three targets. `SelectableText.rich` was tried in its place and is | ||
| 1467 | + ;; worse: it does not dispatch to span recognizers either, and it is not | ||
| 1468 | + ;; one widget for the tree but one per paragraph. | ||
| 1469 | + ;; | ||
| 1470 | + ;; A link you can follow beats a line you can copy, so the selection is | ||
| 1471 | + ;; what goes. Getting both back means a paragraph that hit-tests its own | ||
| 1472 | + ;; runs — a tap mapped to a `TextPosition` and looked up — which is real | ||
| 1473 | + ;; work in `frq.hiccup` and not a widget you wrap the tree in. | ||
| 1469 | (f/widget | 1474 | (f/widget |
| 1470 | :context ctx | 1475 | :context ctx |
| 1471 | ;; One watch, not twenty. `lines` is a local atom and not a cell, so it | 1476 | ;; One watch, not twenty. `lines` is a local atom and not a cell, so it |