From 0b9c6879c90eceb9792ca09a1cfe8e937d2fd206 Mon Sep 17 00:00:00 2001 From: hazre Date: Sun, 15 Mar 2026 00:59:34 +0100 Subject: [PATCH 1/7] fix: improve ux on voice memos --- .../features/room/AudioAttachmentPreview.tsx | 194 +++++++++++++++ .../features/room/AudioMessageRecorder.tsx | 222 ++++++++++++------ src/app/features/room/RoomInput.tsx | 184 ++++++++++----- src/app/utils/debug.ts | 3 +- 4 files changed, 472 insertions(+), 131 deletions(-) create mode 100644 src/app/features/room/AudioAttachmentPreview.tsx diff --git a/src/app/features/room/AudioAttachmentPreview.tsx b/src/app/features/room/AudioAttachmentPreview.tsx new file mode 100644 index 000000000..e054eb0b8 --- /dev/null +++ b/src/app/features/room/AudioAttachmentPreview.tsx @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Box, Chip, Icon, Icons, IconButton, Text, color, config, toRem } from 'folds'; + +type AudioAttachmentPreviewProps = { + audioUrl: string; + waveform: number[]; + duration: number; // seconds + onDelete: () => void; +}; + +function formatTime(s: number): string { + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${m}:${sec.toString().padStart(2, '0')}`; +} + +const BAR_COUNT = 44; + +/** + * Attachment-area chip for a just-recorded voice message. + * + * Shows a play/pause button, a clickable waveform scrubber that fills + * with Primary colour as playback advances, a duration counter, and a + * delete button that matches the UploadBoard cancel chip style. + */ +export function AudioAttachmentPreview({ + audioUrl, + waveform, + duration, + onDelete, +}: AudioAttachmentPreviewProps) { + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const audioRef = useRef(null); + const rafRef = useRef(null); + + // Downsample waveform to BAR_COUNT display bars + const bars = Array.from({ length: BAR_COUNT }, (_, i) => { + const src = waveform.length > 0 ? waveform : Array(BAR_COUNT).fill(0.3); + const step = src.length / BAR_COUNT; + return src[Math.floor(i * step)] ?? 0; + }); + + const progress = duration > 0 ? Math.min(currentTime / duration, 1) : 0; + + // Animate current-time with rAF while playing for a smooth scrubber + const startRaf = useCallback((audio: HTMLAudioElement) => { + const tick = () => { + setCurrentTime(audio.currentTime); + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + }, []); + + const stopRaf = useCallback(() => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }, []); + + useEffect(() => { + const audio = new Audio(audioUrl); + audioRef.current = audio; + + audio.onended = () => { + setIsPlaying(false); + setCurrentTime(0); + stopRaf(); + }; + + return () => { + audio.pause(); + stopRaf(); + }; + }, [audioUrl, stopRaf]); + + const handlePlayPause = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + + if (isPlaying) { + audio.pause(); + setIsPlaying(false); + stopRaf(); + } else { + audio.play().catch(() => {}); + setIsPlaying(true); + startRaf(audio); + } + }, [isPlaying, startRaf, stopRaf]); + + const handleScrubClick = useCallback( + (e: React.MouseEvent) => { + const audio = audioRef.current; + if (!audio || !duration) return; + const rect = e.currentTarget.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + audio.currentTime = ratio * duration; + setCurrentTime(audio.currentTime); + }, + [duration] + ); + + return ( + + {/* Play / Pause */} + + + + + {/* Waveform scrubber */} + + {bars.map((level, i) => { + const barRatio = i / BAR_COUNT; + const played = barRatio <= progress; + return ( +
+ ); + })} + + + {/* Time display: shows current position while playing, total when paused */} + + {formatTime(isPlaying ? currentTime : duration)} + + + {/* Delete — matches UploadBoardHeader "Remove" chip style */} + } + title="Remove voice message" + aria-label="Remove voice message" + /> + + ); +} diff --git a/src/app/features/room/AudioMessageRecorder.tsx b/src/app/features/room/AudioMessageRecorder.tsx index f324d444c..dd5d3bab0 100644 --- a/src/app/features/room/AudioMessageRecorder.tsx +++ b/src/app/features/room/AudioMessageRecorder.tsx @@ -1,7 +1,12 @@ -import { VoiceRecorder } from '$plugins/voice-recorder-kit'; -import FocusTrap from 'focus-trap-react'; -import { Box, Icon, Icons, Text, color, config } from 'folds'; -import { useRef } from 'react'; +import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; +import { useVoiceRecorder } from '$plugins/voice-recorder-kit'; +import type { VoiceRecorderStopPayload } from '$plugins/voice-recorder-kit'; +import { Box, Text, color, config, toRem } from 'folds'; + +export type AudioMessageRecorderHandle = { + stop: () => void; + cancel: () => void; +}; type AudioMessageRecorderProps = { onRecordingComplete: (audioBlob: Blob) => void; @@ -10,83 +15,148 @@ type AudioMessageRecorderProps = { onAudioLengthUpdate: (length: number) => void; }; -// We use a react voice recorder library to handle the recording of audio messages, as it provides a simple API and handles the complexities of recording audio in the browser. -// The component is wrapped in a focus trap to ensure that keyboard users can easily navigate and interact with the recorder without accidentally losing focus or interacting with other parts of the UI. -// The styling is kept simple and consistent with the rest of the app, using Folds' design tokens for colors, spacing, and typography. -// we use a modified version of https://www.npmjs.com/package/react-voice-recorder-kit for the recording -export function AudioMessageRecorder({ - onRecordingComplete, - onRequestClose, - onWaveformUpdate, - onAudioLengthUpdate, -}: AudioMessageRecorderProps) { - const containerRef = useRef(null); +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +} + +const KEYFRAMES = ` +@keyframes recDotPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.25; } +} +`; +if (typeof document !== 'undefined') { + const styleId = '__audio-recorder-keyframes'; + if (!document.getElementById(styleId)) { + const style = document.createElement('style'); + style.id = styleId; + style.textContent = KEYFRAMES; + document.head.appendChild(style); + } +} + +export const AudioMessageRecorder = forwardRef< + AudioMessageRecorderHandle, + AudioMessageRecorderProps +>(({ onRecordingComplete, onRequestClose, onWaveformUpdate, onAudioLengthUpdate }, ref) => { const isDismissedRef = useRef(false); + // Guard against React Strict Mode's double-invoke of the autoStart effect, + // which fires onstop with a ~110-byte blob before the user does anything. + const userRequestedStopRef = useRef(false); + + // Keep stable refs for prop callbacks so useVoiceRecorder's internal + // useCallbacks never need to be recreated (which would reset the timer). + const onRecordingCompleteRef = useRef(onRecordingComplete); + onRecordingCompleteRef.current = onRecordingComplete; + const onRequestCloseRef = useRef(onRequestClose); + onRequestCloseRef.current = onRequestClose; + const onWaveformUpdateRef = useRef(onWaveformUpdate); + onWaveformUpdateRef.current = onWaveformUpdate; + const onAudioLengthUpdateRef = useRef(onAudioLengthUpdate); + onAudioLengthUpdateRef.current = onAudioLengthUpdate; + + // Stable stop handler — empty dep array intentional; live values via refs. + + const stableOnStop = useCallback((payload: VoiceRecorderStopPayload) => { + if (!userRequestedStopRef.current) return; + if (isDismissedRef.current) return; + onRecordingCompleteRef.current(payload.audioFile); + onWaveformUpdateRef.current(payload.waveform); + onAudioLengthUpdateRef.current(payload.audioLength); + }, []); + + // Stable delete handler — empty dep array intentional; live values via refs. + + const stableOnDelete = useCallback(() => { + isDismissedRef.current = true; + onRequestCloseRef.current(); + }, []); + + const { levels, seconds, handleStop, handleDelete } = useVoiceRecorder({ + autoStart: true, + onStop: stableOnStop, + onDelete: stableOnDelete, + }); + + const doStop = useCallback(() => { + if (isDismissedRef.current) return; + userRequestedStopRef.current = true; + handleStop(); + }, [handleStop]); + + const doCancel = useCallback(() => { + if (isDismissedRef.current) return; + isDismissedRef.current = true; + handleDelete(); + }, [handleDelete]); + + useImperativeHandle(ref, () => ({ stop: doStop, cancel: doCancel }), [doStop, doCancel]); + + const BAR_COUNT = 28; + const step = Math.max(1, levels.length / BAR_COUNT); + const bars = Array.from( + { length: BAR_COUNT }, + (_, i) => levels[Math.min(Math.floor(i * step), levels.length - 1)] ?? 0.15 + ); - // uses default styling, we use at other places return ( - { - isDismissedRef.current = true; - onRequestClose(); - }, - clickOutsideDeactivates: true, - allowOutsideClick: true, - fallbackFocus: () => containerRef.current!, - }} + -
- - Audio Message Recorder - { - if (isDismissedRef.current) return; - // closes the recorder and sends the audio file back to the parent component to be uploaded and sent as a message - onRecordingComplete(audioFile); - onWaveformUpdate(waveform); - onAudioLengthUpdate(audioLength); - }} - buttonBackgroundColor={color.SurfaceVariant.Container} - buttonHoverBackgroundColor={color.SurfaceVariant.ContainerHover} - iconColor={color.Primary.Main} - // icons for the recorder, we use Folds' icon library to keep the styling consistent with the rest of the app - customPauseIcon={} - customPlayIcon={} - customDeleteIcon={} - customStopIcon={} - customRepeatIcon={} - customResumeIcon={} + {/* Pulsing red dot */} +
+ + {/* Live waveform bars */} + + {bars.map((level, i) => ( +
- -
- + ))} +
+ + {/* Timer */} + + {formatTime(seconds)} + + ); -} +}); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index b280cf5d5..a531f1735 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -24,6 +24,7 @@ import { ReactEditor } from 'slate-react'; import { Editor, Point, Range, Transforms } from 'slate'; import { Box, + color, config, Dialog, Icon, @@ -158,7 +159,8 @@ import { getVideoMsgContent, } from './msgContent'; import { CommandAutocomplete } from './CommandAutocomplete'; -import { AudioMessageRecorder } from './AudioMessageRecorder'; +import { AudioMessageRecorder, AudioMessageRecorderHandle } from './AudioMessageRecorder'; +import { AudioAttachmentPreview } from './AudioAttachmentPreview'; const getReplyContent = (replyDraft: IReplyDraft | undefined): IEventRelation => { if (!replyDraft) return {}; @@ -251,6 +253,18 @@ export const RoomInput = forwardRef( const [showAudioRecorder, setShowAudioRecorder] = useState(false); const [audioMsgWaveform, setAudioMsgWaveform] = useState(undefined); const [audioMsgLength, setAudioMsgLength] = useState(undefined); + // pendingAudioFileRef is a ref (not state) so its value is set synchronously + // before handleFiles → setSelectedFiles (Jotai) can trigger a render. + // Using useState here caused a render window where selectedFiles already contained + // the file but pendingAudioFile state was still null, letting UploadCardRenderer + // render for the audio file and trigger an upload-during-render state dispatch. + const pendingAudioFileRef = useRef(null); + const [pendingAudioUrl, setPendingAudioUrl] = useState(null); + const audioRecorderRef = useRef(null); + // Tracks when the mic button was pressed on mobile so we can distinguish + // a quick tap (toggle — user stops manually) from a long hold (send on release). + const micHoldStartRef = useRef(0); + const HOLD_THRESHOLD_MS = 400; const [autocompleteQuery, setAutocompleteQuery] = useState>(); const [isQuickTextReact, setQuickTextReact] = useState(false); @@ -421,6 +435,19 @@ export const RoomInput = forwardRef( const handleRemoveUpload = useCallback( (upload: TUploadContent | TUploadContent[]) => { const uploads = Array.isArray(upload) ? upload : [upload]; + // If the pending audio file is being removed (via UploadBoard's Remove button), + // clean up the object URL and reset audio preview state. + uploads.forEach((u) => { + if (u === pendingAudioFileRef.current) { + setPendingAudioUrl((url) => { + if (url) URL.revokeObjectURL(url); + return null; + }); + pendingAudioFileRef.current = null; + setAudioMsgWaveform(undefined); + setAudioMsgLength(undefined); + } + }); setSelectedFiles({ type: 'DELETE', item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)), @@ -827,18 +854,35 @@ export const RoomInput = forwardRef( {Array.from(selectedFiles) .reverse() - .map((fileItem, index) => ( - - ))} + .map((fileItem, index) => { + // pendingAudioFileRef is a ref so it's available synchronously + // even in Jotai-triggered renders — UploadCardRenderer never + // renders for the audio file, preventing the upload-during-render + // setState warning on UploadBoardHeader. + if (fileItem.file === pendingAudioFileRef.current && pendingAudioUrl) { + return ( + + ); + } + return ( + + ); + })} )} @@ -1059,58 +1103,90 @@ export const RoomInput = forwardRef( } after={ <> + {/* ── Recording pill: grows into available space left of mic button ── */} + {showAudioRecorder && ( + setShowAudioRecorder(false)} + onRecordingComplete={(audioBlob) => { + const file = new File([audioBlob], `sable-audio-message-${Date.now()}.ogg`, { + type: audioBlob.type, + }); + // Set ref synchronously BEFORE handleFiles so the Jotai render + // that follows immediately sees it and skips UploadCardRenderer. + pendingAudioFileRef.current = file; + setPendingAudioUrl(URL.createObjectURL(audioBlob)); + handleFiles([file]); + setShowAudioRecorder(false); + }} + onAudioLengthUpdate={(len) => setAudioMsgLength(len)} + onWaveformUpdate={(w) => setAudioMsgWaveform(w)} + /> + )} + + {/* ── Mic button — always present; icon swaps to Stop while recording ── */} setToolbar(!toolbar)} + title={showAudioRecorder ? 'Stop recording' : 'Record audio message'} + aria-label={showAudioRecorder ? 'Stop recording' : 'Record audio message'} + aria-pressed={showAudioRecorder} + onClick={() => { + if (mobileOrTablet()) return; // mobile handled via pointerdown/up + if (showAudioRecorder) { + audioRecorderRef.current?.stop(); + } else { + setShowAudioRecorder(true); + } + }} + onPointerDown={() => { + if (!mobileOrTablet()) return; + if (showAudioRecorder) return; // already recording — onClick will stop it + micHoldStartRef.current = Date.now(); + setShowAudioRecorder(true); + + // One-shot global listeners: the mic button stays mounted now but + // the ref is already populated by the time pointerup fires. + const cleanup = () => { + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onCancel); + }; + const onUp = () => { + cleanup(); + const held = Date.now() - micHoldStartRef.current; + if (held >= HOLD_THRESHOLD_MS) { + audioRecorderRef.current?.stop(); + } + // Short tap → recorder stays open; tap stop-icon to finish + }; + const onCancel = () => { + cleanup(); + audioRecorderRef.current?.cancel(); + }; + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onCancel); + }} > - + + + {/* ── Toolbar toggle — always visible ── */} setShowAudioRecorder(!showAudioRecorder)} + title={toolbar ? 'Hide Toolbar' : 'Show Toolbar'} + aria-pressed={toolbar} + aria-label={toolbar ? 'Hide Toolbar' : 'Show Toolbar'} + onClick={() => setToolbar(!toolbar)} > - + - {showAudioRecorder && ( - { - setShowAudioRecorder(false); - }} - onRecordingComplete={(audioBlob) => { - const file = new File( - [audioBlob], - `sable-audio-message-${Date.now()}.ogg`, - { - type: audioBlob.type, - } - ); - handleFiles([file]); - // Close the recorder after handling the file, to give some feedback that the recording was successful - setShowAudioRecorder(false); - }} - onAudioLengthUpdate={(len) => setAudioMsgLength(len)} - onWaveformUpdate={(w) => setAudioMsgWaveform(w)} - /> - } - /> - )} {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( localStorage.getItem('sable_debug') === '1'; +export const isDebug = (): boolean => + import.meta.env.DEV || localStorage.getItem('sable_debug') === '1'; type LogLevel = 'log' | 'warn' | 'error'; From bf6d7ce4a6b3232900267a662363f70984d2ee59 Mon Sep 17 00:00:00 2001 From: hazre Date: Sun, 15 Mar 2026 00:59:34 +0100 Subject: [PATCH 2/7] fix: a lot more polish on voice messages --- package.json | 1 + pnpm-lock.yaml | 15 + .../components/upload-card/UploadCard.css.ts | 64 +- .../upload-card/UploadCardRenderer.tsx | 177 ++++- .../features/room/AudioAttachmentPreview.tsx | 194 ------ .../features/room/AudioMessageRecorder.css.ts | 121 ++++ .../features/room/AudioMessageRecorder.tsx | 240 ++++--- src/app/features/room/RoomInput.tsx | 130 ++-- src/app/features/room/msgContent.ts | 14 +- src/app/plugins/voice-recorder-kit/README.md | 492 -------------- .../voice-recorder-kit/VoiceRecorder.tsx | 620 ------------------ src/app/plugins/voice-recorder-kit/icons.tsx | 76 --- src/app/plugins/voice-recorder-kit/index.ts | 2 - src/app/plugins/voice-recorder-kit/types.ts | 41 -- .../voice-recorder-kit/useVoiceRecorder.ts | 25 +- src/app/state/room/roomInputDrafts.ts | 2 + 16 files changed, 612 insertions(+), 1602 deletions(-) delete mode 100644 src/app/features/room/AudioAttachmentPreview.tsx create mode 100644 src/app/features/room/AudioMessageRecorder.css.ts delete mode 100644 src/app/plugins/voice-recorder-kit/README.md delete mode 100644 src/app/plugins/voice-recorder-kit/VoiceRecorder.tsx delete mode 100644 src/app/plugins/voice-recorder-kit/icons.tsx diff --git a/package.json b/package.json index 371e7a975..14cd7c172 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@fontsource-variable/nunito": "5.2.7", "@fontsource/space-mono": "5.2.9", + "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.90.21", "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-virtual": "^3.13.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0d64afd3..a1c45c6d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: '@fontsource/space-mono': specifier: 5.2.9 version: 5.2.9 + '@phosphor-icons/react': + specifier: ^2.1.10 + version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@18.3.1) @@ -1549,6 +1552,13 @@ packages: cpu: [x64] os: [win32] + '@phosphor-icons/react@2.1.10': + resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -6360,6 +6370,11 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.19.1': optional: true + '@phosphor-icons/react@2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@pkgr/core@0.2.9': {} '@poppinss/colors@4.1.6': diff --git a/src/app/components/upload-card/UploadCard.css.ts b/src/app/components/upload-card/UploadCard.css.ts index ad3caf10e..d02cbe3f5 100644 --- a/src/app/components/upload-card/UploadCard.css.ts +++ b/src/app/components/upload-card/UploadCard.css.ts @@ -1,6 +1,6 @@ import { style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; -import { RadiiVariant, color, config } from 'folds'; +import { DefaultReset, RadiiVariant, color, config, toRem } from 'folds'; export const UploadCard = recipe({ base: { @@ -34,3 +34,65 @@ export const UploadCardError = style({ padding: `0 ${config.space.S100}`, color: color.Critical.Main, }); + +export const AudioPreviewContainer = style([ + DefaultReset, + { + backgroundColor: color.SurfaceVariant.Container, + border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, + borderRadius: config.radii.R400, + padding: config.space.S300, + width: '100%', + maxWidth: toRem(400), + boxSizing: 'border-box', + }, +]); + +export const AudioWaveformContainer = style([ + DefaultReset, + { + minHeight: 44, + cursor: 'pointer', + userSelect: 'none', + overflow: 'hidden', + }, +]); + +export const AudioWaveformBar = style([ + DefaultReset, + { + width: 2, + height: 3, + borderRadius: 1, + flexShrink: 0, + transition: 'background-color 40ms, opacity 40ms', + pointerEvents: 'none', + }, +]); + +export const AudioWaveformBarPlayed = style([ + DefaultReset, + { + backgroundColor: color.Secondary.Main, + opacity: 1, + }, +]); + +export const AudioWaveformBarUnplayed = style([ + DefaultReset, + { + backgroundColor: color.SurfaceVariant.OnContainer, + opacity: 0.5, + }, +]); + +export const AudioTimeDisplay = style([ + DefaultReset, + { + fontVariantNumeric: 'tabular-nums', + color: color.SurfaceVariant.OnContainer, + minWidth: toRem(30), + textAlign: 'right', + flexShrink: 0, + }, +]); diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index 55dbba3c7..aa060835c 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useMemo, useState } from 'react'; +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { Box, Chip, @@ -14,6 +14,7 @@ import { toRem, } from 'folds'; import { HTMLReactParserOptions } from 'html-react-parser'; +import { Play, Pause } from '@phosphor-icons/react'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { getReactCustomHtmlParser, LINKIFY_OPTS } from '$plugins/react-custom-html-parser'; @@ -27,6 +28,7 @@ import { roomUploadAtomFamily, TUploadItem, TUploadMetadata } from '$state/room/ import { useObjectURL } from '$hooks/useObjectURL'; import { useMediaConfig } from '$hooks/useMediaConfig'; import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard'; +import * as css from './UploadCard.css'; import { DescriptionEditor } from './UploadDescriptionEditor'; type PreviewImageProps = { @@ -71,6 +73,178 @@ function PreviewVideo({ fileItem }: PreviewVideoProps) { ); } +const BAR_COUNT = 44; + +function formatAudioTime(s: number): string { + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${m}:${sec.toString().padStart(2, '0')}`; +} + +type PreviewAudioProps = { + fileItem: TUploadItem; +}; +function PreviewAudio({ fileItem }: PreviewAudioProps) { + const { originalFile, metadata } = fileItem; + const audioUrl = useObjectURL(originalFile); + const { waveform, audioDuration } = metadata; + const duration = audioDuration ?? 0; + + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const audioRef = useRef(null); + const rafRef = useRef(null); + + const bars = useMemo(() => { + if (!waveform || waveform.length === 0) { + return Array(BAR_COUNT).fill(0.3); + } + const step = waveform.length / BAR_COUNT; + return Array.from({ length: BAR_COUNT }, (_, i) => waveform[Math.floor(i * step)] ?? 0); + }, [waveform]); + + const progress = duration > 0 ? Math.min(currentTime / duration, 1) : 0; + + useEffect(() => { + if (!audioUrl) { + return undefined; + } + const audio = new Audio(audioUrl); + audioRef.current = audio; + + audio.onended = () => { + setIsPlaying(false); + setCurrentTime(0); + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + + return () => { + audio.pause(); + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + }; + }, [audioUrl]); + + const startRaf = (audio: HTMLAudioElement) => { + const tick = () => { + setCurrentTime(audio.currentTime); + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + }; + + const stopRaf = () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + + const handlePlayPause = () => { + const audio = audioRef.current; + if (!audio) return; + + if (isPlaying) { + audio.pause(); + setIsPlaying(false); + stopRaf(); + } else { + audio.play().catch(() => {}); + setIsPlaying(true); + startRaf(audio); + } + }; + + const handleScrubClick = (e: React.MouseEvent) => { + const audio = audioRef.current; + if (!audio || !duration) return; + const rect = e.currentTarget.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + audio.currentTime = ratio * duration; + setCurrentTime(audio.currentTime); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const audio = audioRef.current; + if (!audio || !duration) return; + + const SEEK_STEP = 5; + let newTime = currentTime; + + if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + e.preventDefault(); + newTime = Math.max(0, currentTime - SEEK_STEP); + } else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + e.preventDefault(); + newTime = Math.min(duration, currentTime + SEEK_STEP); + } else if (e.key === 'Home') { + e.preventDefault(); + newTime = 0; + } else if (e.key === 'End') { + e.preventDefault(); + newTime = duration; + } else { + return; + } + + audio.currentTime = newTime; + setCurrentTime(newTime); + }; + + return ( + + + {isPlaying ? : } + + + + {bars.map((level, i) => { + const barRatio = i / BAR_COUNT; + const played = barRatio <= progress; + return ( +
+ ); + })} + + + + {formatAudioTime(isPlaying ? currentTime : duration)} + + + ); +} + type MediaPreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void; @@ -247,6 +421,7 @@ export function UploadCardRenderer({ )} + {fileItem.metadata.waveform && } {upload.status === UploadStatus.Idle && !fileSizeExceeded && ( )} diff --git a/src/app/features/room/AudioAttachmentPreview.tsx b/src/app/features/room/AudioAttachmentPreview.tsx deleted file mode 100644 index e054eb0b8..000000000 --- a/src/app/features/room/AudioAttachmentPreview.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Box, Chip, Icon, Icons, IconButton, Text, color, config, toRem } from 'folds'; - -type AudioAttachmentPreviewProps = { - audioUrl: string; - waveform: number[]; - duration: number; // seconds - onDelete: () => void; -}; - -function formatTime(s: number): string { - const m = Math.floor(s / 60); - const sec = Math.floor(s % 60); - return `${m}:${sec.toString().padStart(2, '0')}`; -} - -const BAR_COUNT = 44; - -/** - * Attachment-area chip for a just-recorded voice message. - * - * Shows a play/pause button, a clickable waveform scrubber that fills - * with Primary colour as playback advances, a duration counter, and a - * delete button that matches the UploadBoard cancel chip style. - */ -export function AudioAttachmentPreview({ - audioUrl, - waveform, - duration, - onDelete, -}: AudioAttachmentPreviewProps) { - const [isPlaying, setIsPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const audioRef = useRef(null); - const rafRef = useRef(null); - - // Downsample waveform to BAR_COUNT display bars - const bars = Array.from({ length: BAR_COUNT }, (_, i) => { - const src = waveform.length > 0 ? waveform : Array(BAR_COUNT).fill(0.3); - const step = src.length / BAR_COUNT; - return src[Math.floor(i * step)] ?? 0; - }); - - const progress = duration > 0 ? Math.min(currentTime / duration, 1) : 0; - - // Animate current-time with rAF while playing for a smooth scrubber - const startRaf = useCallback((audio: HTMLAudioElement) => { - const tick = () => { - setCurrentTime(audio.currentTime); - rafRef.current = requestAnimationFrame(tick); - }; - rafRef.current = requestAnimationFrame(tick); - }, []); - - const stopRaf = useCallback(() => { - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } - }, []); - - useEffect(() => { - const audio = new Audio(audioUrl); - audioRef.current = audio; - - audio.onended = () => { - setIsPlaying(false); - setCurrentTime(0); - stopRaf(); - }; - - return () => { - audio.pause(); - stopRaf(); - }; - }, [audioUrl, stopRaf]); - - const handlePlayPause = useCallback(() => { - const audio = audioRef.current; - if (!audio) return; - - if (isPlaying) { - audio.pause(); - setIsPlaying(false); - stopRaf(); - } else { - audio.play().catch(() => {}); - setIsPlaying(true); - startRaf(audio); - } - }, [isPlaying, startRaf, stopRaf]); - - const handleScrubClick = useCallback( - (e: React.MouseEvent) => { - const audio = audioRef.current; - if (!audio || !duration) return; - const rect = e.currentTarget.getBoundingClientRect(); - const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - audio.currentTime = ratio * duration; - setCurrentTime(audio.currentTime); - }, - [duration] - ); - - return ( - - {/* Play / Pause */} - - - - - {/* Waveform scrubber */} - - {bars.map((level, i) => { - const barRatio = i / BAR_COUNT; - const played = barRatio <= progress; - return ( -
- ); - })} - - - {/* Time display: shows current position while playing, total when paused */} - - {formatTime(isPlaying ? currentTime : duration)} - - - {/* Delete — matches UploadBoardHeader "Remove" chip style */} - } - title="Remove voice message" - aria-label="Remove voice message" - /> - - ); -} diff --git a/src/app/features/room/AudioMessageRecorder.css.ts b/src/app/features/room/AudioMessageRecorder.css.ts new file mode 100644 index 000000000..44c7bcd02 --- /dev/null +++ b/src/app/features/room/AudioMessageRecorder.css.ts @@ -0,0 +1,121 @@ +import { keyframes, style } from '@vanilla-extract/css'; +import { DefaultReset, color, config, toRem } from 'folds'; + +const RecDotPulse = keyframes({ + '0%, 100%': { opacity: 1 }, + '50%': { opacity: 0.25 }, +}); + +const SlideOutLeft = keyframes({ + '0%': { transform: 'translateX(0)', opacity: 1 }, + '100%': { transform: 'translateX(-100%)', opacity: 0 }, +}); + +const Shake = keyframes({ + '0%, 100%': { transform: 'translateX(0)' }, + '20%': { transform: 'translateX(-4px)' }, + '40%': { transform: 'translateX(4px)' }, + '60%': { transform: 'translateX(-4px)' }, + '80%': { transform: 'translateX(4px)' }, +}); + +export const Container = style([ + DefaultReset, + { + minWidth: 0, + overflow: 'hidden', + padding: `0 ${config.space.S200}`, + touchAction: 'pan-y', + userSelect: 'none', + }, +]); + +export const ContainerCanceling = style({ + animation: `${SlideOutLeft} 200ms ease-out forwards`, +}); + +export const ContainerShake = style({ + animation: `${Shake} 300ms ease-out`, +}); + +export const RecDot = style([ + DefaultReset, + { + width: 7, + height: 7, + borderRadius: '50%', + backgroundColor: color.Critical.Main, + flexShrink: 0, + animation: `${RecDotPulse} 1.4s ease-in-out infinite`, + }, +]); + +export const WaveformContainer = style([ + DefaultReset, + { + height: 22, + overflow: 'hidden', + minWidth: 0, + }, +]); + +export const WaveformBar = style([ + DefaultReset, + { + width: 2, + height: 3, + borderRadius: 1, + backgroundColor: color.Primary.Main, + transition: 'height 70ms ease-out', + flexShrink: 0, + }, +]); + +export const Timer = style([ + DefaultReset, + { + fontVariantNumeric: 'tabular-nums', + color: color.Critical.Main, + minWidth: config.space.S300, + flexShrink: 0, + fontWeight: 600, + }, +]); + +export const CancelHint = style([ + DefaultReset, + { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + display: 'flex', + alignItems: 'center', + paddingLeft: config.space.S200, + color: color.Critical.Main, + fontSize: toRem(12), + fontWeight: 600, + opacity: 0, + transition: 'opacity 100ms ease-out', + pointerEvents: 'none', + }, +]); + +export const CancelHintVisible = style({ + opacity: 1, +}); + +export const SrOnly = style([ + DefaultReset, + { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + borderWidth: 0, + }, +]); diff --git a/src/app/features/room/AudioMessageRecorder.tsx b/src/app/features/room/AudioMessageRecorder.tsx index dd5d3bab0..7ac2e18c4 100644 --- a/src/app/features/room/AudioMessageRecorder.tsx +++ b/src/app/features/room/AudioMessageRecorder.tsx @@ -1,7 +1,23 @@ -import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; import { useVoiceRecorder } from '$plugins/voice-recorder-kit'; import type { VoiceRecorderStopPayload } from '$plugins/voice-recorder-kit'; -import { Box, Text, color, config, toRem } from 'folds'; +import { mobileOrTablet } from '$utils/user-agent'; +import { Box, Text, IconButton, Icon, Icons } from 'folds'; +import * as css from './AudioMessageRecorder.css'; + +export type AudioRecordingCompletePayload = { + audioBlob: Blob; + waveform: number[]; + audioLength: number; +}; export type AudioMessageRecorderHandle = { stop: () => void; @@ -9,7 +25,7 @@ export type AudioMessageRecorderHandle = { }; type AudioMessageRecorderProps = { - onRecordingComplete: (audioBlob: Blob) => void; + onRecordingComplete: (payload: AudioRecordingCompletePayload) => void; onRequestClose: () => void; onWaveformUpdate: (waveform: number[]) => void; onAudioLengthUpdate: (length: number) => void; @@ -21,33 +37,20 @@ function formatTime(seconds: number): string { return `${m}:${s.toString().padStart(2, '0')}`; } -const KEYFRAMES = ` -@keyframes recDotPulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.25; } -} -`; -if (typeof document !== 'undefined') { - const styleId = '__audio-recorder-keyframes'; - if (!document.getElementById(styleId)) { - const style = document.createElement('style'); - style.id = styleId; - style.textContent = KEYFRAMES; - document.head.appendChild(style); - } -} - export const AudioMessageRecorder = forwardRef< AudioMessageRecorderHandle, AudioMessageRecorderProps >(({ onRecordingComplete, onRequestClose, onWaveformUpdate, onAudioLengthUpdate }, ref) => { const isDismissedRef = useRef(false); - // Guard against React Strict Mode's double-invoke of the autoStart effect, - // which fires onstop with a ~110-byte blob before the user does anything. const userRequestedStopRef = useRef(false); + const [isCanceling, setIsCanceling] = useState(false); + const [isShaking, setIsShaking] = useState(false); + const [swipeX, setSwipeX] = useState(0); + const [showCancelHint, setShowCancelHint] = useState(false); + const [announcedTime, setAnnouncedTime] = useState(0); + const touchStartXRef = useRef(0); + const isSwipingRef = useRef(false); - // Keep stable refs for prop callbacks so useVoiceRecorder's internal - // useCallbacks never need to be recreated (which would reset the timer). const onRecordingCompleteRef = useRef(onRecordingComplete); onRecordingCompleteRef.current = onRecordingComplete; const onRequestCloseRef = useRef(onRequestClose); @@ -57,24 +60,24 @@ export const AudioMessageRecorder = forwardRef< const onAudioLengthUpdateRef = useRef(onAudioLengthUpdate); onAudioLengthUpdateRef.current = onAudioLengthUpdate; - // Stable stop handler — empty dep array intentional; live values via refs. - const stableOnStop = useCallback((payload: VoiceRecorderStopPayload) => { if (!userRequestedStopRef.current) return; if (isDismissedRef.current) return; - onRecordingCompleteRef.current(payload.audioFile); + onRecordingCompleteRef.current({ + audioBlob: payload.audioFile, + waveform: payload.waveform, + audioLength: payload.audioLength, + }); onWaveformUpdateRef.current(payload.waveform); onAudioLengthUpdateRef.current(payload.audioLength); }, []); - // Stable delete handler — empty dep array intentional; live values via refs. - const stableOnDelete = useCallback(() => { isDismissedRef.current = true; onRequestCloseRef.current(); }, []); - const { levels, seconds, handleStop, handleDelete } = useVoiceRecorder({ + const { levels, seconds, error, handleStop, handleDelete } = useVoiceRecorder({ autoStart: true, onStop: stableOnStop, onDelete: stableOnDelete, @@ -88,75 +91,140 @@ export const AudioMessageRecorder = forwardRef< const doCancel = useCallback(() => { if (isDismissedRef.current) return; - isDismissedRef.current = true; - handleDelete(); + setIsCanceling(true); + setTimeout(() => { + isDismissedRef.current = true; + handleDelete(); + }, 180); }, [handleDelete]); useImperativeHandle(ref, () => ({ stop: doStop, cancel: doCancel }), [doStop, doCancel]); - const BAR_COUNT = 28; - const step = Math.max(1, levels.length / BAR_COUNT); - const bars = Array.from( - { length: BAR_COUNT }, - (_, i) => levels[Math.min(Math.floor(i * step), levels.length - 1)] ?? 0.15 + useEffect(() => { + if (seconds > 0 && seconds % 30 === 0 && seconds !== announcedTime) { + setAnnouncedTime(seconds); + } + }, [seconds, announcedTime]); + + const CANCEL_THRESHOLD = 80; + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + if (!mobileOrTablet()) return; + touchStartXRef.current = e.clientX; + isSwipingRef.current = true; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, []); + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if (!isSwipingRef.current || !mobileOrTablet()) return; + const deltaX = e.clientX - touchStartXRef.current; + if (deltaX < 0) { + setSwipeX(Math.max(deltaX, -CANCEL_THRESHOLD - 20)); + setShowCancelHint(deltaX < -30); + } else { + setSwipeX(0); + setShowCancelHint(false); + } + }, []); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + if (!isSwipingRef.current || !mobileOrTablet()) return; + isSwipingRef.current = false; + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + + const deltaX = e.clientX - touchStartXRef.current; + if (deltaX < -CANCEL_THRESHOLD) { + doCancel(); + } else if (deltaX < -30) { + setIsShaking(true); + setTimeout(() => setIsShaking(false), 300); + } + setSwipeX(0); + setShowCancelHint(false); + }, + [doCancel] ); + const BAR_COUNT = 28; + const bars = useMemo(() => { + const step = Math.max(1, levels.length / BAR_COUNT); + return Array.from( + { length: BAR_COUNT }, + (_, i) => levels[Math.min(Math.floor(i * step), levels.length - 1)] ?? 0.15 + ); + }, [levels]); + + const containerClassName = [ + css.Container, + isCanceling ? css.ContainerCanceling : null, + isShaking ? css.ContainerShake : null, + ] + .filter(Boolean) + .join(' '); + return ( - - {/* Pulsing red dot */} -
- - {/* Live waveform bars */} + <> + {error && ( + + {error} + + )} - {bars.map((level, i) => ( +
+ + + {bars.map((level, i) => ( +
+ ))} + + + + {formatTime(seconds)} + + {announcedTime > 0 && announcedTime === seconds && ( + + Recording duration: {formatTime(announcedTime)} + + )} + + + + + + {showCancelHint && (
- ))} + role="status" + aria-live="polite" + className={[css.CancelHint, css.CancelHintVisible].join(' ')} + > + Release to cancel +
+ )} - - {/* Timer */} - - {formatTime(seconds)} - - + ); }); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index a531f1735..6b30f685e 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -150,6 +150,7 @@ import { usePowerLevelsContext } from '$hooks/usePowerLevels'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice'; +import { Microphone, Stop } from '@phosphor-icons/react'; import { SchedulePickerDialog } from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; import { @@ -160,7 +161,6 @@ import { } from './msgContent'; import { CommandAutocomplete } from './CommandAutocomplete'; import { AudioMessageRecorder, AudioMessageRecorderHandle } from './AudioMessageRecorder'; -import { AudioAttachmentPreview } from './AudioAttachmentPreview'; const getReplyContent = (replyDraft: IReplyDraft | undefined): IEventRelation => { if (!replyDraft) return {}; @@ -251,18 +251,7 @@ export const RoomInput = forwardRef( const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [showAudioRecorder, setShowAudioRecorder] = useState(false); - const [audioMsgWaveform, setAudioMsgWaveform] = useState(undefined); - const [audioMsgLength, setAudioMsgLength] = useState(undefined); - // pendingAudioFileRef is a ref (not state) so its value is set synchronously - // before handleFiles → setSelectedFiles (Jotai) can trigger a render. - // Using useState here caused a render window where selectedFiles already contained - // the file but pendingAudioFile state was still null, letting UploadCardRenderer - // render for the audio file and trigger an upload-during-render state dispatch. - const pendingAudioFileRef = useRef(null); - const [pendingAudioUrl, setPendingAudioUrl] = useState(null); const audioRecorderRef = useRef(null); - // Tracks when the mic button was pressed on mobile so we can distinguish - // a quick tap (toggle — user stops manually) from a long hold (send on release). const micHoldStartRef = useRef(0); const HOLD_THRESHOLD_MS = 400; const [autocompleteQuery, setAutocompleteQuery] = @@ -274,7 +263,7 @@ export const RoomInput = forwardRef( const [inputKey, setInputKey] = useState(0); const handleFiles = useCallback( - async (files: File[]) => { + async (files: File[], audioMeta?: { waveform: number[]; audioDuration: number }) => { setUploadBoard(true); const safeFiles = files.map(safeFile); const fileItems: TUploadItem[] = []; @@ -288,6 +277,8 @@ export const RoomInput = forwardRef( ...ef, metadata: { markedAsSpoiler: false, + waveform: audioMeta?.waveform, + audioDuration: audioMeta?.audioDuration, }, }) ); @@ -299,6 +290,8 @@ export const RoomInput = forwardRef( encInfo: undefined, metadata: { markedAsSpoiler: false, + waveform: audioMeta?.waveform, + audioDuration: audioMeta?.audioDuration, }, }) ); @@ -435,19 +428,6 @@ export const RoomInput = forwardRef( const handleRemoveUpload = useCallback( (upload: TUploadContent | TUploadContent[]) => { const uploads = Array.isArray(upload) ? upload : [upload]; - // If the pending audio file is being removed (via UploadBoard's Remove button), - // clean up the object URL and reset audio preview state. - uploads.forEach((u) => { - if (u === pendingAudioFileRef.current) { - setPendingAudioUrl((url) => { - if (url) URL.revokeObjectURL(url); - return null; - }); - pendingAudioFileRef.current = null; - setAudioMsgWaveform(undefined); - setAudioMsgLength(undefined); - } - }); setSelectedFiles({ type: 'DELETE', item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)), @@ -480,7 +460,7 @@ export const RoomInput = forwardRef( return getVideoMsgContent(mx, fileItem, upload.mxc); } if (fileItem.file.type.startsWith('audio')) { - return getAudioMsgContent(fileItem, upload.mxc, audioMsgWaveform, audioMsgLength); + return getAudioMsgContent(fileItem, upload.mxc); } return getFileMsgContent(fileItem, upload.mxc); }); @@ -744,6 +724,10 @@ export const RoomInput = forwardRef( } if (isKeyHotkey('escape', evt)) { evt.preventDefault(); + if (showAudioRecorder) { + audioRecorderRef.current?.cancel(); + return; + } if (autocompleteQuery) { setAutocompleteQuery(undefined); return; @@ -751,7 +735,15 @@ export const RoomInput = forwardRef( setReplyDraft(undefined); } }, - [submit, roomId, setReplyDraft, enterForNewline, autocompleteQuery, isComposing] + [ + submit, + roomId, + setReplyDraft, + enterForNewline, + autocompleteQuery, + isComposing, + showAudioRecorder, + ] ); const handleKeyUp: KeyboardEventHandler = useCallback( @@ -854,35 +846,18 @@ export const RoomInput = forwardRef( {Array.from(selectedFiles) .reverse() - .map((fileItem, index) => { - // pendingAudioFileRef is a ref so it's available synchronously - // even in Jotai-triggered renders — UploadCardRenderer never - // renders for the audio file, preventing the upload-during-render - // setState warning on UploadBoardHeader. - if (fileItem.file === pendingAudioFileRef.current && pendingAudioUrl) { - return ( - - ); - } - return ( - - ); - })} + .map((fileItem, index) => ( + + ))} )} @@ -1108,19 +1083,22 @@ export const RoomInput = forwardRef( setShowAudioRecorder(false)} - onRecordingComplete={(audioBlob) => { - const file = new File([audioBlob], `sable-audio-message-${Date.now()}.ogg`, { - type: audioBlob.type, + onRecordingComplete={(payload) => { + const file = new File( + [payload.audioBlob], + `sable-audio-message-${Date.now()}.ogg`, + { + type: payload.audioBlob.type, + } + ); + handleFiles([file], { + waveform: payload.waveform, + audioDuration: payload.audioLength, }); - // Set ref synchronously BEFORE handleFiles so the Jotai render - // that follows immediately sees it and skips UploadCardRenderer. - pendingAudioFileRef.current = file; - setPendingAudioUrl(URL.createObjectURL(audioBlob)); - handleFiles([file]); setShowAudioRecorder(false); }} - onAudioLengthUpdate={(len) => setAudioMsgLength(len)} - onWaveformUpdate={(w) => setAudioMsgWaveform(w)} + onAudioLengthUpdate={() => {}} + onWaveformUpdate={() => {}} /> )} @@ -1149,10 +1127,7 @@ export const RoomInput = forwardRef( // One-shot global listeners: the mic button stays mounted now but // the ref is already populated by the time pointerup fires. - const cleanup = () => { - window.removeEventListener('pointerup', onUp); - window.removeEventListener('pointercancel', onCancel); - }; + let cleanup: () => void; const onUp = () => { cleanup(); const held = Date.now() - micHoldStartRef.current; @@ -1165,14 +1140,19 @@ export const RoomInput = forwardRef( cleanup(); audioRecorderRef.current?.cancel(); }; + cleanup = () => { + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onCancel); + }; window.addEventListener('pointerup', onUp); window.addEventListener('pointercancel', onCancel); }} > - + {showAudioRecorder ? ( + + ) : ( + + )} {/* ── Toolbar toggle — always visible ── */} diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index 5536f7d05..7da6d955a 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -148,13 +148,9 @@ export type AudioMsgContent = IContent & { audioLength?: number; }; -export const getAudioMsgContent = ( - item: TUploadItem, - mxc: string, - waveform?: number[], - audioLength?: number -): AudioMsgContent => { - const { file, encInfo } = item; +export const getAudioMsgContent = (item: TUploadItem, mxc: string): AudioMsgContent => { + const { file, encInfo, metadata } = item; + const { waveform, audioDuration, markedAsSpoiler } = metadata; const content: IContent = { msgtype: MsgType.Audio, filename: file.name, @@ -166,8 +162,8 @@ export const getAudioMsgContent = ( size: file.size, }, 'org.matrix.msc1767.audio': { - waveform: waveform?.map((v) => Math.round(v * 1024)), // scale waveform values to fit in 10 bits (0-1024) for more efficient storage, as per MSC1767 spec - duration: item.metadata.markedAsSpoiler || !audioLength ? 0 : audioLength * 1000, // if marked as spoiler, set duration to 0 to hide it in clients that support msc1767 + waveform: waveform?.map((v) => Math.round(v * 1024)), + duration: markedAsSpoiler || !audioDuration ? 0 : audioDuration * 1000, }, }; if (encInfo) { diff --git a/src/app/plugins/voice-recorder-kit/README.md b/src/app/plugins/voice-recorder-kit/README.md deleted file mode 100644 index 49b9edd3b..000000000 --- a/src/app/plugins/voice-recorder-kit/README.md +++ /dev/null @@ -1,492 +0,0 @@ -# react-voice-recorder-kit - -A lightweight React library for voice recording with audio waveform visualization and no UI framework dependencies - -* No UI framework dependencies (Pure React + Inline CSS) -* Animated audio waveform visualization (40 bars) -* Ready-to-use component -* Fully customizable hook -* TypeScript support -* Compatible with Next.js, Vite, CRA, and more - ---- - -## Screenshots - -### Initial State (Ready to Record) -

