feat: Enhance scrolling behavior and styles across components; add centralized scroll utilities

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-28 22:51:50 +10:00
parent e6af56a417
commit cad4852878
8 changed files with 151 additions and 61 deletions

View File

@@ -7,6 +7,7 @@ export const Image = style([
display: 'block', display: 'block',
maxWidth: '100%', maxWidth: '100%',
maxHeight: '100%', maxHeight: '100%',
borderRadius: 'inherit',
imageRendering: 'auto', imageRendering: 'auto',
backfaceVisibility: 'hidden', backfaceVisibility: 'hidden',
transform: 'translateZ(0)', transform: 'translateZ(0)',

View File

@@ -197,7 +197,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
} }
return ( return (
<Attachment outlined={outlined}> <Attachment outlined={outlined} transparent>
<AttachmentBox> <AttachmentBox>
{renderImageContent({ {renderImageContent({
body: content.body || 'Image', body: content.body || 'Image',

View File

@@ -8,6 +8,8 @@ export const RelativeBase = style([
display: 'flex', display: 'flex',
maxWidth: '100%', maxWidth: '100%',
maxHeight: '100%', maxHeight: '100%',
borderRadius: '7px',
overflow: 'hidden',
}, },
]); ]);
@@ -19,6 +21,8 @@ export const AbsoluteContainer = style([
justifyContent: 'center', justifyContent: 'center',
maxWidth: '100%', maxWidth: '100%',
maxHeight: '100%', maxHeight: '100%',
borderRadius: 'inherit',
overflow: 'hidden',
}, },
]); ]);

View File

