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>
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() | ||
added
nim/src/frq/links.nim +127 -0 | new file mode 100644 | ||
| @@ -0,0 +1,127 @@ | ||
| 1 | +## What is at the end of a link, for the card under the message. | |
| 2 | +## | |
| 3 | +## The cache the screens read, and the pure string work around it. The half | |
| 4 | +## that fetches is `linkfetch`, split off for the reason `profile` and | |
| 5 | +## `profilefetch` are split: a screen asks what is known and never for it to | |
| 6 | +## be found out, and a screen that reached an HTTP client could not be | |
| 7 | +## compiled for a target without sockets. | |
| 8 | +## | |
| 9 | +## The metadata is OpenGraph, and it comes from one of two places. freeq | |
| 10 | +## carries a sender's own preview on the message as `+freeq.at/link-*` tags, | |
| 11 | +## which is the better answer where it is there: it is signed with the line | |
| 12 | +## and it costs nothing. Where it is not, freeq's own server will fetch the | |
| 13 | +## page and hand back the tags — `GET /api/v1/og?url=`. Asking the server | |
| 14 | +## rather than the page is what keeps a reader's browsing off every host | |
| 15 | +## somebody pastes a link to, and is why there is no direct fetch here even | |
| 16 | +## on the desktop, where one would work. | |
| 17 | + | |
| 18 | +import std/[json, strutils, tables, uri] | |
| 19 | +import frq/textruns | |
| 20 | + | |
| 21 | +type | |
| 22 | + PreviewStatus* = enum | |
| 23 | + lsLoading = "loading", lsReady = "ready", lsFailed = "failed" | |
| 24 | + | |
| 25 | + Preview* = object | |
| 26 | + status*: PreviewStatus | |
| 27 | + title*, description*, siteName*, image*: string | |
| 28 | + | |
| 29 | +var cache: Table[string, Preview] | |
| 30 | + | |
| 31 | +func previewable*(url: string): bool = | |
| 32 | + ## Whether this link is a web page worth a card. | |
| 33 | + ## | |
| 34 | + ## A picture already draws itself inline, a media file has nothing to say | |
| 35 | + ## about itself in HTML, and freeq's own API URLs are not pages at all — | |
| 36 | + ## asking about any of them is a round trip that can only come back empty. | |
| 37 | + if not (url.startsWith("http://") or url.startsWith("https://")): return false | |
| 38 | + let low = url.toLowerAscii | |
| 39 | + if low.contains("/api/v1/"): return false | |
| 40 | + for ext in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", | |
| 41 | + ".mp3", ".m4a", ".mp4", ".mov", ".webm", ".ogg", ".wav", ".aac", | |
| 42 | + ".pdf", ".zip"]: | |
| 43 | + # The extension at the end of the path, not anywhere in the URL: a query | |
| 44 | + # string that mentions `.png` is still a page. | |
| 45 | + var stop = low.len | |
| 46 | + for i, c in low: | |
| 47 | + if c in {'?', '#'}: | |
| 48 | + stop = i | |
| 49 | + break | |
| 50 | + if low[0 ..< stop].endsWith(ext): return false | |
| 51 | + true | |
| 52 | + | |
| 53 | +func firstPreviewUrl*(text: string): string = | |
| 54 | + ## The one link in a message that gets a card, or "". | |
| 55 | + ## | |
| 56 | + ## The first, and only the first: a line with four links in it is a line | |
| 57 | + ## about the links, and four cards under it is a screenful for one message. | |
| 58 | + for r in textRuns(text): | |
| 59 | + if r.kind == rkLink and previewable(r.value): | |
| 60 | + return r.value | |
| 61 | + "" | |
| 62 | + | |
| 63 | +func domainOf*(url: string): string = | |
| 64 | + ## `news.example.com` out of a URL, without the `www.` — what the card says | |
| 65 | + ## it is showing you, and the one part of a link a reader reads. | |
| 66 | + var rest = url | |
| 67 | + let scheme = rest.find("://") | |
| 68 | + if scheme >= 0: rest = rest[scheme + 3 .. ^1] | |
| 69 | + for i, c in rest: | |
| 70 | + if c in {'/', '?', '#'}: | |
| 71 | + rest = rest[0 ..< i] | |
| 72 | + break | |
| 73 | + let at = rest.rfind('@') # userinfo, which is not the host | |
| 74 | + if at >= 0: rest = rest[at + 1 .. ^1] | |
| 75 | + let colon = rest.find(':') # a port | |
| 76 | + if colon >= 0: rest = rest[0 ..< colon] | |
| 77 | + rest = rest.toLowerAscii | |
| 78 | + if rest.startsWith("www."): rest[4 .. ^1] else: rest | |
| 79 | + | |
| 80 | +func ogEndpoint*(host, url: string): string = | |
| 81 | + ## freeq's preview proxy, on the server this client is connected to. | |
| 82 | + ## | |
| 83 | + ## Port 6697 is the IRC socket and has nothing to do with this; the REST | |
| 84 | + ## side is plain HTTPS, so whatever port the form carries is dropped. | |
| 85 | + var h = host.strip() | |
| 86 | + let colon = h.find(':') | |
| 87 | + if colon >= 0: h = h[0 ..< colon] | |
| 88 | + if h.len == 0: return "" | |
| 89 | + # `usePlus = false`: a space comes out as %20 rather than as `+`, which is | |
| 90 | + # form encoding and means a literal plus in a path — a real one in a URL — | |
| 91 | + # would have to be escaped to survive. %20 is right for both. | |
| 92 | + "https://" & h & "/api/v1/og?url=" & encodeUrl(url, usePlus = false) | |
| 93 | + | |
| 94 | +func parsePreview*(body: JsonNode): Preview = | |
| 95 | + ## The card's fields out of an `/api/v1/og` answer. Every one of them is | |
| 96 | + ## optional there — a page with no OpenGraph at all answers with four | |
| 97 | + ## nulls — so a preview with nothing in it is a failure rather than an | |
| 98 | + ## empty card. | |
| 99 | + result = Preview(status: lsReady, | |
| 100 | + title: body{"title"}.getStr().strip(), | |
| 101 | + description: body{"description"}.getStr().strip(), | |
| 102 | + siteName: body{"site_name"}.getStr().strip(), | |
| 103 | + image: body{"image"}.getStr().strip()) | |
| 104 | + if result.title.len == 0 and result.image.len == 0: | |
| 105 | + result = Preview(status: lsFailed) | |
| 106 | + | |
| 107 | +proc remember*(url: string, p: Preview) = | |
| 108 | + ## Put a preview in the cache. For `linkfetch`, and for the reducer where a | |
| 109 | + ## message arrived carrying its own. | |
| 110 | + cache[url] = p | |
| 111 | + | |
| 112 | +proc known*(url: string): bool = cache.hasKey(url) | |
| 113 | + | |
| 114 | +proc lookup*(url: string): (Preview, bool) = | |
| 115 | + ## What is known about this link right now — `lookup` rather than `entry`, | |
| 116 | + ## which is what `profile` calls its own, because a screen reads both and | |
| 117 | + ## two procs of that name taking a string are ambiguous at the call site. | |
| 118 | + ##, and whether anything is. A | |
| 119 | + ## lookup and never a fetch: this is called from the render path. | |
| 120 | + if cache.hasKey(url): (cache[url], true) | |
| 121 | + else: (Preview(), false) | |
| 122 | + | |
| 123 | +proc setPreviewForTest*(url: string, p: Preview) = cache[url] = p | |
| 124 | + | |
| 125 | +proc forgetPreviews*() = | |
| 126 | + ## For a test that wants a known starting point. | |
| 127 | + cache.clear() | |
| new file mode 100644 | |||
| @@ -0,0 +1,127 @@ | |||
| 1 | +## What is at the end of a link, for the card under the message. | ||
| 2 | +## | ||
| 3 | +## The cache the screens read, and the pure string work around it. The half | ||
| 4 | +## that fetches is `linkfetch`, split off for the reason `profile` and | ||
| 5 | +## `profilefetch` are split: a screen asks what is known and never for it to | ||
| 6 | +## be found out, and a screen that reached an HTTP client could not be | ||
| 7 | +## compiled for a target without sockets. | ||
| 8 | +## | ||
| 9 | +## The metadata is OpenGraph, and it comes from one of two places. freeq | ||
| 10 | +## carries a sender's own preview on the message as `+freeq.at/link-*` tags, | ||
| 11 | +## which is the better answer where it is there: it is signed with the line | ||
| 12 | +## and it costs nothing. Where it is not, freeq's own server will fetch the | ||
| 13 | +## page and hand back the tags — `GET /api/v1/og?url=`. Asking the server | ||
| 14 | +## rather than the page is what keeps a reader's browsing off every host | ||
| 15 | +## somebody pastes a link to, and is why there is no direct fetch here even | ||
| 16 | +## on the desktop, where one would work. | ||
| 17 | + | ||
| 18 | +import std/[json, strutils, tables, uri] | ||
| 19 | +import frq/textruns | ||
| 20 | + | ||
| 21 | +type | ||
| 22 | + PreviewStatus* = enum | ||
| 23 | + lsLoading = "loading", lsReady = "ready", lsFailed = "failed" | ||
| 24 | + | ||
| 25 | + Preview* = object | ||
| 26 | + status*: PreviewStatus | ||
| 27 | + title*, description*, siteName*, image*: string | ||
| 28 | + | ||
| 29 | +var cache: Table[string, Preview] | ||
| 30 | + | ||
| 31 | +func previewable*(url: string): bool = | ||
| 32 | + ## Whether this link is a web page worth a card. | ||
| 33 | + ## | ||
| 34 | + ## A picture already draws itself inline, a media file has nothing to say | ||
| 35 | + ## about itself in HTML, and freeq's own API URLs are not pages at all — | ||
| 36 | + ## asking about any of them is a round trip that can only come back empty. | ||
| 37 | + if not (url.startsWith("http://") or url.startsWith("https://")): return false | ||
| 38 | + let low = url.toLowerAscii | ||
| 39 | + if low.contains("/api/v1/"): return false | ||
| 40 | + for ext in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", | ||
| 41 | + ".mp3", ".m4a", ".mp4", ".mov", ".webm", ".ogg", ".wav", ".aac", | ||
| 42 | + ".pdf", ".zip"]: | ||
| 43 | + # The extension at the end of the path, not anywhere in the URL: a query | ||
| 44 | + # string that mentions `.png` is still a page. | ||
| 45 | + var stop = low.len | ||
| 46 | + for i, c in low: | ||
| 47 | + if c in {'?', '#'}: | ||
| 48 | + stop = i | ||
| 49 | + break | ||
| 50 | + if low[0 ..< stop].endsWith(ext): return false | ||
| 51 | + true | ||
| 52 | + | ||
| 53 | +func firstPreviewUrl*(text: string): string = | ||
| 54 | + ## The one link in a message that gets a card, or "". | ||
| 55 | + ## | ||
| 56 | + ## The first, and only the first: a line with four links in it is a line | ||
| 57 | + ## about the links, and four cards under it is a screenful for one message. | ||
| 58 | + for r in textRuns(text): | ||
| 59 | + if r.kind == rkLink and previewable(r.value): | ||
| 60 | + return r.value | ||
| 61 | + "" | ||
| 62 | + | ||
| 63 | +func domainOf*(url: string): string = | ||
| 64 | + ## `news.example.com` out of a URL, without the `www.` — what the card says | ||
| 65 | + ## it is showing you, and the one part of a link a reader reads. | ||
| 66 | + var rest = url | ||
| 67 | + let scheme = rest.find("://") | ||
| 68 | + if scheme >= 0: rest = rest[scheme + 3 .. ^1] | ||
| 69 | + for i, c in rest: | ||
| 70 | + if c in {'/', '?', '#'}: | ||
| 71 | + rest = rest[0 ..< i] | ||
| 72 | + break | ||
| 73 | + let at = rest.rfind('@') # userinfo, which is not the host | ||
| 74 | + if at >= 0: rest = rest[at + 1 .. ^1] | ||
| 75 | + let colon = rest.find(':') # a port | ||
| 76 | + if colon >= 0: rest = rest[0 ..< colon] | ||
| 77 | + rest = rest.toLowerAscii | ||
| 78 | + if rest.startsWith("www."): rest[4 .. ^1] else: rest | ||
| 79 | + | ||
| 80 | +func ogEndpoint*(host, url: string): string = | ||
| 81 | + ## freeq's preview proxy, on the server this client is connected to. | ||
| 82 | + ## | ||
| 83 | + ## Port 6697 is the IRC socket and has nothing to do with this; the REST | ||
| 84 | + ## side is plain HTTPS, so whatever port the form carries is dropped. | ||
| 85 | + var h = host.strip() | ||
| 86 | + let colon = h.find(':') | ||
| 87 | + if colon >= 0: h = h[0 ..< colon] | ||
| 88 | + if h.len == 0: return "" | ||
| 89 | + # `usePlus = false`: a space comes out as %20 rather than as `+`, which is | ||
| 90 | + # form encoding and means a literal plus in a path — a real one in a URL — | ||
| 91 | + # would have to be escaped to survive. %20 is right for both. | ||
| 92 | + "https://" & h & "/api/v1/og?url=" & encodeUrl(url, usePlus = false) | ||
| 93 | + | ||
| 94 | +func parsePreview*(body: JsonNode): Preview = | ||
| 95 | + ## The card's fields out of an `/api/v1/og` answer. Every one of them is | ||
| 96 | + ## optional there — a page with no OpenGraph at all answers with four | ||
| 97 | + ## nulls — so a preview with nothing in it is a failure rather than an | ||
| 98 | + ## empty card. | ||
| 99 | + result = Preview(status: lsReady, | ||
| 100 | + title: body{"title"}.getStr().strip(), | ||
| 101 | + description: body{"description"}.getStr().strip(), | ||
| 102 | + siteName: body{"site_name"}.getStr().strip(), | ||
| 103 | + image: body{"image"}.getStr().strip()) | ||
| 104 | + if result.title.len == 0 and result.image.len == 0: | ||
| 105 | + result = Preview(status: lsFailed) | ||
| 106 | + | ||
| 107 | +proc remember*(url: string, p: Preview) = | ||
| 108 | + ## Put a preview in the cache. For `linkfetch`, and for the reducer where a | ||
| 109 | + ## message arrived carrying its own. | ||
| 110 | + cache[url] = p | ||
| 111 | + | ||
| 112 | +proc known*(url: string): bool = cache.hasKey(url) | ||
| 113 | + | ||
| 114 | +proc lookup*(url: string): (Preview, bool) = | ||
| 115 | + ## What is known about this link right now — `lookup` rather than `entry`, | ||
| 116 | + ## which is what `profile` calls its own, because a screen reads both and | ||
| 117 | + ## two procs of that name taking a string are ambiguous at the call site. | ||
| 118 | + ##, and whether anything is. A | ||
| 119 | + ## lookup and never a fetch: this is called from the render path. | ||
| 120 | + if cache.hasKey(url): (cache[url], true) | ||
| 121 | + else: (Preview(), false) | ||
| 122 | + | ||
| 123 | +proc setPreviewForTest*(url: string, p: Preview) = cache[url] = p | ||
| 124 | + | ||
| 125 | +proc forgetPreviews*() = | ||
| 126 | + ## For a test that wants a known starting point. | ||
| 127 | + cache.clear() | ||
modified
nim/src/frq/reducer.nim +37 -2 | @@ -16,6 +16,8 @@ import std/sets | ||
| 16 | 16 | import frq/[cells, model, rooms, reactions, trace, ircparse, clock, |
| 17 | 17 | atproto, handshake, textruns, members, msgsig, profile, store, |
| 18 | 18 | profilefetch, edits] |
| 19 | +import frq/links as lk | |
| 20 | +import frq/linkfetch as lf | |
| 19 | 21 | import frq/conn as tr |
| 20 | 22 | import frq/oauth as oa |
| 21 | 23 | |
| @@ -47,6 +49,9 @@ proc wantFace(m: Message) | ||
| 47 | 49 | ## And this because `sendDraft` is: our own line wants a face as much as |
| 48 | 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 | 55 | proc openSocket() |
| 51 | 56 | ## And this because a browser sign-in finishes above it: the host answers |
| 52 | 57 | ## asynchronously, and what it answers with is "now you may connect". |
| @@ -780,6 +785,33 @@ proc wantFace(m: Message) = | ||
| 780 | 785 | let actor = actorFor(did, m.frm) |
| 781 | 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 | 815 | proc note(room: string, m: Message) = |
| 784 | 816 | app.rooms.ensureRoom(room) |
| 785 | 817 | var r = app.rooms[room] |
| @@ -788,6 +820,7 @@ proc note(room: string, m: Message) = | ||
| 788 | 820 | r.lastActivity = nowMs() |
| 789 | 821 | app.rooms[room] = r.recount(app.formNick) |
| 790 | 822 | wantFace(m) |
| 823 | + wantPreview(m) | |
| 791 | 824 | |
| 792 | 825 | proc drain*() = |
| 793 | 826 | # The browser handoff, before the socket: a sign-in that just landed should |
| @@ -920,6 +953,7 @@ proc drain*() = | ||
| 920 | 953 | if hasRep: m.replyTo = rep |
| 921 | 954 | let (tally, hasTally) = tagValue(p.tags, "+freeq.at/reacts") |
| 922 | 955 | if hasTally: m.reactions = parseTally(tally) |
| 956 | + notePreviewTags(p.tags) | |
| 923 | 957 | note(room, m) |
| 924 | 958 | |
| 925 | 959 | of "TAGMSG": |
| @@ -1089,8 +1123,9 @@ proc drain*() = | ||
| 1089 | 1123 | else: |
| 1090 | 1124 | trace("skip", p.command & " " & $p.params) |
| 1091 | 1125 | |
| 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() | |
| 1094 | 1129 | |
| 1095 | 1130 | # A line arriving moves the marker in the room being looked at, and closing |
| 1096 | 1131 | # 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 tr | 21 | import frq/conn as tr |
| 20 | import frq/oauth as oa | 22 | 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 as | 49 | ## 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 answers | 56 | ## 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 should | 826 | # The browser handoff, before the socket: a sign-in that just landed should |
| @@ -920,6 +953,7 @@ proc drain*() = | |||
| 920 | if hasRep: m.replyTo = rep | 953 | 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 closing | 1130 | # 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 saving | 1131 | # 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] | ||
| 16 | 16 | import std/options |
| 17 | 17 | import frq/[ui, cells, model, clock, reactions, textruns, members, |
| 18 | 18 | glyphs, emoji, rooms, profile] |
| 19 | +import frq/links as lk | |
| 19 | 20 | from frq/screens/connect import errorNote |
| 20 | 21 | from frq/screens/frame import tabBar |
| 21 | 22 | |
| @@ -95,6 +96,34 @@ func runNodes(m: Message): Node = | ||
| 95 | 96 | of rkText: result.children.add text(r.value) |
| 96 | 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 | 127 | const pickerColumns = 8 |
| 99 | 128 | ## How wide the emoji grid is. Narrow enough to sit under a message on a |
| 100 | 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 | 238 | images.children.add image(m.imageUrl, maxWidth = 320, maxHeight = 240, |
| 210 | 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 | 249 | # The picker, under the message it is for and nowhere else. |
| 213 | 250 | var picker = vbox(%*{"key": "picker", "marginBottom": 4}) |
| 214 | 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 | 260 | n(if highlit: "card" else: "vbox", |
| 224 | 261 | %*{"key": (if highlit: "body-card" else: "body-plain"), |
| 225 | 262 | "spacing": 2, "margin": 0}, |
| 226 | - @[who, body, picker, images, pills]) | |
| 263 | + @[who, body, picker, images, preview, pills]) | |
| 227 | 264 | |
| 228 | 265 | proc messageRow(s: State, room: Room, i: int, m: Message): Node = |
| 229 | 266 | ## 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/options | 16 | 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 errorNote | 20 | from frq/screens/connect import errorNote |
| 20 | from frq/screens/frame import tabBar | 21 | 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 = 8 | 127 | const pickerColumns = 8 |
| 99 | ## How wide the emoji grid is. Narrow enough to sit under a message on a | 128 | ## 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 @@ | ||
| 3 | 3 | import std/[json, sequtils, strutils, tables, unicode, unittest] |
| 4 | 4 | import frq/[ui, cells, model, textruns] |
| 5 | 5 | import frq/screens/chat as cs |
| 6 | +import frq/links as lk | |
| 6 | 7 | |
| 7 | 8 | proc find(node: Node, tag: string): seq[Node] = |
| 8 | 9 | if node.isNil: return |
| @@ -116,6 +117,35 @@ suite "the chat screen": | ||
| 116 | 117 | check img.len == 1 |
| 117 | 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 | 149 | test "a reply quotes what it answers, and offers a way there": |
| 120 | 150 | var r = s.rooms["#test"] |
| 121 | 151 | 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 cs | 5 | 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: return | 9 | if node.isNil: return |
| @@ -116,6 +117,35 @@ suite "the chat screen": | |||
| 116 | check img.len == 1 | 117 | 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/tests/tlinks.nim +88 -0 | new file mode 100644 | ||
| @@ -0,0 +1,88 @@ | ||
| 1 | +## What is at the end of a link. No network: every case here is a URL in and | |
| 2 | +## an answer out, or a body in and a card's fields out. | |
| 3 | + | |
| 4 | +import std/[json, strutils, unittest] | |
| 5 | +import frq/links | |
| 6 | + | |
| 7 | +suite "previewable": | |
| 8 | + test "a web page is": | |
| 9 | + check previewable("https://example.com/a-post") | |
| 10 | + check previewable("http://example.com") | |
| 11 | + test "a picture is not — it already draws itself inline": | |
| 12 | + check not previewable("https://x.com/a.png") | |
| 13 | + check not previewable("https://x.com/A.JPEG") | |
| 14 | + test "nor is a media file, which has no HTML to read": | |
| 15 | + check not previewable("https://x.com/song.mp3") | |
| 16 | + check not previewable("https://x.com/clip.webm") | |
| 17 | + test "an extension in the query string is not the path's": | |
| 18 | + check previewable("https://example.com/page?img=a.png") | |
| 19 | + test "freeq's own API is not a page": | |
| 20 | + check not previewable("https://irc.freeq.at/api/v1/media/abc") | |
| 21 | + test "nor is something that is not a URL at all": | |
| 22 | + check not previewable("example.com") | |
| 23 | + check not previewable("") | |
| 24 | + | |
| 25 | +suite "firstPreviewUrl": | |
| 26 | + test "the first link that is a page": | |
| 27 | + check firstPreviewUrl("see https://example.com/x now") == | |
| 28 | + "https://example.com/x" | |
| 29 | + test "only the first — four cards for one line is a screenful": | |
| 30 | + check firstPreviewUrl("https://a.example/1 https://b.example/2") == | |
| 31 | + "https://a.example/1" | |
| 32 | + test "a picture is skipped in favour of the page after it": | |
| 33 | + check firstPreviewUrl("https://x.com/a.png and https://example.com") == | |
| 34 | + "https://example.com" | |
| 35 | + test "a line with no link has none": | |
| 36 | + check firstPreviewUrl("just words") == "" | |
| 37 | + | |
| 38 | +suite "domainOf": | |
| 39 | + test "the host, without the www": | |
| 40 | + check domainOf("https://www.example.com/a/b?c=d") == "example.com" | |
| 41 | + check domainOf("https://news.example.com") == "news.example.com" | |
| 42 | + test "a port and userinfo are not the host": | |
| 43 | + check domainOf("http://example.com:8080/x") == "example.com" | |
| 44 | + check domainOf("https://user@example.com/x") == "example.com" | |
| 45 | + | |
| 46 | +suite "ogEndpoint": | |
| 47 | + test "freeq's proxy, on the server we are connected to": | |
| 48 | + check ogEndpoint("irc.freeq.at", "https://example.com/a b") == | |
| 49 | + "https://irc.freeq.at/api/v1/og?url=https%3A%2F%2Fexample.com%2Fa%20b" | |
| 50 | + test "the IRC port has nothing to do with the REST side": | |
| 51 | + check ogEndpoint("irc.freeq.at:6697", "https://e.com").startsWith( | |
| 52 | + "https://irc.freeq.at/api/v1/og?") | |
| 53 | + test "no host, no endpoint": | |
| 54 | + check ogEndpoint("", "https://e.com") == "" | |
| 55 | + | |
| 56 | +suite "parsePreview": | |
| 57 | + test "the fields the card paints": | |
| 58 | + let p = parsePreview(%*{"title": "A post", "description": "About it", | |
| 59 | + "image": "https://e.com/i.png", | |
| 60 | + "site_name": "Example"}) | |
| 61 | + check p.status == lsReady | |
| 62 | + check p.title == "A post" | |
| 63 | + check p.description == "About it" | |
| 64 | + check p.image == "https://e.com/i.png" | |
| 65 | + check p.siteName == "Example" | |
| 66 | + test "a page with no OpenGraph answers with nulls, and that is a failure": | |
| 67 | + # Rather than an empty card, which would be a row about this client. | |
| 68 | + check parsePreview(%*{"title": newJNull(), "description": newJNull(), | |
| 69 | + "image": newJNull(), | |
| 70 | + "site_name": newJNull()}).status == lsFailed | |
| 71 | + test "a description alone is not enough to draw": | |
| 72 | + check parsePreview(%*{"description": "only this"}).status == lsFailed | |
| 73 | + test "a picture alone is": | |
| 74 | + check parsePreview(%*{"image": "https://e.com/i.png"}).status == lsReady | |
| 75 | + | |
| 76 | +suite "the cache": | |
| 77 | + setup: | |
| 78 | + forgetPreviews() | |
| 79 | + test "nothing is known until it is remembered": | |
| 80 | + check not known("https://e.com") | |
| 81 | + let (_, has) = lookup("https://e.com") | |
| 82 | + check not has | |
| 83 | + test "and then it is": | |
| 84 | + remember("https://e.com", Preview(status: lsReady, title: "T")) | |
| 85 | + check known("https://e.com") | |
| 86 | + let (p, has) = lookup("https://e.com") | |
| 87 | + check has | |
| 88 | + check p.title == "T" | |
| new file mode 100644 | |||
| @@ -0,0 +1,88 @@ | |||
| 1 | +## What is at the end of a link. No network: every case here is a URL in and | ||
| 2 | +## an answer out, or a body in and a card's fields out. | ||
| 3 | + | ||
| 4 | +import std/[json, strutils, unittest] | ||
| 5 | +import frq/links | ||
| 6 | + | ||
| 7 | +suite "previewable": | ||
| 8 | + test "a web page is": | ||
| 9 | + check previewable("https://example.com/a-post") | ||
| 10 | + check previewable("http://example.com") | ||
| 11 | + test "a picture is not — it already draws itself inline": | ||
| 12 | + check not previewable("https://x.com/a.png") | ||
| 13 | + check not previewable("https://x.com/A.JPEG") | ||
| 14 | + test "nor is a media file, which has no HTML to read": | ||
| 15 | + check not previewable("https://x.com/song.mp3") | ||
| 16 | + check not previewable("https://x.com/clip.webm") | ||
| 17 | + test "an extension in the query string is not the path's": | ||
| 18 | + check previewable("https://example.com/page?img=a.png") | ||
| 19 | + test "freeq's own API is not a page": | ||
| 20 | + check not previewable("https://irc.freeq.at/api/v1/media/abc") | ||
| 21 | + test "nor is something that is not a URL at all": | ||
| 22 | + check not previewable("example.com") | ||
| 23 | + check not previewable("") | ||
| 24 | + | ||
| 25 | +suite "firstPreviewUrl": | ||
| 26 | + test "the first link that is a page": | ||
| 27 | + check firstPreviewUrl("see https://example.com/x now") == | ||
| 28 | + "https://example.com/x" | ||
| 29 | + test "only the first — four cards for one line is a screenful": | ||
| 30 | + check firstPreviewUrl("https://a.example/1 https://b.example/2") == | ||
| 31 | + "https://a.example/1" | ||
| 32 | + test "a picture is skipped in favour of the page after it": | ||
| 33 | + check firstPreviewUrl("https://x.com/a.png and https://example.com") == | ||
| 34 | + "https://example.com" | ||
| 35 | + test "a line with no link has none": | ||
| 36 | + check firstPreviewUrl("just words") == "" | ||
| 37 | + | ||
| 38 | +suite "domainOf": | ||
| 39 | + test "the host, without the www": | ||
| 40 | + check domainOf("https://www.example.com/a/b?c=d") == "example.com" | ||
| 41 | + check domainOf("https://news.example.com") == "news.example.com" | ||
| 42 | + test "a port and userinfo are not the host": | ||
| 43 | + check domainOf("http://example.com:8080/x") == "example.com" | ||
| 44 | + check domainOf("https://user@example.com/x") == "example.com" | ||
| 45 | + | ||
| 46 | +suite "ogEndpoint": | ||
| 47 | + test "freeq's proxy, on the server we are connected to": | ||
| 48 | + check ogEndpoint("irc.freeq.at", "https://example.com/a b") == | ||
| 49 | + "https://irc.freeq.at/api/v1/og?url=https%3A%2F%2Fexample.com%2Fa%20b" | ||
| 50 | + test "the IRC port has nothing to do with the REST side": | ||
| 51 | + check ogEndpoint("irc.freeq.at:6697", "https://e.com").startsWith( | ||
| 52 | + "https://irc.freeq.at/api/v1/og?") | ||
| 53 | + test "no host, no endpoint": | ||
| 54 | + check ogEndpoint("", "https://e.com") == "" | ||
| 55 | + | ||
| 56 | +suite "parsePreview": | ||
| 57 | + test "the fields the card paints": | ||
| 58 | + let p = parsePreview(%*{"title": "A post", "description": "About it", | ||
| 59 | + "image": "https://e.com/i.png", | ||
| 60 | + "site_name": "Example"}) | ||
| 61 | + check p.status == lsReady | ||
| 62 | + check p.title == "A post" | ||
| 63 | + check p.description == "About it" | ||
| 64 | + check p.image == "https://e.com/i.png" | ||
| 65 | + check p.siteName == "Example" | ||
| 66 | + test "a page with no OpenGraph answers with nulls, and that is a failure": | ||
| 67 | + # Rather than an empty card, which would be a row about this client. | ||
| 68 | + check parsePreview(%*{"title": newJNull(), "description": newJNull(), | ||
| 69 | + "image": newJNull(), | ||
| 70 | + "site_name": newJNull()}).status == lsFailed | ||
| 71 | + test "a description alone is not enough to draw": | ||
| 72 | + check parsePreview(%*{"description": "only this"}).status == lsFailed | ||
| 73 | + test "a picture alone is": | ||
| 74 | + check parsePreview(%*{"image": "https://e.com/i.png"}).status == lsReady | ||
| 75 | + | ||
| 76 | +suite "the cache": | ||
| 77 | + setup: | ||
| 78 | + forgetPreviews() | ||
| 79 | + test "nothing is known until it is remembered": | ||
| 80 | + check not known("https://e.com") | ||
| 81 | + let (_, has) = lookup("https://e.com") | ||
| 82 | + check not has | ||
| 83 | + test "and then it is": | ||
| 84 | + remember("https://e.com", Preview(status: lsReady, title: "T")) | ||
| 85 | + check known("https://e.com") | ||
| 86 | + let (p, has) = lookup("https://e.com") | ||
| 87 | + check has | ||
| 88 | + check p.title == "T" | ||
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 | ||