From b32d1abda459eb8ec6821eee4fe5084cfa4a2d09 Mon Sep 17 00:00:00 2001 From: chohongm Date: Tue, 5 Mar 2024 00:26:46 +0900 Subject: [PATCH 1/5] initial commit --- src/types.ts | 6 + src/ui/Avatar/index.tsx | 3 + src/ui/Label/index.tsx | 21 +- .../MessageContent/MessageProfile/index.tsx | 3 + src/ui/MessageContent/index.scss | 48 +++-- src/ui/MessageContent/index.tsx | 203 ++++++++++++------ src/ui/ThreadReplies/index.tsx | 18 +- src/utils/index.ts | 20 +- 8 files changed, 215 insertions(+), 107 deletions(-) diff --git a/src/types.ts b/src/types.ts index 03bd9395f..8add00959 100644 --- a/src/types.ts +++ b/src/types.ts @@ -86,3 +86,9 @@ export interface UploadedFileInfoWithUpload { } export type SendbirdTheme = 'light' | 'dark'; + +export enum MessageContentMiddleContainerType { + DEFAULT = 'default', + WIDE = 'wide', + FULL = 'full', +}; diff --git a/src/ui/Avatar/index.tsx b/src/ui/Avatar/index.tsx index 2fafe8b82..8644e3f4b 100644 --- a/src/ui/Avatar/index.tsx +++ b/src/ui/Avatar/index.tsx @@ -147,6 +147,7 @@ interface AvatarProps { width?: string | number, zIndex?: string | number, left?: string, + bottom?: string, src?: string | Array, alt?: string, onClick?(): void, @@ -162,6 +163,7 @@ function Avatar( height = '56px', zIndex = 0, left = '', + bottom = '', onClick, customDefaultComponent, }: AvatarProps, @@ -180,6 +182,7 @@ function Avatar( width, zIndex, left, + bottom, }} onClick={onClick} onKeyDown={onClick} diff --git a/src/ui/Label/index.tsx b/src/ui/Label/index.tsx index f6bce9965..2bbb9b00d 100644 --- a/src/ui/Label/index.tsx +++ b/src/ui/Label/index.tsx @@ -1,10 +1,11 @@ -import React from 'react'; +import React, {RefObject} from 'react'; import './index.scss'; import { Typography, Colors } from './types'; import { changeTypographyToClassName, changeColorToClassName } from './utils'; import getStringSet from './stringSet'; import { ObjectValues } from '../../utils/typeHelpers/objectValues'; +import {ThreadReplies} from '../ThreadReplies'; type LabelProps = { className?: string | string[]; @@ -12,12 +13,15 @@ type LabelProps = { color?: ObjectValues; children?: React.ReactNode; }; -export default function Label({ - className = [], - type, - color, - children = null, -}: LabelProps) { +export function Label( + { + className = [], + type, + color, + children = null, + }: LabelProps, + ref?: RefObject, +) { return ( // Donot make this into div // Mention uses Label. If we use div, it would break the mention detection on Paste @@ -29,6 +33,7 @@ export default function Label({ changeTypographyToClassName(type), changeColorToClassName(color), ].join(' ')} + ref={ref} > {children} @@ -39,3 +44,5 @@ const LabelTypography = Typography; const LabelColors = Colors; const LabelStringSet = getStringSet('en'); export { LabelTypography, LabelColors, LabelStringSet }; + +export default React.forwardRef(Label); \ No newline at end of file diff --git a/src/ui/MessageContent/MessageProfile/index.tsx b/src/ui/MessageContent/MessageProfile/index.tsx index 98fa715d8..bdd6a0798 100644 --- a/src/ui/MessageContent/MessageProfile/index.tsx +++ b/src/ui/MessageContent/MessageProfile/index.tsx @@ -14,6 +14,7 @@ import { UserProfileContext } from '../../../lib/UserProfileContext'; export interface MessageProfileProps extends MessageContentProps { isByMe?: boolean; displayThreadReplies?: boolean; + bottom?: string } export default function MessageProfile( @@ -26,6 +27,7 @@ export default function MessageProfile( chainBottom = false, isByMe, displayThreadReplies, + bottom, } = props; const avatarRef = useRef(null); @@ -52,6 +54,7 @@ export default function MessageProfile( ref={avatarRef} width="28px" height="28px" + bottom={bottom} onClick={(): void => { if (!disableUserProfile) toggleDropdown(); }} diff --git a/src/ui/MessageContent/index.scss b/src/ui/MessageContent/index.scss index 47b45c89e..cd7bd3865 100644 --- a/src/ui/MessageContent/index.scss +++ b/src/ui/MessageContent/index.scss @@ -1,13 +1,5 @@ @import '../../styles/variables'; -:root { - --sendbird-feedback-buttons-container-height: 36px; - --sendbird-feedback-buttons-container-margin-top: 4px; - --sendbird-thread-replies-height: 28px; - --sendbird-thread-replies-margin-top: 4px; - --sendbird-feedback-buttons-container-margin-bottom: 4px; -} - .sendbird-message-content { display: inline-flex; flex-direction: row; @@ -29,6 +21,13 @@ } } + // Below is for both wide and full because this class only affects middle and right parts of message content. + .ui_container_type__wide { + @include mobile() { + max-width: 100%; + } + } + .sendbird-message-content__middle { .sendbird-message-content__middle__quote-message.use-quote { bottom: -8px; @@ -37,14 +36,6 @@ } } -.sendbird-message-content__feedback { - margin-bottom: calc( - var(--sendbird-feedback-buttons-container-height) - + var(--sendbird-feedback-buttons-container-margin-top) - + var(--sendbird-feedback-buttons-container-margin-bottom) - ); -} - .sendbird-message-content__middle__quote-message__quote { width: 100%; } @@ -93,6 +84,11 @@ display: none; } } + .ui_container_type__wide { + min-width: fit-content; + bottom: -16px; + right: 0px; + } } .sendbird-message-content__middle__sender-name { @@ -239,6 +235,11 @@ position: relative; } } + .ui_container_type__wide { + min-width: fit-content; + bottom: -16px; + left: 0px; + } } } @@ -315,23 +316,24 @@ margin-top: 4px; } +// feedback buttons +.sendbird-message-content__middle__feedback-buttons { + margin-top: 4px; +} + .sendbird-voice-message-item-body.sendbird-message-content__middle__message-item-body { height: 50px; min-width: 136px; } .sendbird-message-content__middle__body-container__feedback-buttons-container { - position: absolute; - bottom: calc(-1 * (var(--sendbird-feedback-buttons-container-height) + var(--sendbird-feedback-buttons-container-margin-top))); + margin-top: 4px; + position: relative; display: flex; gap: 4px; } -.sendbird-message-content__middle__body-container__feedback-buttons-container_with-thread-replies { - bottom: calc(-1 * (var(--sendbird-feedback-buttons-container-height) + var(--sendbird-feedback-buttons-container-margin-top) + var(--sendbird-thread-replies-height) + var(--sendbird-thread-replies-margin-top))); -} - // Template message's default width should be 100% (fills empty space as much as possible) .sendbird-message-content__middle__for_template_message { width: 100%; -} \ No newline at end of file +} diff --git a/src/ui/MessageContent/index.tsx b/src/ui/MessageContent/index.tsx index 682330360..146482562 100644 --- a/src/ui/MessageContent/index.tsx +++ b/src/ui/MessageContent/index.tsx @@ -1,5 +1,5 @@ import React, { - ReactElement, ReactNode, useContext, + ReactElement, ReactNode, useContext, useEffect, useRef, useState, } from 'react'; @@ -21,7 +21,9 @@ import { isThumbnailMessage, SendableMessageType, CoreMessageType, - isMultipleFilesMessage, isTemplateMessage, + isMultipleFilesMessage, + isTemplateMessage, + getMessageContentMiddleClassNameByContainerType, } from '../../utils'; import { LocalizationContext, useLocalization } from '../../lib/LocalizationContext'; import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; @@ -140,6 +142,9 @@ export default function MessageContent(props: MessageContentProps): ReactElement const { config, eventHandlers } = useSendbirdStateContext?.() || {}; const onPressUserProfileHandler = eventHandlers?.reaction?.onPressUserProfile; const contentRef = useRef(null); + const timestampRef = useRef(null); + const threadRepliesRef = useRef(null); + const feedbackButtonsRef = useRef(null); const { isMobile } = useMediaQueryContext(); const [showMenu, setShowMenu] = useState(false); @@ -149,6 +154,8 @@ export default function MessageContent(props: MessageContentProps): ReactElement const [showFeedbackOptionsMenu, setShowFeedbackOptionsMenu] = useState(false); const [showFeedbackModal, setShowFeedbackModal] = useState(false); const [feedbackFailedText, setFeedbackFailedText] = useState(''); + const [isMiddleFullWidth, setIsMiddleFullWidth] = useState(false); + const [totalBottom, setTotalBottom] = useState(0); const { stringSet } = useContext(LocalizationContext); @@ -174,7 +181,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement && message?.myFeedbackStatus && message.myFeedbackStatus !== SbFeedbackStatus.NOT_APPLICABLE; const isFeedbackEnabled = config?.groupChannel?.enableFeedback && isFeedbackMessage; - const feedbackMessageClassName = isFeedbackEnabled ? 'sendbird-message-content__feedback' : ''; + /** * For TemplateMessage, do not display: * - in web view: @@ -190,6 +197,14 @@ export default function MessageContent(props: MessageContentProps): ReactElement const showThreadReplies = isNotTemplateMessage && displayThreadReplies; const showRightContent = isNotTemplateMessage && !isByMe && !isMobile; + const messageContentMiddleClassNameByType= + getMessageContentMiddleClassNameByContainerType({ + message, + isMobile, + isMiddleFullWidth + }); + const isTimestampBottom = !!messageContentMiddleClassNameByType; + const onCloseFeedbackForm = () => { setShowFeedbackModal(false); }; @@ -223,9 +238,42 @@ export default function MessageContent(props: MessageContentProps): ReactElement return (); } + useEffect(() => { + const getTotalBottom = (): number => { + let sum = 2; + if (timestampRef.current && isTimestampBottom) { + sum += 4 + (timestampRef.current?.clientHeight ?? 0); + } + if (threadRepliesRef.current) { + sum += 4 + (threadRepliesRef.current?.clientHeight ?? 0); + } + if (feedbackButtonsRef.current) { + sum += 4 + (feedbackButtonsRef.current?.clientHeight ?? 0); + } + return sum; + }; + setTotalBottom(getTotalBottom()); + const processMiddleWidth = () => { + if (contentRef.current) { + const parentWidth = contentRef.current.parentNode.clientWidth; + const elementWidth = contentRef.current.clientWidth; + setIsMiddleFullWidth(elementWidth + 80 > parentWidth); + } + }; + processMiddleWidth(); + window.addEventListener('resize', processMiddleWidth); + return () => { + window.removeEventListener('resize', processMiddleWidth); + }; + }, []); + return (
setMouseHover(true)} onMouseLeave={() => setMouseHover(false)} > @@ -236,6 +284,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement ...props, isByMe, displayThreadReplies, + bottom: totalBottom > 0 ? totalBottom + 'px' : '', }) } {/* outgoing menu */} @@ -278,6 +327,10 @@ export default function MessageContent(props: MessageContentProps): ReactElement className={getClassName([ 'sendbird-message-content__middle', isTemplateMessage(message) ? 'sendbird-message-content__middle__for_template_message' : '', + getMessageContentMiddleClassNameByContainerType({ + message, + isMobile, + }) ])} {...(isMobile ? { ...longPress } : {})} ref={contentRef} @@ -320,7 +373,14 @@ export default function MessageContent(props: MessageContentProps): ReactElement {/* message status component when sent by me */} {(isByMe && !chainBottom) && (
+ className={getClassName([ + 'sendbird-message-content__middle__body-container__created-at', + 'left', + supposedHoverClassName, + messageContentMiddleClassNameByType + ])} + ref={timestampRef} + >
)} - {/* Feedback buttons */} - { - isFeedbackEnabled &&
- { - if (!message?.myFeedback?.rating) { - try { - await message.submitFeedback({ - rating: FeedbackRating.GOOD, - }); - openFeedbackFormOrMenu(); - } catch (error) { - config?.logger?.error?.('Channel: Submit feedback failed.', error); - setFeedbackFailedText(stringSet.FEEDBACK_FAILED_SUBMIT); - } - } else { - openFeedbackFormOrMenu(); - } - }} - disabled={message?.myFeedback && message.myFeedback.rating !== FeedbackRating.GOOD} - > - - - { - if (!message?.myFeedback?.rating) { - try { - await message.submitFeedback({ - rating: FeedbackRating.BAD, - }); - openFeedbackFormOrMenu(); - } catch (error) { - config?.logger?.error?.('Channel: Submit feedback failed.', error); - setFeedbackFailedText(stringSet.FEEDBACK_FAILED_SUBMIT); - } - } else { - openFeedbackFormOrMenu(); - } - }} - disabled={message?.myFeedback && message.myFeedback.rating !== FeedbackRating.BAD} - > - - -
- } {/* message timestamp when sent by others */} {(!isByMe && !chainBottom) && (
{/* right */} + {showRightContent && (
- {showRightContent && (
{isReactionEnabledInChannel && ( renderEmojiMenu({ @@ -486,8 +549,8 @@ export default function MessageContent(props: MessageContentProps): ReactElement deleteMessage, })}
- )}
+ )} { showMenu && ( message?.isUserMessage?.() || message?.isFileMessage?.() || message?.isMultipleFilesMessage?.() diff --git a/src/ui/ThreadReplies/index.tsx b/src/ui/ThreadReplies/index.tsx index fcd73aa53..72d8e3eea 100644 --- a/src/ui/ThreadReplies/index.tsx +++ b/src/ui/ThreadReplies/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {RefObject} from 'react'; import { ThreadInfo } from '@sendbird/chat/message'; import './index.scss'; @@ -13,11 +13,14 @@ export interface ThreadRepliesProps { onClick?: (e: React.MouseEvent | React.KeyboardEvent) => void; } -export default function ThreadReplies({ - className, - threadInfo, - onClick, -}: ThreadRepliesProps): React.ReactElement { +export function ThreadReplies( + { + className, + threadInfo, + onClick, + }: ThreadRepliesProps, + ref?: RefObject, +): React.ReactElement { const { mostRepliedUsers = [], replyCount, @@ -35,6 +38,7 @@ export default function ThreadReplies({ onClick(e); e?.stopPropagation(); }} + ref={ref} >
{mostRepliedUsers.slice(0, 4).map((user) => { @@ -91,3 +95,5 @@ export default function ThreadReplies({
); } + +export default React.forwardRef(ThreadReplies); diff --git a/src/utils/index.ts b/src/utils/index.ts index 2e44c0e23..b260258ef 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -13,7 +13,7 @@ import { import { OpenChannel, SendbirdOpenChat } from '@sendbird/chat/openChannel'; import { getOutgoingMessageState, OutgoingMessageStates } from './exports/getOutgoingMessageState'; -import { Nullable } from '../types'; +import {MessageContentMiddleContainerType, Nullable} from '../types'; import { match } from 'ts-pattern'; // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Complete_list_of_MIME_types @@ -277,6 +277,24 @@ export const isTemplateMessage = (message: CoreMessageType): boolean => !!( message && message.extendedMessagePayload?.['template'] ); +export const getMessageContentMiddleClassNameByContainerType = ({ + message, + isMobile, + isMiddleFullWidth = true, +}: { + message: CoreMessageType, + isMobile: boolean, + isMiddleFullWidth?: boolean, +}): string => { + if (!isMobile || !isMiddleFullWidth) return ''; + + const containerType: string | undefined = message.extendedMessagePayload?.['ui']?.['container_type']; + return match(MessageContentMiddleContainerType.WIDE) + .with(MessageContentMiddleContainerType.WIDE, () => 'ui_container_type__wide') + .with(MessageContentMiddleContainerType.FULL, () => 'ui_container_type__wide') + .otherwise(() => ''); +} + export const isOGMessage = (message: SendableMessageType): boolean => !!( message && isUserMessage(message) && message?.ogMetaData && ( message.ogMetaData?.url From 353812f289713bd1ed35c26b6a32d7b736d718bb Mon Sep 17 00:00:00 2001 From: chohongm Date: Tue, 5 Mar 2024 11:01:24 +0900 Subject: [PATCH 2/5] lint --- src/types.ts | 2 +- src/ui/Label/index.tsx | 5 ++--- .../__snapshots__/MessageContent.spec.js.snap | 8 +++---- src/ui/MessageContent/index.tsx | 21 +++++++++---------- src/ui/ThreadReplies/index.tsx | 2 +- src/utils/index.ts | 4 ++-- 6 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src/types.ts b/src/types.ts index 8add00959..a9ec0903d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -91,4 +91,4 @@ export enum MessageContentMiddleContainerType { DEFAULT = 'default', WIDE = 'wide', FULL = 'full', -}; +} diff --git a/src/ui/Label/index.tsx b/src/ui/Label/index.tsx index 2bbb9b00d..f2b23effa 100644 --- a/src/ui/Label/index.tsx +++ b/src/ui/Label/index.tsx @@ -1,11 +1,10 @@ -import React, {RefObject} from 'react'; +import React, { RefObject } from 'react'; import './index.scss'; import { Typography, Colors } from './types'; import { changeTypographyToClassName, changeColorToClassName } from './utils'; import getStringSet from './stringSet'; import { ObjectValues } from '../../utils/typeHelpers/objectValues'; -import {ThreadReplies} from '../ThreadReplies'; type LabelProps = { className?: string | string[]; @@ -45,4 +44,4 @@ const LabelColors = Colors; const LabelStringSet = getStringSet('en'); export { LabelTypography, LabelColors, LabelStringSet }; -export default React.forwardRef(Label); \ No newline at end of file +export default React.forwardRef(Label); diff --git a/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap b/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap index 850101661..3945e4067 100644 --- a/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap +++ b/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap @@ -3,7 +3,7 @@ exports[`ui/MessageContent should do a snapshot test of the MessageContent DOM 1`] = `
mock-date diff --git a/src/ui/MessageContent/index.tsx b/src/ui/MessageContent/index.tsx index 146482562..b26b86fa3 100644 --- a/src/ui/MessageContent/index.tsx +++ b/src/ui/MessageContent/index.tsx @@ -197,12 +197,11 @@ export default function MessageContent(props: MessageContentProps): ReactElement const showThreadReplies = isNotTemplateMessage && displayThreadReplies; const showRightContent = isNotTemplateMessage && !isByMe && !isMobile; - const messageContentMiddleClassNameByType= - getMessageContentMiddleClassNameByContainerType({ - message, - isMobile, - isMiddleFullWidth - }); + const messageContentMiddleClassNameByType = getMessageContentMiddleClassNameByContainerType({ + message, + isMobile, + isMiddleFullWidth, + }); const isTimestampBottom = !!messageContentMiddleClassNameByType; const onCloseFeedbackForm = () => { @@ -242,13 +241,13 @@ export default function MessageContent(props: MessageContentProps): ReactElement const getTotalBottom = (): number => { let sum = 2; if (timestampRef.current && isTimestampBottom) { - sum += 4 + (timestampRef.current?.clientHeight ?? 0); + sum += 4 + (timestampRef.current?.clientHeight ?? 0); } if (threadRepliesRef.current) { - sum += 4 + (threadRepliesRef.current?.clientHeight ?? 0); + sum += 4 + (threadRepliesRef.current?.clientHeight ?? 0); } if (feedbackButtonsRef.current) { - sum += 4 + (feedbackButtonsRef.current?.clientHeight ?? 0); + sum += 4 + (feedbackButtonsRef.current?.clientHeight ?? 0); } return sum; }; @@ -330,7 +329,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement getMessageContentMiddleClassNameByContainerType({ message, isMobile, - }) + }), ])} {...(isMobile ? { ...longPress } : {})} ref={contentRef} @@ -377,7 +376,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement 'sendbird-message-content__middle__body-container__created-at', 'left', supposedHoverClassName, - messageContentMiddleClassNameByType + messageContentMiddleClassNameByType, ])} ref={timestampRef} > diff --git a/src/ui/ThreadReplies/index.tsx b/src/ui/ThreadReplies/index.tsx index 72d8e3eea..1d493f318 100644 --- a/src/ui/ThreadReplies/index.tsx +++ b/src/ui/ThreadReplies/index.tsx @@ -1,4 +1,4 @@ -import React, {RefObject} from 'react'; +import React, { RefObject } from 'react'; import { ThreadInfo } from '@sendbird/chat/message'; import './index.scss'; diff --git a/src/utils/index.ts b/src/utils/index.ts index b260258ef..3563bd892 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -13,7 +13,7 @@ import { import { OpenChannel, SendbirdOpenChat } from '@sendbird/chat/openChannel'; import { getOutgoingMessageState, OutgoingMessageStates } from './exports/getOutgoingMessageState'; -import {MessageContentMiddleContainerType, Nullable} from '../types'; +import { MessageContentMiddleContainerType, Nullable } from '../types'; import { match } from 'ts-pattern'; // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Complete_list_of_MIME_types @@ -293,7 +293,7 @@ export const getMessageContentMiddleClassNameByContainerType = ({ .with(MessageContentMiddleContainerType.WIDE, () => 'ui_container_type__wide') .with(MessageContentMiddleContainerType.FULL, () => 'ui_container_type__wide') .otherwise(() => ''); -} +}; export const isOGMessage = (message: SendableMessageType): boolean => !!( message && isUserMessage(message) && message?.ogMetaData && ( From 95000e91f5b700ac53227b82b4582d81d70fcd02 Mon Sep 17 00:00:00 2001 From: chohongm Date: Tue, 5 Mar 2024 11:26:04 +0900 Subject: [PATCH 3/5] fix bottom timestamp height --- src/ui/MessageContent/index.scss | 2 +- src/ui/MessageContent/index.tsx | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/ui/MessageContent/index.scss b/src/ui/MessageContent/index.scss index cd7bd3865..0f38085ad 100644 --- a/src/ui/MessageContent/index.scss +++ b/src/ui/MessageContent/index.scss @@ -237,7 +237,7 @@ } .ui_container_type__wide { min-width: fit-content; - bottom: -16px; + bottom: -20px; left: 0px; } } diff --git a/src/ui/MessageContent/index.tsx b/src/ui/MessageContent/index.tsx index b26b86fa3..3878aed12 100644 --- a/src/ui/MessageContent/index.tsx +++ b/src/ui/MessageContent/index.tsx @@ -241,13 +241,13 @@ export default function MessageContent(props: MessageContentProps): ReactElement const getTotalBottom = (): number => { let sum = 2; if (timestampRef.current && isTimestampBottom) { - sum += 4 + (timestampRef.current?.clientHeight ?? 0); + sum += 4 + timestampRef.current.clientHeight; } if (threadRepliesRef.current) { - sum += 4 + (threadRepliesRef.current?.clientHeight ?? 0); + sum += 4 + threadRepliesRef.current.clientHeight; } if (feedbackButtonsRef.current) { - sum += 4 + (feedbackButtonsRef.current?.clientHeight ?? 0); + sum += 4 + feedbackButtonsRef.current.clientHeight; } return sum; }; @@ -264,7 +264,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement return () => { window.removeEventListener('resize', processMiddleWidth); }; - }, []); + }, [isTimestampBottom]); return (
)}
+ {/* bottom timestamp empty container */} + {isTimestampBottom &&
} {/* thread replies */} {showThreadReplies && ( Date: Tue, 5 Mar 2024 17:02:11 +0900 Subject: [PATCH 4/5] apply template render types: wide and full --- .../components/MessageList/index.scss | 2 -- .../__snapshots__/MessageContent.spec.js.snap | 2 +- src/ui/MessageContent/index.scss | 27 +++++++++++++++++-- src/ui/MessageContent/index.tsx | 21 ++++++++++----- src/utils/index.ts | 14 +++++++--- 5 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/modules/GroupChannel/components/MessageList/index.scss b/src/modules/GroupChannel/components/MessageList/index.scss index 7f7bf87f8..202804d51 100644 --- a/src/modules/GroupChannel/components/MessageList/index.scss +++ b/src/modules/GroupChannel/components/MessageList/index.scss @@ -8,8 +8,6 @@ position: relative; .sendbird-conversation__messages-padding { position: relative; - padding-left: 24px; - padding-right: 24px; height: 100%; overflow-x: hidden; overflow-y: scroll; diff --git a/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap b/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap index 3945e4067..f755329f1 100644 --- a/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap +++ b/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap @@ -3,7 +3,7 @@ exports[`ui/MessageContent should do a snapshot test of the MessageContent DOM 1`] = `
.ui_container_type__full{ + @include mobile() { + padding: 0; + width: 100%; + } +} + .sendbird-message-content { display: inline-flex; flex-direction: row; position: relative; - width: 100%; + width: calc(100% - 48px); + padding: 0 24px; &.incoming { justify-content: flex-start; @@ -21,13 +29,18 @@ } } - // Below is for both wide and full because this class only affects middle and right parts of message content. .ui_container_type__wide { @include mobile() { max-width: 100%; } } + .ui_container_type__full { + @include mobile() { + max-width: 100%; + } + } + .sendbird-message-content__middle { .sendbird-message-content__middle__quote-message.use-quote { bottom: -8px; @@ -89,6 +102,11 @@ bottom: -16px; right: 0px; } + .ui_container_type__full { + min-width: fit-content; + bottom: -16px; + right: 0px; + } } .sendbird-message-content__middle__sender-name { @@ -240,6 +258,11 @@ bottom: -20px; left: 0px; } + .ui_container_type__full { + min-width: fit-content; + bottom: -20px; + left: 0px; + } } } diff --git a/src/ui/MessageContent/index.tsx b/src/ui/MessageContent/index.tsx index 3878aed12..51f3b8ca8 100644 --- a/src/ui/MessageContent/index.tsx +++ b/src/ui/MessageContent/index.tsx @@ -35,7 +35,7 @@ import MobileMenu from '../MobileMenu'; import { useMediaQueryContext } from '../../lib/MediaQueryContext'; import ThreadReplies from '../ThreadReplies'; import { ThreadReplySelectType } from '../../modules/Channel/context/const'; -import { Nullable, ReplyType } from '../../types'; +import { MessageContentMiddleContainerType, Nullable, ReplyType } from '../../types'; import { noop } from '../../utils/utils'; import MessageProfile, { MessageProfileProps } from './MessageProfile'; import MessageBody, { MessageBodyProps } from './MessageBody'; @@ -48,6 +48,9 @@ import { SbFeedbackStatus } from './types'; import MessageFeedbackFailedModal from '../../modules/Channel/components/MessageFeedbackFailedModal'; import { MobileBottomSheetProps } from '../MobileMenu/types'; +const TIMESTAMP_WIDTH = 71; +const LEFT_WIDTH = 40; + export interface MessageContentProps { className?: string | Array; userId: string; @@ -76,7 +79,7 @@ export interface MessageContentProps { onQuoteMessageClick?: (props: { message: SendableMessageType }) => void; onMessageHeightChange?: () => void; - // For injecting customizable sub-components + // For injecting customizable subcomponents renderSenderProfile?: (props: MessageProfileProps) => ReactNode; renderMessageBody?: (props: MessageBodyProps) => ReactNode; renderMessageHeader?: (props: MessageHeaderProps) => ReactNode; @@ -202,6 +205,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement isMobile, isMiddleFullWidth, }); + const isFullType = message.extendedMessagePayload?.['ui']?.['container_type'] === MessageContentMiddleContainerType.FULL; const isTimestampBottom = !!messageContentMiddleClassNameByType; const onCloseFeedbackForm = () => { @@ -256,7 +260,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement if (contentRef.current) { const parentWidth = contentRef.current.parentNode.clientWidth; const elementWidth = contentRef.current.clientWidth; - setIsMiddleFullWidth(elementWidth + 80 > parentWidth); + setIsMiddleFullWidth(elementWidth + TIMESTAMP_WIDTH + LEFT_WIDTH > parentWidth); } }; processMiddleWidth(); @@ -272,12 +276,14 @@ export default function MessageContent(props: MessageContentProps): ReactElement className, 'sendbird-message-content', isByMeClassName, + messageContentMiddleClassNameByType, ])} onMouseOver={() => setMouseHover(true)} onMouseLeave={() => setMouseHover(false)} > {/* left */} -
+ {!isFullType + &&
{ renderSenderProfile({ ...props, @@ -320,7 +326,8 @@ export default function MessageContent(props: MessageContentProps): ReactElement )}
)} -
+
} + {/* middle */}
} @@ -520,6 +527,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement
}
+ {/* right */} {showRightContent && (
)} + { showMenu && ( message?.isUserMessage?.() || message?.isFileMessage?.() || message?.isMultipleFilesMessage?.() diff --git a/src/utils/index.ts b/src/utils/index.ts index 3563bd892..244d83d85 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -288,11 +288,17 @@ export const getMessageContentMiddleClassNameByContainerType = ({ }): string => { if (!isMobile || !isMiddleFullWidth) return ''; + /** + * FULL: template message only. + * WIDE: all message types. + */ const containerType: string | undefined = message.extendedMessagePayload?.['ui']?.['container_type']; - return match(MessageContentMiddleContainerType.WIDE) - .with(MessageContentMiddleContainerType.WIDE, () => 'ui_container_type__wide') - .with(MessageContentMiddleContainerType.FULL, () => 'ui_container_type__wide') - .otherwise(() => ''); + if (isTemplateMessage(message) && containerType === MessageContentMiddleContainerType.FULL) { + return 'ui_container_type__full'; + } else if (containerType === MessageContentMiddleContainerType.WIDE) { + return 'ui_container_type__wide'; + } + return ''; }; export const isOGMessage = (message: SendableMessageType): boolean => !!( From 484fceeb3194fe0a6bdf7864fb6f88ca43e750a1 Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Fri, 15 Mar 2024 17:20:33 +0900 Subject: [PATCH 5/5] feat: Carousel UI component (#1002) Fixes: [AC-1230](https://sendbird.atlassian.net/browse/AC-1230) ### Changelogs - Added `Carousel` ui component - `MessageTemplate` now supports composite templates [AC-1230]: https://sendbird.atlassian.net/browse/AC-1230?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ reference: https://stackblitz.com/edit/stackblitz-starters-bmuznq?file=src%2FCarousel.tsx,src%2FApp.tsx --- package.json | 2 +- src/lib/dux/appInfo/actionTypes.ts | 12 +- src/lib/dux/appInfo/initialState.ts | 4 +- src/lib/dux/appInfo/reducers.ts | 52 ++-- src/lib/dux/appInfo/utils.ts | 2 +- src/lib/hooks/useMessageTemplateUtils.ts | 74 ++++-- src/lib/types.ts | 2 +- .../components/Message/MessageView.tsx | 2 +- .../components/MessageList/index.tsx | 5 +- .../MessageTemplateWrapper/index.tsx | 7 +- src/ui/Carousel/index.scss | 4 + src/ui/Carousel/index.tsx | 236 +++++++++++++++++ src/ui/MessageContent/MessageBody/index.tsx | 2 +- src/ui/MessageContent/index.scss | 15 +- src/ui/MessageContent/index.tsx | 63 ++--- src/ui/MessageTemplate/index.scss | 4 + src/ui/MessageTemplate/index.tsx | 4 +- src/ui/TemplateMessageItemBody/index.tsx | 243 ++++++++++++++---- src/ui/TemplateMessageItemBody/types.ts | 19 +- .../TemplateMessageItemBody/utils/mapData.ts | 14 +- .../utils/selectColorVariablesByTheme.ts | 14 +- src/utils/index.ts | 40 ++- yarn.lock | 10 +- 23 files changed, 641 insertions(+), 189 deletions(-) create mode 100644 src/ui/Carousel/index.scss create mode 100644 src/ui/Carousel/index.tsx diff --git a/package.json b/package.json index 1f6542177..7b689c532 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "react-dom": "^16.8.6 || ^17.0.0 || ^18.0.0" }, "dependencies": { - "@sendbird/chat": "^4.11.0", + "@sendbird/chat": "^4.11.3", "@sendbird/react-uikit-message-template-view": "0.0.1-alpha.65", "@sendbird/uikit-tools": "0.0.1-alpha.65", "css-vars-ponyfill": "^2.3.2", diff --git a/src/lib/dux/appInfo/actionTypes.ts b/src/lib/dux/appInfo/actionTypes.ts index abc952336..f90c4ebdc 100644 --- a/src/lib/dux/appInfo/actionTypes.ts +++ b/src/lib/dux/appInfo/actionTypes.ts @@ -3,9 +3,9 @@ import { MessageTemplatesInfo, ProcessedMessageTemplate } from './initialState'; export const APP_INFO_ACTIONS = { INITIALIZE_MESSAGE_TEMPLATES_INFO: 'INITIALIZE_MESSAGE_TEMPLATES_INFO', - UPSERT_MESSAGE_TEMPLATE: 'UPSERT_MESSAGE_TEMPLATE', - UPSERT_WAITING_TEMPLATE_KEY: 'UPSERT_WAITING_TEMPLATE_KEY', - MARK_ERROR_WAITING_TEMPLATE_KEY: 'MARK_ERROR_WAITING_TEMPLATE_KEY', + UPSERT_MESSAGE_TEMPLATES: 'UPSERT_MESSAGE_TEMPLATES', + UPSERT_WAITING_TEMPLATE_KEYS: 'UPSERT_WAITING_TEMPLATE_KEYS', + MARK_ERROR_WAITING_TEMPLATE_KEYS: 'MARK_ERROR_WAITING_TEMPLATE_KEYS', } as const; export type TemplatesMapData = { @@ -15,9 +15,9 @@ export type TemplatesMapData = { type APP_INFO_PAYLOAD_TYPES = { [APP_INFO_ACTIONS.INITIALIZE_MESSAGE_TEMPLATES_INFO]: MessageTemplatesInfo, - [APP_INFO_ACTIONS.UPSERT_MESSAGE_TEMPLATE]: TemplatesMapData, - [APP_INFO_ACTIONS.UPSERT_WAITING_TEMPLATE_KEY]: { key: string, requestedAt: number }, - [APP_INFO_ACTIONS.MARK_ERROR_WAITING_TEMPLATE_KEY]: { key: string }, + [APP_INFO_ACTIONS.UPSERT_MESSAGE_TEMPLATES]: TemplatesMapData[], + [APP_INFO_ACTIONS.UPSERT_WAITING_TEMPLATE_KEYS]: { keys: string[], requestedAt: number }, + [APP_INFO_ACTIONS.MARK_ERROR_WAITING_TEMPLATE_KEYS]: { keys: string[], messageId: number }, }; export type AppInfoActionTypes = CreateAction; diff --git a/src/lib/dux/appInfo/initialState.ts b/src/lib/dux/appInfo/initialState.ts index 0e380aab4..b3ccc7d81 100644 --- a/src/lib/dux/appInfo/initialState.ts +++ b/src/lib/dux/appInfo/initialState.ts @@ -1,5 +1,5 @@ export type ProcessedMessageTemplate = { - uiTemplate: string; // This is stringified ui_template. + uiTemplate: string; // This is stringified ui_template.body.items colorVariables?: Record; }; @@ -10,7 +10,7 @@ export interface MessageTemplatesInfo { export interface WaitingTemplateKeyData { requestedAt: number; - isError: boolean; + erroredMessageIds: number[]; } export interface AppInfoStateType { diff --git a/src/lib/dux/appInfo/reducers.ts b/src/lib/dux/appInfo/reducers.ts index 0e72c2c38..3494608c6 100644 --- a/src/lib/dux/appInfo/reducers.ts +++ b/src/lib/dux/appInfo/reducers.ts @@ -13,40 +13,54 @@ export default function reducer(state: AppInfoStateType, action: AppInfoActionTy }; }) .with( - { type: APP_INFO_ACTIONS.UPSERT_MESSAGE_TEMPLATE }, + { type: APP_INFO_ACTIONS.UPSERT_MESSAGE_TEMPLATES }, ({ payload }) => { const templatesInfo = state.messageTemplatesInfo; if (!templatesInfo) return state; // Not initialized. Ignore. - const { key, template } = payload; - templatesInfo.templatesMap[key] = template; - - delete state.waitingTemplateKeysMap[key]; - + const waitingTemplateKeysMap = { ...state.waitingTemplateKeysMap }; + payload.forEach((templatesMapData) => { + const { key, template } = templatesMapData; + templatesInfo.templatesMap[key] = template; + delete waitingTemplateKeysMap[key]; + }); return { ...state, + waitingTemplateKeysMap, messageTemplatesInfo: templatesInfo, }; }) .with( - { type: APP_INFO_ACTIONS.UPSERT_WAITING_TEMPLATE_KEY }, + { type: APP_INFO_ACTIONS.UPSERT_WAITING_TEMPLATE_KEYS }, ({ payload }) => { - const { key, requestedAt } = payload; - state.waitingTemplateKeysMap[key] = { - requestedAt, - isError: false, + const { keys, requestedAt } = payload; + const waitingTemplateKeysMap = { ...state.waitingTemplateKeysMap }; + keys.forEach((key) => { + waitingTemplateKeysMap[key] = { + erroredMessageIds: waitingTemplateKeysMap[key]?.erroredMessageIds ?? [], + requestedAt, + }; + }); + return { + ...state, + waitingTemplateKeysMap, }; - return { ...state }; }) .with( - { type: APP_INFO_ACTIONS.MARK_ERROR_WAITING_TEMPLATE_KEY }, + { type: APP_INFO_ACTIONS.MARK_ERROR_WAITING_TEMPLATE_KEYS }, ({ payload }) => { - const { key } = payload; - const waitingTemplateKeyData: WaitingTemplateKeyData | undefined = state.waitingTemplateKeysMap[key]; - if (waitingTemplateKeyData) { - waitingTemplateKeyData.isError = true; - } - return { ...state }; + const { keys, messageId } = payload; + const waitingTemplateKeysMap = { ...state.waitingTemplateKeysMap }; + keys.forEach((key) => { + const waitingTemplateKeyData: WaitingTemplateKeyData | undefined = waitingTemplateKeysMap[key]; + if (waitingTemplateKeyData && waitingTemplateKeyData.erroredMessageIds.indexOf(messageId) === -1) { + waitingTemplateKeyData.erroredMessageIds.push(messageId); + } + }); + return { + ...state, + waitingTemplateKeysMap, + }; }) .otherwise(() => { return state; diff --git a/src/lib/dux/appInfo/utils.ts b/src/lib/dux/appInfo/utils.ts index 01823b9fe..11159c462 100644 --- a/src/lib/dux/appInfo/utils.ts +++ b/src/lib/dux/appInfo/utils.ts @@ -11,7 +11,7 @@ export const getProcessedTemplate = (parsedTemplate: SendbirdMessageTemplate): P }; }; -export const getProcessedTemplates = ( +export const getProcessedTemplatesMap = ( parsedTemplates: SendbirdMessageTemplate[], ): Record => { const processedTemplates = {}; diff --git a/src/lib/hooks/useMessageTemplateUtils.ts b/src/lib/hooks/useMessageTemplateUtils.ts index b4c8725d7..e0a6fef40 100644 --- a/src/lib/hooks/useMessageTemplateUtils.ts +++ b/src/lib/hooks/useMessageTemplateUtils.ts @@ -1,7 +1,7 @@ import React from 'react'; import { AppInfoStateType, MessageTemplatesInfo, ProcessedMessageTemplate } from '../dux/appInfo/initialState'; import { SendbirdMessageTemplate } from '../../ui/TemplateMessageItemBody/types'; -import { getProcessedTemplate, getProcessedTemplates } from '../dux/appInfo/utils'; +import { getProcessedTemplate, getProcessedTemplatesMap } from '../dux/appInfo/utils'; import SendbirdChat from '@sendbird/chat'; import { APP_INFO_ACTIONS, AppInfoActionTypes } from '../dux/appInfo/actionTypes'; import { CACHED_MESSAGE_TEMPLATES_KEY, CACHED_MESSAGE_TEMPLATES_TOKEN_KEY } from '../../utils/consts'; @@ -18,15 +18,15 @@ interface UseMessageTemplateUtilsProps { export interface UseMessageTemplateUtilsWrapper { getCachedTemplate: (key: string) => ProcessedMessageTemplate | null; - updateMessageTemplatesInfo: (templateKey: string, requestedAt: number) => Promise; + updateMessageTemplatesInfo: (templateKeys: string[], messageId: number, requestedAt: number) => Promise; initializeMessageTemplatesInfo: (readySdk: SendbirdChat) => Promise; } const { INITIALIZE_MESSAGE_TEMPLATES_INFO, - UPSERT_MESSAGE_TEMPLATE, - UPSERT_WAITING_TEMPLATE_KEY, - MARK_ERROR_WAITING_TEMPLATE_KEY, + UPSERT_MESSAGE_TEMPLATES, + UPSERT_WAITING_TEMPLATE_KEYS, + MARK_ERROR_WAITING_TEMPLATE_KEYS, } = APP_INFO_ACTIONS; export default function useMessageTemplateUtils({ @@ -105,7 +105,7 @@ export default function useMessageTemplateUtils({ const parsedTemplates = await fetchAllMessageTemplates(readySdk); const newMessageTemplatesInfo: MessageTemplatesInfo = { token: sdkMessageTemplateToken, - templatesMap: getProcessedTemplates(parsedTemplates), + templatesMap: getProcessedTemplatesMap(parsedTemplates), }; appInfoDispatcher({ type: INITIALIZE_MESSAGE_TEMPLATES_INFO, payload: newMessageTemplatesInfo }); localStorage.setItem(CACHED_MESSAGE_TEMPLATES_TOKEN_KEY, sdkMessageTemplateToken); @@ -118,7 +118,7 @@ export default function useMessageTemplateUtils({ const parsedTemplates: SendbirdMessageTemplate[] = JSON.parse(cachedMessageTemplates); const newMessageTemplatesInfo: MessageTemplatesInfo = { token: sdkMessageTemplateToken, - templatesMap: getProcessedTemplates(parsedTemplates), + templatesMap: getProcessedTemplatesMap(parsedTemplates), }; appInfoDispatcher({ type: INITIALIZE_MESSAGE_TEMPLATES_INFO, payload: newMessageTemplatesInfo }); } @@ -128,54 +128,72 @@ export default function useMessageTemplateUtils({ * If given message is a template message with template key and if the key does not exist in the cache, * update the cache by fetching the template. */ - const updateMessageTemplatesInfo = async (templateKey: string, requestedAt: number): Promise => { + const updateMessageTemplatesInfo = async ( + templateKeys: string[], + messageId: number, + requestedAt: number, + ): Promise => { if (appInfoDispatcher) { appInfoDispatcher({ - type: UPSERT_WAITING_TEMPLATE_KEY, + type: UPSERT_WAITING_TEMPLATE_KEYS, payload: { - key: templateKey, + keys: templateKeys, requestedAt, }, }); - - let parsedTemplate: SendbirdMessageTemplate | null = null; + const newParsedTemplates: SendbirdMessageTemplate[] | null = []; try { - const newTemplate = await sdk.message.getMessageTemplate(templateKey); - parsedTemplate = JSON.parse(newTemplate.template); + let hasMore = true; + let token = null; + while (hasMore) { + const result = await sdk.message.getMessageTemplatesByToken(token, { + keys: templateKeys, + }); + result.templates.forEach((newTemplate) => { + newParsedTemplates.push(JSON.parse(newTemplate.template)); + }); + hasMore = result.hasMore; + token = result.token; + } } catch (e) { - logger?.error?.('Sendbird | fetchProcessedMessageTemplate failed', e); + logger?.error?.('Sendbird | fetchProcessedMessageTemplates failed', e, templateKeys); } - - if (parsedTemplate) { + if (newParsedTemplates.length > 0) { // Update cache const cachedMessageTemplates: string | null = localStorage.getItem(CACHED_MESSAGE_TEMPLATES_KEY); if (cachedMessageTemplates) { const parsedTemplates: SendbirdMessageTemplate[] = JSON.parse(cachedMessageTemplates); - parsedTemplates.push(parsedTemplate); + const existingKeys = parsedTemplates.map((parsedTemplate) => parsedTemplate.key); + newParsedTemplates.forEach((newParsedTemplate) => { + if (!existingKeys.includes(newParsedTemplate.key)) { + parsedTemplates.push(newParsedTemplate); + } + }); localStorage.setItem(CACHED_MESSAGE_TEMPLATES_KEY, JSON.stringify(parsedTemplates)); } else { - localStorage.setItem(CACHED_MESSAGE_TEMPLATES_KEY, JSON.stringify([parsedTemplate])); + localStorage.setItem(CACHED_MESSAGE_TEMPLATES_KEY, JSON.stringify([newParsedTemplates])); } // Update memory - const processedTemplate: ProcessedMessageTemplate = getProcessedTemplate(parsedTemplate); appInfoDispatcher({ - type: UPSERT_MESSAGE_TEMPLATE, - payload: { - key: templateKey, - template: processedTemplate, - }, + type: UPSERT_MESSAGE_TEMPLATES, + payload: newParsedTemplates.map((newParsedTemplate) => { + return { + key: newParsedTemplate.key, + template: getProcessedTemplate(newParsedTemplate), + }; + }), }); } else { appInfoDispatcher({ - type: MARK_ERROR_WAITING_TEMPLATE_KEY, + type: MARK_ERROR_WAITING_TEMPLATE_KEYS, payload: { - key: templateKey, + keys: templateKeys, + messageId, }, }); } } }; - return { getCachedTemplate, updateMessageTemplatesInfo, diff --git a/src/lib/types.ts b/src/lib/types.ts index a3648483e..44be5cc5f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -257,6 +257,6 @@ export type SendbirdChatInitParams = Omit, 'appId'> export type CustomExtensionParams = Record; export type SendbirdProviderUtils = { - updateMessageTemplatesInfo: (templateKey: string, createdAt: number) => Promise; + updateMessageTemplatesInfo: (templateKeys: string[], messageId: number, createdAt: number) => Promise; getCachedTemplate: (key: string) => ProcessedMessageTemplate | null; }; diff --git a/src/modules/GroupChannel/components/Message/MessageView.tsx b/src/modules/GroupChannel/components/Message/MessageView.tsx index 0569edf4d..d2e26f3f6 100644 --- a/src/modules/GroupChannel/components/Message/MessageView.tsx +++ b/src/modules/GroupChannel/components/Message/MessageView.tsx @@ -260,7 +260,7 @@ const MessageView = (props: MessageViewProps) => { setQuoteMessage, onReplyInThread: onReplyInThreadClick, onQuoteMessageClick: onQuoteMessageClick, - onMessageHeightChange: handleScroll, + onMessageHeightChange: (a?: boolean) => handleScroll(a), })} { /* Suggested Replies */ } { diff --git a/src/modules/GroupChannel/components/MessageList/index.tsx b/src/modules/GroupChannel/components/MessageList/index.tsx index a83165eda..67d98550b 100644 --- a/src/modules/GroupChannel/components/MessageList/index.tsx +++ b/src/modules/GroupChannel/components/MessageList/index.tsx @@ -153,7 +153,10 @@ export const MessageList = ({
-
+
{messages.map((message, idx) => { const { chainTop, chainBottom, hasSeparator } = getMessagePartsInfo({ allMessages: messages as CoreMessageType[], diff --git a/src/modules/GroupChannel/components/MessageTemplateWrapper/index.tsx b/src/modules/GroupChannel/components/MessageTemplateWrapper/index.tsx index b859d97e6..0a8989018 100644 --- a/src/modules/GroupChannel/components/MessageTemplateWrapper/index.tsx +++ b/src/modules/GroupChannel/components/MessageTemplateWrapper/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactElement } from 'react'; import { BaseMessage } from '@sendbird/chat/message'; import MessageTemplate, { MessageTemplateProps } from '../../../../ui/MessageTemplate'; import { MessageProvider as MessageTemplateProvider } from '@sendbird/react-uikit-message-template-view'; @@ -7,7 +7,10 @@ export interface MessageTemplateWrapperProps extends MessageTemplateProps { message: BaseMessage; } -export const MessageTemplateWrapper = ({ message, templateItems }: MessageTemplateWrapperProps) => { +export const MessageTemplateWrapper = ({ + message, + templateItems, +}: MessageTemplateWrapperProps): ReactElement => { return ; diff --git a/src/ui/Carousel/index.scss b/src/ui/Carousel/index.scss new file mode 100644 index 000000000..2b36c0334 --- /dev/null +++ b/src/ui/Carousel/index.scss @@ -0,0 +1,4 @@ +.sendbird-carousel-items-wrapper { + display: flex; + box-sizing: border-box; +} \ No newline at end of file diff --git a/src/ui/Carousel/index.tsx b/src/ui/Carousel/index.tsx new file mode 100644 index 000000000..70ed0af32 --- /dev/null +++ b/src/ui/Carousel/index.tsx @@ -0,0 +1,236 @@ +import './index.scss'; +import React, { ReactElement, useRef, useState } from 'react'; +import { useMediaQueryContext } from '../../lib/MediaQueryContext'; + +const PADDING_WIDTH = 24; +const CONTENT_LEFT_WIDTH = 40; +const SWIPE_THRESHOLD = 30; +const LAST_ITEM_RIGHT_SNAP_THRESHOLD = 100; + +interface ItemPosition { + start: number; + end: number; +} + +interface CarouselItemProps { + key: string; + item: ReactElement; + defaultWidth: string; +} + +/** + * fixed sized template items should use its child width. + * Whereas flex sized template items should use its parent's width. + * @param item + */ +function shouldRenderAsFixed(item: ReactElement) { + return item.props.templateItems[0].width?.type === 'fixed'; +} + +function CarouselItem({ + key, + item, + defaultWidth, +}: CarouselItemProps): ReactElement { + return
+ {item} +
; +} + +interface CarouselProps { + id: string; + items: ReactElement[]; + gap?: number; +} + +export function Carousel({ + id, + items, + gap = 8, +}: CarouselProps): ReactElement { + const { isMobile } = useMediaQueryContext(); + const carouselRef = useRef(null); + const screenWidth = window.innerWidth; + const defaultItemWidth = carouselRef.current?.clientWidth ?? 0; + const itemWidths = items.map((item) => { + if (shouldRenderAsFixed(item)) { + return item.props.templateItems[0].width?.value; + } + return defaultItemWidth; + }); + const allItemsWidth = itemWidths.reduce((prev, curr) => prev + gap + curr); + const lastItemWidth = itemWidths[itemWidths.length - 1]; + const isLastItemNarrow = lastItemWidth <= LAST_ITEM_RIGHT_SNAP_THRESHOLD; + const isLastTwoItemsFitScreen = getIsLastTwoItemsFitScreen(); + const itemPositions: ItemPosition[] = getEachItemPositions(); + const [currentIndex, setCurrentIndex] = useState(0); + const [dragging, setDragging] = useState<'vertical' | 'horizontal' | null>(null); + const [startX, setStartX] = useState(0); + const [offset, setOffset] = useState(0); + const [translateX, setTranslateX] = useState(0); + const handleMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + setDragging('horizontal'); + setStartX(event.clientX); + }; + + const handleMouseMove = (event: React.MouseEvent) => { + if (!dragging) return; + const currentX = event.clientX; + const newOffset = currentX - startX; + setOffset(newOffset); + }; + + const handleMouseUp = () => { + if (!dragging) return; + setDragging(null); + onDragEnd(); + }; + + const handleTouchStart = (event: React.TouchEvent) => { + setStartX(event.touches[0].clientX); + }; + + const handleTouchMove = (event: React.TouchEvent) => { + if (!startX) return; + const touchMoveX = event.touches[0].clientX; + const deltaX = Math.abs(touchMoveX - startX); + const deltaY = Math.abs(event.touches[0].clientY - event.touches[event.touches.length - 1].clientY); + const threshold = 5; + + if (dragging === 'horizontal' || (dragging !== 'vertical' && deltaX > deltaY + threshold)) { + const parentElement = document.getElementsByClassName('sendbird-conversation__messages-padding'); + (parentElement[0] as HTMLElement).style.overflowY = 'hidden'; + if (dragging !== 'horizontal') setDragging('horizontal'); + const newOffset = event.touches[0].clientX - startX; + if (newOffset !== offset) setOffset(newOffset); + } else if (dragging !== 'vertical') setDragging('vertical'); + }; + + const handleTouchEnd = () => { + if (dragging !== null) { + setDragging(null); + } + onDragEnd(); + }; + + const handleDragEnd = () => { + const absOffset = Math.abs(offset); + if (absOffset >= SWIPE_THRESHOLD) { + // If dragged to left, next index should be to the right + if (offset < 0 && currentIndex < items.length - 1) { + const nextIndex = currentIndex + 1; + setTranslateX(itemPositions[nextIndex].start); + setCurrentIndex(nextIndex); + // If dragged to right, next index should be to the left + } else if (offset > 0 && currentIndex > 0) { + const nextIndex = currentIndex - 1; + setTranslateX(itemPositions[nextIndex].start); + setCurrentIndex(nextIndex); + } + } + setOffset(0); + }; + + const handleDragEndForMobile = () => { + const absOffset = Math.abs(offset); + if (absOffset >= SWIPE_THRESHOLD) { + // If dragged to left, next index should be to the right + if (offset < 0 && currentIndex < items.length - 1) { + const nextIndex = currentIndex + 1; + /** + * This is special logic for "더 보기" button for Socar use-case. + * The button will have a small width (less than 50px). + * We want to include this button in the view and snap to right padding wall IFF !isLastTwoItemsFitScreen. + */ + if (isLastItemNarrow) { + if (isLastTwoItemsFitScreen) { + if (nextIndex !== items.length - 1) { + setTranslateX(itemPositions[nextIndex].start); + setCurrentIndex(nextIndex); + } + } else if (nextIndex !== items.length - 1) { + setTranslateX(itemPositions[nextIndex].start); + setCurrentIndex(nextIndex); + } else { + const translateWidth = itemPositions[nextIndex].start - lastItemWidth; + const rightEmptyWidth = screenWidth - (allItemsWidth + translateWidth + PADDING_WIDTH + CONTENT_LEFT_WIDTH); + setTranslateX(translateWidth + rightEmptyWidth); + setCurrentIndex(nextIndex); + } + } else { + setTranslateX(itemPositions[nextIndex].start); + setCurrentIndex(nextIndex); + } + // If dragged to right, next index should be to the left + } else if (offset > 0 && currentIndex > 0) { + const nextIndex = currentIndex - 1; + setTranslateX(itemPositions[nextIndex].start); + setCurrentIndex(nextIndex); + } + } + setOffset(0); + const parentElement = document.getElementsByClassName('sendbird-conversation__messages-padding'); + (parentElement[0] as HTMLElement).style.overflowY = 'scroll'; + }; + + function getCurrentTranslateX() { + return translateX + offset; + } + + function getIsLastTwoItemsFitScreen() { + const restItemsWidth = itemWidths.slice(-2).reduce((prev, curr) => prev + gap + curr); + const restTotalWidth = PADDING_WIDTH + CONTENT_LEFT_WIDTH + restItemsWidth; + return restTotalWidth <= screenWidth; + } + + const onDragEnd = isMobile ? handleDragEndForMobile : handleDragEnd; + const currentTranslateX = getCurrentTranslateX(); + + function getEachItemPositions(): ItemPosition[] { + let accumulator = 0; + return itemWidths.map((itemWidth, i): ItemPosition => { + if (i > 0) { + accumulator -= gap; + } + const itemPosition = { + start: accumulator, + end: accumulator - itemWidth, + }; + accumulator -= itemWidth; + return itemPosition; + }); + } + + return ( +
+
+ {items.map((item, index) => ( + + ))} +
+
+ ); +} + +export default Carousel; diff --git a/src/ui/MessageContent/MessageBody/index.tsx b/src/ui/MessageContent/MessageBody/index.tsx index 6c01a747d..3a9e370ca 100644 --- a/src/ui/MessageContent/MessageBody/index.tsx +++ b/src/ui/MessageContent/MessageBody/index.tsx @@ -28,7 +28,7 @@ export interface MessageBodyProps { channel: Nullable; message: CoreMessageType; showFileViewer?: (bool: boolean) => void; - onMessageHeightChange?: () => void; + onMessageHeightChange?: (isBottomMessageAffected?: boolean) => void; mouseHover: boolean; isMobile: boolean; diff --git a/src/ui/MessageContent/index.scss b/src/ui/MessageContent/index.scss index 7a664d9ca..f9862aa0a 100644 --- a/src/ui/MessageContent/index.scss +++ b/src/ui/MessageContent/index.scss @@ -1,6 +1,6 @@ @import '../../styles/variables'; -.sendbird-msg-hoc > .ui_container_type__full{ +.sendbird-msg-hoc > .ui_container_type__full { @include mobile() { padding: 0; width: 100%; @@ -97,6 +97,11 @@ display: none; } } + .ui_container_type__default-carousel { + min-width: fit-content; + bottom: -16px; + right: 0px; + } .ui_container_type__wide { min-width: fit-content; bottom: -16px; @@ -181,6 +186,9 @@ .sendbird-message-content__middle__body-container__created-at { display: none; } + .ui_container_type__default-carousel { + display: flex; + } } } } @@ -253,6 +261,11 @@ position: relative; } } + .ui_container_type__default-carousel { + min-width: fit-content; + bottom: -20px; + left: 0px; + } .ui_container_type__wide { min-width: fit-content; bottom: -20px; diff --git a/src/ui/MessageContent/index.tsx b/src/ui/MessageContent/index.tsx index 51f3b8ca8..8390a3f5c 100644 --- a/src/ui/MessageContent/index.tsx +++ b/src/ui/MessageContent/index.tsx @@ -1,29 +1,26 @@ -import React, { - ReactElement, ReactNode, useContext, useEffect, - useRef, - useState, -} from 'react'; +import React, { ReactElement, ReactNode, useContext, useEffect, useRef, useState } from 'react'; import format from 'date-fns/format'; import './index.scss'; import MessageStatus from '../MessageStatus'; import { MessageMenu, MessageMenuProps } from '../MessageItemMenu'; import { MessageEmojiMenu, MessageEmojiMenuProps } from '../MessageItemReactionMenu'; -import Label, { LabelTypography, LabelColors } from '../Label'; +import Label, { LabelColors, LabelTypography } from '../Label'; import EmojiReactions, { EmojiReactionsProps } from '../EmojiReactions'; import ClientAdminMessage from '../AdminMessage'; import QuoteMessage from '../QuoteMessage'; import { + CoreMessageType, getClassName, + getMessageContentMiddleClassNameByContainerType, + isMultipleFilesMessage, isOGMessage, + isTemplateMessage, isThumbnailMessage, SendableMessageType, - CoreMessageType, - isMultipleFilesMessage, - isTemplateMessage, - getMessageContentMiddleClassNameByContainerType, + UI_CONTAINER_TYPES, } from '../../utils'; import { LocalizationContext, useLocalization } from '../../lib/LocalizationContext'; import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; @@ -35,7 +32,7 @@ import MobileMenu from '../MobileMenu'; import { useMediaQueryContext } from '../../lib/MediaQueryContext'; import ThreadReplies from '../ThreadReplies'; import { ThreadReplySelectType } from '../../modules/Channel/context/const'; -import { MessageContentMiddleContainerType, Nullable, ReplyType } from '../../types'; +import { Nullable, ReplyType } from '../../types'; import { noop } from '../../utils/utils'; import MessageProfile, { MessageProfileProps } from './MessageProfile'; import MessageBody, { MessageBodyProps } from './MessageBody'; @@ -48,9 +45,6 @@ import { SbFeedbackStatus } from './types'; import MessageFeedbackFailedModal from '../../modules/Channel/components/MessageFeedbackFailedModal'; import { MobileBottomSheetProps } from '../MobileMenu/types'; -const TIMESTAMP_WIDTH = 71; -const LEFT_WIDTH = 40; - export interface MessageContentProps { className?: string | Array; userId: string; @@ -77,7 +71,7 @@ export interface MessageContentProps { onReplyInThread?: (props: { message: SendableMessageType }) => void; // onClick listener for thread quote message view (for open thread module) onQuoteMessageClick?: (props: { message: SendableMessageType }) => void; - onMessageHeightChange?: () => void; + onMessageHeightChange?: (isBottomMessageAffected?: boolean) => void; // For injecting customizable subcomponents renderSenderProfile?: (props: MessageProfileProps) => ReactNode; @@ -157,9 +151,13 @@ export default function MessageContent(props: MessageContentProps): ReactElement const [showFeedbackOptionsMenu, setShowFeedbackOptionsMenu] = useState(false); const [showFeedbackModal, setShowFeedbackModal] = useState(false); const [feedbackFailedText, setFeedbackFailedText] = useState(''); - const [isMiddleFullWidth, setIsMiddleFullWidth] = useState(false); const [totalBottom, setTotalBottom] = useState(0); + const uiContainerType: UI_CONTAINER_TYPES = getMessageContentMiddleClassNameByContainerType({ + message, + isMobile, + }); + const { stringSet } = useContext(LocalizationContext); const isByMe = (userId === (message as SendableMessageType)?.sender?.userId) @@ -200,13 +198,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement const showThreadReplies = isNotTemplateMessage && displayThreadReplies; const showRightContent = isNotTemplateMessage && !isByMe && !isMobile; - const messageContentMiddleClassNameByType = getMessageContentMiddleClassNameByContainerType({ - message, - isMobile, - isMiddleFullWidth, - }); - const isFullType = message.extendedMessagePayload?.['ui']?.['container_type'] === MessageContentMiddleContainerType.FULL; - const isTimestampBottom = !!messageContentMiddleClassNameByType; + const isTimestampBottom = !!uiContainerType; const onCloseFeedbackForm = () => { setShowFeedbackModal(false); @@ -256,18 +248,6 @@ export default function MessageContent(props: MessageContentProps): ReactElement return sum; }; setTotalBottom(getTotalBottom()); - const processMiddleWidth = () => { - if (contentRef.current) { - const parentWidth = contentRef.current.parentNode.clientWidth; - const elementWidth = contentRef.current.clientWidth; - setIsMiddleFullWidth(elementWidth + TIMESTAMP_WIDTH + LEFT_WIDTH > parentWidth); - } - }; - processMiddleWidth(); - window.addEventListener('resize', processMiddleWidth); - return () => { - window.removeEventListener('resize', processMiddleWidth); - }; }, [isTimestampBottom]); return ( @@ -276,13 +256,13 @@ export default function MessageContent(props: MessageContentProps): ReactElement className, 'sendbird-message-content', isByMeClassName, - messageContentMiddleClassNameByType, + uiContainerType, ])} onMouseOver={() => setMouseHover(true)} onMouseLeave={() => setMouseHover(false)} > {/* left */} - {!isFullType + {uiContainerType !== UI_CONTAINER_TYPES.FULL &&
{ renderSenderProfile({ @@ -333,10 +313,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement className={getClassName([ 'sendbird-message-content__middle', isTemplateMessage(message) ? 'sendbird-message-content__middle__for_template_message' : '', - getMessageContentMiddleClassNameByContainerType({ - message, - isMobile, - }), + uiContainerType, ])} {...(isMobile ? { ...longPress } : {})} ref={contentRef} @@ -383,7 +360,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement 'sendbird-message-content__middle__body-container__created-at', 'left', supposedHoverClassName, - messageContentMiddleClassNameByType, + uiContainerType, ])} ref={timestampRef} > @@ -439,7 +416,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement 'sendbird-message-content__middle__body-container__created-at', 'right', supposedHoverClassName, - messageContentMiddleClassNameByType, + uiContainerType, ])} type={LabelTypography.CAPTION_3} color={LabelColors.ONBACKGROUND_2} diff --git a/src/ui/MessageTemplate/index.scss b/src/ui/MessageTemplate/index.scss index ad1360088..3fe0415aa 100644 --- a/src/ui/MessageTemplate/index.scss +++ b/src/ui/MessageTemplate/index.scss @@ -1,3 +1,7 @@ .sendbird-message-template__root * { box-sizing: border-box; } + +.sendbird-message-template__root { + border-radius: 0; +} \ No newline at end of file diff --git a/src/ui/MessageTemplate/index.tsx b/src/ui/MessageTemplate/index.tsx index cc88eb836..dfb1f2778 100644 --- a/src/ui/MessageTemplate/index.tsx +++ b/src/ui/MessageTemplate/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { parser, renderer } from '@sendbird/react-uikit-message-template-view'; -import { createMessageTemplate } from '@sendbird/uikit-message-template'; +import { type ComponentsUnion, createMessageTemplate } from '@sendbird/uikit-message-template'; import { MessageTemplateItem } from '../TemplateMessageItemBody/types'; import './index.scss'; @@ -26,7 +26,7 @@ const { MessageTemplate: CustomTemplate } = createMessageTemplate({ }); export function MessageTemplate({ templateItems }: MessageTemplateProps) { - return ; + return ; } export default MessageTemplate; diff --git a/src/ui/TemplateMessageItemBody/index.tsx b/src/ui/TemplateMessageItemBody/index.tsx index 1851b53a7..832e47c76 100644 --- a/src/ui/TemplateMessageItemBody/index.tsx +++ b/src/ui/TemplateMessageItemBody/index.tsx @@ -1,9 +1,9 @@ import './index.scss'; import React, { ReactElement, useEffect, useState } from 'react'; import type { BaseMessage } from '@sendbird/chat/message'; -import { getClassName } from '../../utils'; +import { getClassName, removeAtAndBraces, startsWithAtAndEndsWithBraces } from '../../utils'; import MessageTemplateWrapper from '../../modules/GroupChannel/components/MessageTemplateWrapper'; -import { MessageTemplateData, MessageTemplateItem } from './types'; +import { CarouselItem, MessageTemplateData, MessageTemplateItem, SimpleTemplateData } from './types'; import restoreNumbersFromMessageTemplateObject from './utils/restoreNumbersFromMessageTemplateObject'; import mapData from './utils/mapData'; import selectColorVariablesByTheme from './utils/selectColorVariablesByTheme'; @@ -12,15 +12,23 @@ import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import { ProcessedMessageTemplate, WaitingTemplateKeyData } from '../../lib/dux/appInfo/initialState'; import FallbackTemplateMessageItemBody from './FallbackTemplateMessageItemBody'; import LoadingTemplateMessageItemBody from './LoadingTemplateMessageItemBody'; +import Carousel from '../Carousel'; import MessageTemplateErrorBoundary from '../MessageTemplate/messageTemplateErrorBoundary'; const TEMPLATE_FETCH_RETRY_BUFFER_TIME_IN_MILLIES = 500; // It takes about 450ms for isError update +interface RenderData { + filledMessageTemplateItemsList: MessageTemplateItem[][]; + carouselItem: CarouselItem; + isErrored: boolean; +} + interface TemplateMessageItemBodyProps { className?: string | Array; message: BaseMessage; isByMe?: boolean; theme?: SendbirdTheme; + onLoad?: () => void; } /** @@ -49,9 +57,6 @@ export function TemplateMessageItemBody({ isByMe = false, theme = 'light', }: TemplateMessageItemBodyProps): ReactElement { - const store = useSendbirdStateContext(); - const logger = store?.config?.logger; - const templateData: MessageTemplateData | undefined = message.extendedMessagePayload?.['template'] as MessageTemplateData; if (!templateData?.key) { return ; @@ -68,72 +73,188 @@ export function TemplateMessageItemBody({ updateMessageTemplatesInfo, } = globalState.utils; - const waitingTemplateKeysMap = globalState.stores.appInfoStore.waitingTemplateKeysMap; + const logger = globalState.config.logger; - const [ - filledMessageTemplateItems, - setFilledMessageTemplateItems, - ] = useState(() => { - const cachedTemplate = getCachedTemplate(templateData.key); - if (cachedTemplate) { - return getFilledMessageTemplateWithData( - JSON.parse(cachedTemplate.uiTemplate), - templateData.variables ?? {}, - cachedTemplate.colorVariables, - theme, - ); - } else { - return []; - } - }); - const [ - isErrored, - setIsErrored, - ] = useState(false); + const waitingTemplateKeysMap = globalState.stores.appInfoStore.waitingTemplateKeysMap; const waitingTemplateKeysMapString = Object.entries(waitingTemplateKeysMap) .map(([key, value]) => { - return [key, value.requestedAt, value.isError].join('-'); - }).join(','); + return [key, value.requestedAt, value.erroredMessageIds.join(',')].join('-'); + }).join('_'); - useEffect(() => { - // Do not put && !isErrored here in case where errored key is fetched in the future by future message - if (filledMessageTemplateItems.length === 0) { - const cachedTemplate: ProcessedMessageTemplate | null = getCachedTemplate(templateKey); - if (cachedTemplate) { + const [ + renderData, + setRenderData, + ] = useState(getFilledMessageTemplateItems()); + + function getFilledMessageTemplateItemsForCarouselTemplate(simpleTemplateDataList: SimpleTemplateData[]) { + const cachedSimpleTemplates: ProcessedMessageTemplate[] = []; + const simpleTemplatesVariables: Array | undefined> = []; + simpleTemplateDataList.forEach((simpleTemplateData: SimpleTemplateData) => { + const simpleTemplateKey = simpleTemplateData.key; + if (!simpleTemplateKey) { + logger.error('TemplateMessageItemBody | simple template keys are not found in view_variables: ', simpleTemplateDataList); + throw new Error(); + } + const simpleCachedTemplate = getCachedTemplate(simpleTemplateKey); + cachedSimpleTemplates.push(simpleCachedTemplate); + simpleTemplatesVariables.push(simpleTemplateData.variables); + }); + const filledMessageTemplateItemsList = cachedSimpleTemplates + .map((cachedSimpleTemplate, index) => { + const templateItems: MessageTemplateItem[] = JSON.parse(cachedSimpleTemplate.uiTemplate); const filledMessageTemplateItems: MessageTemplateItem[] = getFilledMessageTemplateWithData( - JSON.parse(cachedTemplate.uiTemplate), - templateData.variables ?? {}, - cachedTemplate.colorVariables, + templateItems, + simpleTemplatesVariables[index] ?? {}, + cachedSimpleTemplate.colorVariables, theme, ); - setFilledMessageTemplateItems(filledMessageTemplateItems); - } else if (!isErrored) { // This prevents duplicate GET calls by already errored message when a new message with same key is calling GET - /** - * Attempt GET template by key IFF one of below cases is met: - * 1. This is the first GET call for the template key. - * 2. Minimum buffer time has passed since the previous GET error. - */ + return filledMessageTemplateItems; + }); + return filledMessageTemplateItemsList; + } + + function getFilledMessageTemplateItemsForSimpleTemplate( + templateItems: MessageTemplateItem[], + colorVariables: Record, + ) { + const filledMessageTemplateItems: MessageTemplateItem[] = getFilledMessageTemplateWithData( + templateItems, + templateData.variables ?? {}, + colorVariables, + theme, + ); + return [filledMessageTemplateItems]; + } + + function getFilledMessageTemplateItems(): RenderData { + const result = { + filledMessageTemplateItemsList: [], + carouselItem: undefined, + isErrored: false, + }; + + const nonCachedTemplateKeys: string[] = []; + const cachedTemplate = getCachedTemplate(templateKey); + if (!cachedTemplate) { + nonCachedTemplateKeys.push(templateKey); + } + if (templateData.view_variables) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + Object.entries(templateData.view_variables).forEach(([_, simpleTemplateDataList]) => { + simpleTemplateDataList.forEach((simpleTemplateData: SimpleTemplateData) => { + const simpleTemplateKey = simpleTemplateData?.key; + if (simpleTemplateKey) { + if (!getCachedTemplate(simpleTemplateKey)) { + if (simpleTemplateKey && nonCachedTemplateKeys.indexOf(simpleTemplateKey) === -1) { + nonCachedTemplateKeys.push(simpleTemplateKey); + } + } + } + }); + }); + } + try { + if (nonCachedTemplateKeys.length > 0) { + tryFetchTemplateByKey(nonCachedTemplateKeys); + } else { + const parsedUiTemplate: MessageTemplateItem[] = JSON.parse(cachedTemplate.uiTemplate); + if (!Array.isArray(parsedUiTemplate) || parsedUiTemplate.length === 0) { + logger.error('TemplateMessageItemBody | parsed template is missing ui_template: ', parsedUiTemplate); + throw new Error(); + } + if ( + templateData.view_variables + || parsedUiTemplate[0].type === 'carouselView' + || typeof parsedUiTemplate[0]['items'] === 'string' + || parsedUiTemplate[0]['spacing'] + ) { + if (!templateData.view_variables) { + logger.error('TemplateMessageItemBody | template key suggests composite template but template data is missing view_variables: ', templateKey, templateData); + throw new Error(); + } + const carouselItem: CarouselItem = parsedUiTemplate[0] as CarouselItem; + if (carouselItem.type !== 'carouselView' + || typeof carouselItem.items !== 'string' + || !startsWithAtAndEndsWithBraces(carouselItem.items) + || !carouselItem.spacing + ) { + logger.error('TemplateMessageItemBody | composite template is malformed: ', templateKey, carouselItem); + throw new Error(); + } + if (parsedUiTemplate.length > 1) { // TODO: in future, support multiple templates + logger.error('TemplateMessageItemBody | composite template currently does not support multiple items: ', parsedUiTemplate); + throw new Error(); + } + const reservationKey = removeAtAndBraces(carouselItem.items); + const simpleTemplateDataList: SimpleTemplateData[] | undefined = templateData.view_variables[reservationKey]; + if (!simpleTemplateDataList) { + logger.error('TemplateMessageItemBody | no reservation key found in view_variables: ', reservationKey, templateData.view_variables); + throw new Error(); + } + result.filledMessageTemplateItemsList = getFilledMessageTemplateItemsForCarouselTemplate( + simpleTemplateDataList, + ); + result.carouselItem = carouselItem; + } else { + result.filledMessageTemplateItemsList = getFilledMessageTemplateItemsForSimpleTemplate( + parsedUiTemplate, + cachedTemplate.colorVariables, + ); + } + } + } catch (e) { + result.isErrored = true; + } + return result; + } + + useEffect(() => { + if (!renderData.isErrored && renderData.filledMessageTemplateItemsList.length === 0) { + const newRenderData: RenderData = getFilledMessageTemplateItems(); + setRenderData(newRenderData); + } + }, [templateData.key, waitingTemplateKeysMapString]); + + /** + * Attempt GET template by key IFF one of below cases is met: + * 1. This is the first GET call for the template key. + * 2. Minimum buffer time has passed since the previous GET error. + */ + function tryFetchTemplateByKey(templateKeys: string[]) { + if (templateKeys.length > 0) { + const waitingTemplateKeyDataList: [string, WaitingTemplateKeyData | undefined][] = []; + templateKeys.forEach((templateKey) => { const waitingTemplateKeyData: WaitingTemplateKeyData | undefined = waitingTemplateKeysMap[templateKey]; - const requestedAt = Date.now(); + waitingTemplateKeyDataList.push([templateKey, waitingTemplateKeyData]); + }); + + const requestedAt = Date.now(); + const keysToUpdate: string[] = []; + waitingTemplateKeyDataList.forEach(([templateKey, waitingTemplateKeyData]) => { if ( !waitingTemplateKeyData || ( - requestedAt > waitingTemplateKeyData.requestedAt + TEMPLATE_FETCH_RETRY_BUFFER_TIME_IN_MILLIES + waitingTemplateKeyData.erroredMessageIds.indexOf(message.messageId) === -1 + && requestedAt > waitingTemplateKeyData.requestedAt + TEMPLATE_FETCH_RETRY_BUFFER_TIME_IN_MILLIES ) ) { - updateMessageTemplatesInfo(templateData.key, Date.now()); - } else if (waitingTemplateKeyData && waitingTemplateKeyData.isError) { - setIsErrored(true); + keysToUpdate.push(templateKey); + } else if (waitingTemplateKeyData.erroredMessageIds.indexOf(message.messageId) > -1) { + throw new Error(); } + }); + if (keysToUpdate.length > 0) { + updateMessageTemplatesInfo(keysToUpdate, message.messageId, requestedAt); } } - }, [templateData.key, waitingTemplateKeysMapString]); + } - if (filledMessageTemplateItems.length === 0) { - if (isErrored) { - return ; - } + if (renderData.isErrored) { + return ; + } + + if (renderData.filledMessageTemplateItemsList.length === 0) { return ; } @@ -147,7 +268,21 @@ export function TemplateMessageItemBody({ fallbackMessage={} logger={logger} > - + { + !renderData.carouselItem + ? + : ( + + ))} + gap={renderData.carouselItem.spacing} + /> + }
); diff --git a/src/ui/TemplateMessageItemBody/types.ts b/src/ui/TemplateMessageItemBody/types.ts index 33c091478..851330e60 100644 --- a/src/ui/TemplateMessageItemBody/types.ts +++ b/src/ui/TemplateMessageItemBody/types.ts @@ -1,4 +1,5 @@ import type { ComponentsUnion } from '@sendbird/uikit-message-template'; +import type { ViewStyle } from '@sendbird/uikit-message-template/src/types/styles'; type SendbirdFontWeight = 'bold' | 'normal'; @@ -79,9 +80,20 @@ export type MessageTemplateTheme = { }; }; -export type MessageTemplateItem = ComponentsUnion['properties']; +export type MessageTemplateItem = ComponentsUnion['properties'] | CarouselItem; +export interface CarouselItem { + type: string; + viewStyle?: ViewStyle; + spacing: number; + items: string; // Reservation key. ex. "@some_key" +} -export type MessageTemplateData = { +// FIXME: This needs to be updated in the future. +export type MessageTemplateData = SimpleTemplateData & { + view_variables?: Record; // Reference: https://sendbird.atlassian.net/wiki/spaces/UK/pages/2265484095/UIKit+message+template+syntax+extension+proposal#View-variables-in-message-payload +}; + +export type SimpleTemplateData = { key: string; variables?: Record; }; @@ -96,7 +108,6 @@ export type SendbirdMessageTemplate = { items: MessageTemplateItem[]; }; }; - color_variables?: Record; - name?: string; + color_variables?: Record; }; diff --git a/src/ui/TemplateMessageItemBody/utils/mapData.ts b/src/ui/TemplateMessageItemBody/utils/mapData.ts index af05f15cf..8a282019c 100644 --- a/src/ui/TemplateMessageItemBody/utils/mapData.ts +++ b/src/ui/TemplateMessageItemBody/utils/mapData.ts @@ -1,4 +1,12 @@ import flattenObject from './flattenObject'; +import { convertArgbToRgba } from './selectColorVariablesByTheme'; + +const COLOR_KEYS: Record = { + color: true, + tintColor: true, + backgroundColor: true, + borderColor: true, +}; type SourceData = Record; type MappedData = Record | Array; @@ -38,7 +46,11 @@ export default function mapData | Array): [MessageTemplateTheme, MessageTemplateTheme] => { - const light = {}; +const splitColorVariables = (colorVariables: Record): [Record, Record] => { const light = {}; const dark = {}; for (const key in colorVariables) { @@ -27,21 +25,21 @@ const splitColorVariables = (colorVariables: Record): [MessageTempl dark[key] = nestedDark; } else if (typeof value === 'string') { const [lightColor, darkColor] = value.split(','); - light[key] = convertArgbToRgba(lightColor); - dark[key] = convertArgbToRgba(darkColor || lightColor); // when dark color is not provided, use light color + light[key] = lightColor; + dark[key] = darkColor || lightColor; // when dark color is not provided, use light color } else { light[key] = value; dark[key] = value; } } } - return [light, dark] as [MessageTemplateTheme, MessageTemplateTheme]; + return [light, dark]; }; export default function selectColorVariablesByTheme({ colorVariables, theme }: { colorVariables: Record; theme: SendbirdTheme; -}): MessageTemplateTheme { +}): Record { const [light, dark] = splitColorVariables(colorVariables); return theme === 'light' ? light : dark; } diff --git a/src/utils/index.ts b/src/utils/index.ts index 244d83d85..8508301ac 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -277,28 +277,42 @@ export const isTemplateMessage = (message: CoreMessageType): boolean => !!( message && message.extendedMessagePayload?.['template'] ); +export const isCompositeTemplateMessage = (message: CoreMessageType): boolean => !!( + message && message.extendedMessagePayload?.['template']?.['view_variables'] +); + +export enum UI_CONTAINER_TYPES { + DEFAULT = '', + WIDE = 'ui_container_type__wide', + FULL = 'ui_container_type__full', + DEFAULT_CAROUSEL = 'ui_container_type__default-carousel', +} + export const getMessageContentMiddleClassNameByContainerType = ({ message, isMobile, - isMiddleFullWidth = true, }: { message: CoreMessageType, isMobile: boolean, - isMiddleFullWidth?: boolean, -}): string => { - if (!isMobile || !isMiddleFullWidth) return ''; - +}): UI_CONTAINER_TYPES => { /** * FULL: template message only. * WIDE: all message types. */ const containerType: string | undefined = message.extendedMessagePayload?.['ui']?.['container_type']; + if (isCompositeTemplateMessage(message)) { + /** + * Composite templates must have default carousel view irregardless of given containerType. + */ + return UI_CONTAINER_TYPES.DEFAULT_CAROUSEL; + } + if (!isMobile) return UI_CONTAINER_TYPES.DEFAULT; if (isTemplateMessage(message) && containerType === MessageContentMiddleContainerType.FULL) { - return 'ui_container_type__full'; + return UI_CONTAINER_TYPES.FULL; } else if (containerType === MessageContentMiddleContainerType.WIDE) { - return 'ui_container_type__wide'; + return UI_CONTAINER_TYPES.WIDE; } - return ''; + return UI_CONTAINER_TYPES.DEFAULT; }; export const isOGMessage = (message: SendableMessageType): boolean => !!( @@ -398,6 +412,16 @@ export const getClassName = (classNames: string | Array>) ? classNames.reduce(reducer, []).join(' ') : classNames ); + +export const startsWithAtAndEndsWithBraces = (str: string) => { + const regex = /^\{@.*\}$/; + return regex.test(str); +}; + +export const removeAtAndBraces = (str: string) => { + return str.replace(/^\{@|}$/g, ''); +}; + export const isReactedBy = (userId: string, reaction: Reaction): boolean => ( reaction.userIds.some((reactorUserId: string): boolean => reactorUserId === userId) ); diff --git a/yarn.lock b/yarn.lock index d40d7cd4b..492d4b231 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2647,15 +2647,15 @@ __metadata: languageName: node linkType: hard -"@sendbird/chat@npm:^4.11.0": - version: 4.11.0 - resolution: "@sendbird/chat@npm:4.11.0" +"@sendbird/chat@npm:^4.11.3": + version: 4.11.3 + resolution: "@sendbird/chat@npm:4.11.3" peerDependencies: "@react-native-async-storage/async-storage": ^1.17.6 peerDependenciesMeta: "@react-native-async-storage/async-storage": optional: true - checksum: 6caa0cbde6407a4b71862374ec0e6cb37ec900551303a95f43982e909e2e68883b688d6595a6c8097a3901f5da990679b08a9c18312cbb69e1815eaaa865c525 + checksum: 17ed462c2db13afacd93dd3219a6af4f032f9f251e2e6af3d38cc7cd6b3f3b3648753cf5bcd6a7a9b6ad492971d73e473cb09cddb58aa6c36c7c5dae3a1015e1 languageName: node linkType: hard @@ -2699,7 +2699,7 @@ __metadata: "@rollup/plugin-node-resolve": ^15.2.3 "@rollup/plugin-replace": ^5.0.4 "@rollup/plugin-typescript": ^11.1.5 - "@sendbird/chat": ^4.11.0 + "@sendbird/chat": ^4.11.3 "@sendbird/react-uikit-message-template-view": 0.0.1-alpha.65 "@sendbird/uikit-tools": 0.0.1-alpha.65 "@storybook/addon-actions": ^6.5.10