diff --git a/.changeset/polish-mobile-chat-input.md b/.changeset/polish-mobile-chat-input.md new file mode 100644 index 0000000000..3a96b999b8 --- /dev/null +++ b/.changeset/polish-mobile-chat-input.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Polish the mobile chat input with adaptive send and recording controls, easier photo attachment, and more reliable Android uploads. diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 29e8136e15..8c1d553c4a 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -102,7 +102,6 @@ import { matchesShortcut } from '../../keyboard/shortcuts'; import { getMentionContent, isThreadRelationEvent, reactionOrEditEvent } from '$utils/room'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '$hooks/useCommands'; import { mobileOrTablet } from '$utils/user-agent'; -import { useElementSizeObserver } from '$hooks/useElementSizeObserver'; import { Reply, ThreadIndicator } from '$components/message'; import { roomToParentsAtom } from '$state/room/roomToParents'; import { nicknamesAtom } from '$state/nicknames'; @@ -146,6 +145,7 @@ import { composerIcon, dropzoneIcon, File as FileIcon, + Image as ImageIcon, ListBullets, MapPinPlusIcon, menuIcon, @@ -154,7 +154,6 @@ import { getPhosphorIconSize, PlusCircle, Smiley, - Sticker, Stop, X, } from '$components/icons/phosphor'; @@ -194,7 +193,6 @@ import * as prefix from '$unstable/prefixes'; import { PollDialog } from './poll-modals'; import { LocationDialog } from './location-modal'; import { useClientConfig } from '$hooks/useClientConfig'; -import { GifIcon } from '@phosphor-icons/react'; import { PersonaPicker } from './persona-picker/PersonaPicker.tsx'; // Returns the event ID of the most recent non-reaction/non-edit event in a thread, @@ -298,7 +296,6 @@ export const RoomInput = forwardRef( const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [editorOldAddFile] = useSetting(settingsAtom, 'editorOldAddFile'); - const [showGifPicker] = useSetting(settingsAtom, 'enableGifPicker'); const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); @@ -402,6 +399,19 @@ export const RoomInput = forwardRef( async (files: File[], audioMeta?: { waveform: number[]; audioDuration: number }) => { setUploadBoard(true); const safeFiles = files.map(safeFile); + // Eager-read to avoid Android content URI expiry after SAF picker + const blobbedFiles = mobileOrTablet() + ? await Promise.all( + safeFiles.map(async (f) => { + try { + const buf = await f.arrayBuffer(); + return new File([buf], f.name, { type: f.type, lastModified: f.lastModified }); + } catch { + return f; + } + }) + ) + : safeFiles; const makeMetadata = () => ({ markedAsSpoiler: false, waveform: audioMeta?.waveform, @@ -409,7 +419,7 @@ export const RoomInput = forwardRef( }); if (room.hasEncryptionStateEvent()) { - const placeholders: TUploadItem[] = safeFiles.map((f) => ({ + const placeholders: TUploadItem[] = blobbedFiles.map((f) => ({ file: f, originalFile: f, encInfo: undefined, @@ -436,7 +446,7 @@ export const RoomInput = forwardRef( setSelectedFiles({ type: 'PUT', - item: safeFiles.map((f) => ({ + item: blobbedFiles.map((f) => ({ file: f, originalFile: f, encInfo: undefined, @@ -449,7 +459,11 @@ export const RoomInput = forwardRef( const pickFile = useFilePicker(handleFiles, true); const handlePaste = useFilePasteHandler(handleFiles); const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles); - const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500); + const [hasText, setHasText] = useState(false); + const handleEditorChange = useCallback(() => { + setHasText(!isEmptyEditor(editor)); + }, [editor]); + const hasContent = hasText || selectedFiles.length > 0; const isComposing = useComposingCheck(); @@ -476,11 +490,6 @@ export const RoomInput = forwardRef( 'sendIndividualAttachmentAsCaption' ); - useElementSizeObserver( - useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]), - useCallback((width) => setHideStickerBtn(width < 500), []) - ); - const replyEvent = replyDraft ? room.findEventById(replyDraft.eventId) : undefined; // Seed the reply draft with the thread relation whenever we're in thread @@ -1528,6 +1537,7 @@ export const RoomInput = forwardRef( suppressBlurRefocusRef={suppressBlurRefocusRef} onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} + onChange={handleEditorChange} onPaste={handlePaste} responsiveAfter={audioRecorder} forceMultilineLayout={showAudioRecorder} @@ -1739,6 +1749,17 @@ export const RoomInput = forwardRef( > Add Location + { + pickFile('image/*'); + setAddMenuAnchor(undefined); + }} + before={menuIcon(ImageIcon)} + > + Photos + ( } after={ <> - {/* ── Mic button — always present; icon swaps to Stop while recording ── */} - { - if (mobileOrTablet() && !showAudioRecorder) return; - if (showAudioRecorder) { - audioRecorderRef.current?.stop(); - } else { - setShowAudioRecorder(true); - } - }} - onPointerDown={() => { - if (!mobileOrTablet()) return; - if (showAudioRecorder) return; - micHoldStartRef.current = Date.now(); - setShowAudioRecorder(true); - - function onUp() { - cleanup(); - const held = Date.now() - micHoldStartRef.current; - if (held >= HOLD_THRESHOLD_MS) { - setTimeout(() => { - audioRecorderRef.current?.stop(); - }, 50); - } else { - setTimeout(() => { - audioRecorderRef.current?.cancel(); - }, 50); - } - } - function cleanup() { - window.removeEventListener('pointerup', onUp); - window.removeEventListener('pointercancel', cleanup); - } - window.addEventListener('pointerup', onUp); - window.addEventListener('pointercancel', cleanup); - }} - > - {showAudioRecorder ? ( - - ) : ( - composerIcon(Microphone) - )} - - - - {() => { const emojiBoard = ( @@ -1865,67 +1828,22 @@ export const RoomInput = forwardRef( /> ); const triggers = ( - <> - {showGifPicker && ( - setEmojiBoardTab(EmojiBoardTab.Gif)} - onPointerDown={suppressEditorRefocus} - variant="SurfaceVariant" - size="300" - radii="300" - style={{ backgroundColor: 'transparent' }} - > - {composerIcon(GifIcon, { - weight: emojiBoardTab === EmojiBoardTab.Gif ? 'fill' : 'regular', - })} - - )} - {!hideStickerBtn && ( - setEmojiBoardTab(EmojiBoardTab.Sticker)} - onPointerDown={suppressEditorRefocus} - variant="SurfaceVariant" - size="300" - radii="300" - style={{ backgroundColor: 'transparent' }} - title="open sticker picker" - aria-label="Open sticker picker" - > - {composerIcon(Sticker, { - weight: emojiBoardTab === EmojiBoardTab.Sticker ? 'fill' : 'regular', - })} - - )} - setEmojiBoardTab(EmojiBoardTab.Emoji)} - onPointerDown={suppressEditorRefocus} - variant="SurfaceVariant" - size="300" - radii="300" - style={{ backgroundColor: 'transparent' }} - title="open emoji picker" - aria-label="Open emoji picker" - > - {composerIcon(Smiley, { - weight: hideStickerBtn - ? emojiBoardTab - ? 'fill' - : 'regular' - : emojiBoardTab === EmojiBoardTab.Emoji - ? 'fill' - : 'regular', - })} - - + setEmojiBoardTab(EmojiBoardTab.Emoji)} + onPointerDown={suppressEditorRefocus} + variant="SurfaceVariant" + size="300" + radii="300" + style={{ backgroundColor: 'transparent' }} + title="open emoji board" + aria-label="Open emoji board" + > + {composerIcon(Smiley, { + weight: emojiBoardTab !== undefined ? 'fill' : 'regular', + })} + ); if (mobileOrTablet()) { return ( @@ -1966,6 +1884,120 @@ export const RoomInput = forwardRef( ); }} + + + + { + if (showAudioRecorder) { + audioRecorderRef.current?.stop(); + return; + } + if (hasContent) { + if (isLongPress.current) { + isLongPress.current = false; + return; + } + submit(); + return; + } + if (mobileOrTablet()) return; + setShowAudioRecorder(true); + }} + onMouseDown={(e: MouseEvent) => { + if (hasContent) e.preventDefault(); + }} + onPointerDown={() => { + if (showAudioRecorder) return; + if (hasContent) { + isLongPress.current = false; + if (mobileOrTablet() && delayedEventsSupported) { + longPressTimer.current = setTimeout(() => { + isLongPress.current = true; + setShowSchedulePicker(true); + }, 1000); + } + return; + } + if (!mobileOrTablet()) return; + micHoldStartRef.current = Date.now(); + setShowAudioRecorder(true); + + function onUp() { + cleanup(); + const held = Date.now() - micHoldStartRef.current; + if (held >= HOLD_THRESHOLD_MS) { + setTimeout(() => { + audioRecorderRef.current?.stop(); + }, 50); + } else { + setTimeout(() => { + audioRecorderRef.current?.cancel(); + }, 50); + } + } + function cleanup() { + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', cleanup); + } + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', cleanup); + }} + onPointerUp={() => { + if (longPressTimer.current !== null) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }} + onPointerCancel={() => { + if (longPressTimer.current !== null) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }} + disabled={hasContent && sendBusy && !showAudioRecorder} + className={hasContent && delayedEventsSupported ? css.SplitSendButton : undefined} + > + {showAudioRecorder ? ( + + ) : hasContent ? ( + sendBusy ? ( + + ) : scheduledTime ? ( + composerIcon(Clock) + ) : ( + composerIcon(PaperPlaneTilt) + ) + ) : ( + composerIcon(Microphone) + )} + ( } /> - + {delayedEventsSupported && !mobileOrTablet() && ( { - if (isLongPress.current) { - isLongPress.current = false; - return; - } - submit(); - }} - onMouseDown={(e: MouseEvent) => e.preventDefault()} - onPointerDown={() => { - isLongPress.current = false; - if (mobileOrTablet() && delayedEventsSupported) { - longPressTimer.current = setTimeout(() => { - isLongPress.current = true; - setShowSchedulePicker(true); - }, 1000); - } - }} - onPointerUp={() => { - if (longPressTimer.current !== null) { - clearTimeout(longPressTimer.current); - longPressTimer.current = null; - } - }} - onPointerCancel={() => { - if (longPressTimer.current !== null) { - clearTimeout(longPressTimer.current); - longPressTimer.current = null; - } + onClick={(evt: MouseEvent) => { + setScheduleMenuAnchor(evt.currentTarget.getBoundingClientRect()); }} + title="Schedule Message" + aria-label="Schedule message send" variant={scheduledTime ? 'Primary' : 'SurfaceVariant'} + style={{ backgroundColor: 'transparent' }} size="300" radii="0" - className={delayedEventsSupported ? css.SplitSendButton : undefined} + className={css.SplitChevronButton} > - {sendBusy ? ( - - ) : scheduledTime ? ( - composerIcon(Clock) - ) : ( - composerIcon(PaperPlaneTilt) - )} + {chipIcon(CaretDown)} - {delayedEventsSupported && !mobileOrTablet() && ( - ) => { - setScheduleMenuAnchor(evt.currentTarget.getBoundingClientRect()); - }} - title="Schedule Message" - aria-label="Schedule message send" - variant={scheduledTime ? 'Primary' : 'SurfaceVariant'} - style={{ backgroundColor: 'transparent' }} - size="300" - radii="0" - className={css.SplitChevronButton} - > - {chipIcon(CaretDown)} - - )} - + )} } bottom={}