diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index e91a84bad..2526e5119 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -11,6 +11,7 @@ import { WidgetsPanel } from './widgets/WidgetsPanel'; import { widgetsPanelAtom } from '../../state/widgetsPanel'; import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { useMediaQuery } from '../../hooks/useMediaQuery'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels'; @@ -150,10 +151,16 @@ export function Room() { (isDesktop || (!galleryOpen && !widgetsOpen)); // Desktop: the persisted drawer preference. Mobile: a transient panel the // user opened from the header menu (never the desktop default). + // [Gitea #218] Three right-hand columns (timeline + thread/gallery/widgets + // panel + member drawer) don't fit under ~1500 px — both composers shrank to + // a few characters. Like Element, the member drawer yields to the content + // panel on narrower desktops and comes back when it closes. + const narrowDesktop = useMediaQuery('(max-width: 1500px)'); + const contentPanelOpen = showThreadPanel || showGallery || showWidgets || showThreadsList; const showMembers = !callView && (isDesktop - ? isDrawer + ? isDrawer && !(narrowDesktop && contentPanelOpen) : mobileMembersOpen && !activeThreadId && !galleryOpen && !widgetsOpen && !threadsListOpen); // Leaving a room on a phone closes its member panel. diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 667aaf12f..5c9a49d72 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -169,10 +169,21 @@ interface RoomInputProps { // message" handler). Threads pass a distinct name so the main timeline's handler // doesn't fire for the thread composer, and vice-versa. editableName?: string; + // [Gitea #218] Force the one-row "+ | input | emoji | send" layout regardless + // of viewport — the 360 px thread panel can't fit the full toolbar. + compactLayout?: boolean; } export const RoomInput = forwardRef( ( - { editor, fileDropContainerRef, roomId, room, threadRootId, editableName = 'RoomInput' }, + { + editor, + fileDropContainerRef, + roomId, + room, + threadRootId, + editableName = 'RoomInput', + compactLayout = false, + }, ref, ) => { const mx = useMatrixClient(); @@ -254,7 +265,7 @@ export const RoomInput = forwardRef( // 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 compact = isMobile || screenSize === ScreenSize.Mobile || compactLayout; 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 diff --git a/src/app/features/room/thread/ThreadPanel.tsx b/src/app/features/room/thread/ThreadPanel.tsx index 85177a93d..be37f7497 100644 --- a/src/app/features/room/thread/ThreadPanel.tsx +++ b/src/app/features/room/thread/ThreadPanel.tsx @@ -200,6 +200,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps) editor={editor} editableName="ThreadInput" fileDropContainerRef={fileDropContainerRef} + compactLayout /> diff --git a/src/app/hooks/useMediaQuery.ts b/src/app/hooks/useMediaQuery.ts new file mode 100644 index 000000000..46cc2ee89 --- /dev/null +++ b/src/app/hooks/useMediaQuery.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; + +const read = (query: string): boolean => + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia(query).matches; + +/** Reactive `window.matchMedia(query).matches`. SSR-safe (false). */ +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => read(query)); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return undefined; + } + const mql = window.matchMedia(query); + const onChange = (event: MediaQueryListEvent) => setMatches(event.matches); + setMatches(mql.matches); + mql.addEventListener('change', onChange); + return () => mql.removeEventListener('change', onChange); + }, [query]); + + return matches; +}