From cad4852878441fd8323e5261e5bdcb241ed7e030 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Tue, 28 Apr 2026 22:51:50 +1000 Subject: [PATCH] feat: Enhance scrolling behavior and styles across components; add centralized scroll utilities Co-authored-by: Copilot --- src/app/components/media/media.css.ts | 1 + .../components/message/MsgTypeRenderers.tsx | 2 +- .../components/message/content/style.css.ts | 4 + src/app/features/room/RoomTimeline.tsx | 120 +++++++++++------- src/app/features/room/ThreadView.tsx | 6 +- src/app/hooks/useVirtualPaginator.ts | 15 ++- src/app/utils/dom.ts | 61 ++++++++- src/ext.d.ts | 3 + 8 files changed, 151 insertions(+), 61 deletions(-) diff --git a/src/app/components/media/media.css.ts b/src/app/components/media/media.css.ts index f167966..406294a 100644 --- a/src/app/components/media/media.css.ts +++ b/src/app/components/media/media.css.ts @@ -7,6 +7,7 @@ export const Image = style([ display: 'block', maxWidth: '100%', maxHeight: '100%', + borderRadius: 'inherit', imageRendering: 'auto', backfaceVisibility: 'hidden', transform: 'translateZ(0)', diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 544b7ce..a67af02 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -197,7 +197,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) { } return ( - + {renderImageContent({ body: content.body || 'Image', diff --git a/src/app/components/message/content/style.css.ts b/src/app/components/message/content/style.css.ts index 9d79dfc..7912f83 100644 --- a/src/app/components/message/content/style.css.ts +++ b/src/app/components/message/content/style.css.ts @@ -8,6 +8,8 @@ export const RelativeBase = style([ display: 'flex', maxWidth: '100%', maxHeight: '100%', + borderRadius: '7px', + overflow: 'hidden', }, ]); @@ -19,6 +21,8 @@ export const AbsoluteContainer = style([ justifyContent: 'center', maxWidth: '100%', maxHeight: '100%', + borderRadius: 'inherit', + overflow: 'hidden', }, ]); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d4b87c8..2997026 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -52,7 +52,7 @@ import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator'; import { useAlive } from '../../hooks/useAlive'; -import { editableActiveElement, scrollToBottom } from '../../utils/dom'; +import { autoScrollToBottom, editableActiveElement } from '../../utils/dom'; import { DefaultPlaceholder, CompactPlaceholder, @@ -514,6 +514,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const space = useSpaceOptionally(); const markAsRead = useMarkAsRead(mx); const location = useLocation(); + const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest; const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); @@ -530,16 +531,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const atBottomRef = useRef(atBottom); atBottomRef.current = atBottom; - const shouldAutoScrollRef = useRef(true); const [latestUnreadMessage, setLatestUnreadMessage] = useState<{ sender: string; content: string; } | null>(null); const scrollRef = useRef(null); + const timelineContentRef = useRef(null); + const timelineContentHeightRef = useRef(0); const scrollToBottomRef = useRef({ count: 0, smooth: true, + force: false, }); const [focusItem, setFocusItem] = useState< @@ -639,6 +642,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli setTimeline(getInitialTimeline(room)); scrollToBottomRef.current.count += 1; scrollToBottomRef.current.smooth = false; + scrollToBottomRef.current.force = true; }, [alive, room]) ); @@ -647,52 +651,49 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli useCallback( (mEvt: MatrixEvent) => { const isOwnMessage = mEvt.getSender() === mx.getUserId(); - const shouldScroll = atBottomRef.current || (shouldAutoScrollRef.current && isOwnMessage); + const shouldStickToBottom = atBottomRef.current || isOwnMessage; - if (shouldScroll) { - if (isOwnMessage) { - shouldAutoScrollRef.current = true; - } - - if (document.hasFocus() && (!unreadInfo || isOwnMessage)) { - requestAnimationFrame(() => markAsRead(mEvt.getRoomId()!, hideActivity)); - } - - if (!document.hasFocus() && !unreadInfo) { - setUnreadInfo(getRoomUnreadInfo(room)); - } + // Always update timeline with new message + setTimeline((ct) => ({ + ...ct, + range: { + start: ct.range.start + 1, + end: ct.range.end + 1, + }, + })); + // Handle own messages and focus + if (isOwnMessage) { setLatestUnreadMessage(null); - scrollToBottomRef.current.count += 1; - scrollToBottomRef.current.smooth = true; - - setTimeline((ct) => ({ - ...ct, - range: { - start: ct.range.start + 1, - end: ct.range.end + 1, - }, - })); - return; + if (document.hasFocus() && (!unreadInfo)) { + const roomId = mEvt.getRoomId(); + if (roomId) { + requestAnimationFrame(() => markAsRead(roomId, hideActivity)); + } + } + } else if (!document.hasFocus() && !unreadInfo) { + // Show unread notification for other users' messages when unfocused + setUnreadInfo(getRoomUnreadInfo(room)); } - if (!isOwnMessage) { - shouldAutoScrollRef.current = false; - const senderName = getMemberDisplayName(room, mEvt.getSender() ?? '') - ?? getMxIdLocalPart(mEvt.getSender() ?? '') - ?? 'Unknown'; + // Preserve the pre-update bottom state so a newly added message keeps the view pinned. + scrollToBottomRef.current.count += 1; + scrollToBottomRef.current.smooth = !isOwnMessage; + scrollToBottomRef.current.force = shouldStickToBottom; + + // 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 messageText = content.body ?? content.msgtype ?? 'New message'; setLatestUnreadMessage({ 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] ) @@ -753,13 +754,35 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli if (!editorBaseEntry || !scrollElement) return; if (atBottomRef.current) { - scrollToBottom(scrollElement); + autoScrollToBottom(scrollElement, false, true); } }; }, [getScrollElement, 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 readUptoEventId = readUptoEventIdRef.current; if (!readUptoEventId) { @@ -777,7 +800,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli useCallback((entry: IntersectionObserverEntry) => { if (!entry.isIntersecting) { setAtBottom(false); - shouldAutoScrollRef.current = false; } }, []), { wait: 1000 } @@ -791,7 +813,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli if (targetEntry) debounceSetAtBottom(targetEntry); if (targetEntry?.isIntersecting && atLiveEndRef.current) { setAtBottom(true); - shouldAutoScrollRef.current = true; setLatestUnreadMessage(null); if (document.hasFocus()) { tryAutoMarkAsRead(); @@ -864,22 +885,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // Scroll to latest when clicking on already-selected room useEffect(() => { - const scrollToLatest = (location.state as any)?.scrollToLatest; if (scrollToLatest) { - shouldAutoScrollRef.current = true; setLatestUnreadMessage(null); setUnreadInfo(undefined); setTimeline(getInitialTimeline(room)); scrollToBottomRef.current.count += 1; scrollToBottomRef.current.smooth = false; + scrollToBottomRef.current.force = true; } - }, [(location.state as any)?.scrollToLatest, room]); + }, [scrollToLatest, room]); // Scroll to bottom on initial timeline load useLayoutEffect(() => { const scrollEl = scrollRef.current; if (scrollEl) { - scrollToBottom(scrollEl); + autoScrollToBottom(scrollEl, false, true); } }, []); @@ -926,8 +946,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli useLayoutEffect(() => { if (scrollToBottomCount > 0) { const scrollEl = scrollRef.current; - if (scrollEl) - scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant'); + if (scrollEl) { + autoScrollToBottom( + scrollEl, + scrollToBottomRef.current.smooth, + scrollToBottomRef.current.force + ); + } } }, [scrollToBottomCount]); @@ -980,11 +1005,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli if (eventId) { navigateRoom(room.roomId, undefined, { replace: true }); } - shouldAutoScrollRef.current = true; setLatestUnreadMessage(null); setTimeline(getInitialTimeline(room)); scrollToBottomRef.current.count += 1; scrollToBottomRef.current.smooth = false; + scrollToBottomRef.current.force = true; }; const handleJumpToUnread = () => { @@ -1995,6 +2020,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli )} { const el = scrollRef.current; - if (el) scrollToBottom(el); + if (el) autoScrollToBottom(el); }, [events.length]); const handleClose = useCallback(() => { @@ -481,7 +481,7 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) { const el = scrollRef.current?.querySelector( `[data-message-id="${targetId}"]` ) as HTMLElement | null; - if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + if (el) scrollElementIntoView(el, { behavior: 'smooth', block: 'center' }); }, []); const handleReactionToggle = useCallback( diff --git a/src/app/hooks/useVirtualPaginator.ts b/src/app/hooks/useVirtualPaginator.ts index 5ad056a..cacd6b4 100644 --- a/src/app/hooks/useVirtualPaginator.ts +++ b/src/app/hooks/useVirtualPaginator.ts @@ -5,6 +5,7 @@ import { getScrollInfo, isInScrollView, isIntersectingScrollView, + scrollToPosition, } from '../utils/dom'; const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor'; @@ -218,7 +219,7 @@ export const useVirtualPaginator = ( scrollTo = element.offsetTop - Math.round(scrollInfo.viewHeight) + element.clientHeight; } - scrollElement.scrollTo({ + scrollToPosition(scrollElement, { top: scrollTo - (opts?.offset ?? 0), behavior: opts?.behavior, }); @@ -253,10 +254,12 @@ export const useVirtualPaginator = ( if (!itemElement) { const scrollElement = getScrollElement(); - scrollElement?.scrollTo({ - top: opts?.offset ?? 0, - behavior: opts?.behavior, - }); + if (scrollElement) { + scrollToPosition(scrollElement, { + top: opts?.offset ?? 0, + behavior: opts?.behavior, + }); + } return true; } return scrollToElement(itemElement, opts); @@ -364,7 +367,7 @@ export const useVirtualPaginator = ( const offsetAddition = offsetTop - oldOffsetTop; const restoreTop = oldScrollTop + offsetAddition; - scrollEl.scrollTo({ + scrollToPosition(scrollEl, { top: restoreTop, behavior: 'instant', }); diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index 71e8e22..0e9bc11 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -172,6 +172,32 @@ export type ScrollInfo = { viewHeight: number; 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 => ({ offsetTop: Math.round(target.offsetTop), top: Math.round(target.scrollTop), @@ -180,17 +206,44 @@ export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({ scrollable: target.scrollHeight > target.offsetHeight, }); -export const scrollToBottom = (scrollEl: HTMLElement, behavior?: 'auto' | 'instant' | 'smooth') => { - scrollEl.scrollTo({ +export const scrollToBottom = (scrollEl: HTMLElement, behavior?: ScrollBehavior) => { + scrollToPosition(scrollEl, { top: Math.round(scrollEl.scrollHeight - scrollEl.offsetHeight), 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) => { // Try Electron clipboard first (if available) - if ('electron' in window && (window as any).electron?.clipboard?.writeText) { - (window as any).electron.clipboard.writeText(text); + if ('electron' in window && window.electron?.clipboard?.writeText) { + window.electron.clipboard.writeText(text); return; } diff --git a/src/ext.d.ts b/src/ext.d.ts index e89820f..983f499 100644 --- a/src/ext.d.ts +++ b/src/ext.d.ts @@ -53,6 +53,9 @@ interface PluginAPI { interface ElectronAPI { platform?: string; arch?: string; + clipboard?: { + writeText: (text: string) => void; + }; plugins?: PluginAPI; }