Last active
April 9, 2023 18:35
-
-
Save SomiDivian/68d58b8dddf2b1503097898b464c3e07 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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