@@ -52,7 +52,7 @@ import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator'; import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive'; import { useAlive } from '../../hooks/useAlive';
import { editableActiveElement, scrollToBottom } from '../../utils/dom'; import { autoScrollToBottom, editableActiveElement } from '../../utils/dom';
import { import {
DefaultPlaceholder, DefaultPlaceholder,
CompactPlaceholder, CompactPlaceholder,
@@ -514,6 +514,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const markAsRead = useMarkAsRead(mx); const markAsRead = useMarkAsRead(mx);
const location = useLocation(); const location = useLocation();
const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
@@ -530,16 +531,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const atBottomRef = useRef(atBottom); const atBottomRef = useRef(atBottom);
atBottomRef.current = atBottom; atBottomRef.current = atBottom;
const shouldAutoScrollRef = useRef<boolean>(true);
const [latestUnreadMessage, setLatestUnreadMessage] = useState<{ const [latestUnreadMessage, setLatestUnreadMessage] = useState<{
sender: string; sender: string;
content: string; content: string;
} | null>(null); } | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const timelineContentRef = useRef<HTMLDivElement>(null);
const timelineContentHeightRef = useRef(0);
const scrollToBottomRef = useRef({ const scrollToBottomRef = useRef({
count: 0, count: 0,
smooth: true, smooth: true,
force: false,
}); });
const [focusItem, setFocusItem] = useState< const [focusItem, setFocusItem] = useState<
@@ -639,6 +642,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
setTimeline(getInitialTimeline(room)); setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1; scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false; scrollToBottomRef.current.smooth = false;
scrollToBottomRef.current.force = true;
}, [alive, room]) }, [alive, room])
); );
@@ -647,52 +651,49 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useCallback( useCallback(
(mEvt: MatrixEvent) => { (mEvt: MatrixEvent) => {
const isOwnMessage = mEvt.getSender() === mx.getUserId(); const isOwnMessage = mEvt.getSender() === mx.getUserId();
const shouldScroll = atBottomRef.current || (shouldAutoScrollRef.current && isOwnMessage); const shouldStickToBottom = atBottomRef.current || isOwnMessage;
if (shouldScroll) { // Always update timeline with new message
if (isOwnMessage) { setTimeline((ct) => ({
shouldAutoScrollRef.current = true; ...ct,
} range: {
start: ct.range.start + 1,
if (document.hasFocus() && (!unreadInfo || isOwnMessage)) { end: ct.range.end + 1,
requestAnimationFrame(() => markAsRead(mEvt.getRoomId()!, hideActivity)); },
} }));
if (!document.hasFocus() && !unreadInfo) {
setUnreadInfo(getRoomUnreadInfo(room));
}
// Handle own messages and focus
if (isOwnMessage) {
setLatestUnreadMessage(null); setLatestUnreadMessage(null);
scrollToBottomRef.current.count += 1; if (document.hasFocus() && (!unreadInfo)) {
scrollToBottomRef.current.smooth = true; const roomId = mEvt.getRoomId();
if (roomId) {
setTimeline((ct) => ({ requestAnimationFrame(() => markAsRead(roomId, hideActivity));
...ct, }
range: { }
start: ct.range.start + 1, } else if (!document.hasFocus() && !unreadInfo) {
end: ct.range.end + 1, // Show unread notification for other users' messages when unfocused
}, setUnreadInfo(getRoomUnreadInfo(room));
}));
return;
} }
if (!isOwnMessage) { // Preserve the pre-update bottom state so a newly added message keeps the view pinned.
shouldAutoScrollRef.current = false; scrollToBottomRef.current.count += 1;
const senderName = getMemberDisplayName(room, mEvt.getSender() ?? '') scrollToBottomRef.current.smooth = !isOwnMessage;
?? getMxIdLocalPart(mEvt.getSender() ?? '') scrollToBottomRef.current.force = shouldStickToBottom;
?? 'Unknown';
// Show unread message preview for incoming messages when not at bottom
if (!isOwnMessage && !atBottomRef.current) {
const senderName =
getMemberDisplayName(room, mEvt.getSender() ?? '') ??
getMxIdLocalPart(mEvt.getSender() ?? '') ??
'Unknown';
const content = mEvt.getContent(); const content = mEvt.getContent();
const messageText = content.body ?? content.msgtype ?? 'New message'; const messageText = content.body ?? content.msgtype ?? 'New message';
setLatestUnreadMessage({ setLatestUnreadMessage({
sender: senderName, sender: senderName,
content: messageText.length > 100 ? messageText.substring(0, 100) + '...' : messageText, content: messageText.length > 100 ? `${messageText.substring(0, 100)}...` : messageText,
}); });
} }
setTimeline((ct) => ({ ...ct }));
if (!unreadInfo) {
setUnreadInfo(getRoomUnreadInfo(room));
}
}, },
[mx, room, unreadInfo, hideActivity, markAsRead] [mx, room, unreadInfo, hideActivity, markAsRead]
) )
@@ -753,13 +754,35 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (!editorBaseEntry || !scrollElement) return; if (!editorBaseEntry || !scrollElement) return;
if (atBottomRef.current) { if (atBottomRef.current) {
scrollToBottom(scrollElement); autoScrollToBottom(scrollElement, false, true);
} }
}; };
}, [getScrollElement, roomInputRef]), }, [getScrollElement, roomInputRef]),
useCallback(() => roomInputRef.current, [roomInputRef]) useCallback(() => roomInputRef.current, [roomInputRef])
); );
useResizeObserver(
useCallback((entries) => {
const timelineContent = timelineContentRef.current;
const scrollElement = getScrollElement();
if (!timelineContent || !scrollElement) return;
const timelineEntry = getResizeObserverEntry(timelineContent, entries);
if (!timelineEntry) return;
const nextHeight = timelineEntry.contentRect.height;
const previousHeight = timelineContentHeightRef.current;
timelineContentHeightRef.current = nextHeight;
if (previousHeight === 0 || nextHeight <= previousHeight || !atBottomRef.current) {
return;
}
autoScrollToBottom(scrollElement, false, true);
}, [getScrollElement]),
useCallback(() => timelineContentRef.current, [])
);
const tryAutoMarkAsRead = useCallback(() => { const tryAutoMarkAsRead = useCallback(() => {
const readUptoEventId = readUptoEventIdRef.current; const readUptoEventId = readUptoEventIdRef.current;
if (!readUptoEventId) { if (!readUptoEventId) {
@@ -777,7 +800,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useCallback((entry: IntersectionObserverEntry) => { useCallback((entry: IntersectionObserverEntry) => {
if (!entry.isIntersecting) { if (!entry.isIntersecting) {
setAtBottom(false); setAtBottom(false);
shouldAutoScrollRef.current = false;
} }
}, []), }, []),
{ wait: 1000 } { wait: 1000 }
@@ -791,7 +813,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (targetEntry) debounceSetAtBottom(targetEntry); if (targetEntry) debounceSetAtBottom(targetEntry);
if (targetEntry?.isIntersecting && atLiveEndRef.current) { if (targetEntry?.isIntersecting && atLiveEndRef.current) {
setAtBottom(true); setAtBottom(true);
shouldAutoScrollRef.current = true;
setLatestUnreadMessage(null); setLatestUnreadMessage(null);
if (document.hasFocus()) { if (document.hasFocus()) {
tryAutoMarkAsRead(); tryAutoMarkAsRead();
@@ -864,22 +885,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Scroll to latest when clicking on already-selected room // Scroll to latest when clicking on already-selected room
useEffect(() => { useEffect(() => {
const scrollToLatest = (location.state as any)?.scrollToLatest;
if (scrollToLatest) { if (scrollToLatest) {
shouldAutoScrollRef.current = true;
setLatestUnreadMessage(null); setLatestUnreadMessage(null);
setUnreadInfo(undefined); setUnreadInfo(undefined);
setTimeline(getInitialTimeline(room)); setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1; scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false; scrollToBottomRef.current.smooth = false;
scrollToBottomRef.current.force = true;
} }
}, [(location.state as any)?.scrollToLatest, room]); }, [scrollToLatest, room]);
// Scroll to bottom on initial timeline load // Scroll to bottom on initial timeline load
useLayoutEffect(() => { useLayoutEffect(() => {
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
if (scrollEl) { if (scrollEl) {
scrollToBottom(scrollEl); autoScrollToBottom(scrollEl, false, true);
} }
}, []); }, []);
@@ -926,8 +946,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useLayoutEffect(() => { useLayoutEffect(() => {
if (scrollToBottomCount > 0) { if (scrollToBottomCount > 0) {
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
if (scrollEl) if (scrollEl) {
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant'); autoScrollToBottom(
scrollEl,
scrollToBottomRef.current.smooth,
scrollToBottomRef.current.force
);
}
} }
}, [scrollToBottomCount]); }, [scrollToBottomCount]);
@@ -980,11 +1005,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (eventId) { if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true }); navigateRoom(room.roomId, undefined, { replace: true });
} }
shouldAutoScrollRef.current = true;
setLatestUnreadMessage(null); setLatestUnreadMessage(null);
setTimeline(getInitialTimeline(room)); setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1; scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false; scrollToBottomRef.current.smooth = false;
scrollToBottomRef.current.force = true;
}; };
const handleJumpToUnread = () => { const handleJumpToUnread = () => {
@@ -1995,6 +2020,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
)} )}
<Scroll ref={scrollRef} visibility="Hover"> <Scroll ref={scrollRef} visibility="Hover">
<Box <Box
ref={timelineContentRef}
direction="Column" direction="Column"
justifyContent="End" justifyContent="End"
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }} style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}

