nandi/frqpublic Fork
⑂ 050629b
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.

Download ZIP

A link says what is at the end of it

A message with a link in it gets a card under it: the picture, the site,
the title as the thing you press, and two lines of the description. The
link stays in the text — a preview is an addition to what was said, not a
replacement for it.

The metadata comes from one of two places, and never from the page. freeq
carries a sender's own preview on the message as `+freeq.at/link-*` tags,
which is the better answer where it is there: signed with the line, and no
round trip. Where it is not, freeq's server fetches the page and hands back
the OpenGraph tags — `GET /api/v1/og?url=`, which is what its own web
client asks and is the reason this client never touches the host somebody
pasted. A reader's browsing stays between them and the server they are
already talking to.

The split is `profile` and `profilefetch` again, for the same reason: the
cache lives in `links`, which a screen may read, and the fetching lives in
`linkfetch`, which it may not — a screen that reached an HTTP client is a
screen the web target cannot compile. So `linkfetch` is two files, a worker
thread with a channel each way on the desktop and a `fetch` with a callback
in the browser, and the shared half above them never learns which it has.

Nothing is drawn while a preview is in flight, and nothing where it failed.
A card that says "loading" is a row that appears, changes height and
vanishes again in every line of a replayed backlog; a card that says "no
preview" is a card about this client rather than about the link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-20T16:54:42-07:00 Browse files
050629b parent: 3c639b8
added nim/src/frq/linkfetch.nim +93 -0
new file mode 100644
@@ -0,0 +1,93 @@
1+## Going and getting a link's preview, off the thread that draws it.
2+##
3+## `profilefetch` with a different URL in it, and deliberately so: the shape
4+## is a worker taking requests off one channel and putting answers on
5+## another, with `collect` folding them into the cache on the thread that
6+## owns it. A preview is an HTTPS round trip like a profile, and a backlog of
7+## a hundred lines is as many of them.
8+##
9+## What it asks is freeq's own `/api/v1/og`, never the page. The server
10+## fetches and parses; this client learns nothing about the host somebody
11+## linked to, and neither does that host learn about the reader.
12+
13+import std/[exitprocs, httpclient, json, net, strutils]
14+import frq/[links, trace]
15+
16+var
17+ requests: Channel[string] ## "<endpoint>\x1f<url>"
18+ answers: Channel[string] ## "<url>\x1f<json>", the json empty on failure
19+ fetcher: Thread[void]
20+ fetching: bool
21+ stopping: bool
22+
23+proc fetchOne(endpoint: string): string =
24+ ## The body, or "" where there is not one. No socket timeout, for the
25+ ## reason `atproto` gives: a timeout here dies on the first signal.
26+ var c: HttpClient
27+ try:
28+ c = newHttpClient(sslContext = newContext(verifyMode = CVerifyPeer))
29+ c.getContent(endpoint)
30+ except CatchableError as e:
31+ trace("link", "!! " & e.msg)
32+ ""
33+ finally:
34+ if c != nil:
35+ try: c.close() except CatchableError: discard
36+
37+proc fetcherBody() {.thread.} =
38+ {.gcsafe.}:
39+ while true:
40+ let job = requests.recv()
41+ if job.len == 0 or stopping: break
42+ let sep = job.find('\x1f')
43+ if sep < 0: continue
44+ let endpoint = job[0 ..< sep]
45+ let url = job[sep + 1 .. ^1]
46+ trace("link", "GET " & endpoint)
47+ answers.send(url & "\x1f" & fetchOne(endpoint))
48+
49+proc want*(host, url: string) =
50+ ## Ask for a preview, without waiting for it.
51+ ##
52+ ## Once per link for the run: `lsLoading` goes in the cache here, so the
53+ ## same URL pasted twice in a room is one round trip, and a re-render while
54+ ## the first is in flight is not a second.
55+ if url.len == 0 or known(url) or not previewable(url): return
56+ let endpoint = ogEndpoint(host, url)
57+ if endpoint.len == 0: return
58+ remember(url, Preview(status: lsLoading))
59+ if not fetching:
60+ fetching = true
61+ createThread(fetcher, fetcherBody)
62+ requests.send(endpoint & "\x1f" & url)
63+
64+proc stopFetching() =
65+ ## Stop the worker and wait for it, at exit — the SIGSEGV inside
66+ ## `net.newContext` that `profilefetch` documents is this thread's too.
67+ if not fetching: return
68+ stopping = true
69+ requests.send("")
70+ joinThread(fetcher)
71+ fetching = false
72+
73+addExitProc(stopFetching)
74+
75+proc collect*(): bool =
76+ ## Fold whatever has come back into the cache. True where anything did, so
77+ ## a caller knows the screen has something new on it.
78+ while true:
79+ let (ok, msg) = answers.tryRecv()
80+ if not ok: break
81+ let sep = msg.find('\x1f')
82+ if sep < 0: continue
83+ let url = msg[0 ..< sep]
84+ let body = msg[sep + 1 .. ^1]
85+ remember(url,
86+ if body.len == 0: Preview(status: lsFailed)
87+ else:
88+ try: parsePreview(parseJson(body))
89+ except CatchableError: Preview(status: lsFailed))
90+ result = true
91+
92+requests.open()
93+answers.open()
new file mode 100644
@@ -0,0 +1,93 @@
1+## Going and getting a link's preview, off the thread that draws it.
2+##
3+## `profilefetch` with a different URL in it, and deliberately so: the shape
4+## is a worker taking requests off one channel and putting answers on
5+## another, with `collect` folding them into the cache on the thread that
6+## owns it. A preview is an HTTPS round trip like a profile, and a backlog of
7+## a hundred lines is as many of them.
8+##
9+## What it asks is freeq's own `/api/v1/og`, never the page. The server
10+## fetches and parses; this client learns nothing about the host somebody
11+## linked to, and neither does that host learn about the reader.
12+
13+import std/[exitprocs, httpclient, json, net, strutils]
14+import frq/[links, trace]
15+
16+var
17+ requests: Channel[string] ## "<endpoint>\x1f<url>"
18+ answers: Channel[string] ## "<url>\x1f<json>", the json empty on failure
19+ fetcher: Thread[void]
20+ fetching: bool
21+ stopping: bool
22+
23+proc fetchOne(endpoint: string): string =
24+ ## The body, or "" where there is not one. No socket timeout, for the
25+ ## reason `atproto` gives: a timeout here dies on the first signal.
26+ var c: HttpClient
27+ try:
28+ c = newHttpClient(sslContext = newContext(verifyMode = CVerifyPeer))
29+ c.getContent(endpoint)
30+ except CatchableError as e:
31+ trace("link", "!! " & e.msg)
32+ ""
33+ finally:
34+ if c != nil:
35+ try: c.close() except CatchableError: discard
36+
37+proc fetcherBody() {.thread.} =
38+ {.gcsafe.}:
39+ while true:
40+ let job = requests.recv()
41+ if job.len == 0 or stopping: break
42+ let sep = job.find('\x1f')
43+ if sep < 0: continue
44+ let endpoint = job[0 ..< sep]
45+ let url = job[sep + 1 .. ^1]
46+ trace("link", "GET " & endpoint)
47+ answers.send(url & "\x1f" & fetchOne(endpoint))
48+
49+proc want*(host, url: string) =
50+ ## Ask for a preview, without waiting for it.
51+ ##
52+ ## Once per link for the run: `lsLoading` goes in the cache here, so the
53+ ## same URL pasted twice in a room is one round trip, and a re-render while
54+ ## the first is in flight is not a second.
55+ if url.len == 0 or known(url) or not previewable(url): return
56+ let endpoint = ogEndpoint(host, url)
57+ if endpoint.len == 0: return
58+ remember(url, Preview(status: lsLoading))
59+ if not fetching:
60+ fetching = true
61+ createThread(fetcher, fetcherBody)
62+ requests.send(endpoint & "\x1f" & url)
63+
64+proc stopFetching() =
65+ ## Stop the worker and wait for it, at exit — the SIGSEGV inside
66+ ## `net.newContext` that `profilefetch` documents is this thread's too.
67+ if not fetching: return
68+ stopping = true
69+ requests.send("")
70+ joinThread(fetcher)
71+ fetching = false
72+
73+addExitProc(stopFetching)
74+
75+proc collect*(): bool =
76+ ## Fold whatever has come back into the cache. True where anything did, so
77+ ## a caller knows the screen has something new on it.
78+ while true:
79+ let (ok, msg) = answers.tryRecv()
80+ if not ok: break
81+ let sep = msg.find('\x1f')
82+ if sep < 0: continue
83+ let url = msg[0 ..< sep]
84+ let body = msg[sep + 1 .. ^1]
85+ remember(url,
86+ if body.len == 0: Preview(status: lsFailed)
87+ else:
88+ try: parsePreview(parseJson(body))
89+ except CatchableError: Preview(status: lsFailed))
90+ result = true
91+
92+requests.open()
93+answers.open()
modified nim/src/frq/reducer.nim +37 -2
@@ -16,6 +16,8 @@ import std/sets
1616 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,
1717 atproto, handshake, textruns, members, msgsig, profile, store,
1818 profilefetch, edits]
19+import frq/links as lk
20+import frq/linkfetch as lf
1921 import frq/conn as tr
2022 import frq/oauth as oa
2123
@@ -47,6 +49,9 @@ proc wantFace(m: Message)
4749 ## And this because `sendDraft` is: our own line wants a face as much as
4850 ## anybody's.
4951
52+proc wantPreview(m: Message)
53+ ## And this for the same reason: a link in our own line gets a card too.
54+
5055 proc openSocket()
5156 ## And this because a browser sign-in finishes above it: the host answers
5257 ## asynchronously, and what it answers with is "now you may connect".
@@ -780,6 +785,33 @@ proc wantFace(m: Message) =
780785 let actor = actorFor(did, m.frm)
781786 if actor.len > 0: want(actor)
782787
788+proc notePreviewTags(tags: string) =
789+ ## A preview the sender sent with their line, into the cache.
790+ ##
791+ ## freeq carries one as `+freeq.at/link-*`, signed with the message the way
792+ ## an attachment is. Where it is there it is the better answer than asking
793+ ## the server to go and look: it costs no round trip, and it is what the
794+ ## author said the link was rather than what the page says about itself
795+ ## today. `want` finds the URL already known and asks nothing.
796+ let (url, hasUrl) = tagValue(tags, "+freeq.at/link-url")
797+ if not hasUrl or not lk.previewable(url) or lk.known(url): return
798+ let (title, _) = tagValue(tags, "+freeq.at/link-title")
799+ let (desc, _) = tagValue(tags, "+freeq.at/link-desc")
800+ let (image, _) = tagValue(tags, "+freeq.at/link-image")
801+ if title.len == 0 and image.len == 0: return
802+ lk.remember(url, lk.Preview(status: lk.lsReady, title: title,
803+ description: desc, image: image))
804+
805+proc wantPreview(m: Message) =
806+ ## Ask what is at the end of the link in this line, if it has one.
807+ ##
808+ ## Here rather than in the screen because a screen must not fetch, and here
809+ ## rather than at the PRIVMSG because a line arrives by three roads — said,
810+ ## replayed, or echoed back — and `note` is the one they all pass through.
811+ if m.system: return
812+ let url = firstPreviewUrl(m.text)
813+ if url.len > 0: lf.want(app.formHost, url)
814+
783815 proc note(room: string, m: Message) =
784816 app.rooms.ensureRoom(room)
785817 var r = app.rooms[room]
@@ -788,6 +820,7 @@ proc note(room: string, m: Message) =
788820 r.lastActivity = nowMs()
789821 app.rooms[room] = r.recount(app.formNick)
790822 wantFace(m)
823+ wantPreview(m)
791824
792825 proc drain*() =
793826 # The browser handoff, before the socket: a sign-in that just landed should
@@ -920,6 +953,7 @@ proc drain*() =
920953 if hasRep: m.replyTo = rep
921954 let (tally, hasTally) = tagValue(p.tags, "+freeq.at/reacts")
922955 if hasTally: m.reactions = parseTally(tally)
956+ notePreviewTags(p.tags)
923957 note(room, m)
924958
925959 of "TAGMSG":
@@ -1089,8 +1123,9 @@ proc drain*() =
10891123 else:
10901124 trace("skip", p.command & " " & $p.params)
10911125
1092- # Faces that have come back since the last frame.
1093- discard collect()
1126+ # Faces that have come back since the last frame, and previews.
1127+ discard profilefetch.collect()
1128+ discard lf.collect()
10941129
10951130 # A line arriving moves the marker in the room being looked at, and closing
10961131 # the window is not a moment this client gets told about — so the saving
@@ -16,6 +16,8 @@ import std/sets
16 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,16 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,
17 atproto, handshake, textruns, members, msgsig, profile, store,17 atproto, handshake, textruns, members, msgsig, profile, store,
18 profilefetch, edits]18 profilefetch, edits]
19+import frq/links as lk
20+import frq/linkfetch as lf
19 import frq/conn as tr21 import frq/conn as tr
20 import frq/oauth as oa22 import frq/oauth as oa
21 23
@@ -47,6 +49,9 @@ proc wantFace(m: Message)
47 ## And this because `sendDraft` is: our own line wants a face as much as49 ## And this because `sendDraft` is: our own line wants a face as much as
48 ## anybody's.50 ## anybody's.
49 51
52+proc wantPreview(m: Message)
53+ ## And this for the same reason: a link in our own line gets a card too.
54+
50 proc openSocket()55 proc openSocket()
51 ## And this because a browser sign-in finishes above it: the host answers56 ## And this because a browser sign-in finishes above it: the host answers
52 ## asynchronously, and what it answers with is "now you may connect".57 ## asynchronously, and what it answers with is "now you may connect".
@@ -780,6 +785,33 @@ proc wantFace(m: Message) =
780 let actor = actorFor(did, m.frm)785 let actor = actorFor(did, m.frm)
781 if actor.len > 0: want(actor)786 if actor.len > 0: want(actor)
782 787
788+proc notePreviewTags(tags: string) =
789+ ## A preview the sender sent with their line, into the cache.
790+ ##
791+ ## freeq carries one as `+freeq.at/link-*`, signed with the message the way
792+ ## an attachment is. Where it is there it is the better answer than asking
793+ ## the server to go and look: it costs no round trip, and it is what the
794+ ## author said the link was rather than what the page says about itself
795+ ## today. `want` finds the URL already known and asks nothing.
796+ let (url, hasUrl) = tagValue(tags, "+freeq.at/link-url")
797+ if not hasUrl or not lk.previewable(url) or lk.known(url): return
798+ let (title, _) = tagValue(tags, "+freeq.at/link-title")
799+ let (desc, _) = tagValue(tags, "+freeq.at/link-desc")
800+ let (image, _) = tagValue(tags, "+freeq.at/link-image")
801+ if title.len == 0 and image.len == 0: return
802+ lk.remember(url, lk.Preview(status: lk.lsReady, title: title,
803+ description: desc, image: image))
804+
805+proc wantPreview(m: Message) =
806+ ## Ask what is at the end of the link in this line, if it has one.
807+ ##
808+ ## Here rather than in the screen because a screen must not fetch, and here
809+ ## rather than at the PRIVMSG because a line arrives by three roads — said,
810+ ## replayed, or echoed back — and `note` is the one they all pass through.
811+ if m.system: return
812+ let url = firstPreviewUrl(m.text)
813+ if url.len > 0: lf.want(app.formHost, url)
814+
783 proc note(room: string, m: Message) =815 proc note(room: string, m: Message) =
784 app.rooms.ensureRoom(room)816 app.rooms.ensureRoom(room)
785 var r = app.rooms[room]817 var r = app.rooms[room]
@@ -788,6 +820,7 @@ proc note(room: string, m: Message) =
788 r.lastActivity = nowMs()820 r.lastActivity = nowMs()
789 app.rooms[room] = r.recount(app.formNick)821 app.rooms[room] = r.recount(app.formNick)
790 wantFace(m)822 wantFace(m)
823+ wantPreview(m)
791 824
792 proc drain*() =825 proc drain*() =
793 # The browser handoff, before the socket: a sign-in that just landed should826 # The browser handoff, before the socket: a sign-in that just landed should
@@ -920,6 +953,7 @@ proc drain*() =
920 if hasRep: m.replyTo = rep953 if hasRep: m.replyTo = rep
921 let (tally, hasTally) = tagValue(p.tags, "+freeq.at/reacts")954 let (tally, hasTally) = tagValue(p.tags, "+freeq.at/reacts")
922 if hasTally: m.reactions = parseTally(tally)955 if hasTally: m.reactions = parseTally(tally)
956+ notePreviewTags(p.tags)
923 note(room, m)957 note(room, m)
924 958
925 of "TAGMSG":959 of "TAGMSG":
@@ -1089,8 +1123,9 @@ proc drain*() =
1089 else:1123 else:
1090 trace("skip", p.command & " " & $p.params)1124 trace("skip", p.command & " " & $p.params)
1091 1125
1092- # Faces that have come back since the last frame.1126+ # Faces that have come back since the last frame, and previews.
1093- discard collect()1127+ discard profilefetch.collect()
1128+ discard lf.collect()
1094 1129
1095 # A line arriving moves the marker in the room being looked at, and closing1130 # A line arriving moves the marker in the room being looked at, and closing
1096 # the window is not a moment this client gets told about — so the saving1131 # the window is not a moment this client gets told about — so the saving
modified nim/src/frq/screens/chat.nim +38 -1
@@ -16,6 +16,7 @@ import std/[algorithm, json, strutils, tables]
1616 import std/options
1717 import frq/[ui, cells, model, clock, reactions, textruns, members,
1818 glyphs, emoji, rooms, profile]
19+import frq/links as lk
1920 from frq/screens/connect import errorNote
2021 from frq/screens/frame import tabBar
2122
@@ -95,6 +96,34 @@ func runNodes(m: Message): Node =
9596 of rkText: result.children.add text(r.value)
9697 of rkLink: result.children.add link(r.value, r.value)
9798
99+proc previewCard(url: string): Node =
100+ ## What is at the end of a link, under the line that pasted it.
101+ ##
102+ ## Nothing while it is being fetched and nothing where it failed: a card
103+ ## that says "loading" is a row that appears, changes height and
104+ ## disappears again in every message of a backlog, and a card that says
105+ ## "no preview" is a card about this client rather than about the link.
106+ ## The link itself is in the text either way, so the failure mode is the
107+ ## message as it reads without any of this.
108+ ##
109+ ## The title is the control rather than the whole card: `link` is the one
110+ ## tag that opens a URL, and a card that took the press would need the
111+ ## renderer to learn a second way — which is a change on both sides of a
112+ ## boundary that is supposed to hold.
113+ let (p, has) = lk.lookup(url)
114+ if not has or p.status != lk.lsReady: return nil
115+ result = card(%*{"key": "preview", "spacing": 2})
116+ if p.image.len > 0:
117+ result.children.add image(p.image, maxWidth = 320, maxHeight = 160,
118+ onClick = "lightbox:" & p.image)
119+ let site = if p.siteName.len > 0: p.siteName else: lk.domainOf(url)
120+ if site.len > 0:
121+ result.children.add dimLabel(site)
122+ if p.title.len > 0:
123+ result.children.add link(p.title, url)
124+ if p.description.len > 0:
125+ result.children.add text(p.description, lines = 2)
126+
98127 const pickerColumns = 8
99128 ## How wide the emoji grid is. Narrow enough to sit under a message on a
100129 ## phone without the compose bar leaving the screen.
@@ -209,6 +238,14 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
209238 images.children.add image(m.imageUrl, maxWidth = 320, maxHeight = 240,
210239 onClick = "lightbox:" & m.imageUrl)
211240
241+ # What the link in the message turned out to be, where it has come back.
242+ var preview = vbox(%*{"key": "preview-row", "spacing": 4})
243+ if not m.system:
244+ let url = firstPreviewUrl(m.text)
245+ if url.len > 0:
246+ let cardNode = previewCard(url)
247+ if not cardNode.isNil: preview.children.add cardNode
248+
212249 # The picker, under the message it is for and nowhere else.
213250 var picker = vbox(%*{"key": "picker", "marginBottom": 4})
214251 if s.reacting.has and s.reacting.id == rowId(m):
@@ -223,7 +260,7 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
223260 n(if highlit: "card" else: "vbox",
224261 %*{"key": (if highlit: "body-card" else: "body-plain"),
225262 "spacing": 2, "margin": 0},
226- @[who, body, picker, images, pills])
263+ @[who, body, picker, images, preview, pills])
227264
228265 proc messageRow(s: State, room: Room, i: int, m: Message): Node =
229266 ## One message: who said it, when, what you can do to it, and the words.
@@ -16,6 +16,7 @@ import std/[algorithm, json, strutils, tables]
16 import std/options16 import std/options
17 import frq/[ui, cells, model, clock, reactions, textruns, members,17 import frq/[ui, cells, model, clock, reactions, textruns, members,
18 glyphs, emoji, rooms, profile]18 glyphs, emoji, rooms, profile]
19+import frq/links as lk
19 from frq/screens/connect import errorNote20 from frq/screens/connect import errorNote
20 from frq/screens/frame import tabBar21 from frq/screens/frame import tabBar
21 22
@@ -95,6 +96,34 @@ func runNodes(m: Message): Node =
95 of rkText: result.children.add text(r.value)96 of rkText: result.children.add text(r.value)
96 of rkLink: result.children.add link(r.value, r.value)97 of rkLink: result.children.add link(r.value, r.value)
97 98
99+proc previewCard(url: string): Node =
100+ ## What is at the end of a link, under the line that pasted it.
101+ ##
102+ ## Nothing while it is being fetched and nothing where it failed: a card
103+ ## that says "loading" is a row that appears, changes height and
104+ ## disappears again in every message of a backlog, and a card that says
105+ ## "no preview" is a card about this client rather than about the link.
106+ ## The link itself is in the text either way, so the failure mode is the
107+ ## message as it reads without any of this.
108+ ##
109+ ## The title is the control rather than the whole card: `link` is the one
110+ ## tag that opens a URL, and a card that took the press would need the
111+ ## renderer to learn a second way — which is a change on both sides of a
112+ ## boundary that is supposed to hold.
113+ let (p, has) = lk.lookup(url)
114+ if not has or p.status != lk.lsReady: return nil
115+ result = card(%*{"key": "preview", "spacing": 2})
116+ if p.image.len > 0:
117+ result.children.add image(p.image, maxWidth = 320, maxHeight = 160,
118+ onClick = "lightbox:" & p.image)
119+ let site = if p.siteName.len > 0: p.siteName else: lk.domainOf(url)
120+ if site.len > 0:
121+ result.children.add dimLabel(site)
122+ if p.title.len > 0:
123+ result.children.add link(p.title, url)
124+ if p.description.len > 0:
125+ result.children.add text(p.description, lines = 2)
126+
98 const pickerColumns = 8127 const pickerColumns = 8
99 ## How wide the emoji grid is. Narrow enough to sit under a message on a128 ## How wide the emoji grid is. Narrow enough to sit under a message on a
100 ## phone without the compose bar leaving the screen.129 ## phone without the compose bar leaving the screen.
@@ -209,6 +238,14 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
209 images.children.add image(m.imageUrl, maxWidth = 320, maxHeight = 240,238 images.children.add image(m.imageUrl, maxWidth = 320, maxHeight = 240,
210 onClick = "lightbox:" & m.imageUrl)239 onClick = "lightbox:" & m.imageUrl)
211 240
241+ # What the link in the message turned out to be, where it has come back.
242+ var preview = vbox(%*{"key": "preview-row", "spacing": 4})
243+ if not m.system:
244+ let url = firstPreviewUrl(m.text)
245+ if url.len > 0:
246+ let cardNode = previewCard(url)
247+ if not cardNode.isNil: preview.children.add cardNode
248+
212 # The picker, under the message it is for and nowhere else.249 # The picker, under the message it is for and nowhere else.
213 var picker = vbox(%*{"key": "picker", "marginBottom": 4})250 var picker = vbox(%*{"key": "picker", "marginBottom": 4})
214 if s.reacting.has and s.reacting.id == rowId(m):251 if s.reacting.has and s.reacting.id == rowId(m):
@@ -223,7 +260,7 @@ proc messageBody(s: State, room: Room, m: Message, highlit: bool): Node =
223 n(if highlit: "card" else: "vbox",260 n(if highlit: "card" else: "vbox",
224 %*{"key": (if highlit: "body-card" else: "body-plain"),261 %*{"key": (if highlit: "body-card" else: "body-plain"),
225 "spacing": 2, "margin": 0},262 "spacing": 2, "margin": 0},
226- @[who, body, picker, images, pills])263+ @[who, body, picker, images, preview, pills])
227 264
228 proc messageRow(s: State, room: Room, i: int, m: Message): Node =265 proc messageRow(s: State, room: Room, i: int, m: Message): Node =
229 ## One message: who said it, when, what you can do to it, and the words.266 ## One message: who said it, when, what you can do to it, and the words.
modified nim/tests/tchat.nim +30 -0
@@ -3,6 +3,7 @@
33 import std/[json, sequtils, strutils, tables, unicode, unittest]
44 import frq/[ui, cells, model, textruns]
55 import frq/screens/chat as cs
6+import frq/links as lk
67
78 proc find(node: Node, tag: string): seq[Node] =
89 if node.isNil: return
@@ -116,6 +117,35 @@ suite "the chat screen":
116117 check img.len == 1
117118 check img[0].props{"onClick"}.getStr().startsWith("lightbox:")
118119
120+ test "a link with a preview back gets a card under the message":
121+ lk.forgetPreviews()
122+ lk.setPreviewForTest("https://example.com",
123+ lk.Preview(status: lk.lsReady, title: "A post", description: "About it",
124+ siteName: "Example", image: "https://example.com/i.png"))
125+ var r = s.rooms["#test"]
126+ r.messages[0].text = "see https://example.com now"
127+ s.rooms["#test"] = r
128+ let t = cs.chatScreen(s, true)
129+ check t.labels("link").anyIt(it == "A post")
130+ check t.labels("dim-label").anyIt(it == "Example")
131+ check t.find("image").anyIt(
132+ it.props{"src"}.getStr() == "https://example.com/i.png")
133+ lk.forgetPreviews()
134+
135+ test "a link still being fetched gets nothing — not a row that flickers":
136+ lk.forgetPreviews()
137+ lk.setPreviewForTest("https://example.com",
138+ lk.Preview(status: lk.lsLoading))
139+ var r = s.rooms["#test"]
140+ r.messages[0].text = "see https://example.com now"
141+ s.rooms["#test"] = r
142+ let t = cs.chatScreen(s, true)
143+ # The link itself is still there; only the card is absent.
144+ check t.labels("link").anyIt(it == "https://example.com")
145+ check not t.find("image").anyIt(
146+ it.props{"src"}.getStr() == "https://example.com/i.png")
147+ lk.forgetPreviews()
148+
119149 test "a reply quotes what it answers, and offers a way there":
120150 var r = s.rooms["#test"]
121151 r.messages[1].replyTo = "1"
@@ -3,6 +3,7 @@
3 import std/[json, sequtils, strutils, tables, unicode, unittest]3 import std/[json, sequtils, strutils, tables, unicode, unittest]
4 import frq/[ui, cells, model, textruns]4 import frq/[ui, cells, model, textruns]
5 import frq/screens/chat as cs5 import frq/screens/chat as cs
6+import frq/links as lk
6 7
7 proc find(node: Node, tag: string): seq[Node] =8 proc find(node: Node, tag: string): seq[Node] =
8 if node.isNil: return9 if node.isNil: return
@@ -116,6 +117,35 @@ suite "the chat screen":
116 check img.len == 1117 check img.len == 1
117 check img[0].props{"onClick"}.getStr().startsWith("lightbox:")118 check img[0].props{"onClick"}.getStr().startsWith("lightbox:")
118 119
120+ test "a link with a preview back gets a card under the message":
121+ lk.forgetPreviews()
122+ lk.setPreviewForTest("https://example.com",
123+ lk.Preview(status: lk.lsReady, title: "A post", description: "About it",
124+ siteName: "Example", image: "https://example.com/i.png"))
125+ var r = s.rooms["#test"]
126+ r.messages[0].text = "see https://example.com now"
127+ s.rooms["#test"] = r
128+ let t = cs.chatScreen(s, true)
129+ check t.labels("link").anyIt(it == "A post")
130+ check t.labels("dim-label").anyIt(it == "Example")
131+ check t.find("image").anyIt(
132+ it.props{"src"}.getStr() == "https://example.com/i.png")
133+ lk.forgetPreviews()
134+
135+ test "a link still being fetched gets nothing — not a row that flickers":
136+ lk.forgetPreviews()
137+ lk.setPreviewForTest("https://example.com",
138+ lk.Preview(status: lk.lsLoading))
139+ var r = s.rooms["#test"]
140+ r.messages[0].text = "see https://example.com now"
141+ s.rooms["#test"] = r
142+ let t = cs.chatScreen(s, true)
143+ # The link itself is still there; only the card is absent.
144+ check t.labels("link").anyIt(it == "https://example.com")
145+ check not t.find("image").anyIt(
146+ it.props{"src"}.getStr() == "https://example.com/i.png")
147+ lk.forgetPreviews()
148+
119 test "a reply quotes what it answers, and offers a way there":149 test "a reply quotes what it answers, and offers a way there":
120 var r = s.rooms["#test"]150 var r = s.rooms["#test"]
121 r.messages[1].replyTo = "1"151 r.messages[1].replyTo = "1"
added nim/web/frq/linkfetch.nim +50 -0
new file mode 100644
@@ -0,0 +1,50 @@
1+## Going and getting a link's preview, in a browser.
2+##
3+## The desktop runs a worker thread with a channel each way; here `want` asks
4+## JavaScript to fetch and `collect` folds the answer in — the same three
5+## steps with `fetch` and a callback where the other has a thread and a
6+## queue, exactly as `profilefetch` divides.
7+##
8+## Same endpoint, and here it is the only one that would work at all: a page
9+## cannot read a third-party site's HTML, so `/api/v1/og` is both the private
10+## answer and the possible one.
11+
12+import std/deques
13+import std/json
14+import frq/links
15+
16+var answers: Deque[(string, string)] ## url, body — the body empty on failure
17+
18+{.emit: """
19+function frqFetchPreview(endpoint, url, done) {
20+ fetch(endpoint)
21+ .then(function (r) { return r.ok ? r.text() : ""; })
22+ .then(function (t) { done(url, t); })
23+ .catch(function () { done(url, ""); });
24+}
25+""".}
26+
27+proc fetchPreview(endpoint, url: cstring, done: proc (a, b: cstring))
28+ {.importc: "frqFetchPreview".}
29+
30+proc arrived(url, body: cstring) =
31+ answers.addLast(($url, $body))
32+
33+proc want*(host, url: string) =
34+ ## Once per link for the run, as on the desktop.
35+ if url.len == 0 or known(url) or not previewable(url): return
36+ let endpoint = ogEndpoint(host, url)
37+ if endpoint.len == 0: return
38+ remember(url, Preview(status: lsLoading))
39+ fetchPreview(endpoint.cstring, url.cstring, arrived)
40+
41+proc collect*(): bool =
42+ ## Fold whatever has come back into the cache. True where anything did.
43+ while answers.len > 0:
44+ let (url, body) = answers.popFirst()
45+ remember(url,
46+ if body.len == 0: Preview(status: lsFailed)
47+ else:
48+ try: parsePreview(parseJson(body))
49+ except CatchableError: Preview(status: lsFailed))
50+ result = true
new file mode 100644
@@ -0,0 +1,50 @@
1+## Going and getting a link's preview, in a browser.
2+##
3+## The desktop runs a worker thread with a channel each way; here `want` asks
4+## JavaScript to fetch and `collect` folds the answer in — the same three
5+## steps with `fetch` and a callback where the other has a thread and a
6+## queue, exactly as `profilefetch` divides.
7+##
8+## Same endpoint, and here it is the only one that would work at all: a page
9+## cannot read a third-party site's HTML, so `/api/v1/og` is both the private
10+## answer and the possible one.
11+
12+import std/deques
13+import std/json
14+import frq/links
15+
16+var answers: Deque[(string, string)] ## url, body — the body empty on failure
17+
18+{.emit: """
19+function frqFetchPreview(endpoint, url, done) {
20+ fetch(endpoint)
21+ .then(function (r) { return r.ok ? r.text() : ""; })
22+ .then(function (t) { done(url, t); })
23+ .catch(function () { done(url, ""); });
24+}
25+""".}
26+
27+proc fetchPreview(endpoint, url: cstring, done: proc (a, b: cstring))
28+ {.importc: "frqFetchPreview".}
29+
30+proc arrived(url, body: cstring) =
31+ answers.addLast(($url, $body))
32+
33+proc want*(host, url: string) =
34+ ## Once per link for the run, as on the desktop.
35+ if url.len == 0 or known(url) or not previewable(url): return
36+ let endpoint = ogEndpoint(host, url)
37+ if endpoint.len == 0: return
38+ remember(url, Preview(status: lsLoading))
39+ fetchPreview(endpoint.cstring, url.cstring, arrived)
40+
41+proc collect*(): bool =
42+ ## Fold whatever has come back into the cache. True where anything did.
43+ while answers.len > 0:
44+ let (url, body) = answers.popFirst()
45+ remember(url,
46+ if body.len == 0: Preview(status: lsFailed)
47+ else:
48+ try: parsePreview(parseJson(body))
49+ except CatchableError: Preview(status: lsFailed))
50+ result = true