diff --git a/src/pages/OldCommon/store/saga.tsx b/src/pages/OldCommon/store/saga.tsx index 77a6affc69..0116608c02 100644 --- a/src/pages/OldCommon/store/saga.tsx +++ b/src/pages/OldCommon/store/saga.tsx @@ -353,6 +353,7 @@ export function* loadDiscussionDetail( ...(checkIsUserDiscussionMessage(parentMessage) && { ownerId: parentMessage.ownerId, }), + createdAt: parentMessage.createdAt, } : null; return newDiscussionMessage; @@ -472,6 +473,7 @@ export function* loadProposalDetail( ...(checkIsUserDiscussionMessage(parentMessage) && { ownerId: parentMessage.ownerId, }), + createdAt: parentMessage.createdAt, } : null; return newDiscussionMessage; diff --git a/src/pages/common/components/ChatComponent/ChatComponent.module.scss b/src/pages/common/components/ChatComponent/ChatComponent.module.scss index 5f67be2cb0..d026dc8057 100644 --- a/src/pages/common/components/ChatComponent/ChatComponent.module.scss +++ b/src/pages/common/components/ChatComponent/ChatComponent.module.scss @@ -134,3 +134,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 b5f881624c..6f5ddc2663 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, @@ -162,7 +167,13 @@ export default function ChatComponent({ discussionUsers, fetchDiscussionUsers, } = useDiscussionChatAdapter({ + discussionId, hasPermissionToHide, + textStyles: { + mentionTextCurrentUser: styles.mentionTextCurrentUser, + singleEmojiText: styles.singleEmojiText, + multipleEmojiText: styles.multipleEmojiText, + }, }); const { chatMessagesData, @@ -232,18 +243,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[] >([]); @@ -451,6 +459,7 @@ export default function ChatComponent({ text: discussionMessageReply.text, files: discussionMessageReply.files, images: discussionMessageReply.images, + createdAt: discussionMessageReply.createdAt, } : null, images: imagesPreview?.map((file) => @@ -478,10 +487,7 @@ export default function ChatComponent({ }); } else { pendingMessages.forEach((pendingMessage) => { - discussionMessagesData.addDiscussionMessage( - discussionId, - pendingMessage, - ); + discussionMessagesData.addDiscussionMessage(pendingMessage); }); } @@ -686,6 +692,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(); + dispatch(chatActions.clearCurrentDiscussionMessageReply()); + } + }, [discussionId, dispatch]); + + useEffect(() => { + if (isTopReached && discussionId) { + discussionMessagesData.fetchDiscussionMessages(); + } + }, [isTopReached, discussionId]); + return (
diff --git a/src/pages/common/components/ChatComponent/components/ChatContent/ChatContent.tsx b/src/pages/common/components/ChatComponent/components/ChatContent/ChatContent.tsx index e047d3c936..cab1db47ec 100644 --- a/src/pages/common/components/ChatComponent/components/ChatContent/ChatContent.tsx +++ b/src/pages/common/components/ChatComponent/components/ChatContent/ChatContent.tsx @@ -11,21 +11,26 @@ import { useSelector } from "react-redux"; import { scroller, animateScroll } from "react-scroll"; import { v4 as uuidv4 } from "uuid"; import { selectUser } from "@/pages/Auth/store/selectors"; -import { ChatMessage, InternalLinkData } from "@/shared/components"; +import { DiscussionMessageService } from "@/services"; +import { + ChatMessage, + InternalLinkData, + DMChatMessage, +} from "@/shared/components"; import { ChatType, - LOADER_APPEARANCE_DELAY, QueryParamKey, + LOADER_APPEARANCE_DELAY, } from "@/shared/constants"; -import { useForceUpdate, useQueryParams } from "@/shared/hooks"; +import { useQueryParams } from "@/shared/hooks"; import { checkIsUserDiscussionMessage, CommonFeedObjectUserUnique, CommonMember, DirectParent, - DiscussionMessage, User, Circles, + DiscussionMessageWithParsedText, } from "@/shared/models"; import { Loader } from "@/shared/ui-kit"; import { formatDate } from "@/shared/utils"; @@ -42,7 +47,8 @@ interface ChatContentInterface { commonMember: CommonMember | null; governanceCircles?: Circles; chatWrapperId: string; - messages: Record; + messages: Record; + discussionMessages: DiscussionMessageWithParsedText[] | null; dateList: string[]; lastSeenItem?: CommonFeedObjectUserUnique["lastSeen"]; hasPermissionToHide: boolean; @@ -56,6 +62,8 @@ interface ChatContentInterface { onFeedItemClick?: (feedItemId: string) => void; onInternalLinkClick?: (data: InternalLinkData) => void; isEmpty?: boolean; + isChatChannel: boolean; + fetchReplied: (messageId: string, endDate: Date) => Promise; } const isToday = (someDate: Date) => { @@ -76,7 +84,6 @@ const ChatContent: ForwardRefRenderFunction< commonMember, governanceCircles, chatWrapperId, - messages, dateList, lastSeenItem, hasPermissionToHide, @@ -90,6 +97,10 @@ const ChatContent: ForwardRefRenderFunction< onFeedItemClick, onInternalLinkClick, isEmpty, + messages, + isChatChannel, + fetchReplied, + discussionMessages, }, chatContentRef, ) => { @@ -97,13 +108,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, @@ -154,7 +158,25 @@ const ChatContent: ForwardRefRenderFunction< setScrolledToMessage(true); }, [chatWrapperId, highlightedMessageId, dateList.length, scrolledToMessage]); - function scrollToRepliedMessage(messageId: string) { + const [shouldScrollToElementId, setShouldScrollToElementId] = + useState(); + + useEffect(() => { + if ( + shouldScrollToElementId && + discussionMessages?.find((item) => item.id === shouldScrollToElementId) + ) { + setHighlightedMessageId(shouldScrollToElementId); + setShouldScrollToElementId(""); + } + }, [shouldScrollToElementId, discussionMessages]); + + async function scrollToRepliedMessage(messageId: string, endDate: Date) { + await fetchReplied(messageId, endDate); + setShouldScrollToElementId(messageId); + } + + function scrollToRepliedMessageDMChat(messageId: string) { scroller.scrollTo(messageId, { containerId: chatWrapperId, delay: 0, @@ -167,7 +189,20 @@ const ChatContent: ForwardRefRenderFunction< useEffect(() => { if (typeof messageIdParam === "string") { - setHighlightedMessageId(messageIdParam); + (async () => { + try { + const messageData = + await DiscussionMessageService.getDiscussionMessageById( + messageIdParam, + ); + scrollToRepliedMessage( + messageData.id, + messageData.createdAt.toDate(), + ); + } catch (err) { + setShouldScrollToElementId(""); + } + })(); } }, [messageIdParam]); @@ -218,7 +253,26 @@ const ChatContent: ForwardRefRenderFunction< const isMyMessageNext = checkIsUserDiscussionMessage(nextMessage) && nextMessage.ownerId === userId; - const messageEl = ( + const messageEl = isChatChannel ? ( + + ) : ( void; + onFeedItemClick?: (feedItemId: string) => void; + directParent?: DirectParent | null; + textStyles: TextStyles; + discussionId: string; } interface Return { @@ -22,15 +28,11 @@ interface Return { } export const useDiscussionChatAdapter = (options: Options): Return => { - const { hasPermissionToHide } = options; - const discussionMessagesData = useDiscussionMessagesById({ - hasPermissionToHide, - }); - const { markFeedItemAsSeen } = useMarkFeedItemAsSeen(); - const { data: commonMembers, fetchCommonMembers } = useCommonMembers(); + const { hasPermissionToHide, textStyles, discussionId } = options; + const user = useSelector(selectUser()); const userId = user?.uid; - + const { data: commonMembers, fetchCommonMembers } = useCommonMembers(); const users = useMemo( () => commonMembers @@ -38,6 +40,13 @@ export const useDiscussionChatAdapter = (options: Options): Return => { .map(({ user }) => user), [userId, commonMembers], ); + const discussionMessagesData = useDiscussionMessagesById({ + discussionId, + hasPermissionToHide, + users, + textStyles + }); + const { markFeedItemAsSeen } = useMarkFeedItemAsSeen(); const fetchDiscussionUsers = useCallback( (commonId: string, circleVisibility?: string[]) => { diff --git a/src/services/DiscussionMessage.ts b/src/services/DiscussionMessage.ts index 0e00343066..459ad6d596 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,69 @@ 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 getDiscussionMessagesByEndDate = async ( + discussionId: string, + lastVisible: firebase.firestore.QueryDocumentSnapshot | null, + endDate: Date + ): Promise<{ + data: DiscussionMessage[], + lastVisibleSnapshot: firebase.firestore.QueryDocumentSnapshot + }> => { + const snapshot = await this.getDiscussionMessageCollection().where( + "discussionId", + "==", + discussionId, + ).where("createdAt", ">=", endDate) + .orderBy('createdAt', 'desc') + .startAfter(lastVisible) + .get(); + + const data = transformFirebaseDataList(snapshot); + const snapshotOfItemsAfterEndDate = await this.getDiscussionMessageCollection().where( + "discussionId", + "==", + discussionId, + ).orderBy('createdAt', 'desc') + .startAfter(snapshot.docs[data.length - 1]) + .limit(5) + .get(); + const dataAfterEndDate = transformFirebaseDataList(snapshotOfItemsAfterEndDate); + + return { + data: [...data, ...dataAfterEndDate], + lastVisibleSnapshot: snapshotOfItemsAfterEndDate.docs[dataAfterEndDate.length - 1] + }; + } + + public getDiscussionMessagesByDiscussionId = (discussionId: string, + lastVisible: firebase.firestore.QueryDocumentSnapshot | null, + callback: (snapshot: firebase.firestore.QuerySnapshot, + 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 7dba05e36c..e848e04513 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,18 +27,13 @@ import { CommonMember, checkIsSystemDiscussionMessage, checkIsUserDiscussionMessage, - DiscussionMessage, User, DirectParent, Circles, + DiscussionMessageWithParsedText, + ParentDiscussionMessage, } from "@/shared/models"; -import { - FilePreview, - FilePreviewVariant, - countTextEditorEmojiElements, - getFileName, - parseStringToTextEditorValue, -} from "@/shared/ui-kit"; +import { FilePreview, FilePreviewVariant, getFileName } from "@/shared/ui-kit"; import { ChatImageGallery } from "@/shared/ui-kit"; import { StaticLinkType, isRtlText, getUserName } from "@/shared/utils"; import { convertBytes } from "@/shared/utils/convertBytes"; @@ -49,12 +43,12 @@ import { getTextFromTextEditorString } from "./utils"; import styles from "./ChatMessage.module.scss"; interface ChatMessageProps { - discussionMessage: DiscussionMessage; + discussionMessage: DiscussionMessageWithParsedText; chatType: ChatType; highlighted?: boolean; className?: string; user: User | null; - scrollToRepliedMessage: (messageId: string) => void; + scrollToRepliedMessage: (messageId: string, messageDate: Date) => void; hasPermissionToHide: boolean; users: User[]; feedItemId: string; @@ -96,7 +90,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 +103,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 +131,7 @@ export default function ChatMessage({ }); setReplyMessageText(parsedText); + setParentMessage(parentMessage); })(); }, [ users, @@ -212,6 +141,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); }; @@ -239,7 +187,8 @@ export default function ChatMessage({ data.params[QueryParamKey.Item] === feedItemId && typeof messageId === "string" ) { - scrollToRepliedMessage(messageId); + parentMessage && + scrollToRepliedMessage(messageId, parentMessage.createdAt.toDate()); } else { onInternalLinkClick?.(data); } @@ -247,24 +196,30 @@ export default function ChatMessage({ [feedItemId, scrollToRepliedMessage, onInternalLinkClick], ); + const scrollToReplied = (): void => { + if (parentMessage) { + scrollToRepliedMessage( + parentMessage?.id as string, + parentMessage.createdAt.toDate(), + ); + } + }; + 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); - }} + onClick={scrollToReplied} className={classNames(styles.replyMessageContainer, { [styles.replyMessageContainerCurrentUser]: !isNotCurrentUserMessage, })} @@ -287,9 +242,9 @@ export default function ChatMessage({ [styles.replyMessageNameWithImage]: image, })} > - {userId === discussionMessage.parentMessage.ownerId + {userId === parentMessage.ownerId ? "You" - : discussionMessage.parentMessage?.ownerName} + : parentMessage?.ownerName}
@@ -324,13 +277,7 @@ export default function ChatMessage({
); - }, [ - discussionMessage.parentMessage, - replyMessageText, - hasPermissionToHide, - isNotCurrentUserMessage, - userId, - ]); + }, [parentMessage, hasPermissionToHide, isNotCurrentUserMessage, userId]); const filePreview = useMemo( () => discussionMessage.files?.[0], @@ -373,8 +320,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 +358,7 @@ export default function ChatMessage({ - {!messageText.length && isMessageDataFetching ? ( - Loading... - ) : ( - messageText.map((text) => text) - )} + {discussionMessage.parsedText.map((text) => text)} {!isSystemMessage && (