atagen

malice

public
Star0
Watch0

Commit 85334efe0e3a

tarball

atagen <boss@atagen.co>2026-10-27 05:56 UTC

3 files changed+23-6
web: let link previews land without animating the timeline's height

Previews resolve late and one at a time, and each grew its row through nested expand transitions, an outer one for the list and an inner one per card, so stacked previews kept shoving the timeline for a second after everything else had settled. Cards now fade in at full size, previews resolving within 150ms of each other land as one update, and banner images reserve their known size before they decode.
Modified file: web/src/lib/LinkCard.svelte+1 additions−1 deletions
@@ -40,7 +40,7 @@
4040 </span>
4141 {/if}
4242 {#if imageSrc}
43- <img class="link-image" src={imageSrc} alt="" loading="lazy" on:error={() => (imageFailed = true)} />
43+ <img class="link-image" src={imageSrc} alt="" width={image?.width ?? undefined} height={image?.height ?? undefined} loading="lazy" on:error={() => (imageFailed = true)} />
4444 {/if}
4545 </a>
4646
Modified file: web/src/lib/MessageRow.svelte+2 additions−2 deletions
@@ -560,9 +560,9 @@
560560 <MediaAttachmentView attachment={m.media} />
561561 {/if}
562562 {#if cards.length > 0}
563- <div class="link-previews" transition:expand>
563+ <div class="link-previews" in:dissolve>
564564 {#each cards as card (card.link.href)}
565- <div transition:expand><LinkCard link={card.link} preview={card.preview} /></div>
565+ <div in:dissolve><LinkCard link={card.link} preview={card.preview} /></div>
566566 {/each}
567567 </div>
568568 {/if}
Modified file: web/src/lib/link-previews.ts+20 additions−3 deletions
@@ -5,6 +5,7 @@ import type { LinkPreview } from './types';
55
66 const EVENT_TYPE = 'dev.malice.url_previews';
77 const BACKOFF_MS = 30_000;
8+const BATCH_MS = 150;
89
910 export interface UrlPreviewSettings {
1011 encrypted_rooms: boolean;
@@ -16,18 +17,31 @@ export const linkPreviews = writable<Record<string, LinkPreview | null>>({});
1617 export const urlPreviewSettings = writable<UrlPreviewSettings>(DEFAULT_SETTINGS);
1718
1819 const inFlight = new Set<string>();
20+const landed: Record<string, LinkPreview | null> = {};
21+let flushTimer: ReturnType<typeof setTimeout> | null = null;
22+
23+/** Previews resolve one by one after a room opens, and each one landing on its own shifted the timeline again. */
24+function land(url: string, preview: LinkPreview | null): void {
25+ landed[url] = preview;
26+ flushTimer ??= setTimeout(() => {
27+ flushTimer = null;
28+ const batch = { ...landed };
29+ for (const key of Object.keys(landed)) delete landed[key];
30+ linkPreviews.update((all) => ({ ...all, ...batch }));
31+ }, BATCH_MS);
32+}
1933 let backoffUntil = 0;
2034 let refusalLogged = false;
2135
2236 export function requestLinkPreview(roomId: string, url: string): void {
23- if (inFlight.has(url) || url in get(linkPreviews) || Date.now() < backoffUntil) return;
37+ if (inFlight.has(url) || url in landed || url in get(linkPreviews) || Date.now() < backoffUntil) return;
2438 inFlight.add(url);
2539 getUrlPreview(roomId, url)
26- .then((raw) => linkPreviews.update((all) => ({ ...all, [url]: parseLinkPreview(raw) })))
40+ .then((raw) => land(url, parseLinkPreview(raw)))
2741 .catch((e) => {
2842 if (e instanceof ApiError && e.status === 429) backoffUntil = Date.now() + BACKOFF_MS;
2943 else if (e instanceof ApiError && e.status === 403) {
30- linkPreviews.update((all) => ({ ...all, [url]: null }));
44+ land(url, null);
3145 if (!refusalLogged) console.warn('The homeserver refused a link preview', e.message);
3246 refusalLogged = true;
3347 }
@@ -57,5 +71,8 @@ export function resetLinkPreviews(): void {
5771 linkPreviews.set({});
5872 urlPreviewSettings.set(DEFAULT_SETTINGS);
5973 inFlight.clear();
74+ if (flushTimer) clearTimeout(flushTimer);
75+ flushTimer = null;
76+ for (const key of Object.keys(landed)) delete landed[key];
6077 backoffUntil = 0;
6178 }