Skip to content

Instantly share code, notes, and snippets.

@PaperPlane01
Last active September 5, 2020 02:25
Show Gist options
  • Select an option

  • Save PaperPlane01/d8bc7fb9dcff06cc86fba220d0d9417d to your computer and use it in GitHub Desktop.

Select an option

Save PaperPlane01/d8bc7fb9dcff06cc86fba220d0d9417d to your computer and use it in GitHub Desktop.
react-virtuoso keep scroll position
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>
)
}
});
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