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

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

No files matched your search

@@ -3,6 +3,7 @@
import type {Channel} from '@app/features/channel/models/Channel';
import {type SendMessageFunction, useMessageSubmission} from '@app/features/messaging/hooks/useMessageSubmission';
import {TypingUtils} from '@app/features/typing/utils/TypingUtils';
import {MessageFlags} from '@fluxer/constants/src/ChannelConstants';
import {act, createElement} from 'react';
import {createRoot, type Root} from 'react-dom/client';
@@ -51,7 +52,7 @@ vi.mock('@app/features/slowmode/components/alerts/SlowmodeRateLimitedModal', ()
SlowmodeRateLimitedModal: () => null,
}));
vi.mock('@app/features/ui/commands/ModalCommands', () => ({modal: (render: unknown) => render, push: vi.fn()}));
vi.mock('@app/features/typing/utils/TypingUtils', () => ({TypingUtils: {clear: vi.fn()}}));
vi.mock('@app/features/typing/utils/TypingUtils', () => ({TypingUtils: {handleOwnMessageSent: vi.fn()}}));
vi.mock('@app/features/platform/utils/ComponentBus', () => ({ComponentBus: {dispatch: vi.fn()}}));
vi.mock('@lingui/core/macro', () => ({msg: (value: unknown) => value}));
vi.mock('@lingui/react/macro', () => ({useLingui: () => ({i18n: {_: () => ''}})}));
@@ -61,16 +62,20 @@ const channel = {id: 'c'} as unknown as Channel;
let host: HTMLDivElement;
let root: Root;
function renderSendMessage(): SendMessageFunction {
let sendMessage: SendMessageFunction | null = null;
function renderSubmission(): ReturnType<typeof useMessageSubmission> {
let submission: ReturnType<typeof useMessageSubmission> | null = null;
const Probe = () => {
sendMessage = useMessageSubmission({channel, referencedMessage: null, replyingMessage: null}).sendMessage;
submission = useMessageSubmission({channel, referencedMessage: null, replyingMessage: null});
return null;
};
act(() => {
root.render(createElement(Probe));
});
return sendMessage!;
return submission!;
}
function renderSendMessage(): SendMessageFunction {
return renderSubmission().sendMessage;
}
beforeEach(() => {
@@ -124,6 +129,22 @@ describe('useMessageSubmission', () => {
);
});
it('tells typing that its own message was sent before creating the optimistic message', () => {
const handleOwnMessageSent = vi.mocked(TypingUtils.handleOwnMessageSent);
const {sendMessage, sendOptimisticMessage} = renderSubmission();
expect(sendMessage('hello', false)).toBe(true);
sendOptimisticMessage({content: 'a sticker caption'}, {hasAttachments: false});
expect(handleOwnMessageSent.mock.calls).toEqual([['c'], ['c']]);
expect(messageCommands.createOptimistic).toHaveBeenCalledTimes(2);
const typingOrder = handleOwnMessageSent.mock.invocationCallOrder;
const optimisticOrder = messageCommands.createOptimistic.mock.invocationCallOrder;
expect(typingOrder[0]!).toBeLessThan(optimisticOrder[0]!);
expect(optimisticOrder[0]!).toBeLessThan(typingOrder[1]!);
expect(typingOrder[1]!).toBeLessThan(optimisticOrder[1]!);
});
it('sends the text after @silent with the silent flag', () => {
const sendMessage = renderSendMessage();
expect(sendMessage('@silent hi', false)).toBe(true);
@@ -103,7 +103,7 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
const nonce = SnowflakeUtils.fromTimestamp(Date.now());
if (!MessageCommands.reserveSend(channel.id, nonce)) return false;
const messageReference = MessageSubmitUtils.prepareMessageReference(channel.id, referencedMessage);
TypingUtils.clear(channel.id);
TypingUtils.handleOwnMessageSent(channel.id);
DraftCommands.deleteDraft(channel.id);
MessageCommands.stopReply(channel.id);
const uploadingAttachments = MessageSubmitUtils.createUploadingAttachments(
@@ -182,7 +182,7 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag
if (isBlockedBySlowmode(channel)) return;
const nonce = SnowflakeUtils.fromTimestamp(Date.now());
if (!MessageCommands.reserveSend(channel.id, nonce)) return;
TypingUtils.clear(channel.id);
TypingUtils.handleOwnMessageSent(channel.id);
MessageCommands.stopReply(channel.id);
const message = new Message({
id: nonce,
@@ -0,0 +1,306 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
import {act, createElement, useRef, useState} from 'react';
import {createRoot, type Root} from 'react-dom/client';
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/messaging/commands/DraftCommands', () => ({createDraft: vi.fn(), deleteDraft: vi.fn()}));
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 {useTextareaDraftAndTyping} = await import('@app/features/messaging/hooks/useTextareaDraftAndTyping');
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');
interface ComposerProps {
channelId: string;
draft?: string | null;
isAutocompleteAttached?: boolean;
enabled?: boolean;
typingEnabled?: boolean;
isEditingMessageInComposer?: boolean;
}
let host: HTMLDivElement;
let root: Root;
let setComposerValue: (value: string) => void = () => undefined;
function Composer({
channelId,
draft = null,
isAutocompleteAttached = false,
enabled = true,
typingEnabled,
isEditingMessageInComposer = false,
}: ComposerProps) {
const [value, setValue] = useState('');
const previousValueRef = useRef('');
setComposerValue = setValue;
useTextareaDraftAndTyping({
channelId,
value,
setValue,
draft,
previousValueRef,
isAutocompleteAttached,
enabled,
typingEnabled,
isEditingMessageInComposer,
});
return null;
}
function render(props: ComposerProps): void {
act(() => {
root.render(createElement(Composer, props));
});
}
function type(value: string): void {
act(() => {
setComposerValue(value);
});
}
function advance(ms: number): void {
act(() => {
vi.advanceTimersByTime(ms);
});
}
function recordLegacyCalls(): Array<[string, string]> {
const calls: Array<[string, string]> = [];
vi.spyOn(LegacyTypingUtils, 'typing').mockImplementation((channelId) => {
calls.push(['typing', channelId]);
});
vi.spyOn(LegacyTypingUtils, 'clear').mockImplementation((channelId) => {
calls.push(['clear', channelId]);
});
return calls;
}
beforeEach(() => {
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
vi.useFakeTimers();
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(() => {
act(() => {
root.unmount();
});
vi.restoreAllMocks();
TypingPolicy.applyPolicy('legacy');
LegacyTypingIndicator.reset();
RollingTypingSender.reset();
RollingTypingStore.reset();
document.body.replaceChildren();
vi.useRealTimers();
vi.clearAllMocks();
});
describe('useTextareaDraftAndTyping', () => {
it('calls the legacy composer effect on exactly the head dependency changes under control', () => {
const calls = recordLegacyCalls();
render({channelId: 'c1'});
expect(calls).toEqual([['clear', 'c1']]);
type('h');
render({channelId: 'c1'});
render({channelId: 'c1', isAutocompleteAttached: true});
render({channelId: 'c1', isAutocompleteAttached: true, typingEnabled: false});
render({channelId: 'c1', isAutocompleteAttached: true, typingEnabled: true});
expect(calls).toEqual([
['clear', 'c1'],
['typing', 'c1'],
['clear', 'c1'],
['clear', 'c1'],
['clear', 'c1'],
]);
});
it('does not rerun the effect when editing mode toggles under control', () => {
const calls = recordLegacyCalls();
render({channelId: 'c1'});
type('hello');
calls.length = 0;
render({channelId: 'c1', isEditingMessageInComposer: true});
render({channelId: 'c1', isEditingMessageInComposer: false});
expect(calls).toEqual([]);
});
it('clears the legacy state on unmount under control', () => {
const calls = recordLegacyCalls();
render({channelId: 'c1'});
type('hello');
calls.length = 0;
act(() => {
root.unmount();
});
root = createRoot(host);
expect(calls).toEqual([['clear', 'c1']]);
});
it('clears the legacy state when the composer is disabled under control', () => {
const calls = recordLegacyCalls();
render({channelId: 'c1'});
type('hello');
calls.length = 0;
render({channelId: 'c1', enabled: false});
expect(calls).toEqual([['clear', 'c1']]);
});
it('arms no rolling timer under control', () => {
render({channelId: 'c1'});
type('hello');
expect(LegacyTypingIndicator.isLocalTyping('c1', 'me')).toBe(true);
expect(RollingTypingStore.countTypists('c1')).toBe(0);
advance(1500);
expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING('c1'));
expect(RollingTypingStore.countTypists('c1')).toBe(0);
});
it('sends typing once for a burst of keystrokes under treatment', () => {
TypingPolicy.applyPolicy('rolling');
render({channelId: 'c1'});
let draft = '';
for (const character of 'hello there, how are you') {
draft += character;
type(draft);
advance(150);
}
advance(3000);
expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING('c1'));
});
it('never sends typing when entering mobile edit mode under treatment', () => {
TypingPolicy.applyPolicy('rolling');
render({channelId: 'c1'});
act(() => {
root.render(createElement(Composer, {channelId: 'c1', isEditingMessageInComposer: true}));
setComposerValue('the message being edited');
});
type('the message being edited again');
advance(20000);
render({channelId: 'c1', isEditingMessageInComposer: false});
render({channelId: 'c1', isEditingMessageInComposer: true});
type('a later edit keystroke');
advance(20000);
expect(doubles.post).not.toHaveBeenCalled();
expect(RollingTypingStore.isTyping('c1', 'me')).toBe(false);
});
it('sends nothing for a draft present at mount under treatment', () => {
TypingPolicy.applyPolicy('rolling');
render({channelId: 'c1', draft: 'saved draft'});
advance(0);
render({channelId: 'c1', draft: 'saved draft', isAutocompleteAttached: true});
advance(20000);
expect(doubles.post).not.toHaveBeenCalled();
});
it('sends nothing for a draft restored after mount under treatment', () => {
TypingPolicy.applyPolicy('rolling');
render({channelId: 'c1'});
advance(1000);
render({channelId: 'c1', draft: 'synced from another device'});
advance(0);
render({channelId: 'c1', draft: 'synced from another device', isAutocompleteAttached: true});
advance(20000);
expect(doubles.post).not.toHaveBeenCalled();
});
it('does not cancel a pending send when the channel changes under treatment', () => {
TypingPolicy.applyPolicy('rolling');
render({channelId: 'c1'});
type('hello');
advance(500);
render({channelId: 'c2'});
advance(1000);
expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING('c1'));
advance(20000);
expect(doubles.post).toHaveBeenCalledTimes(1);
});
it('cancels a pending send when the composer becomes disabled under treatment', () => {
TypingPolicy.applyPolicy('rolling');
render({channelId: 'c1'});
type('hello');
advance(500);
render({channelId: 'c1', enabled: false});
advance(20000);
expect(doubles.post).not.toHaveBeenCalled();
expect(RollingTypingStore.isTyping('c1', 'me')).toBe(false);
});
it('starts no typing merely because the policy flipped on', () => {
render({channelId: 'c1'});
type('hello');
advance(1500);
expect(doubles.post).toHaveBeenCalledTimes(1);
act(() => {
TypingPolicy.applyPolicy('rolling');
});
render({channelId: 'c1'});
advance(20000);
expect(doubles.post).toHaveBeenCalledTimes(1);
expect(RollingTypingStore.isTyping('c1', 'me')).toBe(false);
});
it('posts nothing for a spaces-only draft under treatment', () => {
TypingPolicy.applyPolicy('rolling');
render({channelId: 'c1'});
type(' ');
type(' ');
type('\t ');
advance(20000);
expect(doubles.post).not.toHaveBeenCalled();
expect(RollingTypingStore.isTyping('c1', 'me')).toBe(false);
});
});
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as DraftCommands from '@app/features/messaging/commands/DraftCommands';
import * as ReplaceCommandUtils from '@app/features/messaging/utils/ReplaceCommandUtils';
import type {MentionSegment, TextareaSegmentManager} from '@app/features/messaging/utils/TextareaSegmentManager';
import {TypingUtils} from '@app/features/typing/utils/TypingUtils';
import {useEffect, useRef} from 'react';
@@ -57,6 +56,8 @@ export const useTextareaDraftAndTyping = ({
isEditingMessageInComposer,
}: UseTextareaDraftAndTypingOptions) => {
const isRestoringDraftRef = useRef(false);
const isEditingMessageInComposerRef = useRef(isEditingMessageInComposer);
const typingPreviousValueRef = useRef<{channelId: string; value: string} | null>(null);
const currentDraftRef = useRef(draft);
const currentDraftSegmentsRef = useRef<ReadonlyArray<MentionSegment>>(draftSegments ?? []);
const pendingDraftRef = useRef<{
@@ -68,9 +69,12 @@ export const useTextareaDraftAndTyping = ({
currentDraftRef.current = draft;
currentDraftSegmentsRef.current = draftSegments ?? [];
}, [draft, draftSegments]);
useEffect(() => {
isEditingMessageInComposerRef.current = isEditingMessageInComposer;
}, [isEditingMessageInComposer]);
useEffect(() => {
return () => {
TypingUtils.clear(channelId);
TypingUtils.releaseComposer(channelId);
};
}, [channelId]);
useEffect(() => {
@@ -163,20 +167,19 @@ export const useTextareaDraftAndTyping = ({
};
}, [channelId, isEditingMessageInComposer]);
useEffect(() => {
const typingPreviousValue = typingPreviousValueRef.current;
typingPreviousValueRef.current = {channelId, value};
if (isRestoringDraftRef.current) {
return;
}
if (!enabled || !typingEnabled) {
TypingUtils.clear(channelId);
return;
}
const content = value.trim();
const isInReplaceMode = ReplaceCommandUtils.isReplaceCommand(content);
const isSlashCommand = content.startsWith('/');
if (content && !isAutocompleteAttached && !isInReplaceMode && !isSlashCommand) {
TypingUtils.typing(channelId);
} else {
TypingUtils.clear(channelId);
}
TypingUtils.handleComposerChange({
channelId,
value,
previousValue: typingPreviousValue?.channelId === channelId ? typingPreviousValue.value : null,
isAutocompleteAttached,
enabled,
typingEnabled,
isEditingMessageInComposer: isEditingMessageInComposerRef.current,
});
}, [channelId, value, isAutocompleteAttached, enabled, typingEnabled]);
};
@@ -29,6 +29,10 @@ export interface AutocompleteTrigger {
matchedText: string;
}
export function isEmojiReactionShorthand(value: string): boolean {
return EMOJI_REACTION_REGEX.test(value);
}
export function detectAutocompleteTrigger(textUpToCursor: string): AutocompleteTrigger | null {
const emojiReactionMatch = textUpToCursor.match(EMOJI_REACTION_REGEX);
if (emojiReactionMatch) {