View File

@@ -96,7 +96,7 @@ import {
} from '../../utils/matrix'; } from '../../utils/matrix';
import { GetContentCallback, MessageEvent } from '../../../types/matrix/room'; import { GetContentCallback, MessageEvent } from '../../../types/matrix/room';
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time'; import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
import { scrollToBottom } from '../../utils/dom'; import { autoScrollToBottom, scrollElementIntoView } from '../../utils/dom';
type ThreadViewProps = { type ThreadViewProps = {
room: Room; room: Room;
@@ -406,7 +406,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
// Scroll to bottom on mount and when new events arrive. // Scroll to bottom on mount and when new events arrive.
useLayoutEffect(() => { useLayoutEffect(() => {
const el = scrollRef.current; const el = scrollRef.current;
if (el) scrollToBottom(el); if (el) autoScrollToBottom(el);
}, [events.length]); }, [events.length]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -481,7 +481,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
const el = scrollRef.current?.querySelector( const el = scrollRef.current?.querySelector(
`[data-message-id="${targetId}"]` `[data-message-id="${targetId}"]`
) as HTMLElement | null; ) as HTMLElement | null;
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (el) scrollElementIntoView(el, { behavior: 'smooth', block: 'center' });
}, []); }, []);
const handleReactionToggle = useCallback( const handleReactionToggle = useCallback(

View File

@@ -5,6 +5,7 @@ import {
getScrollInfo, getScrollInfo,
isInScrollView, isInScrollView,
isIntersectingScrollView, isIntersectingScrollView,
scrollToPosition,
} from '../utils/dom'; } from '../utils/dom';
const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor'; const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
@@ -218,7 +219,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
scrollTo = element.offsetTop - Math.round(scrollInfo.viewHeight) + element.clientHeight; scrollTo = element.offsetTop - Math.round(scrollInfo.viewHeight) + element.clientHeight;
} }
scrollElement.scrollTo({ scrollToPosition(scrollElement, {
top: scrollTo - (opts?.offset ?? 0), top: scrollTo - (opts?.offset ?? 0),
behavior: opts?.behavior, behavior: opts?.behavior,
}); });
@@ -253,10 +254,12 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
if (!itemElement) { if (!itemElement) {
const scrollElement = getScrollElement(); const scrollElement = getScrollElement();
scrollElement?.scrollTo({ if (scrollElement) {
top: opts?.offset ?? 0, scrollToPosition(scrollElement, {
behavior: opts?.behavior, top: opts?.offset ?? 0,
}); behavior: opts?.behavior,
});
}
return true; return true;
} }
return scrollToElement(itemElement, opts); return scrollToElement(itemElement, opts);
@@ -364,7 +367,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
const offsetAddition = offsetTop - oldOffsetTop; const offsetAddition = offsetTop - oldOffsetTop;
const restoreTop = oldScrollTop + offsetAddition; const restoreTop = oldScrollTop + offsetAddition;
scrollEl.scrollTo({ scrollToPosition(scrollEl, {
top: restoreTop, top: restoreTop,
behavior: 'instant', behavior: 'instant',
}); });

