diff --git a/CHANGELOG.md b/CHANGELOG.md index f24408d36..4189a853a 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` + +### 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 + ## [v3.13.5] (Apr 5, 2024) ### Fixes diff --git a/package.json b/package.json index 28f5f31de..4249afe89 100644 --- a/package.json +++ b/package.json @@ -67,9 +67,9 @@ "react-dom": "^16.8.6 || ^17.0.0 || ^18.0.0" }, "dependencies": { - "@sendbird/chat": "^4.11.0", - "@sendbird/react-uikit-message-template-view": "0.0.1-alpha.68", - "@sendbird/uikit-tools": "0.0.1-alpha.68", + "@sendbird/chat": "^4.11.3", + "@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/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` 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..54b99449b 100644 --- a/src/lib/dux/appInfo/initialState.ts +++ b/src/lib/dux/appInfo/initialState.ts @@ -1,5 +1,6 @@ export type ProcessedMessageTemplate = { - uiTemplate: string; // This is stringified ui_template. + version: number; + uiTemplate: string; // This is stringified ui_template.body.items colorVariables?: Record; }; @@ -10,7 +11,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..ea1ce6f4c 100644 --- a/src/lib/dux/appInfo/utils.ts +++ b/src/lib/dux/appInfo/utils.ts @@ -6,12 +6,13 @@ import { SendbirdMessageTemplate } from '../../../ui/TemplateMessageItemBody/typ */ export const getProcessedTemplate = (parsedTemplate: SendbirdMessageTemplate): ProcessedMessageTemplate => { return { + version: Number(parsedTemplate.ui_template.version), uiTemplate: JSON.stringify(parsedTemplate.ui_template.body.items), colorVariables: parsedTemplate.color_variables, }; }; -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 785aa3d6e..a51c5eb35 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -258,6 +258,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/MessageList/index.scss b/src/modules/GroupChannel/components/MessageList/index.scss index 7f7bf87f8..ec150d546 100644 --- a/src/modules/GroupChannel/components/MessageList/index.scss +++ b/src/modules/GroupChannel/components/MessageList/index.scss @@ -8,11 +8,8 @@ position: relative; .sendbird-conversation__messages-padding { position: relative; - padding-left: 24px; - padding-right: 24px; height: 100%; overflow-x: hidden; - overflow-y: scroll; } .sendbird-separator, .sendbird-admin-message { 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..350b3c388 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,9 +7,13 @@ export interface MessageTemplateWrapperProps extends MessageTemplateProps { message: BaseMessage; } -export const MessageTemplateWrapper = ({ message, templateItems }: MessageTemplateWrapperProps) => { +export const MessageTemplateWrapper = ({ + message, + templateVersion, + templateItems, +}: MessageTemplateWrapperProps): ReactElement => { return - + ; }; diff --git a/src/types.ts b/src/types.ts index 03bd9395f..d5bbeeb75 100644 --- a/src/types.ts +++ b/src/types.ts @@ -86,3 +86,8 @@ export interface UploadedFileInfoWithUpload { } export type SendbirdTheme = 'light' | 'dark'; + +export enum MessageContentMiddleContainerType { + DEFAULT = 'default', + WIDE = 'wide', +} 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/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/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 && (
; 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 08eaaa1ac..a206f386d 100644 --- a/src/ui/MessageContent/MessageBody/index.tsx +++ b/src/ui/MessageContent/MessageBody/index.tsx @@ -24,11 +24,13 @@ import TemplateMessageItemBody from '../../TemplateMessageItemBody'; import type { OnBeforeDownloadFileMessageType } from '../../../modules/GroupChannel/context/GroupChannelProvider'; 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; + onTemplateMessageRenderedCallback?: (renderedTemplateBodyType: RenderedTemplateBodyType) => void; onMessageHeightChange?: () => void; onBeforeDownloadFileMessage?: OnBeforeDownloadFileMessageType; @@ -45,6 +47,7 @@ export default function MessageBody(props: MessageBodyProps): ReactElement { channel, showFileViewer, onMessageHeightChange, + onTemplateMessageRenderedCallback, onBeforeDownloadFileMessage, mouseHover, @@ -69,6 +72,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/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..b63657108 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
+ class="sendbird-avatar-img--default " + style="width: 28px; height: 28px;" + > +
+ +
+
mock-date diff --git a/src/ui/MessageContent/index.scss b/src/ui/MessageContent/index.scss index 47b45c89e..17040a483 100644 --- a/src/ui/MessageContent/index.scss +++ b/src/ui/MessageContent/index.scss @@ -1,18 +1,11 @@ @import '../../styles/variables'; -:root { - --sendbird-feedback-buttons-container-height: 36px; - --sendbird-feedback-buttons-container-margin-top: 4px; - --sendbird-thread-replies-height: 28px; - --sendbird-thread-replies-margin-top: 4px; - --sendbird-feedback-buttons-container-margin-bottom: 4px; -} - .sendbird-message-content { display: inline-flex; flex-direction: row; position: relative; - width: 100%; + width: calc(100% - 48px); + padding: 0 24px; &.incoming { justify-content: flex-start; @@ -29,6 +22,12 @@ } } + .ui_container_type__wide { + @include mobile() { + max-width: 100%; + } + } + .sendbird-message-content__middle { .sendbird-message-content__middle__quote-message.use-quote { bottom: -8px; @@ -37,14 +36,6 @@ } } -.sendbird-message-content__feedback { - margin-bottom: calc( - var(--sendbird-feedback-buttons-container-height) - + var(--sendbird-feedback-buttons-container-margin-top) - + var(--sendbird-feedback-buttons-container-margin-bottom) - ); -} - .sendbird-message-content__middle__quote-message__quote { width: 100%; } @@ -93,12 +84,22 @@ display: none; } } + .ui_container_type__default-carousel { + min-width: fit-content; + bottom: -16px; + left: 2px; + } + .ui_container_type__wide { + min-width: fit-content; + bottom: -16px; + right: 2px; + } } .sendbird-message-content__middle__sender-name { margin-bottom: 4px; margin-left: 12px; - overflow-x: hidden; + overflow: hidden; position: relative; text-overflow: ellipsis; width: 100%; @@ -167,6 +168,9 @@ .sendbird-message-content__middle__body-container__created-at { display: none; } + .ui_container_type__default-carousel { + display: flex; + } } } } @@ -239,6 +243,16 @@ position: relative; } } + .ui_container_type__default-carousel { + min-width: fit-content; + bottom: -20px; + left: 2px; + } + .ui_container_type__wide { + min-width: fit-content; + bottom: -20px; + left: 2px; + } } } @@ -315,23 +329,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 2cc6a3648..8c13bd790 100644 --- a/src/ui/MessageContent/index.tsx +++ b/src/ui/MessageContent/index.tsx @@ -1,15 +1,11 @@ -import React, { - ReactElement, ReactNode, useContext, - useRef, - useState, -} from 'react'; +import React, { ReactElement, ReactNode, useContext, useMemo, 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'; @@ -17,12 +13,15 @@ import QuoteMessage from '../QuoteMessage'; import type { OnBeforeDownloadFileMessageType } from '../../modules/GroupChannel/context/GroupChannelProvider'; 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'; @@ -76,7 +75,7 @@ export interface MessageContentProps { onMessageHeightChange?: () => void; onBeforeDownloadFileMessage?: OnBeforeDownloadFileMessageType; - // For injecting customizable sub-components + // For injecting customizable subcomponents renderSenderProfile?: (props: MessageProfileProps) => ReactNode; renderMessageBody?: (props: MessageBodyProps) => ReactNode; renderMessageHeader?: (props: MessageHeaderProps) => ReactNode; @@ -144,6 +143,9 @@ export default function MessageContent(props: MessageContentProps): ReactElement const { logger } = config; 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); @@ -153,6 +155,21 @@ export default function MessageContent(props: MessageContentProps): ReactElement const [showFeedbackOptionsMenu, setShowFeedbackOptionsMenu] = useState(false); const [showFeedbackModal, setShowFeedbackModal] = useState(false); const [feedbackFailedText, setFeedbackFailedText] = useState(''); + 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); @@ -178,7 +195,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: @@ -194,6 +211,24 @@ export default function MessageContent(props: MessageContentProps): ReactElement const showThreadReplies = isNotTemplateMessage && displayThreadReplies; const showRightContent = isNotTemplateMessage && !isByMe && !isMobile; + 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); }; @@ -229,17 +264,23 @@ export default function MessageContent(props: MessageContentProps): ReactElement return (
setMouseHover(true)} onMouseLeave={() => setMouseHover(false)} > {/* left */} -
+ {
{ renderSenderProfile({ ...props, isByMe, displayThreadReplies, + bottom: totalBottom > 0 ? totalBottom + 'px' : '', }) } {/* outgoing menu */} @@ -276,12 +317,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({ @@ -491,8 +554,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..21bbd3c07 100644 --- a/src/ui/MessageTemplate/index.scss +++ b/src/ui/MessageTemplate/index.scss @@ -1,3 +1,8 @@ .sendbird-message-template__root * { box-sizing: border-box; } + +.sendbird-message-template__root { + border-radius: 0; + font-family: var(--sendbird-font-family-default); +} \ No newline at end of file diff --git a/src/ui/MessageTemplate/index.tsx b/src/ui/MessageTemplate/index.tsx index cc88eb836..abd6512e7 100644 --- a/src/ui/MessageTemplate/index.tsx +++ b/src/ui/MessageTemplate/index.tsx @@ -1,21 +1,21 @@ import React from 'react'; import { parser, renderer } from '@sendbird/react-uikit-message-template-view'; -import { createMessageTemplate } from '@sendbird/uikit-message-template'; -import { MessageTemplateItem } from '../TemplateMessageItemBody/types'; +import { type ComponentsUnion, createMessageTemplate } from '@sendbird/uikit-message-template'; import './index.scss'; export interface MessageTemplateProps { - templateItems: MessageTemplateItem[]; + templateVersion: number; + templateItems: ComponentsUnion['properties'][]; } const { MessageTemplate: CustomTemplate } = createMessageTemplate({ parser, renderer, - Container: ({ children }) => { + Container: ({ children, className }) => { return (
@@ -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/MessageTemplate/messageTemplateErrorBoundary.tsx b/src/ui/MessageTemplate/messageTemplateErrorBoundary.tsx index cae0522d6..5394c77de 100644 --- a/src/ui/MessageTemplate/messageTemplateErrorBoundary.tsx +++ b/src/ui/MessageTemplate/messageTemplateErrorBoundary.tsx @@ -1,9 +1,12 @@ import { Component, ErrorInfo, ReactNode } from 'react'; import { LoggerInterface } from '../../lib/Logger'; +import { RenderedTemplateBodyType } from '../MessageContent/MessageBody'; interface ErrorBoundaryProps { children: ReactNode; fallbackMessage: ReactNode; + onTemplateMessageRenderedCallback: (renderedTemplateBodyType: RenderedTemplateBodyType) => void; + isComposite?: boolean; logger?: LoggerInterface; } @@ -27,8 +30,10 @@ export class MessageTemplateErrorBoundary extends Component
+ class="sendbird-og-message-item-body__og-thumbnail__place-holder" + > +
+ +
+
; message: BaseMessage; isByMe?: boolean; theme?: SendbirdTheme; + 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; }; @@ -48,19 +73,27 @@ export function TemplateMessageItemBody({ message, isByMe = false, theme = 'light', + onTemplateMessageRenderedCallback = () => { /* noop */ }, }: TemplateMessageItemBodyProps): ReactElement { - const store = useSendbirdStateContext(); - const logger = store?.config?.logger; - 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 { @@ -68,75 +101,248 @@ 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 filledMessageTemplateItems: MessageTemplateItem[] = getFilledMessageTemplateWithData( - JSON.parse(cachedTemplate.uiTemplate), - templateData.variables ?? {}, - cachedTemplate.colorVariables, + const [ + renderData, + setRenderData, + ] = useState(getFilledMessageTemplateItems()); + + 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('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({ + template: templateItems, + templateData: simpleTemplatesVariables[index], + colorVariables: 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 + }); + return filledMessageTemplateItems; + }); + 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); + const filledMessageTemplateItems: MessageTemplateItem[] = getFilledMessageTemplateWithData({ + template: uiTemplate.body.items, + }); + filledTemplates.push(filledMessageTemplateItems); + }); + return { + maxVersion, + filledTemplates, + }; + } + + function getFilledMessageTemplateItemsForSimpleTemplate( + templateItems: MessageTemplateItem[], + colorVariables?: Record, + ): MessageTemplateItem[] { + const filledMessageTemplateItems: MessageTemplateItem[] = getFilledMessageTemplateWithData({ + template: templateItems, + templateData: templateData?.variables ?? {}, + colorVariables, + theme, + }); + return filledMessageTemplateItems; + } + + function getFilledMessageTemplateItems(): RenderData { + const result: RenderData = { + filledMessageTemplateItemsList: [], + isErrored: false, + }; + + const nonCachedTemplateKeys: string[] = []; + const cachedTemplate = getCachedTemplate(templateKey); + if (!cachedTemplate) { + nonCachedTemplateKeys.push(templateKey); + } + if (templateData.view_variables) { + 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) { + 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('TemplateMessageItemBody | parsed template is missing ui_template. See error log in console for details'); + } /** - * 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. + * Composite template validation */ + if (parsedUiTemplate[0].type === CompositeComponentType.Carousel) { + const carouselItem = parsedUiTemplate[0] as unknown as CarouselItem; + 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('TemplateMessageItemBody | composite template currently does not support multiple items. See error log in console for details'); + } + 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); + 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, + ); + 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)) { + 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( + simpleTemplates, + ); + 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, + ); + } + } + } 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(`TemplateMessageItemBody | fetching template key ${templateKey} for messageId: ${message.messageId} has failed.`); } + }); + if (keysToUpdate.length > 0) { + updateMessageTemplatesInfo(keysToUpdate, message.messageId, requestedAt); } } - }, [templateData.key, waitingTemplateKeysMapString]); + } - if (filledMessageTemplateItems.length === 0) { - if (isErrored) { - return ; - } - return ; + if (renderData.isErrored) { + return getFailedBody(); } + if (renderData.filledMessageTemplateItemsList.length === 0) { + return ; + } return (
} + fallbackMessage={ + + } + onTemplateMessageRenderedCallback={onTemplateMessageRenderedCallback} + isComposite={renderData.isComposite} logger={logger} > - +
); diff --git a/src/ui/TemplateMessageItemBody/types.ts b/src/ui/TemplateMessageItemBody/types.ts index 33c091478..4a8dbce9d 100644 --- a/src/ui/TemplateMessageItemBody/types.ts +++ b/src/ui/TemplateMessageItemBody/types.ts @@ -1,4 +1,4 @@ -import type { ComponentsUnion } from '@sendbird/uikit-message-template'; +import { ComponentsUnion, CompositeComponentType } from '@sendbird/uikit-message-template'; type SendbirdFontWeight = 'bold' | 'normal'; @@ -81,22 +81,34 @@ export type MessageTemplateTheme = { export type MessageTemplateItem = ComponentsUnion['properties']; -export type MessageTemplateData = { +export interface CarouselItem { + type: CompositeComponentType.Carousel; + spacing: number; + items: string | SendbirdUiTemplate[]; // Reservation key. ex. "{@some_key}" +} + +// 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; }; +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[]; - }; - }; - color_variables?: Record; - + ui_template: SendbirdUiTemplate; 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..0dbb6a914 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 @@ -276,6 +276,30 @@ export const isThreadMessage = (message: CoreMessageType): boolean => ( export const isTemplateMessage = (message: CoreMessageType): boolean => !!( message && message.extendedMessagePayload?.['template'] ); +export enum UI_CONTAINER_TYPES { + DEFAULT = '', + WIDE = 'ui_container_type__wide', + 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 (!isMobile) return UI_CONTAINER_TYPES.DEFAULT; + 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 && ( @@ -374,6 +398,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 cca254d60..17e5a321b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2647,37 +2647,37 @@ __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 -"@sendbird/react-uikit-message-template-view@npm:0.0.1-alpha.68": - version: 0.0.1-alpha.68 - resolution: "@sendbird/react-uikit-message-template-view@npm:0.0.1-alpha.68" +"@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.68 + "@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: 4ff7f8765c4f110de0f2bddfdda402c20e0366ec70d1e3bc5e0ea152faa0c2c096afae9438cb54ba03977fbe2ace639c59365726c3cb6c80e5518e5bfac80ed4 + checksum: f08023a2f679c942fbce73288a40752e7c1a8f03b487b4dffa2e76495f1078706cab3db41e0f02468569717dc9b84f26e8d0addbdc80bd5d9ae1184d3ec33c66 languageName: node linkType: hard -"@sendbird/uikit-message-template@npm:^0.0.1-alpha.68": - version: 0.0.1-alpha.68 - resolution: "@sendbird/uikit-message-template@npm:0.0.1-alpha.68" +"@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: c133e36d7636618a8eb2d35b1041eb683127b570df26dc3f1def040a43b46cb5588b0d4f68804ab2f20455544a3f6afbf79970c3625f3c089bb2d4ef8723fea9 + checksum: 71b66ab67cbeb07f6020d7d56e0e3464b17cfdd7f201025ac7415dfad83525416699a4eceac049c78d4b68e10f64a559437d274c59e1bf6f884f4fc6dddc7e8a languageName: node linkType: hard @@ -2699,9 +2699,9 @@ __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/react-uikit-message-template-view": 0.0.1-alpha.68 - "@sendbird/uikit-tools": 0.0.1-alpha.68 + "@sendbird/chat": ^4.11.3 + "@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.68": - version: 0.0.1-alpha.68 - resolution: "@sendbird/uikit-tools@npm:0.0.1-alpha.68" +"@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: ce1b2d183fdad6dfb6d8d2da5da9afe798e99d7a60ef4735aa708da20989dd6c2263200b1d7920e7712d429dd3f8902e27879391c1755c9de16c3dd171509ebc + checksum: a209342267a8ddbbdd9d310a42def22d54a20ad30a54002de893480ed0bc26c0d3ebb7614ceb39340f3e6eb0d7bf5067769a0b73aca135b9e954580a8f5b08c0 languageName: node linkType: hard