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();