Last active
September 5, 2020 02:25
-
-
Save PaperPlane01/d8bc7fb9dcff06cc86fba220d0d9417d to your computer and use it in GitHub Desktop.
react-virtuoso keep scroll position
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, {Fragment, FunctionComponent, UIEvent, useEffect, useLayoutEffect, useRef, useState} from "react"; | |
| import {observer} from "mobx-react"; | |
| import {createStyles, Hidden, makeStyles, Theme, useMediaQuery, useTheme} from "@material-ui/core"; | |
| import {Virtuoso} from "react-virtuoso"; | |
| import {MessagesListItem} from "./MessagesListItem"; | |
| import {MessagesListBottom} from "./MessagesListBottom"; | |
| import {useStore} from "../../store"; | |
| const useStyles = makeStyles((theme: Theme) => createStyles({ | |
| messagesList: { | |
| [theme.breakpoints.up("lg")]: { | |
| overflowY: "auto", | |
| }, | |
| [theme.breakpoints.down("md")]: { | |
| overflowY: "auto", | |
| overflowX: "auto" | |
| } | |
| } | |
| })); | |
| interface MessagesListStyles { | |
| height: string | number, | |
| paddingBottom: number | |
| } | |
| interface VirtuosoInitialTopMostIndexMap { | |
| [chatId: string]: { | |
| index: number, | |
| previous: number[] | |
| } | |
| } | |
| // Map for keeping scroll position after switching chats | |
| const virtuosoInitialTopMostIndexMap: VirtuosoInitialTopMostIndexMap = {}; | |
| interface VirtuosoLastVisibleIndexMap { | |
| [chatId: string]: number | |
| } | |
| // Map for keeping last visible item of list | |
| // This is used on mobile devices | |
| const virtuosoLastVisibleIndexMap: VirtuosoLastVisibleIndexMap = {}; | |
| const setInitialTopMostItem = (index: number, chatId: string) => { | |
| if (!virtuosoInitialTopMostIndexMap[chatId]) { | |
| virtuosoInitialTopMostIndexMap[chatId] = { | |
| index: 0, | |
| previous: [] | |
| } | |
| } | |
| if (virtuosoInitialTopMostIndexMap[chatId].previous && virtuosoInitialTopMostIndexMap[chatId].previous.length !== 0) { | |
| const previous = virtuosoInitialTopMostIndexMap[chatId].previous[virtuosoInitialTopMostIndexMap[chatId].previous.length - 1]; | |
| // For some reason react-virtuoso shifts startIndex position by 1 even if it's not been visible (even if overscan is not used) | |
| // This causes scroll position to shift by 1 upwards for no reason | |
| if ((previous - index) === 1) { | |
| // Negate this effect | |
| index = previous; | |
| } else if ((previous - index) > 20) { | |
| // Looks like some kind of race condition happens when switching between chats. | |
| // For some reason position of current chat is set to previous chat on first render. | |
| // We detect too large difference between current position and previous position of chat to avoid | |
| // scrolling to incorrect position when switching back. | |
| // This is a hacky work-around but I can't see any other way currently :( | |
| index = previous; | |
| } | |
| } | |
| // Save scroll position for selected chat | |
| virtuosoInitialTopMostIndexMap[chatId].index = index; | |
| if (virtuosoInitialTopMostIndexMap[chatId].previous) { | |
| virtuosoInitialTopMostIndexMap[chatId].previous.push(index); | |
| if (virtuosoInitialTopMostIndexMap[chatId].previous.length > 30) { | |
| // Do cleanup if we have too many items in scroll history array | |
| virtuosoInitialTopMostIndexMap[chatId].previous = virtuosoInitialTopMostIndexMap[chatId].previous.slice(25) | |
| } | |
| } else { | |
| virtuosoInitialTopMostIndexMap[chatId].previous = [index]; | |
| } | |
| } | |
| export const MessagesList: FunctionComponent = observer(() => { | |
| const { | |
| messagesOfChat: { | |
| messagesOfChat | |
| }, | |
| messageCreation: { | |
| referredMessageId, | |
| createMessageForm: { | |
| text | |
| }, | |
| emojiPickerExpanded | |
| }, | |
| chatsPreferences: { | |
| useVirtualScroll | |
| }, | |
| chat: { | |
| selectedChatId, | |
| } | |
| } = useStore(); | |
| const [reachedBottom, setReachedBottom] = useState(true); | |
| const theme = useTheme(); | |
| const phantomBottomRef = useRef<HTMLDivElement>(null); | |
| const messagesListBottomRef = useRef<HTMLDivElement>(null); | |
| const classes = useStyles(); | |
| const onSmallScreen = useMediaQuery(theme.breakpoints.down("md")); | |
| const virtuosoRef = useRef<any>(); | |
| const calculateStyles = (): MessagesListStyles => { | |
| let height: string | number; | |
| let paddingBottom: number = 0; | |
| if (onSmallScreen) { | |
| if (useVirtualScroll) { | |
| if (messagesListBottomRef && messagesListBottomRef.current) { | |
| const heightToSubtract = theme.spacing(7) + messagesListBottomRef.current.getBoundingClientRect().height; | |
| height = window.innerHeight - heightToSubtract; | |
| } else { | |
| height = `calc(100vh - ${referredMessageId ? 238 : 154}px)`; | |
| } | |
| } else { | |
| height = "100%"; | |
| if (messagesListBottomRef && messagesListBottomRef.current) { | |
| paddingBottom = messagesListBottomRef.current.getBoundingClientRect().height; | |
| } | |
| } | |
| } else { | |
| if (messagesListBottomRef && messagesListBottomRef.current) { | |
| const heightToSubtract = theme.spacing(8) + messagesListBottomRef.current.getBoundingClientRect().height + theme.spacing(2); | |
| height = window.innerHeight - heightToSubtract; | |
| } else { | |
| height = `calc(100vh - ${referredMessageId ? 238 : 154}px)`; | |
| } | |
| } | |
| return {height, paddingBottom}; | |
| } | |
| const [styles, setStyles] = useState(calculateStyles()); | |
| const scrollToBottom = (): void => { | |
| if (reachedBottom && phantomBottomRef && phantomBottomRef.current) { | |
| setTimeout(() => phantomBottomRef!.current!.scrollIntoView()); | |
| } | |
| }; | |
| const handleDivScroll = (event: UIEvent<HTMLElement>): void => { | |
| const coveredDistance = event.currentTarget.scrollHeight - event.currentTarget.scrollTop; | |
| const reachedBottom = coveredDistance - event.currentTarget.clientHeight <= 1; | |
| setReachedBottom(reachedBottom); | |
| }; | |
| const handleWindowScroll = (): void => { | |
| if (!useVirtualScroll) { | |
| const windowHeight = "innerHeight" in window ? window.innerHeight : document.documentElement.offsetHeight; | |
| const body = document.body; | |
| const html = document.documentElement; | |
| const documentHeight = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight); | |
| const windowBottom = windowHeight + window.pageYOffset; | |
| setReachedBottom( documentHeight - windowBottom <= 1); | |
| } | |
| } | |
| useEffect(scrollToBottom, [messagesOfChat, emojiPickerExpanded]); | |
| useEffect(() => { | |
| const handleResize = () => { | |
| setStyles(calculateStyles()); | |
| if (reachedBottom) { | |
| scrollToBottom(); | |
| } | |
| } | |
| document.addEventListener("scroll", handleWindowScroll) | |
| window.addEventListener("resize", handleResize); | |
| return () => { | |
| document.removeEventListener("scroll", handleWindowScroll); | |
| window.removeEventListener("resize", handleResize); | |
| } | |
| }); | |
| useEffect(() => { | |
| // Check if we have saved top item index for this chat | |
| if (!onSmallScreen) { | |
| // Look for initial top most item if we are not on small screen | |
| if (virtuosoRef && virtuosoRef.current && selectedChatId && virtuosoInitialTopMostIndexMap[selectedChatId]) { | |
| // Scroll to the top item to restore scroll position | |
| virtuosoRef.current.scrollToIndex(virtuosoInitialTopMostIndexMap[selectedChatId].index); | |
| } | |
| } else { | |
| // Look for last visible index if we are on small screen | |
| if (virtuosoRef && virtuosoRef.current && selectedChatId && virtuosoLastVisibleIndexMap[selectedChatId]) { | |
| virtuosoRef.current.scrollToIndex(virtuosoLastVisibleIndexMap[selectedChatId]); | |
| } | |
| } | |
| }, [selectedChatId, onSmallScreen]) | |
| useLayoutEffect( | |
| () => setStyles(calculateStyles()), | |
| [ | |
| messagesOfChat, | |
| referredMessageId, | |
| text, | |
| onSmallScreen, | |
| emojiPickerExpanded | |
| ] | |
| ); | |
| if (!useVirtualScroll) { | |
| return ( | |
| <Fragment> | |
| <div className={classes.messagesList} | |
| onScroll={handleDivScroll} | |
| id="messagesList" | |
| style={styles} | |
| > | |
| {messagesOfChat.map(messageId => ( | |
| <MessagesListItem messageId={messageId} | |
| key={messageId} | |
| /> | |
| ))} | |
| <div id="phantomBottom" ref={phantomBottomRef}/> | |
| </div> | |
| <MessagesListBottom ref={messagesListBottomRef}/> | |
| </Fragment> | |
| ) | |
| } else { | |
| return ( | |
| <div id="messagesList"> | |
| <Hidden mdDown> | |
| <Virtuoso totalCount={messagesOfChat.length} | |
| item={index => { | |
| return ( | |
| <MessagesListItem messageId={messagesOfChat[index]} | |
| key={messagesOfChat[index]} | |
| onVisibilityChange={visible => { | |
| if (index === messagesOfChat.length - 1) { | |
| setReachedBottom(visible); | |
| } | |
| }} | |
| /> | |
| ) | |
| }} | |
| style={styles} | |
| defaultItemHeight={128} | |
| followOutput | |
| footer={() => <div id="phantomBottom" ref={phantomBottomRef}/>} | |
| rangeChanged={({startIndex}) => { | |
| if (startIndex > 5) { | |
| setInitialTopMostItem(startIndex, selectedChatId!); | |
| } | |
| }} | |
| ref={virtuosoRef} | |
| /> | |
| </Hidden> | |
| <Hidden lgUp> | |
| <Virtuoso totalCount={messagesOfChat.length} | |
| item={index => ( | |
| <MessagesListItem messageId={messagesOfChat[index]} | |
| key={messagesOfChat[index]} | |
| onVisibilityChange={visible => { | |
| if (visible) { | |
| if (virtuosoLastVisibleIndexMap[selectedChatId!]) { | |
| if (Math.abs(index - virtuosoLastVisibleIndexMap[selectedChatId!]) > 3) { | |
| virtuosoLastVisibleIndexMap[selectedChatId!] = index; | |
| } | |
| } else { | |
| virtuosoLastVisibleIndexMap[selectedChatId!] = index; | |
| } | |
| } | |
| if (index === messagesOfChat.length - 1) { | |
| setReachedBottom(visible); | |
| } | |
| }} | |
| /> | |
| )} | |
| style={styles} | |
| defaultItemHeight={120} | |
| overscan={2400} | |
| footer={() => <div id="phantomBottom" ref={phantomBottomRef}/>} | |
| followOutput | |
| ref={virtuosoRef} | |
| /> | |
| </Hidden> | |
| <MessagesListBottom ref={messagesListBottomRef}/> | |
| </div> | |
| ) | |
| } | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, {FunctionComponent, memo, useEffect} from "react"; | |
| import {observer} from "mobx-react"; | |
| import { | |
| Card, | |
| CardActions, | |
| CardContent, | |
| CardHeader, | |
| createStyles, | |
| makeStyles, | |
| Theme, | |
| Tooltip, | |
| Typography, | |
| } from "@material-ui/core"; | |
| import {Edit} from "@material-ui/icons"; | |
| import {format, isSameDay, isSameYear, Locale} from "date-fns"; | |
| import randomColor from "randomcolor"; | |
| import ReactVisibilitySensor from "react-visibility-sensor"; | |
| import {MenuItemType, MessageMenu} from "./MessageMenu"; | |
| import {ReferredMessageContent} from "./ReferredMessageContent"; | |
| import {Avatar} from "../../Avatar"; | |
| import {useAuthorization, useLocalization, useRouter, useStore} from "../../store"; | |
| import {Routes} from "../../router"; | |
| import {MarkdownTextWithEmoji} from "../../Emoji/components"; | |
| const {Link} = require("mobx-router"); | |
| interface MessagesListItemProps { | |
| messageId: string, | |
| fullWidth?: boolean, | |
| onMenuItemClick?: (menuItemType: MenuItemType) => void, | |
| onVisibilityChange?: (visible: boolean) => void | |
| } | |
| const getCreatedAtLabel = (createdAt: Date, locale: Locale): string => { | |
| const currentDate = new Date(); | |
| if (isSameDay(createdAt, currentDate)) { | |
| return format(createdAt, "HH:mm", {locale}); | |
| } else if (isSameYear(createdAt, currentDate)) { | |
| return format(createdAt, "d MMM HH:mm", {locale}); | |
| } else { | |
| return format(createdAt, "d MMM yyyy HH:mm", {locale}); | |
| } | |
| }; | |
| const useStyles = makeStyles((theme: Theme) => createStyles({ | |
| messageListItemWrapper: { | |
| display: "flex", | |
| paddingBottom: theme.spacing(1), | |
| "& p, ol, ul, pre": { | |
| marginBlockStart: "unset", | |
| marginBlockEnd: "unset", | |
| paddingBottom: theme.spacing(1) | |
| } | |
| }, | |
| messageOfCurrentUserListItemWrapper: { | |
| [theme.breakpoints.down("md")]: { | |
| flexDirection: "row-reverse" | |
| } | |
| }, | |
| messageCard: { | |
| borderRadius: 8, | |
| wordBreak: "break-word", | |
| [theme.breakpoints.up("lg")]: { | |
| maxWidth: "50%" | |
| }, | |
| [theme.breakpoints.down("md")]: { | |
| maxWidth: "60%" | |
| }, | |
| [theme.breakpoints.down("sm")]: { | |
| maxWidth: "70%" | |
| }, | |
| overflowX: "auto" | |
| }, | |
| messageCardFullWidth: { | |
| borderRadius: 8, | |
| marginLeft: theme.spacing(1), | |
| wordBreak: "break-word", | |
| width: "100%", | |
| overflowX: "auto" | |
| }, | |
| messageOfCurrentUserCard: { | |
| backgroundColor: theme.palette.primary.light, | |
| color: theme.palette.getContrastText(theme.palette.primary.light) | |
| }, | |
| cardHeaderRoot: { | |
| paddingBottom: 0, | |
| alignItems: "flex-start" | |
| }, | |
| cardHeaderContent: { | |
| paddingRight: theme.spacing(1), | |
| }, | |
| cardHeaderAction: { | |
| marginRight: -16, | |
| paddingRight: theme.spacing(1) | |
| }, | |
| cardContentRoot: { | |
| paddingTop: 0, | |
| paddingBottom: 0 | |
| }, | |
| cardActionsRoot: { | |
| paddingTop: 0, | |
| float: "right" | |
| }, | |
| undecoratedLink: { | |
| textDecoration: "none", | |
| color: "inherit" | |
| }, | |
| avatarOfCurrentUserContainer: { | |
| [theme.breakpoints.up("lg")]: { | |
| paddingRight: theme.spacing(1), | |
| }, | |
| [theme.breakpoints.down("md")]: { | |
| paddingLeft: theme.spacing(1), | |
| } | |
| }, | |
| avatarContainer: { | |
| paddingRight: theme.spacing(1), | |
| }, | |
| withCode: { | |
| maxWidth: "100%" | |
| } | |
| })); | |
| const _MessagesListItem: FunctionComponent<MessagesListItemProps> = observer(({ | |
| messageId, | |
| fullWidth = false, | |
| onMenuItemClick, | |
| onVisibilityChange | |
| }) => { | |
| const { | |
| entities: { | |
| users: { | |
| findById: findUser | |
| }, | |
| messages: { | |
| findById: findMessage | |
| } | |
| } | |
| } = useStore(); | |
| const {l, dateFnsLocale} = useLocalization(); | |
| const {currentUser} = useAuthorization(); | |
| const routerStore = useRouter(); | |
| const classes = useStyles(); | |
| useEffect(() => { | |
| return () => { | |
| if (onVisibilityChange) { | |
| onVisibilityChange(false); | |
| } | |
| } | |
| }, []) | |
| const message = findMessage(messageId); | |
| const sender = findUser(message.sender); | |
| const createAtLabel = getCreatedAtLabel(message.createdAt, dateFnsLocale); | |
| const color = randomColor({seed: sender.id}); | |
| const avatarLetter = `${sender.firstName[0]}${sender.lastName ? sender.lastName[0] : ""}`; | |
| const sentByCurrentUser = currentUser && currentUser.id === sender.id; | |
| const containsCode = message.text.includes("`"); | |
| const handleMenuItemClick = (menuItemType: MenuItemType): void => { | |
| if (onMenuItemClick) { | |
| onMenuItemClick(menuItemType); | |
| } | |
| }; | |
| return ( | |
| <ReactVisibilitySensor onChange={onVisibilityChange}> | |
| <div className={`${classes.messageListItemWrapper} ${sentByCurrentUser && !fullWidth && classes.messageOfCurrentUserListItemWrapper}`} | |
| id={`message-${messageId}`} | |
| > | |
| <Link store={routerStore} | |
| className={`${classes.undecoratedLink} ${sentByCurrentUser ? classes.avatarOfCurrentUserContainer : classes.avatarContainer}`} | |
| view={Routes.userPage} | |
| params={{slug: sender.slug || sender.id}} | |
| > | |
| <Avatar avatarLetter={avatarLetter} | |
| avatarColor={color} | |
| avatarId={sender.avatarId} | |
| /> | |
| </Link> | |
| <Card className={`${fullWidth ? classes.messageCardFullWidth : classes.messageCard} ${sentByCurrentUser && classes.messageOfCurrentUserCard} ${containsCode && classes.withCode}`}> | |
| <CardHeader title={ | |
| <Link store={routerStore} | |
| className={classes.undecoratedLink} | |
| view={Routes.userPage} | |
| params={{slug: sender.slug || sender.id}} | |
| > | |
| <Typography variant="body1" style={{color}}> | |
| <strong>{sender.firstName} {sender.lastName && sender.lastName}</strong> | |
| </Typography> | |
| </Link> | |
| } | |
| classes={{ | |
| root: classes.cardHeaderRoot, | |
| action: classes.cardHeaderAction, | |
| content: classes.cardHeaderContent | |
| }} | |
| action={<MessageMenu messageId={messageId} onMenuItemClick={handleMenuItemClick}/>} | |
| /> | |
| <CardContent classes={{ | |
| root: classes.cardContentRoot | |
| }}> | |
| <ReferredMessageContent messageId={message.referredMessageId}/> | |
| {message.deleted | |
| ? <i>{l("message.deleted")}</i> | |
| : ( | |
| <MarkdownTextWithEmoji text={message.text} | |
| emojiData={message.emoji} | |
| /> | |
| ) | |
| } | |
| </CardContent> | |
| <CardActions classes={{ | |
| root: classes.cardActionsRoot | |
| }}> | |
| <Typography variant="caption" color="textSecondary"> | |
| {createAtLabel} | |
| {message.updatedAt && ( | |
| <Tooltip title={l("message.updated-at", {updatedAt: getCreatedAtLabel(message.updatedAt, dateFnsLocale)})}> | |
| <span> | |
| , | |
| {" "} | |
| <Edit fontSize="inherit"/> | |
| {l("message.edited")} | |
| </span> | |
| </Tooltip> | |
| )} | |
| </Typography> | |
| </CardActions> | |
| </Card> | |
| </div> | |
| </ReactVisibilitySensor> | |
| ) | |
| }); | |
| export const MessagesListItem = memo(_MessagesListItem); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment