fix(app): backport rendering improvements (#1580)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-13 19:52:00 +02:00
1 parent 0c291a01da
commit 42cdc1f877
513 files changed
+98743 -70397

No files matched your search

@@ -3,23 +3,25 @@
import {assign, getInitialSnapshot, type SnapshotFrom, setup, transition} from 'xstate';
export const LOCAL_TYPING_REMOTE_SEND_DELAY_MS = 1500;
export const LOCAL_TYPING_REMOTE_REFRESH_MS = 10000 * 0.8;
export const LOCAL_TYPING_REMOTE_REFRESH_MS = 8000;
export const LOCAL_TYPING_IDLE_RESET_MS = 10000;
export interface LocalTypingMachineInput {
channelId?: string | null;
localTyping?: boolean;
remoteCooldownChannelId?: string | null;
remoteCooldownUntil?: number | null;
remotePending?: boolean;
remotePendingVersion?: number;
}
export interface LocalTypingMachineContext {
channelId: string | null;
localTyping: boolean;
lastChangeAt: number;
remoteCooldownChannelId: string | null;
remoteCooldownUntil: number | null;
remotePending: boolean;
lastRemoteSentAt: number | null;
remoteSendAt: number | null;
remotePendingVersion: number;
}
@@ -38,6 +40,11 @@ export type LocalTypingMachineEvent =
channelId: string;
now: number;
pendingVersion: number;
}
| {
type: 'localTyping.idleElapsed';
channelId: string;
now: number;
};
export interface LocalTypingModel {
@@ -47,21 +54,49 @@ export interface LocalTypingModel {
remotePendingVersion: number;
remoteCooldownChannelId: string | null;
remoteCooldownUntil: number | null;
remoteSendAt: number | null;
remoteSendDelayMs: number;
localIdleAt: number | null;
}
export type LocalTypingSnapshot = SnapshotFrom<typeof localTypingStateMachine>;
function canScheduleRemoteSend(context: LocalTypingMachineContext, channelId: string, now: number): boolean {
return (
context.remoteCooldownChannelId !== channelId ||
context.remoteCooldownUntil == null ||
context.remoteCooldownUntil <= now
);
function initialLocalTypingContext(input: LocalTypingMachineInput = {}): LocalTypingMachineContext {
return {
channelId: input.channelId == null ? null : input.channelId,
localTyping: input.localTyping == null ? false : input.localTyping,
lastChangeAt: 0,
remoteCooldownChannelId: input.remoteCooldownChannelId == null ? null : input.remoteCooldownChannelId,
remoteCooldownUntil: input.remoteCooldownUntil == null ? null : input.remoteCooldownUntil,
lastRemoteSentAt: null,
remoteSendAt: null,
remotePendingVersion: input.remotePendingVersion == null ? 0 : input.remotePendingVersion,
};
}
function matchesActiveChannel(context: LocalTypingMachineContext, channelId: string): boolean {
return context.localTyping && context.channelId === channelId;
function resetLocalTypingContext(context: LocalTypingMachineContext): LocalTypingMachineContext {
return {
channelId: null,
localTyping: false,
lastChangeAt: 0,
remoteCooldownChannelId: context.remoteCooldownChannelId,
remoteCooldownUntil: context.remoteCooldownUntil,
lastRemoteSentAt: null,
remoteSendAt: null,
remotePendingVersion: context.remotePendingVersion,
};
}
function isRemoteCooldownActive(context: LocalTypingMachineContext, channelId: string, now: number): boolean {
const cooldownUntil = context.remoteCooldownUntil == null ? 0 : context.remoteCooldownUntil;
return context.remoteCooldownChannelId === channelId && cooldownUntil > now;
}
function desiredLocalIdleAt(context: LocalTypingMachineContext): number | null {
if (!context.localTyping || context.channelId == null) {
return null;
}
return context.lastChangeAt + LOCAL_TYPING_IDLE_RESET_MS;
}
export const localTypingStateMachine = setup({
@@ -75,50 +110,73 @@ export const localTypingStateMachine = setup({
if (event.type !== 'localTyping.started') {
return {};
}
const shouldScheduleRemote = canScheduleRemoteSend(context, event.channelId, event.now);
if (!context.localTyping || context.channelId !== event.channelId) {
const shouldScheduleRemote = !isRemoteCooldownActive(context, event.channelId, event.now);
return {
channelId: event.channelId,
localTyping: true,
lastChangeAt: event.now,
lastRemoteSentAt: null,
remoteSendAt: shouldScheduleRemote ? event.now + LOCAL_TYPING_REMOTE_SEND_DELAY_MS : null,
remotePendingVersion: shouldScheduleRemote ? context.remotePendingVersion + 1 : context.remotePendingVersion,
};
}
if (context.remoteSendAt != null) {
return {
lastChangeAt: event.now,
};
}
if (context.lastRemoteSentAt != null && event.now > context.lastRemoteSentAt) {
return {
lastChangeAt: event.now,
remoteSendAt: context.lastRemoteSentAt + LOCAL_TYPING_REMOTE_REFRESH_MS,
remotePendingVersion: context.remotePendingVersion + 1,
};
}
const shouldScheduleRemote = !isRemoteCooldownActive(context, event.channelId, event.now);
return {
channelId: event.channelId,
localTyping: true,
remotePending: shouldScheduleRemote,
lastChangeAt: event.now,
remoteSendAt: shouldScheduleRemote ? event.now + LOCAL_TYPING_REMOTE_SEND_DELAY_MS : null,
remotePendingVersion: shouldScheduleRemote ? context.remotePendingVersion + 1 : context.remotePendingVersion,
};
}),
stopLocalTyping: assign(({context, event}) => {
if (event.type !== 'localTyping.stopped' || !matchesActiveChannel(context, event.channelId)) {
if (event.type !== 'localTyping.stopped' || !context.localTyping || context.channelId !== event.channelId) {
return {};
}
return {
channelId: null,
localTyping: false,
remotePending: false,
};
return resetLocalTypingContext(context);
}),
markRemoteSent: assign(({context, event}) => {
if (
event.type !== 'localTyping.remoteSent' ||
!context.localTyping ||
context.channelId !== event.channelId ||
context.remotePendingVersion !== event.pendingVersion ||
!context.remotePending
context.remotePendingVersion !== event.pendingVersion
) {
return {};
}
return {
remotePending: false,
remoteCooldownChannelId: event.channelId,
remoteCooldownUntil: event.now + LOCAL_TYPING_REMOTE_REFRESH_MS,
lastRemoteSentAt: event.now,
remoteSendAt: null,
};
}),
markIdleElapsed: assign(({context, event}) => {
if (
event.type !== 'localTyping.idleElapsed' ||
!context.localTyping ||
context.channelId !== event.channelId ||
event.now - context.lastChangeAt < LOCAL_TYPING_IDLE_RESET_MS
) {
return {};
}
return resetLocalTypingContext(context);
}),
},
}).createMachine({
id: 'localTyping',
context: ({input}) => ({
channelId: input.channelId ?? null,
localTyping: input.localTyping ?? false,
remoteCooldownChannelId: input.remoteCooldownChannelId ?? null,
remoteCooldownUntil: input.remoteCooldownUntil ?? null,
remotePending: input.remotePending ?? false,
remotePendingVersion: input.remotePendingVersion ?? 0,
}),
context: ({input}) => initialLocalTypingContext(input),
initial: 'ready',
states: {
ready: {
@@ -126,6 +184,7 @@ export const localTypingStateMachine = setup({
'localTyping.started': {actions: 'startLocalTyping'},
'localTyping.stopped': {actions: 'stopLocalTyping'},
'localTyping.remoteSent': {actions: 'markRemoteSent'},
'localTyping.idleElapsed': {actions: 'markIdleElapsed'},
},
},
},
@@ -146,10 +205,12 @@ export function selectLocalTypingModel(snapshot: LocalTypingSnapshot): LocalTypi
return {
channelId: snapshot.context.channelId,
localTyping: snapshot.context.localTyping,
remotePending: snapshot.context.remotePending,
remotePending: snapshot.context.remoteSendAt != null,
remotePendingVersion: snapshot.context.remotePendingVersion,
remoteCooldownChannelId: snapshot.context.remoteCooldownChannelId,
remoteCooldownUntil: snapshot.context.remoteCooldownUntil,
remoteSendAt: snapshot.context.remoteSendAt,
remoteSendDelayMs: LOCAL_TYPING_REMOTE_SEND_DELAY_MS,
localIdleAt: desiredLocalIdleAt(snapshot.context),
};
}
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {action, makeAutoObservable} from 'mobx';
import {action, makeAutoObservable, observable} from 'mobx';
type TypingEntry = Readonly<{
expiresAt: number;
@@ -11,8 +11,13 @@ type TypingEntriesByChannel = Record<string, Record<string, TypingEntry>>;
type TypingUsersCacheEntry = Readonly<{
users: ReadonlyArray<string>;
expiresAt: number | null;
version: number;
}>;
export interface LocalTypingResetOwner {
reset(): void;
}
export const TYPING_INDICATOR_TIMEOUT_MS = 10000;
const EMPTY_TYPING_USERS: ReadonlyArray<string> = Object.freeze([]);
@@ -20,14 +25,21 @@ class TypingIndicator {
remoteTypingUsersByChannel: TypingEntriesByChannel = {};
localTypingUsersByChannel: TypingEntriesByChannel = {};
private typingUsersCacheByChannel: Record<string, TypingUsersCacheEntry | undefined> = {};
private typingUsersVersionByChannel = new Map<string, number>();
private expiryTimerId: NodeJS.Timeout | null = null;
private localTypingResetOwner: LocalTypingResetOwner | null = null;
constructor() {
makeAutoObservable<this, 'typingUsersCacheByChannel' | 'expiryTimerId'>(
makeAutoObservable<
this,
'typingUsersCacheByChannel' | 'typingUsersVersionByChannel' | 'expiryTimerId' | 'localTypingResetOwner'
>(
this,
{
expiryTimerId: false,
localTypingResetOwner: false,
typingUsersCacheByChannel: false,
typingUsersVersionByChannel: observable.shallow,
},
{autoBind: true},
);
@@ -35,8 +47,10 @@ class TypingIndicator {
getTypingUsers(channelId: string): ReadonlyArray<string> {
const now = Date.now();
const storedVersion = this.typingUsersVersionByChannel.get(channelId);
const version = storedVersion == null ? 0 : storedVersion;
const cached = this.typingUsersCacheByChannel[channelId];
if (cached && (cached.expiresAt === null || cached.expiresAt > now)) {
if (cached && cached.version === version && (cached.expiresAt === null || cached.expiresAt > now)) {
return cached.users;
}
const {expiresAt, users} = this.collectTypingUsers(channelId, now);
@@ -44,6 +58,7 @@ class TypingIndicator {
this.typingUsersCacheByChannel[channelId] = {
expiresAt,
users: typedUsers,
version,
};
return typedUsers;
}
@@ -105,7 +120,6 @@ class TypingIndicator {
private upsertTypingEntry(entriesByChannel: TypingEntriesByChannel, channelId: string, userId: string): void {
const now = Date.now();
this.removeExpiredTypingEntries(now);
if (!entriesByChannel[channelId]) {
entriesByChannel[channelId] = {};
}
@@ -116,7 +130,7 @@ class TypingIndicator {
expiresAt: now + TYPING_INDICATOR_TIMEOUT_MS,
};
if (!wasVisible) {
this.invalidateTypingUsers(channelId);
this.invalidateTypingUsers(channelId, now);
}
this.scheduleNextExpiryTimer(now);
}
@@ -130,8 +144,9 @@ class TypingIndicator {
if (Object.keys(channelUsers).length === 0) {
delete entriesByChannel[channelId];
}
this.invalidateTypingUsers(channelId);
this.scheduleNextExpiryTimer(Date.now());
const now = Date.now();
this.invalidateTypingUsers(channelId, now);
this.scheduleNextExpiryTimer(now);
}
private removeExpiredTypingEntries(now: number): void {
@@ -153,7 +168,7 @@ class TypingIndicator {
delete entriesByChannel[channelId];
}
if (removed) {
this.invalidateTypingUsers(channelId);
this.invalidateTypingUsers(channelId, now);
}
}
}
@@ -198,8 +213,24 @@ class TypingIndicator {
this.expiryTimerId = null;
}
private invalidateTypingUsers(channelId: string): void {
private hasActiveTypingUsers(channelId: string, now: number): boolean {
const hasActiveEntry = (entries: Record<string, TypingEntry> | undefined) =>
entries !== undefined && Object.values(entries).some((entry) => entry.expiresAt > now);
return (
hasActiveEntry(this.remoteTypingUsersByChannel[channelId]) ||
hasActiveEntry(this.localTypingUsersByChannel[channelId])
);
}
private invalidateTypingUsers(channelId: string, now: number): void {
delete this.typingUsersCacheByChannel[channelId];
if (!this.hasActiveTypingUsers(channelId, now)) {
this.typingUsersVersionByChannel.delete(channelId);
return;
}
const currentVersion = this.typingUsersVersionByChannel.get(channelId);
const nextVersion = currentVersion == null ? 1 : currentVersion + 1;
this.typingUsersVersionByChannel.set(channelId, nextVersion);
}
@action
@@ -210,10 +241,22 @@ class TypingIndicator {
@action
reset(): void {
const resetOwner = this.localTypingResetOwner;
if (resetOwner != null) {
resetOwner.reset();
}
this.clearExpiryTimer();
this.remoteTypingUsersByChannel = {};
this.localTypingUsersByChannel = {};
this.typingUsersCacheByChannel = {};
this.typingUsersVersionByChannel.clear();
}
registerLocalTypingResetOwner(owner: LocalTypingResetOwner): void {
if (this.localTypingResetOwner != null) {
throw new Error('Local typing reset owner is already registered');
}
this.localTypingResetOwner = owner;
}
@action
@@ -10,10 +10,19 @@ import {
selectLocalTypingModel,
transitionLocalTypingSnapshot,
} from '@app/features/typing/state/LocalTypingStateMachine';
import TypingIndicator from '@app/features/typing/state/TypingIndicator';
class TypingManager {
private snapshot: LocalTypingSnapshot = createLocalTypingSnapshot();
private timeoutId: NodeJS.Timeout | null = null;
private remoteSendTimerId: NodeJS.Timeout | null = null;
private localIdleTimerId: NodeJS.Timeout | null = null;
private scheduledRemoteSendAt: number | null = null;
private scheduledRemoteChannelId: string | null = null;
private scheduledRemotePendingVersion: number | null = null;
private scheduledLocalIdleAt: number | null = null;
private scheduledLocalIdleChannelId: string | null = null;
private remoteSendTimerGeneration = 0;
private localIdleTimerGeneration = 0;
typing(channelId: string): void {
const currentUserId = Authentication.currentUserId;
@@ -32,9 +41,6 @@ class TypingManager {
clear(channelId: string): void {
const currentUserId = Authentication.currentUserId;
if (!currentUserId) {
return;
}
this.transition(
{
type: 'localTyping.stopped',
@@ -44,12 +50,21 @@ class TypingManager {
);
}
private transition(event: LocalTypingMachineEvent, currentUserId: string): void {
reset(): void {
this.clearRemoteSendTimer();
this.clearLocalIdleTimer();
this.snapshot = createLocalTypingSnapshot();
}
private transition(event: LocalTypingMachineEvent, currentUserId: string | null): void {
const previousModel = selectLocalTypingModel(this.snapshot);
this.snapshot = transitionLocalTypingSnapshot(this.snapshot, event);
const nextModel = selectLocalTypingModel(this.snapshot);
this.applyLocalTypingMutation(previousModel, nextModel, currentUserId, event);
this.applyRemoteSendSchedule(previousModel, nextModel);
if (currentUserId != null) {
this.applyLocalTypingMutation(previousModel, nextModel, currentUserId, event);
}
this.applyRemoteSendSchedule(nextModel);
this.applyLocalIdleSchedule(nextModel);
}
private applyLocalTypingMutation(
@@ -70,33 +85,68 @@ class TypingManager {
}
}
private applyRemoteSendSchedule(previousModel: LocalTypingModel, nextModel: LocalTypingModel): void {
if (!nextModel.remotePending || !nextModel.channelId) {
this.clearRemoteSendTimer();
return;
}
private applyRemoteSendSchedule(nextModel: LocalTypingModel): void {
if (
previousModel.remotePending &&
previousModel.channelId === nextModel.channelId &&
previousModel.remotePendingVersion === nextModel.remotePendingVersion
nextModel.remoteSendAt === this.scheduledRemoteSendAt &&
nextModel.channelId === this.scheduledRemoteChannelId &&
nextModel.remotePendingVersion === this.scheduledRemotePendingVersion
) {
return;
}
this.clearRemoteSendTimer();
const channelId = nextModel.channelId;
const pendingVersion = nextModel.remotePendingVersion;
this.timeoutId = setTimeout(() => {
this.sendTyping(channelId, pendingVersion);
}, nextModel.remoteSendDelayMs);
}
private sendTyping(channelId: string, pendingVersion: number): void {
TypingCommands.sendTyping(channelId);
this.timeoutId = null;
const currentUserId = Authentication.currentUserId;
if (!currentUserId) {
if (nextModel.remoteSendAt == null || !nextModel.channelId) {
return;
}
const channelId = nextModel.channelId;
const pendingVersion = nextModel.remotePendingVersion;
const remoteSendAt = nextModel.remoteSendAt;
const timerGeneration = this.remoteSendTimerGeneration;
this.scheduledRemoteSendAt = remoteSendAt;
this.scheduledRemoteChannelId = channelId;
this.scheduledRemotePendingVersion = pendingVersion;
this.remoteSendTimerId = setTimeout(
() => this.sendTyping(channelId, pendingVersion, timerGeneration),
Math.max(0, remoteSendAt - Date.now()),
);
}
private applyLocalIdleSchedule(nextModel: LocalTypingModel): void {
if (
nextModel.localIdleAt === this.scheduledLocalIdleAt &&
nextModel.channelId === this.scheduledLocalIdleChannelId
) {
return;
}
this.clearLocalIdleTimer();
if (nextModel.localIdleAt == null || !nextModel.channelId) {
return;
}
const channelId = nextModel.channelId;
const localIdleAt = nextModel.localIdleAt;
const timerGeneration = this.localIdleTimerGeneration;
this.scheduledLocalIdleAt = localIdleAt;
this.scheduledLocalIdleChannelId = channelId;
this.localIdleTimerId = setTimeout(
() => this.expireLocalTyping(channelId, timerGeneration),
Math.max(0, localIdleAt - Date.now()),
);
}
private sendTyping(channelId: string, pendingVersion: number, timerGeneration: number): void {
if (timerGeneration !== this.remoteSendTimerGeneration) {
return;
}
this.remoteSendTimerId = null;
this.scheduledRemoteSendAt = null;
this.scheduledRemoteChannelId = null;
this.scheduledRemotePendingVersion = null;
const currentUserId = Authentication.currentUserId;
if (!currentUserId) {
this.clear(channelId);
return;
}
TypingCommands.sendTyping(channelId);
TypingCommands.startLocalTyping(channelId, currentUserId);
this.transition(
{
type: 'localTyping.remoteSent',
@@ -108,13 +158,49 @@ class TypingManager {
);
}
private clearRemoteSendTimer(): void {
if (!this.timeoutId) {
private expireLocalTyping(channelId: string, timerGeneration: number): void {
if (timerGeneration !== this.localIdleTimerGeneration) {
return;
}
clearTimeout(this.timeoutId);
this.timeoutId = null;
this.localIdleTimerId = null;
this.scheduledLocalIdleAt = null;
this.scheduledLocalIdleChannelId = null;
const currentUserId = Authentication.currentUserId;
if (!currentUserId) {
this.clear(channelId);
return;
}
this.transition(
{
type: 'localTyping.idleElapsed',
channelId,
now: Date.now(),
},
currentUserId,
);
}
private clearRemoteSendTimer(): void {
this.remoteSendTimerGeneration += 1;
if (this.remoteSendTimerId != null) {
clearTimeout(this.remoteSendTimerId);
this.remoteSendTimerId = null;
}
this.scheduledRemoteSendAt = null;
this.scheduledRemoteChannelId = null;
this.scheduledRemotePendingVersion = null;
}
private clearLocalIdleTimer(): void {
this.localIdleTimerGeneration += 1;
if (this.localIdleTimerId != null) {
clearTimeout(this.localIdleTimerId);
this.localIdleTimerId = null;
}
this.scheduledLocalIdleAt = null;
this.scheduledLocalIdleChannelId = null;
}
}
export const TypingUtils = new TypingManager();
TypingIndicator.registerLocalTypingResetOwner(TypingUtils);