Skip to content

Commit

Permalink
improvement: scroll to new messages smoothly
Browse files Browse the repository at this point in the history
TODO:
- [ ] Test it and see if it gets stuck often enough
  for it to be critical. I have seen it happen a few times.
  Maybe for 1 out of 100 messages.
- [ ] Think about putting behind an experimental setting?
  • Loading branch information
WofWca committed Oct 1, 2024
1 parent c981d4e commit 200682f
Showing 1 changed file with 89 additions and 1 deletion.
90 changes: 89 additions & 1 deletion packages/frontend/src/components/message/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ export default function MessageList({ accountId, chat, refComposer }: Props) {
}
}, [jumpToMessage])

const pendingProgrammaticSmoothScrollTo = useRef<null | number>(null)
const pendingProgrammaticSmoothScrollTimeout = useRef<number>(-1)

const onScroll = useCallback(
(ev: React.UIEvent<HTMLDivElement> | null) => {
if (!messageListRef.current) {
Expand Down Expand Up @@ -277,6 +280,10 @@ export default function MessageList({ accountId, chat, refComposer }: Props) {
showJumpDownButton,
]
)
const onScrollEnd = useCallback((_ev: Event) => {
clearTimeout(pendingProgrammaticSmoothScrollTimeout.current)
pendingProgrammaticSmoothScrollTo.current = null
}, [])

// This `useLayoutEffect` is made to run whenever `viewState` changes.
// `viewState` controls the desired scroll position of `messageListRef`.
Expand All @@ -293,6 +300,33 @@ export default function MessageList({ accountId, chat, refComposer }: Props) {
return
}

if (pendingProgrammaticSmoothScrollTo.current != null) {
// Let's finish the pending scroll immediately
// so that our further calculations that are based on `scrollTop`
// (e.g. whether we're close to the bottom (`ifClose`)) are correct.
//
// FYI instead of interrupting the pending scroll, we could
// postpone calling `unlockScroll` when initiating a smooth scroll
// until the said scroll finishes (see `scheduler.lockedQueuedEffect()`).
// This would queue new scrollTo "events" until after
// the smooth scroll finishes.
log.debug(
'New viewState received, but a previous programmatic smooth scroll ' +
"is pending. Let's finish the pending one immediately. " +
`Scrolling to ${pendingProgrammaticSmoothScrollTo.current}`
)
messageListRef.current.scrollTop =
pendingProgrammaticSmoothScrollTo.current
clearTimeout(pendingProgrammaticSmoothScrollTimeout.current)
pendingProgrammaticSmoothScrollTo.current = null

// But keep in mind that we record `lastKnownScrollTop`
// in `chat_view_reducer`, and that recording could happen during
// a pending smooth scroll.
// This does not appear to matter though. We don't use
// `lastKnownScrollTop` too much.
}

const { scrollTo, lastKnownScrollHeight } = viewState

log.debug(
Expand Down Expand Up @@ -383,7 +417,44 @@ export default function MessageList({ accountId, chat, refComposer }: Props) {
)

if (shouldScrollToBottom) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
const scrollTo = messageListRef.current.scrollHeight
// Smooth scroll for newly arrived messages.
// TODO also add this for self-sent messages.
// In that case 'scrollToMessage' is used though...
messageListRef.current.scrollTo({
top: scrollTo,
behavior: 'smooth',
})
pendingProgrammaticSmoothScrollTo.current = scrollTo

// Smooth scroll duration is not defined by the spec:
// https://drafts.csswg.org/cssom-view/#scrolling:
// > in a user-agent-defined fashion
// > over a user-agent-defined amount of time
// As of 2024-09, on Firefox it appears to range from
// 300 to 1000 ms, depending on scroll amount.
// On Chromium: 50-700
const smoothScrollMaxDuration = 1000

// Why is 'scrollend' event not enough?
// - Because the user might interrup such a scroll and start scrolling
// wherever they like, and 'scrollend' won't fire
// until they finish scrolling.
// - Because 'scrollend' is not supported by WebKit yet
// https://webkit.org/b/201556
// and we'll be running on WebKit when we switch to Tauri.
clearTimeout(pendingProgrammaticSmoothScrollTimeout.current)
pendingProgrammaticSmoothScrollTimeout.current = window.setTimeout(
() => {
pendingProgrammaticSmoothScrollTo.current = null

console.warn(
'Smooth scroll: scrollend did not fire before timeout.\n' +
'Did the user scroll, or did the smooth scroll take so long?'
)
},
smoothScrollMaxDuration
)
}
} else {
log.debug(
Expand Down Expand Up @@ -517,6 +588,7 @@ export default function MessageList({ accountId, chat, refComposer }: Props) {
>
<MessageListInner
onScroll={onScroll}
onScrollEnd={onScrollEnd}
oldestFetchedMessageIndex={oldestFetchedMessageListItemIndex}
messageListItems={messageListItems}
activeView={activeView}
Expand Down Expand Up @@ -551,6 +623,7 @@ export type ConversationType = {
export const MessageListInner = React.memo(
(props: {
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
onScrollEnd: (event: Event) => void
oldestFetchedMessageIndex: number
messageListItems: T.MessageListItem[]
activeView: T.MessageListItem[]
Expand All @@ -563,6 +636,7 @@ export const MessageListInner = React.memo(
}) => {
const {
onScroll,
onScrollEnd,
messageListItems,
messageCache,
activeView,
Expand Down Expand Up @@ -672,6 +746,20 @@ export const MessageListInner = React.memo(
}
}, [hasChatChanged])

// onScrollend is not defined in React, let's attach manually...
useEffect(() => {
const el = messageListRef.current
if (!el) {
return
}

el.addEventListener('scrollend', onScrollEnd)
return () => el.removeEventListener('scrollend', onScrollEnd)

// Yes, re-run on every re-render, because `messageListRef` might change
// over the lifetime of this component.
})

if (!loaded) {
return (
<div id='message-list' ref={messageListRef} onScroll={onScroll2}>
Expand Down

0 comments on commit 200682f

Please sign in to comment.