feat(composer): floating overlay above timeline with Gemini-style two-row layout and Android-WebView stuck-hover gate

This commit is contained in:
heaven 2026-05-11 17:47:37 +03:00
parent 2337b05140
commit 41a9af19e3
5 changed files with 247 additions and 65 deletions

View file

@ -19,7 +19,6 @@ import {
Icon, Icon,
IconButton, IconButton,
Icons, Icons,
Line,
Overlay, Overlay,
OverlayBackdrop, OverlayBackdrop,
OverlayCenter, OverlayCenter,
@ -33,7 +32,6 @@ import {
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { import {
CustomEditor, CustomEditor,
Toolbar,
toMatrixCustomHTML, toMatrixCustomHTML,
toPlainText, toPlainText,
AUTOCOMPLETE_PREFIXES, AUTOCOMPLETE_PREFIXES,
@ -105,7 +103,6 @@ import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../.
import { CommandAutocomplete } from './CommandAutocomplete'; import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent'; import { mobileOrTablet } from '../../utils/user-agent';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { ReplyLayout, ThreadIndicator } from '../../components/message'; import { ReplyLayout, ThreadIndicator } from '../../components/message';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@ -120,6 +117,52 @@ import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck'; 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 `<svg viewBox="0 0 24 24" fill="none">`,
// 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: () => (
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
),
Smile: () => (
<>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
<path
d="M8 14s1.5 2 4 2 4-2 4-2M9 10h.01M15 10h.01"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</>
),
Send: () => (
<>
<path
d="M22 2L11 13"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M22 2L15 22L11 13L2 9L22 2Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
),
};
interface RoomInputProps { interface RoomInputProps {
editor: Editor; editor: Editor;
fileDropContainerRef: RefObject<HTMLElement>; fileDropContainerRef: RefObject<HTMLElement>;
@ -182,7 +225,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents); const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [autocompleteQuery, setAutocompleteQuery] = const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>(); useState<AutocompleteQuery<AutocompletePrefix>>();
@ -232,15 +274,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const pickFile = useFilePicker(handleFiles, true); const pickFile = useFilePicker(handleFiles, true);
const handlePaste = useFilePasteHandler(handleFiles); const handlePaste = useFilePasteHandler(handleFiles);
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles); const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
const isComposing = useComposingCheck(); const isComposing = useComposingCheck();
useElementSizeObserver(
useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]),
useCallback((width) => setHideStickerBtn(width < 500), [])
);
useEffect(() => { useEffect(() => {
Transforms.insertFragment(editor, msgDraft); Transforms.insertFragment(editor, msgDraft);
}, [editor, msgDraft]); }, [editor, msgDraft]);
@ -613,26 +649,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</div> </div>
) )
} }
before={ bottom={
<IconButton <Box
onClick={() => pickFile('*')} alignItems="Center"
variant="SurfaceVariant" gap="200"
size="300" style={{ padding: `${toRem(4)} ${toRem(8)} ${toRem(6)}` }}
radii="300"
> >
<Icon src={Icons.PlusCircle} />
</IconButton>
}
after={
<>
<IconButton <IconButton
onClick={() => pickFile('*')}
variant="SurfaceVariant" variant="SurfaceVariant"
fill="None"
size="300" size="300"
radii="300" radii="300"
onClick={() => setToolbar(!toolbar)}
> >
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} /> <Icon src={StreamComposerIcons.Plus} />
</IconButton> </IconButton>
<Box grow="Yes" />
<UseStateProvider initial={undefined}> <UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
<PopOut <PopOut
@ -647,7 +679,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} }
content={ content={
<EmojiBoard <EmojiBoard
tab={emojiBoardTab} tab={emojiBoardTab ?? EmojiBoardTab.Emoji}
onTabChange={setEmojiBoardTab} onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false} returnFocusOnDeactivate={false}
@ -666,52 +698,39 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
/> />
} }
> >
{!hideStickerBtn && (
<IconButton
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon
src={Icons.Sticker}
filled={emojiBoardTab === EmojiBoardTab.Sticker}
/>
</IconButton>
)}
<IconButton <IconButton
ref={emojiBtnRef} ref={emojiBtnRef}
aria-pressed={ aria-pressed={!!emojiBoardTab}
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)} onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant" variant="SurfaceVariant"
fill="None"
size="300" size="300"
radii="300" radii="300"
> >
<Icon <Icon src={StreamComposerIcons.Smile} />
src={Icons.Smile}
filled={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
}
/>
</IconButton> </IconButton>
</PopOut> </PopOut>
)} )}
</UseStateProvider> </UseStateProvider>
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300"> <IconButton
<Icon src={Icons.Send} /> onClick={(evt) => {
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"
>
<Icon src={StreamComposerIcons.Send} />
</IconButton> </IconButton>
</> </Box>
}
bottom={
toolbar && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
)
} }
/> />
</div> </div>

