fix(app): restore the mobile voice message recorder (#1776)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-20 04:33:24 +02:00
1 parent ddc8837d4f
commit ded51b65d3
8 files changed
+144 -818

No files matched your search

@@ -1375,7 +1375,10 @@ export const LexicalChannelTextareaContent = observer(
showStickersButton={showStickersButton}
showEmojiButton={showEmojiButton}
showMessageSendButton={showMessageSendButton}
showVoiceMessageButton={false}
canRecordVoice={canAttachFilesInChannel(channel)}
isEditingMessage={isEditingMessageInComposer || editingMessageId != null}
hasPendingSticker={hasPendingSticker}
voiceTooltipAnchorRef={contentAreaRef}
expressionPickerOpen={expressionPickerOpen}
selectedTab={selectedTab}
isMobile={mobileLayout.enabled}
@@ -7,6 +7,34 @@
justify-content: center;
}
.voiceButton {
display: flex;
align-items: center;
justify-content: center;
min-width: var(--textarea-button-height);
height: var(--textarea-button-height);
padding: 0;
color: var(--text-primary-muted);
border-radius: var(--radius-md);
background: transparent;
border: none;
outline: none;
cursor: pointer;
flex-shrink: 0;
touch-action: none;
}
.voiceButton:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.voiceButtonIcon {
width: var(--textarea-button-icon-size);
height: var(--textarea-button-icon-size);
flex-shrink: 0;
}
.holdTooltip {
background-color: var(--brand-primary);
color: var(--text-on-brand-primary);
@@ -33,6 +61,10 @@
}
.overlayInner {
display: flex;
align-items: flex-end;
gap: 0.375rem;
padding: 0.5rem;
width: 100%;
min-width: 0;
box-sizing: border-box;
@@ -133,6 +165,7 @@
flex-shrink: 0;
display: flex;
align-items: center;
width: 2.25rem;
height: 2.25rem;
}
@@ -2,7 +2,6 @@
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
import layoutStyles from '@app/features/channel/components/textarea/MobileTextareaLayout.module.css';
import styles from '@app/features/channel/components/VoiceMessageRecorder.module.css';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
@@ -529,7 +528,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
>
<motion.button
type="button"
className={layoutStyles.mobileVoiceButton}
className={styles.voiceButton}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerMove={handlePointerMove}
@@ -541,7 +540,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
data-flx="channel.voice-message-recorder.button.pointer-down"
>
<MicrophoneIcon
className={layoutStyles.mobileRightButtonIcon}
className={styles.voiceButtonIcon}
data-flx="channel.voice-message-recorder.microphone-icon"
/>
</motion.button>
@@ -578,10 +577,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
transition={resolvedOverlayMotion.transition}
data-flx="channel.voice-message-recorder.recording-overlay"
>
<div
className={clsx(styles.overlayInner, layoutStyles.mobileTextareaWrapper)}
data-flx="channel.voice-message-recorder.overlay-inner"
>
<div className={styles.overlayInner} data-flx="channel.voice-message-recorder.overlay-inner">
<button
type="button"
className={clsx(styles.iconButton, styles.trashButton)}
@@ -610,7 +606,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
</div>
</div>
<div
className={clsx(styles.lockIndicatorWrapper, layoutStyles.mobileRightButtonContainer)}
className={styles.lockIndicatorWrapper}
data-flx="channel.voice-message-recorder.lock-indicator-wrapper"
>
<button
@@ -1,260 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
import {TextareaButton} from '@app/features/channel/components/textarea/TextareaButton';
import styles from '@app/features/channel/components/textarea/TextareaButtons.module.css';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {prepareVoiceMessageWav} from '@app/features/voice/utils/VoiceMessageRecordingUtils';
import {sendVoiceMessage} from '@app/features/voice/utils/VoiceMessageSendUtils';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {MicrophoneIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {motion, type Variants} from 'framer-motion';
import type React from 'react';
import {useCallback, useMemo, useRef, useState} from 'react';
const RECORDING_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
message: 'Recording failed. Try again.',
comment: 'Error toast shown when the hold-to-record voice message recording fails.',
});
const VOICE_MESSAGE_DESCRIPTOR = msg({
message: 'Voice message',
comment: 'Filename label used for voice messages sent via the hold-to-record button.',
});
const UNABLE_TO_SEND_VOICE_MESSAGE_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
message: 'Unable to send voice message. Try again.',
comment: 'Error toast shown when sending a hold-to-record voice message fails.',
});
const VOICE_RECORDING_IS_NOT_SUPPORTED_IN_THIS_BROWSER_DESCRIPTOR = msg({
message: 'Voice recording is not supported in this browser.',
comment: 'Inline error when MediaRecorder is unsupported for the hold-to-record voice message flow.',
});
const UNABLE_TO_START_RECORDING_PLEASE_ALLOW_MICROPHONE_ACCESS_DESCRIPTOR = msg({
message: 'Unable to start recording. Allow microphone access.',
comment: 'Inline error when microphone permission is denied for the hold-to-record voice message flow.',
});
const RELEASE_TO_SEND_DESCRIPTOR = msg({
message: 'Release to send',
comment: 'Tooltip on the hold-to-record button while the user is actively holding to record.',
});
const HOLD_TO_SEND_VOICE_MESSAGE_DESCRIPTOR = msg({
message: 'Hold to send voice message',
comment: 'Tooltip on the hold-to-record button in its idle state.',
});
const logger = new Logger('HoldToRecordButton');
const recordingVariants: Variants = {
idle: {
scale: 1,
boxShadow: '0 0 0 0px rgba(252, 95, 105, 0)',
},
recording: {
scale: [1, 1.05, 1],
boxShadow: [
'0 0 0 0px rgba(252, 95, 105, 0.35)',
'0 0 0 8px rgba(252, 95, 105, 0.15)',
'0 0 0 0px rgba(252, 95, 105, 0.35)',
],
transition: {
duration: 1.2,
repeat: Infinity,
ease: 'easeInOut',
},
},
};
interface HoldToRecordButtonProps {
channelId: string;
disabled?: boolean;
onFallback?: () => void;
}
const HoldToRecordButton: React.FC<HoldToRecordButtonProps> = ({channelId, disabled, onFallback}) => {
const {i18n} = useLingui();
const [status, setStatus] = useState<'idle' | 'recording' | 'sending'>('idle');
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Array<Blob>>([]);
const pointerIdRef = useRef<number | null>(null);
const maxTimerRef = useRef<number | null>(null);
const maxRecordingSeconds = useMemo(
() => LimitResolver.resolve({key: 'max_voice_message_duration', fallback: 1200}),
[],
);
const clearMaxTimer = useCallback(() => {
if (maxTimerRef.current) {
clearTimeout(maxTimerRef.current);
maxTimerRef.current = null;
}
}, []);
const releaseStream = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
}, []);
const cleanupRecording = useCallback(() => {
clearMaxTimer();
releaseStream();
mediaRecorderRef.current = null;
}, [clearMaxTimer, releaseStream]);
const handleRecordingStop = useCallback(
async (blob: Blob) => {
if (blob.size === 0) {
setStatus('idle');
setError(i18n._(RECORDING_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR));
return;
}
setStatus('sending');
try {
const {file, duration, waveform} = await prepareVoiceMessageWav(blob, `voice-message-${Date.now()}.wav`);
await sendVoiceMessage({
channelId,
file,
waveform,
duration,
title: i18n._(VOICE_MESSAGE_DESCRIPTOR),
});
setStatus('idle');
setError(null);
} catch (err) {
logger.error('Failed to send voice message', err);
setError(i18n._(UNABLE_TO_SEND_VOICE_MESSAGE_PLEASE_TRY_AGAIN_DESCRIPTOR));
setStatus('idle');
}
},
[channelId, i18n],
);
const handleStopEvent = useCallback(() => {
const recorder = mediaRecorderRef.current;
if (!recorder) {
setStatus('idle');
return;
}
const recordedChunks = chunksRef.current;
const blob = new Blob(recordedChunks, {type: recorder.mimeType || 'audio/ogg'});
chunksRef.current = [];
cleanupRecording();
if (recordedChunks.length === 0) {
setError(i18n._(RECORDING_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR));
setStatus('idle');
return;
}
void handleRecordingStop(blob);
}, [cleanupRecording, handleRecordingStop, i18n]);
const handleDataAvailable = useCallback((event: BlobEvent) => {
if (event.data && event.data.size > 0) {
chunksRef.current = [...chunksRef.current, event.data];
}
}, []);
const stopRecording = useCallback(() => {
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state !== 'inactive') {
recorder.stop();
}
}, []);
const startRecording = useCallback(async () => {
if (status === 'recording' || status === 'sending') return;
if (typeof navigator.mediaDevices === 'undefined' || typeof MediaRecorder === 'undefined') {
setError(i18n._(VOICE_RECORDING_IS_NOT_SUPPORTED_IN_THIS_BROWSER_DESCRIPTOR));
onFallback?.();
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
const preferredType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
? 'audio/ogg;codecs=opus'
: 'audio/webm;codecs=opus';
const recorder = new MediaRecorder(stream, {mimeType: preferredType});
chunksRef.current = [];
recorder.addEventListener('dataavailable', handleDataAvailable);
recorder.addEventListener('stop', handleStopEvent, {once: true});
recorder.start();
mediaRecorderRef.current = recorder;
streamRef.current = stream;
setStatus('recording');
setError(null);
clearMaxTimer();
maxTimerRef.current = window.setTimeout(() => {
stopRecording();
}, maxRecordingSeconds * 1000);
} catch (err) {
logger.error('Failed to start voice recording', err);
setError(i18n._(UNABLE_TO_START_RECORDING_PLEASE_ALLOW_MICROPHONE_ACCESS_DESCRIPTOR));
}
}, [clearMaxTimer, handleDataAvailable, handleStopEvent, maxRecordingSeconds, status, i18n]);
const handlePointerDown = useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
if (disabled || status === 'sending') return;
event.preventDefault();
pointerIdRef.current = event.pointerId;
event.currentTarget.setPointerCapture(event.pointerId);
void startRecording();
},
[disabled, startRecording, status],
);
const handlePointerUp = useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
if (pointerIdRef.current !== event.pointerId) return;
event.preventDefault();
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {}
pointerIdRef.current = null;
stopRecording();
},
[stopRecording],
);
const handlePointerCancel = useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
if (pointerIdRef.current !== event.pointerId) return;
event.preventDefault();
pointerIdRef.current = null;
stopRecording();
},
[stopRecording],
);
const indicatorText = useMemo(() => {
if (error) return error;
if (status === 'recording') return i18n._(RELEASE_TO_SEND_DESCRIPTOR);
return i18n._(HOLD_TO_SEND_VOICE_MESSAGE_DESCRIPTOR);
}, [error, status, i18n.locale]);
return (
<motion.div
className={styles.holdButtonWrapper}
variants={recordingVariants}
animate={status === 'recording' ? 'recording' : 'idle'}
data-flx="channel.textarea.hold-to-record-button.hold-button-wrapper"
>
<TextareaButton
icon={MicrophoneIcon}
label={i18n._(VOICE_MESSAGE_DESCRIPTOR)}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onPointerLeave={handlePointerCancel}
disabled={disabled || status === 'sending'}
className={clsx(styles.holdButton, status === 'recording' && styles.holdButtonRecording)}
data-flx="channel.textarea.hold-to-record-button.hold-button.pointer-down"
/>
<motion.span
key={indicatorText}
className={clsx(
styles.holdIndicator,
status === 'recording' && styles.holdIndicatorRecording,
error && styles.holdIndicatorError,
)}
initial={Accessibility.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: 5}}
animate={{opacity: 1, y: 0}}
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.15}}
data-flx="channel.textarea.hold-to-record-button.hold-indicator"
>
{indicatorText}
</motion.span>
</motion.div>
);
};
export default HoldToRecordButton;
@@ -1,158 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.mobileTextareaWrapper {
display: flex;
align-items: flex-end;
gap: 0.375rem;
padding: 0.5rem;
width: 100%;
box-sizing: border-box;
}
.mobilePlusButtonContainer {
display: flex;
align-items: flex-end;
flex-shrink: 0;
height: 2.25rem;
}
.mobilePlusButton {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background-color: var(--background-tertiary);
color: var(--text-primary);
border: none;
cursor: pointer;
flex-shrink: 0;
padding: 0;
}
.mobilePlusButtonIcon {
width: 1.25rem;
height: 1.25rem;
}
.mobileContentWrapper {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-end;
gap: 0.375rem;
}
.mobileInputContainer {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-end;
background-color: var(--background-tertiary);
border-radius: 1.125rem;
padding: 0;
position: relative;
min-height: 2.25rem;
}
.mobileInputContent {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0;
font-size: 0.9375rem;
}
.mobileEmojiButtonContainer {
position: absolute;
right: 0.25rem;
bottom: 0.125rem;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.mobileEmojiButton {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background-color: transparent;
color: var(--text-primary-muted);
border: none;
cursor: pointer;
flex-shrink: 0;
padding: 0;
}
.mobileEmojiButtonIcon {
width: 1.375rem;
height: 1.375rem;
}
.mobileRightButtonContainer {
flex-shrink: 0;
display: flex;
align-items: flex-end;
position: relative;
width: 2.25rem;
height: 2.25rem;
}
.mobileRightButtonSlot {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.mobileSendButton {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background-color: var(--brand-primary);
color: white;
border: none;
cursor: pointer;
flex-shrink: 0;
}
.mobileSendButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mobileVoiceButton {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background-color: var(--background-tertiary);
color: var(--text-primary);
border: none;
cursor: pointer;
flex-shrink: 0;
touch-action: none;
}
.mobileVoiceButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mobileRightButtonIcon {
width: 1.25rem;
height: 1.25rem;
}
@@ -1,297 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import type {AutocompleteOption} from '@app/features/channel/components/Autocomplete';
import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';
import {transitionMobileTextareaButtonState} from '@app/features/channel/components/textarea/MobileTextareaButtonStateMachine';
import styles from '@app/features/channel/components/textarea/MobileTextareaLayout.module.css';
import textareaStyles from '@app/features/channel/components/textarea/TextareaInput.module.css';
import {TextareaInputField} from '@app/features/channel/components/textarea/TextareaInputField';
import VoiceMessageRecorder from '@app/features/channel/components/VoiceMessageRecorder';
import {EMOJIS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import type {TextareaTextChangeHint} from '@app/features/messaging/utils/TextareaSegmentManager';
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
import {getReducedMotionProps, type MotionAnimation} from '@app/features/ui/utils/ReducedMotionAnimation';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ArrowUpIcon, PlusIcon, SmileyIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import type React from 'react';
import {type Dispatch, type SetStateAction, useMemo, useRef} from 'react';
const OPEN_MENU_DESCRIPTOR = msg({
message: 'Open menu',
comment: 'Button or menu action label in the channel and chat mobile textarea layout. Keep it concise.',
});
const SEND_MESSAGE_DESCRIPTOR = msg({
message: 'Send message',
comment: 'Button or menu action label in the channel and chat mobile textarea layout. Keep it concise.',
});
const MOBILE_BUTTON_SWAP_MOTION: MotionAnimation = {
initial: {opacity: 0, scale: 0.86, y: 3},
animate: {opacity: 1, scale: 1, y: 0},
exit: {opacity: 0, scale: 0.9, y: -2},
transition: {
opacity: {duration: 0.08},
y: {duration: 0.08, ease: 'easeOut'},
scale: {type: 'spring', stiffness: 560, damping: 28, mass: 0.55},
},
};
interface MobileTextareaLayoutProps {
disabled: boolean;
canAttachFiles: boolean;
value: string;
placeholderText: string;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
scrollerRef: React.RefObject<ScrollerHandle | null>;
isFocused: boolean;
isAutocompleteAttached: boolean;
autocompleteListId?: string;
autocompleteOptions: Array<AutocompleteOption>;
selectedIndex: number;
channelId: string;
isSlowmodeActive: boolean;
isOverCharacterLimit: boolean;
isEditingMessage: boolean;
hasContent: boolean;
hasAttachments: boolean;
hasPendingSticker?: boolean;
isEditingScheduledMessage: boolean;
onFocus: () => void;
onBlur: () => void;
onChange: (value: string, inputType?: string, hint?: TextareaTextChangeHint) => void;
onHeightChange: (height: number) => void;
onCursorMove: () => void;
onArrowUp: (event: React.KeyboardEvent) => void;
onSubmit: () => void;
onAutocompleteSelect: (option: AutocompleteOption) => void;
setSelectedIndex: Dispatch<SetStateAction<number>>;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onPlusClick: () => void;
onEmojiClick: () => void;
}
export function MobileTextareaLayout({
disabled,
canAttachFiles,
value,
placeholderText,
textareaRef,
scrollerRef,
isFocused,
isAutocompleteAttached,
autocompleteListId,
autocompleteOptions,
selectedIndex,
channelId,
isSlowmodeActive,
isOverCharacterLimit,
isEditingMessage,
hasContent,
hasAttachments,
hasPendingSticker,
isEditingScheduledMessage,
onFocus,
onBlur,
onChange,
onHeightChange,
onCursorMove,
onArrowUp,
onSubmit,
onAutocompleteSelect,
setSelectedIndex,
onKeyDown,
onPlusClick,
onEmojiClick,
}: MobileTextareaLayoutProps) {
const {i18n} = useLingui();
const buttonSwapMotion = getReducedMotionProps(MOBILE_BUTTON_SWAP_MOTION, Accessibility.useReducedMotion);
const rightButtonState = useMemo(
() =>
transitionMobileTextareaButtonState({
disabled,
canRecordVoice: canAttachFiles,
value,
isSlowmodeActive,
isOverCharacterLimit,
isEditingMessage,
hasContent,
hasAttachments,
hasPendingSticker: Boolean(hasPendingSticker),
isEditingScheduledMessage,
}),
[
canAttachFiles,
disabled,
hasAttachments,
hasContent,
hasPendingSticker,
isEditingMessage,
isEditingScheduledMessage,
isOverCharacterLimit,
isSlowmodeActive,
value,
],
);
const sendButtonDisabled = rightButtonState.sendButton.disabled;
const sendLockRef = useRef(false);
const handleSendClick = () => {
if (sendLockRef.current) {
return;
}
sendLockRef.current = true;
setTimeout(() => {
sendLockRef.current = false;
}, 500);
onSubmit();
};
const shouldShowVoiceButton = rightButtonState.visibleButton === 'voice';
const voiceButtonDisabled = rightButtonState.voiceButton.disabled;
const voiceTooltipAnchorRef = useRef<HTMLDivElement | null>(null);
return (
<div
className={clsx(styles.mobileTextareaWrapper, disabled && wrapperStyles.disabled)}
data-flx="channel.textarea.mobile-textarea-layout.mobile-textarea-wrapper"
>
{!disabled && canAttachFiles && (
<div
className={styles.mobilePlusButtonContainer}
data-flx="channel.textarea.mobile-textarea-layout.mobile-plus-button-container"
>
<button
type="button"
className={styles.mobilePlusButton}
onClick={onPlusClick}
aria-label={i18n._(OPEN_MENU_DESCRIPTOR)}
data-flx="channel.textarea.mobile-textarea-layout.mobile-plus-button.plus-click"
>
<PlusIcon
className={styles.mobilePlusButtonIcon}
weight="bold"
data-flx="channel.textarea.mobile-textarea-layout.mobile-plus-button-icon"
/>
</button>
</div>
)}
<div
className={styles.mobileContentWrapper}
data-flx="channel.textarea.mobile-textarea-layout.mobile-content-wrapper"
>
<div
className={styles.mobileInputContainer}
ref={voiceTooltipAnchorRef}
data-flx="channel.textarea.mobile-textarea-layout.mobile-input-container"
>
<div
className={styles.mobileInputContent}
data-flx="channel.textarea.mobile-textarea-layout.mobile-input-content"
>
<Scroller
ref={scrollerRef}
fade={true}
className={textareaStyles.scroller}
key="mobile-textarea-scroller"
data-flx="channel.textarea.mobile-textarea-layout.scroller"
>
<div className={textareaStyles.flexColumn} data-flx="channel.textarea.mobile-textarea-layout.div">
<TextareaInputField
channelId={channelId}
disabled={disabled}
isMobile={true}
value={value}
placeholder={placeholderText}
textareaRef={textareaRef}
isFocused={isFocused}
isAutocompleteAttached={isAutocompleteAttached}
autocompleteListId={autocompleteListId}
autocompleteOptions={autocompleteOptions}
selectedIndex={selectedIndex}
className={textareaStyles.textareaMobile}
onFocus={onFocus}
onBlur={onBlur}
onChange={onChange}
onHeightChange={onHeightChange}
onCursorMove={onCursorMove}
onArrowUp={onArrowUp}
onEnter={onSubmit}
onAutocompleteSelect={onAutocompleteSelect}
setSelectedIndex={setSelectedIndex}
onKeyDown={onKeyDown}
data-flx="channel.textarea.mobile-textarea-layout.textarea-input-field.change"
/>
</div>
</Scroller>
</div>
{!disabled && (
<div
className={styles.mobileEmojiButtonContainer}
data-flx="channel.textarea.mobile-textarea-layout.mobile-emoji-button-container"
>
<button
type="button"
className={styles.mobileEmojiButton}
onClick={onEmojiClick}
aria-label={i18n._(EMOJIS_DESCRIPTOR)}
data-flx="channel.textarea.mobile-textarea-layout.mobile-emoji-button.emoji-click"
>
<SmileyIcon
className={styles.mobileEmojiButtonIcon}
weight="fill"
data-flx="channel.textarea.mobile-textarea-layout.mobile-emoji-button-icon"
/>
</button>
</div>
)}
</div>
<div
className={styles.mobileRightButtonContainer}
data-flx="channel.textarea.mobile-textarea-layout.mobile-right-button-container"
>
<AnimatePresence initial={false} data-flx="channel.textarea.mobile-textarea-layout.animate-presence">
{shouldShowVoiceButton ? (
<motion.div
key="voice-button"
className={styles.mobileRightButtonSlot}
data-flx="channel.textarea.mobile-textarea-layout.div--2"
{...buttonSwapMotion}
>
<VoiceMessageRecorder
channelId={channelId}
disabled={voiceButtonDisabled}
tooltipAnchorRef={voiceTooltipAnchorRef}
data-flx="channel.textarea.mobile-textarea-layout.voice-message-recorder"
/>
</motion.div>
) : (
<motion.div
key="send-button"
className={styles.mobileRightButtonSlot}
data-flx="channel.textarea.mobile-textarea-layout.mobile-send-button.send-click"
{...buttonSwapMotion}
>
<button
type="button"
className={styles.mobileSendButton}
onClick={handleSendClick}
aria-label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
disabled={sendButtonDisabled}
data-flx="channel.textarea.mobile-textarea-layout.mobile-send-button.button"
>
<ArrowUpIcon
className={styles.mobileRightButtonIcon}
weight="bold"
data-flx="channel.textarea.mobile-textarea-layout.mobile-right-button-icon"
/>
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
}
MobileTextareaLayout.displayName = 'MobileTextareaLayout';
@@ -14,33 +14,17 @@
align-items: stretch;
}
.holdButtonWrapper {
.mobileRightButtonContainer {
position: relative;
flex-shrink: 0;
width: var(--textarea-button-height);
height: var(--textarea-button-height);
}
.mobileRightButtonSlot {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
touch-action: none;
}
.holdButton {
touch-action: none;
}
.holdButtonRecording {
box-shadow: 0 0 0 0.125rem rgba(252, 95, 105, 0.35);
}
.holdIndicator {
font-size: 0.625rem;
text-transform: uppercase;
color: var(--text-primary-muted);
}
.holdIndicatorRecording {
color: var(--accent-danger);
}
.holdIndicatorError {
color: var(--accent-danger);
font-weight: 600;
justify-content: center;
}
@@ -1,10 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import HoldToRecordButton from '@app/features/channel/components/textarea/HoldToRecordButton';
import {transitionMobileTextareaButtonState} from '@app/features/channel/components/textarea/MobileTextareaButtonStateMachine';
import {TextareaButton} from '@app/features/channel/components/textarea/TextareaButton';
import textareaButtonsStyles from '@app/features/channel/components/textarea/TextareaButtons.module.css';
import styles from '@app/features/channel/components/textarea/TextareaInput.module.css';
import VoiceMessageRecorder from '@app/features/channel/components/VoiceMessageRecorder';
import type {ExpressionPickerTabType} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
import {
EMOJIS_DESCRIPTOR,
@@ -12,29 +13,28 @@ import {
MEDIA_DESCRIPTOR,
STICKERS_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {getReducedMotionProps} from '@app/features/ui/utils/ReducedMotionAnimation';
import {getReducedMotionProps, type MotionAnimation} from '@app/features/ui/utils/ReducedMotionAnimation';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {
GifIcon,
ImageSquareIcon,
MicrophoneIcon,
PaperPlaneRightIcon,
SmileyIcon,
StickerIcon,
} from '@phosphor-icons/react';
import {GifIcon, ImageSquareIcon, PaperPlaneRightIcon, SmileyIcon, StickerIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import React from 'react';
import React, {useMemo} from 'react';
const VOICE_MESSAGE_DESCRIPTOR = msg({
message: 'Voice message',
comment: 'Short label in the channel and chat textarea buttons. Keep it concise.',
});
const SEND_MESSAGE_DESCRIPTOR = msg({
message: 'Send message',
comment: 'Button or menu action label in the channel and chat textarea buttons. Keep it concise.',
});
const MOBILE_BUTTON_SWAP_MOTION: MotionAnimation = {
initial: {opacity: 0, scale: 0.86, y: 3},
animate: {opacity: 1, scale: 1, y: 0},
exit: {opacity: 0, scale: 0.9, y: -2},
transition: {
opacity: {duration: 0.08},
y: {duration: 0.08, ease: 'easeOut'},
scale: {type: 'spring', stiffness: 560, damping: 28, mass: 0.55},
},
};
interface TextareaButtonsProps {
disabled: boolean;
@@ -44,8 +44,10 @@ interface TextareaButtonsProps {
showStickersButton: boolean;
showEmojiButton: boolean;
showMessageSendButton: boolean;
showVoiceMessageButton?: boolean;
onVoiceMessageClick?: () => void;
canRecordVoice: boolean;
isEditingMessage: boolean;
hasPendingSticker: boolean;
voiceTooltipAnchorRef?: React.RefObject<HTMLElement | null>;
channelId: string;
expressionPickerOpen: boolean;
selectedTab: ExpressionPickerTabType;
@@ -71,8 +73,10 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
showStickersButton,
showEmojiButton,
showMessageSendButton,
showVoiceMessageButton,
onVoiceMessageClick,
canRecordVoice,
isEditingMessage,
hasPendingSticker,
voiceTooltipAnchorRef,
channelId,
expressionPickerOpen,
selectedTab,
@@ -90,22 +94,40 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
ref,
) => {
const {i18n} = useLingui();
const buttonModel = useMemo(
() =>
transitionMobileTextareaButtonState({
disabled,
canRecordVoice,
value: '',
isSlowmodeActive,
isOverCharacterLimit: isOverLimit,
isEditingMessage,
hasContent,
hasAttachments,
hasPendingSticker,
isEditingScheduledMessage: false,
}),
[
canRecordVoice,
disabled,
hasAttachments,
hasContent,
hasPendingSticker,
isEditingMessage,
isOverLimit,
isSlowmodeActive,
],
);
if (disabled) {
return null;
}
const buttonSwapMotion = getReducedMotionProps(
{
initial: {scale: 0.8, opacity: 0},
animate: {scale: 1, opacity: 1},
exit: {scale: 0.8, opacity: 0},
transition: {duration: 0.15, ease: 'easeOut'},
},
Accessibility.useReducedMotion,
);
const buttonSwapMotion = getReducedMotionProps(MOBILE_BUTTON_SWAP_MOTION, Accessibility.useReducedMotion);
const shouldShowDesktopSendButton = showMessageSendButton;
const baseSendDisabled = isSlowmodeActive || isOverLimit || disableSendButton;
const sendButtonDisabled = baseSendDisabled || (!hasContent && !hasAttachments);
const shouldShowHoldToRecord = isMobile && showVoiceMessageButton && !hasContent && !hasAttachments;
const desktopSendDisabled =
isSlowmodeActive || isOverLimit || (!hasContent && !hasAttachments) || disableSendButton;
const shouldShowVoiceButton = buttonModel.visibleButton === 'voice';
const mobileSendDisabled = buttonModel.sendButton.disabled || Boolean(disableSendButton);
return (
<div
className={clsx(styles.buttonContainerDense, styles.sideButtonPadding)}
@@ -167,48 +189,51 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
className={textareaButtonsStyles.invisibleTrigger}
data-flx="channel.textarea.textarea-buttons.div"
/>
{isMobile && showVoiceMessageButton && onVoiceMessageClick && !shouldShowHoldToRecord && (
<TextareaButton
icon={MicrophoneIcon}
label={i18n._(VOICE_MESSAGE_DESCRIPTOR)}
onClick={onVoiceMessageClick}
data-flx="channel.textarea.textarea-buttons.textarea-button.voice-message-click"
/>
)}
{isMobile && (
<AnimatePresence mode="wait" initial={false} data-flx="channel.textarea.textarea-buttons.animate-presence">
{shouldShowHoldToRecord ? (
<motion.div
key="hold-to-record"
data-flx="channel.textarea.textarea-buttons.div--2"
{...buttonSwapMotion}
>
<HoldToRecordButton
channelId={channelId}
disabled={baseSendDisabled}
onFallback={onVoiceMessageClick}
data-flx="channel.textarea.textarea-buttons.hold-to-record-button"
/>
</motion.div>
) : (
<motion.div key="send-button" data-flx="channel.textarea.textarea-buttons.div--3" {...buttonSwapMotion}>
<TextareaButton
disabled={sendButtonDisabled}
icon={PaperPlaneRightIcon}
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
onClick={onSubmit}
keybindCombo={{key: 'Enter'}}
data-flx="channel.textarea.textarea-buttons.textarea-button.submit"
/>
</motion.div>
)}
</AnimatePresence>
<div
className={textareaButtonsStyles.mobileRightButtonContainer}
data-flx="channel.textarea.textarea-buttons.mobile-right-button-container"
>
<AnimatePresence initial={false} data-flx="channel.textarea.textarea-buttons.animate-presence">
{shouldShowVoiceButton ? (
<motion.div
key="voice-button"
className={textareaButtonsStyles.mobileRightButtonSlot}
data-flx="channel.textarea.textarea-buttons.div--2"
{...buttonSwapMotion}
>
<VoiceMessageRecorder
channelId={channelId}
disabled={buttonModel.voiceButton.disabled}
tooltipAnchorRef={voiceTooltipAnchorRef}
data-flx="channel.textarea.textarea-buttons.voice-message-recorder"
/>
</motion.div>
) : (
<motion.div
key="send-button"
className={textareaButtonsStyles.mobileRightButtonSlot}
data-flx="channel.textarea.textarea-buttons.div--3"
{...buttonSwapMotion}
>
<TextareaButton
disabled={mobileSendDisabled}
icon={PaperPlaneRightIcon}
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
onClick={onSubmit}
keybindCombo={{key: 'Enter'}}
data-flx="channel.textarea.textarea-buttons.textarea-button.submit"
/>
</motion.div>
)}
</AnimatePresence>
</div>
)}
{!isMobile && shouldShowDesktopSendButton && (
<>
<div className={styles.divider} data-flx="channel.textarea.textarea-buttons.divider" />
<TextareaButton
disabled={isSlowmodeActive || isOverLimit || (!hasContent && !hasAttachments) || disableSendButton}
disabled={desktopSendDisabled}
icon={PaperPlaneRightIcon}
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
onClick={onSubmit}