atagen

malice

public
Star0
Watch0

Commit d07bca5da4ae

tarball

atagen <boss@atagen.co>2026-10-26 03:48 UTC

15 files changed+643-138
web: window and animate the timeline

A trace showed a 10.9s room switch building 1.5M DOM nodes, because every loaded row rendered with its hover toolbar and four window listeners. The timeline now renders a bounded sliding window, rows mount toolbars and listeners on demand, and live arrivals and removals animate while window growth stays instant. Rows are focusable groups again, which the earlier visibility-hidden toolbar fix had broken for assistive tech, and the account badge, reply quotes, image placeholders and mobile search bar no longer overlap or shift.
Modified file: web/src/App.svelte+137 additions−25 deletions
@@ -87,6 +87,7 @@
8787 import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';
8888 import type { PendingMessage } from './lib/pending';
8989 import { TimelineViewport } from './lib/timeline-viewport';
90+ import { motionActive, motionDuration } from './lib/motion';
9091 import { mentionsFromHtml, resolveMentions } from './lib/mentions';
9192 import type { Mention } from './lib/mentions';
9293 import { loadQuickReactions, quickReactions, saveQuickReactions } from './lib/quick-reactions';
@@ -144,7 +145,9 @@
144145 let composer = '';
145146 let drafts: Record<string, string> = {};
146147 let stickToBottom = true;
148+ let farFromLatest = false;
147149 let timelineEl: HTMLElement | null = null;
150+ let timelineRows: RoomTimelineRows | null = null;
148151 let timelineViewport: TimelineViewport | null = null;
149152 let composerRef: Composer | null = null;
150153 let avatarInput: HTMLInputElement | null = null;
@@ -173,9 +176,6 @@
173176 let spaceChildrenError: string | null = null;
174177 let avatarByUser: Record<string, string | null> = {};
175178 let restoringRoute = false;
176- function motionDuration(ms: number): number {
177- return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : ms;
178- }
179179
180180 // account data is read from the local cache, which is empty on a fresh device until the first sync lands
181181 $: {
@@ -660,9 +660,50 @@
660660 openReady = nextReady;
661661 }
662662
663- function scrollToBottom(): void {
664- if (timelineViewport?.element === timelineEl) timelineViewport.scrollToBottom();
665- else if (timelineEl) timelineEl.scrollTop = timelineEl.scrollHeight;
663+ function scrollToBottom(smooth = false): void {
664+ const reset = timelineRows?.resetToTail() ?? false;
665+ const scroll = () => {
666+ if (timelineViewport?.element === timelineEl) timelineViewport.scrollToBottom(smooth && !reset);
667+ else if (timelineEl) timelineEl.scrollTop = timelineEl.scrollHeight;
668+ };
669+ if (reset) void tick().then(scroll);
670+ else scroll();
671+ }
672+
673+ function jumpToLatest(): void {
674+ stickToBottom = true;
675+ scrollToBottom(true);
676+ queueReadReceipt();
677+ }
678+
679+ function lastEventId(list: TimelineItem[]): string | null {
680+ for (let i = list.length - 1; i >= 0; i -= 1) {
681+ const item = list[i];
682+ if (item.kind === 'event') return item.event.item_id;
683+ }
684+ return null;
685+ }
686+
687+ function countArrivals(list: TimelineItem[], seen: string | null): number {
688+ if (seen === null) return 0;
689+ const at = list.findIndex((item) => item.kind === 'event' && item.event.item_id === seen);
690+ if (at < 0) return 0;
691+ let count = 0;
692+ for (let i = at + 1; i < list.length; i += 1) {
693+ const item = list[i];
694+ if (item.kind === 'event' && !item.event.own) count += 1;
695+ }
696+ return count;
697+ }
698+
699+ let seenLatest: string | null = null;
700+ $: if (stickToBottom) seenLatest = lastEventId(items);
701+ $: if (stickToBottom) farFromLatest = false;
702+ $: arrivals = stickToBottom ? 0 : countArrivals(items, seenLatest);
703+
704+ function anchorGrowth(): void {
705+ timelineViewport?.capture();
706+ void tick().then(() => timelineViewport?.restore());
666707 }
667708
668709 function noteUserScroll(): void {
@@ -673,15 +714,20 @@
673714 if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(e.key)) noteUserScroll();
674715 }
675716
717+ const WINDOW_EDGE = 600;
718+
676719 function onTimelineScroll(): void {
677720 if (!timelineEl) return;
678721 if (timelineViewport?.consumeScroll()) return;
679722 const gap = timelineEl.scrollHeight - timelineEl.scrollTop - timelineEl.clientHeight;
680723 const wasAtBottom = stickToBottom;
681- stickToBottom = gap < 2;
724+ stickToBottom = gap < 2 && (timelineRows?.atTail() ?? true);
725+ farFromLatest = gap > 240 || !(timelineRows?.atTail() ?? true);
682726 if (stickToBottom && !wasAtBottom) queueReadReceipt();
683727 timelineViewport?.capture();
684- if (timelineEl.scrollTop < 80 && !hasStart && !paginateBusy) void paginate();
728+ const grew = (timelineEl.scrollTop < WINDOW_EDGE && timelineRows?.grow('up')) || (gap < WINDOW_EDGE && timelineRows?.grow('down'));
729+ if (grew) anchorGrowth();
730+ else if (timelineEl.scrollTop < 80 && !hasStart && !paginateBusy) void paginate();
685731 }
686732
687733 function pinTimeline(node: HTMLElement) {
@@ -689,7 +735,7 @@
689735 timelineViewport = viewport;
690736 const observer = new ResizeObserver(() => {
691737 if (timelineViewport !== viewport || viewport.roomId !== $selectedRoomId) return;
692- if (stickToBottom) viewport.scrollToBottom(true);
738+ if (stickToBottom) viewport.scrollToBottom(!motionActive());
693739 else viewport.restore();
694740 });
695741 observer.observe(node);
@@ -707,7 +753,7 @@
707753 if (roomId !== $selectedRoomId) return;
708754 await tick();
709755 if (roomId !== $selectedRoomId || timelineViewport?.roomId !== roomId) return;
710- if (stickToBottom) timelineViewport.scrollToBottom(true);
756+ if (stickToBottom) timelineViewport.scrollToBottom(!motionActive());
711757 else timelineViewport.restore();
712758 queueReadReceipt();
713759 }
@@ -1075,15 +1121,11 @@
10751121 pinPreview = pinned;
10761122 pinJumpBusy = true;
10771123 try {
1078- for (let attempt = 0; attempt < 24; attempt += 1) {
1079- if (($timelines[roomId] ?? []).some((item) => item.kind === 'event' && item.event.event_id === eventId)) {
1080- pinPreview = null;
1081- await tick();
1082- jumpTo(eventId);
1083- return;
1084- }
1085- await paginateRoom(roomId);
1086- await new Promise((resolve) => window.setTimeout(resolve, 180));
1124+ if (await loadEvent(roomId, eventId)) {
1125+ pinPreview = null;
1126+ await tick();
1127+ await jumpTo(eventId);
1128+ return;
10871129 }
10881130 pushBanner('Could not load that pinned message');
10891131 } catch (e) {
@@ -1093,20 +1135,62 @@
10931135 }
10941136 }
10951137
1096- function jumpTo(eventId: string): void {
1097- const el = timelineEl?.querySelector<HTMLElement>(`[data-event="${CSS.escape(eventId)}"]`);
1138+ function loaded(roomId: string, eventId: string): boolean {
1139+ return ($timelines[roomId] ?? []).some((item) => item.kind === 'event' && item.event.event_id === eventId);
1140+ }
1141+
1142+ async function loadEvent(roomId: string, eventId: string): Promise<boolean> {
1143+ let stalled = 0;
1144+ for (let attempt = 0; attempt < 200 && stalled < 3; attempt += 1) {
1145+ const list = $timelines[roomId] ?? [];
1146+ if (loaded(roomId, eventId)) return true;
1147+ if ($selectedRoomId !== roomId || list.some((item) => item.kind === 'start')) return false;
1148+ await paginateRoom(roomId);
1149+ await new Promise((resolve) => window.setTimeout(resolve, 180));
1150+ stalled = ($timelines[roomId] ?? []).length > list.length ? 0 : stalled + 1;
1151+ }
1152+ return loaded(roomId, eventId);
1153+ }
1154+
1155+ let replyJumpBusy = false;
1156+
1157+ async function jumpTo(eventId: string): Promise<void> {
1158+ let el = timelineEl?.querySelector<HTMLElement>(`[data-event="${CSS.escape(eventId)}"]`);
1159+ let recentred = false;
1160+ if (!el && timelineRows?.has(eventId)) {
1161+ stickToBottom = false;
1162+ recentred = timelineRows.reveal(eventId);
1163+ await tick();
1164+ el = timelineEl?.querySelector<HTMLElement>(`[data-event="${CSS.escape(eventId)}"]`);
1165+ }
10981166 const root = !el && $selectedRoomId ? threadHolding($selectedRoomId, eventId) : null;
10991167 if (root) {
11001168 openThreadHere(root, eventId);
11011169 return;
11021170 }
1171+ const roomId = $selectedRoomId;
1172+ if (!el && roomId && !loaded(roomId, eventId) && !replyJumpBusy) {
1173+ replyJumpBusy = true;
1174+ try {
1175+ if (await loadEvent(roomId, eventId)) {
1176+ await tick();
1177+ return jumpTo(eventId);
1178+ }
1179+ } catch (e) {
1180+ pushBanner(e instanceof Error ? e.message : 'Could not load the original message');
1181+ return;
1182+ } finally {
1183+ replyJumpBusy = false;
1184+ }
1185+ }
11031186 if (!el) {
1104- pushBanner('The original message is not loaded. Scroll up to load older messages.');
1187+ pushBanner('Could not load the original message');
11051188 return;
11061189 }
11071190 stickToBottom = false;
11081191 timelineViewport?.releaseScroll();
1109- el.scrollIntoView({ block: 'center', behavior: 'smooth' });
1192+ el.scrollIntoView({ block: 'center', behavior: recentred ? 'instant' : 'smooth' });
1193+ if (recentred) timelineViewport?.capture();
11101194 el.classList.remove('flash');
11111195 void el.offsetWidth;
11121196 el.classList.add('flash');
@@ -1240,9 +1324,20 @@
12401324 out[s.user_id] = { user_id: s.user_id, name: senderLabel(s), avatar: s.avatar };
12411325 }
12421326 for (const m of roster) out[m.user_id] = { user_id: m.user_id, name: m.name, avatar: m.avatar };
1327+ const prev = lastPeople;
1328+ const keys = Object.keys(out);
1329+ if (
1330+ Object.keys(prev).length === keys.length &&
1331+ keys.every((k) => prev[k] && prev[k].name === out[k].name && prev[k].avatar === out[k].avatar)
1332+ ) {
1333+ return prev;
1334+ }
1335+ lastPeople = out;
12431336 return out;
12441337 }
12451338
1339+ let lastPeople: Record<string, Person> = {};
1340+
12461341 function senderAvatarFor(userId: string): string | null {
12471342 return avatarByUser[userId] ?? null;
12481343 }
@@ -1271,7 +1366,7 @@
12711366 return;
12721367 }
12731368 await tick();
1274- if (timelineEl?.querySelector(`[data-event="${CSS.escape(eventId)}"]`)) jumpTo(eventId);
1369+ await jumpTo(eventId);
12751370 }
12761371
12771372 function threadHolding(roomId: string, eventId: string): string | null {
@@ -2412,6 +2507,8 @@
24122507 >
24132508 <div class="timeline-inner" use:pinTimeline>
24142509 <RoomTimelineRows
2510+ bind:this={timelineRows}
2511+ following={stickToBottom}
24152512 {items}
24162513 pending={pendingItems}
24172514 joined={selectedRoom.membership === 'Joined'}
@@ -2436,15 +2533,30 @@
24362533 onMore={(ev, x, y) => void openMessageMenu(ev, x, y)}
24372534 onOpenThread={openThreadHere}
24382535 onReply={startReply}
2439- onJump={jumpTo}
2536+ onJump={(eventId) => void jumpTo(eventId)}
24402537 onEdit={startEdit}
24412538 onPaginate={() => void paginate()}
2539+ onGrew={anchorGrowth}
24422540 onFillGap={(gapId) => void fillGap(gapId)}
24432541 onRetryRecent={() => void retryRecentHistory(selectedRoom.room_id)}
24442542 onRetryPending={retryPending}
24452543 onDiscardPending={discardPending}
24462544 />
24472545 </div>
2546+ <div class="jump-anchor">
2547+ {#if !stickToBottom && (farFromLatest || arrivals > 0)}
2548+ <button
2549+ type="button"
2550+ class="jump-latest"
2551+ aria-label="Jump to latest messages"
2552+ transition:fly={{ y: 8, duration: motionDuration(150) }}
2553+ on:click={jumpToLatest}
2554+ >
2555+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M6 13l6 6 6-6" /></svg>
2556+ {#if arrivals > 0}<span class="jump-count">{arrivals > 99 ? '99+' : arrivals}</span>{/if}
2557+ </button>
2558+ {/if}
2559+ </div>
24482560 </div>
24492561
24502562 {#if selectedRoom.membership === 'Joined'}
Modified file: web/src/app.css+42 additions−16 deletions
@@ -227,8 +227,7 @@ input, textarea { font: inherit; }
227227 .app > .chat-head { grid-column: 1 / -1; grid-row: 1; }
228228 .app > .rail { grid-column: 1; grid-row: 2; }
229229 .app > .sidebar { grid-column: 2; grid-row: 2; }
230-.app > .account { grid-column: 1; grid-row: 3; }
231-@media (min-width: 641px) { .app.rail-folded > .account { grid-column: 2; } }
230+.app > .account { grid-column: 1 / 3; grid-row: 3; }
232231 .app > .chat { grid-column: 3; grid-row: 2 / 4; }
233232 .app > .thread { grid-column: 4; grid-row: 2 / 4; }
234233 .app > .members { grid-column: 5; grid-row: 2 / 4; }
@@ -447,11 +446,11 @@ input, textarea { font: inherit; }
447446 border-top: 1px solid var(--border);
448447 border-right: 1px solid var(--border);
449448 background: var(--rail-bg);
450- transition: padding var(--pane-ease);
449+ transition: padding var(--pane-ease), background-color var(--pane-ease), border-color var(--pane-ease);
451450 }
452451 .me-btn {
453452 flex: 1;
454- min-width: 0;
453+ min-width: 32px;
455454 display: flex;
456455 align-items: center;
457456 gap: 10px;
@@ -466,18 +465,21 @@ input, textarea { font: inherit; }
466465 .me-names { min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
467466 .me-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
468467 .account-actions { flex: none; display: flex; gap: 2px; }
469-.app.rail-folded.nav-folded .account { padding-right: 0; }
470-.app.rail-folded.nav-folded .account .me-names, .app.rail-folded.nav-folded .account-actions { display: none; }
468+.app.nav-folded .account { padding-right: 0; }
469+.app.nav-folded .account-actions { display: none; }
470+.app.nav-folded .account .me-names { display: none; transition: display 0s linear 0.22s; transition-behavior: allow-discrete; }
471471 @media (min-width: 1101px) {
472472 .app.nav-folded > .rail { grid-row: 2 / 4; }
473+ .app.rail-folded .account { padding-left: 14px; }
473474 .app.rail-folded.nav-folded > .account {
474- grid-column: 3;
475- justify-self: start;
475+ overflow: visible;
476476 z-index: 2;
477- padding: 8px 0 8px 12px;
478- border: none;
479- background: none;
477+ padding: 8px 0 8px 14px;
478+ border-color: transparent;
479+ background-color: transparent;
480+ transition: padding var(--pane-ease), background-color var(--pane-ease), border-color var(--pane-ease);
480481 }
482+ .app.rail-folded.nav-folded .account .me-names { transition: none; }
481483 .app.rail-folded.nav-folded .composer { padding-left: 48px; }
482484 }
483485 .me-avatar {
@@ -687,6 +689,26 @@ input, textarea { font: inherit; }
687689 padding: 8px 14px 10px;
688690 min-height: 0;
689691 }
692+.jump-anchor { position: sticky; bottom: 0; height: 0; display: flex; justify-content: flex-end; overflow: visible; }
693+.jump-latest {
694+ position: absolute;
695+ bottom: 6px;
696+ right: 2px;
697+ display: inline-flex;
698+ align-items: center;
699+ gap: 6px;
700+ height: 30px;
701+ padding: 0 8px;
702+ border: 1px solid var(--border);
703+ border-radius: var(--r-sm);
704+ background: var(--panel-2);
705+ color: var(--text);
706+ box-shadow: 0 4px 14px rgb(0 0 0 / 0.3);
707+ cursor: pointer;
708+}
709+.jump-latest:hover { border-color: var(--accent); }
710+.jump-latest:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
711+.jump-count { min-width: 16px; padding: 0 4px; border-radius: var(--r-sm); background: var(--accent); color: var(--on-accent); font-size: 11px; font-weight: 700; line-height: 16px; text-align: center; }
690712 .timeline-top { display: flex; justify-content: center; padding: 4px 0 12px; }
691713 .start-note { text-align: center; color: var(--muted); font-size: 12.5px; }
692714 .timeline-empty { text-align: center; padding: 32px 0; }
@@ -914,6 +936,7 @@ input, textarea { font: inherit; }
914936 .member-group-label span { font-size: 10px; font-weight: 500; letter-spacing: 0; }
915937 .member-group-list { list-style: none; margin: 0; padding: 0; }
916938 .member { width: 100%; display: flex; align-items: center; gap: 9px; padding: 6px 8px; border: none; border-radius: var(--r-md); background: transparent; color: var(--text); font: inherit; text-align: left; cursor: pointer; }
939+.member { content-visibility: auto; contain-intrinsic-size: auto 41px; }
917940 .member:hover { background: var(--panel-2); }
918941 .member-names { min-width: 0; display: flex; flex-direction: column; }
919942 .member-name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1027,14 +1050,16 @@ input, textarea { font: inherit; }
10271050 .app { --side-w: var(--side-open, 260px); }
10281051 .app:not(.nav-folded) .account .me-names,
10291052 .app:not(.nav-folded) .account-actions { display: flex; }
1053+ .app.rail-folded .account { padding-left: 14px; }
10301054 .app.rail-folded.nav-folded > .account {
1031- grid-column: 3;
1032- justify-self: start;
1055+ overflow: visible;
10331056 z-index: 2;
1034- padding: 8px 0 8px 12px;
1035- border: none;
1036- background: none;
1057+ padding: 10px 0 12px 14px;
1058+ border-color: transparent;
1059+ background-color: transparent;
1060+ transition: padding var(--pane-ease), background-color var(--pane-ease), border-color var(--pane-ease);
10371061 }
1062+ .app.rail-folded.nav-folded .account .me-names { transition: none; }
10381063 .app.rail-folded.nav-folded .composer { padding-left: 48px; }
10391064 .app.rail-folded { --rail-w: 0px; }
10401065 .app.rail-folded > .rail { visibility: hidden !important; }
@@ -1076,6 +1101,7 @@ input, textarea { font: inherit; }
10761101 .app > .sidebar { left: 52px; transform: translateX(calc(-100% - 52px)); }
10771102 .scrim { left: 52px; }
10781103 .rail-btn { width: 40px; height: 40px; font-size: 16px; }
1104+ .side-search { padding: 10px 8px 12px; }
10791105 .empty-cta { flex-direction: column; }
10801106 }
10811107
Modified file: web/src/lib/Emoji.svelte+3 additions−1 deletions
@@ -4,6 +4,7 @@
44 export let emoji: string;
55 export let size: number | string = 20;
66 export let label: string | null = null;
7+ export let lazy = false;
78
89 let failed = false;
910 $: emoji, (failed = false);
@@ -21,7 +22,8 @@
2122 aria-label={label ?? undefined}
2223 style={`width:${dim};height:${dim};vertical-align:${align}`}
2324 draggable="false"
24- loading="lazy"
25+ loading={lazy ? 'lazy' : undefined}
26+ decoding="async"
2527 on:error={() => (failed = true)}
2628 />
2729 {/if}
Modified file: web/src/lib/MediaAttachmentView.svelte+23 additions−8 deletions
@@ -10,12 +10,17 @@
1010
1111 const PREVIEWABLE = new Set(['Image', 'Sticker']);
1212 const RETRY_DELAYS = [1000, 3000, 10000];
13+ const MAX_W = 420;
14+ const MAX_H = 320;
1315
1416 // Encrypted media often has no fetchable thumbnail, so request the bounded
1517 // full bytes directly instead of flashing a broken thumbnail first.
1618 $: fullUrl = messageMediaUrl(attachment.id, 'Full');
1719 $: thumbUrl = messageMediaUrl(attachment.id, 'Thumbnail');
1820 $: previewable = PREVIEWABLE.has(attachment.kind);
21+ $: sized = !!attachment.width && !!attachment.height;
22+ $: frame = frameSize(attachment.width, attachment.height);
23+ $: gaveUp = fullFailed && retryTimer === null && retryCount >= RETRY_DELAYS.length;
1924
2025 let fullFailed = false;
2126 let loaded = false;
@@ -65,6 +70,12 @@
6570 lightbox.set({ src: fullUrl, alt: attachment.caption || attachment.filename, filename: attachment.filename, mxc: attachment.mxc });
6671 }
6772
73+ function frameSize(width: number | null, height: number | null): string {
74+ if (!width || !height) return 'width:240px;height:160px';
75+ const scale = Math.min(1, MAX_W / width, MAX_H / height);
76+ return `width:${Math.max(1, Math.round(width * scale))}px;aspect-ratio:${width}/${height}`;
77+ }
78+
6879 function sizeLabel(): string {
6980 if (typeof attachment.size_bytes !== 'number') return '';
7081 const bytes = attachment.size_bytes;
@@ -84,16 +95,16 @@
8495
8596 <svelte:window on:online={() => { if (fullFailed) retryPreview(true); }} />
8697
87-{#if previewable && !fullFailed}
98+{#if previewable && !gaveUp}
8899 <figure class="media-figure">
89- <a href={fullUrl} target="_blank" rel="noopener noreferrer" title={attachment.filename} on:click={openLightbox}>
100+ <a class="media-frame" class:loaded class:natural={!sized && loaded} style={sized || !loaded ? frame : undefined} href={fullUrl} target="_blank" rel="noopener noreferrer" title={attachment.filename} on:click={openLightbox}>
90101 <img
91102 class="media-image"
92103 class:loaded
93104 src={previewUrl}
94105 alt={attachment.caption || attachment.filename}
95106 loading="lazy"
96- on:load={() => (loaded = true)}
107+ on:load={() => ((loaded = true), (fullFailed = false))}
97108 on:error={previewFailed}
98109 />
99110 </a>
@@ -108,11 +119,11 @@
108119 </figcaption>
109120 {/if}
110121 </figure>
111-{:else if previewable && fullFailed}
122+{:else if previewable}
112123 <div class="media-file">
113124 <span class="file-icon" aria-hidden="true"><Emoji emoji="🖼️" size={18} /></span>
114125 <a href={fullUrl} target="_blank" rel="noopener noreferrer"><EmojiText text={attachment.caption || attachment.filename} /></a>
115- <span class="media-size">{retryTimer !== null ? 'Retrying preview…' : 'Preview unavailable'}</span>
126+ <span class="media-size">Preview unavailable</span>
116127 <button type="button" class="ghost-btn" on:click={() => retryPreview(true)}>Retry</button>
117128 </div>
118129 {:else if attachment.kind === 'Video'}
@@ -150,15 +161,19 @@
150161
151162 <style>
152163 .media-figure { margin: 6px 0 2px; max-width: 420px; }
153- .media-image {
164+ .media-frame {
154165 display: block;
155166 max-width: 100%;
156- max-height: 320px;
157167 border-radius: var(--r-md);
158168 border: 1px solid var(--border);
159169 background: var(--bg-soft);
170+ overflow: hidden;
160171 }
161- .media-image.loaded { border-color: transparent; background: none; }
172+ .media-frame.loaded { border-color: transparent; background: none; }
173+ .media-frame.natural { display: inline-block; }
174+ .natural .media-image { width: auto; height: auto; max-width: 100%; max-height: 320px; }
175+ .media-image { display: block; width: 100%; height: 100%; object-fit: contain; opacity: 0; }
176+ .media-image.loaded { opacity: 1; }
162177 .media-video { display: block; max-width: 100%; max-height: 320px; border-radius: var(--r-md); background: #000; }
163178 .media-audio { display: block; width: 100%; max-width: 420px; margin-top: 6px; }
164179 .media-block { margin: 6px 0 2px; max-width: 420px; }
Modified file: web/src/lib/MessageRow.svelte+147 additions−54 deletions
@@ -1,3 +1,5 @@
1+<svelte:options immutable />
2+
13 <script lang="ts">
24 import MediaAttachmentView from './MediaAttachmentView.svelte';
35 import MxcAvatar from './MxcAvatar.svelte';
@@ -6,6 +8,9 @@
68 import MessageText from './MessageText.svelte';
79 import ReactionPicker from './ReactionPicker.svelte';
810 import LinkCard from './LinkCard.svelte';
11+ import { afterUpdate, onDestroy } from 'svelte';
12+ import { dissolve, expand, rowIn, rowOut, settleHeight } from './motion';
13+ import { selectedRow } from './selected-row';
914 import { mediaUrl, messageMediaUrl } from './api';
1015 import { keywordMatcher } from './keywords';
1116 import type { ImagePack, LinkPreview, Person, QuickReaction, ReplyReference, SendState, TimelineContent, TimelineEvent } from './types';
@@ -73,9 +78,12 @@
7378 'im.ponies.room_emotes': 'updated a sticker pack'
7479 };
7580
81+ const owner = Symbol();
7682 let row: HTMLDivElement;
7783 let pickerPop: HTMLDivElement | undefined;
78- let selected = false;
84+ let hovered = false;
85+ let focused = false;
86+ let reactorKey: string | null = null;
7987 let pickerOpen = false;
8088 let pickerStyle = '';
8189
@@ -101,11 +109,17 @@
101109 $: readers = event.read_by.map((id) => people[id] ?? { user_id: id, name: id, avatar: null });
102110 $: interactive = event.content.kind === 'Message' || event.content.kind === 'Sticker' || event.content.kind === 'Poll';
103111 $: message = event.content.kind === 'Message' ? event.content.message : null;
104- $: stripped = stripMarkdownPills(message ? (message.media ? message.media.caption : message.body) ?? '' : '');
105- $: linked = message ? [...mentionsFromHtml(message.formatted_body), ...stripped.mentions] : [];
112+ $: rawText = message ? (message.media ? message.media.caption : message.body) ?? '' : '';
113+ $: formattedBody = message?.formatted_body ?? null;
114+ $: hasMessage = message !== null;
115+ $: stripped = stripMarkdownPills(rawText);
116+ $: linked = hasMessage ? [...mentionsFromHtml(formattedBody), ...stripped.mentions] : [];
117+ $: nameOf = (id: string) => people[id]?.name ?? null;
106118 $: mentions = resolveMentions(stripped.text, linked);
107119 $: mentionsMe = self !== null && [...linked, ...mentions].some((m) => m.user_id === self);
108- $: keywordHit = !event.own && (event.highlight === true || (message !== null && ($keywordMatcher?.test(stripped.text) ?? false)));
120+ $: own = event.own;
121+ $: highlighted = event.highlight === true;
122+ $: keywordHit = !own && (highlighted || (hasMessage && ($keywordMatcher?.test(stripped.text) ?? false)));
109123 $: previews = findUrls(stripped.text);
110124 $: bundled = message ? message.link_previews : [];
111125 $: cards = previews
@@ -119,19 +133,90 @@
119133
120134 $: sent = interactive && showSent && event.own && event.send_state.kind === 'Sent' && event.event_id !== null;
121135
136+ let lastShape = '';
137+ let heightBefore: number | null = null;
138+ $: noteShape(`${event.content.kind}|${rawText}|${formattedBody}|${message?.edited}|${event.reply_to?.details.kind}|${showSender}`);
139+
140+ function noteShape(shape: string): void {
141+ if (row && lastShape && shape !== lastShape) heightBefore = row.offsetHeight;
142+ lastShape = shape;
143+ }
144+
145+ afterUpdate(() => {
146+ if (heightBefore === null) return;
147+ const from = heightBefore;
148+ heightBefore = null;
149+ requestAnimationFrame(() => row && settleHeight(row, from));
150+ });
151+
122152 function sendLabel(state: SendState): string {
123153 if (state.kind === 'Failed') return state.will_retry ? 'failed — will retry' : 'failed — not delivered';
124154 if (state.kind === 'Sending') return 'sending…';
125155 return '';
126156 }
127157
158+ $: selected = $selectedRow === owner;
159+ $: showActions = !readOnly && interactive && !!event.event_id && (selected || pickerOpen || ($selectedRow === null && (hovered || focused)));
160+
161+ $: syncSelectionListeners(selected);
162+ $: syncPickerListeners(pickerOpen);
163+
164+ function onEscape(e: KeyboardEvent) {
165+ if (e.key === 'Escape') select(false);
166+ }
167+
168+ let selectionAttached = false;
169+ let pickerAttached = false;
170+
171+ function syncSelectionListeners(on: boolean) {
172+ if (on === selectionAttached) return;
173+ selectionAttached = on;
174+ if (on) {
175+ window.addEventListener('pointerdown', deselectOutside);
176+ window.addEventListener('keydown', onEscape);
177+ } else {
178+ window.removeEventListener('pointerdown', deselectOutside);
179+ window.removeEventListener('keydown', onEscape);
180+ }
181+ }
182+
183+ function syncPickerListeners(on: boolean) {
184+ if (on === pickerAttached) return;
185+ pickerAttached = on;
186+ const op = on ? 'addEventListener' : 'removeEventListener';
187+ window[op]('scroll', closeOnScroll, true);
188+ window[op]('resize', closePicker);
189+ }
190+
191+ onDestroy(() => {
192+ syncSelectionListeners(false);
193+ syncPickerListeners(false);
194+ selectedRow.update((o) => (o === owner ? null : o));
195+ });
196+
197+ function select(on: boolean) {
198+ selectedRow.update((o) => (on ? owner : o === owner ? null : o));
199+ }
200+
128201 function toggleSelected(e: MouseEvent) {
129202 if (readOnly || !interactive || (e.target as Element).closest('a, button, input, video, audio, .picker-pop')) return;
130- selected = !selected;
203+ select(!selected);
204+ }
205+
206+ function onRowKeydown(e: KeyboardEvent) {
207+ if (e.target !== row || readOnly || !interactive) return;
208+ if (e.key === 'Enter' || e.key === ' ') {
209+ e.preventDefault();
210+ select(!selected);
211+ }
212+ }
213+
214+ function onFocusOut(e: FocusEvent) {
215+ if (!row.contains(e.relatedTarget as Node | null)) focused = false;
131216 }
132217
133218 function deselectOutside(e: PointerEvent) {
134- if (selected && !pickerOpen && !row.contains(e.target as Node)) selected = false;
219+ if (selected && !pickerOpen && !row.contains(e.target as Node)) select(false);
135220 }
136221
137222 function openPicker(e: MouseEvent) {
@@ -146,7 +231,7 @@
146231 const maxLeft = window.innerWidth - PICKER_WIDTH - PICKER_MARGIN;
147232 const left = Math.max(PICKER_MARGIN, Math.min(rect.right - PICKER_WIDTH, maxLeft));
148233 pickerStyle = `top:${top}px;left:${left}px;height:${height}px`;
149- selected = true;
234+ select(true);
150235 pickerOpen = true;
151236 }
152237
@@ -258,16 +343,11 @@
258343 }
259344 </script>
260345
261-<svelte:window
262- on:pointerdown={deselectOutside}
263- on:keydown={(e) => e.key === 'Escape' && (selected = false)}
264- on:scroll|capture={closeOnScroll}
265- on:resize={closePicker}
266-/>
267-
268-<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
346+<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
269347 <div
270348 class="msg"
349+ role="group"
350+ tabindex={interactive && !readOnly ? 0 : undefined}
271351 class:own={event.own}
272352 class:editing
273353 class:mentioned={mentionsMe || keywordHit}
@@ -276,10 +356,17 @@
276356 class:selected
277357 data-item={itemId}
278358 data-event={event.event_id}
359+ in:rowIn|global
360+ out:rowOut|global
279361 bind:this={row}
280362 on:click={toggleSelected}
363+ on:keydown={onRowKeydown}
364+ on:pointerenter={() => (hovered = true)}
365+ on:pointerleave={() => (hovered = false)}
366+ on:focusin={() => (focused = true)}
367+ on:focusout={onFocusOut}
281368 >
282- {#if !readOnly && interactive && event.event_id}
369+ {#if showActions && event.event_id}
283370 <div class="actions" role="toolbar" aria-label={`Actions for message from ${displayName}`}>
284371 {#each quickReactions as r (r.key)}
285372 {@const label = r.shortcode ? `:${r.shortcode}:` : r.key}
@@ -364,6 +451,27 @@
364451 </div>
365452 {/if}
366453 {/if}
454+ {#if event.reply_to}
455+ {@const target = event.reply_to.event_id}
456+ <div class="reply-line" class:linked={showSender}>
457+ <button
458+ type="button"
459+ class="reply-quote"
460+ disabled={!onJump}
461+ title="Jump to the original message"
462+ on:click|stopPropagation={() => onJump?.(target)}
463+ >
464+ {#if event.reply_to.details.kind === 'Ready'}
465+ <span class="reply-sender"><EmojiText text={event.reply_to.details.sender.display_name ?? people[event.reply_to.details.sender.user_id]?.name ?? senderLabel(event.reply_to.details.sender)} /></span>
466+ <span class="reply-text"><EmojiText text={replyPreview(event.reply_to.details.content)} /></span>
467+ {:else if event.reply_to.details.kind === 'Pending'}
468+ Loading reply…
469+ {:else}
470+ Reply unavailable
471+ {/if}
472+ </button>
473+ </div>
474+ {/if}
367475 {#if showSender}
368476 <button
369477 type="button"
@@ -375,7 +483,7 @@
375483 on:contextmenu|preventDefault|stopPropagation={(ev) => openUser(ev, true)}
376484 >
377485 {#if senderAvatar}
378- <MxcAvatar mxc={senderAvatar} label={name} size={34} />
486+ <MxcAvatar mxc={senderAvatar} label={name} size={34} eager />
379487 {:else}
380488 <span class="avatar" style={`background:hsl(${senderHue(event.sender.user_id)} 35% 32%)`}>
381489 <span><EmojiText text={initialsOf(name)} /></span>
@@ -409,42 +517,23 @@
409517 {formatDay(event.timestamp_ms)} {formatClock(event.timestamp_ms)}
410518 </time>
411519 {#if event.content.kind === 'Message' && event.content.message.edited}
412- <span class="edited" title="Edited">(edited)</span>
520+ <span class="edited" title="Edited" transition:dissolve>(edited)</span>
413521 {/if}
414522 </div>
415523 {/if}
416- {#if event.reply_to}
417- {@const target = event.reply_to.event_id}
418- <button
419- type="button"
420- class="reply-quote"
421- disabled={!onJump}
422- title="Jump to the original message"
423- on:click|stopPropagation={() => onJump?.(target)}
424- >
425- {#if event.reply_to.details.kind === 'Ready'}
426- <span class="reply-sender"><EmojiText text={event.reply_to.details.sender.display_name ?? people[event.reply_to.details.sender.user_id]?.name ?? senderLabel(event.reply_to.details.sender)} /></span>
427- <span class="reply-text"><EmojiText text={replyPreview(event.reply_to.details.content)} /></span>
428- {:else if event.reply_to.details.kind === 'Pending'}
429- Loading reply…
430- {:else}
431- Reply unavailable
432- {/if}
433- </button>
434- {/if}
435524
436525 {#if event.content.kind === 'Message'}
437526 {@const m = event.content.message}
438527 {#if stripped.text}
439- <div class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>{#if m.kind === 'Emote'}* <EmojiText text={displayName} /> {/if}<MessageText text={stripped.text} html={m.formatted_body} jumbo={m.kind === 'Text'} {mentions} {self} nameOf={(id) => people[id]?.name ?? null} />{#if m.edited && !showSender}<span class="edited" title="Edited">(edited)</span>{/if}</div>
528+ <div class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>{#if m.kind === 'Emote'}* <EmojiText text={displayName} /> {/if}<MessageText text={stripped.text} html={m.formatted_body} jumbo={m.kind === 'Text'} {mentions} {self} {nameOf} />{#if m.edited && !showSender}<span class="edited" title="Edited" transition:dissolve>(edited)</span>{/if}</div>
440529 {/if}
441530 {#if m.media}
442531 <MediaAttachmentView attachment={m.media} />
443532 {/if}
444533 {#if cards.length > 0}
445- <div class="link-previews">
534+ <div class="link-previews" transition:expand>
446535 {#each cards as card (card.link.href)}
447- <LinkCard link={card.link} preview={card.preview} />
536+ <div transition:expand><LinkCard link={card.link} preview={card.preview} /></div>
448537 {/each}
449538 </div>
450539 {/if}
@@ -459,14 +548,20 @@
459548 {/if}
460549
461550 {#if interactive && event.reactions.length > 0}
462- <div class="reactions" aria-label="Reactions">
551+ <div class="reactions" aria-label="Reactions" transition:expand>
463552 {#each event.reactions as r (r.key)}
464553 <button
465554 type="button"
466555 class="reaction"
556+ transition:expand={{ axis: 'x' }}
467557 class:mine={r.own}
468558 aria-label={`${r.shortcode ? `:${r.shortcode}:` : r.key.startsWith('mxc://') ? 'custom emote' : r.key}, ${r.count} reaction${r.count === 1 ? '' : 's'}`}
469559 disabled={readOnly || !event.event_id || reacting}
560+ aria-describedby={reactorKey === r.key ? `${itemId}-reactors-${r.key}` : undefined}
561+ on:pointerenter={() => (reactorKey = r.key)}
562+ on:pointerleave={() => (reactorKey = null)}
563+ on:focus={() => (reactorKey = r.key)}
564+ on:blur={() => (reactorKey = null)}
470565 on:click={() => event.event_id && onReact(event.event_id, r.key)}
471566 >
472567 {#if r.image}
@@ -477,10 +572,12 @@
477572 <Emoji emoji={r.key} size={16} />
478573 {/if}
479574 <span class="count">{r.count}</span>
575+ {#if reactorKey === r.key}
480576 <span class="reactors" role="tooltip" id={`${itemId}-reactors-${r.key}`}>
481577 <span class="reactors-key"><EmojiText text={r.shortcode ? `:${r.shortcode}:` : r.key.startsWith('mxc://') ? 'custom emote' : r.key} /></span>
482578 <EmojiText text={reactorNames(r.senders)} />
483579 </span>
580+ {/if}
484581 </button>
485582 {/each}
486583 </div>
@@ -488,20 +585,20 @@
488585 {#if threadRoot && threadReplies > 0}
489586 {@const root = threadRoot}
490587 {@const latest = event.thread_summary?.latest_event_timestamp_ms ?? null}
491- <button type="button" class="thread-chip" on:click|stopPropagation={() => onOpenThread?.(root)}>
588+ <button type="button" class="thread-chip" transition:expand on:click|stopPropagation={() => onOpenThread?.(root)}>
492589 <span class="thread-count">{threadReplies} {threadReplies === 1 ? 'reply' : 'replies'}</span>
493590 {#if latest}<time datetime={new Date(latest).toISOString()}>{formatDay(latest)} {formatClock(latest)}</time>{/if}
494591 </button>
495592 {/if}
496- {#if sent}<span class="sent-tick" title="Sent" aria-label="Sent">✓</span>{/if}
593+ {#if sent}<span class="sent-tick" title="Sent" aria-label="Sent" transition:dissolve>✓</span>{/if}
497594 {#if interactive && sendLabel(event.send_state)}
498- <div class="msg-foot">
595+ <div class="msg-foot" transition:expand>
499596 <span class="sendstate" class:failed={event.send_state.kind === 'Failed'}>{sendLabel(event.send_state)}</span>
500597 </div>
501598 {/if}
502599 {#if interactive && readers.length > 0}
503600 {@const seenBy = `Seen by ${readers.map((r) => r.name).join(', ')}`}
504- <div class="readby" title={seenBy} aria-label={seenBy} role="img">
601+ <div class="readby" transition:dissolve title={seenBy} aria-label={seenBy} role="img">
505602 {#each readers.slice(0, MAX_READERS) as r (r.user_id)}
506603 <MxcAvatar mxc={r.avatar} label={r.name} size={16} />
507604 {/each}
@@ -512,7 +609,9 @@
512609 </div>
513610
514611 <style>
515- .msg { position: relative; display: flex; gap: 10px; padding: var(--msg-gap, 3px) 0; }
612+ .msg { position: relative; display: flex; flex-wrap: wrap; gap: 0 10px; padding: var(--msg-gap, 3px) 0; }
613+ .reply-line { position: relative; flex: 0 0 100%; min-width: 0; padding-left: 44px; }
614+ .reply-line.linked::before { content: ''; position: absolute; left: 16px; top: 50%; bottom: -3px; width: 24px; border-left: 2px solid var(--border); border-top: 2px solid var(--border); border-top-left-radius: 8px; pointer-events: none; }
516615 .msg:hover, .msg.selected { background: var(--hover-faint); }
517616 .msg:hover .hover-time, .msg.selected .hover-time { opacity: 1; }
518617 .msg-foot { display: flex; align-items: center; gap: 8px; }
@@ -533,7 +632,7 @@
533632 }
534633 .avatar-spacer { flex: none; width: 34px; padding-top: 2px; text-align: center; }
535634 .hover-time { opacity: 0; font-size: 10.5px; color: var(--muted); }
536- .msg-body { min-width: 0; flex: 1; }
635+ .msg-body { min-width: 0; flex: 1; transition: padding-right var(--pop); }
537636 .msg-head { display: flex; align-items: baseline; gap: 8px; }
538637 .federation-lag { display: inline-flex; align-items: center; gap: 3px; color: var(--danger); font-size: 11px; font-weight: 650; }
539638 .federation-lag > span:last-child { display: inline-grid; place-items: center; min-width: 24px; height: 18px; padding: 0 4px; border: 1px solid currentColor; border-radius: 999px; }
@@ -593,7 +692,7 @@
593692 left: 0;
594693 bottom: calc(100% + 6px);
595694 z-index: 15;
596- display: none;
695+ display: block;
597696 width: max-content;
598697 max-width: 260px;
599698 padding: 6px 9px;
@@ -609,7 +708,6 @@
609708 pointer-events: none;
610709 }
611710 .reactors-key { display: block; color: var(--muted); font-size: 11px; }
612- .reaction:hover .reactors, .reaction:focus-visible .reactors { display: block; }
613711 .reaction.mine { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
614712 .reaction:disabled { cursor: default; opacity: 0.85; }
615713 .msg.has-readers .msg-body { padding-right: 72px; }
@@ -625,12 +723,7 @@
625723 border: 1px solid var(--border);
626724 border-radius: var(--r-md);
627725 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
628- opacity: 0;
629- visibility: hidden;
630- pointer-events: none;
631726 }
632- .msg:hover .actions, .msg:focus-within .actions, .msg.selected .actions { opacity: 1; visibility: visible; pointer-events: auto; }
633- :global(.timeline-inner:has(.msg.selected)) .msg:not(.selected) .actions { opacity: 0; visibility: hidden; pointer-events: none; }
634727 .actions button {
635728 background: transparent;
636729 border: 1px solid transparent;
Modified file: web/src/lib/MessageText.svelte+2 additions−2 deletions
@@ -56,8 +56,8 @@
5656 .link:hover, .rich :global(.link:hover) { text-decoration: underline; }
5757 .rich { white-space: normal; }
5858 .rich :global(:is(p, ul, ol, blockquote, h1, h2, h3, h4, h5, h6, table, details)) { margin: 4px 0; }
59- .rich :global(:first-child) { margin-top: 0; }
60- .rich :global(:last-child) { margin-bottom: 0; }
59+ .rich > :global(:first-child), .rich :global(:is(blockquote, li, details, td, th) > :first-child) { margin-top: 0; }
60+ .rich > :global(:last-child), .rich :global(:is(blockquote, li, details, td, th) > :last-child) { margin-bottom: 0; }
6161 .rich :global(:is(ul, ol)) { padding-left: 22px; }
6262 .rich :global(blockquote) {
6363 padding: 2px 10px;
Modified file: web/src/lib/MxcAvatar.svelte+10 additions−3 deletions
@@ -9,6 +9,7 @@
99 /** 'circle' for people, 'rounded' for rooms/spaces */
1010 export let shape: 'circle' | 'rounded' = 'circle';
1111 export let seed: string | null = null;
12+ export let eager = false;
1213
1314 const density = typeof window === 'undefined' ? 1 : Math.min(window.devicePixelRatio || 1, 3);
1415 // rounding up to a 32px step keeps the number of cached variants per avatar small
@@ -17,7 +18,10 @@
1718 let failed = false;
1819 $: if (mxc) failed = false;
1920 $: px = `${size}px`;
20- $: fontPx = `${Math.max(10, Math.round(size * 0.36))}px`;
21+ $: compact = size < 24;
22+ $: fontPx = `${compact ? Math.round(size * 0.56) : Math.max(10, Math.round(size * 0.36))}px`;
23+ $: initials = initialsOf(label || '#');
24+ $: shown = compact ? ([...new Intl.Segmenter().segment(initials)][0]?.segment ?? '#') : initials;
2125 $: hue = senderHue(seed ?? label ?? '#');
2226 </script>
2327
@@ -28,7 +32,8 @@
2832 aria-hidden="true"
2933 width={size}
3034 height={size}
31- loading="lazy"
35+ loading={eager ? 'eager' : 'lazy'}
36+ decoding="async"
3237 class="mxc-avatar"
3338 class:circle={shape === 'circle'}
3439 style={`width:${px};height:${px};`}
@@ -36,7 +41,7 @@
3641 />
3742 {:else}
3843 <span class="mxc-fallback" class:circle={shape === 'circle'} aria-hidden="true" style={`width:${px};height:${px};font-size:${fontPx};background:hsl(${hue} 32% 30%);`}>
39- <span><EmojiText text={initialsOf(label || '#')} /></span>
44+ <span><EmojiText text={shown} /></span>
4045 </span>
4146 {/if}
4247
@@ -56,6 +61,8 @@
5661 color: #fff;
5762 font-weight: 700;
5863 letter-spacing: 0.02em;
64+ line-height: 1;
65+ overflow: hidden;
5966 }
6067 .mxc-fallback.circle { border-radius: 50%; }
6168 </style>
Modified file: web/src/lib/ReactionPicker.svelte+1 additions−1 deletions
@@ -171,7 +171,7 @@
171171 disabled={customising && full && !isQuick(e.emoji)}
172172 on:click={() => choose(e.emoji, e.shortcodes[0] ?? null)}
173173 >
174- <Emoji emoji={e.emoji} size={24} />
174+ <Emoji emoji={e.emoji} size={24} lazy />
175175 </button>
176176 {/each}
177177 </div>
Modified file: web/src/lib/RoomTimelineRows.svelte+165 additions−15 deletions
@@ -2,8 +2,9 @@
22 import EmojiText from './EmojiText.svelte';
33 import MessageRow from './MessageRow.svelte';
44 import { formatDay } from './format';
5+ import { expand, motionActive, rowIn, rowOut, setRowMotion } from './motion';
56 import type { PendingMessage } from './pending';
6- import { buildGroups, lastOwnSent, lastSenderIs, pendingToEvent } from './timeline-groups';
7+ import { buildGroups, type Group, lastOwnSent, lastSenderIs, pendingToEvent } from './timeline-groups';
78 import type { ImagePack, LinkPreview, Person, QuickReaction, RecentHistoryState, ReplyReference, TimelineEvent, TimelineItem } from './types';
89
910 export let items: TimelineItem[];
@@ -23,6 +24,7 @@
2324 export let recentRetrying = false;
2425 export let paginating = false;
2526 export let fillingGap: string | null = null;
27+ export let following = true;
2628 export let onReact: (eventId: string, key: string, shortcode?: string) => void;
2729 export let onQuickChange: (list: QuickReaction[]) => void;
2830 export let onUser: (userId: string, x: number, y: number, menu: boolean) => void;
@@ -33,56 +35,204 @@
3335 export let onJump: (eventId: string) => void;
3436 export let onEdit: (eventId: string, body: string) => void;
3537 export let onPaginate: () => void;
38+ export let onGrew: () => void;
3639 export let onFillGap: (gapId: string) => void;
3740 export let onRetryRecent: () => void;
3841 export let onRetryPending: (id: string) => void;
3942 export let onDiscardPending: (id: string) => void;
4043
44+ const INITIAL_GROUPS = 150;
45+ const READ_CAP = 400;
46+ const READ_CONTEXT = 20;
47+ const STEP = 30;
48+ const MAX_GROUPS = 300;
49+ const JUMP_HALF = 60;
50+ const LIVE_MAX = 8;
51+
52+ let ready = false;
53+ let start = 0;
54+ let end = 0;
55+ let tail = true;
56+ let startKey: string | null = null;
57+ let endKey: string | null = null;
58+ let quiet = false;
59+ let mounted = false;
60+ let shownKeys: string[] = [];
61+ let shownPending = new Map<string, PendingMessage['status']>();
62+ let firstKey: string | undefined;
63+
64+ function eventKey(list: Group[], from: number, step: 1 | -1): string | null {
65+ for (let i = from; i >= 0 && i < list.length; i += step) if (list[i].item.kind === 'event') return list[i].key;
66+ return null;
67+ }
68+
69+ function settle(list: Group[]): void {
70+ startKey = eventKey(list, start, 1);
71+ endKey = eventKey(list, end - 1, -1);
72+ tail = end >= list.length;
73+ }
74+
75+ function reconcile(list: Group[], follow: boolean): void {
76+ if (!ready) {
77+ if (list.length === 0) return;
78+ start = Math.max(0, list.length - INITIAL_GROUPS);
79+ const marker = list.findIndex((g) => g.item.kind === 'readMarker');
80+ if (marker >= 0 && marker < start) start = Math.max(0, marker - READ_CONTEXT, list.length - READ_CAP);
81+ end = list.length;
82+ ready = true;
83+ settle(list);
84+ return;
85+ }
86+ const oldest = start === 0;
87+ let s = startKey === null ? start : list.findIndex((g) => g.key === startKey);
88+ if (s < 0) s = start;
89+ if (oldest) s = 0;
90+ let e = list.length;
91+ if (!tail && !follow) {
92+ const at = endKey === null ? -1 : list.findIndex((g) => g.key === endKey);
93+ e = at >= 0 ? at + 1 : Math.min(end, list.length);
94+ }
95+ s = Math.min(s, e);
96+ if (e - s > MAX_GROUPS) {
97+ if (follow && e === list.length) s = e - MAX_GROUPS;
98+ else if (oldest) e = s + MAX_GROUPS;
99+ else s = e - MAX_GROUPS;
100+ }
101+ start = s;
102+ end = e;
103+ settle(list);
104+ }
105+
106+ export function atTail(): boolean {
107+ return tail;
108+ }
109+
110+ export function has(eventId: string): boolean {
111+ return grouped.some((g) => g.item.kind === 'event' && g.item.event.event_id === eventId);
112+ }
113+
114+ export function reveal(eventId: string): boolean {
115+ const at = grouped.findIndex((g) => g.item.kind === 'event' && g.item.event.event_id === eventId);
116+ if (at < 0 || (at >= start && at < end)) return false;
117+ quiet = true;
118+ start = Math.max(0, at - JUMP_HALF);
119+ end = Math.min(grouped.length, at + JUMP_HALF);
120+ settle(grouped);
121+ return true;
122+ }
123+
124+ export function resetToTail(): boolean {
125+ if (tail) return false;
126+ quiet = true;
127+ start = Math.max(0, grouped.length - INITIAL_GROUPS);
128+ end = grouped.length;
129+ settle(grouped);
130+ return true;
131+ }
132+
133+ export function grow(direction: 'up' | 'down'): boolean {
134+ if (direction === 'up') {
135+ if (start === 0) return false;
136+ quiet = true;
137+ start = Math.max(0, start - STEP);
138+ if (end - start > MAX_GROUPS) end = start + MAX_GROUPS;
139+ } else {
140+ if (end >= grouped.length) return false;
141+ quiet = true;
142+ end = Math.min(grouped.length, end + STEP);
143+ if (end - start > MAX_GROUPS) start = end - MAX_GROUPS;
144+ }
145+ settle(grouped);
146+ return true;
147+ }
148+
149+ function loadOlder(): void {
150+ if (grow('up')) onGrew();
151+ else onPaginate();
152+ }
153+
154+ function trackMotion(vis: Group[], list: Group[], pend: PendingMessage[]): void {
155+ const keys = vis.map((g) => g.key);
156+ const pendingNow = new Map<string, PendingMessage['status']>(pend.map((p) => [`pending-${p.id}`, p.status]));
157+ const enter = new Set<string>();
158+ const leave = new Set<string>();
159+ const prepended = firstKey !== list[0]?.key;
160+ if (mounted && !quiet && !prepended && shownKeys.length > 0) {
161+ const old = new Set(shownKeys);
162+ const now = new Set(keys);
163+ const listKeys = new Set(list.map((g) => g.key));
164+ const added = keys.filter((k) => !old.has(k));
165+ const gone = shownKeys.filter((k) => !now.has(k) && !listKeys.has(k));
166+ if (added.length <= LIVE_MAX && gone.length <= LIVE_MAX) {
167+ const byKey = new Map(vis.map((g) => [g.key, g.item] as const));
168+ const echoed = shownPending.size > 0 && !motionActive();
169+ const lastOld = keys.lastIndexOf(shownKeys[shownKeys.length - 1]);
170+ for (const k of added) {
171+ const item = byKey.get(k);
172+ const appended = lastOld >= 0 && keys.indexOf(k) > lastOld;
173+ if ((appended || k === 'readMarker') && !(echoed && item?.kind === 'event' && item.event.own)) enter.add(k);
174+ }
175+ for (const k of gone) leave.add(k);
176+ }
177+ for (const id of pendingNow.keys()) if (!shownPending.has(id)) enter.add(id);
178+ for (const [id, status] of shownPending) if (!pendingNow.has(id) && status === 'failed') leave.add(id);
179+ }
180+ mounted = true;
181+ quiet = false;
182+ shownKeys = keys;
183+ shownPending = pendingNow;
184+ firstKey = list[0]?.key;
185+ setRowMotion(enter, leave);
186+ }
187+
41188 $: hasStart = items.some((item) => item.kind === 'start');
42189 $: grouped = buildGroups(items);
190+ $: reconcile(grouped, following);
191+ $: visible = grouped.slice(start, end);
192+ $: trackMotion(visible, grouped, tail ? pending : []);
43193 $: lastSentId = lastOwnSent(items);
44194 $: pendingFollowsSelf = lastSenderIs(items, self);
45195 </script>
46196
47197 <div class="timeline-top">
48198 {#if recentState === 'Loading'}
49- <p class="side-note">Loading recent history…</p>
199+ <p class="side-note" transition:expand>Loading recent history…</p>
50200 {:else if recentState && typeof recentState === 'object'}
51- <div class="history-gap">
201+ <div class="history-gap" transition:expand>
52202 <span>Recent history could not be loaded</span>
53203 <button type="button" class="ghost-btn" disabled={recentRetrying} on:click={onRetryRecent}>
54204 {recentRetrying ? 'Loading…' : 'Retry'}
55205 </button>
56206 </div>
57207 {/if}
58- {#if hasStart}
59- <p class="start-note">Beginning of history</p>
208+ {#if hasStart && start === 0}
209+ <p class="start-note" transition:expand>Beginning of history</p>
60210 {:else}
61- <button type="button" class="ghost-btn" disabled={paginating || recentState === 'Loading'} on:click={onPaginate}>
211+ <button type="button" class="ghost-btn" transition:expand disabled={paginating || recentState === 'Loading'} on:click={loadOlder}>
62212 {paginating ? 'Loading…' : 'Load older messages'}
63213 </button>
64214 {/if}
65215 </div>
66216 {#if grouped.length === 0}
67- <p class="side-note timeline-empty">No messages yet</p>
217+ <p class="side-note timeline-empty" transition:expand>No messages yet</p>
68218 {:else}
69- {#each grouped as g (g.key)}
219+ {#each visible as g (g.key)}
70220 {#if g.item.kind === 'day'}
71- <div class="day-row"><span>{formatDay(g.item.timestamp_ms)}</span></div>
221+ <div class="day-row" data-row={g.key} in:rowIn|global out:rowOut|global><span>{formatDay(g.item.timestamp_ms)}</span></div>
72222 {:else if g.item.kind === 'readMarker'}
73- <div class="new-row"><span>New</span></div>
223+ <div class="new-row" data-row={g.key} in:rowIn|global out:rowOut|global><span>New</span></div>
74224 {:else if g.item.kind === 'start'}
75- <p class="start-note">Beginning of history</p>
225+ <p class="start-note" data-row={g.key} in:rowIn|global out:rowOut|global>Beginning of history</p>
76226 {:else if g.item.kind === 'historyGap'}
77227 {@const gap = g.item.gap}
78- <div class="history-gap">
228+ <div class="history-gap" data-row={g.key} in:rowIn|global out:rowOut|global>
79229 <span>{gap.failure ?? 'Some history is not loaded'}</span>
80230 <button type="button" class="ghost-btn" disabled={!!fillingGap || gap.lifecycle === 'Unavailable'} on:click={() => onFillGap(gap.id)}>
81231 {fillingGap === gap.id ? 'Loading…' : gap.lifecycle === 'Unavailable' ? 'History unavailable' : 'Fetch older history'}
82232 </button>
83233 </div>
84234 {:else if g.item.kind === 'unknown'}
85- <p class="unknown-note">Unsupported event ({g.item.label})</p>
235+ <p class="unknown-note" data-row={g.key} in:rowIn|global out:rowOut|global>Unsupported event ({g.item.label})</p>
86236 {:else}
87237 <MessageRow
88238 itemId={g.key}
@@ -111,7 +261,7 @@
111261 {/if}
112262 {/each}
113263 {/if}
114-{#each pending as p, i (p.id)}
264+{#each tail ? pending : [] as p, i (p.id)}
115265 <MessageRow
116266 itemId={`pending-${p.id}`}
117267 event={pendingToEvent(p, self, selfName)}
@@ -127,7 +277,7 @@
127277 {onQuickChange}
128278 />
129279 {#if p.status === 'failed'}
130- <div class="pending-actions">
280+ <div class="pending-actions" transition:expand>
131281 <span class="pending-error" role="alert"><EmojiText text={p.error ?? ''} /></span>
132282 <button type="button" class="ghost-btn" on:click={() => onRetryPending(p.id)}>Retry</button>
133283 <button type="button" class="ghost-btn danger" on:click={() => onDiscardPending(p.id)}>Discard</button>
Modified file: web/src/lib/mentions.ts+14 additions−7 deletions
@@ -62,17 +62,24 @@ export function resolveMentions(body: string, picked: Mention[]): Mention[] {
6262 return out;
6363 }
6464
65+let scratch: HTMLElement | null = null;
66+
6567 /** User pills already in a sent message, so editing it keeps them linked. */
6668 export function mentionsFromHtml(html: string | null): Mention[] {
6769 if (!html) return [];
68- const doc = new DOMParser().parseFromString(html, 'text/html');
69- const out: Mention[] = [];
70- for (const a of doc.querySelectorAll('a[href^="https://matrix.to/#/@"]')) {
71- const userId = decodeURIComponent(a.getAttribute('href')!.slice('https://matrix.to/#/'.length).split('?')[0]);
72- const text = a.textContent ?? '';
73- if (text) out.push({ user_id: userId, text });
70+ scratch ??= document.implementation.createHTMLDocument('').body;
71+ scratch.innerHTML = html;
72+ try {
73+ const out: Mention[] = [];
74+ for (const a of scratch.querySelectorAll('a[href^="https://matrix.to/#/@"]')) {
75+ const userId = decodeURIComponent(a.getAttribute('href')!.slice('https://matrix.to/#/'.length).split('?')[0]);
76+ const text = a.textContent ?? '';
77+ if (text) out.push({ user_id: userId, text });
78+ }
79+ return out;
80+ } finally {
81+ scratch.replaceChildren();
7482 }
75- return out;
7683 }
7784
7885 const MARKDOWN_PILL = /\[([^\]\n]+)\]\(https:\/\/matrix\.to\/#\/(@[^)\s?]+)[^)\s]*\)/g;
Added file: web/src/lib/motion.ts+67 additions
@@ -0,0 +1,67 @@
1+import { cubicOut } from 'svelte/easing';
2+import { fade, slide, type TransitionConfig } from 'svelte/transition';
3+
4+const entering = new Set<string>();
5+const leaving = new Set<string>();
6+let busyUntil = 0;
7+
8+export function motionDuration(ms: number): number {
9+ return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : ms;
10+}
11+
12+export function motionActive(): boolean {
13+ return performance.now() < busyUntil;
14+}
15+
16+function noteBusy(ms: number): void {
17+ busyUntil = Math.max(busyUntil, performance.now() + ms + 60);
18+}
19+
20+export function setRowMotion(enter: Iterable<string>, leave: Iterable<string>): void {
21+ entering.clear();
22+ leaving.clear();
23+ for (const key of enter) entering.add(key);
24+ for (const key of leave) leaving.add(key);
25+}
26+
27+function rowKey(node: Element): string {
28+ const { item, row } = (node as HTMLElement).dataset;
29+ return item ?? row ?? '';
30+}
31+
32+function reveal(node: Element, ms: number, axis: 'x' | 'y' = 'y'): TransitionConfig {
33+ const duration = motionDuration(ms);
34+ if (duration === 0) return { duration: 0 };
35+ noteBusy(duration);
36+ const base = slide(node, { duration, easing: cubicOut, axis });
37+ return { duration, easing: cubicOut, css: (t, u) => `${base.css?.(t, u) ?? ''}opacity: ${t};` };
38+}
39+
40+export function rowIn(node: Element): TransitionConfig {
41+ return entering.has(rowKey(node)) ? reveal(node, 200) : { duration: 0 };
42+}
43+
44+export function rowOut(node: Element): TransitionConfig {
45+ return leaving.has(rowKey(node)) ? reveal(node, 160) : { duration: 0 };
46+}
47+
48+export function expand(node: Element, params: { axis?: 'x' | 'y' } = {}): TransitionConfig {
49+ return reveal(node, 170, params.axis);
50+}
51+
52+export function dissolve(node: Element): TransitionConfig {
53+ const duration = motionDuration(150);
54+ if (duration > 0) noteBusy(duration);
55+ return fade(node, { duration });
56+}
57+
58+export function settleHeight(node: HTMLElement, from: number): void {
59+ const duration = motionDuration(180);
60+ const to = node.offsetHeight;
61+ if (duration === 0 || Math.abs(to - from) < 2 || node.getAnimations({ subtree: true }).length > 0) return;
62+ noteBusy(duration);
63+ node.animate(
64+ [{ height: `${from}px`, overflow: 'hidden' }, { height: `${to}px`, overflow: 'hidden' }],
65+ { duration, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' }
66+ );
67+}
Modified file: web/src/lib/rich.ts+14 additions−2 deletions
@@ -61,8 +61,20 @@ function convert(node: Node): RichNode[] {
6161 return [{ kind: 'element', tag: tag === 'font' ? 'span' : tag, attrs, children: children() }];
6262 }
6363
64+let scratch: HTMLElement | null = null;
65+
66+/** Parses into one shared inert body, so no Document is allocated per message. */
67+function withParsedBody<T>(html: string, read: (body: HTMLElement) => T): T {
68+ scratch ??= document.implementation.createHTMLDocument('').body;
69+ scratch.innerHTML = html;
70+ try {
71+ return read(scratch);
72+ } finally {
73+ scratch.replaceChildren();
74+ }
75+}
76+
6477 /** A sanitised tree for a message's `formatted_body`, safe to render without `{@html}`. */
6578 export function parseRich(html: string): RichNode[] {
66- const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
67- return [...doc.body.childNodes].flatMap(convert);
79+ return withParsedBody(html, (body) => [...body.childNodes].flatMap(convert));
6880 }
Added file: web/src/lib/selected-row.ts+3 additions
@@ -0,0 +1,3 @@
1+import { writable } from 'svelte/store';
2+
3+export const selectedRow = writable<symbol | null>(null);
Modified file: web/src/lib/timeline-groups.ts+5 additions−1 deletions
@@ -23,7 +23,11 @@ export function buildGroups(list: TimelineItem[]): Group[] {
2323 out.push({ key: e.item_id || e.event_id || `m-${index}`, item, showSender });
2424 } else {
2525 prevSender = null;
26- const key = item.kind === 'historyGap' ? `gap-${item.gap.id}` : `${item.kind}-${index}`;
26+ const key =
27+ item.kind === 'historyGap' ? `gap-${item.gap.id}` :
28+ item.kind === 'day' ? `day-${item.timestamp_ms}` :
29+ item.kind === 'readMarker' ? 'readMarker' :
30+ `${item.kind}-${index}`;
2731 out.push({ key, item, showSender: true });
2832 }
2933 });
Modified file: web/src/lib/timeline-viewport.ts+10 additions−3 deletions
@@ -22,12 +22,19 @@ export class TimelineViewport {
2222 const bottom = top + this.element.clientHeight;
2323 this.anchors = [];
2424 this.top = this.element.scrollTop;
25- for (const row of this.element.querySelectorAll<HTMLElement>('[data-item]')) {
25+ const rows = this.element.querySelectorAll<HTMLElement>('[data-item]');
26+ let low = 0;
27+ let high = rows.length;
28+ while (low < high) {
29+ const mid = (low + high) >> 1;
30+ if (rows[mid].getBoundingClientRect().bottom <= top) low = mid + 1;
31+ else high = mid;
32+ }
33+ for (let i = low; i < rows.length && this.anchors.length < 3; i += 1) {
34+ const row = rows[i];
2635 const rect = row.getBoundingClientRect();
27- if (rect.bottom <= top) continue;
2836 if (rect.top >= bottom) break;
2937 this.anchors.push({ itemId: row.dataset.item, eventId: row.dataset.event, offset: rect.top - top });
30- if (this.anchors.length === 3) break;
3138 }
3239 }