From 46548360928fe1cb64ce402d27b3dcf3502e2069 Mon Sep 17 00:00:00 2001 From: heaven Date: Wed, 13 May 2026 01:36:29 +0300 Subject: [PATCH] feat(chat): hide composer on scroll-up past 200px and replace jump-to-latest chip with circular FAB that pulses on incoming live messages --- src/app/features/room/RoomTimeline.css.ts | 76 ++++++++++- src/app/features/room/RoomTimeline.tsx | 159 ++++++++++++++++++++-- src/app/features/room/RoomView.css.ts | 26 ++++ src/app/features/room/RoomView.tsx | 36 +++-- 4 files changed, 269 insertions(+), 28 deletions(-) diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index 9cd428ed..2f55eaca 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -1,5 +1,6 @@ +import { globalStyle, keyframes, style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; -import { DefaultReset, config } from 'folds'; +import { DefaultReset, color, config, toRem } from 'folds'; export const TimelineFloat = recipe({ base: [ @@ -17,9 +18,6 @@ export const TimelineFloat = recipe({ Top: { top: config.space.S400, }, - Bottom: { - bottom: config.space.S400, - }, }, }, defaultVariants: { @@ -28,3 +26,73 @@ export const TimelineFloat = recipe({ }); export type TimelineFloatVariants = RecipeVariants; + +// "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', + }, + }, +}); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 75486429..b4b2fac5 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -123,6 +123,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useIsOneOnOne } from '../../hooks/useRoom'; +import { isNativePlatform } from '../../utils/capacitor'; import { useChannelsMode, useThreadDrawerOpen } from '../../hooks/useChannelsMode'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; @@ -230,10 +231,32 @@ type RoomTimelineProps = { // re-anchor the scroll to the bottom whenever it changes and the user // was already at the bottom. 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; +// 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 = { linkedTimelines: EventTimeline[]; range: ItemRange; @@ -485,6 +508,7 @@ export function RoomTimeline({ roomInputRef, editor, bottomOverlayHeight = 0, + onComposerHiddenChange, }: RoomTimelineProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -588,6 +612,18 @@ export function RoomTimeline({ const atBottomRef = useRef(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(null); const scrollToBottomRef = useRef({ 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( async ( evtId: string, @@ -896,6 +957,64 @@ export function RoomTimeline({ if (el) scrollToBottom(el); }, [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) useResizeObserver( useMemo(() => { @@ -1238,6 +1357,7 @@ export function RoomTimeline({ htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment + eventId={mEvent.getId()} /> )} @@ -1372,6 +1492,7 @@ export function RoomTimeline({ htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment + eventId={mEvent.getId()} /> ); } @@ -2140,19 +2261,31 @@ export function RoomTimeline({ - {!atBottom && ( - - } - onClick={handleJumpToLatest} - > - {t('Room.jump_to_latest')} - - - )} + ); } diff --git a/src/app/features/room/RoomView.css.ts b/src/app/features/room/RoomView.css.ts index a3d414c4..66cee962 100644 --- a/src/app/features/room/RoomView.css.ts +++ b/src/app/features/room/RoomView.css.ts @@ -20,6 +20,32 @@ import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe'; // the same Android-WebView stuck-:hover suppression. 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}`, { backgroundColor: color.Surface.Container, borderRadius: toRem(VOJO_HORSESHOE_RADIUS_PX), diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 9f34adf6..4b4b0198 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -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 { EventType } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; @@ -58,11 +58,14 @@ 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). + // Live composer height — RoomTimeline mirrors it as `paddingBottom` so + // the last message stays flush above the overlay. 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 { roomId } = room; @@ -102,6 +105,13 @@ export function RoomView({ eventId }: { eventId?: string }) { return () => ro.disconnect(); }, [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( window, useCallback( @@ -145,18 +155,22 @@ export function RoomView({ eventId }: { eventId?: string }) { roomInputRef={roomInputRef} editor={editor} bottomOverlayHeight={composerHeight} + onComposerHiddenChange={setComposerHidden} /> {!threadDrawerOpen && (
setComposerHidden(false)} >