atagen

malice

public
Star0
Watch0

Commit b8ab00377c20

tarball

atagen <boss@atagen.co>2026-10-22 13:19 UTC

6 files changed+229-16
web: add a rich link card fed by a link preview map

The card renders title, description and image when a preview exists and falls back to the url-only card otherwise, so the fetch can land later without touching the layout.
Added file: MACLTOK_URL_PREVIEW.md+49 additions
@@ -0,0 +1,49 @@
1+# macltok request, URL previews via the homeserver
2+
3+## Problem
4+
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+## Request
10+
11+```rust
12+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-out
44+
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 context
48+
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`.
Added file: web/src/lib/LinkCard.stories.ts+76 additions
@@ -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': ICON
17+};
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+ ...overrides
30+ };
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+};
Added file: web/src/lib/LinkCard.svelte+69 additions
@@ -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>
Modified file: web/src/lib/MessageRow.stories.ts+18 additions
@@ -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: null
95+ }
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'), {
Modified file: web/src/lib/MessageRow.svelte+4 additions−16 deletions
@@ -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;
Modified file: web/src/lib/types.ts+13 additions
@@ -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+}