feat(composer): floating overlay above timeline with Gemini-style two-row layout and Android-WebView stuck-hover gate
This commit is contained in:
parent
212a6084c5
commit
7aaba05e46
5 changed files with 247 additions and 65 deletions
|
|
@ -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 `<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 {
|
||||
editor: Editor;
|
||||
fileDropContainerRef: RefObject<HTMLElement>;
|
||||
|
|
@ -182,7 +225,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
|
||||
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
||||
|
||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [autocompleteQuery, setAutocompleteQuery] =
|
||||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
||||
|
||||
|
|
@ -232,15 +274,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
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<HTMLDivElement, RoomInputProps>(
|
|||
</div>
|
||||
)
|
||||
}
|
||||
before={
|
||||
<IconButton
|
||||
onClick={() => pickFile('*')}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
bottom={
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ padding: `${toRem(4)} ${toRem(8)} ${toRem(6)}` }}
|
||||
>
|
||||
<Icon src={Icons.PlusCircle} />
|
||||
</IconButton>
|
||||
}
|
||||
after={
|
||||
<>
|
||||
<IconButton
|
||||
onClick={() => pickFile('*')}
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => setToolbar(!toolbar)}
|
||||
>
|
||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||
<Icon src={StreamComposerIcons.Plus} />
|
||||
</IconButton>
|
||||
<Box grow="Yes" />
|
||||
<UseStateProvider initial={undefined}>
|
||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
||||
<PopOut
|
||||
|
|
@ -647,7 +679,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
}
|
||||
content={
|
||||
<EmojiBoard
|
||||
tab={emojiBoardTab}
|
||||
tab={emojiBoardTab ?? EmojiBoardTab.Emoji}
|
||||
onTabChange={setEmojiBoardTab}
|
||||
imagePackRooms={imagePackRooms}
|
||||
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
|
||||
ref={emojiBtnRef}
|
||||
aria-pressed={
|
||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||
}
|
||||
aria-pressed={!!emojiBoardTab}
|
||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon
|
||||
src={Icons.Smile}
|
||||
filled={
|
||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||
}
|
||||
/>
|
||||
<Icon src={StreamComposerIcons.Smile} />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
|
||||
<Icon src={Icons.Send} />
|
||||
<IconButton
|
||||
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>
|
||||
</>
|
||||
}
|
||||
bottom={
|
||||
toolbar && (
|
||||
<div>
|
||||
<Line variant="SurfaceVariant" size="300" />
|
||||
<Toolbar />
|
||||
</div>
|
||||
)
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2057,7 +2057,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
<Box
|
||||
direction="Column"
|
||||
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 && (
|
||||
<div
|
||||
|
|
|
|||
81
src/app/features/room/RoomView.css.ts
Normal file
81
src/app/features/room/RoomView.css.ts
Normal 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',
|
||||
}
|
||||
);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import { Box, Text, color, config } from 'folds';
|
||||
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Text, color, config, toRem } from 'folds';
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
|
|
@ -20,6 +20,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
|||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
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 shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||
|
|
@ -55,6 +57,12 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
|||
export function RoomView({ eventId }: { eventId?: string }) {
|
||||
const roomInputRef = 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 { 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 (
|
||||
<Page ref={roomViewRef} style={{ backgroundColor: color.SurfaceVariant.Container }}>
|
||||
<Page ref={roomViewRef} style={pageStyle}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
|
|
@ -115,8 +150,21 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||
<RoomViewTyping room={room} />
|
||||
</Box>
|
||||
{!threadDrawerOpen && (
|
||||
<Box shrink="No" direction="Column">
|
||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||
<div
|
||||
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 ? (
|
||||
<RoomTombstone
|
||||
roomId={roomId}
|
||||
|
|
@ -146,7 +194,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue