feat(chat): hide composer on scroll-up past 200px and replace jump-to-latest chip with circular FAB that pulses on incoming live messages

This commit is contained in:
heaven 2026-05-13 01:36:29 +03:00
parent 635fb91022
commit 4654836092
4 changed files with 269 additions and 28 deletions

View file

@ -1,5 +1,6 @@
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { DefaultReset, config } from 'folds'; import { DefaultReset, color, config, toRem } from 'folds';
export const TimelineFloat = recipe({ export const TimelineFloat = recipe({
base: [ base: [
@ -17,9 +18,6 @@ export const TimelineFloat = recipe({
Top: { Top: {
top: config.space.S400, top: config.space.S400,
}, },
Bottom: {
bottom: config.space.S400,
},
}, },
}, },
defaultVariants: { defaultVariants: {
@ -28,3 +26,73 @@ export const TimelineFloat = recipe({
}); });
export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>; export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
// "Jump to latest" FAB. Bottom-right, circular, lavender brand accent.
// `data-hidden` encodes visibility (state inline-styled would clobber the
// `:active` press feedback). Inline `bottom` at the use site offsets for
// the live composer height.
export const JumpToLatestFab = style([
DefaultReset,
{
position: 'absolute',
right: config.space.S400,
width: toRem(44),
height: toRem(44),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: color.Primary.Main,
color: color.Primary.OnMain,
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.28), 0 2px 4px rgba(0, 0, 0, 0.16)',
transition:
'transform 180ms ease-out, opacity 180ms ease-out, background-color 120ms ease',
zIndex: 5,
transform: 'scale(1)',
opacity: 1,
selectors: {
'&[data-hidden="true"]': {
transform: 'scale(0.6)',
opacity: 0,
pointerEvents: 'none',
},
'&:active': {
transform: 'scale(0.94)',
},
},
'@media': {
'(prefers-reduced-motion: reduce)': {
transition: 'none',
},
},
},
]);
// Mouse-only hover brighten; touch sessions stay flat to avoid the stuck
// `:hover` paint on Android WebView (same precedent as ChatComposer).
globalStyle(`:root[data-input="mouse"] ${JumpToLatestFab}:hover`, {
filter: 'brightness(1.08)',
});
// Bounce animation played when a fresh live message arrives while the
// user is scrolled away from the live edge — signals "there's something
// new below" without dragging in a numeric badge. The slot is keyed by
// a counter at the use site so each new message remounts the span and
// restarts the animation.
const pulseKeyframes = keyframes({
'0%': { transform: 'scale(1)' },
'40%': { transform: 'scale(1.28)' },
'100%': { transform: 'scale(1)' },
});
export const FabIconPulseSlot = style({
display: 'flex',
animation: `${pulseKeyframes} 520ms ease-out`,
'@media': {
'(prefers-reduced-motion: reduce)': {
animation: 'none',
},
},
});

View file

@ -123,6 +123,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsOneOnOne } from '../../hooks/useRoom'; import { useIsOneOnOne } from '../../hooks/useRoom';
import { isNativePlatform } from '../../utils/capacitor';
import { useChannelsMode, useThreadDrawerOpen } from '../../hooks/useChannelsMode'; import { useChannelsMode, useThreadDrawerOpen } from '../../hooks/useChannelsMode';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
@ -230,10 +231,32 @@ type RoomTimelineProps = {
// re-anchor the scroll to the bottom whenever it changes and the user // re-anchor the scroll to the bottom whenever it changes and the user
// was already at the bottom. // was already at the bottom.
bottomOverlayHeight?: number; bottomOverlayHeight?: number;
// Native-only scroll-aware composer hide. Timeline owns the scroll
// element, RoomView owns the composer DOM — so direction detection
// lives here and reports up. Web ignores the callback entirely; the
// gating happens inside the listener attach effect.
onComposerHiddenChange?: (hidden: boolean) => void;
}; };
const PAGINATION_LIMIT = 80; const PAGINATION_LIMIT = 80;
// Native scroll-aware composer thresholds. Asymmetric hysteresis: easier to
// reveal the composer than to hide it, so a flick up-then-down can't strand
// it in hidden state. NEAR_BOTTOM_PX matches roughly the visible breathing
// room below the last message (timeline paddingBottom + overlay padding) —
// while the user is within this band of the live edge, small scrolls never
// strip the composer.
const COMPOSER_HIDE_DELTA_PX = 12;
const COMPOSER_SHOW_DELTA_PX = 6;
const COMPOSER_NEAR_BOTTOM_PX = 200;
// Jump-to-latest FAB visibility thresholds. Decoupled from the auto-follow
// `atBottom` gate (which carries a 1s debounce designed for live-timeline
// tracking, not visual UI) — FAB needs eager response. Show / hide hysteresis
// avoids flicker at the boundary on tiny scrolls.
const FAB_SHOW_DISTANCE_PX = 150;
const FAB_HIDE_DISTANCE_PX = 50;
type Timeline = { type Timeline = {
linkedTimelines: EventTimeline[]; linkedTimelines: EventTimeline[];
range: ItemRange; range: ItemRange;
@ -485,6 +508,7 @@ export function RoomTimeline({
roomInputRef, roomInputRef,
editor, editor,
bottomOverlayHeight = 0, bottomOverlayHeight = 0,
onComposerHiddenChange,
}: RoomTimelineProps) { }: RoomTimelineProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
@ -588,6 +612,18 @@ export function RoomTimeline({
const atBottomRef = useRef(atBottom); const atBottomRef = useRef(atBottom);
atBottomRef.current = atBottom; atBottomRef.current = atBottom;
// Jump-to-latest FAB visibility — driven by distance from the live edge,
// not by the debounced auto-follow `atBottom`. Initial `true` because the
// common open-room state is "scrolled to bottom"; deep-links to a specific
// event will fire scroll events that correct the state.
const [fabHidden, setFabHidden] = useState(true);
const fabHiddenRef = useRef(fabHidden);
fabHiddenRef.current = fabHidden;
// Pulse counter — bumped on each fresh live message while the FAB is
// showing. Drives the `key` of an icon-wrap span so React remounts it and
// the CSS keyframes restart.
const [pulseCount, setPulseCount] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottomRef = useRef({ const scrollToBottomRef = useRef({
count: 0, count: 0,
@ -800,6 +836,31 @@ export function RoomTimeline({
) )
); );
// Pulse the FAB when a fresh message from another user lands on the live
// timeline while the user is scrolled away. Edits / reactions / own
// messages are filtered so the cue fires only for genuinely new content
// worth jumping back for.
useLiveEventArrive(
room,
useCallback(
(mEvt: MatrixEvent) => {
if (fabHiddenRef.current) return;
if (mEvt.getSender() === mx.getUserId()) return;
if (reactionOrEditEvent(mEvt)) return;
const type = mEvt.getType();
if (
type !== MessageEvent.RoomMessage &&
type !== MessageEvent.RoomMessageEncrypted &&
type !== MessageEvent.Sticker
) {
return;
}
setPulseCount((c) => c + 1);
},
[mx]
)
);
const handleOpenEvent = useCallback( const handleOpenEvent = useCallback(
async ( async (
evtId: string, evtId: string,
@ -896,6 +957,64 @@ export function RoomTimeline({
if (el) scrollToBottom(el); if (el) scrollToBottom(el);
}, [bottomOverlayHeight, getScrollElement]); }, [bottomOverlayHeight, getScrollElement]);
// Single scroll listener covering two concerns:
// 1. FAB visibility (all platforms) — eager distance gate with show /
// hide hysteresis, decoupled from `atBottom`'s 1s debounce.
// 2. Composer slide/fade (Capacitor native only) — direction-tracked
// accumulator with asymmetric hysteresis; hide only fires past the
// near-bottom band so the timeline's bottom breathing room can be
// revealed by small scrolls without stripping the composer.
// The atBottom force-show effect below corrects composer state on
// programmatic jumps to bottom (jump-to-latest, send, layout re-anchor).
useEffect(() => {
const scrollEl = getScrollElement();
if (!scrollEl) return undefined;
const native = isNativePlatform();
let lastTop = scrollEl.scrollTop;
let accumulator = 0;
let lastDir: 1 | -1 | 0 = 0;
const handler = () => {
const top = scrollEl.scrollTop;
const distFromBottom = scrollEl.scrollHeight - top - scrollEl.clientHeight;
setFabHidden((prev) => {
if (distFromBottom > FAB_SHOW_DISTANCE_PX) return false;
if (distFromBottom <= FAB_HIDE_DISTANCE_PX) return true;
return prev;
});
if (!native || !onComposerHiddenChange) return;
const delta = top - lastTop;
lastTop = top;
if (delta === 0) return;
const dir: 1 | -1 = delta > 0 ? 1 : -1;
if (dir !== lastDir) {
accumulator = delta;
lastDir = dir;
} else {
accumulator += delta;
}
if (accumulator <= -COMPOSER_HIDE_DELTA_PX) {
if (distFromBottom > COMPOSER_NEAR_BOTTOM_PX) {
onComposerHiddenChange(true);
}
accumulator = 0;
} else if (accumulator >= COMPOSER_SHOW_DELTA_PX) {
onComposerHiddenChange(false);
accumulator = 0;
}
};
scrollEl.addEventListener('scroll', handler, { passive: true });
return () => scrollEl.removeEventListener('scroll', handler);
}, [getScrollElement, onComposerHiddenChange]);
useEffect(() => {
if (!isNativePlatform()) return;
if (atBottom && onComposerHiddenChange) onComposerHiddenChange(false);
}, [atBottom, onComposerHiddenChange]);
// Stay at bottom when scroll container resizes (e.g. Android keyboard open/close) // Stay at bottom when scroll container resizes (e.g. Android keyboard open/close)
useResizeObserver( useResizeObserver(
useMemo(() => { useMemo(() => {
@ -1238,6 +1357,7 @@ export function RoomTimeline({
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
outlineAttachment outlineAttachment
eventId={mEvent.getId()}
/> />
)} )}
</Message> </Message>
@ -1372,6 +1492,7 @@ export function RoomTimeline({
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
outlineAttachment outlineAttachment
eventId={mEvent.getId()}
/> />
); );
} }
@ -2140,19 +2261,31 @@ export function RoomTimeline({
<span ref={atBottomAnchorRef} /> <span ref={atBottomAnchorRef} />
</Box> </Box>
</Scroll> </Scroll>
{!atBottom && ( <button
<TimelineFloat position="Bottom"> type="button"
<Chip className={css.JumpToLatestFab}
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={handleJumpToLatest} onClick={handleJumpToLatest}
aria-label={t('Room.jump_to_latest')}
aria-hidden={fabHidden}
tabIndex={fabHidden ? -1 : 0}
data-hidden={fabHidden ? 'true' : 'false'}
style={{
// Sit S400 above the overlay; tracks composer growth (reply
// preview, multi-line typing) via the live composer height.
bottom: `calc(${bottomOverlayHeight}px + ${config.space.S400})`,
}}
> >
<Text size="L400">{t('Room.jump_to_latest')}</Text> <span
</Chip> // Remounting on `pulseCount` change restarts the keyframes
</TimelineFloat> // animation. pulseCount=0 mounts without the class so the FAB
)} // doesn't auto-pulse on first reveal.
key={pulseCount}
className={pulseCount === 0 ? undefined : css.FabIconPulseSlot}
style={pulseCount === 0 ? { display: 'flex' } : undefined}
>
<Icon size="300" src={Icons.ChevronBottom} />
</span>
</button>
</Box> </Box>
); );
} }

View file

@ -20,6 +20,32 @@ import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// the same Android-WebView stuck-:hover suppression. // the same Android-WebView stuck-:hover suppression.
export const ChatComposer = style({}); export const ChatComposer = style({});
// Outer absolute-positioned wrapper for the composer overlay. Carries the
// slide/fade transition driven by the `data-hidden` attribute set from
// React state. CSS class (not inline `transition`) so the
// `prefers-reduced-motion` media query can disable the motion.
export const ComposerOverlay = style({
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
zIndex: 10,
transition: 'transform 220ms ease-out, opacity 220ms ease-out',
willChange: 'transform, opacity',
selectors: {
'&[data-hidden="true"]': {
transform: 'translateY(120%)',
opacity: 0,
pointerEvents: 'none',
},
},
'@media': {
'(prefers-reduced-motion: reduce)': {
transition: 'none',
},
},
});
globalStyle(`${ChatComposer} .${Editor}`, { globalStyle(`${ChatComposer} .${Editor}`, {
backgroundColor: color.Surface.Container, backgroundColor: color.Surface.Container,
borderRadius: toRem(VOJO_HORSESHOE_RADIUS_PX), borderRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Box, Text, color, config, toRem } 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';
@ -58,11 +58,14 @@ 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); const composerWrapRef = useRef<HTMLDivElement>(null);
// Live composer height — feeds `--vojo-composer-height` on the chat // Live composer height — RoomTimeline mirrors it as `paddingBottom` so
// surface so RoomTimeline can pad its scroll content's bottom by the // the last message stays flush above the overlay.
// 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 [composerHeight, setComposerHeight] = useState(0);
// Native scroll-aware composer (Android Capacitor only). RoomTimeline
// owns the scroll element and reports direction; we translate the wrap
// via the `data-hidden` attribute on the CSS class. paddingBottom on
// the timeline stays unchanged so the message column doesn't reflow.
const [composerHidden, setComposerHidden] = useState(false);
const room = useRoom(); const room = useRoom();
const { roomId } = room; const { roomId } = room;
@ -102,6 +105,13 @@ export function RoomView({ eventId }: { eventId?: string }) {
return () => ro.disconnect(); return () => ro.disconnect();
}, [threadDrawerOpen, tombstoneEvent, canMessage]); }, [threadDrawerOpen, tombstoneEvent, canMessage]);
// Reset hidden state before paint when the room or drawer changes, so a
// composer-hidden tail from the previous surface can't flash through the
// 220ms transition on remount.
useLayoutEffect(() => {
setComposerHidden(false);
}, [roomId, threadDrawerOpen]);
useKeyDown( useKeyDown(
window, window,
useCallback( useCallback(
@ -145,18 +155,22 @@ export function RoomView({ eventId }: { eventId?: string }) {
roomInputRef={roomInputRef} roomInputRef={roomInputRef}
editor={editor} editor={editor}
bottomOverlayHeight={composerHeight} bottomOverlayHeight={composerHeight}
onComposerHiddenChange={setComposerHidden}
/> />
<RoomViewTyping room={room} /> <RoomViewTyping room={room} />
</Box> </Box>
{!threadDrawerOpen && ( {!threadDrawerOpen && (
<div <div
ref={composerWrapRef} ref={composerWrapRef}
// zIndex must beat the timeline's Stream-rail dot halos (zIndex: 2 // zIndex (in css.ComposerOverlay) must beat the Stream-rail dot
// in layout.css.ts:332,618). They share the Page's stacking context // halos (zIndex: 2 in layout.css.ts) — shared stacking context
// since the Scroll wrapper isn't a positioned ancestor, so a z=1 // with the Page. `data-hidden` toggles the slide/fade.
// overlay loses to the dots and lets them paint through the // `onFocusCapture` covers programmatic focus while the wrap is
// composer card — visually it reads as a "transparent" form. // off-screen (e.g. `ReactEditor.focus(editor)` from useKeyDown's
style={{ position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 10 }} // auto-focus when the user starts typing).
className={css.ComposerOverlay}
data-hidden={composerHidden ? 'true' : 'false'}
onFocusCapture={() => setComposerHidden(false)}
> >
<div <div
className={css.ChatComposer} className={css.ChatComposer}