From 147858de2f65179bca4622d70efa50f4042952ea Mon Sep 17 00:00:00 2001 From: Myx Date: Thu, 4 Jun 2026 18:29:33 +0200 Subject: [PATCH] fix: Improve autoscroll to bottom --- toju-app/src/app/domains/chat/README.md | 3 +- .../domain/rules/auto-scroll.rules.spec.ts | 50 +++++++++++++++++++ .../chat/domain/rules/auto-scroll.rules.ts | 44 ++++++++++++++++ .../chat-message-list.component.ts | 37 +++++++++++--- 4 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 toju-app/src/app/domains/chat/domain/rules/auto-scroll.rules.spec.ts create mode 100644 toju-app/src/app/domains/chat/domain/rules/auto-scroll.rules.ts diff --git a/toju-app/src/app/domains/chat/README.md b/toju-app/src/app/domains/chat/README.md index 0a0462f..125411a 100644 --- a/toju-app/src/app/domains/chat/README.md +++ b/toju-app/src/app/domains/chat/README.md @@ -14,7 +14,8 @@ chat/ ├── domain/ │ └── rules/ │ ├── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp -│ └── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits +│ ├── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits +│ └── auto-scroll.rules.ts resolveAutoScrollBehavior: instant on channel switch, smooth for live msgs │ ├── feature/ │ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays) diff --git a/toju-app/src/app/domains/chat/domain/rules/auto-scroll.rules.spec.ts b/toju-app/src/app/domains/chat/domain/rules/auto-scroll.rules.spec.ts new file mode 100644 index 0000000..3f645fb --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/auto-scroll.rules.spec.ts @@ -0,0 +1,50 @@ +import { resolveAutoScrollBehavior } from './auto-scroll.rules'; + +describe('resolveAutoScrollBehavior', () => { + const base = { + newMessages: true, + forceLocalSend: false, + distanceFromBottom: 0, + withinInitialGrace: false + }; + + it('does nothing when no new messages arrived', () => { + expect(resolveAutoScrollBehavior({ ...base, newMessages: false })).toBe('none'); + }); + + it('jumps instantly for the local user own send regardless of grace', () => { + expect(resolveAutoScrollBehavior({ ...base, forceLocalSend: true })).toBe('instant'); + expect( + resolveAutoScrollBehavior({ ...base, forceLocalSend: true, withinInitialGrace: true }) + ).toBe('instant'); + }); + + it('jumps instantly when near bottom while settling after a channel switch', () => { + expect( + resolveAutoScrollBehavior({ ...base, distanceFromBottom: 40, withinInitialGrace: true }) + ).toBe('instant'); + }); + + it('animates smoothly for live messages once settled and near bottom', () => { + expect( + resolveAutoScrollBehavior({ ...base, distanceFromBottom: 40, withinInitialGrace: false }) + ).toBe('smooth'); + }); + + it('shows the indicator (no scroll) when far from the bottom', () => { + expect(resolveAutoScrollBehavior({ ...base, distanceFromBottom: 800 })).toBe('none'); + expect( + resolveAutoScrollBehavior({ ...base, distanceFromBottom: 800, withinInitialGrace: true }) + ).toBe('none'); + }); + + it('honours a custom sticky threshold', () => { + expect( + resolveAutoScrollBehavior({ ...base, distanceFromBottom: 150, stickyThreshold: 100 }) + ).toBe('none'); + + expect( + resolveAutoScrollBehavior({ ...base, distanceFromBottom: 80, stickyThreshold: 100 }) + ).toBe('smooth'); + }); +}); diff --git a/toju-app/src/app/domains/chat/domain/rules/auto-scroll.rules.ts b/toju-app/src/app/domains/chat/domain/rules/auto-scroll.rules.ts new file mode 100644 index 0000000..feddc19 --- /dev/null +++ b/toju-app/src/app/domains/chat/domain/rules/auto-scroll.rules.ts @@ -0,0 +1,44 @@ +/** Behaviour the message list should apply when new messages arrive. */ +export type AutoScrollBehavior = 'smooth' | 'instant' | 'none'; + +export interface AutoScrollDecisionInput { + /** Whether the message count grew since the last render. */ + readonly newMessages: boolean; + /** Forced jump for the local user's own just-sent message. */ + readonly forceLocalSend: boolean; + /** Pixels from the bottom of the scroll container. */ + readonly distanceFromBottom: number; + /** + * True while the conversation is still settling after a channel/server + * switch (initial scroll + async embed/image height changes). During this + * window we jump instantly so we never animate to the wrong position. + */ + readonly withinInitialGrace: boolean; + /** Distance threshold under which the list is considered stuck to bottom. */ + readonly stickyThreshold?: number; +} + +/** + * Decides how the message list should react when the message count changes. + * + * - No new messages -> `none`. + * - Local user's own send -> `instant` (never animate your own message). + * - Near bottom during the post-switch settle window -> `instant` (so loading + * a channel always lands at the bottom without a mid-chat smooth animation). + * - Near bottom afterwards -> `smooth` (live messages animate into view). + * - Far from bottom -> `none` (show the new-messages indicator instead). + */ +export function resolveAutoScrollBehavior(input: AutoScrollDecisionInput): AutoScrollBehavior { + if (!input.newMessages) + return 'none'; + + if (input.forceLocalSend) + return 'instant'; + + const threshold = input.stickyThreshold ?? 300; + + if (input.distanceFromBottom > threshold) + return 'none'; + + return input.withinInitialGrace ? 'instant' : 'smooth'; +} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts index ede554d..cc4687b 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts @@ -16,6 +16,7 @@ import { } from '@angular/core'; import { Store } from '@ngrx/store'; import { Attachment } from '../../../../../attachment'; +import { resolveAutoScrollBehavior } from '../../../../domain/rules/auto-scroll.rules'; import { getMessageTimestamp } from '../../../../domain/rules/message.rules'; import { Message, User } from '../../../../../../shared-kernel'; import { @@ -264,14 +265,22 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { const forceLocalSendScroll = this.shouldForceLocalSendScroll(); if (newMessages) { - if (forceLocalSendScroll || distanceFromBottom <= 300) { + const behavior = resolveAutoScrollBehavior({ + newMessages, + forceLocalSend: forceLocalSendScroll, + distanceFromBottom, + withinInitialGrace: this.isWithinInitialScrollGrace() + }); + + if (behavior === 'instant') { if (forceLocalSendScroll) { this.clearLocalSendScrollPending(); - this.scheduleScrollToBottomAfterRender(true); - } else { - this.scheduleScrollToBottomSmooth(); } + this.scheduleScrollToBottomAfterRender(true); + this.showNewMessagesBar.set(false); + } else if (behavior === 'smooth') { + this.scheduleScrollToBottomSmooth(); this.showNewMessagesBar.set(false); } else { queueMicrotask(() => this.showNewMessagesBar.set(true)); @@ -301,8 +310,12 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { this.showNewMessagesBar.set(false); this.lastMessageCount = this.messages().length; this.scheduleCodeHighlight(); - } else if (!this.loading()) { - this.initialScrollPending = false; + } else { + // No messages rendered yet for this conversation. Stay armed so the + // very first batch (cached or async-loaded) anchors to the bottom. + // For a genuinely empty conversation this flag simply stays true and + // is harmless: onMessagesChanged is gated on it, and the next + // ngAfterViewChecked with messages will perform the initial scroll. this.lastMessageCount = 0; } @@ -560,6 +573,18 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { return !!latestMessage && latestMessage.senderId === this.currentUserId(); } + /** + * True while the conversation is still settling after a channel/server + * switch. The bottom-scroll watch is active during this window (initial + * scroll + async embed/image height changes), so we jump instantly instead + * of animating to avoid landing in the middle of the chat. The watch ends + * after the timeout or as soon as the user scrolls, after which live + * messages animate smoothly again. + */ + private isWithinInitialScrollGrace(): boolean { + return this.bottomScrollObserver !== null; + } + private getMessageDateTimestamp(message: Message): number { return message.timestamp || getMessageTimestamp(message); }