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 && (