From 3df7920fa62c8cb7b74cb9f22904664202072133 Mon Sep 17 00:00:00 2001 From: Pavel Meyer Date: Mon, 13 Nov 2023 00:34:43 +0300 Subject: [PATCH 01/13] CW-2196 Added Chat pagination logic --- .../ChatComponent/ChatComponent.module.scss | 14 +- .../ChatComponent/ChatComponent.tsx | 62 ++++-- .../components/ChatContent/ChatContent.tsx | 15 +- .../hooks/useDiscussionChatAdapter.ts | 33 +-- src/services/DiscussionMessage.ts | 26 +++ .../Chat/ChatMessage/ChatMessage.tsx | 156 +++++--------- .../useCases/useDiscussionMessagesById.ts | 192 ++++++++++++++---- src/shared/models/DiscussionMessage.tsx | 7 + src/store/states/cache/actions.ts | 21 +- src/store/states/cache/constants.ts | 1 - src/store/states/cache/reducer.tsx | 70 ++++--- 11 files changed, 370 insertions(+), 227 deletions(-) diff --git a/src/pages/common/components/ChatComponent/ChatComponent.module.scss b/src/pages/common/components/ChatComponent/ChatComponent.module.scss index 32838bd7c3..4932997b4e 100644 --- a/src/pages/common/components/ChatComponent/ChatComponent.module.scss +++ b/src/pages/common/components/ChatComponent/ChatComponent.module.scss @@ -19,7 +19,6 @@ display: flex; flex-direction: column-reverse; padding: 0.5rem 2rem 0; - padding-bottom: calc(var(--chat-input-wrapper-height) + 7rem); } .emptyChat { @@ -129,3 +128,16 @@ $phone-breakpoint: 415px; font-size: $large; line-height: 2.125rem; } + +.mentionTextCurrentUser { + color: $c-pink-mention-2; + font-weight: 600; +} + +.singleEmojiText { + font-size: $xlarge; +} + +.multipleEmojiText { + font-size: $large; +} diff --git a/src/pages/common/components/ChatComponent/ChatComponent.tsx b/src/pages/common/components/ChatComponent/ChatComponent.tsx index 0abf8d3382..12590909f5 100644 --- a/src/pages/common/components/ChatComponent/ChatComponent.tsx +++ b/src/pages/common/components/ChatComponent/ChatComponent.tsx @@ -8,7 +8,7 @@ import React, { ReactNode, } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { useDebounce, useMeasure } from "react-use"; +import { useDebounce, useMeasure, useScroll } from "react-use"; import classNames from "classnames"; import isHotkey from "is-hotkey"; import { debounce, delay, omit } from "lodash"; @@ -36,7 +36,7 @@ import { CommonMember, DirectParent, Discussion, - DiscussionMessage, + DiscussionMessageWithParsedText, Timestamp, UserDiscussionMessage, } from "@/shared/models"; @@ -94,7 +94,7 @@ interface ChatComponentInterface { } interface Messages { - [key: number]: DiscussionMessage[]; + [key: number]: DiscussionMessageWithParsedText[]; } type CreateDiscussionMessageDtoWithFilesPreview = CreateDiscussionMessageDto & { @@ -102,7 +102,10 @@ type CreateDiscussionMessageDtoWithFilesPreview = CreateDiscussionMessageDto & { imagesPreview?: FileInfo[] | null; }; -function groupday(acc: any, currentValue: DiscussionMessage): Messages { +function groupday( + acc: any, + currentValue: DiscussionMessageWithParsedText, +): Messages { const d = new Date(currentValue.createdAt.seconds * 1000); const i = Math.floor(d.getTime() / (1000 * 60 * 60 * 24)); const timestamp = i * (1000 * 60 * 60 * 24); @@ -113,6 +116,8 @@ function groupday(acc: any, currentValue: DiscussionMessage): Messages { const CHAT_HOT_KEYS = [HotKeys.Enter, HotKeys.ModEnter, HotKeys.ShiftEnter]; +const SCROLL_THRESHOLD = 400; + export default function ChatComponent({ commonId, type, @@ -163,6 +168,11 @@ export default function ChatComponent({ fetchDiscussionUsers, } = useDiscussionChatAdapter({ hasPermissionToHide, + textStyles: { + mentionTextCurrentUser: styles.mentionTextCurrentUser, + singleEmojiText: styles.singleEmojiText, + multipleEmojiText: styles.multipleEmojiText, + }, }); const { chatMessagesData, @@ -232,18 +242,15 @@ export default function ChatComponent({ ); const messages = useMemo( - () => (discussionMessages ?? []).reduce(groupday, {}), + () => + ((discussionMessages ?? []) as DiscussionMessageWithParsedText[]).reduce( + groupday, + {}, + ), [discussionMessages], ); const dateList = useMemo(() => Object.keys(messages), [messages]); - useEffect(() => { - if (discussionId) { - discussionMessagesData.fetchDiscussionMessages(discussionId); - dispatch(chatActions.clearCurrentDiscussionMessageReply()); - } - }, [discussionId]); - const [newMessages, setMessages] = useState< CreateDiscussionMessageDtoWithFilesPreview[] >([]); @@ -686,6 +693,35 @@ export default function ChatComponent({ ); }; + const { y } = useScroll(chatContainerRef); + + const isTopReached = useMemo(() => { + const currentScrollPosition = Math.abs(y); // Since y can be negative + const container = chatContainerRef.current; + + if (!container) return false; + + const scrollHeight = container.scrollHeight; + const clientHeight = container.clientHeight; + + return ( + scrollHeight - clientHeight - currentScrollPosition <= SCROLL_THRESHOLD + ); + }, [y]); + + useEffect(() => { + if (discussionId) { + discussionMessagesData.fetchDiscussionMessages(discussionId); + } + }, [isTopReached, discussionId]); + + useEffect(() => { + if (isTopReached && discussionId && !discussionMessagesData.isEndOfList) { + discussionMessagesData.fetchDiscussionMessages(discussionId); + dispatch(chatActions.clearCurrentDiscussionMessageReply()); + } + }, [isTopReached, discussionId, dispatch]); + return (
; + messages: Record; dateList: string[]; lastSeenItem?: CommonFeedObjectUserUnique["lastSeen"]; hasPermissionToHide: boolean; @@ -76,7 +76,6 @@ const ChatContent: ForwardRefRenderFunction< commonMember, governanceCircles, chatWrapperId, - messages, dateList, lastSeenItem, hasPermissionToHide, @@ -90,6 +89,7 @@ const ChatContent: ForwardRefRenderFunction< onFeedItemClick, onInternalLinkClick, isEmpty, + messages, }, chatContentRef, ) => { @@ -97,13 +97,6 @@ const ChatContent: ForwardRefRenderFunction< const userId = user?.uid; const queryParams = useQueryParams(); const messageIdParam = queryParams[QueryParamKey.Message]; - const forceUpdate = useForceUpdate(); - - useEffect(() => { - if (messages) { - forceUpdate(); - } - }, [messages]); const [highlightedMessageId, setHighlightedMessageId] = useState( () => (typeof messageIdParam === "string" && messageIdParam) || null, diff --git a/src/pages/common/components/ChatComponent/hooks/useDiscussionChatAdapter.ts b/src/pages/common/components/ChatComponent/hooks/useDiscussionChatAdapter.ts index 9f95bcaff0..a73d175c35 100644 --- a/src/pages/common/components/ChatComponent/hooks/useDiscussionChatAdapter.ts +++ b/src/pages/common/components/ChatComponent/hooks/useDiscussionChatAdapter.ts @@ -6,10 +6,15 @@ import { useDiscussionMessagesById, useMarkFeedItemAsSeen, } from "@/shared/hooks/useCases"; -import { User } from "@/shared/models"; +import { DirectParent, User } from "@/shared/models"; +import { TextStyles } from "@/shared/hooks/useCases/useDiscussionMessagesById"; interface Options { hasPermissionToHide: boolean; + onUserClick?: (userId: string) => void; + onFeedItemClick?: (feedItemId: string) => void; + directParent?: DirectParent | null; + textStyles: TextStyles; } interface Return { @@ -22,22 +27,24 @@ interface Return { } export const useDiscussionChatAdapter = (options: Options): Return => { - const { hasPermissionToHide } = options; + const { hasPermissionToHide, textStyles} = options; + + const user = useSelector(selectUser()); + const userId = user?.uid; + const { data: commonMembers, fetchCommonMembers } = useCommonMembers(); + const users = useMemo( + () => + commonMembers + .filter((member) => member.userId !== userId) + .map(({ user }) => user), + [userId, commonMembers], + ); const discussionMessagesData = useDiscussionMessagesById({ hasPermissionToHide, + users, + textStyles }); const { markFeedItemAsSeen } = useMarkFeedItemAsSeen(); - const { data: commonMembers, fetchCommonMembers } = useCommonMembers(); - const user = useSelector(selectUser()); - const userId = user?.uid; - - const users = useMemo( - () => - commonMembers - .filter((member) => member.userId !== userId) - .map(({ user }) => user), - [userId, commonMembers], - ); const fetchDiscussionUsers = useCallback( (commonId: string, circleVisibility?: string[]) => { diff --git a/src/services/DiscussionMessage.ts b/src/services/DiscussionMessage.ts index 0e00343066..cd34575094 100644 --- a/src/services/DiscussionMessage.ts +++ b/src/services/DiscussionMessage.ts @@ -6,6 +6,7 @@ import { firestoreDataConverter, transformFirebaseDataList, convertObjectDatesToFirestoreTimestamps, + transformFirebaseDataSingle, } from "@/shared/utils"; import firebase from "@/shared/utils/firebase"; import { Api } from "."; @@ -19,6 +20,31 @@ class DiscussionMessageService { .collection(Collection.DiscussionMessage) .withConverter(converter); + public getDiscussionMessageById = async (discussionMessageId: string): Promise => { + const discussionMessage = await this.getDiscussionMessageCollection().doc(discussionMessageId).get(); + + return transformFirebaseDataSingle(discussionMessage); + } + + public getDiscussionMessagesByDiscussionId = (discussionId: string, lastVisible: DiscussionMessage | null, callback: (snapshot: any, discussion: DiscussionMessage[]) => void,): UnsubscribeFunction => { + let query = this.getDiscussionMessageCollection().where( + "discussionId", + "==", + discussionId, + ).limit(15).orderBy('createdAt', 'desc'); + + if (lastVisible) { + query = query.startAfter(lastVisible); + } + + return query.onSnapshot((snapshot) => { + callback( + snapshot, + transformFirebaseDataList(snapshot), + ); + }); + } + public subscribeToDiscussionMessages = ( discussionId: string, callback: (discussion: DiscussionMessage[]) => void, diff --git a/src/shared/components/Chat/ChatMessage/ChatMessage.tsx b/src/shared/components/Chat/ChatMessage/ChatMessage.tsx index 3d4a50379f..846a7610ef 100644 --- a/src/shared/components/Chat/ChatMessage/ChatMessage.tsx +++ b/src/shared/components/Chat/ChatMessage/ChatMessage.tsx @@ -7,7 +7,7 @@ import React, { } from "react"; import classNames from "classnames"; import { useLongPress } from "use-long-press"; -import { Logger } from "@/services"; +import { DiscussionMessageService } from "@/services"; import { ElementDropdown, UserAvatar, @@ -20,7 +20,6 @@ import { QueryParamKey, } from "@/shared/constants"; import { Colors } from "@/shared/constants"; -import { useRoutesContext } from "@/shared/contexts"; import { useModal } from "@/shared/hooks"; import { useIsTabletView } from "@/shared/hooks/viewport"; import { ModerationFlags } from "@/shared/interfaces/Moderation"; @@ -28,17 +27,16 @@ import { CommonMember, checkIsSystemDiscussionMessage, checkIsUserDiscussionMessage, - DiscussionMessage, User, DirectParent, Circles, + DiscussionMessageWithParsedText, + ParentDiscussionMessage, } from "@/shared/models"; import { FilePreview, FilePreviewVariant, - countTextEditorEmojiElements, getFileName, - parseStringToTextEditorValue, } from "@/shared/ui-kit"; import { ChatImageGallery } from "@/shared/ui-kit"; import { StaticLinkType, isRtlText, getUserName } from "@/shared/utils"; @@ -49,7 +47,7 @@ import { getTextFromTextEditorString } from "./utils"; import styles from "./ChatMessage.module.scss"; interface ChatMessageProps { - discussionMessage: DiscussionMessage; + discussionMessage: DiscussionMessageWithParsedText; chatType: ChatType; highlighted?: boolean; className?: string; @@ -96,7 +94,6 @@ export default function ChatMessage({ onFeedItemClick, onInternalLinkClick, }: ChatMessageProps) { - const { getCommonPagePath, getCommonPageAboutTabPath } = useRoutesContext(); const [isEditMode, setEditMode] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); const isTabletView = useIsTabletView(); @@ -110,91 +107,26 @@ export default function ChatMessage({ const isNotCurrentUserMessage = !isUserDiscussionMessage || userId !== discussionMessageUserId; - const [messageText, setMessageText] = useState<(string | JSX.Element)[]>([]); - const [isMessageDataFetching, setIsMessageDataFetching] = useState(false); - const [replyMessageText, setReplyMessageText] = useState< (string | JSX.Element)[] >([]); - const createdAtDate = new Date(discussionMessage.createdAt.seconds * 1000); - const editedAtDate = new Date( - (discussionMessage.editedAt?.seconds ?? 0) * 1000, - ); - - const { - isShowing: isShowingUserProfile, - onClose: onCloseUserProfile, - onOpen: onOpenUserProfile, - } = useModal(false); - - const handleUserClick = () => { - if (onUserClick && discussionMessageUserId) { - onUserClick(discussionMessageUserId); - } else { - onOpenUserProfile(); - } - }; + const [parentMessage, setParentMessage] = useState(); useEffect(() => { (async () => { - if (!discussionMessage.text) { - setMessageText([]); + if (!discussionMessage?.parentId) { return; } - const emojiCount = countTextEditorEmojiElements( - parseStringToTextEditorValue(discussionMessage.text), - ); - - setIsMessageDataFetching(true); - - try { - const parsedText = await getTextFromTextEditorString({ - textEditorString: discussionMessage.text, - users, - mentionTextClassName: !isNotCurrentUserMessage - ? styles.mentionTextCurrentUser - : "", - emojiTextClassName: classNames({ - [styles.singleEmojiText]: emojiCount.isSingleEmoji, - [styles.multipleEmojiText]: emojiCount.isMultipleEmoji, - }), - commonId: discussionMessage.commonId, - systemMessage: isSystemMessage ? discussionMessage : undefined, - getCommonPagePath, - getCommonPageAboutTabPath, - directParent, - onUserClick, - onFeedItemClick, - }); - - setMessageText(parsedText); - } catch (error) { - Logger.error(error); - } finally { - setIsMessageDataFetching(false); - } - })(); - }, [ - users, - discussionMessage.text, - isNotCurrentUserMessage, - discussionMessage.commonId, - isSystemMessage, - getCommonPagePath, - getCommonPageAboutTabPath, - onUserClick, - ]); - - useEffect(() => { - (async () => { - if (!discussionMessage?.parentMessage?.text) { - return; - } + const parentMessage = + discussionMessage?.parentMessage || + (await DiscussionMessageService.getDiscussionMessageById( + discussionMessage?.parentId, + )); const parsedText = await getTextFromTextEditorString({ - textEditorString: discussionMessage?.parentMessage.text, + textEditorString: parentMessage.text, users, commonId: discussionMessage.commonId, directParent, @@ -203,6 +135,7 @@ export default function ChatMessage({ }); setReplyMessageText(parsedText); + setParentMessage(parentMessage); })(); }, [ users, @@ -212,6 +145,25 @@ export default function ChatMessage({ onUserClick, ]); + const createdAtDate = new Date(discussionMessage.createdAt.seconds * 1000); + const editedAtDate = new Date( + (discussionMessage.editedAt?.seconds ?? 0) * 1000, + ); + + const { + isShowing: isShowingUserProfile, + onClose: onCloseUserProfile, + onOpen: onOpenUserProfile, + } = useModal(false); + + const handleUserClick = () => { + if (onUserClick && discussionMessageUserId) { + onUserClick(discussionMessageUserId); + } else { + onOpenUserProfile(); + } + }; + const handleLongPress = () => { setIsMenuOpen(true); }; @@ -249,21 +201,20 @@ export default function ChatMessage({ const ReplyMessage = useCallback(() => { if ( - !discussionMessage.parentMessage?.id || - (discussionMessage.parentMessage?.moderation?.flag === - ModerationFlags.Hidden && + !parentMessage?.id || + (parentMessage?.moderation?.flag === ModerationFlags.Hidden && !hasPermissionToHide) ) { return null; } - const image = discussionMessage.parentMessage?.images?.[0]?.value; - const file = discussionMessage.parentMessage?.files?.[0]; + const image = parentMessage?.images?.[0]?.value; + const file = parentMessage?.files?.[0]; return (
{ - scrollToRepliedMessage(discussionMessage.parentMessage?.id as string); + scrollToRepliedMessage(parentMessage?.id as string); }} className={classNames(styles.replyMessageContainer, { [styles.replyMessageContainerCurrentUser]: !isNotCurrentUserMessage, @@ -287,9 +238,9 @@ export default function ChatMessage({ [styles.replyMessageNameWithImage]: image, })} > - {userId === discussionMessage.parentMessage.ownerId + {userId === parentMessage.ownerId ? "You" - : discussionMessage.parentMessage?.ownerName} + : parentMessage?.ownerName}
@@ -324,13 +273,7 @@ export default function ChatMessage({
); - }, [ - discussionMessage.parentMessage, - replyMessageText, - hasPermissionToHide, - isNotCurrentUserMessage, - userId, - ]); + }, [parentMessage, hasPermissionToHide, isNotCurrentUserMessage, userId]); const filePreview = useMemo( () => discussionMessage.files?.[0], @@ -364,7 +307,9 @@ export default function ChatMessage({ isProposalMessage={chatType === ChatType.ProposalComments} isChatMessage={chatType === ChatType.ChatMessages} discussionMessage={discussionMessage} - onClose={() => setEditMode(false)} + onClose={() => { + setEditMode(false); + }} commonMember={commonMember} /> ) : ( @@ -373,8 +318,7 @@ export default function ChatMessage({ className={classNames(styles.messageText, { [styles.messageTextCurrentUser]: !isNotCurrentUserMessage, [styles.messageTextRtl]: isRtlText(discussionMessage.text), - [styles.messageTextWithReply]: - !!discussionMessage.parentMessage?.id, + [styles.messageTextWithReply]: !!parentMessage?.id, [styles.systemMessage]: isSystemMessage, [styles.highlighted]: highlighted && isNotCurrentUserMessage, [styles.highlightedOwn]: @@ -412,11 +356,7 @@ export default function ChatMessage({ - {!messageText.length && isMessageDataFetching ? ( - Loading... - ) : ( - messageText.map((text) => text) - )} + {discussionMessage.parsedText.map((text) => text)} {!isSystemMessage && (