feat(composer): floating overlay above timeline with Gemini-style two-row layout and Android-WebView stuck-hover gate
This commit is contained in:
parent
2337b05140
commit
41a9af19e3
5 changed files with 247 additions and 65 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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 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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue