From 31791b972e2f413ba387a2195caf3fdca68ef009 Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Fri, 15 Mar 2024 17:24:01 +0900 Subject: [PATCH 01/16] feat: Apply template container type to message content (#999) Fixes: [AC-1497](https://sendbird.atlassian.net/browse/AC-1497) ### Changelogs - Added 'wide' and 'full' width support for `MessageContent` when value exists in `message.extendedMessagePayload['ui']['container_type']` [AC-1497]: https://sendbird.atlassian.net/browse/AC-1497?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- 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.scss | 2 - .../components/MessageList/index.tsx | 5 +- .../MessageTemplateWrapper/index.tsx | 7 +- src/types.ts | 6 + src/ui/Avatar/index.tsx | 3 + src/ui/Carousel/index.scss | 4 + src/ui/Carousel/index.tsx | 236 +++++++++++++++++ src/ui/Label/index.tsx | 20 +- src/ui/MessageContent/MessageBody/index.tsx | 2 +- .../MessageContent/MessageProfile/index.tsx | 3 + .../__snapshots__/MessageContent.spec.js.snap | 6 +- src/ui/MessageContent/index.scss | 82 ++++-- src/ui/MessageContent/index.tsx | 216 ++++++++++------ 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/ui/ThreadReplies/index.tsx | 18 +- src/utils/index.ts | 50 +++- yarn.lock | 10 +- 30 files changed, 860 insertions(+), 258 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.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/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/types.ts b/src/types.ts index 03bd9395f..a9ec0903d 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/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/Label/index.tsx b/src/ui/Label/index.tsx index f6bce9965..f2b23effa 100644 --- a/src/ui/Label/index.tsx +++ b/src/ui/Label/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { RefObject } from 'react'; import './index.scss'; import { Typography, Colors } from './types'; @@ -12,12 +12,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 +32,7 @@ export default function Label({ changeTypographyToClassName(type), changeColorToClassName(color), ].join(' ')} + ref={ref} > {children} @@ -39,3 +43,5 @@ const LabelTypography = Typography; const LabelColors = Colors; const LabelStringSet = getStringSet('en'); export { LabelTypography, LabelColors, LabelStringSet }; + +export default React.forwardRef(Label); 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/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/__tests__/__snapshots__/MessageContent.spec.js.snap b/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap index 850101661..f755329f1 100644 --- a/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap +++ b/src/ui/MessageContent/__tests__/__snapshots__/MessageContent.spec.js.snap @@ -15,7 +15,7 @@ exports[`ui/MessageContent should do a snapshot test of the MessageContent DOM 1
mock-date diff --git a/src/ui/MessageContent/index.scss b/src/ui/MessageContent/index.scss index 47b45c89e..f9862aa0a 100644 --- a/src/ui/MessageContent/index.scss +++ b/src/ui/MessageContent/index.scss @@ -1,18 +1,18 @@ @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-msg-hoc > .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; @@ -29,6 +29,18 @@ } } + .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; @@ -37,14 +49,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 +97,21 @@ 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; + right: 0px; + } + .ui_container_type__full { + min-width: fit-content; + bottom: -16px; + right: 0px; + } } .sendbird-message-content__middle__sender-name { @@ -167,6 +186,9 @@ .sendbird-message-content__middle__body-container__created-at { display: none; } + .ui_container_type__default-carousel { + display: flex; + } } } } @@ -239,6 +261,21 @@ 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; + left: 0px; + } + .ui_container_type__full { + min-width: fit-content; + bottom: -20px; + left: 0px; + } } } @@ -315,23 +352,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..8390a3f5c 100644 --- a/src/ui/MessageContent/index.tsx +++ b/src/ui/MessageContent/index.tsx @@ -1,27 +1,26 @@ -import React, { - ReactElement, ReactNode, useContext, - 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, + UI_CONTAINER_TYPES, } from '../../utils'; import { LocalizationContext, useLocalization } from '../../lib/LocalizationContext'; import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; @@ -72,9 +71,9 @@ 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 sub-components + // For injecting customizable subcomponents renderSenderProfile?: (props: MessageProfileProps) => ReactNode; renderMessageBody?: (props: MessageBodyProps) => ReactNode; renderMessageHeader?: (props: MessageHeaderProps) => ReactNode; @@ -140,6 +139,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 +151,12 @@ 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({ + message, + isMobile, + }); const { stringSet } = useContext(LocalizationContext); @@ -174,7 +182,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 +198,8 @@ export default function MessageContent(props: MessageContentProps): ReactElement const showThreadReplies = isNotTemplateMessage && displayThreadReplies; const showRightContent = isNotTemplateMessage && !isByMe && !isMobile; + const isTimestampBottom = !!uiContainerType; + const onCloseFeedbackForm = () => { setShowFeedbackModal(false); }; @@ -223,19 +233,43 @@ 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 (
setMouseHover(true)} onMouseLeave={() => setMouseHover(false)} > {/* left */} -
+ {uiContainerType !== UI_CONTAINER_TYPES.FULL + &&
{ renderSenderProfile({ ...props, isByMe, displayThreadReplies, + bottom: totalBottom > 0 ? totalBottom + 'px' : '', }) } {/* outgoing menu */} @@ -272,12 +306,14 @@ export default function MessageContent(props: MessageContentProps): ReactElement )}
)} -
+
} + {/* middle */}
+ className={getClassName([ + 'sendbird-message-content__middle__body-container__created-at', + 'left', + supposedHoverClassName, + uiContainerType, + ])} + 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) && ( )}
+ {/* bottom timestamp empty container */} + {isTimestampBottom &&
} {/* thread replies */} {showThreadReplies && ( onReplyInThread?.({ message: message as SendableMessageType })} + ref={threadRepliesRef} /> )} + {/* 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} + > + + +
+ }
+ {/* right */} + {showRightContent && (
- {showRightContent && (
{isReactionEnabledInChannel && ( renderEmojiMenu({ @@ -486,8 +541,9 @@ export default function MessageContent(props: MessageContentProps): ReactElement deleteMessage, })}
- )}
+ )} + { showMenu && ( message?.isUserMessage?.() || message?.isFileMessage?.() || message?.isMultipleFilesMessage?.() 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/ui/ThreadReplies/index.tsx b/src/ui/ThreadReplies/index.tsx index fcd73aa53..1d493f318 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..8508301ac 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,44 @@ 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, +}: { + message: CoreMessageType, + isMobile: boolean, +}): 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_TYPES.FULL; + } else if (containerType === MessageContentMiddleContainerType.WIDE) { + return UI_CONTAINER_TYPES.WIDE; + } + return UI_CONTAINER_TYPES.DEFAULT; +}; + export const isOGMessage = (message: SendableMessageType): boolean => !!( message && isUserMessage(message) && message?.ogMetaData && ( message.ogMetaData?.url @@ -374,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 From d7adafbf0f490bb640dd05719b0239c1851c7305 Mon Sep 17 00:00:00 2001 From: chohongm Date: Fri, 15 Mar 2024 17:33:02 +0900 Subject: [PATCH 02/16] v3.14.0-beta --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- rollup.module-exports.mjs | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af6ade2d1..6342ca1ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog - v3 +## [v3.14.0-beta] (Mar 15, 2024) + +### Features +* Added 'wide' and 'full' width support for `MessageContent` when value exists in `message.extendedMessagePayload['ui']['container_type']` +* Added `Carousel` ui component +* `MessageTemplate` now supports composite templates + + ## [v3.13.2] (Mar 14, 2024) ### Features diff --git a/package.json b/package.json index 7b689c532..ba89290e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sendbird/uikit-react", - "version": "3.13.2", + "version": "3.14.0-beta", "description": "Sendbird UIKit for React: A feature-rich and customizable chat UI kit with messaging, channel management, and user authentication.", "keywords": [ "sendbird", diff --git a/rollup.module-exports.mjs b/rollup.module-exports.mjs index eda8392dc..4acb682b8 100644 --- a/rollup.module-exports.mjs +++ b/rollup.module-exports.mjs @@ -239,4 +239,5 @@ export default { 'ui/Word': 'src/ui/Word/index.tsx', 'ui/FeedbackIconButton': 'src/ui/FeedbackIconButton/index.tsx', 'ui/MobileFeedbackMenu': 'src/ui/MobileFeedbackMenu/index.tsx', + 'ui/Carousel': 'src/ui/Carousel/index.tsx', }; From 3b2a5a9570c910a5095ddc69df6e841ab4774451 Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Fri, 22 Mar 2024 11:42:18 +0900 Subject: [PATCH 03/16] bug-fix: Fix carousel swipe flickering issue + Add missing default font-family to template root (#1022) ### Changelogs - Fixed a bug where swiping carousel in mobile view displaying flickering effect - Added missing default font-family value to template root --- src/ui/Carousel/index.tsx | 310 ++++++++++++++++++++---------- src/ui/MessageTemplate/index.scss | 3 +- 2 files changed, 206 insertions(+), 107 deletions(-) diff --git a/src/ui/Carousel/index.tsx b/src/ui/Carousel/index.tsx index 70ed0af32..8cd540f46 100644 --- a/src/ui/Carousel/index.tsx +++ b/src/ui/Carousel/index.tsx @@ -1,10 +1,9 @@ import './index.scss'; -import React, { ReactElement, useRef, useState } from 'react'; -import { useMediaQueryContext } from '../../lib/MediaQueryContext'; +import React, { ReactElement, useEffect, useRef, useState } from 'react'; const PADDING_WIDTH = 24; const CONTENT_LEFT_WIDTH = 40; -const SWIPE_THRESHOLD = 30; +const SWIPE_THRESHOLD = 15; const LAST_ITEM_RIGHT_SNAP_THRESHOLD = 100; interface ItemPosition { @@ -28,11 +27,10 @@ function shouldRenderAsFixed(item: ReactElement) { } function CarouselItem({ - key, item, defaultWidth, }: CarouselItemProps): ReactElement { - return
+ return
{item}
; } @@ -41,14 +39,29 @@ interface CarouselProps { id: string; items: ReactElement[]; gap?: number; + classNameWithTouchAction?: string; } -export function Carousel({ +interface Position { + x: number; + y: number; +} + +interface DraggingInfo { + scrolling: boolean; + dragging: boolean; + startPos: Position | null; + offset: number; + translateX: number; + currentIndex: number; +} + +export const Carousel = React.memo(({ id, items, gap = 8, -}: CarouselProps): ReactElement { - const { isMobile } = useMediaQueryContext(); + classNameWithTouchAction = 'sendbird-conversation__messages-padding', +}: CarouselProps): ReactElement => { const carouselRef = useRef(null); const screenWidth = window.innerWidth; const defaultItemWidth = carouselRef.current?.clientWidth ?? 0; @@ -63,119 +76,212 @@ export function Carousel({ 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 [draggingInfo, setDraggingInfo] = useState({ + scrolling: false, + dragging: false, + startPos: null, + offset: 0, + translateX: 0, + currentIndex: 0, + }); + const handleMouseDown = (event: React.MouseEvent) => { - event.preventDefault(); - setDragging('horizontal'); - setStartX(event.clientX); + setDraggingInfo({ + ...draggingInfo, + scrolling: false, + dragging: true, + startPos: { + x: event.clientX, + y: event.clientY, + }, + offset: 0, + }); }; const handleMouseMove = (event: React.MouseEvent) => { - if (!dragging) return; + if (!draggingInfo.dragging) return; const currentX = event.clientX; - const newOffset = currentX - startX; - setOffset(newOffset); + const newOffset = currentX - draggingInfo.startPos.x; + setDraggingInfo({ + ...draggingInfo, + offset: newOffset, + }); }; const handleMouseUp = () => { - if (!dragging) return; - setDragging(null); - onDragEnd(); + if (!draggingInfo.dragging) return; + handleDragEnd(); + }; + + const blockScroll = () => { + const parentElements = document.getElementsByClassName(classNameWithTouchAction); + const parentElement: HTMLElement = parentElements[0] as HTMLElement; + if (parentElement) { + parentElement.style.touchAction = 'pan-x'; + } + }; + + const unblockScroll = () => { + const parentElements = document.getElementsByClassName(classNameWithTouchAction); + const parentElement: HTMLElement = parentElements[0] as HTMLElement; + if (parentElement) { + parentElement.style.touchAction = 'pan-y'; + } }; const handleTouchStart = (event: React.TouchEvent) => { - setStartX(event.touches[0].clientX); + setDraggingInfo({ + ...draggingInfo, + scrolling: false, + dragging: false, + startPos: { + x: event.touches[0].clientX, + y: event.touches[0].clientY, + }, + offset: 0, + }); }; + useEffect(() => { + if (draggingInfo.scrolling) { + unblockScroll(); + } + + }, [draggingInfo.scrolling]); + const handleTouchMove = (event: React.TouchEvent) => { - if (!startX) return; + if (!draggingInfo.startPos || draggingInfo.scrolling) return; + + const startPos = draggingInfo.startPos; 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 touchMoveY = event.touches[0].clientY; + const deltaX = Math.abs(touchMoveX - startPos.x); + const deltaY = Math.abs(touchMoveY - startPos.y); + const newOffset = touchMoveX - startPos.x; - const handleTouchEnd = () => { - if (dragging !== null) { - setDragging(null); + if (newOffset === draggingInfo.offset) return; + if (draggingInfo.dragging) { + setDraggingInfo({ + ...draggingInfo, + offset: newOffset, + }); + return; + } + if (deltaY > deltaX) { + setDraggingInfo({ + ...draggingInfo, + scrolling: true, + }); + } else { + blockScroll(); + setDraggingInfo({ + ...draggingInfo, + dragging: true, + offset: newOffset, + }); } - onDragEnd(); + }; + + const getNewDraggingInfo = (props: { newTranslateX?: number, nextIndex?: number } = {}): DraggingInfo => { + const { newTranslateX, nextIndex } = props; + const { translateX, currentIndex } = draggingInfo; + return { + scrolling: false, + dragging: false, + startPos: null, + offset: 0, + translateX: newTranslateX ?? translateX, + currentIndex: nextIndex ?? currentIndex, + }; }; const handleDragEnd = () => { + const offset = draggingInfo.offset; 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 (absOffset < SWIPE_THRESHOLD) { + setDraggingInfo(getNewDraggingInfo()); + return; + } + // If dragged to left, next index should be to the right + const currentIndex = draggingInfo.currentIndex; + if (offset < 0 && currentIndex < items.length - 1) { + const nextIndex = currentIndex + 1; + setDraggingInfo(getNewDraggingInfo({ + newTranslateX: itemPositions[nextIndex].start, + 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); - } + } else if (offset > 0 && currentIndex > 0) { + const nextIndex = currentIndex - 1; + setDraggingInfo(getNewDraggingInfo({ + newTranslateX: itemPositions[nextIndex].start, + nextIndex, + })); + } else { + setDraggingInfo(getNewDraggingInfo()); } - setOffset(0); }; - - const handleDragEndForMobile = () => { + const handleTouchEnd = () => { + const { offset, currentIndex } = draggingInfo; 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); + if (absOffset < SWIPE_THRESHOLD) { + setDraggingInfo(getNewDraggingInfo()); + return; + } + // 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) { + setDraggingInfo(getNewDraggingInfo({ + newTranslateX: itemPositions[nextIndex].start, + nextIndex, + })); } else { - const translateWidth = itemPositions[nextIndex].start - lastItemWidth; - const rightEmptyWidth = screenWidth - (allItemsWidth + translateWidth + PADDING_WIDTH + CONTENT_LEFT_WIDTH); - setTranslateX(translateWidth + rightEmptyWidth); - setCurrentIndex(nextIndex); + setDraggingInfo(getNewDraggingInfo()); } + } else if (nextIndex !== items.length - 1) { + setDraggingInfo(getNewDraggingInfo({ + newTranslateX: itemPositions[nextIndex].start, + nextIndex, + })); } else { - setTranslateX(itemPositions[nextIndex].start); - setCurrentIndex(nextIndex); + const translateWidth = itemPositions[nextIndex].start - lastItemWidth; + const rightEmptyWidth = screenWidth - (allItemsWidth + translateWidth + PADDING_WIDTH + CONTENT_LEFT_WIDTH); + setDraggingInfo(getNewDraggingInfo({ + newTranslateX: translateWidth + rightEmptyWidth, + 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); + } else { + setDraggingInfo(getNewDraggingInfo({ + newTranslateX: itemPositions[nextIndex].start, + nextIndex, + })); } + // If dragged to right, next index should be to the left + } else if (offset > 0 && currentIndex > 0) { + const nextIndex = currentIndex - 1; + setDraggingInfo(getNewDraggingInfo({ + newTranslateX: itemPositions[nextIndex].start, + nextIndex, + })); + } else { + setDraggingInfo(getNewDraggingInfo()); + } + if (draggingInfo.dragging) { + unblockScroll(); } - setOffset(0); - const parentElement = document.getElementsByClassName('sendbird-conversation__messages-padding'); - (parentElement[0] as HTMLElement).style.overflowY = 'scroll'; }; function getCurrentTranslateX() { - return translateX + offset; + return draggingInfo.translateX + draggingInfo.offset; } function getIsLastTwoItemsFitScreen() { @@ -184,9 +290,6 @@ export function Carousel({ return restTotalWidth <= screenWidth; } - const onDragEnd = isMobile ? handleDragEndForMobile : handleDragEnd; - const currentTranslateX = getCurrentTranslateX(); - function getEachItemPositions(): ItemPosition[] { let accumulator = 0; return itemWidths.map((itemWidth, i): ItemPosition => { @@ -206,9 +309,6 @@ export function Carousel({
-
- {items.map((item, index) => ( - - ))} -
+ {items.map((item, index) => ( + + ))}
); -} +}); export default Carousel; diff --git a/src/ui/MessageTemplate/index.scss b/src/ui/MessageTemplate/index.scss index 3fe0415aa..21bbd3c07 100644 --- a/src/ui/MessageTemplate/index.scss +++ b/src/ui/MessageTemplate/index.scss @@ -3,5 +3,6 @@ } .sendbird-message-template__root { - border-radius: 0; + border-radius: 0; + font-family: var(--sendbird-font-family-default); } \ No newline at end of file From 6da6d760dbe5a65e13e9eaab65edf57fb190cd8b Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Fri, 22 Mar 2024 11:47:39 +0900 Subject: [PATCH 04/16] v3.14.0-beta.2 --- CHANGELOG.md | 7 ++++++- package.json | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6342ca1ed..b1e80fe22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog - v3 +## [v3.14.0-beta.2] (Mar 22, 2024) + +### Fixes +* Fixed a bug where swiping `Carousel` in mobile view displaying flickering effect +* Added missing default `font-family` value to `sendbird-message-template__root` + ## [v3.14.0-beta] (Mar 15, 2024) ### Features @@ -7,7 +13,6 @@ * Added `Carousel` ui component * `MessageTemplate` now supports composite templates - ## [v3.13.2] (Mar 14, 2024) ### Features diff --git a/package.json b/package.json index ba89290e7..afdb7a10d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sendbird/uikit-react", - "version": "3.14.0-beta", + "version": "3.14.0-beta.2", "description": "Sendbird UIKit for React: A feature-rich and customizable chat UI kit with messaging, channel management, and user authentication.", "keywords": [ "sendbird", From c6140834ea1c9335640aad207d2a0e65a4d4a809 Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Fri, 22 Mar 2024 12:42:50 +0900 Subject: [PATCH 05/16] update steps.md --- scripts/steps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/steps.md b/scripts/steps.md index bbdce7a11..c350d55c0 100644 --- a/scripts/steps.md +++ b/scripts/steps.md @@ -34,7 +34,7 @@ - Add all merged [SBISSUES tickets](https://sendbird.atlassian.net/jira/dashboards/11202?maximized=25045) in the `Linked issues` as `blocks` (When the ticket status changes to `Released`, Atlassan automatically adds a comment to each linked issues). 8. Ask EM to review the release ticket and await for `Release approved`. 9. In the root, `yarn build` to create new build files. Once created, make sure files in `dist` is newly created/updated. -10. Change directory to `./dist` and then publish `npm publish` +10. Change directory to `./dist` and then publish `npm publish` (`npm publish --tag beta` for beta release). 11. In the release branch, create a new tag `v{X.X.X}` and the push the tag `git push v{X.X.X} origin`. 12. After release do the followings: - Update release ticket to `Released` From 5c0e90ac5777a07214c2158773437958f99ff230 Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Thu, 28 Mar 2024 14:00:22 +0900 Subject: [PATCH 06/16] feat: Remove carousel component and use message template instead + some spec changes (#1032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: [AC-1230](https://sendbird.atlassian.net/browse/AC-1230) - 카루셀을 UIKit 에서 빼고 Core 쪽으로 옮긴다. - 카루셀은 View style 무시, container type 무시 (미래에 요청 있을 때 오픈) - container type full 은 뺀다 => default, wide 만 지원한다 => 지원 없는 container type 을 받으면 default 로 그려준다. - template version validation 을 추가한다. ### Changelogs - Removed `Carousel` ui component as message template returns carousel component instead - Container type 'full' is no longer supported due to inconsistent default style of parent template components with other types - Added template version validation. If template's version is unsupported, fallback message will be rendered instead. [AC-1230]: https://sendbird.atlassian.net/browse/AC-1230?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Ahyoung Ryu --- package.json | 4 +- rollup.module-exports.mjs | 1 - src/lib/dux/appInfo/initialState.ts | 1 + src/lib/dux/appInfo/utils.ts | 1 + .../components/MessageList/index.scss | 1 - .../MessageTemplateWrapper/index.tsx | 3 +- src/ui/Carousel/index.scss | 4 - src/ui/Carousel/index.tsx | 334 ------------------ src/ui/MessageContent/index.tsx | 3 +- src/ui/MessageTemplate/index.tsx | 8 +- src/ui/TemplateMessageItemBody/index.tsx | 56 ++- src/ui/TemplateMessageItemBody/types.ts | 9 +- src/utils/index.ts | 5 +- yarn.lock | 30 +- 14 files changed, 57 insertions(+), 403 deletions(-) delete mode 100644 src/ui/Carousel/index.scss delete mode 100644 src/ui/Carousel/index.tsx diff --git a/package.json b/package.json index afdb7a10d..caeab2773 100644 --- a/package.json +++ b/package.json @@ -68,8 +68,8 @@ }, "dependencies": { "@sendbird/chat": "^4.11.3", - "@sendbird/react-uikit-message-template-view": "0.0.1-alpha.65", - "@sendbird/uikit-tools": "0.0.1-alpha.65", + "@sendbird/react-uikit-message-template-view": "0.0.1-alpha.69", + "@sendbird/uikit-tools": "0.0.1-alpha.69", "css-vars-ponyfill": "^2.3.2", "date-fns": "^2.16.1", "dompurify": "^3.0.1" diff --git a/rollup.module-exports.mjs b/rollup.module-exports.mjs index 4acb682b8..eda8392dc 100644 --- a/rollup.module-exports.mjs +++ b/rollup.module-exports.mjs @@ -239,5 +239,4 @@ export default { 'ui/Word': 'src/ui/Word/index.tsx', 'ui/FeedbackIconButton': 'src/ui/FeedbackIconButton/index.tsx', 'ui/MobileFeedbackMenu': 'src/ui/MobileFeedbackMenu/index.tsx', - 'ui/Carousel': 'src/ui/Carousel/index.tsx', }; diff --git a/src/lib/dux/appInfo/initialState.ts b/src/lib/dux/appInfo/initialState.ts index b3ccc7d81..54b99449b 100644 --- a/src/lib/dux/appInfo/initialState.ts +++ b/src/lib/dux/appInfo/initialState.ts @@ -1,4 +1,5 @@ export type ProcessedMessageTemplate = { + version: number; uiTemplate: string; // This is stringified ui_template.body.items colorVariables?: Record; }; diff --git a/src/lib/dux/appInfo/utils.ts b/src/lib/dux/appInfo/utils.ts index 11159c462..cc568d5d2 100644 --- a/src/lib/dux/appInfo/utils.ts +++ b/src/lib/dux/appInfo/utils.ts @@ -6,6 +6,7 @@ import { SendbirdMessageTemplate } from '../../../ui/TemplateMessageItemBody/typ */ export const getProcessedTemplate = (parsedTemplate: SendbirdMessageTemplate): ProcessedMessageTemplate => { return { + version: parsedTemplate.ui_template.version, uiTemplate: JSON.stringify(parsedTemplate.ui_template.body.items), colorVariables: parsedTemplate.color_variables, }; diff --git a/src/modules/GroupChannel/components/MessageList/index.scss b/src/modules/GroupChannel/components/MessageList/index.scss index 202804d51..ec150d546 100644 --- a/src/modules/GroupChannel/components/MessageList/index.scss +++ b/src/modules/GroupChannel/components/MessageList/index.scss @@ -10,7 +10,6 @@ position: relative; height: 100%; overflow-x: hidden; - overflow-y: scroll; } .sendbird-separator, .sendbird-admin-message { diff --git a/src/modules/GroupChannel/components/MessageTemplateWrapper/index.tsx b/src/modules/GroupChannel/components/MessageTemplateWrapper/index.tsx index 0a8989018..350b3c388 100644 --- a/src/modules/GroupChannel/components/MessageTemplateWrapper/index.tsx +++ b/src/modules/GroupChannel/components/MessageTemplateWrapper/index.tsx @@ -9,10 +9,11 @@ export interface MessageTemplateWrapperProps extends MessageTemplateProps { export const MessageTemplateWrapper = ({ message, + templateVersion, templateItems, }: MessageTemplateWrapperProps): ReactElement => { return - + ; }; diff --git a/src/ui/Carousel/index.scss b/src/ui/Carousel/index.scss deleted file mode 100644 index 2b36c0334..000000000 --- a/src/ui/Carousel/index.scss +++ /dev/null @@ -1,4 +0,0 @@ -.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 deleted file mode 100644 index 8cd540f46..000000000 --- a/src/ui/Carousel/index.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import './index.scss'; -import React, { ReactElement, useEffect, useRef, useState } from 'react'; - -const PADDING_WIDTH = 24; -const CONTENT_LEFT_WIDTH = 40; -const SWIPE_THRESHOLD = 15; -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({ - item, - defaultWidth, -}: CarouselItemProps): ReactElement { - return
- {item} -
; -} - -interface CarouselProps { - id: string; - items: ReactElement[]; - gap?: number; - classNameWithTouchAction?: string; -} - -interface Position { - x: number; - y: number; -} - -interface DraggingInfo { - scrolling: boolean; - dragging: boolean; - startPos: Position | null; - offset: number; - translateX: number; - currentIndex: number; -} - -export const Carousel = React.memo(({ - id, - items, - gap = 8, - classNameWithTouchAction = 'sendbird-conversation__messages-padding', -}: CarouselProps): ReactElement => { - 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 [draggingInfo, setDraggingInfo] = useState({ - scrolling: false, - dragging: false, - startPos: null, - offset: 0, - translateX: 0, - currentIndex: 0, - }); - - const handleMouseDown = (event: React.MouseEvent) => { - setDraggingInfo({ - ...draggingInfo, - scrolling: false, - dragging: true, - startPos: { - x: event.clientX, - y: event.clientY, - }, - offset: 0, - }); - }; - - const handleMouseMove = (event: React.MouseEvent) => { - if (!draggingInfo.dragging) return; - const currentX = event.clientX; - const newOffset = currentX - draggingInfo.startPos.x; - setDraggingInfo({ - ...draggingInfo, - offset: newOffset, - }); - }; - - const handleMouseUp = () => { - if (!draggingInfo.dragging) return; - handleDragEnd(); - }; - - const blockScroll = () => { - const parentElements = document.getElementsByClassName(classNameWithTouchAction); - const parentElement: HTMLElement = parentElements[0] as HTMLElement; - if (parentElement) { - parentElement.style.touchAction = 'pan-x'; - } - }; - - const unblockScroll = () => { - const parentElements = document.getElementsByClassName(classNameWithTouchAction); - const parentElement: HTMLElement = parentElements[0] as HTMLElement; - if (parentElement) { - parentElement.style.touchAction = 'pan-y'; - } - }; - - const handleTouchStart = (event: React.TouchEvent) => { - setDraggingInfo({ - ...draggingInfo, - scrolling: false, - dragging: false, - startPos: { - x: event.touches[0].clientX, - y: event.touches[0].clientY, - }, - offset: 0, - }); - }; - - useEffect(() => { - if (draggingInfo.scrolling) { - unblockScroll(); - } - - }, [draggingInfo.scrolling]); - - const handleTouchMove = (event: React.TouchEvent) => { - if (!draggingInfo.startPos || draggingInfo.scrolling) return; - - const startPos = draggingInfo.startPos; - const touchMoveX = event.touches[0].clientX; - const touchMoveY = event.touches[0].clientY; - const deltaX = Math.abs(touchMoveX - startPos.x); - const deltaY = Math.abs(touchMoveY - startPos.y); - const newOffset = touchMoveX - startPos.x; - - if (newOffset === draggingInfo.offset) return; - if (draggingInfo.dragging) { - setDraggingInfo({ - ...draggingInfo, - offset: newOffset, - }); - return; - } - if (deltaY > deltaX) { - setDraggingInfo({ - ...draggingInfo, - scrolling: true, - }); - } else { - blockScroll(); - setDraggingInfo({ - ...draggingInfo, - dragging: true, - offset: newOffset, - }); - } - }; - - const getNewDraggingInfo = (props: { newTranslateX?: number, nextIndex?: number } = {}): DraggingInfo => { - const { newTranslateX, nextIndex } = props; - const { translateX, currentIndex } = draggingInfo; - return { - scrolling: false, - dragging: false, - startPos: null, - offset: 0, - translateX: newTranslateX ?? translateX, - currentIndex: nextIndex ?? currentIndex, - }; - }; - - const handleDragEnd = () => { - const offset = draggingInfo.offset; - const absOffset = Math.abs(offset); - if (absOffset < SWIPE_THRESHOLD) { - setDraggingInfo(getNewDraggingInfo()); - return; - } - // If dragged to left, next index should be to the right - const currentIndex = draggingInfo.currentIndex; - if (offset < 0 && currentIndex < items.length - 1) { - const nextIndex = currentIndex + 1; - setDraggingInfo(getNewDraggingInfo({ - newTranslateX: itemPositions[nextIndex].start, - nextIndex, - })); - // If dragged to right, next index should be to the left - } else if (offset > 0 && currentIndex > 0) { - const nextIndex = currentIndex - 1; - setDraggingInfo(getNewDraggingInfo({ - newTranslateX: itemPositions[nextIndex].start, - nextIndex, - })); - } else { - setDraggingInfo(getNewDraggingInfo()); - } - }; - const handleTouchEnd = () => { - const { offset, currentIndex } = draggingInfo; - const absOffset = Math.abs(offset); - if (absOffset < SWIPE_THRESHOLD) { - setDraggingInfo(getNewDraggingInfo()); - return; - } - // 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) { - setDraggingInfo(getNewDraggingInfo({ - newTranslateX: itemPositions[nextIndex].start, - nextIndex, - })); - } else { - setDraggingInfo(getNewDraggingInfo()); - } - } else if (nextIndex !== items.length - 1) { - setDraggingInfo(getNewDraggingInfo({ - newTranslateX: itemPositions[nextIndex].start, - nextIndex, - })); - } else { - const translateWidth = itemPositions[nextIndex].start - lastItemWidth; - const rightEmptyWidth = screenWidth - (allItemsWidth + translateWidth + PADDING_WIDTH + CONTENT_LEFT_WIDTH); - setDraggingInfo(getNewDraggingInfo({ - newTranslateX: translateWidth + rightEmptyWidth, - nextIndex, - })); - } - } else { - setDraggingInfo(getNewDraggingInfo({ - newTranslateX: itemPositions[nextIndex].start, - nextIndex, - })); - } - // If dragged to right, next index should be to the left - } else if (offset > 0 && currentIndex > 0) { - const nextIndex = currentIndex - 1; - setDraggingInfo(getNewDraggingInfo({ - newTranslateX: itemPositions[nextIndex].start, - nextIndex, - })); - } else { - setDraggingInfo(getNewDraggingInfo()); - } - if (draggingInfo.dragging) { - unblockScroll(); - } - }; - - function getCurrentTranslateX() { - return draggingInfo.translateX + draggingInfo.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; - } - - 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/index.tsx b/src/ui/MessageContent/index.tsx index 8390a3f5c..24d1581e1 100644 --- a/src/ui/MessageContent/index.tsx +++ b/src/ui/MessageContent/index.tsx @@ -262,8 +262,7 @@ export default function MessageContent(props: MessageContentProps): ReactElement onMouseLeave={() => setMouseHover(false)} > {/* left */} - {uiContainerType !== UI_CONTAINER_TYPES.FULL - &&
+ {
{ renderSenderProfile({ ...props, diff --git a/src/ui/MessageTemplate/index.tsx b/src/ui/MessageTemplate/index.tsx index dfb1f2778..9ca3f58d9 100644 --- a/src/ui/MessageTemplate/index.tsx +++ b/src/ui/MessageTemplate/index.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { parser, renderer } from '@sendbird/react-uikit-message-template-view'; import { type ComponentsUnion, createMessageTemplate } from '@sendbird/uikit-message-template'; -import { MessageTemplateItem } from '../TemplateMessageItemBody/types'; import './index.scss'; export interface MessageTemplateProps { - templateItems: MessageTemplateItem[]; + templateVersion: number; + templateItems: ComponentsUnion['properties'][]; } const { MessageTemplate: CustomTemplate } = createMessageTemplate({ @@ -25,8 +25,8 @@ const { MessageTemplate: CustomTemplate } = createMessageTemplate({ }, }); -export function MessageTemplate({ templateItems }: MessageTemplateProps) { - return ; +export function MessageTemplate({ templateItems, templateVersion }: MessageTemplateProps) { + return ; } export default MessageTemplate; diff --git a/src/ui/TemplateMessageItemBody/index.tsx b/src/ui/TemplateMessageItemBody/index.tsx index 832e47c76..a242c1373 100644 --- a/src/ui/TemplateMessageItemBody/index.tsx +++ b/src/ui/TemplateMessageItemBody/index.tsx @@ -3,7 +3,7 @@ import React, { ReactElement, useEffect, useState } from 'react'; import type { BaseMessage } from '@sendbird/chat/message'; import { getClassName, removeAtAndBraces, startsWithAtAndEndsWithBraces } from '../../utils'; import MessageTemplateWrapper from '../../modules/GroupChannel/components/MessageTemplateWrapper'; -import { CarouselItem, MessageTemplateData, MessageTemplateItem, SimpleTemplateData } from './types'; +import { CarouselItem, CarouselType, MessageTemplateData, MessageTemplateItem, SimpleTemplateData } from './types'; import restoreNumbersFromMessageTemplateObject from './utils/restoreNumbersFromMessageTemplateObject'; import mapData from './utils/mapData'; import selectColorVariablesByTheme from './utils/selectColorVariablesByTheme'; @@ -12,15 +12,14 @@ 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; + filledMessageTemplateItemsList: MessageTemplateItem[]; isErrored: boolean; + templateVersion?: number; } interface TemplateMessageItemBodyProps { @@ -100,7 +99,7 @@ export function TemplateMessageItemBody({ cachedSimpleTemplates.push(simpleCachedTemplate); simpleTemplatesVariables.push(simpleTemplateData.variables); }); - const filledMessageTemplateItemsList = cachedSimpleTemplates + const filledMessageTemplateItemsList: MessageTemplateItem[][] = cachedSimpleTemplates .map((cachedSimpleTemplate, index) => { const templateItems: MessageTemplateItem[] = JSON.parse(cachedSimpleTemplate.uiTemplate); const filledMessageTemplateItems: MessageTemplateItem[] = getFilledMessageTemplateWithData( @@ -117,20 +116,19 @@ export function TemplateMessageItemBody({ function getFilledMessageTemplateItemsForSimpleTemplate( templateItems: MessageTemplateItem[], colorVariables: Record, - ) { + ): MessageTemplateItem[] { const filledMessageTemplateItems: MessageTemplateItem[] = getFilledMessageTemplateWithData( templateItems, templateData.variables ?? {}, colorVariables, theme, ); - return [filledMessageTemplateItems]; + return filledMessageTemplateItems; } function getFilledMessageTemplateItems(): RenderData { - const result = { + const result: RenderData = { filledMessageTemplateItemsList: [], - carouselItem: undefined, isErrored: false, }; @@ -165,7 +163,7 @@ export function TemplateMessageItemBody({ } if ( templateData.view_variables - || parsedUiTemplate[0].type === 'carouselView' + || parsedUiTemplate[0].type === CarouselType || typeof parsedUiTemplate[0]['items'] === 'string' || parsedUiTemplate[0]['spacing'] ) { @@ -173,8 +171,8 @@ export function TemplateMessageItemBody({ 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' + const carouselItem = parsedUiTemplate[0] as unknown as CarouselItem; + if (carouselItem.type !== CarouselType || typeof carouselItem.items !== 'string' || !startsWithAtAndEndsWithBraces(carouselItem.items) || !carouselItem.spacing @@ -192,10 +190,14 @@ export function TemplateMessageItemBody({ logger.error('TemplateMessageItemBody | no reservation key found in view_variables: ', reservationKey, templateData.view_variables); throw new Error(); } - result.filledMessageTemplateItemsList = getFilledMessageTemplateItemsForCarouselTemplate( - simpleTemplateDataList, - ); - result.carouselItem = carouselItem; + result.templateVersion = cachedTemplate.version; + result.filledMessageTemplateItemsList = [{ + type: carouselItem.type as any, + spacing: carouselItem.spacing, + items: getFilledMessageTemplateItemsForCarouselTemplate( + simpleTemplateDataList, + ), + }]; } else { result.filledMessageTemplateItemsList = getFilledMessageTemplateItemsForSimpleTemplate( parsedUiTemplate, @@ -268,21 +270,13 @@ 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 851330e60..ab3425b13 100644 --- a/src/ui/TemplateMessageItemBody/types.ts +++ b/src/ui/TemplateMessageItemBody/types.ts @@ -1,5 +1,4 @@ import type { ComponentsUnion } from '@sendbird/uikit-message-template'; -import type { ViewStyle } from '@sendbird/uikit-message-template/src/types/styles'; type SendbirdFontWeight = 'bold' | 'normal'; @@ -80,12 +79,14 @@ export type MessageTemplateTheme = { }; }; -export type MessageTemplateItem = ComponentsUnion['properties'] | CarouselItem; +export type MessageTemplateItem = ComponentsUnion['properties']; + +export const CarouselType = 'carouselView'; + export interface CarouselItem { type: string; - viewStyle?: ViewStyle; spacing: number; - items: string; // Reservation key. ex. "@some_key" + items: string; // Reservation key. ex. "{@some_key}" } // FIXME: This needs to be updated in the future. diff --git a/src/utils/index.ts b/src/utils/index.ts index 8508301ac..1d3d9a383 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -284,7 +284,6 @@ export const isCompositeTemplateMessage = (message: CoreMessageType): boolean => export enum UI_CONTAINER_TYPES { DEFAULT = '', WIDE = 'ui_container_type__wide', - FULL = 'ui_container_type__full', DEFAULT_CAROUSEL = 'ui_container_type__default-carousel', } @@ -307,9 +306,7 @@ export const getMessageContentMiddleClassNameByContainerType = ({ return UI_CONTAINER_TYPES.DEFAULT_CAROUSEL; } if (!isMobile) return UI_CONTAINER_TYPES.DEFAULT; - if (isTemplateMessage(message) && containerType === MessageContentMiddleContainerType.FULL) { - return UI_CONTAINER_TYPES.FULL; - } else if (containerType === MessageContentMiddleContainerType.WIDE) { + if (containerType === MessageContentMiddleContainerType.WIDE) { return UI_CONTAINER_TYPES.WIDE; } return UI_CONTAINER_TYPES.DEFAULT; diff --git a/yarn.lock b/yarn.lock index 492d4b231..b0e177a2b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2659,25 +2659,25 @@ __metadata: languageName: node linkType: hard -"@sendbird/react-uikit-message-template-view@npm:0.0.1-alpha.65": - version: 0.0.1-alpha.65 - resolution: "@sendbird/react-uikit-message-template-view@npm:0.0.1-alpha.65" +"@sendbird/react-uikit-message-template-view@npm:0.0.1-alpha.69": + version: 0.0.1-alpha.69 + resolution: "@sendbird/react-uikit-message-template-view@npm:0.0.1-alpha.69" dependencies: - "@sendbird/uikit-message-template": ^0.0.1-alpha.65 + "@sendbird/uikit-message-template": ^0.0.1-alpha.69 peerDependencies: "@sendbird/chat": ">=4.3.0 <5" react: ">=16.8.6" react-dom: ">=16.8.6" - checksum: d44cf69c677a2b2a2e0aa6e90ad2aa918133cb9cb5d1ccd42d62e76e6c4ff823366bde2652998db4decf183aca4b7c001d3276e226965f5b484a06145aae5351 + checksum: f3e5c23b21fcfb751a4a93ff49e11f2c00863f621c18d6ca807f85f74ee81fe30cb686111f82ff4506677622981c86e626312a2e8092a62bb0f2ab1acd061d79 languageName: node linkType: hard -"@sendbird/uikit-message-template@npm:^0.0.1-alpha.65": - version: 0.0.1-alpha.65 - resolution: "@sendbird/uikit-message-template@npm:0.0.1-alpha.65" +"@sendbird/uikit-message-template@npm:^0.0.1-alpha.69": + version: 0.0.1-alpha.69 + resolution: "@sendbird/uikit-message-template@npm:0.0.1-alpha.69" peerDependencies: react: ">=16.8.6" - checksum: 6a430a3b3f53353b8a121fd78c8aa637baf86f9992b70cde161e10fd72e7ff0e1c9f2b716c1e0aa328f7ed1c7f750483c8d14a02205baef39edb04bfc9baf571 + checksum: fcea34576ee09b59b871fd9cec34eb121047925e6d0d03a2a72b9ff5b10421ae89959d1a693b001d87f47ffb781bfd7a49354e4a22f2cc99b4e637cfba8a57c5 languageName: node linkType: hard @@ -2700,8 +2700,8 @@ __metadata: "@rollup/plugin-replace": ^5.0.4 "@rollup/plugin-typescript": ^11.1.5 "@sendbird/chat": ^4.11.3 - "@sendbird/react-uikit-message-template-view": 0.0.1-alpha.65 - "@sendbird/uikit-tools": 0.0.1-alpha.65 + "@sendbird/react-uikit-message-template-view": 0.0.1-alpha.69 + "@sendbird/uikit-tools": 0.0.1-alpha.69 "@storybook/addon-actions": ^6.5.10 "@storybook/addon-docs": ^6.5.10 "@storybook/addon-links": ^6.5.10 @@ -2766,13 +2766,13 @@ __metadata: languageName: unknown linkType: soft -"@sendbird/uikit-tools@npm:0.0.1-alpha.65": - version: 0.0.1-alpha.65 - resolution: "@sendbird/uikit-tools@npm:0.0.1-alpha.65" +"@sendbird/uikit-tools@npm:0.0.1-alpha.69": + version: 0.0.1-alpha.69 + resolution: "@sendbird/uikit-tools@npm:0.0.1-alpha.69" peerDependencies: "@sendbird/chat": ">=4.10.5 <5" react: ">=16.8.6" - checksum: c6a7fecc7dbae40901e3f42988db85ca8c53a01e40522a3165fce780888e56b95bd858c233b23cf3368615593edf7691ce8ae62898695d28214b06e14268a653 + checksum: b1eb04697cbeceae0c4bf4c971a086934429c7dd2d6b7298b94fcccea746e6fc93a168c483130bfdea583a50de7d632174cf49b8798fc2f5e7beb937d16a12b2 languageName: node linkType: hard From 6cd74a17ef6001496aa828059371742505842232 Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Fri, 29 Mar 2024 15:30:10 +0900 Subject: [PATCH 07/16] bug-fix: Added error catch in malformed template payload (#1033) Added error catch in malformed template payload --------- Co-authored-by: Ahyoung Ryu --- src/ui/TemplateMessageItemBody/index.tsx | 28 ++++++++++++++---------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/ui/TemplateMessageItemBody/index.tsx b/src/ui/TemplateMessageItemBody/index.tsx index a242c1373..f5adc9852 100644 --- a/src/ui/TemplateMessageItemBody/index.tsx +++ b/src/ui/TemplateMessageItemBody/index.tsx @@ -138,19 +138,25 @@ export function TemplateMessageItemBody({ 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 { + // 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 + && !getCachedTemplate(simpleTemplateKey) + && !nonCachedTemplateKeys.includes(simpleTemplateKey) + ) { + nonCachedTemplateKeys.push(simpleTemplateKey); } - } + }); }); - }); + } catch (e) { + logger.error('TemplateMessageItemBody | received view_variables is malformed: ', templateData); + result.isErrored = true; + return result; + } } try { if (nonCachedTemplateKeys.length > 0) { From 42b5884a7ab7938171a51ff890a895f8993ebeb7 Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Mon, 1 Apr 2024 18:41:09 +0900 Subject: [PATCH 08/16] spec-change: Move timestamp to bottom left for carousel (#1034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ![Screenshot 2024-04-01 at 1 35 26 PM](https://github.com/sendbird/sendbird-uikit-react/assets/16806397/ce86a702-1e97-4423-83a6-6f09f45d3330) --- src/ui/MessageContent/index.scss | 31 ++++--------------------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/src/ui/MessageContent/index.scss b/src/ui/MessageContent/index.scss index f9862aa0a..0fb6d9123 100644 --- a/src/ui/MessageContent/index.scss +++ b/src/ui/MessageContent/index.scss @@ -1,12 +1,5 @@ @import '../../styles/variables'; -.sendbird-msg-hoc > .ui_container_type__full { - @include mobile() { - padding: 0; - width: 100%; - } -} - .sendbird-message-content { display: inline-flex; flex-direction: row; @@ -35,12 +28,6 @@ } } - .ui_container_type__full { - @include mobile() { - max-width: 100%; - } - } - .sendbird-message-content__middle { .sendbird-message-content__middle__quote-message.use-quote { bottom: -8px; @@ -100,17 +87,12 @@ .ui_container_type__default-carousel { min-width: fit-content; bottom: -16px; - right: 0px; + left: 2px; } .ui_container_type__wide { min-width: fit-content; bottom: -16px; - right: 0px; - } - .ui_container_type__full { - min-width: fit-content; - bottom: -16px; - right: 0px; + right: 2px; } } @@ -264,17 +246,12 @@ .ui_container_type__default-carousel { min-width: fit-content; bottom: -20px; - left: 0px; + left: 2px; } .ui_container_type__wide { min-width: fit-content; bottom: -20px; - left: 0px; - } - .ui_container_type__full { - min-width: fit-content; - bottom: -20px; - left: 0px; + left: 2px; } } } From de446c3506d34898f1c5d86f973fc00dc654c2c7 Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Mon, 1 Apr 2024 19:35:15 +0900 Subject: [PATCH 09/16] feat: support carousel template with simple templates (#1035) 1. Carousel templates created with direct simple templates in items (no reservations keys) should be suppported as fallback logic. 2. timestamp location of carousel template body must be dynamically updated. 3. For composite template, template version should be max of (composite + its simple templstes). --- src/lib/dux/appInfo/utils.ts | 2 +- src/ui/MessageContent/MessageBody/index.tsx | 4 + src/ui/MessageContent/index.tsx | 53 +++-- .../messageTemplateErrorBoundary.tsx | 5 + src/ui/TemplateMessageItemBody/index.tsx | 201 ++++++++++++------ src/ui/TemplateMessageItemBody/types.ts | 22 +- src/utils/index.ts | 11 - 7 files changed, 189 insertions(+), 109 deletions(-) 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; From 4c80d8f9c764594511cea2d1bdf8327d07fb8bd2 Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Thu, 4 Apr 2024 11:52:59 +0900 Subject: [PATCH 10/16] bug-fix: fix template qa issues (#1036) Fixes: - [AC-1824](https://sendbird.atlassian.net/browse/AC-1824) 1. fix default profile not showing issue 2. fix scroll displayed in message sender title container [AC-1824]: https://sendbird.atlassian.net/browse/AC-1824?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- .../__snapshots__/Avatar.spec.js.snap | 30 +++++++++++++++---- .../__tests__/ChannelAvatar.spec.js | 2 +- src/ui/ImageRenderer/index.tsx | 2 +- .../__snapshots__/MessageContent.spec.js.snap | 15 ++++++++-- src/ui/MessageContent/index.scss | 2 +- .../OGMessageItemBody.spec.js.snap | 14 +++++++-- 6 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/ui/Avatar/__tests__/__snapshots__/Avatar.spec.js.snap b/src/ui/Avatar/__tests__/__snapshots__/Avatar.spec.js.snap index 29941c75a..e8fda54dd 100644 --- a/src/ui/Avatar/__tests__/__snapshots__/Avatar.spec.js.snap +++ b/src/ui/Avatar/__tests__/__snapshots__/Avatar.spec.js.snap @@ -58,9 +58,18 @@ exports[`ui/Avatar should do a snapshot test of the Avatar DOM with four element style="width: 100%; min-width: min(400px, 56px); max-width: 400px;" >
+ class="sendbird-avatar-img--default " + style="width: 56px; height: 56px;" + > +
+ +
+
@@ -212,9 +221,18 @@ exports[`ui/Avatar should render default image if src is empty 1`] = ` style="width: 100%; min-width: min(400px, 56px); max-width: 400px;" >
+ class="sendbird-avatar-img--default " + style="width: 56px; height: 56px;" + > +
+ +
+
diff --git a/src/ui/ChannelAvatar/__tests__/ChannelAvatar.spec.js b/src/ui/ChannelAvatar/__tests__/ChannelAvatar.spec.js index bea2e96ff..dc7ed9617 100644 --- a/src/ui/ChannelAvatar/__tests__/ChannelAvatar.spec.js +++ b/src/ui/ChannelAvatar/__tests__/ChannelAvatar.spec.js @@ -8,7 +8,7 @@ describe('ui/ChannelAvatar', () => { it('should render a normal default channel avatar', function() { const targetClassName = "sendbird-chat-header--avatar--group-channel"; render(); - expect(screen.getByRole('button').className).toContain(targetClassName); + expect(screen.getAllByRole('button')[0].className).toContain(targetClassName); }); it('should render a default avatar of broadcastChannel', function() { diff --git a/src/ui/ImageRenderer/index.tsx b/src/ui/ImageRenderer/index.tsx index 1e4b0fbd3..973197e42 100644 --- a/src/ui/ImageRenderer/index.tsx +++ b/src/ui/ImageRenderer/index.tsx @@ -135,7 +135,7 @@ const ImageRenderer = ({ }} > {placeholderVisible && renderPlaceholder()} - {defaultComponentVisible ? renderDefault() : renderImage()} + {(!internalUrl || defaultComponentVisible) ? renderDefault() : renderImage()} {shadeOnHover && (
+ class="sendbird-avatar-img--default " + style="width: 28px; height: 28px;" + > +
+ +
+
diff --git a/src/ui/MessageContent/index.scss b/src/ui/MessageContent/index.scss index 0fb6d9123..17040a483 100644 --- a/src/ui/MessageContent/index.scss +++ b/src/ui/MessageContent/index.scss @@ -99,7 +99,7 @@ .sendbird-message-content__middle__sender-name { margin-bottom: 4px; margin-left: 12px; - overflow-x: hidden; + overflow: hidden; position: relative; text-overflow: ellipsis; width: 100%; diff --git a/src/ui/OGMessageItemBody/__tests__/__snapshots__/OGMessageItemBody.spec.js.snap b/src/ui/OGMessageItemBody/__tests__/__snapshots__/OGMessageItemBody.spec.js.snap index 116c3a9d3..4eda22239 100644 --- a/src/ui/OGMessageItemBody/__tests__/__snapshots__/OGMessageItemBody.spec.js.snap +++ b/src/ui/OGMessageItemBody/__tests__/__snapshots__/OGMessageItemBody.spec.js.snap @@ -24,9 +24,17 @@ exports[`ui/OGMessageItemBody should do a snapshot test of the OGMessageItemBody style="width: 100%; min-width: min(400px, 100%); max-width: 400px;" >
+ class="sendbird-og-message-item-body__og-thumbnail__place-holder" + > +
+ +
+
Date: Thu, 4 Apr 2024 11:54:04 +0900 Subject: [PATCH 11/16] upgrade core package versions --- package.json | 4 ++-- yarn.lock | 30 +++++++++++++++--------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index caeab2773..8b4b3f1f7 100644 --- a/package.json +++ b/package.json @@ -68,8 +68,8 @@ }, "dependencies": { "@sendbird/chat": "^4.11.3", - "@sendbird/react-uikit-message-template-view": "0.0.1-alpha.69", - "@sendbird/uikit-tools": "0.0.1-alpha.69", + "@sendbird/react-uikit-message-template-view": "0.0.1-alpha.70", + "@sendbird/uikit-tools": "0.0.1-alpha.70", "css-vars-ponyfill": "^2.3.2", "date-fns": "^2.16.1", "dompurify": "^3.0.1" diff --git a/yarn.lock b/yarn.lock index b0e177a2b..9cf7ea70e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2659,25 +2659,25 @@ __metadata: languageName: node linkType: hard -"@sendbird/react-uikit-message-template-view@npm:0.0.1-alpha.69": - version: 0.0.1-alpha.69 - resolution: "@sendbird/react-uikit-message-template-view@npm:0.0.1-alpha.69" +"@sendbird/react-uikit-message-template-view@npm:0.0.1-alpha.70": + version: 0.0.1-alpha.70 + resolution: "@sendbird/react-uikit-message-template-view@npm:0.0.1-alpha.70" dependencies: - "@sendbird/uikit-message-template": ^0.0.1-alpha.69 + "@sendbird/uikit-message-template": ^0.0.1-alpha.70 peerDependencies: "@sendbird/chat": ">=4.3.0 <5" react: ">=16.8.6" react-dom: ">=16.8.6" - checksum: f3e5c23b21fcfb751a4a93ff49e11f2c00863f621c18d6ca807f85f74ee81fe30cb686111f82ff4506677622981c86e626312a2e8092a62bb0f2ab1acd061d79 + checksum: 2a9a153589f0c2a11c2cf25b7d49a61c09983d06e9a6611f0eef5803cb9139d14a0fc39daff9416afee2cea7eb7eced86633fc9f82cddb80bc2548ed82aea695 languageName: node linkType: hard -"@sendbird/uikit-message-template@npm:^0.0.1-alpha.69": - version: 0.0.1-alpha.69 - resolution: "@sendbird/uikit-message-template@npm:0.0.1-alpha.69" +"@sendbird/uikit-message-template@npm:^0.0.1-alpha.70": + version: 0.0.1-alpha.70 + resolution: "@sendbird/uikit-message-template@npm:0.0.1-alpha.70" peerDependencies: react: ">=16.8.6" - checksum: fcea34576ee09b59b871fd9cec34eb121047925e6d0d03a2a72b9ff5b10421ae89959d1a693b001d87f47ffb781bfd7a49354e4a22f2cc99b4e637cfba8a57c5 + checksum: 3ddb281dacd212133d0e7b5b0b6da2bb0a232c5f3884d4542ede61004304008217b68144ce64dc18dec5507fc72b72e3c07bc0312b1b79fdc2c6b7a8c16afa5a languageName: node linkType: hard @@ -2700,8 +2700,8 @@ __metadata: "@rollup/plugin-replace": ^5.0.4 "@rollup/plugin-typescript": ^11.1.5 "@sendbird/chat": ^4.11.3 - "@sendbird/react-uikit-message-template-view": 0.0.1-alpha.69 - "@sendbird/uikit-tools": 0.0.1-alpha.69 + "@sendbird/react-uikit-message-template-view": 0.0.1-alpha.70 + "@sendbird/uikit-tools": 0.0.1-alpha.70 "@storybook/addon-actions": ^6.5.10 "@storybook/addon-docs": ^6.5.10 "@storybook/addon-links": ^6.5.10 @@ -2766,13 +2766,13 @@ __metadata: languageName: unknown linkType: soft -"@sendbird/uikit-tools@npm:0.0.1-alpha.69": - version: 0.0.1-alpha.69 - resolution: "@sendbird/uikit-tools@npm:0.0.1-alpha.69" +"@sendbird/uikit-tools@npm:0.0.1-alpha.70": + version: 0.0.1-alpha.70 + resolution: "@sendbird/uikit-tools@npm:0.0.1-alpha.70" peerDependencies: "@sendbird/chat": ">=4.10.5 <5" react: ">=16.8.6" - checksum: b1eb04697cbeceae0c4bf4c971a086934429c7dd2d6b7298b94fcccea746e6fc93a168c483130bfdea583a50de7d632174cf49b8798fc2f5e7beb937d16a12b2 + checksum: 5dbfc844d7fdaed2eef1120a77192ae47ba181ae37e699199d59635a0cc56dc94b93363eec02fa3245ec119a9ea780843bd920abb2ca7a875b5f092145ec36f5 languageName: node linkType: hard From d82a50e689403e31ed7d81ce70b6db9cc1632e6e Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Thu, 4 Apr 2024 13:28:46 +0900 Subject: [PATCH 12/16] bug-fix: Fix bug where argb conversion step is skipped for carousel without reservation key (#1038) - Fix bug where argb conversion step is skipped for carousel without reservation key - string to numeric conversion skipped --- src/ui/TemplateMessageItemBody/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ui/TemplateMessageItemBody/index.tsx b/src/ui/TemplateMessageItemBody/index.tsx index 7b381fa0c..28208eb66 100644 --- a/src/ui/TemplateMessageItemBody/index.tsx +++ b/src/ui/TemplateMessageItemBody/index.tsx @@ -158,7 +158,10 @@ export function TemplateMessageItemBody({ const filledTemplates: MessageTemplateItem[][] = []; uiTemplates.forEach((uiTemplate: SendbirdUiTemplate) => { maxVersion = Math.max(maxVersion, uiTemplate.version); - filledTemplates.push(uiTemplate.body.items); + const filledMessageTemplateItems: MessageTemplateItem[] = getFilledMessageTemplateWithData({ + template: uiTemplate.body.items, + }); + filledTemplates.push(filledMessageTemplateItems); }); return { maxVersion, From a208b84a381154b4a02c8f320cf6e42eb1434b1e Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Fri, 5 Apr 2024 12:10:57 +0900 Subject: [PATCH 13/16] bug-fix: Fix carousel template parent classname not applied (#1039) --- package.json | 4 ++-- src/ui/MessageTemplate/index.tsx | 4 ++-- yarn.lock | 30 +++++++++++++++--------------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 8b4b3f1f7..96872b75f 100644 --- a/package.json +++ b/package.json @@ -68,8 +68,8 @@ }, "dependencies": { "@sendbird/chat": "^4.11.3", - "@sendbird/react-uikit-message-template-view": "0.0.1-alpha.70", - "@sendbird/uikit-tools": "0.0.1-alpha.70", + "@sendbird/react-uikit-message-template-view": "0.0.1-alpha.72", + "@sendbird/uikit-tools": "0.0.1-alpha.72", "css-vars-ponyfill": "^2.3.2", "date-fns": "^2.16.1", "dompurify": "^3.0.1" diff --git a/src/ui/MessageTemplate/index.tsx b/src/ui/MessageTemplate/index.tsx index 9ca3f58d9..abd6512e7 100644 --- a/src/ui/MessageTemplate/index.tsx +++ b/src/ui/MessageTemplate/index.tsx @@ -11,11 +11,11 @@ export interface MessageTemplateProps { const { MessageTemplate: CustomTemplate } = createMessageTemplate({ parser, renderer, - Container: ({ children }) => { + Container: ({ children, className }) => { return (
diff --git a/yarn.lock b/yarn.lock index 9cf7ea70e..17e5a321b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2659,25 +2659,25 @@ __metadata: languageName: node linkType: hard -"@sendbird/react-uikit-message-template-view@npm:0.0.1-alpha.70": - version: 0.0.1-alpha.70 - resolution: "@sendbird/react-uikit-message-template-view@npm:0.0.1-alpha.70" +"@sendbird/react-uikit-message-template-view@npm:0.0.1-alpha.72": + version: 0.0.1-alpha.72 + resolution: "@sendbird/react-uikit-message-template-view@npm:0.0.1-alpha.72" dependencies: - "@sendbird/uikit-message-template": ^0.0.1-alpha.70 + "@sendbird/uikit-message-template": ^0.0.1-alpha.72 peerDependencies: "@sendbird/chat": ">=4.3.0 <5" react: ">=16.8.6" react-dom: ">=16.8.6" - checksum: 2a9a153589f0c2a11c2cf25b7d49a61c09983d06e9a6611f0eef5803cb9139d14a0fc39daff9416afee2cea7eb7eced86633fc9f82cddb80bc2548ed82aea695 + checksum: f08023a2f679c942fbce73288a40752e7c1a8f03b487b4dffa2e76495f1078706cab3db41e0f02468569717dc9b84f26e8d0addbdc80bd5d9ae1184d3ec33c66 languageName: node linkType: hard -"@sendbird/uikit-message-template@npm:^0.0.1-alpha.70": - version: 0.0.1-alpha.70 - resolution: "@sendbird/uikit-message-template@npm:0.0.1-alpha.70" +"@sendbird/uikit-message-template@npm:^0.0.1-alpha.72": + version: 0.0.1-alpha.72 + resolution: "@sendbird/uikit-message-template@npm:0.0.1-alpha.72" peerDependencies: react: ">=16.8.6" - checksum: 3ddb281dacd212133d0e7b5b0b6da2bb0a232c5f3884d4542ede61004304008217b68144ce64dc18dec5507fc72b72e3c07bc0312b1b79fdc2c6b7a8c16afa5a + checksum: 71b66ab67cbeb07f6020d7d56e0e3464b17cfdd7f201025ac7415dfad83525416699a4eceac049c78d4b68e10f64a559437d274c59e1bf6f884f4fc6dddc7e8a languageName: node linkType: hard @@ -2700,8 +2700,8 @@ __metadata: "@rollup/plugin-replace": ^5.0.4 "@rollup/plugin-typescript": ^11.1.5 "@sendbird/chat": ^4.11.3 - "@sendbird/react-uikit-message-template-view": 0.0.1-alpha.70 - "@sendbird/uikit-tools": 0.0.1-alpha.70 + "@sendbird/react-uikit-message-template-view": 0.0.1-alpha.72 + "@sendbird/uikit-tools": 0.0.1-alpha.72 "@storybook/addon-actions": ^6.5.10 "@storybook/addon-docs": ^6.5.10 "@storybook/addon-links": ^6.5.10 @@ -2766,13 +2766,13 @@ __metadata: languageName: unknown linkType: soft -"@sendbird/uikit-tools@npm:0.0.1-alpha.70": - version: 0.0.1-alpha.70 - resolution: "@sendbird/uikit-tools@npm:0.0.1-alpha.70" +"@sendbird/uikit-tools@npm:0.0.1-alpha.72": + version: 0.0.1-alpha.72 + resolution: "@sendbird/uikit-tools@npm:0.0.1-alpha.72" peerDependencies: "@sendbird/chat": ">=4.10.5 <5" react: ">=16.8.6" - checksum: 5dbfc844d7fdaed2eef1120a77192ae47ba181ae37e699199d59635a0cc56dc94b93363eec02fa3245ec119a9ea780843bd920abb2ca7a875b5f092145ec36f5 + checksum: a209342267a8ddbbdd9d310a42def22d54a20ad30a54002de893480ed0bc26c0d3ebb7614ceb39340f3e6eb0d7bf5067769a0b73aca135b9e954580a8f5b08c0 languageName: node linkType: hard From a17237706e5704efe4122f3d9b761cf0bc0f7fd9 Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Fri, 5 Apr 2024 15:19:55 +0900 Subject: [PATCH 14/16] feat: Limit number of simple templates for carousel template. (#1042) Co-authored-by: Baek EunSeo --- src/ui/TemplateMessageItemBody/index.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/ui/TemplateMessageItemBody/index.tsx b/src/ui/TemplateMessageItemBody/index.tsx index 28208eb66..dc098f895 100644 --- a/src/ui/TemplateMessageItemBody/index.tsx +++ b/src/ui/TemplateMessageItemBody/index.tsx @@ -242,11 +242,15 @@ export function TemplateMessageItemBody({ 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]; + let 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'); } + if (simpleTemplateDataList.length > 10) { + logger.warning('TemplateMessageItemBody | composite template with more than 10 simple templates will only render the first 10 items: ', reservationKey, templateData.view_variables); + simpleTemplateDataList = simpleTemplateDataList.slice(0, 10); + } const { maxVersion, filledTemplates } = getFilledMessageTemplateItemsForCarouselTemplateByMessagePayload( simpleTemplateDataList, ); @@ -258,8 +262,13 @@ export function TemplateMessageItemBody({ items: filledTemplates, }]; } else if (Array.isArray(carouselItem.items)) { + let simpleTemplates: SendbirdUiTemplate[] = carouselItem.items; + if (carouselItem.items.length > 10) { + logger.warning('TemplateMessageItemBody | composite template with more than 10 simple templates will only render the first 10 items: ', carouselItem); + simpleTemplates = carouselItem.items.slice(0, 10); + } const { maxVersion, filledTemplates } = getFilledMessageTemplateItemsForCarouselTemplate( - carouselItem.items, + simpleTemplates, ); result.isComposite = true; result.templateVersion = Math.max(cachedTemplate.version, maxVersion); From 4fc5bf3bf23802fd370d61b859509be1599ec858 Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Fri, 5 Apr 2024 16:05:27 +0900 Subject: [PATCH 15/16] Add changelog --- CHANGELOG.md | 14 +++ src/types.ts | 1 - src/ui/Carousel/index.scss | 4 + src/ui/Carousel/index.tsx | 236 +++++++++++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 src/ui/Carousel/index.scss create mode 100644 src/ui/Carousel/index.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f24408d36..21041e7a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog - v3 +## [v3.14.0] (Apr 5, 2024) +### Feature +- `TemplateMessageItemBody` now supports `CarouselView` type template +- Added 'wide' width support for `MessageContent` when value exists in `message.extendedMessagePayload['ui']['container_type']` +- Added template version validation for `TemplateMessageItemBody` +- Removed default values of `borderRadius`, `backgroundColor`, and `color` for message template items + +### Message template fixes +- Fixed a bug where argb color values are not converted to rgba +- Fixed a bug where style properties expecting numeric values are set with string values + +### Other fixes +- Fixed a bug where scroll bar is displayed in message sender name container + ## [v3.13.5] (Apr 5, 2024) ### Fixes diff --git a/src/types.ts b/src/types.ts index a9ec0903d..d5bbeeb75 100644 --- a/src/types.ts +++ b/src/types.ts @@ -90,5 +90,4 @@ export type SendbirdTheme = 'light' | 'dark'; export enum MessageContentMiddleContainerType { DEFAULT = 'default', WIDE = 'wide', - FULL = 'full', } 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; From 7d129d577bbb707e32c2ce8d66ddb082e7b1311d Mon Sep 17 00:00:00 2001 From: Liam Hongman Cho Date: Fri, 5 Apr 2024 16:12:29 +0900 Subject: [PATCH 16/16] Add changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21041e7a2..4189a853a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,11 @@ - `TemplateMessageItemBody` now supports `CarouselView` type template - Added 'wide' width support for `MessageContent` when value exists in `message.extendedMessagePayload['ui']['container_type']` - Added template version validation for `TemplateMessageItemBody` -- Removed default values of `borderRadius`, `backgroundColor`, and `color` for message template items -### Message template fixes +### Message template fixes/updates - Fixed a bug where argb color values are not converted to rgba - Fixed a bug where style properties expecting numeric values are set with string values +- Removed default values of `borderRadius`, `backgroundColor`, and `color` for message template items ### Other fixes - Fixed a bug where scroll bar is displayed in message sender name container