mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(app): gate reworked typing indicators behind an experiment (#2782)
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user