Skip to content

Instantly share code, notes, and snippets.

@SomiDivian
Last active April 9, 2023 18:35
Show Gist options
  • Save SomiDivian/68d58b8dddf2b1503097898b464c3e07 to your computer and use it in GitHub Desktop.
Save SomiDivian/68d58b8dddf2b1503097898b464c3e07 to your computer and use it in GitHub Desktop.
const ConversationContainer = ({
conversation,
onClose,
client,
count,
}: {
conversation: Conversation;
onClose?: () => void;
client: Client;
/** unread messages count */
count?: number | null;
}) => {
// ⚙️ App State
const { classes, cx } = useStyles({});
const user = useUser()
const { messages, getMessages, setMessages } = useMessage();
// ⚙️ Setup Virtualizer
const ref = useRef<VirtuosoHandle>(null);
const scrollerRef = useRef<HTMLElement>();
const INITIAL_ITEM_COUNT = 25;
// ⚙️ Setup Twilio
// 💡 We need to get `prevPage()` and `hasPrevPage` along the way
const [data, setData] = useState<Paginator<Message> | null>(null);
const [loading, setLoading] = useState(false);
const [typing, setTyping] = useState<string[]>([]);
// ⚙️ Other Setup
// 💡 if true we show affix
const [atBottom, setAtBottom] = useState(true);
// ^ we scrollToBottom by default
const [bannerMessage, setBannerMessage] = useState<Date | null>(null);
// ⚙️ First load
const { isLoading } = useQuery(
["messages-" + conversation.sid],
async () => {
const data = await conversation?.getMessages(INITIAL_ITEM_COUNT);
setData(data);
setMessages(data.items);
// 💡 No need to scrollToBottom, we've set `initialTopMostItemIndex`
// 💡 No need to setFirstItemIndex(), we initialized it with 0
return data;
},
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
enabled: !!conversation,
}
);
// 🌟 handle new messages
useIsomorphicLayoutEffect(() => {
const newMessageListener = (message: Message) => {
if (message.conversation.sid !== conversation.sid) return;
const messages = getMessages();
// needed in dev-mode
if (messages.find((m) => m.sid === message.sid)) return;
setMessages(messages.concat(message));
const meAuthor = message.author === user.id
if (atBottom || meAuthor) {
setTimeout(async () => {
ref.current?.scrollToIndex({
index: messages.length,
align: "end",
});
await conversation.setAllMessagesRead();
}, 1000);
}
};
const updatedMessageListener = (updated: MessageUpdatedEventArgs) => {
const message = updated.message;
if (message.conversation.sid !== conversation.sid) return;
const messages = getMessages();
const index = messages.findIndex((m) => m.sid === message.sid);
if (index === -1) return;
messages[index] = message;
setMessages(messages);
};
const typingStartedListener = (participant: Participant) => {
setTyping((prev) => {
if (prev.includes(participant.identity || "")) return prev;
return [...prev, participant.identity || ""];
});
};
const typingEndedListener = (participant: Participant) => {
setTyping((prev) => {
return prev.filter((p) => p !== (participant.identity || ""));
});
};
client.on("messageAdded", newMessageListener);
client.on("messageUpdated", updatedMessageListener);
client.on("typingStarted", typingStartedListener);
client.on("typingEnded", typingEndedListener);
// because we added `atBottom` as a dependency
return () => {
client.off("messageAdded", newMessageListener);
client.off("messageUpdated", updatedMessageListener);
client.off("typingStarted", typingStartedListener);
client.off("typingEnded", typingEndedListener);
};
}, [client, atBottom]);
// 🌟 handle unread messages count
useIsomorphicLayoutEffect(() => {
if (!count || !atBottom) return;
// ^ means the unread messages not in-view
conversation.setAllMessagesUnread();
// TODO: partially `unread()` as user scrolls down
}, [count, atBottom]);
// ⚙️ Toolkit
const prependItems = useCallback(() => {
if (!data?.hasPrevPage || isLoading || loading) return;
setLoading(true);
data.prevPage().then((_) => {
const newItems = _.items;
const allItems = [...newItems, ...getMessages()];
setMessages(allItems);
setData(_);
// to prevent virtuoso async glitch
setTimeout(() => {
// 💡 We need to scroll to previous firstItemIndex
ref.current?.scrollToIndex({
index: 0 + newItems.length,
behavior: "auto",
offset: 0,
});
setLoading(false);
}, 10);
});
return false;
}, [data, getMessages, isLoading, loading, setMessages]);
const sendMessage = async (message: string) => {
await conversation.sendMessage(message);
};
const sendMedia = async (media: FileWithPath[]) => {
const promises = media.map((m) =>
conversation.sendMessage({
contentType: m.type,
media: m,
filename: m.name,
})
);
await Promise.all(promises);
};
if (isLoading) return null;
return (
<Stack className={cx(classes.root)} pos="relative">
<Header
onClose={() => {
setMessages([]);
if (onClose) onClose();
}}
conversation={conversation}
typing={typing}
/>
<Virtuoso
// ⚙️ Setup
ref={ref}
scrollerRef={(r) => {
if (r instanceof HTMLElement) {
scrollerRef.current = r;
}
}}
style={{
// height: MAX_HEIGHT - 2 * STICKY_HEIGHT,
height: `calc(100vh - ${STICKY_HEIGHT * 2}px)`,
position: "absolute",
top: STICKY_HEIGHT,
left: 0,
right: 0,
bottom: STICKY_HEIGHT,
}}
overscan={0}
// ⚡ start from the last item. i.e scrollToBottom
initialTopMostItemIndex={messages.length - 1}
// ⚡ we will make sure this is `0` all the time
firstItemIndex={0}
totalCount={messages.length}
// ⚡ handle atBottom
atBottomStateChange={(bottom) => {
setAtBottom(bottom);
}}
// ⚡ handle prepend
atTopStateChange={(top) => {
if (!top) return;
// to prevent load on first render
if (
scrollerRef.current?.scrollHeight ===
scrollerRef.current?.clientHeight
)
return;
prependItems();
}}
// ⚡ handle banner
rangeChanged={(range) => {
const firstMessage = messages[range.startIndex];
if (!firstMessage) {
setBannerMessage(null);
return;
} else {
setBannerMessage(firstMessage.dateCreated);
}
}}
itemContent={(idx) => {
const message = messages[idx]!;
return (
<motion.div
style={{
padding: "0 10px",
paddingTop: 16,
}}
initial={{ opacity: 0 }}
animate={{ opacity: loading ? 0 : 1 }}
>
<MessageComponent
key={message.sid}
message={message}
color={color}
/>
</motion.div>
);
}}
/>
{bannerMessage && (
<Badge
size="sm"
color={color}
maw="fit-content"
sx={{
position: "absolute",
top: 8 + STICKY_HEIGHT,
left: 0,
right: 0,
margin: "0 auto",
}}
>
{bannerMessage.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
weekday: "short",
})}
</Badge>
)}
{!atBottom && (
<Box className={classes.atBottom} bottom={10 + 80}>
<Indicator
disabled={(count || 0) <= 0}
inline
label={count}
styles={{
common: {
width: 20,
height: 20,
},
}}
>
<ActionIcon
radius="xl"
size="xl"
variant="default"
p="xs"
onClick={() =>
ref.current?.scrollToIndex({
index: messages.length - 1,
behavior: "auto",
})
}
>
<IconChevronsDown />
</ActionIcon>
</Indicator>
</Box>
)}
<Footer
send={sendMessage}
sendMedia={sendMedia}
indicateTyping={() => conversation.typing()}
/>
</Stack>
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment