fix: Improve autoscroll to bottom
This commit is contained in:
@@ -14,7 +14,8 @@ chat/
|
|||||||
├── domain/
|
├── domain/
|
||||||
│ └── rules/
|
│ └── rules/
|
||||||
│ ├── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp
|
│ ├── 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/
|
├── feature/
|
||||||
│ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays)
|
│ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays)
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { Attachment } from '../../../../../attachment';
|
import { Attachment } from '../../../../../attachment';
|
||||||
|
import { resolveAutoScrollBehavior } from '../../../../domain/rules/auto-scroll.rules';
|
||||||
import { getMessageTimestamp } from '../../../../domain/rules/message.rules';
|
import { getMessageTimestamp } from '../../../../domain/rules/message.rules';
|
||||||
import { Message, User } from '../../../../../../shared-kernel';
|
import { Message, User } from '../../../../../../shared-kernel';
|
||||||
import {
|
import {
|
||||||
@@ -264,14 +265,22 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
const forceLocalSendScroll = this.shouldForceLocalSendScroll();
|
const forceLocalSendScroll = this.shouldForceLocalSendScroll();
|
||||||
|
|
||||||
if (newMessages) {
|
if (newMessages) {
|
||||||
if (forceLocalSendScroll || distanceFromBottom <= 300) {
|
const behavior = resolveAutoScrollBehavior({
|
||||||
|
newMessages,
|
||||||
|
forceLocalSend: forceLocalSendScroll,
|
||||||
|
distanceFromBottom,
|
||||||
|
withinInitialGrace: this.isWithinInitialScrollGrace()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (behavior === 'instant') {
|
||||||
if (forceLocalSendScroll) {
|
if (forceLocalSendScroll) {
|
||||||
this.clearLocalSendScrollPending();
|
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);
|
this.showNewMessagesBar.set(false);
|
||||||
} else {
|
} else {
|
||||||
queueMicrotask(() => this.showNewMessagesBar.set(true));
|
queueMicrotask(() => this.showNewMessagesBar.set(true));
|
||||||
@@ -301,8 +310,12 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
this.showNewMessagesBar.set(false);
|
this.showNewMessagesBar.set(false);
|
||||||
this.lastMessageCount = this.messages().length;
|
this.lastMessageCount = this.messages().length;
|
||||||
this.scheduleCodeHighlight();
|
this.scheduleCodeHighlight();
|
||||||
} else if (!this.loading()) {
|
} else {
|
||||||
this.initialScrollPending = false;
|
// 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;
|
this.lastMessageCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,6 +573,18 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
return !!latestMessage && latestMessage.senderId === this.currentUserId();
|
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 {
|
private getMessageDateTimestamp(message: Message): number {
|
||||||
return message.timestamp || getMessageTimestamp(message);
|
return message.timestamp || getMessageTimestamp(message);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user