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:
parent
635fb91022
commit
4654836092
4 changed files with 269 additions and 28 deletions
|
|
@ -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<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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(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()}
|
||||
/>
|
||||
)}
|
||||
</Message>
|
||||
|
|
@ -1372,6 +1492,7 @@ export function RoomTimeline({
|
|||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment
|
||||
eventId={mEvent.getId()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -2140,19 +2261,31 @@ export function RoomTimeline({
|
|||
<span ref={atBottomAnchorRef} />
|
||||
</Box>
|
||||
</Scroll>
|
||||
{!atBottom && (
|
||||
<TimelineFloat position="Bottom">
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
||||
onClick={handleJumpToLatest}
|
||||
>
|
||||
<Text size="L400">{t('Room.jump_to_latest')}</Text>
|
||||
</Chip>
|
||||
</TimelineFloat>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.JumpToLatestFab}
|
||||
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})`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
// Remounting on `pulseCount` change restarts the keyframes
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<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).
|
||||
// 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}
|
||||
/>
|
||||
<RoomViewTyping room={room} />
|
||||
</Box>
|
||||
{!threadDrawerOpen && (
|
||||
<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 }}
|
||||
// zIndex (in css.ComposerOverlay) must beat the Stream-rail dot
|
||||
// halos (zIndex: 2 in layout.css.ts) — shared stacking context
|
||||
// with the Page. `data-hidden` toggles the slide/fade.
|
||||
// `onFocusCapture` covers programmatic focus while the wrap is
|
||||
// off-screen (e.g. `ReactEditor.focus(editor)` from useKeyDown's
|
||||
// auto-focus when the user starts typing).
|
||||
className={css.ComposerOverlay}
|
||||
data-hidden={composerHidden ? 'true' : 'false'}
|
||||
onFocusCapture={() => setComposerHidden(false)}
|
||||
>
|
||||
<div
|
||||
className={css.ChatComposer}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue