nandi/frqpublic Fork 0
8d6f6cb
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.

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>
nandi committed 2026-09-18T15:35:33-07:00 Browse files
8d6f6cb parent: ec5b47d
modified flutter/src/frq/hiccup.cljd +11 -0
@@ -827,6 +827,17 @@
827827 ;; The check is not just the prop: a sentence with a button
828828 ;; in it is not a sentence, and a caller that asks for one
829829 ;; 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`.
830841 (and (true? (:inline p)) (inline? (body node)))
831842 (m/Text.rich (m/TextSpan .children (inline-spans ctx (body node)))
832843 .softWrap true)
@@ -827,6 +827,17 @@
827 ;; The check is not just the prop: a sentence with a button827 ;; 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 one828 ;; 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 @@
175175 ;; simply another tab here. Popup blockers can refuse it, which is why the
176176 ;; seam lets this answer false the OAuth screen shows the URL to open by
177177 ;; 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.
178193 :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))))))
182216 :config-dir (fn [] root)
183217 :file-exists? (fn [p] (some? (slurp* p)))
184218 ;; 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 the175 ;; 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 by176 ;; 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- (try194+ (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 no218 ;; 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 @@
14551455 (m/Scaffold)
14561456 .body
14571457 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.
14631459 ;;
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.
14691474 (f/widget
14701475 :context ctx
14711476 ;; 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 .body1456 .body
1457 m/SafeArea1457 m/SafeArea
1458- ;; Text you can select, which on the web is not the default: a browser1458+ ;; 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 one1460+ ;; 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 than1461+ ;; 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 a1462+ ;; 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/SelectionArea1464+ ;; 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/widget1474 (f/widget
1470 :context ctx1475 :context ctx
1471 ;; One watch, not twenty. `lines` is a local atom and not a cell, so it1476 ;; One watch, not twenty. `lines` is a local atom and not a cell, so it