/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {Typing} from '@app/components/channel/Typing'; import styles from '@app/components/channel/TypingUsers.module.css'; import {AvatarStack} from '@app/components/uikit/avatars/AvatarStack'; import {ComponentDispatch} from '@app/lib/ComponentDispatch'; import type {ChannelRecord} from '@app/records/ChannelRecord'; import type {UserRecord} from '@app/records/UserRecord'; import AuthenticationStore from '@app/stores/AuthenticationStore'; import DeveloperOptionsStore from '@app/stores/DeveloperOptionsStore'; import GuildMemberStore from '@app/stores/GuildMemberStore'; import RelationshipStore from '@app/stores/RelationshipStore'; import TypingStore from '@app/stores/TypingStore'; import UserStore from '@app/stores/UserStore'; import messageStyles from '@app/styles/Message.module.css'; import * as NicknameUtils from '@app/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 {useEffect, useState} from 'react'; const SEVERAL_PEOPLE_DESCRIPTOR = msg`Several people are typing...`; const HANDFUL_DESCRIPTOR = msg`A handful of keyboard warriors are assembling...`; const SYMPHONY_DESCRIPTOR = msg`A symphony of clacking keys is underway...`; const FIESTA_DESCRIPTOR = msg`It's a full-blown typing fiesta in here`; const APOCALYPSE_DESCRIPTOR = msg`Whoa, it's a typing apocalypse`; const getDisplayName = (user: UserRecord, guildId?: string | null) => NicknameUtils.getNickname(user, guildId ?? undefined); export const getTypingText = (i18n: I18n, typingUsers: Array, channel: ChannelRecord) => { const [a, b, c] = typingUsers.map((user) => { const member = GuildMemberStore.getMember(channel.guildId ?? '', user.id); return ( {getDisplayName(user, channel.guildId)} ); }); if (typingUsers.length === 1) { return {a} is typing...; } if (typingUsers.length === 2) { return ( {a} and {b} are typing... ); } if (typingUsers.length === 3) { return ( {a}, {b} and {c} are typing... ); } 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); }; export const usePresentableTypingUsers = (channel: ChannelRecord) => { const typingUserIds = TypingStore.getTypingUsers(channel.id); const currentUserId = AuthenticationStore.currentUserId; const showSelf = DeveloperOptionsStore.showMyselfTyping; const filteredTypingUserIds = typingUserIds.filter( (userId) => (showSelf || userId !== currentUserId) && !RelationshipStore.isBlocked(userId), ); return [...filteredTypingUserIds] .map((userId) => UserStore.getUser(userId)) .filter((user): user is UserRecord => user != null); }; const AVATAR_THRESHOLD = 5; export const TypingUsers = observer( ({ channel, withText = true, showAvatars = true, }: { channel: ChannelRecord; withText?: boolean; showAvatars?: boolean; }) => { const {i18n} = useLingui(); const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false); useEffect(() => { const unsubscribe = ComponentDispatch.subscribe('TEXTAREA_AUTOCOMPLETE_CHANGED', (payload?: unknown) => { const {channelId, open} = (payload ?? {}) as {channelId?: string; open?: boolean}; if (channelId === channel.id) { setIsAutocompleteOpen(!!open); } }); return unsubscribe; }, [channel.id]); const typingUsers = usePresentableTypingUsers(channel); if (typingUsers.length === 0 || isAutocompleteOpen) { return null; } return (
{withText && ( <> {showAvatars && ( )} {getTypingText(i18n, typingUsers, channel)} )}
); }, );