From 41a9af19e35e3214b43b1b14146041bf327fc112 Mon Sep 17 00:00:00 2001 From: heaven Date: Mon, 11 May 2026 17:47:37 +0300 Subject: [PATCH] feat(composer): floating overlay above timeline with Gemini-style two-row layout and Android-WebView stuck-hover gate --- src/app/features/room/RoomInput.tsx | 135 ++++++++++++++----------- src/app/features/room/RoomTimeline.tsx | 12 ++- src/app/features/room/RoomView.css.ts | 81 +++++++++++++++ src/app/features/room/RoomView.tsx | 60 +++++++++-- src/index.tsx | 24 +++++ 5 files changed, 247 insertions(+), 65 deletions(-) create mode 100644 src/app/features/room/RoomView.css.ts diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 2bbf49fe..fd45d467 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -19,7 +19,6 @@ import { Icon, IconButton, Icons, - Line, Overlay, OverlayBackdrop, OverlayCenter, @@ -33,7 +32,6 @@ import { import { useMatrixClient } from '../../hooks/useMatrixClient'; import { CustomEditor, - Toolbar, toMatrixCustomHTML, toPlainText, AUTOCOMPLETE_PREFIXES, @@ -105,7 +103,6 @@ import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../. import { CommandAutocomplete } from './CommandAutocomplete'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands'; import { mobileOrTablet } from '../../utils/user-agent'; -import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { ReplyLayout, ThreadIndicator } from '../../components/message'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -120,6 +117,52 @@ import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useComposingCheck } from '../../hooks/useComposingCheck'; +// Composer action-row icons — stroke-based outline style from the Dawn +// canon (docs/design/new-direct-messages-design/project/shared.jsx line +// 3-22). folds Icon wraps these in ``, +// so each IconSrc returns just the path/shape elements. Matches the +// Gemini-style flat composer aesthetic: thin 1.6-1.8 strokes, round caps, +// no fills. +const StreamComposerIcons = { + Plus: () => ( + + ), + Smile: () => ( + <> + + + + ), + Send: () => ( + <> + + + + ), +}; + interface RoomInputProps { editor: Editor; fileDropContainerRef: RefObject; @@ -182,7 +225,6 @@ export const RoomInput = forwardRef( const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents); - const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [autocompleteQuery, setAutocompleteQuery] = useState>(); @@ -232,15 +274,9 @@ 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 isComposing = useComposingCheck(); - useElementSizeObserver( - useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]), - useCallback((width) => setHideStickerBtn(width < 500), []) - ); - useEffect(() => { Transforms.insertFragment(editor, msgDraft); }, [editor, msgDraft]); @@ -613,26 +649,22 @@ export const RoomInput = forwardRef( ) } - before={ - pickFile('*')} - variant="SurfaceVariant" - size="300" - radii="300" + bottom={ + - - - } - after={ - <> pickFile('*')} variant="SurfaceVariant" + fill="None" size="300" radii="300" - onClick={() => setToolbar(!toolbar)} > - + + {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( ( } content={ ( /> } > - {!hideStickerBtn && ( - setEmojiBoardTab(EmojiBoardTab.Sticker)} - variant="SurfaceVariant" - size="300" - radii="300" - > - - - )} setEmojiBoardTab(EmojiBoardTab.Emoji)} variant="SurfaceVariant" + fill="None" size="300" radii="300" > - + )} - - + { + submit(); + // Defense-in-depth against the Android WebView's synthetic + // :hover/:focus-visible persistence (the CSS gate in + // RoomView.css.ts handles the same class of bug, but + // explicitly blurring the tap target also clears any + // lingering :focus state regardless of input-mode + // attribution). + evt.currentTarget.blur(); + }} + variant="SurfaceVariant" + fill="None" + size="300" + radii="300" + > + - - } - bottom={ - toolbar && ( -
- - -
- ) + } /> diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 51e2b224..2b0f502f 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -2057,7 +2057,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli {!canPaginateBack && rangeAtStart && getItems().length > 0 && (
{ @@ -55,6 +57,12 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { export function RoomView({ eventId }: { eventId?: string }) { const roomInputRef = useRef(null); const roomViewRef = useRef(null); + const composerWrapRef = useRef(null); + // Live composer height — feeds `--vojo-composer-height` on the chat + // surface so RoomTimeline can pad its scroll content's bottom by the + // exact overlay height. ResizeObserver keeps the value in sync as the + // composer grows (multi-line text, reply preview, file upload card). + const [composerHeight, setComposerHeight] = useState(0); const room = useRoom(); const { roomId } = room; @@ -79,6 +87,21 @@ export function RoomView({ eventId }: { eventId?: string }) { // `TimelineRenderingType.Thread` context. const threadDrawerOpen = useThreadDrawerOpen(); + useEffect(() => { + const el = composerWrapRef.current; + if (!el) { + setComposerHeight(0); + return undefined; + } + setComposerHeight(el.getBoundingClientRect().height); + const ro = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) setComposerHeight(entry.contentRect.height); + }); + ro.observe(el); + return () => ro.disconnect(); + }, [threadDrawerOpen, tombstoneEvent, canMessage]); + useKeyDown( window, useCallback( @@ -102,8 +125,20 @@ export function RoomView({ eventId }: { eventId?: string }) { ) ); + // Composer renders as a true overlay (absolute, bottom-stuck) above the + // timeline so messages can scroll behind it — WhatsApp / Telegram pattern. + // The CSS variable lets RoomTimeline.tsx pad its scroll-content bottom by + // the exact composer height, so the latest message sits flush above the + // composer at rest and older messages slide out from underneath as the + // user scrolls up. + const pageStyle = { + backgroundColor: color.SurfaceVariant.Container, + position: 'relative', + '--vojo-composer-height': `${composerHeight}px`, + } as CSSProperties; + return ( - + {!threadDrawerOpen && ( - -
+
+
{tombstoneEvent ? ( )}
- +
)} ); diff --git a/src/index.tsx b/src/index.tsx index 1d8eb46d..d3b34941 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -24,6 +24,30 @@ import { setupExternalLinkHandler } from './app/utils/capacitor'; document.body.classList.add(configClass, varsClass); setupExternalLinkHandler(); +// Input-mode detector for hover/focus styling. Capacitor's Android Chromium +// WebView synthesises `:hover` and `:focus-visible` on the focused element +// after a tap and never clears them until the next interaction elsewhere, +// so without a gate every tap leaves a sticky highlight on the tapped +// button. The widget-telegram bundle uses the same pattern — see +// `apps/widget-telegram/src/main.tsx`. matchMedia interaction queries are +// unreliable on Android WebView (`hover: hover` reports TRUE on a pure-touch +// device); the only honest signal is `pointerdown.pointerType`, which the +// WebView reports truthfully. Initial mode is 'mouse' so desktop/hybrid +// users get hover affordances on first paint — a touch user cannot trigger +// hover before tapping, and our capture-phase listener moves the attribute +// to 'touch' in the same frame as any synthesised :hover would paint. +const setVojoInputMode = (mode: 'touch' | 'mouse'): void => { + document.documentElement.dataset.input = mode; +}; +setVojoInputMode('mouse'); +window.addEventListener( + 'pointerdown', + (event) => { + setVojoInputMode(event.pointerType === 'mouse' ? 'mouse' : 'touch'); + }, + { passive: true, capture: true } +); + // Register Service Worker if ('serviceWorker' in navigator) { const swUrl =