Problem
On a phone, a voice note is recorded by holding the mic button. The gesture handlers read a stale copy of the recorder state, so most of the gesture does nothing.
ComposerPrimaryAction binds window pointermove, pointerup, and pointercancel listeners once, at press time (apps/webapp/src/components/chatroom/components/MessageComposer/components/Actions/ActionButtons/ComposerPrimaryAction.tsx:23-42, :44-53). The listeners keep the voice object from that render, when phase was 'idle'. Nothing binds the listeners again when voice changes. listeningRef only blocks a second bind while a hold is active.
useVoiceRecorder guards its handlers on the phase of the render that created them (apps/webapp/src/components/chatroom/components/MessageComposer/hooks/useVoiceRecorder.ts):
moveHold returns early unless phase === 'recording' (:253).
endHold does the same (:275).
stopRecording does the same (:123).
startHold arms the 5-minute cap with the stopRecording of its own render (:226).
Measured on 14ab7f9c1 with the real useVoiceRecorder and React 19.2.8. The run used happy-dom. It faked getUserMedia, MediaRecorder, AudioContext, requestAnimationFrame, and the object-URL calls. It caught the 5-minute cap callback by patching window.setTimeout, then fired it by hand. The harness calls the handlers that the window listeners hold, from the press-time render.
| Step |
Result |
Press: startHold |
phase becomes recording |
Slide up 200 px: moveHold (lock needs 80) |
isLocked stays false |
Slide left 200 px: moveHold (cancel needs 80) |
isCancelArmed stays false |
Release: endHold |
phase stays recording |
| The 5-minute cap fires |
phase stays recording |
Control: endHold from the latest render |
phase becomes preview |
Desktop: startLockedFromMenu, then the 5-minute cap fires |
phase stays recording |
What a phone user sees:
- The recording keeps running after release. No preview appears.
- The input row stops responding. While an unlocked recording runs, the row gets
pointer-events-none (apps/webapp/src/components/chatroom/components/MessageComposer/components/layouts/ComposerBar.tsx:63, :103). Lock never arms, so the row stays blocked until the recording ends.
- The bar shows only Cancel, because Stop appears only when locked (
apps/webapp/src/components/chatroom/components/MessageComposer/components/VoiceRecordingBar.tsx:98-110).
- The hints "slide up to lock" and "slide left to cancel" are hidden below the
sm breakpoint (VoiceRecordingBar.tsx:91). On a phone in portrait, the user never sees them.
On desktop, the mic button and Record voice in the insert menu both call startLockedFromMenu. The Stop button works there, because the bar reads the latest stopRecording. Only the 5-minute cap is broken on desktop.
On a quick tap, or at the microphone prompt, the finger can lift before getUserMedia resolves (useVoiceRecorder.ts:172). A second harness run released during that wait, and even endHold from the latest render returned early. When getUserMedia resolved, the recording started, unlocked, with no finger down.
Steps to reproduce
- Sign in on a phone. Open a channel with an empty composer.
- Press and hold the mic. Allow the microphone.
- Release.
- See the recording continue. No preview appears, and the input row does not respond.
- Tap Cancel to stop it. The note is lost.
Acceptance criteria
Agent Brief
Category: bug
Summary: Hold-to-record handlers and the 5-minute cap must act on the live recorder state, not the state of the press-time render.
Current behavior:
Window listeners bound at press time call recorder handlers that captured phase === 'idle'. Release, slide-to-lock, slide-to-cancel, and the 5-minute cap all return early. A phone recording never reaches the preview. Only a discard ends it, such as Cancel or leaving the chat.
Desired behavior:
Every gesture handler and the 5-minute cap act on the current phase. Release stops the recording unless it is locked or cancel-armed. A release before the recording starts leaves no recording and no preview. The cap stops any recording at 5 minutes. The lock and cancel hints are visible on phones.
Key interfaces:
useVoiceRecorder() — startHold, moveHold, endHold, stopRecording, startLockedFromMenu; isLockedRef and isCancelArmedRef already show the ref pattern.
UseVoiceRecorderReturn — the object that ComposerPrimaryAction keeps in its window listeners.
VoiceRecordingBar — shows Stop only when locked, and hides the gesture hints on small screens.
Out of scope
Notes
A working pattern is already in the tree. ComposerBar reads stopAndCleanup through a ref, so a stale closure cannot run (apps/webapp/src/components/chatroom/components/MessageComposer/components/layouts/ComposerBar.tsx:47-48).
The handlers came in with 3e08d6a71 (2026-07-01).
Final sign-off needs a real phone, in iOS Safari and Android Chrome.
Problem
On a phone, a voice note is recorded by holding the mic button. The gesture handlers read a stale copy of the recorder state, so most of the gesture does nothing.
ComposerPrimaryActionbinds windowpointermove,pointerup, andpointercancellisteners once, at press time (apps/webapp/src/components/chatroom/components/MessageComposer/components/Actions/ActionButtons/ComposerPrimaryAction.tsx:23-42,:44-53). The listeners keep thevoiceobject from that render, whenphasewas'idle'. Nothing binds the listeners again whenvoicechanges.listeningRefonly blocks a second bind while a hold is active.useVoiceRecorderguards its handlers on thephaseof the render that created them (apps/webapp/src/components/chatroom/components/MessageComposer/hooks/useVoiceRecorder.ts):moveHoldreturns early unlessphase === 'recording'(:253).endHolddoes the same (:275).stopRecordingdoes the same (:123).startHoldarms the 5-minute cap with thestopRecordingof its own render (:226).Measured on
14ab7f9c1with the realuseVoiceRecorderand React 19.2.8. The run used happy-dom. It fakedgetUserMedia,MediaRecorder,AudioContext,requestAnimationFrame, and the object-URL calls. It caught the 5-minute cap callback by patchingwindow.setTimeout, then fired it by hand. The harness calls the handlers that the window listeners hold, from the press-time render.startHoldphasebecomesrecordingmoveHold(lock needs 80)isLockedstaysfalsemoveHold(cancel needs 80)isCancelArmedstaysfalseendHoldphasestaysrecordingphasestaysrecordingendHoldfrom the latest renderphasebecomespreviewstartLockedFromMenu, then the 5-minute cap firesphasestaysrecordingWhat a phone user sees:
pointer-events-none(apps/webapp/src/components/chatroom/components/MessageComposer/components/layouts/ComposerBar.tsx:63,:103). Lock never arms, so the row stays blocked until the recording ends.apps/webapp/src/components/chatroom/components/MessageComposer/components/VoiceRecordingBar.tsx:98-110).smbreakpoint (VoiceRecordingBar.tsx:91). On a phone in portrait, the user never sees them.On desktop, the mic button and Record voice in the insert menu both call
startLockedFromMenu. The Stop button works there, because the bar reads the lateststopRecording. Only the 5-minute cap is broken on desktop.On a quick tap, or at the microphone prompt, the finger can lift before
getUserMediaresolves (useVoiceRecorder.ts:172). A second harness run released during that wait, and evenendHoldfrom the latest render returned early. WhengetUserMediaresolved, the recording started, unlocked, with no finger down.Steps to reproduce
Acceptance criteria
Agent Brief
Category: bug
Summary: Hold-to-record handlers and the 5-minute cap must act on the live recorder state, not the state of the press-time render.
Current behavior:
Window listeners bound at press time call recorder handlers that captured
phase === 'idle'. Release, slide-to-lock, slide-to-cancel, and the 5-minute cap all return early. A phone recording never reaches the preview. Only a discard ends it, such as Cancel or leaving the chat.Desired behavior:
Every gesture handler and the 5-minute cap act on the current phase. Release stops the recording unless it is locked or cancel-armed. A release before the recording starts leaves no recording and no preview. The cap stops any recording at 5 minutes. The lock and cancel hints are visible on phones.
Key interfaces:
useVoiceRecorder()—startHold,moveHold,endHold,stopRecording,startLockedFromMenu;isLockedRefandisCancelArmedRefalready show the ref pattern.UseVoiceRecorderReturn— the object thatComposerPrimaryActionkeeps in its window listeners.VoiceRecordingBar— shows Stop only when locked, and hides the gesture hints on small screens.Out of scope
Notes
A working pattern is already in the tree.
ComposerBarreadsstopAndCleanupthrough a ref, so a stale closure cannot run (apps/webapp/src/components/chatroom/components/MessageComposer/components/layouts/ComposerBar.tsx:47-48).The handlers came in with
3e08d6a71(2026-07-01).Final sign-off needs a real phone, in iOS Safari and Android Chrome.