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/ │ └── 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 │ └── auto-scroll.rules.ts resolveAutoScrollBehavior (instant on channel switch, smooth for live msgs) + isStuckToBottom predicate
├── feature/ ├── feature/
│ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays) │ ├── 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 | | `getLatestTimestamp(msgs)` | Max timestamp across a batch, used for sync ordering |
| `chunkArray(items, size)` | Splits arrays into fixed-size chunks for batched transfer | | `chunkArray(items, size)` | Splits arrays into fixed-size chunks for batched transfer |
| `findMissingIds(remote, local)` | Compares inventories and returns IDs to request | | `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 ## Typing indicator

View File

@@ -1,4 +1,4 @@
import { resolveAutoScrollBehavior } from './auto-scroll.rules'; import { isStuckToBottom, resolveAutoScrollBehavior } from './auto-scroll.rules';
describe('resolveAutoScrollBehavior', () => { describe('resolveAutoScrollBehavior', () => {
const base = { const base = {
@@ -48,3 +48,28 @@ describe('resolveAutoScrollBehavior', () => {
).toBe('smooth'); ).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'; 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,54 +30,59 @@
<p class="text-sm">Be the first to say something!</p> <p class="text-sm">Be the first to say something!</p>
</div> </div>
} @else { } @else {
@if (hasMoreMessages()) { <div
<div class="flex items-center justify-center py-3"> #messagesContent
@if (loadingMore()) { class="space-y-4"
<div class="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div> >
} @else { @if (hasMoreMessages()) {
<button <div class="flex items-center justify-center py-3">
type="button" @if (loadingMore()) {
(click)="loadMore()" <div class="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div>
class="rounded-md px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground" } @else {
> <button
Load older messages type="button"
</button> (click)="loadMore()"
} class="rounded-md px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
</div> >
} Load older messages
</button>
@for (message of messages(); track message.id; let index = $index) { }
@if (dateSeparatorLabels().get(index); as separatorLabel) {
<div
appThemeNode="chatDateSeparator"
class="flex items-center gap-3 py-1"
>
<div class="h-px flex-1 bg-border"></div>
<span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
{{ separatorLabel }}
</span>
<div class="h-px flex-1 bg-border"></div>
</div> </div>
} }
<app-chat-message-item @for (message of messages(); track message.id; let index = $index) {
[message]="message" @if (dateSeparatorLabels().get(index); as separatorLabel) {
[repliedMessage]="findRepliedMessage(message.replyToId)" <div
[currentUserId]="currentUserId()" appThemeNode="chatDateSeparator"
[isAdmin]="isAdmin()" class="flex items-center gap-3 py-1"
[userLookup]="userLookup()" >
(replyRequested)="handleReplyRequested($event)" <div class="h-px flex-1 bg-border"></div>
(deleteRequested)="handleDeleteRequested($event)" <span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
(editSaved)="handleEditSaved($event)" {{ separatorLabel }}
(reactionAdded)="handleReactionAdded($event)" </span>
(reactionToggled)="handleReactionToggled($event)" <div class="h-px flex-1 bg-border"></div>
(referenceRequested)="handleReferenceRequested($event)" </div>
(downloadRequested)="handleDownloadRequested($event)" }
(imageOpened)="handleImageOpened($event)"
(imageContextMenuRequested)="handleImageContextMenuRequested($event)" <app-chat-message-item
(embedRemoved)="handleEmbedRemoved($event)" [message]="message"
/> [repliedMessage]="findRepliedMessage(message.replyToId)"
} [currentUserId]="currentUserId()"
[isAdmin]="isAdmin()"
[userLookup]="userLookup()"
(replyRequested)="handleReplyRequested($event)"
(deleteRequested)="handleDeleteRequested($event)"
(editSaved)="handleEditSaved($event)"
(reactionAdded)="handleReactionAdded($event)"
(reactionToggled)="handleReactionToggled($event)"
(referenceRequested)="handleReferenceRequested($event)"
(downloadRequested)="handleDownloadRequested($event)"
(imageOpened)="handleImageOpened($event)"
(imageContextMenuRequested)="handleImageContextMenuRequested($event)"
(embedRemoved)="handleEmbedRemoved($event)"
/>
}
</div>
} }
@if (showNewMessagesBar()) { @if (showNewMessagesBar()) {

View File

@@ -16,8 +16,12 @@ 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 {
STICKY_BOTTOM_THRESHOLD,
isStuckToBottom,
resolveAutoScrollBehavior
} from '../../../../domain/rules/auto-scroll.rules';
import { Message, User } from '../../../../../../shared-kernel'; import { Message, User } from '../../../../../../shared-kernel';
import { import {
ChatMessageDeleteEvent, ChatMessageDeleteEvent,
@@ -57,6 +61,7 @@ declare global {
}) })
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>; @ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
@ViewChild('messagesContent') messagesContent?: ElementRef<HTMLDivElement>;
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly allUsers = this.store.selectSignal(selectAllUsers); private readonly allUsers = this.store.selectSignal(selectAllUsers);
@@ -172,15 +177,30 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return index; return index;
}); });
private bottomScrollObserver: MutationObserver | null = null; private contentResizeObserver: ResizeObserver | null = null;
private bottomScrollTimer: ReturnType<typeof setTimeout> | null = null; private observedContent: HTMLElement | null = null;
private boundOnImageLoad: (() => void) | null = null;
private localSendScrollPending = false; private localSendScrollPending = false;
private localSendScrollTimer: ReturnType<typeof setTimeout> | null = null; private localSendScrollTimer: ReturnType<typeof setTimeout> | null = null;
private isAutoScrolling = false; private isAutoScrolling = false;
private lastMessageCount = 0; private lastMessageCount = 0;
private initialScrollPending = true; private initialScrollPending = true;
private prismHighlightScheduled = false; 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 * Set when an older-page DB fetch is in flight. While true, the
* `onMessagesChanged` effect treats incoming message-count growth as a * `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 distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
const newMessages = currentCount > this.lastMessageCount; const newMessages = currentCount > this.lastMessageCount;
const forceLocalSendScroll = this.shouldForceLocalSendScroll(); const forceLocalSendScroll = this.shouldForceLocalSendScroll();
const behavior = resolveAutoScrollBehavior({
newMessages,
forceLocalSend: forceLocalSendScroll,
distanceFromBottom,
withinInitialGrace: this.withinInitialGrace(),
stickyThreshold: STICKY_BOTTOM_THRESHOLD
});
if (newMessages) { if (behavior === 'none') {
const behavior = resolveAutoScrollBehavior({ if (newMessages) {
newMessages, queueMicrotask(() => this.showNewMessagesBar.set(true));
forceLocalSend: forceLocalSendScroll, }
distanceFromBottom, } else {
withinInitialGrace: this.isWithinInitialScrollGrace() this.stickToBottom = true;
}); this.showNewMessagesBar.set(false);
if (behavior === 'instant') { if (behavior === 'instant') {
if (forceLocalSendScroll) { if (forceLocalSendScroll) {
@@ -278,12 +305,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
} }
this.scheduleScrollToBottomAfterRender(true); this.scheduleScrollToBottomAfterRender(true);
this.showNewMessagesBar.set(false);
} else if (behavior === 'smooth') {
this.scheduleScrollToBottomSmooth();
this.showNewMessagesBar.set(false);
} else { } else {
queueMicrotask(() => this.showNewMessagesBar.set(true)); this.scheduleScrollToBottomSmooth();
} }
} }
@@ -299,6 +322,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
if (this.initialScrollPending) { if (this.initialScrollPending) {
if (this.messages().length > 0) { if (this.messages().length > 0) {
this.initialScrollPending = false; this.initialScrollPending = false;
this.stickToBottom = true;
this.settleUntil = Date.now() + ChatMessageListComponent.INITIAL_SETTLE_MS;
this.isAutoScrolling = true; this.isAutoScrolling = true;
element.scrollTop = element.scrollHeight; element.scrollTop = element.scrollHeight;
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -306,27 +331,24 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
}); });
this.clearLocalSendScrollPending(); this.clearLocalSendScrollPending();
this.startBottomScrollWatch(); this.startContentResizeWatch();
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length; this.lastMessageCount = this.messages().length;
this.scheduleCodeHighlight(); this.scheduleCodeHighlight();
} else { } else if (!this.loading()) {
// No messages rendered yet for this conversation. Stay armed so the this.initialScrollPending = false;
// 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;
} }
return; return;
} }
this.startContentResizeWatch();
this.scheduleCodeHighlight(); this.scheduleCodeHighlight();
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.stopBottomScrollWatch(); this.stopContentResizeWatch();
this.clearLocalSendScrollPending(); this.clearLocalSendScrollPending();
} }
@@ -344,16 +366,13 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return; return;
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight; 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); this.showNewMessagesBar.set(false);
} }
if (this.bottomScrollObserver) {
this.stopBottomScrollWatch();
}
if (element.scrollTop < 150 && !this.loadingMore()) { if (element.scrollTop < 150 && !this.loadingMore()) {
const canFetchOlderFromDb = const canFetchOlderFromDb =
!this.hasMoreMessages() !this.hasMoreMessages()
@@ -420,11 +439,13 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
} }
readLatest(): void { readLatest(): void {
this.stickToBottom = true;
this.scrollToBottomSmooth(); this.scrollToBottomSmooth();
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
} }
scrollToBottomAfterLocalSend(): void { scrollToBottomAfterLocalSend(): void {
this.stickToBottom = true;
this.localSendScrollPending = true; this.localSendScrollPending = true;
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
this.scheduleScrollToBottomAfterRender(true); this.scheduleScrollToBottomAfterRender(true);
@@ -492,7 +513,9 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
private resetScrollingState(): void { private resetScrollingState(): void {
this.initialScrollPending = true; this.initialScrollPending = true;
this.stopBottomScrollWatch(); this.stickToBottom = true;
this.settleUntil = Date.now() + ChatMessageListComponent.INITIAL_SETTLE_MS;
this.stopContentResizeWatch();
this.clearLocalSendScrollPending(); this.clearLocalSendScrollPending();
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
this.lastMessageCount = 0; this.lastMessageCount = 0;
@@ -501,47 +524,53 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.loadingMore.set(false); this.loadingMore.set(false);
} }
private startBottomScrollWatch(): void { private withinInitialGrace(): boolean {
this.stopBottomScrollWatch(); return Date.now() < this.settleUntil;
}
const element = this.messagesContainer?.nativeElement; /**
* Keeps the list pinned to the latest message while the user is at the
if (!element) * 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; 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()); requestAnimationFrame(() => this.scrollToBottomInstant());
}); });
this.bottomScrollObserver.observe(element, { this.contentResizeObserver.observe(content);
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);
} }
private stopBottomScrollWatch(): void { private stopContentResizeWatch(): void {
if (this.bottomScrollObserver) { if (this.contentResizeObserver) {
this.bottomScrollObserver.disconnect(); this.contentResizeObserver.disconnect();
this.bottomScrollObserver = null; this.contentResizeObserver = null;
} }
if (this.boundOnImageLoad && this.messagesContainer) { this.observedContent = null;
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
this.boundOnImageLoad = null;
}
if (this.bottomScrollTimer) {
clearTimeout(this.bottomScrollTimer);
this.bottomScrollTimer = null;
}
} }
private armLocalSendScrollTimeout(): void { private armLocalSendScrollTimeout(): void {
@@ -573,18 +602,6 @@ 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);
} }
@@ -630,7 +647,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.scrollToBottomInstant(); this.scrollToBottomInstant();
if (watchForLayoutChanges) { if (watchForLayoutChanges) {
this.startBottomScrollWatch(); this.startContentResizeWatch();
} }
}); });
}); });

View File

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