View file

@ -2057,7 +2057,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Box <Box
direction="Column" direction="Column"
justifyContent="End" justifyContent="End"
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }} style={{
minHeight: '100%',
// Bottom padding adds the composer's live height (set as the
// `--vojo-composer-height` var on the chat surface by RoomView)
// so the latest message sits flush above the overlaid composer
// instead of disappearing behind it. Falls back to 0 when the
// var is unset (e.g. thread-drawer view doesn't mount the
// overlay composer).
paddingTop: config.space.S600,
paddingBottom: `calc(${config.space.S600} + var(--vojo-composer-height, 0px))`,
}}
> >
{!canPaginateBack && rangeAtStart && getItems().length > 0 && ( {!canPaginateBack && rangeAtStart && getItems().length > 0 && (
<div <div

View file

@ -0,0 +1,81 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
import {
Editor,
EditorTextarea,
EditorTextareaScroll,
} from '../../components/editor/Editor.css';
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// Main chat composer — Dawn canon (stream-v2-dawn.jsx line 285-307): a
// floating dark card with all-corners rounded geometry and a two-row layout
// (input on top, action buttons below). Radius matches the silhouette
// horseshoe at the top of the chat (32px) for visual harmony — top and
// bottom of the chat surface mirror the same curvature. The default Editor
// styling (pill R400, lighter SurfaceVariant fill) is preserved for the
// thread-drawer composer, message-edit overlay and preview cases — none of
// them mount inside `ChatComposer`. The touch-hover gate at the bottom of
// this file also covers `RoomTombstone` / `RoomInputPlaceholder` since they
// share the wrap, which is intentional: their action buttons benefit from
// the same Android-WebView stuck-:hover suppression.
export const ChatComposer = style({});
globalStyle(`${ChatComposer} .${Editor}`, {
backgroundColor: color.Surface.Container,
borderRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
boxShadow: 'none',
// Asymmetric outer buffer for the 32px corner-radius card:
// * Horizontal 16px — keeps the placeholder + Plus column visually
// clear of the side curves.
// * Vertical 6px — minimum that still leaves comfortable clearance
// from the top/bottom curves while keeping the card compact.
// Bottom-left geometry check (radius=32, centre at (32, card_bottom-32)):
// * button-bottom = card_bottom - (6 outer + 6 row) = card_bottom - 12
// → curve x at y=12 from card bottom is ~7px from card left
// * button-left = 16 (outer) + 8 (row paddingLeft) = 24
// → ~17px clearance between visible button edge and the curve
// Top-left mirror (curve at y=19 from top is ~2.8px from left, text-left
// at 28px → ~25px clearance) is even more generous, which fits the
// single-line composer that grows downward when wrapping.
padding: `${toRem(6)} ${toRem(16)}`,
});
// Visual alignment goal: typed-text glyph-start and Plus-icon glyph-start
// sit on the same vertical column at 28px from the card edge (mirrored on
// the right for Send).
// text-glyph-start = outer (16) + textarea paddingLeft (12) = 28
// icon-glyph-left = outer (16) + row paddingLeft (8) + button pad (4) = 28
// One column line through "Написать сообщение…" and the leading Plus.
globalStyle(`${ChatComposer} ${EditorTextareaScroll}:first-child ${EditorTextarea}`, {
paddingLeft: toRem(12),
});
globalStyle(`${ChatComposer} ${EditorTextareaScroll}:last-child ${EditorTextarea}`, {
paddingRight: toRem(12),
});
// NB: do NOT override `EditorPlaceholderTextVisual.paddingLeft` here. Slate
// renders the placeholder span absolutely-positioned (`position: absolute;
// top: 0`) inside the leaf text node — its origin is the contenteditable
// content-box, already shifted by the textarea paddingLeft above. An
// additional paddingLeft here would visually shift the placeholder right of
// the real caret. The `Editor.css.ts` default (paddingLeft: 1px) keeps the
// placeholder glyph one pixel right of the caret — virtually co-located.
// Suppress folds' `IconButton :hover / :focus-visible` background paint on
// touch sessions. Capacitor's Android WebView synthesises both pseudoclasses
// on the tapped element after release and never clears them until the next
// interaction elsewhere — without this gate the action-row buttons stay
// highlighted in grey after every tap. Mirrors the widget-telegram fix
// (apps/widget-telegram/src/styles.css line 247-272) but scoped to the chat
// composer so we don't regress hover affordances anywhere else. The input
// mode comes from `src/index.tsx`'s `:root[data-input]` attribute, which is
// the only honest signal on Android WebView (matchMedia hover/pointer
// queries lie there). NB: only `:focus-visible` is gated — plain `:focus`
// is left alone so a hybrid device (touch screen + Bluetooth keyboard)
// keeps the focus ring during keyboard tab-traversal even after a touch
// session is detected.
globalStyle(
`:root[data-input="touch"] ${ChatComposer} button:hover, :root[data-input="touch"] ${ChatComposer} button:focus-visible`,
{
backgroundColor: 'transparent',
}
);

View file

@ -1,5 +1,5 @@
import React, { useCallback, useRef } from 'react'; import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import { Box, Text, color, config } from 'folds'; import { Box, Text, color, config, toRem } from 'folds';
import { EventType } from 'matrix-js-sdk'; import { EventType } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
@ -20,6 +20,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { useThreadDrawerOpen } from '../../hooks/useChannelsMode'; import { useThreadDrawerOpen } from '../../hooks/useChannelsMode';
import { VOJO_HORSESHOE_GAP_PX } from '../../styles/horseshoe';
import * as css from './RoomView.css';
const FN_KEYS_REGEX = /^F\d+$/; const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
@ -55,6 +57,12 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
export function RoomView({ eventId }: { eventId?: string }) { export function RoomView({ eventId }: { eventId?: string }) {
const roomInputRef = useRef<HTMLDivElement>(null); const roomInputRef = useRef<HTMLDivElement>(null);
const roomViewRef = useRef<HTMLDivElement>(null); const roomViewRef = useRef<HTMLDivElement>(null);
const composerWrapRef = useRef<HTMLDivElement>(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 room = useRoom();
const { roomId } = room; const { roomId } = room;
@ -79,6 +87,21 @@ export function RoomView({ eventId }: { eventId?: string }) {
// `TimelineRenderingType.Thread` context. // `TimelineRenderingType.Thread` context.
const threadDrawerOpen = useThreadDrawerOpen(); 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( useKeyDown(
window, window,
useCallback( 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 ( return (
<Page ref={roomViewRef} style={{ backgroundColor: color.SurfaceVariant.Container }}> <Page ref={roomViewRef} style={pageStyle}>
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<RoomTimeline <RoomTimeline
key={roomId} key={roomId}
@ -115,8 +150,21 @@ export function RoomView({ eventId }: { eventId?: string }) {
<RoomViewTyping room={room} /> <RoomViewTyping room={room} />
</Box> </Box>
{!threadDrawerOpen && ( {!threadDrawerOpen && (
<Box shrink="No" direction="Column"> <div
<div style={{ padding: `0 ${config.space.S400}` }}> ref={composerWrapRef}
// zIndex must beat the timeline's Stream-rail dot halos (zIndex: 2
// in layout.css.ts:332,618). They share the Page's stacking context
// since the Scroll wrapper isn't a positioned ancestor, so a z=1
// overlay loses to the dots and lets them paint through the
// composer card — visually it reads as a "transparent" form.
style={{ position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 10 }}
>
<div
className={css.ChatComposer}
style={{
padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`,
}}
>
{tombstoneEvent ? ( {tombstoneEvent ? (
<RoomTombstone <RoomTombstone
roomId={roomId} roomId={roomId}
@ -146,7 +194,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
</> </>
)} )}
</div> </div>
</Box> </div>
)} )}
</Page> </Page>
); );

View file

@ -24,6 +24,30 @@ import { setupExternalLinkHandler } from './app/utils/capacitor';
document.body.classList.add(configClass, varsClass); document.body.classList.add(configClass, varsClass);
setupExternalLinkHandler(); 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 // Register Service Worker
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
const swUrl = const swUrl =