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/.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/.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 ", ] 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/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 290f7596b..9c40ea3bf 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -13,6 +13,29 @@ 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 EditorRowMultilineWithResponsiveAfter = style({ + gridTemplateColumns: 'auto 1fr auto', + gridTemplateAreas: ` + "before textarea textarea" + "before responsive-after after" + `, +}); + export const EditorOptions = style([ DefaultReset, { @@ -20,14 +43,30 @@ 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 EditorTextarea = style([ DefaultReset, { flexGrow: 1, - height: '100%', - padding: `${toRem(13)} ${toRem(1)}`, + height: 'auto', + padding: `${toRem(13)} 0 0`, selectors: { [`${EditorTextareaScroll}:first-child &`]: { paddingLeft: toRem(13), @@ -42,6 +81,15 @@ export const EditorTextarea = style([ }, ]); +export const EditorResponsiveAfterMultiline = style([ + EditorOptions, + { + gridArea: 'responsive-after', + minWidth: 0, + alignSelf: 'stretch', + }, +]); + export const EditorPlaceholderContainer = style([ DefaultReset, { @@ -57,6 +105,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.test.tsx b/src/app/components/editor/Editor.test.tsx new file mode 100644 index 000000000..ab38d73a5 --- /dev/null +++ b/src/app/components/editor/Editor.test.tsx @@ -0,0 +1,543 @@ +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'; +import { useEditor, CustomEditor } from './Editor'; +import { BlockType } from './types'; +import * as css from './Editor.css'; + +let shouldWrapToggleHarness = false; +let measurementCacheScrollHeightReads = 0; + +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 + /> + ); +} + +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) { + 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', () => { + 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; + 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(); + 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 + ); + }); + + 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 27df4cf6c..d2b44e0f1 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -5,10 +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, @@ -53,12 +56,28 @@ 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; bottom?: ReactNode; before?: ReactNode; after?: ReactNode; + responsiveAfter?: ReactNode; + forceMultilineLayout?: boolean; maxHeight?: string; editor: Editor; placeholder?: string; @@ -77,6 +96,8 @@ export const CustomEditor = forwardRef( bottom, before, after, + responsiveAfter, + forceMultilineLayout = false, maxHeight = '50vh', editor, placeholder, @@ -97,6 +118,265 @@ 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 = hasResponsiveAfter && layoutIsMultiline; + const showResponsiveAfterInline = hasResponsiveAfter && !showResponsiveAfterInFooter; + + 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; + const row = rowRef.current; + const textMeasurer = textMeasurerRef.current; + if (editable && row && textMeasurer) { + const scroll = editable.parentElement as HTMLDivElement | null; + const computedStyle = getComputedStyle(editable); + const beforeWidth = beforeRef.current?.offsetWidth ?? 0; + const afterWidth = afterRef.current?.offsetWidth ?? 0; + const rowSingleLineWidth = row.offsetWidth - beforeWidth - afterWidth; + 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( + 0, + rowSingleLineWidth - renderedSingleLineWidth + ); + } + } + + 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; + + 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; + // 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); + 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; + setIsMultiline(nextMultiline); + } else { + const nextMultiline = hasMultipleBlocks || hasExplicitNewlines; + 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); + }, + [onChange] + ); const renderElement = useCallback( (props: RenderElementProps) => , @@ -133,24 +413,35 @@ export const CustomEditor = forwardRef( ); return ( -
- +
+ {top} - - {before && ( - + + {hasBefore && ( + {before} )} ( }} /> - {after && ( - + {(hasAfter || showResponsiveAfterInline) && ( + + {showResponsiveAfterInline && responsiveAfter} {after} )} + {showResponsiveAfterInFooter && ( + + {responsiveAfter} + + )} {bottom} 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/features/room/AudioMessageRecorder.css.ts b/src/app/features/room/AudioMessageRecorder.css.ts index 47b165841..d53440044 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: '100%', + maxWidth: toRem(280), minWidth: 0, overflow: 'hidden', touchAction: 'pan-y', @@ -56,6 +57,7 @@ export const WaveformContainer = style([ height: 22, overflow: 'hidden', minWidth: 0, + flexGrow: 1, }, ]); diff --git a/src/app/features/room/AudioMessageRecorder.tsx b/src/app/features/room/AudioMessageRecorder.tsx index 5ec592bc8..136ccb6f8 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; @@ -56,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({ @@ -102,14 +113,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 +145,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 +165,15 @@ export const AudioMessageRecorder = forwardRef< {error} )} - +
- + {bars.map((level, i) => (
( [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) { @@ -1136,10 +1169,12 @@ 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} + responsiveAfter={audioRecorder} + forceMultilineLayout={showAudioRecorder} top={ <> {scheduledTime && ( @@ -1241,45 +1276,19 @@ export const RoomInput = forwardRef( } before={ - !(showAudioRecorder && mobileOrTablet()) && ( - 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 && ( - 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 ── */} ( 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 { 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: { 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', 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..e623c1b17 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,52 @@ 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) { + // 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(() => {}); + } }, []); const stopTimer = useCallback(() => { @@ -219,7 +255,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 +263,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 +346,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic mediaRecorder.onstop = () => { cleanupAudioContext(); cleanupStream(); + cleanupMediaRecorder(); stopTimer(); setIsRecording(false); setIsPaused(false); @@ -380,11 +421,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 +501,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic } cleanupAudioContext(); cleanupStream(); + cleanupMediaRecorder(); stopTimer(); setIsRecording(false); setIsStopped(true); @@ -470,6 +514,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic audioFile, audioUrl, cleanupAudioContext, + cleanupMediaRecorder, cleanupStream, emitStopPayload, stopTimer, @@ -511,6 +556,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic } cleanupAudioContext(); cleanupStream(); + cleanupMediaRecorder(); stopTimer(); setIsRecording(false); setIsStopped(true); @@ -523,6 +569,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic audioFile, audioUrl, cleanupAudioContext, + cleanupMediaRecorder, cleanupStream, emitStopPayload, stopTimer, @@ -681,6 +728,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic mediaRecorder.onstop = () => { cleanupAudioContext(); cleanupStream(); + cleanupMediaRecorder(); stopTimer(); setIsRecording(false); setIsPaused(false); @@ -744,6 +792,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 +800,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic }, [ audioCodec, cleanupAudioContext, + cleanupMediaRecorder, cleanupStream, emitStopPayload, getAudioLength, @@ -772,6 +822,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic cleanupAudioContext(); cleanupStream(); + cleanupMediaRecorder(); stopTimer(); setIsPlaying(false); setIsStopped(true); @@ -795,7 +846,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 +863,7 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic cleanupAudioContext(); cleanupStream(); + cleanupMediaRecorder(); stopTimer(); setIsRecording(false); setIsStopped(false); @@ -842,7 +894,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 +904,8 @@ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}): UseVoic const mediaRecorder = mediaRecorderRef.current; if (mediaRecorder && mediaRecorder.state !== 'inactive') { mediaRecorder.stop(); + } else { + cleanupMediaRecorder(); } cleanupAudioContext(); cleanupStream(); @@ -869,7 +923,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'; 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], }, }, -}); +}));