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
@@ -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,
|
||||
);
|
||||
Reference in new issue
Block a user