Search code examples
reactjsreact-infinite-scroll-component

How to get the index of items visible in the viewport?


folks, I am building a chat app. Currently, I am working on a feature in which when the user scrolls a sticky date is shown to the top, based on the date of the messages visible in the viewport area. But I don't know how to get the index of the last item visible in the viewport.

Here is the codesandbox

Here is the code

const MessageComponent = () => {
  const { id } = useParams();
  const {
    data: messageData,
    isSuccess,
    isError,
    isLoading,
    isFetching,
    hasMoreData,
    firstData,
    isUninitialized,
  } = useGetData(id);

  const { loadMore } = useGetScrollData(messageData, id);

  const user = User();

  const chatId =
    messageData[0]?.type == "date"
      ? messageData[1]?.chat?._id
      : messageData[0]?.chat?._id;
  const islastItemChatId: boolean = messageData.length > 0 && chatId != id;
  const scrollRef = useScrollRef(islastItemChatId, id);

  const scrollFunc = (e: any) => {
    // let m = e.target.scrollHeight + e.target.scrollTop;
    // let i = e.target.scrollHeight - m;
    // console.log({ i, e });
    if (!scrollRef.current) return;
    const containerMiddle =
      scrollRef.current.scrollTop +
      scrollRef.current.getBoundingClientRect().height / 2;
    const infiniteScrollItems = scrollRef.current.children[0].children;
    console.log({ containerMiddle, infiniteScrollItems, e: e.target });
  };

  return (
    <>
      {messageData.length > 0 && (
        <div
          id="scrollableDiv"
          style={{
            height: "80%",
            overflow: "auto",
            display: "flex",
            flexDirection: "column-reverse",
            padding: "10px 0px",
          }}
          ref={scrollRef}
        >
          <InfiniteScroll
            dataLength={messageData.length}
            hasMore={hasMoreData}
            onScroll={scrollFunc}
            loader={
              <div className="loading-container">
                <div className="lds-ring">
                  <div></div>
                </div>
              </div>
            }
            endMessage={
              <div className="message-container date">
                <div className={`text-container sender large-margin`}>
                  <span>You have seen all the messages</span>
                </div>
              </div>
            }
            style={{ display: "flex", flexDirection: "column-reverse" }}
            next={loadMore}
            inverse={true}
            scrollableTarget="scrollableDiv"
          >
            {messageData.map((item, index: number) => {
              const isUserChat = item?.sender?._id === user._id;
              const className =
                item?.type == "date"
                  ? "date"
                  : isUserChat
                  ? "user-message"
                  : "sender-message";

              const prevItem: IMessageData | null =
                index < messageData?.length ? messageData[index - 1] : null;

              const nextItem: IMessageData | null =
                index < messageData?.length ? messageData[index + 1] : null;

              return (
                <Message
                  key={item._id}
                  item={item}
                  prevItem={prevItem}
                  className={className}
                  isUserChat={isUserChat}
                  index={index}
                  nextItem={nextItem}
                />
              );
            })}
          </InfiniteScroll>
        </div>
      )}
    </>
  );
};


Solution

  • The recommended way to check the visibility of an element is using the Intersection Observer API.

    The lib react-intersection-observer can help you to observe all the elements and map the indexes with some states.

    However, for your use case, you can minimize the element to observe by only observing the visibility of where a new date starts and where the last message is for that date and setting the stickiness of the date label accordingly.

    I implemented the solution here https://codesandbox.io/s/sticky-date-chat-ykmlx2?file=/src/App.js

    I built an UI with this structure

    ------
    marker -> observed element that signals when the block of a date starts going up
    ------
    date label -> a label to show the date which has conditional stickiness (position: sticky)
    ------
    date messages -> the block of messages for the same date
    ------
    last date message -> observe the last message to know if a block of dates is still  visible
    ------
    

    This logic is contained in the MessagesBlock component

    const MessagesBlock = ({ block }) => {
      const [refDateMarker, inViewDateMarker] = useInView();
      const [refLastMessage, inViewLastMessage, entryLastMessage] = useInView();
      let styles = {};
      // make the date sticky when the marker has already gone up
      // and the last message is either visible or hasn't reached the viewport yet
      if (
        !inViewDateMarker &&
        (inViewLastMessage ||
          (!inViewLastMessage && entryLastMessage?.boundingClientRect?.y > 0))
      ) {
        styles = { position: "sticky", top: 0 };
      }
    
      return (
        <>
          <div ref={refDateMarker} style={{ height: 1 }} />
          <div
            style={{
              ...styles,
              // some more styles omitted for brevity
            }}
          >
            {dayjs(block[0].createdAt).format("DD/MM/YYYY")}
          </div>
          {block.map((item, index) => {
            ...
            return (
              <Message
                ref={index === block.length - 1 ? refLastMessage : null}
                key={item._id}
                item={item}
                className={className}
                isUserChat={isUserChat}
                index={index}
              />
            );
          })}
        </>
      );
    };
    

    Of course, to achieve this, I had to change the data a little. I used the groupby function provided by lodash

    import groupBy from "lodash.groupby";
    ...
    const byDate = groupBy(data, (item) =>
      dayjs(item.createdAt).format("DD/MM/YYYY")
    );
    

    And render the blocks in the scroll container

        <InfiniteScroll
         ...
        >
          {Object.entries(byDate).map(([date, block]) => (
            <MessagesBlock block={block} />
          ))}
        </InfiniteScroll>
    

    I had to make some style changes and omit some details from your version, but I hope you can add them to my provided solution.