Skip to content

Commit

Permalink
improvement: scroll to new messages smoothly
Browse files Browse the repository at this point in the history
Though this seems to cause the chat to "scroll up" on its own
from time to time, resulting in behavior observed e.g. in
- #4186
- #3763
- #3753

Let's do some testing and see if this should be reverted.
  • Loading branch information
WofWca committed Nov 22, 2024
1 parent 9685b50 commit 2ce7de9
Show file tree
Hide file tree
Showing 2 changed files with 90 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
## Added
- Add "Learn More" button to "Disappearing Messages" dialog #4330
- new icon for Mac users
- smooth-scroll to newly arriving messages instead of jumping instantly #4125

## Changed
- enable Telegram-style Ctrl + ArrowUp to reply by default #4333
Expand Down
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 @@ -407,7 +441,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 @@ -541,6 +612,7 @@ export default function MessageList({ accountId, chat, refComposer }: Props) {
>
<MessageListInner
onScroll={onScroll}
onScrollEnd={onScrollEnd}
oldestFetchedMessageIndex={oldestFetchedMessageListItemIndex}
messageListItems={messageListItems}
activeView={activeView}
Expand Down Expand Up @@ -575,6 +647,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 @@ -587,6 +660,7 @@ export const MessageListInner = React.memo(
}) => {
const {
onScroll,
onScrollEnd,
messageListItems,
messageCache,
activeView,
Expand Down Expand Up @@ -696,6 +770,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 2ce7de9

Please sign in to comment.