[skip ci] feat: prepare for public release

This commit is contained in:
Hampus Kraft
2026-01-02 19:27:51 +00:00
parent 197b23757f
commit 5ae825fc7d
199 changed files with 38391 additions and 33358 deletions

View File

@@ -23,8 +23,11 @@ import {PhoneIcon, VideoCameraIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as CallActionCreators from '~/actions/CallActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {ChannelTypes} from '~/Constants';
import type {ChannelRecord} from '~/records/ChannelRecord';
import CallStateStore from '~/stores/CallStateStore';
import UserStore from '~/stores/UserStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import * as CallUtils from '~/utils/CallUtils';
import {ChannelHeaderIcon} from './ChannelHeaderIcon';
@@ -38,9 +41,20 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
const participants = call ? CallStateStore.getParticipants(channel.id) : [];
const participantCount = participants.length;
const currentUser = UserStore.getCurrentUser();
const isUnclaimed = !(currentUser?.isClaimed() ?? false);
const is1to1 = channel.type === ChannelTypes.DM;
const blocked = isUnclaimed && is1to1;
const handleClick = React.useCallback(
async (event: React.MouseEvent) => {
if (blocked) {
ToastActionCreators.createToast({
type: 'error',
children: t`Claim your account to start or join 1:1 calls.`,
});
return;
}
if (isInCall) {
void CallActionCreators.leaveCall(channel.id);
} else if (hasActiveCall) {
@@ -50,7 +64,7 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
await CallUtils.checkAndStartCall(channel.id, silent);
}
},
[channel.id, isInCall, hasActiveCall],
[channel.id, isInCall, hasActiveCall, blocked],
);
let label: string;
@@ -67,7 +81,13 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
: t`Join Voice Call (${participantCount} participants)`;
}
} else {
label = isInCall ? t`Leave Voice Call` : hasActiveCall ? t`Join Voice Call` : t`Start Voice Call`;
label = blocked
? t`Claim your account to call`
: isInCall
? t`Leave Voice Call`
: hasActiveCall
? t`Join Voice Call`
: t`Start Voice Call`;
}
return (
@@ -76,6 +96,7 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
label={label}
isSelected={isInCall}
onClick={handleClick}
disabled={blocked}
keybindAction="start_pm_call"
/>
);
@@ -90,9 +111,20 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
const participants = call ? CallStateStore.getParticipants(channel.id) : [];
const participantCount = participants.length;
const currentUser = UserStore.getCurrentUser();
const isUnclaimed = !(currentUser?.isClaimed() ?? false);
const is1to1 = channel.type === ChannelTypes.DM;
const blocked = isUnclaimed && is1to1;
const handleClick = React.useCallback(
async (event: React.MouseEvent) => {
if (blocked) {
ToastActionCreators.createToast({
type: 'error',
children: t`Claim your account to start or join 1:1 calls.`,
});
return;
}
if (isInCall) {
void CallActionCreators.leaveCall(channel.id);
} else if (hasActiveCall) {
@@ -102,7 +134,7 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
await CallUtils.checkAndStartCall(channel.id, silent);
}
},
[channel.id, isInCall, hasActiveCall],
[channel.id, isInCall, hasActiveCall, blocked],
);
let label: string;
@@ -119,10 +151,24 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
: t`Join Video Call (${participantCount} participants)`;
}
} else {
label = isInCall ? t`Leave Video Call` : hasActiveCall ? t`Join Video Call` : t`Start Video Call`;
label = blocked
? t`Claim your account to call`
: isInCall
? t`Leave Video Call`
: hasActiveCall
? t`Join Video Call`
: t`Start Video Call`;
}
return <ChannelHeaderIcon icon={VideoCameraIcon} label={label} isSelected={isInCall} onClick={handleClick} />;
return (
<ChannelHeaderIcon
icon={VideoCameraIcon}
label={label}
isSelected={isInCall}
onClick={handleClick}
disabled={blocked}
/>
);
});
export const CallButtons = {

View File

@@ -33,7 +33,7 @@ import type {UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import MemberSidebarStore from '~/stores/MemberSidebarStore';
import UserStore from '~/stores/UserStore';
import type {GroupDMMemberGroup, MemberGroup} from '~/utils/MemberListUtils';
import type {GroupDMMemberGroup} from '~/utils/MemberListUtils';
import * as MemberListUtils from '~/utils/MemberListUtils';
import * as NicknameUtils from '~/utils/NicknameUtils';
import styles from './ChannelMembers.module.css';
@@ -65,35 +65,6 @@ const SkeletonMemberItem = ({index}: {index: number}) => {
);
};
const _MemberListGroup = observer(
({guild, group, channelId}: {guild: GuildRecord; group: MemberGroup; channelId: string}) => (
<div className={styles.groupContainer}>
<div className={styles.groupHeader}>
{group.displayName} {group.count}
</div>
<div className={styles.membersList}>
{group.members.map((member: GuildMemberRecord) => {
const user = member.user;
const userId = user.id;
return (
<MemberListItem
key={userId}
user={user}
channelId={channelId}
guildId={guild.id}
isOwner={guild.isOwner(userId)}
roleColor={member.getColorString?.() ?? undefined}
displayName={NicknameUtils.getNickname(user, guild.id)}
disableBackdrop={true}
/>
);
})}
</div>
<div className={styles.groupSpacer} />
</div>
),
);
interface GroupDMMemberListGroupProps {
group: GroupDMMemberGroup;
channelId: string;

View File

@@ -40,9 +40,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import {MessagePreviewContext} from '~/Constants';
import {Message, type MessageBehaviorOverrides} from '~/components/channel/Message';
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
import {MessageContextPrefix} from '~/components/shared/MessageContextPrefix/MessageContextPrefix';
import {Avatar} from '~/components/uikit/Avatar';
import {Button} from '~/components/uikit/Button/Button';
import {ContextMenuCloseProvider} from '~/components/uikit/ContextMenu/ContextMenu';
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
@@ -58,9 +56,7 @@ import ChannelSearchStore, {getChannelSearchContextId} from '~/stores/ChannelSea
import ChannelStore from '~/stores/ChannelStore';
import GuildNSFWAgreeStore from '~/stores/GuildNSFWAgreeStore';
import GuildStore from '~/stores/GuildStore';
import UserStore from '~/stores/UserStore';
import {applyChannelSearchHighlight, clearChannelSearchHighlight} from '~/utils/ChannelSearchHighlight';
import * as ChannelUtils from '~/utils/ChannelUtils';
import {goToMessage} from '~/utils/MessageNavigator';
import * as RouterUtils from '~/utils/RouterUtils';
import {tokenizeSearchQuery} from '~/utils/SearchQueryTokenizer';
@@ -78,14 +74,6 @@ import type {SearchMachineState} from './SearchResultsUtils';
import {areSegmentsEqual} from './SearchResultsUtils';
import {DEFAULT_SCOPE_VALUE, getScopeOptionsForChannel} from './searchScopeOptions';
const getChannelDisplayName = (channel: ChannelRecord): string => {
if (channel.isPrivate()) {
return ChannelUtils.getDMDisplayName(channel);
}
return channel.name?.trim() || ChannelUtils.getName(channel);
};
const getChannelGuild = (channel: ChannelRecord): GuildRecord | null => {
if (!channel.guildId) {
return null;
@@ -101,37 +89,6 @@ const getChannelPath = (channel: ChannelRecord): string => {
return Routes.dmChannel(channel.id);
};
const _renderChannelIcon = (channel: ChannelRecord): React.ReactNode => {
if (channel.isPersonalNotes()) {
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
}
if (channel.isDM()) {
const recipientId = channel.recipientIds[0];
const recipient = recipientId ? UserStore.getUser(recipientId) : null;
if (recipient) {
return (
<div className={styles.channelIconAvatar}>
<Avatar user={recipient} size={20} status={null} className={styles.channelIconAvatarImage} />
</div>
);
}
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
}
if (channel.isGroupDM()) {
return (
<div className={styles.channelIconAvatar}>
<GroupDMAvatar channel={channel} size={20} disableStatusIndicator />
</div>
);
}
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
};
interface ChannelSearchResultsProps {
channel: ChannelRecord;
searchQuery: string;
@@ -753,7 +710,6 @@ export const ChannelSearchResults = observer(
}
const channelGuild = getChannelGuild(messageChannel);
const _channelDisplayName = getChannelDisplayName(messageChannel);
const showGuildMeta = shouldShowGuildMetaForScope(
channelGuild,
(activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope,

View File

@@ -31,6 +31,7 @@ import {
} from '~/components/embeds/EmbedCard/EmbedCard';
import cardStyles from '~/components/embeds/EmbedCard/EmbedCard.module.css';
import {useEmbedSkeletonOverride} from '~/components/embeds/EmbedCard/useEmbedSkeletonOverride';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {Button} from '~/components/uikit/Button/Button';
import i18n from '~/i18n';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
@@ -48,6 +49,7 @@ export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) {
const giftState = GiftStore.gifts.get(code) ?? null;
const gift = giftState?.data;
const creator = UserStore.getUser(gift?.created_by?.id ?? '');
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
const shouldForceSkeleton = useEmbedSkeletonOverride();
React.useEffect(() => {
@@ -76,6 +78,10 @@ export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) {
const durationText = getGiftDurationText(i18n, gift);
const handleRedeem = async () => {
if (isUnclaimed) {
openClaimAccountModal({force: true});
return;
}
try {
await GiftActionCreators.redeem(i18n, code);
} catch (error) {
@@ -87,17 +93,22 @@ export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) {
<span className={styles.subRow}>{t`From ${creator.username}#${creator.discriminator}`}</span>
) : undefined;
const helpText = gift.redeemed ? t`Already redeemed` : t`Click to claim your gift!`;
const helpText = gift.redeemed
? t`Already redeemed`
: isUnclaimed
? t`Claim your account to redeem this gift.`
: t`Click to claim your gift!`;
const footer = gift.redeemed ? (
<Button variant="primary" matchSkeletonHeight disabled>
{t`Gift Claimed`}
</Button>
) : (
<Button variant="primary" matchSkeletonHeight onClick={handleRedeem}>
{t`Claim Gift`}
</Button>
);
const footer =
gift.redeemed && !isUnclaimed ? (
<Button variant="primary" matchSkeletonHeight disabled>
{t`Gift Claimed`}
</Button>
) : (
<Button variant="primary" matchSkeletonHeight onClick={handleRedeem} disabled={gift.redeemed || isUnclaimed}>
{gift.redeemed ? t`Gift Claimed` : isUnclaimed ? t`Claim Account to Redeem` : t`Claim Gift`}
</Button>
);
return (
<EmbedCard

View File

@@ -108,7 +108,6 @@ const MessageReactionItem = observer(
const emojiName = getEmojiName(reaction.emoji);
const emojiUrl = useEmojiURL({emoji: reaction.emoji, isHovering});
const _emojiIdentifier = reaction.emoji.id ?? reaction.emoji.name;
const isUnicodeEmoji = reaction.emoji.id == null;
const variants = {

View File

@@ -280,9 +280,7 @@ export const Messages = observer(function Messages({channel}: {channel: ChannelR
const data = payload as {channelId?: string; heightDelta?: number} | undefined;
if (data?.channelId && data.channelId !== channel.id) return;
if (scrollManager.isPinned()) {
scrollManager.handleScroll();
}
scrollManager.handleScroll();
};
const onFocusBottommostMessage = (payload?: unknown) => {

View File

@@ -23,7 +23,7 @@ import {observer} from 'mobx-react-lite';
import {useEffect, useState} from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {PhoneAddModal} from '~/components/modals/PhoneAddModal';
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
import {Button} from '~/components/uikit/Button/Button';
@@ -103,7 +103,7 @@ export const UnclaimedAccountBarrier = observer(({onAction}: BarrierProps) => {
small={true}
onClick={() => {
onAction?.();
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
openClaimAccountModal({force: true});
}}
>
<Trans>Claim Account</Trans>
@@ -234,7 +234,7 @@ export const UnclaimedDMBarrier = observer(({onAction}: BarrierProps) => {
small={true}
onClick={() => {
onAction?.();
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
openClaimAccountModal({force: true});
}}
>
<Trans>Claim Account</Trans>

View File

@@ -205,6 +205,7 @@ export const DMChannelView = observer(({channelId}: DMChannelViewProps) => {
const title = isDM && displayName ? `@${displayName}` : displayName;
useFluxerDocumentTitle(title);
const isGroupDM = channel?.type === ChannelTypes.GROUP_DM;
const isPersonalNotes = channel?.type === ChannelTypes.DM_PERSONAL_NOTES;
const callHeaderState = useCallHeaderState(channel);
const call = callHeaderState.call;
const showCompactVoiceView = callHeaderState.controlsVariant === 'inCall';
@@ -411,7 +412,7 @@ export const DMChannelView = observer(({channelId}: DMChannelViewProps) => {
textarea={
isDM && isRecipientBlocked && recipient ? (
<BlockedUserBarrier userId={recipient.id} username={recipient.username} />
) : isCurrentUserUnclaimed ? (
) : isCurrentUserUnclaimed && isDM && !isPersonalNotes && !isGroupDM ? (
<UnclaimedDMBarrier />
) : (
<ChannelTextarea channel={channel} />

View File

@@ -17,15 +17,19 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {WarningCircleIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as RelationshipActionCreators from '~/actions/RelationshipActionCreators';
import {APIErrorCodes} from '~/Constants';
import {Input} from '~/components/form/Input';
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
import {Button} from '~/components/uikit/Button/Button';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import UserStore from '~/stores/UserStore';
import {getApiErrorCode} from '~/utils/ApiErrorUtils';
import styles from './AddFriendForm.module.css';
@@ -35,11 +39,30 @@ interface AddFriendFormProps {
export const AddFriendForm: React.FC<AddFriendFormProps> = observer(({onSuccess}) => {
const {t} = useLingui();
const [input, setInput] = React.useState('');
const [isLoading, setIsLoading] = React.useState(false);
const [resultStatus, setResultStatus] = React.useState<'success' | 'error' | null>(null);
const [errorCode, setErrorCode] = React.useState<string | null>(null);
const isClaimed = UserStore.currentUser?.isClaimed() ?? true;
if (!isClaimed) {
return (
<StatusSlate
Icon={WarningCircleIcon}
title={<Trans>Claim your account</Trans>}
description={<Trans>Claim your account to send friend requests.</Trans>}
actions={[
{
text: <Trans>Claim Account</Trans>,
onClick: () => openClaimAccountModal({force: true}),
variant: 'primary',
},
]}
/>
);
}
const parseInput = (input: string): [string, string] => {
const parts = input.split('#');
if (parts.length > 1) {

View File

@@ -29,6 +29,7 @@ import {AvatarStack} from '~/components/uikit/avatars/AvatarStack';
import {Button} from '~/components/uikit/Button/Button';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {ProfileRecord} from '~/records/ProfileRecord';
import GuildMemberStore from '~/stores/GuildMemberStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
@@ -92,6 +93,7 @@ export const DMWelcomeSection: React.FC<DMWelcomeSectionProps> = observer(functi
};
const hasMutualGuilds = mutualGuilds.length > 0;
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
const shouldShowActionButton =
!user.bot &&
(relationshipType === undefined ||
@@ -103,12 +105,22 @@ export const DMWelcomeSection: React.FC<DMWelcomeSectionProps> = observer(functi
const renderActionButton = () => {
if (user.bot) return null;
switch (relationshipType) {
case undefined:
return (
<Button small={true} onClick={handleSendFriendRequest}>
case undefined: {
const tooltipText = t`Claim your account to send friend requests.`;
const button = (
<Button small={true} onClick={handleSendFriendRequest} disabled={currentUserUnclaimed}>
<Trans>Send Friend Request</Trans>
</Button>
);
if (currentUserUnclaimed) {
return (
<Tooltip text={tooltipText} maxWidth="xl">
<div>{button}</div>
</Tooltip>
);
}
return button;
}
case RelationshipTypes.INCOMING_REQUEST:
return (
<div className={styles.actionButtonsContainer}>

View File

@@ -457,8 +457,8 @@ export const EmbedGifv: FC<
const {width, aspectRatio} = style;
const containerStyle = {
'--embed-width': `${width}px`,
maxWidth: `${width}px`,
width: '100%',
maxWidth: '100%',
width,
aspectRatio,
} as React.CSSProperties;
@@ -488,7 +488,7 @@ export const EmbedGifv: FC<
type="gifv"
handlePress={openImagePreview}
>
<div className={styles.videoWrapper}>
<div className={styles.videoWrapper} style={aspectRatio ? {aspectRatio} : undefined}>
{(!loaded || error) && thumbHashURL && (
<img src={thumbHashURL} className={styles.thumbHashPlaceholder} alt={t`Loading placeholder`} />
)}
@@ -551,6 +551,7 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
const {shouldBlur, gateReason} = useNSFWMedia(nsfw, channelId);
const containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const isHoveredRef = useRef(false);
const defaultName = deriveDefaultNameFromMessage({
message,
@@ -622,16 +623,21 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
useEffect(() => {
if (gifAutoPlay) return;
const img = imgRef.current;
const container = containerRef.current;
if (!img || !container) return;
if (!container) return;
const handleMouseEnter = () => {
if (FocusManager.isFocused() && img) {
img.src = optimizedAnimatedURL;
isHoveredRef.current = true;
if (FocusManager.isFocused()) {
const img = imgRef.current;
if (img) {
img.src = optimizedAnimatedURL;
}
}
};
const handleMouseLeave = () => {
isHoveredRef.current = false;
const img = imgRef.current;
if (img) {
img.src = optimizedStaticURL;
}
@@ -646,6 +652,24 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
};
}, [gifAutoPlay, optimizedAnimatedURL, optimizedStaticURL]);
useEffect(() => {
if (gifAutoPlay) return;
const unsubscribe = FocusManager.subscribe((focused) => {
const img = imgRef.current;
if (!img) return;
if (!focused) {
img.src = optimizedStaticURL;
return;
}
if (isHoveredRef.current && focused) {
img.src = optimizedAnimatedURL;
}
});
return unsubscribe;
}, [gifAutoPlay, optimizedAnimatedURL, optimizedStaticURL]);
if (shouldBlur) {
const {style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}, {forceScale: true});
const {width: _width, height: _height, ...styleWithoutDimensions} = style;
@@ -679,8 +703,8 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
const {width, aspectRatio} = style;
const containerStyle = {
'--embed-width': `${width}px`,
maxWidth: `${width}px`,
width: '100%',
maxWidth: '100%',
width,
aspectRatio,
} as React.CSSProperties;
@@ -716,7 +740,7 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
contentHash={contentHash}
message={message}
>
<div className={styles.videoWrapper}>
<div className={styles.videoWrapper} style={aspectRatio ? {aspectRatio} : undefined}>
{(!loaded || error) && thumbHashURL && (
<img src={thumbHashURL} className={styles.thumbHashPlaceholder} alt={t`Loading placeholder`} />
)}

View File

@@ -251,6 +251,11 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
aspectRatio: true,
},
);
const resolvedContainerStyle: React.CSSProperties = {
...containerStyle,
width: dimensions.width,
maxWidth: '100%',
};
const shouldRenderPlaceholder = error || !loaded;
@@ -295,7 +300,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
<div className={styles.blurContainer}>
<div className={clsx(styles.rowContainer, isInline && styles.justifyEnd)}>
<div className={styles.innerContainer}>
<div className={styles.imageWrapper} style={containerStyle}>
<div className={styles.imageWrapper} style={resolvedContainerStyle}>
<div className={styles.imageContainer}>
{thumbHashURL && (
<div className={styles.thumbHashContainer}>
@@ -328,7 +333,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
<div className={clsx(styles.rowContainer, isInline && styles.justifyEnd)}>
<MediaContainer
className={clsx(styles.mediaContainer, styles.cursorPointer)}
style={containerStyle}
style={resolvedContainerStyle}
showFavoriteButton={showFavoriteButton}
isFavorited={isFavorited}
onFavoriteClick={handleFavoriteClick}

View File

@@ -247,7 +247,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
}
: {
width: dimensions.width,
maxWidth: dimensions.width,
maxWidth: '100%',
aspectRatio,
};

View File

@@ -79,7 +79,6 @@ export const PickerSearchInput = React.forwardRef<HTMLInputElement, PickerSearch
{value, onChange, placeholder, inputRef, onKeyDown, maxLength = 100, showBackButton = false, onBackButtonClick},
forwardedRef,
) => {
const _inputElementRef = React.useRef<HTMLInputElement | null>(null);
const {t} = useLingui();
const inputElementRef = React.useRef<HTMLInputElement | null>(null);
const {canFocus, safeFocusTextarea} = useInputFocusManagement(inputElementRef);