Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to implement a React infinite scroll component for a chat app like Facebook Messenger?

I know there are many questions about this topic (React infinite scroll), my question aims to go more in-depth in order to identify the best currently available solution to implement such a component.

I am working on a chat app and I have created a component similar to the Facebook's Messenger chat window which you can see on desktop browsers.

Facebook:

enter image description here

Mine (so far):

enter image description here enter image description here

Implementing the infinite scroll with infinite loading turns out to be tricky. From a UX perspective, I need to always satisfy at least the following properties:

  1. The height of each row message should be dynamically computed just-in-time because I do not know the height of the message in advance as they do not have a fixed height;
  2. Whenever a user types a new message, the scroll must automatically reach the bottom of the scrollable component to the last just sent message. The scrollable component itself has a top and bottom padding (or I can also use a margin) in order to leave some space between the top and the first and the bottom and the last message of the chat (look at the above images);
  3. The chat is inside a popover element which opens with a fade-in animation and it can be closed and opened by the user while they are using the page;

Now, in order to do that, I have already tried several libraries:

  • react-infinite: my first attempt, abandoned because it needs to know the heights of all the elements in advance;
  • react-list: I found it really powerful, the thing is that if I close my popover and reopen it after sometimes it loses some already rendered messages and it seems to me that it could be a bug of the react-list component. Also, the component does not allow me to display the scrolling bottom upwards (see https://github.com/coderiety/react-list/issues/50);
  • react-virtualized: very powerful, but I found it tricky to use List with an InfiniteLoader together with AutoSizer, CellMeasurer and CellMeasurerCache. Also, as I send a message if I call List.scrollToIndex(lastIndex) to scroll automatically the container to the bottom the scroll does not reach the bottom completely, as the scrollable container has top and bottom padding. I couldn't achieve a satisfiable result with this component.
  • react-infinite-any-height: I would like to give it a try, but currently it seems that it hasn't been ported to React 16 yet if I install it NPM warns me about an unsatisfied peer dependency of React 15, but I use React 16.

So my question is more a way to confront each other: have someone of you ever had to implement a React chat component with the 3 requirements I have written above? What library did you use? As Facebook Messenger handles this pretty well and they use React, do someone of you know how did they implement such a component? If I inspect the chat messages of the Facebook chat window it seems that it keeps all the already rendered messages in the DOM. But, if so, couldn't this affect performance?

So I have more questions than answers for now. I would really like to find a component that suits my needs. The other option would be to implement my own.

like image 873
tonix Avatar asked Jul 10 '19 10:07

tonix


1 Answers

UPDATE 2022

I have created an infinite scroll React component called react-really-simple-infinite-scroll, you can find it on GitHub (https://github.com/tonix-tuft/react-really-simple-infinite-scroll) and install it with npm (https://www.npmjs.com/package/react-really-simple-infinite-scroll):

npm install --save react-really-simple-infinite-scroll
npm install --save react react-dom # install React peer deps

Usage:

import React, { useState, useCallback, useEffect } from "react";
import { ReallySimpleInfiniteScroll } from "react-really-simple-infinite-scroll";

// You can use any loading component you want. This is just an example using a spinner from "react-spinners-kit".
import { CircleSpinner } from "react-spinners-kit";

/**
 * @type {number}
 */
let itemId = 0;

/**
 * @type {Function}
 */
const generateMoreItems = numberOfItemsToGenerate => {
  const items = [];
  for (let i = 0; i < numberOfItemsToGenerate; i++) {
    itemId++;
    items.push({
      id: itemId,
      label: `Item ${itemId}`,
    });
  }
  return items;
};

export default function App() {
  const [displayInverse, setDisplayInverse] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const [isInfiniteLoading, setIsInfiniteLoading] = useState(true);
  const [items, setItems] = useState([]);

  const onInfiniteLoadCallback = useCallback(() => {
    setIsInfiniteLoading(true);
    setTimeout(() => {
      const moreItems = generateMoreItems(25);
      setItems(items => items.concat(moreItems));
      setIsInfiniteLoading(false);
    }, 1000);
  }, []);

  useEffect(() => {
    onInfiniteLoadCallback();
  }, [onInfiniteLoadCallback]);

  useEffect(() => {
    if (items.length >= 200) {
      setHasMore(false);
    }
  }, [items.length]);

  return (
    <div className="app">
      <ReallySimpleInfiniteScroll
        key={displayInverse}
        className={`infinite-scroll ${
          items.length && displayInverse
            ? "display-inverse"
            : "display-not-inverse"
        }`}
        hasMore={hasMore}
        length={items.length}
        loadingComponent={
          <div className="loading-component">
            <div className="spinner">
              <CircleSpinner size={20} />
            </div>{" "}
            <span className="loading-label">Loading...</span>
          </div>
        }
        isInfiniteLoading={isInfiniteLoading}
        onInfiniteLoad={onInfiniteLoadCallback}
        displayInverse={displayInverse}
      >
        {(displayInverse ? items.slice().reverse() : items).map(item => (
          <div key={item.id} className="item">
            {item.label}
          </div>
        ))}
      </ReallySimpleInfiniteScroll>
      <div>
        <button
          onClick={() => setDisplayInverse(displayInverse => !displayInverse)}
        >
          Toggle displayInverse
        </button>
      </div>
    </div>
  );
}

ORIGINAL ANSWER:

I ended up implementing my own very simple infinite scroll component (didn't refactor it to use hooks yet, though):


import React from "react";
import {
    isUndefined,
    hasVerticalScrollbar,
    hasHorizontalScrollbar,
    isInt,
    debounce
} from "js-utl";
import { classNames } from "react-js-utl/utils";

export default class SimpleInfiniteScroll extends React.Component {
    constructor(props) {
        super(props);

        this.handleScroll = this.handleScroll.bind(this);
        this.onScrollStop = debounce(this.onScrollStop.bind(this), 100);

        this.itemsIdsRefsMap = {};
        this.isLoading = false;
        this.isScrolling = false;
        this.lastScrollStopPromise = null;
        this.lastScrollStopPromiseResolve = null;

        this.node = React.createRef();
    }

    componentDidMount() {
        this.scrollToStart();
    }

    getNode() {
        return this.node && this.node.current;
    }

    getSnapshotBeforeUpdate(prevProps) {
        if (prevProps.children.length < this.props.children.length) {
            const list = this.node.current;
            const axis = this.axis();
            const scrollDimProperty = this.scrollDimProperty(axis);
            const scrollProperty = this.scrollProperty(axis);
            const scrollDelta = list[scrollDimProperty] - list[scrollProperty];

            return {
                scrollDelta
            };
        }
        return null;
    }

    componentDidUpdate(prevProps, prevState, snapshot) {
        if (
            this.isLoading &&
            ((prevProps.isInfiniteLoading && !this.props.isInfiniteLoading) ||
                ((this.props.hasMore || prevProps.hasMore) &&
                    prevProps.children.length !==
                        this.props.children.length)) &&
            snapshot
        ) {
            if (this.props.displayInverse) {
                const list = this.node.current;
                const axis = this.axis();
                const scrollDimProperty = this.scrollDimProperty(axis);
                const scrollProperty = this.scrollProperty(axis);
                const scrollDelta = snapshot.scrollDelta;
                const scrollTo = list[scrollDimProperty] - scrollDelta;

                this.scrollTo(scrollProperty, scrollTo);
            }
            this.isLoading = false;
        }
    }

    loadingComponentRenderer() {
        const { loadingComponent } = this.props;

        return (
            <div
                className="simple-infinite-scroll-loading-component"
                key={-2}
            >
                {loadingComponent}
            </div>
        );
    }

    axis() {
        return this.props.axis === "x" ? "x" : "y";
    }

    scrollProperty(axis) {
        return axis === "y" ? "scrollTop" : "scrollLeft";
    }

    offsetProperty(axis) {
        return axis === "y" ? "offsetHeight" : "offsetWidth";
    }

    clientDimProperty(axis) {
        return axis === "y" ? "clientHeight" : "clientWidth";
    }

    scrollDimProperty(axis) {
        return axis === "y" ? "scrollHeight" : "scrollWidth";
    }

    hasScrollbarFunction(axis) {
        return axis === "y" ? hasVerticalScrollbar : hasHorizontalScrollbar;
    }

    scrollToStart() {
        const axis = this.axis();
        this.scrollTo(
            this.scrollProperty(axis),
            !this.props.displayInverse ? 0 : this.scrollDimProperty(axis)
        );
    }

    scrollToEnd() {
        const axis = this.axis();
        this.scrollTo(
            this.scrollProperty(axis),
            !this.props.displayInverse ? this.scrollDimProperty(axis) : 0
        );
    }

    scrollTo(scrollProperty, scrollPositionOrPropertyOfScrollable) {
        const scrollableContentNode = this.node.current;
        if (scrollableContentNode) {
            scrollableContentNode[scrollProperty] = isInt(
                scrollPositionOrPropertyOfScrollable
            )
                ? scrollPositionOrPropertyOfScrollable
                : scrollableContentNode[scrollPositionOrPropertyOfScrollable];
        }
    }

    scrollToId(id) {
        if (this.itemsIdsRefsMap[id] && this.itemsIdsRefsMap[id].current) {
            this.itemsIdsRefsMap[id].current.scrollIntoView();
        }
    }

    scrollStopPromise() {
        return (
            (this.isScrolling && this.lastScrollStopPromise) ||
            Promise.resolve()
        );
    }

    onScrollStop(callback) {
        callback();
        this.isScrolling = false;
        this.lastScrollStopPromise = null;
        this.lastScrollStopPromiseResolve = null;
    }

    handleScroll(e) {
        const {
            isInfiniteLoading,
            hasMore,
            infiniteLoadBeginEdgeOffset,
            displayInverse
        } = this.props;

        this.isScrolling = true;
        this.lastScrollStopPromise =
            this.lastScrollStopPromise ||
            new Promise(resolve => {
                this.lastScrollStopPromiseResolve = resolve;
            });
        this.onScrollStop(() => {
            this.lastScrollStopPromiseResolve &&
                this.lastScrollStopPromiseResolve();
        });

        this.props.onScroll && this.props.onScroll(e);

        if (
            this.props.onInfiniteLoad &&
            (!isUndefined(hasMore) ? hasMore : !isInfiniteLoading) &&
            this.node.current &&
            !this.isLoading
        ) {
            const axis = this.axis();
            const scrollableContentNode = this.node.current;
            const scrollProperty = this.scrollProperty(axis);
            const offsetProperty = this.offsetProperty(axis);
            const scrollDimProperty = this.scrollDimProperty(axis);
            const currentScroll = scrollableContentNode[scrollProperty];
            const currentDim = scrollableContentNode[offsetProperty];
            const scrollDim = scrollableContentNode[scrollDimProperty];

            const finalInfiniteLoadBeginEdgeOffset = !isUndefined(
                infiniteLoadBeginEdgeOffset
            )
                ? infiniteLoadBeginEdgeOffset
                : currentDim / 2;

            let thresoldWasReached = false;
            if (!displayInverse) {
                const clientDimProperty = this.clientDimProperty(axis);
                const clientDim = scrollableContentNode[clientDimProperty];
                thresoldWasReached =
                    currentScroll +
                        clientDim +
                        finalInfiniteLoadBeginEdgeOffset >=
                    scrollDim;
            } else {
                thresoldWasReached =
                    currentScroll <= finalInfiniteLoadBeginEdgeOffset;
            }
            if (thresoldWasReached) {
                this.isLoading = true;
                this.props.onInfiniteLoad();
            }
        }
    }

    render() {
        const {
            children,
            displayInverse,
            isInfiniteLoading,
            className,
            hasMore
        } = this.props;

        return (
            <div
                className={classNames("simple-infinite-scroll", className)}
                ref={this.node}
                onScroll={this.handleScroll}
                onMouseOver={this.props.onInfiniteScrollMouseOver}
                onMouseOut={this.props.onInfiniteScrollMouseOut}
                onMouseEnter={this.props.onInfiniteScrollMouseEnter}
                onMouseLeave={this.props.onInfiniteScrollMouseLeave}
            >
                {(hasMore || isInfiniteLoading) &&
                    displayInverse &&
                    this.loadingComponentRenderer()}
                {children}
                {(hasMore || isInfiniteLoading) &&
                    !displayInverse &&
                    this.loadingComponentRenderer()}
            </div>
        );
    }
}

And in this.props.children I pass it an array of React elements of the following component's class which extends React.PureComponent:

...

export default class ChatMessage extends React.PureComponent {
    ...
}

This way, when re-rendering, only the components that have changed since the last render are re-rendered.

I have also used an immutable data structure to store the collection of the chat messages, in particularly immutable-linked-ordered-map (https://github.com/tonix-tuft/immutable-linked-ordered-map) which allows me to achieve O(1) time complexity for insertions, removals and updates of a message as well as almost O(1) time complexity for lookups. Essentially, ImmutableLinkedOrderedMap is an ordered immutable map, like associative arrays in PHP, but immutable:


const map = new ImmutableLinkedOrderedMap({
    mode: ImmutableLinkedOrderedMap.MODE.MULTIWAY,
    initialItems: [
        {
            id: 1, // <--- "[keyPropName] === 'id'"
            text: "Message text",
            // ...
        },
        {
            id: 2,
            text: "Another message text",
            // ...
        },
        // ...
    ]
})
map.get(2) // Will return: { id: 2, text: "Another message text", /* ... */ }
const newMessage = { id: 3, text: "Yet another message text", /* ... */ };
const newMap = map.set(newMessage);

console.log(map !== newMap); // true
console.log(map.length); // 2
console.log(newMap.length); // 3

let messages = newMap.replace(3, newMessage)
console.log(messages === newMap); // true, because newMessage with ID 3 didn't change
messages = newMap.replace(3, { ...newMessage, read: true })
console.log(messages === newMap); // false


Then, when I render the messages stored in the map, I simply call its .values() method which returns an array and I map that array to render the messages, e.g.:


<SimpleInfiniteScroll>
    {messages.values().map((message) => <ChatMessage ... />)}
</SimpleInfiniteScroll>

like image 84
tonix Avatar answered Oct 04 '22 00:10

tonix