View File

@@ -172,6 +172,32 @@ export type ScrollInfo = {
viewHeight: number; viewHeight: number;
scrollable: boolean; scrollable: boolean;
}; };
export type ScrollBehavior = 'auto' | 'instant' | 'smooth';
export type ScrollPosition = {
top?: number;
left?: number;
behavior?: ScrollBehavior;
};
/**
* Centralized wrapper for element scrolling.
*/
export const scrollToPosition = (scrollEl: HTMLElement, position: ScrollPosition): void => {
scrollEl.scrollTo(position);
};
/**
* Centralized wrapper for scrolling a target into the current viewport.
*/
export const scrollElementIntoView = (
element: HTMLElement,
options?: ScrollIntoViewOptions
): void => {
element.scrollIntoView(options);
};
export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({ export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
offsetTop: Math.round(target.offsetTop), offsetTop: Math.round(target.offsetTop),
top: Math.round(target.scrollTop), top: Math.round(target.scrollTop),
@@ -180,17 +206,44 @@ export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
scrollable: target.scrollHeight > target.offsetHeight, scrollable: target.scrollHeight > target.offsetHeight,
}); });
export const scrollToBottom = (scrollEl: HTMLElement, behavior?: 'auto' | 'instant' | 'smooth') => { export const scrollToBottom = (scrollEl: HTMLElement, behavior?: ScrollBehavior) => {
scrollEl.scrollTo({ scrollToPosition(scrollEl, {
top: Math.round(scrollEl.scrollHeight - scrollEl.offsetHeight), top: Math.round(scrollEl.scrollHeight - scrollEl.offsetHeight),
behavior, behavior,
}); });
}; };
/**
* Check if scroll element is at or near the bottom (within threshold).
* Useful for "sticky to bottom" behavior in chat-like interfaces.
* @param scrollEl - The scrollable element
* @param threshold - Distance from bottom in pixels (default: 50px)
* @returns true if scrolled to bottom within threshold
*/
export const isScrolledToBottom = (scrollEl: HTMLElement, threshold = 50): boolean => {
const { scrollTop, offsetHeight, scrollHeight } = scrollEl;
const scrollPosition = scrollTop + offsetHeight;
return scrollHeight - scrollPosition <= threshold;
};
/**
* Shared auto-scroll behavior for chat-like timelines that should stay pinned to bottom.
* Only scrolls if already near the bottom unless a call site explicitly forces it.
*/
export const autoScrollToBottom = (
scrollEl: HTMLElement,
smooth = false,
force = false
): void => {
if (force || isScrolledToBottom(scrollEl)) {
scrollToBottom(scrollEl, smooth ? 'smooth' : 'instant');
}
};
export const copyToClipboard = (text: string) => { export const copyToClipboard = (text: string) => {
// Try Electron clipboard first (if available) // Try Electron clipboard first (if available)
if ('electron' in window && (window as any).electron?.clipboard?.writeText) { if ('electron' in window && window.electron?.clipboard?.writeText) {
(window as any).electron.clipboard.writeText(text); window.electron.clipboard.writeText(text);
return; return;
} }

3
src/ext.d.ts vendored
View File

@@ -53,6 +53,9 @@ interface PluginAPI {
interface ElectronAPI { interface ElectronAPI {
platform?: string; platform?: string;
arch?: string; arch?: string;
clipboard?: {
writeText: (text: string) => void;
};
plugins?: PluginAPI; plugins?: PluginAPI;
} }