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/lib/utils/__tests__/uikitConfigMapper.spec.ts b/src/lib/utils/__tests__/uikitConfigMapper.spec.ts index e9160db99..d69699bf3 100644 --- a/src/lib/utils/__tests__/uikitConfigMapper.spec.ts +++ b/src/lib/utils/__tests__/uikitConfigMapper.spec.ts @@ -38,6 +38,7 @@ describe('uikitConfigMapper', () => { expect(result).toHaveProperty('groupChannel.enableTypingIndicator'); expect(result).toHaveProperty('groupChannel.threadReplySelectType'); expect(result).toHaveProperty('groupChannel.input.enableDocument'); + expect(result).toHaveProperty('groupChannel.enableMarkAsUnread'); expect(result).toHaveProperty('openChannel.enableOgtag'); expect(result).toHaveProperty('openChannel.input.enableDocument'); @@ -50,6 +51,7 @@ describe('uikitConfigMapper', () => { const uikitOptions = { groupChannel: { enableMention: false, + enableMarkAsUnread: true, }, groupChannelSettings: { enableMessageSearch: false, @@ -58,6 +60,7 @@ describe('uikitConfigMapper', () => { const result = uikitConfigMapper({ legacyConfig, uikitOptions }); expect(result.groupChannel?.enableMention).toBe(false); + expect(result.groupChannel?.enableMarkAsUnread).toBe(true); expect(result.groupChannelSettings?.enableMessageSearch).toBe(false); }); it('should return true <-> false flipped result for disableUserProfile when its converted into enableUsingDefaultUserProfile', () => { @@ -74,4 +77,14 @@ describe('uikitConfigMapper', () => { .common?.enableUsingDefaultUserProfile, ).toBe(false); }); + it('should correctly handle enableMarkAsUnread from uikitOptions', () => { + const uikitOptions = { + groupChannel: { + enableMarkAsUnread: true, + }, + } as UIKitOptions; + const result = uikitConfigMapper({ legacyConfig: mockLegacyConfig, uikitOptions }); + + expect(result.groupChannel?.enableMarkAsUnread).toBe(true); + }); }); diff --git a/src/lib/utils/uikitConfigMapper.ts b/src/lib/utils/uikitConfigMapper.ts index 9ecdbb471..ff00c9385 100644 --- a/src/lib/utils/uikitConfigMapper.ts +++ b/src/lib/utils/uikitConfigMapper.ts @@ -41,6 +41,7 @@ export function uikitConfigMapper({ suggestedRepliesDirection: uikitOptions.groupChannel?.suggestedRepliesDirection, enableMarkdownForUserMessage: uikitOptions.groupChannel?.enableMarkdownForUserMessage, enableFormTypeMessage: uikitOptions.groupChannel?.enableFormTypeMessage, + enableMarkAsUnread: uikitOptions.groupChannel?.enableMarkAsUnread, }, groupChannelList: { enableTypingIndicator: uikitOptions.groupChannelList?.enableTypingIndicator ?? isTypingIndicatorEnabledOnChannelList, diff --git a/src/modules/Channel/components/NewMessageCountFloatingButton/index.tsx b/src/modules/Channel/components/NewMessageCountFloatingButton/index.tsx new file mode 100644 index 000000000..16efd3265 --- /dev/null +++ b/src/modules/Channel/components/NewMessageCountFloatingButton/index.tsx @@ -0,0 +1,3 @@ +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/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 964166823..99df31eca 100644 --- a/src/modules/Channel/context/hooks/useHandleChannelEvents.ts +++ b/src/modules/Channel/context/hooks/useHandleChannelEvents.ts @@ -107,6 +107,31 @@ function useHandleChannelEvents({ }); } }, + onUserMarkedRead: (channel, userIds) => { + logger.info('Channel | useHandleChannelEvents: onUserMarkedAsRead', channel, userIds); + if (compareIds(channel?.url, channelUrl)) { + messagesDispatcher({ + type: messageActions.MARK_AS_READ, + payload: { + channel, + userIds, + }, + }); + } + }, + onUserMarkedUnread: (channel, userIds) => { + logger.info('Channel | useHandleChannelEvents: onUserMarkedUnread', channel, userIds); + // TODO:: MADOKA 이 부분에 대해서 명확하게 확인해야 함. + if (compareIds(channel?.url, channelUrl)) { + messagesDispatcher({ + type: messageActions.MARK_AS_UNREAD, + payload: { + channel, + userIds, + }, + }); + } + }, // 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..a514db2af 100644 --- a/src/modules/GroupChannel/components/Message/MessageView.tsx +++ b/src/modules/GroupChannel/components/Message/MessageView.tsx @@ -22,10 +22,12 @@ 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; hasSeparator?: boolean; + hasNewMessageSeparator?: boolean; chainTop?: boolean; chainBottom?: boolean; handleScroll?: (isBottomMessageAffected?: boolean) => void; @@ -49,6 +51,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. @@ -79,6 +85,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,9 +114,11 @@ const MessageView = (props: MessageViewProps) => { message, children, hasSeparator, + hasNewMessageSeparator, chainTop, chainBottom, handleScroll, + onNewMessageSeparatorVisibilityChange, // MessageViewProps channel, @@ -132,6 +141,7 @@ const MessageView = (props: MessageViewProps) => { updateUserMessage, resendMessage, deleteMessage, + markAsUnread, setAnimatedMessageId, animatedMessageId, @@ -288,6 +298,7 @@ const MessageView = (props: MessageViewProps) => { onMessageHeightChange: handleScroll, onBeforeDownloadFileMessage, filterEmojiCategoryIds, + markAsUnread, })} { /* Suggested Replies */ } { @@ -415,6 +426,15 @@ const MessageView = (props: MessageViewProps) => { ))} + {/* new message indicator */} + {hasNewMessageSeparator + && ( + + + + )} {renderChildren()} ); 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/MessageInputWrapper/MessageInputWrapperView.tsx b/src/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView.tsx index 9e9b38520..ee6b3e44c 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 ? ( diff --git a/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts b/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts index 718e6cfac..64eefdbc1 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 { @@ -14,12 +14,16 @@ export interface GetMessagePartsInfoProps { currentMessage: CoreMessageType; currentChannel?: GroupChannel | null; replyType?: string; + hasPrevious?: boolean; + firstUnreadMessageId?: number | string | undefined; + isUnreadMessageExistInChannel?: React.MutableRefObject; } interface OutPuts { chainTop: boolean, chainBottom: boolean, hasSeparator: boolean, + hasNewMessageSeparator: boolean, } /** @@ -33,6 +37,8 @@ export const getMessagePartsInfo = ({ currentMessage, currentChannel = null, replyType = '', + firstUnreadMessageId, + isUnreadMessageExistInChannel, }: GetMessagePartsInfoProps): OutPuts => { const previousMessage = allMessages[currentIndex - 1]; const nextMessage = allMessages[currentIndex + 1]; @@ -47,9 +53,13 @@ export const getMessagePartsInfo = ({ // https://stackoverflow.com/a/41855608 const hasSeparator = isLocalMessage ? false : !(previousMessageCreatedAt && (isSameDay(currentCreatedAt, previousMessageCreatedAt))); + + const hasNewMessageSeparator = (isLocalMessage || !isUnreadMessageExistInChannel?.current) ? false : (!isAdminMessage(currentMessage) && firstUnreadMessageId === currentMessage.messageId); + return { chainTop, chainBottom, hasSeparator, + hasNewMessageSeparator, }; }; diff --git a/src/modules/GroupChannel/components/MessageList/index.tsx b/src/modules/GroupChannel/components/MessageList/index.tsx index d58cc534e..c29ef07bf 100644 --- a/src/modules/GroupChannel/components/MessageList/index.tsx +++ b/src/modules/GroupChannel/components/MessageList/index.tsx @@ -1,15 +1,17 @@ import './index.scss'; -import React, { useEffect, useState } from 'react'; +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 } from '../../../../utils'; +import { CoreMessageType, isSendableMessage, getHTMLTextDirection, SendableMessageType, isAdminMessage } 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 UnreadCountFloatingButton from '../UnreadCountFloatingButton'; +import NewMessageCountFloatingButton from '../NewMessageCountFloatingButton'; import FrozenNotification from '../FrozenNotification'; import { SCROLL_BUFFER } from '../../../../utils/consts'; import TypingIndicatorBubble from '../../../../ui/TypingIndicatorBubble'; @@ -85,26 +87,108 @@ export const MessageList = (props: GroupChannelMessageListProps) => { scrollRef, scrollPositionRef, scrollDistanceFromBottomRef, + markAsUnreadSourceRef, + readState, }, actions: { scrollToBottom, setIsScrollBottomReached, + markAsReadAll, + markAsUnread, }, } = useGroupChannel(); - const { state } = useSendbird(); + const { state, state: { config: { groupChannel: { enableMarkAsUnread } } } } = useSendbird(); const { stringSet } = useLocalization(); const [unreadSinceDate, setUnreadSinceDate] = useState(); + const [showUnreadCount, setShowUnreadCount] = useState(false); + + const isInitializedRef = useRef(false); + const separatorMessageRef = useRef(undefined); + const isUnreadMessageExistInChannel = useRef(false); + + // Find the first unread message + const firstUnreadMessage = useMemo(() => { + if (!enableMarkAsUnread || !isInitializedRef.current || messages.length === 0) { + return undefined; + } + + if (readState === 'read') { + separatorMessageRef.current = undefined; + isUnreadMessageExistInChannel.current = false; + } else if (readState === 'unread') { + separatorMessageRef.current = undefined; + isUnreadMessageExistInChannel.current = true; + } + + const myLastRead = currentChannel.myLastRead; + const willFindMessageCreatedAt = myLastRead + 1; + + // 조건 1: 정확히 myLastRead + 1인 메시지 찾기 + const exactMatchMessage = messages.find((message) => message.createdAt === willFindMessageCreatedAt); + + if (exactMatchMessage) { + return exactMatchMessage as CoreMessageType; + } + + // 조건 2: myLastRead + 1보다 큰 첫 번째 메시지 찾기 (Admin 메시지 제외) + return messages.find((message) => message.createdAt > willFindMessageCreatedAt && !isAdminMessage(message as any)) as CoreMessageType | undefined; + }, [messages, currentChannel?.myLastRead, readState]); + + useEffect(() => { + if (currentChannel?.url && loading) { + // done get channel and messages + setShowUnreadCount(currentChannel?.unreadMessageCount > 0); + isInitializedRef.current = true; + isUnreadMessageExistInChannel.current = currentChannel?.lastMessage?.createdAt > currentChannel?.myLastRead; + } + }, [currentChannel?.url, loading]); useEffect(() => { - if (isScrollBottomReached) { - setUnreadSinceDate(undefined); - } else { - setUnreadSinceDate(new Date()); + if (!isInitializedRef.current) return; + + if (!enableMarkAsUnread) { + // backward compatibility + if (isScrollBottomReached) { + setUnreadSinceDate(undefined); + } else { + setUnreadSinceDate(new Date()); + } + } else if (isScrollBottomReached) { + if (markAsUnreadSourceRef?.current !== 'manual') { + if (currentChannel?.unreadMessageCount > 0) { + if (separatorMessageRef.current || !isUnreadMessageExistInChannel.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 && currentChannel?.unreadMessageCount > 0) { + setShowUnreadCount(false); + } else if (!isNewMessageSeparatorVisible && currentChannel?.unreadMessageCount > 0) { + setShowUnreadCount(true); + } + + if (isNewMessageSeparatorVisible && markAsUnreadSourceRef?.current !== 'manual') { + if (newMessages?.length > 0) { + markAsUnread(newMessages[0] as SendableMessageType, 'internal'); + separatorMessageRef.current = undefined; + } else if (firstUnreadMessage) { + if (!separatorMessageRef.current || separatorMessageRef.current.messageId !== firstUnreadMessage.messageId) { + separatorMessageRef.current = firstUnreadMessage; + } + markAsReadAll(currentChannel); + } + } + }, [firstUnreadMessage, markAsUnread, markAsReadAll, currentChannel?.unreadMessageCount]); + /** * 1. Move the message list scroll * when each message's height is changed by `reactions` OR `showEdit` @@ -129,6 +213,22 @@ export const MessageList = (props: GroupChannelMessageListProps) => { return renderFrozenNotification(); }, unreadMessagesNotification() { + if (enableMarkAsUnread) { + if (!showUnreadCount || currentChannel?.unreadMessageCount === 0) return null; + return ( + { + if (newMessages.length > 0) { + resetNewMessages(); + } + markAsReadAll(currentChannel); + }} + /> + ); + } if (isScrollBottomReached || !unreadSinceDate) return null; return ( { ); }, + newMessageCount() { + // 스크롤이 bottom에 있을 때는 new message count를 표시하지 않음 + if (isScrollBottomReached) return null; + return ( + scrollToBottom()} + /> + ); + }, }; if (loading) { @@ -182,14 +293,20 @@ export const MessageList = (props: GroupChannelMessageListProps) => { onLoadPrevious={loadPrevious} onScrollPosition={(it) => { const isScrollBottomReached = it === 'bottom'; - if (newMessages.length > 0 && isScrollBottomReached) { - resetNewMessages(); + if (isInitializedRef.current && isScrollBottomReached) { + if (newMessages.length > 0) { + resetNewMessages(); + } else if (!isUnreadMessageExistInChannel.current && currentChannel?.unreadMessageCount === 0) { + markAsReadAll(currentChannel); + } } setIsScrollBottomReached(isScrollBottomReached); }} messages={messages} renderMessage={({ message, index }) => { - const { chainTop, chainBottom, hasSeparator } = getMessagePartsInfo({ + const finalFirstUnreadMessageId = separatorMessageRef.current?.messageId || firstUnreadMessage?.messageId; + + const { chainTop, chainBottom, hasSeparator, hasNewMessageSeparator } = getMessagePartsInfo({ allMessages: messages as CoreMessageType[], stringSet, replyType: replyType ?? 'NONE', @@ -197,19 +314,25 @@ export const MessageList = (props: GroupChannelMessageListProps) => { currentIndex: index, currentMessage: message as CoreMessageType, currentChannel: currentChannel!, + firstUnreadMessageId: finalFirstUnreadMessageId, + isUnreadMessageExistInChannel, }); + const isOutgoingMessage = isSendableMessage(message) && message.sender.userId === state.config.userId; + return ( {renderMessage({ handleScroll: onMessageContentSizeChanged, message: message as EveryMessage, hasSeparator, + hasNewMessageSeparator, chainTop, chainBottom, renderMessageContent, renderSuggestedReplies, renderCustomSeparator, + onNewMessageSeparatorVisibilityChange: checkDisplayedNewMessageSeparator, })} ); @@ -225,6 +348,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { <>{renderer.frozenNotification()} <>{renderer.unreadMessagesNotification()} <>{renderer.scrollToBottomButton()} + <>{renderer.newMessageCount()} ); diff --git a/src/modules/GroupChannel/components/NewMessageCountFloatingButton/index.scss b/src/modules/GroupChannel/components/NewMessageCountFloatingButton/index.scss new file mode 100644 index 000000000..1bd4df3fa --- /dev/null +++ b/src/modules/GroupChannel/components/NewMessageCountFloatingButton/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/NewMessageCountFloatingButton/index.tsx b/src/modules/GroupChannel/components/NewMessageCountFloatingButton/index.tsx new file mode 100644 index 000000000..fa3218c7a --- /dev/null +++ b/src/modules/GroupChannel/components/NewMessageCountFloatingButton/index.tsx @@ -0,0 +1,64 @@ +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'; +import { useMediaQueryContext } from '../../../../lib/MediaQueryContext'; + +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 { isMobile } = useMediaQueryContext(); + + 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 ( +
+ + { + !isMobile && ( + + ) + } +
+ ); +}; + +export default NewMessageCount; diff --git a/src/modules/GroupChannel/components/UnreadCount/index.scss b/src/modules/GroupChannel/components/UnreadCount/index.scss index fcff9113a..50850f31b 100644 --- a/src/modules/GroupChannel/components/UnreadCount/index.scss +++ b/src/modules/GroupChannel/components/UnreadCount/index.scss @@ -2,52 +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'; + @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; -} + .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/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..31b2e236e --- /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 05f049b5e..82c884683 100644 --- a/src/modules/GroupChannel/context/GroupChannelProvider.tsx +++ b/src/modules/GroupChannel/context/GroupChannelProvider.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useEffect, useRef, createContext } from 'react'; +import React, { useMemo, useEffect, useRef, createContext, useCallback } from 'react'; import { ReplyType as ChatReplyType, } from '@sendbird/chat/message'; @@ -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, @@ -45,6 +46,7 @@ const initialState = () => ({ quoteMessage: null, animatedMessageId: null, isScrollBottomReached: true, + readState: null, scrollRef: { current: null }, scrollDistanceFromBottomRef: { current: 0 }, @@ -149,7 +151,7 @@ const GroupChannelManager :React.FC(undefined); + + const markAsUnread = useCallback((message: any, source?: 'manual' | '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, config.groupChannel.enableMarkAsUnread]); + // Message Collection setup const messageDataSource = useGroupChannelMessages(sdkStore.sdk, state.currentChannel!, { startingPoint, @@ -184,8 +211,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) => { @@ -195,7 +224,7 @@ const GroupChannelManager :React.FC actions.scrollToBottom(true), 10); + setTimeout(async () => actions.scrollToBottom(true), 10); } }, onChannelDeleted: () => { @@ -206,7 +235,17 @@ const GroupChannelManager :React.FC { + onChannelUpdated: (channel, ctx) => { + 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); }, logger: logger as any, @@ -239,8 +278,8 @@ const GroupChannelManager :React.FC { @@ -345,6 +384,8 @@ const GroupChannelManager :React.FC = (props) => { return ( - + {props.children} diff --git a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts index a3c6bea0e..b57f8a538 100644 --- a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts +++ b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts @@ -24,6 +24,10 @@ export interface GroupChannelActions extends MessageActions { // Channel actions setCurrentChannel: (channel: GroupChannel) => void; handleChannelError: (error: SendbirdError) => void; + markAsReadAll: (channel: GroupChannel) => void; + markAsUnread: (message: SendableMessageType, source?: 'manual' | 'internal') => void; + setReadStateChanged: (state: string) => void; + setFirstUnreadMessageId: (messageId: number | string | null) => void; // Message actions sendUserMessage: (params: UserMessageCreateParams) => Promise; @@ -73,15 +77,24 @@ 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 && state.currentChannel.myMemberState !== 'none') { - markAsReadScheduler.push(state.currentChannel); + if (!state.disableMarkAsRead) { + if (!config.groupChannel.enableMarkAsUnread && state.currentChannel.myMemberState !== 'none') { + markAsReadScheduler.push(state.currentChannel); + } } } - }, [state.scrollRef.current, config.isOnline, markAsReadScheduler]); + }, [state.scrollRef.current, config.isOnline, markAsReadScheduler, config.groupChannel.enableMarkAsUnread]); + + const markAsReadAll = useCallback((channel: GroupChannel) => { + if (config.isOnline && !state.disableMarkAsRead && channel) { + markAsReadScheduler?.push(channel); + } + }, [config.isOnline, state.disableMarkAsRead]); const scrollToMessage = useCallback(async ( createdAt: number, @@ -184,10 +197,22 @@ export const useGroupChannel = () => { store.setState(state => ({ ...state, quoteMessage: message })); }, []); + const setReadStateChanged = useCallback((readState: string) => { + store.setState(state => ({ ...state, readState })); + }, []); + + const setFirstUnreadMessageId = useCallback((messageId: number | null) => { + store.setState(state => ({ ...state, firstUnreadMessageId: messageId })); + }, []); + const actions: GroupChannelActions = useMemo(() => { return { setCurrentChannel, handleChannelError, + markAsReadAll, + markAsUnread: state.markAsUnread, + setReadStateChanged, + setFirstUnreadMessageId, setQuoteMessage, scrollToBottom, scrollToMessage, @@ -199,6 +224,10 @@ export const useGroupChannel = () => { }, [ setCurrentChannel, handleChannelError, + markAsReadAll, + state.markAsUnread, + setReadStateChanged, + setFirstUnreadMessageId, setQuoteMessage, scrollToBottom, scrollToMessage, diff --git a/src/modules/GroupChannel/context/hooks/useMessageActions.ts b/src/modules/GroupChannel/context/hooks/useMessageActions.ts index d10e17568..3babb5075 100644 --- a/src/modules/GroupChannel/context/hooks/useMessageActions.ts +++ b/src/modules/GroupChannel/context/hooks/useMessageActions.ts @@ -151,7 +151,13 @@ 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); + 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 +165,15 @@ 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], ), @@ -167,7 +181,13 @@ export function useMessageActions(params: Params): MessageActions { async (params) => { const internalParams = buildInternalMessageParams(params); const processedParams = await processParams(onBeforeSendMultipleFilesMessage, internalParams, 'multipleFiles') as MultipleFilesMessageCreateParams; - return sendMultipleFilesMessage(processedParams, asyncScrollToBottom); + const message = await sendMultipleFilesMessage(processedParams, asyncScrollToBottom); + pubSub.publish(PUBSUB_TOPICS.SEND_FILE_MESSAGE, { + channel: currentChannel, + message, + publishingModules: [PublishingModuleType.CHANNEL], + }); + return message; }, [buildInternalMessageParams, sendMultipleFilesMessage, scrollToBottom, processParams], ), diff --git a/src/modules/GroupChannel/context/types.ts b/src/modules/GroupChannel/context/types.ts index 76cff319e..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; @@ -63,6 +64,10 @@ interface InternalGroupChannelState extends MessageDataSource { disableMarkAsRead: boolean; scrollBehavior: 'smooth' | 'auto'; + // Actions (React UIKit specific) + markAsUnread?: (message: SendableMessageType) => void; + 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 ? ( + + 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..65df3a29c 100644 --- a/src/ui/DateSeparator/index.tsx +++ b/src/ui/DateSeparator/index.tsx @@ -14,11 +14,13 @@ export interface DateSeparatorProps { className?: string | Array; separatorColor?: Colors; } + const DateSeparator = ({ children = undefined, className = '', separatorColor = 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..7df588e90 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 messages', + 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 @@ -91,7 +94,7 @@ const stringSet = { TYPING_INDICATOR__AND: 'and', TYPING_INDICATOR__ARE_TYPING: 'are typing...', TYPING_INDICATOR__MULTIPLE_TYPING: 'Several people are typing...', - CHANNEL_FROZEN: 'Channel frozen', + CHANNEL_FROZEN: 'Channel is frozen', PLACE_HOLDER__NO_CHANNEL: 'No channels', PLACE_HOLDER__WRONG: 'Something went wrong', PLACE_HOLDER__RETRY_TO_CONNECT: 'Retry', @@ -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 58be3f11f..f33359210 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..4057d33cb 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) && } + {enableMarkAsUnread && showMenuItemMarkAsUnread(params) && } {showMenuItemResend(params) && } {showMenuItemDelete(params) && } diff --git a/src/ui/MessageMenu/MessageMenuProvider.tsx b/src/ui/MessageMenu/MessageMenuProvider.tsx index 8dc80c95d..77c593837 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?: '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 81d934146..f37b9623b 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, 'manual'); + } + hideMenu(); + props.onClick?.(e); + }} + > + {props.children ?? ( + <> + + + + )} + + ); +}; diff --git a/src/ui/MessageMenu/menuItems/MessageMenuItems.tsx b/src/ui/MessageMenu/menuItems/MessageMenuItems.tsx index 2b5867014..c1c2f3fed 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, 'manual'); + } + 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..8e6cc8b75 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, 'manual'); + } + hideMenu(); + props.onClick?.(e); + }} + > + {props.children ?? ( + <> + + + + )} + + ); +}; diff --git a/src/ui/MobileMenu/MobileBottomSheet.tsx b/src/ui/MobileMenu/MobileBottomSheet.tsx index c78959d2a..f3a0efe97 100644 --- a/src/ui/MobileMenu/MobileBottomSheet.tsx +++ b/src/ui/MobileMenu/MobileBottomSheet.tsx @@ -29,6 +29,7 @@ import { ThreadMenuItem, DeleteMenuItem, DownloadMenuItem, + MarkAsUnreadMenuItem, } from '../MessageMenu/menuItems/BottomSheetMenuItems'; import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; @@ -56,7 +57,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); @@ -82,6 +83,11 @@ const MobileBottomSheet: React.FunctionComponent = (prop && !isThreadMessage(message) && (channel?.isGroupChannel() && !(channel as GroupChannel)?.isBroadcast); + 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); const emojis = emojiContainer && getEmojiListAll(emojiContainer); @@ -98,6 +104,7 @@ const MobileBottomSheet: React.FunctionComponent = (prop showRemove, deleteMessage, resendMessage, + markAsUnread: props.markAsUnread, isOnline, disableDeleteMessage: disableDelete, triggerRef: null, @@ -192,18 +199,20 @@ const MobileBottomSheet: React.FunctionComponent = (prop ReplyMenuItem, ThreadMenuItem, DeleteMenuItem, + MarkAsUnreadMenuItem, }, }) ?? ( <> {showMenuItemCopy && } {showMenuItemEdit && } + {enableMarkAsUnread && showMenuItemMarkAsUnread && } {showMenuItemResend && } {showMenuItemReply && } {showMenuItemThread && } {showMenuItemDeleteFinal && } {showMenuItemDownload && } - )} + )}ß
)}
diff --git a/src/ui/MobileMenu/MobileContextMenu.tsx b/src/ui/MobileMenu/MobileContextMenu.tsx index 97a116664..e2798fa22 100644 --- a/src/ui/MobileMenu/MobileContextMenu.tsx +++ b/src/ui/MobileMenu/MobileContextMenu.tsx @@ -23,6 +23,7 @@ import { ResendMenuItem, DeleteMenuItem, DownloadMenuItem, + MarkAsUnreadMenuItem, } from '../MessageMenu/menuItems/MobileMenuItems'; import { MenuItems } from '../ContextMenu'; import { noop } from '../../utils/utils'; @@ -48,7 +49,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 +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 showMenuItemMarkAsUnread = !isFailedMessage(message) && !isPendingMessage(message) && message.parentMessageId <= 0 && channel?.isGroupChannel?.(); const disableDeleteMessage = (deleteMenuState !== undefined && deleteMenuState === 'DISABLE') || (message?.threadInfo?.replyCount ?? 0) > 0; const contextValue: MobileMessageMenuContextProps = { @@ -72,6 +74,7 @@ const MobileContextMenu: React.FunctionComponent = (props: BaseMe showRemove, deleteMessage, resendMessage, + markAsUnread: props.markAsUnread, isOnline, disableDeleteMessage, triggerRef: parentRef as MutableRefObject, @@ -94,6 +97,7 @@ const MobileContextMenu: React.FunctionComponent = (props: BaseMe ReplyMenuItem, ThreadMenuItem, EditMenuItem, + MarkAsUnreadMenuItem, ResendMenuItem, DeleteMenuItem, }, @@ -103,6 +107,7 @@ const MobileContextMenu: React.FunctionComponent = (props: BaseMe {showMenuItemReply && } {showMenuItemThread && } {showMenuItemEdit && } + {enableMarkAsUnread && showMenuItemMarkAsUnread && } {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/ui/NewMessageSeparator/index.scss b/src/ui/NewMessageSeparator/index.scss new file mode 100644 index 000000000..35e8a51ed --- /dev/null +++ b/src/ui/NewMessageSeparator/index.scss @@ -0,0 +1,27 @@ +@import '../../styles/variables'; + +.sendbird-separator { + width: 100%; + display: flex; + align-items: center; + + .sendbird-separator__left { + border: none; + height: 1px; + display: inline-block; + width: 100%; + } + + .sendbird-separator__right { + border: none; + height: 1px; + display: inline-block; + width: 100%; + } + + .sendbird-separator__text { + margin: 0px 16px; + display: flex; + white-space: nowrap; + } +} diff --git a/src/ui/NewMessageSeparator/index.tsx b/src/ui/NewMessageSeparator/index.tsx new file mode 100644 index 000000000..fcb45b2c6 --- /dev/null +++ b/src/ui/NewMessageSeparator/index.tsx @@ -0,0 +1,80 @@ +import React, { ReactElement, useLayoutEffect, useRef, useCallback } from 'react'; + +import './index.scss'; + +import { + Colors, + changeColorToClassName, +} from '../../utils/color'; + +import Label, { LabelTypography, LabelColors } from '../Label'; + +export interface NewMessageIndicatorProps { + children?: string | ReactElement; + className?: string | Array; + 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, + }, + ); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [handleVisibilityChange, onVisibilityChange]); + + return ( +
+
+
+ { + children + || ( + + ) + } +
+
+
+ ); +}; + +export default NewMessageIndicator; diff --git a/src/utils/menuConditions.ts b/src/utils/menuConditions.ts index 5015c1928..8b1ae908a 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, replyType }: MenuConditionsParams) => { + return !isFailedMessage(message) && !isPendingMessage(message) && channel?.isGroupChannel?.() && replyType !== 'THREAD'; +}; 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