atagen

malice

public
Star0
Watch0

Commit 988fbf982ada

tarball

atagen <boss@atagen.co>2026-10-27 03:55 UTC

2 files changed+57-12
web: drive jumps to a message through the timeline viewport

jumpTo used a browser smooth scrollIntoView that TimelineViewport didn't know about, so every anchor restore from settling images or history filling in aborted it and snapped the view back toward where it started. reveal() animates the jump itself, retargets it when layout shifts mid-flight, and holds the landed row until the user scrolls. farFromLatest is now measured on consumed scrolls too, or the jump to latest arrow never showed after a jump.
Modified file: web/src/App.svelte+12 additions−5 deletions
@@ -775,13 +775,13 @@
775775 const top = timelineEl.scrollTop;
776776 const scrolledUp = top < lastScrollTop - 1;
777777 lastScrollTop = top;
778+ measureLatest();
778779 if (timelineViewport?.consumeScroll()) return;
779780 const gap = timelineEl.scrollHeight - top - timelineEl.clientHeight;
780781 const wasAtBottom = stickToBottom;
781782 const byUser = scrollbarHeld || performance.now() - userScrollAt < USER_SCROLL_MS;
782783 stickToBottom = (gap < 2 || (wasAtBottom && !(byUser && scrolledUp))) && (timelineRows?.atTail() ?? true);
783784 if (stickToBottom && gap >= 2) timelineViewport?.scrollToBottom(false);
784- farFromLatest = gap > 240 || !(timelineRows?.atTail() ?? true);
785785 if (stickToBottom && !wasAtBottom) queueReadReceipt();
786786 timelineViewport?.capture();
787787 const grew = (timelineEl.scrollTop < WINDOW_EDGE && timelineRows?.grow('up')) || (gap < WINDOW_EDGE && timelineRows?.grow('down'));
@@ -789,13 +789,22 @@
789789 else if (timelineEl.scrollTop < 80 && !hasStart && !paginateBusy) void paginate();
790790 }
791791
792+ function measureLatest(): void {
793+ if (!timelineEl) return;
794+ const gap = timelineEl.scrollHeight - timelineEl.scrollTop - timelineEl.clientHeight;
795+ farFromLatest = gap > 240 || !(timelineRows?.atTail() ?? true);
796+ }
797+
792798 function pinTimeline(node: HTMLElement) {
793799 const viewport = new TimelineViewport(node.parentElement!, $selectedRoomId);
794800 timelineViewport = viewport;
795801 const observer = new ResizeObserver(() => {
796802 if (timelineViewport !== viewport || viewport.roomId !== $selectedRoomId) return;
797803 if (stickToBottom) viewport.scrollToBottom(!motionActive());
798- else viewport.restore();
804+ else {
805+ viewport.restore();
806+ measureLatest();
807+ }
799808 });
800809 observer.observe(node);
801810 if (node.parentElement) observer.observe(node.parentElement);
@@ -1275,9 +1284,7 @@
12751284 return;
12761285 }
12771286 stickToBottom = false;
1278- timelineViewport?.releaseScroll();
1279- el.scrollIntoView({ block: 'center', behavior: recentred ? 'instant' : 'smooth' });
1280- if (recentred) timelineViewport?.capture();
1287+ timelineViewport?.reveal(el, !recentred);
12811288 el.classList.remove('flash');
12821289 void el.offsetWidth;
12831290 el.classList.add('flash');
Modified file: web/src/lib/timeline-viewport.ts+45 additions−7 deletions
@@ -9,7 +9,7 @@ export class TimelineViewport {
99 private top = 0;
1010 private scrollTarget: number | null = null;
1111 private frame: number | null = null;
12- private animation: { from: number; to: number; start: number } | null = null;
12+ private animation: { from: number; to: number; start: number; anchored: boolean } | null = null;
1313 private initialized = false;
1414 private settledTop: number | null = null;
1515
@@ -46,22 +46,46 @@ export class TimelineViewport {
4646 }
4747
4848 restore(): void {
49- this.releaseScroll();
49+ const animation = this.animation?.anchored ? this.animation : null;
50+ if (!animation) this.releaseScroll();
5051 const rows = [...this.element.querySelectorAll<HTMLElement>('[data-item]')];
51- const top = this.element.getBoundingClientRect().top + this.element.clientTop;
5252 for (const anchor of this.anchors) {
5353 const row = rows.find((row) => row.dataset.item === anchor.itemId) ??
5454 (anchor.eventId ? rows.find((row) => row.dataset.event === anchor.eventId) : undefined);
5555 if (!row) continue;
56- this.setTop(this.element.scrollTop + row.getBoundingClientRect().top - top - anchor.offset);
56+ const target = this.element.scrollTop + this.offsetOf(row) - anchor.offset;
57+ if (animation) {
58+ const shift = target - animation.to;
59+ animation.from += shift;
60+ animation.to = target;
61+ this.setTop(this.element.scrollTop + shift);
62+ return;
63+ }
64+ this.setTop(target);
5765 this.top = this.element.scrollTop;
5866 this.settledTop = this.top;
5967 return;
6068 }
69+ this.releaseScroll();
6170 this.setTop(this.top);
6271 this.recapture();
6372 }
6473
74+ reveal(row: HTMLElement, smooth: boolean): void {
75+ this.releaseScroll();
76+ const max = Math.max(0, this.element.scrollHeight - this.element.clientHeight);
77+ const centred = this.element.scrollTop + this.offsetOf(row) - Math.max(0, (this.element.clientHeight - row.offsetHeight) / 2);
78+ const target = Math.min(max, Math.max(0, centred));
79+ this.anchors = [{ itemId: row.dataset.item, eventId: row.dataset.event, offset: this.offsetOf(row) - (target - this.element.scrollTop) }];
80+ this.top = target;
81+ if (smooth && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
82+ this.animate({ from: this.element.scrollTop, to: target, start: performance.now(), anchored: true });
83+ return;
84+ }
85+ this.setTop(target);
86+ this.hold();
87+ }
88+
6589 scrollToBottom(smooth = false): void {
6690 const target = Math.max(0, this.element.scrollHeight - this.element.clientHeight);
6791 const animate = smooth && this.initialized && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -74,8 +98,7 @@ export class TimelineViewport {
7498 }
7599 if (this.animation && Math.abs(this.animation.to - target) < 0.5) return;
76100 if (Math.abs(this.element.scrollTop - target) < 0.5) return;
77- this.animation = { from: this.element.scrollTop, to: target, start: performance.now() };
78- if (this.frame === null) this.frame = requestAnimationFrame(this.advance);
101+ this.animate({ from: this.element.scrollTop, to: target, start: performance.now(), anchored: false });
79102 }
80103
81104 consumeScroll(): boolean {
@@ -93,6 +116,20 @@ export class TimelineViewport {
93116 this.scrollTarget = null;
94117 }
95118
119+ private animate(animation: NonNullable<TimelineViewport['animation']>): void {
120+ this.animation = animation;
121+ if (this.frame === null) this.frame = requestAnimationFrame(this.advance);
122+ }
123+
124+ private hold(): void {
125+ this.settledTop = this.element.scrollTop;
126+ this.scrollTarget = null;
127+ }
128+
129+ private offsetOf(row: HTMLElement): number {
130+ return row.getBoundingClientRect().top - this.element.getBoundingClientRect().top - this.element.clientTop;
131+ }
132+
96133 private advance = (now: number): void => {
97134 this.frame = null;
98135 const animation = this.animation;
@@ -103,7 +140,8 @@ export class TimelineViewport {
103140 if (progress < 1) this.frame = requestAnimationFrame(this.advance);
104141 else {
105142 this.animation = null;
106- this.recapture();
143+ if (animation.anchored) this.hold();
144+ else this.recapture();
107145 }
108146 };