diff --git a/toju-app/src/app/domains/chat/README.md b/toju-app/src/app/domains/chat/README.md index 125411a..a4dcee2 100644 --- a/toju-app/src/app/domains/chat/README.md +++ b/toju-app/src/app/domains/chat/README.md @@ -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 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 index 3f645fb..5bcfbe9 100644 --- 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 @@ -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); + }); +}); 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 index feddc19..11d12e7 100644 --- 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 @@ -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; +} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html index 7774886..414d31b 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html @@ -30,54 +30,59 @@

Be the first to say something!

} @else { - @if (hasMoreMessages()) { -
- @if (loadingMore()) { -
- } @else { - - } -
- } - - @for (message of messages(); track message.id; let index = $index) { - @if (dateSeparatorLabels().get(index); as separatorLabel) { -
-
- - {{ separatorLabel }} - -
+
+ @if (hasMoreMessages()) { +
+ @if (loadingMore()) { +
+ } @else { + + }
} - - } + @for (message of messages(); track message.id; let index = $index) { + @if (dateSeparatorLabels().get(index); as separatorLabel) { +
+
+ + {{ separatorLabel }} + +
+
+ } + + + } +
} @if (showNewMessagesBar()) { 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 cc4687b..148c7f2 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,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; + @ViewChild('messagesContent') messagesContent?: ElementRef; 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 | null = null; - private boundOnImageLoad: (() => void) | null = null; + private contentResizeObserver: ResizeObserver | null = null; + private observedContent: HTMLElement | null = null; private localSendScrollPending = false; private localSendScrollTimer: ReturnType | 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,14 +283,21 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight; const newMessages = currentCount > this.lastMessageCount; const forceLocalSendScroll = this.shouldForceLocalSendScroll(); + const behavior = resolveAutoScrollBehavior({ + newMessages, + forceLocalSend: forceLocalSendScroll, + distanceFromBottom, + withinInitialGrace: this.withinInitialGrace(), + stickyThreshold: STICKY_BOTTOM_THRESHOLD + }); - if (newMessages) { - const behavior = resolveAutoScrollBehavior({ - newMessages, - forceLocalSend: forceLocalSendScroll, - distanceFromBottom, - withinInitialGrace: this.isWithinInitialScrollGrace() - }); + if (behavior === 'none') { + if (newMessages) { + queueMicrotask(() => this.showNewMessagesBar.set(true)); + } + } else { + this.stickToBottom = true; + this.showNewMessagesBar.set(false); if (behavior === 'instant') { if (forceLocalSendScroll) { @@ -278,12 +305,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { } 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; - this.bottomScrollObserver = new MutationObserver(() => { + 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; + 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(); } }); }); diff --git a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts index 6835607..16fbde8 100644 --- a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts +++ b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts @@ -44,7 +44,6 @@ export class DirectCallService { private readonly users = this.store.selectSignal(selectAllUsers); private readonly sessionsSignal = signal([]); private readonly mobileOverlayCallId = signal(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 { - 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): 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 { - const ids = new Set(); - 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();