diff --git a/README.md b/README.md index 29cc3ec25..f5507095f 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,14 @@ Sendbird UIKit for React-Native is a development kit with an user interface that This mono-repository the UIKit source code is consists as explained below. - [**packages/uikit-react-native**](/packages/uikit-react-native) is where you can find the open source code. Check out [UIKit Open Source Guidelines](/OPENSOURCE_GUIDELINES.md) for more information regarding our stance on open source. -- [**sample**](/sample) is a chat app with UIKit’s core features in which you can see items such as push notifications, total unread message count and auto sign-in are demonstrated. When you sign in to the sample app, you will only see a list of channels rendered by the [GroupChannelListFragment](https://sendbird.com/docs/uikit/v3/react-native/key-functions/list-channels) on the screen. +- [**sample**](/sample) is a chat app with UIKit’s core features in which you can see items such as push notifications, total unread message count and auto sign-in are demonstrated. When you sign in to the sample app, you will only see a list of channels rendered by the [GroupChannelListFragment](https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/list-channels) on the screen. - [**packages/uikit-react-native-foundation**](/packages/uikit-react-native-foundation) is a UI package for `uikit-react-native`. - [**packages/uikit-chat-hooks**](/packages/uikit-chat-hooks) is a react hooks package for `uikit-react-native`. - [**packages/uikit-utils**](/packages/uikit-utils) is a utility package for `uikit-react-native`. ### More about Sendbird UIKit for React-Native -Find out more about Sendbird UIKit for React-Native at [UIKit for React Native doc](https://sendbird.com/docs/uikit/v3/react-native/overview). +Find out more about Sendbird UIKit for React-Native at [UIKit for React Native doc](https://sendbird.com/docs/chat/uikit/v3/react-native/overview). If you need any help in resolving any issues or have questions, visit [our community](https://community.sendbird.com).
diff --git a/docs-validation/1_introduction/Authentication.tsx b/docs-validation/1_introduction/Authentication.tsx index fedc77e01..3a3950f68 100644 --- a/docs-validation/1_introduction/Authentication.tsx +++ b/docs-validation/1_introduction/Authentication.tsx @@ -5,7 +5,7 @@ const PROFILE_FILE: FileType = { name: '', size: 0, type: '', uri: '' }; /** * Connect to the Sendbird server - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/authentication#2-connect-to-the-sendbird-server} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/authentication#2-connect-to-the-sendbird-server} * */ import { useConnection } from '@sendbird/uikit-react-native'; @@ -27,7 +27,7 @@ const Component = () => { /** * Disconnect from the Sendbird server - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/authentication#2-disconnect-from-the-sendbird-server} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/authentication#2-disconnect-from-the-sendbird-server} * */ const Component2 = () => { const { disconnect } = useConnection(); @@ -37,7 +37,7 @@ const Component2 = () => { /** * Retrieve online status of current user - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/authentication#2-retrieve-online-status-of-current-user} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/authentication#2-retrieve-online-status-of-current-user} * */ import { useSendbirdChat } from '@sendbird/uikit-react-native'; @@ -54,7 +54,7 @@ const Component3 = () => { /** * Register for push notifications - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/authentication#2-register-for-push-notifications} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/authentication#2-register-for-push-notifications} * */ import RNFBMessaging from '@react-native-firebase/messaging'; import * as Permissions from 'react-native-permissions'; @@ -77,7 +77,7 @@ const App = () => { /** * Unregister push notifications - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/authentication#2-unregister-push-notifications} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/authentication#2-unregister-push-notifications} * */ const App2 = () => { return ( @@ -92,7 +92,7 @@ const App2 = () => { /** * Update user profile - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/authentication#2-update-user-profile} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/authentication#2-update-user-profile} * */ const Component4 = () => { const { updateCurrentUserInfo } = useSendbirdChat(); diff --git a/docs-validation/1_introduction/NativeModules.tsx b/docs-validation/1_introduction/NativeModules.tsx index ecb7cc3e7..decadec9c 100644 --- a/docs-validation/1_introduction/NativeModules.tsx +++ b/docs-validation/1_introduction/NativeModules.tsx @@ -3,15 +3,19 @@ import { createExpoFileService, createExpoMediaService, createExpoNotificationService, + createExpoPlayerService, + createExpoRecorderService, createNativeClipboardService, createNativeFileService, createNativeMediaService, createNativeNotificationService, + createNativePlayerService, + createNativeRecorderService, } from '@sendbird/uikit-react-native'; /** * Helper functions#React Native CLI - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/native-modules#2-helper-functions-3-react-native-cli} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/native-modules#2-helper-functions-3-react-native-cli} * */ import Clipboard from '@react-native-clipboard/clipboard'; import { CameraRoll } from '@react-native-camera-roll/camera-roll'; @@ -23,29 +27,40 @@ import * as ImagePicker from 'react-native-image-picker'; import * as Permissions from 'react-native-permissions'; import * as CreateThumbnail from 'react-native-create-thumbnail'; import * as ImageResizer from '@bam.tech/react-native-image-resizer'; +import * as AudioRecorderPlayer from 'react-native-audio-recorder-player'; -const NativeClipboardService = createNativeClipboardService(Clipboard); -const NativeNotificationService = createNativeNotificationService({ - messagingModule: RNFBMessaging, - permissionModule: Permissions, -}); -const NativeFileService = createNativeFileService({ - fsModule: FileAccess, - permissionModule: Permissions, - imagePickerModule: ImagePicker, - mediaLibraryModule: CameraRoll, - documentPickerModule: DocumentPicker, -}); -const NativeMediaService = createNativeMediaService({ - VideoComponent: Video, - thumbnailModule: CreateThumbnail, - imageResizerModule: ImageResizer, -}); +const nativePlatformServices = { + clipboard: createNativeClipboardService(Clipboard), + notification: createNativeNotificationService({ + messagingModule: RNFBMessaging, + permissionModule: Permissions, + }), + file: createNativeFileService({ + imagePickerModule: ImagePicker, + documentPickerModule: DocumentPicker, + permissionModule: Permissions, + fsModule: FileAccess, + mediaLibraryModule: CameraRoll, + }), + media: createNativeMediaService({ + VideoComponent: Video, + thumbnailModule: CreateThumbnail, + imageResizerModule: ImageResizer, + }), + player: createNativePlayerService({ + audioRecorderModule: AudioRecorderPlayer, + permissionModule: Permissions, + }), + recorder: createNativeRecorderService({ + audioRecorderModule: AudioRecorderPlayer, + permissionModule: Permissions, + }), +}; /** ------------------ **/ /** * Helper functions#Expo CLI - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/native-modules#2-helper-functions-3-expo-cli} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/native-modules#2-helper-functions-3-expo-cli} * */ import * as ExpoClipboard from 'expo-clipboard'; import * as ExpoDocumentPicker from 'expo-document-picker'; @@ -57,18 +72,26 @@ import * as ExpoAV from 'expo-av'; import * as ExpoVideoThumbnail from 'expo-video-thumbnails'; import * as ExpoImageManipulator from 'expo-image-manipulator'; -const ExpoNotificationService = createExpoNotificationService(ExpoNotifications); -const ExpoClipboardService = createExpoClipboardService(ExpoClipboard); -const ExpoFileService = createExpoFileService({ - fsModule: ExpoFS, - imagePickerModule: ExpoImagePicker, - mediaLibraryModule: ExpoMediaLibrary, - documentPickerModule: ExpoDocumentPicker, -}); -const ExpoMediaService = createExpoMediaService({ - avModule: ExpoAV, - thumbnailModule: ExpoVideoThumbnail, - imageManipulator: ExpoImageManipulator, - fsModule: ExpoFS, -}) +const expoPlatformServices = { + clipboard: createExpoClipboardService(ExpoClipboard), + notification: createExpoNotificationService(ExpoNotifications), + file: createExpoFileService({ + fsModule: ExpoFS, + imagePickerModule: ExpoImagePicker, + mediaLibraryModule: ExpoMediaLibrary, + documentPickerModule: ExpoDocumentPicker, + }), + media: createExpoMediaService({ + avModule: ExpoAV, + thumbnailModule: ExpoVideoThumbnail, + imageManipulator: ExpoImageManipulator, + fsModule: ExpoFS, + }), + player: createExpoPlayerService({ + avModule: ExpoAV, + }), + recorder: createExpoRecorderService({ + avModule: ExpoAV, + }), +}; /** ------------------ **/ diff --git a/docs-validation/1_introduction/ScreenNavigation.tsx b/docs-validation/1_introduction/ScreenNavigation.tsx index 8b70eef4b..04bf96822 100644 --- a/docs-validation/1_introduction/ScreenNavigation.tsx +++ b/docs-validation/1_introduction/ScreenNavigation.tsx @@ -4,7 +4,7 @@ const GroupChannelScreen = () => ; /** * Set up navigation in a fragment - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/screen-navigation#2-set-up-navigation-in-a-fragment} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/screen-navigation#2-set-up-navigation-in-a-fragment} * */ // @ts-ignore import { Navigation } from 'react-native-navigation'; @@ -34,7 +34,7 @@ const GroupChannelListScreen = (props: { componentId: string }) => { /** * Integrate navigation library - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/screen-navigation#2-integrate-navigation-library} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/screen-navigation#2-integrate-navigation-library} * */ Navigation.registerComponent('GroupChannel', () => GroupChannelScreen); Navigation.registerComponent('GroupChannelList', () => GroupChannelListScreen); diff --git a/docs-validation/1_introduction/SendYourFirstMessage.tsx b/docs-validation/1_introduction/SendYourFirstMessage.tsx index f1a5774e2..07023e5c1 100644 --- a/docs-validation/1_introduction/SendYourFirstMessage.tsx +++ b/docs-validation/1_introduction/SendYourFirstMessage.tsx @@ -2,14 +2,17 @@ import React from 'react'; /** * Implement platform service interfaces using native modules - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/send-first-message#2-get-started-3-step-4-implement-platform-service-interfaces-using-native-modules} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/send-first-message#2-get-started-3-step-4-implement-platform-service-interfaces-using-native-modules} * */ import { createNativeClipboardService, createNativeFileService, createNativeMediaService, createNativeNotificationService, -} from '@sendbird/uikit-react-native'; + createNativePlayerService, + createNativeRecorderService, + SendbirdUIKitContainerProps +} from "@sendbird/uikit-react-native"; import Clipboard from '@react-native-clipboard/clipboard'; import { CameraRoll } from '@react-native-camera-roll/camera-roll'; @@ -21,29 +24,41 @@ import * as ImagePicker from 'react-native-image-picker'; import * as Permissions from 'react-native-permissions'; import * as CreateThumbnail from 'react-native-create-thumbnail'; import * as ImageResizer from '@bam.tech/react-native-image-resizer'; - -const ClipboardService = createNativeClipboardService(Clipboard); -const NotificationService = createNativeNotificationService({ - messagingModule: RNFBMessaging, - permissionModule: Permissions, -}); -const FileService = createNativeFileService({ - fsModule: FileAccess, - permissionModule: Permissions, - imagePickerModule: ImagePicker, - mediaLibraryModule: CameraRoll, - documentPickerModule: DocumentPicker, -}); -const MediaService = createNativeMediaService({ - VideoComponent: Video, - thumbnailModule: CreateThumbnail, - imageResizerModule: ImageResizer, -}); +import * as AudioRecorderPlayer from 'react-native-audio-recorder-player'; + + +export const platformServices: SendbirdUIKitContainerProps['platformServices'] = { + clipboard: createNativeClipboardService(Clipboard), + notification: createNativeNotificationService({ + messagingModule: RNFBMessaging, + permissionModule: Permissions, + }), + file: createNativeFileService({ + imagePickerModule: ImagePicker, + documentPickerModule: DocumentPicker, + permissionModule: Permissions, + fsModule: FileAccess, + mediaLibraryModule: CameraRoll, + }), + media: createNativeMediaService({ + VideoComponent: Video, + thumbnailModule: CreateThumbnail, + imageResizerModule: ImageResizer, + }), + player: createNativePlayerService({ + audioRecorderModule: AudioRecorderPlayer, + permissionModule: Permissions, + }), + recorder: createNativeRecorderService({ + audioRecorderModule: AudioRecorderPlayer, + permissionModule: Permissions, + }), +}; /** ------------------ **/ /** * Wrap your app in SendbirdUIKitContainer - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/send-first-message#2-get-started-3-step-5-wrap-your-app-in-sendbirduikitcontainer} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/send-first-message#2-get-started-3-step-5-wrap-your-app-in-sendbirduikitcontainer} * */ import { SendbirdUIKitContainer } from '@sendbird/uikit-react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -53,12 +68,7 @@ const App = () => { {/* Rest of your app */} @@ -68,7 +78,7 @@ const App = () => { /** * Create a fragment and module components - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/send-first-message#2-get-started-3-step-7-create-a-fragment-and-module-components} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/send-first-message#2-get-started-3-step-7-create-a-fragment-and-module-components} * */ import { useNavigation, useRoute } from '@react-navigation/native'; import { useGroupChannel } from '@sendbird/uikit-chat-hooks'; @@ -146,7 +156,7 @@ const GroupChannelScreen = () => { /** * Register navigation library to the screen - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/send-first-message#2-get-started-3-step-8-register-navigation-library-to-the-screen} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/send-first-message#2-get-started-3-step-8-register-navigation-library-to-the-screen} * */ import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; @@ -177,12 +187,7 @@ const App2 = () => { @@ -192,7 +197,7 @@ const App2 = () => { /** * Connect to the Sendbird server - * {@link https://sendbird.com/docs/uikit/v3/react-native/introduction/send-first-message#2-get-started-3-step-9-connect-to-the-sendbird-server} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/send-first-message#2-get-started-3-step-9-connect-to-the-sendbird-server} * */ import { Pressable, Text, View } from 'react-native'; import { useConnection } from '@sendbird/uikit-react-native'; diff --git a/docs-validation/2_features/DeliveryReceipt.tsx b/docs-validation/2_features/DeliveryReceipt.tsx index bf773d338..11b0a280d 100644 --- a/docs-validation/2_features/DeliveryReceipt.tsx +++ b/docs-validation/2_features/DeliveryReceipt.tsx @@ -1,6 +1,6 @@ /** * How to use - * {@link https://sendbird.com/docs/uikit/v3/react-native/features/delivery-receipt#2-how-to-use} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/features/delivery-receipt#2-how-to-use} * */ import { SendbirdUIKitContainer } from '@sendbird/uikit-react-native'; @@ -12,7 +12,7 @@ const App = () => { /** * Icon resource - * {@link https://sendbird.com/docs/uikit/v3/react-native/features/delivery-receipt#2-customize-the-ui-for-delivery-receipt-3-icon-resource} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/features/delivery-receipt#2-customize-the-ui-for-delivery-receipt-3-icon-resource} * */ import { Icon } from '@sendbird/uikit-react-native-foundation'; diff --git a/docs-validation/2_features/FileSharing.tsx b/docs-validation/2_features/FileSharing.tsx index 2d6debc75..5bec6f2ba 100644 --- a/docs-validation/2_features/FileSharing.tsx +++ b/docs-validation/2_features/FileSharing.tsx @@ -9,7 +9,7 @@ const isImageFile = (x: string) => x; /** * Customize the UI for file sharing - * {@link https://sendbird.com/docs/uikit/v3/react-native/features/file-sharing#2-customize-the-ui-for-file-sharing} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/features/file-sharing#2-customize-the-ui-for-file-sharing} * */ import { createGroupChannelFragment, GroupChannelMessageRenderer } from '@sendbird/uikit-react-native'; @@ -32,7 +32,7 @@ const GroupChannelScreen = () => { /** * Color resource - * {@link https://sendbird.com/docs/uikit/v3/react-native/features/file-sharing#2-customize-the-ui-for-file-sharing-3-color-resource} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/features/file-sharing#2-customize-the-ui-for-file-sharing-3-color-resource} * */ function _colorResource(colors: UIKitColors) { colors.ui.groupChannelMessage; @@ -50,7 +50,7 @@ function _colorResource(colors: UIKitColors) { /** * Icon resource - * {@link https://sendbird.com/docs/uikit/v3/react-native/features/file-sharing#2-customize-the-ui-for-file-sharing-3-icon-resource} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/features/file-sharing#2-customize-the-ui-for-file-sharing-3-icon-resource} * */ Icon.Assets['add'] = require('your_icons/add_icon.png'); Icon.Assets['document'] = require('your_icons/document_icon.png'); @@ -61,7 +61,7 @@ Icon.Assets['play'] = require('your_icons/play_icon.png'); /** * String resource - * {@link https://sendbird.com/docs/uikit/v3/react-native/features/file-sharing#2-customize-the-ui-for-file-sharing-3-string-resource} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/features/file-sharing#2-customize-the-ui-for-file-sharing-3-string-resource} * */ function _stringResource(str: StringSet) { str.GROUP_CHANNEL; @@ -83,7 +83,7 @@ function _stringResource(str: StringSet) { /** * Image compression - * {@link https://sendbird.com/docs/uikit/v3/react-native/features/file-sharing#2-image-compression} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/features/file-sharing#2-image-compression} * */ import { SendbirdUIKitContainer } from '@sendbird/uikit-react-native'; diff --git a/docs-validation/2_features/ReadReceipt.tsx b/docs-validation/2_features/ReadReceipt.tsx index 29c31d531..0dec7b514 100644 --- a/docs-validation/2_features/ReadReceipt.tsx +++ b/docs-validation/2_features/ReadReceipt.tsx @@ -1,6 +1,6 @@ /** * How to use - * {@link https://sendbird.com/docs/uikit/v3/react-native/features/read-receipt#2-how-to-use} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/features/read-receipt#2-how-to-use} * */ import { SendbirdUIKitContainer } from '@sendbird/uikit-react-native'; const App = () => { @@ -11,7 +11,7 @@ const App = () => { /** * Icon resource - * {@link https://sendbird.com/docs/uikit/v3/react-native/features/read-receipt#2-customize-the-ui-for-delivery-receipt-3-icon-resource} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/features/read-receipt#2-customize-the-ui-for-delivery-receipt-3-icon-resource} * */ import { Icon } from '@sendbird/uikit-react-native-foundation'; diff --git a/docs-validation/2_features/TypingIndicator.tsx b/docs-validation/2_features/TypingIndicator.tsx index 7a6985a2c..5d79b9fec 100644 --- a/docs-validation/2_features/TypingIndicator.tsx +++ b/docs-validation/2_features/TypingIndicator.tsx @@ -2,7 +2,7 @@ import type { StringSet } from '@sendbird/uikit-react-native'; /** * How to use - * {@link https://sendbird.com/docs/uikit/v3/react-native/features/typing-indicator#2-how-to-use} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/features/typing-indicator#2-how-to-use} * */ import { SendbirdUIKitContainer } from '@sendbird/uikit-react-native'; const App = () => { @@ -13,7 +13,7 @@ const App = () => { /** * String resource - * {@link https://sendbird.com/docs/uikit/v3/react-native/features/typing-indicator#2-customize-the-ui-for-typing-indicator-3-string-resource} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/features/typing-indicator#2-customize-the-ui-for-typing-indicator-3-string-resource} * */ type TypingIndicatorTypings = StringSet['LABELS']['TYPING_INDICATOR_TYPINGS']; /** ------------------ **/ diff --git a/docs-validation/3_core-components/Hooks.tsx b/docs-validation/3_core-components/Hooks.tsx index aaf7feaf0..1bb6a7caf 100644 --- a/docs-validation/3_core-components/Hooks.tsx +++ b/docs-validation/3_core-components/Hooks.tsx @@ -1,6 +1,6 @@ /** * useSendbirdChat - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/hooks#-3-usesendbirdchat-} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/hooks#-3-usesendbirdchat-} * */ import { useSendbirdChat } from '@sendbird/uikit-react-native'; @@ -15,7 +15,7 @@ const Component = () => { /** * useConnection - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/hooks#-3-useconnection-} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/hooks#-3-useconnection-} * */ import { useConnection, SendbirdUIKitContainer } from '@sendbird/uikit-react-native'; @@ -38,7 +38,7 @@ const App = () => { /** * useUIKitTheme - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/hooks#-3-useuikittheme-} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/hooks#-3-useuikittheme-} * */ import { useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; @@ -52,7 +52,7 @@ const Component3 = () => { /** * usePlatformService - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/hooks#-3-useplatformservice-} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/hooks#-3-useplatformservice-} * */ import { usePlatformService } from '@sendbird/uikit-react-native'; @@ -70,7 +70,7 @@ const Component4 = () => { /** * useLocalization - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/hooks#-3-uselocalization-} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/hooks#-3-uselocalization-} * */ import { useLocalization } from '@sendbird/uikit-react-native'; @@ -81,7 +81,7 @@ const Component5 = () => { /** * useToast - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/hooks#-3-usetoast-} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/hooks#-3-usetoast-} * */ import { useToast } from '@sendbird/uikit-react-native-foundation'; @@ -93,7 +93,7 @@ const Component6 = () => { /** * usePrompt - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/hooks#-3-useprompt-} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/hooks#-3-useprompt-} * */ import { usePrompt } from '@sendbird/uikit-react-native-foundation'; @@ -109,7 +109,7 @@ const Component7 = () => { /** * useBottomSheet - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/hooks#-3-usebottomsheet-} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/hooks#-3-usebottomsheet-} * */ import { useBottomSheet } from '@sendbird/uikit-react-native-foundation'; @@ -128,7 +128,7 @@ const Component8 = () => { /** * useActionMenu - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/hooks#-3-useactionmenu-} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/hooks#-3-useactionmenu-} * */ import { useActionMenu } from '@sendbird/uikit-react-native-foundation'; @@ -147,7 +147,7 @@ const Component9 = () => { /** * useAlert - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/hooks#-3-usealert-} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/hooks#-3-usealert-} * */ import { useAlert } from '@sendbird/uikit-react-native-foundation'; diff --git a/docs-validation/3_core-components/Provider/PlatformServiceProvider.tsx b/docs-validation/3_core-components/Provider/PlatformServiceProvider.tsx index 710c30df0..4e9c32c18 100644 --- a/docs-validation/3_core-components/Provider/PlatformServiceProvider.tsx +++ b/docs-validation/3_core-components/Provider/PlatformServiceProvider.tsx @@ -6,7 +6,7 @@ const MyDocumentPickerModule = { getDocumentAsync: async (_: object) => ({ type: '', mimeType: '', uri: '', size: 0, name: '' }), }; const MyMediaLibraryModule = { requestPermission: async () => 0, saveToLibrary: async (_: string) => 0 }; -const RNFetchBlob = { config: (_: object) => ({ fetch: async (_: string, __: string) => ({ path: () => '' }) }) }; +const RNFetchBlob = { config: (_: object) => ({ fetch: async (_: string, __: string) => ({ path: () => '' }) }), cacheDir: 'cache' }; type FileCompat = { name: string; uri: string; size: number; type: string }; type SaveRes = null | string; type OpenMediaLibraryRes = null | Array; @@ -14,7 +14,7 @@ type GetFileRes = null | FileCompat; /** * FileServiceInterface - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/provider/platformserviceprovider#2-fileserviceinterface} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/provider/platformserviceprovider#2-fileserviceinterface} * */ async function fileServiceInterface(service: FileServiceInterface) { const mediaType = '' as 'photo' | 'video' | 'all' | undefined; @@ -50,7 +50,7 @@ async function mediaServiceInterface(service: MediaServiceInterface) { /** * Usage - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/provider/platformserviceprovider#2-usage} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/provider/platformserviceprovider#2-usage} * */ import { usePlatformService } from '@sendbird/uikit-react-native'; @@ -61,7 +61,7 @@ const Component = () => { /** * Direct implementation - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/provider/platformserviceprovider#2-direct-implementation} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/provider/platformserviceprovider#2-direct-implementation} * */ import { FilePickerResponse, @@ -113,5 +113,12 @@ class MyFileService implements FileServiceInterface { return response.path(); } + createRecordFilePath(customExtension = 'm4a'): { recordFilePath: string; uri: string } { + const filename = `${Date.now()}.${customExtension}`; + return { + uri: `${RNFetchBlob.cacheDir}/${filename}`, + recordFilePath: `${RNFetchBlob.cacheDir}/${filename}`, + } + } } /** ------------------ **/ diff --git a/docs-validation/3_core-components/Provider/SendbirdChatProvider.tsx b/docs-validation/3_core-components/Provider/SendbirdChatProvider.tsx index 0e314f89f..c96147ffa 100644 --- a/docs-validation/3_core-components/Provider/SendbirdChatProvider.tsx +++ b/docs-validation/3_core-components/Provider/SendbirdChatProvider.tsx @@ -3,7 +3,7 @@ import React from 'react'; /** * SendbirdChatProvider - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/provider/sendbirdchatprovider#1-sendbirdchatprovider} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/provider/sendbirdchatprovider#1-sendbirdchatprovider} * */ import { useConnection, useSendbirdChat } from '@sendbird/uikit-react-native'; import { Button, Text } from '@sendbird/uikit-react-native-foundation'; diff --git a/docs-validation/3_core-components/SendbirdUIKitContainer.tsx b/docs-validation/3_core-components/SendbirdUIKitContainer.tsx index 6f11550c7..d4b9ca528 100644 --- a/docs-validation/3_core-components/SendbirdUIKitContainer.tsx +++ b/docs-validation/3_core-components/SendbirdUIKitContainer.tsx @@ -3,7 +3,7 @@ import React from 'react'; /** * SendbirdUIKitContainer - * {@link https://sendbird.com/docs/uikit/v3/react-native/core-components/sendbirduikitcontainer#1-sendbirduikitcontainer} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/sendbirduikitcontainer#1-sendbirduikitcontainer} * */ import { SendbirdUIKitContainer } from '@sendbird/uikit-react-native'; diff --git a/docs-validation/4_key-functions/Architecture/Context.tsx b/docs-validation/4_key-functions/Architecture/Context.tsx index 0a24f9ba3..902e0b5f5 100644 --- a/docs-validation/4_key-functions/Architecture/Context.tsx +++ b/docs-validation/4_key-functions/Architecture/Context.tsx @@ -1,6 +1,6 @@ /** * How to use context - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/architecture/context#2-how-to-use-context} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/architecture/context#2-how-to-use-context} * */ import React, { useContext } from 'react'; import { Text } from 'react-native'; diff --git a/docs-validation/4_key-functions/Architecture/Fragment.tsx b/docs-validation/4_key-functions/Architecture/Fragment.tsx index d390f4518..eab6a14cd 100644 --- a/docs-validation/4_key-functions/Architecture/Fragment.tsx +++ b/docs-validation/4_key-functions/Architecture/Fragment.tsx @@ -5,7 +5,7 @@ const FriendComponent = (_: { user: SendbirdUser }) => <>; /** * Customize a Fragment - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/architecture/fragment#2-customize-a-fragment} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/architecture/fragment#2-customize-a-fragment} * */ import { CustomQuery, useUserList } from '@sendbird/uikit-chat-hooks'; import { createUserListModule, useSendbirdChat } from '@sendbird/uikit-react-native'; diff --git a/docs-validation/4_key-functions/Architecture/Module.tsx b/docs-validation/4_key-functions/Architecture/Module.tsx index a2d471558..639522324 100644 --- a/docs-validation/4_key-functions/Architecture/Module.tsx +++ b/docs-validation/4_key-functions/Architecture/Module.tsx @@ -4,7 +4,7 @@ const CustomHeader = () => <>; /** * Create a module - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/architecture/module#2-create-a-module} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/architecture/module#2-create-a-module} * */ import { createGroupChannelModule } from '@sendbird/uikit-react-native'; const GroupChannelModule = createGroupChannelModule(); @@ -26,7 +26,7 @@ const RenderModule = () => { /** * Customize a module - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/architecture/module#2-customize-a-module} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/architecture/module#2-customize-a-module} * */ import { createGroupChannelFragment } from '@sendbird/uikit-react-native'; @@ -38,7 +38,7 @@ const GroupChannelFragment = createGroupChannelFragment({ Header: CustomHeader } /** * Customize a module component - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/architecture/module#2-customize-a-module-3-customize-a-module-component} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/architecture/module#2-customize-a-module-3-customize-a-module-component} * */ import { Text } from 'react-native'; import type { GroupChannelProps } from '@sendbird/uikit-react-native'; diff --git a/docs-validation/4_key-functions/ChattingInAChannel/ChatInAGroupChannel.tsx b/docs-validation/4_key-functions/ChattingInAChannel/ChatInAGroupChannel.tsx index 08622bc17..bef2841cd 100644 --- a/docs-validation/4_key-functions/ChattingInAChannel/ChatInAGroupChannel.tsx +++ b/docs-validation/4_key-functions/ChattingInAChannel/ChatInAGroupChannel.tsx @@ -4,7 +4,7 @@ const AdvertiseMessage = (_:object) => <> /** * Usage - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-usage} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-usage} * */ import { useSendbirdChat, createGroupChannelFragment } from "@sendbird/uikit-react-native"; import { useGroupChannel } from "@sendbird/uikit-chat-hooks"; @@ -33,7 +33,7 @@ const GroupChannelScreen = ({ route: { params } }: any) => { /** * Context - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-context} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-context} * */ function _context(_: GroupChannelContextsType) { const fragment = useContext(_.Fragment); @@ -52,7 +52,7 @@ function _context(_: GroupChannelContextsType) { /** * Fragment - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-context-3-fragment} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-context-3-fragment} * */ const Component = () => { const { @@ -69,7 +69,7 @@ const Component = () => { /** * TypingIndicator - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-context-3-typeselector} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-context-3-typeselector} * */ const Component2 = () => { const { typingUsers } = useContext(GroupChannelContexts.TypingIndicator); @@ -78,7 +78,7 @@ const Component2 = () => { /** * Customization - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-customization} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-customization} * */ import React, { useContext, useLayoutEffect } from 'react'; import { Pressable } from 'react-native'; diff --git a/docs-validation/4_key-functions/ChattingInAChannel/ChatInAnOpenChannel.tsx b/docs-validation/4_key-functions/ChattingInAChannel/ChatInAnOpenChannel.tsx index b444ee305..ca766ea5d 100644 --- a/docs-validation/4_key-functions/ChattingInAChannel/ChatInAnOpenChannel.tsx +++ b/docs-validation/4_key-functions/ChattingInAChannel/ChatInAnOpenChannel.tsx @@ -4,7 +4,7 @@ const DonationMessage = (_:object) => <> /** * Usage - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-an-open-channel#2-usage} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-an-open-channel#2-usage} * */ import { useSendbirdChat, createOpenChannelFragment } from "@sendbird/uikit-react-native"; import { useOpenChannel } from "@sendbird/uikit-chat-hooks"; @@ -35,7 +35,7 @@ const OpenChannelScreen = ({ route: { params } }: any) => { /** * Context - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-context} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-context} * */ function _context(_: OpenChannelContextsType) { const fragment = useContext(_.Fragment); @@ -49,7 +49,7 @@ function _context(_: OpenChannelContextsType) { /** * Fragment - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-context-3-fragment} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-context-3-fragment} * */ const Component = () => { const { @@ -64,7 +64,7 @@ const Component = () => { /** * Customization - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-customization} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/chatting-in-a-channel/chat-in-a-group-channel#2-customization} * */ import React, { useContext, useLayoutEffect } from 'react'; import { Pressable } from 'react-native'; diff --git a/docs-validation/4_key-functions/ConfiguringChannelSettings/ConfigureGroupChannelSettings.tsx b/docs-validation/4_key-functions/ConfiguringChannelSettings/ConfigureGroupChannelSettings.tsx index 52d12d54f..25b87bb6e 100644 --- a/docs-validation/4_key-functions/ConfiguringChannelSettings/ConfigureGroupChannelSettings.tsx +++ b/docs-validation/4_key-functions/ConfiguringChannelSettings/ConfigureGroupChannelSettings.tsx @@ -2,7 +2,7 @@ import type { GroupChannelSettingsContextsType } from '@sendbird/uikit-react-nat /** * Usage - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-group-channel-settings#2-usage} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-group-channel-settings#2-usage} * */ import React, { useState } from 'react'; import { useSendbirdChat, createGroupChannelSettingsFragment } from '@sendbird/uikit-react-native'; @@ -41,7 +41,7 @@ const GroupChannelSettingsScreen = ({ route: { params } }: any) => { /** * Context - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-group-channel-settings#2-context} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-group-channel-settings#2-context} * */ function _context(_: GroupChannelSettingsContextsType) { const fragment = useContext(_.Fragment); @@ -54,7 +54,7 @@ function _context(_: GroupChannelSettingsContextsType) { /** * Fragment - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-group-channel-settings#2-context-3-fragment} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-group-channel-settings#2-context-3-fragment} * */ import { useContext } from 'react'; import { GroupChannelSettingsContexts } from '@sendbird/uikit-react-native'; @@ -66,7 +66,7 @@ const Component = () => { /** * Customization - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-group-channel-settings#2-customization} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-group-channel-settings#2-customization} * */ import { Share } from 'react-native'; diff --git a/docs-validation/4_key-functions/ConfiguringChannelSettings/ConfigureOpenChannelSettings.tsx b/docs-validation/4_key-functions/ConfiguringChannelSettings/ConfigureOpenChannelSettings.tsx index 59bc40108..36360fcc8 100644 --- a/docs-validation/4_key-functions/ConfiguringChannelSettings/ConfigureOpenChannelSettings.tsx +++ b/docs-validation/4_key-functions/ConfiguringChannelSettings/ConfigureOpenChannelSettings.tsx @@ -2,7 +2,7 @@ import type { OpenChannelSettingsContextsType } from '@sendbird/uikit-react-nati /** * Usage - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-open-channel-settings#2-usage} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-open-channel-settings#2-usage} * */ import React, { useState } from 'react'; import { useSendbirdChat, createOpenChannelSettingsFragment } from '@sendbird/uikit-react-native'; @@ -35,7 +35,7 @@ const OpenChannelSettingsScreen = ({ route: { params } }: any) => { /** * Context - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-open-channel-settings#2-context} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-open-channel-settings#2-context} * */ function _context(_: OpenChannelSettingsContextsType) { const fragment = useContext(_.Fragment); @@ -48,7 +48,7 @@ function _context(_: OpenChannelSettingsContextsType) { /** * Fragment - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-open-channel-settings#2-context-3-fragment} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-open-channel-settings#2-context-3-fragment} * */ import { useContext } from 'react'; import { OpenChannelSettingsContexts } from '@sendbird/uikit-react-native'; @@ -60,7 +60,7 @@ const Component = () => { /** * Customization - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-open-channel-settings#2-customization} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/configuring-channel-settings/configure-open-channel-settings#2-customization} * */ import { Share } from 'react-native'; diff --git a/docs-validation/4_key-functions/CreatingAChannel/CreateAGroupChannel.tsx b/docs-validation/4_key-functions/CreatingAChannel/CreateAGroupChannel.tsx index 65e4164ec..90d77d0d1 100644 --- a/docs-validation/4_key-functions/CreatingAChannel/CreateAGroupChannel.tsx +++ b/docs-validation/4_key-functions/CreatingAChannel/CreateAGroupChannel.tsx @@ -11,7 +11,7 @@ const createMyAppUserQuery = () => ({ /** * Usage - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-usage} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-usage} * */ import type { GroupChannelType, UserListContextsType } from "@sendbird/uikit-react-native"; import type { SendbirdUser } from '@sendbird/uikit-utils'; @@ -34,7 +34,7 @@ const GroupChannelCreateScreen = ({ route: { params } }: any) => { /** * Context - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-context} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-context} * */ function _context(_: UserListContextsType) { const fragment = useContext(_.Fragment); @@ -49,7 +49,7 @@ function _context(_: UserListContextsType) { /** * Fragment - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-context-3-fragment} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-context-3-fragment} * */ import { UserListContexts } from "@sendbird/uikit-react-native"; const Component = () => { @@ -59,7 +59,7 @@ const Component = () => { /** * List - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-context-3-list} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-context-3-list} * */ const Component2 = () => { @@ -69,7 +69,7 @@ const Component2 = () => { /** * Customization - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-customization} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-customization} * */ import type { UserStruct } from '@sendbird/uikit-utils'; import { CustomQuery } from '@sendbird/uikit-chat-hooks'; diff --git a/docs-validation/4_key-functions/InviteUsers.tsx b/docs-validation/4_key-functions/InviteUsers.tsx index 656b83cac..75af96f71 100644 --- a/docs-validation/4_key-functions/InviteUsers.tsx +++ b/docs-validation/4_key-functions/InviteUsers.tsx @@ -11,7 +11,7 @@ const createMyAppUserQuery = () => ({ /** * Usage - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-usage} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-usage} * */ import { useState } from 'react'; import { useSendbirdChat, createGroupChannelInviteFragment } from "@sendbird/uikit-react-native"; @@ -39,7 +39,7 @@ const GroupChannelInviteScreen = ({ route: { params } }: any) => { /** * Context - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-context} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-context} * */ import type { UserListContextsType } from "@sendbird/uikit-react-native"; @@ -56,7 +56,7 @@ function _context(_: UserListContextsType) { /** * Fragment - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-context-3-fragment} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-context-3-fragment} * */ import { useContext } from 'react'; import { UserListContexts } from "@sendbird/uikit-react-native"; @@ -68,7 +68,7 @@ const Component = () => { /** * List - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-context-3-list} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-context-3-list} * */ // import { useContext } from 'react'; // import { UserListContexts } from "@sendbird/uikit-react-native"; @@ -80,7 +80,7 @@ const Component2 = () => { /** * Customization - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-customization} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/creating-a-channel/create-a-group-channel#2-customization} * */ import type { UserStruct } from '@sendbird/uikit-utils'; import { CustomQuery } from '@sendbird/uikit-chat-hooks'; diff --git a/docs-validation/4_key-functions/ListChannels.tsx b/docs-validation/4_key-functions/ListChannels.tsx index 72e049d7b..2f46e79fa 100644 --- a/docs-validation/4_key-functions/ListChannels.tsx +++ b/docs-validation/4_key-functions/ListChannels.tsx @@ -3,7 +3,7 @@ import React, { useContext, useLayoutEffect } from 'react'; /** * Usage - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/list-channels#2-usage} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/list-channels#2-usage} * */ import { createGroupChannelListFragment } from '@sendbird/uikit-react-native'; @@ -23,7 +23,7 @@ const GroupChannelListScreen = () => { /** * Context - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/list-channels#2-context} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/list-channels#2-context} * */ function _context(_: GroupChannelListContextsType) { const fragment = useContext(_.Fragment); @@ -39,7 +39,7 @@ function _context(_: GroupChannelListContextsType) { /** * Fragment - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/list-channels#2-context-3-fragment} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/list-channels#2-context-3-fragment} * */ // import { useContext } from 'react'; import { GroupChannelListContexts } from '@sendbird/uikit-react-native'; @@ -51,7 +51,7 @@ const Component = () => { /** * TypeSelector - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/list-channels#2-context-3-typeselector} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/list-channels#2-context-3-typeselector} * */ // import { useContext } from 'react'; // import { GroupChannelListContexts } from '@sendbird/uikit-react-native'; @@ -63,7 +63,7 @@ const Component2 = () => { /** * Customization - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/list-channels#2-customization} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/list-channels#2-customization} * */ // import React, { useContext, useLayoutEffect } from 'react'; import { Pressable } from 'react-native'; diff --git a/docs-validation/4_key-functions/ModeratingChannelsAndMembers/ModerateGroupChannelsAndMembers.tsx b/docs-validation/4_key-functions/ModeratingChannelsAndMembers/ModerateGroupChannelsAndMembers.tsx index 5892e8af6..045d3a5d1 100644 --- a/docs-validation/4_key-functions/ModeratingChannelsAndMembers/ModerateGroupChannelsAndMembers.tsx +++ b/docs-validation/4_key-functions/ModeratingChannelsAndMembers/ModerateGroupChannelsAndMembers.tsx @@ -2,7 +2,7 @@ const MyHeader = () => null; /** * - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/moderating-channels-and-members/moderate-group-channels-and-members} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/moderating-channels-and-members/moderate-group-channels-and-members} * */ import React from 'react'; @@ -33,7 +33,7 @@ const GroupChannelModerationScreen = ({ route: { params } }: any) => { /** * - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/moderating-channels-and-members/moderate-group-channels-and-members} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/moderating-channels-and-members/moderate-group-channels-and-members} * */ const GroupChannelModerationFragment2 = createGroupChannelModerationFragment({ Header: () => , // Use custom header diff --git a/docs-validation/4_key-functions/ModeratingChannelsAndMembers/ModerateOpenChannelsAndParticipants.tsx b/docs-validation/4_key-functions/ModeratingChannelsAndMembers/ModerateOpenChannelsAndParticipants.tsx index 876c6ba9d..27f9bc66e 100644 --- a/docs-validation/4_key-functions/ModeratingChannelsAndMembers/ModerateOpenChannelsAndParticipants.tsx +++ b/docs-validation/4_key-functions/ModeratingChannelsAndMembers/ModerateOpenChannelsAndParticipants.tsx @@ -2,7 +2,7 @@ const MyHeader = () => null; /** * - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/moderating-channels-and-members/moderate-group-channels-and-members} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/moderating-channels-and-members/moderate-group-channels-and-members} * */ import React from 'react'; @@ -33,7 +33,7 @@ const OpenChannelModerationScreen = ({ route: { params } }: any) => { /** * - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/moderating-channels-and-members/moderate-group-channels-and-members} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/moderating-channels-and-members/moderate-group-channels-and-members} * */ const OpenChannelModerationFragment2 = createOpenChannelModerationFragment({ Header: () => , // Use custom header diff --git a/docs-validation/4_key-functions/Overview.tsx b/docs-validation/4_key-functions/Overview.tsx index 11aafcdaf..4c65017df 100644 --- a/docs-validation/4_key-functions/Overview.tsx +++ b/docs-validation/4_key-functions/Overview.tsx @@ -3,7 +3,7 @@ const useHooksForChat = () => ({ dataA: '', dataB: '' }); /** * Key functions - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/overview} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/overview} * */ import React, { createContext, useContext } from 'react'; import { Text } from 'react-native'; diff --git a/docs-validation/4_key-functions/RegisteringAsOperator/RegisterMemberAsOperators.tsx b/docs-validation/4_key-functions/RegisteringAsOperator/RegisterMemberAsOperators.tsx index dec42689c..8fc0dce28 100644 --- a/docs-validation/4_key-functions/RegisteringAsOperator/RegisterMemberAsOperators.tsx +++ b/docs-validation/4_key-functions/RegisteringAsOperator/RegisterMemberAsOperators.tsx @@ -2,7 +2,7 @@ const MyHeader = () => null; /** * - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/registering-as-operator-register-member-as-operators} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/registering-as-operator-register-member-as-operators} * */ import React from 'react'; @@ -29,7 +29,7 @@ const GroupChannelRegisterOperatorScreen = ({ route: { params } }: any) => { /** * - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/registering-as-operator-register-member-as-operators} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/registering-as-operator-register-member-as-operators} * */ const GroupChannelRegisterOperatorFragment2 = createGroupChannelRegisterOperatorFragment({ Header: () => , // Use custom header diff --git a/docs-validation/4_key-functions/RegisteringAsOperator/RegisterParticipantsAsOperators.tsx b/docs-validation/4_key-functions/RegisteringAsOperator/RegisterParticipantsAsOperators.tsx index 6c3707f07..abec02d61 100644 --- a/docs-validation/4_key-functions/RegisteringAsOperator/RegisterParticipantsAsOperators.tsx +++ b/docs-validation/4_key-functions/RegisteringAsOperator/RegisterParticipantsAsOperators.tsx @@ -2,7 +2,7 @@ const MyHeader = () => null; /** * - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/registering-as-operator-register-participants-as-operators} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/registering-as-operator-register-participants-as-operators} * */ import React from 'react'; @@ -29,7 +29,7 @@ const OpenChannelRegisterOperatorScreen = ({ route: { params } }: any) => { /** * - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/registering-as-operator-register-participants-as-operators} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/registering-as-operator-register-participants-as-operators} * */ const OpenChannelRegisterOperatorFragment2 = createOpenChannelRegisterOperatorFragment({ Header: () => , // Use custom header diff --git a/docs-validation/4_key-functions/SearchMessage.tsx b/docs-validation/4_key-functions/SearchMessage.tsx index 1b85229e5..fc20278d0 100644 --- a/docs-validation/4_key-functions/SearchMessage.tsx +++ b/docs-validation/4_key-functions/SearchMessage.tsx @@ -1,6 +1,6 @@ /** * Usage - * {@link https://sendbird.com/docs/uikit/v3/react-native/key-functions/list-channels#2-usage} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/key-functions/list-channels#2-usage} * */ import React from 'react'; diff --git a/docs-validation/5_customization/Overview.tsx b/docs-validation/5_customization/Overview.tsx index 31f1b7042..28e3c0c37 100644 --- a/docs-validation/5_customization/Overview.tsx +++ b/docs-validation/5_customization/Overview.tsx @@ -4,7 +4,7 @@ const Analytics = { logError: (_: unknown) => 0 }; /** * HeaderComponent - * {@link https://sendbird.com/docs/uikit/v3/react-native/customization/overview#2-sendbirduikitcontainer-3-headercomponent} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/customization/overview#2-sendbirduikitcontainer-3-headercomponent} * */ import React, { useEffect } from 'react'; import { Pressable } from 'react-native'; @@ -43,7 +43,7 @@ const App = () => { /** * ErrorBoundary - * {@link https://sendbird.com/docs/uikit/v3/react-native/customization/overview#2-sendbirduikitcontainer-3-errorboundary} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/customization/overview#2-sendbirduikitcontainer-3-errorboundary} * */ import { View } from 'react-native'; // import { Text, Button } from '@sendbird/uikit-react-native-foundation'; diff --git a/docs-validation/6_themes/Overview.tsx b/docs-validation/6_themes/Overview.tsx index 6f707846d..64e70319b 100644 --- a/docs-validation/6_themes/Overview.tsx +++ b/docs-validation/6_themes/Overview.tsx @@ -12,7 +12,7 @@ const CustomPalette: UIKitPalette = Palette; /** * Themes - * {@link https://sendbird.com/docs/uikit/v3/react-native/themes/overview#1-themes} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/themes/overview#1-themes} * */ import { useColorScheme, View, Text } from 'react-native'; @@ -34,7 +34,7 @@ const App = () => { /** * UIKitTheme - * {@link https://sendbird.com/docs/uikit/v3/react-native/themes/overview#2-uikittheme} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/themes/overview#2-uikittheme} * */ function colorScheme(_: UIKitColorScheme) { switch (_) { @@ -53,7 +53,7 @@ function theme(_: UIKitTheme) { /** * How to use - * {@link https://sendbird.com/docs/uikit/v3/react-native/themes/overview#2-how-to-use} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/themes/overview#2-how-to-use} * */ import { useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; @@ -69,7 +69,7 @@ const Component = () => { /** * Customize the theme - * {@link https://sendbird.com/docs/uikit/v3/react-native/themes/overview#2-customize-the-theme} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/themes/overview#2-customize-the-theme} * */ // import { DarkUIKitTheme, createTheme } from '@sendbird/uikit-react-native-foundation'; diff --git a/docs-validation/7_resources/ColorResource.tsx b/docs-validation/7_resources/ColorResource.tsx index c32c42d2e..60dcb79f2 100644 --- a/docs-validation/7_resources/ColorResource.tsx +++ b/docs-validation/7_resources/ColorResource.tsx @@ -10,7 +10,7 @@ import { /** * UIKitPalette - * {@link https://sendbird.com/docs/uikit/v3/react-native/resources/color-resource#2-uikitpalette} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/resources/color-resource#2-uikitpalette} * */ import type { UIKitPalette } from '@sendbird/uikit-react-native-foundation'; @@ -63,7 +63,7 @@ const Palette: UIKitPalette = { /** * UIKitColors - * {@link https://sendbird.com/docs/uikit/v3/react-native/resources/color-resource#2-uikitpalette} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/resources/color-resource#2-uikitpalette} * */ function uikitColors(_: UIKitColors) { const { @@ -99,7 +99,7 @@ const Component = () => { /** * How to use - * {@link https://sendbird.com/docs/uikit/v3/react-native/resources/color-resource#2-how-to-use} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/resources/color-resource#2-how-to-use} * */ // import { useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; // import { View, Text } from 'react-native'; @@ -117,7 +117,7 @@ const Component2 = () => { /** * Customize with default themes - * {@link https://sendbird.com/docs/uikit/v3/react-native/resources/color-resource#2-customize-the-colors-3-customize-with-default-themes} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/resources/color-resource#2-customize-the-colors-3-customize-with-default-themes} * */ // import { LightUIKitTheme, Palette } from '@sendbird/uikit-react-native-foundation'; @@ -148,7 +148,7 @@ LightUIKitTheme.colors.ui.button.contained = { /** * Customize the createTheme() - * {@link https://sendbird.com/docs/uikit/v3/react-native/resources/color-resource#2-customize-the-colors-3-customize-with-createtheme-} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/resources/color-resource#2-customize-the-colors-3-customize-with-createtheme-} * */ import { SendbirdUIKitContainer } from '@sendbird/uikit-react-native'; // import { createTheme } from '@sendbird/uikit-react-native-foundation'; diff --git a/docs-validation/7_resources/IconResource.tsx b/docs-validation/7_resources/IconResource.tsx index 447564f54..464c7e61c 100644 --- a/docs-validation/7_resources/IconResource.tsx +++ b/docs-validation/7_resources/IconResource.tsx @@ -5,7 +5,7 @@ import { Icon } from '@sendbird/uikit-react-native-foundation'; /** * Icon component - * {@link https://sendbird.com/docs/uikit/v3/react-native/resources/icon-resource#2-how-to-use-3-icon-component} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/resources/icon-resource#2-how-to-use-3-icon-component} * */ // import { Pressable } from 'react-native'; // import { Icon } from '@sendbird/uikit-react-native-foundation'; @@ -21,7 +21,7 @@ const CameraButton = (props: object) => { /** * Icon.Assets - * {@link https://sendbird.com/docs/uikit/v3/react-native/resources/icon-resource#2-how-to-use-3-icon-assets} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/resources/icon-resource#2-how-to-use-3-icon-assets} * */ // import { Image, Pressable } from 'react-native'; // import { Icon } from '@sendbird/uikit-react-native-foundation'; @@ -37,7 +37,7 @@ const CameraButton2 = (props: object) => { /** * Customize the icons - * {@link https://sendbird.com/docs/uikit/v3/react-native/resources/icon-resource#2-customize-the-icons} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/resources/icon-resource#2-customize-the-icons} * */ // import { Icon } from '@sendbird/uikit-react-native-foundation'; diff --git a/docs-validation/7_resources/StringResource.tsx b/docs-validation/7_resources/StringResource.tsx index b755e479f..7ec370e62 100644 --- a/docs-validation/7_resources/StringResource.tsx +++ b/docs-validation/7_resources/StringResource.tsx @@ -4,7 +4,7 @@ const Navigations = () => <>; /** * Customize the StringSet - * {@link https://sendbird.com/docs/uikit/v3/react-native/resources/string-resource#2-customize-the-stringset} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/resources/string-resource#2-customize-the-stringset} * */ import { View } from 'react-native'; import dateLocale from 'date-fns/locale/ko'; diff --git a/docs-validation/7_resources/TypographyResource.tsx b/docs-validation/7_resources/TypographyResource.tsx index 0039d0aa4..3a1aca347 100644 --- a/docs-validation/7_resources/TypographyResource.tsx +++ b/docs-validation/7_resources/TypographyResource.tsx @@ -3,7 +3,7 @@ import { Text, createTheme } from '@sendbird/uikit-react-native-foundation'; /** * Text component - * {@link https://sendbird.com/docs/uikit/v3/react-native/resources/typography-resource#2-how-to-use-3-text-component} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/resources/typography-resource#2-how-to-use-3-text-component} * */ const TextList = () => { return ( @@ -21,7 +21,7 @@ const TextList = () => { /** * Typography property - * {@link https://sendbird.com/docs/uikit/v3/react-native/resources/typography-resource#2-how-to-use-3-typography-property} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/resources/typography-resource#2-how-to-use-3-typography-property} * */ import { useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; // import { Text } from 'react-native'; @@ -35,7 +35,7 @@ const Component = () => { /** * Customize with default themes - * {@link https://sendbird.com/docs/uikit/v3/react-native/resources/typography-resource#2-customize-the-typography-3-customize-with-default-themes} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/resources/typography-resource#2-customize-the-typography-3-customize-with-default-themes} * */ import { LightUIKitTheme, createTypography } from '@sendbird/uikit-react-native-foundation'; @@ -63,7 +63,7 @@ LightUIKitTheme.typography.h1 = {}; /** * Customize with createTheme() - * {@link https://sendbird.com/docs/uikit/v3/react-native/resources/typography-resource#2-customize-the-typography-3-customize-with-createtheme-} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/resources/typography-resource#2-customize-the-typography-3-customize-with-createtheme-} * */ // import { createTheme, LightUIKitTheme } from '@sendbird/uikit-react-native-foundation'; diff --git a/docs-validation/8_beta-features/MessageSearch.tsx b/docs-validation/8_beta-features/MessageSearch.tsx index 149a2d2f5..e8859fa82 100644 --- a/docs-validation/8_beta-features/MessageSearch.tsx +++ b/docs-validation/8_beta-features/MessageSearch.tsx @@ -24,7 +24,7 @@ const App = () => { /** * Customize the UI for message search - * {@link https://sendbird.com/docs/uikit/v3/react-native/beta-features/message-search#2-customize-the-ui-for-message-search} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/beta-features/message-search#2-customize-the-ui-for-message-search} * */ const MessageSearchFragment = createMessageSearchFragment(); @@ -42,7 +42,7 @@ const MessageSearchScreen = () => { /** * String resource - * {@link https://sendbird.com/docs/uikit/v3/react-native/features/message-search#2-customize-the-ui-for-message-search-3-string-resource} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/features/message-search#2-customize-the-ui-for-message-search-3-string-resource} * */ function _stringResource(str: StringSet) { str.MESSAGE_SEARCH.HEADER_INPUT_PLACEHOLDER @@ -57,7 +57,7 @@ function _stringResource(str: StringSet) { /** * Icon resource - * {@link https://sendbird.com/docs/uikit/v3/react-native/features/message-search#2-customize-the-ui-for-message-search-3-icon-resource} + * {@link https://sendbird.com/docs/chat/uikit/v3/react-native/features/message-search#2-customize-the-ui-for-message-search-3-icon-resource} * */ Icon.Assets['photo'] = require('your_icons/photo_icon.png'); Icon.Assets['play'] = require('your_icons/play_icon.png'); diff --git a/package.json b/package.json index ee57b6767..4842ea216 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "bump:minor": "lerna version minor --no-git-tag-version", "bump:patch": "lerna version patch --no-git-tag-version", "deploy:npm": "lerna publish from-git", + "deploy:local": "lerna run build && lerna run publish:local", "make:feat": "yarn workspace @sendbird/uikit-react-native create-domain" }, "devDependencies": { @@ -63,7 +64,8 @@ "react-native-safe-area-context": "^3.3.2", "react-test-renderer": "^17.0.2", "typedoc": "^0.25.3", - "typescript": "5.2.2" + "typescript": "5.2.2", + "yalc": "^1.0.0-pre.53" }, "jest": { "preset": "react-native", @@ -96,7 +98,7 @@ ] }, "resolutions": { - "@sendbird/chat": "4.9.8", + "@sendbird/chat": "4.9.10", "@types/react": "^18", "@types/react-native": "^0.67" } diff --git a/packages/uikit-chat-hooks/package.json b/packages/uikit-chat-hooks/package.json index 825536035..eda95e3f9 100644 --- a/packages/uikit-chat-hooks/package.json +++ b/packages/uikit-chat-hooks/package.json @@ -26,7 +26,8 @@ "test": "jest", "build": "bob build", "clean": "del lib", - "publish:next": "npm publish --tag next" + "publish:next": "npm publish --tag next", + "publish:local": "yalc publish" }, "repository": { "type": "git", diff --git a/packages/uikit-react-native-foundation/package.json b/packages/uikit-react-native-foundation/package.json index 5ebb2b3bd..e707cbbf9 100644 --- a/packages/uikit-react-native-foundation/package.json +++ b/packages/uikit-react-native-foundation/package.json @@ -29,6 +29,7 @@ "build": "bob build", "clean": "del lib", "publish:next": "npm publish --tag next", + "publish:local": "yalc publish", "generate-icons": "node src/assets/bundle-icons.js" }, "repository": { diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off-filled.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off-filled.png new file mode 100644 index 000000000..d733b17e7 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off-filled.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off-filled@2x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off-filled@2x.png new file mode 100644 index 000000000..bbf6a8555 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off-filled@2x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off-filled@3x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off-filled@3x.png new file mode 100644 index 000000000..5142f0783 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off-filled@3x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off.png new file mode 100644 index 000000000..8c347d9c5 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off@2x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off@2x.png new file mode 100644 index 000000000..137516558 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off@2x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off@3x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off@3x.png new file mode 100644 index 000000000..536b8ccd7 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-off@3x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on-filled.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on-filled.png new file mode 100644 index 000000000..f2a761336 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on-filled.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on-filled@2x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on-filled@2x.png new file mode 100644 index 000000000..d7c893523 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on-filled@2x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on-filled@3x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on-filled@3x.png new file mode 100644 index 000000000..d4e5910ed Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on-filled@3x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on.png new file mode 100644 index 000000000..c1631de50 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on@2x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on@2x.png new file mode 100644 index 000000000..493affeab Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on@2x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on@3x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on@3x.png new file mode 100644 index 000000000..2844da276 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-audio-on@3x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-pause.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-pause.png new file mode 100644 index 000000000..b55829037 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-pause.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-pause@2x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-pause@2x.png new file mode 100644 index 000000000..f4a2e9a8b Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-pause@2x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-pause@3x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-pause@3x.png new file mode 100644 index 000000000..d2def7b67 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-pause@3x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-recording.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-recording.png new file mode 100644 index 000000000..9c7d62cd3 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-recording.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-recording@2x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-recording@2x.png new file mode 100644 index 000000000..ab6b6fb7c Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-recording@2x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-recording@3x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-recording@3x.png new file mode 100644 index 000000000..ec3f1a33f Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-recording@3x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-stop.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-stop.png new file mode 100644 index 000000000..200bd6c48 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-stop.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-stop@2x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-stop@2x.png new file mode 100644 index 000000000..8a0c7bdd7 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-stop@2x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-stop@3x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-stop@3x.png new file mode 100644 index 000000000..ad74026b7 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-stop@3x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/index.ts b/packages/uikit-react-native-foundation/src/assets/icon/index.ts index c8401b31a..7943fea40 100644 --- a/packages/uikit-react-native-foundation/src/assets/icon/index.ts +++ b/packages/uikit-react-native-foundation/src/assets/icon/index.ts @@ -4,6 +4,10 @@ const IconAssets = { 'add': require('./icon-add.png'), 'archive': require('./icon-archive.png'), 'arrow-left': require('./icon-arrow-left.png'), + 'audio-off-filled': require('./icon-audio-off-filled.png'), + 'audio-off': require('./icon-audio-off.png'), + 'audio-on-filled': require('./icon-audio-on-filled.png'), + 'audio-on': require('./icon-audio-on.png'), 'ban': require('./icon-ban.png'), 'broadcast': require('./icon-broadcast.png'), 'camera': require('./icon-camera.png'), @@ -40,12 +44,14 @@ const IconAssets = { 'notifications-off-filled': require('./icon-notifications-off-filled.png'), 'notifications': require('./icon-notifications.png'), 'operator': require('./icon-operator.png'), + 'pause': require('./icon-pause.png'), 'photo': require('./icon-photo.png'), 'play': require('./icon-play.png'), 'plus': require('./icon-plus.png'), 'question': require('./icon-question.png'), 'radio-off': require('./icon-radio-off.png'), 'radio-on': require('./icon-radio-on.png'), + 'recording': require('./icon-recording.png'), 'refresh': require('./icon-refresh.png'), 'remove': require('./icon-remove.png'), 'reply-filled': require('./icon-reply-filled.png'), @@ -54,6 +60,7 @@ const IconAssets = { 'send': require('./icon-send.png'), 'settings-filled': require('./icon-settings-filled.png'), 'spinner': require('./icon-spinner.png'), + 'stop': require('./icon-stop.png'), 'streaming': require('./icon-streaming.png'), 'supergroup': require('./icon-supergroup.png'), 'theme': require('./icon-theme.png'), diff --git a/packages/uikit-react-native-foundation/src/components/Box/index.tsx b/packages/uikit-react-native-foundation/src/components/Box/index.tsx index dc8badc06..98e76ae07 100644 --- a/packages/uikit-react-native-foundation/src/components/Box/index.tsx +++ b/packages/uikit-react-native-foundation/src/components/Box/index.tsx @@ -45,7 +45,7 @@ const Box = ({ style, children, ...props }: BoxProps) => { const boxStyle = useBoxStyle(props); return ( - + {children} ); diff --git a/packages/uikit-react-native-foundation/src/components/Icon/index.tsx b/packages/uikit-react-native-foundation/src/components/Icon/index.tsx index b51bafa9e..28ea9c1c4 100644 --- a/packages/uikit-react-native-foundation/src/components/Icon/index.tsx +++ b/packages/uikit-react-native-foundation/src/components/Icon/index.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from 'react'; +import React from 'react'; import { Image, ImageStyle, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; import { FileType, convertFileTypeToMessageType, getFileIconFromMessageType } from '@sendbird/uikit-utils'; @@ -17,10 +17,8 @@ type Props = { style?: StyleProp; containerStyle?: StyleProp; }; -const Icon: ((props: Props) => ReactNode) & { - Assets: typeof IconAssets; - File: typeof FileIcon; -} = ({ icon, color, size = 24, containerStyle, style }) => { + +const Icon = ({ icon, color, size = 24, containerStyle, style }: Props) => { const sizeStyle = sizeStyles[size as SizeFactor] ?? { width: size, height: size }; const { colors } = useUIKitTheme(); return ( @@ -68,6 +66,7 @@ const sizeStyles = createStyleSheet({ }, }); -Icon.Assets = IconAssets; -Icon.File = FileIcon; -export default Icon; +export default Object.assign(Icon, { + Assets: IconAssets, + File: FileIcon, +}); diff --git a/packages/uikit-react-native-foundation/src/components/Modal/index.tsx b/packages/uikit-react-native-foundation/src/components/Modal/index.tsx index 3cbe04667..fc798390c 100644 --- a/packages/uikit-react-native-foundation/src/components/Modal/index.tsx +++ b/packages/uikit-react-native-foundation/src/components/Modal/index.tsx @@ -86,7 +86,7 @@ const Modal = ({ void; onLongPress?: (event: GestureResponderEvent) => void; delayLongPress?: number; diff --git a/packages/uikit-react-native-foundation/src/components/ProgressBar/index.tsx b/packages/uikit-react-native-foundation/src/components/ProgressBar/index.tsx new file mode 100644 index 000000000..53a179765 --- /dev/null +++ b/packages/uikit-react-native-foundation/src/components/ProgressBar/index.tsx @@ -0,0 +1,72 @@ +import React, { ReactNode, useEffect, useRef } from 'react'; +import { Animated, Easing, StyleSheet, ViewStyle } from 'react-native'; + +import { NOOP } from '@sendbird/uikit-utils'; + +import useUIKitTheme from '../../theme/useUIKitTheme'; +import Box from '../Box'; + +type Props = { + current: number; + total: number; + trackColor?: string; + barColor?: string; + overlay?: ReactNode | undefined; + style?: ViewStyle; +}; +const ProgressBar = ({ current = 100, total = 100, trackColor, barColor, overlay, style }: Props) => { + const { colors } = useUIKitTheme(); + + const uiColors = { + track: trackColor ?? colors.primary, + bar: barColor ?? colors.onBackground01, + }; + + const progress = useRef(new Animated.Value(0)).current; + const percent = current / total; + const stopped = percent === 0; + + useEffect(() => { + if (!Number.isNaN(percent)) { + const animation = Animated.timing(progress, { + toValue: stopped ? 0 : percent, + duration: stopped ? 0 : 100, + useNativeDriver: false, + easing: Easing.linear, + }); + + animation.start(); + return () => animation.stop(); + } + + return NOOP; + }, [percent]); + + return ( + + + {overlay} + + ); +}; + +export default ProgressBar; diff --git a/packages/uikit-react-native-foundation/src/index.ts b/packages/uikit-react-native-foundation/src/index.ts index e9d4ae611..47d860989 100644 --- a/packages/uikit-react-native-foundation/src/index.ts +++ b/packages/uikit-react-native-foundation/src/index.ts @@ -9,6 +9,7 @@ export { default as Image } from './components/Image'; export { default as ImageWithPlaceholder } from './components/ImageWithPlaceholder'; export { default as Modal } from './components/Modal'; export { default as PressBox } from './components/PressBox'; +export { default as ProgressBar } from './components/ProgressBar'; export { default as RegexText } from './components/RegexText'; export type { RegexTextPattern } from './components/RegexText'; export { default as Switch } from './components/Switch'; diff --git a/packages/uikit-react-native-foundation/src/styles/createScaleFactor.ts b/packages/uikit-react-native-foundation/src/styles/createScaleFactor.ts index 1e6eb8dcc..363dcaf58 100644 --- a/packages/uikit-react-native-foundation/src/styles/createScaleFactor.ts +++ b/packages/uikit-react-native-foundation/src/styles/createScaleFactor.ts @@ -10,9 +10,9 @@ const createScaleFactor = (deviceWidth = DESIGNED_DEVICE_WIDTH) => { return (dp: number) => PixelRatio.roundToNearestPixel(dp * rangedRatio); }; -createScaleFactor.updateScaleFactor = (scaleFactor: (dp: number) => number) => { - DEFAULT_SCALE_FACTOR = scaleFactor; -}; - export let DEFAULT_SCALE_FACTOR = createScaleFactor(); -export default createScaleFactor; +export default Object.assign(createScaleFactor, { + updateScaleFactor: (scaleFactor: (dp: number) => number) => { + DEFAULT_SCALE_FACTOR = scaleFactor; + }, +}); diff --git a/packages/uikit-react-native-foundation/src/theme/DarkUIKitTheme.ts b/packages/uikit-react-native-foundation/src/theme/DarkUIKitTheme.ts index 4541d7d50..ed0b5a9ad 100644 --- a/packages/uikit-react-native-foundation/src/theme/DarkUIKitTheme.ts +++ b/packages/uikit-react-native-foundation/src/theme/DarkUIKitTheme.ts @@ -3,41 +3,6 @@ import createTheme from './createTheme'; const DarkUIKitTheme = createTheme({ colorScheme: 'dark', colors: (palette) => { - const groupChannelMessage = { - incoming: { - enabled: { - textMsg: palette.onBackgroundDark01, - textEdited: palette.onBackgroundDark02, - textTime: palette.onBackgroundDark03, - textSenderName: palette.onBackgroundDark02, - background: palette.background400, - }, - pressed: { - textMsg: palette.onBackgroundDark01, - textEdited: palette.onBackgroundDark02, - textTime: palette.onBackgroundDark03, - textSenderName: palette.onBackgroundDark02, - background: palette.primary500, - }, - }, - outgoing: { - enabled: { - textMsg: palette.onBackgroundLight01, - textEdited: palette.onBackgroundLight02, - textTime: palette.onBackgroundDark03, - textSenderName: palette.transparent, - background: palette.primary200, - }, - pressed: { - textMsg: palette.onBackgroundLight01, - textEdited: palette.onBackgroundLight02, - textTime: palette.onBackgroundDark03, - textSenderName: palette.transparent, - background: palette.primary300, - }, - }, - }; - return { primary: palette.primary200, secondary: palette.secondary200, @@ -149,7 +114,6 @@ const DarkUIKitTheme = createTheme({ }, }, }, - message: groupChannelMessage, dateSeparator: { default: { none: { @@ -158,7 +122,60 @@ const DarkUIKitTheme = createTheme({ }, }, }, - groupChannelMessage, + groupChannelMessage: { + incoming: { + enabled: { + textMsg: palette.onBackgroundDark01, + textEdited: palette.onBackgroundDark02, + textTime: palette.onBackgroundDark03, + textSenderName: palette.onBackgroundDark02, + background: palette.background400, + textVoicePlaytime: palette.onBackgroundDark01, + voiceSpinner: palette.primary200, + voiceProgressTrack: palette.background400, + voiceActionIcon: palette.primary200, + voiceActionIconBackground: palette.background700, + }, + pressed: { + textMsg: palette.onBackgroundDark01, + textEdited: palette.onBackgroundDark02, + textTime: palette.onBackgroundDark03, + textSenderName: palette.onBackgroundDark02, + background: palette.primary500, + textVoicePlaytime: palette.onBackgroundDark01, + voiceSpinner: palette.primary200, + voiceProgressTrack: palette.background400, + voiceActionIcon: palette.primary200, + voiceActionIconBackground: palette.background700, + }, + }, + outgoing: { + enabled: { + textMsg: palette.onBackgroundLight01, + textEdited: palette.onBackgroundLight02, + textTime: palette.onBackgroundDark03, + textSenderName: palette.transparent, + background: palette.primary200, + textVoicePlaytime: palette.onBackgroundLight01, + voiceSpinner: palette.primary300, + voiceProgressTrack: palette.primary200, + voiceActionIcon: palette.primary200, + voiceActionIconBackground: palette.background700, + }, + pressed: { + textMsg: palette.onBackgroundLight01, + textEdited: palette.onBackgroundLight02, + textTime: palette.onBackgroundDark03, + textSenderName: palette.transparent, + background: palette.primary300, + textVoicePlaytime: palette.onBackgroundLight01, + voiceSpinner: palette.primary300, + voiceProgressTrack: palette.primary200, + voiceActionIcon: palette.primary200, + voiceActionIconBackground: palette.background700, + }, + }, + }, groupChannelPreview: { default: { none: { @@ -243,6 +260,32 @@ const DarkUIKitTheme = createTheme({ }, }, }, + voiceMessageInput: { + default: { + active: { + textCancel: palette.primary200, + textTime: palette.onBackgroundLight01, + background: palette.background600, + actionIcon: palette.onBackgroundDark01, + actionIconBackground: palette.background500, + sendIcon: palette.onBackgroundLight01, + sendIconBackground: palette.primary200, + progressTrack: palette.primary200, + recording: palette.error300, + }, + inactive: { + textCancel: palette.primary200, + textTime: palette.onBackgroundDark03, + background: palette.background600, + actionIcon: palette.onBackgroundDark01, + actionIconBackground: palette.background500, + sendIcon: palette.onBackgroundDark04, + sendIconBackground: palette.background500, + progressTrack: palette.background400, + recording: palette.error200, + }, + }, + }, }, }; }, diff --git a/packages/uikit-react-native-foundation/src/theme/LightUIKitTheme.ts b/packages/uikit-react-native-foundation/src/theme/LightUIKitTheme.ts index a1a034e61..0466b3277 100644 --- a/packages/uikit-react-native-foundation/src/theme/LightUIKitTheme.ts +++ b/packages/uikit-react-native-foundation/src/theme/LightUIKitTheme.ts @@ -3,41 +3,6 @@ import createTheme from './createTheme'; const LightUIKitTheme = createTheme({ colorScheme: 'light', colors: (palette) => { - const groupChannelMessage = { - incoming: { - enabled: { - textMsg: palette.onBackgroundLight01, - textEdited: palette.onBackgroundLight02, - textTime: palette.onBackgroundLight03, - textSenderName: palette.onBackgroundLight02, - background: palette.background100, - }, - pressed: { - textMsg: palette.onBackgroundLight01, - textEdited: palette.onBackgroundLight02, - textTime: palette.onBackgroundLight03, - textSenderName: palette.onBackgroundLight02, - background: palette.primary100, - }, - }, - outgoing: { - enabled: { - textMsg: palette.onBackgroundDark01, - textEdited: palette.onBackgroundDark02, - textTime: palette.onBackgroundLight03, - textSenderName: palette.transparent, - background: palette.primary300, - }, - pressed: { - textMsg: palette.onBackgroundDark01, - textEdited: palette.onBackgroundDark02, - textTime: palette.onBackgroundLight03, - textSenderName: palette.transparent, - background: palette.primary400, - }, - }, - }; - return { primary: palette.primary300, secondary: palette.secondary300, @@ -149,7 +114,6 @@ const LightUIKitTheme = createTheme({ }, }, }, - message: groupChannelMessage, dateSeparator: { default: { none: { @@ -158,7 +122,60 @@ const LightUIKitTheme = createTheme({ }, }, }, - groupChannelMessage, + groupChannelMessage: { + incoming: { + enabled: { + textMsg: palette.onBackgroundLight01, + textEdited: palette.onBackgroundLight02, + textTime: palette.onBackgroundLight03, + textSenderName: palette.onBackgroundLight02, + background: palette.background100, + textVoicePlaytime: palette.onBackgroundLight01, + voiceSpinner: palette.primary300, + voiceProgressTrack: palette.background100, + voiceActionIcon: palette.primary300, + voiceActionIconBackground: palette.background50, + }, + pressed: { + textMsg: palette.onBackgroundLight01, + textEdited: palette.onBackgroundLight02, + textTime: palette.onBackgroundLight03, + textSenderName: palette.onBackgroundLight02, + background: palette.primary100, + textVoicePlaytime: palette.onBackgroundLight01, + voiceSpinner: palette.primary300, + voiceProgressTrack: palette.background100, + voiceActionIcon: palette.primary300, + voiceActionIconBackground: palette.background50, + }, + }, + outgoing: { + enabled: { + textMsg: palette.onBackgroundDark01, + textEdited: palette.onBackgroundDark02, + textTime: palette.onBackgroundLight03, + textSenderName: palette.transparent, + background: palette.primary300, + textVoicePlaytime: palette.onBackgroundDark01, + voiceSpinner: palette.primary200, + voiceProgressTrack: palette.primary300, + voiceActionIcon: palette.primary300, + voiceActionIconBackground: palette.background50, + }, + pressed: { + textMsg: palette.onBackgroundDark01, + textEdited: palette.onBackgroundDark02, + textTime: palette.onBackgroundLight03, + textSenderName: palette.transparent, + background: palette.primary400, + textVoicePlaytime: palette.onBackgroundDark01, + voiceSpinner: palette.primary200, + voiceProgressTrack: palette.primary300, + voiceActionIcon: palette.primary300, + voiceActionIconBackground: palette.background50, + }, + }, + }, groupChannelPreview: { default: { none: { @@ -243,6 +260,32 @@ const LightUIKitTheme = createTheme({ }, }, }, + voiceMessageInput: { + default: { + active: { + textCancel: palette.primary300, + textTime: palette.onBackgroundDark01, + background: palette.background50, + actionIcon: palette.onBackgroundLight01, + actionIconBackground: palette.background100, + sendIcon: palette.onBackgroundDark01, + sendIconBackground: palette.primary300, + progressTrack: palette.primary300, + recording: palette.error300, + }, + inactive: { + textCancel: palette.primary300, + textTime: palette.onBackgroundLight03, + background: palette.background50, + actionIcon: palette.onBackgroundLight01, + actionIconBackground: palette.background100, + sendIcon: palette.onBackgroundLight04, + sendIconBackground: palette.background100, + progressTrack: palette.background100, + recording: palette.error300, + }, + }, + }, }, }; }, diff --git a/packages/uikit-react-native-foundation/src/types.ts b/packages/uikit-react-native-foundation/src/types.ts index 6b19e4288..f9cd9d426 100644 --- a/packages/uikit-react-native-foundation/src/types.ts +++ b/packages/uikit-react-native-foundation/src/types.ts @@ -43,7 +43,8 @@ export type Component = | 'ProfileCard' | 'Reaction' | 'OpenChannelMessage' - | 'OpenChannelPreview'; + | 'OpenChannelPreview' + | 'VoiceMessageInput'; export type GetColorTree< Tree extends { @@ -74,6 +75,7 @@ export type ComponentColorTree = GetColorTree<{ Reaction: 'default' | 'rounded'; OpenChannelMessage: 'default'; OpenChannelPreview: 'default'; + VoiceMessageInput: 'default'; }; State: { Header: 'none'; @@ -89,6 +91,7 @@ export type ComponentColorTree = GetColorTree<{ Reaction: 'enabled' | 'selected'; OpenChannelMessage: 'enabled' | 'pressed'; OpenChannelPreview: 'none'; + VoiceMessageInput: 'active' | 'inactive'; }; ColorPart: { Header: 'background' | 'borderBottom'; @@ -98,7 +101,17 @@ export type ComponentColorTree = GetColorTree<{ Badge: 'text' | 'background'; Placeholder: 'content' | 'highlight'; DateSeparator: 'text' | 'background'; - GroupChannelMessage: 'textMsg' | 'textEdited' | 'textSenderName' | 'textTime' | 'background'; + GroupChannelMessage: + | 'textMsg' + | 'textEdited' + | 'textSenderName' + | 'textTime' + | 'textVoicePlaytime' + | 'background' + | 'voiceSpinner' + | 'voiceProgressTrack' + | 'voiceActionIcon' + | 'voiceActionIconBackground'; GroupChannelPreview: | 'textTitle' | 'textTitleCaption' @@ -128,6 +141,16 @@ export type ComponentColorTree = GetColorTree<{ | 'background' | 'coverBackground' | 'separator'; + VoiceMessageInput: + | 'textCancel' + | 'textTime' + | 'background' + | 'actionIcon' + | 'actionIconBackground' + | 'sendIcon' + | 'sendIconBackground' + | 'progressTrack' + | 'recording'; }; }>; export type ComponentColors = { @@ -175,6 +198,7 @@ export interface UIKitColors { reaction: ComponentColors<'Reaction'>; openChannelMessage: ComponentColors<'OpenChannelMessage'>; openChannelPreview: ComponentColors<'OpenChannelPreview'>; + voiceMessageInput: ComponentColors<'VoiceMessageInput'>; }; } diff --git a/packages/uikit-react-native-foundation/src/ui/Avatar/index.tsx b/packages/uikit-react-native-foundation/src/ui/Avatar/index.tsx index d900937cb..e819ed179 100644 --- a/packages/uikit-react-native-foundation/src/ui/Avatar/index.tsx +++ b/packages/uikit-react-native-foundation/src/ui/Avatar/index.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useState } from 'react'; +import React, { useState } from 'react'; import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; import { conditionChaining } from '@sendbird/uikit-utils'; @@ -10,10 +10,6 @@ import useUIKitTheme from '../../theme/useUIKitTheme'; import AvatarGroup from './AvatarGroup'; import AvatarIcon from './AvatarIcon'; -type SubComponents = { - Group: typeof AvatarGroup; - Icon: typeof AvatarIcon; -}; type Props = { uri?: string; size?: number; @@ -21,13 +17,8 @@ type Props = { muted?: boolean; containerStyle?: StyleProp; }; -const Avatar: ((props: Props) => ReactNode) & SubComponents = ({ - uri, - square, - muted = false, - size = 56, - containerStyle, -}) => { + +const Avatar = ({ uri, square, muted = false, size = 56, containerStyle }: Props) => { const { colors, palette } = useUIKitTheme(); const [loadFailure, setLoadFailure] = useState(false); @@ -74,6 +65,7 @@ const styles = createStyleSheet({ }, }); -Avatar.Group = AvatarGroup; -Avatar.Icon = AvatarIcon; -export default Avatar; +export default Object.assign(Avatar, { + Group: AvatarGroup, + Icon: AvatarIcon, +}); diff --git a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx new file mode 100644 index 000000000..2a9924c43 --- /dev/null +++ b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useState } from 'react'; + +import type { SendbirdFileMessage } from '@sendbird/uikit-utils'; +import { millsToMSS } from '@sendbird/uikit-utils'; + +import Box from '../../components/Box'; +import Icon from '../../components/Icon'; +import PressBox from '../../components/PressBox'; +import ProgressBar from '../../components/ProgressBar'; +import Text from '../../components/Text'; +import createStyleSheet from '../../styles/createStyleSheet'; +import useUIKitTheme from '../../theme/useUIKitTheme'; +import LoadingSpinner from '../LoadingSpinner'; +import MessageContainer from './MessageContainer'; +import type { GroupChannelMessageProps } from './index'; + +export type VoiceFileMessageState = { + status: 'preparing' | 'playing' | 'paused'; + currentTime: number; + duration: number; +}; + +type Props = GroupChannelMessageProps< + SendbirdFileMessage, + { + durationMetaArrayKey?: string; + onUnmount: () => void; + } +>; +const VoiceFileMessage = (props: Props) => { + const { + onLongPress, + variant = 'incoming', + onToggleVoiceMessage, + message, + durationMetaArrayKey = 'KEY_VOICE_MESSAGE_DURATION', + onUnmount, + } = props; + + const { colors } = useUIKitTheme(); + + const [state, setState] = useState(() => { + const meta = message.metaArrays.find((it) => it.key === durationMetaArrayKey); + const value = meta?.value?.[0]; + const initialDuration = value ? parseInt(value, 10) : 0; + return { + status: 'paused', + currentTime: 0, + duration: initialDuration, + }; + }); + + useEffect(() => { + return () => { + onUnmount(); + }; + }, []); + + const uiColors = colors.ui.groupChannelMessage[variant]; + const remainingTime = state.duration - state.currentTime; + + return ( + + + onToggleVoiceMessage?.(state, setState)} onLongPress={onLongPress}> + + {state.status === 'preparing' ? ( + + ) : ( + + )} + + {millsToMSS(state.currentTime === 0 ? state.duration : remainingTime)} + + + } + /> + + {props.children} + + + ); +}; + +const styles = createStyleSheet({ + container: { + borderRadius: 16, + overflow: 'hidden', + }, + image: { + maxWidth: 240, + width: 240, + height: 160, + borderRadius: 16, + overflow: 'hidden', + }, +}); + +export default VoiceFileMessage; diff --git a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.unknown.tsx b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.unknown.tsx index 07439ab96..5f8288257 100644 --- a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.unknown.tsx +++ b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.unknown.tsx @@ -14,7 +14,7 @@ const UnknownMessage = (props: GroupChannelMessageProps) => { const { variant = 'incoming' } = props; const { colors } = useUIKitTheme(); - const color = colors.ui.groupChannelMessage['incoming']; + const color = colors.ui.groupChannelMessage[variant]; const titleColor = variant === 'incoming' ? colors.onBackground01 : colors.onBackgroundReverse01; const descColor = variant === 'incoming' ? colors.onBackground02 : colors.onBackgroundReverse02; diff --git a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/index.ts b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/index.ts index af071158f..bc46a214e 100644 --- a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/index.ts +++ b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/index.ts @@ -6,6 +6,8 @@ import AdminMessage from './Message.admin'; import FileMessage from './Message.file'; import ImageFileMessage from './Message.file.image'; import VideoFileMessage from './Message.file.video'; +import VoiceFileMessage from './Message.file.voice'; +import type { VoiceFileMessageState } from './Message.file.voice'; import UnknownMessage from './Message.unknown'; import UserMessage from './Message.user'; import OpenGraphUser from './Message.user.og'; @@ -34,6 +36,10 @@ export type GroupChannelMessageProps void; onPressURL?: (url: string) => void; onPressMentionedUser?: (mentionedUser?: SendbirdUser) => void; + onToggleVoiceMessage?: ( + state: VoiceFileMessageState, + setState: React.Dispatch>, + ) => Promise; } & AdditionalProps; const GroupChannelMessage = { @@ -42,6 +48,7 @@ const GroupChannelMessage = { File: FileMessage, ImageFile: ImageFileMessage, VideoFile: VideoFileMessage, + VoiceFile: VoiceFileMessage, Admin: AdminMessage, Unknown: UnknownMessage, }; diff --git a/packages/uikit-react-native-foundation/src/ui/Header/index.tsx b/packages/uikit-react-native-foundation/src/ui/Header/index.tsx index 54983724b..c9e231c28 100644 --- a/packages/uikit-react-native-foundation/src/ui/Header/index.tsx +++ b/packages/uikit-react-native-foundation/src/ui/Header/index.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from 'react'; +import React from 'react'; import { TouchableOpacity, TouchableOpacityProps, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -26,11 +26,7 @@ export type HeaderProps = BaseHeaderProps< >; const AlignMapper = { left: 'flex-start', center: 'center', right: 'flex-end' } as const; -const Header: ((props: HeaderProps) => ReactNode) & { - Button: typeof HeaderButton; - Title: typeof HeaderTitle; - Subtitle: typeof HeaderSubtitle; -} = ({ +const Header = ({ children, titleAlign, title = null, @@ -41,7 +37,7 @@ const Header: ((props: HeaderProps) => ReactNode) & { clearTitleMargin = false, clearStatusBarTopInset = false, statusBarTopInsetAs = 'padding', -}) => { +}: HeaderProps) => { const { topInset, defaultTitleAlign, defaultHeight } = useHeaderStyle(); const { colors } = useUIKitTheme(); @@ -183,7 +179,8 @@ const styles = createStyleSheet({ }, }); -Header.Button = HeaderButton; -Header.Title = HeaderTitle; -Header.Subtitle = HeaderSubtitle; -export default Header; +export default Object.assign(Header, { + Button: HeaderButton, + Title: HeaderTitle, + Subtitle: HeaderSubtitle, +}); diff --git a/packages/uikit-react-native/README.md b/packages/uikit-react-native/README.md index 228782ae9..6b4713793 100644 --- a/packages/uikit-react-native/README.md +++ b/packages/uikit-react-native/README.md @@ -78,6 +78,7 @@ Add the following permissions to your `android/app/src/main/AndroidManifest.xml` package="com.your.app"> + @@ -123,6 +124,8 @@ const App = () => { notification: NotificationService, clipboard: ClipboardService, media: MediaService, + recorder: RecorderService, + player: PlayerService, }} > {/* ... */} @@ -134,7 +137,7 @@ const App = () => { In order to implement the interfaces to your React Native app more easily, we provide various helper functions for each interface. > **NOTE**: Helper function is not required! You can implement it with native modules you're using. -> More details about PlatformService interfaces, please see [here](https://sendbird.com/docs/uikit/v3/react-native/core-components/provider/platformserviceprovider) +> More details about PlatformService interfaces, please see [here](https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/provider/platformserviceprovider) **Using React Native CLI** @@ -147,6 +150,7 @@ npm install react-native-video \ react-native-image-picker \ react-native-document-picker \ react-native-create-thumbnail \ + react-native-audio-recorder-player \ @react-native-clipboard/clipboard \ @react-native-camera-roll/camera-roll \ @react-native-firebase/app \ @@ -157,34 +161,45 @@ npx pod-install ``` ```ts -import * as ImageResizer from '@bam.tech/react-native-image-resizer'; -import { CameraRoll } from '@react-native-camera-roll/camera-roll'; import Clipboard from '@react-native-clipboard/clipboard'; +import { CameraRoll } from '@react-native-camera-roll/camera-roll'; import RNFBMessaging from '@react-native-firebase/messaging'; -import * as CreateThumbnail from 'react-native-create-thumbnail'; +import Video from 'react-native-video'; import * as DocumentPicker from 'react-native-document-picker'; import * as FileAccess from 'react-native-file-access'; import * as ImagePicker from 'react-native-image-picker'; import * as Permissions from 'react-native-permissions'; -import Video from 'react-native-video'; - -const NativeClipboardService = createNativeClipboardService(Clipboard); -const NativeNotificationService = createNativeNotificationService({ - messagingModule: RNFBMessaging, - permissionModule: Permissions, -}); -const NativeFileService = createNativeFileService({ - fsModule: FileAccess, - permissionModule: Permissions, - imagePickerModule: ImagePicker, - mediaLibraryModule: CameraRoll, - documentPickerModule: DocumentPicker, -}); -const NativeMediaService = createNativeMediaService({ - VideoComponent: Video, - thumbnailModule: CreateThumbnail, - imageResizerModule: ImageResizer, -}); +import * as CreateThumbnail from 'react-native-create-thumbnail'; +import * as ImageResizer from '@bam.tech/react-native-image-resizer'; +import * as AudioRecorderPlayer from 'react-native-audio-recorder-player'; + +const nativePlatformServices = { + clipboard: createNativeClipboardService(Clipboard), + notification: createNativeNotificationService({ + messagingModule: RNFBMessaging, + permissionModule: Permissions, + }), + file: createNativeFileService({ + imagePickerModule: ImagePicker, + documentPickerModule: DocumentPicker, + permissionModule: Permissions, + fsModule: FileAccess, + mediaLibraryModule: CameraRoll, + }), + media: createNativeMediaService({ + VideoComponent: Video, + thumbnailModule: CreateThumbnail, + imageResizerModule: ImageResizer, + }), + player: createNativePlayerService({ + audioRecorderModule: AudioRecorderPlayer, + permissionModule: Permissions, + }), + recorder: createNativeRecorderService({ + audioRecorderModule: AudioRecorderPlayer, + permissionModule: Permissions, + }), +}; ``` **Using Expo CLI** @@ -204,30 +219,38 @@ expo install expo-image-picker \ ``` ```ts -import * as ExpoAV from 'expo-av'; import * as ExpoClipboard from 'expo-clipboard'; import * as ExpoDocumentPicker from 'expo-document-picker'; import * as ExpoFS from 'expo-file-system'; -import * as ExpoImageManipulator from 'expo-image-manipulator'; import * as ExpoImagePicker from 'expo-image-picker'; import * as ExpoMediaLibrary from 'expo-media-library'; import * as ExpoNotifications from 'expo-notifications'; +import * as ExpoAV from 'expo-av'; import * as ExpoVideoThumbnail from 'expo-video-thumbnails'; +import * as ExpoImageManipulator from 'expo-image-manipulator'; -const ExpoNotificationService = createExpoNotificationService(ExpoNotifications); -const ExpoClipboardService = createExpoClipboardService(ExpoClipboard); -const ExpoFileService = createExpoFileService({ - fsModule: ExpoFS, - imagePickerModule: ExpoImagePicker, - mediaLibraryModule: ExpoMediaLibrary, - documentPickerModule: ExpoDocumentPicker, -}); -const ExpoMediaService = createExpoMediaService({ - avModule: ExpoAV, - thumbnailModule: ExpoVideoThumbnail, - imageManipulator: ExpoImageManipulator, - fsModule: ExpoFS, -}); +const expoPlatformServices = { + clipboard: createExpoClipboardService(ExpoClipboard), + notification: createExpoNotificationService(ExpoNotifications), + file: createExpoFileService({ + fsModule: ExpoFS, + imagePickerModule: ExpoImagePicker, + mediaLibraryModule: ExpoMediaLibrary, + documentPickerModule: ExpoDocumentPicker, + }), + media: createExpoMediaService({ + avModule: ExpoAV, + thumbnailModule: ExpoVideoThumbnail, + imageManipulator: ExpoImageManipulator, + fsModule: ExpoFS, + }), + player: createExpoPlayerService({ + avModule: ExpoAV, + }), + recorder: createExpoRecorderService({ + avModule: ExpoAV, + }), +}; ``` ### Local caching (required) @@ -236,7 +259,7 @@ You can implement Local caching easily. ```shell npm i @react-native-async-storage/async-storage -npx pod-isntall +npx pod-install ``` ```tsx @@ -280,7 +303,7 @@ const App = () => { ### Integration with navigation library Now you can create a screen and integrate it with a navigation library like [`react-navigation`](https://reactnavigation.org/). -See more details on [here](https://st.sendbird.com/docs/uikit/v3/react-native/introduction/screen-navigation) +See more details on [here](https://sendbird.com/docs/chat/uikit/v3/react-native/introduction/screen-navigation) The example below shows how to integrate using `react-navigation`. @@ -420,6 +443,8 @@ const App = () => { notification: NotificationService, clipboard: ClipboardService, media: MediaService, + recorder: RecorderService, + player: PlayerService, }} > @@ -429,4 +454,4 @@ const App = () => { ``` > You can use sendbird sdk using `useSendbirdChat()` hook, and you can connect or disconnect using `useConnection()` hook. -> for more details about hooks, please refer to our [docs](https://sendbird.com/docs/uikit/v3/react-native/core-components/hooks) +> for more details about hooks, please refer to our [docs](https://sendbird.com/docs/chat/uikit/v3/react-native/core-components/hooks) diff --git a/packages/uikit-react-native/package.json b/packages/uikit-react-native/package.json index 8e8c84d70..417bd552d 100644 --- a/packages/uikit-react-native/package.json +++ b/packages/uikit-react-native/package.json @@ -39,6 +39,7 @@ "build": "yarn generate-version && bob build", "clean": "del lib", "publish:next": "npm publish --tag next", + "publish:local": "yalc publish", "generate-version": "node scripts/generate-version.js ./src/version.ts", "create-domain": "node scripts/create-core-domain" }, @@ -89,6 +90,7 @@ "js-convert-case": "^4.2.0", "react": "17.0.2", "react-native": "0.67.5", + "react-native-audio-recorder-player": "^3.6.0", "react-native-builder-bob": "^0.18.0", "react-native-create-thumbnail": "^1.5.1", "react-native-document-picker": "^8.0.0", @@ -118,6 +120,7 @@ "expo-video-thumbnails": ">=6.4.0", "react": ">=17.0.2", "react-native": ">=0.65.0", + "react-native-audio-recorder-player": ">=3.6.0", "react-native-create-thumbnail": ">=1.5.1", "react-native-document-picker": ">=8.0.0", "react-native-file-access": ">=2.4.3", diff --git a/packages/uikit-react-native/src/components/ChannelInput/MessageToReplyPreview.tsx b/packages/uikit-react-native/src/components/ChannelInput/MessageToReplyPreview.tsx new file mode 100644 index 000000000..6bbfe0743 --- /dev/null +++ b/packages/uikit-react-native/src/components/ChannelInput/MessageToReplyPreview.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { TouchableOpacity, View } from 'react-native'; + +import { + Icon, + ImageWithPlaceholder, + Text, + VideoThumbnail, + createStyleSheet, + useUIKitTheme, +} from '@sendbird/uikit-react-native-foundation'; +import { + FileIcon, + SendbirdBaseMessage, + SendbirdFileMessage, + SendbirdUserMessage, + getFileIconFromMessageType, + getMessageType, + getThumbnailUriFromFileMessage, +} from '@sendbird/uikit-utils'; + +import { useLocalization, usePlatformService } from '../../hooks/useContext'; + +export type MessageToReplyPreviewProps = { + messageToReply?: SendbirdFileMessage | SendbirdUserMessage; + setMessageToReply?: (message?: undefined | SendbirdFileMessage | SendbirdUserMessage) => void; +}; + +export const MessageToReplyPreview = ({ messageToReply, setMessageToReply }: MessageToReplyPreviewProps) => { + const { colors, select, palette } = useUIKitTheme(); + const { mediaService } = usePlatformService(); + const { STRINGS } = useLocalization(); + + const getFileIconAsImage = (url: string) => { + return ; + }; + + const getFileIconAsVideoThumbnail = (url: string) => { + return ( + mediaService.getVideoThumbnail({ url: uri, timeMills: 1000 })} + /> + ); + }; + + const getFileIconAsSymbol = (icon: FileIcon) => { + return ( + + ); + }; + + const getFileIcon = (messageToReply: SendbirdBaseMessage) => { + if (messageToReply?.isFileMessage()) { + const messageType = getMessageType(messageToReply); + switch (messageType) { + case 'file.image': + return getFileIconAsImage(getThumbnailUriFromFileMessage(messageToReply)); + case 'file.video': + return getFileIconAsVideoThumbnail(getThumbnailUriFromFileMessage(messageToReply)); + case 'file.voice': + return null; + default: + return getFileIconAsSymbol(getFileIconFromMessageType(messageType)); + } + } + return null; + }; + + if (!messageToReply) return null; + + return ( + + + {getFileIcon(messageToReply)} + + + {STRINGS.LABELS.CHANNEL_INPUT_REPLY_PREVIEW_TITLE(messageToReply.sender)} + + + {STRINGS.LABELS.CHANNEL_INPUT_REPLY_PREVIEW_BODY(messageToReply)} + + + + setMessageToReply?.(undefined)}> + + + + ); +}; + +const styles = createStyleSheet({ + previewImage: { + width: 36, + height: 36, + borderRadius: 10, + marginTop: 2, + marginRight: 10, + overflow: 'hidden', + }, + messageToReplyContainer: { + flexDirection: 'row', + paddingLeft: 18, + paddingRight: 16, + paddingTop: 10, + paddingBottom: 8, + alignItems: 'center', + borderTopWidth: 1, + }, + fileIcon: { + width: 36, + height: 36, + borderRadius: 10, + marginRight: 10, + marginTop: 2, + }, + closeIcon: { + marginLeft: 4, + padding: 4, + }, +}); diff --git a/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx b/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx index cd1a53c0e..19cd66d67 100644 --- a/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx +++ b/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx @@ -8,35 +8,22 @@ import { View, } from 'react-native'; -import { MentionType } from '@sendbird/chat/message'; -import type { BottomSheetItem } from '@sendbird/uikit-react-native-foundation'; +import { MentionType, MessageMetaArray } from '@sendbird/chat/message'; import { Icon, - ImageWithPlaceholder, - Text, + Modal, TextInput, - VideoThumbnail, createStyleSheet, useAlert, useBottomSheet, useToast, useUIKitTheme, } from '@sendbird/uikit-react-native-foundation'; -import { - FileIcon, - Logger, - SendbirdBaseMessage, - SendbirdChannel, - getFileIconFromMessageType, - getMessageType, - getThumbnailUriFromFileMessage, - isImage, - shouldCompressImage, - useIIFE, -} from '@sendbird/uikit-utils'; +import { Logger, useDeferredModalState, useIIFE } from '@sendbird/uikit-utils'; +import { VOICE_MESSAGE_META_ARRAY_DURATION_KEY, VOICE_MESSAGE_META_ARRAY_MESSAGE_TYPE_KEY } from '../../constants'; +import { useChannelInputItems } from '../../hooks/useChannelInputItems'; import { useLocalization, usePlatformService, useSendbirdChat } from '../../hooks/useContext'; -import SBUError from '../../libs/SBUError'; import SBUUtils from '../../libs/SBUUtils'; import type { FileType } from '../../platform/types'; import type { MentionedUser } from '../../types'; @@ -51,6 +38,8 @@ interface SendInputProps extends ChannelInputProps { const SendInput = forwardRef(function SendInput( { + VoiceMessageInput, + MessageToReplyPreview, AttachmentsButton, onPressSendUserMessage, onPressSendFileMessage, @@ -67,12 +56,18 @@ const SendInput = forwardRef(function SendInput( }, ref, ) { + const { playerService, recorderService } = usePlatformService(); const { mentionManager, sbOptions } = useSendbirdChat(); - const { select, colors, palette } = useUIKitTheme(); const { STRINGS } = useLocalization(); const { openSheet } = useBottomSheet(); const toast = useToast(); - const { mediaService } = usePlatformService(); + + const { + onClose, + onDismiss, + visible: voiceMessageInputVisible, + setVisible: setVoiceMessageInputVisible, + } = useDeferredModalState(); const messageReplyParams = useIIFE(() => { const { groupChannel } = sbOptions.uikit; @@ -122,8 +117,35 @@ const SendInput = forwardRef(function SendInput( setMessageToReply?.(); }; + const sendVoiceMessage = (file: FileType, durationMills: number) => { + if (inputMuted) { + toast.show(STRINGS.TOAST.USER_MUTED_ERROR, 'error'); + Logger.error(STRINGS.TOAST.USER_MUTED_ERROR); + } else if (inputFrozen) { + toast.show(STRINGS.TOAST.CHANNEL_FROZEN_ERROR, 'error'); + Logger.error(STRINGS.TOAST.CHANNEL_FROZEN_ERROR); + } else { + onPressSendFileMessage({ + file, + metaArrays: [ + new MessageMetaArray({ + key: VOICE_MESSAGE_META_ARRAY_DURATION_KEY, + value: [String(durationMills)], + }), + new MessageMetaArray({ + key: VOICE_MESSAGE_META_ARRAY_MESSAGE_TYPE_KEY, + value: [`voice/${recorderService.options.extension}`], + }), + ], + ...messageReplyParams, + }).catch(onFailureToSend); + } + + onChangeText(''); + setMessageToReply?.(); + }; + const sheetItems = useChannelInputItems(channel, sendFileMessage); - const onPressAttachment = () => openSheet({ sheetItems }); const getPlaceholder = () => { if (inputMuted) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_MUTED; @@ -134,90 +156,16 @@ const SendInput = forwardRef(function SendInput( return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_ACTIVE; }; - const getFileIconAsImage = (url: string) => { - return ; - }; - - const getFileIconAsVideoThumbnail = (url: string) => { - return ( - mediaService.getVideoThumbnail({ url: uri, timeMills: 1000 })} - /> - ); - }; - - const getFileIconAsSymbol = (icon: FileIcon) => { - return ( - - ); - }; - - const getFileIcon = (messageToReply: SendbirdBaseMessage) => { - if (messageToReply?.isFileMessage()) { - const messageType = getMessageType(messageToReply); - switch (messageType) { - case 'file.image': - return getFileIconAsImage(getThumbnailUriFromFileMessage(messageToReply)); - case 'file.video': - return getFileIconAsVideoThumbnail(getThumbnailUriFromFileMessage(messageToReply)); - default: - return getFileIconAsSymbol(getFileIconFromMessageType(messageType)); - } - } - return null; - }; + const voiceMessageEnabled = channel.isGroupChannel() && sbOptions.uikit.groupChannel.channel.enableVoiceMessage; + const sendButtonVisible = Boolean(text.trim()); return ( - {messageToReply && ( - - - {getFileIcon(messageToReply)} - - - {STRINGS.LABELS.CHANNEL_INPUT_REPLY_PREVIEW_TITLE(messageToReply.sender)} - - - {STRINGS.LABELS.CHANNEL_INPUT_REPLY_PREVIEW_BODY(messageToReply)} - - - - setMessageToReply?.(undefined)}> - - - + {MessageToReplyPreview && ( + )} - {AttachmentsButton && } + {AttachmentsButton && openSheet({ sheetItems })} disabled={inputDisabled} />} (function SendInput( )} - {Boolean(text.trim()) && ( - - - + {voiceMessageEnabled && ( + setVoiceMessageInputVisible(true)} + /> + )} + + {voiceMessageEnabled && VoiceMessageInput && ( + { + onDismiss(); + Promise.allSettled([playerService.reset(), recorderService.reset()]); + }} + backgroundStyle={{ justifyContent: 'flex-end' }} + visible={voiceMessageInputVisible} + type={'slide-no-gesture'} + > + sendVoiceMessage(file, duration)} /> + )} ); }); -const useChannelInputItems = (channel: SendbirdChannel, sendFileMessage: (file: FileType) => void) => { - const { sbOptions, imageCompressionConfig } = useSendbirdChat(); +type InputButtonProps = { visible: boolean; disabled: boolean; onPress: () => void }; +const VoiceMessageButton = ({ visible, disabled, onPress }: InputButtonProps) => { const { STRINGS } = useLocalization(); - const { fileService, mediaService } = usePlatformService(); const { alert } = useAlert(); - const toast = useToast(); - - const sheetItems: BottomSheetItem['sheetItems'] = []; - const input = useIIFE(() => { - switch (true) { - case channel.isOpenChannel(): - return sbOptions.uikit.openChannel.channel.input; - case channel.isGroupChannel(): - return sbOptions.uikit.groupChannel.channel.input; - default: - return { - enableDocument: true, - camera: { enablePhoto: true, enableVideo: true }, - gallery: { enablePhoto: true, enableVideo: true }, - }; + const { playerService, recorderService } = usePlatformService(); + const { colors } = useUIKitTheme(); + if (!visible) return null; + + const onPressWithPermissionCheck = async () => { + const recorderGranted = await recorderService.requestPermission(); + if (!recorderGranted) { + alert({ + title: STRINGS.DIALOG.ALERT_PERMISSIONS_TITLE, + message: STRINGS.DIALOG.ALERT_PERMISSIONS_MESSAGE( + STRINGS.LABELS.PERMISSION_MICROPHONE, + STRINGS.LABELS.PERMISSION_APP_NAME, + ), + buttons: [{ text: STRINGS.DIALOG.ALERT_PERMISSIONS_OK, onPress: () => SBUUtils.openSettings() }], + }); + Logger.error('Failed to request permission for recorder'); + return; } - }); - if (input.camera.enablePhoto) { - sheetItems.push({ - title: STRINGS.LABELS.CHANNEL_INPUT_ATTACHMENT_CAMERA_PHOTO, - icon: 'camera', - onPress: async () => { - const mediaFile = await fileService.openCamera({ - mediaType: 'photo', - onOpenFailure: (error) => { - if (error.code === SBUError.CODE.ERR_PERMISSIONS_DENIED) { - alert({ - title: STRINGS.DIALOG.ALERT_PERMISSIONS_TITLE, - message: STRINGS.DIALOG.ALERT_PERMISSIONS_MESSAGE( - STRINGS.LABELS.PERMISSION_CAMERA, - STRINGS.LABELS.PERMISSION_APP_NAME, - ), - buttons: [{ text: STRINGS.DIALOG.ALERT_PERMISSIONS_OK, onPress: () => SBUUtils.openSettings() }], - }); - } else { - toast.show(STRINGS.TOAST.OPEN_CAMERA_ERROR, 'error'); - } - }, - }); - - if (mediaFile) { - // Image compression - if ( - isImage(mediaFile.uri, mediaFile.type) && - shouldCompressImage(mediaFile.type, sbOptions.chat.imageCompressionEnabled) - ) { - await SBUUtils.safeRun(async () => { - const compressed = await mediaService.compressImage({ - uri: mediaFile.uri, - maxWidth: imageCompressionConfig.width, - maxHeight: imageCompressionConfig.height, - compressionRate: imageCompressionConfig.compressionRate, - }); - - if (compressed) { - mediaFile.uri = compressed.uri; - mediaFile.size = compressed.size; - } - }); - } - - sendFileMessage(mediaFile); - } - }, - }); - } - - if (input.camera.enableVideo) { - sheetItems.push({ - title: STRINGS.LABELS.CHANNEL_INPUT_ATTACHMENT_CAMERA_VIDEO, - icon: 'camera', - onPress: async () => { - const mediaFile = await fileService.openCamera({ - mediaType: 'video', - onOpenFailure: (error) => { - if (error.code === SBUError.CODE.ERR_PERMISSIONS_DENIED) { - alert({ - title: STRINGS.DIALOG.ALERT_PERMISSIONS_TITLE, - message: STRINGS.DIALOG.ALERT_PERMISSIONS_MESSAGE( - STRINGS.LABELS.PERMISSION_CAMERA, - STRINGS.LABELS.PERMISSION_APP_NAME, - ), - buttons: [{ text: STRINGS.DIALOG.ALERT_PERMISSIONS_OK, onPress: () => SBUUtils.openSettings() }], - }); - } else { - toast.show(STRINGS.TOAST.OPEN_CAMERA_ERROR, 'error'); - } - }, - }); - - if (mediaFile) { - sendFileMessage(mediaFile); - } - }, - }); - } - - if (input.gallery.enablePhoto || input.gallery.enableVideo) { - const mediaType = (() => { - switch (true) { - case input.gallery.enablePhoto && input.gallery.enableVideo: - return 'all'; - case input.gallery.enablePhoto && !input.gallery.enableVideo: - return 'photo'; - case !input.gallery.enablePhoto && input.gallery.enableVideo: - return 'video'; - default: - return 'all'; - } - })(); - - sheetItems.push({ - title: STRINGS.LABELS.CHANNEL_INPUT_ATTACHMENT_PHOTO_LIBRARY, - icon: 'photo', - onPress: async () => { - const mediaFiles = await fileService.openMediaLibrary({ - selectionLimit: 1, - mediaType, - onOpenFailure: (error) => { - if (error.code === SBUError.CODE.ERR_PERMISSIONS_DENIED) { - alert({ - title: STRINGS.DIALOG.ALERT_PERMISSIONS_TITLE, - message: STRINGS.DIALOG.ALERT_PERMISSIONS_MESSAGE( - STRINGS.LABELS.PERMISSION_DEVICE_STORAGE, - STRINGS.LABELS.PERMISSION_APP_NAME, - ), - buttons: [{ text: STRINGS.DIALOG.ALERT_PERMISSIONS_OK, onPress: () => SBUUtils.openSettings() }], - }); - } else { - toast.show(STRINGS.TOAST.OPEN_PHOTO_LIBRARY_ERROR, 'error'); - } - }, - }); - - if (mediaFiles && mediaFiles[0]) { - const mediaFile = mediaFiles[0]; - - // Image compression - if ( - isImage(mediaFile.uri, mediaFile.type) && - shouldCompressImage(mediaFile.type, sbOptions.chat.imageCompressionEnabled) - ) { - await SBUUtils.safeRun(async () => { - const compressed = await mediaService.compressImage({ - uri: mediaFile.uri, - maxWidth: imageCompressionConfig.width, - maxHeight: imageCompressionConfig.height, - compressionRate: imageCompressionConfig.compressionRate, - }); - - if (compressed) { - mediaFile.uri = compressed.uri; - mediaFile.size = compressed.size; - } - }); - } - - sendFileMessage(mediaFile); - } - }, - }); - } - - if (input.enableDocument) { - sheetItems.push({ - title: STRINGS.LABELS.CHANNEL_INPUT_ATTACHMENT_FILES, - icon: 'document', - onPress: async () => { - const documentFile = await fileService.openDocument({ - onOpenFailure: () => toast.show(STRINGS.TOAST.OPEN_FILES_ERROR, 'error'), - }); - - if (documentFile) { - // Image compression - if ( - isImage(documentFile.uri, documentFile.type) && - shouldCompressImage(documentFile.type, sbOptions.chat.imageCompressionEnabled) - ) { - await SBUUtils.safeRun(async () => { - const compressed = await mediaService.compressImage({ - uri: documentFile.uri, - maxWidth: imageCompressionConfig.width, - maxHeight: imageCompressionConfig.height, - compressionRate: imageCompressionConfig.compressionRate, - }); - - if (compressed) { - documentFile.uri = compressed.uri; - documentFile.size = compressed.size; - } - }); - } + const playerGranted = await playerService.requestPermission(); + if (!playerGranted) { + alert({ + title: STRINGS.DIALOG.ALERT_PERMISSIONS_TITLE, + message: STRINGS.DIALOG.ALERT_PERMISSIONS_MESSAGE( + STRINGS.LABELS.PERMISSION_DEVICE_STORAGE, + STRINGS.LABELS.PERMISSION_APP_NAME, + ), + buttons: [{ text: STRINGS.DIALOG.ALERT_PERMISSIONS_OK, onPress: () => SBUUtils.openSettings() }], + }); + Logger.error('Failed to request permission for player'); + return; + } - sendFileMessage(documentFile); - } - }, - }); - } + onPress(); + }; - return sheetItems; + return ( + + + + ); +}; +const UserMessageSendButton = ({ visible, disabled, onPress }: InputButtonProps) => { + const { colors } = useUIKitTheme(); + if (!visible) return null; + return ( + + + + ); }; const styles = createStyleSheet({ @@ -474,18 +291,10 @@ const styles = createStyleSheet({ maxHeight: 36 * Platform.select({ ios: 2.5, default: 2 }), borderRadius: 20, }, - iconSend: { + sendIcon: { marginLeft: 4, padding: 4, }, - previewImage: { - width: 36, - height: 36, - borderRadius: 10, - marginTop: 2, - marginRight: 10, - overflow: 'hidden', - }, }); export default SendInput; diff --git a/packages/uikit-react-native/src/components/ChannelInput/VoiceMessageInput.tsx b/packages/uikit-react-native/src/components/ChannelInput/VoiceMessageInput.tsx new file mode 100644 index 000000000..4a272a733 --- /dev/null +++ b/packages/uikit-react-native/src/components/ChannelInput/VoiceMessageInput.tsx @@ -0,0 +1,206 @@ +import React, { useEffect, useRef } from 'react'; +import { Animated } from 'react-native'; + +import { + Box, + Icon, + PressBox, + ProgressBar, + Text, + createStyleSheet, + useUIKitTheme, +} from '@sendbird/uikit-react-native-foundation'; +import { conditionChaining, millsToMMSS } from '@sendbird/uikit-utils'; + +import { useLocalization } from '../../hooks/useContext'; +import useVoiceMessageInput from '../../hooks/useVoiceMessageInput'; +import type { FileType } from '../../platform/types'; + +export type VoiceMessageInputProps = { + onClose: () => Promise; + onSend: (params: { file: FileType; duration: number }) => void; +}; + +const VoiceMessageInput = ({ onClose, onSend }: VoiceMessageInputProps) => { + const { STRINGS } = useLocalization(); + const { colors } = useUIKitTheme(); + const { actions, state } = useVoiceMessageInput({ + onSend: (file, duration) => onSend({ file, duration }), + onClose, + }); + + const uiColors = colors.ui.voiceMessageInput.default[state.status !== 'idle' ? 'active' : 'inactive']; + + const onPressCancel = async () => { + actions.cancel(); + onClose(); + }; + + const onPressSend = async () => { + actions.send(); + onClose(); + }; + + const onPressVoiceMessageAction = () => { + switch (state.status) { + case 'idle': + actions.startRecording(); + break; + case 'recording': + if (lessThanMinimumDuration) { + actions.cancel(); + } else { + actions.stopRecording(); + } + break; + case 'recording_completed': + case 'playing_paused': + actions.playPlayer(); + break; + case 'playing': + actions.pausePlayer(); + break; + } + }; + const renderActionIcon = () => { + switch (state.status) { + case 'idle': + return ; + case 'recording': + return ; + case 'recording_completed': + case 'playing_paused': + return ; + case 'playing': + return ; + } + }; + + const isRecorderState = state.status === 'recording' || state.status === 'recording_completed'; + const lessThanMinimumDuration = state.recordingTime.currentTime < state.recordingTime.minDuration; + const remainingTime = state.playingTime.duration - state.playingTime.currentTime; + + return ( + + {/** Progress bar **/} + + + + {millsToMMSS(isRecorderState ? state.recordingTime.currentTime : remainingTime)} + + + } + /> + + + {/** Cancel / Send **/} + + + + + + + {/** Record / Stop / Play / Pause **/} + + + + {renderActionIcon()} + + + + + + ); +}; + +const RecordingLight = (props: { visible: boolean }) => { + const { colors } = useUIKitTheme(); + + const value = useRef(new Animated.Value(0)).current; + const animation = useRef( + Animated.loop( + Animated.sequence([ + Animated.timing(value, { toValue: 1, duration: 500, useNativeDriver: true }), + Animated.timing(value, { toValue: 0, duration: 500, useNativeDriver: true }), + ]), + ), + ).current; + + useEffect(() => { + if (props.visible) animation.start(); + return () => { + animation.reset(); + }; + }, [props.visible]); + + if (!props.visible) return null; + return ( + + ); +}; + +const CancelButton = (props: { onPress: () => void; label: string }) => { + const { colors } = useUIKitTheme(); + + return ( + + + + {props.label} + + + + ); +}; + +const SendButton = (props: { onPress: () => void; disabled: boolean }) => { + const { colors } = useUIKitTheme(); + + const uiColors = colors.ui.voiceMessageInput.default[props.disabled ? 'inactive' : 'active']; + + return ( + + + + + + ); +}; + +const styles = createStyleSheet({ + container: { + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + }, + progressBar: { + height: 36, + marginBottom: 16, + borderRadius: 18, + }, +}); + +export default VoiceMessageInput; diff --git a/packages/uikit-react-native/src/components/ChannelInput/index.tsx b/packages/uikit-react-native/src/components/ChannelInput/index.tsx index 1f51781c2..dcb05a4d1 100644 --- a/packages/uikit-react-native/src/components/ChannelInput/index.tsx +++ b/packages/uikit-react-native/src/components/ChannelInput/index.tsx @@ -23,7 +23,10 @@ import type { MentionedUser, Range } from '../../types'; import type { AttachmentsButtonProps } from './AttachmentsButton'; import AttachmentsButton from './AttachmentsButton'; import EditInput from './EditInput'; +import type { MessageToReplyPreviewProps } from './MessageToReplyPreview'; +import { MessageToReplyPreview } from './MessageToReplyPreview'; import SendInput from './SendInput'; +import VoiceMessageInput, { VoiceMessageInputProps } from './VoiceMessageInput'; export type SuggestedMentionListProps = { text: string; @@ -65,6 +68,8 @@ export type ChannelInputProps = { // sub-components AttachmentsButton?: (props: AttachmentsButtonProps) => React.ReactNode | null; + MessageToReplyPreview?: (props: MessageToReplyPreviewProps) => React.ReactNode | null; + VoiceMessageInput?: (props: VoiceMessageInputProps) => React.ReactNode | null; }; const AUTO_FOCUS = Platform.select({ ios: false, android: true, default: false }); @@ -93,6 +98,7 @@ const ChannelInput = (props: ChannelInputProps) => { const mentionAvailable = sbOptions.uikit.groupChannel.channel.enableMention && channel.isGroupChannel() && !channel.isBroadcast; + const inputKeyToRemount = GET_INPUT_KEY(mentionAvailable ? mentionedUsers.length === 0 : false); const [inputHeight, setInputHeight] = useState(styles.inputDefault.height); @@ -129,7 +135,9 @@ const ChannelInput = (props: ChannelInputProps) => { onChangeText={onChangeText} onSelectionChange={onSelectionChange} mentionedUsers={mentionedUsers} + VoiceMessageInput={props.VoiceMessageInput ?? VoiceMessageInput} AttachmentsButton={props.AttachmentsButton ?? AttachmentsButton} + MessageToReplyPreview={props.MessageToReplyPreview ?? MessageToReplyPreview} /> )} {inputMode === 'edit' && messageToEdit && ( diff --git a/packages/uikit-react-native/src/components/ChannelMessageList/index.tsx b/packages/uikit-react-native/src/components/ChannelMessageList/index.tsx index 951d7e31d..27254ed50 100644 --- a/packages/uikit-react-native/src/components/ChannelMessageList/index.tsx +++ b/packages/uikit-react-native/src/components/ChannelMessageList/index.tsx @@ -22,6 +22,7 @@ import { getFileExtension, getFileType, isMyMessage, + isVoiceMessage, messageKeyExtractor, shouldRenderReaction, toMegabyte, @@ -277,7 +278,7 @@ const useGetMessagePressActions = void; }; -const GroupChannelMessageParentMessage = ({ variant, message, childMessage, onPress }: Props) => { +const GroupChannelMessageParentMessage = ({ variant, channel, message, childMessage, onPress }: Props) => { const { currentUser } = useSendbirdChat(); const groupChannelPubSub = useContext(GroupChannelContexts.PubSub); const { select, colors, palette } = useUIKitTheme(); @@ -52,6 +54,19 @@ const GroupChannelMessageParentMessage = ({ variant, message, childMessage, onPr }); }, []); + const renderMessageWithText = (message: string) => { + return ( + + + {message} + + + ); + }; + const renderFileMessageAsVideoThumbnail = (url: string) => { return ( { + if (channel.messageOffsetTimestamp > parentMessage.createdAt) { + return renderMessageWithText(STRINGS.LABELS.MESSAGE_UNAVAILABLE); + } + switch (type) { case 'user': case 'user.opengraph': { - return ( - - - {(parentMessage as SendbirdUserMessage).message} - - - ); + return renderMessageWithText((parentMessage as SendbirdUserMessage).message); } case 'file': case 'file.audio': { @@ -109,6 +119,9 @@ const GroupChannelMessageParentMessage = ({ variant, message, childMessage, onPr case 'file.image': { return renderFileMessageAsPreview(getThumbnailUriFromFileMessage(parentMessage as SendbirdFileMessage)); } + case 'file.voice': { + return renderMessageWithText(STRINGS.LABELS.VOICE_MESSAGE); + } default: { return null; } diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx index 1d9cba6fa..218c5b703 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import type { GroupChannelMessageProps, RegexTextPattern } from '@sendbird/uikit-react-native-foundation'; import { Box, GroupChannelMessage, Text, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; @@ -10,11 +10,13 @@ import { calcMessageGrouping, getMessageType, isMyMessage, + isVoiceMessage, shouldRenderParentMessage, shouldRenderReaction, useIIFE, } from '@sendbird/uikit-utils'; +import { VOICE_MESSAGE_META_ARRAY_DURATION_KEY } from '../../constants'; import type { GroupChannelProps } from '../../domain/groupChannel/types'; import { useLocalization, usePlatformService, useSendbirdChat } from '../../hooks/useContext'; import SBUUtils from '../../libs/SBUUtils'; @@ -36,10 +38,11 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' prevMessage, nextMessage, }) => { + const playerUnsubscribes = useRef<(() => void)[]>([]); const { palette } = useUIKitTheme(); const { sbOptions, currentUser, mentionManager } = useSendbirdChat(); const { STRINGS } = useLocalization(); - const { mediaService } = usePlatformService(); + const { mediaService, playerService } = usePlatformService(); const { groupWithPrev, groupWithNext } = calcMessageGrouping( Boolean(enableMessageGrouping), message, @@ -58,6 +61,16 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' return null; }); + const resetPlayer = async () => { + playerUnsubscribes.current.forEach((unsubscribe) => { + try { + unsubscribe(); + } catch {} + }); + playerUnsubscribes.current.length = 0; + await playerService.reset(); + }; + const variant = isMyMessage(message, currentUser?.userId) ? 'outgoing' : 'incoming'; const messageProps: Omit, 'message'> = { @@ -72,6 +85,55 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' onPressMentionedUser: (mentionedUser) => { if (mentionedUser) onShowUserProfile?.(mentionedUser); }, + onToggleVoiceMessage: async (state, setState) => { + if (isVoiceMessage(message) && message.sendingStatus === 'succeeded') { + if (playerService.uri === message.url) { + if (playerService.state === 'playing') { + await playerService.pause(); + } else { + await playerService.play(message.url); + } + } else { + if (playerService.state !== 'idle') { + await resetPlayer(); + } + + const shouldSeekToTime = state.duration > state.currentTime && state.currentTime > 0; + let seekFinished = !shouldSeekToTime; + + const forPlayback = playerService.addPlaybackListener(({ stopped, currentTime, duration }) => { + if (seekFinished) { + setState((prevState) => ({ ...prevState, currentTime: stopped ? 0 : currentTime, duration })); + } + }); + const forState = playerService.addStateListener((state) => { + switch (state) { + case 'preparing': + setState((prevState) => ({ ...prevState, status: 'preparing' })); + break; + case 'playing': + setState((prevState) => ({ ...prevState, status: 'playing' })); + break; + case 'idle': + case 'paused': { + setState((prevState) => ({ ...prevState, status: 'paused' })); + break; + } + case 'stopped': + setState((prevState) => ({ ...prevState, status: 'paused' })); + break; + } + }); + playerUnsubscribes.current.push(forPlayback, forState); + + await playerService.play(message.url); + if (shouldSeekToTime) { + await playerService.seek(state.currentTime); + seekFinished = true; + } + } + } + }, groupedWithPrev: groupWithPrev, groupedWithNext: groupWithNext, children: reactionChildren, @@ -80,9 +142,10 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' ) : null, parentMessage: shouldRenderParentMessage(message) ? ( ) : null, @@ -184,6 +247,20 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' /> ); } + case 'file.voice': { + return ( + { + if (isVoiceMessage(message) && playerService.uri === message.url) { + resetPlayer(); + } + }} + {...messageProps} + /> + ); + } case 'unknown': default: { return ; diff --git a/packages/uikit-react-native/src/components/MessageSearchResultItem.tsx b/packages/uikit-react-native/src/components/MessageSearchResultItem.tsx index 78f9191fc..f99ffda38 100644 --- a/packages/uikit-react-native/src/components/MessageSearchResultItem.tsx +++ b/packages/uikit-react-native/src/components/MessageSearchResultItem.tsx @@ -10,7 +10,7 @@ import { useUIKitTheme, } from '@sendbird/uikit-react-native-foundation'; import type { SendbirdBaseMessage } from '@sendbird/uikit-utils'; -import { getFileIconFromMessage, useIIFE } from '@sendbird/uikit-utils'; +import { getFileIconFromMessage, isVoiceMessage, useIIFE } from '@sendbird/uikit-utils'; import type { MessageSearchProps } from '../domain/messageSearch/types'; import { useLocalization } from '../hooks/useContext'; @@ -21,6 +21,7 @@ const MessageSearchResultItem: MessageSearchProps['List']['renderSearchResultIte const fileIcon = useIIFE(() => { if (!message?.isFileMessage()) return undefined; + if (isVoiceMessage(message)) return undefined; return getFileIconFromMessage(message); }); diff --git a/packages/uikit-react-native/src/components/OpenChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/OpenChannelMessageRenderer/index.tsx index 97946b062..b1be3d6df 100644 --- a/packages/uikit-react-native/src/components/OpenChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/OpenChannelMessageRenderer/index.tsx @@ -63,6 +63,7 @@ const OpenChannelMessageRenderer: OpenChannelProps['Fragment']['renderMessage'] } } case 'file': + case 'file.voice': case 'file.audio': { return ; } diff --git a/packages/uikit-react-native/src/constants.ts b/packages/uikit-react-native/src/constants.ts index b6d41c627..fb26b3cf8 100644 --- a/packages/uikit-react-native/src/constants.ts +++ b/packages/uikit-react-native/src/constants.ts @@ -3,3 +3,5 @@ export const MESSAGE_SEARCH_SAFE_SCROLL_DELAY = 500; export const MESSAGE_FOCUS_ANIMATION_DELAY = 250; export const UNKNOWN_USER_ID = '##__USER_ID_IS_NOT_PROVIDED__##'; +export const VOICE_MESSAGE_META_ARRAY_DURATION_KEY = 'KEY_VOICE_MESSAGE_DURATION'; +export const VOICE_MESSAGE_META_ARRAY_MESSAGE_TYPE_KEY = 'KEY_INTERNAL_MESSAGE_TYPE'; diff --git a/packages/uikit-react-native/src/containers/GroupChannelPreviewContainer.tsx b/packages/uikit-react-native/src/containers/GroupChannelPreviewContainer.tsx index 7cf0dc419..428a8f629 100644 --- a/packages/uikit-react-native/src/containers/GroupChannelPreviewContainer.tsx +++ b/packages/uikit-react-native/src/containers/GroupChannelPreviewContainer.tsx @@ -17,6 +17,7 @@ import { getFileTypeFromMessage, isDifferentChannel, isMyMessage, + isVoiceMessage, useIIFE, useUniqHandlerId, } from '@sendbird/uikit-utils'; @@ -56,6 +57,7 @@ const GroupChannelPreviewContainer = ({ onPress, onLongPress, channel }: Props) const fileType = useIIFE(() => { if (!channel.lastMessage?.isFileMessage()) return undefined; if (typingUsers.length > 0) return undefined; + if (isVoiceMessage(channel.lastMessage)) return undefined; return getFileTypeFromMessage(channel.lastMessage); }); diff --git a/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx b/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx index 668b3a6cd..f59a2c04f 100644 --- a/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx +++ b/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx @@ -37,6 +37,7 @@ import ImageCompressionConfig from '../libs/ImageCompressionConfig'; import InternalLocalCacheStorage from '../libs/InternalLocalCacheStorage'; import MentionConfig, { MentionConfigInterface } from '../libs/MentionConfig'; import MentionManager from '../libs/MentionManager'; +import VoiceMessageConfig, { VoiceMessageConfigInterface } from '../libs/VoiceMessageConfig'; import StringSetEn from '../localization/StringSet.en'; import type { StringSet } from '../localization/StringSet.type'; import SBUDynamicModule from '../platform/dynamicModule'; @@ -45,6 +46,8 @@ import type { FileServiceInterface, MediaServiceInterface, NotificationServiceInterface, + PlayerServiceInterface, + RecorderServiceInterface, } from '../platform/types'; import type { ErrorBoundaryProps, LocalCacheStorage } from '../types'; import VERSION from '../version'; @@ -61,7 +64,7 @@ export const SendbirdUIKit = Object.freeze({ }, }); -type UnimplementedFeatures = 'enableVoiceMessage' | 'threadReplySelectType' | 'replyType'; +type UnimplementedFeatures = 'threadReplySelectType' | 'replyType'; export type ChatOmittedInitParams = Omit< SendbirdChatParams<[GroupChannelModule, OpenChannelModule]>, (typeof chatOmitKeys)[number] @@ -89,6 +92,8 @@ export type SendbirdUIKitContainerProps = React.PropsWithChildren<{ notification: NotificationServiceInterface; clipboard: ClipboardServiceInterface; media: MediaServiceInterface; + player: PlayerServiceInterface; + recorder: RecorderServiceInterface; }; chatOptions: { localCacheStorage: LocalCacheStorage; @@ -134,23 +139,24 @@ export type SendbirdUIKitContainerProps = React.PropsWithChildren<{ }; userMention?: Pick, 'mentionLimit' | 'suggestionLimit' | 'debounceMills'>; imageCompression?: Partial; + voiceMessage?: PartialDeep; }>; -const SendbirdUIKitContainer = ({ - children, - appId, - chatOptions, - uikitOptions, - platformServices, - localization, - styles, - errorBoundary, - toast, - userProfile, - reaction, - userMention, - imageCompression, -}: SendbirdUIKitContainerProps) => { +const SendbirdUIKitContainer = (props: SendbirdUIKitContainerProps) => { + const { + children, + appId, + chatOptions, + uikitOptions, + platformServices, + localization, + styles, + errorBoundary, + toast, + userProfile, + reaction, + } = props; + if (!chatOptions.localCacheStorage) { throw new Error('SendbirdUIKitContainer: chatOptions.localCacheStorage is required'); } @@ -167,26 +173,9 @@ const SendbirdUIKitContainer = ({ return sendbird.chatSDK; }); + const { imageCompressionConfig, voiceMessageConfig, mentionConfig } = useConfigInstance(props); const emojiManager = useMemo(() => new EmojiManager(internalStorage), [internalStorage]); - - const mentionManager = useMemo(() => { - const config = new MentionConfig({ - mentionLimit: userMention?.mentionLimit || MentionConfig.DEFAULT.MENTION_LIMIT, - suggestionLimit: userMention?.suggestionLimit || MentionConfig.DEFAULT.SUGGESTION_LIMIT, - debounceMills: userMention?.debounceMills ?? MentionConfig.DEFAULT.DEBOUNCE_MILLS, - delimiter: MentionConfig.DEFAULT.DELIMITER, - trigger: MentionConfig.DEFAULT.TRIGGER, - }); - return new MentionManager(config); - }, [userMention?.mentionLimit, userMention?.suggestionLimit, userMention?.debounceMills]); - - const imageCompressionConfig = useMemo(() => { - return new ImageCompressionConfig({ - compressionRate: imageCompression?.compressionRate || ImageCompressionConfig.DEFAULT.COMPRESSION_RATE, - width: imageCompression?.width, - height: imageCompression?.height, - }); - }, [imageCompression?.compressionRate, imageCompression?.width, imageCompression?.height]); + const mentionManager = useMemo(() => new MentionManager(mentionConfig), [mentionConfig]); useLayoutEffect(() => { if (!isFirstMount) { @@ -233,6 +222,7 @@ const SendbirdUIKitContainer = ({ emojiManager={emojiManager} mentionManager={mentionManager} imageCompressionConfig={imageCompressionConfig} + voiceMessageConfig={voiceMessageConfig} enableAutoPushTokenRegistration={ chatOptions.enableAutoPushTokenRegistration ?? SendbirdUIKit.DEFAULT.AUTO_PUSH_TOKEN_REGISTRATION } @@ -247,6 +237,9 @@ const SendbirdUIKitContainer = ({ notificationService={platformServices.notification} clipboardService={platformServices.clipboard} mediaService={platformServices.media} + playerService={platformServices.player} + recorderService={platformServices.recorder} + voiceMessageConfig={voiceMessageConfig} > { + const mentionConfig = useMemo(() => { + return new MentionConfig({ + mentionLimit: userMention?.mentionLimit || MentionConfig.DEFAULT.MENTION_LIMIT, + suggestionLimit: userMention?.suggestionLimit || MentionConfig.DEFAULT.SUGGESTION_LIMIT, + debounceMills: userMention?.debounceMills ?? MentionConfig.DEFAULT.DEBOUNCE_MILLS, + delimiter: MentionConfig.DEFAULT.DELIMITER, + trigger: MentionConfig.DEFAULT.TRIGGER, + }); + }, [userMention?.mentionLimit, userMention?.suggestionLimit, userMention?.debounceMills]); + + const imageCompressionConfig = useMemo(() => { + return new ImageCompressionConfig({ + compressionRate: imageCompression?.compressionRate || ImageCompressionConfig.DEFAULT.COMPRESSION_RATE, + width: imageCompression?.width, + height: imageCompression?.height, + }); + }, [imageCompression?.compressionRate, imageCompression?.width, imageCompression?.height]); + + const voiceMessageConfig = useMemo(() => { + return new VoiceMessageConfig({ + recorder: { + minDuration: voiceMessage?.recorder?.minDuration ?? VoiceMessageConfig.DEFAULT.RECORDER.MIN_DURATION, + maxDuration: voiceMessage?.recorder?.maxDuration ?? VoiceMessageConfig.DEFAULT.RECORDER.MAX_DURATION, + }, + }); + }, [voiceMessage?.recorder?.minDuration, voiceMessage?.recorder?.maxDuration]); + + return { + mentionConfig, + imageCompressionConfig, + voiceMessageConfig, + }; +}; + export default SendbirdUIKitContainer; diff --git a/packages/uikit-react-native/src/contexts/PlatformServiceCtx.tsx b/packages/uikit-react-native/src/contexts/PlatformServiceCtx.tsx index a70c654be..1aea5ca3d 100644 --- a/packages/uikit-react-native/src/contexts/PlatformServiceCtx.tsx +++ b/packages/uikit-react-native/src/contexts/PlatformServiceCtx.tsx @@ -1,37 +1,39 @@ -import React from 'react'; +import React, { useEffect } from 'react'; +import { useAppState } from '@sendbird/uikit-utils'; + +import VoiceMessageConfig from '../libs/VoiceMessageConfig'; import type { ClipboardServiceInterface, FileServiceInterface, MediaServiceInterface, NotificationServiceInterface, + PlayerServiceInterface, + RecorderServiceInterface, } from '../platform/types'; -type Props = React.PropsWithChildren<{ - fileService: FileServiceInterface; - clipboardService: ClipboardServiceInterface; - notificationService: NotificationServiceInterface; - mediaService: MediaServiceInterface; -}>; - export type PlatformServiceContextType = { fileService: FileServiceInterface; clipboardService: ClipboardServiceInterface; notificationService: NotificationServiceInterface; mediaService: MediaServiceInterface; + recorderService: RecorderServiceInterface; + playerService: PlayerServiceInterface; }; +type Props = React.PropsWithChildren; export const PlatformServiceContext = React.createContext(null); -export const PlatformServiceProvider = ({ - children, - fileService, - clipboardService, - notificationService, - mediaService, -}: Props) => { - return ( - - {children} - - ); +export const PlatformServiceProvider = ({ children, voiceMessageConfig, ...services }: Props) => { + useEffect(() => { + services.recorderService.options.minDuration = voiceMessageConfig.recorder.minDuration; + services.recorderService.options.maxDuration = voiceMessageConfig.recorder.maxDuration; + }, [voiceMessageConfig]); + + useAppState('change', (state) => { + if (state !== 'active') { + Promise.allSettled([services.playerService.reset(), services.recorderService.reset()]); + } + }); + + return {children}; }; diff --git a/packages/uikit-react-native/src/contexts/SendbirdChatCtx.tsx b/packages/uikit-react-native/src/contexts/SendbirdChatCtx.tsx index a11c9257b..267507f38 100644 --- a/packages/uikit-react-native/src/contexts/SendbirdChatCtx.tsx +++ b/packages/uikit-react-native/src/contexts/SendbirdChatCtx.tsx @@ -13,6 +13,7 @@ import { confirmAndMarkAsDelivered, useAppState, useForceUpdate } from '@sendbir import type EmojiManager from '../libs/EmojiManager'; import type ImageCompressionConfig from '../libs/ImageCompressionConfig'; import type MentionManager from '../libs/MentionManager'; +import type VoiceMessageConfig from '../libs/VoiceMessageConfig'; import type { FileType } from '../platform/types'; export interface ChatRelatedFeaturesInUIKit { @@ -23,18 +24,23 @@ export interface ChatRelatedFeaturesInUIKit { interface Props extends ChatRelatedFeaturesInUIKit, React.PropsWithChildren { sdkInstance: SendbirdChatSDK; + emojiManager: EmojiManager; mentionManager: MentionManager; imageCompressionConfig: ImageCompressionConfig; + voiceMessageConfig: VoiceMessageConfig; } export type SendbirdChatContextType = { sdk: SendbirdChatSDK; + currentUser?: SendbirdUser; + setCurrentUser: React.Dispatch>; + + // feature related instances emojiManager: EmojiManager; mentionManager: MentionManager; imageCompressionConfig: ImageCompressionConfig; - currentUser?: SendbirdUser; - setCurrentUser: React.Dispatch>; + voiceMessageConfig: VoiceMessageConfig; // helper functions updateCurrentUserInfo: (nickname?: string, profile?: string | FileType) => Promise; @@ -84,6 +90,7 @@ export const SendbirdChatProvider = ({ emojiManager, mentionManager, imageCompressionConfig, + voiceMessageConfig, enableAutoPushTokenRegistration, enableUseUserIdForNickname, enableImageCompression, @@ -155,6 +162,7 @@ export const SendbirdChatProvider = ({ emojiManager, mentionManager, imageCompressionConfig, + voiceMessageConfig, currentUser, setCurrentUser, diff --git a/packages/uikit-react-native/src/domain/groupChannel/module/moduleContext.tsx b/packages/uikit-react-native/src/domain/groupChannel/module/moduleContext.tsx index 192b7e1ac..d0e0e947f 100644 --- a/packages/uikit-react-native/src/domain/groupChannel/module/moduleContext.tsx +++ b/packages/uikit-react-native/src/domain/groupChannel/module/moduleContext.tsx @@ -11,6 +11,7 @@ import { SendbirdMessage, SendbirdUser, SendbirdUserMessage, + getGroupChannelChatAvailableState, isDifferentChannel, useFreshCallback, useUniqHandlerId, @@ -97,7 +98,9 @@ export const GroupChannelContextsProvider: GroupChannelModule['Provider'] = ({ }, onChannelFrozen(frozenChannel) { if (frozenChannel.url === channel.url) { - setMessageToReply(undefined); + if (frozenChannel.isGroupChannel() && getGroupChannelChatAvailableState(channel).frozen) { + setMessageToReply(undefined); + } } }, onUserMuted(mutedChannel, user) { diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx index 1def49a9f..e618b776d 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ReplyType } from '@sendbird/chat/message'; import { useGroupChannelMessages } from '@sendbird/uikit-chat-hooks'; @@ -25,7 +25,7 @@ import type { GroupChannelProps, GroupChannelPubSubContextPayload, } from '../domain/groupChannel/types'; -import { useSendbirdChat } from '../hooks/useContext'; +import { usePlatformService, useSendbirdChat } from '../hooks/useContext'; import pubsub from '../utils/pubsub'; const createGroupChannelFragment = (initModule?: Partial): GroupChannelFragment => { @@ -52,6 +52,7 @@ const createGroupChannelFragment = (initModule?: Partial): G sortComparator = messageComparator, flatListProps, }) => { + const { playerService, recorderService } = usePlatformService(); const { sdk, currentUser, sbOptions } = useSendbirdChat(); const [internalSearchItem, setInternalSearchItem] = useState(searchItem); @@ -97,6 +98,30 @@ const createGroupChannelFragment = (initModule?: Partial): G enableCollectionWithoutLocalCache: true, }); + const onBlurFragment = () => { + return Promise.allSettled([playerService.reset(), recorderService.reset()]); + }; + const _onPressHeaderLeft = useFreshCallback(async () => { + await onBlurFragment(); + onPressHeaderLeft(); + }); + const _onPressHeaderRight = useFreshCallback(async () => { + await onBlurFragment(); + onPressHeaderRight(); + }); + const _onPressMediaMessage: NonNullable = useFreshCallback( + async (message, deleteMessage, uri) => { + await onBlurFragment(); + onPressMediaMessage(message, deleteMessage, uri); + }, + ); + + useEffect(() => { + return () => { + onBlurFragment(); + }; + }, []); + const renderItem: GroupChannelProps['MessageList']['renderMessage'] = useFreshCallback((props) => { if (renderMessage) return renderMessage(props); return ; @@ -176,8 +201,8 @@ const createGroupChannelFragment = (initModule?: Partial): G > }> ): G renderScrollToBottomButton={renderScrollToBottomButton} onResendFailedMessage={resendMessage} onDeleteMessage={deleteMessage} - onPressMediaMessage={onPressMediaMessage} + onPressMediaMessage={_onPressMediaMessage} flatListProps={memoizedFlatListProps} /> void) => { + const { sbOptions, imageCompressionConfig } = useSendbirdChat(); + const { STRINGS } = useLocalization(); + const { fileService, mediaService } = usePlatformService(); + const { alert } = useAlert(); + const toast = useToast(); + + const sheetItems: BottomSheetItem['sheetItems'] = []; + const input = useIIFE(() => { + switch (true) { + case channel.isOpenChannel(): + return sbOptions.uikit.openChannel.channel.input; + case channel.isGroupChannel(): + return sbOptions.uikit.groupChannel.channel.input; + default: + return { + enableDocument: true, + camera: { enablePhoto: true, enableVideo: true }, + gallery: { enablePhoto: true, enableVideo: true }, + }; + } + }); + + if (input.camera.enablePhoto) { + sheetItems.push({ + title: STRINGS.LABELS.CHANNEL_INPUT_ATTACHMENT_CAMERA_PHOTO, + icon: 'camera', + onPress: async () => { + const mediaFile = await fileService.openCamera({ + mediaType: 'photo', + onOpenFailure: (error) => { + if (error.code === SBUError.CODE.ERR_PERMISSIONS_DENIED) { + alert({ + title: STRINGS.DIALOG.ALERT_PERMISSIONS_TITLE, + message: STRINGS.DIALOG.ALERT_PERMISSIONS_MESSAGE( + STRINGS.LABELS.PERMISSION_CAMERA, + STRINGS.LABELS.PERMISSION_APP_NAME, + ), + buttons: [{ text: STRINGS.DIALOG.ALERT_PERMISSIONS_OK, onPress: () => SBUUtils.openSettings() }], + }); + } else { + toast.show(STRINGS.TOAST.OPEN_CAMERA_ERROR, 'error'); + } + }, + }); + + if (mediaFile) { + // Image compression + if ( + isImage(mediaFile.uri, mediaFile.type) && + shouldCompressImage(mediaFile.type, sbOptions.chat.imageCompressionEnabled) + ) { + await SBUUtils.safeRun(async () => { + const compressed = await mediaService.compressImage({ + uri: mediaFile.uri, + maxWidth: imageCompressionConfig.width, + maxHeight: imageCompressionConfig.height, + compressionRate: imageCompressionConfig.compressionRate, + }); + + if (compressed) { + mediaFile.uri = compressed.uri; + mediaFile.size = compressed.size; + } + }); + } + + sendFileMessage(mediaFile); + } + }, + }); + } + + if (input.camera.enableVideo) { + sheetItems.push({ + title: STRINGS.LABELS.CHANNEL_INPUT_ATTACHMENT_CAMERA_VIDEO, + icon: 'camera', + onPress: async () => { + const mediaFile = await fileService.openCamera({ + mediaType: 'video', + onOpenFailure: (error) => { + if (error.code === SBUError.CODE.ERR_PERMISSIONS_DENIED) { + alert({ + title: STRINGS.DIALOG.ALERT_PERMISSIONS_TITLE, + message: STRINGS.DIALOG.ALERT_PERMISSIONS_MESSAGE( + STRINGS.LABELS.PERMISSION_CAMERA, + STRINGS.LABELS.PERMISSION_APP_NAME, + ), + buttons: [{ text: STRINGS.DIALOG.ALERT_PERMISSIONS_OK, onPress: () => SBUUtils.openSettings() }], + }); + } else { + toast.show(STRINGS.TOAST.OPEN_CAMERA_ERROR, 'error'); + } + }, + }); + + if (mediaFile) { + sendFileMessage(mediaFile); + } + }, + }); + } + + if (input.gallery.enablePhoto || input.gallery.enableVideo) { + const mediaType = (() => { + switch (true) { + case input.gallery.enablePhoto && input.gallery.enableVideo: + return 'all'; + case input.gallery.enablePhoto && !input.gallery.enableVideo: + return 'photo'; + case !input.gallery.enablePhoto && input.gallery.enableVideo: + return 'video'; + default: + return 'all'; + } + })(); + + sheetItems.push({ + title: STRINGS.LABELS.CHANNEL_INPUT_ATTACHMENT_PHOTO_LIBRARY, + icon: 'photo', + onPress: async () => { + const mediaFiles = await fileService.openMediaLibrary({ + selectionLimit: 1, + mediaType, + onOpenFailure: (error) => { + if (error.code === SBUError.CODE.ERR_PERMISSIONS_DENIED) { + alert({ + title: STRINGS.DIALOG.ALERT_PERMISSIONS_TITLE, + message: STRINGS.DIALOG.ALERT_PERMISSIONS_MESSAGE( + STRINGS.LABELS.PERMISSION_DEVICE_STORAGE, + STRINGS.LABELS.PERMISSION_APP_NAME, + ), + buttons: [{ text: STRINGS.DIALOG.ALERT_PERMISSIONS_OK, onPress: () => SBUUtils.openSettings() }], + }); + } else { + toast.show(STRINGS.TOAST.OPEN_PHOTO_LIBRARY_ERROR, 'error'); + } + }, + }); + + if (mediaFiles && mediaFiles[0]) { + const mediaFile = mediaFiles[0]; + + // Image compression + if ( + isImage(mediaFile.uri, mediaFile.type) && + shouldCompressImage(mediaFile.type, sbOptions.chat.imageCompressionEnabled) + ) { + await SBUUtils.safeRun(async () => { + const compressed = await mediaService.compressImage({ + uri: mediaFile.uri, + maxWidth: imageCompressionConfig.width, + maxHeight: imageCompressionConfig.height, + compressionRate: imageCompressionConfig.compressionRate, + }); + + if (compressed) { + mediaFile.uri = compressed.uri; + mediaFile.size = compressed.size; + } + }); + } + + sendFileMessage(mediaFile); + } + }, + }); + } + + if (input.enableDocument) { + sheetItems.push({ + title: STRINGS.LABELS.CHANNEL_INPUT_ATTACHMENT_FILES, + icon: 'document', + onPress: async () => { + const documentFile = await fileService.openDocument({ + onOpenFailure: () => toast.show(STRINGS.TOAST.OPEN_FILES_ERROR, 'error'), + }); + + if (documentFile) { + // Image compression + if ( + isImage(documentFile.uri, documentFile.type) && + shouldCompressImage(documentFile.type, sbOptions.chat.imageCompressionEnabled) + ) { + await SBUUtils.safeRun(async () => { + const compressed = await mediaService.compressImage({ + uri: documentFile.uri, + maxWidth: imageCompressionConfig.width, + maxHeight: imageCompressionConfig.height, + compressionRate: imageCompressionConfig.compressionRate, + }); + + if (compressed) { + documentFile.uri = compressed.uri; + documentFile.size = compressed.size; + } + }); + } + + sendFileMessage(documentFile); + } + }, + }); + } + + return sheetItems; +}; diff --git a/packages/uikit-react-native/src/hooks/useConnection.ts b/packages/uikit-react-native/src/hooks/useConnection.ts index 831433cc6..fb1aadbcd 100644 --- a/packages/uikit-react-native/src/hooks/useConnection.ts +++ b/packages/uikit-react-native/src/hooks/useConnection.ts @@ -67,7 +67,7 @@ const useConnection = () => { Logger.warn('[useConnection]', 'clear cached-data'); await sdk.clearCachedData().catch((e) => Logger.warn('[useConnection]', 'clear cached-data failure', e)); } else if (sdk.currentUser) { - await initEmoji(sdk, emojiManager); + await Promise.allSettled([initEmoji(sdk, emojiManager), initDashboardConfigs(sdk)]); Logger.debug('[useConnection]', 'connected! (offline)'); setCurrentUser(sdk.currentUser); diff --git a/packages/uikit-react-native/src/hooks/useVoiceMessageInput.ts b/packages/uikit-react-native/src/hooks/useVoiceMessageInput.ts new file mode 100644 index 000000000..224b0972f --- /dev/null +++ b/packages/uikit-react-native/src/hooks/useVoiceMessageInput.ts @@ -0,0 +1,237 @@ +import { useRef, useState } from 'react'; + +import { useAlert } from '@sendbird/uikit-react-native-foundation'; +import { Logger, getVoiceMessageFileObject, matchesOneOf } from '@sendbird/uikit-utils'; + +import SBUUtils from '../libs/SBUUtils'; +import { FileType } from '../platform/types'; +import { useLocalization, usePlatformService } from './useContext'; + +type State = { + /** + * Status + * + * idle: + * - cancel(): idle + * - startRecording(): recording + * recording: + * - cancel(): idle + * - stopRecording(): recording_completed + * - send(): recording_completed > idle + * recording_completed: + * - cancel(): idle + * - playPlayer(): playing + * - send(): idle + * playing: + * - cancel(): idle + * - pausePlayer(): playing_paused + * - send(): idle + * playing_paused: + * - cancel(): idle + * - playPlayer(): playing + * - send(): idle + * */ + status: 'idle' | 'recording' | 'recording_completed' | 'playing' | 'playing_paused'; + recordingTime: { + currentTime: number; + minDuration: number; + maxDuration: number; + }; + playingTime: { + currentTime: number; + duration: number; + }; +}; + +export interface VoiceMessageInputResult { + actions: { + cancel: () => Promise; + startRecording: () => Promise; + stopRecording: () => Promise; + playPlayer: () => Promise; + pausePlayer: () => Promise; + send: () => Promise; + }; + state: State; +} + +type Props = { + onClose: () => Promise; + onSend: (voiceFile: FileType, duration: number) => void; +}; + +const useVoiceMessageInput = ({ onSend, onClose }: Props): VoiceMessageInputResult => { + const { alert } = useAlert(); + const { STRINGS } = useLocalization(); + const { recorderService, playerService, fileService } = usePlatformService(); + const [status, setStatus] = useState('idle'); + + const [recordingTime, setRecordingTime] = useState({ + currentTime: 0, + minDuration: recorderService.options.minDuration, + maxDuration: recorderService.options.maxDuration, + }); + const [playingTime, setPlayingTime] = useState({ + currentTime: 0, + duration: 0, + }); + + const recordingPath = useRef<{ recordFilePath: string; uri: string }>(); + const getVoiceMessageRecordingPath = () => { + if (!recordingPath.current) throw new Error('No recording path'); + return recordingPath.current; + }; + const setVoiceMessageRecordingPath = (path: { recordFilePath: string; uri: string }) => { + recordingPath.current = path; + }; + + const clear = async () => { + recordingPath.current = undefined; + await playerService.reset(); + await recorderService.reset(); + setRecordingTime({ + currentTime: 0, + minDuration: recorderService.options.minDuration, + maxDuration: recorderService.options.maxDuration, + }); + setPlayingTime({ + currentTime: 0, + duration: 0, + }); + setStatus('idle'); + }; + + return { + state: { + status, + recordingTime, + playingTime, + }, + actions: { + async cancel() { + await clear(); + }, + async startRecording() { + const granted = await recorderService.requestPermission(); + if (!granted) { + await onClose(); + alert({ + title: STRINGS.DIALOG.ALERT_PERMISSIONS_TITLE, + message: STRINGS.DIALOG.ALERT_PERMISSIONS_MESSAGE( + STRINGS.LABELS.PERMISSION_MICROPHONE, + STRINGS.LABELS.PERMISSION_APP_NAME, + ), + buttons: [{ text: STRINGS.DIALOG.ALERT_PERMISSIONS_OK, onPress: () => SBUUtils.openSettings() }], + }); + Logger.error('Failed to request permission for recorder'); + return; + } + + if (matchesOneOf(status, ['idle'])) { + // Before start recording, if player is not idle, reset it. + if (playerService.state !== 'idle') { + await playerService.reset(); + } + + const unsubscribeRecording = recorderService.addRecordingListener(({ currentTime }) => { + setRecordingTime({ + currentTime, + maxDuration: recorderService.options.maxDuration, + minDuration: recorderService.options.minDuration, + }); + setPlayingTime((prev) => ({ ...prev, duration: currentTime })); + }); + + const unsubscribeState = recorderService.addStateListener((state) => { + switch (state) { + case 'recording': + setStatus('recording'); + break; + case 'completed': + setStatus('recording_completed'); + unsubscribeRecording(); + unsubscribeState(); + break; + } + }); + + if (SBUUtils.isExpo()) { + await recorderService.record(); + if (recorderService.uri) { + setVoiceMessageRecordingPath({ recordFilePath: recorderService.uri, uri: recorderService.uri }); + } + } else { + setVoiceMessageRecordingPath(fileService.createRecordFilePath(recorderService.options.extension)); + await recorderService.record(getVoiceMessageRecordingPath().recordFilePath); + } + } + }, + async stopRecording() { + if (matchesOneOf(status, ['recording'])) { + await recorderService.stop(); + } + }, + async playPlayer() { + const granted = await playerService.requestPermission(); + if (!granted) { + alert({ + title: STRINGS.DIALOG.ALERT_PERMISSIONS_TITLE, + message: STRINGS.DIALOG.ALERT_PERMISSIONS_MESSAGE( + STRINGS.LABELS.PERMISSION_DEVICE_STORAGE, + STRINGS.LABELS.PERMISSION_APP_NAME, + ), + buttons: [{ text: STRINGS.DIALOG.ALERT_PERMISSIONS_OK, onPress: () => SBUUtils.openSettings() }], + }); + Logger.error('Failed to request permission for player'); + return; + } + + if (matchesOneOf(status, ['recording_completed', 'playing_paused'])) { + const unsubscribePlayback = playerService.addPlaybackListener(({ currentTime, duration }) => { + setPlayingTime({ currentTime, duration }); + }); + + const unsubscribeState = playerService.addStateListener((state) => { + switch (state) { + case 'playing': + setStatus('playing'); + break; + case 'paused': { + setStatus('playing_paused'); + unsubscribeState(); + unsubscribePlayback(); + break; + } + case 'stopped': { + setStatus('playing_paused'); + unsubscribeState(); + unsubscribePlayback(); + setPlayingTime((prev) => ({ ...prev, currentTime: 0 })); + break; + } + } + }); + + await playerService.play(getVoiceMessageRecordingPath().recordFilePath); + } + }, + async pausePlayer() { + if (matchesOneOf(status, ['playing'])) { + await playerService.pause(); + } + }, + async send() { + if ( + matchesOneOf(status, ['recording', 'recording_completed', 'playing', 'playing_paused']) && + recordingPath.current + ) { + const voiceFile = getVoiceMessageFileObject(recordingPath.current.uri, recorderService.options.extension); + onSend(voiceFile, Math.floor(recordingTime.currentTime)); + await clear(); + } + }, + }, + }; +}; + +export default useVoiceMessageInput; diff --git a/packages/uikit-react-native/src/index.ts b/packages/uikit-react-native/src/index.ts index 1e59a09b0..56d0f0dde 100644 --- a/packages/uikit-react-native/src/index.ts +++ b/packages/uikit-react-native/src/index.ts @@ -69,10 +69,14 @@ export { default as createNativeFileService } from './platform/createFileService export { default as createNativeClipboardService } from './platform/createClipboardService.native'; export { default as createNativeNotificationService } from './platform/createNotificationService.native'; export { default as createNativeMediaService } from './platform/createMediaService.native'; +export { default as createNativePlayerService } from './platform/createPlayerService.native'; +export { default as createNativeRecorderService } from './platform/createRecorderService.native'; export { default as createExpoFileService } from './platform/createFileService.expo'; export { default as createExpoClipboardService } from './platform/createClipboardService.expo'; export { default as createExpoNotificationService } from './platform/createNotificationService.expo'; export { default as createExpoMediaService } from './platform/createMediaService.expo'; +export { default as createExpoPlayerService } from './platform/createPlayerService.expo'; +export { default as createExpoRecorderService } from './platform/createRecorderService.expo'; export * from './platform/types'; /** Feature - shared **/ diff --git a/packages/uikit-react-native/src/libs/SBUUtils.ts b/packages/uikit-react-native/src/libs/SBUUtils.ts index c54de8e78..bda3dec13 100644 --- a/packages/uikit-react-native/src/libs/SBUUtils.ts +++ b/packages/uikit-react-native/src/libs/SBUUtils.ts @@ -37,4 +37,9 @@ export default class SBUUtils { await callback(); } catch (e) {} } + + static isExpo() { + const _g = global ?? window; + return typeof _g === 'object' && 'expo' in _g; + } } diff --git a/packages/uikit-react-native/src/libs/VoiceMessageConfig.ts b/packages/uikit-react-native/src/libs/VoiceMessageConfig.ts new file mode 100644 index 000000000..c6863f7f6 --- /dev/null +++ b/packages/uikit-react-native/src/libs/VoiceMessageConfig.ts @@ -0,0 +1,28 @@ +export interface VoiceMessageConfigInterface { + recorder: { + minDuration: number; + maxDuration: number; + }; +} + +class VoiceMessageConfig { + static DEFAULT = { + RECORDER: { + MIN_DURATION: 1000, + MAX_DURATION: 600 * 1000, + EXTENSION: 'm4a', + + BIT_RATE: 12000, + SAMPLE_RATE: 11025, + CHANNELS: 1, + }, + }; + + constructor(private _config: VoiceMessageConfigInterface) {} + + get recorder() { + return this._config.recorder; + } +} + +export default VoiceMessageConfig; diff --git a/packages/uikit-react-native/src/localization/StringSet.type.ts b/packages/uikit-react-native/src/localization/StringSet.type.ts index a12a1e938..d6256daed 100644 --- a/packages/uikit-react-native/src/localization/StringSet.type.ts +++ b/packages/uikit-react-native/src/localization/StringSet.type.ts @@ -240,6 +240,7 @@ export interface StringSet { PERMISSION_APP_NAME: string; PERMISSION_CAMERA: string; PERMISSION_DEVICE_STORAGE: string; + PERMISSION_MICROPHONE: string; USER_NO_NAME: string; CHANNEL_NO_MEMBERS: string; @@ -249,6 +250,7 @@ export interface StringSet { parentMessage: SendbirdUserMessage | SendbirdFileMessage, currentUserId?: string, ) => string; + MESSAGE_UNAVAILABLE: string; USER_BAR_ME_POSTFIX: string; USER_BAR_OPERATOR: string; @@ -291,6 +293,10 @@ export interface StringSet { /** Channel > Message > Failed **/ CHANNEL_MESSAGE_FAILED_RETRY: string; CHANNEL_MESSAGE_FAILED_REMOVE: string; + + /** Voice message **/ + VOICE_MESSAGE: string; + VOICE_MESSAGE_INPUT_CANCEL: string; }; FILE_VIEWER: { TITLE: (message: SendbirdFileMessage) => string; @@ -331,6 +337,8 @@ export interface StringSet { RESEND_MSG_ERROR: string; DELETE_MSG_ERROR: string; SEND_MSG_ERROR: string; + USER_MUTED_ERROR: string; + CHANNEL_FROZEN_ERROR: string; UPDATE_MSG_ERROR: string; TURN_ON_NOTIFICATIONS_ERROR: string; TURN_OFF_NOTIFICATIONS_ERROR: string; diff --git a/packages/uikit-react-native/src/localization/createBaseStringSet.ts b/packages/uikit-react-native/src/localization/createBaseStringSet.ts index 8c4a18c60..d4a8553bc 100644 --- a/packages/uikit-react-native/src/localization/createBaseStringSet.ts +++ b/packages/uikit-react-native/src/localization/createBaseStringSet.ts @@ -3,16 +3,16 @@ import type { Locale } from 'date-fns'; import type { PartialDeep } from '@sendbird/uikit-utils'; import { getDateSeparatorFormat, - getFileTypeFromMessage, - getGroupChannelLastMessage, getGroupChannelPreviewTime, getGroupChannelTitle, getMessagePreviewBody, getMessagePreviewTime, getMessagePreviewTitle, getMessageTimeFormat, + getMessageType, getOpenChannelParticipants, getOpenChannelTitle, + isVoiceMessage, } from '@sendbird/uikit-utils'; import { UNKNOWN_USER_ID } from '../constants'; @@ -33,6 +33,7 @@ type StringSetCreateOptions = { export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOptions): StringSet => { const USER_NO_NAME = overrides?.LABELS?.USER_NO_NAME ?? '(No name)'; const CHANNEL_NO_MEMBERS = overrides?.LABELS?.CHANNEL_NO_MEMBERS ?? '(No members)'; + return { OPEN_CHANNEL: { HEADER_TITLE: (channel) => getOpenChannelTitle(channel), @@ -191,7 +192,11 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp CHANNEL_PREVIEW_TITLE: (currentUserId, channel) => getGroupChannelTitle(currentUserId, channel, USER_NO_NAME, CHANNEL_NO_MEMBERS), CHANNEL_PREVIEW_TITLE_CAPTION: (channel, locale) => getGroupChannelPreviewTime(channel, locale ?? dateLocale), - CHANNEL_PREVIEW_BODY: (channel) => getGroupChannelLastMessage(channel), + CHANNEL_PREVIEW_BODY: (channel) => { + if (!channel.lastMessage) return ''; + if (isVoiceMessage(channel.lastMessage)) return 'Voice message'; + return getMessagePreviewBody(channel.lastMessage); + }, TYPE_SELECTOR_HEADER_TITLE: 'Channel type', TYPE_SELECTOR_GROUP: 'Group', TYPE_SELECTOR_SUPER_GROUP: 'Super group', @@ -232,7 +237,10 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp HEADER_INPUT_PLACEHOLDER: 'Search', HEADER_RIGHT: 'Search', SEARCH_RESULT_ITEM_TITLE: (message) => getMessagePreviewTitle(message), - SEARCH_RESULT_ITEM_BODY: (message) => getMessagePreviewBody(message), + SEARCH_RESULT_ITEM_BODY: (message) => { + if (isVoiceMessage(message)) return 'Voice message'; + return getMessagePreviewBody(message); + }, SEARCH_RESULT_ITEM_TITLE_CAPTION: (message, locale) => { return getMessagePreviewTime(message.createdAt, locale ?? dateLocale); }, @@ -241,6 +249,7 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp PERMISSION_APP_NAME: 'Application', PERMISSION_CAMERA: 'camera', PERMISSION_DEVICE_STORAGE: 'device storage', + PERMISSION_MICROPHONE: 'microphone', USER_NO_NAME, CHANNEL_NO_MEMBERS, TYPING_INDICATOR_TYPINGS: (users, NO_NAME = USER_NO_NAME) => { @@ -255,6 +264,7 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp const receiverNickname = parent.sender.nickname || USER_NO_NAME; return `${reply.sender.userId !== currentUserId ? senderNickname : 'You'} replied to ${receiverNickname}`; }, + MESSAGE_UNAVAILABLE: 'Message unavailable', USER_BAR_ME_POSTFIX: ' (You)', USER_BAR_OPERATOR: 'Operator', @@ -288,22 +298,26 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp CHANNEL_INPUT_REPLY_PREVIEW_TITLE: (user) => `Reply to ${user.nickname || USER_NO_NAME}`, CHANNEL_INPUT_REPLY_PREVIEW_BODY: (message) => { if (message.isFileMessage()) { - const fileType = getFileTypeFromMessage(message); - switch (fileType) { - case 'image': + const messageType = getMessageType(message); + switch (messageType) { + case 'file.image': return message.type.toLowerCase().includes('gif') ? 'GIF' : 'Photo'; - case 'video': + case 'file.video': return 'Video'; - case 'audio': + case 'file.audio': return 'Audio'; + case 'file.voice': + return 'Voice message'; default: return message.name; } } else if (message.isUserMessage()) { return message.message; } - return 'Unknown message type.'; + return 'Unknown message'; }, + VOICE_MESSAGE: 'Voice message', + VOICE_MESSAGE_INPUT_CANCEL: 'Cancel', ...overrides?.LABELS, }, FILE_VIEWER: { @@ -329,7 +343,7 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp ALERT_DEFAULT_OK: 'OK', ALERT_PERMISSIONS_TITLE: 'Allow access?', ALERT_PERMISSIONS_MESSAGE: (permission, appName = 'Application') => { - return `${appName} need permission to access your ${permission}.`; + return `${appName} needs permission to access your ${permission}.`; }, ALERT_PERMISSIONS_OK: 'Go to settings', PROMPT_DEFAULT_OK: 'Submit', @@ -348,6 +362,8 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp DELETE_MSG_ERROR: "Couldn't delete message.", RESEND_MSG_ERROR: "Couldn't send message.", SEND_MSG_ERROR: "Couldn't send message.", + USER_MUTED_ERROR: "You're muted by the operator.", + CHANNEL_FROZEN_ERROR: 'Channel is frozen.', UPDATE_MSG_ERROR: "Couldn't edit message.", TURN_ON_NOTIFICATIONS_ERROR: "Couldn't turn on notifications.", TURN_OFF_NOTIFICATIONS_ERROR: "Couldn't turn off notifications.", diff --git a/packages/uikit-react-native/src/platform/createFileService.expo.ts b/packages/uikit-react-native/src/platform/createFileService.expo.ts index a98091994..5c363692e 100644 --- a/packages/uikit-react-native/src/platform/createFileService.expo.ts +++ b/packages/uikit-react-native/src/platform/createFileService.expo.ts @@ -142,6 +142,16 @@ const createExpoFileService = ({ } return response.uri; } + createRecordFilePath(customExtension = 'm4a'): { recordFilePath: string; uri: string } { + const basePath = fsModule.cacheDirectory; + if (!basePath) throw new Error('Cannot determine directory'); + + const filename = `record-${Date.now()}.${customExtension}`; + return { + uri: `${basePath}/${filename}`, + recordFilePath: `${basePath}/${filename}`, + }; + } } return new ExpoFileServiceInterface(); diff --git a/packages/uikit-react-native/src/platform/createFileService.native.ts b/packages/uikit-react-native/src/platform/createFileService.native.ts index 814c80b84..afee38f3d 100644 --- a/packages/uikit-react-native/src/platform/createFileService.native.ts +++ b/packages/uikit-react-native/src/platform/createFileService.native.ts @@ -241,6 +241,25 @@ const createNativeFileService = ({ } as const, }; }; + + createRecordFilePath(customExtension = 'm4a'): { recordFilePath: string; uri: string } { + const filename = `record-${Date.now()}.${customExtension}`; + const path = `${fsModule.Dirs.CacheDir}/${filename}`; + return Platform.select({ + ios: { + uri: path, + recordFilePath: filename, + }, + android: { + uri: path.startsWith('file://') ? path : 'file://' + path, + recordFilePath: path, + }, + default: { + uri: path, + recordFilePath: path, + }, + }); + } } return new NativeFileService(); diff --git a/packages/uikit-react-native/src/platform/createPlayerService.expo.tsx b/packages/uikit-react-native/src/platform/createPlayerService.expo.tsx new file mode 100644 index 000000000..ab416c57b --- /dev/null +++ b/packages/uikit-react-native/src/platform/createPlayerService.expo.tsx @@ -0,0 +1,142 @@ +import type * as ExpoAV from 'expo-av'; + +import { matchesOneOf } from '@sendbird/uikit-utils'; + +import expoPermissionGranted from '../utils/expoPermissionGranted'; +import type { PlayerServiceInterface, Unsubscribe } from './types'; + +type Modules = { + avModule: typeof ExpoAV; +}; +type PlaybackListener = Parameters[number]; +type StateListener = Parameters[number]; +const createExpoPlayerService = ({ avModule }: Modules): PlayerServiceInterface => { + const sound = new avModule.Audio.Sound(); + + class VoicePlayer implements PlayerServiceInterface { + uri?: string; + state: PlayerServiceInterface['state'] = 'idle'; + + private readonly playbackSubscribers = new Set(); + private readonly stateSubscribers = new Set(); + + private setState = (state: PlayerServiceInterface['state']) => { + this.state = state; + this.stateSubscribers.forEach((callback) => { + callback(state); + }); + }; + + private setListener = () => { + sound.setProgressUpdateIntervalAsync(100); + sound.setOnPlaybackStatusUpdate((status) => { + if (status.isLoaded) { + if (status.didJustFinish) this.stop(); + if (status.isPlaying) { + this.playbackSubscribers.forEach((callback) => { + callback({ + currentTime: status.positionMillis, + duration: status.durationMillis ?? 0, + stopped: status.didJustFinish, + }); + }); + } + } + }); + }; + + private removeListener = () => { + sound.setOnPlaybackStatusUpdate(null); + }; + + public requestPermission = async (): Promise => { + const status = await avModule.Audio.getPermissionsAsync(); + if (expoPermissionGranted([status])) { + return true; + } else { + const status = await avModule.Audio.requestPermissionsAsync(); + return expoPermissionGranted([status]); + } + }; + + public addPlaybackListener = (callback: PlaybackListener): Unsubscribe => { + this.playbackSubscribers.add(callback); + return () => { + this.playbackSubscribers.delete(callback); + }; + }; + + public addStateListener = (callback: (state: PlayerServiceInterface['state']) => void): Unsubscribe => { + this.stateSubscribers.add(callback); + return () => { + this.stateSubscribers.delete(callback); + }; + }; + + private prepare = async (uri: string) => { + this.setState('preparing'); + await sound.loadAsync({ uri }, { shouldPlay: false }, true); + this.uri = uri; + }; + + public play = async (uri: string): Promise => { + if (matchesOneOf(this.state, ['idle', 'stopped'])) { + try { + await this.prepare(uri); + this.setListener(); + await sound.playAsync(); + this.setState('playing'); + } catch (e) { + this.setState('idle'); + this.uri = undefined; + this.removeListener(); + throw e; + } + } else if (matchesOneOf(this.state, ['paused']) && this.uri === uri) { + try { + this.setListener(); + await sound.playAsync(); + this.setState('playing'); + } catch (e) { + this.removeListener(); + throw e; + } + } + }; + + public pause = async (): Promise => { + if (matchesOneOf(this.state, ['playing'])) { + await sound.pauseAsync(); + this.removeListener(); + this.setState('paused'); + } + }; + + public stop = async (): Promise => { + if (matchesOneOf(this.state, ['playing', 'paused'])) { + await sound.stopAsync(); + await sound.unloadAsync(); + this.removeListener(); + this.setState('stopped'); + } + }; + + public reset = async (): Promise => { + await this.stop(); + this.setState('idle'); + this.uri = undefined; + this.playbackSubscribers.clear(); + this.stateSubscribers.clear(); + }; + + public seek = async (time: number): Promise => { + if (matchesOneOf(this.state, ['playing', 'paused'])) { + await sound.playFromPositionAsync(time); + } + }; + } + + return new VoicePlayer(); +}; + +export default createExpoPlayerService; diff --git a/packages/uikit-react-native/src/platform/createPlayerService.native.tsx b/packages/uikit-react-native/src/platform/createPlayerService.native.tsx new file mode 100644 index 000000000..65abfb65a --- /dev/null +++ b/packages/uikit-react-native/src/platform/createPlayerService.native.tsx @@ -0,0 +1,148 @@ +import { Platform } from 'react-native'; +import type * as RNAudioRecorder from 'react-native-audio-recorder-player'; +import * as Permissions from 'react-native-permissions'; + +import { matchesOneOf, sleep } from '@sendbird/uikit-utils'; + +import type { PlayerServiceInterface, Unsubscribe } from './types'; + +type Modules = { + audioRecorderModule: typeof RNAudioRecorder; + permissionModule: typeof Permissions; +}; +type PlaybackListener = Parameters[number]; +type StateListener = Parameters[number]; +const createNativePlayerService = ({ audioRecorderModule, permissionModule }: Modules): PlayerServiceInterface => { + const module = new audioRecorderModule.default(); + + class VoicePlayer implements PlayerServiceInterface { + uri?: string; + state: PlayerServiceInterface['state'] = 'idle'; + + private readonly playbackSubscribers = new Set(); + private readonly stateSubscribers = new Set(); + + constructor() { + module.setSubscriptionDuration(0.1); + } + + private setState = (state: PlayerServiceInterface['state']) => { + this.state = state; + this.stateSubscribers.forEach((callback) => { + callback(state); + }); + }; + + private setListener = () => { + module.addPlayBackListener((data) => { + const stopped = data.currentPosition >= data.duration; + + if (stopped) this.stop(); + if (this.state === 'playing') { + this.playbackSubscribers.forEach((callback) => { + callback({ currentTime: data.currentPosition, duration: data.duration, stopped }); + }); + } + }); + }; + + private removeListener = () => { + module.removePlayBackListener(); + }; + + public requestPermission = async (): Promise => { + if (Platform.OS === 'android') { + const { READ_MEDIA_AUDIO, READ_EXTERNAL_STORAGE } = permissionModule.PERMISSIONS.ANDROID; + const permission = Platform.Version > 32 ? READ_MEDIA_AUDIO : READ_EXTERNAL_STORAGE; + + const status = await permissionModule.check(permission); + if (status === 'granted') { + return true; + } else { + const status = await permissionModule.request(permission); + return status === 'granted'; + } + } else { + return true; + } + }; + + public addPlaybackListener = (callback: PlaybackListener): Unsubscribe => { + this.playbackSubscribers.add(callback); + return () => { + this.playbackSubscribers.delete(callback); + }; + }; + + public addStateListener = (callback: (state: PlayerServiceInterface['state']) => void): Unsubscribe => { + this.stateSubscribers.add(callback); + return () => { + this.stateSubscribers.delete(callback); + }; + }; + + public play = async (uri: string): Promise => { + if (matchesOneOf(this.state, ['idle', 'stopped'])) { + try { + this.setState('preparing'); + this.uri = uri; + this.setListener(); + + // FIXME: Workaround, `module.startPlayer()` caused a significant frame-drop and prevented the 'preparing' UI transition. + await sleep(0); + await module.startPlayer(uri); + + this.setState('playing'); + } catch (e) { + this.setState('idle'); + this.uri = undefined; + this.removeListener(); + throw e; + } + } else if (matchesOneOf(this.state, ['paused']) && this.uri === uri) { + try { + this.setListener(); + await module.resumePlayer(); + this.setState('playing'); + } catch (e) { + this.removeListener(); + throw e; + } + } + }; + + public pause = async (): Promise => { + if (matchesOneOf(this.state, ['playing'])) { + await module.pausePlayer(); + this.removeListener(); + this.setState('paused'); + } + }; + + public stop = async (): Promise => { + if (matchesOneOf(this.state, ['preparing', 'playing', 'paused'])) { + await module.stopPlayer(); + this.removeListener(); + this.setState('stopped'); + } + }; + + public reset = async (): Promise => { + await this.stop(); + this.setState('idle'); + this.uri = undefined; + this.playbackSubscribers.clear(); + this.stateSubscribers.clear(); + }; + + public seek = async (time: number): Promise => { + if (matchesOneOf(this.state, ['playing', 'paused'])) { + await module.seekToPlayer(time); + } + }; + } + + return new VoicePlayer(); +}; + +export default createNativePlayerService; diff --git a/packages/uikit-react-native/src/platform/createRecorderService.expo.tsx b/packages/uikit-react-native/src/platform/createRecorderService.expo.tsx new file mode 100644 index 000000000..25b2006c7 --- /dev/null +++ b/packages/uikit-react-native/src/platform/createRecorderService.expo.tsx @@ -0,0 +1,160 @@ +import * as ExpoAV from 'expo-av'; +import type { RecordingOptions } from 'expo-av/build/Audio/Recording.types'; +import { Platform } from 'react-native'; + +import { matchesOneOf, sleep } from '@sendbird/uikit-utils'; + +import VoiceMessageConfig from '../libs/VoiceMessageConfig'; +import expoPermissionGranted from '../utils/expoPermissionGranted'; +import type { RecorderServiceInterface, Unsubscribe } from './types'; + +type RecordingListener = Parameters[number]; +type StateListener = Parameters[number]; +type Modules = { + avModule: typeof ExpoAV; +}; +const createExpoRecorderService = ({ avModule }: Modules): RecorderServiceInterface => { + class VoiceRecorder implements RecorderServiceInterface { + public uri: RecorderServiceInterface['uri'] = undefined; + public state: RecorderServiceInterface['state'] = 'idle'; + public options: RecorderServiceInterface['options'] = { + minDuration: VoiceMessageConfig.DEFAULT.RECORDER.MIN_DURATION, + maxDuration: VoiceMessageConfig.DEFAULT.RECORDER.MAX_DURATION, + extension: VoiceMessageConfig.DEFAULT.RECORDER.EXTENSION, + }; + + // NOTE: In Android, even when startRecorder() is awaited, if stop() is executed immediately afterward, an error occurs + private _recordStartedAt = 0; + private _getRecorderStopSafeBuffer = () => { + const minWaitingTime = 500; + const elapsedTime = Date.now() - this._recordStartedAt; + if (elapsedTime > minWaitingTime) return 0; + else return minWaitingTime - elapsedTime; + }; + + private _recorder = new avModule.Audio.Recording(); + private readonly _recordingSubscribers = new Set(); + private readonly _stateSubscribers = new Set(); + private readonly _audioSettings = { + sampleRate: VoiceMessageConfig.DEFAULT.RECORDER.SAMPLE_RATE, + bitRate: VoiceMessageConfig.DEFAULT.RECORDER.BIT_RATE, + numberOfChannels: VoiceMessageConfig.DEFAULT.RECORDER.CHANNELS, + // encoding: mpeg4_aac + }; + private readonly _audioOptions: RecordingOptions = { + android: { + ...this._audioSettings, + extension: `.${this.options.extension}`, + audioEncoder: avModule.Audio.AndroidAudioEncoder.AAC, + outputFormat: avModule.Audio.AndroidOutputFormat.MPEG_4, + }, + ios: { + ...this._audioSettings, + extension: `.${this.options.extension}`, + outputFormat: avModule.Audio.IOSOutputFormat.MPEG4AAC, + audioQuality: avModule.Audio.IOSAudioQuality.HIGH, + }, + web: {}, + }; + + private prepare = async () => { + this.setState('preparing'); + if (Platform.OS === 'ios') { + await avModule.Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true }); + } + + if (this._recorder._isDoneRecording) { + this._recorder = new avModule.Audio.Recording(); + } + this._recorder.setProgressUpdateInterval(100); + this._recorder.setOnRecordingStatusUpdate((status) => { + const completed = status.durationMillis >= this.options.maxDuration; + if (completed) this.stop(); + if (status.isRecording) { + this._recordingSubscribers.forEach((callback) => { + callback({ currentTime: status.durationMillis, completed: completed }); + }); + } + }); + await this._recorder.prepareToRecordAsync(this._audioOptions); + }; + + private setState = (state: RecorderServiceInterface['state']) => { + this.state = state; + this._stateSubscribers.forEach((callback) => { + callback(state); + }); + }; + + public requestPermission = async (): Promise => { + const status = await avModule.Audio.getPermissionsAsync(); + if (expoPermissionGranted([status])) { + return true; + } else { + const status = await avModule.Audio.requestPermissionsAsync(); + return expoPermissionGranted([status]); + } + }; + + public addRecordingListener = (callback: RecordingListener): Unsubscribe => { + this._recordingSubscribers.add(callback); + return () => { + this._recordingSubscribers.delete(callback); + }; + }; + + public addStateListener = (callback: StateListener): Unsubscribe => { + this._stateSubscribers.add(callback); + return () => { + this._stateSubscribers.delete(callback); + }; + }; + + public record = async (): Promise => { + if (matchesOneOf(this.state, ['idle', 'completed'])) { + try { + await this.prepare(); + await this._recorder.startAsync(); + + if (Platform.OS === 'android') { + this._recordStartedAt = Date.now(); + } + + const uri = this._recorder.getURI(); + if (uri) this.uri = uri; + this.setState('recording'); + } catch (e) { + this.setState('idle'); + throw e; + } + } + }; + + public stop = async (): Promise => { + if (matchesOneOf(this.state, ['recording'])) { + if (Platform.OS === 'android') { + const buffer = this._getRecorderStopSafeBuffer(); + if (buffer > 0) await sleep(buffer); + } + + await this._recorder.stopAndUnloadAsync(); + if (Platform.OS === 'ios') { + await avModule.Audio.setAudioModeAsync({ allowsRecordingIOS: false, playsInSilentModeIOS: false }); + } + this.setState('completed'); + } + }; + + public reset = async (): Promise => { + await this.stop(); + this.uri = undefined; + this._recordingSubscribers.clear(); + this._recorder = new avModule.Audio.Recording(); + this.setState('idle'); + }; + } + + return new VoiceRecorder(); +}; + +export default createExpoRecorderService; diff --git a/packages/uikit-react-native/src/platform/createRecorderService.native.tsx b/packages/uikit-react-native/src/platform/createRecorderService.native.tsx new file mode 100644 index 000000000..094e9ed54 --- /dev/null +++ b/packages/uikit-react-native/src/platform/createRecorderService.native.tsx @@ -0,0 +1,170 @@ +import { Platform } from 'react-native'; +import * as RNAudioRecorder from 'react-native-audio-recorder-player'; +import * as Permissions from 'react-native-permissions'; +import { Permission } from 'react-native-permissions/src/types'; + +import { matchesOneOf, sleep } from '@sendbird/uikit-utils'; + +import VoiceMessageConfig from '../libs/VoiceMessageConfig'; +import nativePermissionGranted from '../utils/nativePermissionGranted'; +import type { RecorderServiceInterface, Unsubscribe } from './types'; + +type RecordingListener = Parameters[number]; +type StateListener = Parameters[number]; +type Modules = { + audioRecorderModule: typeof RNAudioRecorder; + permissionModule: typeof Permissions; +}; +const createNativeRecorderService = ({ audioRecorderModule, permissionModule }: Modules): RecorderServiceInterface => { + const module = new audioRecorderModule.default(); + + class VoiceRecorder implements RecorderServiceInterface { + public uri: RecorderServiceInterface['uri'] = undefined; + public state: RecorderServiceInterface['state'] = 'idle'; + public options: RecorderServiceInterface['options'] = { + minDuration: VoiceMessageConfig.DEFAULT.RECORDER.MIN_DURATION, + maxDuration: VoiceMessageConfig.DEFAULT.RECORDER.MAX_DURATION, + extension: VoiceMessageConfig.DEFAULT.RECORDER.EXTENSION, + }; + + // NOTE: In Android, even when startRecorder() is awaited, if stop() is executed immediately afterward, an error occurs + private _recordStartedAt = 0; + private _getRecorderStopSafeBuffer = () => { + const minWaitingTime = 500; + const elapsedTime = Date.now() - this._recordStartedAt; + if (elapsedTime > minWaitingTime) return 0; + else return minWaitingTime - elapsedTime; + }; + + private readonly recordingSubscribers = new Set(); + private readonly stateSubscribers = new Set(); + private readonly audioSettings = { + sampleRate: VoiceMessageConfig.DEFAULT.RECORDER.SAMPLE_RATE, + bitRate: VoiceMessageConfig.DEFAULT.RECORDER.BIT_RATE, + audioChannels: VoiceMessageConfig.DEFAULT.RECORDER.CHANNELS, + // encoding: mpeg4_aac + }; + private readonly audioOptions = Platform.select({ + android: { + AudioEncodingBitRateAndroid: this.audioSettings.bitRate, + AudioChannelsAndroid: this.audioSettings.audioChannels, + AudioSamplingRateAndroid: this.audioSettings.sampleRate, + AudioEncoderAndroid: audioRecorderModule.AudioEncoderAndroidType.AAC, + OutputFormatAndroid: audioRecorderModule.OutputFormatAndroidType.MPEG_4, + AudioSourceAndroid: audioRecorderModule.AudioSourceAndroidType.VOICE_RECOGNITION, + }, + ios: { + AVEncoderBitRateKeyIOS: this.audioSettings.bitRate, + AVNumberOfChannelsKeyIOS: this.audioSettings.audioChannels, + AVSampleRateKeyIOS: this.audioSettings.sampleRate, + AVFormatIDKeyIOS: audioRecorderModule.AVEncodingOption.mp4, // same with aac + AVEncoderAudioQualityKeyIOS: audioRecorderModule.AVEncoderAudioQualityIOSType.high, + }, + default: {}, + }); + + constructor() { + module.setSubscriptionDuration(0.1); + module.addRecordBackListener((data) => { + const completed = data.currentPosition >= this.options.maxDuration; + + if (completed) this.stop(); + if (this.state === 'recording') { + this.recordingSubscribers.forEach((callback) => { + callback({ currentTime: data.currentPosition, completed }); + }); + } + }); + } + + private setState = (state: RecorderServiceInterface['state']) => { + this.state = state; + this.stateSubscribers.forEach((callback) => { + callback(state); + }); + }; + + public requestPermission = async (): Promise => { + const permission: Permission[] | undefined = Platform.select({ + android: [permissionModule.PERMISSIONS.ANDROID.RECORD_AUDIO], + ios: [permissionModule.PERMISSIONS.IOS.MICROPHONE], + windows: [permissionModule.PERMISSIONS.WINDOWS.MICROPHONE], + default: undefined, + }); + + if (Platform.OS === 'android' && Platform.Version <= 28) { + permission?.push(permissionModule.PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE); + } + + if (permission) { + const status = await permissionModule.checkMultiple(permission); + if (nativePermissionGranted(status)) { + return true; + } else { + const status = await permissionModule.requestMultiple(permission); + return nativePermissionGranted(status); + } + } else { + return true; + } + }; + + public addRecordingListener = (callback: RecordingListener): Unsubscribe => { + this.recordingSubscribers.add(callback); + return () => { + this.recordingSubscribers.delete(callback); + }; + }; + + public addStateListener = (callback: StateListener): Unsubscribe => { + this.stateSubscribers.add(callback); + return () => { + this.stateSubscribers.delete(callback); + }; + }; + + public record = async (uri: string): Promise => { + if (matchesOneOf(this.state, ['idle', 'completed'])) { + try { + this.setState('preparing'); + await module.startRecorder(uri, { + ...this.audioOptions, + }); + + if (Platform.OS === 'android') { + this._recordStartedAt = Date.now(); + } + + this.uri = uri; + this.setState('recording'); + } catch (e) { + this.setState('idle'); + throw e; + } + } + }; + + public stop = async (): Promise => { + if (matchesOneOf(this.state, ['recording'])) { + if (Platform.OS === 'android') { + const buffer = this._getRecorderStopSafeBuffer(); + if (buffer > 0) await sleep(buffer); + } + + await module.stopRecorder(); + this.setState('completed'); + } + }; + + public reset = async (): Promise => { + await this.stop(); + this.uri = undefined; + this.recordingSubscribers.clear(); + this.setState('idle'); + }; + } + + return new VoiceRecorder(); +}; + +export default createNativeRecorderService; diff --git a/packages/uikit-react-native/src/platform/types.ts b/packages/uikit-react-native/src/platform/types.ts index 91fdb68d5..d27589194 100644 --- a/packages/uikit-react-native/src/platform/types.ts +++ b/packages/uikit-react-native/src/platform/types.ts @@ -60,6 +60,7 @@ export interface FileSystemServiceInterface { // - Supports opening documents in place // - Application supports iTunes file sharing save(options?: SaveOptions): Promise; + createRecordFilePath(customExtension?: string): { recordFilePath: string; uri: string }; } // ---------- MediaService ---------- // @@ -105,3 +106,113 @@ export interface MediaServiceInterface { getVideoThumbnail(options: GetVideoThumbnailOptions): GetVideoThumbnailResult; compressImage(options: CompressImageOptions): CompressImageResult; } + +// ---------- PlayerService ---------- // +export interface PlayerServiceInterface { + uri?: string; + state: 'idle' | 'preparing' | 'playing' | 'paused' | 'stopped'; + + /** + * Check and request permission for the player. + * */ + requestPermission(): Promise; + + /** + * Add a playback listener. + * */ + addPlaybackListener( + callback: (params: { currentTime: number; duration: number; stopped: boolean }) => void, + ): Unsubscribe; + + /** + * Add a state listener. + * */ + addStateListener(callback: (state: PlayerServiceInterface['state']) => void): Unsubscribe; + + /** + * State transition: + * [idle, stopped] to [playing] - start from the beginning + * [paused] to [playing] - resume + * */ + play(uri: string): Promise; + + /** + * State transition: + * [playing] to [paused] + * */ + pause(): Promise; + + /** + * State transition: + * [preparing, playing, paused] to [stop] + * */ + stop(): Promise; + + /** + * State transition: + * [*] to [idle] + * */ + reset(): Promise; + + /** + * Seek time, only available when the state is [playing, paused]. + * */ + seek(time: number): Promise; +} + +// ---------- RecorderService ---------- // +export interface RecorderOptions { + /** + * Minimum recording duration (milliseconds). + * */ + minDuration: number; + + /** + * Maximum recording duration (milliseconds). + * */ + maxDuration: number; + + /** + * File extension for recorded audio file + * */ + extension: string; +} + +export interface RecorderServiceInterface { + uri?: string; + options: RecorderOptions; + state: 'idle' | 'preparing' | 'recording' | 'completed'; + + /** + * Check and request permission for the recorder. + * */ + requestPermission(): Promise; + + /** + * Add recording listener. + * */ + addRecordingListener(callback: (params: { currentTime: number; completed: boolean }) => void): Unsubscribe; + + /** + * Add state listener. + * */ + addStateListener(callback: (state: RecorderServiceInterface['state']) => void): Unsubscribe; + + /** + * State transition: + * [idle, completed] to [recording] + * */ + record(uri?: string): Promise; + + /** + * State transition: + * [recording] to [completed] + * */ + stop(): Promise; + + /** + * State transition: + * [*] to [idle] + * */ + reset(): Promise; +} diff --git a/packages/uikit-testing-tools/package.json b/packages/uikit-testing-tools/package.json index 3ab89d36e..de6a38180 100644 --- a/packages/uikit-testing-tools/package.json +++ b/packages/uikit-testing-tools/package.json @@ -20,6 +20,7 @@ "sideEffects": false, "scripts": { "test": "jest", + "build": "bob build", "clean": "del lib" }, "repository": { diff --git a/packages/uikit-testing-tools/src/mocks/createMockChannel.ts b/packages/uikit-testing-tools/src/mocks/createMockChannel.ts index cb6369555..5d6425249 100644 --- a/packages/uikit-testing-tools/src/mocks/createMockChannel.ts +++ b/packages/uikit-testing-tools/src/mocks/createMockChannel.ts @@ -30,6 +30,7 @@ import type { PinnedMessageListQuery, PinnedMessageListQueryParams, } from '@sendbird/chat/lib/__definition'; +import { FileCompat } from '@sendbird/chat/lib/__definition'; import { MessageChangelogs, MessageRequestHandler, @@ -49,6 +50,7 @@ import type { SendbirdGroupChannel, SendbirdMember, SendbirdMessageCollection, + SendbirdMultipleFilesMessage, SendbirdOpenChannel, SendbirdRestrictedUser, SendbirdUserMessage, @@ -123,14 +125,9 @@ class MockChannel implements GetMockProps => { + refresh = jest.fn(async (): Promise => { this.params.sdk?.__throwIfFailureTest(); - - if (this.isGroupChannel()) { - return this.asGroupChannel() as never; - } else { - return this.asOpenChannel() as never; - } + return this; }); enter = jest.fn(async () => { @@ -352,6 +349,23 @@ class MockChannel implements GetMockProps { throw new Error('Method not implemented.'); } + resendMessage( + failedMessage: SendbirdMultipleFilesMessage, + ): MultipleFilesMessageRequestHandler; + resendMessage(failedMessage: SendbirdFileMessage, file?: FileCompat): MessageRequestHandler; + resendMessage(failedMessage: SendbirdUserMessage): MessageRequestHandler; + resendMessage(): unknown { + throw new Error('Method not implemented.'); + } + copyMessage( + channel: SendbirdGroupChannel, + message: SendbirdMultipleFilesMessage, + ): MessageRequestHandler; + copyMessage(channel: SendbirdBaseChannel, message: SendbirdFileMessage): MessageRequestHandler; + copyMessage(channel: SendbirdBaseChannel, message: SendbirdUserMessage): MessageRequestHandler; + copyMessage(): unknown { + throw new Error('Method not implemented.'); + } sendUserMessage(): MessageRequestHandler { throw new Error('Method not implemented.'); } diff --git a/packages/uikit-testing-tools/src/mocks/createMockMessage.ts b/packages/uikit-testing-tools/src/mocks/createMockMessage.ts index 43f4aa3f2..806b34d67 100644 --- a/packages/uikit-testing-tools/src/mocks/createMockMessage.ts +++ b/packages/uikit-testing-tools/src/mocks/createMockMessage.ts @@ -1,4 +1,5 @@ import { ChannelType } from '@sendbird/chat'; +import { NotificationData } from '@sendbird/chat/feedChannel'; import { MessageType, SendingStatus } from '@sendbird/chat/message'; import type { SendbirdAdminMessage, @@ -65,6 +66,7 @@ class MockMessage implements GetMockProps { appleCriticalAlertOptions = null; scheduledInfo = null; extendedMessage = {}; + notificationData: NotificationData | null = null; isFileMessage(): this is SendbirdFileMessage { return this.messageType === MessageType.FILE && !Object.prototype.hasOwnProperty.call(this, 'fileInfoList'); diff --git a/packages/uikit-utils/package.json b/packages/uikit-utils/package.json index c55c8dbbc..b04f9f9ce 100644 --- a/packages/uikit-utils/package.json +++ b/packages/uikit-utils/package.json @@ -25,7 +25,8 @@ "test": "jest", "build": "bob build", "clean": "del lib", - "publish:next": "npm publish --tag next" + "publish:next": "npm publish --tag next", + "publish:local": "yalc publish" }, "repository": { "type": "git", diff --git a/packages/uikit-utils/src/hooks/react-native.ts b/packages/uikit-utils/src/hooks/react-native.ts index 5675f6944..e9fa8f7d0 100644 --- a/packages/uikit-utils/src/hooks/react-native.ts +++ b/packages/uikit-utils/src/hooks/react-native.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { AppState, AppStateEvent, AppStateStatus } from 'react-native'; import { EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -44,3 +44,26 @@ export const useAppState = (type: AppStateEvent, listener: AppStateListener) => }; }, []); }; + +/** + * To display a new modal in React-Native, you should ensure that a new modal is opened only after the existing modal has been dismissed to avoid conflicts. + * To achieve this, you can use a deferred onClose that can be awaited until the onDismiss is called. + * */ +export const useDeferredModalState = () => { + const resolveRef = useRef<(value: void) => void>(); + const [visible, setVisible] = useState(false); + + return { + onClose: () => { + return new Promise((resolve) => { + resolveRef.current = resolve; + setVisible(false); + }); + }, + onDismiss: () => { + resolveRef.current?.(); + }, + visible, + setVisible, + }; +}; diff --git a/packages/uikit-utils/src/sendbird/message.ts b/packages/uikit-utils/src/sendbird/message.ts index 65401a651..97b98b318 100644 --- a/packages/uikit-utils/src/sendbird/message.ts +++ b/packages/uikit-utils/src/sendbird/message.ts @@ -1,6 +1,6 @@ import { MessageSearchOrder } from '@sendbird/chat/message'; -import { getFileExtension, getFileType } from '../shared/file'; +import { getFileExtension, getFileType, parseMimeType } from '../shared/file'; import type { SendbirdBaseChannel, SendbirdBaseMessage, @@ -161,7 +161,7 @@ export type MessageType = | 'file' | 'unknown' | `user.${'opengraph'}` - | `file.${'image' | 'video' | 'audio'}`; + | `file.${'image' | 'video' | 'audio' | 'voice'}`; export type FileType = 'file' | 'image' | 'audio' | 'video'; @@ -221,6 +221,10 @@ export function getMessageType(message: SendbirdMessage): MessageType { } if (message.isFileMessage()) { + if (isVoiceMessage(message)) { + return 'file.voice'; + } + const fileType = getFileTypeFromMessage(message); switch (fileType) { case 'image': @@ -247,3 +251,26 @@ export function getDefaultMessageSearchQueryParams(channel: SendbirdGroupChannel order: MessageSearchOrder.TIMESTAMP, }; } + +const SBU_MIME_PARAM_KEY = 'sbu_type'; +const SBU_MIME_PARAM_VOICE_MESSAGE_VALUE = 'voice'; + +export function isVoiceMessage(message: SendbirdMessage): message is SendbirdFileMessage { + if (!message.isFileMessage()) return false; + + const { parameters } = parseMimeType(message.type); + return !!parameters[SBU_MIME_PARAM_KEY] && parameters[SBU_MIME_PARAM_KEY] === SBU_MIME_PARAM_VOICE_MESSAGE_VALUE; +} + +export function getVoiceMessageFileObject(uri: string, extension = 'm4a') { + return { + uri, + type: getVoiceMessageMimeType(extension), + name: `Voice_message.${extension}`, + size: 0, + }; +} + +export function getVoiceMessageMimeType(extension = 'm4a') { + return `audio/${extension};${SBU_MIME_PARAM_KEY}=${SBU_MIME_PARAM_VOICE_MESSAGE_VALUE}`; +} diff --git a/packages/uikit-utils/src/shared/file.ts b/packages/uikit-utils/src/shared/file.ts index 0daa8c112..cd9ba7ca4 100644 --- a/packages/uikit-utils/src/shared/file.ts +++ b/packages/uikit-utils/src/shared/file.ts @@ -18,6 +18,7 @@ const EXTENSION_MIME_MAP = { // Audio 'aac': 'audio/aac', + 'm4a': 'audio/m4a', 'mid': 'audio/midi', 'mp3': 'audio/mpeg', 'ogg': 'audio/ogg', diff --git a/packages/uikit-utils/src/shared/index.ts b/packages/uikit-utils/src/shared/index.ts index 8dcb54e4f..4f340a343 100644 --- a/packages/uikit-utils/src/shared/index.ts +++ b/packages/uikit-utils/src/shared/index.ts @@ -51,3 +51,11 @@ export function mergeObjectArrays(A: T[], B: T[], key: keyof T): T[] { return newArr; } + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function matchesOneOf(value: V, candidates: C) { + return candidates.includes(value); +} diff --git a/packages/uikit-utils/src/ui-format/common.ts b/packages/uikit-utils/src/ui-format/common.ts index 5e8183d67..ff34e9ace 100644 --- a/packages/uikit-utils/src/ui-format/common.ts +++ b/packages/uikit-utils/src/ui-format/common.ts @@ -125,3 +125,30 @@ export const getMessagePreviewTime = (timestamp: number, locale?: Locale) => { return format(timestamp, 'yyyy/MM/dd', { locale }); }; + +/** + * milliseconds to 00:00 format + * */ +export const millsToMMSS = (mills: number) => { + let seconds = Math.floor(Math.max(0, mills) / 1000); + const minutes = Math.floor(seconds / 60); + seconds = seconds % 60; + + const mm = String(minutes).padStart(2, '0'); + const ss = String(seconds).padStart(2, '0'); + + return `${mm}:${ss}`; +}; + +/** + * milliseconds to 0:00 format + * */ +export const millsToMSS = (mills: number) => { + let seconds = Math.floor(Math.max(0, mills) / 1000); + const minutes = Math.floor(seconds / 60); + seconds = seconds % 60; + + const ss = String(seconds).padStart(2, '0'); + + return `${minutes}:${ss}`; +}; diff --git a/sample/android/app/src/main/AndroidManifest.xml b/sample/android/app/src/main/AndroidManifest.xml index a88ebdde9..bdfbd0386 100644 --- a/sample/android/app/src/main/AndroidManifest.xml +++ b/sample/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,8 @@ + + diff --git a/sample/ios/Podfile.lock b/sample/ios/Podfile.lock index 425cdda9e..96d2201b0 100644 --- a/sample/ios/Podfile.lock +++ b/sample/ios/Podfile.lock @@ -435,6 +435,8 @@ PODS: - React-jsi (= 0.67.5) - React-logger (= 0.67.5) - React-perflogger (= 0.67.5) + - RNAudioRecorderPlayer (3.5.4): + - React-Core - RNCAsyncStorage (1.18.1): - React-Core - RNCClipboard (1.11.2): @@ -545,6 +547,7 @@ DEPENDENCIES: - React-RCTVibration (from `../../node_modules/react-native/Libraries/Vibration`) - React-runtimeexecutor (from `../../node_modules/react-native/ReactCommon/runtimeexecutor`) - ReactCommon/turbomodule/core (from `../../node_modules/react-native/ReactCommon`) + - RNAudioRecorderPlayer (from `../../node_modules/react-native-audio-recorder-player`) - "RNCAsyncStorage (from `../../node_modules/@react-native-async-storage/async-storage`)" - "RNCClipboard (from `../../node_modules/@react-native-clipboard/clipboard`)" - "RNCPushNotificationIOS (from `../../node_modules/@react-native-community/push-notification-ios`)" @@ -677,6 +680,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/react-native/ReactCommon/runtimeexecutor" ReactCommon: :path: "../../node_modules/react-native/ReactCommon" + RNAudioRecorderPlayer: + :path: "../../node_modules/react-native-audio-recorder-player" RNCAsyncStorage: :path: "../../node_modules/@react-native-async-storage/async-storage" RNCClipboard: @@ -770,6 +775,7 @@ SPEC CHECKSUMS: React-RCTVibration: c3b8a3245267a3849b0c7cb91a37606bf5f3aa65 React-runtimeexecutor: 434efc9e5b6d0f14f49867f130b39376c971c1aa ReactCommon: a30c2448e5a88bae6fcb0e3da124c14ae493dac1 + RNAudioRecorderPlayer: 1ff1ddabbe89031df12ba9d5ac2fa724697c5c48 RNCAsyncStorage: b90b71f45b8b97be43bc4284e71a6af48ac9f547 RNCClipboard: 3f0451a8100393908bea5c5c5b16f96d45f30bfc RNCPushNotificationIOS: 64218f3c776c03d7408284a819b2abfda1834bc8 diff --git a/sample/package.json b/sample/package.json index 8190bf48e..826755ebb 100644 --- a/sample/package.json +++ b/sample/package.json @@ -8,7 +8,7 @@ "start": "react-native start", "test": "jest", "lint": "eslint . --ext .js,.jsx,.ts,.tsx", - "adb:reverse": "adb start-server && adb reverse tcp:8081 tcp:8081", + "adb:reverse": "adb start-server && sh ./scripts/adb-reverse.sh", "get-stories": "sb-rn-get-stories", "storybook-watcher": "sb-rn-watcher" }, @@ -40,6 +40,7 @@ "fbjs": "^3.0.4", "react": "17.0.2", "react-native": "0.67.5", + "react-native-audio-recorder-player": "^3.6.0", "react-native-create-thumbnail": "^1.5.1", "react-native-document-picker": "^8.0.0", "react-native-fast-image": "^8.5.11", diff --git a/sample/scripts/adb-reverse.sh b/sample/scripts/adb-reverse.sh new file mode 100644 index 000000000..a92dd07a5 --- /dev/null +++ b/sample/scripts/adb-reverse.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +devices=$(adb devices | awk '$2 == "device" {print $1}') + +for device_id in $devices +do + adb -s $device_id reverse tcp:8081 tcp:8081 +done diff --git a/sample/src/App.tsx b/sample/src/App.tsx index e4bcaf446..e617fe5a1 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -9,15 +9,7 @@ import { DarkUIKitTheme, LightUIKitTheme } from '@sendbird/uikit-react-native-fo // import LogView from './components/LogView'; import { APP_ID } from './env'; -import { - ClipboardService, - FileService, - GetTranslucent, - MediaService, - NotificationService, - RootStack, - SetSendbirdSDK, -} from './factory'; +import { GetTranslucent, RootStack, SetSendbirdSDK, platformServices } from './factory'; import useAppearance from './hooks/useAppearance'; import { Routes, navigationActions, navigationRef } from './libs/navigation'; import { notificationHandler } from './libs/notification'; @@ -82,12 +74,7 @@ const App = () => { onInitialized: SetSendbirdSDK, enableAutoPushTokenRegistration: true, }} - platformServices={{ - file: FileService, - notification: NotificationService, - clipboard: ClipboardService, - media: MediaService, - }} + platformServices={platformServices} styles={{ defaultHeaderTitleAlign: 'left', //'center', theme: isLightTheme ? LightUIKitTheme : DarkUIKitTheme, @@ -208,7 +195,7 @@ const Navigations = () => { * * @example How to customize UIKit global navigation * ``` - * const UseReactNavigationHeader: HeaderStyleContextType['HeaderComponent'] = ({ + * const ReactNavigationHeader: HeaderStyleContextType['HeaderComponent'] = ({ * title, * right, * left, @@ -233,9 +220,7 @@ const Navigations = () => { * return ( * * * @@ -245,4 +230,39 @@ const Navigations = () => { * ``` * */ +/** + * @example How to implement custom local cache storage + * ``` + * import { MMKV } from 'react-native-mmkv'; + * import { LocalCacheStorage } from '@sendbird/uikit-react-native'; + * + * const mmkvStorage = new MMKV(); + * const localCacheStorage: LocalCacheStorage = { + * async getAllKeys() { + * return mmkvStorage.getAllKeys(); + * }, + * async setItem(key: string, value: string) { + * return mmkvStorage.set(key, value); + * }, + * async getItem(key: string) { + * return mmkvStorage.getString(key) ?? null; + * }, + * async removeItem(key: string) { + * return mmkvStorage.delete(key); + * }, + * }; + * + * const App = () => { + * return ( + * + * + * + * ); + * }; + * ``` + * */ + export default App; diff --git a/sample/src/factory/index.ts b/sample/src/factory/index.ts index 9ef462885..b44f1dc24 100644 --- a/sample/src/factory/index.ts +++ b/sample/src/factory/index.ts @@ -4,6 +4,7 @@ import Clipboard from '@react-native-clipboard/clipboard'; import RNFBMessaging from '@react-native-firebase/messaging'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { Platform, StatusBar } from 'react-native'; +import * as AudioRecorderPlayer from 'react-native-audio-recorder-player'; import * as CreateThumbnail from 'react-native-create-thumbnail'; import * as DocumentPicker from 'react-native-document-picker'; import * as FileAccess from 'react-native-file-access'; @@ -12,10 +13,13 @@ import * as Permissions from 'react-native-permissions'; import Video from 'react-native-video'; import { + SendbirdUIKitContainerProps, createNativeClipboardService, createNativeFileService, createNativeMediaService, createNativeNotificationService, + createNativePlayerService, + createNativeRecorderService, } from '@sendbird/uikit-react-native'; import { Logger, SendbirdChatSDK } from '@sendbird/uikit-utils'; @@ -26,23 +30,33 @@ export const GetSendbirdSDK = () => AppSendbirdSDK; export const SetSendbirdSDK = (sdk: SendbirdChatSDK) => (AppSendbirdSDK = sdk); export const RootStack = createNativeStackNavigator(); -export const ClipboardService = createNativeClipboardService(Clipboard); -export const NotificationService = createNativeNotificationService({ - messagingModule: RNFBMessaging, - permissionModule: Permissions, -}); -export const FileService = createNativeFileService({ - imagePickerModule: ImagePicker, - documentPickerModule: DocumentPicker, - permissionModule: Permissions, - fsModule: FileAccess, - mediaLibraryModule: CameraRoll, -}); -export const MediaService = createNativeMediaService({ - VideoComponent: Video, - thumbnailModule: CreateThumbnail, - imageResizerModule: ImageResizer, -}); +export const platformServices: SendbirdUIKitContainerProps['platformServices'] = { + clipboard: createNativeClipboardService(Clipboard), + notification: createNativeNotificationService({ + messagingModule: RNFBMessaging, + permissionModule: Permissions, + }), + file: createNativeFileService({ + imagePickerModule: ImagePicker, + documentPickerModule: DocumentPicker, + permissionModule: Permissions, + fsModule: FileAccess, + mediaLibraryModule: CameraRoll, + }), + media: createNativeMediaService({ + VideoComponent: Video, + thumbnailModule: CreateThumbnail, + imageResizerModule: ImageResizer, + }), + player: createNativePlayerService({ + audioRecorderModule: AudioRecorderPlayer, + permissionModule: Permissions, + }), + recorder: createNativeRecorderService({ + audioRecorderModule: AudioRecorderPlayer, + permissionModule: Permissions, + }), +}; export const GetTranslucent = (state = true) => { Platform.OS === 'android' && StatusBar.setTranslucent(state); diff --git a/sample/src/screens/uikit/SettingsScreen.tsx b/sample/src/screens/uikit/SettingsScreen.tsx index 2f1a319a5..d344c9d83 100644 --- a/sample/src/screens/uikit/SettingsScreen.tsx +++ b/sample/src/screens/uikit/SettingsScreen.tsx @@ -80,7 +80,7 @@ const SettingsScreen = () => { if (!photo) return; - await updateCurrentUserInfo(sdk.currentUser.nickname, photo); + await updateCurrentUserInfo(sdk.currentUser?.nickname, photo); }, }, { @@ -106,7 +106,7 @@ const SettingsScreen = () => { }); if (!files || !files[0]) return; - await updateCurrentUserInfo(sdk.currentUser.nickname, files[0]); + await updateCurrentUserInfo(sdk.currentUser?.nickname, files[0]); }, }, ], diff --git a/yarn.lock b/yarn.lock index b9fcec25e..ce9f4cca9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3372,10 +3372,10 @@ dependencies: nanoid "^3.1.23" -"@sendbird/chat@4.9.8", "@sendbird/chat@^4.9.8": - version "4.9.8" - resolved "https://registry.yarnpkg.com/@sendbird/chat/-/chat-4.9.8.tgz#c70378fc1da684ea0c532b2e55bd8b1f3939838c" - integrity sha512-tRTr/5iXl4Fhxon6T8yO2xaRxvyO2KctFjqdEHVLW9JuhWEAWiWZsY/5Lbxivi/Pt+ebiQuBhl/af3V1WOJDCg== +"@sendbird/chat@4.9.10", "@sendbird/chat@^4.9.8": + version "4.9.10" + resolved "https://registry.yarnpkg.com/@sendbird/chat/-/chat-4.9.10.tgz#b92a3f5d4ec895acd5c2ebbadf4fa10aa0ebda10" + integrity sha512-WR2DoFu190/8Z6UhSjeNNQE6vIhrfBgwH5hdL0Jb4mVQNm8bSBd9jbwlDzvBAZCAqHxvXwNU+rH2VlKvaw84wQ== "@sendbird/react-native-scrollview-enhancer@^0.2.1": version "0.2.1" @@ -6801,6 +6801,11 @@ domexception@^2.0.1: dependencies: webidl-conversions "^5.0.0" +dooboolab-welcome@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/dooboolab-welcome/-/dooboolab-welcome-1.3.2.tgz#4928595312f0429b4ea1b485ba8767bae6acdab7" + integrity sha512-2NbMaIIURElxEf/UAoVUFlXrO+7n/FRhLCiQlk4fkbGRh9cJ3/f8VEMPveR9m4Ug2l2Zey+UCXjd6EcBqHJ5bw== + dot-prop@^5.1.0: version "5.3.0" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" @@ -8067,7 +8072,7 @@ fs-extra@^11.1.0: jsonfile "^6.0.1" universalify "^2.0.0" -fs-extra@^8.1.0: +fs-extra@^8.0.1, fs-extra@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== @@ -8720,6 +8725,13 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha512-DUNFN5j7Tln0D+TxzloUjKB+CtVu6myn0JEFak6dG18mNt9YkQ6lzGCdafwofISZ1lLF3xRHJ98VKy9ynkcFaA== +ignore-walk@^3.0.3: + version "3.0.4" + resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.4.tgz#c9a09f69b7c7b479a5d74ac1a3c0d4236d2a6335" + integrity sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ== + dependencies: + minimatch "^3.0.4" + ignore-walk@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-5.0.1.tgz#5f199e23e1288f518d90358d461387788a154776" @@ -8814,6 +8826,11 @@ ini@^1.3.2, ini@^1.3.4: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== +ini@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" + integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== + init-package-json@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/init-package-json/-/init-package-json-3.0.2.tgz#f5bc9bac93f2bdc005778bc2271be642fecfcd69" @@ -11733,6 +11750,16 @@ npm-package-arg@^9.0.0, npm-package-arg@^9.0.1: semver "^7.3.5" validate-npm-package-name "^4.0.0" +npm-packlist@^2.1.5: + version "2.2.2" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-2.2.2.tgz#076b97293fa620f632833186a7a8f65aaa6148c8" + integrity sha512-Jt01acDvJRhJGthnUJVF/w6gumWOZxO7IkpY/lsX9//zqQgnF7OJaxgQXcerd4uQOLu7W5bkb4mChL9mdfm+Zg== + dependencies: + glob "^7.1.6" + ignore-walk "^3.0.3" + npm-bundled "^1.1.1" + npm-normalize-package-bin "^1.0.1" + npm-packlist@^5.1.0, npm-packlist@^5.1.1: version "5.1.3" resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-5.1.3.tgz#69d253e6fd664b9058b85005905012e00e69274b" @@ -12911,6 +12938,13 @@ react-is@^17.0.1, react-is@^17.0.2: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +react-native-audio-recorder-player@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/react-native-audio-recorder-player/-/react-native-audio-recorder-player-3.6.0.tgz#3e64113197c6e0e988338ac31b3edf058521a928" + integrity sha512-eSg9kqihUACXWaqiUUxrjKfgkppmi49PAvl7GEq2hPogwuDAs/eeimT9mPYFKpp5UHCxDIdmMcG7pcMGRynz5w== + dependencies: + dooboolab-welcome "^1.3.2" + react-native-builder-bob@^0.18.0, react-native-builder-bob@^0.18.2: version "0.18.3" resolved "https://registry.yarnpkg.com/react-native-builder-bob/-/react-native-builder-bob-0.18.3.tgz#fb4d3e50a3b2290db3c88de6d40403ac7eb9f85f" @@ -15562,6 +15596,20 @@ y18n@^5.0.5: resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== +yalc@^1.0.0-pre.53: + version "1.0.0-pre.53" + resolved "https://registry.yarnpkg.com/yalc/-/yalc-1.0.0-pre.53.tgz#c51db2bb924a6908f4cb7e82af78f7e5606810bc" + integrity sha512-tpNqBCpTXplnduzw5XC+FF8zNJ9L/UXmvQyyQj7NKrDNavbJtHvzmZplL5ES/RCnjX7JR7W9wz5GVDXVP3dHUQ== + dependencies: + chalk "^4.1.0" + detect-indent "^6.0.0" + fs-extra "^8.0.1" + glob "^7.1.4" + ignore "^5.0.4" + ini "^2.0.0" + npm-packlist "^2.1.5" + yargs "^16.1.1" + yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" @@ -15617,7 +15665,7 @@ yargs@^15.1.0, yargs@^15.3.1, yargs@^15.4.1: y18n "^4.0.0" yargs-parser "^18.1.2" -yargs@^16.2.0: +yargs@^16.1.1, yargs@^16.2.0: version "16.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==