diff --git a/exports.js b/exports.js index 82b7448d4..70a3c9680 100644 --- a/exports.js +++ b/exports.js @@ -91,6 +91,18 @@ export default { 'MessageSearch/context': 'src/smart-components/MessageSearch/context/MessageSearchProvider.tsx', 'MessageSearch/components/MessageSearchUI': 'src/smart-components/MessageSearch/components/MessageSearchUI/index.tsx', + // Thread + Thread: 'src/smart-components/Thread/index.tsx', + 'Thread/context': 'src/smart-components/Thread/context/ThreadProvider.tsx', + 'Thread/context/types': 'src/smart-components/Thread/types.tsx', + 'Thread/components/ThreadUI': 'src/smart-components/Thread/components/ThreadUI/index.tsx', + 'Thread/components/ThreadHeader': 'src/smart-components/Thread/components/ThreadHeader/index.tsx', + 'Thread/components/ParentMessageInfo': 'src/smart-components/Thread/components/ParentMessageInfo/index.tsx', + 'Thread/components/ParentMessageInfoItem': 'src/smart-components/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx', + 'Thread/components/ThreadList': 'src/smart-components/Thread/components/ThreadList/index.tsx', + 'Thread/components/ThreadListItem': 'src/smart-components/Thread/components/ThreadList/ThreadListItem.tsx', + 'Thread/components/ThreadMessageInput': 'src/smart-components/Thread/components/ThreadMessageInput/index.tsx', + // CreateChannel CreateChannel: 'src/smart-components/CreateChannel/index.tsx', 'CreateChannel/context': 'src/smart-components/CreateChannel/context/CreateChannelProvider.tsx', @@ -154,6 +166,7 @@ export default { 'ui/SortByRow': 'src/ui/SortByRow/index.tsx', 'ui/TextButton': 'src/ui/TextButton/index.tsx', 'ui/TextMessageItemBody': 'src/ui/TextMessageItemBody/index.tsx', + 'ui/ThreadReplies': 'src/ui/ThreadReplies/index.tsx', 'ui/ThumbnailMessageItemBody': 'src/ui/ThumbnailMessageItemBody/index.tsx', 'ui/Tooltip': 'src/ui/Tooltip/index.tsx', 'ui/TooltipWrapper': 'src/ui/TooltipWrapper/index.tsx', diff --git a/scripts/index_d_ts b/scripts/index_d_ts index 720dcfa45..d25da61d2 100644 --- a/scripts/index_d_ts +++ b/scripts/index_d_ts @@ -12,6 +12,7 @@ declare module "SendbirdUIKitGlobal" { SessionHandler, User, ApplicationUserListQueryParams, + EmojiContainer, } from '@sendbird/chat'; import type { GroupChannel, @@ -24,6 +25,7 @@ declare module "SendbirdUIKitGlobal" { } from '@sendbird/chat/groupChannel'; import type { AdminMessage, + BaseMessage, FailedMessageHandler, FileMessage, FileMessageCreateParams, @@ -268,6 +270,9 @@ declare module "SendbirdUIKitGlobal" { resizingWidth?: number | string, resizingHeight?: number | string, }; + isTypingIndicatorEnabledOnChannelList?: boolean; + isMessageReceiptStatusEnabledOnChannelList?: boolean; + replyType: ReplyType; } export interface SdkStore { error: boolean; @@ -453,6 +458,11 @@ declare module "SendbirdUIKitGlobal" { messageListParams?: MessageListParams; }; + export enum ThreadReplySelectType { + PARENT = 'PARENT', + THREAD = 'THREAD', + } + export type ChannelContextProps = { children?: React.ReactElement; channelUrl: string; @@ -468,6 +478,7 @@ declare module "SendbirdUIKitGlobal" { onSearchClick?(): void; onBackClick?(): void; replyType?: ReplyType; + threadReplySelectType?: ThreadReplySelectType; queries?: ChannelQueries; renderUserProfile?: (props: RenderUserProfileProps) => React.ReactNode | React.ReactElement; disableUserProfile?: boolean; @@ -868,6 +879,131 @@ declare module "SendbirdUIKitGlobal" { onCloseClick?: () => void; } + /** + * Thread + */ + export enum ChannelStateTypes { + NIL = 'NIL', + LOADING = 'LOADING', + INVALID = 'INVALID', + INITIALIZED = 'INITIALIZED', + } + export enum ParentMessageInfoStateTypes { + NIL = 'NIL', + LOADING = 'LOADING', + INVALID = 'INVALID', + INITIALIZED = 'INITIALIZED', + } + export enum ThreadListStateTypes { + NIL = 'NIL', + LOADING = 'LOADING', + INVALID = 'INVALID', + INITIALIZED = 'INITIALIZED', + } + + export interface ThreadProps extends ThreadProviderProps, ThreadContextInitialState { + className?: string; + } + + export interface ThreadProviderInterface extends ThreadProviderProps, ThreadContextInitialState { + fetchPrevThreads: (callback?: (messages?: Array) => void) => void; + fetchNextThreads: (callback?: (messages?: Array) => void) => void; + toggleReaction: (message, key, isReacted) => void; + sendMessage: (props: { + message: UserMessage, + quoteMessage?: UserMessage | FileMessage, + mentionTemplate?: string, + mentionedUsers?: Array, + }) => void; + sendFileMessage: (file: File, quoteMessage: UserMessage | FileMessage) => void; + resendMessage: (failedMessage: UserMessage | FileMessage) => void; + updateMessage: (props, callback?: () => void) => void; + deleteMessage: (message: UserMessage | FileMessage) => Promise; + nicknamesMap: Map; + } + + export type ThreadProviderProps = { + children?: React.ReactElement; + channelUrl: string; + message: UserMessage | FileMessage; + onHeaderActionClick?: () => void; + onMoveToParentMessage?: (props: { message: UserMessage | FileMessage, channel: GroupChannel }) => void; + disableUserProfile?: boolean; + renderUserProfile?: (props: { user: User, close: () => void }) => React.ReactElement; + onUserProfileMessage?: (channel: GroupChannel) => void; + } + + export interface ThreadContextInitialState { + currentChannel: GroupChannel; + allThreadMessages: Array; + parentMessage: UserMessage | FileMessage; + channelStatus: ChannelStateTypes; + parentMessageInfoStatus: ParentMessageInfoStateTypes; + threadListStatus: ThreadListStateTypes; + hasMorePrev: boolean; + hasMoreNext: boolean; + emojiContainer: EmojiContainer; + isMuted: boolean; + isChannelFrozen: boolean; + currentUserId: string; + } + + export interface ThreadUIProps { + renderHeader?: () => React.ReactElement; + renderParentMessageInfo?: () => React.ReactElement; + renderMessage?: (props: { message: UserMessage | FileMessage }) => React.ReactElement; + renderMessageInput?: () => React.ReactElement; + renderCustomSeparator?: () => React.ReactElement; + renderParentMessageInfoPlaceholder?: (type: ParentMessageInfoStateTypes) => React.ReactElement; + renderThreadListPlaceHolder?: (type: ThreadListStateTypes) => React.ReactElement; + } + + type EventType = React.MouseEvent | React.KeyboardEvent; + export interface ThreadHeaderProps { + className?: string; + channelName: string; + renderActionIcon?: (props: { onActionIconClick: (e: EventType) => void }) => React.ReactElement; + onActionIconClick?: (e: EventType) => void; + onChannelNameClick?: (e: EventType) => void; + } + + export interface ParentMessageInfoProps { + className?: string; + } + + export interface ParentMessageInfoItemProps { + className?: string; + message: UserMessage | FileMessage; + showFileViewer?: (bool: boolean) => void; + } + + export interface ThreadListProps { + className?: string; + allThreadMessages: Array; + renderMessage?: (props: { + message: UserMessage | FileMessage, + chainTop: boolean, + chainBottom: boolean, + hasSeparator: boolean, + }) => React.ReactElement; + renderCustomSeparator?: (props: { message: UserMessage | FileMessage }) => React.ReactElement; + scrollRef?: React.RefObject; + scrollBottom?: number; + } + export interface ThreadListItemProps { + className?: string; + message: UserMessage | FileMessage; + chainTop?: boolean; + chainBottom?: boolean; + hasSeparator?: boolean; + renderCustomSeparator?: (props: { message: UserMessage | FileMessage }) => React.ReactElement; + handleScroll?: () => void; + } + + export interface ThreadMessageInputProps { + className?: string; + } + /** * CreateChannel */ @@ -1122,6 +1258,7 @@ declare module '@sendbird/uikit-react/Channel/context' { import SendbirdUIKitGlobal from 'SendbirdUIKitGlobal'; export const ChannelProvider: React.FunctionComponent; export function useChannelContext(): SendbirdUIKitGlobal.ChannelProviderInterface; + export const ThreadReplySelectType: SendbirdUIKitGlobal.ThreadReplySelectType; } declare module '@sendbird/uikit-react/Channel/components/ChannelHeader' { @@ -1343,6 +1480,64 @@ declare module '@sendbird/uikit-react/MessageSearch/components/MessageSearchUI' export default MessageSearchUI; } +/** + * Thread + */ +declare module '@sendbird/uikit-react/Thread' { + import SendbirdUIKitGlobal from 'SendbirdUIKitGlobal'; + const Thread: React.FC; + export default Thread; +} + +declare module '@sendbird/uikit-react/Thread/context' { + import SendbirdUIKitGlobal from 'SendbirdUIKitGlobal'; + export const useThreadContext: () => SendbirdUIKitGlobal.ThreadProviderInterface; + export const ThreadProvider: React.FC; +} + +declare module '@sendbird/uikit-react/Thread/context/types' { + import SendbirdUIKitGlobal from 'SendbirdUIKitGlobal'; + export const ChannelStateTypes: SendbirdUIKitGlobal.ChannelStateTypes; + export const ParentMessageInfoStateTypes: SendbirdUIKitGlobal.ParentMessageInfoStateTypes; + export const ThreadListStateTypes: SendbirdUIKitGlobal.ThreadListStateTypes; +} + +declare module '@sendbird/uikit-react/Thread/components/ThreadUI' { + import SendbirdUIKitGlobal from 'SendbirdUIKitGlobal'; + const ThreadUI: React.FC; + export default ThreadUI; +} +declare module '@sendbird/uikit-react/Thread/components/ThreadHeader' { + import SendbirdUIKitGlobal from 'SendbirdUIKitGlobal'; + const ThreadHeader: React.FC; + export default ThreadHeader; +} +declare module '@sendbird/uikit-react/Thread/components/ParentMessageInfo' { + import SendbirdUIKitGlobal from 'SendbirdUIKitGlobal'; + const ParentMessageInfo: React.FC; + export default ParentMessageInfo; +} +declare module '@sendbird/uikit-react/Thread/components/ParentMessageInfoItem' { + import SendbirdUIKitGlobal from 'SendbirdUIKitGlobal'; + const ParentMessageInfoItem: React.FC; + export default ParentMessageInfoItem; +} +declare module '@sendbird/uikit-react/Thread/components/ThreadList' { + import SendbirdUIKitGlobal from 'SendbirdUIKitGlobal'; + const ThreadList: React.FC; + export default ThreadList; +} +declare module '@sendbird/uikit-react/Thread/components/ThreadListItem' { + import SendbirdUIKitGlobal from 'SendbirdUIKitGlobal'; + const ThreadListItem: React.FC; + export default ThreadListItem; +} +declare module '@sendbird/uikit-react/Thread/components/ThreadMessageInput' { + import SendbirdUIKitGlobal from 'SendbirdUIKitGlobal'; + const ThreadMessageInput: React.FC; + export default ThreadMessageInput; +} + /** * CreateChannel */ @@ -1633,16 +1828,17 @@ declare module '@sendbird/uikit-react/ui/IconButton' { declare module '@sendbird/uikit-react/ui/ImageRenderer' { interface ImageRendererProps { - className?: string | Array, - defaultComponent?: () => React.ReactElement, - placeHolder?: () => React.ReactElement, - alt?: string, - width?: number, - height?: number, - fixedSize?: boolean, - circle?: boolean, - onLoad?: () => void, - onError?: () => void, + className?: string | Array; + url: string; + alt?: string; + width?: string | number; + height?: string | number; + circle?: boolean; + fixedSize?: boolean; + placeHolder?: ((props: { style: { [key: string]: string | number } }) => React.ReactElement) | React.ReactElement; + defaultComponent?: (() => React.ReactElement) | React.ReactElement; + onLoad?: () => void; + onError?: () => void; } const ImageRenderer: React.FC; export default ImageRenderer; @@ -1778,7 +1974,7 @@ declare module '@sendbird/uikit-react/ui/MessageItemMenu' { import type { GroupChannel } from '@sendbird/chat/groupChannel'; import type { FileMessage, UserMessage } from '@sendbird/chat/message'; import type { OpenChannel } from '@sendbird/chat/openChannel'; - import type SenbirdUIKitGlobal from 'SendbirdUIKitGlobal'; + type ReplyType = "NONE" | "QUOTE_REPLY" | "THREAD"; interface MessageItemMenuProps { className?: string | Array; @@ -1786,12 +1982,15 @@ declare module '@sendbird/uikit-react/ui/MessageItemMenu' { channel: GroupChannel | OpenChannel; isByMe?: boolean; disabled?: boolean; - replyType?: SenbirdUIKitGlobal.ReplyType; + replyType?: ReplyType; + disableDeleteMessage?: boolean; showEdit?: (bool: boolean) => void; showRemove?: (bool: boolean) => void; resendMessage?: (message: UserMessage | FileMessage) => void; setQuoteMessage?: (message: UserMessage | FileMessage) => void; setSupposedHover?: (bool: boolean) => void; + onReplyInThread?: (props: { message: UserMessage | FileMessage }) => void; + onMoveToParentMessage?: () => void; } const MessageItemMenu: React.FC; export default MessageItemMenu; @@ -2087,6 +2286,17 @@ declare module '@sendbird/uikit-react/ui/TextMessageItemBody' { export default TextMessageItemBody; } +declare module '@sendbird/uikit-react/ui/ThreadReplies' { + import type { ThreadInfo } from '@sendbird/chat/message'; + interface ThreadRepliesProps { + className?: string; + threadInfo: ThreadInfo; + onClick?: (e: React.MouseEvent | React.KeyboardEvent) => void; + } + const ThreadReplies: React.FC; + export default ThreadReplies; +} + declare module '@sendbird/uikit-react/ui/ThumbnailMessageItemBody' { import type { FileMessage } from '@sendbird/chat/message'; interface ThumbnailMessageItemBodyProps { @@ -2096,6 +2306,7 @@ declare module '@sendbird/uikit-react/ui/ThumbnailMessageItemBody' { mouseHover?: boolean; isReactionEnabled?: boolean; showFileViewer?: (bool: boolean) => void; + style?: Record; } const ThumbnailMessageItemBody: React.FC; export default ThumbnailMessageItemBody; diff --git a/src/lib/Sendbird.jsx b/src/lib/Sendbird.jsx index 94fd66aeb..b508378a3 100644 --- a/src/lib/Sendbird.jsx +++ b/src/lib/Sendbird.jsx @@ -52,6 +52,7 @@ export default function Sendbird(props) { isMentionEnabled, isTypingIndicatorEnabledOnChannelList, isMessageReceiptStatusEnabledOnChannelList, + replyType, } = props; const mediaQueryBreakPoint = false; @@ -202,6 +203,7 @@ export default function Sendbird(props) { }, isTypingIndicatorEnabledOnChannelList, isMessageReceiptStatusEnabledOnChannelList, + replyType, }, }} > @@ -270,6 +272,7 @@ Sendbird.propTypes = { }), isTypingIndicatorEnabledOnChannelList: PropTypes.bool, isMessageReceiptStatusEnabledOnChannelList: PropTypes.bool, + replyType: PropTypes.oneOf(['NONE', 'QUOTE_REPLY', 'THREAD']), }; Sendbird.defaultProps = { @@ -296,4 +299,5 @@ Sendbird.defaultProps = { isMentionEnabled: false, isTypingIndicatorEnabledOnChannelList: false, isMessageReceiptStatusEnabledOnChannelList: false, + replyType: 'NONE', }; diff --git a/src/lib/types.ts b/src/lib/types.ts index 6f7d4fdb0..7b3e1b8ed 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -22,6 +22,7 @@ import type { import type SendBirdTypes from '../types'; import { UikitMessageHandler } from './selectors'; import { Logger } from './SendbirdState'; +import { ReplyType } from 'SendbirdUIKitGlobal'; export interface SendBirdProviderProps { userId: string; @@ -78,6 +79,7 @@ export interface SendBirdStateConfig { }; isTypingIndicatorEnabledOnChannelList?: boolean; isMessageReceiptStatusEnabledOnChannelList?: boolean; + replyType: ReplyType; } export interface SdkStore { error: boolean; diff --git a/src/smart-components/App/AppLayout.tsx b/src/smart-components/App/AppLayout.tsx index bcce7a59d..d0bc709de 100644 --- a/src/smart-components/App/AppLayout.tsx +++ b/src/smart-components/App/AppLayout.tsx @@ -1,9 +1,9 @@ import React, { useState } from 'react'; +import type { FileMessage, UserMessage } from '@sendbird/chat/message'; import type { AppLayoutProps } from './types'; import { useMediaQueryContext } from '../../lib/MediaQueryContext'; - import { DesktopLayout } from './DesktopLayout'; import { MobileLayout } from './MobileLayout'; @@ -18,9 +18,11 @@ export const AppLayout: React.FC = ( showSearchIcon, onProfileEditSuccess, disableAutoSelect, - currentChannelUrl, - setCurrentChannelUrl, + currentChannel, + setCurrentChannel, } = props; + const [showThread, setShowThread] = useState(false); + const [threadTargetMessage, setThreadTargetMessage] = useState(null); const [showSettings, setShowSettings] = useState(false); const [showSearch, setShowSearch] = useState(false); const [highlightedMessage, setHighlightedMessage] = useState(null); @@ -38,8 +40,8 @@ export const AppLayout: React.FC = ( isReactionEnabled={isReactionEnabled} showSearchIcon={showSearchIcon} onProfileEditSuccess={onProfileEditSuccess} - currentChannelUrl={currentChannelUrl} - setCurrentChannelUrl={setCurrentChannelUrl} + currentChannel={currentChannel} + setCurrentChannel={setCurrentChannel} highlightedMessage={highlightedMessage} setHighlightedMessage={setHighlightedMessage} startingPoint={startingPoint} @@ -55,8 +57,12 @@ export const AppLayout: React.FC = ( showSearchIcon={showSearchIcon} onProfileEditSuccess={onProfileEditSuccess} disableAutoSelect={disableAutoSelect} - currentChannelUrl={currentChannelUrl} - setCurrentChannelUrl={setCurrentChannelUrl} + currentChannel={currentChannel} + setCurrentChannel={setCurrentChannel} + showThread={showThread} + setShowThread={setShowThread} + threadTargetMessage={threadTargetMessage} + setThreadTargetMessage={setThreadTargetMessage} showSettings={showSettings} setShowSettings={setShowSettings} showSearch={showSearch} diff --git a/src/smart-components/App/DesktopLayout.tsx b/src/smart-components/App/DesktopLayout.tsx index 5fc66552e..bb739654e 100644 --- a/src/smart-components/App/DesktopLayout.tsx +++ b/src/smart-components/App/DesktopLayout.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import type { DesktopLayoutProps } from './types'; @@ -6,6 +6,8 @@ import ChannelList from '../ChannelList'; import Channel from '../Channel'; import ChannelSettings from '../ChannelSettings'; import MessageSearchPannel from '../MessageSearch'; +import Thread from '../Thread'; +import { BaseMessage, UserMessage } from '@sendbird/chat/message'; export const DesktopLayout: React.FC = ( props: DesktopLayoutProps, @@ -18,8 +20,8 @@ export const DesktopLayout: React.FC = ( showSearchIcon, onProfileEditSuccess, disableAutoSelect, - currentChannelUrl, - setCurrentChannelUrl, + currentChannel, + setCurrentChannel, showSettings, setShowSettings, showSearch, @@ -28,7 +30,12 @@ export const DesktopLayout: React.FC = ( setHighlightedMessage, startingPoint, setStartingPoint, + showThread, + setShowThread, + threadTargetMessage, + setThreadTargetMessage, } = props; + const [animatedMessageId, setAnimatedMessageId] = useState(null); return (
@@ -39,10 +46,10 @@ export const DesktopLayout: React.FC = ( onChannelSelect={(channel) => { setStartingPoint(null); setHighlightedMessage(null); - if (channel?.url) { - setCurrentChannelUrl(channel.url); + if (channel) { + setCurrentChannel(channel); } else { - setCurrentChannelUrl(''); + setCurrentChannel(null); } }} /> @@ -55,17 +62,45 @@ export const DesktopLayout: React.FC = ( `} > { setShowSearch(false); + setShowThread(false); setShowSettings(!showSettings); }} onSearchClick={() => { setShowSettings(false); + setShowThread(false); setShowSearch(!showSearch); }} + onReplyInThread={({ message }) => { // parent message + setShowSettings(false); + setShowSearch(false); + if (replyType === 'THREAD') { + setThreadTargetMessage({ + parentMessage: message as BaseMessage, + parentMessageId: message?.messageId, + } as UserMessage); + setShowThread(true); + } + }} + onQuoteMessageClick={({ message }) => { // thread message + setShowSettings(false); + setShowSearch(false); + if (replyType === 'THREAD') { + setThreadTargetMessage(message); + setShowThread(true); + } + }} + onMessageAnimated={() => { + setAnimatedMessageId(null); + }} + onMessageHighlighted={() => { + setHighlightedMessage(null); + }} showSearchIcon={showSearchIcon} startingPoint={startingPoint} + animatedMessage={animatedMessageId} highlightedMessage={highlightedMessage} isReactionEnabled={isReactionEnabled} replyType={replyType} @@ -76,7 +111,7 @@ export const DesktopLayout: React.FC = (
{ setShowSettings(false); }} @@ -86,7 +121,7 @@ export const DesktopLayout: React.FC = ( {showSearch && (
{ if (message.messageId === highlightedMessage) { setHighlightedMessage(null); @@ -104,6 +139,27 @@ export const DesktopLayout: React.FC = ( />
)} + {showThread && ( + { + setShowThread(false); + }} + onMoveToParentMessage={({ message, channel }) => { + if (channel?.url !== currentChannel?.url) { + setCurrentChannel(channel); + } + if (message?.messageId !== animatedMessageId) { + setStartingPoint(message?.createdAt); + } + setTimeout(() => { + setAnimatedMessageId(message?.messageId); + }, 500); + }} + /> + )}
) }; diff --git a/src/smart-components/App/MobileLayout.tsx b/src/smart-components/App/MobileLayout.tsx index 0752bce97..9c8e7c544 100644 --- a/src/smart-components/App/MobileLayout.tsx +++ b/src/smart-components/App/MobileLayout.tsx @@ -31,8 +31,8 @@ export const MobileLayout: React.FC = ( isReactionEnabled, showSearchIcon, onProfileEditSuccess, - currentChannelUrl, - setCurrentChannelUrl, + currentChannel, + setCurrentChannel, highlightedMessage, setHighlightedMessage, startingPoint, @@ -62,17 +62,17 @@ export const MobileLayout: React.FC = ( if (sdk?.groupChannel?.addGroupChannelHandler) { const handler = new GroupChannelHandler({ onUserBanned: (groupChannel, user) => { - if (groupChannel.url === currentChannelUrl && user?.userId === userId) { + if (groupChannel.url === currentChannel?.url && user?.userId === userId) { setPanel(PANELS.CHANNEL_LIST); } }, onChannelDeleted: (channelUrl) => { - if (channelUrl === currentChannelUrl) { + if (channelUrl === currentChannel?.url) { setPanel(PANELS.CHANNEL_LIST); } }, onUserLeft: (groupChannel, user) => { - if (groupChannel.url === currentChannelUrl && user?.userId === userId) { + if (groupChannel.url === currentChannel?.url && user?.userId === userId) { setPanel(PANELS.CHANNEL_LIST); } }, @@ -92,7 +92,7 @@ export const MobileLayout: React.FC = ( { - setCurrentChannelUrl(channel?.url); + setCurrentChannel(channel); setPanel(PANELS.CHANNEL); }} allowProfileEdit={allowProfileEdit} @@ -107,7 +107,7 @@ export const MobileLayout: React.FC = (
{ setPanel(PANELS.MESSAGE_SEARCH); }} @@ -130,7 +130,7 @@ export const MobileLayout: React.FC = ( panel === PANELS?.CHANNEL_SETTINGS && (
{ setPanel(PANELS.CHANNEL); }} @@ -145,7 +145,7 @@ export const MobileLayout: React.FC = ( panel === PANELS?.MESSAGE_SEARCH && (
{ setPanel(PANELS.CHANNEL); }} diff --git a/src/smart-components/App/index.jsx b/src/smart-components/App/index.jsx index 47abdb833..8ee774214 100644 --- a/src/smart-components/App/index.jsx +++ b/src/smart-components/App/index.jsx @@ -43,7 +43,7 @@ export default function App(props) { isTypingIndicatorEnabledOnChannelList, isMessageReceiptStatusEnabledOnChannelList, } = props; - const [currentChannelUrl, setCurrentChannelUrl] = useState(null); + const [currentChannel, setCurrentChannel] = useState(null); return ( { - setCurrentChannelUrl(channel?.url); + currentChannel(channel); }} isTypingIndicatorEnabledOnChannelList={isTypingIndicatorEnabledOnChannelList} isMessageReceiptStatusEnabledOnChannelList={isMessageReceiptStatusEnabledOnChannelList} + replyType={replyType} > fitPageSize( config={{ logLevel: 'all' }} queries={{}} replyType="QUOTE_REPLY" + isReactionEnabled isMentionEnabled isTypingIndicatorEnabledOnChannelList isMessageReceiptStatusEnabledOnChannelList @@ -299,6 +300,7 @@ export const user3 = () => fitPageSize( profileUrl={addProfile} config={{ logLevel: 'all' }} replyType="QUOTE_REPLY" + isReactionEnabled isMentionEnabled isTypingIndicatorEnabledOnChannelList isMessageReceiptStatusEnabledOnChannelList diff --git a/src/smart-components/App/types.ts b/src/smart-components/App/types.ts index a226de614..ce22674d2 100644 --- a/src/smart-components/App/types.ts +++ b/src/smart-components/App/types.ts @@ -1,4 +1,7 @@ import type { User } from "@sendbird/chat"; +import type { GroupChannel } from "@sendbird/chat/groupChannel"; +import type { FileMessage, UserMessage } from "@sendbird/chat/message"; + import type { Locale } from "date-fns"; import { @@ -16,13 +19,11 @@ export interface AppLayoutProps { showSearchIcon?: boolean; onProfileEditSuccess?(user: User): void; disableAutoSelect?: boolean; - currentChannelUrl?: string; - setCurrentChannelUrl?: React.Dispatch; + currentChannel?: GroupChannel; + setCurrentChannel?: React.Dispatch; } export interface MobileLayoutProps extends AppLayoutProps { - currentChannelUrl?: string; - setCurrentChannelUrl?: React.Dispatch; highlightedMessage?: number; setHighlightedMessage?: React.Dispatch; startingPoint?: number; @@ -34,12 +35,14 @@ export interface DesktopLayoutProps extends AppLayoutProps { setShowSettings: React.Dispatch; showSearch: boolean; setShowSearch: React.Dispatch; - currentChannelUrl?: string; - setCurrentChannelUrl?: React.Dispatch; highlightedMessage?: number; setHighlightedMessage?: React.Dispatch; startingPoint?: number; setStartingPoint?: React.Dispatch; + showThread: boolean; + setShowThread: React.Dispatch; + threadTargetMessage: UserMessage | FileMessage; + setThreadTargetMessage: React.Dispatch; } export default interface AppProps { diff --git a/src/smart-components/Channel/components/ChannelUI/channel-ui.scss b/src/smart-components/Channel/components/ChannelUI/channel-ui.scss index 0a3b341ec..19b088289 100644 --- a/src/smart-components/Channel/components/ChannelUI/channel-ui.scss +++ b/src/smart-components/Channel/components/ChannelUI/channel-ui.scss @@ -13,7 +13,7 @@ } .sendbird-conversation__messages { - overflow-y: auto; + overflow: hidden; flex: 1 1 0; order: 2; } diff --git a/src/smart-components/Channel/components/Message/index.tsx b/src/smart-components/Channel/components/Message/index.tsx index c5a3d8a19..0074f9f53 100644 --- a/src/smart-components/Channel/components/Message/index.tsx +++ b/src/smart-components/Channel/components/Message/index.tsx @@ -72,6 +72,7 @@ const Message = (props: MessageUIProps): React.FC | React.ReactE updateMessage, scrollToMessage, replyType, + threadReplySelectType, isReactionEnabled, toggleReaction, emojiContainer, @@ -79,6 +80,10 @@ const Message = (props: MessageUIProps): React.FC | React.ReactE setQuoteMessage, resendMessage, renderUserMentionItem, + onReplyInThread, + onQuoteMessageClick, + onMessageAnimated, + onMessageHighlighted, } = useChannelContext(); const [showEdit, setShowEdit] = useState(false); @@ -139,6 +144,7 @@ const Message = (props: MessageUIProps): React.FC | React.ReactE }, 500); setTimeout(() => { setHighLightedMessageId(0); + onMessageHighlighted?.(); }, 1600); } } else { @@ -156,12 +162,13 @@ const Message = (props: MessageUIProps): React.FC | React.ReactE }, 500); setTimeout(() => { setAnimatedMessageId(0); + onMessageAnimated?.(); }, 1600); } } else { setIsAnimated(false); } - }, [animatedMessageId, useMessageScrollRef.current, message.messageId]); + }, [animatedMessageId, useMessageScrollRef.current, message.messageId, onMessageAnimated]); const renderedMessage = useMemo(() => { return renderMessage?.({ message, @@ -240,6 +247,9 @@ const Message = (props: MessageUIProps): React.FC | React.ReactE mentionSelectedUser={selectedUser} isMentionEnabled={isMentionEnabled} message={message} + onStartTyping={() => { + currentGroupChannel?.startTyping?.(); + }} onUpdateMessage={({ messageId, message, mentionTemplate }) => { updateMessage({ messageId, @@ -248,6 +258,7 @@ const Message = (props: MessageUIProps): React.FC | React.ReactE mentionTemplate, }); setShowEdit(false); + currentGroupChannel?.endTyping?.(); }} onCancelEdit={() => { setMentionNickname(''); @@ -255,6 +266,7 @@ const Message = (props: MessageUIProps): React.FC | React.ReactE setMentionedUserIds([]); setMentionSuggestedUsers([]) setShowEdit(false); + currentGroupChannel?.endTyping?.(); }} onUserMentioned={(user) => { if (selectedUser?.userId === user?.userId) { @@ -318,6 +330,7 @@ const Message = (props: MessageUIProps): React.FC | React.ReactE chainBottom={chainBottom} isReactionEnabled={isReactionEnabled} replyType={replyType} + threadReplySelectType={threadReplySelectType} nicknamesMap={nicknamesMap} emojiContainer={emojiContainer} showEdit={setShowEdit} @@ -326,6 +339,8 @@ const Message = (props: MessageUIProps): React.FC | React.ReactE resendMessage={resendMessage} toggleReaction={toggleReaction} setQuoteMessage={setQuoteMessage} + onReplyInThread={onReplyInThread} + onQuoteMessageClick={onQuoteMessageClick} /> ) } diff --git a/src/smart-components/Channel/components/MessageInput/index.tsx b/src/smart-components/Channel/components/MessageInput/index.tsx index 447ba89a0..9920f709c 100644 --- a/src/smart-components/Channel/components/MessageInput/index.tsx +++ b/src/smart-components/Channel/components/MessageInput/index.tsx @@ -157,7 +157,7 @@ const MessageInputWrapper = ( setMentionNickname(''); setMentionedUsers([]); setQuoteMessage(null); - channel?.endTyping(); + channel?.endTyping?.(); }} onFileUpload={(file) => { sendFileMessage(file, quoteMessage); diff --git a/src/smart-components/Channel/components/MessageList/index.tsx b/src/smart-components/Channel/components/MessageList/index.tsx index 95252009a..35857b002 100644 --- a/src/smart-components/Channel/components/MessageList/index.tsx +++ b/src/smart-components/Channel/components/MessageList/index.tsx @@ -40,6 +40,7 @@ const MessageList: React.FC = (props: MessageListProps) => { messageActionTypes, currentGroupChannel, disableMarkAsRead, + replyType, } = useChannelContext(); const [scrollBottom, setScrollBottom] = useState(0); @@ -74,7 +75,8 @@ const MessageList: React.FC = (props: MessageListProps) => { onScrollDownCallback(([messages]) => { if (messages) { try { - element.scrollTop = scrollHeight - clientHeight; + // element.scrollTop = scrollHeight - clientHeight; + // scrollRef.current.scrollTop = scrollHeight - clientHeight; } catch (error) { // } @@ -121,7 +123,7 @@ const MessageList: React.FC = (props: MessageListProps) => { const previousMessage = allMessages[idx - 1]; const nextMessage = allMessages[idx + 1]; const [chainTop, chainBottom] = isMessageGroupingEnabled - ? compareMessagesForGrouping(previousMessage, m, nextMessage) + ? compareMessagesForGrouping(previousMessage, m, nextMessage, currentGroupChannel, replyType) : [false, false]; const previousMessageCreatedAt = previousMessage?.createdAt; const currentCreatedAt = m.createdAt; @@ -138,7 +140,7 @@ const MessageList: React.FC = (props: MessageListProps) => { current.scrollTop += bottom - scrollBottom; } } - } + }; return ( ; onUserItemClick?: (member: User) => void; @@ -30,6 +32,7 @@ const DEBOUNCING_TIME = 300; function SuggestedMentionList(props: SuggestedMentionListProps): JSX.Element { const { + className, targetNickname = '', // memberListQuery, onUserItemClick, @@ -41,10 +44,12 @@ function SuggestedMentionList(props: SuggestedMentionListProps): JSX.Element { maxMentionCount = MAX_USER_MENTION_COUNT, maxSuggestionCount = MAX_USER_SUGGESTION_COUNT, } = props; + const currentGroupChannel = useChannelContext?.()?.currentGroupChannel; + const currentChannel = useThreadContext?.()?.currentChannel; + const channelInstance = currentGroupChannel || currentChannel; const { config, stores } = useSendbirdStateContext(); const { logger } = config; const currentUserId = stores?.sdkStore?.sdk?.currentUser?.userId || ''; - const { currentGroupChannel } = useChannelContext(); const scrollRef = useRef(null); const { stringSet } = useContext(LocalizationContext); const [timer, setTimer] = useState(null); @@ -90,7 +95,7 @@ function SuggestedMentionList(props: SuggestedMentionListProps): JSX.Element { /* Fetch member list */ useEffect(() => { - if (!currentGroupChannel?.createMemberListQuery) { + if (!channelInstance?.createMemberListQuery) { logger.warning('SuggestedMentionList: Creating member list query failed'); return; } @@ -99,7 +104,7 @@ function SuggestedMentionList(props: SuggestedMentionListProps): JSX.Element { return; } - const query = currentGroupChannel?.createMemberListQuery({ + const query = channelInstance?.createMemberListQuery({ limit: maxSuggestionCount + 1, // because current user could be included nicknameStartsWithFilter: searchString.slice(USER_MENTION_TEMP_CHAR.length), }); @@ -124,7 +129,7 @@ function SuggestedMentionList(props: SuggestedMentionListProps): JSX.Element { logger.error('SuggestedMentionList: Fetching member list failed', error); } }); - }, [currentGroupChannel?.url, searchString]); + }, [channelInstance?.url, searchString]); if (!ableAddMention && currentMemberList.length === 0) { return null; @@ -132,7 +137,7 @@ function SuggestedMentionList(props: SuggestedMentionListProps): JSX.Element { return (
diff --git a/src/smart-components/Channel/context/ChannelProvider.tsx b/src/smart-components/Channel/context/ChannelProvider.tsx index 0cd626aea..7d44653be 100644 --- a/src/smart-components/Channel/context/ChannelProvider.tsx +++ b/src/smart-components/Channel/context/ChannelProvider.tsx @@ -1,4 +1,3 @@ - import React, { useEffect, useState, @@ -43,6 +42,7 @@ import useMemoizedEmojiListItems from './hooks/useMemoizedEmojiListItems'; import useToggleReactionCallback from './hooks/useToggleReactionCallback'; import useScrollToMessage from './hooks/useScrollToMessage'; import { CustomUseReducerDispatcher } from '../../../lib/SendbirdState'; +import { ThreadReplySelectType as _ThreadReplySelectType } from './const'; export type MessageListParams = { // https://sendbird.github.io/core-sdk-javascript/module-model_params_messageListParams-MessageListParams.html @@ -66,12 +66,15 @@ export type ChannelQueries = { messageListParams?: MessageListParams; }; +export const ThreadReplySelectType = _ThreadReplySelectType; + export type ChannelContextProps = { children?: React.ReactElement; channelUrl: string; isReactionEnabled?: boolean; isMessageGroupingEnabled?: boolean; showSearchIcon?: boolean; + animatedMessage?: number; highlightedMessage?: number; startingPoint?: number; onBeforeSendUserMessage?(text: string, quotedMessage?: UserMessage | FileMessage): UserMessageCreateParams; @@ -81,10 +84,15 @@ export type ChannelContextProps = { onSearchClick?(): void; onBackClick?(): void; replyType?: ReplyType; + threadReplySelectType?: ThreadReplySelectType; queries?: ChannelQueries; renderUserProfile?: (props: RenderUserProfileProps) => React.ReactElement; disableUserProfile?: boolean; disableMarkAsRead?: boolean; + onReplyInThread?: (props: { message: UserMessage | FileMessage }) => void; + onQuoteMessageClick?: (props: { message: UserMessage | FileMessage }) => void; + onMessageAnimated?: () => void; + onMessageHighlighted?: () => void; }; interface MessageStoreInterface { @@ -157,6 +165,7 @@ const ChannelProvider: React.FC = (props: ChannelContextPro isReactionEnabled, isMessageGroupingEnabled = true, showSearchIcon, + animatedMessage, highlightedMessage, startingPoint, onBeforeSendUserMessage, @@ -166,8 +175,13 @@ const ChannelProvider: React.FC = (props: ChannelContextPro onSearchClick, onBackClick, replyType, + threadReplySelectType = ThreadReplySelectType.THREAD, queries, disableMarkAsRead = false, + onReplyInThread, + onQuoteMessageClick, + onMessageAnimated, + onMessageHighlighted, } = props; const globalStore = useSendbirdStateContext(); @@ -188,7 +202,7 @@ const ChannelProvider: React.FC = (props: ChannelContextPro useEffect(() => { setInitialTimeStamp(startingPoint); }, [startingPoint, channelUrl]); - const [animatedMessageId, setAnimatedMessageId] = useState(null); + const [animatedMessageId, setAnimatedMessageId] = useState(0); const [highLightedMessageId, setHighLightedMessageId] = useState(highlightedMessage); useEffect(() => { setHighLightedMessageId(highlightedMessage); @@ -238,6 +252,13 @@ const ChannelProvider: React.FC = (props: ChannelContextPro : new Map() ), [currentGroupChannel?.members]); + // Animate message + useEffect(() => { + if (animatedMessage) { + setAnimatedMessageId(animatedMessage); + } + }, [animatedMessage]); + // Scrollup is default scroll for channel const onScrollCallback = useScrollCallback({ currentGroupChannel, oldestMessageTimeStamp, userFilledMessageListQuery, replyType, @@ -297,6 +318,7 @@ const ChannelProvider: React.FC = (props: ChannelContextPro { currentGroupChannel, sdkInit, + currentUserId: userId, hasMoreNext, disableMarkAsRead }, @@ -394,8 +416,13 @@ const ChannelProvider: React.FC = (props: ChannelContextPro onSearchClick, onBackClick, replyType, + threadReplySelectType, queries, disableMarkAsRead, + onReplyInThread, + onQuoteMessageClick, + onMessageAnimated, + onMessageHighlighted, // messagesStore allMessages, diff --git a/src/smart-components/Channel/context/const.ts b/src/smart-components/Channel/context/const.ts index 1451f4c0e..410881a1a 100644 --- a/src/smart-components/Channel/context/const.ts +++ b/src/smart-components/Channel/context/const.ts @@ -4,3 +4,8 @@ export const NEXT_RESULT_SIZE = 15; export const MAX_USER_MENTION_COUNT = 10; export const MAX_USER_SUGGESTION_COUNT = 15; export const USER_MENTION_TEMP_CHAR = '@'; + +export enum ThreadReplySelectType { + PARENT = 'PARENT', + THREAD = 'THREAD', +} diff --git a/src/smart-components/Channel/context/dux/reducers.js b/src/smart-components/Channel/context/dux/reducers.js index 6ecd2fd73..b74e1a1e8 100644 --- a/src/smart-components/Channel/context/dux/reducers.js +++ b/src/smart-components/Channel/context/dux/reducers.js @@ -8,8 +8,6 @@ import { filterMessageListParams, getSendingMessageStatus } from '../../../../ut const { SUCCEEDED, - FAILED, - PENDING, } = getSendingMessageStatus(); const getOldestMessageTimeStamp = (messages = []) => { const oldestMessage = messages[0]; @@ -168,23 +166,25 @@ export default function reducer(state, action) { ], }; case actionTypes.SEND_MESSAGEGE_SUCESS: { - const newMessages = state.allMessages.map((m) => ( - compareIds(m.reqId, action.payload.reqId) ? action.payload : m + const message = action.payload; + const filteredMessages = state.allMessages.filter((m) => ( + m?.reqId !== message?.reqId )); - [...newMessages].sort((a, b) => ( - ( - a.sendingStatus - && b.sendingStatus - && a.sendingStatus === SUCCEEDED - && ( - b.sendingStatus === PENDING - || b.sendingStatus === FAILED - ) - ) ? -1 : 1 + const pendingIndex = filteredMessages.findIndex((msg) => ( + msg?.sendingStatus === 'pending' || msg?.sendingStatus === 'failed' )); return { ...state, - allMessages: newMessages, + allMessages: pendingIndex > -1 + ? [ + ...filteredMessages.slice(0, pendingIndex), + message, + ...filteredMessages.slice(pendingIndex), + ] + : [ + ...filteredMessages, + message, + ], }; } case actionTypes.SEND_MESSAGEGE_FAILURE: { @@ -209,6 +209,7 @@ export default function reducer(state, action) { case actionTypes.SET_CHANNEL_INVALID: { return { ...state, + currentGroupChannel: null, isInvalid: true, }; } diff --git a/src/smart-components/Channel/context/hooks/useHandleChannelEvents.ts b/src/smart-components/Channel/context/hooks/useHandleChannelEvents.ts index 78f78e717..6dd6de428 100644 --- a/src/smart-components/Channel/context/hooks/useHandleChannelEvents.ts +++ b/src/smart-components/Channel/context/hooks/useHandleChannelEvents.ts @@ -20,6 +20,7 @@ import * as messageActions from '../dux/actionTypes'; interface DynamicParams { sdkInit: boolean; hasMoreNext: boolean; + currentUserId: string; disableMarkAsRead: boolean; currentGroupChannel: GroupChannel; } @@ -31,21 +32,19 @@ interface StaticParams { messagesDispatcher: CustomUseReducerDispatcher; } -function useHandleChannelEvents( - { - sdkInit, - hasMoreNext, - disableMarkAsRead, - currentGroupChannel, - }: DynamicParams, - { - sdk, - logger, - scrollRef, - setQuoteMessage, - messagesDispatcher, - }: StaticParams, -): void { +function useHandleChannelEvents({ + sdkInit, + hasMoreNext, + currentUserId, + disableMarkAsRead, + currentGroupChannel, +}: DynamicParams, { + sdk, + logger, + scrollRef, + setQuoteMessage, + messagesDispatcher, +}: StaticParams): void { useEffect(() => { const channelUrl = currentGroupChannel?.url; const channelHandlerId = uuidv4(); @@ -193,8 +192,19 @@ function useHandleChannelEvents( }); } }, + onUserLeft: (channel, user) => { + if (compareIds(channel?.url, channelUrl)) { + logger.info('Channel | useHandleChannelEvents: onUserLeft', { channel, user }); + if (user?.userId === currentUserId) { + messagesDispatcher({ + type: messageActions.SET_CURRENT_CHANNEL, + payload: null, + }); + } + } + }, }; - logger.info('Channel | useHandleChannelEvents: Setup event handler', channelHandlerId); + logger.info('Channel | useHandleChannelEvents: Setup event handler', { channelHandlerId, channelHandler }); // Add this group channel handler to the Sendbird chat instance sdk.groupChannel?.addGroupChannelHandler(channelHandlerId, new GroupChannelHandler(channelHandler)); } diff --git a/src/smart-components/Channel/context/hooks/useHandleReconnect.ts b/src/smart-components/Channel/context/hooks/useHandleReconnect.ts index 09823870a..73bd79940 100644 --- a/src/smart-components/Channel/context/hooks/useHandleReconnect.ts +++ b/src/smart-components/Channel/context/hooks/useHandleReconnect.ts @@ -1,10 +1,7 @@ import { useEffect } from 'react'; import type { GroupChannel, SendbirdGroupChat } from '@sendbird/chat/groupChannel'; -import { - MessageListParams, - ReplyType, -} from '@sendbird/chat/message'; +import { MessageListParams, ReplyType } from '@sendbird/chat/message'; import * as utils from '../utils'; import { PREV_RESULT_SIZE } from '../const'; import * as messageActionTypes from '../dux/actionTypes'; @@ -21,7 +18,7 @@ interface StaticParams { sdk: SendbirdGroupChat; currentGroupChannel: GroupChannel; scrollRef: React.RefObject; - messagesDispatcher: ({ type: string, payload: any }) => void; + messagesDispatcher: (props: { type: string, payload: any }) => void; userFilledMessageListQuery?: Record; } diff --git a/src/smart-components/Channel/context/hooks/useInitialMessagesFetch.js b/src/smart-components/Channel/context/hooks/useInitialMessagesFetch.js index d79743bfa..72964e58b 100644 --- a/src/smart-components/Channel/context/hooks/useInitialMessagesFetch.js +++ b/src/smart-components/Channel/context/hooks/useInitialMessagesFetch.js @@ -31,7 +31,7 @@ function useInitialMessagesFetch({ } messageListParams.isInclusive = true; messageListParams.includeReactions = true; - if (replyType && replyType === 'QUOTE_REPLY') { + if (replyType && (replyType === 'QUOTE_REPLY' || replyType === 'THREAD')) { messageListParams.includeThreadInfo = true; messageListParams.includeParentMessageInfo = true; messageListParams.replyType = ReplyType.ONLY_REPLY_TO_CHANNEL; @@ -41,7 +41,7 @@ function useInitialMessagesFetch({ messageListParams[key] = userFilledMessageListQuery[key]; }); } - if ((replyType && replyType === 'QUOTE_REPLY') || userFilledMessageListQuery) { + if ((replyType && (replyType === 'QUOTE_REPLY' || replyType === 'THREAD')) || userFilledMessageListQuery) { logger.info('Channel useInitialMessagesFetch: Setup messageListParams', messageListParams); messagesDispatcher({ type: messageActionTypes.MESSAGE_LIST_PARAMS_CHANGED, diff --git a/src/smart-components/Channel/context/hooks/useResendMessageCallback.js b/src/smart-components/Channel/context/hooks/useResendMessageCallback.js index 5386c3fbb..6938f3d89 100644 --- a/src/smart-components/Channel/context/hooks/useResendMessageCallback.js +++ b/src/smart-components/Channel/context/hooks/useResendMessageCallback.js @@ -11,9 +11,7 @@ function useResendMessageCallback({ return useCallback((failedMessage) => { logger.info('Channel: Resending message has started', failedMessage); const { messageType, file } = failedMessage; - if (failedMessage && typeof failedMessage.isResendable === 'function' - && failedMessage.isResendable() - ) { + if (failedMessage?.isResendable) { // Move the logic setting sendingStatus to pending into the reducer // eslint-disable-next-line no-param-reassign failedMessage.requestState = 'pending'; diff --git a/src/smart-components/Channel/context/hooks/useScrollCallback.js b/src/smart-components/Channel/context/hooks/useScrollCallback.js index 6e421c447..cbdcd7c71 100644 --- a/src/smart-components/Channel/context/hooks/useScrollCallback.js +++ b/src/smart-components/Channel/context/hooks/useScrollCallback.js @@ -25,7 +25,7 @@ function useScrollCallback({ isInclusive: true, includeReactions: isReactionEnabled, }; - if (replyType && replyType === 'QUOTE_REPLY') { + if (replyType === 'QUOTE_REPLY' || replyType === 'THREAD') { messageListParams.includeThreadInfo = true; messageListParams.includeParentMessageInfo = true; messageListParams.replyType = ReplyType.ONLY_REPLY_TO_CHANNEL; diff --git a/src/smart-components/Channel/context/hooks/useScrollDownCallback.js b/src/smart-components/Channel/context/hooks/useScrollDownCallback.js index cb5a166b4..878409109 100644 --- a/src/smart-components/Channel/context/hooks/useScrollDownCallback.js +++ b/src/smart-components/Channel/context/hooks/useScrollDownCallback.js @@ -24,7 +24,7 @@ function useScrollDownCallback({ isInclusive: true, includeReactions: isReactionEnabled, }; - if (replyType && replyType === 'QUOTE_REPLY') { + if (replyType && (replyType === 'QUOTE_REPLY' || replyType === 'THREAD')) { messageListParams.includeThreadInfo = true; messageListParams.includeParentMessageInfo = true; messageListParams.replyType = ReplyType.ONLY_REPLY_TO_CHANNEL; diff --git a/src/smart-components/Channel/context/utils.js b/src/smart-components/Channel/context/utils.js index 56e7c6156..d33667fad 100644 --- a/src/smart-components/Channel/context/utils.js +++ b/src/smart-components/Channel/context/utils.js @@ -217,7 +217,11 @@ export const compareMessagesForGrouping = ( currMessage, nextMessage, currentChannel, + replyType, ) => { + if (replyType === 'THREAD' && currMessage?.threadInfo) { + return [false, false]; + } const sendingStatus = currMessage?.sendingStatus || ''; const isAcceptable = sendingStatus !== 'pending' && sendingStatus !== 'failed'; return [ diff --git a/src/smart-components/Channel/index.tsx b/src/smart-components/Channel/index.tsx index 67a9e00d0..0bb60adcd 100644 --- a/src/smart-components/Channel/index.tsx +++ b/src/smart-components/Channel/index.tsx @@ -6,8 +6,7 @@ import { } from './context/ChannelProvider'; import ChannelUI, { ChannelUIProps } from './components/ChannelUI'; -export interface ChannelProps extends ChannelContextProps, ChannelUIProps { -} +export interface ChannelProps extends ChannelContextProps, ChannelUIProps { } const Channel: React.FC = (props: ChannelProps) => { return ( @@ -16,6 +15,7 @@ const Channel: React.FC = (props: ChannelProps) => { isReactionEnabled={props?.isReactionEnabled} isMessageGroupingEnabled={props?.isMessageGroupingEnabled} showSearchIcon={props?.showSearchIcon} + animatedMessage={props?.animatedMessage} highlightedMessage={props?.highlightedMessage} startingPoint={props?.startingPoint} onBeforeSendUserMessage={props?.onBeforeSendUserMessage} @@ -25,10 +25,15 @@ const Channel: React.FC = (props: ChannelProps) => { onSearchClick={props?.onSearchClick} onBackClick={props?.onBackClick} replyType={props?.replyType} + threadReplySelectType={props?.threadReplySelectType} queries={props?.queries} renderUserProfile={props?.renderUserProfile} disableUserProfile={props?.disableUserProfile} disableMarkAsRead={props?.disableMarkAsRead} + onReplyInThread={props?.onReplyInThread} + onQuoteMessageClick={props?.onQuoteMessageClick} + onMessageAnimated={props?.onMessageAnimated} + onMessageHighlighted={props?.onMessageHighlighted} > = (props: ChannelListUIProps) isTyping={typingChannels?.some(({ url }) => url === channel?.url)} renderChannelAction={(() => ( onLeaveChannel(channel, null)} /> diff --git a/src/smart-components/ChannelList/components/ChannelPreview/index.tsx b/src/smart-components/ChannelList/components/ChannelPreview/index.tsx index 8ac0772d8..c21524160 100644 --- a/src/smart-components/ChannelList/components/ChannelPreview/index.tsx +++ b/src/smart-components/ChannelList/components/ChannelPreview/index.tsx @@ -64,7 +64,9 @@ const ChannelPreview: React.FC = ({ const onLongPress = useLongPress({ onLongPress: () => { - setShowMobileLeave(true); + if (isMobile) { + setShowMobileLeave(true); + } }, onClick, }, { diff --git a/src/smart-components/ChannelList/components/ChannelPreviewAction.jsx b/src/smart-components/ChannelList/components/ChannelPreviewAction.jsx index fa3a61e6f..309602c9f 100644 --- a/src/smart-components/ChannelList/components/ChannelPreviewAction.jsx +++ b/src/smart-components/ChannelList/components/ChannelPreviewAction.jsx @@ -11,7 +11,11 @@ import IconButton from '../../../ui/IconButton'; import Icon, { IconTypes, IconColors } from '../../../ui/Icon'; import LeaveChannelModal from './LeaveChannel'; -export default function ChannelPreviewAction({ disabled, onLeaveChannel }) { +export default function ChannelPreviewAction({ + channel, + disabled, + onLeaveChannel, +}) { const parentRef = useRef(null); const parentContainerRef = useRef(null); const [showModal, setShowModal] = useState(false); @@ -63,6 +67,7 @@ export default function ChannelPreviewAction({ disabled, onLeaveChannel }) { { showModal && ( { setShowModal(false); onLeaveChannel(); @@ -78,8 +83,10 @@ export default function ChannelPreviewAction({ disabled, onLeaveChannel }) { ChannelPreviewAction.propTypes = { disabled: PropTypes.bool, onLeaveChannel: PropTypes.func.isRequired, + channel: PropTypes.shape({}), }; ChannelPreviewAction.defaultProps = { disabled: false, + channel: null, }; diff --git a/src/smart-components/ChannelList/components/LeaveChannel/index.tsx b/src/smart-components/ChannelList/components/LeaveChannel/index.tsx index 8582f349e..43fc092df 100644 --- a/src/smart-components/ChannelList/components/LeaveChannel/index.tsx +++ b/src/smart-components/ChannelList/components/LeaveChannel/index.tsx @@ -6,34 +6,38 @@ import { noop } from '../../../../utils/utils'; import Modal from '../../../../ui/Modal'; import { useChannelListContext } from '../../context/ChannelListProvider'; import { useLocalization } from '../../../../lib/LocalizationContext'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; export type LeaveChannelProps = { + channel?: GroupChannel; onSubmit: () => void; onCancel: () => void; } const LeaveChannel: React.FC = (props: LeaveChannelProps) => { const { + channel = null, onSubmit = noop, onCancel = noop, } = props; - const channel = useChannelListContext()?.currentChannel; + const channelFromContext = useChannelListContext()?.currentChannel; + const leavingChannel = channel || channelFromContext; const state = useSendbirdStateContext(); const { stringSet } = useLocalization(); const logger = state?.config?.logger; const isOnline = state?.config?.isOnline; - if (channel) { + if (leavingChannel) { return ( { - logger.info('ChannelSettings: Leaving channel', channel); - channel?.leave() + logger.info('ChannelSettings: Leaving channel', leavingChannel); + leavingChannel?.leave() .then(() => { - logger.info('ChannelSettings: Leaving channel successful!', channel); + logger.info('ChannelSettings: Leaving channel successful!', leavingChannel); onSubmit(); }); }} diff --git a/src/smart-components/ChannelSettings/components/ModerationPanel/MembersModal.tsx b/src/smart-components/ChannelSettings/components/ModerationPanel/MembersModal.tsx index e09a678ae..8ce1e55fd 100644 --- a/src/smart-components/ChannelSettings/components/ModerationPanel/MembersModal.tsx +++ b/src/smart-components/ChannelSettings/components/ModerationPanel/MembersModal.tsx @@ -15,6 +15,7 @@ import { noop } from '../../../../utils/utils'; import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; +import { Member } from '@sendbird/chat/groupChannel'; interface Props { onCancel(): void; @@ -67,11 +68,11 @@ export default function MembersModal({ onCancel }: Props): ReactElement { }} > { - members.map((member) => ( + members.map((member: Member) => ( ( <> {channel?.myRole === 'operator' && ( @@ -99,6 +100,7 @@ export default function MembersModal({ onCancel }: Props): ReactElement { openLeft > { if ((member.role !== 'operator')) { channel?.addOperators([member.userId]).then(() => { diff --git a/src/smart-components/ChannelSettings/components/ModerationPanel/MutedMembersModal.tsx b/src/smart-components/ChannelSettings/components/ModerationPanel/MutedMembersModal.tsx index 681c9ab6d..9832fe399 100644 --- a/src/smart-components/ChannelSettings/components/ModerationPanel/MutedMembersModal.tsx +++ b/src/smart-components/ChannelSettings/components/ModerationPanel/MutedMembersModal.tsx @@ -70,7 +70,7 @@ export default function MutedMembersModal({ > { members.map((member) => ( ( diff --git a/src/smart-components/ChannelSettings/components/ModerationPanel/OperatorList.tsx b/src/smart-components/ChannelSettings/components/ModerationPanel/OperatorList.tsx index e1b2386a7..623f1fe8a 100644 --- a/src/smart-components/ChannelSettings/components/ModerationPanel/OperatorList.tsx +++ b/src/smart-components/ChannelSettings/components/ModerationPanel/OperatorList.tsx @@ -71,6 +71,9 @@ export const OperatorList = (): ReactElement => { user={operator} currentUser={userId} action={({ actionRef }) => { + if (operator?.userId === userId) { + return null; + } return ( ( diff --git a/src/smart-components/ChannelSettings/components/ModerationPanel/OperatorsModal.tsx b/src/smart-components/ChannelSettings/components/ModerationPanel/OperatorsModal.tsx index bc5f181c7..927dd08d9 100644 --- a/src/smart-components/ChannelSettings/components/ModerationPanel/OperatorsModal.tsx +++ b/src/smart-components/ChannelSettings/components/ModerationPanel/OperatorsModal.tsx @@ -23,7 +23,7 @@ export default function OperatorsModal({ onCancel }: Props): ReactElement { const { channel } = useChannelSettingsContext(); const state = useSendbirdStateContext(); - const currentUser = state?.config?.userId; + const currentUserId = state?.config?.userId; const { stringSet } = useContext(LocalizationContext); useEffect(() => { @@ -34,7 +34,7 @@ export default function OperatorsModal({ onCancel }: Props): ReactElement { setOperators(operators); }); setOperatorQuery(operatorListQuery); - }, []) + }, []); return (
- { operators.map((member) => ( + {operators.map((member) => ( ( - ( - - - - )} - menuItems={(closeDropdown) => ( - - { - channel?.removeOperators([member.userId]).then(() => { - setOperators(operators.filter(({ userId }) => { - return userId !== member.userId; - })); - }); - closeDropdown(); - }} + member?.userId !== currentUserId && ( + ( + - {stringSet.CHANNEL_SETTING__MODERATION__UNREGISTER_OPERATOR} - - - )} - /> + + + )} + menuItems={(closeDropdown) => ( + + { + channel?.removeOperators([member.userId]).then(() => { + setOperators(operators.filter(({ userId }) => { + return userId !== member.userId; + })); + }); + closeDropdown(); + }} + > + {stringSet.CHANNEL_SETTING__MODERATION__UNREGISTER_OPERATOR} + + + )} + /> + ) )} /> ))} diff --git a/src/smart-components/ChannelSettings/components/ModerationPanel/admin-panel.scss b/src/smart-components/ChannelSettings/components/ModerationPanel/admin-panel.scss index ab3edfa0d..f79cb5af3 100644 --- a/src/smart-components/ChannelSettings/components/ModerationPanel/admin-panel.scss +++ b/src/smart-components/ChannelSettings/components/ModerationPanel/admin-panel.scss @@ -12,7 +12,8 @@ .sendbird-more-members__popup-scroll { max-height: 420px; - overflow: scroll; + overflow-x: hidden; + overflow-y: scroll; @include mobile() { max-height: 100%; } diff --git a/src/smart-components/ChannelSettings/components/UserListItem/index.tsx b/src/smart-components/ChannelSettings/components/UserListItem/index.tsx index a663e0d52..57f7fe962 100644 --- a/src/smart-components/ChannelSettings/components/UserListItem/index.tsx +++ b/src/smart-components/ChannelSettings/components/UserListItem/index.tsx @@ -43,7 +43,7 @@ const UserListItem = ({ const { disableUserProfile, renderUserProfile, - } = useContext(UserProfileContext); + } = useContext(UserProfileContext); const injectingClassNames = Array.isArray(className) ? className : [className]; return (
{ renderUserProfile diff --git a/src/smart-components/ChannelSettings/components/UserListItem/user-list-item.scss b/src/smart-components/ChannelSettings/components/UserListItem/user-list-item.scss index 0a70d537a..baf85f053 100644 --- a/src/smart-components/ChannelSettings/components/UserListItem/user-list-item.scss +++ b/src/smart-components/ChannelSettings/components/UserListItem/user-list-item.scss @@ -17,7 +17,6 @@ position: absolute; top: 10px; left: 12px; - z-index: 2; pointer-events: none; } diff --git a/src/smart-components/CreateChannel/components/InviteUsers/invite-users.scss b/src/smart-components/CreateChannel/components/InviteUsers/invite-users.scss index b8c50d937..1d92a4bfa 100644 --- a/src/smart-components/CreateChannel/components/InviteUsers/invite-users.scss +++ b/src/smart-components/CreateChannel/components/InviteUsers/invite-users.scss @@ -7,7 +7,8 @@ .sendbird-create-channel--scroll { height: 360px; - overflow-y: auto; + overflow-x: hidden; + overflow-y: scroll; @include mobile() { height: calc(100vh - 200px); height: calc(calc(var(--sendbird-vh, 1vh) * 100) - 200px); diff --git a/src/smart-components/MessageSearch/components/MessageSearchUI/index.scss b/src/smart-components/MessageSearch/components/MessageSearchUI/index.scss index cdd104f2d..d35c8971c 100644 --- a/src/smart-components/MessageSearch/components/MessageSearchUI/index.scss +++ b/src/smart-components/MessageSearch/components/MessageSearchUI/index.scss @@ -3,7 +3,8 @@ .sendbird-message-search { position: relative; height: 100%; - overflow: scroll; + overflow-x: hidden; + overflow-y: scroll; @include themed() { background-color: t(bg-0); } diff --git a/src/smart-components/OpenChannel/context/hooks/useFileUploadCallback.ts b/src/smart-components/OpenChannel/context/hooks/useFileUploadCallback.ts index ff6d0ae55..1d7e0a906 100644 --- a/src/smart-components/OpenChannel/context/hooks/useFileUploadCallback.ts +++ b/src/smart-components/OpenChannel/context/hooks/useFileUploadCallback.ts @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import { useCallback } from 'react'; import type { OpenChannel, SendbirdOpenChat } from '@sendbird/chat/openChannel'; import type { FileMessageCreateParams } from '@sendbird/chat/message'; @@ -19,8 +19,7 @@ interface DynamicParams { interface StaticParams { sdk: SendbirdOpenChat; logger: Logger; - scrollRef: React.RefObject; - messagesDispatcher: ({ type: string, payload: any }) => void; + messagesDispatcher: (props: { type: string, payload: any }) => void; } type CallbackReturn = (file: File) => void; diff --git a/src/smart-components/OpenChannel/context/hooks/useUpdateMessageCallback.ts b/src/smart-components/OpenChannel/context/hooks/useUpdateMessageCallback.ts index 8ff8be93e..121e68615 100644 --- a/src/smart-components/OpenChannel/context/hooks/useUpdateMessageCallback.ts +++ b/src/smart-components/OpenChannel/context/hooks/useUpdateMessageCallback.ts @@ -11,7 +11,7 @@ interface DynamicParams { } interface StaticParams { logger: Logger; - messagesDispatcher: ({ type: string, payload :any }) => void; + messagesDispatcher: (props: { type: string, payload :any }) => void; } type CallbackReturn = (messageId, text, callback) => void; diff --git a/src/smart-components/OpenChannelSettings/components/ParticipantUI/ParticipantItem.tsx b/src/smart-components/OpenChannelSettings/components/ParticipantUI/ParticipantItem.tsx index 425fd3456..8d10d0771 100644 --- a/src/smart-components/OpenChannelSettings/components/ParticipantUI/ParticipantItem.tsx +++ b/src/smart-components/OpenChannelSettings/components/ParticipantUI/ParticipantItem.tsx @@ -70,7 +70,7 @@ export const UserListItem: React.FC = ({ parentContainRef={avatarRef} // for toggling more options(menus & reactions) closeDropdown={closeDropdown} - style={{ paddingTop: 0, paddingBottom: 0 }} + style={{ paddingTop: '0px', paddingBottom: '0px' }} > { renderUserProfile diff --git a/src/smart-components/Thread/components/ParentMessageInfo/ParentMessageInfoItem.scss b/src/smart-components/Thread/components/ParentMessageInfo/ParentMessageInfoItem.scss new file mode 100644 index 000000000..bd05f0a97 --- /dev/null +++ b/src/smart-components/Thread/components/ParentMessageInfo/ParentMessageInfoItem.scss @@ -0,0 +1,105 @@ +@import '../../../../styles/variables'; + +.sendbird-parent-message-info-item { + position: relative; + margin-top: 8px; +} + +.sendbird-parent-message-info-item__text-message, +.sendbird-parent-message-info-item__og-field { + padding-right: 4px; + display: inline-block; + white-space: pre-line; + word-break: break-all; +} +.sendbird-parent-message-info-item__og-field { + display: inline-flex; + flex-direction: column; + margin-top: 4px; +} +.sendbird-parent-message-info-item__og-field__content { + display: inline-flex; + flex-direction: column; + padding: 8px 12px; + gap: 4px; +} + +.sendbird-parent-message-info-item__file-message { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 8px; +} +.sendbird-parent-message-info-item__file-message__file-name { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; +} + +.sendbird-parent-message-info-item__thumbnail-message { + position: relative; + display: block; + width: 200px; + height: 148px; +} +.sendbird-parent-message-info-item__thumbnail-message__thumbnail { + position: absolute; + border-radius: 16px; +} +.sendbird-parent-message-info-item__thumbnail-message__placeholder { + position: absolute; + width: 100%; + height: 148px; + display: flex; + justify-content: center; + align-items: center; +} +.sendbird-parent-message-info-item__thumbnail-message__placeholder__icon { + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: 50%; + background-color: var(--sendbird-light-background-50); +} +.sendbird-parent-message-info-item__thumbnail-message__image-cover { + position: absolute; + top: 0px; + display: none; + width: 100%; + height: 148px; + border-radius: 16px;; + background-color: var(--sendbird-light-overlay-01); +} +.sendbird-parent-message-info-item__thumbnail-message__video { + position: absolute; + width: 100%; + height: 148px; + border-radius: 16px; +} +.sendbird-parent-message-info-item__thumbnail-message__icon-wrapper { + position: absolute; + width: 100%; + height: 148px; + display: flex; + align-items: center; + justify-content: center; +} +.sendbird-parent-message-info-item__thumbnail-message__icon-wrapper__icon { + width: 56px; + height: 56px; + display: inline-flex; + justify-content: center; + align-items: center; + border-radius: 50%; + background-color: var(--sendbird-light-background-50); +} +.sendbird-parent-message-info-item__thumbnail-message:hover { + cursor: pointer; + .sendbird-parent-message-info-item__thumbnail-message__image-cover { + display: inline-flex; + } +} diff --git a/src/smart-components/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx b/src/smart-components/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx new file mode 100644 index 000000000..9156c707f --- /dev/null +++ b/src/smart-components/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx @@ -0,0 +1,292 @@ +import React, { ReactElement, useState } from 'react'; +import { FileMessage, UserMessage } from '@sendbird/chat/message'; + +import './ParentMessageInfoItem.scss'; + +import { useLocalization } from '../../../../lib/LocalizationContext'; +import { + getUIKitFileType, + getUIKitMessageType, + getUIKitMessageTypes, + isEditedMessage, + isFileMessage, + isGifMessage, + // isOGMessage, + isSentMessage, + isThumbnailMessage, + isUserMessage, + isVideoMessage, + truncateString +} from '../../../../utils'; +import uuidv4 from '../../../../utils/uuid'; + +import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; +import Word from '../../../../ui/Word'; +import ImageRenderer from '../../../../ui/ImageRenderer'; +import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; +import TextButton from '../../../../ui/TextButton'; +import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import EmojiReactions from '../../../../ui/EmojiReactions'; +import { useThreadContext } from '../../context/ThreadProvider'; + +export interface ParentMessageInfoItemProps { + className?: string; + message: UserMessage | FileMessage; + showFileViewer?: (bool: boolean) => void; +} + +export default function ParentMessageInfoItem({ + className, + message, + showFileViewer, +}: ParentMessageInfoItemProps): ReactElement { + const { stores, config } = useSendbirdStateContext(); + const { + replyType, + isMentionEnabled, + isReactionEnabled, + } = config; + const currentUserId = stores?.userStore?.user?.userId; + const { stringSet } = useLocalization(); + const { + currentChannel, + emojiContainer, + nicknamesMap, + toggleReaction, + } = useThreadContext(); + + const isMentionedMessage = isMentionEnabled + && message?.mentionedMessageTemplate?.length > 0 + && message?.mentionedUsers?.length > 0; + + // Emoji reactions + const isReactionActivated = isReactionEnabled + && replyType === 'THREAD' + && !currentChannel?.isSuper + && !currentChannel?.isBroadcast + && message?.reactions?.length > 0; + + // OG message + // const openUrl = () => { + // if (isOGMessage(message) && message?.ogMetaData?.url) { + // window.open(message.ogMetaData.url); + // } + // }; + + // Thumbnail mesage + const [isImageRendered, setImageRendered] = useState(false); + const thumbnailUrl: string = (message as FileMessage)?.thumbnails?.length > 0 + ? (message as FileMessage)?.thumbnails[0]?.url : ''; + + return ( +
+ {isUserMessage(message) && ( + + )} + {/* Will enable ogMessage after Server support - getMessage including og_tag */} + {/* {isOGMessage(message) && ( +
+ ( +
+ +
+ )} + /> +
+ {message?.ogMetaData?.title && ( + + )} + {message?.ogMetaData?.description && ( + + )} + {message?.ogMetaData?.url && ( + + )} +
+
+ )} */} + {isFileMessage(message) && !isThumbnailMessage(message) && ( +
+
+ +
+ { window.open((message as FileMessage)?.url) }} + color={LabelColors.ONBACKGROUND_1} + > + + +
+ )} + {isThumbnailMessage(message) && ( +
{ + if (isSentMessage(message)) { + showFileViewer(true); + } + }} + > + { setImageRendered(true) }} + placeHolder={(style_: Record) => ( +
+
+ +
+
+ )} + /> + {(isVideoMessage(message) && !thumbnailUrl) && !isImageRendered && ( + + )} +
+ {(isVideoMessage(message) || isGifMessage(message)) && ( +
+
+ +
+
+ )} +
+ )} + {getUIKitMessageType(message) === getUIKitMessageTypes?.()?.UNKNOWN && ( +
+ + +
+ )} + + {/* reactions */} + {isReactionActivated && ( +
+ +
+ )} +
+ ); +} diff --git a/src/smart-components/Thread/components/ParentMessageInfo/index.scss b/src/smart-components/Thread/components/ParentMessageInfo/index.scss new file mode 100644 index 000000000..d70b0d848 --- /dev/null +++ b/src/smart-components/Thread/components/ParentMessageInfo/index.scss @@ -0,0 +1,157 @@ +@import '../../../../styles/variables'; + +.sendbird-parent-message-info { + position: relative; + width: 100%; + height: fit-content; + + padding: 12px 12px 12px 16px; + display: inline-flex; + flex-direction: row; + align-items: flex-start; + box-sizing: border-box; +} + +// left +.sendbird-parent-message-info__sender { + position: relative; + min-width: 40px; + min-height: 40px; +} + +// right +.sendbird-parent-message-info__content { + position: relative; + margin-left: 12px; + + display: inline-flex; + flex-direction: column; +} + +.sendbird-parent-message-info__content__info { + position: relative; + max-width: 188px; + height: 16px; + + display: inline-flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; +} +.sendbird-parent-message-info__content__info__sender-name +, .sendbird-parent-message-info__content__info__sender-name--use-reaction { + position: relative; + margin-right: 6px; + + word-break: keep-all; + overflow: hidden; + text-overflow: ellipsis; +} +.sendbird-parent-message-info__content__info__sender-name { + max-width: 142px; +} +.sendbird-parent-message-info__content__info__sender-name--use-reaction { + max-width: 110px; +} +.sendbird-parent-message-info__content__info__sent-at { + position: relative; + max-width: 52px; + height: 12px; + + display: inline-flex; + justify-content: flex-start; + align-items: center; + + white-space: nowrap; + word-break: keep-all; +} + +// message content body +.sendbird-parent-message-info__content__body { + position: relative; + max-width: 210px; + + overflow: hidden; +} + +.sendbird-parent-message-info__content__body.sendbird-thumbnail-message-item-body.incoming { + min-width: 200px; + height: 148px; +} + +.sendbird-parent-message-info__content__reactions { + position: relative; + max-width: 240px; + width: 100%; + height: 100%; +} + +.sendbird-parent-message-info__context-menu { + position: absolute; + top: 6px; + right: 12px; + display: none; +} +.sendbird-parent-message-info__context-menu.use-reaction { + right: 44px; +} +.sendbird-parent-message-info__reaction-menu { + position: absolute; + top: 6px; + right: 12px; + display: none; +} + +.sendbird-parent-message-info .sendbird-text-message-item-body.reactions { + border-radius: 16px; +} +.sendbird-parent-message-info .sendbird-emoji-reactions { + @include themed() { + border: 1px solid t(bg-0); + } +} + +// display menus +.sendbird-parent-message-info:hover .sendbird-parent-message-info__context-menu, +.sendbird-parent-message-info:hover .sendbird-parent-message-info__reaction-menu, +.sendbird-parent-message-info__context-menu.sendbird-mouse-hover, +.sendbird-parent-message-info__reaction-menu.sendbird-mouse-hover { + display: inline-flex; +} + +// background color +.sendbird-parent-message-info .sendbird-parent-message-info__content__body { + @include themed() { + background-color: t(bg-0); + } +} +.sendbird-parent-message-info:hover, +.sendbird-parent-message-info:hover .sendbird-parent-message-info__content__body { + @include themed() { + background-color: t(bg-1); + } +} +.sendbird-parent-message-info:hover .sendbird-emoji-reactions { + @include themed() { + border: 1px solid t(bg-1); + background-color: t(bg-1); + } +} + +// SuggestedMentionList +.parent-message-info--suggested-mention-list { + width: 100%; + margin-left: 0px; + margin-right: 0px; + min-height: 200px; +} +.parent-message-info--suggested-mention-list .sendbird-mention-suggest-list__user-item { + padding-left: 16px; + padding-right: 16px; +} +.parent-message-info--suggested-mention-list .sendbird-mention-suggest-list__user-item .sendbird-mention-suggest-list__user-item__nickname { + max-width: 166px; +} +.parent-message-info--suggested-mention-list .sendbird-mention-suggest-list__user-item .sendbird-mention-suggest-list__user-item__user-id { + max-width: 68px; +} diff --git a/src/smart-components/Thread/components/ParentMessageInfo/index.tsx b/src/smart-components/Thread/components/ParentMessageInfo/index.tsx new file mode 100644 index 000000000..9baaa7068 --- /dev/null +++ b/src/smart-components/Thread/components/ParentMessageInfo/index.tsx @@ -0,0 +1,308 @@ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import format from 'date-fns/format'; +import { FileMessage, UserMessage } from '@sendbird/chat/message'; +import { Role } from '@sendbird/chat'; + +import './index.scss'; +import RemoveMessage from '../RemoveMessageModal'; +import ParentMessageInfoItem from './ParentMessageInfoItem'; + +import { + getSenderName, +} from '../../../../utils'; +import { useLocalization } from '../../../../lib/LocalizationContext'; +import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import { useThreadContext } from '../../context/ThreadProvider'; +import { UserProfileContext } from '../../../../lib/UserProfileContext'; +import SuggestedMentionList from '../../../Channel/components/SuggestedMentionList'; + +import Avatar from '../../../../ui/Avatar'; +import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; +import FileViewer from '../../../../ui/FileViewer'; +import MessageItemMenu from '../../../../ui/MessageItemMenu'; +import MessageItemReactionMenu from '../../../../ui/MessageItemReactionMenu'; +import ContextMenu, { MenuItems } from '../../../../ui/ContextMenu'; +import ConnectedUserProfile from '../../../../ui/UserProfile'; +import { UserProfileContextInterface } from '../../../../ui/MessageContent'; +import MessageInput from '../../../../ui/MessageInput'; +import { MessageInputKeys } from '../../../../ui/MessageInput/const'; + +export interface ParentMessageInfoProps { + className?: string; +} + +export default function ParentMessageInfo({ + className, +}: ParentMessageInfoProps): React.ReactElement { + const { stores, config } = useSendbirdStateContext(); + const { + isMentionEnabled, + isReactionEnabled, + replyType, + isOnline, + userMention, + } = config; + const userId = stores?.userStore?.user?.userId; + const { dateLocale } = useLocalization?.(); + const { + currentChannel, + parentMessage, + allThreadMessages, + emojiContainer, + toggleReaction, + updateMessage, + deleteMessage, + onMoveToParentMessage, + onHeaderActionClick, + isMuted, + isChannelFrozen, + } = useThreadContext(); + + const [showRemove, setShowRemove] = useState(false); + const [supposedHover, setSupposedHover] = useState(false); + const [showFileViewer, setShowFileViewer] = useState(false); + const usingReaction = isReactionEnabled && !currentChannel?.isSuper && !currentChannel?.isBroadcast + + // Edit message + const [showEditInput, setShowEditInput] = useState(false); + const disabled = !isOnline || isMuted || isChannelFrozen && !(currentChannel?.myRole === Role.OPERATOR); + + // Mention + const editMessageInputRef = useRef(null); + const [mentionNickname, setMentionNickname] = useState(''); + const [mentionedUsers, setMentionedUsers] = useState([]); + const [mentionedUserIds, setMentionedUserIds] = useState([]); + const [messageInputEvent, setMessageInputEvent] = useState(null); + const [selectedUser, setSelectedUser] = useState(null); + const [mentionSuggestedUsers, setMentionSuggestedUsers] = useState([]); + const [ableMention, setAbleMention] = useState(true); + const displaySuggestedMentionList = isOnline + && isMentionEnabled + && mentionNickname.length > 0 + && !isMuted + && !(isChannelFrozen && !(currentChannel.myRole === Role.OPERATOR)); + useEffect(() => { + if (mentionedUsers?.length >= userMention?.maxMentionCount) { + setAbleMention(false); + } else { + setAbleMention(true); + } + }, [mentionedUsers]); + useEffect(() => { + setMentionedUsers(mentionedUsers.filter(({ userId }) => { + const i = mentionedUserIds.indexOf(userId); + if (i < 0) { + return false; + } else { + mentionedUserIds.splice(i, 1); + return true; + } + })); + }, [mentionedUserIds]); + + // User Profile + const avatarRef = useRef(null); + const { + disableUserProfile, + renderUserProfile, + } = useContext(UserProfileContext); + + if (showEditInput && parentMessage?.isUserMessage?.()) { + return ( + <> + { + displaySuggestedMentionList && ( + { + if (user) { + setMentionedUsers([...mentionedUsers, user]); + } + setMentionNickname(''); + setSelectedUser(user); + setMessageInputEvent(null); + }} + onFocusItemChange={() => { + setMessageInputEvent(null); + }} + onFetchUsers={(users) => { + setMentionSuggestedUsers(users); + }} + ableAddMention={ableMention} + maxMentionCount={userMention?.maxMentionCount} + maxSuggestionCount={userMention?.maxSuggestionCount} + /> + ) + } + { + currentChannel?.startTyping?.(); + }} + onUpdateMessage={({ messageId, message, mentionTemplate }) => { + updateMessage({ + messageId, + message, + mentionedUsers, + mentionTemplate, + }); + setShowEditInput(false); + currentChannel?.endTyping?.(); + }} + onCancelEdit={() => { + setMentionNickname(''); + setMentionedUsers([]); + setMentionedUserIds([]); + setMentionSuggestedUsers([]) + setShowEditInput(false); + currentChannel?.endTyping?.(); + }} + onUserMentioned={(user) => { + if (selectedUser?.userId === user?.userId) { + setSelectedUser(null); + setMentionNickname(''); + } + }} + onMentionStringChange={(mentionText) => { + setMentionNickname(mentionText); + }} + onMentionedUserIdsUpdated={(userIds) => { + setMentionedUserIds(userIds); + }} + onKeyDown={(e) => { + if (displaySuggestedMentionList && mentionSuggestedUsers?.length > 0 + && ((e.key === MessageInputKeys.Enter && ableMention) || e.key === MessageInputKeys.ArrowUp || e.key === MessageInputKeys.ArrowDown) + ) { + setMessageInputEvent(e); + return true; + } + return false; + }} + /> + + ); + } + + return ( +
+ ( + m?.userId === parentMessage?.sender?.userId)?.profileUrl + || parentMessage?.sender?.profileUrl + } + alt="thread message sender" + width="40px" + height="40px" + onClick={() => { + if (!disableUserProfile) { + toggleDropdown(); + } + }} + /> + )} + menuItems={(closeDropdown) => ( + + {renderUserProfile + ? renderUserProfile({ user: parentMessage?.sender, close: closeDropdown }) + : ( + + )} + + )} + /> +
+
+ + +
+ {/* message content body */} + +
+ {/* context menu */} + 0} + replyType={replyType} + showEdit={setShowEditInput} + showRemove={setShowRemove} + setSupposedHover={setSupposedHover} + onMoveToParentMessage={() => { + onMoveToParentMessage({ message: parentMessage, channel: currentChannel }); + }} + /> + {usingReaction && ( + + )} + {showRemove && ( + setShowRemove(false)} + onSubmit={() => { + onHeaderActionClick?.(); + }} + message={parentMessage} + /> + )} + {showFileViewer && ( + setShowFileViewer(false)} + onDelete={() => { + deleteMessage(parentMessage) + .then(() => { + setShowFileViewer(false); + }); + }} + /> + )} +
+ ); +} diff --git a/src/smart-components/Thread/components/RemoveMessageModal.tsx b/src/smart-components/Thread/components/RemoveMessageModal.tsx new file mode 100644 index 000000000..a228b5a15 --- /dev/null +++ b/src/smart-components/Thread/components/RemoveMessageModal.tsx @@ -0,0 +1,42 @@ +import React, { useContext } from 'react'; + +import Modal from '../../../ui/Modal'; +import { ButtonTypes } from '../../../ui/Button'; +import { LocalizationContext } from '../../../lib/LocalizationContext'; +import { useThreadContext } from '../context/ThreadProvider'; +import { FileMessage, UserMessage } from '@sendbird/chat/message'; + +export interface RemoveMessageProps { + onCancel: () => void; // rename to onClose + onSubmit?: () => void; + message: UserMessage | FileMessage; +} + +const RemoveMessage: React.FC = (props: RemoveMessageProps) => { + const { + onCancel, + onSubmit, + message, + } = props; + const { stringSet } = useContext(LocalizationContext); + const { + deleteMessage, + } = useThreadContext(); + return ( + 0} + onCancel={onCancel} + onSubmit={() => { + deleteMessage(message).then(() => { + onCancel?.(); + onSubmit?.(); + }); + }} + submitText={stringSet.MESSAGE_MENU__DELETE} + titleText={stringSet.MODAL__DELETE_MESSAGE__TITLE} + /> + ); +}; + +export default RemoveMessage; diff --git a/src/smart-components/Thread/components/ThreadHeader/index.scss b/src/smart-components/Thread/components/ThreadHeader/index.scss new file mode 100644 index 000000000..485f06f17 --- /dev/null +++ b/src/smart-components/Thread/components/ThreadHeader/index.scss @@ -0,0 +1,45 @@ +@import '../../../../styles/variables'; + +.sendbird-thread-header { + position: relative; + min-width: 320px; + width: 320px; + min-height: 64px; + height: 64px; + + display: inline; + padding: 13px 24px; + box-sizing: border-box; +} + +.sendbird-thread-header__title { + position: relative; + max-width: 254px; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sendbird-thread-header__channel-name { + position: relative; + max-width: 254px; + height: 12px; + + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sendbird-thread-header__action { + position: absolute; + top: 0px; + right: 0px; + width: 56px; + height: 100%; + + display: inline-flex; + align-items: center; + justify-content: center; +} diff --git a/src/smart-components/Thread/components/ThreadHeader/index.tsx b/src/smart-components/Thread/components/ThreadHeader/index.tsx new file mode 100644 index 000000000..f937d2da9 --- /dev/null +++ b/src/smart-components/Thread/components/ThreadHeader/index.tsx @@ -0,0 +1,78 @@ +import React, { useMemo } from 'react'; + +import './index.scss'; + +import IconButton from '../../../../ui/IconButton'; +import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; +import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; +import TextButton from '../../../../ui/TextButton'; + +import { useLocalization } from '../../../../lib/LocalizationContext'; + +type EventType = React.MouseEvent | React.KeyboardEvent; + +export interface ThreadHeaderProps { + className?: string; + channelName: string; + renderActionIcon?: (props: { onActionIconClick: (e: EventType) => void }) => React.ReactElement; + onActionIconClick?: (e: EventType) => void; + onChannelNameClick?: (e: EventType) => void; +} + +export default function ThreadHeader({ + className, + channelName, + renderActionIcon, + onActionIconClick, + onChannelNameClick, +}: ThreadHeaderProps): React.ReactElement { + const { stringSet } = useLocalization(); + + const MemoizedActionIcon = useMemo(() => { + if (typeof renderActionIcon === 'function') { + return renderActionIcon({ onActionIconClick }); + } + return null; + }, [renderActionIcon]); + return ( +
+ + onChannelNameClick(e)} + disableUnderline + > + + + { + MemoizedActionIcon || ( +
+ onActionIconClick(e)} + > + + +
+ ) + } +
+ ); +} diff --git a/src/smart-components/Thread/components/ThreadList/ThreadListItem.tsx b/src/smart-components/Thread/components/ThreadList/ThreadListItem.tsx new file mode 100644 index 000000000..678eb07bb --- /dev/null +++ b/src/smart-components/Thread/components/ThreadList/ThreadListItem.tsx @@ -0,0 +1,267 @@ +import React, { useMemo, useState, useRef, useEffect, useLayoutEffect } from 'react'; +import format from 'date-fns/format'; +import { FileMessage, UserMessage } from '@sendbird/chat/message'; + +import { useLocalization } from '../../../../lib/LocalizationContext'; +import DateSeparator from '../../../../ui/DateSeparator'; +import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; +import RemoveMessage from '../RemoveMessageModal'; +import FileViewer from '../../../../ui/FileViewer'; +import { useThreadContext } from '../../context/ThreadProvider'; +import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import SuggestedMentionList from '../../../Channel/components/SuggestedMentionList'; +import MessageInput from '../../../../ui/MessageInput'; +import { ThreadListStateTypes } from '../../types'; +import { MessageInputKeys } from '../../../../ui/MessageInput/const'; +import ThreadListItemContent from './ThreadListItemContent'; +import { Role } from '@sendbird/chat'; + +export interface ThreadListItemProps { + className?: string; + message: UserMessage | FileMessage; + chainTop?: boolean; + chainBottom?: boolean; + hasSeparator?: boolean; + renderCustomSeparator?: (props: { message: UserMessage | FileMessage }) => React.ReactElement; + handleScroll?: () => void; +} + +export default function ThreadListItem({ + className, + message, + chainTop, + chainBottom, + hasSeparator, + renderCustomSeparator, + handleScroll, +}: ThreadListItemProps): React.ReactElement { + const { stores, config } = useSendbirdStateContext(); + const { + isReactionEnabled, + isMentionEnabled, + isOnline, + replyType, + userMention, + } = config; + const userId = stores?.userStore?.user?.userId; + const { dateLocale } = useLocalization(); + const threadContext = useThreadContext?.(); + const { + currentChannel, + nicknamesMap, + emojiContainer, + toggleReaction, + threadListStatus, + updateMessage, + resendMessage, + deleteMessage, + isMuted, + isChannelFrozen, + } = threadContext; + const openingMessage = threadContext?.message; + + const [showEdit, setShowEdit] = useState(false); + const [showRemove, setShowRemove] = useState(false); + const [showFileViewer, setShowFileViewer] = useState(false); + const usingReaction = isReactionEnabled && !currentChannel?.isSuper && !currentChannel?.isBroadcast; + + // Move to message + const messageScrollRef = useRef(null); + useLayoutEffect(() => { + if (openingMessage?.messageId === message?.messageId && messageScrollRef?.current) { + messageScrollRef.current?.scrollIntoView({ block: 'center', inline: 'center' }); + } + }, [openingMessage, messageScrollRef?.current]); + + // reactions + useLayoutEffect(() => { + handleScroll?.(); + }, [showEdit, message?.reactions?.length]); + + // mention + const editMessageInputRef = useRef(null); + const [mentionNickname, setMentionNickname] = useState(''); + const [mentionedUsers, setMentionedUsers] = useState([]); + const [mentionedUserIds, setMentionedUserIds] = useState([]); + const [messageInputEvent, setMessageInputEvent] = useState(null); + const [selectedUser, setSelectedUser] = useState(null); + const [mentionSuggestedUsers, setMentionSuggestedUsers] = useState([]); + const [ableMention, setAbleMention] = useState(true); + const displaySuggestedMentionList = isOnline + && isMentionEnabled + && mentionNickname.length > 0 + && !isMuted + && !(isChannelFrozen && !(currentChannel.myRole === Role.OPERATOR)); + useEffect(() => { + if (mentionedUsers?.length >= userMention?.maxMentionCount) { + setAbleMention(false); + } else { + setAbleMention(true); + } + }, [mentionedUsers]); + useEffect(() => { + setMentionedUsers(mentionedUsers.filter(({ userId }) => { + const i = mentionedUserIds.indexOf(userId); + if (i < 0) { + return false; + } else { + mentionedUserIds.splice(i, 1); + return true; + } + })); + }, [mentionedUserIds]); + + // edit input + const disabled = !(threadListStatus === ThreadListStateTypes.INITIALIZED) + || !isOnline + || isMuted + || isChannelFrozen; + + // memorize + const MemorizedSeparator = useMemo(() => { + if (typeof renderCustomSeparator === 'function') { + return renderCustomSeparator?.({ message }); + } + }, [message, renderCustomSeparator]); + + // Edit message + if (showEdit && message.isUserMessage()) { + return ( + <> + { + displaySuggestedMentionList && ( + { + if (user) { + setMentionedUsers([...mentionedUsers, user]); + } + setMentionNickname(''); + setSelectedUser(user); + setMessageInputEvent(null); + }} + onFocusItemChange={() => { + setMessageInputEvent(null); + }} + onFetchUsers={(users) => { + setMentionSuggestedUsers(users); + }} + ableAddMention={ableMention} + maxMentionCount={userMention?.maxMentionCount} + maxSuggestionCount={userMention?.maxSuggestionCount} + /> + ) + } + { + currentChannel?.startTyping?.(); + }} + onUpdateMessage={({ messageId, message, mentionTemplate }) => { + updateMessage({ + messageId, + message, + mentionedUsers, + mentionTemplate, + }); + setShowEdit(false); + currentChannel?.endTyping?.(); + }} + onCancelEdit={() => { + setMentionNickname(''); + setMentionedUsers([]); + setMentionedUserIds([]); + setMentionSuggestedUsers([]) + setShowEdit(false); + currentChannel?.endTyping?.(); + }} + onUserMentioned={(user) => { + if (selectedUser?.userId === user?.userId) { + setSelectedUser(null); + setMentionNickname(''); + } + }} + onMentionStringChange={(mentionText) => { + setMentionNickname(mentionText); + }} + onMentionedUserIdsUpdated={(userIds) => { + setMentionedUserIds(userIds); + }} + onKeyDown={(e) => { + if (displaySuggestedMentionList && mentionSuggestedUsers?.length > 0 + && ((e.key === MessageInputKeys.Enter && ableMention) || e.key === MessageInputKeys.ArrowUp || e.key === MessageInputKeys.ArrowDown) + ) { + setMessageInputEvent(e); + return true; + } + return false; + }} + /> + + ); + } + + return ( +
+ {/* date separator */} + { + hasSeparator && message?.createdAt && (MemorizedSeparator || ( + + + + )) + } + + {/* modal */} + {showRemove && ( + setShowRemove(false)} + /> + )} + {showFileViewer && ( + setShowFileViewer(false)} + onDelete={() => { + deleteMessage(message); + setShowFileViewer(false); + }} + /> + )} +
+ ); +} diff --git a/src/smart-components/Thread/components/ThreadList/ThreadListItemContent.scss b/src/smart-components/Thread/components/ThreadList/ThreadListItemContent.scss new file mode 100644 index 000000000..cb6eb475c --- /dev/null +++ b/src/smart-components/Thread/components/ThreadList/ThreadListItemContent.scss @@ -0,0 +1,265 @@ +@import '../../../../styles/variables'; + +.sendbird-thread-list-item-content { + position: relative; + display: inline-flex; + flex-direction: row; + width: 100%; + height: 100%; + &.incoming { + justify-content: flex-start; + } + &.outgoing { + justify-content: flex-end; + } + .sendbird-thread-list-item-content__middle { + max-width: 200px; + @include mobile() { + max-width: calc(100vw - 100px); + } + } + + .sendbird-thread-list-item-content__middle { + .sendbird-thread-list-item-content__middle__quote-message.use-quote { + margin-top: -8px; + bottom: -8px; + } + } +} + +// incoming +.sendbird-thread-list-item-content.incoming { + .sendbird-thread-list-item-content__left { + position: relative; + display: inline-flex; + min-width: 40px; + + .sendbird-thread-list-item-content__left__avatar { + position: absolute; + left: 0px; + bottom: 2px; + } + } + + .sendbird-thread-list-item-content__middle { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: flex-start; + + .sendbird-thread-list-item-content__middle__body-container { + .sendbird-thread-list-item-content__middle__message-item-body { + max-width: 198px; + } + .sendbird-thread-list-item-content__middle__body-container__created-at { + position: absolute; + bottom: 6px; + right: -84px; + white-space: nowrap; + display: flex; + flex-direction: row; + min-width: 80px; + &.sendbird-mouse-hover { + display: none; + } + } + } + + .sendbird-thread-list-item-content__middle__sender-name { + position: relative; + margin-left: 12px; + margin-bottom: 4px; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } + + .sendbird-thread-list-item-content__middle__quote-message { + position: relative; + width: 100%; + display: inline-flex; + &.outgoing { justify-content: flex-end } + &.incoming { justify-content: flex-start } + &:hover { + cursor: pointer; + } + } + } + + .sendbird-thread-list-item-content__right { + position: relative; + display: inline-flex; + width: 50px; + &.use-reactions { width: 70px; } + margin-left: 4px; + padding-top: 18px; + &.chain-top { + padding-top: 2px; + &.use-quote { + padding-top: 18px; + } + } + + .sendbird-thread-list-item-content-menu { + position: relative; + flex-direction: row; + height: 32px; + display: none; + &.sendbird-mouse-hover { + display: inline-flex; + } + } + } + + &:hover { + .sendbird-thread-list-item-content__right { + .sendbird-thread-list-item-content-menu { + display: inline-flex; + } + } + .sendbird-thread-list-item-content__middle { + .sendbird-thread-list-item-content__middle__body-container { + .sendbird-thread-list-item-content__middle__body-container__created-at { + display: none; + } + } + } + } +} + +// outgoing +.sendbird-thread-list-item-content.outgoing { + .sendbird-thread-list-item-content__left { + position: relative; + box-sizing: border-box; + display: inline-flex; + justify-content: flex-end; + width: 50px; + &.use-reactions { width: 70px } + &.use-quote { + .sendbird-thread-list-item-content-menu { + top: 18px; + } + } + + .sendbird-thread-list-item-content-menu { + position: absolute; + top: 2px; + right: 4px; + flex-direction: row; + display: none; + &.sendbird-mouse-hover { + display: inline-flex; + } + } + } + + .sendbird-thread-list-item-content__middle { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: flex-end; + + .sendbird-thread-list-item-content__middle__quote-message { + position: relative; + width: 100%; + display: inline-flex; + &.outgoing { justify-content: flex-end } + &.incoming { justify-content: flex-start } + } + + .sendbird-thread-list-item-content__middle__body-container { + position: relative; + width: fit-content; + + .sendbird-thread-list-item-content__middle__message-item-body { + max-width: 200px; + } + + .sendbird-thread-list-item-content__middle__body-container__created-at { + position: absolute; + bottom: 2px; + left: -84px; + white-space: nowrap; + display: flex; + justify-content: flex-end; + box-sizing: content-box; + min-width: 80px; + min-height: 16px; + &.sendbird-mouse-hover { + display: none; + } + .sendbird-thread-list-item-content__middle__body-container__created-at__component-container { + position: relative; + display: inline-flex; + } + } + } + } + + .sendbird-thread-list-item-content__right { + display: none; + } + + &:hover { + .sendbird-thread-list-item-content__left { + .sendbird-thread-list-item-content-menu { + display: inline-flex; + } + } + .sendbird-thread-list-item-content__middle { + .sendbird-thread-list-item-content__middle__body-container { + .sendbird-thread-list-item-content__middle__body-container__created-at { + display: none; + } + } + } + } +} + +.sendbird-thread-list-item-content__middle__body-container { + position: relative; + width: fit-content; + display: flex; + flex-direction: column; + + .sendbird-thread-list-item-content__middle__message-item-body { + width: 100%; + box-sizing: border-box; + } +} + +.sendbird-thread-list-item-content-reactions { + position: relative; + width: 100%; + max-width: 400px; + border-radius: 0px 0px 16px 16px; + @include themed() { background-color: t(bg-1) } + &.primary { + @include themed() { background-color: t(primary-3) } + } + &.mouse-hover, &:hover { + @include themed() { background-color: t(bg-2) } + &.primary { + @include themed() { background-color: t(primary-4) } + } + } +} + +// threads +.sendbird-thread-list-item-content__middle__thread-replies { + margin-top: 4px; +} +.sendbird-thread-list-item-content__middle__message-item-body.sendbird-og-message-item-body, +.sendbird-thread-list-item-content__middle__message-item-body.sendbird-thumbnail-message-item-body { + min-width: 200px; + max-width: 200px; +} +.sendbird-thread-list-item-content__middle__message-item-body.sendbird-thumbnail-message-item-body { + height: 148px; +} +.sendbird-thread-list-item-content__middle__message-item-body .sendbird-thumbnail-message-item-body__placeholder, +.sendbird-thread-list-item-content__middle__message-item-body .sendbird-thumbnail-message-item-body__icon-wrapper, +.sendbird-thread-list-item-content__middle__message-item-body .sendbird-thumbnail-message-item-body__video { + height: 148px; +} diff --git a/src/smart-components/Thread/components/ThreadList/ThreadListItemContent.tsx b/src/smart-components/Thread/components/ThreadList/ThreadListItemContent.tsx new file mode 100644 index 000000000..2513a2310 --- /dev/null +++ b/src/smart-components/Thread/components/ThreadList/ThreadListItemContent.tsx @@ -0,0 +1,293 @@ +import React, { useContext, useRef, useState } from 'react'; +import { EmojiContainer } from '@sendbird/chat'; +import { FileMessage, UserMessage } from '@sendbird/chat/message'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; + +import './ThreadListItemContent.scss'; + +import { ReplyType } from '../../../../types'; +import ContextMenu, { MenuItems } from '../../../../ui/ContextMenu'; +import Avatar from '../../../../ui/Avatar'; +import { UserProfileContext } from '../../../../lib/UserProfileContext'; +import { UserProfileContextInterface } from '../../../../ui/MessageContent'; +import UserProfile from '../../../../ui/UserProfile'; +import MessageItemMenu from '../../../../ui/MessageItemMenu'; +import MessageItemReactionMenu from '../../../../ui/MessageItemReactionMenu'; +import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; +import { getClassName, getSenderName, getUIKitMessageType, getUIKitMessageTypes, isOGMessage, isTextMessage, isThumbnailMessage } from '../../../../utils'; +import MessageStatus from '../../../../ui/MessageStatus'; +import EmojiReactions from '../../../../ui/EmojiReactions'; +import format from 'date-fns/format'; +import { useLocalization } from '../../../../lib/LocalizationContext'; +import TextMessageItemBody from '../../../../ui/TextMessageItemBody'; +import OGMessageItemBody from '../../../../ui/OGMessageItemBody'; +import FileMessageItemBody from '../../../../ui/FileMessageItemBody'; +import ThumbnailMessageItemBody from '../../../../ui/ThumbnailMessageItemBody'; +import UnknownMessageItemBody from '../../../../ui/UnknownMessageItemBody'; + +export interface ThreadListItemContentProps { + className?: string; + userId: string; + channel: GroupChannel; + message: UserMessage | FileMessage; + disabled?: boolean; + chainTop?: boolean; + chainBottom?: boolean; + isMentionEnabled?: boolean; + isReactionEnabled?: boolean; + disableQuoteMessage?: boolean; + replyType?: ReplyType; + nicknamesMap?: Map; + emojiContainer?: EmojiContainer; + showEdit?: (bool: boolean) => void; + showRemove?: (bool: boolean) => void; + showFileViewer?: (bool: boolean) => void; + resendMessage?: (message: UserMessage | FileMessage) => void; + toggleReaction?: (message: UserMessage | FileMessage, reactionKey: string, isReacted: boolean) => void; + onReplyInThread?: (props: { message: UserMessage | FileMessage }) => void; +} + +export default function ThreadListItemContent({ + className, + userId, + channel, + message, + disabled = false, + chainTop = false, + chainBottom = false, + isMentionEnabled = false, + isReactionEnabled = false, + disableQuoteMessage = false, + replyType, + nicknamesMap, + emojiContainer, + showEdit, + showRemove, + showFileViewer, + resendMessage, + toggleReaction, + onReplyInThread, +}: ThreadListItemContentProps): React.ReactElement { + const messageTypes = getUIKitMessageTypes(); + const { dateLocale } = useLocalization(); + const [supposedHover, setSupposedHover] = useState(false); + const { + disableUserProfile, + renderUserProfile, + } = useContext(UserProfileContext); + const avatarRef = useRef(null); + + const isByMe = (userId === (message as UserMessage | FileMessage)?.sender?.userId) + || ((message as UserMessage | FileMessage)?.sendingStatus === 'pending') + || ((message as UserMessage | FileMessage)?.sendingStatus === 'failed'); + const useReplying = !!((replyType === 'QUOTE_REPLY' || replyType === 'THREAD') + && message?.parentMessageId && message?.parentMessage + && !disableQuoteMessage + ); + const supposedHoverClassName = supposedHover ? 'sendbird-mouse-hover' : ''; + return ( +
+
+ {(!isByMe && !chainBottom) && ( + ( + member?.userId === message?.sender?.userId)?.profileUrl || message?.sender?.profileUrl || ''} + ref={avatarRef} + width="28px" + height="28px" + onClick={() => { + if (!disableUserProfile) { + toggleDropdown?.(); + } + }} + /> + )} + menuItems={(closeDropdown) => ( + + {renderUserProfile + ? renderUserProfile({ user: message?.sender, close: closeDropdown }) + : + } + + )} + /> + )} + {isByMe && ( +
+ + {isReactionEnabled && ( + + )} +
+ )} +
+
+ {(!isByMe && !chainTop && !useReplying) && ( + + )} +
+ {/* message status component */} + {(isByMe && !chainBottom) && ( +
+
+ +
+
+ )} + {/* message item body components */} + {isTextMessage(message as UserMessage) && ( + + )} + {(isOGMessage(message as UserMessage)) && ( + + )} + {(getUIKitMessageType((message as FileMessage)) === messageTypes.FILE) && ( + + )} + {(isThumbnailMessage(message as FileMessage)) && ( + + )} + {(getUIKitMessageType((message as FileMessage)) === messageTypes.UNKNOWN) && ( + + )} + {/* reactions */} + {(isReactionEnabled && message?.reactions?.length > 0) && ( +
+ +
+ )} + {(!isByMe && !chainBottom) && ( + + )} +
+
+
+ {!isByMe && ( +
+ {isReactionEnabled && ( + + )} + +
+ )} +
+
+ ); +} diff --git a/src/smart-components/Thread/components/ThreadList/index.scss b/src/smart-components/Thread/components/ThreadList/index.scss new file mode 100644 index 000000000..305dd90be --- /dev/null +++ b/src/smart-components/Thread/components/ThreadList/index.scss @@ -0,0 +1,45 @@ +.sendbird-thread-list-item .sendbird-separator { + margin: 4px 0px; +} + +.sendbird-thread-list .sendbird-message-content__middle__sender-name { + white-space: nowrap; + max-width: 210px; + overflow: hidden; + text-overflow: ellipsis; +} + +.sendbird-thread-list .sendbird-message-content.incoming .sendbird-message-content__middle { + max-width: 200px; +} + +.sendbird-thread-list .sendbird-thumbnail-message-item-body.outgoing { + min-width: 200px; + min-height: 148px; + height: 148px; +} + +.sendbird-thread-list .sendbird-message-content .sendbird-message-content__middle { + max-width: 230px; +} + +.sendbird-thread-list .sendbird-message-status__icon.sendbird-message-status--sent { + display: none; +} + +// SuggestedMentionList +.sendbird-thread-list .sendbird-mention-suggest-list { + width: 100%; + margin-left: 0px; + margin-right: 0px; +} +.sendbird-thread-list .sendbird-mention-suggest-list__user-item { + padding-left: 16px; + padding-right: 16px; +} +.sendbird-thread-list .sendbird-mention-suggest-list__user-item .sendbird-mention-suggest-list__user-item__nickname { + max-width: 134px; +} +.sendbird-thread-list .sendbird-mention-suggest-list__user-item .sendbird-mention-suggest-list__user-item__user-id { + max-width: 46px; +} diff --git a/src/smart-components/Thread/components/ThreadList/index.tsx b/src/smart-components/Thread/components/ThreadList/index.tsx new file mode 100644 index 000000000..809b029aa --- /dev/null +++ b/src/smart-components/Thread/components/ThreadList/index.tsx @@ -0,0 +1,103 @@ +import React, { RefObject, useMemo } from 'react'; +import { BaseMessage, FileMessage, UserMessage } from '@sendbird/chat/message'; + +import './index.scss'; +import ThreadListItem from './ThreadListItem'; +import { useThreadContext } from '../../context/ThreadProvider'; +import { compareMessagesForGrouping } from '../../context/utils'; +import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import { isSameDay } from 'date-fns'; + +export interface ThreadListProps { + className?: string; + allThreadMessages: Array; + renderMessage?: (props: { + message: UserMessage | FileMessage, + chainTop: boolean, + chainBottom: boolean, + hasSeparator: boolean, + }) => React.ReactElement; + renderCustomSeparator?: (props: { message: UserMessage | FileMessage }) => React.ReactElement; + scrollRef?: RefObject; + scrollBottom?: number; +} + +export default function ThreadList({ + className, + allThreadMessages, + renderMessage, + renderCustomSeparator, + scrollRef, + scrollBottom, +}: ThreadListProps): React.ReactElement { + const { config } = useSendbirdStateContext(); + const { replyType } = config; + const { + currentChannel, + } = useThreadContext(); + + const MemorizedMessage = useMemo(() => ({ + message, + chainTop, + chainBottom, + hasSeparator, + }) => { + + if (typeof renderMessage === 'function') { + return renderMessage({ + message: message as UserMessage | FileMessage, + chainTop, + chainBottom, + hasSeparator, + }); + } + return null; + }, [renderMessage]); + + return ( +
+ {allThreadMessages.map((message, idx) => { + const prevMessage = allThreadMessages[idx - 1]; + const nextMessage = allThreadMessages[idx + 1]; + const [chainTop, chainBottom] = true// isMessageGroupingEnabled + ? compareMessagesForGrouping( + prevMessage as UserMessage | FileMessage, + message as UserMessage | FileMessage, + nextMessage as UserMessage | FileMessage, + currentChannel, + replyType, + ) + : [false, false]; + const hasSeparator = !(prevMessage?.createdAt > 0 && ( + isSameDay(message?.createdAt, prevMessage?.createdAt) + )); + + const handleScroll = () => { + const current = scrollRef?.current; + if (current) { + const bottom = current.scrollHeight - current.scrollTop - current.offsetHeight; + if (scrollBottom < bottom) { + current.scrollTop += bottom - scrollBottom; + } + } + }; + + return MemorizedMessage({ + message, + chainTop, + chainBottom, + hasSeparator, + }) || ( + + ); + })} +
+ ); +} diff --git a/src/smart-components/Thread/components/ThreadMessageInput/index.scss b/src/smart-components/Thread/components/ThreadMessageInput/index.scss new file mode 100644 index 000000000..4ab98c368 --- /dev/null +++ b/src/smart-components/Thread/components/ThreadMessageInput/index.scss @@ -0,0 +1,18 @@ +@import '../../../../styles/variables'; + +// SuggestedMentionList +.sendbird-thread-message-input .sendbird-mention-suggest-list { + width: 100%; + margin-left: 0px; + margin-right: 0px; +} +.sendbird-thread-message-input .sendbird-mention-suggest-list .sendbird-mention-suggest-list__user-item { + padding-left: 16px; + padding-right: 16px; +} +.sendbird-thread-message-input .sendbird-mention-suggest-list .sendbird-mention-suggest-list__user-item .sendbird-mention-suggest-list__user-item__nickname { + max-width: 134px; +} +.sendbird-thread-message-input .sendbird-mention-suggest-list .sendbird-mention-suggest-list__user-item .sendbird-mention-suggest-list__user-item__user-id { + max-width: 46px; +} diff --git a/src/smart-components/Thread/components/ThreadMessageInput/index.tsx b/src/smart-components/Thread/components/ThreadMessageInput/index.tsx new file mode 100644 index 000000000..40449f30b --- /dev/null +++ b/src/smart-components/Thread/components/ThreadMessageInput/index.tsx @@ -0,0 +1,162 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Role } from '@sendbird/chat'; +import { MutedState } from '@sendbird/chat/groupChannel'; + +import './index.scss'; + +import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import MessageInput from '../../../../ui/MessageInput'; +import { MessageInputKeys } from '../../../../ui/MessageInput/const'; +import SuggestedMentionList from '../../../Channel/components/SuggestedMentionList'; +import { useThreadContext } from '../../context/ThreadProvider'; +import { useLocalization } from '../../../../lib/LocalizationContext'; + +export interface ThreadMessageInputProps { + className?: string; +} + +const ThreadMessageInput = ( + props: ThreadMessageInputProps, + ref: React.MutableRefObject, +): React.ReactElement => { + const { className } = props; + const { config } = useSendbirdStateContext(); + const { stringSet } = useLocalization(); + const { + isMentionEnabled, + isOnline, + userMention, + } = config; + const { + currentChannel, + parentMessage, + sendMessage, + sendFileMessage, + isMuted, + isChannelFrozen, + allThreadMessages, + } = useThreadContext(); + const messageInputRef = useRef(); + + const disabled = isMuted || (!(currentChannel?.myRole === Role.OPERATOR) && isChannelFrozen) || parentMessage === null; + + // mention + const [mentionNickname, setMentionNickname] = useState(''); + const [mentionedUsers, setMentionedUsers] = useState([]); + const [mentionedUserIds, setMentionedUserIds] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [mentionSuggestedUsers, setMentionSuggestedUsers] = useState([]); + const [ableMention, setAbleMention] = useState(true); + const [messageInputEvent, setMessageInputEvent] = useState(null); + const displaySuggestedMentionList = isOnline + && isMentionEnabled + && mentionNickname.length > 0 + // && !utils.isDisabledBecauseFrozen(channel) + // && !utils.isDisabledBecauseMuted(channel); + useEffect(() => { + if (mentionedUsers?.length >= userMention?.maxMentionCount) { + setAbleMention(false); + } else { + setAbleMention(true); + } + }, [mentionedUsers]); + useEffect(() => { + setMentionedUsers(mentionedUsers.filter(({ userId }) => { + const i = mentionedUserIds.indexOf(userId); + if (i < 0) { + return false; + } else { + mentionedUserIds.splice(i, 1); + return true; + } + })); + }, [mentionedUserIds]); + + return ( +
+ { + displaySuggestedMentionList && ( + { + if (user) { + setMentionedUsers([...mentionedUsers, user]); + } + setMentionNickname(''); + setSelectedUser(user); + setMessageInputEvent(null); + }} + onFocusItemChange={() => { + setMessageInputEvent(null); + }} + onFetchUsers={(users) => { + setMentionSuggestedUsers(users); + }} + ableAddMention={ableMention} + maxMentionCount={userMention?.maxMentionCount} + maxSuggestionCount={userMention?.maxSuggestionCount} + /> + ) + } + 0 + ? stringSet.THREAD__INPUT__REPLY_TO_THREAD + : stringSet.THREAD__INPUT__REPLY_IN_THREAD + ) + } + onStartTyping={() => { + currentChannel?.startTyping?.(); + }} + onSendMessage={({ message, mentionTemplate }) => { + sendMessage({ + message: message, + mentionedUsers, + mentionTemplate: mentionTemplate, + quoteMessage: parentMessage, + }); + setMentionNickname(''); + setMentionedUsers([]); + currentChannel?.endTyping?.(); + }} + onFileUpload={(file) => { + sendFileMessage(file, parentMessage); + }} + onUserMentioned={(user) => { + if (selectedUser?.userId === user?.userId) { + setSelectedUser(null); + setMentionNickname(''); + } + }} + onMentionStringChange={(mentionText) => { + setMentionNickname(mentionText); + }} + onMentionedUserIdsUpdated={(userIds) => { + setMentionedUserIds(userIds); + }} + onKeyDown={(e) => { + if (displaySuggestedMentionList && mentionSuggestedUsers?.length > 0 + && ((e.key === MessageInputKeys.Enter && ableMention) || e.key === MessageInputKeys.ArrowUp || e.key === MessageInputKeys.ArrowDown) + ) { + setMessageInputEvent(e); + return true; + } + return false; + }} + /> +
+ ); +} + +export default React.forwardRef(ThreadMessageInput); diff --git a/src/smart-components/Thread/components/ThreadUI/index.scss b/src/smart-components/Thread/components/ThreadUI/index.scss new file mode 100644 index 000000000..c1a510b2b --- /dev/null +++ b/src/smart-components/Thread/components/ThreadUI/index.scss @@ -0,0 +1,69 @@ +@import '../../../../styles/variables'; + +.sendbird-thread-ui { + position: relative; + max-width: 320px; + width: 100%; + height: 100%; + box-sizing: border-box; + + display: flex; + flex-direction: column; + @include themed() { + border-right: 1px solid t(on-bg-4); + border-bottom: 1px solid t(on-bg-4); + background-color: t(bg-0); + } +} + +.sendbird-thread-ui__header { + width: 100%; + @include themed() { + border-top: 1px solid t(on-bg-4); + } +} + +.sendbird-thread-ui--scroll { + overflow-y: scroll; + overflow-x: hidden; + height: 100%; + display: inline-flex; + flex-direction: column; + justify-content: flex-start; +} + +.sendbird-thread-ui__parent-message-info { + @include themed() { + border-top: 1px solid t(on-bg-4); + } +} + +.sendbird-thread-ui__reply-counts { + position: relative; + box-sizing: border-box; + width: 100%; + height: 42px; + + display: inline-flex; + justify-content: start; + align-items: center; + padding: 16px 11px; + + @include themed() { + border-top: 1px solid t(on-bg-4); + border-bottom: 1px solid t(on-bg-4); + } +} + +.sendbird-thread-ui__thread-list { + padding: 16px; +} + +.sendbird-thread-ui__message-input { + padding: 0px 16px; + margin-bottom: 24px; +} + +.sendbird-thread-ui__thread-list.sendbird-place-holder { + box-sizing: border-box; +} diff --git a/src/smart-components/Thread/components/ThreadUI/index.tsx b/src/smart-components/Thread/components/ThreadUI/index.tsx new file mode 100644 index 000000000..77bf8e690 --- /dev/null +++ b/src/smart-components/Thread/components/ThreadUI/index.tsx @@ -0,0 +1,182 @@ +import React, { useRef, useState } from 'react'; +import { FileMessage, UserMessage } from '@sendbird/chat/message'; + +import './index.scss'; + +import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import { useLocalization } from '../../../../lib/LocalizationContext'; +import { getChannelTitle } from '../../../Channel/components/ChannelHeader/utils'; +import { useThreadContext } from '../../context/ThreadProvider'; +import { ParentMessageInfoStateTypes, ThreadListStateTypes } from '../../types'; +import ParentMessageInfo from '../ParentMessageInfo'; +import ThreadHeader from '../ThreadHeader'; +import ThreadList from '../ThreadList'; +import ThreadMessageInput from '../ThreadMessageInput'; +import useMemorizedHeader from './useMemorizedHeader'; +import useMemorizedParentMessageInfo from './useMemorizedParentMessageInfo'; +import useMemorizedThreadList from './useMemorizedThreadList'; +import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; +import { isAboutSame } from '../../context/utils'; + +export interface ThreadUIProps { + renderHeader?: () => React.ReactElement; + renderParentMessageInfo?: () => React.ReactElement; + renderMessage?: (props: { message: UserMessage | FileMessage }) => React.ReactElement; + renderMessageInput?: () => React.ReactElement; + renderCustomSeparator?: () => React.ReactElement; + renderParentMessageInfoPlaceholder?: (type: ParentMessageInfoStateTypes) => React.ReactElement; + renderThreadListPlaceHolder?: (type: ThreadListStateTypes) => React.ReactElement; +} + +const ThreadUI: React.FC = ({ + renderHeader, + renderParentMessageInfo, + renderMessage, + renderMessageInput, + renderCustomSeparator, + renderParentMessageInfoPlaceholder, + renderThreadListPlaceHolder, +}: ThreadUIProps): React.ReactElement => { + const { + stores, + } = useSendbirdStateContext(); + const currentUserId = stores?.sdkStore?.sdk?.currentUser?.userId; + const { + stringSet, + } = useLocalization(); + const { + currentChannel, + allThreadMessages, + parentMessage, + parentMessageInfoStatus, + threadListStatus, + hasMorePrev, + hasMoreNext, + fetchPrevThreads, + fetchNextThreads, + onHeaderActionClick, + onMoveToParentMessage, + } = useThreadContext(); + const replyCount = allThreadMessages.length; + + // Memoized custom components + const MemorizedHeader = useMemorizedHeader({ renderHeader }); + const MemorizedParentMessageInfo = useMemorizedParentMessageInfo({ + parentMessage, + parentMessageInfoStatus, + renderParentMessageInfo, + renderParentMessageInfoPlaceholder, // nil, loading, invalid + }); + const MemorizedThreadList = useMemorizedThreadList({ + threadListStatus, + renderThreadListPlaceHolder, + }); + + // scroll + const [scrollBottom, setScrollBottom] = useState(0); + const scrollRef = useRef(null); + const onScroll = (e) => { + const element = e.target; + const { + scrollTop, + clientHeight, + scrollHeight, + } = element; + + const threadItemNodes = scrollRef.current.querySelectorAll('.sendbird-thread-list-item'); + const firstNode = threadItemNodes?.[0]; + if (isAboutSame(scrollTop, 0, 10) && hasMorePrev) { + fetchPrevThreads((messages) => { + if (messages) { + try { + firstNode?.scrollIntoView?.({ block: 'start', inline: 'nearest' }); + } catch (error) { + // + } + } + }); + } + + if (isAboutSame(clientHeight + scrollTop, scrollHeight, 10) && hasMoreNext) { + const scrollTop_ = scrollTop; + fetchNextThreads((messages) => { + if (messages) { + try { + element.scrollTop = scrollTop_; + scrollRef.current.scrollTop = scrollTop_; + } catch (error) { + // + } + } + }); + } + + // save the lastest scroll bottom value + if (scrollRef?.current) { + const current = scrollRef?.current; + setScrollBottom(current.scrollHeight - current.scrollTop - current.offsetHeight) + } + }; + + return ( +
+ { + MemorizedHeader || ( + onMoveToParentMessage({ message: parentMessage, channel: currentChannel })} + /> + ) + } +
+ { + MemorizedParentMessageInfo || ( + + ) + } + { + replyCount > 0 && ( +
+ +
+ ) + } + { + MemorizedThreadList || ( + + ) + } + {/* MessageInput */} + { + renderMessageInput?.() || ( + + ) + } +
+
+ ); +}; + +export default ThreadUI; diff --git a/src/smart-components/Thread/components/ThreadUI/useMemorizedHeader.tsx b/src/smart-components/Thread/components/ThreadUI/useMemorizedHeader.tsx new file mode 100644 index 000000000..5cb591376 --- /dev/null +++ b/src/smart-components/Thread/components/ThreadUI/useMemorizedHeader.tsx @@ -0,0 +1,14 @@ +import React, { ReactElement, useMemo } from 'react'; + +export interface UseMemorizedHeaderProps { + renderHeader?: () => React.ReactElement; +} + +const useMemorizedHeader = ({ renderHeader }: UseMemorizedHeaderProps): ReactElement => useMemo(() => { + if (typeof renderHeader === 'function') { + return renderHeader(); + } + return null; +}, [renderHeader]); + +export default useMemorizedHeader; diff --git a/src/smart-components/Thread/components/ThreadUI/useMemorizedParentMessageInfo.tsx b/src/smart-components/Thread/components/ThreadUI/useMemorizedParentMessageInfo.tsx new file mode 100644 index 000000000..74a537e43 --- /dev/null +++ b/src/smart-components/Thread/components/ThreadUI/useMemorizedParentMessageInfo.tsx @@ -0,0 +1,72 @@ +import React, { ReactElement, useMemo } from 'react'; + +import { ParentMessageInfoStateTypes } from '../../types'; +import PlaceHolder, { PlaceHolderTypes } from '../../../../ui/PlaceHolder'; +import { FileMessage, UserMessage } from '@sendbird/chat/message'; + +export interface UseMemorizedParentMessageInfoProps { + parentMessage: UserMessage | FileMessage; + parentMessageInfoStatus: ParentMessageInfoStateTypes; + renderParentMessageInfo?: () => React.ReactElement; + renderParentMessageInfoPlaceholder?: (type: ParentMessageInfoStateTypes) => React.ReactElement; +} + +const useMemorizedParentMessageInfo = ({ + parentMessage, + parentMessageInfoStatus, + renderParentMessageInfo, + renderParentMessageInfoPlaceholder, +}: UseMemorizedParentMessageInfoProps): ReactElement => useMemo(() => { + if (parentMessageInfoStatus === ParentMessageInfoStateTypes.NIL + || parentMessageInfoStatus === ParentMessageInfoStateTypes.LOADING + || parentMessageInfoStatus === ParentMessageInfoStateTypes.INVALID + ) { + if (typeof renderParentMessageInfoPlaceholder === 'function') { + return renderParentMessageInfoPlaceholder(parentMessageInfoStatus); + } + switch (parentMessageInfoStatus) { + case ParentMessageInfoStateTypes.NIL: { + return ( + + ); + } + case ParentMessageInfoStateTypes.LOADING: { + return ( + + ); + } + case ParentMessageInfoStateTypes.INVALID: { + return ( + + ); + } + default: { + return null; + } + } + } else if (parentMessageInfoStatus === ParentMessageInfoStateTypes.INITIALIZED) { + if (typeof renderParentMessageInfo === 'function') { + return renderParentMessageInfo(); + } + } + return null; +}, [ + parentMessage, + parentMessageInfoStatus, + renderParentMessageInfo, + renderParentMessageInfoPlaceholder, +]); + +export default useMemorizedParentMessageInfo; diff --git a/src/smart-components/Thread/components/ThreadUI/useMemorizedThreadList.tsx b/src/smart-components/Thread/components/ThreadUI/useMemorizedThreadList.tsx new file mode 100644 index 000000000..a4f16be68 --- /dev/null +++ b/src/smart-components/Thread/components/ThreadUI/useMemorizedThreadList.tsx @@ -0,0 +1,55 @@ +import React, { ReactElement, useMemo } from 'react'; +import PlaceHolder, { PlaceHolderTypes } from '../../../../ui/PlaceHolder'; + +import { ThreadListStateTypes } from '../../types'; + +export interface UseMemorizedThreadListProps { + threadListStatus: ThreadListStateTypes; + renderThreadListPlaceHolder?: (tyep: ThreadListStateTypes) => React.ReactElement; +} + +const useMemorizedThreadList = ({ + threadListStatus, + renderThreadListPlaceHolder, +}: UseMemorizedThreadListProps): ReactElement => useMemo(() => { + if (threadListStatus === ThreadListStateTypes.NIL + || threadListStatus === ThreadListStateTypes.LOADING + || threadListStatus === ThreadListStateTypes.INVALID + ) { + if (typeof renderThreadListPlaceHolder === 'function') { + return renderThreadListPlaceHolder(threadListStatus); + } + switch (threadListStatus) { + case ThreadListStateTypes.LOADING: { + return ( + + ); + } + case ThreadListStateTypes.INVALID: { + return ( + + ); + } + case ThreadListStateTypes.NIL: { + return <>; + } + default: { + return null; + } + } + } + return null; +}, [ + threadListStatus, + renderThreadListPlaceHolder, +]); + +export default useMemorizedThreadList; diff --git a/src/smart-components/Thread/consts.ts b/src/smart-components/Thread/consts.ts new file mode 100644 index 000000000..63883687f --- /dev/null +++ b/src/smart-components/Thread/consts.ts @@ -0,0 +1,2 @@ +export const PREV_THREADS_FETCH_SIZE = 30; +export const NEXT_THREADS_FETCH_SIZE = 30; diff --git a/src/smart-components/Thread/context/ThreadProvider.tsx b/src/smart-components/Thread/context/ThreadProvider.tsx new file mode 100644 index 000000000..7bf5192f6 --- /dev/null +++ b/src/smart-components/Thread/context/ThreadProvider.tsx @@ -0,0 +1,231 @@ +import React, { useReducer, useMemo, useEffect, ReactElement } from 'react'; +import { User } from '@sendbird/chat'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { BaseMessage, FileMessage, UserMessage } from '@sendbird/chat/message'; + +import { getNicknamesMapFromMembers } from './utils'; +import { UserProfileProvider } from '../../../lib/UserProfileContext'; +import { CustomUseReducerDispatcher } from '../../../lib/SendbirdState'; +import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; + +import threadReducer from './dux/reducer'; +import { ThreadContextActionTypes } from './dux/actionTypes'; +import threadInitialState, { ThreadContextInitialState } from './dux/initialState'; + +import useGetChannel from './hooks/useGetChannel'; +import useGetAllEmoji from './hooks/useGetAllEmoji'; +import useGetThreadList from './hooks/useGetThreadList'; +import useGetParentMessage from './hooks/useGetParentMessage'; +import useHandlePubsubEvents from './hooks/useHandlePubsubEvents'; +import useHandleChannelEvents from './hooks/useHandleChannelEvents'; +import useSendFileMessageCallback from './hooks/useSendFileMessage'; +import useUpdateMessageCallback from './hooks/useUpdateMessageCallback'; +import useDeleteMessageCallback from './hooks/useDeleteMessageCallback'; +import useGetPrevThreadsCallback from './hooks/useGetPrevThreadsCallback'; +import useGetNextThreadsCallback from './hooks/useGetNextThreadsCallback'; +import useToggleReactionCallback from './hooks/useToggleReactionsCallback'; +import useSendUserMessageCallback from './hooks/useSendUserMessageCallback'; +import useResendMessageCallback from './hooks/useResendMessageCallback'; + +export type ThreadProviderProps = { + children?: React.ReactElement; + channelUrl: string; + message: UserMessage | FileMessage; + onHeaderActionClick?: () => void; + onMoveToParentMessage?: (props: { message: UserMessage | FileMessage, channel: GroupChannel }) => void; + // User Profile + disableUserProfile?: boolean; + renderUserProfile?: (props: { user: User, close: () => void }) => ReactElement; + onUserProfileMessage?: (channel: GroupChannel) => void; +} +export interface ThreadProviderInterface extends ThreadProviderProps, ThreadContextInitialState { + // hooks for fetching threads + fetchPrevThreads: (callback?: (messages?: Array) => void) => void; + fetchNextThreads: (callback?: (messages?: Array) => void) => void; + toggleReaction: (message, key, isReacted) => void; + sendMessage: (props: { + message: UserMessage, + quoteMessage?: UserMessage | FileMessage, + mentionTemplate?: string, + mentionedUsers?: Array, + }) => void; + sendFileMessage: (file: File, quoteMessage: UserMessage | FileMessage) => void; + resendMessage: (failedMessage: UserMessage | FileMessage) => void; + updateMessage: (props, callback?: () => void) => void; + deleteMessage: (message: UserMessage | FileMessage) => Promise; + nicknamesMap: Map; +} +const ThreadContext = React.createContext(null); + +export const ThreadProvider: React.FC = (props: ThreadProviderProps) => { + const { + children, + channelUrl, + message, + onHeaderActionClick, + onMoveToParentMessage, + // User Profile + disableUserProfile, + renderUserProfile, + onUserProfileMessage, + } = props; + // Context from SendbirdProvider + const globalStore = useSendbirdStateContext(); + const { stores, config } = globalStore; + // // stores + const { sdkStore, userStore } = stores; + const { sdk } = sdkStore; + const { user } = userStore; + const sdkInit = sdkStore?.initialized; + // // config + const { + logger, + pubSub, + replyType, + isMentionEnabled, + isReactionEnabled, + } = config; + + // dux of Thread + const [threadStore, threadDispatcher] = useReducer( + threadReducer, + threadInitialState, + ) as [ThreadContextInitialState, CustomUseReducerDispatcher]; + const { + currentChannel, + allThreadMessages, + parentMessage, + channelStatus, + threadListStatus, + parentMessageInfoStatus, + hasMorePrev, + hasMoreNext, + emojiContainer, + isMuted, + isChannelFrozen, + currentUserId, + }: ThreadContextInitialState = threadStore; + + // Initialization + useEffect(() => { + threadDispatcher({ + type: ThreadContextActionTypes.INIT_USER_ID, + payload: user?.userId, + }); + }, [user]); + useGetChannel({ + channelUrl, + sdkInit, + message, + }, { sdk, logger, threadDispatcher }); + useGetParentMessage({ + channelUrl, + sdkInit, + parentMessageId: message?.parentMessageId, + parentMessage: message?.parentMessage, + }, { sdk, logger, threadDispatcher }); + useGetThreadList({ + sdkInit, + parentMessage, + isReactionEnabled, + anchorMessage: message?.messageId ? message : null, + }, { logger, threadDispatcher }); + useGetAllEmoji({ sdk }, { logger, threadDispatcher }); + // Handle channel events + useHandleChannelEvents({ + sdk, + currentChannel, + }, { logger, threadDispatcher }); + useHandlePubsubEvents({ + sdkInit, + currentChannel, + parentMessage, + }, { logger, pubSub, threadDispatcher }) + + // callbacks + const fetchPrevThreads = useGetPrevThreadsCallback({ + hasMorePrev, + parentMessage, + threadListStatus, + isReactionEnabled, + oldestMessageTimeStamp: allThreadMessages[0]?.createdAt || 0, + }, { logger, threadDispatcher }); + const fetchNextThreads = useGetNextThreadsCallback({ + hasMoreNext, + parentMessage, + threadListStatus, + isReactionEnabled, + latestMessageTimeStamp: allThreadMessages[allThreadMessages.length - 1]?.createdAt || 0 + }, { logger, threadDispatcher }); + const toggleReaction = useToggleReactionCallback({ currentChannel }, { logger }); + const sendMessage = useSendUserMessageCallback({ + isMentionEnabled, + currentChannel, + }, { logger, pubSub, threadDispatcher }); + const sendFileMessage = useSendFileMessageCallback({ + currentChannel, + }, { logger, pubSub, threadDispatcher }); + const resendMessage = useResendMessageCallback({ + currentChannel, + }, { logger, pubSub, threadDispatcher }); + const updateMessage = useUpdateMessageCallback({ + currentChannel, + isMentionEnabled, + }, { logger, pubSub, threadDispatcher }); + const deleteMessage = useDeleteMessageCallback({ currentChannel, threadDispatcher }, { logger }); + + // memo + const nicknamesMap: Map = useMemo(() => ( + (replyType && currentChannel) + ? getNicknamesMapFromMembers(currentChannel?.members) + : new Map() + ), [currentChannel?.members]); + + return ( + + {/* UserProfileProvider */} + + {children} + + + ); +}; + +export type UseThreadContextType = () => ThreadProviderInterface; +export const useThreadContext: UseThreadContextType = () => React.useContext(ThreadContext); diff --git a/src/smart-components/Thread/context/dux/actionTypes.ts b/src/smart-components/Thread/context/dux/actionTypes.ts new file mode 100644 index 000000000..36a7e6d18 --- /dev/null +++ b/src/smart-components/Thread/context/dux/actionTypes.ts @@ -0,0 +1,45 @@ +export enum ThreadContextActionTypes { + // initialize + INIT_USER_ID = 'INIT_USER_ID', + // channel + GET_CHANNEL_START = 'GET_CHANNEL_START', + GET_CHANNEL_SUCCESS = 'GET_CHANNEL_SUCCESS', + GET_CHANNEL_FAILURE = 'GET_CHANNEL_FAILURE', + // emojis + SET_EMOJI_CONTAINER = 'SET_EMOJI_CONTAINER', + // parent message + GET_PARENT_MESSAGE_START = 'GET_PARENT_MESSAGE_START', + GET_PARENT_MESSAGE_SUCCESS = 'GET_PARENT_MESSAGE_SUCCESS', + GET_PARENT_MESSAGE_FAILURE = 'GET_PARENT_MESSAGE_FAILURE', + // fetch threads + INITIALIZE_THREAD_LIST_START = 'INITIALIZE_THREAD_LIST_START', + INITIALIZE_THREAD_LIST_SUCCESS = 'INITIALIZE_THREAD_LIST_SUCCESS', + INITIALIZE_THREAD_LIST_FAILURE = 'INITIALIZE_THREAD_LIST_FAILURE', + GET_PREV_MESSAGES_START = 'GET_PREV_MESSAGES_START', + GET_PREV_MESSAGES_SUCESS = 'GET_PREV_MESSAGES_SUCESS', + GET_PREV_MESSAGES_FAILURE = 'GET_PREV_MESSAGES_FAILURE', + GET_NEXT_MESSAGES_START = 'GET_NEXT_MESSAGES_START', + GET_NEXT_MESSAGES_SUCESS = 'GET_NEXT_MESSAGES_SUCESS', + GET_NEXT_MESSAGES_FAILURE = 'GET_NEXT_MESSAGES_FAILURE', + // handle messages + SEND_MESSAGE_START = 'SEND_MESSAGE_START', + SEND_MESSAGE_SUCESS = 'SEND_MESSAGE_SUCESS', + SEND_MESSAGE_FAILURE = 'SEND_MESSAGE_FAILURE', + RESEND_MESSAGE_START = 'RESEND_MESSAGE_START', + ON_MESSAGE_DELETED_BY_REQ_ID = 'ON_MESSAGE_DELETED_BY_REQ_ID', + // event handlers - message status change + ON_MESSAGE_RECEIVED = 'ON_MESSAGE_RECEIVED', + ON_MESSAGE_UPDATED = 'ON_MESSAGE_UPDATED', + ON_MESSAGE_DELETED = 'ON_MESSAGE_DELETED', + ON_REACTION_UPDATED = 'ON_REACTION_UPDATED', + // event handlers - user status change + ON_USER_MUTED = 'ON_USER_MUTED', + ON_USER_UNMUTED = 'ON_USER_UNMUTED', + ON_USER_BANNED = 'ON_USER_BANNED', + ON_USER_UNBANNED = 'ON_USER_UNBANNED', + ON_USER_LEFT = 'ON_USER_LEFT', + // event handler - channel status change + ON_CHANNEL_FROZEN = 'ON_CHANNEL_FROZEN', + ON_CHANNEL_UNFROZEN = 'ON_CHANNEL_UNFROZEN', + ON_OPERATOR_UPDATED = 'ON_OPERATOR_UPDATED', +} diff --git a/src/smart-components/Thread/context/dux/initialState.ts b/src/smart-components/Thread/context/dux/initialState.ts new file mode 100644 index 000000000..6fecfdac4 --- /dev/null +++ b/src/smart-components/Thread/context/dux/initialState.ts @@ -0,0 +1,40 @@ +import { EmojiContainer } from "@sendbird/chat"; +import { GroupChannel } from "@sendbird/chat/groupChannel"; +import { BaseMessage, FileMessage, UserMessage } from "@sendbird/chat/message"; +import { + ChannelStateTypes, + ParentMessageInfoStateTypes, + ThreadListStateTypes, +} from "../../types"; + +export interface ThreadContextInitialState { + currentChannel: GroupChannel; + allThreadMessages: Array; + parentMessage: UserMessage | FileMessage; + channelStatus: ChannelStateTypes; + parentMessageInfoStatus: ParentMessageInfoStateTypes; + threadListStatus: ThreadListStateTypes; + hasMorePrev: boolean; + hasMoreNext: boolean; + emojiContainer: EmojiContainer; + isMuted: boolean; + isChannelFrozen: boolean; + currentUserId: string; +} + +const initialState: ThreadContextInitialState = { + currentChannel: null, + allThreadMessages: [], + parentMessage: null, + channelStatus: ChannelStateTypes.NIL, + parentMessageInfoStatus: ParentMessageInfoStateTypes.NIL, + threadListStatus: ThreadListStateTypes.NIL, + hasMorePrev: false, + hasMoreNext: false, + emojiContainer: {} as EmojiContainer, + isMuted: false, + isChannelFrozen: false, + currentUserId: '', +} + +export default initialState; diff --git a/src/smart-components/Thread/context/dux/reducer.ts b/src/smart-components/Thread/context/dux/reducer.ts new file mode 100644 index 000000000..d2157cce2 --- /dev/null +++ b/src/smart-components/Thread/context/dux/reducer.ts @@ -0,0 +1,352 @@ +import { GroupChannel } from "@sendbird/chat/groupChannel"; +import { FileMessage, ReactionEvent, UserMessage } from "@sendbird/chat/message"; +import { NEXT_THREADS_FETCH_SIZE, PREV_THREADS_FETCH_SIZE } from "../../consts"; +import { ChannelStateTypes, ParentMessageInfoStateTypes, ThreadListStateTypes } from "../../types"; +import { compareIds } from "../utils"; +import { ThreadContextActionTypes as actionTypes } from "./actionTypes"; +import { ThreadContextInitialState } from "./initialState"; + +interface ActionInterface { + type: actionTypes; + payload?: any; +} + +export default function reducer( + state: ThreadContextInitialState, + action: ActionInterface, +): ThreadContextInitialState { + switch (action.type) { + // initialize + case actionTypes.INIT_USER_ID: { + return { + ...state, + currentUserId: action.payload, + }; + } + case actionTypes.GET_CHANNEL_START: { + return { + ...state, + channelStatus: ChannelStateTypes.LOADING, + currentChannel: null, + }; + } + case actionTypes.GET_CHANNEL_SUCCESS: { + const groupChannel = action.payload.groupChannel as GroupChannel; + return { + ...state, + channelStatus: ChannelStateTypes.INITIALIZED, + currentChannel: groupChannel, + // only support in normal group channel + isMuted: groupChannel?.members?.find((member) => member?.userId === state.currentUserId)?.isMuted || false, + isChannelFrozen: groupChannel?.isFrozen || false, + }; + } + case actionTypes.GET_CHANNEL_FAILURE: { + return { + ...state, + channelStatus: ChannelStateTypes.INVALID, + currentChannel: null, + }; + } + case actionTypes.SET_EMOJI_CONTAINER: { + const { emojiContainer } = action.payload; + return { + ...state, + emojiContainer: emojiContainer, + } + } + case actionTypes.GET_PARENT_MESSAGE_START: { + return { + ...state, + parentMessageInfoStatus: ParentMessageInfoStateTypes.LOADING, + parentMessage: null, + }; + } + case actionTypes.GET_PARENT_MESSAGE_SUCCESS: { + return { + ...state, + parentMessageInfoStatus: ParentMessageInfoStateTypes.INITIALIZED, + parentMessage: action.payload.parentMessage, + }; + } + case actionTypes.GET_PARENT_MESSAGE_FAILURE: { + return { + ...state, + parentMessageInfoStatus: ParentMessageInfoStateTypes.INVALID, + parentMessage: null, + }; + } + // fetch threads + case actionTypes.INITIALIZE_THREAD_LIST_START: { + return { + ...state, + threadListStatus: ThreadListStateTypes.LOADING, + allThreadMessages: [], + }; + } + case actionTypes.INITIALIZE_THREAD_LIST_SUCCESS: { + const { parentMessage, anchorMessage, threadedMessages } = action.payload; + const anchorMessageCreatedAt = (!anchorMessage?.messageId) ? parentMessage?.createdAt : anchorMessage?.createdAt; + const anchorIndex = threadedMessages.findIndex((message) => message?.createdAt > anchorMessageCreatedAt); + const prevThreadMessages = anchorIndex > -1 ? threadedMessages.slice(0, anchorIndex) : threadedMessages; + const anchorThreadMessage = anchorMessage?.messageId ? [anchorMessage] : []; + const nextThreadMessages = anchorIndex > -1 ? threadedMessages.slice(anchorIndex) : []; + return { + ...state, + threadListStatus: ThreadListStateTypes.INITIALIZED, + hasMorePrev: anchorIndex === -1 || anchorIndex === PREV_THREADS_FETCH_SIZE, + hasMoreNext: threadedMessages.length - anchorIndex === NEXT_THREADS_FETCH_SIZE, + allThreadMessages: [prevThreadMessages, anchorThreadMessage, nextThreadMessages].flat(), + }; + } + case actionTypes.INITIALIZE_THREAD_LIST_FAILURE: { + return { + ...state, + threadListStatus: ThreadListStateTypes.INVALID, + allThreadMessages: [], + }; + } + case actionTypes.GET_NEXT_MESSAGES_START: { + return { + ...state, + }; + } + case actionTypes.GET_NEXT_MESSAGES_SUCESS: { + const { threadedMessages } = action.payload; + return { + ...state, + hasMoreNext: threadedMessages.length === NEXT_THREADS_FETCH_SIZE, + allThreadMessages: [...state.allThreadMessages, ...threadedMessages], + }; + } + case actionTypes.GET_NEXT_MESSAGES_FAILURE: { + return { + ...state, + hasMoreNext: false, + }; + } + case actionTypes.GET_PREV_MESSAGES_START: { + return { + ...state, + }; + } + case actionTypes.GET_PREV_MESSAGES_SUCESS: { + const { threadedMessages } = action.payload; + return { + ...state, + hasMorePrev: threadedMessages.length === PREV_THREADS_FETCH_SIZE, + allThreadMessages: [...threadedMessages, ...state.allThreadMessages], + }; + } + case actionTypes.GET_PREV_MESSAGES_FAILURE: { + return { + ...state, + hasMorePrev: false, + }; + } + // event handlers - message status change + case actionTypes.ON_MESSAGE_RECEIVED: { + const { channel, message }: { channel: GroupChannel, message: UserMessage | FileMessage } = action.payload; + + if ( + state.currentChannel?.url !== channel?.url + || state.hasMoreNext + || message?.parentMessage?.messageId !== state?.parentMessage?.messageId + ) { + return state; + } + const isAlreadyReceived = state.allThreadMessages.findIndex((m) => ( + m.messageId === message.messageId + )) > -1; + return { + ...state, + parentMessage: state.parentMessage?.messageId === message?.messageId ? message : state.parentMessage, + allThreadMessages: isAlreadyReceived + ? state.allThreadMessages.map((m) => ( + m.messageId === message.messageId ? message : m + )) + : [ + ...state.allThreadMessages.filter((m) => (m as UserMessage | FileMessage)?.reqId !== message?.reqId), + message, + ], + }; + } + case actionTypes.ON_MESSAGE_UPDATED: { + const { channel, message } = action.payload; + if (state.currentChannel?.url !== channel?.url) { + return state; + } + return { + ...state, + parentMessage: state.parentMessage?.messageId === message?.messageId + ? message + : state.parentMessage, + allThreadMessages: state.allThreadMessages?.map((msg) => ( + (msg?.messageId === message?.messageId) ? message : msg + )), + }; + } + case actionTypes.ON_MESSAGE_DELETED: { + const { channel, messageId } = action.payload; + if (state.currentChannel?.url !== channel?.url) { + return state; + } + if (state?.parentMessage?.messageId === messageId) { + return { + ...state, + parentMessage: null, + parentMessageInfoStatus: ParentMessageInfoStateTypes.NIL, + allThreadMessages: [], + }; + } + return { + ...state, + allThreadMessages: state.allThreadMessages?.filter((msg) => ( + msg?.messageId !== messageId + )), + }; + } + case actionTypes.ON_MESSAGE_DELETED_BY_REQ_ID: { + return { + ...state, + allThreadMessages: state.allThreadMessages.filter((m) => ( + !compareIds((m as UserMessage | FileMessage).reqId, action.payload) + )), + }; + } + case actionTypes.ON_REACTION_UPDATED: { + const reactionEvent = action.payload?.reactionEvent as ReactionEvent; + if (state?.parentMessage?.messageId === reactionEvent?.messageId) { + state.parentMessage?.applyReactionEvent?.(reactionEvent); + } + return { + ...state, + allThreadMessages: state.allThreadMessages.map((m) => { + if (reactionEvent?.messageId === m?.messageId) { + m?.applyReactionEvent?.(reactionEvent); + return m; + } + return m; + }) + }; + } + // event handlers - user status change + case actionTypes.ON_USER_MUTED: { + const { channel, user } = action.payload; + if (state.currentChannel?.url !== channel?.url || state.currentUserId !== user?.userId) { + return state; + } + return { + ...state, + isMuted: true, + }; + } + case actionTypes.ON_USER_UNMUTED: { + const { channel, user } = action.payload; + if (state.currentChannel?.url !== channel?.url || state.currentUserId !== user?.userId) { + return state; + } + return { + ...state, + isMuted: false, + }; + } + case actionTypes.ON_USER_BANNED: { + return { + ...state, + channelStatus: ChannelStateTypes.NIL, + threadListStatus: ThreadListStateTypes.NIL, + parentMessageInfoStatus: ParentMessageInfoStateTypes.NIL, + currentChannel: null, + parentMessage: null, + allThreadMessages: [], + hasMorePrev: false, + hasMoreNext: false, + }; + } + case actionTypes.ON_USER_UNBANNED: { + return { + ...state, + }; + } + case actionTypes.ON_USER_LEFT: { + return { + ...state, + channelStatus: ChannelStateTypes.NIL, + threadListStatus: ThreadListStateTypes.NIL, + parentMessageInfoStatus: ParentMessageInfoStateTypes.NIL, + currentChannel: null, + parentMessage: null, + allThreadMessages: [], + hasMorePrev: false, + hasMoreNext: false, + }; + } + // event handler - channel status change + case actionTypes.ON_CHANNEL_FROZEN: { + return { + ...state, + isChannelFrozen: true, + }; + } + case actionTypes.ON_CHANNEL_UNFROZEN: { + return { + ...state, + isChannelFrozen: false, + }; + } + case actionTypes.ON_OPERATOR_UPDATED: { + const { channel } = action.payload; + if (channel?.url === state.currentChannel?.url) { + return { + ...state, + currentChannel: channel, + } + } + return state; + } + // message + case actionTypes.SEND_MESSAGE_START: { + const { message } = action.payload; + return { + ...state, + allThreadMessages: [ + ...state.allThreadMessages, + message, + ] + }; + } + case actionTypes.SEND_MESSAGE_SUCESS: { + const { message } = action.payload; + const filteredThreadMessages = state.allThreadMessages.filter((m) => ( + !compareIds((m as UserMessage)?.reqId, message?.reqId) + )); + return { + ...state, + allThreadMessages: [ + ...filteredThreadMessages, + message, + ], + }; + } + case actionTypes.SEND_MESSAGE_FAILURE: { + const { message } = action.payload; + return { + ...state, + allThreadMessages: state.allThreadMessages.map((m) => ( + compareIds((m as UserMessage)?.reqId, message?.reqId) + ? message + : m + )) + }; + } + case actionTypes.RESEND_MESSAGE_START: { + return { + ...state, + }; + } + default: { + return state; + } + } +} diff --git a/src/smart-components/Thread/context/hooks/useDeleteMessageCallback.ts b/src/smart-components/Thread/context/hooks/useDeleteMessageCallback.ts new file mode 100644 index 000000000..1b3dbcd95 --- /dev/null +++ b/src/smart-components/Thread/context/hooks/useDeleteMessageCallback.ts @@ -0,0 +1,52 @@ +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { FileMessage, UserMessage } from '@sendbird/chat/message'; +import { useCallback } from 'react'; +import { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; +import { ThreadContextActionTypes } from '../dux/actionTypes'; + +interface DynamicProps { + currentChannel: GroupChannel; + threadDispatcher: CustomUseReducerDispatcher; +} +interface StaticProps { + logger: Logger; +} + +export default function useDeleteMessageCallback({ + currentChannel, + threadDispatcher, +}: DynamicProps, { + logger, +}: StaticProps): (message: UserMessage | FileMessage) => Promise { + return useCallback((message: UserMessage | FileMessage): Promise => { + logger.info('Thread | useDeleteMessageCallback: Deleting message.', message); + const { sendingStatus } = message; + return new Promise((resolve, reject) => { + logger.info('Thread | useDeleteMessageCallback: Deleting message requestState:', sendingStatus); + // Message is only on local + if (sendingStatus === 'failed' || sendingStatus === 'pending') { + logger.info('Thread | useDeleteMessageCallback: Deleted message from local:', message); + threadDispatcher({ + type: ThreadContextActionTypes.ON_MESSAGE_DELETED_BY_REQ_ID, + payload: message.reqId, + }); + resolve(message); + } + + logger.info('Thread | useDeleteMessageCallback: Deleting message from remote:', sendingStatus); + currentChannel?.deleteMessage?.(message) + .then(() => { + logger.info('Thread | useDeleteMessageCallback: Deleting message success!', message); + threadDispatcher({ + type: ThreadContextActionTypes.ON_MESSAGE_DELETED, + payload: { message, channel: currentChannel }, + }); + resolve(message); + }) + .catch((err) => { + logger.warning('Thread | useDeleteMessageCallback: Deleting message failed!', err); + reject(err); + }); + }); + }, [currentChannel]); +} diff --git a/src/smart-components/Thread/context/hooks/useGetAllEmoji.ts b/src/smart-components/Thread/context/hooks/useGetAllEmoji.ts new file mode 100644 index 000000000..db6062bf4 --- /dev/null +++ b/src/smart-components/Thread/context/hooks/useGetAllEmoji.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import { SendbirdGroupChat } from "@sendbird/chat/groupChannel"; +import { CustomUseReducerDispatcher, Logger } from "../../../../lib/SendbirdState"; +import { ThreadContextActionTypes } from '../dux/actionTypes'; + +interface DanamicPrpos { + sdk: SendbirdGroupChat; +} +interface StaticProps { + logger: Logger; + threadDispatcher: CustomUseReducerDispatcher; +} + +export default function useGetAllEmoji({ + sdk, +}: DanamicPrpos, { + logger, + threadDispatcher, +}: StaticProps): void { + useEffect(() => { + if (sdk?.getAllEmoji) { // validation check + sdk?.getAllEmoji() + .then((emojiContainer) => { + logger.info('Thread | useGetAllEmoji: Getting emojis succeeded.', emojiContainer); + threadDispatcher({ + type: ThreadContextActionTypes.SET_EMOJI_CONTAINER, + payload: { emojiContainer }, + }); + }) + .catch((error) => { + logger.info('Thread | useGetAllEmoji: Getting emojis failed.', error); + }); + } + }, [sdk]); +} diff --git a/src/smart-components/Thread/context/hooks/useGetChannel.ts b/src/smart-components/Thread/context/hooks/useGetChannel.ts new file mode 100644 index 000000000..4b37162ba --- /dev/null +++ b/src/smart-components/Thread/context/hooks/useGetChannel.ts @@ -0,0 +1,57 @@ +import { useEffect } from 'react'; +import { SendbirdGroupChat } from '@sendbird/chat/groupChannel'; + +import { Logger } from '../../../../lib/SendbirdState'; +import { ThreadContextActionTypes } from '../dux/actionTypes'; +import { FileMessage, UserMessage } from '@sendbird/chat/message'; + +interface DynamicProps { + channelUrl: string; + sdkInit: boolean; + message: UserMessage | FileMessage; +} + +interface StaticProps { + sdk: SendbirdGroupChat; + logger: Logger; + threadDispatcher: (props: { type: string, payload?: any }) => void; +} + +export default function useGetChannel({ + channelUrl, + sdkInit, + message, +}: DynamicProps, { + sdk, + logger, + threadDispatcher, +}: StaticProps): void { + useEffect(() => { + // validation check + if (sdkInit && channelUrl && sdk?.groupChannel) { + threadDispatcher({ + type: ThreadContextActionTypes.GET_CHANNEL_START, + payload: null, + }); + sdk.groupChannel.getChannel?.(channelUrl) + .then((groupChannel) => { + logger.info('Thread | useInitialize: Get channel succeeded', groupChannel); + threadDispatcher({ + type: ThreadContextActionTypes.GET_CHANNEL_SUCCESS, + payload: { groupChannel }, + }); + }) + .catch((error) => { + logger.info('Thread | useInitialize: Get channel failed', error); + threadDispatcher({ + type: ThreadContextActionTypes.GET_CHANNEL_FAILURE, + payload: error, + }); + }); + } + }, [message, sdkInit]); + /** + * We don't use channelUrl here, + * because Thread must operate independently of the channel. + */ +} diff --git a/src/smart-components/Thread/context/hooks/useGetNextThreadsCallback.ts b/src/smart-components/Thread/context/hooks/useGetNextThreadsCallback.ts new file mode 100644 index 000000000..436a0fb34 --- /dev/null +++ b/src/smart-components/Thread/context/hooks/useGetNextThreadsCallback.ts @@ -0,0 +1,70 @@ +import { BaseMessage, FileMessage, ThreadedMessageListParams, UserMessage } from "@sendbird/chat/message"; +import { useCallback } from "react"; +import { CustomUseReducerDispatcher, Logger } from "../../../../lib/SendbirdState"; +import { NEXT_THREADS_FETCH_SIZE } from "../../consts"; +import { ThreadListStateTypes } from "../../types"; +import { ThreadContextActionTypes } from "../dux/actionTypes"; + +interface DynamicProps { + hasMoreNext: boolean; + parentMessage: UserMessage | FileMessage; + threadListStatus: ThreadListStateTypes; + latestMessageTimeStamp: number; + isReactionEnabled?: boolean; +} +interface StaticProps { + logger: Logger; + threadDispatcher: CustomUseReducerDispatcher; +} + +export default function useGetNextThreadsCallback({ + hasMoreNext, + parentMessage, + threadListStatus, + latestMessageTimeStamp, + isReactionEnabled, +}: DynamicProps, { + logger, + threadDispatcher, +}: StaticProps): (callback: (messages?: Array) => void) => void { + return useCallback((callback) => { + // validation check + if (threadListStatus === ThreadListStateTypes.INITIALIZED + && parentMessage?.getThreadedMessagesByTimestamp + && latestMessageTimeStamp !== 0 + ) { + threadDispatcher({ + type: ThreadContextActionTypes.GET_NEXT_MESSAGES_START, + payload: null, + }); + parentMessage.getThreadedMessagesByTimestamp?.( + latestMessageTimeStamp, + { + prevResultSize: 0, + nextResultSize: NEXT_THREADS_FETCH_SIZE, + includeReactions: isReactionEnabled, + } as ThreadedMessageListParams, + ) + .then(({ parentMessage, threadedMessages }) => { + logger.info('Thread | useGetNextThreadsCallback: Fetch next threads succeeded.', { parentMessage, threadedMessages }) + threadDispatcher({ + type: ThreadContextActionTypes.GET_NEXT_MESSAGES_SUCESS, + payload: { parentMessage, threadedMessages }, + }); + callback(threadedMessages); + }) + .catch((error) => { + logger.info('Thread | useGetNextThreadsCallback: Fetch next threads failed.', error); + threadDispatcher({ + type: ThreadContextActionTypes.GET_NEXT_MESSAGES_FAILURE, + payload: error, + }); + }); + } + }, [ + hasMoreNext, + parentMessage, + threadListStatus, + latestMessageTimeStamp, + ]); +} diff --git a/src/smart-components/Thread/context/hooks/useGetParentMessage.ts b/src/smart-components/Thread/context/hooks/useGetParentMessage.ts new file mode 100644 index 000000000..3aeab4c2d --- /dev/null +++ b/src/smart-components/Thread/context/hooks/useGetParentMessage.ts @@ -0,0 +1,76 @@ +import { useEffect } from 'react'; +import { SendbirdGroupChat } from '@sendbird/chat/groupChannel'; + +import { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; +import { BaseMessage, MessageRetrievalParams } from '@sendbird/chat/message'; +import { ThreadContextActionTypes } from '../dux/actionTypes'; +import { ChannelType } from '@sendbird/chat'; + +interface DynamicProps { + channelUrl: string; + parentMessageId: number; + sdkInit: boolean; + parentMessage?: BaseMessage; +} + +interface StaticProps { + sdk: SendbirdGroupChat; + logger: Logger; + threadDispatcher: CustomUseReducerDispatcher; +} + +export default function useGetParentMessage({ + channelUrl, + parentMessageId, + sdkInit, + parentMessage, +}: DynamicProps, { + sdk, + logger, + threadDispatcher, +}: StaticProps): void { + useEffect(() => { + // validation check + if (sdkInit && sdk?.message?.getMessage) { + threadDispatcher({ + type: ThreadContextActionTypes.GET_PARENT_MESSAGE_START, + payload: null, + }); + const params: MessageRetrievalParams = { + channelUrl, + channelType: ChannelType.GROUP, + messageId: parentMessageId, + includeMetaArray: true, + includeReactions: true, + includeThreadInfo: true, + includePollDetails: true, + includeParentMessageInfo: true, + }; + logger.info('Thread | useGetParentMessage: Get parent message start.', params); + const fetchParentMessage = async () => { + const data = await sdk.message.getMessage?.(params); + return data; + } + fetchParentMessage() + .then((parentMsg) => { + logger.info('Thread | useGetParentMessage: Get parent message succeeded.', parentMessage); + parentMsg.ogMetaData = parentMessage.ogMetaData;// ogMetaData is not included for now + threadDispatcher({ + type: ThreadContextActionTypes.GET_PARENT_MESSAGE_SUCCESS, + payload: { parentMessage: parentMsg }, + }); + }) + .catch((error) => { + logger.info('Thread | useGetParentMessage: Get parent message failed.', error); + threadDispatcher({ + type: ThreadContextActionTypes.GET_PARENT_MESSAGE_FAILURE, + payload: error, + }); + }); + } + }, [sdkInit, parentMessageId]); + /** + * We don't use channelUrl here, + * because Thread must operate independently of the channel. + */ +} diff --git a/src/smart-components/Thread/context/hooks/useGetPrevThreadsCallback.ts b/src/smart-components/Thread/context/hooks/useGetPrevThreadsCallback.ts new file mode 100644 index 000000000..668977f06 --- /dev/null +++ b/src/smart-components/Thread/context/hooks/useGetPrevThreadsCallback.ts @@ -0,0 +1,71 @@ +import { useCallback } from "react"; +import { BaseMessage, FileMessage, ThreadedMessageListParams, UserMessage } from "@sendbird/chat/message"; + +import { CustomUseReducerDispatcher, Logger } from "../../../../lib/SendbirdState"; +import { PREV_THREADS_FETCH_SIZE } from "../../consts"; +import { ThreadListStateTypes } from "../../types"; +import { ThreadContextActionTypes } from "../dux/actionTypes"; + +interface DynamicProps { + hasMorePrev: boolean; + parentMessage: UserMessage | FileMessage; + threadListStatus: ThreadListStateTypes; + oldestMessageTimeStamp: number; + isReactionEnabled?: boolean; +} +interface StaticProps { + logger: Logger; + threadDispatcher: CustomUseReducerDispatcher; +} + +export default function useGetPrevThreadsCallback({ + hasMorePrev, + parentMessage, + threadListStatus, + oldestMessageTimeStamp, + isReactionEnabled, +}: DynamicProps, { + logger, + threadDispatcher, +}: StaticProps): (callback?: (messages?: Array) => void) => void { + return useCallback((callback) => { + // validation check + if (threadListStatus === ThreadListStateTypes.INITIALIZED + && parentMessage?.getThreadedMessagesByTimestamp + && oldestMessageTimeStamp !== 0 + ) { + threadDispatcher({ + type: ThreadContextActionTypes.GET_PREV_MESSAGES_START, + payload: null, + }); + parentMessage.getThreadedMessagesByTimestamp?.( + oldestMessageTimeStamp, + { + prevResultSize: PREV_THREADS_FETCH_SIZE, + nextResultSize: 0, + includeReactions: isReactionEnabled, + } as ThreadedMessageListParams, + ) + .then(({ parentMessage, threadedMessages }) => { + logger.info('Thread | useGetPrevThreadsCallback: Fetch prev threads succeeded.', { parentMessage, threadedMessages }); + threadDispatcher({ + type: ThreadContextActionTypes.GET_PREV_MESSAGES_SUCESS, + payload: { parentMessage, threadedMessages }, + }); + callback(threadedMessages); + }) + .catch((error) => { + logger.info('Thread | useGetPrevThreadsCallback: Fetch prev threads failed.', error); + threadDispatcher({ + type: ThreadContextActionTypes.GET_PREV_MESSAGES_FAILURE, + payload: error, + }); + }); + } + }, [ + hasMorePrev, + parentMessage, + threadListStatus, + oldestMessageTimeStamp, + ]); +} diff --git a/src/smart-components/Thread/context/hooks/useGetThreadList.ts b/src/smart-components/Thread/context/hooks/useGetThreadList.ts new file mode 100644 index 000000000..b0362333d --- /dev/null +++ b/src/smart-components/Thread/context/hooks/useGetThreadList.ts @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; +import { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; +import { FileMessage, ThreadedMessageListParams, UserMessage } from '@sendbird/chat/message'; +import { ThreadContextActionTypes } from '../dux/actionTypes'; +import { NEXT_THREADS_FETCH_SIZE, PREV_THREADS_FETCH_SIZE } from '../../consts'; + +interface DynamicProps { + sdkInit: boolean; + parentMessage: UserMessage | FileMessage; + anchorMessage?: UserMessage | FileMessage; + isReactionEnabled?: boolean; +} + +interface StaticProps { + logger: Logger; + threadDispatcher: CustomUseReducerDispatcher; +} + +export default function useGetThreadList({ + sdkInit, + parentMessage, + anchorMessage, + isReactionEnabled, +}: DynamicProps, { + logger, + threadDispatcher, +}: StaticProps): void { + useEffect(() => { + // validation check + if (sdkInit && parentMessage?.getThreadedMessagesByTimestamp) { + threadDispatcher({ + type: ThreadContextActionTypes.INITIALIZE_THREAD_LIST_START, + payload: null, + }); + const timeStamp = anchorMessage?.createdAt || 0; + const params = { + prevResultSize: PREV_THREADS_FETCH_SIZE, + nextResultSize: NEXT_THREADS_FETCH_SIZE, + includeReactions: isReactionEnabled, + } as ThreadedMessageListParams; + logger.info('Thread | useGetThreadList: Initialize thread list start.', { timeStamp, params }); + parentMessage.getThreadedMessagesByTimestamp?.(timeStamp, params) + .then(({ parentMessage, threadedMessages }) => { + logger.info('Thread | useGetThreadList: Initialize thread list succeeded.', { parentMessage, threadedMessages }); + threadDispatcher({ + type: ThreadContextActionTypes.INITIALIZE_THREAD_LIST_SUCCESS, + payload: { + parentMessage, + anchorMessage, + threadedMessages, + }, + }); + }) + .catch((error) => { + logger.info('Therad | useGetThreadList: Initialize thread list failed.', error); + threadDispatcher({ + type: ThreadContextActionTypes.INITIALIZE_THREAD_LIST_FAILURE, + payload: error, + }); + }); + } + }, [sdkInit, parentMessage, anchorMessage]); +} diff --git a/src/smart-components/Thread/context/hooks/useHandleChannelEvents.ts b/src/smart-components/Thread/context/hooks/useHandleChannelEvents.ts new file mode 100644 index 000000000..3792d0dcc --- /dev/null +++ b/src/smart-components/Thread/context/hooks/useHandleChannelEvents.ts @@ -0,0 +1,134 @@ +import { GroupChannel, GroupChannelHandler, SendbirdGroupChat } from "@sendbird/chat/groupChannel"; +import { useEffect } from "react"; +import { CustomUseReducerDispatcher, Logger } from "../../../../lib/SendbirdState"; +import uuidv4 from "../../../../utils/uuid"; +import { ThreadContextActionTypes } from "../dux/actionTypes"; + + +interface DynamicProps { + sdk: SendbirdGroupChat; + currentChannel: GroupChannel; +} +interface StaticProps { + logger: Logger; + threadDispatcher: CustomUseReducerDispatcher; +} + +export default function useHandleChannelEvents({ + sdk, + currentChannel, +}: DynamicProps, { + logger, + threadDispatcher, +}: StaticProps): void { + useEffect(() => { + const handlerId = uuidv4(); + // validation check + if (sdk?.groupChannel?.addGroupChannelHandler + && currentChannel + ) { + const channelHandlerParams: GroupChannelHandler = { + // message status change + onMessageReceived(channel, message) { + logger.info('Thread | useHandleChannelEvents: onMessageReceived', { channel, message }); + threadDispatcher({ + type: ThreadContextActionTypes.ON_MESSAGE_RECEIVED, + payload: { channel, message }, + }); + }, + onMessageUpdated(channel, message) { + logger.info('Thread | useHandleChannelEvents: onMessageUpdated', { channel, message }); + threadDispatcher({ + type: ThreadContextActionTypes.ON_MESSAGE_UPDATED, + payload: { channel, message }, + }); + }, + onMessageDeleted(channel, messageId) { + logger.info('Thread | useHandleChannelEvents: onMessageDeleted', { channel, messageId }); + threadDispatcher({ + type: ThreadContextActionTypes.ON_MESSAGE_DELETED, + payload: { channel, messageId }, + }); + }, + onReactionUpdated(channel, reactionEvent) { + logger.info('Thread | useHandleChannelEvents: onReactionUpdated', { channel, reactionEvent }); + threadDispatcher({ + type: ThreadContextActionTypes.ON_REACTION_UPDATED, + payload: { channel, reactionEvent }, + }); + }, + // user status change + onUserMuted(channel, user) { + logger.info('Thread | useHandleChannelEvents: onUserMuted', { channel, user }); + threadDispatcher({ + type: ThreadContextActionTypes.ON_USER_MUTED, + payload: { channel, user }, + }); + }, + onUserUnmuted(channel, user) { + logger.info('Thread | useHandleChannelEvents: onUserUnmuted', { channel, user }); + threadDispatcher({ + type: ThreadContextActionTypes.ON_USER_UNMUTED, + payload: { channel, user }, + }); + }, + onUserBanned(channel, user) { + logger.info('Thread | useHandleChannelEvents: onUserBanned', { channel, user }); + threadDispatcher({ + type: ThreadContextActionTypes.ON_USER_BANNED, + payload: { channel, user }, + }); + }, + onUserUnbanned(channel, user) { + logger.info('Thread | useHandleChannelEvents: onUserUnbanned', { channel, user }); + threadDispatcher({ + type: ThreadContextActionTypes.ON_USER_UNBANNED, + payload: { channel, user }, + }); + }, + onUserLeft(channel, user) { + logger.info('Thread | useHandleChannelEvents: onUserLeft', { channel, user }); + threadDispatcher({ + type: ThreadContextActionTypes.ON_USER_LEFT, + payload: { channel, user }, + }); + }, + // channel status change + onChannelFrozen(channel) { + logger.info('Thread | useHandleChannelEvents: onChannelFrozen', { channel }); + threadDispatcher({ + type: ThreadContextActionTypes.ON_CHANNEL_FROZEN, + payload: { channel }, + }); + }, + onChannelUnfrozen(channel) { + logger.info('Thread | useHandleChannelEvents: onChannelUnfrozen', { channel }); + threadDispatcher({ + type: ThreadContextActionTypes.ON_CHANNEL_UNFROZEN, + payload: { channel }, + }); + }, + onOperatorUpdated(channel, users) { + logger.info('Thread | useHandleChannelEvents: onOperatorUpdated', { channel, users }); + threadDispatcher({ + type: ThreadContextActionTypes.ON_OPERATOR_UPDATED, + payload: { channel, users }, + }); + }, + }; + const channelHandler = new GroupChannelHandler(channelHandlerParams); + sdk.groupChannel.addGroupChannelHandler?.(handlerId, channelHandler); + logger.info('Thread | useHandleChannelEvents: Added channelHandler in Thread', { handlerId, channelHandler }); + } + return () => { + // validation check + if (handlerId && sdk?.groupChannel?.removeGroupChannelHandler) { + sdk.groupChannel.removeGroupChannelHandler?.(handlerId); + logger.info('Thread | useHandleChannelEvents: Removed channelHandler in Thread.', handlerId); + } + }; + }, [ + sdk?.groupChannel, + currentChannel, + ]); +} diff --git a/src/smart-components/Thread/context/hooks/useHandlePubsubEvents.ts b/src/smart-components/Thread/context/hooks/useHandlePubsubEvents.ts new file mode 100644 index 000000000..5ae408ec0 --- /dev/null +++ b/src/smart-components/Thread/context/hooks/useHandlePubsubEvents.ts @@ -0,0 +1,87 @@ +import { useEffect } from "react"; +import { GroupChannel } from "@sendbird/chat/groupChannel"; +import { FileMessage, UserMessage } from "@sendbird/chat/message"; + +import { CustomUseReducerDispatcher, Logger } from "../../../../lib/SendbirdState"; +import * as topics from '../../../../lib/pubSub/topics'; +import { scrollIntoLast } from "../utils"; +import { ThreadContextActionTypes } from "../dux/actionTypes"; + +interface DynamicProps { + sdkInit: boolean; + currentChannel: GroupChannel; + parentMessage: UserMessage | FileMessage +} +interface StaticProps { + logger: Logger; + pubSub: any; + threadDispatcher: CustomUseReducerDispatcher; +} + +export default function useHandlePubsubEvents({ + sdkInit, + currentChannel, + parentMessage, +}: DynamicProps, { + pubSub, + threadDispatcher, +}: StaticProps): void { + useEffect(() => { + const pubSubHandler = (): Map => { + const subscriber = new Map(); + if (!pubSub || !pubSub.subscribe) { + return subscriber; + } + subscriber.set(topics.SEND_USER_MESSAGE, pubSub.subscribe(topics.SEND_USER_MESSAGE, (props) => { + const { channel, message } = props; + if (currentChannel?.url === channel?.url + && message?.parentMessageId === parentMessage?.messageId + ) { + threadDispatcher({ + type: ThreadContextActionTypes.SEND_MESSAGE_SUCESS, + payload: { message }, + }); + } + scrollIntoLast?.(); + })); + subscriber.set(topics.SEND_FILE_MESSAGE, pubSub.subscribe(topics.SEND_FILE_MESSAGE, (props) => { + const { channel, message } = props; + if (currentChannel?.url === channel?.url) { + threadDispatcher({ + type: ThreadContextActionTypes.SEND_MESSAGE_SUCESS, + payload: { message }, + }); + } + scrollIntoLast?.(); + })); + subscriber.set(topics.UPDATE_USER_MESSAGE, pubSub.subscribe(topics.UPDATE_USER_MESSAGE, (msg) => { + const { channel, message } = msg; + if (currentChannel?.url === channel?.url) { + threadDispatcher({ + type: ThreadContextActionTypes.ON_MESSAGE_UPDATED, + payload: { channel, message }, + }); + } + })); + subscriber.set(topics.DELETE_MESSAGE, pubSub.subscribe(topics.DELETE_MESSAGE, (msg) => { + const { channel, messageId } = msg; + if (currentChannel?.url === channel?.url) { + threadDispatcher({ + type: ThreadContextActionTypes.ON_MESSAGE_DELETED, + payload: { messageId }, + }); + } + })); + }; + const subscriber = pubSubHandler(); + return () => { + subscriber?.forEach((s) => { + try { + s?.remove(); + } catch { + // + } + }); + } + }, [sdkInit, currentChannel]); +} diff --git a/src/smart-components/Thread/context/hooks/useResendMessageCallback.ts b/src/smart-components/Thread/context/hooks/useResendMessageCallback.ts new file mode 100644 index 000000000..1ed2749e0 --- /dev/null +++ b/src/smart-components/Thread/context/hooks/useResendMessageCallback.ts @@ -0,0 +1,85 @@ +import { GroupChannel } from "@sendbird/chat/groupChannel"; +import { FileMessage, MessageType, SendingStatus, UserMessage } from "@sendbird/chat/message"; +import { useCallback } from "react"; +import { CustomUseReducerDispatcher, Logger } from "../../../../lib/SendbirdState"; +import { ThreadContextActionTypes } from "../dux/actionTypes"; +import * as topics from '../../../../lib/pubSub/topics'; + +interface DynamicProps { + currentChannel: GroupChannel; +} +interface StaticProps { + logger: Logger; + pubSub: any; + threadDispatcher: CustomUseReducerDispatcher; +} + +export default function useResendMessageCallback({ + currentChannel, +}: DynamicProps, { + logger, + pubSub, + threadDispatcher, +}: StaticProps): (failedMessage: UserMessage | FileMessage) => void { + return useCallback((failedMessage: UserMessage | FileMessage) => { + if ((failedMessage as UserMessage | FileMessage)?.isResendable) { + failedMessage.sendingStatus = SendingStatus.PENDING; + logger.info('Thread | useResendMessageCallback: Resending failedMessage start.', failedMessage); + threadDispatcher({ + type: ThreadContextActionTypes.RESEND_MESSAGE_START, + payload: failedMessage, + }); + + if (failedMessage?.isUserMessage?.() || failedMessage?.messageType === MessageType.USER) { + currentChannel?.resendUserMessage(failedMessage as UserMessage) + .then((message) => { + logger.info('Thread | useResendMessageCallback: Resending failedMessage succeeded.', message); + threadDispatcher({ + type: ThreadContextActionTypes.SEND_MESSAGE_SUCESS, + payload: { message }, + }); + pubSub.publish(topics.SEND_USER_MESSAGE, { + channel: currentChannel, + message: message, + }); + }) + .catch((error) => { + logger.warning('Thread | useResendMessageCallback: Resending failedMessage failed.', error); + failedMessage.sendingStatus = SendingStatus.FAILED; + threadDispatcher({ + type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, + payload: { message: failedMessage }, + }); + }); + } else if (failedMessage?.isFileMessage?.() || failedMessage?.messageType === MessageType.FILE) { + currentChannel?.resendFileMessage?.(failedMessage as FileMessage) + .then((message) => { + logger.info('Thread | useResendMessageCallback: Resending failedMessage succeeded.', message); + threadDispatcher({ + type: ThreadContextActionTypes.SEND_MESSAGE_SUCESS, + payload: { message }, + }); + }) + .catch((error) => { + logger.warning('Thread | useResendMessageCallback: Resending failedMessage failed.', error); + failedMessage.sendingStatus = SendingStatus.FAILED; + threadDispatcher({ + type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, + payload: { message: failedMessage }, + }); + pubSub.publish(topics.SEND_FILE_MESSAGE, { + channel: currentChannel, + message: failedMessage, + }); + }); + } else { + logger.warning('Thread | useResendMessageCallback: Message is not resendable.', failedMessage); + failedMessage.sendingStatus = SendingStatus.FAILED; + threadDispatcher({ + type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, + payload: { message: failedMessage }, + }); + } + } + }, [currentChannel]); +} diff --git a/src/smart-components/Thread/context/hooks/useSendFileMessage.ts b/src/smart-components/Thread/context/hooks/useSendFileMessage.ts new file mode 100644 index 000000000..6dba28e07 --- /dev/null +++ b/src/smart-components/Thread/context/hooks/useSendFileMessage.ts @@ -0,0 +1,79 @@ +import { useCallback } from "react"; +import { GroupChannel } from "@sendbird/chat/groupChannel"; +import { FileMessage, FileMessageCreateParams } from "@sendbird/chat/message"; + +import { CustomUseReducerDispatcher, Logger } from "../../../../lib/SendbirdState"; +import { ThreadContextActionTypes } from "../dux/actionTypes"; +import * as topics from '../../../../lib/pubSub/topics'; +import { scrollIntoLast } from "../utils"; + +interface DynamicProps { + currentChannel: GroupChannel; +} +interface StaticProps { + logger: Logger; + pubSub: any; + threadDispatcher: CustomUseReducerDispatcher; +} + +interface LocalFileMessage extends FileMessage { + localUrl: string; + file: File; +} + +export default function useSendFileMessageCallback({ + currentChannel, +}: DynamicProps, { + logger, + pubSub, + threadDispatcher, +}: StaticProps): (file, quoteMessage) => void { + const sendMessage = useCallback((file, quoteMessage) => { + const createParamsDefault = () => { + const params = {} as FileMessageCreateParams; + params.file = file; + if (quoteMessage) { + params.isReplyToChannel = true; + params.parentMessageId = quoteMessage.messageId; + } + return params; + }; + const params = createParamsDefault(); + logger.info('Thread | useSendFileMessageCallback: Sending file message start.', params); + + currentChannel?.sendFileMessage(params) + .onPending((pendingMessage) => { + threadDispatcher({ + type: ThreadContextActionTypes.SEND_MESSAGE_START, + payload: { + /* pubSub is used instead of messagesDispatcher + to avoid redundantly calling `messageActionTypes.SEND_MESSAGEGE_START` */ + message: { + ...pendingMessage, + url: URL.createObjectURL(file), + // pending thumbnail message seems to be failed + requestState: 'pending', + }, + }, + }); + setTimeout(() => scrollIntoLast(), 1000); + }) + .onFailed((error, message) => { + (message as LocalFileMessage).localUrl = URL.createObjectURL(file); + (message as LocalFileMessage).file = file; + logger.info('Thread | useSendFileMessageCallback: Sending file message failed.', { message, error }); + threadDispatcher({ + type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, + payload: { message, error }, + }); + }) + .onSucceeded((message) => { + logger.info('Thread | useSendFileMessageCallback: Sending file message succeeded.', message); + pubSub.publish(topics.SEND_FILE_MESSAGE, { + channel: currentChannel, + message: message, + }); + }); + }, [currentChannel]); + return sendMessage; +} diff --git a/src/smart-components/Thread/context/hooks/useSendUserMessageCallback.ts b/src/smart-components/Thread/context/hooks/useSendUserMessageCallback.ts new file mode 100644 index 000000000..55d4beafc --- /dev/null +++ b/src/smart-components/Thread/context/hooks/useSendUserMessageCallback.ts @@ -0,0 +1,83 @@ +import { useCallback } from 'react'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { UserMessageCreateParams } from '@sendbird/chat/message'; + +import { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; +import { ThreadContextActionTypes } from '../dux/actionTypes'; +import * as topics from '../../../../lib/pubSub/topics'; + +interface DynamicProps { + isMentionEnabled: boolean; + currentChannel: GroupChannel; +} +interface StaticProps { + logger: Logger; + pubSub: any; + threadDispatcher: CustomUseReducerDispatcher; +} + +export default function useSendUserMessageCallback({ + isMentionEnabled, + currentChannel, +}: DynamicProps, { + logger, + pubSub, + threadDispatcher, +}: StaticProps): (props) => void { + const sendMessage = useCallback((props) => { + const { + message, + quoteMessage = null, + mentionTemplate, + mentionedUsers, + } = props; + const createDefaultParams = () => { + const params = {} as UserMessageCreateParams; + params.message = message?.trim() || message; + if (isMentionEnabled && mentionedUsers?.length > 0) { + params.mentionedUsers = mentionedUsers; + } + if (isMentionEnabled && mentionTemplate && mentionedUsers?.length > 0) { + params.mentionedMessageTemplate = mentionTemplate?.trim() || mentionTemplate; + } + if (quoteMessage) { + params.isReplyToChannel = true; + params.parentMessageId = quoteMessage.messageId; + } + return params; + } + + const params = createDefaultParams(); + logger.info('Thread | useSendUserMessageCallback: Sending user message start.', params); + + if (currentChannel?.sendUserMessage) { + currentChannel?.sendUserMessage(params) + .onPending((pendingMessage) => { + threadDispatcher({ + type: ThreadContextActionTypes.SEND_MESSAGE_START, + payload: { message: pendingMessage }, + }); + }) + .onFailed((error, message) => { + logger.info('Thread | useSendUserMessageCallback: Sending user message failed.', { message, error }); + threadDispatcher({ + type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, + payload: { error, message }, + }); + }) + .onSucceeded((message) => { + logger.info('Thread | useSendUserMessageCallback: Sending user message succeeded.', message); + threadDispatcher({ + type: ThreadContextActionTypes.SEND_MESSAGE_SUCESS, + payload: { message }, + }); + // because Thread doesn't subscribe SEND_USER_MESSAGE + pubSub.publish(topics.SEND_USER_MESSAGE, { + channel: currentChannel, + message: message, + }); + }); + } + }, [isMentionEnabled, currentChannel]); + return sendMessage; +} diff --git a/src/smart-components/Thread/context/hooks/useToggleReactionsCallback.ts b/src/smart-components/Thread/context/hooks/useToggleReactionsCallback.ts new file mode 100644 index 000000000..cba593dde --- /dev/null +++ b/src/smart-components/Thread/context/hooks/useToggleReactionsCallback.ts @@ -0,0 +1,36 @@ +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { useCallback } from 'react'; +import { Logger } from '../../../../lib/SendbirdState'; + +interface DynamicProps { + currentChannel: GroupChannel; +} +interface StaticProps { + logger: Logger; +} + +export default function useToggleReactionCallback({ + currentChannel, +}: DynamicProps, { + logger +}: StaticProps): (message, key, isReacted) => void { + return useCallback((message, key, isReacted) => { + if (isReacted) { + currentChannel?.deleteReaction?.(message, key) + .then((res) => { + logger.info('Thread | useToggleReactionsCallback: Delete reaction succeeded.', res); + }) + .catch((err) => { + logger.warning('Thread | useToggleReactionsCallback: Delete reaction failed.', err); + }); + return; + } + currentChannel?.addReaction?.(message, key) + .then((res) => { + logger.info('Thread | useToggleReactionsCallback: Add reaction succeeded.', res); + }) + .catch((err) => { + logger.warning('Thread | useToggleReactionsCallback: Add reaction failed.', err); + }); + }, [currentChannel]); +} diff --git a/src/smart-components/Thread/context/hooks/useUpdateMessageCallback.ts b/src/smart-components/Thread/context/hooks/useUpdateMessageCallback.ts new file mode 100644 index 000000000..d3a18c247 --- /dev/null +++ b/src/smart-components/Thread/context/hooks/useUpdateMessageCallback.ts @@ -0,0 +1,72 @@ +import { useCallback } from "react"; +import { GroupChannel } from "@sendbird/chat/groupChannel"; +import { UserMessage, UserMessageUpdateParams } from "@sendbird/chat/message"; + +import { CustomUseReducerDispatcher, Logger } from "../../../../lib/SendbirdState"; +import { ThreadContextActionTypes } from "../dux/actionTypes"; + +import * as topics from '../../../../lib/pubSub/topics'; + +interface DynamicProps { + currentChannel: GroupChannel; + isMentionEnabled?: boolean; +} +interface StaticProps { + logger: Logger; + pubSub: any; + threadDispatcher: CustomUseReducerDispatcher; +} + +export default function useUpdateMessageCallback({ + currentChannel, + isMentionEnabled, +}: DynamicProps, { + logger, + pubSub, + threadDispatcher, +}: StaticProps): (props) => void { + return useCallback((props) => { + const { + messageId, + message, + mentionedUsers, + mentionTemplate, + } = props; + const createParamsDefault = () => { + const params = {} as UserMessageUpdateParams; + params.message = message; + if (isMentionEnabled && mentionedUsers?.length > 0) { + params.mentionedUsers = mentionedUsers; + } + if (isMentionEnabled && mentionTemplate) { + params.mentionedMessageTemplate = mentionTemplate; + } else { + params.mentionedMessageTemplate = message; + } + return params; + }; + + const params = createParamsDefault(); + logger.info('Thread | useUpdateMessageCallback: Message update start.', params); + + currentChannel?.updateUserMessage?.(messageId, params) + .then((message: UserMessage) => { + logger.info('Thread | useUpdateMessageCallback: Message update succeeded.', message); + threadDispatcher({ + type: ThreadContextActionTypes.ON_MESSAGE_UPDATED, + payload: { + channel: currentChannel, + message: message, + }, + }); + pubSub.publish( + topics.UPDATE_USER_MESSAGE, + { + fromSelector: true, + channel: currentChannel, + message: message, + }, + ); + }); + }, [currentChannel, isMentionEnabled]) +} diff --git a/src/smart-components/Thread/context/utils.ts b/src/smart-components/Thread/context/utils.ts new file mode 100644 index 000000000..f7548d3fd --- /dev/null +++ b/src/smart-components/Thread/context/utils.ts @@ -0,0 +1,95 @@ +import format from 'date-fns/format'; +import { GroupChannel } from "@sendbird/chat/groupChannel"; +import { FileMessage, UserMessage } from "@sendbird/chat/message"; +import { getOutgoingMessageState, OutgoingMessageStates } from "../../../utils/exports/getOutgoingMessageState"; + +export const getNicknamesMapFromMembers = (members = []): Map => { + const nicknamesMap = new Map(); + for (let memberIndex = 0; memberIndex < members.length; memberIndex += 1) { + const { userId, nickname } = members[memberIndex]; + nicknamesMap.set(userId, nickname); + } + return nicknamesMap; +}; + +export const isAboutSame = (a: number, b: number, px: number): boolean => (Math.abs(a - b) <= px); + +export const isEmpty = (val: unknown): boolean => (val === null || val === undefined); + +// Some Ids return string and number inconsistently +// only use to comapre IDs +export function compareIds(a: number | string, b: number | string): boolean { + if (isEmpty(a) || isEmpty(b)) { + return false; + } + const aString = a.toString(); + const bString = b.toString(); + return aString === bString; +} + +export const getMessageCreatedAt = (message: UserMessage | FileMessage): string => format(message.createdAt, 'p'); +export const isReadMessage = (channel: GroupChannel, message: UserMessage | FileMessage): boolean => ( + getOutgoingMessageState(channel, message) === OutgoingMessageStates.READ +); +export const isSameGroup = ( + message: UserMessage | FileMessage, + comparingMessage: UserMessage | FileMessage, + currentChannel: GroupChannel, +): boolean => { + if (!(message + && comparingMessage + && message.messageType + && message.messageType !== 'admin' + && comparingMessage.messageType + && comparingMessage?.messageType !== 'admin' + && message?.sender + && comparingMessage?.sender + && message?.createdAt + && comparingMessage?.createdAt + && message?.sender?.userId + && comparingMessage?.sender?.userId + )) { + return false; + } + return ( + message?.sendingStatus === comparingMessage?.sendingStatus + && message?.sender?.userId === comparingMessage?.sender?.userId + && getMessageCreatedAt(message) === getMessageCreatedAt(comparingMessage) + && isReadMessage(currentChannel, message) === isReadMessage(currentChannel, comparingMessage) + ); +}; + +export const compareMessagesForGrouping = ( + prevMessage: UserMessage | FileMessage, + currMessage: UserMessage | FileMessage, + nextMessage: UserMessage | FileMessage, + currentChannel: GroupChannel, + replyType: string, +): [boolean, boolean] => { + if (replyType === 'THREAD' && currMessage?.threadInfo) { + return [false, false]; + } + const sendingStatus = currMessage?.sendingStatus || ''; + const isAcceptable = sendingStatus !== 'pending' && sendingStatus !== 'failed'; + return [ + isSameGroup(prevMessage, currMessage, currentChannel) && isAcceptable, + isSameGroup(currMessage, nextMessage, currentChannel) && isAcceptable, + ]; +}; + +export const scrollIntoLast = (intialTry = 0): void => { + const MAX_TRIES = 10; + const currentTry = intialTry; + if (currentTry > MAX_TRIES) { + return; + } + try { + const scrollDOM = document.querySelector('.sendbird-thread-ui--scroll'); + // eslint-disable-next-line no-multi-assign + scrollDOM.scrollTop = scrollDOM.scrollHeight; + } catch (error) { + setTimeout(() => { + scrollIntoLast(currentTry + 1); + }, 500 * currentTry); + } +}; diff --git a/src/smart-components/Thread/index.tsx b/src/smart-components/Thread/index.tsx new file mode 100644 index 000000000..5c2f5ff4c --- /dev/null +++ b/src/smart-components/Thread/index.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { + ThreadProvider, + ThreadProviderProps, +} from './context/ThreadProvider'; +import ThreadUI, { ThreadUIProps } from './components/ThreadUI'; + +export interface ThreadProps extends ThreadProviderProps, ThreadUIProps { + className?: string; +} + +const Thread: React.FC = (props: ThreadProps) => { + const { + // props + className, + // ThreadProviderProps + channelUrl, + message, + onHeaderActionClick, + onMoveToParentMessage, + // ThreadUIProps + renderHeader, + renderParentMessageInfo, + renderMessage, + renderMessageInput, + renderCustomSeparator, + renderParentMessageInfoPlaceholder, + renderThreadListPlaceHolder, + } = props; + return ( +
+ + + +
+ ); +}; + +export default Thread; diff --git a/src/smart-components/Thread/stories/index.stories.js b/src/smart-components/Thread/stories/index.stories.js new file mode 100644 index 000000000..99b1c4b29 --- /dev/null +++ b/src/smart-components/Thread/stories/index.stories.js @@ -0,0 +1,13 @@ +import Thread from '../index'; +import Sendbird from '../../../lib/Sendbird'; + +import { fitPageSize } from "../../OpenChannelApp/stories/utils"; + +export default { title: 'Thread' }; + +export const Thread = () => fitPageSize( + + + +); diff --git a/src/smart-components/Thread/types.tsx b/src/smart-components/Thread/types.tsx new file mode 100644 index 000000000..d0a885293 --- /dev/null +++ b/src/smart-components/Thread/types.tsx @@ -0,0 +1,19 @@ +// Initializing status +export enum ChannelStateTypes { + NIL = 'NIL', + LOADING = 'LOADING', + INVALID = 'INVALID', + INITIALIZED = 'INITIALIZED', +} +export enum ParentMessageInfoStateTypes { + NIL = 'NIL', + LOADING = 'LOADING', + INVALID = 'INVALID', + INITIALIZED = 'INITIALIZED', +} +export enum ThreadListStateTypes { + NIL = 'NIL', + LOADING = 'LOADING', + INVALID = 'INVALID', + INITIALIZED = 'INITIALIZED', +} diff --git a/src/ui/AdminMessage/index.tsx b/src/ui/AdminMessage/index.tsx index a1bbc46e4..2aedfc2c4 100644 --- a/src/ui/AdminMessage/index.tsx +++ b/src/ui/AdminMessage/index.tsx @@ -5,7 +5,7 @@ import './index.scss'; import Label, { LabelColors, LabelTypography } from '../Label'; interface AdminMessageProps { - className: string | Array; + className?: string | Array; message: AdminMessageType; } diff --git a/src/ui/ContextMenu/EmojiListItems.tsx b/src/ui/ContextMenu/EmojiListItems.tsx index 180ebd9f6..abfd97910 100644 --- a/src/ui/ContextMenu/EmojiListItems.tsx +++ b/src/ui/ContextMenu/EmojiListItems.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, RefObject, useEffect, useRef, useState } from 'react'; +import React, { ReactElement, ReactNode, RefObject, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import SortByRow from '../SortByRow'; @@ -7,7 +7,7 @@ type SpaceFromTrigger = { x: number, y: number }; type ReactionStyle = { left: number, top: number }; export interface EmojiListItemsProps { closeDropdown: () => void; - children: ReactElement; + children: ReactNode; parentRef: RefObject; parentContainRef: RefObject; spaceFromTrigger?: SpaceFromTrigger; diff --git a/src/ui/ContextMenu/MenuItems.tsx b/src/ui/ContextMenu/MenuItems.tsx index 6fe80067d..065f73018 100644 --- a/src/ui/ContextMenu/MenuItems.tsx +++ b/src/ui/ContextMenu/MenuItems.tsx @@ -99,7 +99,7 @@ export default class MenuItems extends React.Component +
    {children}
- +
), document.getElementById('sendbird-dropdown-portal'), ) diff --git a/src/ui/ContextMenu/index.tsx b/src/ui/ContextMenu/index.tsx index 5e0ee3b25..28c76cb88 100644 --- a/src/ui/ContextMenu/index.tsx +++ b/src/ui/ContextMenu/index.tsx @@ -14,7 +14,7 @@ export const EmojiListItems = _EmojiListItems; export interface MenuItemProps { className?: string | Array; - children: ReactNode | Array; + children: ReactElement | ReactElement[] | ReactNode; onClick?: (e: MouseEvent) => void; disable?: boolean; } diff --git a/src/ui/EmojiReactions/index.scss b/src/ui/EmojiReactions/index.scss index 19a6bfd87..e54ec29a1 100644 --- a/src/ui/EmojiReactions/index.scss +++ b/src/ui/EmojiReactions/index.scss @@ -1,9 +1,7 @@ @import '../../styles/variables'; .sendbird-emoji-reactions { - display: inline-flex; - flex-direction: row; - justify-content: flex-start; + display: inline-block; border-radius: 16px; box-sizing: border-box; width: 100%; @@ -16,9 +14,8 @@ } .sendbird-emoji-reactions__reaction-badge { - display: inline-flex; margin-left: 2px; - margin-right: 2px; + margin-right: 1px; margin-bottom: 4px; } @@ -27,8 +24,18 @@ } } +.sendbird-emoji-reactions .sendbird-context-menu { + height: 26px; +} + .sendbird-emoji-reactions__add-reaction-badge { + position: relative; + top: -4px; display: inline-flex; width: 36px; height: 24px; } + +.sendbird-emoji-reactions .sendbird-context-menu { + margin-left: 2px; +} diff --git a/src/ui/EmojiReactions/index.tsx b/src/ui/EmojiReactions/index.tsx index b4603ef4d..542f9186a 100644 --- a/src/ui/EmojiReactions/index.tsx +++ b/src/ui/EmojiReactions/index.tsx @@ -20,7 +20,7 @@ interface Props { message: UserMessage | FileMessage; emojiContainer: EmojiContainer; memberNicknamesMap: Map; - spaceFromTrigger?: Record; + spaceFromTrigger?: { x: number, y: number }; isByMe?: boolean; toggleReaction?: (message: UserMessage | FileMessage, key: string, byMe: boolean) => void; } @@ -31,7 +31,7 @@ const EmojiReactions = ({ message, emojiContainer, memberNicknamesMap, - spaceFromTrigger = {}, + spaceFromTrigger = { x: 0, y: 0 }, isByMe = false, toggleReaction, }: Props): ReactElement => { @@ -60,7 +60,10 @@ const EmojiReactions = ({ toggleReaction(message, reaction.key, reactedByMe)} + onClick={(e) => { + toggleReaction(message, reaction.key, reactedByMe); + e?.stopPropagation?.(); + }} > { + toggleDropdown(); + e?.stopPropagation?.(); + }} > {getEmojiListAll(emojiContainer).map((emoji: Emoji): ReactElement => { - const isReacted: boolean = message?.reactions?. - filter((reaction: Reaction): boolean => reaction.key === emoji.key)[0]?.userIds?. - some((reactorId: string): boolean => reactorId === userId); + const isReacted: boolean = (message?.reactions?. + find((reaction: Reaction): boolean => reaction.key === emoji.key)?.userIds?. + some((reactorId: string): boolean => reactorId === userId)); return ( { + onClick={(e): void => { closeDropdown(); toggleReaction(message, emoji.key, isReacted); + e?.stopPropagation(); }} > - { - truncateString(message?.name || message?.url, isMobile ? 20 : null) - } + {truncateString(message?.name || message?.url, truncateMaxNum)}
diff --git a/src/ui/ImageRenderer/index.scss b/src/ui/ImageRenderer/index.scss index afd605c33..a4e2140b1 100644 --- a/src/ui/ImageRenderer/index.scss +++ b/src/ui/ImageRenderer/index.scss @@ -6,3 +6,9 @@ .sendbird-image-renderer__hidden-image-loader { display: none; } + +.sendbird-image-renderer, +.sendbird-image-renderer__image { + width: 320px; + height: 180px; +} diff --git a/src/ui/Label/stringSet.js b/src/ui/Label/stringSet.js index 4c0116735..c921af8b4 100644 --- a/src/ui/Label/stringSet.js +++ b/src/ui/Label/stringSet.js @@ -45,7 +45,8 @@ const getStringSet = (lang = 'en') => { EDIT_PROFILE__THEME_LABEL: 'Dark theme', MESSAGE_INPUT__PLACE_HOLDER: 'Enter message', MESSAGE_INPUT__PLACE_HOLDER__DISABLED: 'Chat is unavailable in this channel', - MESSAGE_INPUT__PLACE_HOLDER__MUTED: 'Chat is unavailable because you are being muted', + MESSAGE_INPUT__PLACE_HOLDER__MUTED: 'Chat is unavailable because you\'re muted', + MESSAGE_INPUT__PLACE_HOLDER__MUTED_SHORT: 'You\'re muted', MESSAGE_INPUT__QUOTE_REPLY__PLACE_HOLDER: 'Reply to message', CHANNEL__MESSAGE_LIST__NOTIFICATION__NEW_MESSAGE: 'new message(s) since', CHANNEL__MESSAGE_LIST__NOTIFICATION__ON: 'on', @@ -116,8 +117,11 @@ const getStringSet = (lang = 'en') => { UNKNOWN__UNKNOWN_MESSAGE_TYPE: '(Unknown message type)', UNKNOWN__CANNOT_READ_MESSAGE: 'Cannot read this message.', MESSAGE_EDITED: '(edited)', + // Menu items MESSAGE_MENU__COPY: 'Copy', MESSAGE_MENU__REPLY: 'Reply', + MESSAGE_MENU__THREAD: 'Reply in thread', + MESSAGE_MENU__OPEN_IN_CHANNEL: 'Open in channel', MESSAGE_MENU__EDIT: 'Edit', MESSAGE_MENU__RESEND: 'Resend', MESSAGE_MENU__DELETE: 'Delete', @@ -133,6 +137,7 @@ const getStringSet = (lang = 'en') => { QUOTE_MESSAGE_INPUT__FILE_TYPE__VIDEO: 'Video', QUOTED_MESSAGE__REPLIED_TO: 'replied to', QUOTED_MESSAGE__CURRENT_USER: 'You', + QUOTED_MESSAGE__UNAVAILABLE: 'Message unavailable', // FIXME: get back legacy, remove after refactoring open channel messages CONTEXT_MENU_DROPDOWN__COPY: 'Copy', CONTEXT_MENU_DROPDOWN__EDIT: 'Edit', @@ -150,6 +155,15 @@ const getStringSet = (lang = 'en') => { CREATE_OPEN_CHANNEL_LIST__SUBTITLE__TEXT_SECTION: 'Channel name', CREATE_OPEN_CHANNEL_LIST__SUBTITLE__TEXT_PLACE_HOLDER: 'Enter channel name', CREATE_OPEN_CHANNEL_LIST__SUBMIT: 'Create', + // Thread + THREAD__HEADER_TITLE: 'Thread', + CHANNEL__THREAD_REPLY: 'reply', + CHANNEL__THREAD_REPLIES: 'replies', + CHANNEL__THREAD_OVER_MAX: '99+', + THREAD__THREAD_REPLY: 'reply', + THREAD__THREAD_REPLIES: 'replies', + THREAD__INPUT__REPLY_TO_THREAD: 'Reply to thread', + THREAD__INPUT__REPLY_IN_THREAD: 'Reply in thread', }, }; return stringSet[lang]; diff --git a/src/ui/MentionLabel/index.tsx b/src/ui/MentionLabel/index.tsx index 5feaca07c..b0bf86781 100644 --- a/src/ui/MentionLabel/index.tsx +++ b/src/ui/MentionLabel/index.tsx @@ -81,9 +81,13 @@ export default function MentionLabel(props: MentionLabelProps): JSX.Element { parentRef={mentionRef} parentContainRef={mentionRef} closeDropdown={closeDropdown} - style={{ paddingTop: 0, paddingBottom: 0 }} + style={{ paddingTop: '0px', paddingBottom: '0px' }} > - + )} /> diff --git a/src/ui/MentionUserLabel/index.scss b/src/ui/MentionUserLabel/index.scss index 7c3fdf630..a39e6e3a8 100644 --- a/src/ui/MentionUserLabel/index.scss +++ b/src/ui/MentionUserLabel/index.scss @@ -12,6 +12,10 @@ font-style: normal; line-height: 1.43; letter-spacing: normal; + white-space: pre-line; + width: fit-content; + max-width: 100%; + height: 16px; @include themed() { color: t(on-bg-1); } diff --git a/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap b/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap index 57c75e714..5fdba160f 100644 --- a/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap +++ b/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap @@ -13,7 +13,7 @@ exports[`ui/MessageContent should do a snapshot test of the MessageContent DOM 1 style="display: inline;" >
void }) => React.ReactElement, +} interface Props { className?: string | Array; @@ -55,7 +63,9 @@ interface Props { chainTop?: boolean; chainBottom?: boolean; isReactionEnabled?: boolean; + disableQuoteMessage?: boolean; replyType?: ReplyType; + threadReplySelectType?: ThreadReplySelectType; nicknamesMap?: Map; emojiContainer?: EmojiContainer; scrollToMessage?: (createdAt: number, messageId: number) => void; @@ -65,6 +75,8 @@ interface Props { resendMessage?: (message: UserMessage | FileMessage) => Promise; toggleReaction?: (message: UserMessage | FileMessage, reactionKey: string, isReacted: boolean) => void; setQuoteMessage?: (message: UserMessage | FileMessage) => void; + onReplyInThread?: (props: { message: UserMessage | FileMessage }) => void; + onQuoteMessageClick?: (props: { message: UserMessage | FileMessage }) => void; } export default function MessageContent({ className, @@ -75,7 +87,9 @@ export default function MessageContent({ chainTop = false, chainBottom = false, isReactionEnabled = false, + disableQuoteMessage = false, replyType, + threadReplySelectType, nicknamesMap, emojiContainer, scrollToMessage, @@ -85,11 +99,13 @@ export default function MessageContent({ resendMessage, toggleReaction, setQuoteMessage, + onReplyInThread, + onQuoteMessageClick, }: Props): ReactElement { const messageTypes = getUIKitMessageTypes(); const { dateLocale } = useLocalization(); const { config } = useSendbirdStateContext?.() || {}; - const { disableUserProfile, renderUserProfile } = useContext(UserProfileContext); + const { disableUserProfile, renderUserProfile }: UserProfileContextInterface = useContext(UserProfileContext); const avatarRef = useRef(null); const contentRef = useRef(null); const { isMobile } = useMediaQueryContext(); @@ -98,15 +114,21 @@ export default function MessageContent({ const [supposedHover, setSupposedHover] = useState(false); const isByMe = (userId === (message as UserMessage | FileMessage)?.sender?.userId) - || ((message as UserMessage | FileMessage).sendingStatus === 'pending') - || ((message as UserMessage | FileMessage).sendingStatus === 'failed'); + || ((message as UserMessage | FileMessage)?.sendingStatus === 'pending') + || ((message as UserMessage | FileMessage)?.sendingStatus === 'failed'); const isByMeClassName = isByMe ? 'outgoing' : 'incoming'; const chainTopClassName = chainTop ? 'chain-top' : ''; const isReactionEnabledClassName = isReactionEnabled ? 'use-reactions' : ''; - const supposedHoverClassName = supposedHover ? 'supposed-hover' : ''; - const useReplying = !!((replyType === 'QUOTE_REPLY') && message?.parentMessageId && message?.parentMessage); + const supposedHoverClassName = supposedHover ? 'sendbird-mouse-hover' : ''; + const useReplying = !!((replyType === 'QUOTE_REPLY' || replyType === 'THREAD') + && message?.parentMessageId && message?.parentMessage + && !disableQuoteMessage + ); const useReplyingClassName = useReplying ? 'use-quote' : ''; + // Thread replies + const displayThreadReplies = message?.threadInfo?.replyCount > 0 && replyType === 'THREAD'; + // onMouseDown: (e: React.MouseEvent) => void; // onTouchStart: (e: React.TouchEvent) => void; // onMouseUp: (e: React.MouseEvent) => void; @@ -114,11 +136,13 @@ export default function MessageContent({ // onTouchEnd: (e: React.TouchEvent) => void; const longPress = useLongPress({ onLongPress: () => { - setShowMenu(true); + if (isMobile) { + setShowMenu(true); + } }, onClick: () => { // @ts-ignore - if (isFileMessage(message) && isSentMessage(message)) { + if (isMobile && isThumbnailMessage(message) && isSentMessage(message)) { showFileViewer(true); } }, @@ -127,7 +151,7 @@ export default function MessageContent({ }); if (message?.isAdminMessage?.() || message?.messageType === 'admin') { - return (); + return (); } return (
void): ReactElement => ( member?.userId === message?.sender?.userId)?.profileUrl || message?.sender?.profileUrl || ''} // TODO: Divide getting profileUrl logic to utils @@ -161,7 +185,7 @@ export default function MessageContent({ parentRef={avatarRef} parentContainRef={avatarRef} closeDropdown={closeDropdown} - style={{ paddingTop: 0, paddingBottom: 0 }} + style={{ paddingTop: '0px', paddingBottom: '0px' }} > {renderUserProfile // @ts-ignore @@ -188,6 +212,13 @@ export default function MessageContent({ resendMessage={resendMessage} setQuoteMessage={setQuoteMessage} setSupposedHover={setSupposedHover} + onReplyInThread={({ message }) => { + if (threadReplySelectType === ThreadReplySelectType.THREAD) { + onReplyInThread({ message }); + } else if (threadReplySelectType === ThreadReplySelectType.PARENT) { + scrollToMessage(message.parentMessage.createdAt, message.parentMessageId); + } + }} /> {isReactionEnabled && ( message?.parentMessage?.createdAt)} onClick={() => { - if (message?.parentMessage?.createdAt && message?.parentMessageId) { + if (replyType === 'THREAD' && threadReplySelectType === ThreadReplySelectType.THREAD) { + onQuoteMessageClick?.({ message: message as UserMessage | FileMessage }); + } + if ( + (replyType === 'QUOTE_REPLY' || (replyType === 'THREAD' && threadReplySelectType === ThreadReplySelectType.PARENT)) + && message?.parentMessage?.createdAt && message?.parentMessageId + ) { scrollToMessage(message.parentMessage.createdAt, message.parentMessageId); } }} @@ -329,6 +367,14 @@ export default function MessageContent({ )}
+ {/* thread replies */} + {displayThreadReplies && ( + onReplyInThread?.({ message: message as UserMessage | FileMessage })} + /> + )}
{/* right */}
@@ -357,6 +403,13 @@ export default function MessageContent({ resendMessage={resendMessage} setQuoteMessage={setQuoteMessage} setSupposedHover={setSupposedHover} + onReplyInThread={({ message }) => { + if (threadReplySelectType === ThreadReplySelectType.THREAD) { + onReplyInThread({ message }); + } else if (threadReplySelectType === ThreadReplySelectType.PARENT) { + scrollToMessage(message.parentMessage.createdAt, message.parentMessageId); + } + }} />
)} diff --git a/src/ui/MessageInput/index.jsx b/src/ui/MessageInput/index.jsx index ace98523a..4bf08b6a6 100644 --- a/src/ui/MessageInput/index.jsx +++ b/src/ui/MessageInput/index.jsx @@ -66,6 +66,7 @@ const initialTargetStringInfo = { const MessageInput = React.forwardRef((props, ref) => { const { className, + messageFieldId, isEdit, isMentionEnabled, disabled, @@ -85,6 +86,7 @@ const MessageInput = React.forwardRef((props, ref) => { onKeyUp, onKeyDown, } = props; + const textFieldId = messageFieldId || TEXT_FIELD_ID; const { stringSet } = useContext(LocalizationContext); const fileInputRef = useRef(null); const [isInput, setIsInput] = useState(false); @@ -137,7 +139,7 @@ const MessageInput = React.forwardRef((props, ref) => { // #Mention | Fill message input values useEffect(() => { if (isEdit && message?.messageId) { - // const textField = document.getElementById(TEXT_FIELD_ID); + // const textField = document.getElementById(textFieldId); const textField = ref?.current; if (isMentionEnabled && message?.mentionedUsers?.length > 0 @@ -201,7 +203,7 @@ const MessageInput = React.forwardRef((props, ref) => { endOffsetIndex, } = targetStringInfo; if (targetString && startNodeIndex !== null && startOffsetIndex !== null) { - // const textField = document.getElementById(TEXT_FIELD_ID); + // const textField = document.getElementById(textFieldId); const textField = ref?.current; const childNodes = [...textField?.childNodes]; const frontTextNode = document?.createTextNode( @@ -381,8 +383,8 @@ const MessageInput = React.forwardRef((props, ref) => { ])} >
void; showRemove?: (bool: boolean) => void; resendMessage?: (message: UserMessage | FileMessage) => void; setQuoteMessage?: (message: UserMessage | FileMessage) => void; setSupposedHover?: (bool: boolean) => void; + onReplyInThread?: (props: { message: UserMessage | FileMessage }) => void; + onMoveToParentMessage?: () => void; } export default function MessageItemMenu({ @@ -39,11 +42,14 @@ export default function MessageItemMenu({ isByMe = false, disabled = false, replyType, + disableDeleteMessage = null, showEdit, showRemove, resendMessage, setQuoteMessage, setSupposedHover, + onReplyInThread, + onMoveToParentMessage = null, }: Props): ReactElement { const { stringSet } = useContext(LocalizationContext); const triggerRef = useRef(null); @@ -53,16 +59,26 @@ export default function MessageItemMenu({ const showMenuItemEdit: boolean = (isUserMessage(message as UserMessage) && isSentMessage(message) && isByMe); const showMenuItemResend: boolean = (isFailedMessage(message) && message?.isResendable && isByMe); const showMenuItemDelete: boolean = !isPendingMessage(message) && isByMe; + const showMenuItemOpenInChannel: boolean = onMoveToParentMessage !== null; /** * TODO: Manage timing issue * User delete pending message -> Sending message success */ - const showMenuItemReply: boolean = (replyType === 'QUOTE_REPLY') - && !isFailedMessage(message) + const isReplyTypeEnabled = !isFailedMessage(message) && !isPendingMessage(message) - && (channel?.isGroupChannel() && !(channel as GroupChannel)?.isBroadcast); + && (channel?.isGroupChannel?.() + && !(channel as GroupChannel)?.isBroadcast); + const showMenuItemReply = isReplyTypeEnabled && replyType === 'QUOTE_REPLY'; + const showMenuItemThread = isReplyTypeEnabled && replyType === 'THREAD' && !message?.parentMessageId && onReplyInThread; - if (!(showMenuItemCopy || showMenuItemReply || showMenuItemEdit || showMenuItemResend || showMenuItemDelete)) { + if (!(showMenuItemCopy + || showMenuItemReply + || showMenuItemThread + || showMenuItemOpenInChannel + || showMenuItemEdit + || showMenuItemResend + || showMenuItemDelete + )) { return null; } return ( @@ -130,6 +146,28 @@ export default function MessageItemMenu({ {stringSet.MESSAGE_MENU__REPLY} )} + {showMenuItemThread && ( + { + onReplyInThread?.({ message }); + closeDropdown(); + }} + > + {stringSet.MESSAGE_MENU__THREAD} + + )} + {showMenuItemOpenInChannel && ( + { + onMoveToParentMessage?.(); + closeDropdown(); + }} + > + {stringSet.MESSAGE_MENU__OPEN_IN_CHANNEL} + + )} {showMenuItemEdit && ( 0} + disable={ + typeof disableDeleteMessage === 'boolean' + ? disableDeleteMessage + : message?.threadInfo?.replyCount > 0 + } > {stringSet.MESSAGE_MENU__DELETE} diff --git a/src/ui/MessageSearchFileItem/index.scss b/src/ui/MessageSearchFileItem/index.scss index 6f08b134f..8e034bd24 100644 --- a/src/ui/MessageSearchFileItem/index.scss +++ b/src/ui/MessageSearchFileItem/index.scss @@ -35,8 +35,8 @@ .sendbird-message-search-file-item__right__sender-name { position: absolute; top: 12px; - display: flex; - max-width: 280px; + display: inline-block; + max-width: 146px; height: 16px; overflow: hidden; text-overflow: ellipsis; diff --git a/src/ui/MessageStatus/__tests__/__snapshots__/MessageStatus.spec.js.snap b/src/ui/MessageStatus/__tests__/__snapshots__/MessageStatus.spec.js.snap index 93204549f..8c366c2b4 100644 --- a/src/ui/MessageStatus/__tests__/__snapshots__/MessageStatus.spec.js.snap +++ b/src/ui/MessageStatus/__tests__/__snapshots__/MessageStatus.spec.js.snap @@ -6,7 +6,7 @@ exports[`ui/MessageStatus should do a snapshot test of the MessageStatus DOM 1`] class="example-text sendbird-message-status" >
( getOutgoingMessageState(channel, message) - ), [channel?.getUnreadMemberCount?.(message), channel?.getUndeliveredMemberCount?.(message)]); - const hideMessageStatusIcon = channel?.isGroupChannel() && ( + ), [channel, message]); + const hideMessageStatusIcon = channel?.isGroupChannel?.() && ( (channel.isSuper || channel.isPublic || channel.isBroadcast) && !(status === OutgoingMessageStates.PENDING || status === OutgoingMessageStates.FAILED) ); @@ -71,7 +71,9 @@ export default function MessageStatus({ ) : (
(UserProfileContext); const { isMobile } = useMediaQueryContext(); const openFileUrl = () => { window.open(message.url); }; @@ -64,7 +64,11 @@ export default function OpenchannelFileMessage({ const sender = getSenderFromMessage(message); const [contextMenu, setContextMenu] = useState(false); const longPress = useLongPress({ - onLongPress: () => { setContextMenu(true) }, + onLongPress: () => { + if (isMobile) { + setContextMenu(true); + } + }, onClick: openFileUrl, }, {delay: 300}); return ( @@ -98,7 +102,7 @@ export default function OpenchannelFileMessage({ parentRef={avatarRef} parentContainRef={avatarRef} closeDropdown={closeDropdown} - style={{ paddingTop: 0, paddingBottom: 0 }} + style={{ paddingTop: '0px', paddingBottom: '0px' }} > { renderUserProfile diff --git a/src/ui/OpenchannelOGMessage/index.tsx b/src/ui/OpenchannelOGMessage/index.tsx index 40a055326..d6368b965 100644 --- a/src/ui/OpenchannelOGMessage/index.tsx +++ b/src/ui/OpenchannelOGMessage/index.tsx @@ -74,7 +74,7 @@ export default function OpenchannelOGMessage({ const { defaultImage } = ogMetaData; const sdk = useSendbirdStateContext?.()?.stores?.sdkStore?.sdk; const { stringSet, dateLocale } = useLocalization(); - const { disableUserProfile, renderUserProfile } = useContext(UserProfileContext); + const { disableUserProfile, renderUserProfile } = useContext(UserProfileContext); const [contextStyle, setContextStyle] = useState({}); const [contextMenu, setContextMenu] = useState(false); const onLongPress = useLongPress({ @@ -163,7 +163,7 @@ export default function OpenchannelOGMessage({ parentRef={avatarRef} parentContainRef={avatarRef} closeDropdown={closeDropdown} - style={{ paddingTop: 0, paddingBottom: 0 }} + style={{ paddingTop: '0px', paddingBottom: '0px' }} > { renderUserProfile diff --git a/src/ui/OpenchannelThumbnailMessage/index.tsx b/src/ui/OpenchannelThumbnailMessage/index.tsx index 4fa01f6ff..e9d427e5e 100644 --- a/src/ui/OpenchannelThumbnailMessage/index.tsx +++ b/src/ui/OpenchannelThumbnailMessage/index.tsx @@ -69,7 +69,7 @@ export default function OpenchannelThumbnailMessage({ const status = message?.sendingStatus; const thumbnailUrl = (thumbnails && thumbnails.length > 0 && thumbnails[0].url) || null; const { stringSet, dateLocale } = useLocalization(); - const { disableUserProfile, renderUserProfile } = useContext(UserProfileContext); + const { disableUserProfile, renderUserProfile } = useContext(UserProfileContext); const [messageWidth, setMessageWidth] = useState(360); const [contextMenu, setContextMenu] = useState(false); const messageRef = useRef(null); @@ -134,7 +134,7 @@ export default function OpenchannelThumbnailMessage({ parentRef={avatarRef} parentContainRef={avatarRef} closeDropdown={closeDropdown} - style={{ paddingTop: 0, paddingBottom: 0 }} + style={{ paddingTop: '0px', paddingBottom: '0px' }} > { renderUserProfile diff --git a/src/ui/QuoteMessage/__tests__/__snapshots__/QuoteMessage.spec.js.snap b/src/ui/QuoteMessage/__tests__/__snapshots__/QuoteMessage.spec.js.snap index 7db27a24e..af9dbd379 100644 --- a/src/ui/QuoteMessage/__tests__/__snapshots__/QuoteMessage.spec.js.snap +++ b/src/ui/QuoteMessage/__tests__/__snapshots__/QuoteMessage.spec.js.snap @@ -3,7 +3,7 @@ exports[`ui/QuoteMessage should do a snapshot test of the ReplyingMessageItemBody DOM 1`] = `
- Simon replied to undefined + + Simon + + + replied to + +
; message?: UserMessage | FileMessage; userId?: string; isByMe?: boolean; - className?: string | Array; + isUnavailable?: boolean; onClick?: () => void; } @@ -31,6 +32,7 @@ export default function QuoteMessage({ userId = '', isByMe = false, className, + isUnavailable = false, onClick, }: Props): ReactElement { const { stringSet } = useContext(LocalizationContext); @@ -48,10 +50,18 @@ export default function QuoteMessage({ return (
{ if (onClick) onClick() }} - onTouchEnd={() => { if (onClick) onClick() }} + onClick={() => { + if (!isUnavailable && onClick) { + onClick(); + } + }} + onTouchEnd={() => { + if (!isUnavailable && onClick) { + onClick(); + } + }} >
- {`${currentMessageSenderNickname} ${stringSet.QUOTED_MESSAGE__REPLIED_TO} ${parentMessageSenderNickname}`} + {currentMessageSenderNickname} + {stringSet.QUOTED_MESSAGE__REPLIED_TO} + {parentMessageSenderNickname}
+ {isUnavailable && ( +
+ +
+ )} {/* text message */} - {(isUserMessage(parentMessage as UserMessage) && (parentMessage as UserMessage)?.message?.length > 0) && ( + {((isUserMessage(parentMessage as UserMessage) && (parentMessage as UserMessage)?.message?.length > 0) && !isUnavailable) && (