Commit b8ab00377c20
tarball6 files changed+229-16
@@ -0,0 +1,49 @@
1
+# macltok request, URL previews via the homeserver2
+3
+## Problem4
+5
+Malice's link cards only show the URL's host and path. It wants the page's title, description and image, the way other Matrix clients do. The client can't scrape pages itself. Browsers block it with CORS, and fetching from the malice server would leak its IP to every linked site.6
+7
+Matrix already solves this with `GET /_matrix/client/v1/media/preview_url?url=...&ts=...` (ruma `authenticated_media::get_media_preview`). The homeserver fetches the page, parses OpenGraph, re-hosts `og:image` as an mxc, and returns the `og:*` keys as a loose JSON map. Knowing that endpoint, which keys matter, and that `og:image` is an mxc is protocol work every consumer would repeat, so it belongs in macltok. Nothing in `ClientHandle` reaches it today.8
+9
+## Request10
+11
+```rust12
+impl ClientHandle {13
+ /// None when the server has no preview for the url (404, empty body, or no usable keys)14
+ pub async fn url_preview(&self, url: &str) -> Result<Option<UrlPreview>, Error>;15
+}16
+17
+pub struct UrlPreview {18
+ pub url: String,19
+ pub title: Option<String>,20
+ pub description: Option<String>,21
+ pub site_name: Option<String>,22
+ pub image: Option<UrlPreviewImage>,23
+}24
+25
+pub struct UrlPreviewImage {26
+ /// og:image, already re-hosted by the server, usable with `media` / `media_source`27
+ pub media_id: MediaId,28
+ pub width: Option<u32>,29
+ pub height: Option<u32>,30
+ pub mimetype: Option<String>,31
+ pub size: Option<u64>,32
+}33
+```34
+35
+Things that would help beyond the bare call.36
+37
+1. Fall back to the legacy `/_matrix/media/v3/preview_url` when the server lacks authenticated media, the same way `media` does.38
+2. Cache results per url for a while, including negative ones. Synapse rate-limits this endpoint and a busy timeline repeats links.39
+3. Clamp string lengths, since the server passes page text through unfiltered. Something like 256 chars for title and site name, 1024 for description.40
+4. Treat `og:image` that isn't an `mxc://` uri as absent.41
+5. Map 429 and 5xx to a distinct error, so hosts can tell "try later" from "no preview".42
+43
+## Optional, room level opt-out44
+45
+Element honours `org.matrix.room.preview_urls` room state (`{"disable": true}`, set by room admins) and the per-user `org.matrix.preview_urls` room account data. If macltok exposed those on `RoomSummary`, or as `url_previews_allowed(room_id) -> bool`, malice wouldn't need to know the event types.46
+47
+## Malice side, for context48
+49
+Malice will call this only in unencrypted rooms by default, with a user setting to also allow it in encrypted ones, because the homeserver sees every previewed url. The image goes through malice's existing bounded media endpoint by `media_id`.@@ -0,0 +1,76 @@
1
+import type { Meta, StoryObj } from '@storybook/svelte-vite';2
+import { expect, waitFor } from 'storybook/test';3
+import LinkCard from './LinkCard.svelte';4
+import { findUrls } from './urls';5
+import type { LinkPreview } from './types';6
+7
+const BANNER = `data:image/svg+xml,${encodeURIComponent(8
+ '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630"><defs><linearGradient id="g" x2="1" y2="1"><stop offset="0" stop-color="#7c5cff"/><stop offset="1" stop-color="#ff7ab6"/></linearGradient></defs><rect width="1200" height="630" fill="url(#g)"/><circle cx="900" cy="200" r="120" fill="#fff" opacity=".35"/></svg>'9
+)}`;10
+const ICON = `data:image/svg+xml,${encodeURIComponent(11
+ '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect width="200" height="200" fill="#2b2f3a"/><text x="100" y="128" font-size="96" text-anchor="middle" fill="#fff">🐇</text></svg>'12
+)}`;13
+14
+const IMAGES: Record<string, string> = {15
+ 'mxc://example.org/banner': BANNER,16
+ 'mxc://example.org/icon': ICON17
+};18
+19
+const [link] = findUrls('https://example.org/rabbit-hole/chapter-1?ref=matrix');20
+21
+function preview(overrides: Partial<LinkPreview> = {}): LinkPreview {22
+ return {23
+ url: link.href,24
+ title: 'Down the Rabbit-Hole',25
+ description:26
+ 'Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do.',27
+ site_name: 'Wonderland Press',28
+ image: null,29
+ ...overrides30
+ };31
+}32
+33
+const meta = {34
+ title: 'Timeline/LinkCard',35
+ component: LinkCard,36
+ args: { link, preview: null, imageUrl: (id: string) => IMAGES[id] ?? `/missing/${encodeURIComponent(id)}` }37
+} satisfies Meta<typeof LinkCard>;38
+39
+export default meta;40
+type Story = StoryObj<typeof meta>;41
+42
+export const UrlOnly: Story = {43
+ play: async ({ canvas }) => {44
+ await expect(canvas.getByRole('link')).toHaveAttribute('href', link.href);45
+ await expect(canvas.getByText('example.org')).toBeVisible();46
+ }47
+};48
+export const TextOnly: Story = { args: { preview: preview() } };49
+export const Thumbnail: Story = {50
+ args: { preview: preview({ image: { media_id: 'mxc://example.org/icon', width: 200, height: 200 } }) }51
+};52
+export const Banner: Story = {53
+ args: { preview: preview({ image: { media_id: 'mxc://example.org/banner', width: 1200, height: 630 } }) }54
+};55
+export const UnknownImageSize: Story = {56
+ args: { preview: preview({ image: { media_id: 'mxc://example.org/banner', width: null, height: null } }) }57
+};58
+export const NoSiteName: Story = {59
+ args: { preview: preview({ site_name: null, description: null }) },60
+ play: async ({ canvas }) => {61
+ await expect(canvas.getByText('example.org')).toBeVisible();62
+ await expect(canvas.getByText('Down the Rabbit-Hole')).toBeVisible();63
+ }64
+};65
+export const ImageFails: Story = {66
+ args: { preview: preview({ image: { media_id: 'mxc://example.org/gone', width: 1200, height: 630 } }) },67
+ play: async ({ canvasElement }) => {68
+ await waitFor(() => expect(canvasElement.querySelector('img')).toBeNull());69
+ }70
+};71
+export const NoUsableText: Story = {72
+ args: { preview: preview({ title: null, description: null }) },73
+ play: async ({ canvas }) => {74
+ await expect(canvas.getByText(link.display)).toBeVisible();75
+ }76
+};@@ -0,0 +1,69 @@
1
+<script lang="ts">2
+ import { messageMediaUrl } from './api';3
+ import type { LinkPreview } from './types';4
+ import type { UrlPreview } from './urls';5
+6
+ export let link: UrlPreview;7
+ export let preview: LinkPreview | null = null;8
+ export let imageUrl: (mediaId: string) => string = (mediaId) => messageMediaUrl(mediaId, 'Thumbnail');9
+10
+ const BANNER_MIN_WIDTH = 320;11
+ const BANNER_MIN_ASPECT = 1.3;12
+13
+ let imageFailed = false;14
+15
+ $: image = preview?.image && !imageFailed ? preview.image : null;16
+ $: imageSrc = image ? imageUrl(image.media_id) : null;17
+ $: banner =18
+ image !== null &&19
+ image.width !== null &&20
+ image.height !== null &&21
+ image.width >= BANNER_MIN_WIDTH &&22
+ image.width / image.height >= BANNER_MIN_ASPECT;23
+ $: rich = Boolean(preview && (preview.title || preview.description));24
+ $: if (preview) imageFailed = false;25
+</script>26
+27
+<a class="link-card" class:rich class:banner href={link.href} target="_blank" rel="noopener noreferrer">28
+ {#if rich && preview}29
+ <span class="body">30
+ <span class="link-host">{preview.site_name ?? link.hostname}</span>31
+ {#if preview.title}<span class="link-title">{preview.title}</span>{/if}32
+ {#if preview.description}<span class="link-desc">{preview.description}</span>{/if}33
+ </span>34
+ {:else}35
+ <span class="body">36
+ <span class="link-host">{link.hostname}</span>37
+ {#if link.path}<span class="link-path">{link.path}</span>{/if}38
+ <span class="link-url">{link.display}</span>39
+ </span>40
+ {/if}41
+ {#if imageSrc}42
+ <img class="link-image" src={imageSrc} alt="" loading="lazy" on:error={() => (imageFailed = true)} />43
+ {/if}44
+</a>45
+46
+<style>47
+ .link-card {48
+ display: flex; gap: 10px;49
+ border: 1px solid var(--border); border-radius: 8px;50
+ background: var(--bg-soft); padding: 8px 10px;51
+ text-decoration: none; color: var(--text);52
+ overflow: hidden;53
+ }54
+ .link-card.rich { border-left: 3px solid var(--accent); }55
+ .link-card.banner { flex-direction: column; }56
+ .link-card:hover { border-color: var(--accent); }57
+ .body { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }58
+ .link-host { font-size: 12.5px; font-weight: 650; }59
+ .rich .link-host { font-size: 11.5px; font-weight: 600; color: var(--muted); }60
+ .link-title { font-size: 14px; font-weight: 650; color: var(--accent); overflow-wrap: anywhere; }61
+ .link-desc {62
+ font-size: 12.5px; color: var(--muted); overflow-wrap: anywhere;63
+ display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;64
+ }65
+ .link-path { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }66
+ .link-url { font-size: 11.5px; color: var(--accent); overflow-wrap: anywhere; }67
+ .link-image { flex: none; width: 72px; height: 72px; object-fit: cover; border-radius: 6px; }68
+ .banner .link-image { width: 100%; height: auto; max-height: 220px; }69
+</style>@@ -82,6 +82,24 @@ export const PickerFromToolbar: Story = {
8282
};8383
export const Continuation: Story = { args: { showSender: false } };8484
export const WithLink: Story = { args: { event: event(text('Notes are at https://example.org/rabbit-hole')) } };85
+export const WithRichLink: Story = {86
+ args: {87
+ event: event(text('Notes are at https://example.org/rabbit-hole and https://example.net/tea')),88
+ linkPreviews: {89
+ 'https://example.org/rabbit-hole': {90
+ url: 'https://example.org/rabbit-hole',91
+ title: 'Down the Rabbit-Hole',92
+ description: 'Alice was beginning to get very tired of sitting by her sister on the bank.',93
+ site_name: 'Wonderland Press',94
+ image: null95
+ }96
+ }97
+ },98
+ play: async ({ canvas }) => {99
+ await expect(canvas.getByText('Down the Rabbit-Hole')).toBeVisible();100
+ await expect(canvas.getByText('example.net')).toBeVisible();101
+ }102
+};85103
export const Reactions: Story = {86104
args: {87105
event: event(text('Tea party at six'), {@@ -4,8 +4,9 @@
44
import Emoji from './Emoji.svelte';55
import EmojiText from './EmojiText.svelte';66
import ReactionPicker from './ReactionPicker.svelte';7
+ import LinkCard from './LinkCard.svelte';78
import { mediaUrl } from './api';8
- import type { ImagePack, QuickReaction, SendState, TimelineContent, TimelineEvent } from './types';9
+ import type { ImagePack, LinkPreview, QuickReaction, SendState, TimelineContent, TimelineEvent } from './types';910
import { senderLabel } from './types';1011
import { formatClock, formatDay, initialsOf, senderHue } from './format';1112
import { findUrls } from './urls';@@ -19,6 +20,7 @@
1920
export let quickReactions: QuickReaction[] = [];2021
export let packs: ImagePack[] = [];2122
export let onQuickChange: (list: QuickReaction[]) => void = () => {};23
+ export let linkPreviews: Record<string, LinkPreview> = {};2224
2325
const PICKER_WIDTH = 320;2426
const PICKER_HEIGHT = 380;@@ -266,11 +268,7 @@
266268
{#if previews.length > 0}267269
<div class="link-previews">268270
{#each previews as preview (preview.href)}269
- <a class="link-card" href={preview.href} target="_blank" rel="noopener noreferrer">270
- <span class="link-host">{preview.hostname}</span>271
- {#if preview.path}<span class="link-path">{preview.path}</span>{/if}272
- <span class="link-url">{preview.display}</span>273
- </a>271
+ <LinkCard link={preview} preview={linkPreviews[preview.href] ?? null} />274272
{/each}275273
</div>276274
{/if}@@ -327,16 +325,6 @@
327325
.sendstate { font-size: 11px; color: var(--muted); }328326
.sendstate.failed { color: var(--danger); }329327
.link-previews { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; max-width: 420px; }330
- .link-card {331
- display: flex; flex-direction: column; gap: 1px;332
- border: 1px solid var(--border); border-radius: 8px;333
- background: var(--bg-soft); padding: 8px 10px;334
- text-decoration: none; color: var(--text);335
- }336
- .link-card:hover { border-color: var(--accent); }337
- .link-host { font-size: 12.5px; font-weight: 650; }338
- .link-path { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }339
- .link-url { font-size: 11.5px; color: var(--accent); overflow-wrap: anywhere; }340328
.avatar {341329
flex: none;342330
width: 34px; height: 34px;@@ -667,3 +667,16 @@ export interface QuickReaction {
667667
key: string;668668
shortcode: string | null;669669
}670
+671
+export interface LinkPreviewImage {672
+ media_id: string;673
+ width: number | null;674
+ height: number | null;675
+}676
+export interface LinkPreview {677
+ url: string;678
+ title: string | null;679
+ description: string | null;680
+ site_name: string | null;681
+ image: LinkPreviewImage | null;682
+}