diff --git a/src/app/components/VoiceMessageRecorder.tsx b/src/app/components/VoiceMessageRecorder.tsx index 737abf9d1..db56a7812 100644 --- a/src/app/components/VoiceMessageRecorder.tsx +++ b/src/app/components/VoiceMessageRecorder.tsx @@ -248,7 +248,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { radii="300" title="Record voice message" > - + ); } diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 6ab330f71..1a0ad90f1 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -16,21 +16,16 @@ export const EditorOptions = style([ DefaultReset, { padding: config.space.S200, - '@media': { - // On phones the toolbar can hold many 44px buttons; let them wrap to a - // second line instead of overflowing horizontally. - '(max-width: 750px)': { flexWrap: 'wrap' }, - }, }, ]); -// The composer's before | editable | after row. On phones, allow the toolbar -// (`after`) to wrap below the input instead of squeezing the editable to zero -// and pushing the Send button off-screen. +// The composer's before | editable | after row. It must NOT wrap: folds' +// Scroll (the editable's wrapper) is `width: 100%`, so a wrapping row always +// breaks into three stacked lines (before / editable / after) — the "wonky" +// phone composer. Narrow viewports keep one row by collapsing the secondary +// buttons behind the "+" overflow instead (RoomInput `compact`). export const EditorInputRow = style({ - '@media': { - '(max-width: 750px)': { flexWrap: 'wrap' }, - }, + minWidth: 0, }); export const EditorTextareaScroll = style({}); @@ -41,6 +36,20 @@ export const EditorTextarea = style([ flexGrow: 1, height: '100%', padding: `${toRem(13)} ${toRem(1)}`, + '@media': { + // Phone-width composer rows carry 44px touch targets (MobileTouchTarget), + // so the row is 60px instead of 48px; pad the text to keep it level with + // the buttons instead of hugging the top of the row. Only when the row + // actually has before/after buttons (not the edit-message editor). + '(max-width: 750px)': { + selectors: { + [`${EditorTextareaScroll}:not(:only-child) &`]: { + paddingTop: toRem(19), + paddingBottom: toRem(19), + }, + }, + }, + }, selectors: { [`${EditorTextareaScroll}:first-child &`]: { paddingLeft: toRem(13), @@ -70,6 +79,13 @@ export const EditorPlaceholderTextVisual = style([ display: 'block', paddingTop: toRem(13), paddingLeft: toRem(1), + '@media': { + '(max-width: 750px)': { + selectors: { + [`${EditorTextareaScroll}:not(:only-child) &`]: { paddingTop: toRem(19) }, + }, + }, + }, }, ]); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 84c92abc6..6e4f36403 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -129,6 +129,8 @@ import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useComposingCheck } from '../../hooks/useComposingCheck'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { MobileTouchTarget } from '../../styles/mobile.css'; import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder'; import { PollCreator } from './PollCreator'; import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus'; @@ -235,11 +237,20 @@ export const RoomInput = forwardRef( const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons'); const isMobile = mobileOrTablet(); - // On phones the composer's secondary action buttons (attach, GIF, poll, + const screenSize = useScreenSizeContext(); + // Compact composer: the secondary action buttons (attach, GIF, poll, // location, voice, formatting, schedule) collapse behind a "+" toggle so the - // input stays one compact row instead of wrapping into a tall stack. Emoji + - // Send remain inline. Desktop keeps everything inline (isMobile === false). + // input stays ONE row — [ + | input | emoji | send ]. Keyed on the viewport, + // not only the user agent: a phone-width window on a desktop UA (iPad in + // desktop mode, a split-screen PWA, a docked window) otherwise renders all + // ten controls inline and clips the Send button behind the editor's + // overflow:hidden. Touch-sized (44px) targets stay UA-gated: a narrow + // desktop window still has a mouse. + const compact = isMobile || screenSize === ScreenSize.Mobile; const [mobileToolsOpen, setMobileToolsOpen] = useState(false); + // Both gates on purpose: the class covers phone-width viewports (matches + // VoiceMessageRecorder's idle button, which lives in the same row), the + // inline style covers touch UAs with a wider viewport (tablet landscape). const touchTarget = isMobile ? { minWidth: '44px', minHeight: '44px' } : undefined; const showFormat = composerToolbarButtons?.showFormat ?? true; const showEmoji = composerToolbarButtons?.showEmoji ?? true; @@ -1140,7 +1151,7 @@ export const RoomInput = forwardRef( ) } before={ - isMobile ? ( + compact ? ( setMobileToolsOpen((open) => !open)} aria-label="More actions" @@ -1150,6 +1161,7 @@ export const RoomInput = forwardRef( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} > @@ -1161,6 +1173,7 @@ export const RoomInput = forwardRef( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} > @@ -1174,6 +1187,7 @@ export const RoomInput = forwardRef( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'} aria-pressed={toolbar} onClick={() => setToolbar(!toolbar)} @@ -1200,6 +1214,7 @@ export const RoomInput = forwardRef( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} > ( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} > ( radii="300" disabled={gifUploading} style={touchTarget} + className={MobileTouchTarget} > - {gifUploading ? ( - - ) : ( - - GIF - - )} + {/* Sized like a default Icon (1.5rem) so the button + matches its 32px neighbours instead of a 28×19 stub. */} + + {gifUploading ? ( + + ) : ( + + GIF + + )} + )} @@ -1340,11 +1366,12 @@ export const RoomInput = forwardRef( radii="300" title="Share location" style={touchTarget} + className={MobileTouchTarget} > {locating ? ( - + ) : ( - + )} ) : null; @@ -1359,8 +1386,9 @@ export const RoomInput = forwardRef( radii="300" title="Create poll" style={touchTarget} + className={MobileTouchTarget} > - + ) : null; @@ -1383,10 +1411,11 @@ export const RoomInput = forwardRef( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} aria-label="Schedule message" title="Schedule message" > - + ) : null; @@ -1426,16 +1455,18 @@ export const RoomInput = forwardRef( } }); - // Mobile: keep only emoji/sticker inline beside Send; the rest move + // Compact: keep only emoji/sticker inline beside Send; the rest move // into the "+" overflow row (rendered via `bottom`), led by the attach - // button that `before` gives up on mobile. Desktop renders all inline. + // button that `before` gives up on, and trailed by the draft label so + // the inline row stays [ emoji | count | send ]. Wide viewports render + // everything inline. const emojiInline = orderedButtons.filter( (node) => React.isValidElement(node) && node.key === 'showEmojiSticker', ); const overflowButtons = orderedButtons.filter( (node) => !(React.isValidElement(node) && node.key === 'showEmojiSticker'), ); - if (isMobile) { + if (compact) { composerOverflow = ( <> ( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} > {overflowButtons} + ); } return ( <> - {isMobile ? emojiInline : orderedButtons} + {compact ? emojiInline : orderedButtons} {gifError && ( ( {locationError} )} - + {!compact && } {charCount > 0 && ( ( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} aria-label="Send message" > @@ -1514,7 +1548,7 @@ export const RoomInput = forwardRef( })()} bottom={ <> - {isMobile && mobileToolsOpen && composerOverflow && ( + {compact && mobileToolsOpen && composerOverflow && (