diff --git a/packages/uikit-react-native-core/src/domain/groupChannel/component/GroupChannelMessageList.tsx b/packages/uikit-react-native-core/src/domain/groupChannel/component/GroupChannelMessageList.tsx index 092e6e971..6a960855d 100644 --- a/packages/uikit-react-native-core/src/domain/groupChannel/component/GroupChannelMessageList.tsx +++ b/packages/uikit-react-native-core/src/domain/groupChannel/component/GroupChannelMessageList.tsx @@ -1,53 +1,78 @@ -import isSameDay from 'date-fns/isSameDay'; -import React, { useCallback, useEffect, useRef } from 'react'; -import { FlatList as DefaultFlatList, FlatListProps, ListRenderItem, View } from 'react-native'; +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import { FlatList, FlatListProps, ListRenderItem, Platform, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Text } from '@sendbird/uikit-react-native-foundation'; +import { ChannelFrozenBanner, createStyleSheet, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; import type { SendbirdMessage } from '@sendbird/uikit-utils'; -import { isMyMessage } from '@sendbird/uikit-utils'; +import { isMyMessage, messageKeyExtractor } from '@sendbird/uikit-utils'; import { useLocalization } from '../../../contexts/Localization'; import type { GroupChannelProps } from '../types'; +const HANDLE_NEXT_MSG_SEPARATELY = Platform.select({ android: true, ios: false }); const GroupChannelMessageList: React.FC = ({ + channel, messages, renderMessage, nextMessages, newMessagesFromNext, onBottomReached, onTopReached, - NewMessageTooltip, + NewMessagesTooltip, + ScrollToBottomTooltip, }) => { const { LABEL } = useLocalization(); + const { colors } = useUIKitTheme(); + const { left, right } = useSafeAreaInsets(); + const [scrollLeaveBottom, setScrollLeaveBottom] = useState(false); + const scrollRef = useRef(null); + // NOTE: Cannot wrap with useCallback, because prevMessage (always getting from fresh messages) - const renderItem: ListRenderItem = ({ item, index }) => { - const prevMessage = messages[index + 1]; - - const sameDay = isSameDay(item.createdAt, prevMessage?.createdAt ?? 0); - const separator = sameDay ? null : ( - --- {LABEL.GROUP_CHANNEL.FRAGMENT.LIST_DATE_SEPARATOR(new Date(item.createdAt))} --- - ); - - return ( - - {separator} - {renderMessage(item)} - {index} - - ); - }; + const renderItem: ListRenderItem = ({ item, index }) => ( + {renderMessage(item, messages[index + 1], messages[index - 1])} + ); + + const [newMessages, setNewMessages] = useState(() => newMessagesFromNext); + + useEffect(() => { + if (HANDLE_NEXT_MSG_SEPARATELY) return; + newMessagesFromNext.length !== 0 && setNewMessages((prev) => prev.concat(newMessagesFromNext)); + onBottomReached(); + }, [newMessagesFromNext]); + + const onLeaveScrollBottom = useCallback((val: boolean) => { + if (!HANDLE_NEXT_MSG_SEPARATELY) setNewMessages([]); + setScrollLeaveBottom(val); + }, []); + return ( - - + {channel.isFrozen && ( + + )} + - {NewMessageTooltip && ( - - + {NewMessagesTooltip && ( + + scrollRef.current?.scrollToBottom(false)} + newMessages={HANDLE_NEXT_MSG_SEPARATELY ? newMessagesFromNext : newMessages} + /> + + )} + {ScrollToBottomTooltip && ( + + scrollRef.current?.scrollToBottom(true)} /> )} @@ -62,11 +87,26 @@ type Props = Omit, 'onEndReached'> & { onBottomReached: () => void; onTopReached: () => void; nextMessages: SendbirdMessage[]; + onLeaveScrollBottom: (value: boolean) => void; }; -const FlatList: React.FC = ({ onTopReached, nextMessages, onBottomReached, onScroll, ...props }) => { - const scrollRef = useRef>(null); +const BOTTOM_DETECT_THRESHOLD = 15; +type CustomFlatListRef = { scrollToBottom: (animated?: boolean) => void }; +const CustomFlatList = forwardRef(function CustomFlatList( + { onTopReached, nextMessages, onBottomReached, onLeaveScrollBottom, onScroll, ...props }, + ref, +) { + const { select } = useUIKitTheme(); + const scrollRef = useRef>(null); const yPos = useRef(0); + useImperativeHandle( + ref, + () => ({ + scrollToBottom: (animated = true) => scrollRef.current?.scrollToOffset({ animated, offset: 0 }), + }), + [], + ); + useEffect(() => { const latestMessage = nextMessages[nextMessages.length - 1]; if (!latestMessage) return; @@ -74,13 +114,20 @@ const FlatList: React.FC = ({ onTopReached, nextMessages, onBottomReached if (hasReachedToBottom(yPos.current)) { onBottomReached(); } else if (isMyMessage(latestMessage)) { - scrollRef.current?.scrollToEnd({ animated: false }); + scrollRef.current?.scrollToOffset({ animated: false, offset: 0 }); } }, [onBottomReached, nextMessages]); const _onScroll: Props['onScroll'] = useCallback( (event) => { - yPos.current = event.nativeEvent.contentOffset.y; + const { contentOffset } = event.nativeEvent; + if (BOTTOM_DETECT_THRESHOLD < yPos.current && contentOffset.y <= BOTTOM_DETECT_THRESHOLD) { + onLeaveScrollBottom(false); + } else if (BOTTOM_DETECT_THRESHOLD < contentOffset.y && yPos.current <= BOTTOM_DETECT_THRESHOLD) { + onLeaveScrollBottom(true); + } + + yPos.current = contentOffset.y; onScroll?.(event); if (hasReachedToBottom(yPos.current)) onBottomReached(); @@ -89,15 +136,47 @@ const FlatList: React.FC = ({ onTopReached, nextMessages, onBottomReached ); return ( - ); -}; +}); + +const styles = createStyleSheet({ + frozenBanner: { + position: 'absolute', + zIndex: 999, + top: 8, + left: 8, + right: 8, + }, + frozenListPadding: { + paddingBottom: 32, + }, + newMsgTooltip: { + position: 'absolute', + zIndex: 999, + bottom: 10, + alignSelf: 'center', + }, + scrollTooltip: { + position: 'absolute', + zIndex: 999, + bottom: 10, + right: 16, + }, +}); export default GroupChannelMessageList; diff --git a/packages/uikit-react-native-core/src/domain/groupChannel/types.ts b/packages/uikit-react-native-core/src/domain/groupChannel/types.ts index 75a3de27e..a38b834de 100644 --- a/packages/uikit-react-native-core/src/domain/groupChannel/types.ts +++ b/packages/uikit-react-native-core/src/domain/groupChannel/types.ts @@ -17,7 +17,8 @@ export type GroupChannelProps = { prevMessage?: SendbirdMessage; enableMessageGrouping?: boolean; }>; - NewMessageTooltip?: GroupChannelProps['MessageList']['NewMessageTooltip']; + NewMessagesTooltip?: GroupChannelProps['MessageList']['NewMessagesTooltip']; + ScrollToBottomTooltip?: GroupChannelProps['MessageList']['ScrollToBottomTooltip']; Header?: GroupChannelProps['Header']['Header']; onPressHeaderLeft: GroupChannelProps['Header']['onPressHeaderLeft']; @@ -39,14 +40,27 @@ export type GroupChannelProps = { onPressHeaderRight: () => void; }; MessageList: { + channel: Sendbird.GroupChannel; messages: SendbirdMessage[]; nextMessages: SendbirdMessage[]; newMessagesFromNext: SendbirdMessage[]; onTopReached: () => void; onBottomReached: () => void; - renderMessage: (message: SendbirdMessage, prevMessage?: SendbirdMessage) => React.ReactElement | null; - NewMessageTooltip: null | CommonComponent<{ newMessages: SendbirdMessage[] }>; + renderMessage: ( + message: SendbirdMessage, + prevMessage?: SendbirdMessage, + nextMessage?: SendbirdMessage, + ) => React.ReactElement | null; + NewMessagesTooltip: null | CommonComponent<{ + visible: boolean; + onPress: () => void; + newMessages: SendbirdMessage[]; + }>; + ScrollToBottomTooltip: null | CommonComponent<{ + visible: boolean; + onPress: () => void; + }>; }; }; diff --git a/packages/uikit-react-native-core/src/localization/label.type.ts b/packages/uikit-react-native-core/src/localization/label.type.ts index 51f98c453..97dffe50f 100644 --- a/packages/uikit-react-native-core/src/localization/label.type.ts +++ b/packages/uikit-react-native-core/src/localization/label.type.ts @@ -53,6 +53,8 @@ export interface LabelSet { DIALOG_MESSAGE_COPY: string; /** @domain GroupChannel > Fragment > Dialog > Message > Edit */ DIALOG_MESSAGE_EDIT: string; + /** @domain GroupChannel > Fragment > Dialog > Message > Save */ + DIALOG_MESSAGE_SAVE: string; /** @domain GroupChannel > Fragment > Dialog > Message > Delete */ DIALOG_MESSAGE_DELETE: string; /** @domain GroupChannel > Fragment > Dialog > Message > Delete > Confirm title */ @@ -165,6 +167,7 @@ export const createBaseLabel = ({ dateLocale, overrides }: LabelCreateOptions): DIALOG_MESSAGE_COPY: 'Copy', DIALOG_MESSAGE_EDIT: 'Edit', + DIALOG_MESSAGE_SAVE: 'Save', DIALOG_MESSAGE_DELETE: 'Delete', DIALOG_MESSAGE_DELETE_CONFIRM_TITLE: 'Delete message?', DIALOG_MESSAGE_DELETE_CONFIRM_OK: 'Delete', diff --git a/packages/uikit-react-native-foundation/src/index.tsx b/packages/uikit-react-native-foundation/src/index.tsx index 2e49564d6..037797cd5 100644 --- a/packages/uikit-react-native-foundation/src/index.tsx +++ b/packages/uikit-react-native-foundation/src/index.tsx @@ -12,6 +12,7 @@ export { default as Avatar } from './ui/Avatar'; export { default as Badge } from './ui/Badge'; export { default as BottomSheet } from './ui/BottomSheet'; export { default as Button } from './ui/Button'; +export { default as ChannelFrozenBanner } from './ui/ChannelFrozenBanner'; export { DialogProvider, useActionMenu, useAlert, usePrompt } from './ui/Dialog'; export { default as Divider } from './ui/Divider'; export { default as Header } from './ui/Header'; @@ -25,12 +26,13 @@ export { default as Text } from './ui/Text'; export { default as TextInput } from './ui/TextInput'; /** Styles **/ -export { default as useHeaderStyle } from './styles/useHeaderStyle'; -export { default as getDefaultHeaderHeight } from './styles/getDefaultHeaderHeight'; export { default as createAppearanceHelper } from './styles/createAppearanceHelper'; export { default as createScaleFactor } from './styles/createScaleFactor'; export { default as createStyleSheet } from './styles/createStyleSheet'; +export { default as getDefaultHeaderHeight } from './styles/getDefaultHeaderHeight'; export { HeaderStyleContext, HeaderStyleProvider } from './styles/HeaderStyleContext'; +export { themeFactory } from './styles/themeFactory'; +export { default as useHeaderStyle } from './styles/useHeaderStyle'; /** Types **/ export type { @@ -40,7 +42,7 @@ export type { FontAttributes, BaseHeaderProps, UIKitAppearance, - AppearanceHelper, UIKitColors, ComponentColorTree, + PaletteInterface, } from './types'; diff --git a/packages/uikit-react-native-foundation/src/theme/DarkUIKitTheme.ts b/packages/uikit-react-native-foundation/src/theme/DarkUIKitTheme.ts index 1aa83f844..a187c0cb7 100644 --- a/packages/uikit-react-native-foundation/src/theme/DarkUIKitTheme.ts +++ b/packages/uikit-react-native-foundation/src/theme/DarkUIKitTheme.ts @@ -120,14 +120,14 @@ const DarkUIKitTheme = themeFactory({ enabled: { textMsg: palette.onBackgroundDark01, textEdited: palette.onBackgroundDark02, - textDate: palette.onBackgroundDark03, + textTime: palette.onBackgroundDark03, textSenderName: palette.onBackgroundDark02, background: palette.background400, }, pressed: { textMsg: palette.onBackgroundDark01, textEdited: palette.onBackgroundDark02, - textDate: palette.onBackgroundDark03, + textTime: palette.onBackgroundDark03, textSenderName: palette.onBackgroundDark02, background: palette.primary500, }, @@ -136,19 +136,27 @@ const DarkUIKitTheme = themeFactory({ enabled: { textMsg: palette.onBackgroundLight01, textEdited: palette.onBackgroundLight02, - textDate: palette.onBackgroundDark03, + textTime: palette.onBackgroundDark03, textSenderName: palette.transparent, background: palette.primary200, }, pressed: { textMsg: palette.onBackgroundLight01, textEdited: palette.onBackgroundLight02, - textDate: palette.onBackgroundDark03, + textTime: palette.onBackgroundDark03, textSenderName: palette.transparent, background: palette.primary300, }, }, }, + dateSeparator: { + default: { + none: { + text: palette.onBackgroundDark02, + background: palette.overlay02, + }, + }, + }, }, }), }); diff --git a/packages/uikit-react-native-foundation/src/theme/LightUIKitTheme.ts b/packages/uikit-react-native-foundation/src/theme/LightUIKitTheme.ts index c1cbcc7d7..bc44efa73 100644 --- a/packages/uikit-react-native-foundation/src/theme/LightUIKitTheme.ts +++ b/packages/uikit-react-native-foundation/src/theme/LightUIKitTheme.ts @@ -120,14 +120,14 @@ const LightUIKitTheme = themeFactory({ enabled: { textMsg: palette.onBackgroundLight01, textEdited: palette.onBackgroundLight02, - textDate: palette.onBackgroundLight03, + textTime: palette.onBackgroundLight03, textSenderName: palette.onBackgroundLight02, background: palette.background100, }, pressed: { textMsg: palette.onBackgroundLight01, textEdited: palette.onBackgroundLight02, - textDate: palette.onBackgroundLight03, + textTime: palette.onBackgroundLight03, textSenderName: palette.onBackgroundLight02, background: palette.primary100, }, @@ -136,19 +136,27 @@ const LightUIKitTheme = themeFactory({ enabled: { textMsg: palette.onBackgroundDark01, textEdited: palette.onBackgroundDark02, - textDate: palette.onBackgroundLight03, + textTime: palette.onBackgroundLight03, textSenderName: palette.transparent, background: palette.primary300, }, pressed: { textMsg: palette.onBackgroundDark01, textEdited: palette.onBackgroundDark02, - textDate: palette.onBackgroundLight03, + textTime: palette.onBackgroundLight03, textSenderName: palette.transparent, background: palette.primary400, }, }, }, + dateSeparator: { + default: { + none: { + text: palette.onBackgroundDark01, + background: palette.overlay02, + }, + }, + }, }, }), }); diff --git a/packages/uikit-react-native-foundation/src/theme/Palette.ts b/packages/uikit-react-native-foundation/src/theme/Palette.ts index 0c052ab0a..fb2915d69 100644 --- a/packages/uikit-react-native-foundation/src/theme/Palette.ts +++ b/packages/uikit-react-native-foundation/src/theme/Palette.ts @@ -1,48 +1,4 @@ -export interface PaletteInterface { - primary100: string; - primary200: string; - primary300: string; - primary400: string; - primary500: string; - - secondary100: string; - secondary200: string; - secondary300: string; - secondary400: string; - secondary500: string; - - error100: string; - error200: string; - error300: string; - error400: string; - error500: string; - - background50: string; - background100: string; - background200: string; - background300: string; - background400: string; - background500: string; - background600: string; - background700: string; - - overlay01: string; - overlay02: string; - - information: string; - highlight: string; - transparent: 'transparent'; - - onBackgroundLight01: string; - onBackgroundLight02: string; - onBackgroundLight03: string; - onBackgroundLight04: string; - - onBackgroundDark01: string; - onBackgroundDark02: string; - onBackgroundDark03: string; - onBackgroundDark04: string; -} +import type { PaletteInterface } from '../types'; const Palette: PaletteInterface = { primary100: '#DBD1FF', diff --git a/packages/uikit-react-native-foundation/src/types.ts b/packages/uikit-react-native-foundation/src/types.ts index ed9c62794..953640248 100644 --- a/packages/uikit-react-native-foundation/src/types.ts +++ b/packages/uikit-react-native-foundation/src/types.ts @@ -2,8 +2,6 @@ import type { ReactElement, ReactNode } from 'react'; import type { TextStyle } from 'react-native'; -import type { PaletteInterface } from './theme/Palette'; - export type TypoName = | 'h1' | 'h2' @@ -32,7 +30,7 @@ export interface UIKitTheme { scaleFactor: (dp: number) => number; } -type Component = 'Header' | 'Button' | 'Dialog' | 'Input' | 'Badge' | 'Placeholder' | 'Message'; +type Component = 'Header' | 'Button' | 'Dialog' | 'Input' | 'Badge' | 'Placeholder' | 'Message' | 'DateSeparator'; type GetColorTree< Tree extends { Variant: { @@ -56,6 +54,7 @@ export type ComponentColorTree = GetColorTree<{ Badge: 'default'; Placeholder: 'default'; Message: 'incoming' | 'outgoing'; + DateSeparator: 'default'; }; State: { Header: 'none'; @@ -65,6 +64,7 @@ export type ComponentColorTree = GetColorTree<{ Badge: 'none'; Placeholder: 'none'; Message: 'enabled' | 'pressed'; + DateSeparator: 'none'; }; ColorPart: { Header: 'background' | 'borderBottom'; @@ -73,7 +73,8 @@ export type ComponentColorTree = GetColorTree<{ Input: 'text' | 'placeholder' | 'background' | 'highlight'; Badge: 'text' | 'background'; Placeholder: 'content' | 'highlight'; - Message: 'textMsg' | 'textEdited' | 'textSenderName' | 'textDate' | 'background'; + Message: 'textMsg' | 'textEdited' | 'textSenderName' | 'textTime' | 'background'; + DateSeparator: 'text' | 'background'; }; }>; type ComponentColors = { @@ -116,6 +117,7 @@ export interface UIKitColors { badge: ComponentColors<'Badge'>; placeholder: ComponentColors<'Placeholder'>; message: ComponentColors<'Message'>; + dateSeparator: ComponentColors<'DateSeparator'>; }; } @@ -132,3 +134,48 @@ export type BaseHeaderProps; + text?: string; + textColor?: string; + backgroundColor?: string; +}; + +const ChannelFrozenBanner: React.FC = ({ text = 'Channel frozen', backgroundColor, textColor, style }) => { + const { palette } = useUIKitTheme(); + + return ( + + + {text} + + + ); +}; + +const styles = createStyleSheet({ + container: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 6, + borderRadius: 4, + }, +}); + +export default ChannelFrozenBanner; diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx index 3d73d2e0b..4b5824b6c 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx @@ -1,18 +1,21 @@ import React, { useCallback } from 'react'; -import { Image, View } from 'react-native'; import { useGroupChannelMessages } from '@sendbird/chat-react-hooks'; import type { GroupChannelFragment, GroupChannelModule, GroupChannelProps } from '@sendbird/uikit-react-native-core'; import { createGroupChannelModule, useSendbirdChat } from '@sendbird/uikit-react-native-core'; -import { Text } from '@sendbird/uikit-react-native-foundation'; import { EmptyFunction, messageComparator } from '@sendbird/uikit-utils'; +import DefaultMessageRenderer from '../ui/MessageRenderer'; +import DefaultNewMessagesTooltip from '../ui/NewMessagesTooltip'; +import DefaultScrollToBottomTooltip from '../ui/ScrollToBottomTooltip'; + const createGroupChannelFragment = (initModule?: GroupChannelModule): GroupChannelFragment => { const GroupChannelModule = createGroupChannelModule(initModule); return ({ - MessageRenderer, - NewMessageTooltip = null, + MessageRenderer = DefaultMessageRenderer, + NewMessagesTooltip = DefaultNewMessagesTooltip, + ScrollToBottomTooltip = DefaultScrollToBottomTooltip, enableMessageGrouping = true, Header, onPressHeaderLeft = EmptyFunction, @@ -29,54 +32,18 @@ const createGroupChannelFragment = (initModule?: GroupChannelModule): GroupChann sdk, channel, currentUser?.userId, - { - collectionCreator, - queryCreator, - sortComparator, - enableCollectionWithoutLocalCache: true, - }, + { collectionCreator, queryCreator, sortComparator, enableCollectionWithoutLocalCache: true }, ); const renderMessages: GroupChannelProps['MessageList']['renderMessage'] = useCallback( - (message, prevMessage) => { - if (MessageRenderer) { - return ( - - ); - } - - if (message.isAdminMessage()) { - return ( - - {message.message} - - ); - } - - if (message.isUserMessage()) { - return ( - - {message.message} - - ); - } - - if (message.isFileMessage()) { - return ( - - - - ); - } - + (message, prevMessage, nextMessage) => { return ( - - {'Unknown Message'} - + ); }, [MessageRenderer, enableMessageGrouping], @@ -90,13 +57,15 @@ const createGroupChannelFragment = (initModule?: GroupChannelModule): GroupChann onPressHeaderRight={onPressHeaderRight} /> {children} diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelListFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelListFragment.tsx index 7b4ca8e45..01af48e9e 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelListFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelListFragment.tsx @@ -44,7 +44,7 @@ const createGroupChannelListFragment = (initModule?: Partial 2 ? channel.memberCount : undefined} /> ), diff --git a/packages/uikit-react-native/src/index.tsx b/packages/uikit-react-native/src/index.tsx index c6cd5bb66..ddb2d6016 100644 --- a/packages/uikit-react-native/src/index.tsx +++ b/packages/uikit-react-native/src/index.tsx @@ -1,7 +1,8 @@ /** UI **/ export { default as GroupChannelPreview } from './ui/GroupChannelPreview'; -export { default as UserListItem } from './ui/UserListItem'; +export { default as MessageRenderer } from './ui/MessageRenderer'; export { default as TypedPlaceholder } from './ui/TypedPlaceholder'; +export { default as UserListItem } from './ui/UserListItem'; /** Fragments **/ export { default as createGroupChannelFragment } from './fragments/createGroupChannelFragment'; diff --git a/packages/uikit-react-native/src/ui/MessageRenderer/AdminMessage/index.tsx b/packages/uikit-react-native/src/ui/MessageRenderer/AdminMessage/index.tsx new file mode 100644 index 000000000..860a692ff --- /dev/null +++ b/packages/uikit-react-native/src/ui/MessageRenderer/AdminMessage/index.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import type Sendbird from 'sendbird'; + +import { Text, createStyleSheet, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; + +import type { MessageRendererInterface } from '../index'; + +export type AdminMessageProps = MessageRendererInterface; +const AdminMessage: React.FC = ({ message, nextMessage }) => { + const { colors } = useUIKitTheme(); + + const isNextAdmin = nextMessage?.isAdminMessage(); + return ( + + + {message.message} + + + ); +}; + +const styles = createStyleSheet({ + container: { + width: 300, + alignSelf: 'center', + alignItems: 'center', + }, + nextAdminType: { + marginBottom: 8, + }, + next: { + marginBottom: 16, + }, + text: { + textAlign: 'center', + }, +}); + +export default AdminMessage; diff --git a/packages/uikit-react-native/src/ui/MessageRenderer/FileMessage/BaseFileMessage.tsx b/packages/uikit-react-native/src/ui/MessageRenderer/FileMessage/BaseFileMessage.tsx new file mode 100644 index 000000000..874f3ecc6 --- /dev/null +++ b/packages/uikit-react-native/src/ui/MessageRenderer/FileMessage/BaseFileMessage.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { View } from 'react-native'; + +import { Icon, Text, createStyleSheet, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; + +import type { FileMessageProps } from './index'; + +const iconMapper = { audio: 'file-audio', image: 'photo', video: 'streaming', file: 'file-document' } as const; + +const BaseFileMessage: React.FC = ({ + message, + variant, + pressed, + type, +}) => { + const { colors } = useUIKitTheme(); + const color = colors.ui.message[variant][pressed ? 'pressed' : 'enabled']; + return ( + + + + {message.name} + + + ); +}; + +const styles = createStyleSheet({ + container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + }, +}); + +export default BaseFileMessage; diff --git a/packages/uikit-react-native/src/ui/MessageRenderer/FileMessage/ImageFileMessage.tsx b/packages/uikit-react-native/src/ui/MessageRenderer/FileMessage/ImageFileMessage.tsx new file mode 100644 index 000000000..fe5665364 --- /dev/null +++ b/packages/uikit-react-native/src/ui/MessageRenderer/FileMessage/ImageFileMessage.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Image } from 'react-native'; + +import { createStyleSheet } from '@sendbird/uikit-react-native-foundation'; + +import type { FileMessageProps } from './index'; + +const ImageFileMessage: React.FC = ({ message }) => { + return ; +}; + +const styles = createStyleSheet({ + image: { + width: 240, + height: 160, + borderRadius: 16, + }, +}); + +export default ImageFileMessage; diff --git a/packages/uikit-react-native/src/ui/MessageRenderer/FileMessage/index.tsx b/packages/uikit-react-native/src/ui/MessageRenderer/FileMessage/index.tsx new file mode 100644 index 000000000..606a61677 --- /dev/null +++ b/packages/uikit-react-native/src/ui/MessageRenderer/FileMessage/index.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import type Sendbird from 'sendbird'; + +import type { MessageRendererInterface } from '../index'; +import BaseFileMessage from './BaseFileMessage'; +import ImageFileMessage from './ImageFileMessage'; + +function getFileExtension(filePath: string) { + const idx = filePath.lastIndexOf('.'); + return filePath.slice(idx - filePath.length).toLowerCase(); +} + +const imageRegex = /jpeg|jpg|png|webp|gif/; +const audioRegex = /3gp|aa|aac|aax|act|aiff|alac|amr|ape|au|awb|dss|dvf|flac|gsm|m4a|m4b|m4p|tta|wma|mp3|webm|wav/; +const videoRegex = /mp4|avi/; + +const getFileType = (ext: string) => { + if (ext.match(imageRegex)) return 'image'; + if (ext.match(audioRegex)) return 'audio'; + if (ext.match(videoRegex)) return 'video'; + return 'file'; +}; + +export type FileMessageProps = MessageRendererInterface; +const FileMessage: React.FC = (props) => { + const ext = getFileExtension(props.message.name); + const fileType = getFileType(ext); + + if (fileType === 'image') return ; + return ; +}; + +export default FileMessage; diff --git a/packages/uikit-react-native/src/ui/MessageRenderer/MessageContainer.tsx b/packages/uikit-react-native/src/ui/MessageRenderer/MessageContainer.tsx new file mode 100644 index 000000000..c7b6f2547 --- /dev/null +++ b/packages/uikit-react-native/src/ui/MessageRenderer/MessageContainer.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { View } from 'react-native'; + +import { createStyleSheet } from '@sendbird/uikit-react-native-foundation'; + +const MessageContainer: React.FC = ({ children }) => { + return {children}; +}; + +const styles = createStyleSheet({ + container: { + flexDirection: 'column', + paddingHorizontal: 16, + }, +}); + +export default MessageContainer; diff --git a/packages/uikit-react-native/src/ui/MessageRenderer/MessageDateSeparator.tsx b/packages/uikit-react-native/src/ui/MessageRenderer/MessageDateSeparator.tsx new file mode 100644 index 000000000..b8dd3a60e --- /dev/null +++ b/packages/uikit-react-native/src/ui/MessageRenderer/MessageDateSeparator.tsx @@ -0,0 +1,42 @@ +import isSameDay from 'date-fns/isSameDay'; +import React from 'react'; +import { View } from 'react-native'; + +import { useLocalization } from '@sendbird/uikit-react-native-core'; +import { Text, createStyleSheet, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import type { SendbirdMessage } from '@sendbird/uikit-utils'; + +type Props = { + message: SendbirdMessage; + prevMessage?: SendbirdMessage; +}; + +const MessageDateSeparator: React.FC = ({ message, prevMessage }) => { + const { LABEL } = useLocalization(); + const { colors } = useUIKitTheme(); + const sameDay = isSameDay(message.createdAt, prevMessage?.createdAt ?? 0); + if (sameDay) return null; + return ( + + + + {LABEL.GROUP_CHANNEL.FRAGMENT.LIST_DATE_SEPARATOR(new Date(message.createdAt))} + + + + ); +}; + +const styles = createStyleSheet({ + container: { + alignItems: 'center', + marginVertical: 16, + }, + view: { + borderRadius: 10, + paddingVertical: 4, + paddingHorizontal: 10, + }, +}); + +export default MessageDateSeparator; diff --git a/packages/uikit-react-native/src/ui/MessageRenderer/UnknownMessage/index.tsx b/packages/uikit-react-native/src/ui/MessageRenderer/UnknownMessage/index.tsx new file mode 100644 index 000000000..454bb30af --- /dev/null +++ b/packages/uikit-react-native/src/ui/MessageRenderer/UnknownMessage/index.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { View } from 'react-native'; + +import { useLocalization } from '@sendbird/uikit-react-native-core'; +import { Text, createStyleSheet, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; + +import type { MessageRendererInterface } from '../index'; + +export type UnknownMessageProps = MessageRendererInterface; +const UnknownMessage: React.FC = ({ message, variant, pressed }) => { + const { LABEL } = useLocalization(); + const { colors } = useUIKitTheme(); + const color = colors.ui.message[variant][pressed ? 'pressed' : 'enabled']; + return ( + + + {LABEL.GROUP_CHANNEL.FRAGMENT.LIST_MESSAGE_UNKNOWN_TITLE(message)} + + + {LABEL.GROUP_CHANNEL.FRAGMENT.LIST_MESSAGE_UNKNOWN_DESC(message)} + + + ); +}; + +const styles = createStyleSheet({ + container: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + }, +}); + +export default UnknownMessage; diff --git a/packages/uikit-react-native/src/ui/MessageRenderer/UserMessage/BaseUserMessage.tsx b/packages/uikit-react-native/src/ui/MessageRenderer/UserMessage/BaseUserMessage.tsx new file mode 100644 index 000000000..b513a7f99 --- /dev/null +++ b/packages/uikit-react-native/src/ui/MessageRenderer/UserMessage/BaseUserMessage.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { View } from 'react-native'; + +import { Text, createStyleSheet, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; + +import type { UserMessageProps } from './index'; + +const BaseUserMessage: React.FC = ({ message, variant, pressed }) => { + const { colors } = useUIKitTheme(); + const color = colors.ui.message[variant][pressed ? 'pressed' : 'enabled']; + return ( + + + {message.message} + + + ); +}; +const styles = createStyleSheet({ + container: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + }, +}); + +export default BaseUserMessage; diff --git a/packages/uikit-react-native/src/ui/MessageRenderer/UserMessage/OpenGraphUserMessage.tsx b/packages/uikit-react-native/src/ui/MessageRenderer/UserMessage/OpenGraphUserMessage.tsx new file mode 100644 index 000000000..b169edb99 --- /dev/null +++ b/packages/uikit-react-native/src/ui/MessageRenderer/UserMessage/OpenGraphUserMessage.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Image, View } from 'react-native'; +import type Sendbird from 'sendbird'; + +import { Text, createStyleSheet, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; + +import type { UserMessageProps } from './index'; + +type Props = UserMessageProps & { + ogMetaData: Sendbird.OGMetaData; +}; + +const OpenGraphUserMessage: React.FC = ({ message, variant, pressed, ogMetaData }) => { + const { colors, select, palette } = useUIKitTheme(); + const color = colors.ui.message[variant][pressed ? 'pressed' : 'enabled']; + return ( + + + + {message.message} + + + + + + {ogMetaData.title} + + + {ogMetaData.description} + + + {ogMetaData.url} + + + + ); +}; + +const styles = createStyleSheet({ + container: { + borderRadius: 16, + overflow: 'hidden', + }, + messageContainer: { + paddingVertical: 6, + paddingHorizontal: 12, + }, + ogContainer: { + paddingHorizontal: 12, + paddingTop: 8, + paddingBottom: 12, + }, + ogImage: { + width: 240, + height: 136, + }, + ogTitle: { + marginBottom: 4, + }, + ogDesc: { + marginBottom: 8, + }, +}); + +export default OpenGraphUserMessage; diff --git a/packages/uikit-react-native/src/ui/MessageRenderer/UserMessage/index.tsx b/packages/uikit-react-native/src/ui/MessageRenderer/UserMessage/index.tsx new file mode 100644 index 000000000..4afabaf69 --- /dev/null +++ b/packages/uikit-react-native/src/ui/MessageRenderer/UserMessage/index.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import type Sendbird from 'sendbird'; + +import type { MessageRendererInterface } from '../index'; +import BaseUserMessage from './BaseUserMessage'; +import OpenGraphUserMessage from './OpenGraphUserMessage'; + +export type UserMessageProps = MessageRendererInterface; +const UserMessage: React.FC = (props) => { + if (props.message.ogMetaData) { + return ; + } + + return ; +}; + +export default UserMessage; diff --git a/packages/uikit-react-native/src/ui/MessageRenderer/index.tsx b/packages/uikit-react-native/src/ui/MessageRenderer/index.tsx new file mode 100644 index 000000000..f58c6991d --- /dev/null +++ b/packages/uikit-react-native/src/ui/MessageRenderer/index.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { Pressable, View } from 'react-native'; + +import { useLocalization } from '@sendbird/uikit-react-native-core'; +import { Avatar, Text, createStyleSheet, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import type { SendbirdMessage } from '@sendbird/uikit-utils'; +import { hasSameSender, isMyMessage, messageTime } from '@sendbird/uikit-utils'; + +import AdminMessage from './AdminMessage'; +import FileMessage from './FileMessage'; +import MessageContainer from './MessageContainer'; +import MessageDateSeparator from './MessageDateSeparator'; +import UnknownMessage from './UnknownMessage'; +import UserMessage from './UserMessage'; + +export type MessageStyleVariant = 'outgoing' | 'incoming'; +export interface MessageRendererInterface { + message: T; + prevMessage?: SendbirdMessage; + nextMessage?: SendbirdMessage; + variant: MessageStyleVariant; + groupWithPrev: boolean; + groupWithNext: boolean; + pressed: boolean; +} + +type Props = { + nextMessage?: SendbirdMessage; + message: SendbirdMessage; + prevMessage?: SendbirdMessage; + enableMessageGrouping?: boolean; +}; + +const MessageRenderer: React.FC = ({ message, ...rest }) => { + const variant: MessageStyleVariant = isMyMessage(message) ? 'outgoing' : 'incoming'; + + const groupWithPrev = (() => { + if (!rest.enableMessageGrouping) return false; + if (!rest.prevMessage) return false; + if (message.isAdminMessage()) return false; + if (!hasSameSender(message, rest.prevMessage)) return false; + if (messageTime(new Date(message.createdAt)) !== messageTime(new Date(rest.prevMessage.createdAt))) return false; + return true; + })(); + + const groupWithNext = (() => { + if (!rest.enableMessageGrouping) return false; + if (!rest.nextMessage) return false; + if (message.isAdminMessage()) return false; + if (!hasSameSender(message, rest.nextMessage)) return false; + if (messageTime(new Date(message.createdAt)) !== messageTime(new Date(rest.nextMessage.createdAt))) return false; + return true; + })(); + + const messageComponent = () => { + const props = { ...rest, variant, groupWithNext, groupWithPrev }; + if (message.isUserMessage()) { + return ( + + {({ pressed }) => } + + ); + } + + if (message.isFileMessage()) { + return ( + + {({ pressed }) => } + + ); + } + + if (message.isAdminMessage()) { + return ; + } + + return ( + + {({ pressed }) => } + + ); + }; + + const chatAlignment = { + incoming: styles.chatIncoming, + outgoing: styles.chatOutgoing, + }; + + return ( + + + {message.isAdminMessage() ? ( + messageComponent() + ) : ( + + {variant === 'incoming' && } + + {variant === 'incoming' && } + {messageComponent()} + + {variant === 'incoming' && } + + )} + + ); +}; +// TODO: Outgoing types, extract to components +const IncomingSenderName: React.FC<{ message: SendbirdMessage; grouping: boolean }> = ({ message, grouping }) => { + const { colors } = useUIKitTheme(); + + if (grouping) return null; + return ( + + {(message.isFileMessage() || message.isUserMessage()) && ( + + {message.sender?.nickname} + + )} + + ); +}; +const IncomingAvatar: React.FC<{ message: SendbirdMessage; grouping: boolean }> = ({ message, grouping }) => { + if (grouping) return ; + return ( + + {(message.isFileMessage() || message.isUserMessage()) && } + + ); +}; +const IncomingTime: React.FC<{ message: SendbirdMessage; grouping: boolean }> = ({ message, grouping }) => { + const { LABEL } = useLocalization(); + const { colors } = useUIKitTheme(); + + if (grouping) return null; + return ( + + + {LABEL.GROUP_CHANNEL.FRAGMENT.LIST_MESSAGE_TIME(message)} + + + ); +}; + +const styles = createStyleSheet({ + chatIncoming: { + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'flex-end', + }, + chatOutgoing: { + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'flex-end', + }, + msgContainer: { + maxWidth: 240, + }, + chatGroup: { + marginBottom: 2, + }, + chatNonGroup: { + marginBottom: 16, + }, + + sender: { + marginLeft: 12, + marginBottom: 4, + }, + avatar: { + width: 26, + marginRight: 12, + }, + timeIncoming: { + marginLeft: 4, + }, +}); + +export default React.memo(MessageRenderer); diff --git a/packages/uikit-react-native/src/ui/NewMessagesTooltip/index.tsx b/packages/uikit-react-native/src/ui/NewMessagesTooltip/index.tsx new file mode 100644 index 000000000..342c1532d --- /dev/null +++ b/packages/uikit-react-native/src/ui/NewMessagesTooltip/index.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Platform, TouchableOpacity } from 'react-native'; + +import { useLocalization } from '@sendbird/uikit-react-native-core'; +import { Text, createStyleSheet, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import type { SendbirdMessage } from '@sendbird/uikit-utils'; + +type Props = { + newMessages: SendbirdMessage[]; + visible: boolean; + onPress: () => void; +}; +const NewMessagesTooltip: React.FC = ({ newMessages, visible, onPress }) => { + const { LABEL } = useLocalization(); + const { select, palette, colors } = useUIKitTheme(); + if (newMessages.length === 0 || !visible) return null; + return ( + + + {LABEL.GROUP_CHANNEL.FRAGMENT.LIST_TOOLTIP_NEW_MSG(newMessages)} + + + ); +}; + +const styles = createStyleSheet({ + container: { + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 20, + ...Platform.select({ + android: { + elevation: 4, + }, + ios: { + shadowColor: 'black', + shadowRadius: 4, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + }, + }), + }, +}); +export default React.memo(NewMessagesTooltip); diff --git a/packages/uikit-react-native/src/ui/ScrollToBottomTooltip/index.tsx b/packages/uikit-react-native/src/ui/ScrollToBottomTooltip/index.tsx new file mode 100644 index 000000000..2a300fd3c --- /dev/null +++ b/packages/uikit-react-native/src/ui/ScrollToBottomTooltip/index.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Platform, TouchableOpacity } from 'react-native'; + +import { Icon, createStyleSheet, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; + +type Props = { + visible: boolean; + onPress: () => void; +}; +const ScrollToBottomTooltip: React.FC = ({ visible, onPress }) => { + const { palette, select } = useUIKitTheme(); + return ( + + + + ); +}; + +const styles = createStyleSheet({ + container: { + padding: 8, + borderRadius: 24, + ...Platform.select({ + android: { + elevation: 4, + }, + ios: { + shadowColor: 'black', + shadowRadius: 4, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + }, + }), + }, +}); + +export default React.memo(ScrollToBottomTooltip); diff --git a/packages/uikit-react-native/src/ui/UserListItem/index.tsx b/packages/uikit-react-native/src/ui/UserListItem/index.tsx index 6abeea7ca..ca79ad57c 100644 --- a/packages/uikit-react-native/src/ui/UserListItem/index.tsx +++ b/packages/uikit-react-native/src/ui/UserListItem/index.tsx @@ -22,7 +22,7 @@ const UserListItem: React.FC = ({ uri, name, selected, disabled }) => { - + {name} diff --git a/packages/uikit-utils/src/message/common.ts b/packages/uikit-utils/src/message/common.ts index 3e3f346c6..e75d41b12 100644 --- a/packages/uikit-utils/src/message/common.ts +++ b/packages/uikit-utils/src/message/common.ts @@ -31,3 +31,9 @@ export function messageKeyExtractor(message: SendbirdMessage): string { export function messageComparator(a: T, b: T) { return b.createdAt - a.createdAt; } + +export function hasSameSender(a?: SendbirdMessage, b?: SendbirdMessage) { + if (!a || !b) return false; + if ('sender' in a && 'sender' in b) return a.sender?.userId === b.sender?.userId; + return false; +} diff --git a/sample/src/utils.ts b/sample/src/utils.ts index 1c4de0827..08475a83d 100644 --- a/sample/src/utils.ts +++ b/sample/src/utils.ts @@ -1,3 +1,5 @@ +import type { PaletteInterface } from '@sendbird/uikit-react-native-foundation'; + export const getContrastColor = ( color: | 'transparent' @@ -24,7 +26,7 @@ export const getContrastColor = ( throw new Error('invalid color format:' + color); }; -export const findColorNameFromPalette = (palette: Record, targetHex: string) => { +export const findColorNameFromPalette = (palette: PaletteInterface, targetHex: string) => { const map = Object.entries(palette); const color = map.find(([, hex]) => hex === targetHex); if (!color) return 'NOT_FOUND';