fix: chat scroll fix
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user