- Initial State -

- -### Recording in Progress -![Recording](assets/Screenshot%202025-12-05%20173347.png) - -### Paused State -![Paused](assets/Screenshot%202025-12-05%20173407.png) - -### Recorded Audio Ready to Play (Custom Styled) -![Completed Recording Custom](assets/Screenshot%202025-12-05%20173519.png) - ---- - -## Installation - -```bash -npm install react-voice-recorder-kit -# or -pnpm add react-voice-recorder-kit -# or -yarn add react-voice-recorder-kit -``` - -Requires **React 18+** - ---- - -## Quick Start (Using Component) - -```tsx -'use client' - -import { useState } from 'react' -import { VoiceRecorder } from 'react-voice-recorder-kit' - -export default function Page() { - const [file, setFile] = useState(null) - const [url, setUrl] = useState(null) - const [waveform, setWaveform] = useState([]) - const [audioLength, setAudioLength] = useState(0) - - return ( -
-

React Voice Recorder Kit

- - { - setFile(audioFile) - setUrl(audioUrl) - setWaveform(waveform) - setAudioLength(audioLength) - }} - onDelete={() => { - setFile(null) - setUrl(null) - setWaveform([]) - setAudioLength(0) - }} - /> - - {url && ( -
-
- )} -
- ) -} -``` - ---- - -## Usage in Next.js (App Router) - -```tsx -'use client' - -import { VoiceRecorder } from 'react-voice-recorder-kit' - -export default function VoicePage() { - return ( -
- -
- ) -} -``` - ---- - -## Component API - -### Main Props - -| Prop | Type | Default | Description | -| --------- | --------------------------------- | --------- | ---------------------------------------------- | -| autoStart | boolean | true | Auto-start recording on mount | -| onStop | (payload: { audioFile: Blob; audioUrl: string; waveform: number[]; audioLength: number }) => void | undefined | Callback after recording stops (all values batched) | -| onDelete | () => void | undefined | Callback after recording is deleted | -| width | string \| number | '100%' | Component width | -| height | string \| number | undefined | Component height | -| style | CSSProperties | undefined | Additional styles for container | - -### Styling Props - -| Prop | Type | Default | Description | -| --------------------------- | --------------------------------------- | ------------------------------------------------------------ | ------------------------------------ | -| backgroundColor | string | '#ffffff' | Background color | -| borderColor | string | '#e5e7eb' | Border color | -| borderRadius | string \| number | 4 | Border radius | -| padding | string \| number | '6px 10px' | Internal padding | -| gap | string \| number | 8 | Gap between elements | -| recordingIndicatorColor | string | '#ef4444' | Recording indicator color | -| idleIndicatorColor | string | '#9ca3af' | Idle indicator color | -| timeTextColor | string | undefined | Time text color | -| timeFontSize | string \| number | 12 | Time font size | -| timeFontWeight | string \| number | 500 | Time font weight | -| timeFontFamily | string | 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' | Time font family | -| visualizerBarColor | string \| (level: number, index: number) => string | '#4b5563' | Waveform bar color | -| visualizerBarWidth | number | 3 | Waveform bar width | -| visualizerBarGap | number | 4 | Gap between bars | -| visualizerBarHeight | number | 40 | Waveform bar height | -| visualizerHeight | number | 40 | Total waveform height | -| buttonSize | number | 28 | Button size | -| buttonBackgroundColor | string | '#ffffff' | Button background color | -| buttonBorderColor | string | '#e5e7eb' | Button border color | -| buttonBorderRadius | string \| number | 999 | Button border radius | -| buttonHoverBackgroundColor | string | undefined | Button hover background color | -| buttonGap | number | 4 | Gap between buttons | -| errorTextColor | string | '#dc2626' | Error text color | -| errorFontSize | string \| number | 10 | Error font size | -| errorFontFamily | string | 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' | Error font family | -| iconSize | number | 18 | Icon size | -| iconColor | string | undefined | Icon color | - -### Custom Icon Props - -| Prop | Type | Default | Description | -| --------------- | --------- | --------- | ------------------------ | -| customPlayIcon | ReactNode | undefined | Custom play icon | -| customPauseIcon | ReactNode | undefined | Custom pause icon | -| customStopIcon | ReactNode | undefined | Custom stop icon | -| customResumeIcon| ReactNode | undefined | Custom resume icon | -| customDeleteIcon| ReactNode | undefined | Custom delete icon | -| customRepeatIcon| ReactNode | undefined | Custom repeat icon | - ---- - -## Component Usage Examples - -### Example 1: Simple Usage - -```tsx -import { VoiceRecorder } from 'react-voice-recorder-kit' - -function SimpleRecorder() { - return -} -``` - -### Example 2: Custom Styling - -```tsx -import { VoiceRecorder } from 'react-voice-recorder-kit' - -function CustomStyledRecorder() { - return ( - - ) -} -``` - -### Example 3: Using with Callbacks - -```tsx -import { useState } from 'react' -import { VoiceRecorder } from 'react-voice-recorder-kit' - -function RecorderWithCallbacks() { - const [audioFile, setAudioFile] = useState(null) - - return ( - { - if (audioFile instanceof File) { - console.log('Recording stopped:', audioFile.name) - } - console.log('Audio length (s):', audioLength) - console.log('Waveform points:', waveform.length) - setAudioFile(audioFile) - }} - onDelete={() => { - console.log('Recording deleted') - setAudioFile(null) - }} - /> - ) -} -``` - -### Example 4: Dynamic Color Waveform - -```tsx -import { VoiceRecorder } from 'react-voice-recorder-kit' - -function DynamicColorRecorder() { - return ( - { - const hue = (level * 120).toString() - return `hsl(${hue}, 70%, 50%)` - }} - /> - ) -} -``` - ---- - -## Using the Hook (useVoiceRecorder) - -For full control over the UI, you can use the hook directly. - -### Import - -```ts -import { useVoiceRecorder } from 'react-voice-recorder-kit' -``` - -### Options - -```ts -type UseVoiceRecorderOptions = { - autoStart?: boolean - onStop?: (payload: { audioFile: Blob; audioUrl: string; waveform: number[]; audioLength: number }) => void - onDelete?: () => void -} -``` - -### Return Values - -```ts -type UseVoiceRecorderReturn = { - state: RecorderState - isRecording: boolean - isStopped: boolean - isTemporaryStopped: boolean - isPlaying: boolean - isPaused: boolean - seconds: number - levels: number[] - error: string | null - audioUrl: string | null - audioFile: File | null - waveform: number[] | null - start: () => void - handlePause: () => void - handleStopTemporary: () => void - handleStop: () => void - handleResume: () => void - handlePreviewPlay: () => void - handlePlay: () => void - handleRestart: () => void - handleDelete: () => void - handleRecordAgain: () => void -} -``` - -| Property | Type | Description | -| ----------------- | -------------- | ---------------------------------------------- | -| state | RecorderState | Current state: 'idle' \| 'recording' \| 'paused' \| 'reviewing' \| 'playing' | -| isRecording | boolean | Is currently recording | -| isStopped | boolean | Is recording stopped | -| isTemporaryStopped| boolean | Is recording temporarily stopped | -| isPlaying | boolean | Is currently playing | -| isPaused | boolean | Is recording paused | -| seconds | number | Time in seconds | -| levels | number[] | Array of 40 audio levels (0 to 1) | -| error | string \| null | Error message if any | -| audioUrl | string \| null | URL of recorded audio file | -| audioFile | File \| null | Recorded audio file | -| waveform | number[] \| null | Downsampled waveform points for the recording | -| start | () => void | Start recording | -| handlePause | () => void | Pause recording | -| handleStopTemporary| () => void | Temporary stop and review | -| handleStop | () => void | Stop and save recording | -| handleResume | () => void | Resume recording after pause | -| handlePreviewPlay | () => void | Play preview (in paused state) | -| handlePlay | () => void | Play recorded file | -| handleRestart | () => void | Restart recording | -| handleDelete | () => void | Delete recording and return to initial state | -| handleRecordAgain | () => void | Record again (same as handleRestart) | - ---- - -## Complete Hook Usage Example - -```tsx -'use client' - -import { useVoiceRecorder } from 'react-voice-recorder-kit' - -export default function CustomRecorder() { - const { - state, - isRecording, - isPaused, - isStopped, - isPlaying, - seconds, - levels, - audioUrl, - audioFile, - error, - start, - handlePause, - handleResume, - handleStop, - handlePlay, - handleDelete, - handleRestart - } = useVoiceRecorder({ autoStart: false }) - - const formatTime = (secs: number) => { - const minutes = Math.floor(secs / 60) - const sec = secs % 60 - return `${minutes}:${sec.toString().padStart(2, '0')}` - } - - return ( -
-

Custom Voice Recorder

- -
- Status: {state} | Time: {formatTime(seconds)} -
- -
- {!isRecording && !isStopped && ( - - )} - - {isRecording && !isPaused && ( - <> - - - - - )} - - {isPaused && ( - <> - - - - - )} - - {isStopped && audioUrl && ( - <> - - - - - )} -
- - {error && ( -
- {error} -
- )} - -
- {levels.map((level, index) => { - const height = 5 + level * 35 - return ( -
- ) - })} -
- - {audioUrl && ( -
-
- )} -
- ) -} -``` - ---- - -## Recording States (RecorderState) - -The component and hook have 5 different states: - -- **idle**: Initial state, ready to start -- **recording**: Currently recording -- **paused**: Recording paused (can be resumed) -- **reviewing**: Recording completed and under review -- **playing**: Playing recorded file - ---- - -## Features - -* Voice recording using MediaRecorder API -* Animated audio waveform visualization during recording and playback -* Support for pause and resume -* Support for playing recorded files -* Time display in MM:SS format -* Error handling and error message display -* Ready-to-use UI with control buttons -* Fully customizable styling and sizing -* No external dependencies -* Support for custom icons -* Dynamic color waveforms - ---- - -## Important Notes - -1. Requires microphone access in the browser -2. Recorded files are saved in WebM format -3. In paused state, you can play a preview of the recording -4. You can dynamically set bar colors using `visualizerBarColor` -5. All created URLs are automatically cleaned up - ---- - -## License - -MIT - -(orignal by Mohammadreza Fallahfaal: https://github.com/mohamad-fallah/react-voice-recorder-kit) diff --git a/src/app/plugins/voice-recorder-kit/VoiceRecorder.tsx b/src/app/plugins/voice-recorder-kit/VoiceRecorder.tsx deleted file mode 100644 index a51f634a9..000000000 --- a/src/app/plugins/voice-recorder-kit/VoiceRecorder.tsx +++ /dev/null @@ -1,620 +0,0 @@ -import type { CSSProperties } from 'react'; -import { useMemo, useRef, useEffect, useState } from 'react'; -import { useVoiceRecorder } from './useVoiceRecorder'; -import type { VoiceRecorderProps } from './types'; -import { PlayIcon, PauseIcon, StopIcon, RepeatIcon, DeleteIcon, ResumeIcon } from './icons'; - -function VoiceRecorder(props: VoiceRecorderProps) { - const { - width, - height, - style, - backgroundColor = '#ffffff', - borderColor = '#e5e7eb', - borderRadius = 4, - padding = '6px 10px', - gap = 8, - recordingIndicatorColor = '#ef4444', - idleIndicatorColor = '#9ca3af', - timeTextColor, - timeFontSize = 12, - timeFontWeight = 500, - timeFontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', - visualizerBarColor = '#4b5563', - visualizerBarWidth = 3, - visualizerBarGap = 4, - visualizerBarHeight = 40, - visualizerHeight = 40, - buttonSize = 28, - buttonBackgroundColor = '#ffffff', - buttonBorderColor = '#e5e7eb', - buttonBorderRadius = 999, - buttonHoverBackgroundColor, - buttonGap = 4, - errorTextColor = '#dc2626', - errorFontSize = 10, - errorFontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', - customPlayIcon, - customPauseIcon, - customStopIcon, - customResumeIcon, - customDeleteIcon, - customRepeatIcon, - iconSize = 18, - iconColor, - ...recorderOptions - } = props; - - const { - state, - isRecording, - isStopped, - isPlaying, - seconds, - levels, - error, - handlePause, - handleStopTemporary, - handleStop, - handleResume, - handlePreviewPlay, - handlePlay, - handleRestart, - handleDelete, - } = useVoiceRecorder(recorderOptions); - - const containerRef = useRef(null); - const [visualizerWidth, setVisualizerWidth] = useState(0); - const visualizerRef = useRef(null); - - useEffect(() => { - const updateWidth = () => { - if (visualizerRef.current) { - const availableWidth = visualizerRef.current.offsetWidth; - setVisualizerWidth(Math.max(0, availableWidth)); - } - }; - - updateWidth(); - const resizeObserver = new ResizeObserver(updateWidth); - if (visualizerRef.current) { - resizeObserver.observe(visualizerRef.current); - } - - return () => { - resizeObserver.disconnect(); - }; - }, [width, isStopped, error]); - - const formattedTime = useMemo(() => { - const minutes = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${minutes}:${secs.toString().padStart(2, '0')}`; - }, [seconds]); - - const barWidth = visualizerBarWidth; - const barGap = visualizerBarGap; - - const maxBars = useMemo(() => { - if (visualizerWidth <= 0) { - return Math.max(levels.length, 40); - } - const calculatedBars = Math.floor(visualizerWidth / (barWidth + barGap)); - return Math.max(calculatedBars, 1); - }, [visualizerWidth, levels.length, barWidth, barGap]); - - const displayedLevels = useMemo(() => { - if (maxBars <= 0 || levels.length === 0) { - return Array.from({ length: Math.max(maxBars, 40) }, () => 0.15); - } - - if (maxBars <= levels.length) { - const step = levels.length / maxBars; - return Array.from({ length: maxBars }, (_, i) => { - const start = Math.floor(i * step); - const end = Math.floor((i + 1) * step); - const slice = levels.slice(start, end); - return slice.length > 0 ? Math.max(...slice) : 0.15; - }); - } - - const step = (levels.length - 1) / (maxBars - 1); - return Array.from({ length: maxBars }, (_, i) => { - const position = i * step; - const lowerIndex = Math.floor(position); - const upperIndex = Math.min(Math.ceil(position), levels.length - 1); - const fraction = position - lowerIndex; - - if (lowerIndex === upperIndex) { - return levels[lowerIndex] || 0.15; - } - - return ( - (levels[lowerIndex] || 0.15) * (1 - fraction) + (levels[upperIndex] || 0.15) * fraction - ); - }); - }, [levels, maxBars]); - - const containerStyle: CSSProperties = useMemo( - () => ({ - display: 'flex', - alignItems: 'center', - gap: typeof gap === 'number' ? `${gap}px` : gap, - backgroundColor, - borderRadius: typeof borderRadius === 'number' ? `${borderRadius}px` : borderRadius, - border: `1px solid ${borderColor}`, - padding: typeof padding === 'number' ? `${padding}px` : padding, - width: width ?? '100%', - height, - boxSizing: 'border-box', - ...style, - }), - [width, height, style, backgroundColor, borderColor, borderRadius, padding, gap] - ); - - return ( -
- - - {formattedTime} - -
- {displayedLevels.map((level) => ( - - ))} -
- - {state === 'recording' && ( -
- - - -
- )} - - {state === 'paused' && ( -
- - - - -
- )} - - {state === 'reviewing' && ( -
- - - -
- )} - - {state === 'playing' && ( -
- -
- )} - - {error && ( - - {error} - - )} -
- ); -} - -export default VoiceRecorder; diff --git a/src/app/plugins/voice-recorder-kit/icons.tsx b/src/app/plugins/voice-recorder-kit/icons.tsx deleted file mode 100644 index 29248a00a..000000000 --- a/src/app/plugins/voice-recorder-kit/icons.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import type { FC } from 'react'; - -export const PlayIcon: FC<{ size?: number }> = function ({ size = 18 }) { - return ( - - - - - ); -}; - -export const ResumeIcon: FC<{ size?: number }> = function ({ size = 18 }) { - return ( - - - - ); -}; - -export const PauseIcon: FC<{ size?: number }> = function ({ size = 18 }) { - return ( - - - - - ); -}; - -export const StopIcon: FC<{ size?: number }> = function ({ size = 18 }) { - return ( - - - - ); -}; - -export const DeleteIcon: FC<{ size?: number }> = function ({ size = 18 }) { - return ( - - - - - - - - ); -}; - -export const RepeatIcon: FC<{ size?: number }> = function ({ size = 18 }) { - return ( - - - - ); -}; diff --git a/src/app/plugins/voice-recorder-kit/index.ts b/src/app/plugins/voice-recorder-kit/index.ts index 00564f2bf..00e2e4d7e 100644 --- a/src/app/plugins/voice-recorder-kit/index.ts +++ b/src/app/plugins/voice-recorder-kit/index.ts @@ -1,9 +1,7 @@ export { useVoiceRecorder } from './useVoiceRecorder'; -export { default as VoiceRecorder } from './VoiceRecorder'; export type { UseVoiceRecorderOptions, UseVoiceRecorderReturn, RecorderState, - VoiceRecorderProps, VoiceRecorderStopPayload, } from './types'; diff --git a/src/app/plugins/voice-recorder-kit/types.ts b/src/app/plugins/voice-recorder-kit/types.ts index 42cccd271..95d428775 100644 --- a/src/app/plugins/voice-recorder-kit/types.ts +++ b/src/app/plugins/voice-recorder-kit/types.ts @@ -1,5 +1,3 @@ -import type { CSSProperties, ReactNode } from 'react'; - export type RecorderState = 'idle' | 'recording' | 'paused' | 'reviewing' | 'playing'; export type VoiceRecorderStopPayload = { @@ -39,42 +37,3 @@ export type UseVoiceRecorderReturn = { handleDelete: () => void; handleRecordAgain: () => void; }; - -export type VoiceRecorderProps = UseVoiceRecorderOptions & { - width?: string | number; - height?: string | number; - style?: CSSProperties; - backgroundColor?: string; - borderColor?: string; - borderRadius?: string | number; - padding?: string | number; - gap?: string | number; - recordingIndicatorColor?: string; - idleIndicatorColor?: string; - timeTextColor?: string; - timeFontSize?: string | number; - timeFontWeight?: string | number; - timeFontFamily?: string; - visualizerBarColor?: string | ((level: number, index: number) => string); - visualizerBarWidth?: number; - visualizerBarGap?: number; - visualizerBarHeight?: number; - visualizerHeight?: number; - buttonSize?: number; - buttonBackgroundColor?: string; - buttonBorderColor?: string; - buttonBorderRadius?: string | number; - buttonHoverBackgroundColor?: string; - buttonGap?: number; - errorTextColor?: string; - errorFontSize?: string | number; - errorFontFamily?: string; - customPlayIcon?: ReactNode; - customPauseIcon?: ReactNode; - customStopIcon?: ReactNode; - customResumeIcon?: ReactNode; - customDeleteIcon?: ReactNode; - customRepeatIcon?: ReactNode; - iconSize?: number; - iconColor?: string; -}; diff --git a/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts b/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts index 454762463..1b3ea8a15 100644 --- a/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts +++ b/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts @@ -9,6 +9,15 @@ import type { const BAR_COUNT = 40; const WAVEFORM_POINT_COUNT = 100; +let sharedAudioContext: AudioContext | null = null; + +function getSharedAudioContext(): AudioContext { + if (!sharedAudioContext || sharedAudioContext.state === 'closed') { + sharedAudioContext = new AudioContext(); + } + return sharedAudioContext; +} + // downsample an array of samples to a target count by averaging blocks of samples together function downsampleWaveform(samples: number[], targetCount: number): number[] { if (samples.length === 0) return Array.from({ length: targetCount }, () => 0); @@ -86,7 +95,9 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic } frameCountRef.current = 0; if (audioContextRef.current) { - audioContextRef.current.close().catch(() => {}); + if (audioContextRef.current.state !== 'closed') { + audioContextRef.current.suspend().catch(() => {}); + } audioContextRef.current = null; } analyserRef.current = null; @@ -188,7 +199,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic const setupAudioGraph = useCallback( (stream: MediaStream) => { - const audioContext = new AudioContext(); + const audioContext = getSharedAudioContext(); audioContextRef.current = audioContext; const source = audioContext.createMediaStreamSource(stream); const analyser = audioContext.createAnalyser(); @@ -199,7 +210,9 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic analyserRef.current = analyser; dataArrayRef.current = dataArray; source.connect(analyser); - audioContext.resume().catch(() => {}); + if (audioContext.state === 'suspended') { + audioContext.resume().catch(() => {}); + } animateLevels(); }, [animateLevels] @@ -207,7 +220,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic const setupPlaybackGraph = useCallback( (audio: HTMLAudioElement) => { - const audioContext = new AudioContext(); + const audioContext = getSharedAudioContext(); audioContextRef.current = audioContext; const source = audioContext.createMediaElementSource(audio); const analyser = audioContext.createAnalyser(); @@ -219,7 +232,9 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic dataArrayRef.current = dataArray; source.connect(analyser); analyser.connect(audioContext.destination); - audioContext.resume().catch(() => {}); + if (audioContext.state === 'suspended') { + audioContext.resume().catch(() => {}); + } animateLevels(); }, [animateLevels] diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index 51c3cfaf5..4b167f220 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -9,6 +9,8 @@ import { createListAtom } from '$state/list'; export type TUploadMetadata = { markedAsSpoiler: boolean; + waveform?: number[]; + audioDuration?: number; }; export type TUploadItem = { From 7e76985ebd806d8336747e89c023a68a8e904192 Mon Sep 17 00:00:00 2001 From: hazre Date: Sun, 15 Mar 2026 00:59:34 +0100 Subject: [PATCH 3/7] fix: more polish on voice messages --- .../features/room/AudioMessageRecorder.css.ts | 5 ++- .../features/room/AudioMessageRecorder.tsx | 14 +------- src/app/features/room/RoomInput.tsx | 32 +++++++++++-------- 3 files changed, 21 insertions(+), 30 deletions(-) diff --git a/src/app/features/room/AudioMessageRecorder.css.ts b/src/app/features/room/AudioMessageRecorder.css.ts index 44c7bcd02..47b165841 100644 --- a/src/app/features/room/AudioMessageRecorder.css.ts +++ b/src/app/features/room/AudioMessageRecorder.css.ts @@ -22,9 +22,9 @@ const Shake = keyframes({ export const Container = style([ DefaultReset, { + flexGrow: 1, minWidth: 0, overflow: 'hidden', - padding: `0 ${config.space.S200}`, touchAction: 'pan-y', userSelect: 'none', }, @@ -86,12 +86,11 @@ export const CancelHint = style([ DefaultReset, { position: 'absolute', - left: 0, + left: config.space.S200, top: 0, bottom: 0, display: 'flex', alignItems: 'center', - paddingLeft: config.space.S200, color: color.Critical.Main, fontSize: toRem(12), fontWeight: 600, diff --git a/src/app/features/room/AudioMessageRecorder.tsx b/src/app/features/room/AudioMessageRecorder.tsx index 7ac2e18c4..95c130f1a 100644 --- a/src/app/features/room/AudioMessageRecorder.tsx +++ b/src/app/features/room/AudioMessageRecorder.tsx @@ -10,7 +10,7 @@ import { import { useVoiceRecorder } from '$plugins/voice-recorder-kit'; import type { VoiceRecorderStopPayload } from '$plugins/voice-recorder-kit'; import { mobileOrTablet } from '$utils/user-agent'; -import { Box, Text, IconButton, Icon, Icons } from 'folds'; +import { Box, Text } from 'folds'; import * as css from './AudioMessageRecorder.css'; export type AudioRecordingCompletePayload = { @@ -203,18 +203,6 @@ export const AudioMessageRecorder = forwardRef< )} - - - - {showCancelHint && (
( editableName="RoomInput" editor={editor} key={inputKey} - placeholder="Send a message..." + placeholder={showAudioRecorder && mobileOrTablet() ? '' : 'Send a message...'} onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onPaste={handlePaste} @@ -1065,20 +1065,21 @@ export const RoomInput = forwardRef( } before={ - pickFile('*')} - variant="SurfaceVariant" - size="300" - radii="300" - title="Upload File" - aria-label="Upload and attach a File" - > - - + !(showAudioRecorder && mobileOrTablet()) && ( + pickFile('*')} + variant="SurfaceVariant" + size="300" + radii="300" + title="Upload File" + aria-label="Upload and attach a File" + > + + + ) } after={ <> - {/* ── Recording pill: grows into available space left of mic button ── */} {showAudioRecorder && ( ( aria-label={showAudioRecorder ? 'Stop recording' : 'Record audio message'} aria-pressed={showAudioRecorder} onClick={() => { - if (mobileOrTablet()) return; // mobile handled via pointerdown/up + if (mobileOrTablet() && showAudioRecorder) { + audioRecorderRef.current?.stop(); + return; + } + if (mobileOrTablet()) return; if (showAudioRecorder) { audioRecorderRef.current?.stop(); } else { @@ -1155,7 +1160,6 @@ export const RoomInput = forwardRef( )} - {/* ── Toolbar toggle — always visible ── */} Date: Tue, 17 Mar 2026 16:44:23 +0100 Subject: [PATCH 4/7] fix: prevent first waveform bar from being lit when audio not playing --- src/app/components/upload-card/UploadCardRenderer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index aa060835c..7ca37028c 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -226,7 +226,7 @@ function PreviewAudio({ fileItem }: PreviewAudioProps) { > {bars.map((level, i) => { const barRatio = i / BAR_COUNT; - const played = barRatio <= progress; + const played = progress > 0 && barRatio <= progress; return (
Date: Tue, 17 Mar 2026 17:45:38 +0100 Subject: [PATCH 5/7] fix: waveform algorithm --- .../upload-card/UploadCardRenderer.tsx | 20 ++++++++++++- .../features/room/AudioMessageRecorder.tsx | 28 +++++++++++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index ed77bf7ca..931215b63 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -99,8 +99,26 @@ function PreviewAudio({ fileItem }: PreviewAudioProps) { if (!waveform || waveform.length === 0) { return Array(BAR_COUNT).fill(0.3); } + if (waveform.length <= BAR_COUNT) { + const step = (waveform.length - 1) / (BAR_COUNT - 1); + return Array.from({ length: BAR_COUNT }, (_, i) => { + const position = i * step; + const lower = Math.floor(position); + const upper = Math.min(Math.ceil(position), waveform.length - 1); + const fraction = position - lower; + if (lower === upper) { + return waveform[lower] ?? 0.3; + } + return (waveform[lower] ?? 0.3) * (1 - fraction) + (waveform[upper] ?? 0.3) * fraction; + }); + } const step = waveform.length / BAR_COUNT; - return Array.from({ length: BAR_COUNT }, (_, i) => waveform[Math.floor(i * step)] ?? 0); + return Array.from({ length: BAR_COUNT }, (_, i) => { + const start = Math.floor(i * step); + const end = Math.floor((i + 1) * step); + const slice = waveform.slice(start, end); + return slice.length > 0 ? Math.max(...slice) : 0.3; + }); }, [waveform]); const progress = duration > 0 ? Math.min(currentTime / duration, 1) : 0; diff --git a/src/app/features/room/AudioMessageRecorder.tsx b/src/app/features/room/AudioMessageRecorder.tsx index cca7d5772..0c40dcd7f 100644 --- a/src/app/features/room/AudioMessageRecorder.tsx +++ b/src/app/features/room/AudioMessageRecorder.tsx @@ -150,11 +150,29 @@ export const AudioMessageRecorder = forwardRef< const BAR_COUNT = 28; const bars = useMemo(() => { - const step = Math.max(1, levels.length / BAR_COUNT); - return Array.from( - { length: BAR_COUNT }, - (_, i) => levels[Math.min(Math.floor(i * step), levels.length - 1)] ?? 0.15 - ); + if (levels.length === 0) { + return Array(BAR_COUNT).fill(0.15); + } + if (levels.length <= BAR_COUNT) { + const step = (levels.length - 1) / (BAR_COUNT - 1); + return Array.from({ length: BAR_COUNT }, (_, i) => { + const position = i * step; + const lower = Math.floor(position); + const upper = Math.min(Math.ceil(position), levels.length - 1); + const fraction = position - lower; + if (lower === upper) { + return levels[lower] ?? 0.15; + } + return (levels[lower] ?? 0.15) * (1 - fraction) + (levels[upper] ?? 0.15) * fraction; + }); + } + const step = levels.length / BAR_COUNT; + return Array.from({ length: BAR_COUNT }, (_, i) => { + const start = Math.floor(i * step); + const end = Math.floor((i + 1) * step); + const slice = levels.slice(start, end); + return slice.length > 0 ? Math.max(...slice) : 0.15; + }); }, [levels]); const containerClassName = [ From 35e842f7680802b369c7a2c544fb4001a75be461 Mon Sep 17 00:00:00 2001 From: hazre Date: Tue, 17 Mar 2026 18:05:35 +0100 Subject: [PATCH 6/7] fix: simplify mobile stuff --- .../features/room/AudioMessageRecorder.tsx | 74 +------------------ src/app/features/room/RoomInput.tsx | 25 +++---- .../voice-recorder-kit/useVoiceRecorder.ts | 35 +++++---- 3 files changed, 31 insertions(+), 103 deletions(-) diff --git a/src/app/features/room/AudioMessageRecorder.tsx b/src/app/features/room/AudioMessageRecorder.tsx index 0c40dcd7f..5ec592bc8 100644 --- a/src/app/features/room/AudioMessageRecorder.tsx +++ b/src/app/features/room/AudioMessageRecorder.tsx @@ -9,7 +9,6 @@ import { } from 'react'; import { useVoiceRecorder } from '$plugins/voice-recorder-kit'; import type { VoiceRecorderStopPayload } from '$plugins/voice-recorder-kit'; -import { mobileOrTablet } from '$utils/user-agent'; import { Box, Text } from 'folds'; import * as css from './AudioMessageRecorder.css'; @@ -45,12 +44,7 @@ export const AudioMessageRecorder = forwardRef< const isDismissedRef = useRef(false); const userRequestedStopRef = useRef(false); const [isCanceling, setIsCanceling] = useState(false); - const [isShaking, setIsShaking] = useState(false); - const [swipeX, setSwipeX] = useState(0); - const [showCancelHint, setShowCancelHint] = useState(false); const [announcedTime, setAnnouncedTime] = useState(0); - const touchStartXRef = useRef(0); - const isSwipingRef = useRef(false); const onRecordingCompleteRef = useRef(onRecordingComplete); onRecordingCompleteRef.current = onRecordingComplete; @@ -108,46 +102,6 @@ export const AudioMessageRecorder = forwardRef< } }, [seconds, announcedTime]); - const CANCEL_THRESHOLD = 80; - - const handlePointerDown = useCallback((e: React.PointerEvent) => { - if (!mobileOrTablet()) return; - touchStartXRef.current = e.clientX; - isSwipingRef.current = true; - (e.target as HTMLElement).setPointerCapture(e.pointerId); - }, []); - - const handlePointerMove = useCallback((e: React.PointerEvent) => { - if (!isSwipingRef.current || !mobileOrTablet()) return; - const deltaX = e.clientX - touchStartXRef.current; - if (deltaX < 0) { - setSwipeX(Math.max(deltaX, -CANCEL_THRESHOLD - 20)); - setShowCancelHint(deltaX < -30); - } else { - setSwipeX(0); - setShowCancelHint(false); - } - }, []); - - const handlePointerUp = useCallback( - (e: React.PointerEvent) => { - if (!isSwipingRef.current || !mobileOrTablet()) return; - isSwipingRef.current = false; - (e.target as HTMLElement).releasePointerCapture(e.pointerId); - - const deltaX = e.clientX - touchStartXRef.current; - if (deltaX < -CANCEL_THRESHOLD) { - doCancel(); - } else if (deltaX < -30) { - setIsShaking(true); - setTimeout(() => setIsShaking(false), 300); - } - setSwipeX(0); - setShowCancelHint(false); - }, - [doCancel] - ); - const BAR_COUNT = 28; const bars = useMemo(() => { if (levels.length === 0) { @@ -175,11 +129,7 @@ export const AudioMessageRecorder = forwardRef< }); }, [levels]); - const containerClassName = [ - css.Container, - isCanceling ? css.ContainerCanceling : null, - isShaking ? css.ContainerShake : null, - ] + const containerClassName = [css.Container, isCanceling ? css.ContainerCanceling : null] .filter(Boolean) .join(' '); @@ -190,17 +140,7 @@ export const AudioMessageRecorder = forwardRef< {error} )} - +
@@ -222,16 +162,6 @@ export const AudioMessageRecorder = forwardRef< Recording duration: {formatTime(announcedTime)} )} - - {showCancelHint && ( -
- Release to cancel -
- )}
); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 44747d906..099cfe5cc 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -1237,10 +1237,6 @@ export const RoomInput = forwardRef( aria-label={showAudioRecorder ? 'Stop recording' : 'Record audio message'} aria-pressed={showAudioRecorder} onClick={() => { - if (mobileOrTablet() && showAudioRecorder) { - audioRecorderRef.current?.stop(); - return; - } if (mobileOrTablet()) return; if (showAudioRecorder) { audioRecorderRef.current?.stop(); @@ -1250,31 +1246,30 @@ export const RoomInput = forwardRef( }} onPointerDown={() => { if (!mobileOrTablet()) return; - if (showAudioRecorder) return; // already recording — onClick will stop it + if (showAudioRecorder) return; micHoldStartRef.current = Date.now(); setShowAudioRecorder(true); - // One-shot global listeners: the mic button stays mounted now but - // the ref is already populated by the time pointerup fires. let cleanup: () => void; const onUp = () => { cleanup(); const held = Date.now() - micHoldStartRef.current; if (held >= HOLD_THRESHOLD_MS) { - audioRecorderRef.current?.stop(); + setTimeout(() => { + audioRecorderRef.current?.stop(); + }, 50); + } else { + setTimeout(() => { + audioRecorderRef.current?.cancel(); + }, 50); } - // Short tap → recorder stays open; tap stop-icon to finish - }; - const onCancel = () => { - cleanup(); - audioRecorderRef.current?.cancel(); }; cleanup = () => { window.removeEventListener('pointerup', onUp); - window.removeEventListener('pointercancel', onCancel); + window.removeEventListener('pointercancel', cleanup); }; window.addEventListener('pointerup', onUp); - window.addEventListener('pointercancel', onCancel); + window.addEventListener('pointercancel', cleanup); }} > {showAudioRecorder ? ( diff --git a/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts b/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts index d0f1c2250..f73f7daf0 100644 --- a/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts +++ b/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts @@ -22,24 +22,27 @@ function getSharedAudioContext(): AudioContext { // downsample an array of samples to a target count by averaging blocks of samples together function downsampleWaveform(samples: number[], targetCount: number): number[] { - if (samples.length === 0) return Array.from({ length: targetCount }, () => 0); + if (samples.length === 0) return Array.from({ length: targetCount }, () => 0.15); if (samples.length <= targetCount) { - const padded = [...samples]; - while (padded.length < targetCount) padded.push(0); - return padded; - } - const result: number[] = []; - const blockSize = samples.length / targetCount; - for (let i = 0; i < targetCount; i += 1) { - const start = Math.floor(i * blockSize); - const end = Math.floor((i + 1) * blockSize); - let sum = 0; - for (let j = start; j < end; j += 1) { - sum += samples[j]; - } - result.push(sum / (end - start)); + const step = (samples.length - 1) / (targetCount - 1); + return Array.from({ length: targetCount }, (_, i) => { + const position = i * step; + const lower = Math.floor(position); + const upper = Math.min(Math.ceil(position), samples.length - 1); + const fraction = position - lower; + if (lower === upper) { + return samples[lower] ?? 0.15; + } + return (samples[lower] ?? 0.15) * (1 - fraction) + (samples[upper] ?? 0.15) * fraction; + }); } - return result; + const step = samples.length / targetCount; + return Array.from({ length: targetCount }, (_, i) => { + const start = Math.floor(i * step); + const end = Math.floor((i + 1) * step); + const slice = samples.slice(start, end); + return slice.length > 0 ? Math.max(...slice) : 0.15; + }); } export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoiceRecorderReturn { From 42351dac0361d86e734b8df3c9114b2e86df8816 Mon Sep 17 00:00:00 2001 From: hazre Date: Tue, 17 Mar 2026 18:56:10 +0100 Subject: [PATCH 7/7] docs: add changeset --- ...ecording_ui_it_should_now_feel_a_lot_more_intergrated_.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/improved_voice_message_recording_ui_it_should_now_feel_a_lot_more_intergrated_.md diff --git a/.changeset/improved_voice_message_recording_ui_it_should_now_feel_a_lot_more_intergrated_.md b/.changeset/improved_voice_message_recording_ui_it_should_now_feel_a_lot_more_intergrated_.md new file mode 100644 index 000000000..eaeb76ec6 --- /dev/null +++ b/.changeset/improved_voice_message_recording_ui_it_should_now_feel_a_lot_more_intergrated_.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +# Improved voice message recording UI, it should now feel a lot more integrated.