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

@@ -0,0 +1,123 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Authentication from '@app/features/auth/state/Authentication';
import {Typing} from '@app/features/channel/components/ChannelTyping';
import styles from '@app/features/channel/components/TypingUsers.module.css';
import type {Channel} from '@app/features/channel/models/Channel';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import GuildMembers from '@app/features/member/state/GuildMembers';
import Relationships from '@app/features/relationship/state/Relationships';
import messageStyles from '@app/features/theme/styles/Message.module.css';
import TypingIndicator from '@app/features/typing/legacy/LegacyTypingIndicator';
import {getTypingTierText} from '@app/features/typing/utils/TypingTierText';
import {AvatarStack} from '@app/features/ui/avatars/AvatarStack';
import type {User} from '@app/features/user/models/User';
import Users from '@app/features/user/state/Users';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import type {I18n} from '@lingui/core';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
const getDisplayName = (user: User, guildId?: string | null) => NicknameUtils.getNickname(user, guildId ?? null);
export const getTypingText = (i18n: I18n, typingUsers: ReadonlyArray<User>, channel: Channel) => {
const [a, b, c] = typingUsers.map((user) => {
const member = GuildMembers.getMember(channel.guildId ?? '', user.id);
return (
<span
key={user.id}
className={styles.username}
style={{color: member?.getColorString()}}
data-flx="channel.typing-users.get-typing-text.username"
>
{getDisplayName(user, channel.guildId)}
</span>
);
});
if (typingUsers.length === 1) {
return <Trans>{a} is typing...</Trans>;
}
if (typingUsers.length === 2) {
return (
<Trans>
{a} and {b} are typing...
</Trans>
);
}
if (typingUsers.length === 3) {
return (
<Trans>
{a}, {b} and {c} are typing...
</Trans>
);
}
return getTypingTierText(i18n, typingUsers.length);
};
const EMPTY_TYPING_USER_RECORDS: ReadonlyArray<User> = Object.freeze([]);
export const usePresentableTypingUsers = (channel: Channel): ReadonlyArray<User> => {
const typingUserIds = TypingIndicator.getTypingUsers(channel.id);
if (typingUserIds.length === 0) return EMPTY_TYPING_USER_RECORDS;
const currentUserId = Authentication.currentUserId;
const showSelf = DeveloperOptions.showMyselfTyping;
const result: Array<User> = [];
for (const userId of typingUserIds) {
if (!showSelf && userId === currentUserId) continue;
if (Relationships.isBlocked(userId)) continue;
const user = Users.getUser(userId);
if (user) result.push(user);
}
if (result.length === 0) return EMPTY_TYPING_USER_RECORDS;
return result;
};
const AVATAR_THRESHOLD = 5;
export const TypingUsers = observer(
({channel, withText = true, showAvatars = true}: {channel: Channel; withText?: boolean; showAvatars?: boolean}) => {
const {i18n} = useLingui();
const typingUsers = usePresentableTypingUsers(channel);
if (typingUsers.length === 0) {
return null;
}
return (
<div
className={`${messageStyles.typingContainer} ${messageStyles.typingCluster} ${messageStyles.typingClusterComposerStatus}`}
data-flx="channel.typing-users.div"
>
<div className={styles.composerStatus} data-flx="channel.typing-users.div--2">
<div className={messageStyles.typingIndicator} data-flx="channel.typing-users.div--3">
<Typing
className={styles.typing}
size={20}
style={{
height: 'var(--typing-indicator-animation-size)',
width: 'var(--typing-indicator-animation-size)',
}}
data-flx="channel.typing-users.typing"
/>
</div>
{withText && (
<>
{showAvatars && (
<AvatarStack
size={12}
maxVisible={AVATAR_THRESHOLD}
className={messageStyles.typingAvatarContainer}
users={typingUsers}
guildId={channel.guildId}
channelId={channel.id}
data-flx="channel.typing-users.avatar-stack"
/>
)}
<span
aria-atomic={true}
aria-live="polite"
className={messageStyles.typingText}
data-flx="channel.typing-users.span"
>
{getTypingText(i18n, typingUsers, channel)}
</span>
</>
)}
</div>
</div>
);
},
);
@@ -33,7 +33,7 @@ import {
import {MessageCharacterCounter} from '@app/features/channel/components/MessageCharacterCounter';
import {SlashCommandParamBar} from '@app/features/channel/components/SlashCommandParamBar';
import {SlowmodeIndicator} from '@app/features/channel/components/SlowmodeIndicator';
import {TypingUsers, usePresentableTypingUsers} from '@app/features/channel/components/TypingUsers';
import {TypingAnnouncer, TypingUsers, usePresentableTypingUsers} from '@app/features/channel/components/TypingUsers';
import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';
import {MobileTextareaPlusBottomSheet} from '@app/features/channel/components/textarea/MobileTextareaPlusBottomSheet';
import {TextareaButton} from '@app/features/channel/components/textarea/TextareaButton';
@@ -190,6 +190,7 @@ export const LexicalChannelTextareaContent = observer(
const expressionPickerTriggerRef = useRef<HTMLButtonElement>(null);
const invisibleExpressionPickerTriggerRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const typingStatusRailLeftRef = useRef<HTMLElement>(null);
const contentAreaRef = useRef<HTMLElement | null>(null);
const plusButtonRef = useRef<HTMLButtonElement | null>(null);
const plusMenuOpenedAtRef = useRef(0);
@@ -1246,6 +1247,7 @@ export const LexicalChannelTextareaContent = observer(
data-flx="channel.lexical-channel-textarea-content.flx-channel-textarea-status-rail"
>
<flx-channel-textarea-status-rail-left
ref={typingStatusRailLeftRef}
className={flxElementClassName(wrapperStyles.statusRailLeft)}
data-flx="channel.lexical-channel-textarea-content.flx-channel-textarea-status-rail-left"
>
@@ -1258,10 +1260,12 @@ export const LexicalChannelTextareaContent = observer(
channel={channel}
withText={true}
showAvatars={true}
overflowContainerRef={typingStatusRailLeftRef}
data-flx="channel.lexical-channel-textarea-content.typing-users"
/>
</flx-channel-textarea-typing-slot>
)}
<TypingAnnouncer channel={channel} data-flx="channel.lexical-channel-textarea-content.typing-announcer" />
</flx-channel-textarea-status-rail-left>
{isSlowmodeIndicatorVisible && (
<flx-channel-textarea-slowmode-slot
@@ -0,0 +1,20 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.srOnly {
position: absolute;
width: 0.0625rem;
height: 0.0625rem;
padding: 0;
margin: -0.0625rem;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.measure {
position: absolute;
visibility: hidden;
white-space: nowrap;
pointer-events: none;
}
@@ -0,0 +1,379 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Channel} from '@app/features/channel/models/Channel';
import messageStyles from '@app/features/theme/styles/Message.module.css';
import {runInAction} from 'mobx';
import {act, createElement, Fragment, Profiler, type ReactNode} from 'react';
import {createRoot, type Root} from 'react-dom/client';
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; username: string}>(),
};
});
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),
useLingui: () => ({
i18n: {
locale: 'en-US',
_: (descriptor: {message: string}, values: Record<string, string> = {}) =>
descriptor.message.replace(/\{(\w+)\}/g, (_placeholder, name: string) => values[name] ?? ''),
},
}),
};
});
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)},
}));
vi.mock('@app/features/user/utils/NicknameUtils', () => ({
getNickname: (user: {username: string}) => user.username,
}));
vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => undefined}}));
vi.mock('@app/features/channel/components/ChannelTyping', async () => {
const {createElement: create} = await import('react');
return {Typing: () => create('svg')};
});
vi.mock('@app/features/ui/avatars/AvatarStack', async () => {
const {createElement: create} = await import('react');
return {
AvatarStack: ({className, users}: {className?: string; users: ReadonlyArray<unknown>}) =>
create('div', {className, 'data-avatar-count': users.length}),
};
});
const {RollingTypingAnnouncer, RollingTypingUsers} = await import(
'@app/features/channel/components/RollingTypingUsers'
);
const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore');
class StubResizeObserver {
static instances: Array<StubResizeObserver> = [];
readonly targets: Array<Element> = [];
disconnected = false;
constructor(private readonly callback: () => void) {
StubResizeObserver.instances.push(this);
}
observe(target: Element): void {
this.targets.push(target);
}
disconnect(): void {
this.disconnected = true;
}
trigger(): void {
this.callback();
}
}
const CHANNEL = {id: 'channel', guildId: 'guild'} as unknown as Channel;
const TYPIST_IDS = ['alice', 'bob', 'carol', 'dave', 'erin'];
let host: HTMLDivElement;
let root: Root;
let overflowContainer: HTMLElement;
let overflowContainerRef: {current: HTMLElement | null};
function setWidth(element: Element, property: 'clientWidth' | 'scrollWidth', width: number): void {
Object.defineProperty(element, property, {configurable: true, value: width});
}
function render(node: ReactNode): void {
act(() => {
root.render(node);
});
}
function renderRow(props: {showAvatars?: boolean} = {}): void {
render(
createElement(
Fragment,
null,
createElement(RollingTypingUsers, {channel: CHANNEL, overflowContainerRef, showAvatars: props.showAvatars}),
createElement(RollingTypingAnnouncer, {channel: CHANNEL}),
),
);
}
function startTyping(...userIds: Array<string>): void {
act(() => {
for (const userId of userIds) {
RollingTypingStore.start(CHANNEL.id, userId, userId === 'me' ? 'local' : 'gateway');
}
});
}
function query(flx: string): HTMLElement | null {
return host.querySelector<HTMLElement>(`[data-flx="${flx}"]`);
}
function visibleText(): string | null {
return query('channel.typing-users.span')?.textContent ?? null;
}
function announcer(): HTMLElement {
return query('channel.rolling-typing-users.announcer')!;
}
function measure(): HTMLElement | null {
return query('channel.rolling-typing-users.measure');
}
function activeResizeObservers(): Array<StubResizeObserver> {
return StubResizeObserver.instances.filter((instance) => !instance.disconnected);
}
function remeasure(): void {
act(() => {
for (const instance of activeResizeObservers()) {
instance.trigger();
}
});
}
beforeEach(() => {
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
StubResizeObserver.instances = [];
vi.stubGlobal('ResizeObserver', StubResizeObserver);
host = document.createElement('div');
overflowContainer = document.createElement('div');
document.body.append(overflowContainer, host);
setWidth(overflowContainer, 'clientWidth', 1000);
overflowContainerRef = {current: overflowContainer};
root = createRoot(host);
runInAction(() => {
doubles.auth.currentUserId = 'me';
doubles.developerOptions.showMyselfTyping = false;
doubles.blockedUserIds.clear();
doubles.users.clear();
for (const id of ['me', ...TYPIST_IDS]) {
doubles.users.set(id, {id, username: id});
}
});
});
afterEach(() => {
act(() => {
root.unmount();
});
RollingTypingStore.reset();
document.body.replaceChildren();
vi.unstubAllGlobals();
});
describe('RollingTypingUsers', () => {
it('hides the current user from the typing row', () => {
renderRow();
startTyping('me', 'alice');
expect(visibleText()).toBe('alice is typing...');
expect(host.querySelector('[data-avatar-count]')?.getAttribute('data-avatar-count')).toBe('1');
});
it('shows the current user when the developer option is on', () => {
runInAction(() => {
doubles.developerOptions.showMyselfTyping = true;
});
renderRow();
startTyping('me', 'alice');
expect(visibleText()).toBe('me and alice are typing...');
});
it('hides blocked users from the typing row', () => {
runInAction(() => {
doubles.blockedUserIds.add('bob');
});
renderRow();
startTyping('bob', 'alice');
expect(visibleText()).toBe('alice is typing...');
});
it('skips typists missing from the user cache', () => {
renderRow();
startTyping('stranger', 'alice');
expect(RollingTypingStore.countTypists(CHANNEL.id)).toBe(2);
expect(visibleText()).toBe('alice is typing...');
});
it('renders nothing visible when only self is typing', () => {
renderRow();
startTyping('me');
expect(query('channel.typing-users.div')).toBeNull();
expect(announcer().textContent).toBe('');
});
it('marks the visible text as hidden from assistive technology', () => {
renderRow();
startTyping('alice');
const visible = query('channel.typing-users.span')!;
expect(visible.getAttribute('aria-hidden')).toBe('true');
expect(visible.hasAttribute('aria-live')).toBe(false);
expect(measure()?.getAttribute('aria-hidden')).toBe('true');
});
it('keeps the announcer mounted when nobody is typing', () => {
renderRow();
const mounted = announcer();
expect(mounted.textContent).toBe('');
startTyping('alice');
expect(announcer()).toBe(mounted);
expect(mounted.textContent).toBe('alice is typing...');
act(() => {
RollingTypingStore.remove(CHANNEL.id, 'alice');
});
expect(announcer()).toBe(mounted);
expect(mounted.textContent).toBe('');
});
it('announces through a polite atomic live region', () => {
renderRow();
startTyping('alice', 'bob', 'carol', 'dave');
expect(announcer().getAttribute('aria-live')).toBe('polite');
expect(announcer().getAttribute('aria-atomic')).toBe('true');
expect(announcer().textContent).toBe('Several people are typing...');
});
it('swaps to multiple people when the measured text plus 48 px exceeds the available width', () => {
setWidth(overflowContainer, 'clientWidth', 300);
renderRow({showAvatars: false});
startTyping('alice', 'bob');
expect(visibleText()).toBe('alice and bob are typing...');
setWidth(measure()!, 'scrollWidth', 253);
remeasure();
expect(visibleText()).toBe('Multiple people are typing...');
expect(measure()?.textContent).toBe('alice and bob are typing...');
});
it('keeps the names when they fit with 48 px to spare', () => {
setWidth(overflowContainer, 'clientWidth', 300);
renderRow({showAvatars: false});
startTyping('alice', 'bob');
setWidth(measure()!, 'scrollWidth', 252);
remeasure();
expect(visibleText()).toBe('alice and bob are typing...');
});
it('keeps the full string in the announcer while the visible text overflows', () => {
setWidth(overflowContainer, 'clientWidth', 300);
renderRow({showAvatars: false});
startTyping('alice', 'bob', 'carol');
setWidth(measure()!, 'scrollWidth', 400);
remeasure();
expect(visibleText()).toBe('Multiple people are typing...');
expect(announcer().textContent).toBe('alice, bob and carol are typing...');
});
it('restores the names when the container grows again', () => {
setWidth(overflowContainer, 'clientWidth', 300);
renderRow({showAvatars: false});
startTyping('alice');
setWidth(measure()!, 'scrollWidth', 280);
remeasure();
expect(visibleText()).toBe('Multiple people are typing...');
setWidth(overflowContainer, 'clientWidth', 400);
remeasure();
expect(visibleText()).toBe('alice is typing...');
});
it('does not measure overflow with four or more typists', () => {
setWidth(overflowContainer, 'clientWidth', 10);
renderRow();
startTyping('alice', 'bob', 'carol');
expect(visibleText()).toBe('Multiple people are typing...');
expect(activeResizeObservers()).toHaveLength(1);
startTyping('dave');
expect(visibleText()).toBe('Several people are typing...');
expect(measure()).toBeNull();
expect(activeResizeObservers()).toHaveLength(0);
});
it('subtracts the avatar stack width from the available width', () => {
setWidth(overflowContainer, 'clientWidth', 300);
renderRow({showAvatars: true});
startTyping('alice');
setWidth(measure()!, 'scrollWidth', 200);
remeasure();
expect(visibleText()).toBe('alice is typing...');
const avatarStack = host.querySelector(`.${messageStyles.typingAvatarContainer}`)!;
avatarStack.getBoundingClientRect = () => ({width: 60}) as DOMRect;
remeasure();
expect(visibleText()).toBe('Multiple people are typing...');
});
it('disconnects the resize observer on unmount', () => {
renderRow();
startTyping('alice');
const [resizeObserver] = activeResizeObservers();
expect(resizeObserver?.targets).toEqual([overflowContainer, measure()]);
act(() => {
root.unmount();
});
root = createRoot(host);
expect(resizeObserver?.disconnected).toBe(true);
expect(activeResizeObservers()).toHaveLength(0);
});
it("does not re-render on the current user's own typing", () => {
const onRender = vi.fn();
render(
createElement(
Profiler,
{id: 'typing-row', onRender},
createElement(RollingTypingUsers, {channel: CHANNEL, overflowContainerRef}),
),
);
startTyping('alice');
const commits = onRender.mock.calls.length;
startTyping('me');
act(() => {
RollingTypingStore.start(CHANNEL.id, 'me', 'gateway');
RollingTypingStore.start(CHANNEL.id, 'alice', 'gateway');
});
act(() => {
RollingTypingStore.remove(CHANNEL.id, 'me');
});
expect(onRender).toHaveBeenCalledTimes(commits);
expect(visibleText()).toBe('alice is typing...');
});
});
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Typing} from '@app/features/channel/components/ChannelTyping';
import rollingStyles from '@app/features/channel/components/RollingTypingUsers.module.css';
import styles from '@app/features/channel/components/TypingUsers.module.css';
import type {Channel} from '@app/features/channel/models/Channel';
import messageStyles from '@app/features/theme/styles/Message.module.css';
import RollingTypingStore from '@app/features/typing/rolling/RollingTypingStore';
import {getRollingTypingAnnouncement, getRollingTypingText} from '@app/features/typing/rolling/RollingTypingText';
import {
TYPING_ROLLING_MAX_NAMES,
TYPING_ROLLING_OVERFLOW_SLACK_PX,
} from '@app/features/typing/rolling/TypingSendThrottle';
import {AvatarStack} from '@app/features/ui/avatars/AvatarStack';
import type {User} from '@app/features/user/models/User';
import Users from '@app/features/user/state/Users';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {type RefObject, useLayoutEffect, useRef, useState} from 'react';
const AVATAR_THRESHOLD = 5;
const EMPTY_TYPING_USER_RECORDS: ReadonlyArray<User> = Object.freeze([]);
export const rollingUsePresentableTypingUsers = (channel: Channel): ReadonlyArray<User> => {
const typingUserIds = RollingTypingStore.getTypingUserIds(channel.id);
if (typingUserIds.length === 0) {
return EMPTY_TYPING_USER_RECORDS;
}
const typingUsers: Array<User> = [];
for (const userId of typingUserIds) {
const user = Users.getUser(userId);
if (user) {
typingUsers.push(user);
}
}
return typingUsers;
};
function getRenderedAvatarStackWidth(row: HTMLElement | null): number {
if (row === null) {
return 0;
}
for (const child of row.children) {
if (child.classList.contains(messageStyles.typingAvatarContainer)) {
return child.getBoundingClientRect().width;
}
}
return 0;
}
interface RollingTypingUsersProps {
channel: Channel;
withText?: boolean;
showAvatars?: boolean;
overflowContainerRef?: RefObject<HTMLElement | null>;
}
export const RollingTypingUsers = observer(
({channel, withText = true, showAvatars = true, overflowContainerRef}: RollingTypingUsersProps) => {
const {i18n} = useLingui();
const typingUsers = rollingUsePresentableTypingUsers(channel);
const rowRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
const measuresOverflow = withText && typingUsers.length > 0 && typingUsers.length <= TYPING_ROLLING_MAX_NAMES;
useLayoutEffect(() => {
const container = overflowContainerRef?.current ?? null;
const measure = measureRef.current;
if (!measuresOverflow || container === null || measure === null) {
return;
}
const evaluate = () => {
const availableWidth = container.clientWidth - getRenderedAvatarStackWidth(rowRef.current);
setOverflowing(measure.scrollWidth + TYPING_ROLLING_OVERFLOW_SLACK_PX > availableWidth);
};
evaluate();
const resizeObserver = new ResizeObserver(evaluate);
resizeObserver.observe(container);
resizeObserver.observe(measure);
return () => resizeObserver.disconnect();
}, [measuresOverflow, overflowContainerRef]);
if (typingUsers.length === 0) {
return null;
}
return (
<div
className={`${messageStyles.typingContainer} ${messageStyles.typingCluster} ${messageStyles.typingClusterComposerStatus}`}
data-flx="channel.typing-users.div"
>
<div ref={rowRef} className={styles.composerStatus} data-flx="channel.typing-users.div--2">
<div className={messageStyles.typingIndicator} data-flx="channel.typing-users.div--3">
<Typing
className={styles.typing}
size={20}
style={{
height: 'var(--typing-indicator-animation-size)',
width: 'var(--typing-indicator-animation-size)',
}}
data-flx="channel.typing-users.typing"
/>
</div>
{withText && (
<>
{showAvatars && (
<AvatarStack
size={12}
maxVisible={AVATAR_THRESHOLD}
className={messageStyles.typingAvatarContainer}
users={typingUsers}
guildId={channel.guildId}
channelId={channel.id}
data-flx="channel.typing-users.avatar-stack"
/>
)}
<span aria-hidden={true} className={messageStyles.typingText} data-flx="channel.typing-users.span">
{getRollingTypingText(i18n, typingUsers, channel, measuresOverflow && overflowing)}
</span>
{measuresOverflow && (
<span
ref={measureRef}
aria-hidden={true}
className={`${messageStyles.typingText} ${rollingStyles.measure}`}
data-flx="channel.rolling-typing-users.measure"
>
{getRollingTypingText(i18n, typingUsers, channel, false)}
</span>
)}
</>
)}
</div>
</div>
);
},
);
export const RollingTypingAnnouncer = observer(({channel}: {channel: Channel}) => {
const {i18n} = useLingui();
const typingUsers = rollingUsePresentableTypingUsers(channel);
return (
<span
className={rollingStyles.srOnly}
aria-live="polite"
aria-atomic={true}
data-flx="channel.rolling-typing-users.announcer"
>
{getRollingTypingAnnouncement(i18n, typingUsers, channel)}
</span>
);
});
@@ -0,0 +1,235 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
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 {act, createElement, Fragment, type ReactNode} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {renderToStaticMarkup} from 'react-dom/server';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const doubles = vi.hoisted(() => ({locale: 'en-US'}));
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),
useLingui: () => ({
i18n: {
locale: doubles.locale,
_: (descriptor: {message: string}, values: Record<string, string> = {}) =>
descriptor.message.replace(/\{(\w+)\}/g, (_placeholder, name: string) => values[name] ?? ''),
},
}),
};
});
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(() => Promise.resolve({ok: true}))},
}));
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: (userId: string) => ({id: userId, username: userId})},
}));
vi.mock('@app/features/user/utils/NicknameUtils', () => ({
getNickname: (user: {username: string}) => user.username,
}));
vi.mock('@app/features/member/state/GuildMembers', () => ({
default: {getMember: (_guildId: string, userId: string) => ({getColorString: () => `var(--member-${userId})`})},
}));
vi.mock('@app/features/channel/components/ChannelTyping', async () => {
const {createElement: create} = await import('react');
return {Typing: ({className}: {className?: string}) => create('svg', {className})};
});
vi.mock('@app/features/ui/avatars/AvatarStack', async () => {
const {createElement: create} = await import('react');
return {
AvatarStack: ({className, users}: {className?: string; users: ReadonlyArray<{id: string}>}) =>
create('div', {className, 'data-avatars': users.map((user) => user.id).join(' ')}),
};
});
const {TypingAnnouncer, TypingUsers, getTypingText} = await import('@app/features/channel/components/TypingUsers');
const {TypingUsers: LegacyTypingUsers} = await import('@app/features/channel/components/LegacyTypingUsers');
const {default: TypingPolicy} = await import('@app/features/typing/state/TypingPolicy');
const {default: LegacyTypingIndicator} = await import('@app/features/typing/legacy/LegacyTypingIndicator');
const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore');
const {getTypingTierText} = await import('@app/features/typing/utils/TypingTierText');
const CHANNEL = {id: 'channel', guildId: 'guild'} as unknown as Channel;
const TYPIST_IDS = ['alice', 'bob', 'carol', 'dave', 'erin', 'frank', 'grace'];
let hosts: Array<{host: HTMLDivElement; root: Root}> = [];
function mount(node: ReactNode): HTMLDivElement {
const host = document.createElement('div');
document.body.append(host);
const root = createRoot(host);
hosts.push({host, root});
act(() => {
root.render(node);
});
return host;
}
function i18nFor(locale: string): I18n {
return {locale, _: (descriptor: {message: string}) => `${locale}: ${descriptor.message}`} as unknown as I18n;
}
function usersFor(count: number): Array<User> {
return TYPIST_IDS.slice(0, count).map((id) => ({id, username: id}) as unknown as User);
}
function manyUsers(count: number): Array<User> {
return Array.from(
{length: count},
(_, index) => ({id: `typist-${index}`, username: `typist-${index}`}) as unknown as User,
);
}
function markupOf(node: ReactNode): string {
return renderToStaticMarkup(createElement(Fragment, null, node));
}
beforeEach(() => {
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
doubles.locale = 'en-US';
vi.useFakeTimers();
});
afterEach(() => {
act(() => {
for (const {root} of hosts) {
root.unmount();
}
});
hosts = [];
TypingPolicy.applyPolicy('legacy');
LegacyTypingIndicator.reset();
RollingTypingStore.reset();
document.body.replaceChildren();
vi.useRealTimers();
});
describe('TypingUsers facade', () => {
it('renders the same markup as the legacy component under control', () => {
for (const count of [1, 2, 3, 4, 7]) {
LegacyTypingIndicator.reset();
for (const userId of TYPIST_IDS.slice(0, count)) {
LegacyTypingIndicator.startRemoteTyping(CHANNEL.id, userId);
}
const facadeHost = mount(createElement(TypingUsers, {channel: CHANNEL, withText: true, showAvatars: true}));
const legacyHost = mount(createElement(LegacyTypingUsers, {channel: CHANNEL, withText: true, showAvatars: true}));
expect(facadeHost.innerHTML).not.toBe('');
expect(facadeHost.innerHTML).toBe(legacyHost.innerHTML);
}
});
it('renders no announcer under control', () => {
LegacyTypingIndicator.startRemoteTyping(CHANNEL.id, 'alice');
const host = mount(createElement(TypingAnnouncer, {channel: CHANNEL}));
expect(host.innerHTML).toBe('');
expect(host.querySelector('[aria-live]')).toBeNull();
});
it('renders the rolling row under treatment', () => {
TypingPolicy.applyPolicy('rolling');
RollingTypingStore.start(CHANNEL.id, 'alice', 'gateway');
const host = mount(
createElement(
Fragment,
null,
createElement(TypingUsers, {channel: CHANNEL, withText: true, showAvatars: true}),
createElement(TypingAnnouncer, {channel: CHANNEL}),
),
);
const visible = host.querySelector('[data-flx="channel.typing-users.span"]');
expect(visible?.textContent).toBe('alice is typing...');
expect(visible?.getAttribute('aria-hidden')).toBe('true');
expect(host.querySelector('[data-flx="channel.rolling-typing-users.measure"]')).not.toBeNull();
expect(host.querySelector('[aria-live="polite"]')?.textContent).toBe('alice is typing...');
});
it('switches rows when the policy flips', () => {
LegacyTypingIndicator.startRemoteTyping(CHANNEL.id, 'alice');
const host = mount(
createElement(
Fragment,
null,
createElement(TypingUsers, {channel: CHANNEL}),
createElement(TypingAnnouncer, {channel: CHANNEL}),
),
);
expect(host.querySelector('[data-flx="channel.typing-users.span"]')?.getAttribute('aria-live')).toBe('polite');
expect(host.querySelector('[data-flx="channel.rolling-typing-users.announcer"]')).toBeNull();
act(() => {
TypingPolicy.applyPolicy('rolling');
});
expect(host.querySelector('[data-flx="channel.typing-users.div"]')).toBeNull();
expect(host.querySelector('[data-flx="channel.rolling-typing-users.announcer"]')?.textContent).toBe('');
act(() => {
RollingTypingStore.start(CHANNEL.id, 'bob', 'gateway');
});
expect(host.querySelector('[data-flx="channel.typing-users.span"]')?.getAttribute('aria-hidden')).toBe('true');
expect(host.querySelector('[data-flx="channel.typing-users.span"]')?.textContent).toBe('bob is typing...');
act(() => {
TypingPolicy.applyPolicy('legacy');
});
expect(host.querySelector('[data-flx="channel.typing-users.div"]')).toBeNull();
expect(host.querySelector('[data-flx="channel.rolling-typing-users.announcer"]')).toBeNull();
});
it('keeps the one, two and three name forms identical in both arms', () => {
for (const count of [1, 2, 3]) {
const users = usersFor(count);
const legacyMarkup = markupOf(getTypingText(i18nFor('en-US'), users, CHANNEL));
TypingPolicy.applyPolicy('rolling');
const rollingMarkup = markupOf(getTypingText(i18nFor('en-US'), users, CHANNEL));
TypingPolicy.applyPolicy('legacy');
expect(rollingMarkup).toBe(legacyMarkup);
}
expect(markupOf(getTypingText(i18nFor('en-US'), usersFor(3), CHANNEL)).replace(/<[^>]+>/g, '')).toBe(
'alice, bob and carol are typing...',
);
});
it('resolves the four or more ladder per locale identically in both arms', () => {
for (const locale of ['en-US', 'en-GB', 'de', 'fr', 'ja', 'pt-BR']) {
const i18n = i18nFor(locale);
for (const count of [4, 5, 9, 10, 14, 15, 19, 20, 31]) {
const users = manyUsers(count);
const expected = getTypingTierText(i18n, count);
const legacyText = getTypingText(i18n, users, CHANNEL);
TypingPolicy.applyPolicy('rolling');
const rollingText = getTypingText(i18n, users, CHANNEL);
TypingPolicy.applyPolicy('legacy');
expect(legacyText, `${locale} ${count}`).toBe(expected);
expect(rollingText, `${locale} ${count}`).toBe(expected);
}
}
expect(getTypingText(i18nFor('en-GB'), manyUsers(12), CHANNEL)).toBe('A symphony of clacking keys is underway...');
expect(getTypingText(i18nFor('de'), manyUsers(12), CHANNEL)).toBe('de: Several people are typing...');
});
});
@@ -1,160 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Authentication from '@app/features/auth/state/Authentication';
import {Typing} from '@app/features/channel/components/ChannelTyping';
import styles from '@app/features/channel/components/TypingUsers.module.css';
import {
getTypingText as getLegacyTypingText,
TypingUsers as LegacyTypingUsers,
usePresentableTypingUsers as legacyUsePresentableTypingUsers,
} from '@app/features/channel/components/LegacyTypingUsers';
import {
RollingTypingAnnouncer,
RollingTypingUsers,
rollingUsePresentableTypingUsers,
} from '@app/features/channel/components/RollingTypingUsers';
import type {Channel} from '@app/features/channel/models/Channel';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import GuildMembers from '@app/features/member/state/GuildMembers';
import Relationships from '@app/features/relationship/state/Relationships';
import messageStyles from '@app/features/theme/styles/Message.module.css';
import TypingIndicator from '@app/features/typing/state/TypingIndicator';
import {AvatarStack} from '@app/features/ui/avatars/AvatarStack';
import {getRollingTypingText} from '@app/features/typing/rolling/RollingTypingText';
import TypingPolicy from '@app/features/typing/state/TypingPolicy';
import type {User} from '@app/features/user/models/User';
import Users from '@app/features/user/state/Users';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type {ReactNode, RefObject} from 'react';
const SEVERAL_PEOPLE_ARE_TYPING_DESCRIPTOR = msg({
message: 'Several people are typing...',
comment: 'Label in the channel and chat typing users.',
});
const A_HANDFUL_OF_KEYBOARD_WARRIORS_ARE_ASSEMBLING_DESCRIPTOR = msg({
message: 'A handful of keyboard warriors are assembling...',
comment: 'Description text in the channel and chat typing users.',
});
const A_SYMPHONY_OF_CLACKING_KEYS_IS_UNDERWAY_DESCRIPTOR = msg({
message: 'A symphony of clacking keys is underway...',
comment: 'Description text in the channel and chat typing users.',
});
const IT_S_A_FULL_BLOWN_TYPING_FIESTA_IN_DESCRIPTOR = msg({
message: "It's a full-blown typing fiesta in here",
comment: 'Label in the channel and chat typing users.',
});
const WHOA_IT_S_A_TYPING_APOCALYPSE_DESCRIPTOR = msg({
message: "Whoa, it's a typing apocalypse",
comment: 'Label in the channel and chat typing users.',
});
const SEVERAL_PEOPLE_DESCRIPTOR = SEVERAL_PEOPLE_ARE_TYPING_DESCRIPTOR;
const HANDFUL_DESCRIPTOR = A_HANDFUL_OF_KEYBOARD_WARRIORS_ARE_ASSEMBLING_DESCRIPTOR;
const SYMPHONY_DESCRIPTOR = A_SYMPHONY_OF_CLACKING_KEYS_IS_UNDERWAY_DESCRIPTOR;
const FIESTA_DESCRIPTOR = IT_S_A_FULL_BLOWN_TYPING_FIESTA_IN_DESCRIPTOR;
const APOCALYPSE_DESCRIPTOR = WHOA_IT_S_A_TYPING_APOCALYPSE_DESCRIPTOR;
const getDisplayName = (user: User, guildId?: string | null) => NicknameUtils.getNickname(user, guildId ?? null);
export const getTypingText = (i18n: I18n, typingUsers: ReadonlyArray<User>, channel: Channel) => {
const [a, b, c] = typingUsers.map((user) => {
const member = GuildMembers.getMember(channel.guildId ?? '', user.id);
return (
<span
key={user.id}
className={styles.username}
style={{color: member?.getColorString()}}
data-flx="channel.typing-users.get-typing-text.username"
>
{getDisplayName(user, channel.guildId)}
</span>
);
});
if (typingUsers.length === 1) {
return <Trans>{a} is typing...</Trans>;
}
if (typingUsers.length === 2) {
return (
<Trans>
{a} and {b} are typing...
</Trans>
);
}
if (typingUsers.length === 3) {
return (
<Trans>
{a}, {b} and {c} are typing...
</Trans>
);
}
if (typingUsers.length === 4) {
return i18n._(SEVERAL_PEOPLE_DESCRIPTOR);
}
if (typingUsers.length > 4 && typingUsers.length < 10) {
return i18n._(HANDFUL_DESCRIPTOR);
}
if (typingUsers.length > 9 && typingUsers.length < 15) {
return i18n._(SYMPHONY_DESCRIPTOR);
}
if (typingUsers.length > 14 && typingUsers.length < 20) {
return i18n._(FIESTA_DESCRIPTOR);
}
return i18n._(APOCALYPSE_DESCRIPTOR);
};
const EMPTY_TYPING_USER_RECORDS: ReadonlyArray<User> = Object.freeze([]);
export const usePresentableTypingUsers = (channel: Channel): ReadonlyArray<User> => {
const typingUserIds = TypingIndicator.getTypingUsers(channel.id);
if (typingUserIds.length === 0) return EMPTY_TYPING_USER_RECORDS;
const currentUserId = Authentication.currentUserId;
const showSelf = DeveloperOptions.showMyselfTyping;
const result: Array<User> = [];
for (const userId of typingUserIds) {
if (!showSelf && userId === currentUserId) continue;
if (Relationships.isBlocked(userId)) continue;
const user = Users.getUser(userId);
if (user) result.push(user);
}
if (result.length === 0) return EMPTY_TYPING_USER_RECORDS;
return result;
};
const AVATAR_THRESHOLD = 5;
export const TypingUsers = observer(
({channel, withText = true, showAvatars = true}: {channel: Channel; withText?: boolean; showAvatars?: boolean}) => {
const {i18n} = useLingui();
const typingUsers = usePresentableTypingUsers(channel);
if (typingUsers.length === 0) {
return null;
}
return (
<div
className={`${messageStyles.typingContainer} ${messageStyles.typingCluster} ${messageStyles.typingClusterComposerStatus}`}
data-flx="channel.typing-users.div"
>
<div className={styles.composerStatus} data-flx="channel.typing-users.div--2">
<div className={messageStyles.typingIndicator} data-flx="channel.typing-users.div--3">
<Typing
className={styles.typing}
size={20}
style={{
height: 'var(--typing-indicator-animation-size)',
width: 'var(--typing-indicator-animation-size)',
}}
data-flx="channel.typing-users.typing"
/>
</div>
{withText && (
<>
{showAvatars && (
<AvatarStack
size={12}
maxVisible={AVATAR_THRESHOLD}
className={messageStyles.typingAvatarContainer}
users={typingUsers}
guildId={channel.guildId}
channelId={channel.id}
data-flx="channel.typing-users.avatar-stack"
/>
)}
<span
aria-atomic={true}
aria-live="polite"
className={messageStyles.typingText}
data-flx="channel.typing-users.span"
>
{getTypingText(i18n, typingUsers, channel)}
</span>
</>
)}
</div>
</div>
);
},
interface TypingUsersProps {
channel: Channel;
withText?: boolean;
showAvatars?: boolean;
overflowContainerRef?: RefObject<HTMLElement | null>;
}
export const getTypingText = (i18n: I18n, typingUsers: ReadonlyArray<User>, channel: Channel): ReactNode =>
TypingPolicy.active === 'rolling'
? getRollingTypingText(i18n, typingUsers, channel, false)
: getLegacyTypingText(i18n, typingUsers, channel);
export const usePresentableTypingUsers = (channel: Channel): ReadonlyArray<User> =>
TypingPolicy.active === 'rolling'
? rollingUsePresentableTypingUsers(channel)
: legacyUsePresentableTypingUsers(channel);
export const TypingUsers = observer(({channel, withText, showAvatars, overflowContainerRef}: TypingUsersProps) =>
TypingPolicy.active === 'rolling' ? (
<RollingTypingUsers
channel={channel}
withText={withText}
showAvatars={showAvatars}
overflowContainerRef={overflowContainerRef}
data-flx="channel.typing-users.rolling-typing-users"
/>
) : (
<LegacyTypingUsers
channel={channel}
withText={withText}
showAvatars={showAvatars}
data-flx="channel.typing-users.legacy-typing-users"
/>
),
);
export const TypingAnnouncer = observer(({channel}: {channel: Channel}) =>
TypingPolicy.active === 'rolling' ? (
<RollingTypingAnnouncer channel={channel} data-flx="channel.typing-users.rolling-typing-announcer" />
) : null,
);