mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +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}
|
showStickersButton={showStickersButton}
|
||||||
showEmojiButton={showEmojiButton}
|
showEmojiButton={showEmojiButton}
|
||||||
showMessageSendButton={showMessageSendButton}
|
showMessageSendButton={showMessageSendButton}
|
||||||
showVoiceMessageButton={false}
|
canRecordVoice={canAttachFilesInChannel(channel)}
|
||||||
|
isEditingMessage={isEditingMessageInComposer || editingMessageId != null}
|
||||||
|
hasPendingSticker={hasPendingSticker}
|
||||||
|
voiceTooltipAnchorRef={contentAreaRef}
|
||||||
expressionPickerOpen={expressionPickerOpen}
|
expressionPickerOpen={expressionPickerOpen}
|
||||||
selectedTab={selectedTab}
|
selectedTab={selectedTab}
|
||||||
isMobile={mobileLayout.enabled}
|
isMobile={mobileLayout.enabled}
|
||||||
|
|||||||
@@ -7,6 +7,34 @@
|
|||||||
justify-content: center;
|
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 {
|
.holdTooltip {
|
||||||
background-color: var(--brand-primary);
|
background-color: var(--brand-primary);
|
||||||
color: var(--text-on-brand-primary);
|
color: var(--text-on-brand-primary);
|
||||||
@@ -33,6 +61,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.overlayInner {
|
.overlayInner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -133,6 +165,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
width: 2.25rem;
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||||
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
|
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 styles from '@app/features/channel/components/VoiceMessageRecorder.module.css';
|
||||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||||
import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
|
import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
|
||||||
@@ -529,7 +528,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
|
|||||||
>
|
>
|
||||||
<motion.button
|
<motion.button
|
||||||
type="button"
|
type="button"
|
||||||
className={layoutStyles.mobileVoiceButton}
|
className={styles.voiceButton}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
@@ -541,7 +540,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
|
|||||||
data-flx="channel.voice-message-recorder.button.pointer-down"
|
data-flx="channel.voice-message-recorder.button.pointer-down"
|
||||||
>
|
>
|
||||||
<MicrophoneIcon
|
<MicrophoneIcon
|
||||||
className={layoutStyles.mobileRightButtonIcon}
|
className={styles.voiceButtonIcon}
|
||||||
data-flx="channel.voice-message-recorder.microphone-icon"
|
data-flx="channel.voice-message-recorder.microphone-icon"
|
||||||
/>
|
/>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
@@ -578,10 +577,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
|
|||||||
transition={resolvedOverlayMotion.transition}
|
transition={resolvedOverlayMotion.transition}
|
||||||
data-flx="channel.voice-message-recorder.recording-overlay"
|
data-flx="channel.voice-message-recorder.recording-overlay"
|
||||||
>
|
>
|
||||||
<div
|
<div className={styles.overlayInner} data-flx="channel.voice-message-recorder.overlay-inner">
|
||||||
className={clsx(styles.overlayInner, layoutStyles.mobileTextareaWrapper)}
|
|
||||||
data-flx="channel.voice-message-recorder.overlay-inner"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={clsx(styles.iconButton, styles.trashButton)}
|
className={clsx(styles.iconButton, styles.trashButton)}
|
||||||
@@ -610,7 +606,7 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={clsx(styles.lockIndicatorWrapper, layoutStyles.mobileRightButtonContainer)}
|
className={styles.lockIndicatorWrapper}
|
||||||
data-flx="channel.voice-message-recorder.lock-indicator-wrapper"
|
data-flx="channel.voice-message-recorder.lock-indicator-wrapper"
|
||||||
>
|
>
|
||||||
<button
|
<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;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
justify-content: center;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
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 {TextareaButton} from '@app/features/channel/components/textarea/TextareaButton';
|
||||||
import textareaButtonsStyles from '@app/features/channel/components/textarea/TextareaButtons.module.css';
|
import textareaButtonsStyles from '@app/features/channel/components/textarea/TextareaButtons.module.css';
|
||||||
import styles from '@app/features/channel/components/textarea/TextareaInput.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 type {ExpressionPickerTabType} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
|
||||||
import {
|
import {
|
||||||
EMOJIS_DESCRIPTOR,
|
EMOJIS_DESCRIPTOR,
|
||||||
@@ -12,29 +13,28 @@ import {
|
|||||||
MEDIA_DESCRIPTOR,
|
MEDIA_DESCRIPTOR,
|
||||||
STICKERS_DESCRIPTOR,
|
STICKERS_DESCRIPTOR,
|
||||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
} 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 {msg} from '@lingui/core/macro';
|
||||||
import {useLingui} from '@lingui/react/macro';
|
import {useLingui} from '@lingui/react/macro';
|
||||||
import {
|
import {GifIcon, ImageSquareIcon, PaperPlaneRightIcon, SmileyIcon, StickerIcon} from '@phosphor-icons/react';
|
||||||
GifIcon,
|
|
||||||
ImageSquareIcon,
|
|
||||||
MicrophoneIcon,
|
|
||||||
PaperPlaneRightIcon,
|
|
||||||
SmileyIcon,
|
|
||||||
StickerIcon,
|
|
||||||
} from '@phosphor-icons/react';
|
|
||||||
import {clsx} from 'clsx';
|
import {clsx} from 'clsx';
|
||||||
import {AnimatePresence, motion} from 'framer-motion';
|
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({
|
const SEND_MESSAGE_DESCRIPTOR = msg({
|
||||||
message: 'Send message',
|
message: 'Send message',
|
||||||
comment: 'Button or menu action label in the channel and chat textarea buttons. Keep it concise.',
|
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 {
|
interface TextareaButtonsProps {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
@@ -44,8 +44,10 @@ interface TextareaButtonsProps {
|
|||||||
showStickersButton: boolean;
|
showStickersButton: boolean;
|
||||||
showEmojiButton: boolean;
|
showEmojiButton: boolean;
|
||||||
showMessageSendButton: boolean;
|
showMessageSendButton: boolean;
|
||||||
showVoiceMessageButton?: boolean;
|
canRecordVoice: boolean;
|
||||||
onVoiceMessageClick?: () => void;
|
isEditingMessage: boolean;
|
||||||
|
hasPendingSticker: boolean;
|
||||||
|
voiceTooltipAnchorRef?: React.RefObject<HTMLElement | null>;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
expressionPickerOpen: boolean;
|
expressionPickerOpen: boolean;
|
||||||
selectedTab: ExpressionPickerTabType;
|
selectedTab: ExpressionPickerTabType;
|
||||||
@@ -71,8 +73,10 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
|
|||||||
showStickersButton,
|
showStickersButton,
|
||||||
showEmojiButton,
|
showEmojiButton,
|
||||||
showMessageSendButton,
|
showMessageSendButton,
|
||||||
showVoiceMessageButton,
|
canRecordVoice,
|
||||||
onVoiceMessageClick,
|
isEditingMessage,
|
||||||
|
hasPendingSticker,
|
||||||
|
voiceTooltipAnchorRef,
|
||||||
channelId,
|
channelId,
|
||||||
expressionPickerOpen,
|
expressionPickerOpen,
|
||||||
selectedTab,
|
selectedTab,
|
||||||
@@ -90,22 +94,40 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
|
|||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const {i18n} = useLingui();
|
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) {
|
if (disabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const buttonSwapMotion = getReducedMotionProps(
|
const buttonSwapMotion = getReducedMotionProps(MOBILE_BUTTON_SWAP_MOTION, Accessibility.useReducedMotion);
|
||||||
{
|
|
||||||
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 shouldShowDesktopSendButton = showMessageSendButton;
|
const shouldShowDesktopSendButton = showMessageSendButton;
|
||||||
const baseSendDisabled = isSlowmodeActive || isOverLimit || disableSendButton;
|
const desktopSendDisabled =
|
||||||
const sendButtonDisabled = baseSendDisabled || (!hasContent && !hasAttachments);
|
isSlowmodeActive || isOverLimit || (!hasContent && !hasAttachments) || disableSendButton;
|
||||||
const shouldShowHoldToRecord = isMobile && showVoiceMessageButton && !hasContent && !hasAttachments;
|
const shouldShowVoiceButton = buttonModel.visibleButton === 'voice';
|
||||||
|
const mobileSendDisabled = buttonModel.sendButton.disabled || Boolean(disableSendButton);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(styles.buttonContainerDense, styles.sideButtonPadding)}
|
className={clsx(styles.buttonContainerDense, styles.sideButtonPadding)}
|
||||||
@@ -167,48 +189,51 @@ export const TextareaButtons = React.forwardRef<HTMLDivElement, TextareaButtonsP
|
|||||||
className={textareaButtonsStyles.invisibleTrigger}
|
className={textareaButtonsStyles.invisibleTrigger}
|
||||||
data-flx="channel.textarea.textarea-buttons.div"
|
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 && (
|
{isMobile && (
|
||||||
<AnimatePresence mode="wait" initial={false} data-flx="channel.textarea.textarea-buttons.animate-presence">
|
<div
|
||||||
{shouldShowHoldToRecord ? (
|
className={textareaButtonsStyles.mobileRightButtonContainer}
|
||||||
<motion.div
|
data-flx="channel.textarea.textarea-buttons.mobile-right-button-container"
|
||||||
key="hold-to-record"
|
>
|
||||||
data-flx="channel.textarea.textarea-buttons.div--2"
|
<AnimatePresence initial={false} data-flx="channel.textarea.textarea-buttons.animate-presence">
|
||||||
{...buttonSwapMotion}
|
{shouldShowVoiceButton ? (
|
||||||
>
|
<motion.div
|
||||||
<HoldToRecordButton
|
key="voice-button"
|
||||||
channelId={channelId}
|
className={textareaButtonsStyles.mobileRightButtonSlot}
|
||||||
disabled={baseSendDisabled}
|
data-flx="channel.textarea.textarea-buttons.div--2"
|
||||||
onFallback={onVoiceMessageClick}
|
{...buttonSwapMotion}
|
||||||
data-flx="channel.textarea.textarea-buttons.hold-to-record-button"
|
>
|
||||||
/>
|
<VoiceMessageRecorder
|
||||||
</motion.div>
|
channelId={channelId}
|
||||||
) : (
|
disabled={buttonModel.voiceButton.disabled}
|
||||||
<motion.div key="send-button" data-flx="channel.textarea.textarea-buttons.div--3" {...buttonSwapMotion}>
|
tooltipAnchorRef={voiceTooltipAnchorRef}
|
||||||
<TextareaButton
|
data-flx="channel.textarea.textarea-buttons.voice-message-recorder"
|
||||||
disabled={sendButtonDisabled}
|
/>
|
||||||
icon={PaperPlaneRightIcon}
|
</motion.div>
|
||||||
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
|
) : (
|
||||||
onClick={onSubmit}
|
<motion.div
|
||||||
keybindCombo={{key: 'Enter'}}
|
key="send-button"
|
||||||
data-flx="channel.textarea.textarea-buttons.textarea-button.submit"
|
className={textareaButtonsStyles.mobileRightButtonSlot}
|
||||||
/>
|
data-flx="channel.textarea.textarea-buttons.div--3"
|
||||||
</motion.div>
|
{...buttonSwapMotion}
|
||||||
)}
|
>
|
||||||
</AnimatePresence>
|
<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 && (
|
{!isMobile && shouldShowDesktopSendButton && (
|
||||||
<>
|
<>
|
||||||
<div className={styles.divider} data-flx="channel.textarea.textarea-buttons.divider" />
|
<div className={styles.divider} data-flx="channel.textarea.textarea-buttons.divider" />
|
||||||
<TextareaButton
|
<TextareaButton
|
||||||
disabled={isSlowmodeActive || isOverLimit || (!hasContent && !hasAttachments) || disableSendButton}
|
disabled={desktopSendDisabled}
|
||||||
icon={PaperPlaneRightIcon}
|
icon={PaperPlaneRightIcon}
|
||||||
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
|
label={i18n._(SEND_MESSAGE_DESCRIPTOR)}
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
|
|||||||
Reference in new issue
Block a user