atagen

malice

public
Star0
Watch0

Commit 1b586ca05859

tarball

atagen <boss@atagen.co>2026-10-25 02:38 UTC

3 files changed+135-26
timeline: anchor history and smoothly follow arriving messages
Modified file: web/src/App.svelte+28 additions−23 deletions
@@ -70,6 +70,7 @@
7070 import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';
7171 import type { PendingMessage } from './lib/pending';
7272 import { insideOpenFence } from './lib/markdown';
73+ import { TimelineViewport } from './lib/timeline-viewport';
7374 import { markMentions } from './lib/composer-marks';
7475 import { mentionQueryAt, mentionsFromHtml, rankPeople, resolveMentions } from './lib/mentions';
7576 import type { Mention, MentionQuery } from './lib/mentions';
@@ -119,6 +120,7 @@
119120 let drafts: Record<string, string> = {};
120121 let stickToBottom = true;
121122 let timelineEl: HTMLElement | null = null;
123+ let timelineViewport: TimelineViewport | null = null;
122124 let composerEl: HTMLTextAreaElement | null = null;
123125 let marksEl: HTMLDivElement | null = null;
124126 $: composerMarks = markMentions(
@@ -604,40 +606,42 @@
604606 }
605607
606608 function scrollToBottom(): void {
607- if (timelineEl) timelineEl.scrollTop = timelineEl.scrollHeight;
609+ if (timelineViewport?.element === timelineEl) timelineViewport.scrollToBottom();
610+ else if (timelineEl) timelineEl.scrollTop = timelineEl.scrollHeight;
608611 }
609612
610- let userScrollUntil = 0;
611- let draggingScrollbar = false;
612- let paginationAnchor: { roomId: string; top: number; height: number } | null = null;
613-
614613 function noteUserScroll(): void {
615- userScrollUntil = performance.now() + 300;
614+ timelineViewport?.releaseScroll();
616615 }
617616
618617 function onTimelineKey(e: KeyboardEvent): void {
619618 if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(e.key)) noteUserScroll();
620619 }
621620
622- function onTimelinePointer(e: PointerEvent): void {
623- if (e.target === timelineEl) draggingScrollbar = true;
624- }
625-
626621 function onTimelineScroll(): void {
627622 if (!timelineEl) return;
623+ if (timelineViewport?.consumeScroll()) return;
628624 const gap = timelineEl.scrollHeight - timelineEl.scrollTop - timelineEl.clientHeight;
629- if (gap < 80) stickToBottom = true;
630- else if (draggingScrollbar || performance.now() < userScrollUntil) stickToBottom = false;
625+ stickToBottom = gap < 2;
626+ timelineViewport?.capture();
631627 if (timelineEl.scrollTop < 80 && !hasStart && !paginateBusy) void paginate();
632628 }
633629
634630 function pinTimeline(node: HTMLElement) {
631+ const viewport = new TimelineViewport(node.parentElement!, $selectedRoomId);
632+ timelineViewport = viewport;
635633 const observer = new ResizeObserver(() => {
636- if (stickToBottom) scrollToBottom();
634+ if (timelineViewport !== viewport || viewport.roomId !== $selectedRoomId) return;
635+ if (stickToBottom) viewport.scrollToBottom(true);
636+ else viewport.restore();
637637 });
638638 observer.observe(node);
639639 if (node.parentElement) observer.observe(node.parentElement);
640- return { destroy: () => observer.disconnect() };
640+ return { destroy: () => {
641+ observer.disconnect();
642+ viewport.releaseScroll();
643+ if (timelineViewport === viewport) timelineViewport = null;
644+ } };
641645 }
642646
643647 $: void trackTimeline($selectedRoomId, items);
@@ -645,10 +649,9 @@
645649 if (!roomId || list.length === 0) return;
646650 if (roomId !== $selectedRoomId) return;
647651 await tick();
648- if (paginationAnchor?.roomId === roomId && timelineEl && !stickToBottom) {
649- timelineEl.scrollTop = paginationAnchor.top + timelineEl.scrollHeight - paginationAnchor.height;
650- paginationAnchor = null;
651- } else if (stickToBottom) scrollToBottom();
652+ if (roomId !== $selectedRoomId || timelineViewport?.roomId !== roomId) return;
653+ if (stickToBottom) timelineViewport.scrollToBottom(true);
654+ else timelineViewport.restore();
652655 queueReadReceipt();
653656 }
654657
@@ -1037,6 +1040,7 @@
10371040 return;
10381041 }
10391042 stickToBottom = false;
1043+ timelineViewport?.releaseScroll();
10401044 el.scrollIntoView({ block: 'center', behavior: 'smooth' });
10411045 el.classList.remove('flash');
10421046 void el.offsetWidth;
@@ -1059,10 +1063,12 @@
10591063
10601064 $: void trackPending($selectedRoomId, pendingItems);
10611065 async function trackPending(roomId: string | null, list: PendingMessage[]): Promise<void> {
1062- if (!roomId || list.length === 0) return;
1066+ if (!roomId) return;
10631067 if (roomId !== $selectedRoomId) return;
10641068 await tick();
1065- if (stickToBottom) scrollToBottom();
1069+ if (roomId !== $selectedRoomId || timelineViewport?.roomId !== roomId) return;
1070+ if (stickToBottom) timelineViewport.scrollToBottom(true);
1071+ else timelineViewport.restore();
10661072 }
10671073
10681074 let commandQuery: string | null = null;
@@ -1152,6 +1158,7 @@
11521158 }
11531159
11541160 function onComposerKey(ev: KeyboardEvent): void {
1161+ if (ev.isComposing) return;
11551162 if (emoteQuery && emoteCandidates.length > 0) {
11561163 if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
11571164 ev.preventDefault();
@@ -1278,7 +1285,6 @@
12781285 async function paginate(): Promise<void> {
12791286 const roomId = $selectedRoomId;
12801287 if (!roomId || paginateBusy) return;
1281- if (timelineEl && !stickToBottom) paginationAnchor = { roomId, top: timelineEl.scrollTop, height: timelineEl.scrollHeight };
12821288 paginateBusy = true;
12831289 try {
12841290 await paginateRoom(roomId);
@@ -1773,7 +1779,6 @@
17731779 <svelte:window
17741780 on:keydown={onKeydown}
17751781 on:pointerdown={closeRoomMenuOutside}
1776- on:pointerup={() => (draggingScrollbar = false)}
17771782 />
17781783
17791784 {#if !booted}
@@ -2356,7 +2361,7 @@
23562361 on:wheel={noteUserScroll}
23572362 on:touchmove={noteUserScroll}
23582363 on:keydown={onTimelineKey}
2359- on:pointerdown={onTimelinePointer}
2364+ on:pointerdown={noteUserScroll}
23602365 aria-label={`Messages in ${selectedRoom.name}`}
23612366 >
23622367 <div class="timeline-inner" use:pinTimeline>
Modified file: web/src/app.css+7 additions−3 deletions
@@ -659,6 +659,7 @@ input, textarea { font: inherit; }
659659 .timeline {
660660 flex: 1;
661661 overflow-y: auto;
662+ overflow-anchor: none;
662663 padding: 8px 14px 10px;
663664 min-height: 0;
664665 }
@@ -818,17 +819,20 @@ input, textarea { font: inherit; }
818819 .composer-field > textarea {
819820 resize: none;
820821 background: transparent;
821- color: var(--text);
822+ color: transparent;
823+ caret-color: var(--text);
824+ min-width: 0;
825+ width: 100%;
822826 outline: none;
823827 overflow-y: auto;
824828 }
825829 .composer-marks {
826830 pointer-events: none;
827- color: transparent;
831+ color: var(--text);
828832 overflow: hidden;
829833 }
830834 .composer-marks mark {
831- color: transparent;
835+ color: inherit;
832836 background: color-mix(in srgb, var(--accent) 26%, transparent);
833837 border-radius: var(--r-sm);
834838 box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent);
Added file: web/src/lib/timeline-viewport.ts+100 additions
@@ -0,0 +1,100 @@
1+interface MessageAnchor {
2+ itemId: string | undefined;
3+ eventId: string | undefined;
4+ offset: number;
5+}
6+
7+export class TimelineViewport {
8+ private anchors: MessageAnchor[] = [];
9+ private top = 0;
10+ private scrollTarget: number | null = null;
11+ private frame: number | null = null;
12+ private animation: { from: number; to: number; start: number } | null = null;
13+ private initialized = false;
14+
15+ constructor(readonly element: HTMLElement, readonly roomId: string | null) {
16+ this.capture();
17+ }
18+
19+ capture(): void {
20+ const bounds = this.element.getBoundingClientRect();
21+ const top = bounds.top + this.element.clientTop;
22+ const bottom = top + this.element.clientHeight;
23+ this.anchors = [];
24+ this.top = this.element.scrollTop;
25+ for (const row of this.element.querySelectorAll<HTMLElement>('[data-item]')) {
26+ const rect = row.getBoundingClientRect();
27+ if (rect.bottom <= top) continue;
28+ if (rect.top >= bottom) break;
29+ this.anchors.push({ itemId: row.dataset.item, eventId: row.dataset.event, offset: rect.top - top });
30+ if (this.anchors.length === 3) break;
31+ }
32+ }
33+
34+ restore(): void {
35+ this.releaseScroll();
36+ const rows = [...this.element.querySelectorAll<HTMLElement>('[data-item]')];
37+ const top = this.element.getBoundingClientRect().top + this.element.clientTop;
38+ for (const anchor of this.anchors) {
39+ const row = rows.find((row) => row.dataset.item === anchor.itemId) ??
40+ (anchor.eventId ? rows.find((row) => row.dataset.event === anchor.eventId) : undefined);
41+ if (!row) continue;
42+ this.setTop(this.element.scrollTop + row.getBoundingClientRect().top - top - anchor.offset);
43+ this.capture();
44+ return;
45+ }
46+ this.setTop(this.top);
47+ this.capture();
48+ }
49+
50+ scrollToBottom(smooth = false): void {
51+ const target = Math.max(0, this.element.scrollHeight - this.element.clientHeight);
52+ const animate = smooth && this.initialized && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
53+ this.initialized = true;
54+ if (!animate) {
55+ this.releaseScroll();
56+ this.setTop(target);
57+ this.capture();
58+ return;
59+ }
60+ if (this.animation && Math.abs(this.animation.to - target) < 0.5) return;
61+ if (Math.abs(this.element.scrollTop - target) < 0.5) return;
62+ this.animation = { from: this.element.scrollTop, to: target, start: performance.now() };
63+ if (this.frame === null) this.frame = requestAnimationFrame(this.advance);
64+ }
65+
66+ consumeScroll(): boolean {
67+ const target = this.scrollTarget;
68+ this.scrollTarget = null;
69+ if (target !== null && Math.abs(this.element.scrollTop - target) < 1) return true;
70+ this.releaseScroll();
71+ return false;
72+ }
73+
74+ releaseScroll(): void {
75+ if (this.frame !== null) cancelAnimationFrame(this.frame);
76+ this.frame = null;
77+ this.animation = null;
78+ this.scrollTarget = null;
79+ }
80+
81+ private advance = (now: number): void => {
82+ this.frame = null;
83+ const animation = this.animation;
84+ if (!animation) return;
85+ const progress = Math.min(1, (now - animation.start) / 220);
86+ const eased = 1 - (1 - progress) ** 3;
87+ this.setTop(animation.from + (animation.to - animation.from) * eased);
88+ if (progress < 1) this.frame = requestAnimationFrame(this.advance);
89+ else {
90+ this.animation = null;
91+ this.capture();
92+ }
93+ };
94+
95+ private setTop(top: number): void {
96+ if (Math.abs(this.element.scrollTop - top) < 0.5) return;
97+ this.element.scrollTop = top;
98+ this.scrollTarget = this.element.scrollTop;
99+ }
100+}