mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
fix(app): restore the mobile voice message recorder (#1776)
This commit is contained in:
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}
|
||||
|
||||
Reference in new issue
Block a user