fix: chat scroll fix

This commit is contained in:
2026-06-05 02:46:39 +02:00
parent 9e1d75d038
commit ca069e2f61
6 changed files with 203 additions and 209 deletions

View File

@@ -15,7 +15,7 @@ chat/
│ └── rules/
│ ├── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp
│ ├── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits
│ └── auto-scroll.rules.ts resolveAutoScrollBehavior: instant on channel switch, smooth for live msgs
│ └── auto-scroll.rules.ts resolveAutoScrollBehavior (instant on channel switch, smooth for live msgs) + isStuckToBottom predicate
├── feature/
│ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays)
@@ -148,6 +148,12 @@ graph LR
| `getLatestTimestamp(msgs)` | Max timestamp across a batch, used for sync ordering |
| `chunkArray(items, size)` | Splits arrays into fixed-size chunks for batched transfer |
| `findMissingIds(remote, local)` | Compares inventories and returns IDs to request |
| `resolveAutoScrollBehavior(input)` | Decides `instant` / `smooth` / `none` when the message count changes |
| `isStuckToBottom(distance, threshold?)` | True while the list is close enough to the bottom to keep auto-pinning (default 300px) |
## Auto-scroll
Opening a conversation must land on the newest message even though images, link/media embeds, and plugin-rendered content load asynchronously and change the list height after the first render. `MessageListComponent` keeps a `stickToBottom` flag (set on channel switch and whenever the user scrolls within `STICKY_BOTTOM_THRESHOLD` of the bottom) and observes the rendered message content with a `ResizeObserver`. While stuck, every content height change re-pins to the bottom — with no arbitrary timeout — so late-loading content can never leave the user mid-scroll. The flag clears as soon as the user scrolls up to read history, at which point a `New messages` indicator is shown instead. `resolveAutoScrollBehavior` chooses an instant jump during the post-switch settle window (and for the user's own sends) and a smooth animation for live messages afterwards.
## Typing indicator

View File

@@ -1,4 +1,4 @@
import { resolveAutoScrollBehavior } from './auto-scroll.rules';
import { isStuckToBottom, resolveAutoScrollBehavior } from './auto-scroll.rules';
describe('resolveAutoScrollBehavior', () => {
const base = {
@@ -48,3 +48,28 @@ describe('resolveAutoScrollBehavior', () => {
).toBe('smooth');
});
});
describe('isStuckToBottom', () => {
it('is stuck when exactly at the bottom', () => {
expect(isStuckToBottom(0)).toBe(true);
});
it('is stuck within the default sticky threshold', () => {
expect(isStuckToBottom(300)).toBe(true);
expect(isStuckToBottom(299)).toBe(true);
});
it('is not stuck once past the default threshold', () => {
expect(isStuckToBottom(301)).toBe(false);
expect(isStuckToBottom(2000)).toBe(false);
});
it('honours a custom threshold', () => {
expect(isStuckToBottom(80, 100)).toBe(true);
expect(isStuckToBottom(150, 100)).toBe(false);
});
it('treats negative overscroll distances as stuck', () => {
expect(isStuckToBottom(-20)).toBe(true);
});
});

View File

@@ -42,3 +42,21 @@ export function resolveAutoScrollBehavior(input: AutoScrollDecisionInput): AutoS
return input.withinInitialGrace ? 'instant' : 'smooth';
}
/** Default pixel distance under which the list is considered stuck to bottom. */
export const STICKY_BOTTOM_THRESHOLD = 300;
/**
* Whether the scroll position is close enough to the bottom that the list
* should keep auto-pinning to the latest message. Negative distances (rubber
* band / overscroll) count as stuck.
*
* This is the predicate the message list uses to decide whether a content
* height change (late image/embed/plugin render) should re-pin to bottom.
*/
export function isStuckToBottom(
distanceFromBottom: number,
threshold: number = STICKY_BOTTOM_THRESHOLD
): boolean {
return distanceFromBottom <= threshold;
}

View File

