diff --git a/src/lib/dux/appInfo/utils.ts b/src/lib/dux/appInfo/utils.ts index cc568d5d2..ea1ce6f4c 100644 --- a/src/lib/dux/appInfo/utils.ts +++ b/src/lib/dux/appInfo/utils.ts @@ -6,7 +6,7 @@ import { SendbirdMessageTemplate } from '../../../ui/TemplateMessageItemBody/typ */ export const getProcessedTemplate = (parsedTemplate: SendbirdMessageTemplate): ProcessedMessageTemplate => { return { - version: parsedTemplate.ui_template.version, + version: Number(parsedTemplate.ui_template.version), uiTemplate: JSON.stringify(parsedTemplate.ui_template.body.items), colorVariables: parsedTemplate.color_variables, }; diff --git a/src/ui/MessageContent/MessageBody/index.tsx b/src/ui/MessageContent/MessageBody/index.tsx index 3a9e370ca..07b1537c7 100644 --- a/src/ui/MessageContent/MessageBody/index.tsx +++ b/src/ui/MessageContent/MessageBody/index.tsx @@ -23,12 +23,14 @@ import { match } from 'ts-pattern'; import TemplateMessageItemBody from '../../TemplateMessageItemBody'; const MESSAGE_ITEM_BODY_CLASSNAME = 'sendbird-message-content__middle__message-item-body'; +export type RenderedTemplateBodyType = 'failed' | 'composite' | 'simple'; export interface MessageBodyProps { channel: Nullable; message: CoreMessageType; showFileViewer?: (bool: boolean) => void; onMessageHeightChange?: (isBottomMessageAffected?: boolean) => void; + onTemplateMessageRenderedCallback?: (renderedTemplateBodyType: RenderedTemplateBodyType) => void; mouseHover: boolean; isMobile: boolean; @@ -43,6 +45,7 @@ export default function MessageBody(props: MessageBodyProps): ReactElement { channel, showFileViewer, onMessageHeightChange, + onTemplateMessageRenderedCallback, mouseHover, isMobile, @@ -66,6 +69,7 @@ export default function MessageBody(props: MessageBodyProps): ReactElement { message={message as BaseMessage} isByMe={isByMe} theme={config?.theme as SendbirdTheme} + onTemplateMessageRenderedCallback={onTemplateMessageRenderedCallback} /> )) .when((message) => isOgMessageEnabledInGroupChannel diff --git a/src/ui/MessageContent/index.tsx b/src/ui/MessageContent/index.tsx index 24d1581e1..b9fd33bf2 100644 --- a/src/ui/MessageContent/index.tsx +++ b/src/ui/MessageContent/index.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, ReactNode, useContext, useEffect, useRef, useState } from 'react'; +import React, { ReactElement, ReactNode, useContext, useMemo, useRef, useState } from 'react'; import format from 'date-fns/format'; import './index.scss'; @@ -151,12 +151,21 @@ export default function MessageContent(props: MessageContentProps): ReactElement const [showFeedbackOptionsMenu, setShowFeedbackOptionsMenu] = useState(false); const [showFeedbackModal, setShowFeedbackModal] = useState(false); const [feedbackFailedText, setFeedbackFailedText] = useState(''); - const [totalBottom, setTotalBottom] = useState(0); - - const uiContainerType: UI_CONTAINER_TYPES = getMessageContentMiddleClassNameByContainerType({ + const [uiContainerType, setUiContainerType] = useState(getMessageContentMiddleClassNameByContainerType({ message, isMobile, - }); + })); + + const onTemplateMessageRenderedCallback = (renderedTemplateType: 'failed' | 'composite' | 'simple') => { + if (renderedTemplateType === 'failed') { + setUiContainerType(UI_CONTAINER_TYPES.DEFAULT); + } else if (renderedTemplateType === 'composite') { + /** + * Composite templates must have default carousel view irregardless of given containerType. + */ + setUiContainerType(UI_CONTAINER_TYPES.DEFAULT_CAROUSEL); + } + }; const { stringSet } = useContext(LocalizationContext); @@ -200,6 +209,22 @@ export default function MessageContent(props: MessageContentProps): ReactElement const isTimestampBottom = !!uiContainerType; + const getTotalBottom = (): number => { + let sum = 2; + if (timestampRef.current && isTimestampBottom) { + sum += 4 + timestampRef.current.clientHeight; + } + if (threadRepliesRef.current) { + sum += 4 + threadRepliesRef.current.clientHeight; + } + if (feedbackButtonsRef.current) { + sum += 4 + feedbackButtonsRef.current.clientHeight; + } + return sum; + }; + + const totalBottom = useMemo(() => getTotalBottom(), [isTimestampBottom]); + const onCloseFeedbackForm = () => { setShowFeedbackModal(false); }; @@ -233,23 +258,6 @@ export default function MessageContent(props: MessageContentProps): ReactElement return (); } - useEffect(() => { - const getTotalBottom = (): number => { - let sum = 2; - if (timestampRef.current && isTimestampBottom) { - sum += 4 + timestampRef.current.clientHeight; - } - if (threadRepliesRef.current) { - sum += 4 + threadRepliesRef.current.clientHeight; - } - if (feedbackButtonsRef.current) { - sum += 4 + feedbackButtonsRef.current.clientHeight; - } - return sum; - }; - setTotalBottom(getTotalBottom()); - }, [isTimestampBottom]); - return (
void; + isComposite?: boolean; logger?: LoggerInterface; } @@ -27,8 +30,10 @@ export class MessageTemplateErrorBoundary extends Component void; + onTemplateMessageRenderedCallback?: (renderedTemplateBodyType: RenderedTemplateBodyType) => void; } /** * Returns copied message template object filled with given template data and color variables. */ -const getFilledMessageTemplateWithData = ( +const getFilledMessageTemplateWithData = ({ + template, + templateData = {}, + colorVariables, + theme, +}: { template: MessageTemplateItem[], - templateData: Record, - colorVariables: Record, - theme: SendbirdTheme, -): MessageTemplateItem[] => { - const selectedThemeColorVariables = selectColorVariablesByTheme({ - colorVariables, - theme, - }); + templateData?: Record, + colorVariables?: Record, + theme?: SendbirdTheme, +}): MessageTemplateItem[] => { + let selectedThemeColorVariables = {}; + if (colorVariables && theme) { + selectedThemeColorVariables = selectColorVariablesByTheme({ + colorVariables, + theme, + }); + } + const source = { ...templateData, ...selectedThemeColorVariables }; const parsedTemplate: MessageTemplateItem[] = mapData({ template: restoreNumbersFromMessageTemplateObject(template) as any, - source: { ...templateData, ...selectedThemeColorVariables }, + source, }) as any; return parsedTemplate; }; @@ -55,16 +73,27 @@ export function TemplateMessageItemBody({ message, isByMe = false, theme = 'light', + onTemplateMessageRenderedCallback = () => { /* noop */ }, }: TemplateMessageItemBodyProps): ReactElement { const templateData: MessageTemplateData | undefined = message.extendedMessagePayload?.['template'] as MessageTemplateData; + + const getFailedBody = () => { + onTemplateMessageRenderedCallback('failed'); + return ; + }; + if (!templateData?.key) { - return ; + return getFailedBody(); } const templateKey = templateData.key; const globalState = useSendbirdStateContext(); if (!globalState) { - return ; + return getFailedBody(); } const { @@ -86,43 +115,67 @@ export function TemplateMessageItemBody({ setRenderData, ] = useState(getFilledMessageTemplateItems()); - function getFilledMessageTemplateItemsForCarouselTemplate(simpleTemplateDataList: SimpleTemplateData[]) { + function getFilledMessageTemplateItemsForCarouselTemplateByMessagePayload(simpleTemplateDataList: SimpleTemplateData[]): { + maxVersion: number, + filledTemplates: MessageTemplateItem[][], + } { const cachedSimpleTemplates: ProcessedMessageTemplate[] = []; const simpleTemplatesVariables: Array | undefined> = []; + let maxVersion = 0; 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(); + throw new Error('TemplateMessageItemBody | simple template keys are not found in view_variables.'); } const simpleCachedTemplate = getCachedTemplate(simpleTemplateKey); cachedSimpleTemplates.push(simpleCachedTemplate); simpleTemplatesVariables.push(simpleTemplateData.variables); + maxVersion = Math.max(maxVersion, simpleCachedTemplate.version); }); const filledMessageTemplateItemsList: MessageTemplateItem[][] = cachedSimpleTemplates .map((cachedSimpleTemplate, index) => { const templateItems: MessageTemplateItem[] = JSON.parse(cachedSimpleTemplate.uiTemplate); - const filledMessageTemplateItems: MessageTemplateItem[] = getFilledMessageTemplateWithData( - templateItems, - simpleTemplatesVariables[index] ?? {}, - cachedSimpleTemplate.colorVariables, + const filledMessageTemplateItems: MessageTemplateItem[] = getFilledMessageTemplateWithData({ + template: templateItems, + templateData: simpleTemplatesVariables[index], + colorVariables: cachedSimpleTemplate.colorVariables, theme, - ); + }); return filledMessageTemplateItems; }); - return filledMessageTemplateItemsList; + return { + maxVersion, + filledTemplates: filledMessageTemplateItemsList, + }; + } + + function getFilledMessageTemplateItemsForCarouselTemplate(uiTemplates: SendbirdUiTemplate[]): { + maxVersion: number, + filledTemplates: MessageTemplateItem[][], + } { + let maxVersion = 0; + const filledTemplates: MessageTemplateItem[][] = []; + uiTemplates.forEach((uiTemplate: SendbirdUiTemplate) => { + maxVersion = Math.max(maxVersion, uiTemplate.version); + filledTemplates.push(uiTemplate.body.items); + }); + return { + maxVersion, + filledTemplates, + }; } function getFilledMessageTemplateItemsForSimpleTemplate( templateItems: MessageTemplateItem[], - colorVariables: Record, + colorVariables?: Record, ): MessageTemplateItem[] { - const filledMessageTemplateItems: MessageTemplateItem[] = getFilledMessageTemplateWithData( - templateItems, - templateData.variables ?? {}, + const filledMessageTemplateItems: MessageTemplateItem[] = getFilledMessageTemplateWithData({ + template: templateItems, + templateData: templateData?.variables ?? {}, colorVariables, theme, - ); + }); return filledMessageTemplateItems; } @@ -165,46 +218,59 @@ export function TemplateMessageItemBody({ 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(); + throw new Error('TemplateMessageItemBody | parsed template is missing ui_template. See error log in console for details'); } - if ( - templateData.view_variables - || parsedUiTemplate[0].type === CarouselType - || 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(); - } + /** + * Composite template validation + */ + if (parsedUiTemplate[0].type === CompositeComponentType.Carousel) { const carouselItem = parsedUiTemplate[0] as unknown as CarouselItem; - if (carouselItem.type !== CarouselType - || 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(); + throw new Error('TemplateMessageItemBody | composite template currently does not support multiple items. See error log in console for details'); } - 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.templateVersion = cachedTemplate.version; - result.filledMessageTemplateItemsList = [{ - type: carouselItem.type as any, - spacing: carouselItem.spacing, - items: getFilledMessageTemplateItemsForCarouselTemplate( + if (typeof carouselItem.items === 'string') { + if (!startsWithAtAndEndsWithBraces(carouselItem.items)) { + logger.error('TemplateMessageItemBody | composite template with reservation key must follow the following string format "{@your-reservation-key}": ', templateKey, carouselItem); + throw new Error('TemplateMessageItemBody | composite template with reservation key must follow the following string format "{@your-reservation-key}". See error log in console for details'); + } + if (!templateData.view_variables) { + logger.error('TemplateMessageItemBody | template key suggests composite template but template data is missing view_variables: ', templateKey, templateData); + throw new Error('TemplateMessageItemBody | template key suggests composite template but template data is missing view_variables. See error log in console for details'); + } + 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('TemplateMessageItemBody | no reservation key found in view_variables. See error log in console for details'); + } + const { maxVersion, filledTemplates } = getFilledMessageTemplateItemsForCarouselTemplateByMessagePayload( simpleTemplateDataList, - ), - }]; + ); + result.isComposite = true; + result.templateVersion = Math.max(cachedTemplate.version, maxVersion); + result.filledMessageTemplateItemsList = [{ + type: carouselItem.type as CompositeComponentType, + spacing: carouselItem.spacing, + items: filledTemplates, + }]; + } else if (Array.isArray(carouselItem.items)) { + const { maxVersion, filledTemplates } = getFilledMessageTemplateItemsForCarouselTemplate( + carouselItem.items, + ); + result.isComposite = true; + result.templateVersion = Math.max(cachedTemplate.version, maxVersion); + result.filledMessageTemplateItemsList = [{ + type: carouselItem.type as CompositeComponentType, + spacing: carouselItem.spacing, + items: filledTemplates, + }]; + } else { + logger.error('TemplateMessageItemBody | composite template is malformed: ', templateKey, carouselItem); + throw new Error('TemplateMessageItemBody | composite template is malformed. See error log in console for details'); + } } else { + result.templateVersion = cachedTemplate.version; result.filledMessageTemplateItemsList = getFilledMessageTemplateItemsForSimpleTemplate( parsedUiTemplate, cachedTemplate.colorVariables, @@ -249,7 +315,7 @@ export function TemplateMessageItemBody({ ) { keysToUpdate.push(templateKey); } else if (waitingTemplateKeyData.erroredMessageIds.indexOf(message.messageId) > -1) { - throw new Error(); + throw new Error(`TemplateMessageItemBody | fetching template key ${templateKey} for messageId: ${message.messageId} has failed.`); } }); if (keysToUpdate.length > 0) { @@ -259,13 +325,12 @@ export function TemplateMessageItemBody({ } if (renderData.isErrored) { - return ; + return getFailedBody(); } if (renderData.filledMessageTemplateItemsList.length === 0) { return ; } - return (
} + fallbackMessage={ + + } + onTemplateMessageRenderedCallback={onTemplateMessageRenderedCallback} + isComposite={renderData.isComposite} logger={logger} > ; }; +export interface SendbirdUiTemplate { + version: number; + body: { + items: MessageTemplateItem[]; + }; +} + export type SendbirdMessageTemplate = { key: string; created_at: number; updated_at: number; - ui_template: { - version: number; - body: { - items: MessageTemplateItem[]; - }; - }; + ui_template: SendbirdUiTemplate; name?: string; color_variables?: Record; }; diff --git a/src/utils/index.ts b/src/utils/index.ts index 1d3d9a383..0dbb6a914 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -276,11 +276,6 @@ export const isThreadMessage = (message: CoreMessageType): boolean => ( 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', @@ -299,12 +294,6 @@ export const getMessageContentMiddleClassNameByContainerType = ({ * 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 (containerType === MessageContentMiddleContainerType.WIDE) { return UI_CONTAINER_TYPES.WIDE;