mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user