Commit d07bca5da4ae
tarball15 files changed+643-138
@@ -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 lands181181
$: {@@ -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
<RoomTimelineRows2510
+ 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
+ <button2549
+ 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'}@@ -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
@@ -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}@@ -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 bounded1517
// 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
<img91102
class="media-image"92103
class:loaded93104
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; }@@ -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:window262
- 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
<div270348
class="msg"349
+ role="group"350
+ tabindex={interactive && !readOnly ? 0 : undefined}271351
class:own={event.own}272352
class:editing273353
class:mentioned={mentionsMe || keywordHit}@@ -276,10 +356,17 @@
276356
class:selected277357
data-item={itemId}278358
data-event={event.event_id}359
+ in:rowIn|global360
+ out:rowOut|global279361
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
+ <button458
+ 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 unavailable471
+ {/if}472
+ </button>473
+ </div>474
+ {/if}367475
{#if showSender}368476
<button369477
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
- <button419
- 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 unavailable432
- {/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
<button465554
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;@@ -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;@@ -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>@@ -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>@@ -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
<MessageRow88238
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
<MessageRow116266
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>@@ -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;@@ -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
+}@@ -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
}@@ -0,0 +1,3 @@
1
+import { writable } from 'svelte/store';2
+3
+export const selectedRow = writable<symbol | null>(null);@@ -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
});@@ -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
}