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