@@ -30,6 +30,10 @@
<p class="text-sm">Be the first to say something!</p>
</div>
} @else {
<div
#messagesContent
class="space-y-4"
>
@if (hasMoreMessages()) {
<div class="flex items-center justify-center py-3">
@if (loadingMore()) {
@@ -78,6 +82,7 @@
(embedRemoved)="handleEmbedRemoved($event)"
/>
}
</div>
}
@if (showNewMessagesBar()) {

View File

@@ -16,8 +16,12 @@ 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 {
STICKY_BOTTOM_THRESHOLD,
isStuckToBottom,
resolveAutoScrollBehavior
} from '../../../../domain/rules/auto-scroll.rules';
import { Message, User } from '../../../../../../shared-kernel';
import {
ChatMessageDeleteEvent,
@@ -57,6 +61,7 @@ declare global {
})
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
@ViewChild('messagesContent') messagesContent?: ElementRef<HTMLDivElement>;
private readonly store = inject(Store);
private readonly allUsers = this.store.selectSignal(selectAllUsers);
@@ -172,15 +177,30 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return index;
});
private bottomScrollObserver: MutationObserver | null = null;
private bottomScrollTimer: ReturnType<typeof setTimeout> | null = null;
private boundOnImageLoad: (() => void) | null = null;
private contentResizeObserver: ResizeObserver | null = null;
private observedContent: HTMLElement | null = null;
private localSendScrollPending = false;
private localSendScrollTimer: ReturnType<typeof setTimeout> | null = null;
private isAutoScrolling = false;
private lastMessageCount = 0;
private initialScrollPending = true;
private prismHighlightScheduled = false;
/**
* True while the list should keep auto-pinning to the newest message. Set
* when the conversation opens and whenever the user is scrolled near the
* bottom; cleared once the user scrolls up to read history. The
* `ResizeObserver` only re-pins while this is true, so late-loading images,
* embeds, and plugin content can never knock an at-bottom view off the
* latest message.
*/
private stickToBottom = true;
/**
* Timestamp (ms) until which a freshly opened conversation is still
* settling. Inside this window new messages jump instantly instead of
* animating, so a channel switch always lands at the bottom.
*/
private settleUntil = 0;
private static readonly INITIAL_SETTLE_MS = 1500;
/**
* Set when an older-page DB fetch is in flight. While true, the
* `onMessagesChanged` effect treats incoming message-count growth as a
@@ -263,27 +283,30 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
const newMessages = currentCount > this.lastMessageCount;
const forceLocalSendScroll = this.shouldForceLocalSendScroll();
if (newMessages) {
const behavior = resolveAutoScrollBehavior({
newMessages,
forceLocalSend: forceLocalSendScroll,
distanceFromBottom,
withinInitialGrace: this.isWithinInitialScrollGrace()
withinInitialGrace: this.withinInitialGrace(),
stickyThreshold: STICKY_BOTTOM_THRESHOLD
});
if (behavior === 'none') {
if (newMessages) {
queueMicrotask(() => this.showNewMessagesBar.set(true));
}
} else {
this.stickToBottom = true;
this.showNewMessagesBar.set(false);
if (behavior === 'instant') {
if (forceLocalSendScroll) {
this.clearLocalSendScrollPending();
}
this.scheduleScrollToBottomAfterRender(true);
this.showNewMessagesBar.set(false);
} else if (behavior === 'smooth') {
this.scheduleScrollToBottomSmooth();
this.showNewMessagesBar.set(false);
} else {
queueMicrotask(() => this.showNewMessagesBar.set(true));
this.scheduleScrollToBottomSmooth();
}
}
@@ -299,6 +322,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
if (this.initialScrollPending) {
if (this.messages().length > 0) {
this.initialScrollPending = false;
this.stickToBottom = true;
this.settleUntil = Date.now() + ChatMessageListComponent.INITIAL_SETTLE_MS;
this.isAutoScrolling = true;
element.scrollTop = element.scrollHeight;
requestAnimationFrame(() => {
@@ -306,27 +331,24 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
});
this.clearLocalSendScrollPending();
this.startBottomScrollWatch();
this.startContentResizeWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length;
this.scheduleCodeHighlight();
} 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.
} else if (!this.loading()) {
this.initialScrollPending = false;
this.lastMessageCount = 0;
}
return;
}
this.startContentResizeWatch();
this.scheduleCodeHighlight();
}
ngOnDestroy(): void {
this.stopBottomScrollWatch();
this.stopContentResizeWatch();
this.clearLocalSendScrollPending();
}
@@ -344,16 +366,13 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return;
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
const shouldStickToBottom = distanceFromBottom <= 300;
if (shouldStickToBottom) {
this.stickToBottom = isStuckToBottom(distanceFromBottom);
if (this.stickToBottom) {
this.showNewMessagesBar.set(false);
}
if (this.bottomScrollObserver) {
this.stopBottomScrollWatch();
}
if (element.scrollTop < 150 && !this.loadingMore()) {
const canFetchOlderFromDb =
!this.hasMoreMessages()
@@ -420,11 +439,13 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
}
readLatest(): void {
this.stickToBottom = true;
this.scrollToBottomSmooth();
this.showNewMessagesBar.set(false);
}
scrollToBottomAfterLocalSend(): void {
this.stickToBottom = true;
this.localSendScrollPending = true;
this.showNewMessagesBar.set(false);
this.scheduleScrollToBottomAfterRender(true);
@@ -492,7 +513,9 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
private resetScrollingState(): void {
this.initialScrollPending = true;
this.stopBottomScrollWatch();
this.stickToBottom = true;
this.settleUntil = Date.now() + ChatMessageListComponent.INITIAL_SETTLE_MS;
this.stopContentResizeWatch();
this.clearLocalSendScrollPending();
this.showNewMessagesBar.set(false);
this.lastMessageCount = 0;
@@ -501,47 +524,53 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.loadingMore.set(false);
}
private startBottomScrollWatch(): void {
this.stopBottomScrollWatch();
private withinInitialGrace(): boolean {
return Date.now() < this.settleUntil;
}
const element = this.messagesContainer?.nativeElement;
if (!element)
/**
* Keeps the list pinned to the latest message while the user is at the
* bottom, re-pinning whenever the rendered content changes height. A
* `ResizeObserver` on the message content fires for any cause of growth -
* images decoding, link/media embeds, plugin-rendered content, font swaps,
* reflow - with no arbitrary time limit, so opening a conversation reliably
* lands on the newest message even when content loads asynchronously.
*/
private startContentResizeWatch(): void {
if (typeof ResizeObserver === 'undefined')
return;
const content = this.messagesContent?.nativeElement;
if (!content)
return;
if (this.contentResizeObserver && this.observedContent === content)
return;
this.stopContentResizeWatch();
this.observedContent = content;
this.contentResizeObserver = new ResizeObserver(() => {
if (!this.stickToBottom)
return;
if (this.initialScrollPending || this.pendingOlderFetchScrollHeight !== null)
return;
this.bottomScrollObserver = new MutationObserver(() => {
requestAnimationFrame(() => this.scrollToBottomInstant());
});
this.bottomScrollObserver.observe(element, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src']
});
this.boundOnImageLoad = () => requestAnimationFrame(() => this.scrollToBottomInstant());
element.addEventListener('load', this.boundOnImageLoad, true);
this.bottomScrollTimer = setTimeout(() => this.stopBottomScrollWatch(), 5000);
this.contentResizeObserver.observe(content);
}
private stopBottomScrollWatch(): void {
if (this.bottomScrollObserver) {
this.bottomScrollObserver.disconnect();
this.bottomScrollObserver = null;
private stopContentResizeWatch(): void {
if (this.contentResizeObserver) {
this.contentResizeObserver.disconnect();
this.contentResizeObserver = null;
}
if (this.boundOnImageLoad && this.messagesContainer) {
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
this.boundOnImageLoad = null;
}
if (this.bottomScrollTimer) {
clearTimeout(this.bottomScrollTimer);
this.bottomScrollTimer = null;
}
this.observedContent = null;
}
private armLocalSendScrollTimeout(): void {
@@ -573,18 +602,6 @@ 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);
}
@@ -630,7 +647,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.scrollToBottomInstant();
if (watchForLayoutChanges) {
this.startBottomScrollWatch();
this.startContentResizeWatch();
}
});
});

