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>
)}
</>
);
};
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.