From 22a10232748ea8d27381091f7e827703c42d397d Mon Sep 17 00:00:00 2001 From: danney-chun <63285271+danney-chun@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:27:01 +0900 Subject: [PATCH 01/19] added markAsUnread --- package.json | 6 +- src/lib/Sendbird/context/SendbirdProvider.tsx | 1 + src/lib/Sendbird/context/initialState.ts | 1 + src/lib/Sendbird/types.ts | 1 + src/modules/App/AppLayout.tsx | 2 +- .../Channel/components/MessageList/index.tsx | 67 +++++++++++-- .../components/NewMessageCount/index.tsx | 3 + .../context/hooks/useHandleChannelEvents.ts | 19 ++++ .../components/Message/MessageView.tsx | 23 ++++- .../GroupChannel/components/Message/index.tsx | 2 + .../MessageList/getMessagePartsInfo.ts | 16 +++ .../components/MessageList/index.tsx | 98 +++++++++++++++++-- .../components/NewMessageCount/index.scss | 63 ++++++++++++ .../components/NewMessageCount/index.tsx | 58 +++++++++++ .../components/UnreadCount/index.scss | 67 +++++++++++++ .../components/UnreadCount/index.tsx | 35 ++++--- .../context/GroupChannelProvider.tsx | 39 +++++++- .../context/hooks/useGroupChannel.ts | 20 +++- src/modules/GroupChannel/context/types.ts | 4 + src/svgs/icon-floating-button-close.svg | 3 + src/svgs/icon-mark-as-unread.svg | 10 ++ src/ui/DateSeparator/index.tsx | 5 +- src/ui/Icon/index.tsx | 4 + src/ui/Icon/type.ts | 2 + src/ui/Icon/utils.ts | 1 + src/ui/Label/stringSet.ts | 6 +- src/ui/MessageContent/index.tsx | 5 + src/ui/MessageMenu/MessageMenu.tsx | 10 +- src/ui/MessageMenu/MessageMenuProvider.tsx | 1 + .../menuItems/BottomSheetMenuItems.tsx | 27 +++++ .../menuItems/MessageMenuItems.tsx | 20 ++++ .../MessageMenu/menuItems/MobileMenuItems.tsx | 27 +++++ src/ui/MobileMenu/MobileBottomSheet.tsx | 14 ++- src/ui/MobileMenu/MobileContextMenu.tsx | 14 ++- src/ui/MobileMenu/types.ts | 1 + src/utils/menuConditions.ts | 4 + yarn.lock | 40 ++++---- 37 files changed, 647 insertions(+), 72 deletions(-) create mode 100644 src/modules/Channel/components/NewMessageCount/index.tsx create mode 100644 src/modules/GroupChannel/components/NewMessageCount/index.scss create mode 100644 src/modules/GroupChannel/components/NewMessageCount/index.tsx create mode 100644 src/svgs/icon-floating-button-close.svg create mode 100644 src/svgs/icon-mark-as-unread.svg diff --git a/package.json b/package.json index 01e5fe3e9..1eb7af080 100644 --- a/package.json +++ b/package.json @@ -70,9 +70,9 @@ "react-dom": "^16.8.6 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "dependencies": { - "@sendbird/chat": "^4.19.1", - "@sendbird/react-uikit-message-template-view": "^0.0.8", - "@sendbird/uikit-tools": "^0.0.8", + "@sendbird/chat": "^4.19.2", + "@sendbird/react-uikit-message-template-view": "^0.0.10", + "@sendbird/uikit-tools": "^0.0.10", "css-vars-ponyfill": "^2.3.2", "date-fns": "^2.16.1", "dompurify": "^3.2.4" diff --git a/src/lib/Sendbird/context/SendbirdProvider.tsx b/src/lib/Sendbird/context/SendbirdProvider.tsx index a3f9d24bd..616846fcc 100644 --- a/src/lib/Sendbird/context/SendbirdProvider.tsx +++ b/src/lib/Sendbird/context/SendbirdProvider.tsx @@ -210,6 +210,7 @@ const SendbirdContextManager = ({ suggestedRepliesDirection: configs.groupChannel.channel.suggestedRepliesDirection, enableMarkdownForUserMessage: configs.groupChannel.channel.enableMarkdownForUserMessage, enableFormTypeMessage: configs.groupChannel.channel.enableFormTypeMessage, + enableMarkAsUnread: configs.groupChannel.channel.enableMarkAsUnread, enableReactionsSupergroup: sdkInitialized && configsWithAppAttr(sdk).groupChannel.channel.enableReactionsSupergroup as never, }, groupChannelList: { diff --git a/src/lib/Sendbird/context/initialState.ts b/src/lib/Sendbird/context/initialState.ts index 74812b544..0a4d3b953 100644 --- a/src/lib/Sendbird/context/initialState.ts +++ b/src/lib/Sendbird/context/initialState.ts @@ -69,6 +69,7 @@ const config: SendbirdStateConfig = { suggestedRepliesDirection: 'vertical', enableMarkdownForUserMessage: false, enableFormTypeMessage: false, + enableMarkAsUnread: false, enableReactionsSupergroup: undefined as never, // @deprecated }, groupChannelList: { diff --git a/src/lib/Sendbird/types.ts b/src/lib/Sendbird/types.ts index 5e1bf9854..729891eb1 100644 --- a/src/lib/Sendbird/types.ts +++ b/src/lib/Sendbird/types.ts @@ -280,6 +280,7 @@ export interface SendbirdStateConfig { suggestedRepliesDirection: SBUConfig['groupChannel']['channel']['suggestedRepliesDirection']; enableMarkdownForUserMessage: SBUConfig['groupChannel']['channel']['enableMarkdownForUserMessage']; enableFormTypeMessage: SBUConfig['groupChannel']['channel']['enableFormTypeMessage']; + enableMarkAsUnread: SBUConfig['groupChannel']['channel']['enableMarkAsUnread']; /** * @deprecated Currently, this feature is turned off by default. If you wish to use this feature, contact us: {@link https://dashboard.sendbird.com/settings/contact_us?category=feedback_and_feature_requests&product=UIKit} */ diff --git a/src/modules/App/AppLayout.tsx b/src/modules/App/AppLayout.tsx index c0d160011..1947f215b 100644 --- a/src/modules/App/AppLayout.tsx +++ b/src/modules/App/AppLayout.tsx @@ -39,7 +39,7 @@ export const AppLayout = (props: AppLayoutProps) => { const replyType = props.replyType ?? getCaseResolvedReplyType(globalConfigs.groupChannel.replyType).upperCase; const isReactionEnabled = props.isReactionEnabled ?? globalConfigs.groupChannel.enableReactions; const showSearchIcon = props.showSearchIcon ?? globalConfigs.groupChannelSettings.enableMessageSearch; - + return ( <> { diff --git a/src/modules/Channel/components/MessageList/index.tsx b/src/modules/Channel/components/MessageList/index.tsx index 365c382ee..6e6b36a3d 100644 --- a/src/modules/Channel/components/MessageList/index.tsx +++ b/src/modules/Channel/components/MessageList/index.tsx @@ -1,7 +1,7 @@ /* We operate the CSS files for Channel&GroupChannel modules in the GroupChannel */ import '../../../GroupChannel/components/MessageList/index.scss'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import type { UserMessage } from '@sendbird/chat/message'; import { useChannelContext } from '../../context/ChannelProvider'; @@ -11,6 +11,7 @@ import Message from '../Message'; import { EveryMessage, TypingIndicatorType } from '../../../../types'; import { isAboutSame } from '../../context/utils'; import UnreadCount from '../UnreadCount'; +import NewMessageCount from '../NewMessageCount'; import FrozenNotification from '../FrozenNotification'; import { SCROLL_BUFFER } from '../../../../utils/consts'; import { MessageProvider } from '../../../Message/context/MessageProvider'; @@ -27,6 +28,7 @@ import { deleteNullish } from '../../../../utils/utils'; import { getHTMLTextDirection } from '../../../../utils'; import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; import { useLocalization } from '../../../../lib/LocalizationContext'; +import { useGroupChannel } from '../../../GroupChannel/context/hooks/useGroupChannel'; const SCROLL_BOTTOM_PADDING = 50; @@ -52,7 +54,7 @@ export const MessageList = (props: MessageListProps) => { renderCustomSeparator, renderPlaceholderLoader = () => , renderPlaceholderEmpty = () => , - renderFrozenNotification = () => , + renderFrozenNotification = () => , } = deleteNullish(props); const { @@ -87,6 +89,8 @@ export const MessageList = (props: MessageListProps) => { const [isScrollBottom, setIsScrollBottom] = useState(false); + const { state: { newMessages, markAsUnreadSourceRef } } = useGroupChannel(); + useScrollBehavior(); /** @@ -156,6 +160,10 @@ export const MessageList = (props: MessageListProps) => { * hasMoreNext is true but it needs to be called when hasNext is false when reached bottom as well. */ if (!hasMoreNext && !disableMarkAsRead && !!currentGroupChannel) { + // markAsUnreadSourceRef의 현재 값을 확인 + const currentSource = markAsUnreadSourceRef.current; + console.log('Channel MessageList: markAsUnreadSourceRef current value:', currentSource); + messagesDispatcher({ type: messageActionTypes.MARK_AS_READ, payload: { channel: currentGroupChannel }, @@ -174,6 +182,31 @@ export const MessageList = (props: MessageListProps) => { const { scrollToBottomHandler, scrollBottom } = useSetScrollToBottom({ loading }); + const isShowUnreadCount = useMemo(() => { + if (store?.config?.groupChannel?.enableMarkAsUnread) { + // markAsUnread is enabled + if (currentGroupChannel?.unreadMessageCount > 0) { + return true; + } + return false; + } else { + // markAsUnread is disable + if (currentGroupChannel?.unreadMessageCount > 0 && !isScrollBottom && hasMoreNext) { + return true; + } + return false; + } + }, [currentGroupChannel.unreadMessageCount, isScrollBottom]); + + const isShowNewMessageCount = useMemo(() => { + if (!store?.config?.groupChannel?.enableMarkAsUnread + && (!isScrollBottom || hasMoreNext) + && (unreadSince || unreadSinceDate)) { + return true; + } + return false; + }, [newMessages.length, isScrollBottom]); + if (loading) { return renderPlaceholderLoader(); } @@ -270,15 +303,13 @@ export const MessageList = (props: MessageListProps) => { {currentGroupChannel?.isFrozen && renderFrozenNotification()} { - /** - * Show unread count IFF scroll is not bottom or is bottom but hasNext is true. - */ - (!isScrollBottom || hasMoreNext) && (unreadSince || unreadSinceDate) && ( + isShowUnreadCount && ( { if (scrollRef?.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; if (!disableMarkAsRead && !!currentGroupChannel) { @@ -309,6 +340,28 @@ export const MessageList = (props: MessageListProps) => { ) } + { + /* NewMessageCount - positioned at the bottom of MessageList */ + (isShowNewMessageCount) && ( + { + if (scrollRef?.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + if (!disableMarkAsRead && !!currentGroupChannel) { + markAsReadScheduler.push(currentGroupChannel); + messagesDispatcher({ + type: messageActionTypes.MARK_AS_READ, + payload: { channel: currentGroupChannel }, + }); + } + setInitialTimeStamp(null); + setAnimatedMessageId(null); + setHighLightedMessageId(null); + }} + /> + ) + } ); diff --git a/src/modules/Channel/components/NewMessageCount/index.tsx b/src/modules/Channel/components/NewMessageCount/index.tsx new file mode 100644 index 000000000..cc846e9ba --- /dev/null +++ b/src/modules/Channel/components/NewMessageCount/index.tsx @@ -0,0 +1,3 @@ +import NewMessageCount from '../../../GroupChannel/components/NewMessageCount'; + +export default NewMessageCount; diff --git a/src/modules/Channel/context/hooks/useHandleChannelEvents.ts b/src/modules/Channel/context/hooks/useHandleChannelEvents.ts index 964166823..481d3e0eb 100644 --- a/src/modules/Channel/context/hooks/useHandleChannelEvents.ts +++ b/src/modules/Channel/context/hooks/useHandleChannelEvents.ts @@ -107,6 +107,25 @@ function useHandleChannelEvents({ }); } }, + onUserMarkedRead: (channel, userIds) => { + logger.info('Channel | useHandleChannelEvents: onUserMarkedAsRead', channel, userIds); + if (compareIds(channel?.url, channelUrl)) { + messagesDispatcher({ + type: messageActions.SET_CURRENT_CHANNEL, + payload: channel, + }); + } + }, + onUserMarkedUnread: (channel, userIds) => { + logger.info('Channel | useHandleChannelEvents: onUserMarkedUnread', channel, userIds); + // TODO:: MADOKA 이 부분에 대해서 명확하게 확인해야 함. + if (compareIds(channel?.url, channelUrl)) { + messagesDispatcher({ + type: messageActions.SET_CURRENT_CHANNEL, + payload: channel, + }); + } + }, // before(onDeliveryReceiptUpdated) onUndeliveredMemberStatusUpdated: (channel) => { if (compareIds(channel?.url, channelUrl)) { diff --git a/src/modules/GroupChannel/components/Message/MessageView.tsx b/src/modules/GroupChannel/components/Message/MessageView.tsx index 138030582..85324ffc1 100644 --- a/src/modules/GroupChannel/components/Message/MessageView.tsx +++ b/src/modules/GroupChannel/components/Message/MessageView.tsx @@ -26,6 +26,7 @@ import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface MessageProps { message: EveryMessage; hasSeparator?: boolean; + hasNewMessageSeparator?: boolean; chainTop?: boolean; chainBottom?: boolean; handleScroll?: (isBottomMessageAffected?: boolean) => void; @@ -79,6 +80,7 @@ export interface MessageViewProps extends MessageProps { updateUserMessage: (messageId: number, params: UserMessageUpdateParams) => void; resendMessage: (failedMessage: SendableMessageType) => void; deleteMessage: (message: CoreMessageType) => Promise; + markAsUnread?: (message: SendableMessageType) => void; renderFileViewer: (props: { message: FileMessage; onCancel: () => void }) => React.ReactElement; renderRemoveMessageModal?: (props: { message: EveryMessage; onCancel: () => void }) => React.ReactElement; @@ -107,6 +109,7 @@ const MessageView = (props: MessageViewProps) => { message, children, hasSeparator, + hasNewMessageSeparator, chainTop, chainBottom, handleScroll, @@ -132,6 +135,7 @@ const MessageView = (props: MessageViewProps) => { updateUserMessage, resendMessage, deleteMessage, + markAsUnread, setAnimatedMessageId, animatedMessageId, @@ -288,6 +292,7 @@ const MessageView = (props: MessageViewProps) => { onMessageHeightChange: handleScroll, onBeforeDownloadFileMessage, filterEmojiCategoryIds, + markAsUnread, })} { /* Suggested Replies */ } { @@ -392,6 +397,16 @@ const MessageView = (props: MessageViewProps) => { ); } + const seperatorLabelText = useMemo(() => { + if (!hasSeparator && hasNewMessageSeparator) { + return 'New Message'; + } else { + return format(message.createdAt, stringSet.DATE_FORMAT__MESSAGE_LIST__DATE_SEPARATOR, { + locale: dateLocale, + }); + } + }, [hasSeparator, hasNewMessageSeparator]); + return (
{ ref={messageScrollRef} > {/* date-separator */} - {hasSeparator + {(hasSeparator || hasNewMessageSeparator) && (renderedCustomSeparator || ( - + ))} diff --git a/src/modules/GroupChannel/components/Message/index.tsx b/src/modules/GroupChannel/components/Message/index.tsx index 749bb5716..a725365f6 100644 --- a/src/modules/GroupChannel/components/Message/index.tsx +++ b/src/modules/GroupChannel/components/Message/index.tsx @@ -29,6 +29,7 @@ export const Message = (props: MessageProps): React.ReactElement => { onMessageAnimated, onBeforeDownloadFileMessage, messages, + markAsUnread, }, actions: { toggleReaction, @@ -85,6 +86,7 @@ export const Message = (props: MessageProps): React.ReactElement => { updateUserMessage={updateUserMessage} resendMessage={resendMessage} deleteMessage={deleteMessage as any} + markAsUnread={markAsUnread} animatedMessageId={animatedMessageId} setAnimatedMessageId={setAnimatedMessageId} onMessageAnimated={onMessageAnimated} diff --git a/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts b/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts index 718e6cfac..fbf067ca4 100644 --- a/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts +++ b/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts @@ -14,12 +14,15 @@ export interface GetMessagePartsInfoProps { currentMessage: CoreMessageType; currentChannel?: GroupChannel | null; replyType?: string; + firstUnreadMessageId?: number | string | undefined; } interface OutPuts { chainTop: boolean, chainBottom: boolean, hasSeparator: boolean, + hasNewMessageSeparator: boolean, + newFirstUnreadMessageId?: number | string | undefined, } /** @@ -33,6 +36,7 @@ export const getMessagePartsInfo = ({ currentMessage, currentChannel = null, replyType = '', + firstUnreadMessageId, }: GetMessagePartsInfoProps): OutPuts => { const previousMessage = allMessages[currentIndex - 1]; const nextMessage = allMessages[currentIndex + 1]; @@ -47,9 +51,21 @@ export const getMessagePartsInfo = ({ // https://stackoverflow.com/a/41855608 const hasSeparator = isLocalMessage ? false : !(previousMessageCreatedAt && (isSameDay(currentCreatedAt, previousMessageCreatedAt))); + let hasNewMessageSeparator = false; + let newFirstUnreadMessageId; + if (!firstUnreadMessageId) { + hasNewMessageSeparator = currentChannel.myLastRead === (currentCreatedAt - 1); + if (hasNewMessageSeparator) newFirstUnreadMessageId = currentMessage.messageId; + } else if (currentMessage.messageId === firstUnreadMessageId || currentChannel.myLastRead === (currentCreatedAt - 1)) { + hasNewMessageSeparator = true; + newFirstUnreadMessageId = currentMessage.messageId; + } + return { chainTop, chainBottom, hasSeparator, + hasNewMessageSeparator, + newFirstUnreadMessageId, }; }; diff --git a/src/modules/GroupChannel/components/MessageList/index.tsx b/src/modules/GroupChannel/components/MessageList/index.tsx index d58cc534e..22c614a9b 100644 --- a/src/modules/GroupChannel/components/MessageList/index.tsx +++ b/src/modules/GroupChannel/components/MessageList/index.tsx @@ -1,15 +1,16 @@ import './index.scss'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import type { Member } from '@sendbird/chat/groupChannel'; import { useGroupChannelHandler } from '@sendbird/uikit-tools'; -import { CoreMessageType, isSendableMessage, getHTMLTextDirection } from '../../../../utils'; +import { CoreMessageType, isSendableMessage, getHTMLTextDirection, SendableMessageType } from '../../../../utils'; import { EveryMessage, RenderMessageParamsType, TypingIndicatorType } from '../../../../types'; import PlaceHolder, { PlaceHolderTypes } from '../../../../ui/PlaceHolder'; import Icon, { IconColors, IconTypes } from '../../../../ui/Icon'; import Message from '../Message'; import UnreadCount from '../UnreadCount'; +import NewMessageCount from '../NewMessageCount'; import FrozenNotification from '../FrozenNotification'; import { SCROLL_BUFFER } from '../../../../utils/consts'; import TypingIndicatorBubble from '../../../../ui/TypingIndicatorBubble'; @@ -85,10 +86,13 @@ export const MessageList = (props: GroupChannelMessageListProps) => { scrollRef, scrollPositionRef, scrollDistanceFromBottomRef, + markAsUnreadSourceRef, }, actions: { scrollToBottom, setIsScrollBottomReached, + markAsReadAll, + markAsUnread, }, } = useGroupChannel(); @@ -97,6 +101,9 @@ export const MessageList = (props: GroupChannelMessageListProps) => { const [unreadSinceDate, setUnreadSinceDate] = useState(); + const firstUnreadMessageIdRef = useRef(); + const displayedNewMessageSeparatorRef = useRef(false); + useEffect(() => { if (isScrollBottomReached) { setUnreadSinceDate(undefined); @@ -105,6 +112,12 @@ export const MessageList = (props: GroupChannelMessageListProps) => { } }, [isScrollBottomReached]); + useEffect(() => { + firstUnreadMessageIdRef.current = undefined; + displayedNewMessageSeparatorRef.current = false; + markAsUnreadSourceRef.current = undefined; + }, [currentChannel]); + /** * 1. Move the message list scroll * when each message's height is changed by `reactions` OR `showEdit` @@ -129,13 +142,16 @@ export const MessageList = (props: GroupChannelMessageListProps) => { return renderFrozenNotification(); }, unreadMessagesNotification() { - if (isScrollBottomReached || !unreadSinceDate) return null; + if (markAsUnreadSourceRef.current === 'menu' || displayedNewMessageSeparatorRef.current) return null; return ( scrollToBottom()} + isFrozenChannel={currentChannel?.isFrozen || false} + onClick={() => { + markAsReadAll(); + }} /> ); }, @@ -154,6 +170,49 @@ export const MessageList = (props: GroupChannelMessageListProps) => {
); }, + newMessageCount() { + if (isScrollBottomReached) return null; + return ( + scrollToBottom()} + /> + ); + }, + }; + + const checkNewMessageSeparatorVisibility = () => { + if (displayedNewMessageSeparatorRef.current) return false; + if (currentChannel.unreadMessageCount === 0) return false; + + const newMessageSeparator = document.getElementById('new-message-separator'); + if (!newMessageSeparator) return false; + + const newMessageSeparatorRect = newMessageSeparator.getBoundingClientRect(); + const messageListContainer = document.querySelector('.sendbird-conversation__messages-padding'); + if (!messageListContainer) return false; + + const messageListContainerRect = messageListContainer.getBoundingClientRect(); + const isTopInContainer = newMessageSeparatorRect.top > messageListContainerRect.top; + + if (isTopInContainer) { + displayedNewMessageSeparatorRef.current = true; + return true; + } + + return false; + }; + + const checkDisplayNewMessageSeparator = () => { + if (!checkNewMessageSeparatorVisibility()) return; + + if (displayedNewMessageSeparatorRef.current && newMessages.length > 0) { + markAsUnread(newMessages[0] as SendableMessageType, 'internal'); + firstUnreadMessageIdRef.current = newMessages[0].messageId; + } else if (displayedNewMessageSeparatorRef.current) { + markAsReadAll(); + } }; if (loading) { @@ -182,14 +241,22 @@ export const MessageList = (props: GroupChannelMessageListProps) => { onLoadPrevious={loadPrevious} onScrollPosition={(it) => { const isScrollBottomReached = it === 'bottom'; - if (newMessages.length > 0 && isScrollBottomReached) { - resetNewMessages(); + if (isScrollBottomReached) { + if (newMessages.length > 0) { + resetNewMessages(); + } + if (!state.config.groupChannel.enableMarkAsUnread || displayedNewMessageSeparatorRef.current) { + markAsReadAll(); + } + } else if (!displayedNewMessageSeparatorRef.current) { + checkDisplayNewMessageSeparator(); } + setIsScrollBottomReached(isScrollBottomReached); }} messages={messages} renderMessage={({ message, index }) => { - const { chainTop, chainBottom, hasSeparator } = getMessagePartsInfo({ + const { chainTop, chainBottom, hasSeparator, hasNewMessageSeparator, newFirstUnreadMessageId } = getMessagePartsInfo({ allMessages: messages as CoreMessageType[], stringSet, replyType: replyType ?? 'NONE', @@ -197,7 +264,18 @@ export const MessageList = (props: GroupChannelMessageListProps) => { currentIndex: index, currentMessage: message as CoreMessageType, currentChannel: currentChannel!, + firstUnreadMessageId: firstUnreadMessageIdRef.current, }); + + if (hasNewMessageSeparator && newFirstUnreadMessageId && newFirstUnreadMessageId !== firstUnreadMessageIdRef.current) { + firstUnreadMessageIdRef.current = newFirstUnreadMessageId; + if (markAsUnreadSourceRef.current === 'menu') { + checkNewMessageSeparatorVisibility(); + } else { + checkDisplayNewMessageSeparator(); + } + } + const isOutgoingMessage = isSendableMessage(message) && message.sender.userId === state.config.userId; return ( @@ -205,6 +283,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { handleScroll: onMessageContentSizeChanged, message: message as EveryMessage, hasSeparator, + hasNewMessageSeparator, chainTop, chainBottom, renderMessageContent, @@ -225,6 +304,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { <>{renderer.frozenNotification()} <>{renderer.unreadMessagesNotification()} <>{renderer.scrollToBottomButton()} + <>{renderer.newMessageCount()} ); diff --git a/src/modules/GroupChannel/components/NewMessageCount/index.scss b/src/modules/GroupChannel/components/NewMessageCount/index.scss new file mode 100644 index 000000000..1bd4df3fa --- /dev/null +++ b/src/modules/GroupChannel/components/NewMessageCount/index.scss @@ -0,0 +1,63 @@ +/** + * We operate the CSS files for Channel&GroupChannel modules in the GroupChannel. + * So keep in mind that you should consider both components when you make changes in this file. + */ +@import '../../../../styles/variables'; + +// Floating NewMessageCount button - positioned at the bottom of MessageList +.sendbird-new-message-floating-button { + position: absolute; + bottom: 8px; // Bottom position for new message count + left: 50%; + transform: translateX(-50%); + width: 155px; + height: 40px; + border-radius: 20px; + gap: 4px; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transition: all 0.2s ease-in-out; + z-index: 5; + background-color: #FFFFFF; + + &:hover { + cursor: pointer; + transform: translateX(-50%) translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); + background-color: #F5F5F5; + } + + .sendbird-new-message-floating-button__text { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #742DDD; + line-height: 1; + font-size: 14px; + text-align: center; + height: 100%; + } + + // Icon fixed to right side + .sendbird-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + svg { + display: block; + vertical-align: middle; + } + } +} + +.sendbird-new-message-floating-button--hide { + display: none; +} \ No newline at end of file diff --git a/src/modules/GroupChannel/components/NewMessageCount/index.tsx b/src/modules/GroupChannel/components/NewMessageCount/index.tsx new file mode 100644 index 000000000..4b1c8ef84 --- /dev/null +++ b/src/modules/GroupChannel/components/NewMessageCount/index.tsx @@ -0,0 +1,58 @@ +import './index.scss'; +import React, { useContext, useMemo } from 'react'; + +import { LocalizationContext } from '../../../../lib/LocalizationContext'; +import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; +import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; +import { classnames } from '../../../../utils/utils'; + +export interface NewMessageCountProps { + className?: string; + count: number | undefined; + onClick(): void; +} + +export const NewMessageCount: React.FC = ({ + className = '', + count = 0, + onClick, +}: NewMessageCountProps) => { + const { stringSet } = useContext(LocalizationContext); + + const newMessageCountText = useMemo(() => { + if (count === 1) { + return stringSet.CHANNEL__MESSAGE_LIST__NOTIFICATION__NEW_MESSAGE; + } else if (count > 1) { + return stringSet.CHANNEL__MESSAGE_LIST__NOTIFICATION__NEW_MESSAGE_S; + } + }, [count]); + + return ( +
+ + +
+ ); +}; + +export default NewMessageCount; diff --git a/src/modules/GroupChannel/components/UnreadCount/index.scss b/src/modules/GroupChannel/components/UnreadCount/index.scss index fcff9113a..3371a16a8 100644 --- a/src/modules/GroupChannel/components/UnreadCount/index.scss +++ b/src/modules/GroupChannel/components/UnreadCount/index.scss @@ -4,6 +4,70 @@ */ @import '../../../../styles/variables'; +// Floating UnreadCount button - completely separate from notification styles +.sendbird-unread-floating-button { + position: absolute; + top: 8px; // Default position when no frozen notification + left: 50%; + transform: translateX(-50%); + width: 181px; + height: 40px; + border-radius: 20px; + gap: 4px; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transition: all 0.2s ease-in-out; + z-index: 5; + background-color: #FFFFFF; + + // When frozen notification is present, position below it + &.sendbird-unread-floating-button--below-frozen { + top: 48px; + } + + &:hover { + cursor: pointer; + transform: translateX(-50%) translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); + background-color: #F5F5F5; + } + + .sendbird-unread-floating-button__text { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #00000080; + line-height: 1; + font-size: 14px; + text-align: center; + height: 100%; + } + + // Icon fixed to right side + .sendbird-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + svg { + display: block; + vertical-align: middle; + } + } +} + +.sendbird-unread-floating-button--hide { + display: none; +} + +// FrozenNotification styles (required for Channel&GroupChannel modules) .sendbird-notification--hide, .sendbird-notification { position: absolute; @@ -51,3 +115,6 @@ .sendbird-notification--hide { display: none; } + + + diff --git a/src/modules/GroupChannel/components/UnreadCount/index.tsx b/src/modules/GroupChannel/components/UnreadCount/index.tsx index 972a350a4..75b4046d5 100644 --- a/src/modules/GroupChannel/components/UnreadCount/index.tsx +++ b/src/modules/GroupChannel/components/UnreadCount/index.tsx @@ -4,7 +4,6 @@ import React, { useContext, useMemo } from 'react'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; -import format from 'date-fns/format'; import { classnames } from '../../../../utils/utils'; export interface UnreadCountProps { @@ -14,48 +13,48 @@ export interface UnreadCountProps { lastReadAt?: Date | null; /** @deprecated Please use `lastReadAt` instead * */ time?: string; + isFrozenChannel?: boolean; } export const UnreadCount: React.FC = ({ className = '', count = 0, - time = '', onClick, - lastReadAt, + isFrozenChannel = false, }: UnreadCountProps) => { - const { stringSet, dateLocale } = useContext(LocalizationContext); + const { stringSet } = useContext(LocalizationContext); - const unreadSince = useMemo(() => { - // TODO: Remove this on v4 - if (stringSet.CHANNEL__MESSAGE_LIST__NOTIFICATION__ON !== 'on') { - const timeArray = time?.toString?.()?.split(' ') || []; - timeArray?.splice(-2, 0, stringSet.CHANNEL__MESSAGE_LIST__NOTIFICATION__ON); - return timeArray.join(' '); - } else if (lastReadAt) { - return format(lastReadAt, stringSet.DATE_FORMAT__MESSAGE_LIST__NOTIFICATION__UNREAD_SINCE, { locale: dateLocale }); + const unreadMessageCountText = useMemo(() => { + if (count === 1) { + return stringSet.CHANNEL__MESSAGE_LIST__NOTIFICATION__UNREAD_MESSAGE; + } else if (count > 1) { + return stringSet.CHANNEL__MESSAGE_LIST__NOTIFICATION__UNREAD_MESSAGE_S; } - }, [time, lastReadAt]); + }, [count]); return (
diff --git a/src/modules/GroupChannel/context/GroupChannelProvider.tsx b/src/modules/GroupChannel/context/GroupChannelProvider.tsx index 84649688e..8af16c418 100644 --- a/src/modules/GroupChannel/context/GroupChannelProvider.tsx +++ b/src/modules/GroupChannel/context/GroupChannelProvider.tsx @@ -32,6 +32,7 @@ import type { import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; import useDeepCompareEffect from '../../../hooks/useDeepCompareEffect'; import { deleteNullish } from '../../../utils/utils'; +import { CollectionEventSource } from '@sendbird/chat'; const initialState = { currentChannel: null, @@ -177,6 +178,31 @@ const GroupChannelManager :React.FC(undefined); + + const markAsUnread = useMemo(() => (message: any, source?: 'menu' | 'internal') => { + if (!config.groupChannel.enableMarkAsUnread) return; + if (!state.currentChannel) { + logger?.error?.('GroupChannelProvider: channel is required for markAsUnread'); + return; + } + + try { + if (state.currentChannel.markAsUnread) { + state.currentChannel.markAsUnread(message); + logger?.info?.('GroupChannelProvider: markAsUnread called for message', { + messageId: message.messageId, + source: source || 'unknown', + }); + markAsUnreadSourceRef.current = source || 'internal'; + } else { + logger?.error?.('GroupChannelProvider: markAsUnread method not available in current SDK version'); + } + } catch (error) { + logger?.error?.('GroupChannelProvider: markAsUnread failed', error); + } + }, [state.currentChannel, logger]); + // Message Collection setup const messageDataSource = useGroupChannelMessages(sdkStore.sdk, state.currentChannel!, { startingPoint, @@ -184,8 +210,10 @@ const GroupChannelManager :React.FC !isScrollBottomReached, markAsRead: (channels) => { - if (isScrollBottomReached && !disableMarkAsRead) { - channels.forEach((it) => markAsReadScheduler.push(it)); + if (!config.groupChannel.enableMarkAsUnread) { + if (isScrollBottomReached && !disableMarkAsRead) { + channels.forEach((it) => markAsReadScheduler.push(it)); + } } }, onMessagesReceived: (messages) => { @@ -206,7 +234,10 @@ const GroupChannelManager :React.FC { + onChannelUpdated: (channel, ctx) => { + if (ctx.source === CollectionEventSource.EVENT_CHANNEL_UNREAD) { + console.log('MADOKA #1 onChannelUpdated', ctx.source, ctx.userIds); + } actions.setCurrentChannel(channel); }, logger: logger as any, @@ -345,6 +376,8 @@ const GroupChannelManager :React.FC void; handleChannelError: (error: SendbirdError) => void; + markAsReadAll: () => void; + markAsUnread: (message: SendableMessageType, source?: 'menu' | 'internal') => void; // Message actions sendUserMessage: (params: UserMessageCreateParams) => Promise; @@ -73,16 +75,28 @@ export const useGroupChannel = () => { if (config.isOnline && state.hasNext()) { await state.resetWithStartingPoint(Number.MAX_SAFE_INTEGER); } + state.scrollPubSub.publish('scrollToBottom', { animated }); if (state.currentChannel && !state.hasNext()) { state.resetNewMessages(); if (!state.disableMarkAsRead) { - markAsReadScheduler.push(state.currentChannel); + if (!config.groupChannel.enableMarkAsUnread) { + markAsReadScheduler.push(state.currentChannel); + } } } }, [state.scrollRef.current, config.isOnline, markAsReadScheduler]); + const markAsReadAll = useCallback(() => { + if (!state.markAsUnreadSourceRef?.current) { + state.resetNewMessages(); + } + if (config.isOnline && !state.disableMarkAsRead) { + markAsReadScheduler.push(state.currentChannel); + } + }, [config.isOnline, state.disableMarkAsRead, state.currentChannel]); + const scrollToMessage = useCallback(async ( createdAt: number, messageId: number, @@ -188,6 +202,8 @@ export const useGroupChannel = () => { return { setCurrentChannel, handleChannelError, + markAsReadAll, + markAsUnread: state.markAsUnread, setQuoteMessage, scrollToBottom, scrollToMessage, @@ -199,6 +215,8 @@ export const useGroupChannel = () => { }, [ setCurrentChannel, handleChannelError, + markAsReadAll, + state.markAsUnread, setQuoteMessage, scrollToBottom, scrollToMessage, diff --git a/src/modules/GroupChannel/context/types.ts b/src/modules/GroupChannel/context/types.ts index 76cff319e..490a1e3ac 100644 --- a/src/modules/GroupChannel/context/types.ts +++ b/src/modules/GroupChannel/context/types.ts @@ -63,6 +63,10 @@ interface InternalGroupChannelState extends MessageDataSource { disableMarkAsRead: boolean; scrollBehavior: 'smooth' | 'auto'; + // Actions (React UIKit specific) + markAsUnread?: (message: SendableMessageType) => void; + markAsUnreadSourceRef: React.MutableRefObject<'menu' | 'internal' | null>; + // Legacy - Will be removed after migration scrollPubSub: PubSubTypes; } diff --git a/src/svgs/icon-floating-button-close.svg b/src/svgs/icon-floating-button-close.svg new file mode 100644 index 000000000..bef36eba2 --- /dev/null +++ b/src/svgs/icon-floating-button-close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/svgs/icon-mark-as-unread.svg b/src/svgs/icon-mark-as-unread.svg new file mode 100644 index 000000000..2eacb63f0 --- /dev/null +++ b/src/svgs/icon-mark-as-unread.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/ui/DateSeparator/index.tsx b/src/ui/DateSeparator/index.tsx index 837976273..46d506bdc 100644 --- a/src/ui/DateSeparator/index.tsx +++ b/src/ui/DateSeparator/index.tsx @@ -13,14 +13,17 @@ export interface DateSeparatorProps { children?: string | ReactElement; className?: string | Array; separatorColor?: Colors; + hasNewMessageSeparator?: boolean; } const DateSeparator = ({ children = undefined, className = '', - separatorColor = Colors.ONBACKGROUND_4, + hasNewMessageSeparator = false, + separatorColor = hasNewMessageSeparator ? Colors.PRIMARY : Colors.ONBACKGROUND_4, }: DateSeparatorProps): ReactElement => { return (
; case Types.FEEDBACK_LIKE: return ; case Types.FEEDBACK_DISLIKE: return ; + case Types.MARK_AS_UNREAD: return ; + case Types.FLOATING_BUTTON_CLOSE: return ; default: return 'icon'; // If you see this text 'icon' replace icon for it } } diff --git a/src/ui/Icon/type.ts b/src/ui/Icon/type.ts index 5030719e5..b9622a596 100644 --- a/src/ui/Icon/type.ts +++ b/src/ui/Icon/type.ts @@ -32,6 +32,7 @@ export const Types = { GIF: 'GIF', INFO: 'INFO', LEAVE: 'LEAVE', + MARK_AS_UNREAD: 'MARK_AS_UNREAD', MEMBERS: 'MEMBERS', MESSAGE: 'MESSAGE', MODERATIONS: 'MODERATIONS', @@ -60,5 +61,6 @@ export const Types = { USER: 'USER', FEEDBACK_LIKE: 'FEEDBACK_LIKE', FEEDBACK_DISLIKE: 'FEEDBACK_DISLIKE', + FLOATING_BUTTON_CLOSE: 'FLOATING_BUTTON_CLOSE', } as const; export type Types = typeof Types[keyof typeof Types]; diff --git a/src/ui/Icon/utils.ts b/src/ui/Icon/utils.ts index d1451d1f8..446320be9 100644 --- a/src/ui/Icon/utils.ts +++ b/src/ui/Icon/utils.ts @@ -59,6 +59,7 @@ export function changeTypeToIconClassName(type: Types): string { case Types.GIF: return 'sendbird-icon-gif'; case Types.INFO: return 'sendbird-icon-info'; case Types.LEAVE: return 'sendbird-icon-leave'; + case Types.MARK_AS_UNREAD: return 'sendbird-icon-mark-as-unread'; case Types.MEMBERS: return 'sendbird-icon-members'; case Types.MESSAGE: return 'sendbird-icon-message'; case Types.MODERATIONS: return 'sendbird-icon-moderations'; diff --git a/src/ui/Label/stringSet.ts b/src/ui/Label/stringSet.ts index f099849aa..b1528e901 100644 --- a/src/ui/Label/stringSet.ts +++ b/src/ui/Label/stringSet.ts @@ -12,7 +12,10 @@ const stringSet = { en: { // Group Channel - Conversation MESSAGE_STATUS__YESTERDAY: 'Yesterday', - CHANNEL__MESSAGE_LIST__NOTIFICATION__NEW_MESSAGE: 'new message(s) since', + CHANNEL__MESSAGE_LIST__NOTIFICATION__NEW_MESSAGE: 'new message', + CHANNEL__MESSAGE_LIST__NOTIFICATION__NEW_MESSAGE_S: 'new message', + CHANNEL__MESSAGE_LIST__NOTIFICATION__UNREAD_MESSAGE: 'unread message', + CHANNEL__MESSAGE_LIST__NOTIFICATION__UNREAD_MESSAGE_S: 'unread messages', /** @deprecated Please use `DATE_FORMAT__MESSAGE_LIST__NOTIFICATION__UNREAD_SINCE` instead * */ CHANNEL__MESSAGE_LIST__NOTIFICATION__ON: 'on', // Channel List @@ -168,6 +171,7 @@ const stringSet = { MESSAGE_MENU__RESEND: 'Resend', MESSAGE_MENU__DELETE: 'Delete', MESSAGE_MENU__SAVE: 'Save', + MESSAGE_MENU__MARK_AS_UNREAD: 'Mark as unread', // * FIXME: get back legacy, remove after refactoring open channel messages * CONTEXT_MENU_DROPDOWN__COPY: 'Copy', CONTEXT_MENU_DROPDOWN__EDIT: 'Edit', diff --git a/src/ui/MessageContent/index.tsx b/src/ui/MessageContent/index.tsx index 546f5685c..2d59432ab 100644 --- a/src/ui/MessageContent/index.tsx +++ b/src/ui/MessageContent/index.tsx @@ -77,6 +77,7 @@ export interface MessageContentProps extends MessageComponentRenderers { deleteMessage?: (message: CoreMessageType) => Promise; toggleReaction?: (message: SendableMessageType, reactionKey: string, isReacted: boolean) => void; setQuoteMessage?: (message: SendableMessageType) => void; + markAsUnread?: (message: SendableMessageType) => void; // onClick listener for thread replies view (for open thread module) onReplyInThread?: (props: { message: SendableMessageType }) => void; // onClick listener for thread quote message view (for open thread module) @@ -120,6 +121,7 @@ export function MessageContent(props: MessageContentProps): ReactElement { deleteMessage, toggleReaction, setQuoteMessage, + markAsUnread, onReplyInThread, onQuoteMessageClick, onMessageHeightChange, @@ -310,6 +312,7 @@ export function MessageContent(props: MessageContentProps): ReactElement { showRemove, resendMessage, setQuoteMessage, + markAsUnread, onReplyInThread: ({ message }) => { if (threadReplySelectType === ThreadReplySelectType.THREAD) { onReplyInThread?.({ message }); @@ -543,6 +546,7 @@ export function MessageContent(props: MessageContentProps): ReactElement { showRemove, resendMessage, setQuoteMessage, + markAsUnread, onReplyInThread: ({ message }) => { if (threadReplySelectType === ThreadReplySelectType.THREAD) { onReplyInThread?.({ message }); @@ -574,6 +578,7 @@ export function MessageContent(props: MessageContentProps): ReactElement { setQuoteMessage, toggleReaction, showEdit, + markAsUnread, onReplyInThread: ({ message }) => { if (threadReplySelectType === ThreadReplySelectType.THREAD) { onReplyInThread?.({ message }); diff --git a/src/ui/MessageMenu/MessageMenu.tsx b/src/ui/MessageMenu/MessageMenu.tsx index ead309151..86bf00481 100644 --- a/src/ui/MessageMenu/MessageMenu.tsx +++ b/src/ui/MessageMenu/MessageMenu.tsx @@ -16,6 +16,7 @@ import { EditMenuItem, ResendMenuItem, DeleteMenuItem, + MarkAsUnreadMenuItem, } from './menuItems/MessageMenuItems'; import { ReplyType } from '../../types'; import { @@ -23,6 +24,7 @@ import { showMenuItemCopy, showMenuItemDelete, showMenuItemEdit, + showMenuItemMarkAsUnread, showMenuItemOpenInChannel, showMenuItemReply, showMenuItemResend, @@ -41,6 +43,7 @@ export type RenderMenuItemsParams = { EditMenuItem: (props: PrebuildMenuItemPropsType) => ReactElement; ResendMenuItem: (props: PrebuildMenuItemPropsType) => ReactElement; DeleteMenuItem: (props: PrebuildMenuItemPropsType) => ReactElement; + MarkAsUnreadMenuItem: (props: PrebuildMenuItemPropsType) => ReactElement; }; }; export interface MessageMenuProps { @@ -56,6 +59,7 @@ export interface MessageMenuProps { showRemove?: (bool: boolean) => void; deleteMessage?: (message: SendableMessageType) => void; resendMessage?: (message: SendableMessageType) => void; + markAsUnread?: (message: SendableMessageType) => void; setQuoteMessage?: (message: SendableMessageType) => void; onReplyInThread?: (props: { message: SendableMessageType }) => void; onMoveToParentMessage?: () => void; @@ -75,11 +79,12 @@ export const MessageMenu = ({ showRemove = noop, deleteMessage, resendMessage, + markAsUnread, setQuoteMessage, onReplyInThread, onMoveToParentMessage, }: MessageMenuProps) => { - const { state: { config: { isOnline } } } = useSendbird(); + const { state: { config: { isOnline, groupChannel: { enableMarkAsUnread } } } } = useSendbird(); const triggerRef = useRef(null); const containerRef = useRef(null); @@ -114,6 +119,7 @@ export const MessageMenu = ({ showRemove, deleteMessage, resendMessage, + markAsUnread, isOnline, disableDeleteMessage, triggerRef, @@ -135,6 +141,7 @@ export const MessageMenu = ({ ThreadMenuItem, OpenInChannelMenuItem, EditMenuItem, + MarkAsUnreadMenuItem, ResendMenuItem, DeleteMenuItem, }, @@ -145,6 +152,7 @@ export const MessageMenu = ({ {showMenuItemThread(params) && } {showMenuItemOpenInChannel(params) && } {showMenuItemEdit(params) && } + {showMenuItemMarkAsUnread(params) && enableMarkAsUnread && } {showMenuItemResend(params) && } {showMenuItemDelete(params) && } diff --git a/src/ui/MessageMenu/MessageMenuProvider.tsx b/src/ui/MessageMenu/MessageMenuProvider.tsx index 8dc80c95d..78180229a 100644 --- a/src/ui/MessageMenu/MessageMenuProvider.tsx +++ b/src/ui/MessageMenu/MessageMenuProvider.tsx @@ -11,6 +11,7 @@ interface CommonMessageMenuContextProps { showRemove: (bool: boolean) => void; deleteMessage: (message: SendableMessageType) => void; resendMessage: (message: SendableMessageType) => void; + markAsUnread?: (message: SendableMessageType, source?: 'menu' | 'internal') => void; isOnline: boolean; disableDeleteMessage: boolean | null; triggerRef: MutableRefObject; diff --git a/src/ui/MessageMenu/menuItems/BottomSheetMenuItems.tsx b/src/ui/MessageMenu/menuItems/BottomSheetMenuItems.tsx index 81d934146..f5b10f6e8 100644 --- a/src/ui/MessageMenu/menuItems/BottomSheetMenuItems.tsx +++ b/src/ui/MessageMenu/menuItems/BottomSheetMenuItems.tsx @@ -208,3 +208,30 @@ export const DownloadMenuItem = (props: PrebuildMenuItemPropsType) => { ); }; + +export const MarkAsUnreadMenuItem = (props: PrebuildMenuItemPropsType) => { + const { stringSet } = useLocalization(); + const { message, hideMenu, markAsUnread } = useMessageMenuContext(); + + return ( + { + if (markAsUnread) { + markAsUnread(message, 'menu'); + } + hideMenu(); + props.onClick?.(e); + }} + > + {props.children ?? ( + <> + + + + )} + + ); +}; diff --git a/src/ui/MessageMenu/menuItems/MessageMenuItems.tsx b/src/ui/MessageMenu/menuItems/MessageMenuItems.tsx index 2b5867014..0d44fbfbb 100644 --- a/src/ui/MessageMenu/menuItems/MessageMenuItems.tsx +++ b/src/ui/MessageMenu/menuItems/MessageMenuItems.tsx @@ -179,3 +179,23 @@ export const DeleteMenuItem = (props: PrebuildMenuItemPropsType) => { ); }; + +export const MarkAsUnreadMenuItem = (props: PrebuildMenuItemPropsType) => { + const { stringSet } = useLocalization(); + const { message, hideMenu, markAsUnread } = useMessageMenuContext(); + + return ( + { + if (markAsUnread) { + markAsUnread(message, 'menu'); + } + hideMenu(); + props.onClick?.(e); + }} + > + {props.children ?? stringSet.MESSAGE_MENU__MARK_AS_UNREAD} + + ); +}; diff --git a/src/ui/MessageMenu/menuItems/MobileMenuItems.tsx b/src/ui/MessageMenu/menuItems/MobileMenuItems.tsx index af89a8d12..94b526840 100644 --- a/src/ui/MessageMenu/menuItems/MobileMenuItems.tsx +++ b/src/ui/MessageMenu/menuItems/MobileMenuItems.tsx @@ -207,3 +207,30 @@ export const DownloadMenuItem = (props: PrebuildMenuItemPropsType) => { ); }; + +export const MarkAsUnreadMenuItem = (props: PrebuildMenuItemPropsType) => { + const { stringSet } = useLocalization(); + const { message, hideMenu, markAsUnread } = useMessageMenuContext(); + + return ( + { + if (markAsUnread) { + markAsUnread(message, 'menu'); + } + hideMenu(); + props.onClick?.(e); + }} + > + {props.children ?? ( + <> + + + + )} + + ); +}; diff --git a/src/ui/MobileMenu/MobileBottomSheet.tsx b/src/ui/MobileMenu/MobileBottomSheet.tsx index c78959d2a..af48d6b44 100644 --- a/src/ui/MobileMenu/MobileBottomSheet.tsx +++ b/src/ui/MobileMenu/MobileBottomSheet.tsx @@ -15,6 +15,7 @@ import { isVoiceMessage, isThreadMessage, } from '../../utils'; +import { showMenuItemMarkAsUnread } from '../../utils/menuConditions'; import BottomSheet from '../BottomSheet'; import ImageRenderer from '../ImageRenderer'; import ReactionButton from '../ReactionButton'; @@ -29,6 +30,7 @@ import { ThreadMenuItem, DeleteMenuItem, DownloadMenuItem, + MarkAsUnreadMenuItem, } from '../MessageMenu/menuItems/BottomSheetMenuItems'; import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; @@ -56,7 +58,7 @@ const MobileBottomSheet: React.FunctionComponent = (prop renderMenuItems, } = props; const isByMe = message?.sender?.userId === userId; - const { state: { config: { isOnline } } } = useSendbird(); + const { state: { config: { isOnline, groupChannel: { enableMarkAsUnread } } } } = useSendbird(); const showMenuItemCopy: boolean = isUserMessage(message as UserMessage); const showMenuItemEdit: boolean = (isUserMessage(message as UserMessage) && isSentMessage(message) && isByMe); const showMenuItemResend: boolean = (isOnline && isFailedMessage(message) && message?.isResendable && isByMe); @@ -81,6 +83,13 @@ const MobileBottomSheet: React.FunctionComponent = (prop && !isPendingMessage(message) && !isThreadMessage(message) && (channel?.isGroupChannel() && !(channel as GroupChannel)?.isBroadcast); + // const showMenuItemMarkAsUnreadCondition = showMenuItemMarkAsUnread({ + // message, + // channel, + // isByMe, + // replyType, + // onReplyInThread, + // }); const maxEmojisPerRow = Math.floor(window.innerWidth / EMOJI_SIZE) - 1; const [showEmojisOnly, setShowEmojisOnly] = useState(false); @@ -98,6 +107,7 @@ const MobileBottomSheet: React.FunctionComponent = (prop showRemove, deleteMessage, resendMessage, + markAsUnread: props.markAsUnread, isOnline, disableDeleteMessage: disableDelete, triggerRef: null, @@ -192,11 +202,13 @@ const MobileBottomSheet: React.FunctionComponent = (prop ReplyMenuItem, ThreadMenuItem, DeleteMenuItem, + MarkAsUnreadMenuItem, }, }) ?? ( <> {showMenuItemCopy && } {showMenuItemEdit && } + {showMenuItemMarkAsUnread && enableMarkAsUnread && } {showMenuItemResend && } {showMenuItemReply && } {showMenuItemThread && } diff --git a/src/ui/MobileMenu/MobileContextMenu.tsx b/src/ui/MobileMenu/MobileContextMenu.tsx index 97a116664..e5be8a3f4 100644 --- a/src/ui/MobileMenu/MobileContextMenu.tsx +++ b/src/ui/MobileMenu/MobileContextMenu.tsx @@ -12,6 +12,7 @@ import { isThreadMessage, isVoiceMessage, } from '../../utils'; +import { showMenuItemMarkAsUnread } from '../../utils/menuConditions'; import { MessageMenuProvider } from '../MessageMenu'; import type { MobileMessageMenuContextProps } from '../MessageMenu/MessageMenuProvider'; @@ -23,6 +24,7 @@ import { ResendMenuItem, DeleteMenuItem, DownloadMenuItem, + MarkAsUnreadMenuItem, } from '../MessageMenu/menuItems/MobileMenuItems'; import { MenuItems } from '../ContextMenu'; import { noop } from '../../utils/utils'; @@ -48,7 +50,7 @@ const MobileContextMenu: React.FunctionComponent = (props: BaseMe hideMenu: hideMobileMenu, } = props; const isByMe = message?.sender?.userId === userId; - const { state: { config: { isOnline } } } = useSendbird(); + const { state: { config: { isOnline, groupChannel: { enableMarkAsUnread } } } } = useSendbird(); // Menu Items condition const showMenuItemCopy = isUserMessage(message as UserMessage); @@ -60,6 +62,13 @@ const MobileContextMenu: React.FunctionComponent = (props: BaseMe const showMenuItemDownload = !isPendingMessage(message) && isFileMessage(message) && !(isVoiceMessage(message) && (channel as GroupChannel)?.isSuper || (channel as GroupChannel)?.isBroadcast); const showMenuItemReply = replyType === 'QUOTE_REPLY' && !isFailedMessage(message) && !isPendingMessage(message) && channel?.isGroupChannel(); const showMenuItemThread = replyType === 'THREAD' && !isOpenedFromThread && !isFailedMessage(message) && !isPendingMessage(message) && !isThreadMessage(message) && channel?.isGroupChannel(); + const showMenuItemMarkAsUnreadCondition = showMenuItemMarkAsUnread({ + message, + channel, + isByMe, + replyType, + onReplyInThread, + }); const disableDeleteMessage = (deleteMenuState !== undefined && deleteMenuState === 'DISABLE') || (message?.threadInfo?.replyCount ?? 0) > 0; const contextValue: MobileMessageMenuContextProps = { @@ -72,6 +81,7 @@ const MobileContextMenu: React.FunctionComponent = (props: BaseMe showRemove, deleteMessage, resendMessage, + markAsUnread: props.markAsUnread, isOnline, disableDeleteMessage, triggerRef: parentRef as MutableRefObject, @@ -94,6 +104,7 @@ const MobileContextMenu: React.FunctionComponent = (props: BaseMe ReplyMenuItem, ThreadMenuItem, EditMenuItem, + MarkAsUnreadMenuItem, ResendMenuItem, DeleteMenuItem, }, @@ -103,6 +114,7 @@ const MobileContextMenu: React.FunctionComponent = (props: BaseMe {showMenuItemReply && } {showMenuItemThread && } {showMenuItemEdit && } + {showMenuItemMarkAsUnreadCondition && enableMarkAsUnread && } {showMenuItemResend && } {showMenuItemDeleteFinal && } {showMenuItemDownload && } diff --git a/src/ui/MobileMenu/types.ts b/src/ui/MobileMenu/types.ts index 444e85655..35f6ad9a8 100644 --- a/src/ui/MobileMenu/types.ts +++ b/src/ui/MobileMenu/types.ts @@ -26,6 +26,7 @@ export interface BaseMenuProps { showRemove?: (bool: boolean) => void; resendMessage?: (message: SendableMessageType) => void; deleteMessage?: (message: CoreMessageType) => Promise; + markAsUnread?: (message: SendableMessageType) => void; setQuoteMessage?: (message: SendableMessageType) => void; isReactionEnabled?: boolean; parentRef?: React.RefObject; diff --git a/src/utils/menuConditions.ts b/src/utils/menuConditions.ts index 5015c1928..d142c24ec 100644 --- a/src/utils/menuConditions.ts +++ b/src/utils/menuConditions.ts @@ -53,3 +53,7 @@ export const showMenuItemReply = ({ channel, message, replyType }: MenuCondition export const showMenuItemThread = ({ channel, message, replyType, onReplyInThread }: MenuConditionsParams) => { return isReplyTypeMessageEnabled({ channel, message }) && replyType === 'THREAD' && !message?.parentMessageId && typeof onReplyInThread === 'function'; }; + +export const showMenuItemMarkAsUnread = ({ message, channel }: MenuConditionsParams) => { + return !isFailedMessage(message) && !isPendingMessage(message) && channel?.isGroupChannel?.(); +}; diff --git a/yarn.lock b/yarn.lock index 5f8eca2e5..98de84965 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2747,9 +2747,9 @@ __metadata: languageName: node linkType: hard -"@sendbird/chat@npm:^4.19.1": - version: 4.19.1 - resolution: "@sendbird/chat@npm:4.19.1" +"@sendbird/chat@npm:^4.19.2": + version: 4.19.2 + resolution: "@sendbird/chat@npm:4.19.2" peerDependencies: "@react-native-async-storage/async-storage": ^1.17.6 react-native-mmkv: ">=2.0.0" @@ -2758,29 +2758,29 @@ __metadata: optional: true react-native-mmkv: optional: true - checksum: d500cbd6d994dbfdc8754814b61e8011c2aceb0c5c2a19780fdb9692cdd91a83a750c59aa4dc605c563394abed8242a0a589aa726ed097f2e49e3b7ddef59a65 + checksum: 6cb69158f19633252f474c6edd1ae1e55d1d46dc1dcf628a0a5c5108ee1b9cafd5a2cde63aeb011237d9fdc5789c8388209ced2fc97b0d9841d5c5aa2bb6d9da languageName: node linkType: hard -"@sendbird/react-uikit-message-template-view@npm:^0.0.8": - version: 0.0.8 - resolution: "@sendbird/react-uikit-message-template-view@npm:0.0.8" +"@sendbird/react-uikit-message-template-view@npm:^0.0.10": + version: 0.0.10 + resolution: "@sendbird/react-uikit-message-template-view@npm:0.0.10" dependencies: - "@sendbird/uikit-message-template": ^0.0.8 + "@sendbird/uikit-message-template": ^0.0.10 peerDependencies: "@sendbird/chat": ">=4.3.0 <5" react: ">=16.8.6" react-dom: ">=16.8.6" - checksum: ac178463097f84e7953889b379d34af200a1dc950a3933ab02affedf6bfcf88918c745791ad99dc22720d762b64d2b63ef71fbad7e36287e3d4b1d0dd74c088a + checksum: aac92fb0b963c4ee3f31eea05839cd49c5ad8561c427b5778731fdf61bcbc18076b329ab2866f12b776e77e9b2a9bfbba12263bcf25d4949a34ab80120548d2f languageName: node linkType: hard -"@sendbird/uikit-message-template@npm:^0.0.8": - version: 0.0.8 - resolution: "@sendbird/uikit-message-template@npm:0.0.8" +"@sendbird/uikit-message-template@npm:^0.0.10": + version: 0.0.10 + resolution: "@sendbird/uikit-message-template@npm:0.0.10" peerDependencies: react: ">=16.8.6" - checksum: 59c7f6f78fb6513d62b6fc4bde94bb8701c03d1f8a1e74cc4e9a0aff03d03d2ba813c04ea4c7b3c12a5d19d27f1270ff8e4f3a959329248a7b7d1e0dca4925c8 + checksum: 18dfce8f7c83d37a94bc3cbca0f561d7ed10c4367bcb2d2bf9c9192fce8fa06cfd87e8f968c6f3ae6d464a5119338ee5167b1a055b777bd906664a225c7e486f languageName: node linkType: hard @@ -2803,9 +2803,9 @@ __metadata: "@rollup/plugin-node-resolve": ^15.2.3 "@rollup/plugin-replace": ^5.0.4 "@rollup/plugin-typescript": ^11.1.5 - "@sendbird/chat": ^4.19.1 - "@sendbird/react-uikit-message-template-view": ^0.0.8 - "@sendbird/uikit-tools": ^0.0.8 + "@sendbird/chat": ^4.19.2 + "@sendbird/react-uikit-message-template-view": ^0.0.10 + "@sendbird/uikit-tools": ^0.0.10 "@storybook/addon-essentials": ^8.5.0 "@storybook/manager-api": ^8.5.0 "@storybook/react-vite": ^8.5.0 @@ -2870,13 +2870,13 @@ __metadata: languageName: unknown linkType: soft -"@sendbird/uikit-tools@npm:^0.0.8": - version: 0.0.8 - resolution: "@sendbird/uikit-tools@npm:0.0.8" +"@sendbird/uikit-tools@npm:^0.0.10": + version: 0.0.10 + resolution: "@sendbird/uikit-tools@npm:0.0.10" peerDependencies: "@sendbird/chat": ">=4.10.5 <5" react: ">=16.8.6" - checksum: 5c91ea3d628a317a62e28a6c8f78660844e7b07cb1a7aa7d4827798e08f8a381a19aa994645e92f37612e9deb87f7cef82dd45fc6f3ff0b143d7e0d6e162d663 + checksum: 1b92fe4a5caced7efff4397fdf87912bc958b45f5eae1f986f78961759b18687901cc28bbc6f797a6fb0e1552e56ef5e04a16da0125883f18b00e99fbee5f44b languageName: node linkType: hard From 443585b63134491bb1349d6858e450a6b64c4b1b Mon Sep 17 00:00:00 2001 From: danney-chun <63285271+danney-chun@users.noreply.github.com> Date: Wed, 2 Jul 2025 11:47:42 +0900 Subject: [PATCH 02/19] added undefined check --- src/modules/GroupChannel/components/MessageList/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/modules/GroupChannel/components/MessageList/index.tsx b/src/modules/GroupChannel/components/MessageList/index.tsx index 22c614a9b..4ab48b5f5 100644 --- a/src/modules/GroupChannel/components/MessageList/index.tsx +++ b/src/modules/GroupChannel/components/MessageList/index.tsx @@ -115,7 +115,9 @@ export const MessageList = (props: GroupChannelMessageListProps) => { useEffect(() => { firstUnreadMessageIdRef.current = undefined; displayedNewMessageSeparatorRef.current = false; - markAsUnreadSourceRef.current = undefined; + if (markAsUnreadSourceRef !== undefined) { + markAsUnreadSourceRef.current = undefined; + } }, [currentChannel]); /** @@ -142,7 +144,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { return renderFrozenNotification(); }, unreadMessagesNotification() { - if (markAsUnreadSourceRef.current === 'menu' || displayedNewMessageSeparatorRef.current) return null; + if (markAsUnreadSourceRef?.current === 'menu' || displayedNewMessageSeparatorRef.current) return null; return ( Date: Fri, 4 Jul 2025 03:32:35 +0900 Subject: [PATCH 03/19] fixed QA --- src/modules/App/AppLayout.tsx | 2 +- .../Channel/components/MessageList/index.tsx | 2 +- .../components/Message/MessageView.tsx | 7 +- .../MessageList/getMessagePartsInfo.ts | 14 +- .../components/MessageList/index.tsx | 193 ++++++++++++------ .../context/GroupChannelProvider.tsx | 18 +- .../context/hooks/useGroupChannel.ts | 20 +- src/modules/GroupChannel/context/types.ts | 3 +- .../GroupChannelListItemView.tsx | 3 +- src/ui/DateSeparator/index.tsx | 37 +++- src/ui/MessageMenu/MessageMenuProvider.tsx | 2 +- .../menuItems/BottomSheetMenuItems.tsx | 2 +- .../menuItems/MessageMenuItems.tsx | 2 +- .../MessageMenu/menuItems/MobileMenuItems.tsx | 2 +- 14 files changed, 210 insertions(+), 97 deletions(-) diff --git a/src/modules/App/AppLayout.tsx b/src/modules/App/AppLayout.tsx index 1947f215b..c0d160011 100644 --- a/src/modules/App/AppLayout.tsx +++ b/src/modules/App/AppLayout.tsx @@ -39,7 +39,7 @@ export const AppLayout = (props: AppLayoutProps) => { const replyType = props.replyType ?? getCaseResolvedReplyType(globalConfigs.groupChannel.replyType).upperCase; const isReactionEnabled = props.isReactionEnabled ?? globalConfigs.groupChannel.enableReactions; const showSearchIcon = props.showSearchIcon ?? globalConfigs.groupChannelSettings.enableMessageSearch; - + return ( <> { diff --git a/src/modules/Channel/components/MessageList/index.tsx b/src/modules/Channel/components/MessageList/index.tsx index 6e6b36a3d..c863b3c1a 100644 --- a/src/modules/Channel/components/MessageList/index.tsx +++ b/src/modules/Channel/components/MessageList/index.tsx @@ -163,7 +163,7 @@ export const MessageList = (props: MessageListProps) => { // markAsUnreadSourceRef의 현재 값을 확인 const currentSource = markAsUnreadSourceRef.current; console.log('Channel MessageList: markAsUnreadSourceRef current value:', currentSource); - + messagesDispatcher({ type: messageActionTypes.MARK_AS_READ, payload: { channel: currentGroupChannel }, diff --git a/src/modules/GroupChannel/components/Message/MessageView.tsx b/src/modules/GroupChannel/components/Message/MessageView.tsx index 85324ffc1..951802fd3 100644 --- a/src/modules/GroupChannel/components/Message/MessageView.tsx +++ b/src/modules/GroupChannel/components/Message/MessageView.tsx @@ -50,6 +50,10 @@ export interface MessageProps { * A function that customizes the rendering of the edit input portion of the message component. * */ renderEditInput?: () => React.ReactElement; + /** + * A function that is called when the new message separator visibility changes. + */ + onNewMessageSeparatorVisibilityChange?: (isVisible: boolean) => void; /** * @deprecated Please use `children` instead * @description Customizes all child components of the message. @@ -113,6 +117,7 @@ const MessageView = (props: MessageViewProps) => { chainTop, chainBottom, handleScroll, + onNewMessageSeparatorVisibilityChange, // MessageViewProps channel, @@ -422,7 +427,7 @@ const MessageView = (props: MessageViewProps) => { {/* date-separator */} {(hasSeparator || hasNewMessageSeparator) && (renderedCustomSeparator || ( - + diff --git a/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts b/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts index fbf067ca4..67dc4ecc2 100644 --- a/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts +++ b/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts @@ -14,6 +14,7 @@ export interface GetMessagePartsInfoProps { currentMessage: CoreMessageType; currentChannel?: GroupChannel | null; replyType?: string; + hasPrevious?: boolean; firstUnreadMessageId?: number | string | undefined; } @@ -22,7 +23,6 @@ interface OutPuts { chainBottom: boolean, hasSeparator: boolean, hasNewMessageSeparator: boolean, - newFirstUnreadMessageId?: number | string | undefined, } /** @@ -51,21 +51,13 @@ export const getMessagePartsInfo = ({ // https://stackoverflow.com/a/41855608 const hasSeparator = isLocalMessage ? false : !(previousMessageCreatedAt && (isSameDay(currentCreatedAt, previousMessageCreatedAt))); - let hasNewMessageSeparator = false; - let newFirstUnreadMessageId; - if (!firstUnreadMessageId) { - hasNewMessageSeparator = currentChannel.myLastRead === (currentCreatedAt - 1); - if (hasNewMessageSeparator) newFirstUnreadMessageId = currentMessage.messageId; - } else if (currentMessage.messageId === firstUnreadMessageId || currentChannel.myLastRead === (currentCreatedAt - 1)) { - hasNewMessageSeparator = true; - newFirstUnreadMessageId = currentMessage.messageId; - } + + const hasNewMessageSeparator = firstUnreadMessageId === currentMessage.messageId; return { chainTop, chainBottom, hasSeparator, hasNewMessageSeparator, - newFirstUnreadMessageId, }; }; diff --git a/src/modules/GroupChannel/components/MessageList/index.tsx b/src/modules/GroupChannel/components/MessageList/index.tsx index 22c614a9b..a0e0bf2c7 100644 --- a/src/modules/GroupChannel/components/MessageList/index.tsx +++ b/src/modules/GroupChannel/components/MessageList/index.tsx @@ -1,6 +1,6 @@ import './index.scss'; -import React, { useEffect, useState, useRef } from 'react'; -import type { Member } from '@sendbird/chat/groupChannel'; +import React, { useEffect, useState, useRef, useMemo } from 'react'; +import type { GroupChannel, Member } from '@sendbird/chat/groupChannel'; import { useGroupChannelHandler } from '@sendbird/uikit-tools'; import { CoreMessageType, isSendableMessage, getHTMLTextDirection, SendableMessageType } from '../../../../utils'; @@ -72,6 +72,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { state: { channelUrl, hasNext, + hasPrevious, loading, messages, newMessages, @@ -87,12 +88,14 @@ export const MessageList = (props: GroupChannelMessageListProps) => { scrollPositionRef, scrollDistanceFromBottomRef, markAsUnreadSourceRef, + readState, }, actions: { scrollToBottom, setIsScrollBottomReached, markAsReadAll, markAsUnread, + setReadStateChanged, }, } = useGroupChannel(); @@ -100,23 +103,123 @@ export const MessageList = (props: GroupChannelMessageListProps) => { const { stringSet } = useLocalization(); const [unreadSinceDate, setUnreadSinceDate] = useState(); + const [isChangedChannel, setIsChangedChannel] = useState(false); + const [showUnreadCount, setShowUnreadCount] = useState(true); const firstUnreadMessageIdRef = useRef(); - const displayedNewMessageSeparatorRef = useRef(false); + const hasInitializedRef = useRef(false); + // current channel url ref + const currentChannelUrlRef = useRef(); + // current messages ref + const currentMessagesRef = useRef([]); + + /** + * Find the first unread message in the message list + */ + const findFirstUnreadMessage = ( + message: CoreMessageType, + index: number, + allMessages: CoreMessageType[], + currentChannel: GroupChannel, + hasPrevious: boolean, + ): boolean => { + const currentCreatedAt = message.createdAt; + + // condition 1: message.createdAt === (channel.myLastRead + 1) + if (currentCreatedAt === (currentChannel.myLastRead + 1)) { + return true; + } + + // condition 2: there is no previous message that satisfies the condition, and the current message is the first message that satisfies the condition + if (!hasPrevious && currentCreatedAt > (currentChannel.myLastRead + 1)) { + const hasPreviousMatchingMessage = allMessages + .slice(0, index) + .some(msg => msg.createdAt === (currentChannel.myLastRead + 1)); + return !hasPreviousMatchingMessage; + } + return false; + }; + + const getFirstUnreadMessage = (): number | string => { + if (state.config.groupChannel.enableMarkAsUnread) { + for (let i = 0; i < messages.length; i++) { + const message = messages[i] as CoreMessageType; + const isFind = findFirstUnreadMessage(message, i, messages as CoreMessageType[], currentChannel, hasPrevious()); + if (isFind) { + return message.messageId; + } + } + } + return undefined; + }; + + // check changed channel useEffect(() => { - if (isScrollBottomReached) { - setUnreadSinceDate(undefined); + if (currentChannel?.url !== currentChannelUrlRef.current) { + currentChannelUrlRef.current = currentChannel?.url; + + // initialize + firstUnreadMessageIdRef.current = undefined; + setShowUnreadCount(true); + hasInitializedRef.current = false; + if (markAsUnreadSourceRef?.current !== undefined) { + markAsUnreadSourceRef.current = undefined; + } + setIsChangedChannel(true); } else { - setUnreadSinceDate(new Date()); + setIsChangedChannel(false); } - }, [isScrollBottomReached]); + }, [currentChannel?.url]); + // check changed messages useEffect(() => { - firstUnreadMessageIdRef.current = undefined; - displayedNewMessageSeparatorRef.current = false; - markAsUnreadSourceRef.current = undefined; - }, [currentChannel]); + if (isChangedChannel) { + if (!hasInitializedRef.current) { + if (currentMessagesRef.current !== messages) { + currentMessagesRef.current = messages as CoreMessageType[]; + const firstUnreadMessageId = getFirstUnreadMessage(); + if (firstUnreadMessageId) { + firstUnreadMessageIdRef.current = firstUnreadMessageId; + } + hasInitializedRef.current = true; + } + } + } + }, [messages]); + + useMemo(() => { + if (hasInitializedRef.current) { + const firstUnreadMessageId = getFirstUnreadMessage(); + + if (firstUnreadMessageId && firstUnreadMessageIdRef.current !== firstUnreadMessageId) { + firstUnreadMessageIdRef.current = firstUnreadMessageId; + } + } + }, [messages.length]); + + useEffect(() => { + if (hasInitializedRef.current) { + if (readState === 'unread') { + // when readState === 'unread' find first unread message + const firstUnreadMessageId = getFirstUnreadMessage(); + if (firstUnreadMessageId !== firstUnreadMessageIdRef.current) { + firstUnreadMessageIdRef.current = firstUnreadMessageId; + } + } + setReadStateChanged(null); + } + }, [readState]); + + useEffect(() => { + if (!state.config.groupChannel.enableMarkAsUnread) { + if (isScrollBottomReached) { + setUnreadSinceDate(undefined); + } else { + setUnreadSinceDate(new Date()); + } + } + }, [isScrollBottomReached]); /** * 1. Move the message list scroll @@ -142,7 +245,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { return renderFrozenNotification(); }, unreadMessagesNotification() { - if (markAsUnreadSourceRef.current === 'menu' || displayedNewMessageSeparatorRef.current) return null; + if (markAsUnreadSourceRef?.current === 'manual' || !showUnreadCount) return null; return ( { lastReadAt={unreadSinceDate} isFrozenChannel={currentChannel?.isFrozen || false} onClick={() => { - markAsReadAll(); + markAsReadAll(currentChannel); }} /> ); @@ -182,36 +285,15 @@ export const MessageList = (props: GroupChannelMessageListProps) => { }, }; - const checkNewMessageSeparatorVisibility = () => { - if (displayedNewMessageSeparatorRef.current) return false; - if (currentChannel.unreadMessageCount === 0) return false; - - const newMessageSeparator = document.getElementById('new-message-separator'); - if (!newMessageSeparator) return false; - - const newMessageSeparatorRect = newMessageSeparator.getBoundingClientRect(); - const messageListContainer = document.querySelector('.sendbird-conversation__messages-padding'); - if (!messageListContainer) return false; - - const messageListContainerRect = messageListContainer.getBoundingClientRect(); - const isTopInContainer = newMessageSeparatorRect.top > messageListContainerRect.top; - - if (isTopInContainer) { - displayedNewMessageSeparatorRef.current = true; - return true; - } - - return false; - }; - - const checkDisplayNewMessageSeparator = () => { - if (!checkNewMessageSeparatorVisibility()) return; - - if (displayedNewMessageSeparatorRef.current && newMessages.length > 0) { - markAsUnread(newMessages[0] as SendableMessageType, 'internal'); - firstUnreadMessageIdRef.current = newMessages[0].messageId; - } else if (displayedNewMessageSeparatorRef.current) { - markAsReadAll(); + const checkDisplayedNewMessageSeparator = (isNewMessageSeparatorVisible: boolean) => { + if (isNewMessageSeparatorVisible) { + setShowUnreadCount(false); + if (newMessages.length > 0) { + markAsUnread(newMessages[0] as SendableMessageType, 'internal'); + firstUnreadMessageIdRef.current = newMessages[0].messageId; + } else if (markAsUnreadSourceRef?.current !== 'manual') { + markAsReadAll(currentChannel); + } } }; @@ -241,22 +323,14 @@ export const MessageList = (props: GroupChannelMessageListProps) => { onLoadPrevious={loadPrevious} onScrollPosition={(it) => { const isScrollBottomReached = it === 'bottom'; - if (isScrollBottomReached) { - if (newMessages.length > 0) { - resetNewMessages(); - } - if (!state.config.groupChannel.enableMarkAsUnread || displayedNewMessageSeparatorRef.current) { - markAsReadAll(); - } - } else if (!displayedNewMessageSeparatorRef.current) { - checkDisplayNewMessageSeparator(); + if (hasInitializedRef.current && isScrollBottomReached && newMessages.length > 0) { + resetNewMessages(); } - setIsScrollBottomReached(isScrollBottomReached); }} messages={messages} renderMessage={({ message, index }) => { - const { chainTop, chainBottom, hasSeparator, hasNewMessageSeparator, newFirstUnreadMessageId } = getMessagePartsInfo({ + const { chainTop, chainBottom, hasSeparator, hasNewMessageSeparator } = getMessagePartsInfo({ allMessages: messages as CoreMessageType[], stringSet, replyType: replyType ?? 'NONE', @@ -264,18 +338,10 @@ export const MessageList = (props: GroupChannelMessageListProps) => { currentIndex: index, currentMessage: message as CoreMessageType, currentChannel: currentChannel!, + hasPrevious: hasPrevious(), firstUnreadMessageId: firstUnreadMessageIdRef.current, }); - if (hasNewMessageSeparator && newFirstUnreadMessageId && newFirstUnreadMessageId !== firstUnreadMessageIdRef.current) { - firstUnreadMessageIdRef.current = newFirstUnreadMessageId; - if (markAsUnreadSourceRef.current === 'menu') { - checkNewMessageSeparatorVisibility(); - } else { - checkDisplayNewMessageSeparator(); - } - } - const isOutgoingMessage = isSendableMessage(message) && message.sender.userId === state.config.userId; return ( @@ -289,6 +355,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { renderMessageContent, renderSuggestedReplies, renderCustomSeparator, + onNewMessageSeparatorVisibilityChange: checkDisplayedNewMessageSeparator, })} ); diff --git a/src/modules/GroupChannel/context/GroupChannelProvider.tsx b/src/modules/GroupChannel/context/GroupChannelProvider.tsx index 8af16c418..51cee1faa 100644 --- a/src/modules/GroupChannel/context/GroupChannelProvider.tsx +++ b/src/modules/GroupChannel/context/GroupChannelProvider.tsx @@ -46,6 +46,7 @@ const initialState = { quoteMessage: null, animatedMessageId: null, isScrollBottomReached: true, + readState: null, scrollRef: { current: null }, scrollDistanceFromBottomRef: { current: 0 }, @@ -150,7 +151,7 @@ const GroupChannelManager :React.FC(undefined); + const markAsUnreadSourceRef = useRef<'manual' | 'internal' | undefined>(undefined); - const markAsUnread = useMemo(() => (message: any, source?: 'menu' | 'internal') => { + const markAsUnread = useMemo(() => (message: any, source?: 'manual' | 'internal') => { if (!config.groupChannel.enableMarkAsUnread) return; if (!state.currentChannel) { logger?.error?.('GroupChannelProvider: channel is required for markAsUnread'); @@ -235,8 +236,15 @@ const GroupChannelManager :React.FC { - if (ctx.source === CollectionEventSource.EVENT_CHANNEL_UNREAD) { - console.log('MADOKA #1 onChannelUpdated', ctx.source, ctx.userIds); + if (ctx.source === CollectionEventSource.EVENT_CHANNEL_UNREAD + && ctx.userIds.includes(userId) + ) { + actions.setReadStateChanged('unread'); + } + if (ctx.source === CollectionEventSource.EVENT_CHANNEL_READ + && ctx.userIds.includes(userId) + ) { + actions.setReadStateChanged('read'); } actions.setCurrentChannel(channel); }, diff --git a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts index e22ff3dc9..2033ccabe 100644 --- a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts +++ b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts @@ -24,8 +24,9 @@ export interface GroupChannelActions extends MessageActions { // Channel actions setCurrentChannel: (channel: GroupChannel) => void; handleChannelError: (error: SendbirdError) => void; - markAsReadAll: () => void; - markAsUnread: (message: SendableMessageType, source?: 'menu' | 'internal') => void; + markAsReadAll: (channel: GroupChannel) => void; + markAsUnread: (message: SendableMessageType, source?: 'manual' | 'internal') => void; + setReadStateChanged: (state: string) => void; // Message actions sendUserMessage: (params: UserMessageCreateParams) => Promise; @@ -88,14 +89,11 @@ export const useGroupChannel = () => { } }, [state.scrollRef.current, config.isOnline, markAsReadScheduler]); - const markAsReadAll = useCallback(() => { - if (!state.markAsUnreadSourceRef?.current) { - state.resetNewMessages(); - } + const markAsReadAll = useCallback((channel: GroupChannel) => { if (config.isOnline && !state.disableMarkAsRead) { - markAsReadScheduler.push(state.currentChannel); + markAsReadScheduler.push(channel); } - }, [config.isOnline, state.disableMarkAsRead, state.currentChannel]); + }, [config.isOnline]); const scrollToMessage = useCallback(async ( createdAt: number, @@ -198,12 +196,17 @@ export const useGroupChannel = () => { store.setState(state => ({ ...state, quoteMessage: message })); }, []); + const setReadStateChanged = useCallback((readState: string) => { + store.setState(state => ({ ...state, readState })); + }, []); + const actions: GroupChannelActions = useMemo(() => { return { setCurrentChannel, handleChannelError, markAsReadAll, markAsUnread: state.markAsUnread, + setReadStateChanged, setQuoteMessage, scrollToBottom, scrollToMessage, @@ -217,6 +220,7 @@ export const useGroupChannel = () => { handleChannelError, markAsReadAll, state.markAsUnread, + setReadStateChanged, setQuoteMessage, scrollToBottom, scrollToMessage, diff --git a/src/modules/GroupChannel/context/types.ts b/src/modules/GroupChannel/context/types.ts index 490a1e3ac..3c647e719 100644 --- a/src/modules/GroupChannel/context/types.ts +++ b/src/modules/GroupChannel/context/types.ts @@ -46,6 +46,7 @@ interface InternalGroupChannelState extends MessageDataSource { quoteMessage: SendableMessageType | null; animatedMessageId: number | null; isScrollBottomReached: boolean; + readState: string | null; // References - will be managed together scrollRef: React.RefObject; @@ -65,7 +66,7 @@ interface InternalGroupChannelState extends MessageDataSource { // Actions (React UIKit specific) markAsUnread?: (message: SendableMessageType) => void; - markAsUnreadSourceRef: React.MutableRefObject<'menu' | 'internal' | null>; + markAsUnreadSourceRef: React.MutableRefObject<'manual' | 'internal' | null>; // Legacy - Will be removed after migration scrollPubSub: PubSubTypes; diff --git a/src/modules/GroupChannelList/components/GroupChannelListItem/GroupChannelListItemView.tsx b/src/modules/GroupChannelList/components/GroupChannelListItem/GroupChannelListItemView.tsx index 704a5fbae..4ff5f0b45 100644 --- a/src/modules/GroupChannelList/components/GroupChannelListItem/GroupChannelListItemView.tsx +++ b/src/modules/GroupChannelList/components/GroupChannelListItem/GroupChannelListItemView.tsx @@ -176,8 +176,9 @@ export const GroupChannelListItemView = ({ * Do not show unread count for focused channel. This is because of the limitation where * isScrollBottom and hasNext states needs to be added globally but they are from channel context * so channel list cannot see them with the current architecture. + * However, when enableMarkAsUnread is true, we show unread count even for selected channels. */ - !isSelected && !channel.isEphemeral && ( + (!isSelected || config.groupChannel.enableMarkAsUnread) && !channel.isEphemeral && (
{isMentionEnabled && channel.unreadMentionCount > 0 ? ( ; separatorColor?: Colors; hasNewMessageSeparator?: boolean; + onVisibilityChange?: (isVisible: boolean) => void; } + const DateSeparator = ({ children = undefined, className = '', hasNewMessageSeparator = false, + onVisibilityChange, separatorColor = hasNewMessageSeparator ? Colors.PRIMARY : Colors.ONBACKGROUND_4, }: DateSeparatorProps): ReactElement => { + const separatorRef = useRef(null); + + const handleVisibilityChange = useCallback((isVisible: boolean) => { + onVisibilityChange?.(isVisible); + }, [onVisibilityChange]); + + useLayoutEffect(() => { + const element = separatorRef.current; + if (!element || !hasNewMessageSeparator || !onVisibilityChange) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const visible = entry.isIntersecting; + handleVisibilityChange(visible); + }); + }, + { + threshold: 1.0, + rootMargin: '0px', + root: null, // viewport를 기준으로 관찰 + }, + ); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [hasNewMessageSeparator, handleVisibilityChange, onVisibilityChange]); + return (
void; deleteMessage: (message: SendableMessageType) => void; resendMessage: (message: SendableMessageType) => void; - markAsUnread?: (message: SendableMessageType, source?: 'menu' | 'internal') => void; + markAsUnread?: (message: SendableMessageType, source?: 'manual' | 'internal') => void; isOnline: boolean; disableDeleteMessage: boolean | null; triggerRef: MutableRefObject; diff --git a/src/ui/MessageMenu/menuItems/BottomSheetMenuItems.tsx b/src/ui/MessageMenu/menuItems/BottomSheetMenuItems.tsx index f5b10f6e8..f37b9623b 100644 --- a/src/ui/MessageMenu/menuItems/BottomSheetMenuItems.tsx +++ b/src/ui/MessageMenu/menuItems/BottomSheetMenuItems.tsx @@ -218,7 +218,7 @@ export const MarkAsUnreadMenuItem = (props: PrebuildMenuItemPropsType) => { {...props} onClick={(e) => { if (markAsUnread) { - markAsUnread(message, 'menu'); + markAsUnread(message, 'manual'); } hideMenu(); props.onClick?.(e); diff --git a/src/ui/MessageMenu/menuItems/MessageMenuItems.tsx b/src/ui/MessageMenu/menuItems/MessageMenuItems.tsx index 0d44fbfbb..c1c2f3fed 100644 --- a/src/ui/MessageMenu/menuItems/MessageMenuItems.tsx +++ b/src/ui/MessageMenu/menuItems/MessageMenuItems.tsx @@ -189,7 +189,7 @@ export const MarkAsUnreadMenuItem = (props: PrebuildMenuItemPropsType) => { {...props} onClick={(e) => { if (markAsUnread) { - markAsUnread(message, 'menu'); + markAsUnread(message, 'manual'); } hideMenu(); props.onClick?.(e); diff --git a/src/ui/MessageMenu/menuItems/MobileMenuItems.tsx b/src/ui/MessageMenu/menuItems/MobileMenuItems.tsx index 94b526840..8e6cc8b75 100644 --- a/src/ui/MessageMenu/menuItems/MobileMenuItems.tsx +++ b/src/ui/MessageMenu/menuItems/MobileMenuItems.tsx @@ -217,7 +217,7 @@ export const MarkAsUnreadMenuItem = (props: PrebuildMenuItemPropsType) => { {...props} onClick={(e) => { if (markAsUnread) { - markAsUnread(message, 'menu'); + markAsUnread(message, 'manual'); } hideMenu(); props.onClick?.(e); From 273bd7280e9558c89d11b33b5bea5eb64902bb7c Mon Sep 17 00:00:00 2001 From: danney-chun <63285271+danney-chun@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:29:48 +0900 Subject: [PATCH 04/19] fixed etc --- .../components/Message/MessageView.tsx | 14 ++++++++------ .../GroupChannel/components/MessageList/index.tsx | 8 ++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/modules/GroupChannel/components/Message/MessageView.tsx b/src/modules/GroupChannel/components/Message/MessageView.tsx index 951802fd3..6f4accd77 100644 --- a/src/modules/GroupChannel/components/Message/MessageView.tsx +++ b/src/modules/GroupChannel/components/Message/MessageView.tsx @@ -403,12 +403,14 @@ const MessageView = (props: MessageViewProps) => { } const seperatorLabelText = useMemo(() => { - if (!hasSeparator && hasNewMessageSeparator) { - return 'New Message'; - } else { - return format(message.createdAt, stringSet.DATE_FORMAT__MESSAGE_LIST__DATE_SEPARATOR, { - locale: dateLocale, - }); + if (hasSeparator) { + if (hasNewMessageSeparator) { + return 'New Message'; + } else { + return format(message.createdAt, stringSet.DATE_FORMAT__MESSAGE_LIST__DATE_SEPARATOR, { + locale: dateLocale, + }); + } } }, [hasSeparator, hasNewMessageSeparator]); diff --git a/src/modules/GroupChannel/components/MessageList/index.tsx b/src/modules/GroupChannel/components/MessageList/index.tsx index a0e0bf2c7..afe009914 100644 --- a/src/modules/GroupChannel/components/MessageList/index.tsx +++ b/src/modules/GroupChannel/components/MessageList/index.tsx @@ -172,9 +172,9 @@ export const MessageList = (props: GroupChannelMessageListProps) => { } }, [currentChannel?.url]); - // check changed messages + // when enableMarkAsUnread is true, check changed messages useEffect(() => { - if (isChangedChannel) { + if (state.config.groupChannel.enableMarkAsUnread && isChangedChannel) { if (!hasInitializedRef.current) { if (currentMessagesRef.current !== messages) { currentMessagesRef.current = messages as CoreMessageType[]; @@ -189,7 +189,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { }, [messages]); useMemo(() => { - if (hasInitializedRef.current) { + if (state.config.groupChannel.enableMarkAsUnread && hasInitializedRef.current) { const firstUnreadMessageId = getFirstUnreadMessage(); if (firstUnreadMessageId && firstUnreadMessageIdRef.current !== firstUnreadMessageId) { @@ -199,7 +199,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { }, [messages.length]); useEffect(() => { - if (hasInitializedRef.current) { + if (state.config.groupChannel.enableMarkAsUnread && hasInitializedRef.current) { if (readState === 'unread') { // when readState === 'unread' find first unread message const firstUnreadMessageId = getFirstUnreadMessage(); From 7f80ed99299ba9ea0257e3ca5cc5ca76a2555d9f Mon Sep 17 00:00:00 2001 From: danney-chun <63285271+danney-chun@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:42:22 +0900 Subject: [PATCH 05/19] Update MessageView.tsx --- .../components/Message/MessageView.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/modules/GroupChannel/components/Message/MessageView.tsx b/src/modules/GroupChannel/components/Message/MessageView.tsx index 6f4accd77..b3d7ac803 100644 --- a/src/modules/GroupChannel/components/Message/MessageView.tsx +++ b/src/modules/GroupChannel/components/Message/MessageView.tsx @@ -403,14 +403,12 @@ const MessageView = (props: MessageViewProps) => { } const seperatorLabelText = useMemo(() => { - if (hasSeparator) { - if (hasNewMessageSeparator) { - return 'New Message'; - } else { - return format(message.createdAt, stringSet.DATE_FORMAT__MESSAGE_LIST__DATE_SEPARATOR, { - locale: dateLocale, - }); - } + if (!hasSeparator && hasNewMessageSeparator) { + return 'New Message'; + } else if (hasSeparator) { + return format(message.createdAt, stringSet.DATE_FORMAT__MESSAGE_LIST__DATE_SEPARATOR, { + locale: dateLocale, + }); } }, [hasSeparator, hasNewMessageSeparator]); From 66865f6b86d52f8e70986ca43eb9f84bb0e15f1a Mon Sep 17 00:00:00 2001 From: danney-chun <63285271+danney-chun@users.noreply.github.com> Date: Thu, 10 Jul 2025 00:26:37 +0900 Subject: [PATCH 06/19] apply feed --- .../Channel/components/MessageList/index.tsx | 88 +++++++--- .../index.tsx | 2 +- .../UnreadCountFloatingButton/index.tsx | 3 + .../components/Message/MessageView.tsx | 28 +-- .../MessageList/getMessagePartsInfo.ts | 4 +- .../components/MessageList/index.tsx | 74 +++++--- .../index.scss | 0 .../index.tsx | 21 ++- .../components/UnreadCount/index.scss | 165 ++++++------------ .../components/UnreadCount/index.tsx | 35 ++-- .../UnreadCountFloatingButton/index.scss | 120 +++++++++++++ .../UnreadCountFloatingButton/index.tsx | 61 +++++++ .../context/GroupChannelProvider.tsx | 7 +- .../context/hooks/useGroupChannel.ts | 20 ++- .../context/hooks/useMessageActions.ts | 17 +- src/modules/GroupChannel/context/types.ts | 3 + src/ui/DateSeparator/index.tsx | 40 +---- src/ui/Label/stringSet.ts | 4 +- src/ui/NewMessageSeparator/index.scss | 27 +++ src/ui/NewMessageSeparator/index.tsx | 80 +++++++++ yarn.lock | 6 +- 21 files changed, 552 insertions(+), 253 deletions(-) rename src/modules/Channel/components/{NewMessageCount => NewMessageCountFloatingButton}/index.tsx (76%) create mode 100644 src/modules/Channel/components/UnreadCountFloatingButton/index.tsx rename src/modules/GroupChannel/components/{NewMessageCount => NewMessageCountFloatingButton}/index.scss (100%) rename src/modules/GroupChannel/components/{NewMessageCount => NewMessageCountFloatingButton}/index.tsx (79%) create mode 100644 src/modules/GroupChannel/components/UnreadCountFloatingButton/index.scss create mode 100644 src/modules/GroupChannel/components/UnreadCountFloatingButton/index.tsx create mode 100644 src/ui/NewMessageSeparator/index.scss create mode 100644 src/ui/NewMessageSeparator/index.tsx diff --git a/src/modules/Channel/components/MessageList/index.tsx b/src/modules/Channel/components/MessageList/index.tsx index c863b3c1a..050ea134d 100644 --- a/src/modules/Channel/components/MessageList/index.tsx +++ b/src/modules/Channel/components/MessageList/index.tsx @@ -11,7 +11,8 @@ import Message from '../Message'; import { EveryMessage, TypingIndicatorType } from '../../../../types'; import { isAboutSame } from '../../context/utils'; import UnreadCount from '../UnreadCount'; -import NewMessageCount from '../NewMessageCount'; +import UnreadCountFloatingButton from '../UnreadCountFloatingButton'; +import NewMessageCountFloatingButton from '../NewMessageCountFloatingButton'; import FrozenNotification from '../FrozenNotification'; import { SCROLL_BUFFER } from '../../../../utils/consts'; import { MessageProvider } from '../../../Message/context/MessageProvider'; @@ -184,8 +185,8 @@ export const MessageList = (props: MessageListProps) => { const isShowUnreadCount = useMemo(() => { if (store?.config?.groupChannel?.enableMarkAsUnread) { - // markAsUnread is enabled - if (currentGroupChannel?.unreadMessageCount > 0) { + // markAsUnread is enabled - 스크롤이 bottom에 있을 때는 표시하지 않음 + if (currentGroupChannel?.unreadMessageCount > 0 && !isScrollBottom) { return true; } return false; @@ -199,8 +200,12 @@ export const MessageList = (props: MessageListProps) => { }, [currentGroupChannel.unreadMessageCount, isScrollBottom]); const isShowNewMessageCount = useMemo(() => { + // 스크롤이 bottom에 있을 때는 new message count를 표시하지 않음 + if (isScrollBottom) { + return false; + } if (!store?.config?.groupChannel?.enableMarkAsUnread - && (!isScrollBottom || hasMoreNext) + && hasMoreNext && (unreadSince || unreadSinceDate)) { return true; } @@ -215,6 +220,54 @@ export const MessageList = (props: MessageListProps) => { return renderPlaceholderEmpty(); } + const renderUnreadCount = () => { + if (isShowUnreadCount) { + if (!store?.config?.groupChannel?.enableMarkAsUnread) { + return ( + { + if (scrollRef?.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + if (!disableMarkAsRead && !!currentGroupChannel) { + markAsReadScheduler.push(currentGroupChannel); + messagesDispatcher({ + type: messageActionTypes.MARK_AS_READ, + payload: { channel: currentGroupChannel }, + }); + } + setInitialTimeStamp(null); + setAnimatedMessageId(null); + setHighLightedMessageId(null); + }} + /> + ); + } else { + return ( + { + if (scrollRef?.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + if (!disableMarkAsRead && !!currentGroupChannel) { + markAsReadScheduler.push(currentGroupChannel); + messagesDispatcher({ + type: messageActionTypes.MARK_AS_READ, + payload: { channel: currentGroupChannel }, + }); + } + setInitialTimeStamp(null); + setAnimatedMessageId(null); + setHighLightedMessageId(null); + }} + /> + ); + } + } + }; + return ( <> {!isScrolled && } @@ -302,30 +355,7 @@ export const MessageList = (props: MessageListProps) => {
{currentGroupChannel?.isFrozen && renderFrozenNotification()} - { - isShowUnreadCount && ( - { - if (scrollRef?.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - if (!disableMarkAsRead && !!currentGroupChannel) { - markAsReadScheduler.push(currentGroupChannel); - messagesDispatcher({ - type: messageActionTypes.MARK_AS_READ, - payload: { channel: currentGroupChannel }, - }); - } - setInitialTimeStamp(null); - setAnimatedMessageId(null); - setHighLightedMessageId(null); - }} - /> - ) - } + {renderUnreadCount()} { // This flag is an unmatched variable scrollBottom > SCROLL_BOTTOM_PADDING && ( @@ -343,7 +373,7 @@ export const MessageList = (props: MessageListProps) => { { /* NewMessageCount - positioned at the bottom of MessageList */ (isShowNewMessageCount) && ( - { diff --git a/src/modules/Channel/components/NewMessageCount/index.tsx b/src/modules/Channel/components/NewMessageCountFloatingButton/index.tsx similarity index 76% rename from src/modules/Channel/components/NewMessageCount/index.tsx rename to src/modules/Channel/components/NewMessageCountFloatingButton/index.tsx index cc846e9ba..16efd3265 100644 --- a/src/modules/Channel/components/NewMessageCount/index.tsx +++ b/src/modules/Channel/components/NewMessageCountFloatingButton/index.tsx @@ -1,3 +1,3 @@ -import NewMessageCount from '../../../GroupChannel/components/NewMessageCount'; +import NewMessageCount from '../../../GroupChannel/components/NewMessageCountFloatingButton'; export default NewMessageCount; diff --git a/src/modules/Channel/components/UnreadCountFloatingButton/index.tsx b/src/modules/Channel/components/UnreadCountFloatingButton/index.tsx new file mode 100644 index 000000000..9f99d1be7 --- /dev/null +++ b/src/modules/Channel/components/UnreadCountFloatingButton/index.tsx @@ -0,0 +1,3 @@ +import UnreadCountFloatingButton from '../../../GroupChannel/components/UnreadCountFloatingButton'; + +export default UnreadCountFloatingButton; diff --git a/src/modules/GroupChannel/components/Message/MessageView.tsx b/src/modules/GroupChannel/components/Message/MessageView.tsx index b3d7ac803..502f15818 100644 --- a/src/modules/GroupChannel/components/Message/MessageView.tsx +++ b/src/modules/GroupChannel/components/Message/MessageView.tsx @@ -22,6 +22,7 @@ import SuggestedMentionListView from '../SuggestedMentionList/SuggestedMentionLi import type { OnBeforeDownloadFileMessageType } from '../../context/types'; import { classnames, deleteNullish } from '../../../../utils/utils'; import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; +import NewMessageIndicator from '../../../../ui/NewMessageSeparator'; export interface MessageProps { message: EveryMessage; @@ -402,16 +403,6 @@ const MessageView = (props: MessageViewProps) => { ); } - const seperatorLabelText = useMemo(() => { - if (!hasSeparator && hasNewMessageSeparator) { - return 'New Message'; - } else if (hasSeparator) { - return format(message.createdAt, stringSet.DATE_FORMAT__MESSAGE_LIST__DATE_SEPARATOR, { - locale: dateLocale, - }); - } - }, [hasSeparator, hasNewMessageSeparator]); - return (
{ ref={messageScrollRef} > {/* date-separator */} - {(hasSeparator || hasNewMessageSeparator) + {hasSeparator && (renderedCustomSeparator || ( - + ))} + {/* new message indicator */} + {hasNewMessageSeparator + && ( + + + + )} {renderChildren()}
); diff --git a/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts b/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts index 67dc4ecc2..c01dfc771 100644 --- a/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts +++ b/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts @@ -16,6 +16,7 @@ export interface GetMessagePartsInfoProps { replyType?: string; hasPrevious?: boolean; firstUnreadMessageId?: number | string | undefined; + isCurrentDeviceMessage?: boolean; } interface OutPuts { @@ -37,6 +38,7 @@ export const getMessagePartsInfo = ({ currentChannel = null, replyType = '', firstUnreadMessageId, + isCurrentDeviceMessage, }: GetMessagePartsInfoProps): OutPuts => { const previousMessage = allMessages[currentIndex - 1]; const nextMessage = allMessages[currentIndex + 1]; @@ -52,7 +54,7 @@ export const getMessagePartsInfo = ({ // https://stackoverflow.com/a/41855608 const hasSeparator = isLocalMessage ? false : !(previousMessageCreatedAt && (isSameDay(currentCreatedAt, previousMessageCreatedAt))); - const hasNewMessageSeparator = firstUnreadMessageId === currentMessage.messageId; + const hasNewMessageSeparator = !isCurrentDeviceMessage && firstUnreadMessageId === currentMessage.messageId; return { chainTop, diff --git a/src/modules/GroupChannel/components/MessageList/index.tsx b/src/modules/GroupChannel/components/MessageList/index.tsx index afe009914..7155b801c 100644 --- a/src/modules/GroupChannel/components/MessageList/index.tsx +++ b/src/modules/GroupChannel/components/MessageList/index.tsx @@ -1,5 +1,5 @@ import './index.scss'; -import React, { useEffect, useState, useRef, useMemo } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import type { GroupChannel, Member } from '@sendbird/chat/groupChannel'; import { useGroupChannelHandler } from '@sendbird/uikit-tools'; @@ -10,7 +10,8 @@ import PlaceHolder, { PlaceHolderTypes } from '../../../../ui/PlaceHolder'; import Icon, { IconColors, IconTypes } from '../../../../ui/Icon'; import Message from '../Message'; import UnreadCount from '../UnreadCount'; -import NewMessageCount from '../NewMessageCount'; +import UnreadCountFloatingButton from '../UnreadCountFloatingButton'; +import NewMessageCountFloatingButton from '../NewMessageCountFloatingButton'; import FrozenNotification from '../FrozenNotification'; import { SCROLL_BUFFER } from '../../../../utils/consts'; import TypingIndicatorBubble from '../../../../ui/TypingIndicatorBubble'; @@ -89,6 +90,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { scrollDistanceFromBottomRef, markAsUnreadSourceRef, readState, + currentDeviceMessageIdsRef, }, actions: { scrollToBottom, @@ -96,6 +98,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { markAsReadAll, markAsUnread, setReadStateChanged, + isFromCurrentDevice, }, } = useGroupChannel(); @@ -109,8 +112,8 @@ export const MessageList = (props: GroupChannelMessageListProps) => { const firstUnreadMessageIdRef = useRef(); const hasInitializedRef = useRef(false); - // current channel url ref - const currentChannelUrlRef = useRef(); + // current channel ref + const currentChannelRef = useRef(undefined); // current messages ref const currentMessagesRef = useRef([]); @@ -122,7 +125,6 @@ export const MessageList = (props: GroupChannelMessageListProps) => { index: number, allMessages: CoreMessageType[], currentChannel: GroupChannel, - hasPrevious: boolean, ): boolean => { const currentCreatedAt = message.createdAt; @@ -131,8 +133,8 @@ export const MessageList = (props: GroupChannelMessageListProps) => { return true; } - // condition 2: there is no previous message that satisfies the condition, and the current message is the first message that satisfies the condition - if (!hasPrevious && currentCreatedAt > (currentChannel.myLastRead + 1)) { + // condition 2: there is no message that satisfies the condition, and the current message is the first message that satisfies the condition + if (currentCreatedAt > (currentChannel.myLastRead + 1)) { const hasPreviousMatchingMessage = allMessages .slice(0, index) .some(msg => msg.createdAt === (currentChannel.myLastRead + 1)); @@ -145,8 +147,9 @@ export const MessageList = (props: GroupChannelMessageListProps) => { if (state.config.groupChannel.enableMarkAsUnread) { for (let i = 0; i < messages.length; i++) { const message = messages[i] as CoreMessageType; - const isFind = findFirstUnreadMessage(message, i, messages as CoreMessageType[], currentChannel, hasPrevious()); - if (isFind) { + const isFind = findFirstUnreadMessage(message, i, messages as CoreMessageType[], currentChannel); + + if (isFind && !isFromCurrentDevice(message.messageId)) { return message.messageId; } } @@ -156,8 +159,8 @@ export const MessageList = (props: GroupChannelMessageListProps) => { // check changed channel useEffect(() => { - if (currentChannel?.url !== currentChannelUrlRef.current) { - currentChannelUrlRef.current = currentChannel?.url; + if (currentChannel?.url !== currentChannelRef.current?.url) { + currentChannelRef.current = currentChannel; // initialize firstUnreadMessageIdRef.current = undefined; @@ -167,6 +170,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { markAsUnreadSourceRef.current = undefined; } setIsChangedChannel(true); + currentDeviceMessageIdsRef.current.clear(); } else { setIsChangedChannel(false); } @@ -188,8 +192,10 @@ export const MessageList = (props: GroupChannelMessageListProps) => { } }, [messages]); - useMemo(() => { + useEffect(() => { if (state.config.groupChannel.enableMarkAsUnread && hasInitializedRef.current) { + if (firstUnreadMessageIdRef.current) return; + const firstUnreadMessageId = getFirstUnreadMessage(); if (firstUnreadMessageId && firstUnreadMessageIdRef.current !== firstUnreadMessageId) { @@ -203,6 +209,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { if (readState === 'unread') { // when readState === 'unread' find first unread message const firstUnreadMessageId = getFirstUnreadMessage(); + if (firstUnreadMessageId !== firstUnreadMessageIdRef.current) { firstUnreadMessageIdRef.current = firstUnreadMessageId; } @@ -218,6 +225,12 @@ export const MessageList = (props: GroupChannelMessageListProps) => { } else { setUnreadSinceDate(new Date()); } + } else if (isScrollBottomReached) { + if (!firstUnreadMessageIdRef.current && currentChannel?.unreadMessageCount === 0) { + markAsReadAll(currentChannel); + } else if (!firstUnreadMessageIdRef.current) { + markAsReadAll(currentChannel); + } } }, [isScrollBottomReached]); @@ -245,16 +258,26 @@ export const MessageList = (props: GroupChannelMessageListProps) => { return renderFrozenNotification(); }, unreadMessagesNotification() { - if (markAsUnreadSourceRef?.current === 'manual' || !showUnreadCount) return null; + if (state.config.groupChannel.enableMarkAsUnread) { + if (!showUnreadCount) return null; + return ( + { + markAsReadAll(currentChannel); + }} + /> + ); + } + if (isScrollBottomReached || !unreadSinceDate) return null; return ( { - markAsReadAll(currentChannel); - }} + onClick={() => scrollToBottom()} /> ); }, @@ -274,9 +297,10 @@ export const MessageList = (props: GroupChannelMessageListProps) => { ); }, newMessageCount() { + // 스크롤이 bottom에 있을 때는 new message count를 표시하지 않음 if (isScrollBottomReached) return null; return ( - scrollToBottom()} @@ -291,9 +315,13 @@ export const MessageList = (props: GroupChannelMessageListProps) => { if (newMessages.length > 0) { markAsUnread(newMessages[0] as SendableMessageType, 'internal'); firstUnreadMessageIdRef.current = newMessages[0].messageId; - } else if (markAsUnreadSourceRef?.current !== 'manual') { + } else if (currentChannel?.lastMessage.createdAt > currentChannel.myLastRead + && markAsUnreadSourceRef?.current !== 'manual') + { markAsReadAll(currentChannel); } + } else if (currentChannel?.unreadMessageCount > 0) { + setShowUnreadCount(true); } }; @@ -330,6 +358,8 @@ export const MessageList = (props: GroupChannelMessageListProps) => { }} messages={messages} renderMessage={({ message, index }) => { + const isCurrentDeviceMessage = isFromCurrentDevice(message.messageId); + const { chainTop, chainBottom, hasSeparator, hasNewMessageSeparator } = getMessagePartsInfo({ allMessages: messages as CoreMessageType[], stringSet, @@ -340,9 +370,11 @@ export const MessageList = (props: GroupChannelMessageListProps) => { currentChannel: currentChannel!, hasPrevious: hasPrevious(), firstUnreadMessageId: firstUnreadMessageIdRef.current, + isCurrentDeviceMessage, }); const isOutgoingMessage = isSendableMessage(message) && message.sender.userId === state.config.userId; + return ( {renderMessage({ diff --git a/src/modules/GroupChannel/components/NewMessageCount/index.scss b/src/modules/GroupChannel/components/NewMessageCountFloatingButton/index.scss similarity index 100% rename from src/modules/GroupChannel/components/NewMessageCount/index.scss rename to src/modules/GroupChannel/components/NewMessageCountFloatingButton/index.scss diff --git a/src/modules/GroupChannel/components/NewMessageCount/index.tsx b/src/modules/GroupChannel/components/NewMessageCountFloatingButton/index.tsx similarity index 79% rename from src/modules/GroupChannel/components/NewMessageCount/index.tsx rename to src/modules/GroupChannel/components/NewMessageCountFloatingButton/index.tsx index 4b1c8ef84..8b98d61eb 100644 --- a/src/modules/GroupChannel/components/NewMessageCount/index.tsx +++ b/src/modules/GroupChannel/components/NewMessageCountFloatingButton/index.tsx @@ -5,6 +5,7 @@ import { LocalizationContext } from '../../../../lib/LocalizationContext'; import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; import { classnames } from '../../../../utils/utils'; +import { useMediaQueryContext } from '../../../../lib/MediaQueryContext'; export interface NewMessageCountProps { className?: string; @@ -18,6 +19,8 @@ export const NewMessageCount: React.FC = ({ onClick, }: NewMessageCountProps) => { const { stringSet } = useContext(LocalizationContext); + const { isMobile } = useMediaQueryContext(); + const newMessageCountText = useMemo(() => { if (count === 1) { @@ -42,15 +45,19 @@ export const NewMessageCount: React.FC = ({ color={LabelColors.ONCONTENT_1} type={LabelTypography.CAPTION_2} > - {`${count} `} + {`${count > 99 ? '99+' : count} `} {newMessageCountText} - + { + !isMobile && ( + + ) + }
); }; diff --git a/src/modules/GroupChannel/components/UnreadCount/index.scss b/src/modules/GroupChannel/components/UnreadCount/index.scss index 3371a16a8..50850f31b 100644 --- a/src/modules/GroupChannel/components/UnreadCount/index.scss +++ b/src/modules/GroupChannel/components/UnreadCount/index.scss @@ -2,119 +2,52 @@ * We operate the CSS files for Channel&GroupChannel modules in the GroupChannel. * So keep in mind that you should consider both components when you make changes in this file. */ -@import '../../../../styles/variables'; - -// Floating UnreadCount button - completely separate from notification styles -.sendbird-unread-floating-button { - position: absolute; - top: 8px; // Default position when no frozen notification - left: 50%; - transform: translateX(-50%); - width: 181px; - height: 40px; - border-radius: 20px; - gap: 4px; - display: flex; - align-items: center; - justify-content: space-between; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - transition: all 0.2s ease-in-out; - z-index: 5; - background-color: #FFFFFF; - - // When frozen notification is present, position below it - &.sendbird-unread-floating-button--below-frozen { - top: 48px; - } - - &:hover { - cursor: pointer; - transform: translateX(-50%) translateY(-1px); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); - background-color: #F5F5F5; - } - - .sendbird-unread-floating-button__text { - display: flex; - align-items: center; - justify-content: center; - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: #00000080; - line-height: 1; - font-size: 14px; - text-align: center; - height: 100%; - } - - // Icon fixed to right side - .sendbird-icon { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - svg { - display: block; - vertical-align: middle; - } - } -} - -.sendbird-unread-floating-button--hide { - display: none; -} - -// FrozenNotification styles (required for Channel&GroupChannel modules) -.sendbird-notification--hide, -.sendbird-notification { - position: absolute; -} - -.sendbird-notification { - margin-top: 8px; - margin-left: 24px; - margin-right: 24px; - border-radius: 4px; - padding: 0px 2px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - @include themed() { - background-color: t(primary-3); - } - &:hover { - cursor: pointer; - @include themed() { - background-color: t(primary-4); - } - } - - &.sendbird-notification--frozen { - @include themed() { - background-color: t(information-1); - } - .sendbird-notification__text { - @include themed() { - color: t(on-information-1); - } - } - } - - .sendbird-notification__text { - display: flex; - align-items: center; - justify-content: center; - margin-right: 8px; - } -} - -.sendbird-notification--hide { - display: none; -} - - - + @import '../../../../styles/variables'; + + .sendbird-notification--hide, + .sendbird-notification { + position: absolute; + } + + .sendbird-notification { + margin-top: 8px; + margin-left: 24px; + margin-right: 24px; + border-radius: 4px; + padding: 0px 2px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + @include themed() { + background-color: t(primary-3); + } + &:hover { + cursor: pointer; + @include themed() { + background-color: t(primary-4); + } + } + + &.sendbird-notification--frozen { + @include themed() { + background-color: t(information-1); + } + .sendbird-notification__text { + @include themed() { + color: t(on-information-1); + } + } + } + + .sendbird-notification__text { + display: flex; + align-items: center; + justify-content: center; + margin-right: 8px; + } + } + + .sendbird-notification--hide { + display: none; + } \ No newline at end of file diff --git a/src/modules/GroupChannel/components/UnreadCount/index.tsx b/src/modules/GroupChannel/components/UnreadCount/index.tsx index 75b4046d5..972a350a4 100644 --- a/src/modules/GroupChannel/components/UnreadCount/index.tsx +++ b/src/modules/GroupChannel/components/UnreadCount/index.tsx @@ -4,6 +4,7 @@ import React, { useContext, useMemo } from 'react'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; +import format from 'date-fns/format'; import { classnames } from '../../../../utils/utils'; export interface UnreadCountProps { @@ -13,48 +14,48 @@ export interface UnreadCountProps { lastReadAt?: Date | null; /** @deprecated Please use `lastReadAt` instead * */ time?: string; - isFrozenChannel?: boolean; } export const UnreadCount: React.FC = ({ className = '', count = 0, + time = '', onClick, - isFrozenChannel = false, + lastReadAt, }: UnreadCountProps) => { - const { stringSet } = useContext(LocalizationContext); + const { stringSet, dateLocale } = useContext(LocalizationContext); - const unreadMessageCountText = useMemo(() => { - if (count === 1) { - return stringSet.CHANNEL__MESSAGE_LIST__NOTIFICATION__UNREAD_MESSAGE; - } else if (count > 1) { - return stringSet.CHANNEL__MESSAGE_LIST__NOTIFICATION__UNREAD_MESSAGE_S; + const unreadSince = useMemo(() => { + // TODO: Remove this on v4 + if (stringSet.CHANNEL__MESSAGE_LIST__NOTIFICATION__ON !== 'on') { + const timeArray = time?.toString?.()?.split(' ') || []; + timeArray?.splice(-2, 0, stringSet.CHANNEL__MESSAGE_LIST__NOTIFICATION__ON); + return timeArray.join(' '); + } else if (lastReadAt) { + return format(lastReadAt, stringSet.DATE_FORMAT__MESSAGE_LIST__NOTIFICATION__UNREAD_SINCE, { locale: dateLocale }); } - }, [count]); + }, [time, lastReadAt]); return (
diff --git a/src/modules/GroupChannel/components/UnreadCountFloatingButton/index.scss b/src/modules/GroupChannel/components/UnreadCountFloatingButton/index.scss new file mode 100644 index 000000000..3371a16a8 --- /dev/null +++ b/src/modules/GroupChannel/components/UnreadCountFloatingButton/index.scss @@ -0,0 +1,120 @@ +/** + * We operate the CSS files for Channel&GroupChannel modules in the GroupChannel. + * So keep in mind that you should consider both components when you make changes in this file. + */ +@import '../../../../styles/variables'; + +// Floating UnreadCount button - completely separate from notification styles +.sendbird-unread-floating-button { + position: absolute; + top: 8px; // Default position when no frozen notification + left: 50%; + transform: translateX(-50%); + width: 181px; + height: 40px; + border-radius: 20px; + gap: 4px; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transition: all 0.2s ease-in-out; + z-index: 5; + background-color: #FFFFFF; + + // When frozen notification is present, position below it + &.sendbird-unread-floating-button--below-frozen { + top: 48px; + } + + &:hover { + cursor: pointer; + transform: translateX(-50%) translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); + background-color: #F5F5F5; + } + + .sendbird-unread-floating-button__text { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #00000080; + line-height: 1; + font-size: 14px; + text-align: center; + height: 100%; + } + + // Icon fixed to right side + .sendbird-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + svg { + display: block; + vertical-align: middle; + } + } +} + +.sendbird-unread-floating-button--hide { + display: none; +} + +// FrozenNotification styles (required for Channel&GroupChannel modules) +.sendbird-notification--hide, +.sendbird-notification { + position: absolute; +} + +.sendbird-notification { + margin-top: 8px; + margin-left: 24px; + margin-right: 24px; + border-radius: 4px; + padding: 0px 2px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + @include themed() { + background-color: t(primary-3); + } + &:hover { + cursor: pointer; + @include themed() { + background-color: t(primary-4); + } + } + + &.sendbird-notification--frozen { + @include themed() { + background-color: t(information-1); + } + .sendbird-notification__text { + @include themed() { + color: t(on-information-1); + } + } + } + + .sendbird-notification__text { + display: flex; + align-items: center; + justify-content: center; + margin-right: 8px; + } +} + +.sendbird-notification--hide { + display: none; +} + + + diff --git a/src/modules/GroupChannel/components/UnreadCountFloatingButton/index.tsx b/src/modules/GroupChannel/components/UnreadCountFloatingButton/index.tsx new file mode 100644 index 000000000..35e4eafcd --- /dev/null +++ b/src/modules/GroupChannel/components/UnreadCountFloatingButton/index.tsx @@ -0,0 +1,61 @@ +import './index.scss'; +import React, { useContext, useMemo } from 'react'; + +import { LocalizationContext } from '../../../../lib/LocalizationContext'; +import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; +import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; +import { classnames } from '../../../../utils/utils'; + +export interface UnreadCountProps { + className?: string; + count: number | undefined; + onClick(): void; + isFrozenChannel?: boolean; +} + +export const UnreadCount: React.FC = ({ + className = '', + count = 0, + onClick, + isFrozenChannel = false, +}: UnreadCountProps) => { + const { stringSet } = useContext(LocalizationContext); + + const unreadMessageCountText = useMemo(() => { + if (count === 1) { + return stringSet.CHANNEL__MESSAGE_LIST__NOTIFICATION__UNREAD_MESSAGE; + } else if (count > 1) { + return stringSet.CHANNEL__MESSAGE_LIST__NOTIFICATION__UNREAD_MESSAGE_S; + } + }, [count]); + + return ( +
+ + +
+ ); +}; + +export default UnreadCount; diff --git a/src/modules/GroupChannel/context/GroupChannelProvider.tsx b/src/modules/GroupChannel/context/GroupChannelProvider.tsx index 51cee1faa..f058fc824 100644 --- a/src/modules/GroupChannel/context/GroupChannelProvider.tsx +++ b/src/modules/GroupChannel/context/GroupChannelProvider.tsx @@ -153,6 +153,8 @@ const GroupChannelManager :React.FC>(new Set()); + // ScrollHandler initialization const { scrollRef, @@ -224,7 +226,7 @@ const GroupChannelManager :React.FC actions.scrollToBottom(true), 10); + setTimeout(async () => actions.scrollToBottom(true), 10); } }, onChannelDeleted: () => { @@ -256,6 +258,7 @@ const GroupChannelManager :React.FC { if (data.channel.url === state.currentChannel?.url) { + currentDeviceMessageIdsRef.current.add(data.message.messageId); actions.scrollToBottom(true); } }; @@ -386,6 +390,7 @@ const GroupChannelManager :React.FC void; markAsUnread: (message: SendableMessageType, source?: 'manual' | 'internal') => void; setReadStateChanged: (state: string) => void; + setFirstUnreadMessageId: (messageId: number | string | null) => void; // Message actions sendUserMessage: (params: UserMessageCreateParams) => Promise; @@ -50,6 +51,9 @@ export interface GroupChannelActions extends MessageActions { // Reaction action toggleReaction: (message: SendableMessageType, emojiKey: string, isReacted: boolean) => void; + + // Current device message tracking + isFromCurrentDevice: (messageId: string | number) => boolean; } export const useGroupChannel = () => { @@ -90,8 +94,8 @@ export const useGroupChannel = () => { }, [state.scrollRef.current, config.isOnline, markAsReadScheduler]); const markAsReadAll = useCallback((channel: GroupChannel) => { - if (config.isOnline && !state.disableMarkAsRead) { - markAsReadScheduler.push(channel); + if (config.isOnline && !state.disableMarkAsRead && channel) { + markAsReadScheduler?.push(channel); } }, [config.isOnline]); @@ -200,6 +204,14 @@ export const useGroupChannel = () => { store.setState(state => ({ ...state, readState })); }, []); + const setFirstUnreadMessageId = useCallback((messageId: number | null) => { + store.setState(state => ({ ...state, firstUnreadMessageId: messageId })); + }, []); + + const isFromCurrentDevice = useCallback((messageId: string | number): boolean => { + return state.currentDeviceMessageIdsRef?.current.has(messageId) ?? false; + }, [state.currentDeviceMessageIdsRef]); + const actions: GroupChannelActions = useMemo(() => { return { setCurrentChannel, @@ -207,12 +219,14 @@ export const useGroupChannel = () => { markAsReadAll, markAsUnread: state.markAsUnread, setReadStateChanged, + setFirstUnreadMessageId, setQuoteMessage, scrollToBottom, scrollToMessage, toggleReaction, setAnimatedMessageId, setIsScrollBottomReached, + isFromCurrentDevice, ...messageActions, }; }, [ @@ -221,12 +235,14 @@ export const useGroupChannel = () => { markAsReadAll, state.markAsUnread, setReadStateChanged, + setFirstUnreadMessageId, setQuoteMessage, scrollToBottom, scrollToMessage, toggleReaction, setAnimatedMessageId, setIsScrollBottomReached, + isFromCurrentDevice, messageActions, ]); diff --git a/src/modules/GroupChannel/context/hooks/useMessageActions.ts b/src/modules/GroupChannel/context/hooks/useMessageActions.ts index d10e17568..c903c0dbf 100644 --- a/src/modules/GroupChannel/context/hooks/useMessageActions.ts +++ b/src/modules/GroupChannel/context/hooks/useMessageActions.ts @@ -151,7 +151,14 @@ export function useMessageActions(params: Params): MessageActions { async (params) => { const internalParams = buildInternalMessageParams(params); const processedParams = await processParams(onBeforeSendUserMessage, internalParams, 'user') as UserMessageCreateParams; - return sendUserMessage(processedParams, asyncScrollToBottom); + // return sendUserMessage(processedParams, asyncScrollToBottom); + const message = await sendUserMessage(processedParams, asyncScrollToBottom); + pubSub.publish(PUBSUB_TOPICS.SEND_USER_MESSAGE, { + channel: currentChannel, + message, + publishingModules: [PublishingModuleType.CHANNEL], + }); + return message; }, [buildInternalMessageParams, sendUserMessage, scrollToBottom, processParams], ), @@ -159,7 +166,13 @@ export function useMessageActions(params: Params): MessageActions { async (params) => { const internalParams = buildInternalMessageParams(params); const processedParams = await processParams(onBeforeSendFileMessage, internalParams, 'file') as FileMessageCreateParams; - return sendFileMessage(processedParams, asyncScrollToBottom); + const message = await sendFileMessage(processedParams, asyncScrollToBottom); + pubSub.publish(PUBSUB_TOPICS.SEND_FILE_MESSAGE, { + channel: currentChannel, + message, + publishingModules: [PublishingModuleType.CHANNEL], + }); + return message; }, [buildInternalMessageParams, sendFileMessage, scrollToBottom, processParams], ), diff --git a/src/modules/GroupChannel/context/types.ts b/src/modules/GroupChannel/context/types.ts index 3c647e719..75ea79afc 100644 --- a/src/modules/GroupChannel/context/types.ts +++ b/src/modules/GroupChannel/context/types.ts @@ -48,6 +48,9 @@ interface InternalGroupChannelState extends MessageDataSource { isScrollBottomReached: boolean; readState: string | null; + // Current device message tracking + currentDeviceMessageIdsRef: React.MutableRefObject>; + // References - will be managed together scrollRef: React.RefObject; scrollDistanceFromBottomRef: React.MutableRefObject; diff --git a/src/ui/DateSeparator/index.tsx b/src/ui/DateSeparator/index.tsx index 05f4a3fcd..65df3a29c 100644 --- a/src/ui/DateSeparator/index.tsx +++ b/src/ui/DateSeparator/index.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useLayoutEffect, useRef, useCallback } from 'react'; +import React, { ReactElement } from 'react'; import './index.scss'; @@ -13,52 +13,16 @@ export interface DateSeparatorProps { children?: string | ReactElement; className?: string | Array; separatorColor?: Colors; - hasNewMessageSeparator?: boolean; - onVisibilityChange?: (isVisible: boolean) => void; } const DateSeparator = ({ children = undefined, className = '', - hasNewMessageSeparator = false, - onVisibilityChange, - separatorColor = hasNewMessageSeparator ? Colors.PRIMARY : Colors.ONBACKGROUND_4, + separatorColor = Colors.ONBACKGROUND_4, }: DateSeparatorProps): ReactElement => { - const separatorRef = useRef(null); - - const handleVisibilityChange = useCallback((isVisible: boolean) => { - onVisibilityChange?.(isVisible); - }, [onVisibilityChange]); - - useLayoutEffect(() => { - const element = separatorRef.current; - if (!element || !hasNewMessageSeparator || !onVisibilityChange) return; - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - const visible = entry.isIntersecting; - handleVisibilityChange(visible); - }); - }, - { - threshold: 1.0, - rootMargin: '0px', - root: null, // viewport를 기준으로 관찰 - }, - ); - - observer.observe(element); - - return () => { - observer.disconnect(); - }; - }, [hasNewMessageSeparator, handleVisibilityChange, onVisibilityChange]); return (
; + separatorColor?: Colors; + onVisibilityChange?: (isVisible: boolean) => void; +} + +const NewMessageIndicator = ({ + children = undefined, + className = '', + onVisibilityChange, + separatorColor = Colors.PRIMARY, +}: NewMessageIndicatorProps): ReactElement => { + const separatorRef = useRef(null); + + const handleVisibilityChange = useCallback((isVisible: boolean) => { + onVisibilityChange?.(isVisible); + }, [onVisibilityChange]); + + useLayoutEffect(() => { + const element = separatorRef.current; + if (!element || !onVisibilityChange) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const visible = entry.isIntersecting; + handleVisibilityChange(visible); + }); + }, + { + threshold: 1.0, + rootMargin: '0px', + root: null, // viewport를 기준으로 관찰 + }, + ); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [handleVisibilityChange, onVisibilityChange]); + + return ( +
+
+
+ { + children + || ( + + ) + } +
+
+
+ ); +}; + +export default NewMessageIndicator; diff --git a/yarn.lock b/yarn.lock index 98de84965..0b83b9053 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2748,8 +2748,8 @@ __metadata: linkType: hard "@sendbird/chat@npm:^4.19.2": - version: 4.19.2 - resolution: "@sendbird/chat@npm:4.19.2" + version: 4.19.4 + resolution: "@sendbird/chat@npm:4.19.4" peerDependencies: "@react-native-async-storage/async-storage": ^1.17.6 react-native-mmkv: ">=2.0.0" @@ -2758,7 +2758,7 @@ __metadata: optional: true react-native-mmkv: optional: true - checksum: 6cb69158f19633252f474c6edd1ae1e55d1d46dc1dcf628a0a5c5108ee1b9cafd5a2cde63aeb011237d9fdc5789c8388209ced2fc97b0d9841d5c5aa2bb6d9da + checksum: 3b7c5aa799e1f6794a4c035699a578e346dc437b42368152032f212a1568b8784356953bc2e73e77b990abdc33e27b6e9eb57114f36b2a0164dc0914558d3657 languageName: node linkType: hard From 5e816212a7e26141a698fd2ce5f696a22d2af767 Mon Sep 17 00:00:00 2001 From: danney-chun <63285271+danney-chun@users.noreply.github.com> Date: Thu, 10 Jul 2025 00:34:40 +0900 Subject: [PATCH 07/19] fixed lint --- .../components/NewMessageCountFloatingButton/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/modules/GroupChannel/components/NewMessageCountFloatingButton/index.tsx b/src/modules/GroupChannel/components/NewMessageCountFloatingButton/index.tsx index 8b98d61eb..fa3218c7a 100644 --- a/src/modules/GroupChannel/components/NewMessageCountFloatingButton/index.tsx +++ b/src/modules/GroupChannel/components/NewMessageCountFloatingButton/index.tsx @@ -20,7 +20,6 @@ export const NewMessageCount: React.FC = ({ }: NewMessageCountProps) => { const { stringSet } = useContext(LocalizationContext); const { isMobile } = useMediaQueryContext(); - const newMessageCountText = useMemo(() => { if (count === 1) { From 7b6f6e2ba5f8b9ebd8dedbf70c87b40d9cfa2dd9 Mon Sep 17 00:00:00 2001 From: danney-chun <63285271+danney-chun@users.noreply.github.com> Date: Thu, 10 Jul 2025 16:41:34 +0900 Subject: [PATCH 08/19] clnp 7139 (#1353) ## For Internal Contributors * Follow the [Scaled Trunk-Based Development workflow](https://trunkbaseddevelopment.com/) * Branch naming format: `{type}/TICKET_ID/description` * Type: `feat` / `fix` / `chore` / `doc` / `release` * Receive PR review approvals * Rebase your branch with the main branch and wait for CI to pass * Squash merge your commit * Use imperative language in the title and description * Follow the provided template for PR description and squashing ### Template ``` // PR title (Required) [type]: A short description of the changes in imperative language. // PR description (Optional) Add a brief description of the changes in this PR. Bullet points are also fine. // Footer (Recommended) Fixes [](https://sendbird.atlassian.net/browse/) // Changelogs (Recommended) // Add (internal) at the end of each changelog if internal. ### Changelogs // Co-authors // Add this if you pair programmed or they made significant contributions to the ideas in the code and you want to thank them. Co-authored-by: Name name@example.com, Name2 name@example.com ``` ### Checklist Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If unsure, ask the members. This is a reminder of what we look for before merging your code. - [ ] **All tests pass locally with my changes** - [ ] **I have added tests that prove my fix is effective or that my feature works** - [ ] **Public components / utils / props are appropriately exported** - [ ] I have added necessary documentation (if appropriate) ## External Contributions This project is not yet set up to accept pull requests from external contributors. If you have a pull request that you believe should be accepted, please contact the Developer Relations team with details and we'll evaluate if we can set up a [CLA](https://en.wikipedia.org/wiki/Contributor_License_Agreement) to allow for the contribution. --- .../Channel/components/MessageList/index.tsx | 133 ++++-------------- .../Channel/context/ChannelProvider.tsx | 17 +++ .../Channel/context/dux/actionTypes.ts | 6 + .../context/hooks/useHandleChannelEvents.ts | 15 +- .../MessageInputWrapperView.tsx | 6 +- .../components/MessageList/index.tsx | 13 +- src/ui/MessageMenu/MessageMenu.tsx | 2 +- src/ui/MobileMenu/MobileBottomSheet.tsx | 17 +-- src/ui/MobileMenu/MobileContextMenu.tsx | 11 +- src/utils/menuConditions.ts | 4 +- 10 files changed, 83 insertions(+), 141 deletions(-) diff --git a/src/modules/Channel/components/MessageList/index.tsx b/src/modules/Channel/components/MessageList/index.tsx index 050ea134d..365c382ee 100644 --- a/src/modules/Channel/components/MessageList/index.tsx +++ b/src/modules/Channel/components/MessageList/index.tsx @@ -1,7 +1,7 @@ /* We operate the CSS files for Channel&GroupChannel modules in the GroupChannel */ import '../../../GroupChannel/components/MessageList/index.scss'; -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import type { UserMessage } from '@sendbird/chat/message'; import { useChannelContext } from '../../context/ChannelProvider'; @@ -11,8 +11,6 @@ import Message from '../Message'; import { EveryMessage, TypingIndicatorType } from '../../../../types'; import { isAboutSame } from '../../context/utils'; import UnreadCount from '../UnreadCount'; -import UnreadCountFloatingButton from '../UnreadCountFloatingButton'; -import NewMessageCountFloatingButton from '../NewMessageCountFloatingButton'; import FrozenNotification from '../FrozenNotification'; import { SCROLL_BUFFER } from '../../../../utils/consts'; import { MessageProvider } from '../../../Message/context/MessageProvider'; @@ -29,7 +27,6 @@ import { deleteNullish } from '../../../../utils/utils'; import { getHTMLTextDirection } from '../../../../utils'; import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; import { useLocalization } from '../../../../lib/LocalizationContext'; -import { useGroupChannel } from '../../../GroupChannel/context/hooks/useGroupChannel'; const SCROLL_BOTTOM_PADDING = 50; @@ -55,7 +52,7 @@ export const MessageList = (props: MessageListProps) => { renderCustomSeparator, renderPlaceholderLoader = () => , renderPlaceholderEmpty = () => , - renderFrozenNotification = () => , + renderFrozenNotification = () => , } = deleteNullish(props); const { @@ -90,8 +87,6 @@ export const MessageList = (props: MessageListProps) => { const [isScrollBottom, setIsScrollBottom] = useState(false); - const { state: { newMessages, markAsUnreadSourceRef } } = useGroupChannel(); - useScrollBehavior(); /** @@ -161,10 +156,6 @@ export const MessageList = (props: MessageListProps) => { * hasMoreNext is true but it needs to be called when hasNext is false when reached bottom as well. */ if (!hasMoreNext && !disableMarkAsRead && !!currentGroupChannel) { - // markAsUnreadSourceRef의 현재 값을 확인 - const currentSource = markAsUnreadSourceRef.current; - console.log('Channel MessageList: markAsUnreadSourceRef current value:', currentSource); - messagesDispatcher({ type: messageActionTypes.MARK_AS_READ, payload: { channel: currentGroupChannel }, @@ -183,35 +174,6 @@ export const MessageList = (props: MessageListProps) => { const { scrollToBottomHandler, scrollBottom } = useSetScrollToBottom({ loading }); - const isShowUnreadCount = useMemo(() => { - if (store?.config?.groupChannel?.enableMarkAsUnread) { - // markAsUnread is enabled - 스크롤이 bottom에 있을 때는 표시하지 않음 - if (currentGroupChannel?.unreadMessageCount > 0 && !isScrollBottom) { - return true; - } - return false; - } else { - // markAsUnread is disable - if (currentGroupChannel?.unreadMessageCount > 0 && !isScrollBottom && hasMoreNext) { - return true; - } - return false; - } - }, [currentGroupChannel.unreadMessageCount, isScrollBottom]); - - const isShowNewMessageCount = useMemo(() => { - // 스크롤이 bottom에 있을 때는 new message count를 표시하지 않음 - if (isScrollBottom) { - return false; - } - if (!store?.config?.groupChannel?.enableMarkAsUnread - && hasMoreNext - && (unreadSince || unreadSinceDate)) { - return true; - } - return false; - }, [newMessages.length, isScrollBottom]); - if (loading) { return renderPlaceholderLoader(); } @@ -220,54 +182,6 @@ export const MessageList = (props: MessageListProps) => { return renderPlaceholderEmpty(); } - const renderUnreadCount = () => { - if (isShowUnreadCount) { - if (!store?.config?.groupChannel?.enableMarkAsUnread) { - return ( - { - if (scrollRef?.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - if (!disableMarkAsRead && !!currentGroupChannel) { - markAsReadScheduler.push(currentGroupChannel); - messagesDispatcher({ - type: messageActionTypes.MARK_AS_READ, - payload: { channel: currentGroupChannel }, - }); - } - setInitialTimeStamp(null); - setAnimatedMessageId(null); - setHighLightedMessageId(null); - }} - /> - ); - } else { - return ( - { - if (scrollRef?.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - if (!disableMarkAsRead && !!currentGroupChannel) { - markAsReadScheduler.push(currentGroupChannel); - messagesDispatcher({ - type: messageActionTypes.MARK_AS_READ, - payload: { channel: currentGroupChannel }, - }); - } - setInitialTimeStamp(null); - setAnimatedMessageId(null); - setHighLightedMessageId(null); - }} - /> - ); - } - } - }; - return ( <> {!isScrolled && } @@ -355,27 +269,16 @@ export const MessageList = (props: MessageListProps) => {
{currentGroupChannel?.isFrozen && renderFrozenNotification()} - {renderUnreadCount()} { - // This flag is an unmatched variable - scrollBottom > SCROLL_BOTTOM_PADDING && ( -
- -
- ) - } - { - /* NewMessageCount - positioned at the bottom of MessageList */ - (isShowNewMessageCount) && ( - { if (scrollRef?.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; if (!disableMarkAsRead && !!currentGroupChannel) { @@ -392,6 +295,20 @@ export const MessageList = (props: MessageListProps) => { /> ) } + { + // This flag is an unmatched variable + scrollBottom > SCROLL_BOTTOM_PADDING && ( +
+ +
+ ) + }
); diff --git a/src/modules/Channel/context/ChannelProvider.tsx b/src/modules/Channel/context/ChannelProvider.tsx index bd7cce620..0030bc9c5 100644 --- a/src/modules/Channel/context/ChannelProvider.tsx +++ b/src/modules/Channel/context/ChannelProvider.tsx @@ -5,6 +5,7 @@ import React, { useRef, useMemo, } from 'react'; +import { UIKitConfigProvider, useUIKitConfig } from '@sendbird/uikit-tools'; import type { GroupChannel, Member } from '@sendbird/chat/groupChannel'; import type { @@ -52,6 +53,7 @@ import { PublishingModuleType } from '../../internalInterfaces'; import { ChannelActionTypes } from './dux/actionTypes'; import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; import { useLocalization } from '../../../lib/LocalizationContext'; +import { uikitConfigStorage } from '../../../lib/utils/uikitConfigStorage'; export { ThreadReplySelectType } from './const'; // export for external usage @@ -215,6 +217,7 @@ const ChannelProvider = (props: ChannelContextProps) => { const sdk = globalStore?.stores?.sdkStore?.sdk; const sdkInit = globalStore?.stores?.sdkStore?.initialized; const globalConfigs = globalStore?.config; + const { configs: uikitConfig } = useUIKitConfig(); const [initialTimeStamp, setInitialTimeStamp] = useState(startingPoint); useEffect(() => { @@ -441,6 +444,19 @@ const ChannelProvider = (props: ChannelContextProps) => { }); return ( + { {children} + ); }; diff --git a/src/modules/Channel/context/dux/actionTypes.ts b/src/modules/Channel/context/dux/actionTypes.ts index 98b04e47c..113a798ef 100644 --- a/src/modules/Channel/context/dux/actionTypes.ts +++ b/src/modules/Channel/context/dux/actionTypes.ts @@ -27,6 +27,7 @@ export const ON_MESSAGE_DELETED_BY_REQ_ID = 'ON_MESSAGE_DELETED_BY_REQ_ID'; export const SET_CURRENT_CHANNEL = 'SET_CURRENT_CHANNEL'; export const SET_CHANNEL_INVALID = 'SET_CHANNEL_INVALID'; export const MARK_AS_READ = 'MARK_AS_READ'; +export const MARK_AS_UNREAD = 'MARK_AS_UNREAD'; export const ON_REACTION_UPDATED = 'ON_REACTION_UPDATED'; export const SET_EMOJI_CONTAINER = 'SET_EMOJI_CONTAINER'; export const MESSAGE_LIST_PARAMS_CHANGED = 'MESSAGE_LIST_PARAMS_CHANGED'; @@ -77,6 +78,11 @@ type CHANNEL_PAYLOAD_TYPES = { [RESEND_MESSAGE_START]: SendableMessageType; [MARK_AS_READ]: { channel: null | GroupChannel; + userIds?: null |string[]; + }; + [MARK_AS_UNREAD]: { + channel: null | GroupChannel; + userIds?: null |string[]; }; [ON_MESSAGE_DELETED]: MessageId; [ON_MESSAGE_DELETED_BY_REQ_ID]: RequestId; diff --git a/src/modules/Channel/context/hooks/useHandleChannelEvents.ts b/src/modules/Channel/context/hooks/useHandleChannelEvents.ts index 481d3e0eb..cddd6807c 100644 --- a/src/modules/Channel/context/hooks/useHandleChannelEvents.ts +++ b/src/modules/Channel/context/hooks/useHandleChannelEvents.ts @@ -111,8 +111,11 @@ function useHandleChannelEvents({ logger.info('Channel | useHandleChannelEvents: onUserMarkedAsRead', channel, userIds); if (compareIds(channel?.url, channelUrl)) { messagesDispatcher({ - type: messageActions.SET_CURRENT_CHANNEL, - payload: channel, + type: messageActions.MARK_AS_READ, + payload: { + channel, + userIds, + }, }); } }, @@ -121,8 +124,11 @@ function useHandleChannelEvents({ // TODO:: MADOKA 이 부분에 대해서 명확하게 확인해야 함. if (compareIds(channel?.url, channelUrl)) { messagesDispatcher({ - type: messageActions.SET_CURRENT_CHANNEL, - payload: channel, + type: messageActions.MARK_AS_UNREAD, + payload: { + channel, + userIds, + }, }); } }, @@ -156,6 +162,7 @@ function useHandleChannelEvents({ }, onMessageDeleted: (channel, messageId) => { logger.info('Channel | useHandleChannelEvents: onMessageDeleted', { channel, messageId }); + console.log('MADOKA setQuoteMessage null #6'); setQuoteMessage(null); messagesDispatcher({ type: messageActions.ON_MESSAGE_DELETED, diff --git a/src/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView.tsx b/src/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView.tsx index 9e9b38520..272a38866 100644 --- a/src/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView.tsx +++ b/src/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView.tsx @@ -183,7 +183,9 @@ export const MessageInputWrapperView = React.forwardRef(( )} {quoteMessage && (
- setQuoteMessage(null)} /> + { + setQuoteMessage(null); + }} />
)} {showVoiceMessageInput ? ( @@ -244,11 +246,13 @@ export const MessageInputWrapperView = React.forwardRef(( }); setMentionNickname(''); setMentionedUsers([]); + console.log('MADOKA setQuoteMessage null #3'); setQuoteMessage(null); currentChannel?.endTyping?.(); }} onFileUpload={(fileList) => { handleUploadFiles(fileList); + console.log('MADOKA setQuoteMessage null #4'); setQuoteMessage(null); }} onUserMentioned={(user) => { diff --git a/src/modules/GroupChannel/components/MessageList/index.tsx b/src/modules/GroupChannel/components/MessageList/index.tsx index 7155b801c..9c174e462 100644 --- a/src/modules/GroupChannel/components/MessageList/index.tsx +++ b/src/modules/GroupChannel/components/MessageList/index.tsx @@ -1,5 +1,5 @@ import './index.scss'; -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useMemo } from 'react'; import type { GroupChannel, Member } from '@sendbird/chat/groupChannel'; import { useGroupChannelHandler } from '@sendbird/uikit-tools'; @@ -150,7 +150,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { const isFind = findFirstUnreadMessage(message, i, messages as CoreMessageType[], currentChannel); if (isFind && !isFromCurrentDevice(message.messageId)) { - return message.messageId; + return firstUnreadMessageIdRef.current ? undefined : message.messageId; } } } @@ -159,6 +159,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { // check changed channel useEffect(() => { + if (!currentChannel?.url) return; if (currentChannel?.url !== currentChannelRef.current?.url) { currentChannelRef.current = currentChannel; @@ -169,8 +170,9 @@ export const MessageList = (props: GroupChannelMessageListProps) => { if (markAsUnreadSourceRef?.current !== undefined) { markAsUnreadSourceRef.current = undefined; } - setIsChangedChannel(true); + currentMessagesRef.current = []; currentDeviceMessageIdsRef.current.clear(); + setIsChangedChannel(true); } else { setIsChangedChannel(false); } @@ -187,15 +189,14 @@ export const MessageList = (props: GroupChannelMessageListProps) => { firstUnreadMessageIdRef.current = firstUnreadMessageId; } hasInitializedRef.current = true; + setIsChangedChannel(false); } } } }, [messages]); - useEffect(() => { + useMemo(() => { if (state.config.groupChannel.enableMarkAsUnread && hasInitializedRef.current) { - if (firstUnreadMessageIdRef.current) return; - const firstUnreadMessageId = getFirstUnreadMessage(); if (firstUnreadMessageId && firstUnreadMessageIdRef.current !== firstUnreadMessageId) { diff --git a/src/ui/MessageMenu/MessageMenu.tsx b/src/ui/MessageMenu/MessageMenu.tsx index 86bf00481..4057d33cb 100644 --- a/src/ui/MessageMenu/MessageMenu.tsx +++ b/src/ui/MessageMenu/MessageMenu.tsx @@ -152,7 +152,7 @@ export const MessageMenu = ({ {showMenuItemThread(params) && } {showMenuItemOpenInChannel(params) && } {showMenuItemEdit(params) && } - {showMenuItemMarkAsUnread(params) && enableMarkAsUnread && } + {enableMarkAsUnread && showMenuItemMarkAsUnread(params) && } {showMenuItemResend(params) && } {showMenuItemDelete(params) && } diff --git a/src/ui/MobileMenu/MobileBottomSheet.tsx b/src/ui/MobileMenu/MobileBottomSheet.tsx index af48d6b44..f3a0efe97 100644 --- a/src/ui/MobileMenu/MobileBottomSheet.tsx +++ b/src/ui/MobileMenu/MobileBottomSheet.tsx @@ -15,7 +15,6 @@ import { isVoiceMessage, isThreadMessage, } from '../../utils'; -import { showMenuItemMarkAsUnread } from '../../utils/menuConditions'; import BottomSheet from '../BottomSheet'; import ImageRenderer from '../ImageRenderer'; import ReactionButton from '../ReactionButton'; @@ -83,13 +82,11 @@ const MobileBottomSheet: React.FunctionComponent = (prop && !isPendingMessage(message) && !isThreadMessage(message) && (channel?.isGroupChannel() && !(channel as GroupChannel)?.isBroadcast); - // const showMenuItemMarkAsUnreadCondition = showMenuItemMarkAsUnread({ - // message, - // channel, - // isByMe, - // replyType, - // onReplyInThread, - // }); + + const showMenuItemMarkAsUnread: boolean = !isFailedMessage(message) + && !isPendingMessage(message) + && channel?.isGroupChannel?.() + && replyType !== 'THREAD'; const maxEmojisPerRow = Math.floor(window.innerWidth / EMOJI_SIZE) - 1; const [showEmojisOnly, setShowEmojisOnly] = useState(false); @@ -208,14 +205,14 @@ const MobileBottomSheet: React.FunctionComponent = (prop <> {showMenuItemCopy && } {showMenuItemEdit && } - {showMenuItemMarkAsUnread && enableMarkAsUnread && } + {enableMarkAsUnread && showMenuItemMarkAsUnread && } {showMenuItemResend && } {showMenuItemReply && } {showMenuItemThread && } {showMenuItemDeleteFinal && } {showMenuItemDownload && } - )} + )}ß )} diff --git a/src/ui/MobileMenu/MobileContextMenu.tsx b/src/ui/MobileMenu/MobileContextMenu.tsx index e5be8a3f4..e2798fa22 100644 --- a/src/ui/MobileMenu/MobileContextMenu.tsx +++ b/src/ui/MobileMenu/MobileContextMenu.tsx @@ -12,7 +12,6 @@ import { isThreadMessage, isVoiceMessage, } from '../../utils'; -import { showMenuItemMarkAsUnread } from '../../utils/menuConditions'; import { MessageMenuProvider } from '../MessageMenu'; import type { MobileMessageMenuContextProps } from '../MessageMenu/MessageMenuProvider'; @@ -62,13 +61,7 @@ const MobileContextMenu: React.FunctionComponent = (props: BaseMe const showMenuItemDownload = !isPendingMessage(message) && isFileMessage(message) && !(isVoiceMessage(message) && (channel as GroupChannel)?.isSuper || (channel as GroupChannel)?.isBroadcast); const showMenuItemReply = replyType === 'QUOTE_REPLY' && !isFailedMessage(message) && !isPendingMessage(message) && channel?.isGroupChannel(); const showMenuItemThread = replyType === 'THREAD' && !isOpenedFromThread && !isFailedMessage(message) && !isPendingMessage(message) && !isThreadMessage(message) && channel?.isGroupChannel(); - const showMenuItemMarkAsUnreadCondition = showMenuItemMarkAsUnread({ - message, - channel, - isByMe, - replyType, - onReplyInThread, - }); + const showMenuItemMarkAsUnread = !isFailedMessage(message) && !isPendingMessage(message) && message.parentMessageId <= 0 && channel?.isGroupChannel?.(); const disableDeleteMessage = (deleteMenuState !== undefined && deleteMenuState === 'DISABLE') || (message?.threadInfo?.replyCount ?? 0) > 0; const contextValue: MobileMessageMenuContextProps = { @@ -114,7 +107,7 @@ const MobileContextMenu: React.FunctionComponent = (props: BaseMe {showMenuItemReply && } {showMenuItemThread && } {showMenuItemEdit && } - {showMenuItemMarkAsUnreadCondition && enableMarkAsUnread && } + {enableMarkAsUnread && showMenuItemMarkAsUnread && } {showMenuItemResend && } {showMenuItemDeleteFinal && } {showMenuItemDownload && } diff --git a/src/utils/menuConditions.ts b/src/utils/menuConditions.ts index d142c24ec..8b1ae908a 100644 --- a/src/utils/menuConditions.ts +++ b/src/utils/menuConditions.ts @@ -54,6 +54,6 @@ export const showMenuItemThread = ({ channel, message, replyType, onReplyInThrea return isReplyTypeMessageEnabled({ channel, message }) && replyType === 'THREAD' && !message?.parentMessageId && typeof onReplyInThread === 'function'; }; -export const showMenuItemMarkAsUnread = ({ message, channel }: MenuConditionsParams) => { - return !isFailedMessage(message) && !isPendingMessage(message) && channel?.isGroupChannel?.(); +export const showMenuItemMarkAsUnread = ({ message, channel, replyType }: MenuConditionsParams) => { + return !isFailedMessage(message) && !isPendingMessage(message) && channel?.isGroupChannel?.() && replyType !== 'THREAD'; }; From 8e7971d52e436f2d63dd0a9bd27e814e2252d0c7 Mon Sep 17 00:00:00 2001 From: danney-chun <63285271+danney-chun@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:22:29 +0900 Subject: [PATCH 09/19] removed console log --- src/modules/Channel/context/hooks/useHandleChannelEvents.ts | 1 - .../components/MessageInputWrapper/MessageInputWrapperView.tsx | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/modules/Channel/context/hooks/useHandleChannelEvents.ts b/src/modules/Channel/context/hooks/useHandleChannelEvents.ts index cddd6807c..99df31eca 100644 --- a/src/modules/Channel/context/hooks/useHandleChannelEvents.ts +++ b/src/modules/Channel/context/hooks/useHandleChannelEvents.ts @@ -162,7 +162,6 @@ function useHandleChannelEvents({ }, onMessageDeleted: (channel, messageId) => { logger.info('Channel | useHandleChannelEvents: onMessageDeleted', { channel, messageId }); - console.log('MADOKA setQuoteMessage null #6'); setQuoteMessage(null); messagesDispatcher({ type: messageActions.ON_MESSAGE_DELETED, diff --git a/src/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView.tsx b/src/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView.tsx index 272a38866..ee6b3e44c 100644 --- a/src/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView.tsx +++ b/src/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView.tsx @@ -246,13 +246,11 @@ export const MessageInputWrapperView = React.forwardRef(( }); setMentionNickname(''); setMentionedUsers([]); - console.log('MADOKA setQuoteMessage null #3'); setQuoteMessage(null); currentChannel?.endTyping?.(); }} onFileUpload={(fileList) => { handleUploadFiles(fileList); - console.log('MADOKA setQuoteMessage null #4'); setQuoteMessage(null); }} onUserMentioned={(user) => { From 0fe074decf3979f0fcb13daf28b8cf585bc53d92 Mon Sep 17 00:00:00 2001 From: danney-chun <63285271+danney-chun@users.noreply.github.com> Date: Wed, 16 Jul 2025 14:18:30 +0900 Subject: [PATCH 10/19] fixed QA Issue (CLNP-7115) --- .../components/MessageList/index.tsx | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/modules/GroupChannel/components/MessageList/index.tsx b/src/modules/GroupChannel/components/MessageList/index.tsx index 9c174e462..b37708fe8 100644 --- a/src/modules/GroupChannel/components/MessageList/index.tsx +++ b/src/modules/GroupChannel/components/MessageList/index.tsx @@ -107,9 +107,9 @@ export const MessageList = (props: GroupChannelMessageListProps) => { const [unreadSinceDate, setUnreadSinceDate] = useState(); const [isChangedChannel, setIsChangedChannel] = useState(false); - const [showUnreadCount, setShowUnreadCount] = useState(true); + const [showUnreadCount, setShowUnreadCount] = useState(false); - const firstUnreadMessageIdRef = useRef(); + const firstUnreadMessageRef = useRef(undefined); const hasInitializedRef = useRef(false); // current channel ref @@ -143,14 +143,14 @@ export const MessageList = (props: GroupChannelMessageListProps) => { return false; }; - const getFirstUnreadMessage = (): number | string => { + const getFirstUnreadMessage = (): CoreMessageType | undefined => { if (state.config.groupChannel.enableMarkAsUnread) { for (let i = 0; i < messages.length; i++) { const message = messages[i] as CoreMessageType; const isFind = findFirstUnreadMessage(message, i, messages as CoreMessageType[], currentChannel); if (isFind && !isFromCurrentDevice(message.messageId)) { - return firstUnreadMessageIdRef.current ? undefined : message.messageId; + return message; } } } @@ -164,8 +164,8 @@ export const MessageList = (props: GroupChannelMessageListProps) => { currentChannelRef.current = currentChannel; // initialize - firstUnreadMessageIdRef.current = undefined; - setShowUnreadCount(true); + firstUnreadMessageRef.current = undefined; + setShowUnreadCount(false); hasInitializedRef.current = false; if (markAsUnreadSourceRef?.current !== undefined) { markAsUnreadSourceRef.current = undefined; @@ -184,9 +184,9 @@ export const MessageList = (props: GroupChannelMessageListProps) => { if (!hasInitializedRef.current) { if (currentMessagesRef.current !== messages) { currentMessagesRef.current = messages as CoreMessageType[]; - const firstUnreadMessageId = getFirstUnreadMessage(); - if (firstUnreadMessageId) { - firstUnreadMessageIdRef.current = firstUnreadMessageId; + const firstUnreadMessage = getFirstUnreadMessage(); + if (firstUnreadMessage) { + firstUnreadMessageRef.current = firstUnreadMessage; } hasInitializedRef.current = true; setIsChangedChannel(false); @@ -197,10 +197,13 @@ export const MessageList = (props: GroupChannelMessageListProps) => { useMemo(() => { if (state.config.groupChannel.enableMarkAsUnread && hasInitializedRef.current) { - const firstUnreadMessageId = getFirstUnreadMessage(); - - if (firstUnreadMessageId && firstUnreadMessageIdRef.current !== firstUnreadMessageId) { - firstUnreadMessageIdRef.current = firstUnreadMessageId; + const firstUnreadMessage = getFirstUnreadMessage(); + if (firstUnreadMessage) { + if (!firstUnreadMessageRef.current) { + firstUnreadMessageRef.current = firstUnreadMessage; + } else if (firstUnreadMessageRef.current.messageId !== firstUnreadMessage.messageId && firstUnreadMessage.createdAt < firstUnreadMessageRef.current.createdAt) { + firstUnreadMessageRef.current = firstUnreadMessage; + } } } }, [messages.length]); @@ -208,11 +211,9 @@ export const MessageList = (props: GroupChannelMessageListProps) => { useEffect(() => { if (state.config.groupChannel.enableMarkAsUnread && hasInitializedRef.current) { if (readState === 'unread') { - // when readState === 'unread' find first unread message - const firstUnreadMessageId = getFirstUnreadMessage(); - - if (firstUnreadMessageId !== firstUnreadMessageIdRef.current) { - firstUnreadMessageIdRef.current = firstUnreadMessageId; + const firstUnreadMessage = getFirstUnreadMessage(); + if (firstUnreadMessage && firstUnreadMessageRef.current?.messageId !== firstUnreadMessage.messageId) { + firstUnreadMessageRef.current = firstUnreadMessage; } } setReadStateChanged(null); @@ -227,9 +228,9 @@ export const MessageList = (props: GroupChannelMessageListProps) => { setUnreadSinceDate(new Date()); } } else if (isScrollBottomReached) { - if (!firstUnreadMessageIdRef.current && currentChannel?.unreadMessageCount === 0) { + if (!firstUnreadMessageRef.current && currentChannel?.unreadMessageCount === 0) { markAsReadAll(currentChannel); - } else if (!firstUnreadMessageIdRef.current) { + } else if (!firstUnreadMessageRef.current) { markAsReadAll(currentChannel); } } @@ -315,7 +316,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { setShowUnreadCount(false); if (newMessages.length > 0) { markAsUnread(newMessages[0] as SendableMessageType, 'internal'); - firstUnreadMessageIdRef.current = newMessages[0].messageId; + firstUnreadMessageRef.current = newMessages[0] as CoreMessageType; } else if (currentChannel?.lastMessage.createdAt > currentChannel.myLastRead && markAsUnreadSourceRef?.current !== 'manual') { @@ -370,7 +371,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { currentMessage: message as CoreMessageType, currentChannel: currentChannel!, hasPrevious: hasPrevious(), - firstUnreadMessageId: firstUnreadMessageIdRef.current, + firstUnreadMessageId: firstUnreadMessageRef.current?.messageId, // firstUnreadMessageIdRef.current, isCurrentDeviceMessage, }); From ec05334dcb74642ce93cbf2ef32f65ea40a60a8d Mon Sep 17 00:00:00 2001 From: danney-chun <63285271+danney-chun@users.noreply.github.com> Date: Wed, 16 Jul 2025 14:52:45 +0900 Subject: [PATCH 11/19] New Message -> New Messages --- src/ui/NewMessageSeparator/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/NewMessageSeparator/index.tsx b/src/ui/NewMessageSeparator/index.tsx index e7b78e374..b2a544574 100644 --- a/src/ui/NewMessageSeparator/index.tsx +++ b/src/ui/NewMessageSeparator/index.tsx @@ -67,7 +67,7 @@ const NewMessageIndicator = ({ children || ( ) } From 826421c9c276c2c198cb5fa51b8871f0fdbbdc3d Mon Sep 17 00:00:00 2001 From: danney-chun <63285271+danney-chun@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:43:32 +0900 Subject: [PATCH 12/19] Fixed QA Issue (#1356) QA --- .../components/Message/MessageView.tsx | 2 +- .../MessageList/getMessagePartsInfo.ts | 6 +- .../components/MessageList/index.tsx | 202 ++++++------------ .../UnreadCountFloatingButton/index.tsx | 2 +- .../context/GroupChannelProvider.tsx | 11 +- .../context/hooks/useGroupChannel.ts | 9 - .../context/hooks/useMessageActions.ts | 10 - src/modules/GroupChannel/context/types.ts | 3 - src/ui/NewMessageSeparator/index.tsx | 2 +- 9 files changed, 79 insertions(+), 168 deletions(-) diff --git a/src/modules/GroupChannel/components/Message/MessageView.tsx b/src/modules/GroupChannel/components/Message/MessageView.tsx index 502f15818..a514db2af 100644 --- a/src/modules/GroupChannel/components/Message/MessageView.tsx +++ b/src/modules/GroupChannel/components/Message/MessageView.tsx @@ -431,7 +431,7 @@ const MessageView = (props: MessageViewProps) => { && ( )} diff --git a/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts b/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts index c01dfc771..8adc6eeb9 100644 --- a/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts +++ b/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts @@ -3,7 +3,7 @@ import isSameDay from 'date-fns/isSameDay'; import { compareMessagesForGrouping } from '../../../../utils/messages'; import { ReplyType } from '../../../../types'; -import { CoreMessageType } from '../../../../utils'; +import { CoreMessageType, isAdminMessage } from '../../../../utils'; import { StringSet } from '../../../../ui/Label/stringSet'; export interface GetMessagePartsInfoProps { @@ -16,7 +16,6 @@ export interface GetMessagePartsInfoProps { replyType?: string; hasPrevious?: boolean; firstUnreadMessageId?: number | string | undefined; - isCurrentDeviceMessage?: boolean; } interface OutPuts { @@ -38,7 +37,6 @@ export const getMessagePartsInfo = ({ currentChannel = null, replyType = '', firstUnreadMessageId, - isCurrentDeviceMessage, }: GetMessagePartsInfoProps): OutPuts => { const previousMessage = allMessages[currentIndex - 1]; const nextMessage = allMessages[currentIndex + 1]; @@ -54,7 +52,7 @@ export const getMessagePartsInfo = ({ // https://stackoverflow.com/a/41855608 const hasSeparator = isLocalMessage ? false : !(previousMessageCreatedAt && (isSameDay(currentCreatedAt, previousMessageCreatedAt))); - const hasNewMessageSeparator = !isCurrentDeviceMessage && firstUnreadMessageId === currentMessage.messageId; + const hasNewMessageSeparator = isLocalMessage ? false : (!isAdminMessage(currentMessage) && firstUnreadMessageId === currentMessage.messageId); return { chainTop, diff --git a/src/modules/GroupChannel/components/MessageList/index.tsx b/src/modules/GroupChannel/components/MessageList/index.tsx index b37708fe8..cf6fd0c7d 100644 --- a/src/modules/GroupChannel/components/MessageList/index.tsx +++ b/src/modules/GroupChannel/components/MessageList/index.tsx @@ -1,6 +1,6 @@ import './index.scss'; -import React, { useEffect, useState, useRef, useMemo } from 'react'; -import type { GroupChannel, Member } from '@sendbird/chat/groupChannel'; +import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'; +import type { Member } from '@sendbird/chat/groupChannel'; import { useGroupChannelHandler } from '@sendbird/uikit-tools'; import { CoreMessageType, isSendableMessage, getHTMLTextDirection, SendableMessageType } from '../../../../utils'; @@ -73,7 +73,6 @@ export const MessageList = (props: GroupChannelMessageListProps) => { state: { channelUrl, hasNext, - hasPrevious, loading, messages, newMessages, @@ -90,152 +89,111 @@ export const MessageList = (props: GroupChannelMessageListProps) => { scrollDistanceFromBottomRef, markAsUnreadSourceRef, readState, - currentDeviceMessageIdsRef, }, actions: { scrollToBottom, setIsScrollBottomReached, markAsReadAll, markAsUnread, - setReadStateChanged, - isFromCurrentDevice, }, } = useGroupChannel(); - const { state } = useSendbird(); + const { state, state: { config: { groupChannel: { enableMarkAsUnread } } } } = useSendbird(); const { stringSet } = useLocalization(); const [unreadSinceDate, setUnreadSinceDate] = useState(); - const [isChangedChannel, setIsChangedChannel] = useState(false); const [showUnreadCount, setShowUnreadCount] = useState(false); - const firstUnreadMessageRef = useRef(undefined); - const hasInitializedRef = useRef(false); + const isInitializedRef = useRef(false); + const separatorMessageRef = useRef(undefined); - // current channel ref - const currentChannelRef = useRef(undefined); - // current messages ref - const currentMessagesRef = useRef([]); - - /** - * Find the first unread message in the message list - */ - const findFirstUnreadMessage = ( - message: CoreMessageType, - index: number, - allMessages: CoreMessageType[], - currentChannel: GroupChannel, - ): boolean => { - const currentCreatedAt = message.createdAt; - - // condition 1: message.createdAt === (channel.myLastRead + 1) - if (currentCreatedAt === (currentChannel.myLastRead + 1)) { - return true; + // Find the first unread message + const firstUnreadMessage = useMemo(() => { + if (!enableMarkAsUnread || !isInitializedRef.current || messages.length === 0 || readState === 'read') { + return undefined; } - // condition 2: there is no message that satisfies the condition, and the current message is the first message that satisfies the condition - if (currentCreatedAt > (currentChannel.myLastRead + 1)) { - const hasPreviousMatchingMessage = allMessages - .slice(0, index) - .some(msg => msg.createdAt === (currentChannel.myLastRead + 1)); - return !hasPreviousMatchingMessage; - } - return false; - }; + if (readState === 'unread') separatorMessageRef.current = undefined; - const getFirstUnreadMessage = (): CoreMessageType | undefined => { - if (state.config.groupChannel.enableMarkAsUnread) { - for (let i = 0; i < messages.length; i++) { - const message = messages[i] as CoreMessageType; - const isFind = findFirstUnreadMessage(message, i, messages as CoreMessageType[], currentChannel); + const myLastRead = currentChannel.myLastRead; + const willFindMessageCreatedAt = myLastRead + 1; - if (isFind && !isFromCurrentDevice(message.messageId)) { - return message; - } - } + // 조건 1: 정확히 myLastRead + 1인 메시지 찾기 + const exactMatchMessage = messages.find((message) => message.createdAt === willFindMessageCreatedAt, + ); + + if (exactMatchMessage) { + return exactMatchMessage as CoreMessageType; } - return undefined; - }; - // check changed channel - useEffect(() => { - if (!currentChannel?.url) return; - if (currentChannel?.url !== currentChannelRef.current?.url) { - currentChannelRef.current = currentChannel; + // 조건 2: myLastRead + 1보다 큰 첫 번째 메시지 찾기 + for (let i = 0; i < messages.length; i++) { + const message = messages[i] as CoreMessageType; - // initialize - firstUnreadMessageRef.current = undefined; - setShowUnreadCount(false); - hasInitializedRef.current = false; - if (markAsUnreadSourceRef?.current !== undefined) { - markAsUnreadSourceRef.current = undefined; - } - currentMessagesRef.current = []; - currentDeviceMessageIdsRef.current.clear(); - setIsChangedChannel(true); - } else { - setIsChangedChannel(false); - } - }, [currentChannel?.url]); + if (message.createdAt > willFindMessageCreatedAt) { + // 이전에 정확히 myLastRead + 1인 메시지가 있는지 확인 + const hasPreviousExactMatch = messages + .slice(0, i) + .some(msg => msg.createdAt === willFindMessageCreatedAt); - // when enableMarkAsUnread is true, check changed messages - useEffect(() => { - if (state.config.groupChannel.enableMarkAsUnread && isChangedChannel) { - if (!hasInitializedRef.current) { - if (currentMessagesRef.current !== messages) { - currentMessagesRef.current = messages as CoreMessageType[]; - const firstUnreadMessage = getFirstUnreadMessage(); - if (firstUnreadMessage) { - firstUnreadMessageRef.current = firstUnreadMessage; - } - hasInitializedRef.current = true; - setIsChangedChannel(false); - } - } - } - }, [messages]); - - useMemo(() => { - if (state.config.groupChannel.enableMarkAsUnread && hasInitializedRef.current) { - const firstUnreadMessage = getFirstUnreadMessage(); - if (firstUnreadMessage) { - if (!firstUnreadMessageRef.current) { - firstUnreadMessageRef.current = firstUnreadMessage; - } else if (firstUnreadMessageRef.current.messageId !== firstUnreadMessage.messageId && firstUnreadMessage.createdAt < firstUnreadMessageRef.current.createdAt) { - firstUnreadMessageRef.current = firstUnreadMessage; + if (!hasPreviousExactMatch) { + return message; } } } - }, [messages.length]); + + return undefined; + }, [messages, currentChannel?.myLastRead, readState]); useEffect(() => { - if (state.config.groupChannel.enableMarkAsUnread && hasInitializedRef.current) { - if (readState === 'unread') { - const firstUnreadMessage = getFirstUnreadMessage(); - if (firstUnreadMessage && firstUnreadMessageRef.current?.messageId !== firstUnreadMessage.messageId) { - firstUnreadMessageRef.current = firstUnreadMessage; - } - } - setReadStateChanged(null); + if (currentChannel?.url && loading) { + // done get channel and messages + setShowUnreadCount(currentChannel?.unreadMessageCount > 0); + isInitializedRef.current = true; } - }, [readState]); + }, [currentChannel?.url, loading]); useEffect(() => { - if (!state.config.groupChannel.enableMarkAsUnread) { + if (!isInitializedRef.current) return; + + if (!enableMarkAsUnread) { + // backward compatibility if (isScrollBottomReached) { setUnreadSinceDate(undefined); } else { setUnreadSinceDate(new Date()); } } else if (isScrollBottomReached) { - if (!firstUnreadMessageRef.current && currentChannel?.unreadMessageCount === 0) { - markAsReadAll(currentChannel); - } else if (!firstUnreadMessageRef.current) { - markAsReadAll(currentChannel); + if (markAsUnreadSourceRef?.current !== 'manual') { + if (currentChannel?.unreadMessageCount > 0) { + if (separatorMessageRef.current) { + // called markAsReadAll as after the first unread message is displayed + markAsReadAll(currentChannel); + } + } } } }, [isScrollBottomReached]); + const checkDisplayedNewMessageSeparator = useCallback((isNewMessageSeparatorVisible: boolean) => { + if (!isInitializedRef.current || !firstUnreadMessage) return; + + if (isNewMessageSeparatorVisible) { + setShowUnreadCount(false); + if (newMessages?.length > 0) { + markAsUnread(newMessages[0] as SendableMessageType, 'internal'); + separatorMessageRef.current = undefined; + } else if (firstUnreadMessage && markAsUnreadSourceRef?.current !== 'manual') { + if (!separatorMessageRef.current) { + separatorMessageRef.current = firstUnreadMessage; + } + markAsReadAll(currentChannel); + } + } else if (currentChannel?.unreadMessageCount > 0) { + setShowUnreadCount(true); + } + }, [firstUnreadMessage]); + /** * 1. Move the message list scroll * when each message's height is changed by `reactions` OR `showEdit` @@ -260,8 +218,8 @@ export const MessageList = (props: GroupChannelMessageListProps) => { return renderFrozenNotification(); }, unreadMessagesNotification() { - if (state.config.groupChannel.enableMarkAsUnread) { - if (!showUnreadCount) return null; + if (enableMarkAsUnread) { + if (!showUnreadCount || currentChannel?.unreadMessageCount === 0) return null; return ( { }, }; - const checkDisplayedNewMessageSeparator = (isNewMessageSeparatorVisible: boolean) => { - if (isNewMessageSeparatorVisible) { - setShowUnreadCount(false); - if (newMessages.length > 0) { - markAsUnread(newMessages[0] as SendableMessageType, 'internal'); - firstUnreadMessageRef.current = newMessages[0] as CoreMessageType; - } else if (currentChannel?.lastMessage.createdAt > currentChannel.myLastRead - && markAsUnreadSourceRef?.current !== 'manual') - { - markAsReadAll(currentChannel); - } - } else if (currentChannel?.unreadMessageCount > 0) { - setShowUnreadCount(true); - } - }; - if (loading) { return renderPlaceholderLoader(); } @@ -353,14 +295,14 @@ export const MessageList = (props: GroupChannelMessageListProps) => { onLoadPrevious={loadPrevious} onScrollPosition={(it) => { const isScrollBottomReached = it === 'bottom'; - if (hasInitializedRef.current && isScrollBottomReached && newMessages.length > 0) { + if (isInitializedRef.current && isScrollBottomReached && newMessages.length > 0) { resetNewMessages(); } setIsScrollBottomReached(isScrollBottomReached); }} messages={messages} renderMessage={({ message, index }) => { - const isCurrentDeviceMessage = isFromCurrentDevice(message.messageId); + const finalFirstUnreadMessageId = separatorMessageRef.current?.messageId || firstUnreadMessage?.messageId; const { chainTop, chainBottom, hasSeparator, hasNewMessageSeparator } = getMessagePartsInfo({ allMessages: messages as CoreMessageType[], @@ -370,9 +312,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { currentIndex: index, currentMessage: message as CoreMessageType, currentChannel: currentChannel!, - hasPrevious: hasPrevious(), - firstUnreadMessageId: firstUnreadMessageRef.current?.messageId, // firstUnreadMessageIdRef.current, - isCurrentDeviceMessage, + firstUnreadMessageId: finalFirstUnreadMessageId, }); const isOutgoingMessage = isSendableMessage(message) && message.sender.userId === state.config.userId; diff --git a/src/modules/GroupChannel/components/UnreadCountFloatingButton/index.tsx b/src/modules/GroupChannel/components/UnreadCountFloatingButton/index.tsx index 35e4eafcd..31b2e236e 100644 --- a/src/modules/GroupChannel/components/UnreadCountFloatingButton/index.tsx +++ b/src/modules/GroupChannel/components/UnreadCountFloatingButton/index.tsx @@ -37,7 +37,6 @@ export const UnreadCount: React.FC = ({ className, )} data-testid="sendbird-notification" - onClick={onClick} >