From cd80ca642e6627dddb273e9142bcbb69020be0aa Mon Sep 17 00:00:00 2001 From: hazre Date: Wed, 18 Mar 2026 17:04:00 +0100 Subject: [PATCH 01/15] fix: prevent text selection on following indicator Prevents mobile voice recording glitch where holding the record button would select text from the 'following the conversation' row underneath. --- src/app/features/room/RoomViewFollowing.css.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/features/room/RoomViewFollowing.css.ts b/src/app/features/room/RoomViewFollowing.css.ts index 18b53ac92..3f7bee353 100644 --- a/src/app/features/room/RoomViewFollowing.css.ts +++ b/src/app/features/room/RoomViewFollowing.css.ts @@ -19,6 +19,7 @@ export const RoomViewFollowing = recipe({ backgroundColor: color.Surface.Container, color: color.Surface.OnContainer, outline: 'none', + userSelect: 'none', }, ], variants: { From 2aa9e5a9608636e31125d85b1be38d3d60ff56a6 Mon Sep 17 00:00:00 2001 From: hazre Date: Wed, 18 Mar 2026 17:51:43 +0100 Subject: [PATCH 02/15] fix: allow stop button to work during mobile voice recording When recording on mobile, if the pointerup listener was lost (e.g., dragging off the button), there was no way to stop recording. Now the stop button's onClick handler works on mobile when recording is active, providing a fallback. --- src/app/features/room/RoomInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index a79eb2beb..70d7a92a7 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -1246,7 +1246,7 @@ export const RoomInput = forwardRef( aria-label={showAudioRecorder ? 'Stop recording' : 'Record audio message'} aria-pressed={showAudioRecorder} onClick={() => { - if (mobileOrTablet()) return; + if (mobileOrTablet() && !showAudioRecorder) return; if (showAudioRecorder) { audioRecorderRef.current?.stop(); } else { From 3279fdacc893559e61f9105acc052cd848b9fd7b Mon Sep 17 00:00:00 2001 From: hazre Date: Wed, 18 Mar 2026 18:39:04 +0100 Subject: [PATCH 03/15] fix: render AudioMessageRecorder in main content area during mobile recording Prevents layout shift by rendering the recorder in the editor's main content area instead of in the after buttons section. Uses replacementContent prop to swap the editor with the recorder while maintaining proper padding. --- src/app/components/editor/Editor.css.ts | 17 +++++++++++ src/app/components/editor/Editor.tsx | 38 ++++++++++++++----------- src/app/features/room/RoomInput.tsx | 32 +++++++++++++++++++-- 3 files changed, 67 insertions(+), 20 deletions(-) diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 290f7596b..658bc1af8 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -22,6 +22,23 @@ export const EditorOptions = style([ export const EditorTextareaScroll = style({}); +export const EditorReplacementContent = style([ + DefaultReset, + { + flexGrow: 1, + height: '100%', + padding: `${toRem(13)} ${toRem(1)}`, + selectors: { + [`${EditorTextareaScroll}:first-child &`]: { + paddingLeft: toRem(13), + }, + [`${EditorTextareaScroll}:last-child &`]: { + paddingRight: toRem(13), + }, + }, + }, +]); + export const EditorTextarea = style([ DefaultReset, { diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 27df4cf6c..c706d5e2c 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -68,6 +68,7 @@ type CustomEditorProps = { onPaste?: ClipboardEventHandler; className?: string; variant?: 'Surface' | 'SurfaceVariant' | 'Background'; + replacementContent?: ReactNode; }; export const CustomEditor = forwardRef( ( @@ -86,6 +87,7 @@ export const CustomEditor = forwardRef( onPaste, className, variant = 'SurfaceVariant', + replacementContent, }, ref ) => { @@ -150,23 +152,25 @@ export const CustomEditor = forwardRef( visibility="Always" hideTrack > - { - if (mobileOrTablet()) ReactEditor.focus(editor); - }} - /> + {replacementContent ? ( +
{replacementContent}
+ ) : ( + { + if (mobileOrTablet()) ReactEditor.focus(editor); + }} + /> + )} {after && ( diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 70d7a92a7..96a561315 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -290,6 +290,7 @@ export const RoomInput = forwardRef( const audioRecorderRef = useRef(null); const micHoldStartRef = useRef(0); const HOLD_THRESHOLD_MS = 400; + const isMobileRecording = showAudioRecorder && mobileOrTablet(); const [autocompleteQuery, setAutocompleteQuery] = useState>(); const [isQuickTextReact, setQuickTextReact] = useState(false); @@ -1077,10 +1078,35 @@ export const RoomInput = forwardRef( editableName="RoomInput" editor={editor} key={inputKey} - placeholder={showAudioRecorder && mobileOrTablet() ? '' : 'Send a message...'} + placeholder="Send a message..." onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onPaste={handlePaste} + replacementContent={ + isMobileRecording ? ( + setShowAudioRecorder(false)} + onRecordingComplete={(payload) => { + const extension = getSupportedAudioExtension(payload.audioCodec); + const file = new File( + [payload.audioBlob], + `sable-audio-message-${Date.now()}.${extension}`, + { + type: payload.audioCodec, + } + ); + handleFiles([file], { + waveform: payload.waveform, + audioDuration: payload.audioLength, + }); + setShowAudioRecorder(false); + }} + onAudioLengthUpdate={() => {}} + onWaveformUpdate={() => {}} + /> + ) : undefined + } top={ <> {scheduledTime && ( @@ -1197,7 +1223,7 @@ export const RoomInput = forwardRef( } before={ - !(showAudioRecorder && mobileOrTablet()) && ( + !isMobileRecording && ( pickFile('*')} variant="SurfaceVariant" @@ -1212,7 +1238,7 @@ export const RoomInput = forwardRef( } after={ <> - {showAudioRecorder && ( + {showAudioRecorder && !isMobileRecording && ( setShowAudioRecorder(false)} From d3fc96644e5a09d161fabc54cc00240e05f37683 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 20 Mar 2026 14:50:18 +0100 Subject: [PATCH 04/15] feat: add multiline editor mode --- src/app/components/editor/Editor.css.ts | 50 ++++++++++++- src/app/components/editor/Editor.tsx | 93 +++++++++++++++++++++++-- 2 files changed, 134 insertions(+), 9 deletions(-) diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 658bc1af8..990c6a74e 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -13,6 +13,21 @@ export const Editor = style([ }, ]); +export const EditorRow = style({ + display: 'grid', + gridTemplateColumns: 'auto 1fr auto', + alignItems: 'center', +}); + +export const EditorRowMultiline = style({ + gridTemplateColumns: 'auto 1fr', + gridTemplateAreas: ` + "before textarea" + "before after" + `, + alignItems: 'start', +}); + export const EditorOptions = style([ DefaultReset, { @@ -20,7 +35,23 @@ export const EditorOptions = style([ }, ]); -export const EditorTextareaScroll = style({}); +export const EditorOptionsMultiline = style({ + gridArea: 'before', + alignSelf: 'end', +}); + +export const EditorOptionsAfterMultiline = style({ + gridArea: 'after', + justifySelf: 'end', +}); + +export const EditorTextareaScroll = style({ + minWidth: 0, +}); + +export const EditorTextareaScrollMultiline = style({ + gridArea: 'textarea', +}); export const EditorReplacementContent = style([ DefaultReset, @@ -39,11 +70,16 @@ export const EditorReplacementContent = style([ }, ]); +export const EditorReplacementContentMultiline = style({ + paddingLeft: config.space.S200, + paddingRight: config.space.S200, +}); + export const EditorTextarea = style([ DefaultReset, { flexGrow: 1, - height: '100%', + height: 'auto', padding: `${toRem(13)} ${toRem(1)}`, selectors: { [`${EditorTextareaScroll}:first-child &`]: { @@ -59,6 +95,11 @@ export const EditorTextarea = style([ }, ]); +export const EditorTextareaMultiline = style({ + paddingLeft: config.space.S200, + paddingRight: config.space.S200, +}); + export const EditorPlaceholderContainer = style([ DefaultReset, { @@ -74,6 +115,11 @@ export const EditorPlaceholderTextVisual = style([ display: 'block', paddingTop: toRem(13), paddingLeft: toRem(1), + selectors: { + [`${EditorTextareaScroll}:first-child &`]: { + paddingLeft: toRem(13), + }, + }, }, ]); diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index c706d5e2c..4fa1c0eee 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -5,6 +5,7 @@ import { ReactNode, forwardRef, useCallback, + useRef, useState, } from 'react'; import { Box, Scroll, Text } from 'folds'; @@ -99,6 +100,64 @@ export const CustomEditor = forwardRef( const [slateInitialValue] = useState(() => [ { type: BlockType.Paragraph, children: [{ text: '' }] }, ]); + const editableRef = useRef(null); + const beforeRef = useRef(null); + const afterRef = useRef(null); + const isMultilineRef = useRef(false); + const [isMultiline, setIsMultiline] = useState(false); + const layoutIsMultiline = isMultiline && !replacementContent; + + const handleChange = useCallback( + (value: Descendant[]) => { + const hasMultipleBlocks = editor.children.length > 1; + const text = Editor.string(editor, []); + const hasExplicitNewlines = text.includes('\n'); + + const editable = editableRef.current; + if (editable) { + const computedStyle = getComputedStyle(editable); + const lineHeight = parseFloat(computedStyle.lineHeight) || 20; + const paddingTop = parseFloat(computedStyle.paddingTop) || 0; + const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0; + const contentHeight = editable.scrollHeight - paddingTop - paddingBottom; + const isWrappingNow = contentHeight > lineHeight * 1.5; + + let nextMultiline: boolean; + + if (!isMultilineRef.current) { + nextMultiline = hasMultipleBlocks || hasExplicitNewlines || isWrappingNow; + } else { + const beforeWidth = beforeRef.current?.offsetWidth ?? 0; + const afterWidth = afterRef.current?.offsetWidth ?? 0; + const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0; + const paddingRight = parseFloat(computedStyle.paddingRight) || 0; + const availableSingleLineWidth = + editable.offsetWidth - beforeWidth - afterWidth - paddingLeft - paddingRight; + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + let wouldWrapInSingleLine = false; + if (ctx) { + ctx.font = computedStyle.font; + const textWidth = ctx.measureText(text).width; + wouldWrapInSingleLine = textWidth > availableSingleLineWidth; + } + + nextMultiline = hasMultipleBlocks || hasExplicitNewlines || wouldWrapInSingleLine; + } + + isMultilineRef.current = nextMultiline; + setIsMultiline(nextMultiline); + } else { + const nextMultiline = hasMultipleBlocks || hasExplicitNewlines; + isMultilineRef.current = nextMultiline; + setIsMultiline(nextMultiline); + } + + onChange?.(value); + }, + [editor, onChange] + ); const renderElement = useCallback( (props: RenderElementProps) => , @@ -136,16 +195,25 @@ export const CustomEditor = forwardRef( return (
- + {top} - + {before && ( - + {before} )} ( hideTrack > {replacementContent ? ( -
{replacementContent}
+
+ {replacementContent} +
) : ( ( )}
{after && ( - + {after} )} From 91dce3423d5c9c279ed2cf22544eed87990c2a10 Mon Sep 17 00:00:00 2001 From: hazre Date: Sat, 21 Mar 2026 18:31:59 +0100 Subject: [PATCH 05/15] fix: voice recording making the editor container expand --- src/app/components/editor/Editor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 4fa1c0eee..a8afb579c 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -215,7 +215,7 @@ export const CustomEditor = forwardRef( Date: Sat, 21 Mar 2026 19:11:16 +0100 Subject: [PATCH 06/15] fix: move recorder into multiline footer --- src/app/components/editor/Editor.css.ts | 44 +++--- src/app/components/editor/Editor.test.tsx | 161 ++++++++++++++++++++++ src/app/components/editor/Editor.tsx | 62 +++++---- src/app/features/room/RoomInput.tsx | 109 +++++++-------- 4 files changed, 256 insertions(+), 120 deletions(-) create mode 100644 src/app/components/editor/Editor.test.tsx diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 990c6a74e..9c40ea3bf 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -28,6 +28,14 @@ export const EditorRowMultiline = style({ alignItems: 'start', }); +export const EditorRowMultilineWithResponsiveAfter = style({ + gridTemplateColumns: 'auto 1fr auto', + gridTemplateAreas: ` + "before textarea textarea" + "before responsive-after after" + `, +}); + export const EditorOptions = style([ DefaultReset, { @@ -53,34 +61,12 @@ export const EditorTextareaScrollMultiline = style({ gridArea: 'textarea', }); -export const EditorReplacementContent = style([ - DefaultReset, - { - flexGrow: 1, - height: '100%', - padding: `${toRem(13)} ${toRem(1)}`, - selectors: { - [`${EditorTextareaScroll}:first-child &`]: { - paddingLeft: toRem(13), - }, - [`${EditorTextareaScroll}:last-child &`]: { - paddingRight: toRem(13), - }, - }, - }, -]); - -export const EditorReplacementContentMultiline = style({ - paddingLeft: config.space.S200, - paddingRight: config.space.S200, -}); - export const EditorTextarea = style([ DefaultReset, { flexGrow: 1, height: 'auto', - padding: `${toRem(13)} ${toRem(1)}`, + padding: `${toRem(13)} 0 0`, selectors: { [`${EditorTextareaScroll}:first-child &`]: { paddingLeft: toRem(13), @@ -95,10 +81,14 @@ export const EditorTextarea = style([ }, ]); -export const EditorTextareaMultiline = style({ - paddingLeft: config.space.S200, - paddingRight: config.space.S200, -}); +export const EditorResponsiveAfterMultiline = style([ + EditorOptions, + { + gridArea: 'responsive-after', + minWidth: 0, + alignSelf: 'stretch', + }, +]); export const EditorPlaceholderContainer = style([ DefaultReset, diff --git a/src/app/components/editor/Editor.test.tsx b/src/app/components/editor/Editor.test.tsx new file mode 100644 index 000000000..f0a488b99 --- /dev/null +++ b/src/app/components/editor/Editor.test.tsx @@ -0,0 +1,161 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { Transforms } from 'slate'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { useEditor, CustomEditor } from './Editor'; +import { BlockType } from './types'; +import * as css from './Editor.css'; + +let shouldWrapToggleHarness = false; + +function EditorHarness() { + const editor = useEditor(); + + return ( + <> + + Attach} + after={} + responsiveAfter={
Recorder
} + /> + + ); +} + +function ToggleRecorderHarness() { + const editor = useEditor(); + const [showRecorder, setShowRecorder] = useState(false); + + return ( + <> + + + Attach} + after={} + responsiveAfter={ + showRecorder ?
Recorder
: undefined + } + /> + + ); +} + +function ForcedFooterHarness() { + const editor = useEditor(); + + return ( + Attach} + after={} + responsiveAfter={
Recorder
} + forceMultilineLayout + /> + ); +} + +const nativeScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight'); + +beforeEach(() => { + shouldWrapToggleHarness = false; + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + if ( + this instanceof HTMLElement && + this.getAttribute('data-editable-name') === 'ToggleRecorderHarness' + ) { + return shouldWrapToggleHarness ? 40 : 20; + } + return nativeScrollHeight?.get?.call(this) ?? 0; + }, + }); +}); + +afterEach(() => { + shouldWrapToggleHarness = false; + if (nativeScrollHeight) { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', nativeScrollHeight); + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollHeight'); + } +}); + +describe('CustomEditor', () => { + it('moves responsive after content into the multiline footer without keeping the textarea max height', async () => { + render(); + const editable = document.querySelector('[data-editable-name="EditorHarness"]'); + const scroll = editable?.parentElement as HTMLElement | null; + + expect(scroll).not.toBeNull(); + expect(scroll?.style.maxHeight).toBe('50vh'); + expect(screen.getByText('Attach')).toBeVisible(); + expect(screen.getByText('Send')).toBeVisible(); + expect(screen.getByTestId('recorder').parentElement).toHaveClass(css.EditorOptions); + + fireEvent.click(screen.getByRole('button', { name: 'Make multiline' })); + + await waitFor(() => { + expect(screen.getByTestId('recorder').parentElement).toHaveClass( + css.EditorResponsiveAfterMultiline + ); + expect(scroll?.style.maxHeight).toBe(''); + }); + + expect(screen.getByText('Attach')).toBeVisible(); + expect(screen.getByText('Send')).toBeVisible(); + }); + + it('recomputes multiline layout when inline responsive content makes existing text wrap', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add text' })); + fireEvent.click(screen.getByRole('button', { name: 'Start recorder' })); + + await waitFor(() => { + expect(screen.getByTestId('toggle-recorder').parentElement).toHaveClass( + css.EditorResponsiveAfterMultiline + ); + }); + }); + + it('supports forcing multiline layout so responsive content moves into the footer immediately', () => { + render(); + + expect(screen.getByTestId('forced-footer-recorder').parentElement).toHaveClass( + css.EditorResponsiveAfterMultiline + ); + }); +}); diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index a8afb579c..683ab2c9d 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -60,6 +60,8 @@ type CustomEditorProps = { bottom?: ReactNode; before?: ReactNode; after?: ReactNode; + responsiveAfter?: ReactNode; + forceMultilineLayout?: boolean; maxHeight?: string; editor: Editor; placeholder?: string; @@ -69,7 +71,6 @@ type CustomEditorProps = { onPaste?: ClipboardEventHandler; className?: string; variant?: 'Surface' | 'SurfaceVariant' | 'Background'; - replacementContent?: ReactNode; }; export const CustomEditor = forwardRef( ( @@ -79,6 +80,8 @@ export const CustomEditor = forwardRef( bottom, before, after, + responsiveAfter, + forceMultilineLayout = false, maxHeight = '50vh', editor, placeholder, @@ -88,7 +91,6 @@ export const CustomEditor = forwardRef( onPaste, className, variant = 'SurfaceVariant', - replacementContent, }, ref ) => { @@ -105,7 +107,9 @@ export const CustomEditor = forwardRef( const afterRef = useRef(null); const isMultilineRef = useRef(false); const [isMultiline, setIsMultiline] = useState(false); - const layoutIsMultiline = isMultiline && !replacementContent; + const layoutIsMultiline = isMultiline || forceMultilineLayout; + const showResponsiveAfterInFooter = Boolean(responsiveAfter) && layoutIsMultiline; + const showResponsiveAfterInline = Boolean(responsiveAfter) && !showResponsiveAfterInFooter; const handleChange = useCallback( (value: Descendant[]) => { @@ -198,7 +202,7 @@ export const CustomEditor = forwardRef( {top} {before && ( @@ -215,37 +219,29 @@ export const CustomEditor = forwardRef( - {replacementContent ? ( -
- {replacementContent} -
- ) : ( - { - if (mobileOrTablet()) ReactEditor.focus(editor); - }} - /> - )} + { + if (mobileOrTablet()) ReactEditor.focus(editor); + }} + />
- {after && ( + {(after || showResponsiveAfterInline) && ( ( gap="100" shrink="No" > + {showResponsiveAfterInline && responsiveAfter} {after} )} + {showResponsiveAfterInFooter && ( + + {responsiveAfter} + + )}
{bottom}
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 96a561315..e6203d7c4 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -164,7 +164,11 @@ import { getVideoMsgContent, } from './msgContent'; import { CommandAutocomplete } from './CommandAutocomplete'; -import { AudioMessageRecorder, AudioMessageRecorderHandle } from './AudioMessageRecorder'; +import { + AudioMessageRecorder, + AudioMessageRecorderHandle, + AudioRecordingCompletePayload, +} from './AudioMessageRecorder'; // Returns the event ID of the most recent non-reaction/non-edit event in a thread, // falling back to the thread root if no replies exist yet. @@ -290,7 +294,6 @@ export const RoomInput = forwardRef( const audioRecorderRef = useRef(null); const micHoldStartRef = useRef(0); const HOLD_THRESHOLD_MS = 400; - const isMobileRecording = showAudioRecorder && mobileOrTablet(); const [autocompleteQuery, setAutocompleteQuery] = useState>(); const [isQuickTextReact, setQuickTextReact] = useState(false); @@ -474,6 +477,35 @@ export const RoomInput = forwardRef( [setSelectedFiles, selectedFiles] ); + const handleAudioRecordingComplete = useCallback( + (payload: AudioRecordingCompletePayload) => { + const extension = getSupportedAudioExtension(payload.audioCodec); + const file = new File( + [payload.audioBlob], + `sable-audio-message-${Date.now()}.${extension}`, + { + type: payload.audioCodec, + } + ); + handleFiles([file], { + waveform: payload.waveform, + audioDuration: payload.audioLength, + }); + setShowAudioRecorder(false); + }, + [handleFiles] + ); + + const audioRecorder = showAudioRecorder ? ( + setShowAudioRecorder(false)} + onRecordingComplete={handleAudioRecordingComplete} + onAudioLengthUpdate={() => {}} + onWaveformUpdate={() => {}} + /> + ) : undefined; + const handleCancelUpload = (uploads: Upload[]) => { uploads.forEach((upload) => { if (upload.status === UploadStatus.Loading) { @@ -1082,31 +1114,8 @@ export const RoomInput = forwardRef( onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onPaste={handlePaste} - replacementContent={ - isMobileRecording ? ( - setShowAudioRecorder(false)} - onRecordingComplete={(payload) => { - const extension = getSupportedAudioExtension(payload.audioCodec); - const file = new File( - [payload.audioBlob], - `sable-audio-message-${Date.now()}.${extension}`, - { - type: payload.audioCodec, - } - ); - handleFiles([file], { - waveform: payload.waveform, - audioDuration: payload.audioLength, - }); - setShowAudioRecorder(false); - }} - onAudioLengthUpdate={() => {}} - onWaveformUpdate={() => {}} - /> - ) : undefined - } + responsiveAfter={audioRecorder} + forceMultilineLayout={showAudioRecorder} top={ <> {scheduledTime && ( @@ -1223,45 +1232,19 @@ export const RoomInput = forwardRef( } before={ - !isMobileRecording && ( - pickFile('*')} - variant="SurfaceVariant" - size="300" - radii="300" - title="Upload File" - aria-label="Upload and attach a File" - > - - - ) + pickFile('*')} + variant="SurfaceVariant" + size="300" + radii="300" + title="Upload File" + aria-label="Upload and attach a File" + > + + } after={ <> - {showAudioRecorder && !isMobileRecording && ( - setShowAudioRecorder(false)} - onRecordingComplete={(payload) => { - const extension = getSupportedAudioExtension(payload.audioCodec); - const file = new File( - [payload.audioBlob], - `sable-audio-message-${Date.now()}.${extension}`, - { - type: payload.audioCodec, - } - ); - handleFiles([file], { - waveform: payload.waveform, - audioDuration: payload.audioLength, - }); - setShowAudioRecorder(false); - }} - onAudioLengthUpdate={() => {}} - onWaveformUpdate={() => {}} - /> - )} - {/* ── Mic button — always present; icon swaps to Stop while recording ── */} Date: Sat, 21 Mar 2026 20:11:21 +0100 Subject: [PATCH 07/15] fix: compact and right-align recorder footer --- src/app/components/editor/Editor.tsx | 7 ++++++- src/app/features/room/AudioMessageRecorder.css.ts | 4 +++- src/app/features/room/AudioMessageRecorder.tsx | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 683ab2c9d..b9cdaeb87 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -254,7 +254,12 @@ export const CustomEditor = forwardRef(
)} {showResponsiveAfterInFooter && ( - + {responsiveAfter} )} diff --git a/src/app/features/room/AudioMessageRecorder.css.ts b/src/app/features/room/AudioMessageRecorder.css.ts index 47b165841..803261c5e 100644 --- a/src/app/features/room/AudioMessageRecorder.css.ts +++ b/src/app/features/room/AudioMessageRecorder.css.ts @@ -22,7 +22,8 @@ const Shake = keyframes({ export const Container = style([ DefaultReset, { - flexGrow: 1, + width: 'fit-content', + maxWidth: '100%', minWidth: 0, overflow: 'hidden', touchAction: 'pan-y', @@ -56,6 +57,7 @@ export const WaveformContainer = style([ height: 22, overflow: 'hidden', minWidth: 0, + flexShrink: 0, }, ]); diff --git a/src/app/features/room/AudioMessageRecorder.tsx b/src/app/features/room/AudioMessageRecorder.tsx index 5ec592bc8..7e605ed9f 100644 --- a/src/app/features/room/AudioMessageRecorder.tsx +++ b/src/app/features/room/AudioMessageRecorder.tsx @@ -140,10 +140,10 @@ export const AudioMessageRecorder = forwardRef< {error} )} - +
- + {bars.map((level, i) => (
Date: Sat, 21 Mar 2026 20:40:06 +0100 Subject: [PATCH 08/15] fix: adapt recorder waveform to available width --- .../features/room/AudioMessageRecorder.css.ts | 6 +-- .../features/room/AudioMessageRecorder.tsx | 48 +++++++++++++++---- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/app/features/room/AudioMessageRecorder.css.ts b/src/app/features/room/AudioMessageRecorder.css.ts index 803261c5e..d53440044 100644 --- a/src/app/features/room/AudioMessageRecorder.css.ts +++ b/src/app/features/room/AudioMessageRecorder.css.ts @@ -22,8 +22,8 @@ const Shake = keyframes({ export const Container = style([ DefaultReset, { - width: 'fit-content', - maxWidth: '100%', + width: '100%', + maxWidth: toRem(280), minWidth: 0, overflow: 'hidden', touchAction: 'pan-y', @@ -57,7 +57,7 @@ export const WaveformContainer = style([ height: 22, overflow: 'hidden', minWidth: 0, - flexShrink: 0, + flexGrow: 1, }, ]); diff --git a/src/app/features/room/AudioMessageRecorder.tsx b/src/app/features/room/AudioMessageRecorder.tsx index 7e605ed9f..d8904532a 100644 --- a/src/app/features/room/AudioMessageRecorder.tsx +++ b/src/app/features/room/AudioMessageRecorder.tsx @@ -7,6 +7,7 @@ import { useRef, useState, } from 'react'; +import { useElementSizeObserver } from '$hooks/useElementSizeObserver'; import { useVoiceRecorder } from '$plugins/voice-recorder-kit'; import type { VoiceRecorderStopPayload } from '$plugins/voice-recorder-kit'; import { Box, Text } from 'folds'; @@ -37,14 +38,22 @@ function formatTime(seconds: number): string { return `${m}:${s.toString().padStart(2, '0')}`; } +const MAX_BAR_COUNT = 28; +const MIN_BAR_COUNT = 8; +const BAR_WIDTH_PX = 2; +const BAR_GAP_PX = 4; +const RECORDER_CHROME_PX = 72; + export const AudioMessageRecorder = forwardRef< AudioMessageRecorderHandle, AudioMessageRecorderProps >(({ onRecordingComplete, onRequestClose, onWaveformUpdate, onAudioLengthUpdate }, ref) => { const isDismissedRef = useRef(false); const userRequestedStopRef = useRef(false); + const containerRef = useRef(null); const [isCanceling, setIsCanceling] = useState(false); const [announcedTime, setAnnouncedTime] = useState(0); + const [barCount, setBarCount] = useState(MAX_BAR_COUNT); const onRecordingCompleteRef = useRef(onRecordingComplete); onRecordingCompleteRef.current = onRecordingComplete; @@ -102,14 +111,28 @@ export const AudioMessageRecorder = forwardRef< } }, [seconds, announcedTime]); - const BAR_COUNT = 28; + useElementSizeObserver( + useCallback(() => containerRef.current, []), + useCallback((width) => { + const availableWaveformWidth = Math.max(0, width - RECORDER_CHROME_PX); + const nextBarCount = Math.max( + MIN_BAR_COUNT, + Math.min( + MAX_BAR_COUNT, + Math.floor((availableWaveformWidth + BAR_GAP_PX) / (BAR_WIDTH_PX + BAR_GAP_PX)) + ) + ); + setBarCount((current) => (current === nextBarCount ? current : nextBarCount)); + }, []) + ); + const bars = useMemo(() => { if (levels.length === 0) { - return Array(BAR_COUNT).fill(0.15); + return Array(barCount).fill(0.15); } - if (levels.length <= BAR_COUNT) { - const step = (levels.length - 1) / (BAR_COUNT - 1); - return Array.from({ length: BAR_COUNT }, (_, i) => { + if (levels.length <= barCount) { + const step = (levels.length - 1) / (barCount - 1); + return Array.from({ length: barCount }, (_, i) => { const position = i * step; const lower = Math.floor(position); const upper = Math.min(Math.ceil(position), levels.length - 1); @@ -120,14 +143,14 @@ export const AudioMessageRecorder = forwardRef< 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 step = levels.length / barCount; + return Array.from({ length: barCount }, (_, 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]); + }, [barCount, levels]); const containerClassName = [css.Container, isCanceling ? css.ContainerCanceling : null] .filter(Boolean) @@ -140,10 +163,15 @@ export const AudioMessageRecorder = forwardRef< {error} )} - +
- + {bars.map((level, i) => (
Date: Sat, 21 Mar 2026 20:51:49 +0100 Subject: [PATCH 09/15] fix: stabilize editor multiline wrap detection --- src/app/components/editor/Editor.test.tsx | 394 +++++++++++++++++++++- src/app/components/editor/Editor.tsx | 282 ++++++++++++++-- 2 files changed, 634 insertions(+), 42 deletions(-) diff --git a/src/app/components/editor/Editor.test.tsx b/src/app/components/editor/Editor.test.tsx index f0a488b99..ab38d73a5 100644 --- a/src/app/components/editor/Editor.test.tsx +++ b/src/app/components/editor/Editor.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useState } from 'react'; import { Transforms } from 'slate'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -7,6 +7,7 @@ import { BlockType } from './types'; import * as css from './Editor.css'; let shouldWrapToggleHarness = false; +let measurementCacheScrollHeightReads = 0; function EditorHarness() { const editor = useEditor(); @@ -86,31 +87,225 @@ function ForcedFooterHarness() { ); } +function PasteWrapHarness() { + const editor = useEditor(); + + return ( + <> + + + + ); +} + +function NearThresholdWrapHarness() { + const editor = useEditor(); + + return ( + <> + + + + ); +} + +function TrailingSpacesWrapHarness() { + const editor = useEditor(); + + return ( + <> + + + + ); +} + +function PasteNoWrapHarness() { + const editor = useEditor(); + + return ( + <> + + + + ); +} + +function MeasurementCacheHarness() { + const editor = useEditor(); + + return ( + <> + + + + ); +} + +const createResizeObserverStub = ( + observedElements: Set, + onCreate: (callback: ResizeObserverCallback) => void +) => + function ResizeObserverStub(callback: ResizeObserverCallback) { + onCreate(callback); + + return { + observe(target: Element) { + observedElements.add(target); + }, + unobserve(target: Element) { + observedElements.delete(target); + }, + disconnect() { + observedElements.clear(); + }, + }; + } as unknown as typeof ResizeObserver; + const nativeScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight'); +const nativeOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'); +const nativeClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'); +const nativeRequestAnimationFrame = window.requestAnimationFrame; +const nativeCancelAnimationFrame = window.cancelAnimationFrame; +const nativeGlobalRequestAnimationFrame = globalThis.requestAnimationFrame; +const nativeGlobalCancelAnimationFrame = globalThis.cancelAnimationFrame; +const nativeResizeObserver = globalThis.ResizeObserver; beforeEach(() => { shouldWrapToggleHarness = false; + measurementCacheScrollHeightReads = 0; + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, get() { - if ( - this instanceof HTMLElement && - this.getAttribute('data-editable-name') === 'ToggleRecorderHarness' - ) { - return shouldWrapToggleHarness ? 40 : 20; + if (this instanceof HTMLElement) { + const measurerName = this.getAttribute('data-editor-measurer'); + const measuredText = this.textContent ?? ''; + const hasMeasuredText = measuredText.length > 0; + const isSingleLineProbe = measuredText === 'M'; + + if (measurerName === 'ToggleRecorderHarness') { + if (!hasMeasuredText || isSingleLineProbe) return 20; + return shouldWrapToggleHarness ? 40 : 20; + } + + if (measurerName === 'PasteWrapHarness') { + if (isSingleLineProbe) return 20; + return hasMeasuredText ? 40 : 20; + } + + if (measurerName === 'NearThresholdWrapHarness') { + if (!hasMeasuredText || isSingleLineProbe) return 20; + return this.style.width === '319px' ? 29 : 20; + } + + if (measurerName === 'TrailingSpacesWrapHarness') { + if (!hasMeasuredText || isSingleLineProbe) return 20; + return measuredText.endsWith('\u200B') ? 29 : 20; + } + + if (measurerName === 'PasteNoWrapHarness') { + if (!hasMeasuredText || isSingleLineProbe) return 20; + return 20; + } + + if (measurerName === 'MeasurementCacheHarness') { + if (!hasMeasuredText || isSingleLineProbe) return 20; + measurementCacheScrollHeightReads += 1; + return 40; + } } + return nativeScrollHeight?.get?.call(this) ?? 0; }, }); + + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + get() { + if (this instanceof HTMLElement && this.classList.contains(css.EditorRow)) { + return 320; + } + return nativeOffsetWidth?.get?.call(this) ?? 0; + }, + }); + + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get() { + if (this instanceof HTMLElement && this.classList.contains(css.EditorTextareaScroll)) { + if (this.querySelector('[data-editable-name="NearThresholdWrapHarness"]')) { + return 319; + } + + return 320; + } + + return nativeClientWidth?.get?.call(this) ?? 0; + }, + }); }); afterEach(() => { shouldWrapToggleHarness = false; + measurementCacheScrollHeightReads = 0; + window.requestAnimationFrame = nativeRequestAnimationFrame; + window.cancelAnimationFrame = nativeCancelAnimationFrame; + globalThis.requestAnimationFrame = nativeGlobalRequestAnimationFrame; + globalThis.cancelAnimationFrame = nativeGlobalCancelAnimationFrame; + globalThis.ResizeObserver = nativeResizeObserver; if (nativeScrollHeight) { Object.defineProperty(HTMLElement.prototype, 'scrollHeight', nativeScrollHeight); } else { Reflect.deleteProperty(HTMLElement.prototype, 'scrollHeight'); } + + if (nativeOffsetWidth) { + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', nativeOffsetWidth); + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'offsetWidth'); + } + + if (nativeClientWidth) { + Object.defineProperty(HTMLElement.prototype, 'clientWidth', nativeClientWidth); + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'clientWidth'); + } }); describe('CustomEditor', () => { @@ -118,8 +313,13 @@ describe('CustomEditor', () => { render(); const editable = document.querySelector('[data-editable-name="EditorHarness"]'); const scroll = editable?.parentElement as HTMLElement | null; + const editorRoot = scroll?.parentElement?.parentElement as HTMLElement | null; + const measurer = document.querySelector('[data-editor-measurer="EditorHarness"]'); expect(scroll).not.toBeNull(); + expect(editorRoot).not.toBeNull(); + expect(editorRoot?.contains(measurer)).toBe(true); + expect(measurer?.parentElement).not.toBe(document.body); expect(scroll?.style.maxHeight).toBe('50vh'); expect(screen.getByText('Attach')).toBeVisible(); expect(screen.getByText('Send')).toBeVisible(); @@ -158,4 +358,186 @@ describe('CustomEditor', () => { css.EditorResponsiveAfterMultiline ); }); + + it('detects pasted text that exceeds the single-line width after the deferred layout measurement', async () => { + render(); + const editable = document.querySelector('[data-editable-name="PasteWrapHarness"]'); + const scroll = editable?.parentElement as HTMLElement | null; + + expect(scroll).not.toBeNull(); + expect(scroll).not.toHaveClass(css.EditorTextareaScrollMultiline); + + fireEvent.click(screen.getByRole('button', { name: 'Paste wrapped text' })); + + await waitFor(() => { + expect(scroll).toHaveClass(css.EditorTextareaScrollMultiline); + }); + }); + + it('does not oscillate back to single-line when multiline layout slightly increases the available width', async () => { + const queuedFrames = new Map(); + let nextFrameId = 1; + let resizeObserverCallback: ResizeObserverCallback | undefined; + const observedElements = new Set(); + const flushQueuedFrames = () => { + const pendingFrames = Array.from(queuedFrames.entries()); + queuedFrames.clear(); + pendingFrames.forEach(([, callback]) => { + callback(performance.now()); + }); + }; + + const requestAnimationFrameStub = ((callback: FrameRequestCallback) => { + const frameId = nextFrameId; + nextFrameId += 1; + queuedFrames.set(frameId, callback); + return frameId; + }) as typeof window.requestAnimationFrame; + const cancelAnimationFrameStub = ((frameId: number) => { + queuedFrames.delete(frameId); + }) as typeof window.cancelAnimationFrame; + + window.requestAnimationFrame = requestAnimationFrameStub; + window.cancelAnimationFrame = cancelAnimationFrameStub; + globalThis.requestAnimationFrame = requestAnimationFrameStub; + globalThis.cancelAnimationFrame = cancelAnimationFrameStub; + globalThis.ResizeObserver = createResizeObserverStub(observedElements, (callback) => { + resizeObserverCallback = callback; + }); + + render(); + const editable = document.querySelector('[data-editable-name="NearThresholdWrapHarness"]'); + const scroll = editable?.parentElement as HTMLElement | null; + + expect(scroll).not.toBeNull(); + expect(scroll).not.toHaveClass(css.EditorTextareaScrollMultiline); + + fireEvent.click(screen.getByRole('button', { name: 'Paste near-threshold wrap' })); + + await waitFor(() => { + expect(scroll).toHaveClass(css.EditorTextareaScrollMultiline); + }); + expect(resizeObserverCallback).toBeDefined(); + + act(() => { + resizeObserverCallback?.( + Array.from(observedElements).map((target) => ({ target }) as ResizeObserverEntry), + {} as ResizeObserver + ); + }); + + act(() => { + flushQueuedFrames(); + }); + + expect(scroll).toHaveClass(css.EditorTextareaScrollMultiline); + }); + + it('counts trailing spaces toward the single-line wrap threshold', async () => { + render(); + const editable = document.querySelector('[data-editable-name="TrailingSpacesWrapHarness"]'); + const scroll = editable?.parentElement as HTMLElement | null; + + expect(scroll).not.toBeNull(); + expect(scroll).not.toHaveClass(css.EditorTextareaScrollMultiline); + + fireEvent.click(screen.getByRole('button', { name: 'Paste trailing spaces' })); + + await waitFor(() => { + expect(scroll).toHaveClass(css.EditorTextareaScrollMultiline); + }); + }); + + it('keeps fitting pasted text in single-line mode without deferring to the next frame', () => { + const queuedFrames = new Map(); + let nextFrameId = 1; + + const requestAnimationFrameStub = ((callback: FrameRequestCallback) => { + const frameId = nextFrameId; + nextFrameId += 1; + queuedFrames.set(frameId, callback); + return frameId; + }) as typeof window.requestAnimationFrame; + const cancelAnimationFrameStub = ((frameId: number) => { + queuedFrames.delete(frameId); + }) as typeof window.cancelAnimationFrame; + window.requestAnimationFrame = requestAnimationFrameStub; + window.cancelAnimationFrame = cancelAnimationFrameStub; + globalThis.requestAnimationFrame = requestAnimationFrameStub; + globalThis.cancelAnimationFrame = cancelAnimationFrameStub; + + render(); + const editable = document.querySelector('[data-editable-name="PasteNoWrapHarness"]'); + const scroll = editable?.parentElement as HTMLElement | null; + + expect(scroll).not.toBeNull(); + expect(scroll).not.toHaveClass(css.EditorTextareaScrollMultiline); + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Paste short text' })); + }); + + expect(queuedFrames.size).toBe(0); + expect(scroll).not.toHaveClass(css.EditorTextareaScrollMultiline); + }); + + it('reuses the cached measurement when resize observer fires without changing the single-line width', async () => { + const queuedFrames = new Map(); + let nextFrameId = 1; + let resizeObserverCallback: ResizeObserverCallback | undefined; + const observedElements = new Set(); + const flushQueuedFrames = () => { + let safetyCounter = 0; + while (queuedFrames.size > 0 && safetyCounter < 10) { + const pendingFrames = Array.from(queuedFrames.entries()); + queuedFrames.clear(); + pendingFrames.forEach(([, callback]) => { + callback(performance.now()); + }); + safetyCounter += 1; + } + }; + + const requestAnimationFrameStub = ((callback: FrameRequestCallback) => { + const frameId = nextFrameId; + nextFrameId += 1; + queuedFrames.set(frameId, callback); + return frameId; + }) as typeof window.requestAnimationFrame; + const cancelAnimationFrameStub = ((frameId: number) => { + queuedFrames.delete(frameId); + }) as typeof window.cancelAnimationFrame; + + window.requestAnimationFrame = requestAnimationFrameStub; + window.cancelAnimationFrame = cancelAnimationFrameStub; + globalThis.requestAnimationFrame = requestAnimationFrameStub; + globalThis.cancelAnimationFrame = cancelAnimationFrameStub; + globalThis.ResizeObserver = createResizeObserverStub(observedElements, (callback) => { + resizeObserverCallback = callback; + }); + + render(); + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Add cached text' })); + }); + + await waitFor(() => { + expect(measurementCacheScrollHeightReads).toBe(1); + }); + expect(resizeObserverCallback).toBeDefined(); + + act(() => { + resizeObserverCallback?.( + Array.from(observedElements).map((target) => ({ target }) as ResizeObserverEntry), + {} as ResizeObserver + ); + }); + + act(() => { + flushQueuedFrames(); + }); + + expect(measurementCacheScrollHeightReads).toBe(1); + }); }); diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index b9cdaeb87..30726b811 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -5,11 +5,13 @@ import { ReactNode, forwardRef, useCallback, + useEffect, + useLayoutEffect, useRef, useState, } from 'react'; import { Box, Scroll, Text } from 'folds'; -import { Descendant, Editor, createEditor } from 'slate'; +import { Descendant, Editor, Node, createEditor } from 'slate'; import { Slate, Editable, @@ -54,6 +56,20 @@ export const useEditor = (): Editor => { }; export type EditorChangeHandler = (value: Descendant[]) => void; +const MAX_MULTILINE_MEASURE_RETRIES = 2; +const MULTILINE_HEIGHT_EPSILON = 1; +const TRAILING_SPACE_SENTINEL = '\u200B'; + +const normalizeMeasurementText = (text: string): string => + /[ \t]+$/.test(text) ? `${text}${TRAILING_SPACE_SENTINEL}` : text; + +type MultilineMeasurementCache = { + result: boolean; + singleLineWidth: number; + styleKey: string; + text: string; +}; + type CustomEditorProps = { editableName?: string; top?: ReactNode; @@ -102,52 +118,136 @@ export const CustomEditor = forwardRef( const [slateInitialValue] = useState(() => [ { type: BlockType.Paragraph, children: [{ text: '' }] }, ]); + const rootRef = useRef(null); const editableRef = useRef(null); + const rowRef = useRef(null); const beforeRef = useRef(null); const afterRef = useRef(null); + const textMeasurerRef = useRef(null); + const measurementCacheRef = useRef(null); + const multilineMeasureFrameRef = useRef(null); + const multilineMeasureRetryRef = useRef(0); + const singleLineWidthOffsetRef = useRef(0); + const latestValueRef = useRef(editor.children); const isMultilineRef = useRef(false); const [isMultiline, setIsMultiline] = useState(false); + const [measurementVersion, setMeasurementVersion] = useState(0); + const hasBefore = Boolean(before); + const hasAfter = Boolean(after); + const hasResponsiveAfter = Boolean(responsiveAfter); const layoutIsMultiline = isMultiline || forceMultilineLayout; - const showResponsiveAfterInFooter = Boolean(responsiveAfter) && layoutIsMultiline; - const showResponsiveAfterInline = Boolean(responsiveAfter) && !showResponsiveAfterInFooter; + const showResponsiveAfterInFooter = hasResponsiveAfter && layoutIsMultiline; + const showResponsiveAfterInline = hasResponsiveAfter && !showResponsiveAfterInFooter; - const handleChange = useCallback( - (value: Descendant[]) => { - const hasMultipleBlocks = editor.children.length > 1; - const text = Editor.string(editor, []); + const setRootRef = useCallback( + (node: HTMLDivElement | null) => { + rootRef.current = node; + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + Reflect.set(ref, 'current', node); + } + }, + [ref] + ); + + const updateMultilineLayout = useCallback( + (value: Descendant[] = editor.children) => { + const hasMultipleBlocks = value.length > 1; + const text = value.map((node) => Node.string(node)).join(''); const hasExplicitNewlines = text.includes('\n'); const editable = editableRef.current; - if (editable) { + const row = rowRef.current; + const textMeasurer = textMeasurerRef.current; + if (editable && row && textMeasurer) { + const scroll = editable.parentElement as HTMLDivElement | null; const computedStyle = getComputedStyle(editable); - const lineHeight = parseFloat(computedStyle.lineHeight) || 20; - const paddingTop = parseFloat(computedStyle.paddingTop) || 0; - const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0; - const contentHeight = editable.scrollHeight - paddingTop - paddingBottom; - const isWrappingNow = contentHeight > lineHeight * 1.5; + const beforeWidth = beforeRef.current?.offsetWidth ?? 0; + const afterWidth = afterRef.current?.offsetWidth ?? 0; + const rowSingleLineWidth = row.offsetWidth - beforeWidth - afterWidth; + const isRenderedSingleLine = !layoutIsMultiline; - let nextMultiline: boolean; + if (isRenderedSingleLine && scroll) { + const renderedSingleLineWidth = scroll.clientWidth; + if (renderedSingleLineWidth > 0) { + singleLineWidthOffsetRef.current = Math.max( + 0, + rowSingleLineWidth - renderedSingleLineWidth + ); + } + } - if (!isMultilineRef.current) { - nextMultiline = hasMultipleBlocks || hasExplicitNewlines || isWrappingNow; - } else { - const beforeWidth = beforeRef.current?.offsetWidth ?? 0; - const afterWidth = afterRef.current?.offsetWidth ?? 0; - const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0; - const paddingRight = parseFloat(computedStyle.paddingRight) || 0; - const availableSingleLineWidth = - editable.offsetWidth - beforeWidth - afterWidth - paddingLeft - paddingRight; - - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - let wouldWrapInSingleLine = false; - if (ctx) { - ctx.font = computedStyle.font; - const textWidth = ctx.measureText(text).width; - wouldWrapInSingleLine = textWidth > availableSingleLineWidth; + const singleLineWidth = Math.max( + 0, + rowSingleLineWidth - singleLineWidthOffsetRef.current + ); + + if ( + text.length > 0 && + singleLineWidth <= 0 && + multilineMeasureRetryRef.current < MAX_MULTILINE_MEASURE_RETRIES + ) { + multilineMeasureRetryRef.current += 1; + if (multilineMeasureFrameRef.current !== null) { + cancelAnimationFrame(multilineMeasureFrameRef.current); } + multilineMeasureFrameRef.current = requestAnimationFrame(() => { + multilineMeasureFrameRef.current = null; + updateMultilineLayout(); + }); + return; + } + + multilineMeasureRetryRef.current = 0; + let nextMultiline = hasMultipleBlocks || hasExplicitNewlines; + if (!nextMultiline && text.length > 0) { + const styleKey = [ + computedStyle.font, + computedStyle.lineHeight, + computedStyle.letterSpacing, + computedStyle.fontKerning, + computedStyle.fontFeatureSettings, + computedStyle.fontVariationSettings, + computedStyle.textTransform, + computedStyle.textIndent, + computedStyle.tabSize, + ].join('|'); + const cachedMeasurement = measurementCacheRef.current; - nextMultiline = hasMultipleBlocks || hasExplicitNewlines || wouldWrapInSingleLine; + if ( + cachedMeasurement?.text === text && + cachedMeasurement.singleLineWidth === singleLineWidth && + cachedMeasurement.styleKey === styleKey + ) { + nextMultiline = cachedMeasurement.result; + } else { + textMeasurer.style.font = computedStyle.font; + textMeasurer.style.lineHeight = computedStyle.lineHeight; + textMeasurer.style.letterSpacing = computedStyle.letterSpacing; + textMeasurer.style.fontKerning = computedStyle.fontKerning; + textMeasurer.style.fontFeatureSettings = computedStyle.fontFeatureSettings; + textMeasurer.style.fontVariationSettings = computedStyle.fontVariationSettings; + textMeasurer.style.textTransform = computedStyle.textTransform; + textMeasurer.style.textIndent = computedStyle.textIndent; + textMeasurer.style.tabSize = computedStyle.tabSize; + const measureHeight = (content: string, width: string): number => { + textMeasurer.style.width = width; + textMeasurer.textContent = normalizeMeasurementText(content); + return textMeasurer.scrollHeight; + }; + const singleLineHeight = measureHeight('M', 'max-content'); + const measuredHeight = measureHeight(text, `${Math.max(singleLineWidth, 0)}px`); + nextMultiline = measuredHeight > singleLineHeight + MULTILINE_HEIGHT_EPSILON; + measurementCacheRef.current = { + result: nextMultiline, + singleLineWidth, + styleKey, + text, + }; + } + } else { + measurementCacheRef.current = null; } isMultilineRef.current = nextMultiline; @@ -157,10 +257,119 @@ export const CustomEditor = forwardRef( isMultilineRef.current = nextMultiline; setIsMultiline(nextMultiline); } + }, + [editor, layoutIsMultiline] + ); + + useEffect(() => { + const root = rootRef.current; + if (!root) { + return undefined; + } + + const measurerHost = document.createElement('div'); + const textMeasurer = document.createElement('div'); + measurerHost.setAttribute('aria-hidden', 'true'); + textMeasurer.setAttribute('aria-hidden', 'true'); + if (editableName) { + textMeasurer.dataset.editorMeasurer = editableName; + } + Object.assign(measurerHost.style, { + position: 'absolute', + inset: '0', + width: '0', + height: '0', + overflow: 'hidden', + pointerEvents: 'none', + visibility: 'hidden', + zIndex: '-1', + }); + Object.assign(textMeasurer.style, { + padding: '0', + border: '0', + margin: '0', + whiteSpace: 'pre-wrap', + overflowWrap: 'break-word', + wordBreak: 'break-word', + boxSizing: 'border-box', + }); + measurerHost.appendChild(textMeasurer); + root.appendChild(measurerHost); + textMeasurerRef.current = textMeasurer; + + return () => { + measurementCacheRef.current = null; + textMeasurerRef.current = null; + measurerHost.remove(); + }; + }, [editableName]); + + useEffect( + () => () => { + if (multilineMeasureFrameRef.current !== null) { + cancelAnimationFrame(multilineMeasureFrameRef.current); + } + measurementCacheRef.current = null; + multilineMeasureRetryRef.current = 0; + }, + [] + ); + const queueMultilineMeasurement = useCallback( + (resetRetry = true) => { + if (multilineMeasureFrameRef.current !== null) { + cancelAnimationFrame(multilineMeasureFrameRef.current); + } + if (resetRetry) { + multilineMeasureRetryRef.current = 0; + } + multilineMeasureFrameRef.current = requestAnimationFrame(() => { + multilineMeasureFrameRef.current = null; + updateMultilineLayout(); + }); + }, + [updateMultilineLayout] + ); + + useEffect(() => { + if (typeof ResizeObserver === 'undefined') { + return undefined; + } + + const observer = new ResizeObserver(() => { + queueMultilineMeasurement(); + }); + const observedElements = [rowRef.current, beforeRef.current, afterRef.current].filter( + (element): element is HTMLDivElement => element !== null + ); + + observedElements.forEach((element) => observer.observe(element)); + + return () => observer.disconnect(); + }, [ + queueMultilineMeasurement, + updateMultilineLayout, + hasBefore, + hasAfter, + showResponsiveAfterInline, + ]); + + useLayoutEffect(() => { + updateMultilineLayout(latestValueRef.current); + }, [measurementVersion, updateMultilineLayout]); + + const handleChange = useCallback( + (value: Descendant[]) => { + latestValueRef.current = value; + measurementCacheRef.current = null; + if (multilineMeasureFrameRef.current !== null) { + cancelAnimationFrame(multilineMeasureFrameRef.current); + multilineMeasureFrameRef.current = null; + } + setMeasurementVersion((version) => version + 1); onChange?.(value); }, - [editor, onChange] + [onChange] ); const renderElement = useCallback( @@ -198,14 +407,15 @@ export const CustomEditor = forwardRef( ); return ( -
+
{top} - {before && ( + {hasBefore && ( ( }} /> - {(after || showResponsiveAfterInline) && ( + {(hasAfter || showResponsiveAfterInline) && ( Date: Sun, 22 Mar 2026 14:36:15 +0100 Subject: [PATCH 10/15] chore: add changeset --- ...mprove_multiline_composer_and_voice_recording.md | 13 +++++++++++++ knope.toml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 .changeset/improve_multiline_composer_and_voice_recording.md diff --git a/.changeset/improve_multiline_composer_and_voice_recording.md b/.changeset/improve_multiline_composer_and_voice_recording.md new file mode 100644 index 000000000..d02000bb7 --- /dev/null +++ b/.changeset/improve_multiline_composer_and_voice_recording.md @@ -0,0 +1,13 @@ +--- +default: minor +--- + +# Improve multiline composer and voice recording + +- Add a multiline composer layout for longer drafts. +- Keep the voice recorder between composer actions in multiline mode. +- Show the recorder inside the composer on mobile while recording. +- Prevent the composer from expanding when recording starts. +- Make the recorder footer and waveform fit better across screen sizes. +- Let interrupted mobile recording gestures still stop correctly. +- Stabilize wrap detection around edge cases like narrow widths and trailing spaces. diff --git a/knope.toml b/knope.toml index cb3b69a71..4b3d2fb87 100644 --- a/knope.toml +++ b/knope.toml @@ -64,6 +64,6 @@ repo = "Sable" [release_notes] # The marker is used by prepare-release.yml change_templates = [ - # "### $summary \n\n$details", + "### $summary \n\n$details", "* $summary ", ] From 54ce4aa721884844d5a9380445c834a568bdc773 Mon Sep 17 00:00:00 2001 From: hazre Date: Sun, 22 Mar 2026 15:22:34 +0100 Subject: [PATCH 11/15] chore: add cloudflared tunnels for easier testing on mobile --- package.json | 6 ++++-- pnpm-lock.yaml | 9 +++++++++ vite.config.ts | 5 +++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index eddf73646..3e1e2cb0d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:run": "vitest run", "test:coverage": "vitest run --coverage", "knip": "knip", + "tunnel": "cloudflared tunnel --url http://localhost:8080", "knope": "knope", "document-change": "knope document-change", "postinstall": "node scripts/install-knope.js" @@ -34,9 +35,9 @@ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.4.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", "@fontsource-variable/nunito": "5.2.7", - "@sentry/react": "^10.43.0", "@fontsource/space-mono": "5.2.9", "@phosphor-icons/react": "^2.1.10", + "@sentry/react": "^10.43.0", "@tanstack/react-query": "^5.90.21", "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-virtual": "^3.13.19", @@ -96,12 +97,12 @@ }, "devDependencies": { "@cloudflare/vite-plugin": "^1.26.0", - "@sableclient/sable-call-embedded": "v1.1.4", "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@eslint/compat": "2.0.2", "@eslint/js": "9.39.3", "@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-wasm": "^6.2.2", + "@sableclient/sable-call-embedded": "v1.1.4", "@sentry/vite-plugin": "^5.1.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -120,6 +121,7 @@ "@vitest/coverage-v8": "^4.1.0", "@vitest/ui": "^4.1.0", "buffer": "^6.0.3", + "cloudflared": "^0.7.1", "eslint": "9.39.3", "eslint-config-airbnb-extended": "3.0.1", "eslint-config-prettier": "10.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48a4b328f..06a98e6f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -281,6 +281,9 @@ importers: buffer: specifier: ^6.0.3 version: 6.0.3 + cloudflared: + specifier: ^0.7.1 + version: 0.7.1 eslint: specifier: 9.39.3 version: 9.39.3(jiti@2.6.1) @@ -3324,6 +3327,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cloudflared@0.7.1: + resolution: {integrity: sha512-jJn1Gu9Tf4qnIu8tfiHZ25Hs8rNcRYSVf8zAd97wvYdOCzftm1CTs1S/RPhijjGi8gUT1p9yzfDi9zYlU/0RwA==} + hasBin: true + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -9138,6 +9145,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cloudflared@0.7.1: {} + clsx@2.1.1: {} color-convert@2.0.1: diff --git a/vite.config.ts b/vite.config.ts index a66f458a8..8dd6723cb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -115,7 +115,7 @@ function serverMatrixSdkCryptoWasm() { }; } -export default defineConfig({ +export default defineConfig(({ command }) => ({ appType: 'spa', publicDir: false, base: buildConfig.base, @@ -142,6 +142,7 @@ export default defineConfig({ server: { port: 8080, host: true, + allowedHosts: command === 'serve' ? true : undefined, fs: { // Allow serving files from one level up to the project root allow: ['..'], @@ -243,4 +244,4 @@ export default defineConfig({ plugins: [inject({ Buffer: ['buffer', 'Buffer'] }) as PluginOption], }, }, -}); +})); From 340d643233859702445a4de01023b428ccb30548 Mon Sep 17 00:00:00 2001 From: hazre Date: Sun, 22 Mar 2026 14:52:54 +0100 Subject: [PATCH 12/15] fix: fully tear down voice recorder audio graph --- ...uality_mode_until_the_app_was_restarted.md | 5 + .../useVoiceRecorder.test.tsx | 186 ++++++++++++++++++ .../voice-recorder-kit/useVoiceRecorder.ts | 78 +++++++- 3 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 .changeset/fixed_an_android_issue_where_recording_a_voice_message_with_headphones_could_leave_audio_stuck_in_low_quality_mode_until_the_app_was_restarted.md create mode 100644 src/app/plugins/voice-recorder-kit/useVoiceRecorder.test.tsx diff --git a/.changeset/fixed_an_android_issue_where_recording_a_voice_message_with_headphones_could_leave_audio_stuck_in_low_quality_mode_until_the_app_was_restarted.md b/.changeset/fixed_an_android_issue_where_recording_a_voice_message_with_headphones_could_leave_audio_stuck_in_low_quality_mode_until_the_app_was_restarted.md new file mode 100644 index 000000000..ec64432d8 --- /dev/null +++ b/.changeset/fixed_an_android_issue_where_recording_a_voice_message_with_headphones_could_leave_audio_stuck_in_low_quality_mode_until_the_app_was_restarted.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +# Fixed an Android issue where recording a voice message with headphones could leave audio stuck in low-quality mode until the app was restarted. diff --git a/src/app/plugins/voice-recorder-kit/useVoiceRecorder.test.tsx b/src/app/plugins/voice-recorder-kit/useVoiceRecorder.test.tsx new file mode 100644 index 000000000..4bd620fad --- /dev/null +++ b/src/app/plugins/voice-recorder-kit/useVoiceRecorder.test.tsx @@ -0,0 +1,186 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useVoiceRecorder } from './useVoiceRecorder'; + +type MockTrack = MediaStreamTrack & { stop: ReturnType }; +type MockStream = MediaStream & { getTracks: () => MockTrack[] }; + +type MockNode = { + connect: ReturnType; + disconnect: ReturnType; +}; + +type MockAnalyserNode = MockNode & { + fftSize: number; + smoothingTimeConstant: number; + frequencyBinCount: number; + getByteFrequencyData: ReturnType; +}; + +type MockAudioContextInstance = { + state: AudioContextState; + destination: MockNode; + close: ReturnType; + resume: ReturnType; + suspend: ReturnType; + createMediaStreamSource: ReturnType; + createAnalyser: ReturnType; + createMediaStreamDestination: ReturnType; + createMediaElementSource: ReturnType; +}; + +const nativeAudioContext = globalThis.AudioContext; +const nativeMediaRecorder = globalThis.MediaRecorder; +const nativeRequestAnimationFrame = globalThis.requestAnimationFrame; +const nativeCancelAnimationFrame = globalThis.cancelAnimationFrame; +const nativeMediaDevices = navigator.mediaDevices; + +let inputTrack: MockTrack; +let inputStream: MockStream; +let destinationTrack: MockTrack; +let createdAudioContexts: MockAudioContextInstance[]; + +function createMockTrack(): MockTrack { + return { + stop: vi.fn(), + } as unknown as MockTrack; +} + +function createMockNode(): MockNode { + return { + connect: vi.fn(), + disconnect: vi.fn(), + }; +} + +function createMockAnalyserNode(): MockAnalyserNode { + return { + ...createMockNode(), + fftSize: 0, + smoothingTimeConstant: 0, + frequencyBinCount: 16, + getByteFrequencyData: vi.fn((data: Uint8Array) => data.fill(0)), + }; +} + +class MockMediaRecorder { + public static isTypeSupported = vi.fn(() => true); + + public state: RecordingState = 'inactive'; + + public ondataavailable: ((event: BlobEvent) => void) | null = null; + + public onstop: (() => void) | null = null; + + constructor(public readonly stream: MediaStream) {} + + start() { + this.state = 'recording'; + } + + stop() { + if (this.state === 'inactive') return; + this.state = 'inactive'; + this.onstop?.(); + } + + public requestData = vi.fn(); + + pause() { + this.state = 'paused'; + } +} + +function createMockAudioContext(): MockAudioContextInstance { + const context: MockAudioContextInstance = { + state: 'running', + destination: createMockNode(), + close: vi.fn(async () => { + context.state = 'closed'; + }), + resume: vi.fn(async () => { + context.state = 'running'; + }), + suspend: vi.fn(async () => { + context.state = 'suspended'; + }), + createMediaStreamSource: vi.fn(() => createMockNode()), + createAnalyser: vi.fn(() => createMockAnalyserNode()), + createMediaStreamDestination: vi.fn(() => ({ + ...createMockNode(), + stream: { + getTracks: () => [destinationTrack], + }, + })), + createMediaElementSource: vi.fn(() => createMockNode()), + }; + createdAudioContexts.push(context); + return context; +} + +function MockAudioContext(): MockAudioContextInstance { + return createMockAudioContext(); +} + +beforeEach(() => { + inputTrack = createMockTrack(); + destinationTrack = createMockTrack(); + inputStream = { + getTracks: () => [inputTrack], + } as unknown as MockStream; + createdAudioContexts = []; + + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { + getUserMedia: vi.fn(async () => inputStream), + }, + }); + + globalThis.requestAnimationFrame = vi.fn(() => 1); + globalThis.cancelAnimationFrame = vi.fn(); + + globalThis.AudioContext = MockAudioContext as unknown as typeof AudioContext; + + globalThis.MediaRecorder = MockMediaRecorder as unknown as typeof MediaRecorder; +}); + +afterEach(() => { + globalThis.AudioContext = nativeAudioContext; + globalThis.MediaRecorder = nativeMediaRecorder; + globalThis.requestAnimationFrame = nativeRequestAnimationFrame; + globalThis.cancelAnimationFrame = nativeCancelAnimationFrame; + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: nativeMediaDevices, + }); +}); + +describe('useVoiceRecorder', () => { + it('fully tears down the recording graph when recording stops', async () => { + const { result } = renderHook(() => useVoiceRecorder({ autoStart: false })); + + act(() => { + result.current.start(); + }); + + await waitFor(() => { + expect(result.current.isRecording).toBe(true); + }); + + const recordingContext = createdAudioContexts[0]; + expect(recordingContext).toBeDefined(); + + act(() => { + result.current.handleStop(); + }); + + await waitFor(() => { + expect(result.current.isRecording).toBe(false); + }); + + expect(inputTrack.stop).toHaveBeenCalledTimes(1); + expect(destinationTrack.stop).toHaveBeenCalledTimes(1); + expect(recordingContext?.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts b/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts index f73f7daf0..bdfb5f831 100644 --- a/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts +++ b/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts @@ -72,6 +72,10 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic const chunksRef = useRef([]); const streamRef = useRef(null); const audioContextRef = useRef(null); + const recordingSourceRef = useRef(null); + const recordingAnalyserRef = useRef(null); + const recordingDestinationRef = useRef(null); + const recordingStreamRef = useRef(null); const analyserRef = useRef(null); const dataArrayRef = useRef(null); const animationFrameIdRef = useRef(null); @@ -107,20 +111,49 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic } }, []); + const cleanupMediaRecorder = useCallback(() => { + const mediaRecorder = mediaRecorderRef.current; + mediaRecorderRef.current = null; + if (!mediaRecorder) return; + mediaRecorder.ondataavailable = null; + mediaRecorder.onstop = null; + }, []); + const cleanupAudioContext = useCallback(() => { + const audioContext = audioContextRef.current; + const recordingSource = recordingSourceRef.current; + const recordingAnalyser = recordingAnalyserRef.current; + const recordingDestination = recordingDestinationRef.current; + const recordingStream = recordingStreamRef.current; + if (animationFrameIdRef.current !== null) { cancelAnimationFrame(animationFrameIdRef.current); animationFrameIdRef.current = null; } frameCountRef.current = 0; - if (audioContextRef.current) { - if (audioContextRef.current.state !== 'closed') { - audioContextRef.current.suspend().catch(() => {}); - } - audioContextRef.current = null; - } + audioContextRef.current = null; + recordingSourceRef.current = null; + recordingAnalyserRef.current = null; + recordingDestinationRef.current = null; + recordingStreamRef.current = null; analyserRef.current = null; dataArrayRef.current = null; + + recordingStream?.getTracks().forEach((track) => track.stop()); + recordingSource?.disconnect(); + recordingAnalyser?.disconnect(); + recordingDestination?.disconnect(); + + if (!audioContext) return; + if (recordingStream) { + if (audioContext.state !== 'closed') { + audioContext.close().catch(() => {}); + } + return; + } + if (audioContext.state !== 'closed') { + audioContext.suspend().catch(() => {}); + } }, []); const stopTimer = useCallback(() => { @@ -219,7 +252,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic const setupAudioGraph = useCallback( (stream: MediaStream): MediaStream => { - const audioContext = getSharedAudioContext(); + const audioContext = new AudioContext(); audioContextRef.current = audioContext; const source = audioContext.createMediaStreamSource(stream); const analyser = audioContext.createAnalyser(); @@ -227,12 +260,16 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic analyser.smoothingTimeConstant = 0.6; const bufferLength = analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); + recordingSourceRef.current = source; + recordingAnalyserRef.current = analyser; analyserRef.current = analyser; dataArrayRef.current = dataArray; // Fix for iOS Safari: routing the stream through a MediaStreamDestination // prevents the AudioContext from "stealing" the track from the MediaRecorder const destination = audioContext.createMediaStreamDestination(); + recordingDestinationRef.current = destination; + recordingStreamRef.current = destination.stream; source.connect(analyser); analyser.connect(destination); @@ -306,6 +343,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic mediaRecorder.onstop = () => { cleanupAudioContext(); cleanupStream(); + cleanupMediaRecorder(); stopTimer(); setIsRecording(false); setIsPaused(false); @@ -380,11 +418,13 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic setError('Microphone access denied or an error occurred.'); cleanupAudioContext(); cleanupStream(); + cleanupMediaRecorder(); stopTimer(); setIsRecording(false); } }, [ cleanupAudioContext, + cleanupMediaRecorder, cleanupStream, emitStopPayload, getAudioLength, @@ -458,6 +498,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic } cleanupAudioContext(); cleanupStream(); + cleanupMediaRecorder(); stopTimer(); setIsRecording(false); setIsStopped(true); @@ -470,6 +511,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic audioFile, audioUrl, cleanupAudioContext, + cleanupMediaRecorder, cleanupStream, emitStopPayload, stopTimer, @@ -511,6 +553,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic } cleanupAudioContext(); cleanupStream(); + cleanupMediaRecorder(); stopTimer(); setIsRecording(false); setIsStopped(true); @@ -523,6 +566,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic audioFile, audioUrl, cleanupAudioContext, + cleanupMediaRecorder, cleanupStream, emitStopPayload, stopTimer, @@ -681,6 +725,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic mediaRecorder.onstop = () => { cleanupAudioContext(); cleanupStream(); + cleanupMediaRecorder(); stopTimer(); setIsRecording(false); setIsPaused(false); @@ -744,6 +789,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic setError('Microphone access denied or an error occurred.'); cleanupAudioContext(); cleanupStream(); + cleanupMediaRecorder(); stopTimer(); setIsRecording(false); isResumingRef.current = false; @@ -751,6 +797,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic }, [ audioCodec, cleanupAudioContext, + cleanupMediaRecorder, cleanupStream, emitStopPayload, getAudioLength, @@ -772,6 +819,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic cleanupAudioContext(); cleanupStream(); + cleanupMediaRecorder(); stopTimer(); setIsPlaying(false); setIsStopped(true); @@ -795,7 +843,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic if (onDelete) { onDelete(); } - }, [cleanupAudioContext, cleanupStream, onDelete, stopTimer]); + }, [cleanupAudioContext, cleanupMediaRecorder, cleanupStream, onDelete, stopTimer]); const handleRestart = useCallback(() => { isRestartingRef.current = true; @@ -812,6 +860,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic cleanupAudioContext(); cleanupStream(); + cleanupMediaRecorder(); stopTimer(); setIsRecording(false); setIsStopped(false); @@ -842,7 +891,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic setAudioUrl(null); setAudioFile(null); internalStartRecording(); - }, [cleanupAudioContext, cleanupStream, internalStartRecording, stopTimer]); + }, [cleanupAudioContext, cleanupMediaRecorder, cleanupStream, internalStartRecording, stopTimer]); useEffect(() => { if (autoStart) { @@ -852,6 +901,8 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic const mediaRecorder = mediaRecorderRef.current; if (mediaRecorder && mediaRecorder.state !== 'inactive') { mediaRecorder.stop(); + } else { + cleanupMediaRecorder(); } cleanupAudioContext(); cleanupStream(); @@ -869,7 +920,14 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic temporaryPreviewUrlRef.current = null; } }; - }, [autoStart, cleanupAudioContext, cleanupStream, internalStartRecording, stopTimer]); + }, [ + autoStart, + cleanupAudioContext, + cleanupMediaRecorder, + cleanupStream, + internalStartRecording, + stopTimer, + ]); const getState = (): RecorderState => { if (isPlaying) return 'playing'; From 2af3c72e30d5c620ea3ee83a07296d753329fb81 Mon Sep 17 00:00:00 2001 From: hazre Date: Sun, 22 Mar 2026 17:06:00 +0100 Subject: [PATCH 13/15] fix: voice message preview seeking not working on firefox --- ...rder_from_webm_no_seek_index_to_oggopus.md | 5 +++ .../upload-card/UploadCardRenderer.tsx | 32 ++++++++++++++++--- .../voice-recorder-kit/supportedCodec.ts | 13 +++++--- 3 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 .changeset/fixed_voice_message_scrubbingseeking_on_firefox_by_switching_the_recorder_from_webm_no_seek_index_to_oggopus.md diff --git a/.changeset/fixed_voice_message_scrubbingseeking_on_firefox_by_switching_the_recorder_from_webm_no_seek_index_to_oggopus.md b/.changeset/fixed_voice_message_scrubbingseeking_on_firefox_by_switching_the_recorder_from_webm_no_seek_index_to_oggopus.md new file mode 100644 index 000000000..7b7dd9a3b --- /dev/null +++ b/.changeset/fixed_voice_message_scrubbingseeking_on_firefox_by_switching_the_recorder_from_webm_no_seek_index_to_oggopus.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +# Fixed voice message scrubbing/seeking on Firefox by switching the recorder from WebM (no seek index) to Ogg/Opus. diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index 931215b63..d9fa444f7 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -128,6 +128,10 @@ function PreviewAudio({ fileItem }: PreviewAudioProps) { return undefined; } const audio = new Audio(audioUrl); + audio.preload = 'auto'; + // Explicitly load so Firefox parses metadata immediately, making + // currentTime writable before the user has ever pressed play. + audio.load(); audioRef.current = audio; audio.onended = () => { @@ -177,13 +181,34 @@ function PreviewAudio({ fileItem }: PreviewAudioProps) { } }; + const seekTo = (audio: HTMLAudioElement, targetTime: number) => { + // Alias to a local const to satisfy no-param-reassign. + const el = audio; + if (el.seekable.length > 0) { + el.currentTime = targetTime; + setCurrentTime(targetTime); + } else { + // Metadata not yet loaded (Firefox, first scrub before load() resolves). + // Do NOT call load() again here — that resets currentTime to 0 and + // restarts the fetch. load() was already called in the useEffect; + // just wait for the in-flight loadedmetadata event. + el.addEventListener( + 'loadedmetadata', + () => { + el.currentTime = targetTime; + setCurrentTime(targetTime); + }, + { once: true } + ); + } + }; + 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); + seekTo(audio, ratio * duration); }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -209,8 +234,7 @@ function PreviewAudio({ fileItem }: PreviewAudioProps) { return; } - audio.currentTime = newTime; - setCurrentTime(newTime); + seekTo(audio, newTime); }; return ( diff --git a/src/app/plugins/voice-recorder-kit/supportedCodec.ts b/src/app/plugins/voice-recorder-kit/supportedCodec.ts index 445147cda..a1b094612 100644 --- a/src/app/plugins/voice-recorder-kit/supportedCodec.ts +++ b/src/app/plugins/voice-recorder-kit/supportedCodec.ts @@ -13,14 +13,17 @@ const safariPreferredCodecs = [ ]; const defaultPreferredCodecs = [ - // Chromium / Firefox stable path. - 'audio/webm;codecs=opus', - 'audio/webm', - // Firefox + // Firefox: ogg produces seekable blobs; webm passes isTypeSupported() but + // records without a cue index so currentTime assignment silently fails. + // Must come before webm so Firefox picks ogg. 'audio/ogg;codecs=opus', - 'audio/ogg;codecs=vorbis', 'audio/ogg', + // Chromium: webm is seekable and preferred. Since Chromium doesn't support + // ogg recording, it will skip the above and land here. + 'audio/webm;codecs=opus', + 'audio/webm', // Fallbacks + 'audio/ogg;codecs=vorbis', 'audio/wav;codecs=1', 'audio/wav', 'audio/mpeg', From f22a7649719221a3bcc574a5c8d44f5aa59e7f91 Mon Sep 17 00:00:00 2001 From: hazre Date: Sun, 22 Mar 2026 18:07:39 +0100 Subject: [PATCH 14/15] chore: bring comments back --- src/app/components/editor/Editor.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 30726b811..a461d5ad9 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -445,7 +445,9 @@ export const CustomEditor = forwardRef( onKeyDown={handleKeydown} onKeyUp={onKeyUp} onPaste={onPaste} + // Defer to OS capitalization setting (respects iOS sentence-case toggle). autoCapitalize="sentences" + // keeps focus after pressing send. onBlur={() => { if (mobileOrTablet()) ReactEditor.focus(editor); }} From e5d52300b5abbaed190aa09e83272a51f7d11527 Mon Sep 17 00:00:00 2001 From: hazre Date: Sun, 22 Mar 2026 19:18:52 +0100 Subject: [PATCH 15/15] docs: clarify editor and recorder internals --- src/app/components/editor/Editor.tsx | 6 ++++++ src/app/features/room/AudioMessageRecorder.tsx | 2 ++ src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts | 3 +++ 3 files changed, 11 insertions(+) diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index a461d5ad9..d2b44e0f1 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -169,6 +169,9 @@ export const CustomEditor = forwardRef( const isRenderedSingleLine = !layoutIsMultiline; if (isRenderedSingleLine && scroll) { + // Scroll.clientWidth is the width the editable actually gets after padding and + // scrollbar math. Cache that delta while we are rendered single-line so later + // hidden measurements can compare against the same usable width. const renderedSingleLineWidth = scroll.clientWidth; if (renderedSingleLineWidth > 0) { singleLineWidthOffsetRef.current = Math.max( @@ -231,6 +234,9 @@ export const CustomEditor = forwardRef( textMeasurer.style.textTransform = computedStyle.textTransform; textMeasurer.style.textIndent = computedStyle.textIndent; textMeasurer.style.tabSize = computedStyle.tabSize; + // Measure against a hidden clone instead of the live editable so we can ask + // "would this wrap at single-line width?" without the current layout feeding + // back into the answer. const measureHeight = (content: string, width: string): number => { textMeasurer.style.width = width; textMeasurer.textContent = normalizeMeasurementText(content); diff --git a/src/app/features/room/AudioMessageRecorder.tsx b/src/app/features/room/AudioMessageRecorder.tsx index d8904532a..136ccb6f8 100644 --- a/src/app/features/room/AudioMessageRecorder.tsx +++ b/src/app/features/room/AudioMessageRecorder.tsx @@ -65,6 +65,8 @@ export const AudioMessageRecorder = forwardRef< onAudioLengthUpdateRef.current = onAudioLengthUpdate; const stableOnStop = useCallback((payload: VoiceRecorderStopPayload) => { + // useVoiceRecorder also stops during cancel/teardown paths, so only surface a completed + // recording after an explicit user stop. if (!userRequestedStopRef.current) return; if (isDismissedRef.current) return; onRecordingCompleteRef.current({ diff --git a/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts b/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts index bdfb5f831..e623c1b17 100644 --- a/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts +++ b/src/app/plugins/voice-recorder-kit/useVoiceRecorder.ts @@ -146,11 +146,14 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic if (!audioContext) return; if (recordingStream) { + // Recording contexts are disposable. Closing them fully releases the capture graph so + // mobile browsers do not keep the mic route or low-quality audio mode alive. if (audioContext.state !== 'closed') { audioContext.close().catch(() => {}); } return; } + // Playback reuses a shared context, so suspend it instead of tearing it down. if (audioContext.state !== 'closed') { audioContext.suspend().catch(() => {}); }