fix(app): gate reworked typing indicators behind an experiment (#2782)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-14 21:54:52 +02:00
1 parent 1861432a53
commit c212d315f4
91 files changed
+6137 -1756

No files matched your search

@@ -1,56 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
import {http} from '@app/features/platform/transport/RestTransport';
import {Logger} from '@app/features/platform/utils/AppLogger';
import TypingIndicator from '@app/features/typing/state/TypingIndicator';
const logger = new Logger('Typing');
type TypingMutation = 'start' | 'stop';
async function postTypingIndicator(channelId: string): Promise<void> {
await http.post(Endpoints.CHANNEL_TYPING(channelId));
}
function logTypingSendFailure(channelId: string, error: unknown): void {
logger.error(`Failed to send typing indicator to channel ${channelId}:`, error);
}
function updateTypingState(mutation: TypingMutation, channelId: string, userId: string): void {
if (mutation === 'start') {
TypingIndicator.startRemoteTyping(channelId, userId);
return;
}
TypingIndicator.stopTyping(channelId, userId);
}
export async function sendTyping(channelId: string): Promise<void> {
try {
logger.debug(`Sending typing indicator to channel ${channelId}`);
await postTypingIndicator(channelId);
logger.debug(`Successfully sent typing indicator to channel ${channelId}`);
} catch (error) {
logTypingSendFailure(channelId, error);
}
}
import * as LegacyTypingCommands from '@app/features/typing/legacy/LegacyTypingCommands';
import RollingTypingStore from '@app/features/typing/rolling/RollingTypingStore';
import TypingPolicy from '@app/features/typing/state/TypingPolicy';
export function startTyping(channelId: string, userId: string): void {
logger.debug(`Starting typing indicator for user ${userId} in channel ${channelId}`);
updateTypingState('start', channelId, userId);
}
export function stopTyping(channelId: string, userId: string): void {
logger.debug(`Stopping typing indicator for user ${userId} in channel ${channelId}`);
updateTypingState('stop', channelId, userId);
}
export function startLocalTyping(channelId: string, userId: string): void {
logger.debug(`Starting local typing indicator for user ${userId} in channel ${channelId}`);
TypingIndicator.startLocalTyping(channelId, userId);
}
export function stopLocalTyping(channelId: string, userId: string): void {
logger.debug(`Stopping local typing indicator for user ${userId} in channel ${channelId}`);
TypingIndicator.stopLocalTyping(channelId, userId);
if (TypingPolicy.active === 'rolling') {
RollingTypingStore.start(channelId, userId, 'gateway');
return;
}
LegacyTypingCommands.startTyping(channelId, userId);
}
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as ReplaceCommandUtils from '@app/features/messaging/utils/ReplaceCommandUtils';
import {TypingUtils as LegacyTypingUtils} from '@app/features/typing/legacy/LegacyTypingUtils';
interface LegacyComposerTypingInput {
channelId: string;
value: string;
isAutocompleteAttached: boolean;
enabled: boolean;
typingEnabled: boolean;
}
export function runLegacyComposerTyping({
channelId,
value,
isAutocompleteAttached,
enabled,
typingEnabled,
}: LegacyComposerTypingInput): void {
if (!enabled || !typingEnabled) {
LegacyTypingUtils.clear(channelId);
return;
}
const content = value.trim();
const isInReplaceMode = ReplaceCommandUtils.isReplaceCommand(content);
const isSlashCommand = content.startsWith('/');
if (content && !isAutocompleteAttached && !isInReplaceMode && !isSlashCommand) {
LegacyTypingUtils.typing(channelId);
} else {
LegacyTypingUtils.clear(channelId);
}
}
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
import {http} from '@app/features/platform/transport/RestTransport';
import {Logger} from '@app/features/platform/utils/AppLogger';
import TypingIndicator from '@app/features/typing/legacy/LegacyTypingIndicator';
const logger = new Logger('Typing');
type TypingMutation = 'start' | 'stop';
async function postTypingIndicator(channelId: string): Promise<void> {
await http.post(Endpoints.CHANNEL_TYPING(channelId));
}
function logTypingSendFailure(channelId: string, error: unknown): void {
logger.error(`Failed to send typing indicator to channel ${channelId}:`, error);
}
function updateTypingState(mutation: TypingMutation, channelId: string, userId: string): void {
if (mutation === 'start') {
TypingIndicator.startRemoteTyping(channelId, userId);
return;
}
TypingIndicator.stopTyping(channelId, userId);
}
export async function sendTyping(channelId: string): Promise<void> {
try {
logger.debug(`Sending typing indicator to channel ${channelId}`);
await postTypingIndicator(channelId);
logger.debug(`Successfully sent typing indicator to channel ${channelId}`);
} catch (error) {
logTypingSendFailure(channelId, error);
}
}
export function startTyping(channelId: string, userId: string): void {
logger.debug(`Starting typing indicator for user ${userId} in channel ${channelId}`);
updateTypingState('start', channelId, userId);
}
export function stopTyping(channelId: string, userId: string): void {
logger.debug(`Stopping typing indicator for user ${userId} in channel ${channelId}`);
updateTypingState('stop', channelId, userId);
}
export function startLocalTyping(channelId: string, userId: string): void {
logger.debug(`Starting local typing indicator for user ${userId} in channel ${channelId}`);
TypingIndicator.startLocalTyping(channelId, userId);
}
export function stopLocalTyping(channelId: string, userId: string): void {
logger.debug(`Stopping local typing indicator for user ${userId} in channel ${channelId}`);
TypingIndicator.stopLocalTyping(channelId, userId);
}
@@ -0,0 +1,299 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {action, makeAutoObservable, observable} from 'mobx';
type TypingEntry = Readonly<{
expiresAt: number;
}>;
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([]);
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' | 'typingUsersVersionByChannel' | 'expiryTimerId' | 'localTypingResetOwner'
>(
this,
{
expiryTimerId: false,
localTypingResetOwner: false,
typingUsersCacheByChannel: false,
typingUsersVersionByChannel: observable.shallow,
},
{autoBind: true},
);
}
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.version === version && (cached.expiresAt === null || cached.expiresAt > now)) {
return cached.users;
}
const {expiresAt, users} = this.collectTypingUsers(channelId, now);
const typedUsers = users.length === 0 ? EMPTY_TYPING_USERS : Object.freeze(users);
this.typingUsersCacheByChannel[channelId] = {
expiresAt,
users: typedUsers,
version,
};
return typedUsers;
}
getCount(channelId: string): number {
return this.getTypingUsers(channelId).length;
}
isTyping(channelId: string, userId: string): boolean {
return this.isRemoteTyping(channelId, userId) || this.isLocalTyping(channelId, userId);
}
isMemberListTyping(channelId: string, userId: string, currentUserId: string | null | undefined): boolean {
if (currentUserId && userId === currentUserId) {
return this.isLocalTyping(channelId, userId);
}
return this.isTyping(channelId, userId);
}
isLocalTyping(channelId: string, userId: string): boolean {
return this.hasActiveTypingEntry(this.localTypingUsersByChannel, channelId, userId, Date.now());
}
isRemoteTyping(channelId: string, userId: string): boolean {
return this.hasActiveTypingEntry(this.remoteTypingUsersByChannel, channelId, userId, Date.now());
}
private collectTypingUsers(channelId: string, now: number): {users: Array<string>; expiresAt: number | null} {
const users: Array<string> = [];
const seen = new Set<string>();
let expiresAt: number | null = null;
const collect = (entries: Record<string, TypingEntry> | undefined) => {
if (!entries) return;
for (const [userId, entry] of Object.entries(entries)) {
if (entry.expiresAt <= now) {
continue;
}
if (!seen.has(userId)) {
seen.add(userId);
users.push(userId);
}
expiresAt = expiresAt === null ? entry.expiresAt : Math.min(expiresAt, entry.expiresAt);
}
};
collect(this.remoteTypingUsersByChannel[channelId]);
collect(this.localTypingUsersByChannel[channelId]);
return {expiresAt, users};
}
private hasActiveTypingEntry(
entriesByChannel: TypingEntriesByChannel,
channelId: string,
userId: string,
now: number,
): boolean {
const entry = entriesByChannel[channelId]?.[userId];
return entry !== undefined && entry.expiresAt > now;
}
private upsertTypingEntry(entriesByChannel: TypingEntriesByChannel, channelId: string, userId: string): void {
const now = Date.now();
if (!entriesByChannel[channelId]) {
entriesByChannel[channelId] = {};
}
const channelUsers = entriesByChannel[channelId];
const existingEntry = channelUsers[userId];
const wasVisible = existingEntry !== undefined && existingEntry.expiresAt > now;
channelUsers[userId] = {
expiresAt: now + TYPING_INDICATOR_TIMEOUT_MS,
};
if (!wasVisible) {
this.invalidateTypingUsers(channelId, now);
}
this.scheduleNextExpiryTimer(now);
}
private removeTypingEntry(entriesByChannel: TypingEntriesByChannel, channelId: string, userId: string): void {
const channelUsers = entriesByChannel[channelId];
if (!channelUsers?.[userId]) {
return;
}
delete channelUsers[userId];
if (Object.keys(channelUsers).length === 0) {
delete entriesByChannel[channelId];
}
const now = Date.now();
this.invalidateTypingUsers(channelId, now);
this.scheduleNextExpiryTimer(now);
}
private removeExpiredTypingEntries(now: number): void {
this.removeExpiredTypingEntriesFrom(this.remoteTypingUsersByChannel, now);
this.removeExpiredTypingEntriesFrom(this.localTypingUsersByChannel, now);
}
private removeExpiredTypingEntriesFrom(entriesByChannel: TypingEntriesByChannel, now: number): void {
for (const [channelId, channelUsers] of Object.entries(entriesByChannel)) {
let removed = false;
for (const [userId, entry] of Object.entries(channelUsers)) {
if (entry.expiresAt > now) {
continue;
}
delete channelUsers[userId];
removed = true;
}
if (Object.keys(channelUsers).length === 0) {
delete entriesByChannel[channelId];
}
if (removed) {
this.invalidateTypingUsers(channelId, now);
}
}
}
private getNextExpiresAt(now: number): number | null {
let nextExpiresAt: number | null = null;
const collect = (entriesByChannel: TypingEntriesByChannel) => {
for (const channelUsers of Object.values(entriesByChannel)) {
for (const entry of Object.values(channelUsers)) {
if (entry.expiresAt <= now) {
continue;
}
nextExpiresAt = nextExpiresAt === null ? entry.expiresAt : Math.min(nextExpiresAt, entry.expiresAt);
}
}
};
collect(this.remoteTypingUsersByChannel);
collect(this.localTypingUsersByChannel);
return nextExpiresAt;
}
private scheduleNextExpiryTimer(now: number): void {
this.clearExpiryTimer();
const nextExpiresAt = this.getNextExpiresAt(now);
if (nextExpiresAt === null) {
return;
}
this.expiryTimerId = setTimeout(
() => {
this.expiryTimerId = null;
this.pruneExpiredTypingUsers();
},
Math.max(0, nextExpiresAt - now),
);
}
private clearExpiryTimer(): void {
if (!this.expiryTimerId) {
return;
}
clearTimeout(this.expiryTimerId);
this.expiryTimerId = null;
}
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
pruneExpiredTypingUsers(now = Date.now()): void {
this.removeExpiredTypingEntries(now);
this.scheduleNextExpiryTimer(now);
}
@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
startTyping(channelId: string, userId: string): void {
this.startRemoteTyping(channelId, userId);
}
@action
startRemoteTyping(channelId: string, userId: string): void {
this.upsertTypingEntry(this.remoteTypingUsersByChannel, channelId, userId);
}
@action
stopTyping(channelId: string, userId: string): void {
this.stopLocalTyping(channelId, userId);
this.stopRemoteTyping(channelId, userId);
}
@action
startLocalTyping(channelId: string, userId: string): void {
this.upsertTypingEntry(this.localTypingUsersByChannel, channelId, userId);
}
@action
stopLocalTyping(channelId: string, userId: string): void {
this.removeTypingEntry(this.localTypingUsersByChannel, channelId, userId);
}
@action
stopRemoteTyping(channelId: string, userId: string): void {
this.removeTypingEntry(this.remoteTypingUsersByChannel, channelId, userId);
}
@action
stopTypingOnMessageCreate(message: Message): void {
this.stopTyping(message.channel_id, message.author.id);
}
}
export default new TypingIndicator();
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {readdirSync, readFileSync} from 'node:fs';
import {join} from 'node:path';
import {fileURLToPath} from 'node:url';
import {describe, expect, it} from 'vitest';
const APP_SRC = fileURLToPath(new URL('../../../', import.meta.url));
const LEGACY_SPECIFIER_PAIRS: ReadonlyArray<readonly [relocated: string, head: string]> = [
['@app/features/typing/legacy/LegacyTypingCommands', '@app/features/typing/commands/TypingCommands'],
['@app/features/typing/legacy/LegacyTypingIndicator', '@app/features/typing/state/TypingIndicator'],
['@app/features/typing/legacy/LegacyTypingUtils', '@app/features/typing/utils/TypingUtils'],
];
const HEAD_DIGESTS = {
typingIndicator: 'b1e497ae31f83beff6d3c4cc42f2c103c599541c93aee9db9ba3abed9f316afb',
typingUtils: '6bec349ce19186301bbe976acb82e0e4c1544e10cc848d5490d21d1379cb21e8',
typingCommands: '12268312bfadf9d4721c72c05b660f964d3e038d424dcd3a5ee08f86ae58c5fc',
composerTypingEffectBody: '3a4d3d2625a3bc32cfade4309d18b751685833047fb1d5e7d73ca655257b065a',
localTypingStateMachine: 'cf56293c27bcb7bbb809efc28ff1070234a7a96e198c81f3c91f43169b1457a0',
localTypingStateMachineTest: '90caf6ee8ac780f4b63031056c7e4eab3aeff6dcb759d1f5ba4b76fe08e56558',
} as const;
const HEAD_COMPOSER_TYPING_EFFECT_BODY = `\t\tif (!enabled || !typingEnabled) {
\t\t\tTypingUtils.clear(channelId);
\t\t\treturn;
\t\t}
\t\tconst content = value.trim();
\t\tconst isInReplaceMode = ReplaceCommandUtils.isReplaceCommand(content);
\t\tconst isSlashCommand = content.startsWith('/');
\t\tif (content && !isAutocompleteAttached && !isInReplaceMode && !isSlashCommand) {
\t\t\tTypingUtils.typing(channelId);
\t\t} else {
\t\t\tTypingUtils.clear(channelId);
\t\t}`;
const HEAD_TYPING_USERS_STYLESHEET = [
'/* SPDX-License-Identifier: AGPL-3.0-or-later */',
'',
'.typing {',
'\tmargin-right: 0;',
'\tdisplay: flex;',
'\talign-items: center;',
'\tjustify-content: center;',
'\tcolor: var(--text-primary-muted);',
'}',
'',
'.username {',
'\tfont-weight: 600;',
'}',
'',
'.composerStatus {',
'\tdisplay: inline-flex;',
'\talign-items: center;',
'\theight: var(--typing-pill-height, 1.125rem);',
'\tmax-width: 100%;',
'\tmin-width: 0;',
'\tcolor: var(--text-primary-muted);',
'\tpointer-events: auto;',
'}',
'',
].join('\n');
const TYPING_FACADE_SPECIFIERS = new Set([
'@app/features/channel/components/TypingUsers',
'@app/features/typing/commands/TypingCommands',
'@app/features/typing/state/TypingIndicator',
'@app/features/typing/state/TypingPolicy',
'@app/features/typing/utils/TypingUtils',
]);
const IMPORT_DECLARATION = /^import [^;]+? from '([^']+)';\n/gm;
const COMPOSER_TYPING_SIGNATURE_END = '}: LegacyComposerTypingInput): void {\n';
function readSource(path: string): string {
return readFileSync(join(APP_SRC, path), 'utf8');
}
function sha256(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
function sortImportDeclarations(source: string): string {
const declarations = [...source.matchAll(IMPORT_DECLARATION)];
if (declarations.length === 0) {
return source;
}
const first = declarations[0]!;
const last = declarations[declarations.length - 1]!;
const blockStart = first.index;
const blockEnd = last.index + last[0].length;
expect(source.slice(blockStart, blockEnd)).toBe(declarations.map((declaration) => declaration[0]).join(''));
const sorted = [...declarations].sort((left, right) => {
if (left[1]! === right[1]!) return 0;
return left[1]! < right[1]! ? -1 : 1;
});
return source.slice(0, blockStart) + sorted.map((declaration) => declaration[0]).join('') + source.slice(blockEnd);
}
function restoreHeadSource(source: string): string {
let restored = source;
for (const [relocated, head] of LEGACY_SPECIFIER_PAIRS) {
restored = restored.replaceAll(`'${relocated}'`, `'${head}'`);
}
return sortImportDeclarations(restored);
}
function importSpecifiers(source: string): Array<string> {
return [...source.matchAll(/ from '([^']+)'/g)].map((match) => match[1]!);
}
function extractComposerTypingEffectBody(source: string): string {
const signatureEnd = source.indexOf(COMPOSER_TYPING_SIGNATURE_END);
expect(signatureEnd).toBeGreaterThan(-1);
const body = source.slice(signatureEnd + COMPOSER_TYPING_SIGNATURE_END.length, source.lastIndexOf('\n}\n'));
return body
.split('\n')
.map((line) => `\t${line}`)
.join('\n')
.replaceAll('LegacyTypingUtils.', 'TypingUtils.');
}
describe('legacy typing source parity', () => {
it('keeps the relocated typing store identical to the recorded head source', () => {
const source = readSource('features/typing/legacy/LegacyTypingIndicator.ts');
expect(sha256(source)).toBe(HEAD_DIGESTS.typingIndicator);
});
it('keeps the relocated typing utils identical to the recorded head source apart from its legacy import paths', () => {
const source = readSource('features/typing/legacy/LegacyTypingUtils.ts');
expect(importSpecifiers(source)).toEqual(
expect.arrayContaining([
'@app/features/typing/legacy/LegacyTypingCommands',
'@app/features/typing/legacy/LegacyTypingIndicator',
'@app/features/typing/state/LocalTypingStateMachine',
'@app/features/auth/state/Authentication',
]),
);
expect(sha256(restoreHeadSource(source))).toBe(HEAD_DIGESTS.typingUtils);
});
it('keeps the relocated typing commands identical to the recorded head source apart from its legacy import path', () => {
const source = readSource('features/typing/legacy/LegacyTypingCommands.ts');
expect(importSpecifiers(source)).toContain('@app/features/typing/legacy/LegacyTypingIndicator');
expect(sha256(restoreHeadSource(source))).toBe(HEAD_DIGESTS.typingCommands);
});
it('keeps the extracted composer typing effect identical to the recorded head effect body', () => {
const source = readSource('features/typing/legacy/LegacyComposerTyping.ts');
expect(sha256(HEAD_COMPOSER_TYPING_EFFECT_BODY)).toBe(HEAD_DIGESTS.composerTypingEffectBody);
expect(extractComposerTypingEffectBody(source)).toBe(HEAD_COMPOSER_TYPING_EFFECT_BODY);
});
it('keeps the local typing state machine and its test frozen in place', () => {
expect(sha256(readSource('features/typing/state/LocalTypingStateMachine.ts'))).toBe(
HEAD_DIGESTS.localTypingStateMachine,
);
expect(sha256(readSource('features/typing/state/LocalTypingStateMachine.test.ts'))).toBe(
HEAD_DIGESTS.localTypingStateMachineTest,
);
});
it('keeps the typing users stylesheet frozen in place', () => {
expect(readSource('features/channel/components/TypingUsers.module.css')).toBe(HEAD_TYPING_USERS_STYLESHEET);
});
it('never imports a typing facade from a legacy module', () => {
const legacyModules = [
...readdirSync(join(APP_SRC, 'features/typing/legacy'))
.filter((name) => !name.includes('.test.'))
.map((name) => `features/typing/legacy/${name}`),
'features/channel/components/LegacyTypingUsers.tsx',
];
expect(legacyModules).toHaveLength(5);
const facadeImports = legacyModules.flatMap((path) =>
importSpecifiers(readSource(path))
.filter((specifier) => TYPING_FACADE_SPECIFIERS.has(specifier))
.map((specifier) => `${path} imports ${specifier}`),
);
expect(facadeImports).toEqual([]);
});
});
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Authentication from '@app/features/auth/state/Authentication';
import * as TypingCommands from '@app/features/typing/legacy/LegacyTypingCommands';
import TypingIndicator from '@app/features/typing/legacy/LegacyTypingIndicator';
import {
createLocalTypingSnapshot,
type LocalTypingMachineEvent,
type LocalTypingModel,
type LocalTypingSnapshot,
selectLocalTypingModel,
transitionLocalTypingSnapshot,
} from '@app/features/typing/state/LocalTypingStateMachine';
class TypingManager {
private snapshot: LocalTypingSnapshot = createLocalTypingSnapshot();
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;
if (!currentUserId) {
return;
}
this.transition(
{
type: 'localTyping.started',
channelId,
now: Date.now(),
},
currentUserId,
);
}
clear(channelId: string): void {
const currentUserId = Authentication.currentUserId;
this.transition(
{
type: 'localTyping.stopped',
channelId,
},
currentUserId,
);
}
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);
if (currentUserId != null) {
this.applyLocalTypingMutation(previousModel, nextModel, currentUserId, event);
}
this.applyRemoteSendSchedule(nextModel);
this.applyLocalIdleSchedule(nextModel);
}
private applyLocalTypingMutation(
previousModel: LocalTypingModel,
nextModel: LocalTypingModel,
userId: string,
event: LocalTypingMachineEvent,
): void {
if (previousModel.localTyping && previousModel.channelId && previousModel.channelId !== nextModel.channelId) {
TypingCommands.stopLocalTyping(previousModel.channelId, userId);
}
if (previousModel.localTyping && !nextModel.localTyping && previousModel.channelId) {
TypingCommands.stopLocalTyping(previousModel.channelId, userId);
return;
}
if (event.type === 'localTyping.started' && nextModel.localTyping && nextModel.channelId) {
TypingCommands.startLocalTyping(nextModel.channelId, userId);
}
}
private applyRemoteSendSchedule(nextModel: LocalTypingModel): void {
if (
nextModel.remoteSendAt === this.scheduledRemoteSendAt &&
nextModel.channelId === this.scheduledRemoteChannelId &&
nextModel.remotePendingVersion === this.scheduledRemotePendingVersion
) {
return;
}
this.clearRemoteSendTimer();
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',
channelId,
now: Date.now(),
pendingVersion,
},
currentUserId,
);
}
private expireLocalTyping(channelId: string, timerGeneration: number): void {
if (timerGeneration !== this.localIdleTimerGeneration) {
return;
}
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);
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {decideComposerTyping} from '@app/features/typing/rolling/RollingComposerTypingGate';
import {describe, expect, it} from 'vitest';
type ComposerTypingChange = Parameters<typeof decideComposerTyping>[0];
function decide(change: Partial<ComposerTypingChange>): ReturnType<typeof decideComposerTyping> {
return decideComposerTyping({
previousValue: 'hell',
value: 'hello',
isRestoringDraft: false,
isEditingMessageInComposer: false,
enabled: true,
...change,
});
}
describe('decideComposerTyping', () => {
it('starts typing when the value changes to non-empty text', () => {
expect(decide({})).toBe('start');
expect(decide({previousValue: '', value: 'h'})).toBe('start');
expect(decide({previousValue: 'hello', value: 'hell'})).toBe('start');
});
it('stops typing for a whitespace-only draft', () => {
expect(decide({previousValue: '', value: ' '})).toBe('stop');
expect(decide({previousValue: 'hi', value: ' \n\t'})).toBe('stop');
});
it('stops typing when the value becomes exactly empty', () => {
expect(decide({previousValue: 'h', value: ''})).toBe('stop');
});
it('does nothing when the value is unchanged', () => {
expect(decide({previousValue: 'hello', value: 'hello'})).toBe('none');
expect(decide({previousValue: '', value: ''})).toBe('none');
});
it('does nothing on the first run after mount', () => {
expect(decide({previousValue: null, value: 'saved draft'})).toBe('none');
});
it('does nothing when the channel changed since the previous value', () => {
expect(decide({previousValue: null, value: ''})).toBe('none');
expect(decide({previousValue: null, value: 'draft in the next channel'})).toBe('none');
});
it('does nothing while a draft is being restored', () => {
expect(decide({isRestoringDraft: true})).toBe('none');
expect(decide({isRestoringDraft: true, value: ''})).toBe('none');
});
it('does nothing while editing a message in the composer', () => {
expect(decide({isEditingMessageInComposer: true})).toBe('none');
expect(decide({isEditingMessageInComposer: true, value: ''})).toBe('none');
});
it('does nothing while the composer is disabled', () => {
expect(decide({enabled: false})).toBe('none');
expect(decide({enabled: false, value: ''})).toBe('none');
});
it('suppresses a leading slash', () => {
expect(decide({previousValue: '', value: '/'})).toBe('none');
expect(decide({previousValue: '/gif', value: '/gif cats'})).toBe('none');
});
it('suppresses a bare plus', () => {
expect(decide({previousValue: '', value: '+'})).toBe('none');
expect(decide({previousValue: '+', value: '+1'})).toBe('start');
});
it('suppresses the plus colon emoji shorthand', () => {
expect(decide({previousValue: '+', value: '+:'})).toBe('none');
expect(decide({previousValue: '+:thumbsup', value: '+:thumbsup:'})).toBe('none');
expect(decide({previousValue: '+:thumbs', value: '+:thumbs-up'})).toBe('none');
});
it('suppresses the reaction shorthand with leading whitespace', () => {
expect(decide({previousValue: ' +:', value: ' +:smile'})).toBe('none');
});
it('suppresses a replace command', () => {
expect(decide({previousValue: 's/teh/th', value: 's/teh/the/'})).toBe('none');
expect(decide({previousValue: ' s/teh/th', value: ' s/teh/the '})).toBe('none');
});
it('starts typing while autocomplete is open', () => {
expect(decide({previousValue: '@al', value: '@ali'})).toBe('start');
expect(decide({previousValue: 'nice :smi', value: 'nice :smil'})).toBe('start');
expect(decide({previousValue: '#gen', value: '#gene'})).toBe('start');
});
});
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as ReplaceCommandUtils from '@app/features/messaging/utils/ReplaceCommandUtils';
import {isEmojiReactionShorthand} from '@app/features/messaging/utils/SlashCommandUtils';
const REACTION_SHORTHAND_REGEX = /^\+(?!\w+):?(?!:)(\w+)?:?$/;
interface ComposerTypingChange {
previousValue: string | null;
value: string;
isRestoringDraft: boolean;
isEditingMessageInComposer: boolean;
enabled: boolean;
}
export function decideComposerTyping({
previousValue,
value,
isRestoringDraft,
isEditingMessageInComposer,
enabled,
}: ComposerTypingChange): 'start' | 'stop' | 'none' {
if (!enabled || isEditingMessageInComposer || isRestoringDraft || previousValue === null || value === previousValue) {
return 'none';
}
const content = value.trim();
if (content === '') {
return 'stop';
}
if (
REACTION_SHORTHAND_REGEX.test(value) ||
isEmojiReactionShorthand(value) ||
value.startsWith('/') ||
ReplaceCommandUtils.isReplaceCommand(content)
) {
return 'none';
}
return 'start';
}
@@ -0,0 +1,372 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
import {runInAction} from 'mobx';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const doubles = await vi.hoisted(async () => {
const {observable} = await import('mobx');
return {
auth: observable({currentUserId: 'me' as string | null}),
post: vi.fn((_path: string) => Promise.resolve({ok: true})),
logError: vi.fn(),
};
});
vi.mock('@app/features/platform/utils/AppLogger', () => ({
Logger: class {
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = doubles.logError;
},
}));
vi.mock('@app/features/platform/transport/RestTransport', () => ({http: {post: doubles.post}}));
vi.mock('@app/features/auth/state/Authentication', () => ({default: doubles.auth}));
vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: {showMyselfTyping: false}}));
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
vi.mock('@app/features/user/state/Users', () => ({default: {getUser: () => undefined}}));
const {default: RollingTypingSender} = await import('@app/features/typing/rolling/RollingTypingSender');
const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore');
const START_TIME = 1_000_000;
const CHANNEL = 'channel';
const OTHER_CHANNEL = 'other-channel';
let postTimes: Array<number> = [];
function elapsed(): number {
return Date.now() - START_TIME;
}
function typeContinuously(durationMs: number): void {
const startedAt = Date.now();
while (Date.now() - startedAt < durationMs) {
RollingTypingSender.startTyping(CHANNEL);
vi.advanceTimersByTime(100);
}
}
function advanceTo(elapsedMs: number): void {
vi.advanceTimersByTime(elapsedMs - elapsed());
}
function startOtherTypists(count: number): void {
for (let index = 0; index < count; index += 1) {
RollingTypingStore.start(CHANNEL, `typist-${index}`, 'gateway');
}
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(START_TIME);
postTimes = [];
doubles.post.mockImplementation((_path: string) => {
postTimes.push(elapsed());
return Promise.resolve({ok: true});
});
});
afterEach(() => {
RollingTypingSender.reset();
RollingTypingStore.reset();
runInAction(() => {
doubles.auth.currentUserId = 'me';
});
vi.useRealTimers();
vi.clearAllMocks();
});
describe('RollingTypingSender', () => {
it('posts once 1500 ms after the first keystroke and not before', () => {
RollingTypingSender.startTyping(CHANNEL);
vi.advanceTimersByTime(1499);
expect(doubles.post).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING(CHANNEL));
vi.advanceTimersByTime(20000);
expect(doubles.post).toHaveBeenCalledTimes(1);
});
it('does not push a pending send back on further keystrokes', () => {
RollingTypingSender.startTyping(CHANNEL);
advanceTo(500);
RollingTypingSender.startTyping(CHANNEL);
advanceTo(1400);
RollingTypingSender.startTyping(CHANNEL);
advanceTo(1500);
expect(postTimes).toEqual([1500]);
});
it('writes the self entry at schedule time', () => {
RollingTypingSender.startTyping(CHANNEL);
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(true);
expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'me')).toBe(false);
expect(doubles.post).not.toHaveBeenCalled();
});
it('writes nothing on a throttled keystroke', () => {
RollingTypingSender.startTyping(CHANNEL);
RollingTypingStore.remove(CHANNEL, 'me');
const timersBefore = vi.getTimerCount();
advanceTo(700);
RollingTypingSender.startTyping(CHANNEL);
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
expect(vi.getTimerCount()).toBe(timersBefore);
});
it('sends every 8000 ms while typing continuously', () => {
typeContinuously(20000);
vi.advanceTimersByTime(20000);
expect(postTimes).toEqual([1500, 9500, 17500]);
});
it('posts the second time about 9500 ms after the first keystroke', () => {
typeContinuously(12000);
expect(postTimes[1]).toBe(9500);
expect(postTimes[1]! - postTimes[0]!).toBe(8000);
});
it('posts immediately when typing resumes 16000 ms after the last schedule', () => {
RollingTypingSender.startTyping(CHANNEL);
advanceTo(16000);
RollingTypingSender.startTyping(CHANNEL);
vi.advanceTimersByTime(0);
expect(postTimes).toEqual([1500, 16000]);
});
it('skips the post when six entries including self are typing', () => {
startOtherTypists(5);
RollingTypingSender.startTyping(CHANNEL);
vi.advanceTimersByTime(1500);
expect(RollingTypingStore.countTypists(CHANNEL)).toBe(6);
expect(doubles.post).not.toHaveBeenCalled();
});
it('posts with five entries including self typing', () => {
startOtherTypists(4);
RollingTypingSender.startTyping(CHANNEL);
vi.advanceTimersByTime(1500);
expect(RollingTypingStore.countTypists(CHANNEL)).toBe(5);
expect(doubles.post).toHaveBeenCalledTimes(1);
});
it('advances the throttle clock when the post is skipped', () => {
startOtherTypists(5);
RollingTypingSender.startTyping(CHANNEL);
advanceTo(1500);
expect(doubles.post).not.toHaveBeenCalled();
advanceTo(5000);
for (let index = 0; index < 5; index += 1) {
RollingTypingStore.remove(CHANNEL, `typist-${index}`);
}
RollingTypingSender.startTyping(CHANNEL);
advanceTo(7999);
expect(doubles.post).not.toHaveBeenCalled();
advanceTo(8000);
RollingTypingSender.startTyping(CHANNEL);
advanceTo(9500);
expect(postTimes).toEqual([9500]);
});
it('does not retry a failed post', async () => {
doubles.post.mockImplementationOnce(() => Promise.reject(new Error('Too many requests')));
RollingTypingSender.startTyping(CHANNEL);
await vi.advanceTimersByTimeAsync(1500);
expect(doubles.post).toHaveBeenCalledTimes(1);
expect(doubles.logError).toHaveBeenCalledExactlyOnceWith(
`Failed to send typing indicator to channel ${CHANNEL}:`,
expect.any(Error),
);
await vi.advanceTimersByTimeAsync(30000);
expect(doubles.post).toHaveBeenCalledTimes(1);
});
it('keeps the throttle after a failed post', async () => {
doubles.post.mockImplementationOnce(() => Promise.reject(new Error('Network down')));
RollingTypingSender.startTyping(CHANNEL);
await vi.advanceTimersByTimeAsync(1500);
advanceTo(3000);
RollingTypingSender.startTyping(CHANNEL);
advanceTo(7999);
expect(doubles.post).toHaveBeenCalledTimes(1);
RollingTypingSender.startTyping(CHANNEL);
advanceTo(8000);
RollingTypingSender.startTyping(CHANNEL);
advanceTo(9500);
expect(doubles.post).toHaveBeenCalledTimes(2);
});
it('drops the slot and cancels a pending send on a keystroke in another channel', () => {
RollingTypingSender.startTyping(CHANNEL);
advanceTo(500);
RollingTypingSender.startTyping(OTHER_CHANNEL);
vi.advanceTimersByTime(20000);
expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING(OTHER_CHANNEL));
expect(postTimes).toEqual([2000]);
});
it('leaves the self entry in the previous channel when typing moves on', () => {
RollingTypingSender.startTyping(CHANNEL);
advanceTo(500);
RollingTypingSender.startTyping(OTHER_CHANNEL);
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(true);
expect(RollingTypingStore.isTyping(OTHER_CHANNEL, 'me')).toBe(true);
advanceTo(10000);
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
expect(RollingTypingStore.isTyping(OTHER_CHANNEL, 'me')).toBe(true);
});
it('keeps a pending send alive across a plain channel switch', () => {
RollingTypingSender.startTyping(CHANNEL);
advanceTo(400);
RollingTypingSender.stopTyping(OTHER_CHANNEL);
advanceTo(1500);
expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING(CHANNEL));
});
it('cancels only a pending send and removes the self entry when the field is emptied', () => {
RollingTypingSender.startTyping(CHANNEL);
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
advanceTo(500);
RollingTypingSender.stopTyping(CHANNEL);
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true);
expect(vi.getTimerCount()).toBe(1);
vi.advanceTimersByTime(5000);
expect(doubles.post).not.toHaveBeenCalled();
RollingTypingSender.startTyping(CHANNEL);
vi.advanceTimersByTime(1500);
expect(postTimes).toEqual([7000]);
});
it('leaves the self entry and the throttle alone when emptied after the post fired', () => {
RollingTypingSender.startTyping(CHANNEL);
advanceTo(1500);
expect(doubles.post).toHaveBeenCalledTimes(1);
RollingTypingSender.stopTyping(CHANNEL);
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(true);
advanceTo(3000);
RollingTypingSender.startTyping(CHANNEL);
advanceTo(7999);
expect(doubles.post).toHaveBeenCalledTimes(1);
});
it('drops the slot and the self entry on own message sent', () => {
RollingTypingSender.startTyping(CHANNEL);
advanceTo(500);
RollingTypingSender.handleOwnMessageSent(CHANNEL);
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(20000);
expect(doubles.post).not.toHaveBeenCalled();
});
it('schedules a fresh 1500 ms send for a quick follow-up after own message sent', () => {
RollingTypingSender.startTyping(CHANNEL);
advanceTo(2000);
RollingTypingSender.handleOwnMessageSent(CHANNEL);
advanceTo(2100);
RollingTypingSender.startTyping(CHANNEL);
advanceTo(3599);
expect(postTimes).toEqual([1500]);
advanceTo(3600);
expect(postTimes).toEqual([1500, 3600]);
});
it('bails inside the timer when the signed in account changed', () => {
RollingTypingSender.startTyping(CHANNEL);
advanceTo(1000);
runInAction(() => {
doubles.auth.currentUserId = 'someone-else';
});
advanceTo(5000);
expect(doubles.post).not.toHaveBeenCalled();
});
it('keeps the slot when the store is reset on reconnect', () => {
RollingTypingSender.startTyping(CHANNEL);
advanceTo(500);
RollingTypingStore.reset();
advanceTo(1500);
expect(doubles.post).toHaveBeenCalledTimes(1);
advanceTo(3000);
RollingTypingSender.startTyping(CHANNEL);
advanceTo(7999);
expect(doubles.post).toHaveBeenCalledTimes(1);
});
it('sends no trailing post after a burst ends', () => {
typeContinuously(3000);
vi.advanceTimersByTime(60000);
expect(postTimes).toEqual([1500]);
});
it('releases the pending timer on reset', () => {
RollingTypingSender.startTyping(CHANNEL);
const timersBefore = vi.getTimerCount();
RollingTypingSender.reset();
expect(vi.getTimerCount()).toBe(timersBefore - 1);
vi.advanceTimersByTime(5000);
expect(doubles.post).not.toHaveBeenCalled();
});
it('does nothing without a current user', () => {
runInAction(() => {
doubles.auth.currentUserId = null;
});
RollingTypingSender.startTyping(CHANNEL);
expect(vi.getTimerCount()).toBe(0);
expect(RollingTypingStore.countTypists(CHANNEL)).toBe(0);
vi.advanceTimersByTime(5000);
expect(doubles.post).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,92 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
import Authentication from '@app/features/auth/state/Authentication';
import {http} from '@app/features/platform/transport/RestTransport';
import {Logger} from '@app/features/platform/utils/AppLogger';
import RollingTypingStore from '@app/features/typing/rolling/RollingTypingStore';
import {
planTypingSend,
TYPING_ROLLING_SKIP_ABOVE_TRACKED,
type TypingSendSlot,
} from '@app/features/typing/rolling/TypingSendThrottle';
const logger = new Logger('Typing');
class RollingTypingSender {
private slot: TypingSendSlot | null = null;
startTyping(channelId: string): void {
const userId = Authentication.currentUserId;
if (userId == null) {
return;
}
const now = Date.now();
const plan = planTypingSend(this.slot, channelId, userId, now);
if (plan.dropSlot) {
this.dropSlot();
}
if (plan.action === 'throttled') {
return;
}
const slot: TypingSendSlot = {channelId, userId, timeout: null, prevSend: now};
slot.timeout = setTimeout(() => this.fire(slot), plan.delayMs);
this.slot = slot;
RollingTypingStore.start(channelId, userId, 'local');
}
stopTyping(channelId: string): void {
const slot = this.slot;
if (slot === null || slot.channelId !== channelId || slot.timeout === null) {
return;
}
clearTimeout(slot.timeout);
this.slot = null;
RollingTypingStore.remove(channelId, slot.userId);
}
handleOwnMessageSent(channelId: string): void {
this.dropSlot();
const userId = Authentication.currentUserId;
if (userId != null) {
RollingTypingStore.remove(channelId, userId);
}
}
reset(): void {
this.dropSlot();
}
private dropSlot(): void {
const slot = this.slot;
if (slot === null) {
return;
}
if (slot.timeout !== null) {
clearTimeout(slot.timeout);
}
this.slot = null;
}
private fire(captured: TypingSendSlot): void {
const slot = this.slot;
if (slot !== captured || Authentication.currentUserId !== captured.userId || captured.timeout === null) {
return;
}
captured.timeout = null;
if (RollingTypingStore.countTypists(captured.channelId) > TYPING_ROLLING_SKIP_ABOVE_TRACKED) {
return;
}
void this.postTyping(captured.channelId);
}
private async postTyping(channelId: string): Promise<void> {
try {
await http.post(Endpoints.CHANNEL_TYPING(channelId));
} catch (error) {
logger.error(`Failed to send typing indicator to channel ${channelId}:`, error);
}
}
}
export default new RollingTypingSender();
@@ -0,0 +1,250 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {autorun, runInAction} from 'mobx';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const doubles = await vi.hoisted(async () => {
const {observable} = await import('mobx');
return {
auth: observable({currentUserId: 'me' as string | null}),
developerOptions: observable({showMyselfTyping: false}),
blockedUserIds: observable.set<string>(),
users: observable.map<string, {id: string}>(),
};
});
vi.mock('@app/features/auth/state/Authentication', () => ({default: doubles.auth}));
vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: doubles.developerOptions}));
vi.mock('@app/features/relationship/state/Relationships', () => ({
default: {isBlocked: (userId: string) => doubles.blockedUserIds.has(userId)},
}));
vi.mock('@app/features/user/state/Users', () => ({
default: {getUser: (userId: string) => doubles.users.get(userId)},
}));
const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore');
const CHANNEL = 'channel';
beforeEach(() => {
vi.useFakeTimers();
runInAction(() => {
doubles.auth.currentUserId = 'me';
doubles.developerOptions.showMyselfTyping = false;
doubles.blockedUserIds.clear();
doubles.users.clear();
for (const id of ['me', 'alice', 'bob', 'carol']) {
doubles.users.set(id, {id});
}
});
});
afterEach(() => {
RollingTypingStore.reset();
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('RollingTypingStore', () => {
it('arms a 10000 ms expiry for a new typist', () => {
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
expect(vi.getTimerCount()).toBe(1);
vi.advanceTimersByTime(9999);
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true);
vi.advanceTimersByTime(1);
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
it('restarts the window on a repeated typing start', () => {
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
vi.advanceTimersByTime(6000);
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
expect(vi.getTimerCount()).toBe(1);
vi.advanceTimersByTime(9999);
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true);
vi.advanceTimersByTime(1);
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(false);
});
it('keeps insertion order when a typist refreshes', () => {
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
RollingTypingStore.start(CHANNEL, 'bob', 'gateway');
RollingTypingStore.start(CHANNEL, 'carol', 'gateway');
vi.advanceTimersByTime(3000);
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toEqual(['alice', 'bob', 'carol']);
});
it('does not notify observers when an existing entry is refreshed', () => {
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
const runs = vi.fn();
const dispose = autorun(() => {
runs(
RollingTypingStore.getTypingUserIds(CHANNEL),
RollingTypingStore.countTypists(CHANNEL),
RollingTypingStore.isTyping(CHANNEL, 'alice'),
RollingTypingStore.isConfirmedTyping(CHANNEL, 'alice'),
);
});
expect(runs).toHaveBeenCalledTimes(1);
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
RollingTypingStore.start(CHANNEL, 'alice', 'local');
vi.advanceTimersByTime(9000);
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
expect(runs).toHaveBeenCalledTimes(1);
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true);
dispose();
});
it('marks an entry confirmed only from a gateway typing start', () => {
RollingTypingStore.start(CHANNEL, 'me', 'local');
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(true);
expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'me')).toBe(false);
RollingTypingStore.start(CHANNEL, 'me', 'gateway');
expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'me')).toBe(true);
});
it('never lowers a confirmed entry on a local write', () => {
RollingTypingStore.start(CHANNEL, 'me', 'gateway');
RollingTypingStore.start(CHANNEL, 'me', 'local');
expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'me')).toBe(true);
});
it('removes the author on message create for any author', () => {
RollingTypingStore.start(CHANNEL, 'me', 'local');
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
RollingTypingStore.remove(CHANNEL, 'alice');
RollingTypingStore.remove(CHANNEL, 'me');
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(false);
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
it('wipes every entry and clears every timer on reset', () => {
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
RollingTypingStore.start(CHANNEL, 'me', 'local');
RollingTypingStore.start('other-channel', 'bob', 'gateway');
expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toEqual(['alice']);
expect(vi.getTimerCount()).toBe(3);
RollingTypingStore.reset();
expect(vi.getTimerCount()).toBe(0);
expect(RollingTypingStore.countTypists(CHANNEL)).toBe(0);
expect(RollingTypingStore.countTypists('other-channel')).toBe(0);
expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toEqual([]);
});
it('does not let a timer from before a reset expire a fresh entry', () => {
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
vi.advanceTimersByTime(6000);
RollingTypingStore.reset();
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
vi.advanceTimersByTime(4000);
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true);
vi.advanceTimersByTime(5999);
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true);
vi.advanceTimersByTime(1);
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(false);
});
it('ignores an expiry callback whose handle has been replaced', () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
const staleExpiry = setTimeoutSpy.mock.calls[0]![0] as () => void;
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
staleExpiry();
expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true);
expect(vi.getTimerCount()).toBe(1);
});
it('counts raw entries including self and blocked users', () => {
runInAction(() => {
doubles.blockedUserIds.add('bob');
doubles.users.delete('carol');
});
RollingTypingStore.start(CHANNEL, 'me', 'local');
RollingTypingStore.start(CHANNEL, 'bob', 'gateway');
RollingTypingStore.start(CHANNEL, 'carol', 'gateway');
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
expect(RollingTypingStore.countTypists(CHANNEL)).toBe(4);
expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toEqual(['alice']);
});
it('keeps no cap on tracked typists', () => {
runInAction(() => {
for (let index = 0; index < 50; index += 1) {
doubles.users.set(`typist-${index}`, {id: `typist-${index}`});
}
});
for (let index = 0; index < 50; index += 1) {
RollingTypingStore.start(CHANNEL, `typist-${index}`, 'gateway');
}
expect(RollingTypingStore.countTypists(CHANNEL)).toBe(50);
expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toHaveLength(50);
});
it('drops the channel map when its last typist leaves', () => {
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
RollingTypingStore.start(CHANNEL, 'bob', 'gateway');
RollingTypingStore.remove(CHANNEL, 'alice');
expect(RollingTypingStore['entries'].has(CHANNEL)).toBe(true);
RollingTypingStore.remove(CHANNEL, 'bob');
expect(RollingTypingStore['entries'].has(CHANNEL)).toBe(false);
RollingTypingStore.start(CHANNEL, 'carol', 'gateway');
vi.advanceTimersByTime(10000);
expect(RollingTypingStore['entries'].has(CHANNEL)).toBe(false);
});
it('notifies a member row reader only for its own user', () => {
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
const aliceRow = vi.fn();
const bobRow = vi.fn();
const disposeAliceRow = autorun(() => aliceRow(RollingTypingStore.isTyping(CHANNEL, 'alice')));
const disposeBobRow = autorun(() => bobRow(RollingTypingStore.isTyping(CHANNEL, 'bob')));
RollingTypingStore.start(CHANNEL, 'carol', 'gateway');
RollingTypingStore.start(CHANNEL, 'me', 'local');
RollingTypingStore.start(CHANNEL, 'me', 'gateway');
RollingTypingStore.remove(CHANNEL, 'carol');
expect(aliceRow).toHaveBeenCalledTimes(1);
expect(bobRow).toHaveBeenCalledTimes(1);
RollingTypingStore.start(CHANNEL, 'bob', 'gateway');
expect(bobRow).toHaveBeenCalledTimes(2);
expect(bobRow).toHaveBeenLastCalledWith(true);
expect(aliceRow).toHaveBeenCalledTimes(1);
RollingTypingStore.remove(CHANNEL, 'bob');
expect(bobRow).toHaveBeenCalledTimes(3);
expect(bobRow).toHaveBeenLastCalledWith(false);
expect(aliceRow).toHaveBeenCalledTimes(1);
disposeAliceRow();
disposeBobRow();
});
});
@@ -0,0 +1,139 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Authentication from '@app/features/auth/state/Authentication';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import Relationships from '@app/features/relationship/state/Relationships';
import {TYPING_ROLLING_EXPIRY_MS} from '@app/features/typing/rolling/TypingSendThrottle';
import Users from '@app/features/user/state/Users';
import {action, computed, type IComputedValue, makeObservable, type ObservableMap, observable} from 'mobx';
type RollingTypingOrigin = 'local' | 'gateway';
type RollingTypingEntry = Readonly<{confirmed: boolean}>;
const LOCAL_ENTRY: RollingTypingEntry = Object.freeze({confirmed: false});
const CONFIRMED_ENTRY: RollingTypingEntry = Object.freeze({confirmed: true});
const EMPTY_TYPING_USER_IDS: ReadonlyArray<string> = Object.freeze([]);
function areTypingUserIdsEqual(left: ReadonlyArray<string>, right: ReadonlyArray<string>): boolean {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false;
}
return true;
}
function timerKey(channelId: string, userId: string): string {
return `${channelId}:${userId}`;
}
class RollingTypingStore {
private readonly entries = observable.map<string, ObservableMap<string, RollingTypingEntry>>(undefined, {
deep: false,
});
private readonly timers = new Map<string, NodeJS.Timeout>();
private readonly typingUserIdsByChannel = new Map<string, IComputedValue<ReadonlyArray<string>>>();
constructor() {
makeObservable<this, 'expire'>(this, {start: action, remove: action, reset: action, expire: action});
}
start(channelId: string, userId: string, origin: RollingTypingOrigin): void {
const key = timerKey(channelId, userId);
const previousTimer = this.timers.get(key);
if (previousTimer !== undefined) {
clearTimeout(previousTimer);
}
const timer: NodeJS.Timeout = setTimeout(() => this.expire(channelId, userId, timer), TYPING_ROLLING_EXPIRY_MS);
this.timers.set(key, timer);
const confirmed = origin === 'gateway';
const existingEntries = this.entries.get(channelId);
const existing = existingEntries?.get(userId);
if (existing !== undefined && (existing.confirmed || !confirmed)) {
return;
}
const channelEntries = existingEntries ?? observable.map<string, RollingTypingEntry>(undefined, {deep: false});
if (existingEntries === undefined) {
this.entries.set(channelId, channelEntries);
}
channelEntries.set(userId, confirmed ? CONFIRMED_ENTRY : LOCAL_ENTRY);
}
remove(channelId: string, userId: string): void {
const key = timerKey(channelId, userId);
const timer = this.timers.get(key);
if (timer !== undefined) {
clearTimeout(timer);
this.timers.delete(key);
}
this.deleteEntry(channelId, userId);
}
reset(): void {
for (const timer of this.timers.values()) {
clearTimeout(timer);
}
this.timers.clear();
this.entries.clear();
this.typingUserIdsByChannel.clear();
}
countTypists(channelId: string): number {
return this.entries.get(channelId)?.size ?? 0;
}
getTypingUserIds(channelId: string): ReadonlyArray<string> {
let typingUserIds = this.typingUserIdsByChannel.get(channelId);
if (typingUserIds === undefined) {
typingUserIds = computed(() => this.collectTypingUserIds(channelId), {equals: areTypingUserIdsEqual});
this.typingUserIdsByChannel.set(channelId, typingUserIds);
}
return typingUserIds.get();
}
isTyping(channelId: string, userId: string): boolean {
return this.entries.get(channelId)?.has(userId) ?? false;
}
isConfirmedTyping(channelId: string, userId: string): boolean {
return this.entries.get(channelId)?.get(userId)?.confirmed ?? false;
}
private expire(channelId: string, userId: string, timer: NodeJS.Timeout): void {
const key = timerKey(channelId, userId);
if (this.timers.get(key) !== timer) {
return;
}
this.timers.delete(key);
this.deleteEntry(channelId, userId);
}
private deleteEntry(channelId: string, userId: string): void {
const channelEntries = this.entries.get(channelId);
if (channelEntries === undefined || !channelEntries.delete(userId)) {
return;
}
if (channelEntries.size === 0) {
this.entries.delete(channelId);
}
}
private collectTypingUserIds(channelId: string): ReadonlyArray<string> {
const channelEntries = this.entries.get(channelId);
if (channelEntries === undefined) {
return EMPTY_TYPING_USER_IDS;
}
const currentUserId = Authentication.currentUserId;
const showSelf = DeveloperOptions.showMyselfTyping;
const typingUserIds: Array<string> = [];
for (const userId of channelEntries.keys()) {
if (!showSelf && userId === currentUserId) continue;
if (Relationships.isBlocked(userId)) continue;
if (Users.getUser(userId) === undefined) continue;
typingUserIds.push(userId);
}
return typingUserIds.length === 0 ? EMPTY_TYPING_USER_IDS : typingUserIds;
}
}
export default new RollingTypingStore();
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import {join} from 'node:path';
import {fileURLToPath} from 'node:url';
import type {Channel} from '@app/features/channel/models/Channel';
import type {User} from '@app/features/user/models/User';
import type {I18n} from '@lingui/core';
import {createElement, Fragment, type ReactNode} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {afterEach, describe, expect, it, vi} from 'vitest';
const doubles = vi.hoisted(() => ({
getNickname: vi.fn((user: {id: string}, guildId: string | null) => `${user.id} in ${guildId ?? 'no guild'}`),
}));
vi.mock('@lingui/core/macro', () => ({msg: (descriptor: {message: string}) => descriptor}));
vi.mock('@lingui/react/macro', async () => {
const {createElement: create, Fragment: ReactFragment} = await import('react');
return {Trans: ({children}: {children?: ReactNode}) => create(ReactFragment, null, children)};
});
vi.mock('@app/features/user/utils/NicknameUtils', () => ({getNickname: doubles.getNickname}));
vi.mock('@app/features/member/state/GuildMembers', () => ({
default: {getMember: () => ({getColorString: () => 'rgb(1, 2, 3)'})},
}));
const {getRollingTypingAnnouncement, getRollingTypingText} = await import(
'@app/features/typing/rolling/RollingTypingText'
);
const APP_SRC = fileURLToPath(new URL('../../../', import.meta.url));
const GUILD_CHANNEL = {id: 'channel', guildId: 'guild'} as unknown as Channel;
const DM_CHANNEL = {id: 'dm'} as unknown as Channel;
function i18nFor(locale: string): I18n {
return {
locale,
_: (descriptor: {message: string}, values: Record<string, string> = {}) =>
descriptor.message.replace(/\{(\w+)\}/g, (_placeholder, name: string) => values[name] ?? ''),
} as unknown as I18n;
}
const EN_US = i18nFor('en-US');
const DE = i18nFor('de');
function typists(count: number): Array<User> {
return Array.from({length: count}, (_, index) => ({id: `typist-${index + 1}`}) as unknown as User);
}
function markupOf(node: ReactNode): string {
return renderToStaticMarkup(createElement(Fragment, null, node));
}
function textOf(node: ReactNode): string {
return markupOf(node).replace(/<[^>]+>/g, '');
}
function transMessages(source: string): Array<string> {
return [...source.matchAll(/<Trans>([\s\S]*?)<\/Trans>/g)].map((match) => match[1]!.replace(/\s+/g, ' ').trim());
}
function catalogueIds(locale: string): Set<string> {
const catalogue = readFileSync(join(APP_SRC, `features/i18n/locales/${locale}/messages.po`), 'utf8');
return new Set([...catalogue.matchAll(/^msgid "(.*)"$/gm)].map((match) => match[1]!));
}
afterEach(() => {
doubles.getNickname.mockClear();
});
describe('getRollingTypingText', () => {
it('names a single typist', () => {
const node = getRollingTypingText(EN_US, typists(1), GUILD_CHANNEL, false);
expect(textOf(node)).toBe('typist-1 in guild is typing...');
expect(markupOf(node)).toContain('data-flx="channel.typing-users.get-typing-text.username"');
expect(markupOf(node)).toContain('color:rgb(1, 2, 3)');
});
it('names two typists', () => {
expect(textOf(getRollingTypingText(EN_US, typists(2), GUILD_CHANNEL, false))).toBe(
'typist-1 in guild and typist-2 in guild are typing...',
);
});
it('names three typists without an Oxford comma', () => {
expect(textOf(getRollingTypingText(EN_US, typists(3), GUILD_CHANNEL, false))).toBe(
'typist-1 in guild, typist-2 in guild and typist-3 in guild are typing...',
);
});
it('collapses four typists to several people', () => {
expect(getRollingTypingText(EN_US, typists(4), GUILD_CHANNEL, false)).toBe('Several people are typing...');
expect(getRollingTypingText(DE, typists(4), GUILD_CHANNEL, false)).toBe('Several people are typing...');
expect(doubles.getNickname).not.toHaveBeenCalled();
});
it('collapses twenty typists to several people', () => {
expect(getRollingTypingText(DE, typists(20), GUILD_CHANNEL, false)).toBe('Several people are typing...');
expect(getRollingTypingText(EN_US, typists(20), GUILD_CHANNEL, false)).toBe("Whoa, it's a typing apocalypse");
});
it('shows multiple people for one to three typists when overflowing', () => {
for (const count of [1, 2, 3]) {
expect(getRollingTypingText(EN_US, typists(count), GUILD_CHANNEL, true)).toBe('Multiple people are typing...');
}
});
it('never shows the overflow string for four or more typists', () => {
expect(getRollingTypingText(EN_US, typists(4), GUILD_CHANNEL, true)).toBe('Several people are typing...');
expect(getRollingTypingText(DE, typists(12), GUILD_CHANNEL, true)).toBe('Several people are typing...');
expect(getRollingTypingText(EN_US, typists(12), GUILD_CHANNEL, true)).toBe(
'A symphony of clacking keys is underway...',
);
});
});
describe('getRollingTypingAnnouncement', () => {
it('announces the full count-based string for three typists', () => {
expect(getRollingTypingAnnouncement(EN_US, typists(1), GUILD_CHANNEL)).toBe('typist-1 in guild is typing...');
expect(getRollingTypingAnnouncement(EN_US, typists(2), GUILD_CHANNEL)).toBe(
'typist-1 in guild and typist-2 in guild are typing...',
);
expect(getRollingTypingAnnouncement(EN_US, typists(3), GUILD_CHANNEL)).toBe(
'typist-1 in guild, typist-2 in guild and typist-3 in guild are typing...',
);
});
it('announces several people for four or more typists', () => {
expect(getRollingTypingAnnouncement(EN_US, typists(4), GUILD_CHANNEL)).toBe('Several people are typing...');
expect(getRollingTypingAnnouncement(DE, typists(4), GUILD_CHANNEL)).toBe('Several people are typing...');
expect(getRollingTypingAnnouncement(DE, typists(16), GUILD_CHANNEL)).toBe('Several people are typing...');
expect(getRollingTypingAnnouncement(EN_US, typists(16), GUILD_CHANNEL)).toBe(
"It's a full-blown typing fiesta in here",
);
});
it('announces nothing when nobody is typing', () => {
expect(getRollingTypingAnnouncement(EN_US, [], GUILD_CHANNEL)).toBe('');
expect(getRollingTypingText(EN_US, [], GUILD_CHANNEL, false)).toBeNull();
});
});
describe('rolling typing catalogue', () => {
it('reuses the head catalogue message for each named form', () => {
const rollingSource = readFileSync(join(APP_SRC, 'features/typing/rolling/RollingTypingText.tsx'), 'utf8');
const legacySource = readFileSync(join(APP_SRC, 'features/channel/components/LegacyTypingUsers.tsx'), 'utf8');
const namedForms = ['{a} is typing...', '{a} and {b} are typing...', '{a}, {b} and {c} are typing...'];
const declaredMessages = [...rollingSource.matchAll(/message: '([^']*)'/g)].map((match) => match[1]);
expect(transMessages(rollingSource)).toEqual(namedForms);
expect(transMessages(legacySource)).toEqual(namedForms);
expect(declaredMessages).toEqual([...namedForms, 'Multiple people are typing...']);
const sourceCatalogue = catalogueIds('en-US');
for (const message of [...namedForms, 'Several people are typing...']) {
expect(sourceCatalogue.has(message), message).toBe(true);
}
});
it('resolves guild nicknames for names', () => {
const [typist] = typists(1);
expect(textOf(getRollingTypingText(EN_US, [typist!], GUILD_CHANNEL, false))).toBe('typist-1 in guild is typing...');
expect(getRollingTypingAnnouncement(EN_US, [typist!], DM_CHANNEL)).toBe('typist-1 in no guild is typing...');
expect(doubles.getNickname.mock.calls).toEqual([
[typist, 'guild'],
[typist, null],
]);
});
});
@@ -0,0 +1,99 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/channel/components/TypingUsers.module.css';
import type {Channel} from '@app/features/channel/models/Channel';
import GuildMembers from '@app/features/member/state/GuildMembers';
import {TYPING_ROLLING_MAX_NAMES} from '@app/features/typing/rolling/TypingSendThrottle';
import {getTypingTierText} from '@app/features/typing/utils/TypingTierText';
import type {User} from '@app/features/user/models/User';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {Trans} from '@lingui/react/macro';
import type {ReactNode} from 'react';
const ONE_TYPIST_DESCRIPTOR = msg({
message: '{a} is typing...',
comment: 'Screen reader announcement in the composer when one person is typing. {a} is their display name.',
});
const TWO_TYPISTS_DESCRIPTOR = msg({
message: '{a} and {b} are typing...',
comment:
'Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names.',
});
const THREE_TYPISTS_DESCRIPTOR = msg({
message: '{a}, {b} and {c} are typing...',
comment:
'Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names.',
});
const MULTIPLE_PEOPLE_ARE_TYPING_DESCRIPTOR = msg({
message: 'Multiple people are typing...',
comment: 'Label in the composer typing row when the names of the people typing do not fit.',
});
function getTypistName(user: User, channel: Channel): string {
return NicknameUtils.getNickname(user, channel.guildId ?? null);
}
function getTypingNames(typingUsers: ReadonlyArray<User>, channel: Channel): ReactNode {
const [a, b, c] = typingUsers.slice(0, TYPING_ROLLING_MAX_NAMES).map((user) => (
<span
key={user.id}
className={styles.username}
style={{color: GuildMembers.getMember(channel.guildId ?? '', user.id)?.getColorString()}}
data-flx="channel.typing-users.get-typing-text.username"
>
{getTypistName(user, channel)}
</span>
));
if (typingUsers.length === 1) {
return <Trans>{a} is typing...</Trans>;
}
if (typingUsers.length === 2) {
return (
<Trans>
{a} and {b} are typing...
</Trans>
);
}
return (
<Trans>
{a}, {b} and {c} are typing...
</Trans>
);
}
export function getRollingTypingText(
i18n: I18n,
typingUsers: ReadonlyArray<User>,
channel: Channel,
overflowing: boolean,
): ReactNode {
if (typingUsers.length === 0) {
return null;
}
if (typingUsers.length > TYPING_ROLLING_MAX_NAMES) {
return getTypingTierText(i18n, typingUsers.length);
}
if (overflowing) {
return i18n._(MULTIPLE_PEOPLE_ARE_TYPING_DESCRIPTOR);
}
return getTypingNames(typingUsers, channel);
}
export function getRollingTypingAnnouncement(i18n: I18n, typingUsers: ReadonlyArray<User>, channel: Channel): string {
if (typingUsers.length === 0) {
return '';
}
if (typingUsers.length > TYPING_ROLLING_MAX_NAMES) {
return getTypingTierText(i18n, typingUsers.length);
}
const [a, b, c] = typingUsers.map((user) => getTypistName(user, channel));
if (typingUsers.length === 1) {
return i18n._(ONE_TYPIST_DESCRIPTOR, {a});
}
if (typingUsers.length === 2) {
return i18n._(TWO_TYPISTS_DESCRIPTOR, {a, b});
}
return i18n._(THREE_TYPISTS_DESCRIPTOR, {a, b, c});
}
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {planTypingSend, type TypingSendSlot} from '@app/features/typing/rolling/TypingSendThrottle';
import {describe, expect, it} from 'vitest';
const PREVIOUS_SEND = 100000;
const PENDING_TIMEOUT = {} as NodeJS.Timeout;
function slot(overrides: Partial<TypingSendSlot> = {}): TypingSendSlot {
return {channelId: 'channel', userId: 'me', timeout: null, prevSend: PREVIOUS_SEND, ...overrides};
}
describe('planTypingSend', () => {
it('schedules the first ever send after 1500 ms', () => {
expect(planTypingSend(null, 'channel', 'me', PREVIOUS_SEND)).toEqual({
dropSlot: false,
action: 'schedule',
delayMs: 1500,
});
});
it('throttles while a send is pending', () => {
expect(planTypingSend(slot({timeout: PENDING_TIMEOUT, prevSend: 0}), 'channel', 'me', PREVIOUS_SEND)).toEqual({
dropSlot: false,
action: 'throttled',
});
});
it('throttles when the previous schedule is less than 8000 ms old', () => {
expect(planTypingSend(slot(), 'channel', 'me', PREVIOUS_SEND + 7999)).toEqual({
dropSlot: false,
action: 'throttled',
});
});
it('schedules exactly 8000 ms after the previous schedule', () => {
expect(planTypingSend(slot(), 'channel', 'me', PREVIOUS_SEND + 8000)).toEqual({
dropSlot: false,
action: 'schedule',
delayMs: 1500,
});
});
it('schedules after 1500 ms when the previous schedule is less than 16000 ms old', () => {
expect(planTypingSend(slot(), 'channel', 'me', PREVIOUS_SEND + 15999)).toEqual({
dropSlot: false,
action: 'schedule',
delayMs: 1500,
});
});
it('schedules after 0 ms when the previous schedule is exactly 16000 ms old', () => {
expect(planTypingSend(slot(), 'channel', 'me', PREVIOUS_SEND + 16000)).toEqual({
dropSlot: false,
action: 'schedule',
delayMs: 0,
});
});
it('drops a slot for another channel and then schedules after 1500 ms', () => {
expect(
planTypingSend(slot({channelId: 'other', timeout: PENDING_TIMEOUT}), 'channel', 'me', PREVIOUS_SEND + 1),
).toEqual({dropSlot: true, action: 'schedule', delayMs: 1500});
});
it('drops a slot for another account before checking the throttle', () => {
expect(
planTypingSend(slot({userId: 'previous', timeout: PENDING_TIMEOUT}), 'channel', 'me', PREVIOUS_SEND + 1),
).toEqual({dropSlot: true, action: 'schedule', delayMs: 1500});
});
});
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const TYPING_ROLLING_EXPIRY_MS = 10000;
export const TYPING_ROLLING_SEND_DELAY_MS = 1500;
export const TYPING_ROLLING_THROTTLE_MS = 8000;
export const TYPING_ROLLING_IMMEDIATE_SEND_IDLE_MS = 16000;
export const TYPING_ROLLING_SKIP_ABOVE_TRACKED = 5;
export const TYPING_ROLLING_MAX_NAMES = 3;
export const TYPING_ROLLING_OVERFLOW_SLACK_PX = 48;
export type TypingSendSlot = {
channelId: string;
userId: string;
timeout: NodeJS.Timeout | null;
prevSend: number;
};
type TypingSendPlan = {dropSlot: boolean} & ({action: 'throttled'} | {action: 'schedule'; delayMs: number});
export function planTypingSend(
slot: TypingSendSlot | null,
channelId: string,
userId: string,
now: number,
): TypingSendPlan {
if (slot === null) {
return {dropSlot: false, action: 'schedule', delayMs: TYPING_ROLLING_SEND_DELAY_MS};
}
if (slot.channelId !== channelId || slot.userId !== userId) {
return {dropSlot: true, action: 'schedule', delayMs: TYPING_ROLLING_SEND_DELAY_MS};
}
if (slot.timeout !== null || slot.prevSend + TYPING_ROLLING_THROTTLE_MS > now) {
return {dropSlot: false, action: 'throttled'};
}
const idleLongEnough = slot.prevSend <= now - TYPING_ROLLING_IMMEDIATE_SEND_IDLE_MS;
return {dropSlot: false, action: 'schedule', delayMs: idleLongEnough ? 0 : TYPING_ROLLING_SEND_DELAY_MS};
}
@@ -1,298 +1,50 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import LegacyTypingIndicator from '@app/features/typing/legacy/LegacyTypingIndicator';
import RollingTypingStore from '@app/features/typing/rolling/RollingTypingStore';
import TypingPolicy from '@app/features/typing/state/TypingPolicy';
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {action, makeAutoObservable, observable} from 'mobx';
type TypingEntry = Readonly<{
expiresAt: number;
}>;
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([]);
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' | 'typingUsersVersionByChannel' | 'expiryTimerId' | 'localTypingResetOwner'
>(
this,
{
expiryTimerId: false,
localTypingResetOwner: false,
typingUsersCacheByChannel: false,
typingUsersVersionByChannel: observable.shallow,
},
{autoBind: true},
);
}
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.version === version && (cached.expiresAt === null || cached.expiresAt > now)) {
return cached.users;
startRemoteTyping(channelId: string, userId: string): void {
if (TypingPolicy.active === 'rolling') {
RollingTypingStore.start(channelId, userId, 'gateway');
return;
}
const {expiresAt, users} = this.collectTypingUsers(channelId, now);
const typedUsers = users.length === 0 ? EMPTY_TYPING_USERS : Object.freeze(users);
this.typingUsersCacheByChannel[channelId] = {
expiresAt,
users: typedUsers,
version,
};
return typedUsers;
LegacyTypingIndicator.startRemoteTyping(channelId, userId);
}
getCount(channelId: string): number {
return this.getTypingUsers(channelId).length;
stopTypingOnMessageCreate(message: Message): void {
if (TypingPolicy.active === 'rolling') {
RollingTypingStore.remove(message.channel_id, message.author.id);
return;
}
LegacyTypingIndicator.stopTypingOnMessageCreate(message);
}
reset(): void {
if (TypingPolicy.active === 'rolling') {
RollingTypingStore.reset();
return;
}
LegacyTypingIndicator.reset();
}
isTyping(channelId: string, userId: string): boolean {
return this.isRemoteTyping(channelId, userId) || this.isLocalTyping(channelId, userId);
if (TypingPolicy.active === 'rolling') {
return RollingTypingStore.isTyping(channelId, userId);
}
return LegacyTypingIndicator.isTyping(channelId, userId);
}
isMemberListTyping(channelId: string, userId: string, currentUserId: string | null | undefined): boolean {
if (TypingPolicy.active !== 'rolling') {
return LegacyTypingIndicator.isMemberListTyping(channelId, userId, currentUserId);
}
if (currentUserId && userId === currentUserId) {
return this.isLocalTyping(channelId, userId);
return RollingTypingStore.isConfirmedTyping(channelId, userId);
}
return this.isTyping(channelId, userId);
}
isLocalTyping(channelId: string, userId: string): boolean {
return this.hasActiveTypingEntry(this.localTypingUsersByChannel, channelId, userId, Date.now());
}
isRemoteTyping(channelId: string, userId: string): boolean {
return this.hasActiveTypingEntry(this.remoteTypingUsersByChannel, channelId, userId, Date.now());
}
private collectTypingUsers(channelId: string, now: number): {users: Array<string>; expiresAt: number | null} {
const users: Array<string> = [];
const seen = new Set<string>();
let expiresAt: number | null = null;
const collect = (entries: Record<string, TypingEntry> | undefined) => {
if (!entries) return;
for (const [userId, entry] of Object.entries(entries)) {
if (entry.expiresAt <= now) {
continue;
}
if (!seen.has(userId)) {
seen.add(userId);
users.push(userId);
}
expiresAt = expiresAt === null ? entry.expiresAt : Math.min(expiresAt, entry.expiresAt);
}
};
collect(this.remoteTypingUsersByChannel[channelId]);
collect(this.localTypingUsersByChannel[channelId]);
return {expiresAt, users};
}
private hasActiveTypingEntry(
entriesByChannel: TypingEntriesByChannel,
channelId: string,
userId: string,
now: number,
): boolean {
const entry = entriesByChannel[channelId]?.[userId];
return entry !== undefined && entry.expiresAt > now;
}
private upsertTypingEntry(entriesByChannel: TypingEntriesByChannel, channelId: string, userId: string): void {
const now = Date.now();
if (!entriesByChannel[channelId]) {
entriesByChannel[channelId] = {};
}
const channelUsers = entriesByChannel[channelId];
const existingEntry = channelUsers[userId];
const wasVisible = existingEntry !== undefined && existingEntry.expiresAt > now;
channelUsers[userId] = {
expiresAt: now + TYPING_INDICATOR_TIMEOUT_MS,
};
if (!wasVisible) {
this.invalidateTypingUsers(channelId, now);
}
this.scheduleNextExpiryTimer(now);
}
private removeTypingEntry(entriesByChannel: TypingEntriesByChannel, channelId: string, userId: string): void {
const channelUsers = entriesByChannel[channelId];
if (!channelUsers?.[userId]) {
return;
}
delete channelUsers[userId];
if (Object.keys(channelUsers).length === 0) {
delete entriesByChannel[channelId];
}
const now = Date.now();
this.invalidateTypingUsers(channelId, now);
this.scheduleNextExpiryTimer(now);
}
private removeExpiredTypingEntries(now: number): void {
this.removeExpiredTypingEntriesFrom(this.remoteTypingUsersByChannel, now);
this.removeExpiredTypingEntriesFrom(this.localTypingUsersByChannel, now);
}
private removeExpiredTypingEntriesFrom(entriesByChannel: TypingEntriesByChannel, now: number): void {
for (const [channelId, channelUsers] of Object.entries(entriesByChannel)) {
let removed = false;
for (const [userId, entry] of Object.entries(channelUsers)) {
if (entry.expiresAt > now) {
continue;
}
delete channelUsers[userId];
removed = true;
}
if (Object.keys(channelUsers).length === 0) {
delete entriesByChannel[channelId];
}
if (removed) {
this.invalidateTypingUsers(channelId, now);
}
}
}
private getNextExpiresAt(now: number): number | null {
let nextExpiresAt: number | null = null;
const collect = (entriesByChannel: TypingEntriesByChannel) => {
for (const channelUsers of Object.values(entriesByChannel)) {
for (const entry of Object.values(channelUsers)) {
if (entry.expiresAt <= now) {
continue;
}
nextExpiresAt = nextExpiresAt === null ? entry.expiresAt : Math.min(nextExpiresAt, entry.expiresAt);
}
}
};
collect(this.remoteTypingUsersByChannel);
collect(this.localTypingUsersByChannel);
return nextExpiresAt;
}
private scheduleNextExpiryTimer(now: number): void {
this.clearExpiryTimer();
const nextExpiresAt = this.getNextExpiresAt(now);
if (nextExpiresAt === null) {
return;
}
this.expiryTimerId = setTimeout(
() => {
this.expiryTimerId = null;
this.pruneExpiredTypingUsers();
},
Math.max(0, nextExpiresAt - now),
);
}
private clearExpiryTimer(): void {
if (!this.expiryTimerId) {
return;
}
clearTimeout(this.expiryTimerId);
this.expiryTimerId = null;
}
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
pruneExpiredTypingUsers(now = Date.now()): void {
this.removeExpiredTypingEntries(now);
this.scheduleNextExpiryTimer(now);
}
@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
startTyping(channelId: string, userId: string): void {
this.startRemoteTyping(channelId, userId);
}
@action
startRemoteTyping(channelId: string, userId: string): void {
this.upsertTypingEntry(this.remoteTypingUsersByChannel, channelId, userId);
}
@action
stopTyping(channelId: string, userId: string): void {
this.stopLocalTyping(channelId, userId);
this.stopRemoteTyping(channelId, userId);
}
@action
startLocalTyping(channelId: string, userId: string): void {
this.upsertTypingEntry(this.localTypingUsersByChannel, channelId, userId);
}
@action
stopLocalTyping(channelId: string, userId: string): void {
this.removeTypingEntry(this.localTypingUsersByChannel, channelId, userId);
}
@action
stopRemoteTyping(channelId: string, userId: string): void {
this.removeTypingEntry(this.remoteTypingUsersByChannel, channelId, userId);
}
@action
stopTypingOnMessageCreate(message: Message): void {
this.stopTyping(message.channel_id, message.author.id);
return RollingTypingStore.isTyping(channelId, userId);
}
}
@@ -0,0 +1,181 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {configure} from 'mobx';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
configure({safeDescriptors: false});
const doubles = vi.hoisted(() => ({
post: vi.fn((_path: string) => Promise.resolve({ok: true})),
}));
vi.mock('@app/features/platform/utils/AppLogger', () => ({
Logger: class {
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
}));
vi.mock('@app/features/platform/transport/RestTransport', () => ({http: {get: vi.fn(), post: doubles.post}}));
vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}}));
vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: {showMyselfTyping: false}}));
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
vi.mock('@app/features/user/state/Users', () => ({default: {getUser: () => undefined}}));
const {default: TypingIndicator} = await import('@app/features/typing/state/TypingIndicator');
const {default: TypingPolicy} = await import('@app/features/typing/state/TypingPolicy');
const {default: LegacyTypingIndicator} = await import('@app/features/typing/legacy/LegacyTypingIndicator');
const {default: RollingTypingSender} = await import('@app/features/typing/rolling/RollingTypingSender');
const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore');
const CHANNEL = 'channel';
function messageFrom(authorId: string): Message {
return {channel_id: CHANNEL, author: {id: authorId}} as unknown as Message;
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
TypingPolicy.applyPolicy('legacy');
LegacyTypingIndicator.reset();
RollingTypingSender.reset();
RollingTypingStore.reset();
vi.useRealTimers();
vi.clearAllMocks();
});
describe('TypingIndicator facade', () => {
it('forwards every exposed store method to the legacy singleton with identical arguments under control', () => {
const startRemoteTyping = vi.spyOn(LegacyTypingIndicator, 'startRemoteTyping');
const stopTypingOnMessageCreate = vi.spyOn(LegacyTypingIndicator, 'stopTypingOnMessageCreate');
const reset = vi.spyOn(LegacyTypingIndicator, 'reset');
const isTyping = vi.spyOn(LegacyTypingIndicator, 'isTyping');
const isMemberListTyping = vi.spyOn(LegacyTypingIndicator, 'isMemberListTyping').mockReturnValue(false);
const message = messageFrom('alice');
TypingIndicator.startRemoteTyping(CHANNEL, 'alice');
TypingIndicator.stopTypingOnMessageCreate(message);
TypingIndicator.isTyping(CHANNEL, 'bob');
TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me');
TypingIndicator.isMemberListTyping(CHANNEL, 'bob', null);
TypingIndicator.reset();
expect(startRemoteTyping).toHaveBeenCalledExactlyOnceWith(CHANNEL, 'alice');
expect(stopTypingOnMessageCreate).toHaveBeenCalledExactlyOnceWith(message);
expect(isTyping).toHaveBeenCalledExactlyOnceWith(CHANNEL, 'bob');
expect(isMemberListTyping.mock.calls).toEqual([
[CHANNEL, 'me', 'me'],
[CHANNEL, 'bob', null],
]);
expect(reset).toHaveBeenCalledExactlyOnceWith();
});
it('returns the legacy results for typing and member list readers under control', () => {
vi.spyOn(LegacyTypingIndicator, 'isTyping').mockReturnValueOnce(true).mockReturnValueOnce(false);
vi.spyOn(LegacyTypingIndicator, 'isMemberListTyping').mockReturnValueOnce(false).mockReturnValueOnce(true);
expect(TypingIndicator.isTyping(CHANNEL, 'alice')).toBe(true);
expect(TypingIndicator.isTyping(CHANNEL, 'alice')).toBe(false);
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(false);
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(true);
});
it('arms no rolling timer under control', () => {
TypingIndicator.startRemoteTyping(CHANNEL, 'alice');
expect(LegacyTypingIndicator.isTyping(CHANNEL, 'alice')).toBe(true);
expect(RollingTypingStore.countTypists(CHANNEL)).toBe(0);
expect(vi.getTimerCount()).toBe(1);
TypingIndicator.reset();
expect(vi.getTimerCount()).toBe(0);
});
it('reports the member list self row from local typing under control', () => {
TypingIndicator.startRemoteTyping(CHANNEL, 'me');
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(false);
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'alice')).toBe(true);
LegacyTypingIndicator.startLocalTyping(CHANNEL, 'me');
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(true);
});
it('routes gateway typing starts to the rolling store under treatment', () => {
TypingPolicy.applyPolicy('rolling');
const legacyStartRemoteTyping = vi.spyOn(LegacyTypingIndicator, 'startRemoteTyping');
TypingIndicator.startRemoteTyping(CHANNEL, 'alice');
expect(legacyStartRemoteTyping).not.toHaveBeenCalled();
expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'alice')).toBe(true);
expect(TypingIndicator.isTyping(CHANNEL, 'alice')).toBe(true);
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'alice', 'me')).toBe(true);
expect(LegacyTypingIndicator.isTyping(CHANNEL, 'alice')).toBe(false);
});
it('reports self in the member list only after the server echo under treatment', () => {
TypingPolicy.applyPolicy('rolling');
RollingTypingSender.startTyping(CHANNEL);
expect(TypingIndicator.isTyping(CHANNEL, 'me')).toBe(true);
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(false);
vi.advanceTimersByTime(1500);
expect(doubles.post).toHaveBeenCalledTimes(1);
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(false);
TypingIndicator.startRemoteTyping(CHANNEL, 'me');
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(true);
});
it('keeps the self row dark when the post was skipped under treatment', () => {
TypingPolicy.applyPolicy('rolling');
for (const userId of ['a', 'b', 'c', 'd', 'e']) {
TypingIndicator.startRemoteTyping(CHANNEL, userId);
}
RollingTypingSender.startTyping(CHANNEL);
vi.advanceTimersByTime(1500);
expect(doubles.post).not.toHaveBeenCalled();
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(false);
expect(TypingIndicator.isMemberListTyping(CHANNEL, 'a', 'me')).toBe(true);
});
it('keeps the rolling send slot when the gateway resets under treatment', () => {
TypingPolicy.applyPolicy('rolling');
TypingIndicator.startRemoteTyping(CHANNEL, 'alice');
RollingTypingSender.startTyping(CHANNEL);
vi.advanceTimersByTime(500);
TypingIndicator.reset();
expect(TypingIndicator.isTyping(CHANNEL, 'alice')).toBe(false);
expect(TypingIndicator.isTyping(CHANNEL, 'me')).toBe(false);
vi.advanceTimersByTime(1000);
expect(doubles.post).toHaveBeenCalledTimes(1);
});
it('clears any author on message create under treatment', () => {
TypingPolicy.applyPolicy('rolling');
TypingIndicator.startRemoteTyping(CHANNEL, 'alice');
RollingTypingSender.startTyping(CHANNEL);
TypingIndicator.stopTypingOnMessageCreate(messageFrom('alice'));
TypingIndicator.stopTypingOnMessageCreate(messageFrom('me'));
expect(TypingIndicator.isTyping(CHANNEL, 'alice')).toBe(false);
expect(TypingIndicator.isTyping(CHANNEL, 'me')).toBe(false);
vi.advanceTimersByTime(1500);
expect(doubles.post).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,107 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type ExperimentAssignmentsResponse,
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
readTypingIndicatorReworkAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {
INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT,
type TypingIndicatorReworkAssignmentResponse,
} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas';
import {runInAction} from 'mobx';
import {afterEach, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/platform/utils/AppLogger', () => ({
Logger: class {
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
}));
vi.mock('@app/features/platform/transport/RestTransport', () => ({
http: {get: vi.fn(), post: vi.fn()},
}));
const {ExperimentAssignments} = await import('@app/features/experiment/state/ExperimentAssignments');
const {TypingIndicatorReworkRollout} = await import('@app/features/typing/state/TypingIndicatorReworkRollout');
const TARGETED_ASSIGNMENT: TypingIndicatorReworkAssignmentResponse = {
enabled: true,
config_version: 4,
user_targeted: true,
source: 'canary',
};
function responseWith(assignment: TypingIndicatorReworkAssignmentResponse | undefined): ExperimentAssignmentsResponse {
return {
poll_interval_seconds: 300,
poll_jitter_percent: 15,
assignments: assignment === undefined ? {} : {typing_indicator_rework: assignment},
};
}
function publish(assignment: TypingIndicatorReworkAssignmentResponse | undefined): void {
runInAction(() => {
ExperimentAssignments.response = responseWith(assignment);
});
}
afterEach(() => {
runInAction(() => {
ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
});
});
describe('TypingIndicatorReworkRollout', () => {
it('reads the inert assignment before any response has landed', () => {
expect(TypingIndicatorReworkRollout.assignment).toEqual(INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT);
expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(false);
});
it('treats a missing experiment key as off', () => {
publish(undefined);
expect(readTypingIndicatorReworkAssignment(responseWith(undefined))).toBe(INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT);
expect(TypingIndicatorReworkRollout.assignment).toEqual(INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT);
expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(false);
});
it('uses rolling typing for a targeted account', () => {
publish(TARGETED_ASSIGNMENT);
expect(TypingIndicatorReworkRollout.assignment).toEqual(TARGETED_ASSIGNMENT);
expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(true);
});
it('keeps legacy typing for an account outside the rollout', () => {
publish({...TARGETED_ASSIGNMENT, user_targeted: false, source: null});
expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(false);
});
it('keeps legacy typing while the rollout is disabled instance-wide', () => {
publish({...TARGETED_ASSIGNMENT, enabled: false});
expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(false);
});
it('keeps legacy typing for an excluded account', () => {
publish({enabled: true, config_version: 4, user_targeted: false, source: null});
expect(TypingIndicatorReworkRollout.assignment.enabled).toBe(true);
expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(false);
});
it('follows a later poll that turns the rollout off', () => {
publish(TARGETED_ASSIGNMENT);
expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(true);
publish({...TARGETED_ASSIGNMENT, enabled: false, user_targeted: false, source: null});
expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(false);
});
});
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import {readTypingIndicatorReworkAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {
isTypingIndicatorReworkTargeted,
type TypingIndicatorReworkAssignmentResponse,
} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas';
class TypingIndicatorReworkRolloutSelector {
get assignment(): TypingIndicatorReworkAssignmentResponse {
return readTypingIndicatorReworkAssignment(ExperimentAssignments.response);
}
get usesRollingTyping(): boolean {
return isTypingIndicatorReworkTargeted(this.assignment);
}
}
export const TypingIndicatorReworkRollout = new TypingIndicatorReworkRolloutSelector();
export default TypingIndicatorReworkRollout;
@@ -0,0 +1,289 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import type {TypingIndicatorReworkAssignmentResponse} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas';
import {runInAction} from 'mobx';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const doubles = vi.hoisted(() => ({
post: vi.fn((_path: string) => Promise.resolve({ok: true})),
}));
vi.mock('@app/features/platform/utils/AppLogger', () => ({
Logger: class {
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
}));
vi.mock('@app/features/platform/transport/RestTransport', () => ({http: {get: vi.fn(), post: doubles.post}}));
vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}}));
vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: {showMyselfTyping: false}}));
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
vi.mock('@app/features/user/state/Users', () => ({default: {getUser: () => undefined}}));
const {ExperimentAssignments} = await import('@app/features/experiment/state/ExperimentAssignments');
const {default: TypingPolicy} = await import('@app/features/typing/state/TypingPolicy');
const {default: LegacyTypingIndicator} = await import('@app/features/typing/legacy/LegacyTypingIndicator');
const {TypingUtils: LegacyTypingUtils} = await import('@app/features/typing/legacy/LegacyTypingUtils');
const {default: RollingTypingSender} = await import('@app/features/typing/rolling/RollingTypingSender');
const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore');
const START_TIME = 2_000_000;
const CHANNEL = 'channel';
const TARGETED_ASSIGNMENT: TypingIndicatorReworkAssignmentResponse = {
enabled: true,
config_version: 7,
user_targeted: true,
source: 'canary',
};
const DISABLED_ASSIGNMENT: TypingIndicatorReworkAssignmentResponse = {
enabled: false,
config_version: 8,
user_targeted: false,
source: null,
};
function publish(assignment: TypingIndicatorReworkAssignmentResponse | undefined): void {
runInAction(() => {
ExperimentAssignments.response = {
poll_interval_seconds: 300,
poll_jitter_percent: 15,
assignments: assignment === undefined ? {} : {typing_indicator_rework: assignment},
};
});
}
function advanceTo(elapsedMs: number): void {
vi.advanceTimersByTime(elapsedMs - (Date.now() - START_TIME));
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(START_TIME);
});
afterEach(() => {
TypingPolicy.stop();
runInAction(() => {
ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
});
LegacyTypingIndicator.reset();
RollingTypingSender.reset();
RollingTypingStore.reset();
vi.useRealTimers();
vi.clearAllMocks();
});
describe('TypingPolicy', () => {
it('starts on the legacy policy before any assignment has landed', () => {
TypingPolicy.start();
expect(TypingPolicy.active).toBe('legacy');
});
it('starts on the legacy policy before the policy is started', () => {
publish(TARGETED_ASSIGNMENT);
expect(TypingPolicy.active).toBe('legacy');
});
it('stays on the legacy policy when the experiment key is missing', () => {
TypingPolicy.start();
publish(undefined);
expect(TypingPolicy.active).toBe('legacy');
});
it('moves to the rolling policy when the first poll after ready targets the account', () => {
TypingPolicy.start();
publish(TARGETED_ASSIGNMENT);
expect(TypingPolicy.active).toBe('rolling');
});
it('moves back to the legacy policy when a later poll turns the rollout off', () => {
TypingPolicy.start();
publish(TARGETED_ASSIGNMENT);
publish(DISABLED_ASSIGNMENT);
expect(TypingPolicy.active).toBe('legacy');
});
it('never resets the legacy store while the assignment stays untargeted', () => {
TypingPolicy.start();
LegacyTypingIndicator.startRemoteTyping(CHANNEL, 'alice');
publish({...TARGETED_ASSIGNMENT, user_targeted: false, source: null});
publish(DISABLED_ASSIGNMENT);
publish(undefined);
expect(LegacyTypingIndicator.isTyping(CHANNEL, 'alice')).toBe(true);
});
it('releases the legacy send and idle timers when flipping to rolling', () => {
TypingPolicy.start();
LegacyTypingUtils.typing(CHANNEL);
expect(vi.getTimerCount()).toBe(3);
publish(TARGETED_ASSIGNMENT);
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(30000);
expect(doubles.post).not.toHaveBeenCalled();
});
it('releases the legacy cooldown when flipping to rolling', () => {
TypingPolicy.start();
LegacyTypingUtils.typing(CHANNEL);
advanceTo(1500);
expect(doubles.post).toHaveBeenCalledTimes(1);
publish(TARGETED_ASSIGNMENT);
publish(DISABLED_ASSIGNMENT);
advanceTo(1600);
LegacyTypingUtils.typing(CHANNEL);
advanceTo(3100);
expect(doubles.post).toHaveBeenCalledTimes(2);
});
it('releases the rolling send slot and every entry timer when flipping to legacy', () => {
TypingPolicy.start();
publish(TARGETED_ASSIGNMENT);
RollingTypingSender.startTyping(CHANNEL);
RollingTypingStore.start(CHANNEL, 'alice', 'gateway');
expect(vi.getTimerCount()).toBe(3);
publish(DISABLED_ASSIGNMENT);
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(30000);
expect(doubles.post).not.toHaveBeenCalled();
});
it('leaves no timers armed straight after a flip with pending work on both arms', () => {
TypingPolicy.start();
LegacyTypingUtils.typing(CHANNEL);
LegacyTypingIndicator.startRemoteTyping(CHANNEL, 'alice');
RollingTypingSender.startTyping(CHANNEL);
RollingTypingStore.start(CHANNEL, 'bob', 'gateway');
expect(vi.getTimerCount()).toBeGreaterThanOrEqual(5);
publish(TARGETED_ASSIGNMENT);
expect(vi.getTimerCount()).toBe(0);
});
it('never posts twice from a single pending send across a flip', () => {
TypingPolicy.start();
publish(TARGETED_ASSIGNMENT);
RollingTypingSender.startTyping(CHANNEL);
advanceTo(500);
publish(DISABLED_ASSIGNMENT);
advanceTo(700);
publish(TARGETED_ASSIGNMENT);
advanceTo(800);
RollingTypingSender.startTyping(CHANNEL);
advanceTo(6000);
expect(doubles.post).toHaveBeenCalledTimes(1);
publish(DISABLED_ASSIGNMENT);
LegacyTypingUtils.typing(CHANNEL);
advanceTo(6500);
publish(TARGETED_ASSIGNMENT);
advanceTo(6700);
publish(DISABLED_ASSIGNMENT);
LegacyTypingUtils.typing(CHANNEL);
advanceTo(12000);
expect(doubles.post).toHaveBeenCalledTimes(2);
});
it('starts the rolling sender with a 1500 ms first send after a flip', () => {
TypingPolicy.start();
LegacyTypingUtils.typing(CHANNEL);
advanceTo(1500);
expect(doubles.post).toHaveBeenCalledTimes(1);
advanceTo(2000);
publish(TARGETED_ASSIGNMENT);
RollingTypingSender.startTyping(CHANNEL);
advanceTo(3499);
expect(doubles.post).toHaveBeenCalledTimes(1);
advanceTo(3500);
expect(doubles.post).toHaveBeenCalledTimes(2);
});
it('clears remote typists from both stores on every flip', () => {
TypingPolicy.start();
LegacyTypingIndicator.startRemoteTyping(CHANNEL, 'alice');
RollingTypingStore.start(CHANNEL, 'bob', 'gateway');
publish(TARGETED_ASSIGNMENT);
expect(LegacyTypingIndicator.isTyping(CHANNEL, 'alice')).toBe(false);
expect(RollingTypingStore.isTyping(CHANNEL, 'bob')).toBe(false);
LegacyTypingIndicator.startRemoteTyping(CHANNEL, 'alice');
RollingTypingStore.start(CHANNEL, 'bob', 'gateway');
publish(DISABLED_ASSIGNMENT);
expect(LegacyTypingIndicator.isTyping(CHANNEL, 'alice')).toBe(false);
expect(RollingTypingStore.isTyping(CHANNEL, 'bob')).toBe(false);
});
it('ignores a poll that repeats the current assignment', () => {
TypingPolicy.start();
publish(TARGETED_ASSIGNMENT);
RollingTypingStore.start(CHANNEL, 'bob', 'gateway');
RollingTypingSender.startTyping(CHANNEL);
publish({...TARGETED_ASSIGNMENT});
publish({...TARGETED_ASSIGNMENT, config_version: 9});
expect(TypingPolicy.active).toBe('rolling');
expect(RollingTypingStore.isTyping(CHANNEL, 'bob')).toBe(true);
vi.advanceTimersByTime(1500);
expect(doubles.post).toHaveBeenCalledTimes(1);
});
it('returns to legacy when experiment assignments are reset on logout', () => {
TypingPolicy.start();
publish(TARGETED_ASSIGNMENT);
RollingTypingSender.startTyping(CHANNEL);
ExperimentAssignments.reset();
expect(TypingPolicy.active).toBe('legacy');
expect(vi.getTimerCount()).toBe(0);
});
it('returns to legacy and releases every timer when stopped', () => {
TypingPolicy.start();
publish(TARGETED_ASSIGNMENT);
RollingTypingSender.startTyping(CHANNEL);
RollingTypingStore.start(CHANNEL, 'bob', 'gateway');
TypingPolicy.stop();
expect(TypingPolicy.active).toBe('legacy');
expect(vi.getTimerCount()).toBe(0);
publish(DISABLED_ASSIGNMENT);
publish(TARGETED_ASSIGNMENT);
expect(TypingPolicy.active).toBe('legacy');
});
it('starts twice without installing a second reaction', () => {
TypingPolicy.start();
TypingPolicy.start();
TypingPolicy.stop();
publish(TARGETED_ASSIGNMENT);
expect(TypingPolicy.active).toBe('legacy');
});
});
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import LegacyTypingIndicator from '@app/features/typing/legacy/LegacyTypingIndicator';
import RollingTypingSender from '@app/features/typing/rolling/RollingTypingSender';
import RollingTypingStore from '@app/features/typing/rolling/RollingTypingStore';
import TypingIndicatorReworkRollout from '@app/features/typing/state/TypingIndicatorReworkRollout';
import {action, type IReactionDisposer, makeObservable, observable, reaction} from 'mobx';
type TypingPolicyName = 'legacy' | 'rolling';
class TypingPolicy {
active: TypingPolicyName = 'legacy';
private disposer: IReactionDisposer | null = null;
constructor() {
makeObservable(this, {active: observable, applyPolicy: action});
}
start(): void {
if (this.disposer !== null) {
return;
}
this.disposer = reaction(
() => TypingIndicatorReworkRollout.usesRollingTyping,
(usesRollingTyping) => this.applyPolicy(usesRollingTyping ? 'rolling' : 'legacy'),
{fireImmediately: true},
);
}
stop(): void {
const disposer = this.disposer;
this.disposer = null;
disposer?.();
this.applyPolicy('legacy');
}
applyPolicy(next: TypingPolicyName): void {
if (this.active === next) {
return;
}
LegacyTypingIndicator.reset();
RollingTypingSender.reset();
RollingTypingStore.reset();
this.active = next;
}
}
export default new TypingPolicy();
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {I18n} from '@lingui/core';
import {describe, expect, it, vi} from 'vitest';
vi.mock('@lingui/core/macro', () => ({msg: (descriptor: {message: string}) => descriptor}));
const {getTypingTierText} = await import('@app/features/typing/utils/TypingTierText');
function i18nFor(locale: string): I18n {
return {locale, _: (descriptor: {message: string}) => `[${locale}] ${descriptor.message}`} as unknown as I18n;
}
describe('getTypingTierText', () => {
it('uses the translatable several people line for exactly four typists in every locale', () => {
for (const locale of ['en-US', 'en-GB', 'de', 'ja']) {
expect(getTypingTierText(i18nFor(locale), 4)).toBe(`[${locale}] Several people are typing...`);
}
});
it.each([
['en-US', 5, 'A handful of keyboard warriors are assembling...'],
['en-US', 9, 'A handful of keyboard warriors are assembling...'],
['en-GB', 10, 'A symphony of clacking keys is underway...'],
['en-US', 14, 'A symphony of clacking keys is underway...'],
['en-GB', 15, "It's a full-blown typing fiesta in here"],
['en-US', 19, "It's a full-blown typing fiesta in here"],
['en-GB', 20, "Whoa, it's a typing apocalypse"],
['en-US', 250, "Whoa, it's a typing apocalypse"],
])('keeps the untranslated joke line in %s for %i typists', (locale, count, line) => {
expect(getTypingTierText(i18nFor(locale), count)).toBe(line);
});
it.each([
'ar',
'de',
'es-419',
'fr',
'ja',
'pt-BR',
'sv-SE',
'zh-TW',
])('collapses every crowd to the several people line in %s', (locale) => {
for (const count of [5, 9, 10, 14, 15, 19, 20, 250]) {
expect(getTypingTierText(i18nFor(locale), count)).toBe(`[${locale}] Several people are typing...`);
}
});
it('treats a bare language code as a non-English locale', () => {
expect(getTypingTierText(i18nFor('en'), 12)).toBe('[en] Several people are typing...');
});
});
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
const SEVERAL_PEOPLE_ARE_TYPING_DESCRIPTOR = msg({
message: 'Several people are typing...',
comment: 'Label in the channel and chat typing users.',
});
const TIERED_TYPING_TEXT_LOCALES: ReadonlySet<string> = new Set(['en-US', 'en-GB']);
export function getTypingTierText(i18n: I18n, typingUserCount: number): string {
if (typingUserCount <= 4 || !TIERED_TYPING_TEXT_LOCALES.has(i18n.locale)) {
return i18n._(SEVERAL_PEOPLE_ARE_TYPING_DESCRIPTOR);
}
if (typingUserCount < 10) {
return 'A handful of keyboard warriors are assembling...';
}
if (typingUserCount < 15) {
return 'A symphony of clacking keys is underway...';
}
if (typingUserCount < 20) {
return "It's a full-blown typing fiesta in here";
}
return "Whoa, it's a typing apocalypse";
}
@@ -1,206 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Authentication from '@app/features/auth/state/Authentication';
import * as TypingCommands from '@app/features/typing/commands/TypingCommands';
import {
createLocalTypingSnapshot,
type LocalTypingMachineEvent,
type LocalTypingModel,
type LocalTypingSnapshot,
selectLocalTypingModel,
transitionLocalTypingSnapshot,
} from '@app/features/typing/state/LocalTypingStateMachine';
import TypingIndicator from '@app/features/typing/state/TypingIndicator';
import {runLegacyComposerTyping} from '@app/features/typing/legacy/LegacyComposerTyping';
import {TypingUtils as LegacyTypingUtils} from '@app/features/typing/legacy/LegacyTypingUtils';
import {decideComposerTyping} from '@app/features/typing/rolling/RollingComposerTypingGate';
import RollingTypingSender from '@app/features/typing/rolling/RollingTypingSender';
import TypingPolicy from '@app/features/typing/state/TypingPolicy';
class TypingManager {
private snapshot: LocalTypingSnapshot = createLocalTypingSnapshot();
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;
interface ComposerTypingInput {
channelId: string;
value: string;
previousValue: string | null;
isAutocompleteAttached: boolean;
enabled: boolean;
typingEnabled: boolean;
isEditingMessageInComposer: boolean;
}
typing(channelId: string): void {
const currentUserId = Authentication.currentUserId;
if (!currentUserId) {
class TypingUtilsFacade {
handleComposerChange(input: ComposerTypingInput): void {
if (TypingPolicy.active !== 'rolling') {
runLegacyComposerTyping(input);
return;
}
this.transition(
{
type: 'localTyping.started',
channelId,
now: Date.now(),
},
currentUserId,
);
const decision = decideComposerTyping({
previousValue: input.previousValue,
value: input.value,
isRestoringDraft: false,
isEditingMessageInComposer: input.isEditingMessageInComposer,
enabled: input.enabled && input.typingEnabled,
});
if (decision === 'start') {
RollingTypingSender.startTyping(input.channelId);
} else if (decision === 'stop') {
RollingTypingSender.stopTyping(input.channelId);
}
}
releaseComposer(channelId: string): void {
if (TypingPolicy.active === 'rolling') {
return;
}
LegacyTypingUtils.clear(channelId);
}
clear(channelId: string): void {
const currentUserId = Authentication.currentUserId;
this.transition(
{
type: 'localTyping.stopped',
channelId,
},
currentUserId,
);
}
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);
if (currentUserId != null) {
this.applyLocalTypingMutation(previousModel, nextModel, currentUserId, event);
}
this.applyRemoteSendSchedule(nextModel);
this.applyLocalIdleSchedule(nextModel);
}
private applyLocalTypingMutation(
previousModel: LocalTypingModel,
nextModel: LocalTypingModel,
userId: string,
event: LocalTypingMachineEvent,
): void {
if (previousModel.localTyping && previousModel.channelId && previousModel.channelId !== nextModel.channelId) {
TypingCommands.stopLocalTyping(previousModel.channelId, userId);
}
if (previousModel.localTyping && !nextModel.localTyping && previousModel.channelId) {
TypingCommands.stopLocalTyping(previousModel.channelId, userId);
if (TypingPolicy.active === 'rolling') {
RollingTypingSender.stopTyping(channelId);
return;
}
if (event.type === 'localTyping.started' && nextModel.localTyping && nextModel.channelId) {
TypingCommands.startLocalTyping(nextModel.channelId, userId);
}
LegacyTypingUtils.clear(channelId);
}
private applyRemoteSendSchedule(nextModel: LocalTypingModel): void {
if (
nextModel.remoteSendAt === this.scheduledRemoteSendAt &&
nextModel.channelId === this.scheduledRemoteChannelId &&
nextModel.remotePendingVersion === this.scheduledRemotePendingVersion
) {
handleOwnMessageSent(channelId: string): void {
if (TypingPolicy.active === 'rolling') {
RollingTypingSender.handleOwnMessageSent(channelId);
return;
}
this.clearRemoteSendTimer();
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',
channelId,
now: Date.now(),
pendingVersion,
},
currentUserId,
);
}
private expireLocalTyping(channelId: string, timerGeneration: number): void {
if (timerGeneration !== this.localIdleTimerGeneration) {
return;
}
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;
LegacyTypingUtils.clear(channelId);
}
}
export const TypingUtils = new TypingManager();
TypingIndicator.registerLocalTypingResetOwner(TypingUtils);
export const TypingUtils = new TypingUtilsFacade();
@@ -0,0 +1,176 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const doubles = vi.hoisted(() => ({
post: vi.fn((_path: string) => Promise.resolve({ok: true})),
}));
vi.mock('@app/features/platform/utils/AppLogger', () => ({
Logger: class {
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
}));
vi.mock('@app/features/platform/transport/RestTransport', () => ({http: {get: vi.fn(), post: doubles.post}}));
vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}}));
vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: {showMyselfTyping: false}}));
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
vi.mock('@app/features/user/state/Users', () => ({default: {getUser: () => undefined}}));
const {TypingUtils} = await import('@app/features/typing/utils/TypingUtils');
const {default: TypingPolicy} = await import('@app/features/typing/state/TypingPolicy');
const {TypingUtils: LegacyTypingUtils} = await import('@app/features/typing/legacy/LegacyTypingUtils');
const {default: LegacyTypingIndicator} = await import('@app/features/typing/legacy/LegacyTypingIndicator');
const {default: RollingTypingSender} = await import('@app/features/typing/rolling/RollingTypingSender');
const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore');
type ComposerTypingInput = Parameters<typeof TypingUtils.handleComposerChange>[0];
const CHANNEL = 'channel';
function composerChange(change: Partial<ComposerTypingInput>): ComposerTypingInput {
return {
channelId: CHANNEL,
value: 'hello',
previousValue: 'hell',
isAutocompleteAttached: false,
enabled: true,
typingEnabled: true,
isEditingMessageInComposer: false,
...change,
};
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
TypingPolicy.applyPolicy('legacy');
LegacyTypingIndicator.reset();
RollingTypingSender.reset();
RollingTypingStore.reset();
vi.useRealTimers();
vi.clearAllMocks();
});
describe('TypingUtils facade', () => {
it('maps clear, release composer and own message sent to the legacy clear under control', () => {
const legacyClear = vi.spyOn(LegacyTypingUtils, 'clear');
TypingUtils.clear(CHANNEL);
TypingUtils.releaseComposer('previous-channel');
TypingUtils.handleOwnMessageSent(CHANNEL);
expect(legacyClear.mock.calls).toEqual([[CHANNEL], ['previous-channel'], [CHANNEL]]);
});
it('runs the legacy composer effect with the head inputs under control', () => {
const legacyTyping = vi.spyOn(LegacyTypingUtils, 'typing').mockImplementation(() => undefined);
const legacyClear = vi.spyOn(LegacyTypingUtils, 'clear').mockImplementation(() => undefined);
const cases: Array<[Partial<ComposerTypingInput>, 'typing' | 'clear']> = [
[{value: 'hello'}, 'typing'],
[{value: ' hello '}, 'typing'],
[{value: 'hello', previousValue: 'hello'}, 'typing'],
[{value: 'hello', previousValue: null}, 'typing'],
[{value: 'hello', isEditingMessageInComposer: true}, 'typing'],
[{value: '+:smile'}, 'typing'],
[{value: ''}, 'clear'],
[{value: ' '}, 'clear'],
[{value: 'hello', isAutocompleteAttached: true}, 'clear'],
[{value: '/gif cats'}, 'clear'],
[{value: ' /gif'}, 'clear'],
[{value: 's/teh/the/'}, 'clear'],
[{value: 'hello', enabled: false}, 'clear'],
[{value: 'hello', typingEnabled: false}, 'clear'],
];
for (const [change, expected] of cases) {
legacyTyping.mockClear();
legacyClear.mockClear();
TypingUtils.handleComposerChange(composerChange(change));
const called = expected === 'typing' ? legacyTyping : legacyClear;
const skipped = expected === 'typing' ? legacyClear : legacyTyping;
expect(called, JSON.stringify(change)).toHaveBeenCalledExactlyOnceWith(CHANNEL);
expect(skipped, JSON.stringify(change)).not.toHaveBeenCalled();
}
});
it('treats release composer as a no-op under treatment', () => {
TypingPolicy.applyPolicy('rolling');
const legacyClear = vi.spyOn(LegacyTypingUtils, 'clear');
TypingUtils.handleComposerChange(composerChange({}));
vi.advanceTimersByTime(400);
TypingUtils.releaseComposer(CHANNEL);
vi.advanceTimersByTime(1100);
expect(doubles.post).toHaveBeenCalledTimes(1);
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(true);
expect(legacyClear).not.toHaveBeenCalled();
});
it('cancels a pending send for clear under treatment', () => {
TypingPolicy.applyPolicy('rolling');
TypingUtils.handleComposerChange(composerChange({}));
vi.advanceTimersByTime(400);
TypingUtils.clear(CHANNEL);
vi.advanceTimersByTime(20000);
expect(doubles.post).not.toHaveBeenCalled();
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
});
it('drops the send slot on own message sent under treatment', () => {
TypingPolicy.applyPolicy('rolling');
TypingUtils.handleComposerChange(composerChange({}));
vi.advanceTimersByTime(1500);
expect(doubles.post).toHaveBeenCalledTimes(1);
TypingUtils.handleOwnMessageSent(CHANNEL);
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
vi.advanceTimersByTime(100);
TypingUtils.handleComposerChange(composerChange({previousValue: '', value: 'n'}));
vi.advanceTimersByTime(1500);
expect(doubles.post).toHaveBeenCalledTimes(2);
});
it('starts typing while autocomplete is open under treatment', () => {
TypingPolicy.applyPolicy('rolling');
TypingUtils.handleComposerChange(
composerChange({previousValue: '@al', value: '@ali', isAutocompleteAttached: true}),
);
vi.advanceTimersByTime(1500);
expect(doubles.post).toHaveBeenCalledTimes(1);
});
it('posts nothing for a spaces-only draft under treatment', () => {
TypingPolicy.applyPolicy('rolling');
TypingUtils.handleComposerChange(composerChange({previousValue: '', value: ' '}));
TypingUtils.handleComposerChange(composerChange({previousValue: ' ', value: ' '}));
vi.advanceTimersByTime(20000);
expect(doubles.post).not.toHaveBeenCalled();
expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false);
});
it('treats a composer with typing turned off as disabled under treatment', () => {
TypingPolicy.applyPolicy('rolling');
TypingUtils.handleComposerChange(composerChange({typingEnabled: false}));
vi.advanceTimersByTime(20000);
expect(doubles.post).not.toHaveBeenCalled();
});
});