diff --git a/packages/common/src/models/Chat.ts b/packages/common/src/models/Chat.ts new file mode 100644 index 00000000000..f119541d099 --- /dev/null +++ b/packages/common/src/models/Chat.ts @@ -0,0 +1,8 @@ +import type { ChatMessage } from '@audius/sdk' + +import { Status } from 'models' + +export type ChatMessageWithExtras = ChatMessage & { + status?: Status + hasTail: boolean +} diff --git a/packages/common/src/models/index.ts b/packages/common/src/models/index.ts index d7dd467372a..f64ba361b73 100644 --- a/packages/common/src/models/index.ts +++ b/packages/common/src/models/index.ts @@ -3,6 +3,7 @@ export * from './AudioRewards' export * from './BadgeTier' export * from './Cache' export * from './Chain' +export * from './Chat' export * from './Client' export * from './Collectible' export * from './CollectibleState' diff --git a/packages/common/src/store/pages/chat/selectors.ts b/packages/common/src/store/pages/chat/selectors.ts index b75fd344e39..a34159ebb99 100644 --- a/packages/common/src/store/pages/chat/selectors.ts +++ b/packages/common/src/store/pages/chat/selectors.ts @@ -13,7 +13,11 @@ const { getUsers } = cacheUsersSelectors const { selectById: selectChatById, selectAll: selectAllChats } = chatsAdapter.getSelectors((state) => state.pages.chat.chats) -const { selectAll: getAllChatMessages } = chatMessagesAdapter.getSelectors() +const { + selectAll: getAllChatMessages, + selectById, + selectIds: getChatMessageIds +} = chatMessagesAdapter.getSelectors() export const getChat = selectChatById @@ -106,3 +110,23 @@ export const getOtherChatUsers = (state: CommonState, chatId?: string) => { const chat = getChat(state, chatId) return getOtherChatUsersFromChat(state, chat) } + +export const getChatMessageByIndex = ( + state: CommonState, + chatId: string, + messageIndex: number +) => { + const chatMessagesState = state.pages.chat.messages[chatId] + const messageIds = getChatMessageIds(chatMessagesState) + const messageIdAtIndex = messageIds[messageIndex] + return selectById(chatMessagesState, messageIdAtIndex) +} + +export const getChatMessageById = ( + state: CommonState, + chatId: string, + messageId: string +) => { + const chatMessagesState = state.pages.chat.messages[chatId] + return selectById(chatMessagesState, messageId) +} diff --git a/packages/common/src/store/pages/chat/slice.ts b/packages/common/src/store/pages/chat/slice.ts index f5541f47377..6c5870b27a4 100644 --- a/packages/common/src/store/pages/chat/slice.ts +++ b/packages/common/src/store/pages/chat/slice.ts @@ -15,7 +15,8 @@ import { } from '@reduxjs/toolkit' import dayjs from 'dayjs' -import { ID, Status } from 'models' +import { ID, Status, ChatMessageWithExtras } from 'models' +import { hasTail } from 'utils/chatUtils' import { encodeHashId } from 'utils/hashIds' type UserChatWithMessagesStatus = UserChat & { @@ -23,10 +24,6 @@ type UserChatWithMessagesStatus = UserChat & { messagesSummary?: TypedCommsResponse['summary'] } -type ChatMessageWithSendStatus = ChatMessage & { - status?: Status -} - type ChatState = { chats: EntityState & { status: Status @@ -34,7 +31,7 @@ type ChatState = { } messages: Record< string, - EntityState & { + EntityState & { status?: Status summary?: TypedCommsResponse['summary'] } @@ -69,11 +66,10 @@ const { selectById: getChat } = chatsAdapter.getSelectors( const messageSortComparator = (a: ChatMessage, b: ChatMessage) => dayjs(a.created_at).isBefore(dayjs(b.created_at)) ? 1 : -1 -export const chatMessagesAdapter = - createEntityAdapter({ - selectId: (message) => message.message_id, - sortComparer: messageSortComparator - }) +export const chatMessagesAdapter = createEntityAdapter({ + selectId: (message) => message.message_id, + sortComparer: messageSortComparator +}) const { selectById: getMessage } = chatMessagesAdapter.getSelectors() @@ -161,7 +157,22 @@ const slice = createSlice({ id: chatId, changes: { messagesStatus: Status.SUCCESS, messagesSummary: summary } }) - chatMessagesAdapter.upsertMany(state.messages[chatId], data) + const messagesWithTail = data.map((item, index) => { + return { ...item, hasTail: hasTail(item, data[index - 1]) } + }) + // Recalculate hasTail for latest message of new batch + if (state.messages[chatId].ids.length > 0) { + const prevEarliestMessageId = + state.messages[chatId].ids[state.messages[chatId].ids.length - 1] + const prevEarliestMessage = + state.messages[chatId].entities[prevEarliestMessageId] + const newLatestMessage = messagesWithTail[0] + newLatestMessage.hasTail = hasTail( + newLatestMessage, + prevEarliestMessage + ) + } + chatMessagesAdapter.upsertMany(state.messages[chatId], messagesWithTail) }, fetchMoreMessagesFailed: ( state, @@ -209,7 +220,8 @@ const slice = createSlice({ reactions: [], message: '', sender_user_id: '', - created_at: '' + created_at: '', + hasTail: false }) const existingMessage = getMessage(state.messages[chatId], messageId) const existingReactions = existingMessage?.reactions ?? [] @@ -289,8 +301,22 @@ const slice = createSlice({ ) => { // triggers saga to get chat if not exists const { chatId, message, status } = action.payload + + // Recalculate hasTail of previous message + const prevLatestMessageId = state.messages[chatId].ids[0] + const prevLatestMessage = + state.messages[chatId].entities[prevLatestMessageId] + if (prevLatestMessage) { + const prevMsgHasTail = hasTail(prevLatestMessage, message) + chatMessagesAdapter.updateOne(state.messages[chatId], { + id: prevLatestMessageId, + changes: { hasTail: prevMsgHasTail } + }) + } + chatMessagesAdapter.upsertOne(state.messages[chatId], { ...message, + hasTail: true, status: status ?? Status.IDLE }) chatsAdapter.updateOne(state.chats, { diff --git a/packages/mobile/src/screens/chat-screen/ChatMessageListItem.tsx b/packages/mobile/src/screens/chat-screen/ChatMessageListItem.tsx index 93d2a6b6261..17c8396cb18 100644 --- a/packages/mobile/src/screens/chat-screen/ChatMessageListItem.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatMessageListItem.tsx @@ -1,12 +1,12 @@ -import { forwardRef } from 'react' +import { memo, useCallback } from 'react' -import type { ReactionTypes } from '@audius/common' +import type { ReactionTypes, ChatMessageWithExtras } from '@audius/common' import { accountSelectors, decodeHashId, formatMessageDate } from '@audius/common' -import type { ChatMessage, ChatMessageReaction } from '@audius/sdk' +import type { ChatMessageReaction } from '@audius/sdk' import type { ViewStyle, StyleProp } from 'react-native' import { View } from 'react-native' import { TouchableWithoutFeedback } from 'react-native-gesture-handler' @@ -128,101 +128,110 @@ const ChatReaction = ({ reaction }: ChatReactionProps) => { } type ChatMessageListItemProps = { - message: ChatMessage - hasTail: boolean - shouldShowReaction?: boolean - shouldShowDate?: boolean + message: ChatMessageWithExtras + isPopup: boolean style?: StyleProp - onLongPress?: () => void + onLongPress?: (id: string) => void + itemsRef?: any } -export const ChatMessageListItem = forwardRef( - (props: ChatMessageListItemProps, refProp) => { - const { - message, - hasTail, - shouldShowReaction = true, - shouldShowDate = true, - style: styleProp, - onLongPress - } = props - const styles = useStyles() - const palette = useThemePalette() +export const ChatMessageListItem = memo(function ChatMessageListItem( + props: ChatMessageListItemProps +) { + const { + message, + isPopup = false, + style: styleProp, + onLongPress, + itemsRef + } = props + const styles = useStyles() + const palette = useThemePalette() - const userId = useSelector(getUserId) - const senderUserId = decodeHashId(message.sender_user_id) - const isAuthor = senderUserId === userId + const userId = useSelector(getUserId) + const senderUserId = decodeHashId(message.sender_user_id) + const isAuthor = senderUserId === userId - return ( - <> - 0 - ? styles.reactionMarginBottom - : null, - styleProp - ]} - > - - - - - - {message.message} - - - {message.reactions?.length > 0 ? ( - <> - {shouldShowReaction ? ( - - {message.reactions.map((reaction, index) => { - return ( - - ) - })} - - ) : null} - - ) : null} - - - - {hasTail ? ( - <> + const handleLongPress = useCallback(() => { + onLongPress?.(message.message_id) + }, [message.message_id, onLongPress]) + + return ( + <> + 0 + ? styles.reactionMarginBottom + : null, + styleProp + ]} + > + + + (itemsRef.current[message.message_id] = el) + : null + } > - - + + {message.message} + - {shouldShowDate ? ( - - - {formatMessageDate(message.created_at)} - - + {message.reactions?.length > 0 ? ( + <> + {!isPopup ? ( + + {message.reactions.map((reaction) => { + return ( + + ) + })} + + ) : null} + ) : null} - - ) : null} + + - - ) - } -) + {message.hasTail ? ( + <> + + + + + {!isPopup ? ( + + + {formatMessageDate(message.created_at)} + + + ) : null} + + ) : null} + + + ) +}) diff --git a/packages/mobile/src/screens/chat-screen/ChatScreen.tsx b/packages/mobile/src/screens/chat-screen/ChatScreen.tsx index a38c49b869e..94ce99ca431 100644 --- a/packages/mobile/src/screens/chat-screen/ChatScreen.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatScreen.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react' +import type { ChatMessageWithExtras } from '@audius/common' import { chatActions, accountSelectors, @@ -8,10 +9,8 @@ import { decodeHashId, encodeHashId, Status, - hasTail, isEarliestUnread } from '@audius/common' -import type { ChatMessage } from '@audius/sdk' import { Portal } from '@gorhom/portal' import { useFocusEffect } from '@react-navigation/native' import { @@ -19,14 +18,14 @@ import { View, Text, KeyboardAvoidingView, - Pressable + Pressable, + FlatList } from 'react-native' import { useDispatch, useSelector } from 'react-redux' import IconKebabHorizontal from 'app/assets/images/iconKebabHorizontal.svg' import IconMessage from 'app/assets/images/iconMessage.svg' -import type { FlatListT } from 'app/components/core' -import { Screen, ScreenContent, FlatList } from 'app/components/core' +import { Screen, ScreenContent } from 'app/components/core' import LoadingSpinner from 'app/components/loading-spinner' import { ProfilePicture } from 'app/components/user' import { UserBadges } from 'app/components/user-badges' @@ -44,7 +43,13 @@ import { ChatTextInput } from './ChatTextInput' import { EmptyChatMessages } from './EmptyChatMessages' import { ReactionPopup } from './ReactionPopup' -const { getChatMessages, getOtherChatUsers, getChat } = chatSelectors +const { + getChatMessages, + getOtherChatUsers, + getChat, + getChatMessageById, + getChatMessageByIndex +} = chatSelectors const { fetchMoreMessages, markChatAsRead } = chatActions const { getUserId } = accountSelectors @@ -150,9 +155,9 @@ export const ChatScreen = () => { const [shouldShowPopup, setShouldShowPopup] = useState(false) const hasScrolledToUnreadTag = useRef(false) - const [popupChatIndex, setPopupChatIndex] = useState(null) - const flatListRef = useRef>(null) - const itemsRef = useRef<(View | null)[]>([]) + const [popupMessageId, setPopupMessageId] = useState('') + const flatListRef = useRef>(null) + const itemsRef = useRef>({}) const composeRef = useRef(null) const chatContainerRef = useRef(null) const messageTop = useRef(0) @@ -169,6 +174,9 @@ export const ChatScreen = () => { const unreadCount = chat?.unread_message_count ?? 0 const isLoading = chat?.messagesStatus === Status.LOADING && chatMessages?.length === 0 + const popupMessage = useSelector((state) => + getChatMessageById(state, chatId, popupMessageId) + ) // A ref so that the unread separator doesn't disappear immediately when the chat is marked as read // Using a ref instead of state here to prevent unwanted flickers. @@ -189,13 +197,7 @@ export const ChatScreen = () => { } }, [chatId, chat]) - useEffect(() => { - // Update refs when switching chats or if more chat messages are fetched. - if (chatMessages) { - itemsRef.current = itemsRef.current.slice(0, chatMessages.length) - } - }, [chatMessages]) - + // Find earliest unread message to display unread tag correctly const earliestUnreadIndex = useMemo( () => chatMessages?.findIndex((item, index) => @@ -209,6 +211,10 @@ export const ChatScreen = () => { ), [chatMessages, userIdEncoded] ) + const earliestUnreadMessage = useSelector((state) => + getChatMessageByIndex(state, chatId, earliestUnreadIndex) + ) + const earliestUnreadMessageId = earliestUnreadMessage?.message_id useEffect(() => { // Scroll to earliest unread index, but only the first time @@ -229,30 +235,28 @@ export const ChatScreen = () => { } }, [earliestUnreadIndex, chatMessages]) - const handleScrollToIndexFailed = useCallback( - (e) => { - setTimeout(() => { - flatListRef.current?.scrollToIndex({ - index: e.index, - viewPosition: 0.95, - animated: false - }) - }, 10) - }, - [flatListRef] - ) + const handleScrollToIndexFailed = useCallback((e) => { + setTimeout(() => { + flatListRef.current?.scrollToIndex({ + index: e.index, + viewPosition: 0.95, + animated: false + }) + }, 10) + }, []) - const handleScrollToTop = () => { - if ( - chatId && - chat?.messagesStatus !== Status.LOADING && - chat?.messagesSummary && - chat?.messagesSummary.prev_count > 0 - ) { + const shouldFetchMoreMessages = + chatId && + chat?.messagesStatus !== Status.LOADING && + chat?.messagesSummary && + chat?.messagesSummary.prev_count > 0 + + const handleScrollToTop = useCallback(() => { + if (shouldFetchMoreMessages) { // Fetch more messages when user reaches the top dispatch(fetchMoreMessages({ chatId })) } - } + }, [chatId, dispatch, shouldFetchMoreMessages]) // Mark chat as read when user navigates away from screen useFocusEffect( @@ -275,14 +279,11 @@ export const ChatScreen = () => { const closeReactionPopup = useCallback(() => { setShouldShowPopup(false) - setPopupChatIndex(null) - }, [setShouldShowPopup, setPopupChatIndex]) + setPopupMessageId('') + }, [setShouldShowPopup, setPopupMessageId]) - const handleMessagePress = async (index: number) => { - if (index < 0 || index >= chatMessages.length) { - return - } - const messageRef = itemsRef.current[index] + const handleMessagePress = useCallback(async (id: string) => { + const messageRef = itemsRef.current[id] if (messageRef === null || messageRef === undefined) { return } @@ -295,9 +296,9 @@ export const ChatScreen = () => { }) // Need to subtract spacing(2) to account for padding in message View. messageTop.current = messageY - spacing(2) - setPopupChatIndex(index) + setPopupMessageId(id) setShouldShowPopup(true) - } + }, []) const topBarRight = ( { /> ) + // When reaction popup opens, hide reaction here so it doesn't + // appear underneath the reaction of the message clone inside the + // portal. + const renderItem = useCallback( + ({ item }) => ( + <> + + {item.message_id === earliestUnreadMessageId ? ( + + + + {unreadCount} {pluralize(messages.newMessage, unreadCount > 1)} + + + + ) : null} + + ), + [ + earliestUnreadMessageId, + handleMessagePress, + styles.unreadSeparator, + styles.unreadTag, + styles.unreadTagContainer, + unreadCount + ] + ) + + const maintainVisibleContentPosition = useMemo( + () => ({ + minIndexForVisible: 0, + autoscrollToTopThreshold: + (chatContainerBottom.current - chatContainerTop.current) / 4 + }), + [] + ) + return ( { {/* Everything inside the portal displays on top of all other screen contents. */} - {shouldShowPopup && popupChatIndex !== null ? ( + {shouldShowPopup && popupMessageId && popupMessage ? ( ) : null} @@ -376,43 +412,16 @@ export const ChatScreen = () => { contentContainerStyle={styles.listContentContainer} data={chatMessages} keyExtractor={(message) => message.message_id} - renderItem={({ item, index }) => ( - <> - {/* When reaction popup opens, hide reaction here so it doesn't - appear underneath the reaction of the message clone inside the - portal. */} - (itemsRef.current[index] = el)} - shouldShowReaction={index !== popupChatIndex} - hasTail={hasTail(item, chatMessages[index - 1])} - onLongPress={() => handleMessagePress(index)} - /> - {index === earliestUnreadIndex ? ( - - - - {unreadCount}{' '} - {pluralize(messages.newMessage, unreadCount > 1)} - - - - ) : null} - - )} + renderItem={renderItem} onEndReached={handleScrollToTop} inverted initialNumToRender={chatMessages?.length} ref={flatListRef} onScrollToIndexFailed={handleScrollToIndexFailed} refreshing={chat?.messagesStatus === Status.LOADING} - maintainVisibleContentPosition={{ - minIndexForVisible: 0, - autoscrollToTopThreshold: - (chatContainerBottom.current - - chatContainerTop.current) / - 4 - }} + maintainVisibleContentPosition={ + maintainVisibleContentPosition + } /> ) : ( diff --git a/packages/mobile/src/screens/chat-screen/ReactionPopup.tsx b/packages/mobile/src/screens/chat-screen/ReactionPopup.tsx index df1e2561aed..5703a8780ba 100644 --- a/packages/mobile/src/screens/chat-screen/ReactionPopup.tsx +++ b/packages/mobile/src/screens/chat-screen/ReactionPopup.tsx @@ -1,8 +1,11 @@ import { useRef, useCallback, useEffect } from 'react' -import type { Nullable, ReactionTypes } from '@audius/common' +import type { + ChatMessageWithExtras, + Nullable, + ReactionTypes +} from '@audius/common' import { chatActions, encodeHashId, accountSelectors } from '@audius/common' -import type { ChatMessage } from '@audius/sdk' import { View, Dimensions, Pressable, Animated } from 'react-native' import { useDispatch, useSelector } from 'react-redux' @@ -68,9 +71,8 @@ type ReactionPopupProps = { chatId: string messageTop: number containerBottom: number - hasTail: boolean isAuthor: boolean - message: ChatMessage + message: ChatMessageWithExtras closePopup: () => void } @@ -78,7 +80,6 @@ export const ReactionPopup = ({ chatId, messageTop, containerBottom, - hasTail, isAuthor, message, closePopup @@ -114,7 +115,7 @@ export const ReactionPopup = ({ }, [beginAnimation]) const handleReactionSelected = useCallback( - (message: Nullable, reaction: ReactionTypes) => { + (message: Nullable, reaction: ReactionTypes) => { if (userId && message) { dispatch( setMessageReaction({ @@ -160,9 +161,7 @@ export const ReactionPopup = ({ null} - shouldShowDate={false} + isPopup={true} style={[ styles.popupChatMessage, {