View File

@@ -44,7 +44,6 @@ export class DirectCallService {
private readonly users = this.store.selectSignal(selectAllUsers);
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
private readonly mobileOverlayCallId = signal<string | null>(null);
private readonly pendingIncomingCallEvents: DirectCallEventPayload[] = [];
readonly sessions = computed(() => this.sessionsSignal());
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
@@ -86,18 +85,6 @@ export class DirectCallService {
}
});
effect(() => {
if (!this.currentUserId() || this.pendingIncomingCallEvents.length === 0) {
return;
}
const pendingEvents = this.pendingIncomingCallEvents.splice(0);
for (const payload of pendingEvents) {
void this.handleIncomingCallEvent(payload);
}
});
effect(() => {
const session = this.currentSession();
@@ -172,7 +159,7 @@ export class DirectCallService {
}
const existing = this.sessionById(conversation.id);
const session = existing && existing.status !== 'ended' ? existing : this.createSession({
const session = existing ?? this.createSession({
callId: conversation.id,
conversationId: conversation.id,
createdAt: Date.now(),
@@ -184,14 +171,6 @@ export class DirectCallService {
this.upsertSession(session);
this.currentSession.set(session);
await this.directMessages.recordCallStarted(
session.conversationId,
meParticipant,
Object.values(session.participants).map((participant) => participant.profile),
session.createdAt
);
await this.joinCall(session.callId, false);
this.sendCallEvent(peerParticipant.userId, 'ring', session);
await this.openCallView(session.callId);
@@ -257,8 +236,6 @@ export class DirectCallService {
}
declineIncomingCall(callId: string): void {
this.audio.stop(AppSound.Call);
const session = this.sessionById(callId);
if (!session || session.status === 'ended') {
@@ -276,6 +253,8 @@ export class DirectCallService {
status: 'ended' as const
};
this.audio.stop(AppSound.Call);
if (meId) {
this.broadcastCallEvent('leave', session);
}
@@ -406,29 +385,18 @@ export class DirectCallService {
}
private async handleIncomingCallEvent(payload: DirectCallEventPayload): Promise<void> {
const currentUserIds = this.currentUserIds();
const meId = this.currentUserId();
if (!meId) {
this.pendingIncomingCallEvents.push(payload);
if (!meId || payload.sender.userId === meId) {
return;
}
if (currentUserIds.has(payload.sender.userId)) {
return;
}
if (!this.callPayloadIncludesAnyParticipant(payload, currentUserIds)) {
if (!this.callPayloadIncludesParticipant(payload, meId)) {
return;
}
const participants = this.callParticipantsFromPayload(payload);
const existing = this.sessionById(payload.callId);
if (this.isStaleRingForEndedSession(payload, existing)) {
return;
}
const incomingSession = this.createSession({
callId: payload.callId,
conversationId: payload.conversationId,
@@ -456,22 +424,14 @@ export class DirectCallService {
if (payload.action === 'ring') {
await this.ensureCallConversation(session);
await this.directMessages.recordCallStarted(
session.conversationId,
payload.sender,
Object.values(session.participants).map((participant) => participant.profile),
session.createdAt
);
const latestSession = this.sessionById(session.callId);
if (latestSession && this.shouldPlayIncomingCallAlert(latestSession)) {
if (this.shouldAlertIncomingCall(session)) {
this.audio.playLoop(AppSound.Call);
} else {
this.audio.stop(AppSound.Call);
}
if (latestSession && this.shouldPlayIncomingCallAlert(latestSession)) {
if (this.shouldAlertIncomingCall(session)) {
await this.showIncomingNotification(payload.sender.displayName, payload.callId);
}
@@ -515,14 +475,6 @@ export class DirectCallService {
this.upsertSession(session);
this.currentSession.set(session);
await this.directMessages.recordCallStarted(
session.conversationId,
meParticipant,
Object.values(session.participants).map((participant) => participant.profile),
session.createdAt
);
await this.joinCall(session.callId, false);
this.broadcastCallEvent('ring', this.sessionById(session.callId) ?? session);
await this.router.navigate(['/call', session.callId]);
@@ -759,15 +711,9 @@ export class DirectCallService {
]);
}
private callPayloadIncludesAnyParticipant(payload: DirectCallEventPayload, participantIds: Set<string>): boolean {
return payload.participantIds.some((participantId) => participantIds.has(participantId))
|| (payload.participants ?? []).some((participant) => participantIds.has(participant.userId));
}
private isStaleRingForEndedSession(payload: DirectCallEventPayload, existing: DirectCallSession | null): boolean {
return payload.action === 'ring'
&& existing?.status === 'ended'
&& payload.createdAt <= existing.createdAt;
private callPayloadIncludesParticipant(payload: DirectCallEventPayload, participantId: string): boolean {
return payload.participantIds.includes(participantId)
|| (payload.participants ?? []).some((participant) => participant.userId === participantId);
}
private groupConversationTitle(session: DirectCallSession): string {
@@ -921,10 +867,6 @@ export class DirectCallService {
return session.status !== 'connected' && !this.isDoNotDisturb();
}
private shouldPlayIncomingCallAlert(session: DirectCallSession): boolean {
return this.shouldAlertIncomingCall(session) && this.incomingCall()?.callId === session.callId;
}
private isDoNotDisturb(): boolean {
return this.currentUser()?.status === 'busy';
}
@@ -981,25 +923,6 @@ export class DirectCallService {
return user ? this.userKey(user) : null;
}
private currentUserIds(): Set<string> {
const ids = new Set<string>();
const user = this.currentUser();
if (user?.id) {
ids.add(user.id);
}
if (user?.oderId) {
ids.add(user.oderId);
}
if (user?.peerId) {
ids.add(user.peerId);
}
return ids;
}
private requireCurrentUser(): User {
const user = this.currentUser();