diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7e6f1a7720..c524c794a2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,7 +4,7 @@ on:
push:
branches:
- main
- - develop
+# - develop
permissions:
id-token: write # for OIDC / npm provenance if you use it
diff --git a/README.md b/README.md
index a93c62eca5..973ffc78bb 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
[](https://www.npmjs.com/package/stream-chat-react-native)
[](https://github.com/GetStream/stream-chat-react-native/actions)
[](https://getstream.io/chat/docs/sdk/reactnative)
-
+
diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx
index 771d8db352..4bdc24765c 100644
--- a/examples/SampleApp/App.tsx
+++ b/examples/SampleApp/App.tsx
@@ -60,6 +60,7 @@ import { Toast } from './src/components/ToastComponent/Toast';
import { useClientNotificationsToastHandler } from './src/hooks/useClientNotificationsToastHandler';
import AsyncStore from './src/utils/AsyncStore.ts';
import {
+ MessageInputFloatingConfigItem,
MessageListImplementationConfigItem,
MessageListModeConfigItem,
MessageListPruningConfigItem,
@@ -94,6 +95,7 @@ notifee.onBackgroundEvent(async ({ detail, type }) => {
const Drawer = createDrawerNavigator();
const Stack = createNativeStackNavigator();
const UserSelectorStack = createNativeStackNavigator();
+
const App = () => {
const { chatClient, isConnecting, loginUser, logout, switchUser } = useChatClient();
const [messageListImplementation, setMessageListImplementation] = useState<
@@ -105,8 +107,12 @@ const App = () => {
const [messageListPruning, setMessageListPruning] = useState<
MessageListPruningConfigItem['value'] | undefined
>(undefined);
+ const [messageInputFloating, setMessageInputFloating] = useState<
+ MessageInputFloatingConfigItem['value'] | undefined
+ >(undefined);
const colorScheme = useColorScheme();
const streamChatTheme = useStreamChatTheme();
+ const streami18n = new Streami18n();
useEffect(() => {
const messaging = getMessaging();
@@ -159,6 +165,10 @@ const App = () => {
'@stream-rn-sampleapp-messagelist-pruning',
{ value: undefined },
);
+ const messageInputFloatingStoredValue = await AsyncStore.getItem(
+ '@stream-rn-sampleapp-messageinput-floating',
+ { value: false },
+ );
setMessageListImplementation(
messageListImplementationStoredValue?.id as MessageListImplementationConfigItem['id'],
);
@@ -166,6 +176,9 @@ const App = () => {
setMessageListPruning(
messageListPruningStoredValue?.value as MessageListPruningConfigItem['value'],
);
+ setMessageInputFloating(
+ messageInputFloatingStoredValue?.value as MessageInputFloatingConfigItem['value'],
+ );
};
getMessageListConfig();
return () => {
@@ -209,39 +222,44 @@ const App = () => {
backgroundColor: streamChatTheme.colors?.white_snow || '#FCFCFC',
}}
>
-
-
-
- {isConnecting && !chatClient ? (
-
- ) : chatClient ? (
-
- ) : (
-
- )}
-
-
-
+
+
+
+
+
+ {isConnecting && !chatClient ? (
+
+ ) : chatClient ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
);
};
@@ -265,32 +283,26 @@ const isMessageAIGenerated = (message: LocalMessage) => !!message.ai_generated;
const DrawerNavigatorWrapper: React.FC<{
chatClient: StreamChat;
-}> = ({ chatClient }) => {
- const streamChatTheme = useStreamChatTheme();
- const streami18n = new Streami18n();
-
+ i18nInstance: Streami18n;
+}> = ({ chatClient, i18nInstance }) => {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt b/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt
index f3ca98b78b..79546eb2e2 100644
--- a/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt
+++ b/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt
@@ -1,52 +1,49 @@
package com.sampleapp
-import com.facebook.react.ReactActivity
-import com.facebook.react.ReactActivityDelegate
-import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
-import com.facebook.react.defaults.DefaultReactActivityDelegate
-
-import android.os.Bundle
import android.os.Build
+import android.os.Bundle
import android.view.View
+import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
+import com.facebook.react.ReactActivity
+import com.facebook.react.ReactActivityDelegate
+import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
+import com.facebook.react.defaults.DefaultReactActivityDelegate
class MainActivity : ReactActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(null)
-
- if (Build.VERSION.SDK_INT >= 35) {
- val rootView = findViewById(android.R.id.content)
-
-
- ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, insets ->
- val bars = insets.getInsets(
- WindowInsetsCompat.Type.systemBars()
- or WindowInsetsCompat.Type.displayCutout()
- or WindowInsetsCompat.Type.ime() // adding the ime's height
- )
- rootView.updatePadding(
- left = bars.left,
- top = bars.top,
- right = bars.right,
- bottom = bars.bottom
- )
- WindowInsetsCompat.CONSUMED
- }
- }
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(null)
+
+ if (Build.VERSION.SDK_INT >= 35) {
+ val rootView = findViewById(android.R.id.content)
+
+ val initial = Insets.of(
+ rootView.paddingLeft,
+ rootView.paddingTop,
+ rootView.paddingRight,
+ rootView.paddingBottom
+ )
+
+ ViewCompat.setOnApplyWindowInsetsListener(rootView) { v, insets ->
+ val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
+
+ v.updatePadding(
+ left = initial.left,
+ top = initial.top,
+ right = initial.right,
+ bottom = initial.bottom + ime.bottom
+ )
+
+ insets
}
- /**
- * Returns the name of the main component registered from JavaScript. This is used to schedule
- * rendering of the component.
- */
- override fun getMainComponentName(): String = "SampleApp"
-
- /**
- * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
- * which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
- */
- override fun createReactActivityDelegate(): ReactActivityDelegate =
- DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
+ }
+ }
+
+ override fun getMainComponentName(): String = "SampleApp"
+
+ override fun createReactActivityDelegate(): ReactActivityDelegate =
+ DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
}
diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock
index 171a4084a1..acfbd0474e 100644
--- a/examples/SampleApp/ios/Podfile.lock
+++ b/examples/SampleApp/ios/Podfile.lock
@@ -2036,6 +2036,65 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
+ - react-native-keyboard-controller (1.20.2):
+ - boost
+ - DoubleConversion
+ - fast_float
+ - fmt
+ - glog
+ - hermes-engine
+ - RCT-Folly
+ - RCT-Folly/Fabric
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - react-native-keyboard-controller/common (= 1.20.2)
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/bridging
+ - ReactCommon/turbomodule/core
+ - SocketRocket
+ - Yoga
+ - react-native-keyboard-controller/common (1.20.2):
+ - boost
+ - DoubleConversion
+ - fast_float
+ - fmt
+ - glog
+ - hermes-engine
+ - RCT-Folly
+ - RCT-Folly/Fabric
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/bridging
+ - ReactCommon/turbomodule/core
+ - SocketRocket
+ - Yoga
- react-native-maps (1.20.1):
- React-Core
- react-native-netinfo (11.4.1):
@@ -3165,6 +3224,65 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
+ - Teleport (0.5.4):
+ - boost
+ - DoubleConversion
+ - fast_float
+ - fmt
+ - glog
+ - hermes-engine
+ - RCT-Folly
+ - RCT-Folly/Fabric
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/bridging
+ - ReactCommon/turbomodule/core
+ - SocketRocket
+ - Teleport/common (= 0.5.4)
+ - Yoga
+ - Teleport/common (0.5.4):
+ - boost
+ - DoubleConversion
+ - fast_float
+ - fmt
+ - glog
+ - hermes-engine
+ - RCT-Folly
+ - RCT-Folly/Fabric
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/bridging
+ - ReactCommon/turbomodule/core
+ - SocketRocket
+ - Yoga
- Yoga (0.0.0)
DEPENDENCIES:
@@ -3220,6 +3338,7 @@ DEPENDENCIES:
- "react-native-document-picker (from `../node_modules/@react-native-documents/picker`)"
- "react-native-geolocation (from `../node_modules/@react-native-community/geolocation`)"
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
+ - react-native-keyboard-controller (from `../node_modules/react-native-keyboard-controller`)
- react-native-maps (from `../node_modules/react-native-maps`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
@@ -3269,6 +3388,7 @@ DEPENDENCIES:
- RNWorklets (from `../node_modules/react-native-worklets`)
- SocketRocket (~> 0.7.1)
- stream-chat-react-native (from `../node_modules/stream-chat-react-native`)
+ - Teleport (from `../node_modules/react-native-teleport`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
SPEC REPOS:
@@ -3394,6 +3514,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-community/geolocation"
react-native-image-picker:
:path: "../node_modules/react-native-image-picker"
+ react-native-keyboard-controller:
+ :path: "../node_modules/react-native-keyboard-controller"
react-native-maps:
:path: "../node_modules/react-native-maps"
react-native-netinfo:
@@ -3490,6 +3612,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-worklets"
stream-chat-react-native:
:path: "../node_modules/stream-chat-react-native"
+ Teleport:
+ :path: "../node_modules/react-native-teleport"
Yoga:
:path: "../node_modules/react-native/ReactCommon/yoga"
@@ -3522,7 +3646,7 @@ SPEC CHECKSUMS:
op-sqlite: b9a4028bef60145d7b98fbbc4341c83420cdcfd5
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
- RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
+ RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f
RCTDeprecation: 300c5eb91114d4339b0bb39505d0f4824d7299b7
RCTRequired: e0446b01093475b7082fbeee5d1ef4ad1fe20ac4
RCTTypeSafety: cb974efcdc6695deedf7bf1eb942f2a0603a063f
@@ -3561,6 +3685,7 @@ SPEC CHECKSUMS:
react-native-document-picker: c5fa18e9fc47b34cfbab3b0a4447d0df918a5621
react-native-geolocation: eb39c815c9b58ddc3efb552cafdd4b035e4cf682
react-native-image-picker: e479ec8884df9d99a62c1f53f2307055ad43ea85
+ react-native-keyboard-controller: 6fe65d5d011d88e651d5279396e95e9c1f9458ca
react-native-maps: ee1e65647460c3d41e778071be5eda10e3da6225
react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac
react-native-safe-area-context: 7fd4c2c8023da8e18eaa3424cb49d52f626debee
@@ -3612,6 +3737,7 @@ SPEC CHECKSUMS:
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
stream-chat-react-native: 7a042480d22a8a87aaee6186bf2f1013af017d3a
+ Teleport: 9ae328b3384204b23236e47bd0d8e994ce6bdb48
Yoga: ce248fb32065c9b00451491b06607f1c25b2f1ed
PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d
diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json
index 5da8d63f2a..c1171deb10 100644
--- a/examples/SampleApp/package.json
+++ b/examples/SampleApp/package.json
@@ -47,6 +47,7 @@
"react-native-gesture-handler": "^2.26.0",
"react-native-haptic-feedback": "^2.3.3",
"react-native-image-picker": "^8.2.1",
+ "react-native-keyboard-controller": "^1.20.2",
"react-native-maps": "1.20.1",
"react-native-nitro-modules": "^0.31.3",
"react-native-nitro-sound": "^0.2.9",
@@ -55,6 +56,7 @@
"react-native-screens": "^4.11.1",
"react-native-share": "^12.0.11",
"react-native-svg": "^15.12.0",
+ "react-native-teleport": "^0.5.4",
"react-native-video": "^6.16.1",
"react-native-worklets": "^0.4.1",
"stream-chat-react-native": "link:../../package/native-package",
diff --git a/examples/SampleApp/src/components/SecretMenu.tsx b/examples/SampleApp/src/components/SecretMenu.tsx
index 0037645b82..aff708538f 100644
--- a/examples/SampleApp/src/components/SecretMenu.tsx
+++ b/examples/SampleApp/src/components/SecretMenu.tsx
@@ -26,6 +26,7 @@ export type NotificationConfigItem = { label: string; name: string; id: string }
export type MessageListImplementationConfigItem = { label: string; id: 'flatlist' | 'flashlist' };
export type MessageListModeConfigItem = { label: string; mode: 'default' | 'livestream' };
export type MessageListPruningConfigItem = { label: string; value: 100 | 500 | 1000 | undefined };
+export type MessageInputFloatingConfigItem = { label: string; value: boolean };
const messageListImplementationConfigItems: MessageListImplementationConfigItem[] = [
{ label: 'FlatList', id: 'flatlist' },
@@ -44,6 +45,11 @@ const messageListPruningConfigItems: MessageListPruningConfigItem[] = [
{ label: '1000 Messages', value: 1000 },
];
+const messageInputFloatingConfigItems: MessageInputFloatingConfigItem[] = [
+ { label: 'Normal', value: false },
+ { label: 'Floating', value: true },
+];
+
export const SlideInView = ({
visible,
children,
@@ -161,6 +167,23 @@ const SecretMenuMessageListImplementationConfigItem = ({
);
+const SecretMenuMessageInputFloatingConfigItem = ({
+ messageInputFloatingConfigItem,
+ storeMessageInputFloating,
+ isSelected,
+}: {
+ messageInputFloatingConfigItem: MessageInputFloatingConfigItem;
+ storeMessageInputFloating: (item: MessageInputFloatingConfigItem) => void;
+ isSelected: boolean;
+}) => (
+ storeMessageInputFloating(messageInputFloatingConfigItem)}
+ >
+ {messageInputFloatingConfigItem.label}
+
+);
+
const SecretMenuMessageListModeConfigItem = ({
messageListModeConfigItem,
storeMessageListMode,
@@ -218,6 +241,8 @@ export const SecretMenu = ({
const [selectedMessageListPruning, setSelectedMessageListPruning] = useState<
MessageListPruningConfigItem['value'] | null
>(null);
+ const [selectedMessageInputFloating, setSelectedMessageInputFloating] =
+ useState(false);
const {
theme: {
colors: { black, grey },
@@ -250,12 +275,19 @@ export const SecretMenu = ({
'@stream-rn-sampleapp-messagelist-pruning',
messageListPruningConfigItems[0],
);
+ const messageInputFloating = await AsyncStore.getItem(
+ '@stream-rn-sampleapp-messageinput-floating',
+ messageInputFloatingConfigItems[0],
+ );
setSelectedProvider(notificationProvider?.id ?? notificationConfigItems[0].id);
setSelectedMessageListImplementation(
messageListImplementation?.id ?? messageListImplementationConfigItems[0].id,
);
setSelectedMessageListMode(messageListMode?.mode ?? messageListModeConfigItems[0].mode);
setSelectedMessageListPruning(messageListPruning?.value);
+ setSelectedMessageInputFloating(
+ messageInputFloating?.value ?? messageInputFloatingConfigItems[0].value,
+ );
};
getSelectedConfig();
}, [notificationConfigItems]);
@@ -283,6 +315,11 @@ export const SecretMenu = ({
setSelectedMessageListPruning(item.value);
}, []);
+ const storeMessageInputFloating = useCallback(async (item: MessageInputFloatingConfigItem) => {
+ await AsyncStore.setItem('@stream-rn-sampleapp-messageinput-floating', item);
+ setSelectedMessageInputFloating(item.value);
+ }, []);
+
const removeAllDevices = useCallback(async () => {
const { devices } = await chatClient.getDevices(chatClient.userID);
for (const device of devices ?? []) {
@@ -335,6 +372,22 @@ export const SecretMenu = ({
+
+
+
+ Message Input Floating
+
+ {messageInputFloatingConfigItems.map((item) => (
+
+ ))}
+
+
+
diff --git a/examples/SampleApp/src/context/AppContext.ts b/examples/SampleApp/src/context/AppContext.ts
index 9ec7c80990..b0e7921d67 100644
--- a/examples/SampleApp/src/context/AppContext.ts
+++ b/examples/SampleApp/src/context/AppContext.ts
@@ -4,6 +4,7 @@ import type { StreamChat } from 'stream-chat';
import type { LoginConfig } from '../types';
import {
+ MessageInputFloatingConfigItem,
MessageListImplementationConfigItem,
MessageListModeConfigItem,
MessageListPruningConfigItem,
@@ -15,6 +16,7 @@ type AppContextType = {
logout: () => void;
switchUser: (userId?: string) => void;
messageListImplementation: MessageListImplementationConfigItem['id'];
+ messageInputFloating: MessageInputFloatingConfigItem['value'];
messageListMode: MessageListModeConfigItem['mode'];
messageListPruning: MessageListPruningConfigItem['value'];
};
diff --git a/examples/SampleApp/src/screens/ChannelListScreen.tsx b/examples/SampleApp/src/screens/ChannelListScreen.tsx
index 796406673d..8bc0d965cf 100644
--- a/examples/SampleApp/src/screens/ChannelListScreen.tsx
+++ b/examples/SampleApp/src/screens/ChannelListScreen.tsx
@@ -9,7 +9,7 @@ import {
View,
} from 'react-native';
import { useNavigation, useScrollToTop } from '@react-navigation/native';
-import { ChannelList, CircleClose, Search, useTheme } from 'stream-chat-react-native';
+import { ChannelList, CircleClose, useTheme } from 'stream-chat-react-native';
import { Channel } from 'stream-chat';
import { ChannelPreview } from '../components/ChannelPreview';
import { ChatScreenHeader } from '../components/ChatScreenHeader';
@@ -19,6 +19,7 @@ import { usePaginatedSearchedMessages } from '../hooks/usePaginatedSearchedMessa
import type { ChannelSort } from 'stream-chat';
import { useStreamChatContext } from '../context/StreamChatContext';
+import { Search } from '../icons/Search';
const styles = StyleSheet.create({
channelListContainer: {
diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx
index 406938e40d..70b489cca4 100644
--- a/examples/SampleApp/src/screens/ChannelScreen.tsx
+++ b/examples/SampleApp/src/screens/ChannelScreen.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import type { LocalMessage, Channel as StreamChatChannel } from 'stream-chat';
+import { useHeaderHeight } from '@react-navigation/elements';
import { RouteProp, useFocusEffect, useNavigation } from '@react-navigation/native';
import {
Channel,
@@ -17,9 +18,8 @@ import {
useTranslationContext,
MessageActionsParams,
} from 'stream-chat-react-native';
-import { Platform, Pressable, StyleSheet, View } from 'react-native';
+import { Pressable, StyleSheet, View } from 'react-native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useAppContext } from '../context/AppContext';
import { ScreenHeader } from '../components/ScreenHeader';
@@ -121,9 +121,13 @@ export const ChannelScreen: React.FC = ({
params: { channel: channelFromProp, channelId, messageId },
},
}) => {
- const { chatClient, messageListImplementation, messageListMode, messageListPruning } =
- useAppContext();
- const { bottom } = useSafeAreaInsets();
+ const {
+ chatClient,
+ messageListImplementation,
+ messageListMode,
+ messageListPruning,
+ messageInputFloating,
+ } = useAppContext();
const {
theme: { colors },
} = useTheme();
@@ -210,22 +214,24 @@ export const ChannelScreen: React.FC = ({
},
[chatClient, colors, t, handleMessageInfo],
);
+ const headerHeight = useHeaderHeight();
if (!channel || !chatClient) {
return null;
}
return (
-
+
({ parentMessage: nextValue.parentMessage }) as const;
@@ -64,7 +66,6 @@ const ThreadHeader: React.FC = ({ thread }) => {
return (
@@ -85,6 +86,8 @@ export const ThreadScreen: React.FC = ({
const { client: chatClient } = useChatContext();
const { t } = useTranslationContext();
const { setThread } = useStreamChatContext();
+ const { messageInputFloating } = useAppContext();
+ const headerHeight = useHeaderHeight();
const onPressMessage: NonNullable['onPressMessage']> = (
payload,
@@ -116,25 +119,24 @@ export const ThreadScreen: React.FC = ({
}, [setThread]);
return (
-
+
-
-
-
-
+
+
-
+
);
};
diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock
index 5d203517a1..a93301a6f6 100644
--- a/examples/SampleApp/yarn.lock
+++ b/examples/SampleApp/yarn.lock
@@ -7658,6 +7658,13 @@ react-native-is-edge-to-edge@^1.2.1:
resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz#64e10851abd9d176cbf2b40562f751622bde3358"
integrity sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==
+react-native-keyboard-controller@^1.20.2:
+ version "1.20.2"
+ resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.20.2.tgz#2953341f48e25fec20dd732241cb8152251fd4d1"
+ integrity sha512-3xvPTIfasAbosDxT3Mc6b5Xr/M+yq99ECCM4iGnSAngziIVUZsZuPpfYL7nN1UiN9rQjWKvjdul/jq9E0V1s2w==
+ dependencies:
+ react-native-is-edge-to-edge "^1.2.1"
+
react-native-lightbox@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/react-native-lightbox/-/react-native-lightbox-0.7.0.tgz#e52b4d7fcc141f59d7b23f0180de535e35b20ec9"
@@ -7729,6 +7736,11 @@ react-native-svg@^15.12.0:
css-tree "^1.1.3"
warn-once "0.1.1"
+react-native-teleport@^0.5.4:
+ version "0.5.4"
+ resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-0.5.4.tgz#6af16e3984ee0fc002e5d6c6dae0ad40f22699c2"
+ integrity sha512-kiNbB27lJHAO7DdG7LlPi6DaFnYusVQV9rqp0q5WoRpnqKNckIvzXULox6QpZBstaTF/jkO+xmlkU+pnQqKSAQ==
+
react-native-url-polyfill@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz#db714520a2985cff1d50ab2e66279b9f91ffd589"
diff --git a/package/eslint.config.mjs b/package/eslint.config.mjs
index 5ea72ef943..a8066671b4 100644
--- a/package/eslint.config.mjs
+++ b/package/eslint.config.mjs
@@ -141,17 +141,6 @@ export default tsEslint.config(
'react/prop-types': 0,
'require-await': 2,
semi: [1, 'always'],
- 'sort-imports': [
- 'error',
- {
- allowSeparatedGroups: true,
- ignoreCase: true,
- ignoreDeclarationSort: true,
- ignoreMemberSort: false,
- memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'],
- },
- ],
- 'sort-keys': ['error', 'asc', { caseSensitive: false, minKeys: 2, natural: false }],
'valid-typeof': 2,
'@typescript-eslint/explicit-module-boundary-types': 0,
'@typescript-eslint/no-empty-interface': 0,
diff --git a/package/jest-setup.js b/package/jest-setup.js
index 5b2987f3d0..85760c08a8 100644
--- a/package/jest-setup.js
+++ b/package/jest-setup.js
@@ -1,7 +1,8 @@
/* global require */
-import { FlatList, View } from 'react-native';
+import rn, { FlatList, View } from 'react-native';
import mockRNCNetInfo from '@react-native-community/netinfo/jest/netinfo-mock.js';
+import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock';
import { registerNativeHandlers } from './src/native';
@@ -65,3 +66,34 @@ jest.mock('react-native/Libraries/Components/RefreshControl/RefreshControl', ()
jest.mock('@shopify/flash-list', () => ({
FlashList: undefined,
}));
+
+jest.mock('react-native-teleport', () => {
+ const rn = require('react-native');
+ return {
+ Portal: rn.View,
+ PortalHost: rn.View,
+ PortalProvider: rn.View,
+ usePortal: jest.fn().mockReturnValue({ removePortal: jest.fn() }),
+ };
+});
+
+jest.mock('react-native-teleport', () => {
+ const rn = require('react-native');
+ return {
+ Portal: rn.View,
+ PortalHost: rn.View,
+ PortalProvider: rn.View,
+ usePortal: jest.fn().mockReturnValue({ removePortal: jest.fn() }),
+ };
+});
+
+jest.mock('react-native-safe-area-context', () => mockSafeAreaContext);
+
+jest.mock('./src/components/Message/utils/measureInWindow', () => ({
+ measureInWindow: jest.fn(async () => ({
+ x: 10,
+ y: 100,
+ w: 250,
+ h: 60,
+ })),
+}));
diff --git a/package/package.json b/package/package.json
index 0f4028fa85..4126ff35e2 100644
--- a/package/package.json
+++ b/package/package.json
@@ -91,9 +91,11 @@
"emoji-mart": ">=5.4.0",
"react-native": ">=0.73.0",
"react-native-gesture-handler": ">=2.18.0",
+ "react-native-keyboard-controller": ">=1.20.2",
"react-native-reanimated": ">=3.16.0",
"react-native-safe-area-context": ">=5.4.1",
- "react-native-svg": ">=15.8.0"
+ "react-native-svg": ">=15.8.0",
+ "react-native-teleport": ">=0.5.4"
},
"peerDependenciesMeta": {
"@op-engineering/op-sqlite": {
@@ -107,6 +109,9 @@
},
"@emoji-mart/data": {
"optional": true
+ },
+ "react-native-keyboard-controller": {
+ "optional": true
}
},
"devDependencies": {
@@ -154,9 +159,11 @@
"react-native": "0.80.2",
"react-native-builder-bob": "0.40.11",
"react-native-gesture-handler": "^2.26.0",
+ "react-native-keyboard-controller": "^1.20.2",
"react-native-reanimated": "3.18.0",
"react-native-safe-area-context": "^5.6.1",
"react-native-svg": "15.12.0",
+ "react-native-teleport": "^0.5.4",
"react-test-renderer": "19.1.0",
"rimraf": "^6.0.1",
"typescript": "5.8.3",
diff --git a/package/src/components/Attachment/AudioAttachment.tsx b/package/src/components/Attachment/AudioAttachment.tsx
index 016c787058..cd3f2670ce 100644
--- a/package/src/components/Attachment/AudioAttachment.tsx
+++ b/package/src/components/Attachment/AudioAttachment.tsx
@@ -13,7 +13,7 @@ import {
import { useTheme } from '../../contexts';
import { useStateStore } from '../../hooks';
-import { useAudioPlayerControl } from '../../hooks/useAudioPlayerControl';
+import { useAudioPlayer } from '../../hooks/useAudioPlayer';
import { Audio, Pause, Play } from '../../icons';
import {
NativeHandlers,
@@ -56,21 +56,6 @@ export type AudioAttachmentProps = {
* If true, the audio attachment is in preview mode in the message input.
*/
isPreview?: boolean;
- /**
- * Callback to be called when the audio is loaded
- * @deprecated This is deprecated and will be removed in the future.
- */
- onLoad?: (index: string, duration: number) => void;
- /**
- * Callback to be called when the audio is played or paused
- * @deprecated This is deprecated and will be removed in the future.
- */
- onPlayPause?: (index: string, pausedStatus?: boolean) => void;
- /**
- * Callback to be called when the audio progresses
- * @deprecated This is deprecated and will be removed in the future.
- */
- onProgress?: (index: string, progress: number) => void;
};
const audioPlayerSelector = (state: AudioPlayerState) => ({
@@ -99,7 +84,7 @@ export const AudioAttachment = (props: AudioAttachmentProps) => {
} = props;
const isVoiceRecording = isVoiceRecordingAttachment(item);
- const audioPlayer = useAudioPlayerControl({
+ const audioPlayer = useAudioPlayer({
duration: item.duration ?? 0,
mimeType: item.mime_type ?? '',
requester: isPreview
@@ -269,10 +254,8 @@ export const AudioAttachment = (props: AudioAttachmentProps) => {
/>
) : (
&
Pick & {
- /**
- * @deprecated Use message instead
- * The unique id for the message with file attachments
- */
- messageId: string;
styles?: Partial<{
attachmentContainer: StyleProp;
container: StyleProp;
@@ -49,68 +44,6 @@ const FileAttachmentGroupWithContext = (props: FileAttachmentGroupPropsWithConte
);
}, [files]);
- /**
- * Handler triggered when an audio is loaded in the message input. The initial state is defined for the audio here and the duration is set.
- * @param index - The index of the audio
- * @param duration - The duration of the audio
- *
- * @deprecated This is deprecated and will be removed in the future.
- * FIXME: Remove this in the next major version.
- */
- const onLoad = (index: string, duration: number) => {
- setFilesToDisplay((prevFilesToDisplay) =>
- prevFilesToDisplay.map((fileToDisplay, id) => ({
- ...fileToDisplay,
- duration: id.toString() === index ? duration : fileToDisplay.duration,
- })),
- );
- };
-
- /**
- * Handler which is triggered when the audio progresses/ the thumb is dragged in the progress control. The progressed duration is set here.
- * @param index - The index of the audio
- * @param progress - The progress of the audio
- *
- * @deprecated This is deprecated and will be removed in the future.
- * FIXME: Remove this in the next major version.
- */
- const onProgress = (index: string, progress: number) => {
- setFilesToDisplay((prevFilesToDisplay) =>
- prevFilesToDisplay.map((filesToDisplay, id) => ({
- ...filesToDisplay,
- progress: id.toString() === index ? progress : filesToDisplay.progress,
- })),
- );
- };
-
- /**
- * Handler which controls or sets the paused/played state of the audio.
- * @param index - The index of the audio
- * @param pausedStatus - The paused status of the audio
- *
- * @deprecated This is deprecated and will be removed in the future.
- * FIXME: Remove this in the next major version.
- */
- const onPlayPause = (index: string, pausedStatus?: boolean) => {
- if (pausedStatus === false) {
- // If the status is false we set the audio with the index as playing and the others as paused.
- setFilesToDisplay((prevFilesToDisplay) =>
- prevFilesToDisplay.map((fileToDisplay, id) => ({
- ...fileToDisplay,
- paused: id.toString() !== index,
- })),
- );
- } else {
- // If the status is true we simply set all the audio's paused state as true.
- setFilesToDisplay((prevFilesToDisplay) =>
- prevFilesToDisplay.map((fileToDisplay) => ({
- ...fileToDisplay,
- paused: true,
- })),
- );
- }
- };
-
const {
theme: {
messageSimple: {
@@ -135,9 +68,6 @@ const FileAttachmentGroupWithContext = (props: FileAttachmentGroupPropsWithConte
) : (
@@ -153,8 +83,13 @@ const areEqual = (
prevProps: FileAttachmentGroupPropsWithContext,
nextProps: FileAttachmentGroupPropsWithContext,
) => {
- const { files: prevFiles } = prevProps;
- const { files: nextFiles } = nextProps;
+ const { files: prevFiles, message: prevMessage } = prevProps;
+ const { files: nextFiles, message: nextMessage } = nextProps;
+
+ const messageEqual = prevMessage?.id === nextMessage?.id;
+ if (!messageEqual) {
+ return false;
+ }
return prevFiles.length === nextFiles.length;
};
@@ -164,13 +99,10 @@ const MemoizedFileAttachmentGroup = React.memo(
areEqual,
) as typeof FileAttachmentGroupWithContext;
-export type FileAttachmentGroupProps = Partial<
- Omit
-> &
- Pick;
+export type FileAttachmentGroupProps = Partial;
export const FileAttachmentGroup = (props: FileAttachmentGroupProps) => {
- const { files: propFiles, messageId } = props;
+ const { files: propFiles } = props;
const { files: contextFiles, message } = useMessageContext();
@@ -189,7 +121,6 @@ export const FileAttachmentGroup = (props: FileAttachmentGroupProps) => {
AudioAttachment,
files,
message,
- messageId,
}}
/>
);
diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx
index c23af98dc6..59669fd70d 100644
--- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx
+++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { BackHandler, Keyboard, Platform, StyleSheet } from 'react-native';
+import { BackHandler, EmitterSubscription, Keyboard, Platform, StyleSheet } from 'react-native';
import BottomSheetOriginal from '@gorhom/bottom-sheet';
import type { BottomSheetHandleProps } from '@gorhom/bottom-sheet';
@@ -19,6 +19,7 @@ import { NativeHandlers } from '../../native';
import type { File } from '../../types/types';
import { BottomSheet } from '../BottomSheetCompatibility/BottomSheet';
import { BottomSheetFlatList } from '../BottomSheetCompatibility/BottomSheetFlatList';
+import { KeyboardControllerPackage } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView';
dayjs.extend(duration);
@@ -185,20 +186,18 @@ export const AttachmentPicker = React.forwardRef(
}
closePicker();
};
- const keyboardShowEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
- const keyboardSubscription = Keyboard.addListener(keyboardShowEvent, onKeyboardOpenHandler);
-
+ let keyboardSubscription: EmitterSubscription | null = null;
+ if (KeyboardControllerPackage?.KeyboardEvents) {
+ keyboardSubscription = KeyboardControllerPackage.KeyboardEvents.addListener(
+ 'keyboardWillShow',
+ onKeyboardOpenHandler,
+ );
+ } else {
+ const keyboardShowEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
+ keyboardSubscription = Keyboard.addListener(keyboardShowEvent, onKeyboardOpenHandler);
+ }
return () => {
- // Following if-else condition to avoid deprecated warning coming RN 0.65
- if (keyboardSubscription?.remove) {
- keyboardSubscription.remove();
- return;
- }
- // @ts-ignore
- else if (Keyboard.removeListener) {
- // @ts-ignore
- Keyboard.removeListener(keyboardShowEvent, onKeyboardOpenHandler);
- }
+ keyboardSubscription?.remove();
};
}, [closePicker, selectedPicker, setSelectedPicker]);
diff --git a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx
index a49bfe7c7e..ac23dd1054 100644
--- a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx
+++ b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx
@@ -3,7 +3,6 @@ import {
I18nManager,
TextInput as RNTextInput,
StyleSheet,
- TextInputContentSizeChangeEvent,
TextInputProps,
TextInputSelectionChangeEvent,
} from 'react-native';
@@ -35,7 +34,7 @@ type AutoCompleteInputPropsWithContext = TextInputProps &
* This is currently passed in from MessageInput to avoid rerenders
* that would happen if we put this in the MessageInputContext
*/
- cooldownActive?: boolean;
+ cooldownRemainingSeconds?: number;
TextInputComponent?: React.ComponentType<
TextInputProps & {
ref: React.Ref | undefined;
@@ -55,18 +54,19 @@ const configStateSelector = (state: MessageComposerConfig) => ({
});
const MAX_NUMBER_OF_LINES = 5;
+const LINE_HEIGHT = 20;
+const PADDING_VERTICAL = 12;
const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) => {
const {
channel,
- cooldownActive = false,
+ cooldownRemainingSeconds,
setInputBoxRef,
t,
TextInputComponent = RNTextInput,
...rest
} = props;
const [localText, setLocalText] = useState('');
- const [textHeight, setTextHeight] = useState(0);
const messageComposer = useMessageComposer();
const { textComposer } = messageComposer;
const { command, text } = useStateStore(textComposer.state, textComposerStateSelector);
@@ -115,15 +115,12 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext)
} = useTheme();
const placeholderText = useMemo(() => {
- return command ? t('Search') : cooldownActive ? t('Slow mode ON') : t('Send a message');
- }, [command, cooldownActive, t]);
-
- const handleContentSizeChange = useCallback(
- ({ nativeEvent: { contentSize } }: TextInputContentSizeChangeEvent) => {
- setTextHeight(contentSize.height);
- },
- [],
- );
+ return command
+ ? t('Search')
+ : cooldownRemainingSeconds
+ ? `Slow mode, wait ${cooldownRemainingSeconds}s...`
+ : t('Send a message');
+ }, [command, cooldownRemainingSeconds, t]);
return (
{
- const { channel: prevChannel, cooldownActive: prevCooldownActive, t: prevT } = prevProps;
- const { channel: nextChannel, cooldownActive: nextCooldownActive, t: nextT } = nextProps;
+ const {
+ channel: prevChannel,
+ cooldownRemainingSeconds: prevCooldownRemainingSeconds,
+ t: prevT,
+ } = prevProps;
+ const {
+ channel: nextChannel,
+ cooldownRemainingSeconds: nextCooldownRemainingSeconds,
+ t: nextT,
+ } = nextProps;
const tEqual = prevT === nextT;
if (!tEqual) {
return false;
}
- const cooldownActiveEqual = prevCooldownActive === nextCooldownActive;
- if (!cooldownActiveEqual) {
+ const cooldownRemainingSecondsEqual =
+ prevCooldownRemainingSeconds === nextCooldownRemainingSeconds;
+ if (!cooldownRemainingSecondsEqual) {
return false;
}
@@ -206,6 +211,8 @@ const styles = StyleSheet.create({
flex: 1,
fontSize: 16,
includeFontPadding: false, // for android vertical text centering
+ lineHeight: 20,
+ paddingLeft: 16,
paddingVertical: 12,
textAlignVertical: 'center', // for android vertical text centering
},
diff --git a/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js b/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js
index c3591fc026..945581876e 100644
--- a/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js
+++ b/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.js
@@ -113,29 +113,6 @@ describe('AutoCompleteInput', () => {
});
});
- it('should style the text input with maxHeight that is set by the layout', async () => {
- const channelProps = { channel };
- const props = { numberOfLines: 10 };
-
- renderComponent({ channelProps, client, props });
-
- const { queryByTestId } = screen;
-
- const input = queryByTestId('auto-complete-text-input');
-
- act(() => {
- fireEvent(input, 'contentSizeChange', {
- nativeEvent: {
- contentSize: { height: 100 },
- },
- });
- });
-
- await waitFor(() => {
- expect(input.props.style[1].maxHeight).toBe(1000);
- });
- });
-
it('should call the textComposer setSelection when the onSelectionChange is triggered', async () => {
const { textComposer } = channel.messageComposer;
@@ -166,12 +143,12 @@ describe('AutoCompleteInput', () => {
// TODO: Add a test for command
it.each([
- { cooldownActive: false, result: 'Send a message' },
- { cooldownActive: true, result: 'Slow mode ON' },
+ { cooldownRemainingSeconds: undefined, result: 'Send a message' },
+ { cooldownRemainingSeconds: 10, result: 'Slow mode, wait 10s...' },
])('should have the placeholderText as Slow mode ON when cooldown is active', async (data) => {
const channelProps = { channel };
const props = {
- cooldownActive: data.cooldownActive,
+ cooldownRemainingSeconds: data.cooldownRemainingSeconds,
};
renderComponent({ channelProps, client, props });
diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx
index 723cd9b907..fe0469bd88 100644
--- a/package/src/components/Channel/Channel.tsx
+++ b/package/src/components/Channel/Channel.tsx
@@ -1,5 +1,5 @@
import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { KeyboardAvoidingViewProps, StyleSheet, Text, View } from 'react-native';
+import { StyleSheet, Text, View } from 'react-native';
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
@@ -150,7 +150,10 @@ import {
LoadingErrorProps,
} from '../Indicators/LoadingErrorIndicator';
import { LoadingIndicator as LoadingIndicatorDefault } from '../Indicators/LoadingIndicator';
-import { KeyboardCompatibleView as KeyboardCompatibleViewDefault } from '../KeyboardCompatibleView/KeyboardCompatibleView';
+import {
+ KeyboardCompatibleView as KeyboardCompatibleViewDefault,
+ KeyboardCompatibleViewProps,
+} from '../KeyboardCompatibleView/KeyboardControllerAvoidingView';
import { Message as MessageDefault } from '../Message/Message';
import { MessageAvatar as MessageAvatarDefault } from '../Message/MessageSimple/MessageAvatar';
import { MessageBlocked as MessageBlockedDefault } from '../Message/MessageSimple/MessageBlocked';
@@ -170,9 +173,7 @@ import { MessageTimestamp as MessageTimestampDefault } from '../Message/MessageS
import { ReactionListBottom as ReactionListBottomDefault } from '../Message/MessageSimple/ReactionList/ReactionListBottom';
import { ReactionListTop as ReactionListTopDefault } from '../Message/MessageSimple/ReactionList/ReactionListTop';
import { StreamingMessageView as DefaultStreamingMessageView } from '../Message/MessageSimple/StreamingMessageView';
-import { AttachButton as AttachButtonDefault } from '../MessageInput/AttachButton';
-import { AttachmentUploadPreviewList as AttachmentUploadPreviewDefault } from '../MessageInput/AttachmentUploadPreviewList';
-import { CommandsButton as CommandsButtonDefault } from '../MessageInput/CommandsButton';
+import { AttachmentUploadPreviewList as AttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList';
import { AttachmentUploadProgressIndicator as AttachmentUploadProgressIndicatorDefault } from '../MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator';
import { AudioAttachmentUploadPreview as AudioAttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview';
import { FileAttachmentUploadPreview as FileAttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview';
@@ -184,12 +185,10 @@ import { AudioRecordingLockIndicator as AudioRecordingLockIndicatorDefault } fro
import { AudioRecordingPreview as AudioRecordingPreviewDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingPreview';
import { AudioRecordingWaveform as AudioRecordingWaveformDefault } from '../MessageInput/components/AudioRecorder/AudioRecordingWaveform';
import { CommandInput as CommandInputDefault } from '../MessageInput/components/CommandInput';
-import { InputEditingStateHeader as InputEditingStateHeaderDefault } from '../MessageInput/components/InputEditingStateHeader';
-import { InputReplyStateHeader as InputReplyStateHeaderDefault } from '../MessageInput/components/InputReplyStateHeader';
-import { CooldownTimer as CooldownTimerDefault } from '../MessageInput/CooldownTimer';
-import { InputButtons as InputButtonsDefault } from '../MessageInput/InputButtons';
-import { MoreOptionsButton as MoreOptionsButtonDefault } from '../MessageInput/MoreOptionsButton';
-import { SendButton as SendButtonDefault } from '../MessageInput/SendButton';
+import { InputButtons as InputButtonsDefault } from '../MessageInput/components/InputButtons';
+import { AttachButton as AttachButtonDefault } from '../MessageInput/components/InputButtons/AttachButton';
+import { CooldownTimer as CooldownTimerDefault } from '../MessageInput/components/OutputButtons/CooldownTimer';
+import { SendButton as SendButtonDefault } from '../MessageInput/components/OutputButtons/SendButton';
import { SendMessageDisallowedIndicator as SendMessageDisallowedIndicatorDefault } from '../MessageInput/SendMessageDisallowedIndicator';
import { ShowThreadMessageInChannelButton as ShowThreadMessageInChannelButtonDefault } from '../MessageInput/ShowThreadMessageInChannelButton';
import { StopMessageStreamingButton as DefaultStopMessageStreamingButton } from '../MessageInput/StopMessageStreamingButton';
@@ -204,10 +203,14 @@ import { StickyHeader as StickyHeaderDefault } from '../MessageList/StickyHeader
import { TypingIndicator as TypingIndicatorDefault } from '../MessageList/TypingIndicator';
import { TypingIndicatorContainer as TypingIndicatorContainerDefault } from '../MessageList/TypingIndicatorContainer';
import { UnreadMessagesNotification as UnreadMessagesNotificationDefault } from '../MessageList/UnreadMessagesNotification';
+import { emojis } from '../MessageMenu/emojis';
import { MessageActionList as MessageActionListDefault } from '../MessageMenu/MessageActionList';
import { MessageActionListItem as MessageActionListItemDefault } from '../MessageMenu/MessageActionListItem';
import { MessageMenu as MessageMenuDefault } from '../MessageMenu/MessageMenu';
-import { MessageReactionPicker as MessageReactionPickerDefault } from '../MessageMenu/MessageReactionPicker';
+import {
+ MessageReactionPicker as MessageReactionPickerDefault,
+ toUnicodeScalarString,
+} from '../MessageMenu/MessageReactionPicker';
import { MessageUserReactions as MessageUserReactionsDefault } from '../MessageMenu/MessageUserReactions';
import { MessageUserReactionsAvatar as MessageUserReactionsAvatarDefault } from '../MessageMenu/MessageUserReactionsAvatar';
import { MessageUserReactionsItem as MessageUserReactionsItemDefault } from '../MessageMenu/MessageUserReactionsItem';
@@ -247,6 +250,11 @@ export const reactionData: ReactionData[] = [
Icon: WutReaction,
type: 'wow',
},
+ ...emojis.map((emoji) => ({
+ Icon: () => {emoji},
+ isUnicode: true,
+ type: toUnicodeScalarString(emoji),
+ })),
];
/**
@@ -329,7 +337,6 @@ export type ChannelPropsWithContext = Pick &
| 'FlatList'
| 'forceAlignMessages'
| 'Gallery'
- | 'getMessagesGroupStyles'
| 'getMessageGroupStyle'
| 'Giphy'
| 'giphyVersion'
@@ -415,7 +422,7 @@ export type ChannelPropsWithContext = Pick &
/**
* Additional props passed to keyboard avoiding view
*/
- additionalKeyboardAvoidingViewProps?: Partial;
+ additionalKeyboardAvoidingViewProps?: Partial;
/**
* When true, disables the KeyboardCompatibleView wrapper
*
@@ -471,7 +478,7 @@ export type ChannelPropsWithContext = Pick &
* When true, messageList will be scrolled at first unread message, when opened.
*/
initialScrollToFirstUnreadMessage?: boolean;
- keyboardBehavior?: KeyboardAvoidingViewProps['behavior'];
+ keyboardBehavior?: KeyboardCompatibleViewProps['behavior'];
/**
* Custom wrapper component that handles height adjustment of Channel component when keyboard is opened or dismissed
* Default component (accepts the same props): [KeyboardCompatibleView](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx)
@@ -491,7 +498,7 @@ export type ChannelPropsWithContext = Pick &
* />
* ```
*/
- KeyboardCompatibleView?: React.ComponentType;
+ KeyboardCompatibleView?: React.ComponentType;
keyboardVerticalOffset?: number;
/**
* Custom loading error indicator to override the Stream default
@@ -597,7 +604,6 @@ const ChannelWithContext = (props: PropsWithChildren) =
channel,
children,
client,
- CommandsButton = CommandsButtonDefault,
compressImageQuality,
CooldownTimer = CooldownTimerDefault,
CreatePollContent,
@@ -626,7 +632,6 @@ const ChannelWithContext = (props: PropsWithChildren) =
FlatList = NativeHandlers.FlatList,
forceAlignMessages,
Gallery = GalleryDefault,
- getMessagesGroupStyles,
getMessageGroupStyle,
Giphy = GiphyDefault,
giphyVersion = 'fixed_height',
@@ -661,9 +666,7 @@ const ChannelWithContext = (props: PropsWithChildren) =
InlineUnreadIndicator = InlineUnreadIndicatorDefault,
Input,
InputButtons = InputButtonsDefault,
- InputEditingStateHeader = InputEditingStateHeaderDefault,
CommandInput = CommandInputDefault,
- InputReplyStateHeader = InputReplyStateHeaderDefault,
isAttachmentEqual,
isMessageAIGenerated = () => false,
keyboardBehavior,
@@ -698,6 +701,7 @@ const ChannelWithContext = (props: PropsWithChildren) =
MessageDeleted = MessageDeletedDefault,
MessageEditedTimestamp = MessageEditedTimestampDefault,
MessageError = MessageErrorDefault,
+ messageInputFloating = false,
MessageFooter = MessageFooterDefault,
MessageHeader,
messageId,
@@ -719,7 +723,6 @@ const ChannelWithContext = (props: PropsWithChildren) =
MessageUserReactions = MessageUserReactionsDefault,
MessageUserReactionsAvatar = MessageUserReactionsAvatarDefault,
MessageUserReactionsItem = MessageUserReactionsItemDefault,
- MoreOptionsButton = MoreOptionsButtonDefault,
myMessageTheme,
NetworkDownIndicator = NetworkDownIndicatorDefault,
// TODO: Think about this one
@@ -785,6 +788,7 @@ const ChannelWithContext = (props: PropsWithChildren) =
const [threadHasMore, setThreadHasMore] = useState(true);
const [threadLoadingMore, setThreadLoadingMore] = useState(false);
const [channelUnreadStateStore] = useState(new ChannelUnreadStateStore());
+ // TODO: Think if we can remove this and just rely on the channelUnreadStateStore everywhere.
const setChannelUnreadState = useCallback(
(data: ChannelUnreadStateStoreType['channelUnreadState']) => {
channelUnreadStateStore.channelUnreadState = data;
@@ -1780,7 +1784,6 @@ const ChannelWithContext = (props: PropsWithChildren) =
const channelContext = useCreateChannelContext({
channel,
- channelUnreadState: channelUnreadStateStore.channelUnreadState,
channelUnreadStateStore,
disabled: !!channel?.data?.frozen,
EmptyStateIndicator,
@@ -1856,7 +1859,6 @@ const ChannelWithContext = (props: PropsWithChildren) =
CameraSelectorIcon,
channelId,
CommandInput,
- CommandsButton,
compressImageQuality,
CooldownTimer,
CreatePollContent,
@@ -1874,9 +1876,7 @@ const ChannelWithContext = (props: PropsWithChildren) =
ImageSelectorIcon,
Input,
InputButtons,
- InputEditingStateHeader,
- InputReplyStateHeader,
- MoreOptionsButton,
+ messageInputFloating,
openPollCreationDialog,
SendButton,
sendMessage,
@@ -1930,7 +1930,6 @@ const ChannelWithContext = (props: PropsWithChildren) =
forceAlignMessages,
Gallery,
getMessageGroupStyle,
- getMessagesGroupStyles,
Giphy,
giphyVersion,
handleBan,
diff --git a/package/src/components/Channel/__tests__/ownCapabilities.test.js b/package/src/components/Channel/__tests__/ownCapabilities.test.js
index 876bb5b483..b12e2f874d 100644
--- a/package/src/components/Channel/__tests__/ownCapabilities.test.js
+++ b/package/src/components/Channel/__tests__/ownCapabilities.test.js
@@ -1,6 +1,8 @@
import React from 'react';
import { FlatList } from 'react-native';
+import { SafeAreaProvider } from 'react-native-safe-area-context';
+
import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider';
@@ -47,14 +49,16 @@ describe('Own capabilities', () => {
});
const getComponent = (props = {}) => (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
const generateChannelWithCapabilities = async (capabilities = []) => {
diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts
index d47c70fc5d..824f30cab5 100644
--- a/package/src/components/Channel/hooks/useCreateChannelContext.ts
+++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts
@@ -4,7 +4,6 @@ import type { ChannelContextValue } from '../../../contexts/channelContext/Chann
export const useCreateChannelContext = ({
channel,
- channelUnreadState,
channelUnreadStateStore,
disabled,
EmptyStateIndicator,
@@ -51,7 +50,6 @@ export const useCreateChannelContext = ({
const channelContext: ChannelContextValue = useMemo(
() => ({
channel,
- channelUnreadState,
channelUnreadStateStore,
disabled,
EmptyStateIndicator,
diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts
index 5c5d4a0607..197d1419b8 100644
--- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts
+++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts
@@ -30,7 +30,6 @@ export const useCreateInputMessageInputContext = ({
channelId,
CameraSelectorIcon,
CommandInput,
- CommandsButton,
compressImageQuality,
CooldownTimer,
CreatePollContent,
@@ -48,9 +47,7 @@ export const useCreateInputMessageInputContext = ({
ImageSelectorIcon,
Input,
InputButtons,
- InputEditingStateHeader,
- InputReplyStateHeader,
- MoreOptionsButton,
+ messageInputFloating,
openPollCreationDialog,
SendButton,
sendMessage,
@@ -96,7 +93,6 @@ export const useCreateInputMessageInputContext = ({
AutoCompleteSuggestionList,
CameraSelectorIcon,
CommandInput,
- CommandsButton,
compressImageQuality,
CooldownTimer,
CreatePollContent,
@@ -114,9 +110,7 @@ export const useCreateInputMessageInputContext = ({
ImageSelectorIcon,
Input,
InputButtons,
- InputEditingStateHeader,
- InputReplyStateHeader,
- MoreOptionsButton,
+ messageInputFloating,
openPollCreationDialog,
SendButton,
sendMessage,
diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts
index 690a34a23d..0d469486b2 100644
--- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts
+++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts
@@ -28,7 +28,6 @@ export const useCreateMessagesContext = ({
forceAlignMessages,
Gallery,
getMessageGroupStyle,
- getMessagesGroupStyles,
Giphy,
giphyVersion,
handleBan,
@@ -148,7 +147,6 @@ export const useCreateMessagesContext = ({
forceAlignMessages,
Gallery,
getMessageGroupStyle,
- getMessagesGroupStyles,
Giphy,
giphyVersion,
handleBan,
diff --git a/package/src/components/ImageGallery/ImageGallery.tsx b/package/src/components/ImageGallery/ImageGallery.tsx
index ccd2193b93..866973e785 100644
--- a/package/src/components/ImageGallery/ImageGallery.tsx
+++ b/package/src/components/ImageGallery/ImageGallery.tsx
@@ -1,6 +1,5 @@
import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Image, ImageStyle, Keyboard, StyleSheet, ViewStyle } from 'react-native';
-
+import { Image, ImageStyle, StyleSheet, ViewStyle } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
@@ -50,6 +49,7 @@ import {
BottomSheetModal,
BottomSheetModalProvider,
} from '../BottomSheetCompatibility/BottomSheetModal';
+import { dismissKeyboard } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView';
const MARGIN = 32;
@@ -177,7 +177,7 @@ export const ImageGallery = (props: Props) => {
* Run the fade animation on visible change
*/
useEffect(() => {
- Keyboard.dismiss();
+ dismissKeyboard();
showScreen();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
diff --git a/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx b/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx
index 18ff62f08f..a73ddf45a8 100644
--- a/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx
+++ b/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx
@@ -94,7 +94,6 @@ export const ImageGalleryVideoControl = React.memo(
{
+ const { behavior = 'translate-with-padding', children, ...rest } = props;
+
+ const KeyboardProvider = KeyboardControllerPackage?.KeyboardProvider;
+ const KeyboardAvoidingView = KeyboardControllerPackage?.KeyboardAvoidingView;
+
+ if (KeyboardProvider && KeyboardAvoidingView) {
+ return (
+
+ {/* @ts-expect-error - The reason is that react-native-keyboard-controller's KeyboardAvoidingViewProps is a discriminated union, not a simple behavior union so it complains about the `position` value passed. */}
+
+ {children}
+
+
+ );
+ }
+ const compatibleBehavior =
+ behavior === 'translate-with-padding'
+ ? Platform.OS === 'ios'
+ ? 'padding'
+ : 'position'
+ : behavior;
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const dismissKeyboard = () => {
+ if (KeyboardControllerPackage?.KeyboardController) {
+ KeyboardControllerPackage?.KeyboardController.dismiss();
+ }
+ Keyboard.dismiss();
+};
+
+export { KeyboardControllerPackage };
diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx
index 1949d3d6bb..e9dfa47373 100644
--- a/package/src/components/Message/Message.tsx
+++ b/package/src/components/Message/Message.tsx
@@ -1,5 +1,15 @@
-import React, { useMemo, useState } from 'react';
-import { GestureResponderEvent, Keyboard, StyleProp, View, ViewStyle } from 'react-native';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import {
+ GestureResponderEvent,
+ StyleProp,
+ useWindowDimensions,
+ View,
+ ViewStyle,
+} from 'react-native';
+
+import { useSharedValue } from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { Portal } from 'react-native-teleport';
import type { Attachment, LocalMessage, UserResponse } from 'stream-chat';
@@ -9,6 +19,7 @@ import { useMessageActions } from './hooks/useMessageActions';
import { useMessageDeliveredData } from './hooks/useMessageDeliveryData';
import { useMessageReadData } from './hooks/useMessageReadData';
import { useProcessReactions } from './hooks/useProcessReactions';
+import { measureInWindow } from './utils/measureInWindow';
import { messageActions as defaultMessageActions } from './utils/messageActions';
import {
@@ -25,6 +36,7 @@ import {
useMessageComposerAPIContext,
} from '../../contexts/messageComposerContext/MessageComposerAPIContext';
import { MessageContextValue, MessageProvider } from '../../contexts/messageContext/MessageContext';
+import { useMessageListItemContext } from '../../contexts/messageListItemContext/MessageListItemContext';
import {
MessagesContextValue,
useMessagesContext,
@@ -38,6 +50,7 @@ import {
} from '../../contexts/translationContext/TranslationContext';
import { isVideoPlayerAvailable, NativeHandlers } from '../../native';
+import { closeOverlay, openOverlay, useIsOverlayActive } from '../../state-store';
import { FileTypes } from '../../types/types';
import {
checkMessageEquality,
@@ -48,6 +61,7 @@ import {
MessageStatusTypes,
} from '../../utils/utils';
import type { Thumbnail } from '../Attachment/utils/buildGallery/types';
+import { dismissKeyboard } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView';
export type TouchableEmitter =
| 'fileAttachment'
@@ -192,6 +206,13 @@ export type MessagePropsWithContext = Pick<
| 'supportedReactions'
| 'updateMessage'
| 'PollContent'
+ // TODO: remove this comment later, using it as a pragma mark
+ | 'MessageUserReactions'
+ | 'MessageUserReactionsAvatar'
+ | 'MessageUserReactionsItem'
+ | 'MessageReactionPicker'
+ | 'MessageActionList'
+ | 'MessageActionListItem'
> &
Pick &
Pick & {
@@ -219,12 +240,11 @@ export type MessagePropsWithContext = Pick<
* each individual Message component.
*/
const MessageWithContext = (props: MessagePropsWithContext) => {
- const [messageOverlayVisible, setMessageOverlayVisible] = useState(false);
const [isErrorInMessage, setIsErrorInMessage] = useState(false);
const [showMessageReactions, setShowMessageReactions] = useState(true);
const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false);
const [isEditedMessageOpen, setIsEditedMessageOpen] = useState(false);
- const [selectedReaction, setSelectedReaction] = useState(undefined);
+ // const [selectedReaction, setSelectedReaction] = useState(undefined);
const {
channel,
@@ -232,7 +252,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
deleteMessage: deleteMessageFromContext,
deleteReaction,
deliveredToCount,
- dismissKeyboard,
dismissKeyboardOnMessageTouch,
enableLongPress = true,
enforceUniqueReaction,
@@ -259,7 +278,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
MessageBlocked,
MessageBounce,
messageContentOrder: messageContentOrderProp,
- MessageMenu,
messagesContext,
MessageSimple,
onLongPressMessage: onLongPressMessageProp,
@@ -283,7 +301,15 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
updateMessage,
readBy,
setQuotedMessage,
+ MessageUserReactions,
+ MessageUserReactionsAvatar,
+ MessageUserReactionsItem,
+ MessageReactionPicker,
+ MessageActionList,
+ MessageActionListItem,
} = props;
+ // TODO: V9: Reconsider using safe area insets in every message.
+ const insets = useSafeAreaInsets();
const isMessageAIGenerated = messagesContext.isMessageAIGenerated;
const isAIGenerated = useMemo(
() => isMessageAIGenerated(message),
@@ -299,15 +325,37 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
},
} = useTheme();
- const showMessageOverlay = async (showMessageReactions = false, selectedReaction?: string) => {
- await dismissKeyboard();
- setShowMessageReactions(showMessageReactions);
- setMessageOverlayVisible(true);
- setSelectedReaction(selectedReaction);
+ const topH = useSharedValue<{ w: number; h: number; x: number; y: number } | undefined>(
+ undefined,
+ );
+ const bottomH = useSharedValue<{ w: number; h: number; x: number; y: number } | undefined>(
+ undefined,
+ );
+ const messageH = useSharedValue<{ w: number; h: number; x: number; y: number } | undefined>(
+ undefined,
+ );
+ const [rect, setRect] = useState<{ w: number; h: number; x: number; y: number } | undefined>(
+ undefined,
+ );
+ const { width: screenW } = useWindowDimensions();
+
+ const showMessageOverlay = async (showMessageReactions = false) => {
+ dismissKeyboard();
+ try {
+ const layout = await measureInWindow(messageWrapperRef, insets);
+ setRect(layout);
+ setShowMessageReactions(showMessageReactions);
+ messageH.value = layout;
+ openOverlay(message.id, { bottomH, messageH, topH });
+ } catch (e) {
+ console.error(e);
+ }
};
+ const { setNativeScrollability } = useMessageListItemContext();
+
const dismissOverlay = () => {
- setMessageOverlayVisible(false);
+ closeOverlay();
};
const actionsEnabled =
@@ -341,7 +389,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
const onPress = (error = errorOrFailed) => {
if (dismissKeyboardOnMessageTouch) {
- Keyboard.dismiss();
+ dismissKeyboard();
}
if (isEditedMessage(message)) {
setIsEditedMessageOpen((prevState) => !prevState);
@@ -620,7 +668,10 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
unpinMessage: handleTogglePinMessage,
};
+ const messageWrapperRef = useRef(null);
+
const onLongPress = () => {
+ setNativeScrollability(false);
if (hasAttachmentActions || isBlockedMessage(message) || !enableLongPress) {
return;
}
@@ -633,6 +684,9 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
showMessageOverlay();
};
+ const frozenMessage = useRef(message);
+ const { active: overlayActive } = useIsOverlayActive(message.id);
+
const messageContext = useCreateMessageContext({
actionsEnabled,
alignment,
@@ -652,7 +706,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
isMyMessage,
lastGroupMessage: groupStyles?.[0] === 'single' || groupStyles?.[0] === 'bottom',
members,
- message,
+ message: overlayActive ? frozenMessage.current : message,
messageContentOrder,
myMessageTheme: messagesContext.myMessageTheme,
onLongPress: (payload) => {
@@ -716,7 +770,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
}
: null,
otherAttachments: attachments.other,
- preventPress,
+ preventPress: overlayActive ? true : preventPress,
reactions,
readBy,
setIsEditedMessageOpen,
@@ -728,6 +782,21 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
videos: attachments.videos,
});
+ const prevActive = useRef(overlayActive);
+
+ useEffect(() => {
+ if (!overlayActive && prevActive.current && setNativeScrollability) {
+ setNativeScrollability(true);
+ }
+ prevActive.current = overlayActive;
+ }, [setNativeScrollability, overlayActive]);
+
+ useEffect(() => {
+ if (!overlayActive) {
+ frozenMessage.current = message;
+ }
+ }, [overlayActive, message]);
+
if (!(isMessageTypeDeleted || messageContentOrder.length)) {
return null;
}
@@ -759,20 +828,75 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
]}
testID='message-wrapper'
>
-
+ {overlayActive && rect ? (
+
+ ) : null}
+ {/*TODO: V9: Find a way to separate these in a dedicated file*/}
+
+ {overlayActive && rect ? (
+ {
+ const { width: w, height: h } = e.nativeEvent.layout;
+
+ topH.value = {
+ h,
+ w,
+ x: isMyMessage ? screenW - rect.x - w : rect.x,
+ y: rect.y - h,
+ };
+ }}
+ >
+ reaction.type) || []}
+ />
+
+ ) : null}
+
+
+
+
+
+ {overlayActive && rect ? (
+ {
+ const { width: w, height: h } = e.nativeEvent.layout;
+ bottomH.value = {
+ h,
+ w,
+ x: isMyMessage ? screenW - rect.x - w : rect.x,
+ y: rect.y + rect.h,
+ };
+ }}
+ >
+ {showMessageReactions ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
{isBounceDialogOpen ? (
) : null}
- {messageOverlayVisible ? (
-
- ) : null}
diff --git a/package/src/components/Message/MessageSimple/MessageContent.tsx b/package/src/components/Message/MessageSimple/MessageContent.tsx
index aa7efb1af0..7ed1605897 100644
--- a/package/src/components/Message/MessageSimple/MessageContent.tsx
+++ b/package/src/components/Message/MessageSimple/MessageContent.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useMemo, useState } from 'react';
import {
AnimatableNumericValue,
ColorValue,
@@ -16,6 +16,7 @@ import {
MessageContextValue,
useMessageContext,
} from '../../../contexts/messageContext/MessageContext';
+import { useMessageListItemContext } from '../../../contexts/messageListItemContext/MessageListItemContext';
import {
MessagesContextValue,
useMessagesContext,
@@ -26,8 +27,7 @@ import {
useTranslationContext,
} from '../../../contexts/translationContext/TranslationContext';
-import { useViewport } from '../../../hooks/useViewport';
-
+import { useViewport } from '../../../hooks';
import { checkMessageEquality, checkQuotedMessageEquality } from '../../../utils/utils';
import { Poll } from '../../Poll/Poll';
import { useMessageData } from '../hooks/useMessageData';
@@ -121,6 +121,7 @@ export type MessageContentPropsWithContext = Pick<
* Child of MessageSimple that displays a message's content
*/
const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
+ const [longPressFired, setLongPressFired] = useState(false);
const {
additionalPressableProps,
alignment,
@@ -151,6 +152,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
} = props;
const { client } = useChatContext();
const { PollContent: PollContentOverride } = useMessagesContext();
+ const { vw } = useViewport();
const {
theme: {
@@ -178,7 +180,6 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
},
},
} = useTheme();
- const { vw } = useViewport();
const onLayout: (event: LayoutChangeEvent) => void = ({
nativeEvent: {
@@ -239,10 +240,13 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
return bordersFromTheme;
};
+ const { setNativeScrollability } = useMessageListItemContext();
+
return (
{
+ setLongPressFired(true);
if (onLongPress) {
onLongPress({
emitter: 'messageContent',
@@ -266,8 +270,16 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
});
}
}}
- style={({ pressed }) => [{ opacity: pressed ? 0.5 : 1 }, container]}
+ style={({ pressed }) => [{ opacity: pressed && !longPressFired ? 0.5 : 1 }, container]}
{...additionalPressableProps}
+ onPressOut={(event) => {
+ setLongPressFired(false);
+ setNativeScrollability(true);
+
+ if (additionalPressableProps?.onPressOut) {
+ additionalPressableProps.onPressOut(event);
+ }
+ }}
>
{hasThreadReplies && !threadList && !noBorder && (
@@ -312,7 +324,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
key={`quoted_reply_${messageContentOrderIndex}`}
style={[styles.replyContainer, replyContainer]}
>
-
+
)
);
@@ -322,10 +334,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
));
case 'files':
return (
-
+
);
case 'gallery':
return ;
@@ -376,6 +385,7 @@ const areEqual = (
nextProps: MessageContentPropsWithContext,
) => {
const {
+ preventPress: prevPreventPress,
goToMessage: prevGoToMessage,
groupStyles: prevGroupStyles,
isAttachmentEqual,
@@ -387,6 +397,7 @@ const areEqual = (
t: prevT,
} = prevProps;
const {
+ preventPress: nextPreventPress,
goToMessage: nextGoToMessage,
groupStyles: nextGroupStyles,
isEditedMessageOpen: nextIsEditedMessageOpen,
@@ -397,6 +408,10 @@ const areEqual = (
t: nextT,
} = nextProps;
+ if (prevPreventPress !== nextPreventPress) {
+ return false;
+ }
+
const goToMessageChangedAndMatters =
nextMessage.quoted_message_id && prevGoToMessage !== nextGoToMessage;
if (goToMessageChangedAndMatters) {
diff --git a/package/src/components/Message/MessageSimple/MessageSimple.tsx b/package/src/components/Message/MessageSimple/MessageSimple.tsx
index 472d4c47be..db9e5b4f7e 100644
--- a/package/src/components/Message/MessageSimple/MessageSimple.tsx
+++ b/package/src/components/Message/MessageSimple/MessageSimple.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useState } from 'react';
+import React, { forwardRef, useMemo, useState } from 'react';
import { Dimensions, LayoutChangeEvent, StyleSheet, View } from 'react-native';
import { MessageBubble, SwipableMessageBubble } from './MessageBubble';
@@ -84,7 +84,7 @@ export type MessageSimplePropsWithContext = Pick<
shouldRenderSwipeableWrapper: boolean;
};
-const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => {
+const MessageSimpleWithContext = forwardRef((props, ref) => {
const [messageContentWidth, setMessageContentWidth] = useState(0);
const { width } = Dimensions.get('screen');
const {
@@ -200,100 +200,105 @@ const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => {
});
return (
-
- {alignment === 'left' ? : null}
- {isMessageTypeDeleted ? (
-
- ) : (
-
+
+
+ {alignment === 'left' ? : null}
+ {isMessageTypeDeleted ? (
+
+ ) : (
- {MessageHeader && (
-
+ {MessageHeader && (
+
+ )}
+ {message.pinned ? : null}
+
+ {enableSwipeToReply ? (
+
+ ) : (
+
)}
- {message.pinned ? : null}
+ {reactionListPosition === 'bottom' && ReactionListBottom ? (
+
+ ) : null}
+
+
- {enableSwipeToReply ? (
-
- ) : (
-
- )}
- {reactionListPosition === 'bottom' && ReactionListBottom ? : null}
-
-
-
- )}
+ )}
+
);
-};
+});
const areEqual = (
prevProps: MessageSimplePropsWithContext,
@@ -431,7 +436,7 @@ export type MessageSimpleProps = Partial;
*
* Message UI component
*/
-export const MessageSimple = (props: MessageSimpleProps) => {
+export const MessageSimple = forwardRef((props, ref) => {
const {
alignment,
channel,
@@ -506,9 +511,10 @@ export const MessageSimple = (props: MessageSimpleProps) => {
shouldRenderSwipeableWrapper,
showMessageStatus,
}}
+ ref={ref}
{...props}
/>
);
-};
+});
MessageSimple.displayName = 'MessageSimple{messageSimple{container}}';
diff --git a/package/src/components/Message/MessageSimple/__tests__/Message.test.js b/package/src/components/Message/MessageSimple/__tests__/Message.test.js
index 69779d57fa..1ea9467329 100644
--- a/package/src/components/Message/MessageSimple/__tests__/Message.test.js
+++ b/package/src/components/Message/MessageSimple/__tests__/Message.test.js
@@ -1,5 +1,7 @@
import React from 'react';
+import { SafeAreaProvider } from 'react-native-safe-area-context';
+
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react-native';
import { OverlayProvider } from '../../../../contexts/overlayContext/OverlayProvider';
@@ -37,15 +39,16 @@ describe('Message', () => {
renderMessage = (options) =>
render(
-
-
-
-
-
-
-
- ,
- ,
+
+
+
+
+
+
+
+
+
+ ,
);
});
diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts
index b44fa768e5..67b5ce394e 100644
--- a/package/src/components/Message/hooks/useCreateMessageContext.ts
+++ b/package/src/components/Message/hooks/useCreateMessageContext.ts
@@ -113,6 +113,7 @@ export const useCreateMessageContext = ({
showAvatar,
showMessageStatus,
threadList,
+ preventPress,
],
);
diff --git a/package/src/components/Message/utils/measureInWindow.ts b/package/src/components/Message/utils/measureInWindow.ts
new file mode 100644
index 0000000000..fb22c07e49
--- /dev/null
+++ b/package/src/components/Message/utils/measureInWindow.ts
@@ -0,0 +1,20 @@
+import React from 'react';
+import { Platform, View } from 'react-native';
+import { EdgeInsets } from 'react-native-safe-area-context';
+
+export const measureInWindow = (
+ node: React.RefObject,
+ insets: EdgeInsets,
+): Promise<{ x: number; y: number; w: number; h: number }> => {
+ return new Promise((resolve, reject) => {
+ const handle = node.current;
+ if (!handle)
+ return reject(
+ new Error('The native handle could not be found while invoking measureInWindow.'),
+ );
+
+ handle.measureInWindow((x, y, w, h) =>
+ resolve({ h, w, x, y: y + (Platform.OS === 'android' ? insets.top : 0) }),
+ );
+ });
+};
diff --git a/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx
deleted file mode 100644
index ed225bbabe..0000000000
--- a/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx
+++ /dev/null
@@ -1,277 +0,0 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { FlatList, LayoutChangeEvent, StyleSheet, View } from 'react-native';
-
-import {
- isLocalAudioAttachment,
- isLocalFileAttachment,
- isLocalImageAttachment,
- isLocalVoiceRecordingAttachment,
- isVideoAttachment,
- LocalAttachment,
- LocalImageAttachment,
-} from 'stream-chat';
-
-import { useAudioPreviewManager } from './hooks/useAudioPreviewManager';
-
-import { useMessageComposer } from '../../contexts';
-import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState';
-import {
- MessageInputContextValue,
- useMessageInputContext,
-} from '../../contexts/messageInputContext/MessageInputContext';
-import { useTheme } from '../../contexts/themeContext/ThemeContext';
-import { isSoundPackageAvailable } from '../../native';
-
-const IMAGE_PREVIEW_SIZE = 100;
-const FILE_PREVIEW_HEIGHT = 60;
-
-export type AttachmentUploadPreviewListPropsWithContext = Pick<
- MessageInputContextValue,
- | 'AudioAttachmentUploadPreview'
- | 'FileAttachmentUploadPreview'
- | 'ImageAttachmentUploadPreview'
- | 'VideoAttachmentUploadPreview'
->;
-
-/**
- * AttachmentUploadPreviewList
- * UI Component to preview the files set for upload
- */
-const UnMemoizedAttachmentUploadListPreview = (
- props: AttachmentUploadPreviewListPropsWithContext,
-) => {
- const [flatListWidth, setFlatListWidth] = useState(0);
- const flatListRef = useRef | null>(null);
- const {
- AudioAttachmentUploadPreview,
- FileAttachmentUploadPreview,
- ImageAttachmentUploadPreview,
- VideoAttachmentUploadPreview,
- } = props;
- const { attachmentManager } = useMessageComposer();
- const { attachments } = useAttachmentManagerState();
- const {
- theme: {
- colors: { grey_whisper },
- messageInput: {
- attachmentSeparator,
- attachmentUploadPreviewList: { filesFlatList, imagesFlatList, wrapper },
- },
- },
- } = useTheme();
-
- const imageUploads = attachments.filter((attachment) => isLocalImageAttachment(attachment));
- const fileUploads = useMemo(() => {
- return attachments.filter((attachment) => !isLocalImageAttachment(attachment));
- }, [attachments]);
- const audioUploads = useMemo(() => {
- return fileUploads.filter(
- (attachment) =>
- isLocalAudioAttachment(attachment) || isLocalVoiceRecordingAttachment(attachment),
- );
- }, [fileUploads]);
-
- const { audioAttachmentsStateMap, onLoad, onProgress, onPlayPause } =
- useAudioPreviewManager(audioUploads);
-
- const renderImageItem = useCallback(
- ({ item }: { item: LocalImageAttachment }) => {
- return (
-
- );
- },
- [
- ImageAttachmentUploadPreview,
- attachmentManager.removeAttachments,
- attachmentManager.uploadAttachment,
- ],
- );
-
- const renderFileItem = useCallback(
- ({ item }: { item: LocalAttachment }) => {
- if (isLocalImageAttachment(item)) {
- // This is already handled in the `renderImageItem` above, so we return null here to avoid duplication.
- return null;
- } else if (isLocalVoiceRecordingAttachment(item)) {
- return (
-
- );
- } else if (isLocalAudioAttachment(item)) {
- if (isSoundPackageAvailable()) {
- return (
-
- );
- } else {
- return (
-
- );
- }
- } else if (isVideoAttachment(item)) {
- return (
-
- );
- } else if (isLocalFileAttachment(item)) {
- return (
-
- );
- } else return null;
- },
- [
- AudioAttachmentUploadPreview,
- FileAttachmentUploadPreview,
- VideoAttachmentUploadPreview,
- attachmentManager.removeAttachments,
- attachmentManager.uploadAttachment,
- audioAttachmentsStateMap,
- flatListWidth,
- onLoad,
- onPlayPause,
- onProgress,
- ],
- );
-
- useEffect(() => {
- if (fileUploads.length && flatListRef.current) {
- setTimeout(() => flatListRef.current?.scrollToEnd(), 1);
- }
- }, [fileUploads.length]);
-
- const onLayout = useCallback(
- (event: LayoutChangeEvent) => {
- if (flatListRef.current) {
- setFlatListWidth(event.nativeEvent.layout.width);
- }
- },
- [flatListRef],
- );
-
- if (!attachments.length) {
- return null;
- }
-
- return (
-
- {imageUploads.length ? (
- ({
- index,
- length: IMAGE_PREVIEW_SIZE + 8,
- offset: (IMAGE_PREVIEW_SIZE + 8) * index,
- })}
- horizontal
- keyExtractor={(item) => item.localMetadata.id}
- renderItem={renderImageItem}
- style={[styles.imagesFlatList, imagesFlatList]}
- />
- ) : null}
- {imageUploads.length && fileUploads.length ? (
-
- ) : null}
- {fileUploads.length ? (
- ({
- index,
- length: FILE_PREVIEW_HEIGHT + 8,
- offset: (FILE_PREVIEW_HEIGHT + 8) * index,
- })}
- keyExtractor={(item) => item.localMetadata.id}
- onLayout={onLayout}
- ref={flatListRef}
- renderItem={renderFileItem}
- style={[styles.filesFlatList, filesFlatList]}
- testID={'file-upload-preview'}
- />
- ) : null}
-
- );
-};
-
-export type AttachmentUploadPreviewListProps = Partial;
-
-const MemoizedAttachmentUploadPreviewListWithContext = React.memo(
- UnMemoizedAttachmentUploadListPreview,
-);
-
-/**
- * AttachmentUploadPreviewList
- * UI Component to preview the files set for upload
- */
-export const AttachmentUploadPreviewList = (props: AttachmentUploadPreviewListProps) => {
- const {
- AudioAttachmentUploadPreview,
- FileAttachmentUploadPreview,
- ImageAttachmentUploadPreview,
- VideoAttachmentUploadPreview,
- } = useMessageInputContext();
- return (
-
- );
-};
-
-const styles = StyleSheet.create({
- attachmentSeparator: {
- borderBottomWidth: 1,
- marginVertical: 8,
- },
- filesFlatList: { maxHeight: FILE_PREVIEW_HEIGHT * 2.5 + 16 },
- imagesFlatList: {},
- wrapper: {
- paddingTop: 12,
- },
-});
-
-AttachmentUploadPreviewList.displayName =
- 'AttachmentUploadPreviewList{messageInput{attachmentUploadPreviewList}}';
diff --git a/package/src/components/MessageInput/CommandsButton.tsx b/package/src/components/MessageInput/CommandsButton.tsx
deleted file mode 100644
index 7ca093dece..0000000000
--- a/package/src/components/MessageInput/CommandsButton.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import React, { useCallback } from 'react';
-import type { GestureResponderEvent, PressableProps } from 'react-native';
-import { Pressable } from 'react-native';
-
-import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer';
-import { useTheme } from '../../contexts/themeContext/ThemeContext';
-import { Lightning } from '../../icons/Lightning';
-
-export type CommandsButtonProps = {
- /** Function that opens commands selector. */
- handleOnPress?: PressableProps['onPress'];
-};
-
-export const CommandsButton = (props: CommandsButtonProps) => {
- const { handleOnPress } = props;
- const messageComposer = useMessageComposer();
- const { textComposer } = messageComposer;
-
- const onPressHandler = useCallback(
- async (event: GestureResponderEvent) => {
- if (handleOnPress) {
- handleOnPress(event);
- return;
- }
-
- await textComposer.handleChange({
- selection: {
- end: 1,
- start: 1,
- },
- text: '/',
- });
- },
- [handleOnPress, textComposer],
- );
-
- const {
- theme: {
- colors: { grey },
- messageInput: { commandsButton },
- },
- } = useTheme();
-
- return (
-
-
-
- );
-};
-
-CommandsButton.displayName = 'CommandsButton{messageInput}';
diff --git a/package/src/components/MessageInput/CooldownTimer.tsx b/package/src/components/MessageInput/CooldownTimer.tsx
deleted file mode 100644
index 36c524d2db..0000000000
--- a/package/src/components/MessageInput/CooldownTimer.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import React from 'react';
-import { StyleSheet, Text, View } from 'react-native';
-
-import { useTheme } from '../../contexts/themeContext/ThemeContext';
-
-export type CooldownTimerProps = {
- seconds: number;
-};
-
-const CONTAINER_SIZE = 24;
-const CONTAINER_HORIZONTAL_PADDING = 6;
-const EXTRA_CHARACTER_PADDING = CONTAINER_SIZE - CONTAINER_HORIZONTAL_PADDING * 2;
-
-/**
- * To avoid the container jumping between sizes when there are more
- * than one character in the width of the container since we aren't
- * using a monospaced font.
- */
-const normalizeWidth = (seconds: number) =>
- CONTAINER_SIZE + EXTRA_CHARACTER_PADDING * (`${seconds}`.length - 1);
-
-/**
- * Renders an amount of seconds left for a cooldown to finish.
- *
- * See `useCountdown` for an example of how to set a countdown
- * to use as the source of `seconds`.
- **/
-export const CooldownTimer = (props: CooldownTimerProps) => {
- const { seconds } = props;
- const {
- theme: {
- colors: { black, grey_gainsboro },
- messageInput: {
- cooldownTimer: { container, text },
- },
- },
- } = useTheme();
-
- return (
-
-
- {seconds}
-
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- alignItems: 'center',
- borderRadius: CONTAINER_SIZE / 2,
- height: CONTAINER_SIZE,
- justifyContent: 'center',
- minWidth: CONTAINER_SIZE,
- paddingHorizontal: CONTAINER_HORIZONTAL_PADDING,
- },
- text: { fontSize: 16, fontWeight: '600' },
-});
diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx
index 7ced90ea08..5c6080e790 100644
--- a/package/src/components/MessageInput/MessageInput.tsx
+++ b/package/src/components/MessageInput/MessageInput.tsx
@@ -1,5 +1,5 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
-import { Modal, StyleSheet, TextInput, TextInputProps, View } from 'react-native';
+import React, { useEffect } from 'react';
+import { Modal, Platform, StyleSheet, TextInput, TextInputProps, View } from 'react-native';
import {
Gesture,
@@ -9,19 +9,30 @@ import {
} from 'react-native-gesture-handler';
import Animated, {
Extrapolation,
+ FadeIn,
+ FadeOut,
interpolate,
+ LinearTransition,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
+ ZoomIn,
+ ZoomOut,
} from 'react-native-reanimated';
import { type MessageComposerState, type TextComposerState, type UserResponse } from 'stream-chat';
-import { useAudioController } from './hooks/useAudioController';
+import { OutputButtons } from './components/OutputButtons';
+import { useAudioRecorder } from './hooks/useAudioRecorder';
import { useCountdown } from './hooks/useCountdown';
-import { ChatContextValue, useChatContext, useOwnCapabilitiesContext } from '../../contexts';
+import {
+ ChatContextValue,
+ useAttachmentManagerState,
+ useChatContext,
+ useOwnCapabilitiesContext,
+} from '../../contexts';
import {
AttachmentPickerContextValue,
useAttachmentPickerContext,
@@ -34,9 +45,7 @@ import {
MessageComposerAPIContextValue,
useMessageComposerAPIContext,
} from '../../contexts/messageComposerContext/MessageComposerAPIContext';
-import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState';
import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer';
-import { useMessageComposerHasSendableData } from '../../contexts/messageInputContext/hooks/useMessageComposerHasSendableData';
import {
MessageInputContextValue,
useMessageInputContext,
@@ -52,47 +61,75 @@ import {
useTranslationContext,
} from '../../contexts/translationContext/TranslationContext';
+import { useKeyboardVisibility } from '../../hooks/useKeyboardVisibility';
import { useStateStore } from '../../hooks/useStateStore';
import { isAudioRecorderAvailable, NativeHandlers } from '../../native';
-import { AIStates, useAIState } from '../AITypingIndicatorView';
+import {
+ MessageInputHeightState,
+ messageInputHeightStore,
+ setMessageInputHeight,
+} from '../../state-store/message-input-height-store';
import { AutoCompleteInput } from '../AutoCompleteInput/AutoCompleteInput';
import { CreatePoll } from '../Poll/CreatePollContent';
import { SafeAreaViewWrapper } from '../UIComponents/SafeAreaViewWrapper';
const styles = StyleSheet.create({
- attachmentSeparator: {
- borderBottomWidth: 1,
- marginBottom: 10,
- },
- autoCompleteInputContainer: {
- alignItems: 'center',
- flexDirection: 'row',
- },
- composerContainer: {
+ container: {
alignItems: 'center',
flexDirection: 'row',
+ gap: 8,
justifyContent: 'space-between',
},
- container: {
- borderTopWidth: 1,
- padding: 10,
+ contentContainer: {
+ gap: 4,
+ overflow: 'hidden',
+ paddingHorizontal: 8,
+ },
+ floatingWrapper: {
+ left: 0,
+ paddingHorizontal: 24,
+ position: 'absolute',
+ right: 0,
},
inputBoxContainer: {
- borderRadius: 20,
+ flex: 1,
+ },
+ inputBoxWrapper: {
+ borderRadius: 24,
borderWidth: 1,
flex: 1,
- marginHorizontal: 10,
+ flexDirection: 'row',
},
- micButtonContainer: {},
- optionsContainer: {
+ inputButtonsContainer: {
+ alignSelf: 'flex-end',
+ },
+ inputContainer: {
+ alignItems: 'center',
flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ micButtonContainer: {},
+ outputButtonsContainer: {
+ alignSelf: 'flex-end',
+ padding: 8,
+ },
+ shadow: {
+ elevation: 6,
+
+ shadowColor: 'hsla(0, 0%, 0%, 0.24)',
+ shadowOffset: { height: 4, width: 0 },
+ shadowOpacity: 0.24,
+ shadowRadius: 12,
},
- replyContainer: { paddingBottom: 0, paddingHorizontal: 8, paddingTop: 8 },
- sendButtonContainer: {},
suggestionsListContainer: {
position: 'absolute',
width: '100%',
},
+ wrapper: {
+ borderTopWidth: 1,
+ paddingHorizontal: 24,
+ paddingTop: 24,
+ },
});
type MessageInputPropsWithContext = Pick<
@@ -125,14 +162,13 @@ type MessageInputPropsWithContext = Pick<
| 'Input'
| 'inputBoxRef'
| 'InputButtons'
- | 'InputEditingStateHeader'
| 'CameraSelectorIcon'
| 'CreatePollIcon'
| 'FileSelectorIcon'
+ | 'messageInputFloating'
| 'ImageSelectorIcon'
| 'VideoRecorderSelectorIcon'
| 'CommandInput'
- | 'InputReplyStateHeader'
| 'SendButton'
| 'ShowThreadMessageInChannelButton'
| 'StartAudioRecordingButton'
@@ -148,6 +184,8 @@ type MessageInputPropsWithContext = Pick<
Pick &
Pick & {
editing: boolean;
+ hasAttachments: boolean;
+ isKeyboardVisible: boolean;
TextInputComponent?: React.ComponentType<
TextInputProps & {
ref: React.Ref | undefined;
@@ -157,7 +195,6 @@ type MessageInputPropsWithContext = Pick<
const textComposerStateSelector = (state: TextComposerState) => ({
command: state.command,
- hasText: !!state.text,
mentionedUsers: state.mentionedUsers,
suggestions: state.suggestions,
});
@@ -166,6 +203,10 @@ const messageComposerStateStoreSelector = (state: MessageComposerState) => ({
quotedMessage: state.quotedMessage,
});
+const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({
+ height: state.height,
+});
+
const MessageInputWithContext = (props: MessageInputPropsWithContext) => {
const {
AttachmentPickerSelectionBar,
@@ -186,58 +227,59 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => {
AudioRecordingLockIndicator,
AudioRecordingPreview,
AutoCompleteSuggestionList,
- channel,
closeAttachmentPicker,
closePollCreationDialog,
cooldownEndsAt,
- CooldownTimer,
CreatePollContent,
disableAttachmentPicker,
editing,
+ hasAttachments,
+ messageInputFloating,
Input,
inputBoxRef,
InputButtons,
- InputEditingStateHeader,
CommandInput,
- InputReplyStateHeader,
+ isKeyboardVisible,
isOnline,
members,
Reply,
threadList,
- SendButton,
sendMessage,
showPollCreationDialog,
ShowThreadMessageInChannelButton,
StartAudioRecordingButton,
- StopMessageStreamingButton,
TextInputComponent,
watchers,
} = props;
const messageComposer = useMessageComposer();
+ const { clearEditingState } = useMessageComposerAPIContext();
+ const onDismissEditMessage = () => {
+ clearEditingState();
+ };
const { textComposer } = messageComposer;
- const { command, hasText } = useStateStore(textComposer.state, textComposerStateSelector);
+ const { command } = useStateStore(textComposer.state, textComposerStateSelector);
const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector);
- const { attachments } = useAttachmentManagerState();
- const hasSendableData = useMessageComposerHasSendableData();
-
- const [height, setHeight] = useState(0);
+ const { height } = useStateStore(messageInputHeightStore, messageInputHeightStoreSelector);
const {
theme: {
colors: { border, grey_whisper, white, white_smoke },
messageInput: {
attachmentSelectionBar,
- autoCompleteInputContainer,
- composerContainer,
container,
+ contentContainer,
+ floatingWrapper,
focusedInputBoxContainer,
inputBoxContainer,
+ inputBoxWrapper,
+ inputContainer,
+ inputButtonsContainer,
+ inputFloatingContainer,
micButtonContainer,
- optionsContainer,
- replyContainer,
- sendButtonContainer,
+ outputButtonsContainer,
suggestionsListContainer: { container: suggestionListContainer },
+ wrapper,
},
},
} = useTheme();
@@ -311,11 +353,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => {
const {
deleteVoiceRecording,
micLocked,
- onVoicePlayerPlayPause,
- paused,
permissionsGranted,
- position,
- progress,
recording,
recordingDuration,
recordingStatus,
@@ -324,14 +362,9 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => {
stopVoiceRecording,
uploadVoiceRecording,
waveformData,
- } = useAudioController();
+ } = useAudioRecorder();
const asyncAudioEnabled = audioRecordingEnabled && isAudioRecorderAvailable();
- const showSendingButton = hasText || attachments.length || command;
-
- const isSendingButtonVisible = useMemo(() => {
- return asyncAudioEnabled ? showSendingButton && !recording : true;
- }, [asyncAudioEnabled, recording, showSendingButton]);
const micPositionX = useSharedValue(0);
const micPositionY = useSharedValue(0);
@@ -422,24 +455,31 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => {
],
}));
- const { aiState } = useAIState(channel);
-
- const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]);
- const shouldDisplayStopAIGeneration =
- [AIStates.Thinking, AIStates.Generating].includes(aiState) && !!StopMessageStreamingButton;
+ const BOTTOM_OFFSET = isKeyboardVisible ? 24 : Platform.OS === 'ios' ? 32 : 24;
return (
<>
- setHeight(newHeight)}
- style={[styles.container, { backgroundColor: white, borderColor: border }, container]}
+ }) => setMessageInputHeight(messageInputFloating ? newHeight + BOTTOM_OFFSET : newHeight)} // 24 is the position of the input from the bottom of the screen
+ style={
+ messageInputFloating
+ ? [styles.floatingWrapper, { bottom: BOTTOM_OFFSET }, floatingWrapper]
+ : [
+ styles.wrapper,
+ {
+ backgroundColor: white,
+ borderColor: border,
+ paddingBottom: BOTTOM_OFFSET,
+ },
+ wrapper,
+ ]
+ }
>
- {editing && }
- {quotedMessage && !editing && }
{recording && (
<>
{
/>
{recordingStatus === 'stopped' ? (
{
>
)}
-
+
{Input ? (
) : (
@@ -488,76 +524,118 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => {
/>
) : (
<>
-
+
{InputButtons && }
-
- {quotedMessage && (
-
-
+
+
+ {editing ? (
+
+
+
+ ) : null}
+ {quotedMessage ? (
+
+
+
+ ) : null}
+
- )}
-
-
- {command ? (
-
- ) : (
-
-
+
+
+ {command ? (
+
+ ) : (
+
+ )}
+
+
+
+
- )}
-
+
+
>
)}
- {shouldDisplayStopAIGeneration ? (
-
- ) : isSendingButtonVisible ? (
- cooldownRemainingSeconds ? (
-
- ) : (
-
-
-
- )
- ) : null}
- {audioRecordingEnabled && isAudioRecorderAvailable() && !micLocked && (
+ {asyncAudioEnabled && !micLocked ? (
-
-
+
+
+
+
- )}
+ ) : null}
>
)}
-
+
-
+
-
+
{!disableAttachmentPicker && selectedPicker ? (
- {
]}
>
-
+
) : null}
{showPollCreationDialog ? (
@@ -608,6 +686,8 @@ const areEqual = (
closePollCreationDialog: prevClosePollCreationDialog,
cooldownEndsAt: prevCooldownEndsAt,
editing: prevEditing,
+ hasAttachments: prevHasAttachments,
+ isKeyboardVisible: prevIsKeyboardVisible,
isOnline: prevIsOnline,
openPollCreationDialog: prevOpenPollCreationDialog,
selectedPicker: prevSelectedPicker,
@@ -625,7 +705,9 @@ const areEqual = (
closePollCreationDialog: nextClosePollCreationDialog,
cooldownEndsAt: nextCooldownEndsAt,
editing: nextEditing,
+ isKeyboardVisible: nextIsKeyboardVisible,
isOnline: nextIsOnline,
+ hasAttachments: nextHasAttachments,
openPollCreationDialog: nextOpenPollCreationDialog,
selectedPicker: nextSelectedPicker,
showPollCreationDialog: nextShowPollCreationDialog,
@@ -685,6 +767,16 @@ const areEqual = (
return false;
}
+ const hasAttachmentsEqual = prevHasAttachments === nextHasAttachments;
+ if (!hasAttachmentsEqual) {
+ return false;
+ }
+
+ const isKeyboardVisibleEqual = prevIsKeyboardVisible === nextIsKeyboardVisible;
+ if (!isKeyboardVisibleEqual) {
+ return false;
+ }
+
const isOnlineEqual = prevIsOnline === nextIsOnline;
if (!isOnlineEqual) {
return false;
@@ -762,9 +854,8 @@ export const MessageInput = (props: MessageInputProps) => {
Input,
inputBoxRef,
InputButtons,
- InputEditingStateHeader,
CommandInput,
- InputReplyStateHeader,
+ messageInputFloating,
openPollCreationDialog,
SendButton,
sendMessage,
@@ -783,6 +874,8 @@ export const MessageInput = (props: MessageInputProps) => {
const { clearEditingState } = useMessageComposerAPIContext();
const { Reply } = useMessagesContext();
+ const { attachments } = useAttachmentManagerState();
+ const isKeyboardVisible = useKeyboardVisibility();
const { t } = useTranslationContext();
@@ -831,14 +924,15 @@ export const MessageInput = (props: MessageInputProps) => {
disableAttachmentPicker,
editing,
FileSelectorIcon,
+ hasAttachments: attachments.length > 0,
ImageSelectorIcon,
Input,
inputBoxRef,
InputButtons,
- InputEditingStateHeader,
- InputReplyStateHeader,
+ isKeyboardVisible,
isOnline,
members,
+ messageInputFloating,
openPollCreationDialog,
Reply,
selectedPicker,
diff --git a/package/src/components/MessageInput/MoreOptionsButton.tsx b/package/src/components/MessageInput/MoreOptionsButton.tsx
deleted file mode 100644
index f02227da61..0000000000
--- a/package/src/components/MessageInput/MoreOptionsButton.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from 'react';
-import { Pressable } from 'react-native';
-
-import { useTheme } from '../../contexts/themeContext/ThemeContext';
-import { CircleRight } from '../../icons/CircleRight';
-
-export type MoreOptionsButtonProps = {
- /** Function that opens attachment options bottom sheet */
- handleOnPress?: () => void;
-};
-
-export const MoreOptionsButton = (props: MoreOptionsButtonProps) => {
- const { handleOnPress } = props;
-
- const {
- theme: {
- colors: { accent_blue },
- messageInput: { moreOptionsButton },
- },
- } = useTheme();
-
- return (
- [moreOptionsButton, { opacity: pressed ? 0.8 : 1 }]}
- testID='more-options-button'
- >
-
-
- );
-};
-
-MoreOptionsButton.displayName = 'MoreOptionsButton{messageInput}';
diff --git a/package/src/components/MessageInput/SendButton.tsx b/package/src/components/MessageInput/SendButton.tsx
deleted file mode 100644
index b5b9959cf4..0000000000
--- a/package/src/components/MessageInput/SendButton.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import React, { useCallback } from 'react';
-
-import { Pressable } from 'react-native';
-
-import { TextComposerState } from 'stream-chat';
-
-import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer';
-import {
- MessageInputContextValue,
- useMessageInputContext,
-} from '../../contexts/messageInputContext/MessageInputContext';
-import { useTheme } from '../../contexts/themeContext/ThemeContext';
-import { useStateStore } from '../../hooks/useStateStore';
-import { Search } from '../../icons/Search';
-import { SendRight } from '../../icons/SendRight';
-import { SendUp } from '../../icons/SendUp';
-
-export type SendButtonProps = Partial> & {
- /** Disables the button */
- disabled: boolean;
-};
-
-const textComposerStateSelector = (state: TextComposerState) => ({
- command: state.command,
-});
-
-export const SendButton = (props: SendButtonProps) => {
- const { disabled = false, sendMessage: propsSendMessage } = props;
- const { sendMessage: sendMessageFromContext } = useMessageInputContext();
- const sendMessage = propsSendMessage || sendMessageFromContext;
- const messageComposer = useMessageComposer();
- const { textComposer } = messageComposer;
- const { command } = useStateStore(textComposer.state, textComposerStateSelector);
-
- const {
- theme: {
- colors: { accent_blue, grey_gainsboro },
- messageInput: { searchIcon, sendButton, sendRightIcon, sendUpIcon },
- },
- } = useTheme();
-
- const onPressHandler = useCallback(() => {
- if (disabled) {
- return;
- }
- sendMessage();
- }, [disabled, sendMessage]);
-
- return (
-
- {command ? (
-
- ) : disabled ? (
-
- ) : (
-
- )}
-
- );
-};
-
-SendButton.displayName = 'SendButton{messageInput}';
diff --git a/package/src/components/MessageInput/__tests__/AttachButton.test.js b/package/src/components/MessageInput/__tests__/AttachButton.test.js
index d0201770aa..dbd9ae7305 100644
--- a/package/src/components/MessageInput/__tests__/AttachButton.test.js
+++ b/package/src/components/MessageInput/__tests__/AttachButton.test.js
@@ -8,7 +8,7 @@ import { initiateClientWithChannels } from '../../../mock-builders/api/initiateC
import * as NativeHandler from '../../../native';
import { Channel } from '../../Channel/Channel';
import { Chat } from '../../Chat/Chat';
-import { AttachButton } from '../AttachButton';
+import { AttachButton } from '../components/InputButtons/AttachButton';
const renderComponent = ({ channelProps, client, props }) => {
return render(
diff --git a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js
index 5532989480..91c86b386a 100644
--- a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js
+++ b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js
@@ -14,7 +14,7 @@ import {
import { FileState } from '../../../utils/utils';
import { Channel } from '../../Channel/Channel';
import { Chat } from '../../Chat/Chat';
-import { AttachmentUploadPreviewList } from '../AttachmentUploadPreviewList';
+import { AttachmentUploadPreviewList } from '../components/AttachmentPreview/AttachmentUploadPreviewList';
jest.mock('../../../native.ts', () => {
const { View } = require('react-native');
diff --git a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js
index 00a93050cb..4aebfdb773 100644
--- a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js
+++ b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js
@@ -9,7 +9,7 @@ import { generateAudioAttachment } from '../../../mock-builders/attachments';
import { FileState } from '../../../utils/utils';
import { Channel } from '../../Channel/Channel';
import { Chat } from '../../Chat/Chat';
-import { AttachmentUploadPreviewList } from '../AttachmentUploadPreviewList';
+import { AttachmentUploadPreviewList } from '../components/AttachmentPreview/AttachmentUploadPreviewList';
jest.mock('../../../native.ts', () => {
const View = require('react-native').View;
diff --git a/package/src/components/MessageInput/__tests__/CommandsButton.test.js b/package/src/components/MessageInput/__tests__/CommandsButton.test.js
deleted file mode 100644
index cd226cb652..0000000000
--- a/package/src/components/MessageInput/__tests__/CommandsButton.test.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import React from 'react';
-
-import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
-
-import { OverlayProvider } from '../../../contexts';
-
-import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels';
-import { Channel } from '../../Channel/Channel';
-import { Chat } from '../../Chat/Chat';
-import { CommandsButton } from '../CommandsButton';
-
-const renderComponent = ({ client, channel, props }) => {
- return render(
-
-
-
-
-
-
- ,
- );
-};
-
-describe('CommandsButton', () => {
- let client;
- let channel;
-
- beforeEach(async () => {
- const { client: chatClient, channels } = await initiateClientWithChannels();
- client = chatClient;
- channel = channels[0];
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- cleanup();
- });
-
- it('should render component', async () => {
- const props = {};
- renderComponent({ channel, client, props });
-
- const { queryByTestId } = screen;
-
- await waitFor(() => {
- expect(queryByTestId('commands-button')).toBeTruthy();
- });
- });
-
- it('should call handleOnPress callback when the button is clicked if passed', async () => {
- const handleOnPress = jest.fn();
- const props = { handleOnPress };
-
- renderComponent({ channel, client, props });
-
- const { getByTestId, queryByTestId } = screen;
-
- await waitFor(() => {
- expect(queryByTestId('commands-button')).toBeTruthy();
- });
-
- act(() => {
- fireEvent.press(getByTestId('commands-button'));
- });
-
- await waitFor(() => {
- expect(handleOnPress).toHaveBeenCalled();
- });
- });
-});
diff --git a/package/src/components/MessageInput/__tests__/InputButtons.test.js b/package/src/components/MessageInput/__tests__/InputButtons.test.js
index 501fb72dba..d25e38492d 100644
--- a/package/src/components/MessageInput/__tests__/InputButtons.test.js
+++ b/package/src/components/MessageInput/__tests__/InputButtons.test.js
@@ -1,15 +1,13 @@
import React from 'react';
-import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react-native';
import { OverlayProvider } from '../../../contexts';
import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels';
-import { generateImageAttachment } from '../../../mock-builders/attachments';
-import { FileState } from '../../../utils/utils';
import { Channel } from '../../Channel/Channel';
import { Chat } from '../../Chat/Chat';
-import { InputButtons } from '../InputButtons';
+import { InputButtons } from '../components/InputButtons/index';
const renderComponent = ({ channelProps, client, props }) => {
return render(
@@ -42,24 +40,6 @@ describe('InputButtons', () => {
});
});
- // TODO: Add it back once the command inject PR is merged
- it.skip('should return null if the commands are set on the textComposer', async () => {
- const props = {};
- const channelProps = { channel };
-
- channel.messageComposer.textComposer.setCommand({ description: 'Ban a user', name: 'ban' });
-
- renderComponent({ channelProps, client, props });
-
- const { queryByTestId } = screen;
-
- await waitFor(() => {
- expect(queryByTestId('more-options-button')).toBeFalsy();
- expect(queryByTestId('commands-button')).toBeFalsy();
- expect(queryByTestId('attach-button')).toBeFalsy();
- });
- });
-
it('should return null if hasCommands is false and hasAttachmentUploadCapabilities is false', async () => {
const props = {};
const channelProps = {
@@ -75,28 +55,11 @@ describe('InputButtons', () => {
const { queryByTestId } = screen;
await waitFor(() => {
- expect(queryByTestId('more-options-button')).toBeFalsy();
expect(queryByTestId('commands-button')).toBeFalsy();
expect(queryByTestId('attach-button')).toBeFalsy();
});
});
- it('should show more options when the hasCommand is true and the hasAttachmentUploadCapabilities is true', async () => {
- const props = {};
- const channelProps = {
- channel,
- };
-
- renderComponent({ channelProps, client, props });
-
- const { queryByTestId } = screen;
-
- await waitFor(() => {
- expect(queryByTestId('commands-button')).toBeTruthy();
- expect(queryByTestId('attach-button')).toBeTruthy();
- });
- });
-
it('should show only attach button when the hasCommand is false and the hasAttachmentUploadCapabilities is true', async () => {
const props = {};
const channelProps = {
@@ -114,25 +77,6 @@ describe('InputButtons', () => {
});
});
- it('should show only commands button when the hasCommand is true and the hasAttachmentUploadCapabilities is false', async () => {
- const props = {};
- const channelProps = {
- channel,
- overrideOwnCapabilities: {
- uploadFile: false,
- },
- };
-
- renderComponent({ channelProps, client, props });
-
- const { queryByTestId } = screen;
-
- await waitFor(() => {
- expect(queryByTestId('commands-button')).toBeTruthy();
- expect(queryByTestId('attach-button')).toBeFalsy();
- });
- });
-
it('should not show commands buttons when there is text in the textComposer', async () => {
const props = {};
const channelProps = {
@@ -146,62 +90,4 @@ describe('InputButtons', () => {
expect(queryByTestId('commands-button')).toBeFalsy();
});
});
-
- it('should show more options button when there is text in the textComposer', async () => {
- const props = {};
- const channelProps = {
- channel,
- };
- channel.messageComposer.textComposer.setText('hello');
-
- renderComponent({ channelProps, client, props });
-
- const { queryByTestId } = screen;
-
- await waitFor(() => {
- expect(queryByTestId('more-options-button')).toBeTruthy();
- });
-
- act(() => {
- fireEvent.press(queryByTestId('more-options-button'));
- });
-
- await waitFor(() => {
- // Falsy, because the textComposer has text. This is a good test.
- expect(queryByTestId('commands-button')).toBeFalsy();
- expect(queryByTestId('attach-button')).toBeTruthy();
- });
- });
-
- it('should show more options button when there is attachments', async () => {
- const props = {};
- const channelProps = {
- channel,
- };
- channel.messageComposer.attachmentManager.upsertAttachments([
- generateImageAttachment({
- localMetadata: {
- id: 'image-attachment',
- uploadState: FileState.UPLOADING,
- },
- }),
- ]);
-
- renderComponent({ channelProps, client, props });
-
- const { queryByTestId } = screen;
-
- await waitFor(() => {
- expect(queryByTestId('more-options-button')).toBeTruthy();
- });
-
- act(() => {
- fireEvent.press(queryByTestId('more-options-button'));
- });
-
- await waitFor(() => {
- expect(queryByTestId('commands-button')).toBeTruthy();
- expect(queryByTestId('attach-button')).toBeTruthy();
- });
- });
});
diff --git a/package/src/components/MessageInput/__tests__/MessageInput.test.js b/package/src/components/MessageInput/__tests__/MessageInput.test.js
index 4243d7eba6..8cea3bef40 100644
--- a/package/src/components/MessageInput/__tests__/MessageInput.test.js
+++ b/package/src/components/MessageInput/__tests__/MessageInput.test.js
@@ -9,7 +9,6 @@ import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvide
import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels';
-import { NativeHandlers } from '../../../native';
import { AttachmentPickerSelectionBar } from '../../AttachmentPicker/components/AttachmentPickerSelectionBar';
import { CameraSelectorIcon } from '../../AttachmentPicker/components/CameraSelectorIcon';
import { FileSelectorIcon } from '../../AttachmentPicker/components/FileSelectorIcon';
@@ -90,61 +89,60 @@ describe('MessageInput', () => {
});
});
- it('should start the audio recorder on long press and cleanup on unmount', async () => {
- renderComponent({
- channelProps: { audioRecordingEnabled: true, channel },
- client,
- props: {},
- });
-
- const { queryByTestId, unmount } = screen;
-
- const audioButton = queryByTestId('audio-button');
-
- act(() => {
- fireEvent(audioButton, 'longPress');
- });
-
- await waitFor(() => {
- expect(NativeHandlers.Audio.startRecording).toHaveBeenCalledTimes(1);
- expect(NativeHandlers.Audio.stopRecording).not.toHaveBeenCalled();
- expect(queryByTestId('recording-active-container')).toBeTruthy();
- expect(Alert.alert).not.toHaveBeenCalledWith('Hold to start recording.');
- });
-
- await act(() => {
- unmount();
- });
-
- await waitFor(() => {
- expect(NativeHandlers.Audio.stopRecording).toHaveBeenCalledTimes(1);
- // once when starting the recording, once on unmount
- expect(NativeHandlers.Audio.stopPlayer).toHaveBeenCalledTimes(2);
- });
- });
-
- it('should trigger an alert if a normal press happened on audio recording', async () => {
- renderComponent({
- channelProps: { audioRecordingEnabled: true, channel },
- client,
- props: {},
- });
-
- const { queryByTestId } = screen;
-
- const audioButton = queryByTestId('audio-button');
-
- act(() => {
- fireEvent.press(audioButton);
- });
-
- await waitFor(() => {
- expect(NativeHandlers.Audio.startRecording).not.toHaveBeenCalled();
- expect(NativeHandlers.Audio.stopRecording).not.toHaveBeenCalled();
- expect(queryByTestId('recording-active-container')).not.toBeTruthy();
- // This is sort of a brittle test, but there doesn't seem to be another way
- // to target alerts. The reason why it's here is because we had a bug with it.
- expect(Alert.alert).toHaveBeenCalledWith('Hold to start recording.');
- });
- });
+ // TODO: Once the async audio design is done, fix it
+ // it('should start the audio recorder on long press and cleanup on unmount', async () => {
+ // renderComponent({
+ // channelProps: { audioRecordingEnabled: false, channel }, // TODO: Once the async audio design is done, fix it
+ // client,
+ // props: {},
+ // });
+
+ // const { getByLabelText, unmount } = screen;
+
+ // const audioButton = getByLabelText('Start recording');
+
+ // act(() => {
+ // fireEvent(audioButton, 'longPress');
+ // });
+
+ // await waitFor(() => {
+ // expect(NativeHandlers.Audio.startRecording).toHaveBeenCalledTimes(1);
+ // expect(NativeHandlers.Audio.stopRecording).not.toHaveBeenCalled();
+ // expect(queryByTestId('recording-active-container')).toBeTruthy();
+ // expect(Alert.alert).not.toHaveBeenCalledWith('Hold to start recording.');
+ // });
+
+ // await act(() => {
+ // unmount();
+ // });
+
+ // await waitFor(() => {
+ // expect(NativeHandlers.Audio.stopRecording).toHaveBeenCalledTimes(1);
+ // });
+ // });
+
+ // it('should trigger an alert if a normal press happened on audio recording', async () => {
+ // renderComponent({
+ // channelProps: { audioRecordingEnabled: false, channel },
+ // client,
+ // props: {},
+ // });
+
+ // const { getByLabelText, queryByTestId } = screen;
+
+ // const audioButton = getByLabelText('Start recording');
+
+ // act(() => {
+ // fireEvent.press(audioButton);
+ // });
+
+ // await waitFor(() => {
+ // expect(NativeHandlers.Audio.startRecording).not.toHaveBeenCalled();
+ // expect(NativeHandlers.Audio.stopRecording).not.toHaveBeenCalled();
+ // expect(queryByTestId('recording-active-container')).not.toBeTruthy();
+ // // This is sort of a brittle test, but there doesn't seem to be another way
+ // // to target alerts. The reason why it's here is because we had a bug with it.
+ // expect(Alert.alert).toHaveBeenCalledWith('Hold to start recording.');
+ // });
+ // });
});
diff --git a/package/src/components/MessageInput/__tests__/SendButton.test.js b/package/src/components/MessageInput/__tests__/SendButton.test.js
index e501588eb6..f237aad828 100644
--- a/package/src/components/MessageInput/__tests__/SendButton.test.js
+++ b/package/src/components/MessageInput/__tests__/SendButton.test.js
@@ -7,7 +7,7 @@ import { OverlayProvider } from '../../../contexts';
import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels';
import { Channel } from '../../Channel/Channel';
import { Chat } from '../../Chat/Chat';
-import { SendButton } from '../SendButton';
+import { SendButton } from '../components/OutputButtons/SendButton';
const renderComponent = ({ client, channel, props }) => {
return render(
@@ -60,7 +60,6 @@ describe('SendButton', () => {
await waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
- expect(getByTestId('send-up')).toBeDefined();
});
const snapshot = toJSON();
@@ -90,7 +89,6 @@ describe('SendButton', () => {
await waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(0);
- expect(getByTestId('send-right')).toBeDefined();
});
const snapshot = toJSON();
@@ -99,21 +97,4 @@ describe('SendButton', () => {
expect(snapshot).toMatchSnapshot();
});
});
-
- // TODO: Add it back once the command inject PR is merged
- it.skip('should show search button if the command is enabled', async () => {
- const sendMessage = jest.fn();
-
- const props = { sendMessage };
-
- channel.messageComposer.textComposer.setCommand({ description: 'Ban a user', name: 'ban' });
-
- renderComponent({ channel, client, props });
-
- const { queryByTestId } = screen;
-
- await waitFor(() => {
- expect(queryByTestId('search-icon')).toBeTruthy();
- });
- });
});
diff --git a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap
index c88aacebce..8c62fffa57 100644
--- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap
+++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap
@@ -1,610 +1,1042 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`AttachButton should call handleAttachButtonPress when the button is clicked if passed 1`] = `
-
+
-
-
-
-
-
-
-
-
-
-
+ strokeLinecap={1}
+ strokeLinejoin={1}
+ strokeWidth={1.5}
+ />
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
`;
exports[`AttachButton should render a enabled AttachButton 1`] = `
-
+
-
-
-
-
-
-
-
-
-
-
+ strokeLinecap={1}
+ strokeLinejoin={1}
+ strokeWidth={1.5}
+ />
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
`;
exports[`AttachButton should render an disabled AttachButton 1`] = `
-
+
-
-
-
-
-
-
-
-
-
-
+ strokeLinecap={1}
+ strokeLinejoin={1}
+ strokeWidth={1.5}
+ />
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
`;
diff --git a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap
index ca6b9d765b..9ba722b90a 100644
--- a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap
+++ b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap
@@ -1,357 +1,689 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`SendButton should render a SendButton 1`] = `
-
+
-
-
-
-
-
-
+ strokeLinecap={1}
+ strokeLinejoin={1}
+ strokeWidth={1.5}
+ >
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
`;
exports[`SendButton should render a disabled SendButton 1`] = `
-
+
-
-
-
-
-
-
+ strokeLinecap={1}
+ strokeLinejoin={1}
+ strokeWidth={1.5}
+ >
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
`;
diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx
new file mode 100644
index 0000000000..dc2ffd3349
--- /dev/null
+++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentRemoveControl.tsx
@@ -0,0 +1,58 @@
+import React, { useMemo } from 'react';
+
+import { Pressable, PressableProps, StyleSheet } from 'react-native';
+
+import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
+import { NewClose } from '../../../../icons/NewClose';
+
+type AttachmentRemoveControlProps = PressableProps;
+
+export const AttachmentRemoveControl = ({ onPress }: AttachmentRemoveControlProps) => {
+ const {
+ theme: {
+ colors: { control },
+ messageInput: {
+ dismissAttachmentUpload: { dismiss, dismissIcon, dismissIconColor },
+ },
+ },
+ } = useTheme();
+ const styles = useStyles();
+
+ return (
+ [
+ styles.dismiss,
+ {
+ opacity: pressed ? 0.8 : 1,
+ },
+ dismiss,
+ ]}
+ testID='remove-upload-preview'
+ >
+
+
+ );
+};
+
+const useStyles = () => {
+ const {
+ theme: {
+ colors: { control },
+ radius,
+ },
+ } = useTheme();
+ return useMemo(
+ () =>
+ StyleSheet.create({
+ dismiss: {
+ backgroundColor: control.remove,
+ borderColor: control.border,
+ borderRadius: radius.xl,
+ borderWidth: 2,
+ overflow: 'hidden',
+ },
+ }),
+ [control, radius],
+ );
+};
diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx
index 6ff419c2f3..096d293824 100644
--- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx
+++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUnsupportedIndicator.tsx
@@ -3,11 +3,9 @@ import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext';
-import { Warning } from '../../../../icons/Warning';
+import { ErrorCircle } from '../../../../icons/ErrorCircle';
import { Progress, ProgressIndicatorTypes } from '../../../../utils/utils';
-const WARNING_ICON_SIZE = 16;
-
export type AttachmentUnsupportedIndicatorProps = {
/** Type of active indicator */
indicatorType?: Progress;
@@ -21,7 +19,7 @@ export const AttachmentUnsupportedIndicator = ({
}: AttachmentUnsupportedIndicatorProps) => {
const {
theme: {
- colors: { accent_red, grey_dark, overlay, white },
+ colors: { accent_error, overlay },
messageInput: {
attachmentUnsupportedIndicator: { container, text, warningIcon },
},
@@ -42,16 +40,14 @@ export const AttachmentUnsupportedIndicator = ({
container,
]}
>
-
-
- {t('Not supported')}
-
+ {t('Not supported')}
);
};
@@ -61,7 +57,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
flexDirection: 'row',
marginTop: 4,
- paddingHorizontal: 2,
},
imageStyle: {
borderRadius: 16,
@@ -70,13 +65,11 @@ const styles = StyleSheet.create({
},
warningIconStyle: {
borderRadius: 24,
- marginTop: 6,
},
warningText: {
alignItems: 'center',
color: 'black',
- fontSize: 10,
- justifyContent: 'center',
+ fontSize: 12,
marginHorizontal: 4,
},
});
diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx
new file mode 100644
index 0000000000..60b2fa8ecc
--- /dev/null
+++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx
@@ -0,0 +1,258 @@
+import React, { useCallback, useMemo } from 'react';
+import { FlatList, StyleSheet, View } from 'react-native';
+
+import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
+
+import {
+ isLocalAudioAttachment,
+ isLocalFileAttachment,
+ isLocalImageAttachment,
+ isLocalVoiceRecordingAttachment,
+ isVideoAttachment,
+ LocalAttachment,
+} from 'stream-chat';
+
+import { useMessageComposer } from '../../../../contexts';
+import { useAttachmentManagerState } from '../../../../contexts/messageInputContext/hooks/useAttachmentManagerState';
+import {
+ MessageInputContextValue,
+ useMessageInputContext,
+} from '../../../../contexts/messageInputContext/MessageInputContext';
+import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
+import { isSoundPackageAvailable } from '../../../../native';
+
+const IMAGE_PREVIEW_SIZE = 72;
+const FILE_PREVIEW_HEIGHT = 224;
+
+export type AttachmentUploadListPreviewPropsWithContext = Pick<
+ MessageInputContextValue,
+ | 'AudioAttachmentUploadPreview'
+ | 'FileAttachmentUploadPreview'
+ | 'ImageAttachmentUploadPreview'
+ | 'VideoAttachmentUploadPreview'
+>;
+
+const ItemSeparatorComponent = () => {
+ const styles = useStyles();
+ const {
+ theme: {
+ messageInput: {
+ attachmentUploadPreviewList: { itemSeparator },
+ },
+ },
+ } = useTheme();
+ return ;
+};
+
+const getItemLayout = (data: ArrayLike | null | undefined, index: number) => {
+ const item = data?.[index];
+ if (item && isLocalImageAttachment(item as LocalAttachment)) {
+ return {
+ index,
+ length: IMAGE_PREVIEW_SIZE + 8,
+ offset: (IMAGE_PREVIEW_SIZE + 8) * index,
+ };
+ }
+ return {
+ index,
+ length: FILE_PREVIEW_HEIGHT + 8,
+ offset: (FILE_PREVIEW_HEIGHT + 8) * index,
+ };
+};
+
+/**
+ * AttachmentUploadPreviewList
+ * UI Component to preview the files set for upload
+ */
+const UnMemoizedAttachmentUploadPreviewList = (
+ props: AttachmentUploadListPreviewPropsWithContext,
+) => {
+ const {
+ AudioAttachmentUploadPreview,
+ FileAttachmentUploadPreview,
+ ImageAttachmentUploadPreview,
+ VideoAttachmentUploadPreview,
+ } = props;
+ const { attachmentManager } = useMessageComposer();
+ const { attachments } = useAttachmentManagerState();
+
+ const styles = useStyles();
+ const {
+ theme: {
+ messageInput: {
+ attachmentUploadPreviewList: { flatList },
+ },
+ },
+ } = useTheme();
+
+ const renderItem = useCallback(
+ ({ item }: { item: LocalAttachment }) => {
+ if (isLocalImageAttachment(item)) {
+ return (
+
+
+
+ );
+ } else if (isLocalVoiceRecordingAttachment(item)) {
+ return (
+
+
+
+ );
+ } else if (isLocalAudioAttachment(item)) {
+ if (isSoundPackageAvailable()) {
+ return (
+
+
+
+ );
+ } else {
+ return (
+
+
+
+ );
+ }
+ } else if (isVideoAttachment(item)) {
+ return (
+
+
+
+ );
+ } else if (isLocalFileAttachment(item)) {
+ return (
+
+
+
+ );
+ } else return null;
+ },
+ [
+ AudioAttachmentUploadPreview,
+ FileAttachmentUploadPreview,
+ ImageAttachmentUploadPreview,
+ VideoAttachmentUploadPreview,
+ attachmentManager.removeAttachments,
+ attachmentManager.uploadAttachment,
+ ],
+ );
+
+ if (!attachments.length) {
+ return null;
+ }
+
+ return (
+ item.localMetadata.id}
+ renderItem={renderItem}
+ showsHorizontalScrollIndicator={false}
+ style={[styles.flatList, flatList]}
+ testID={'attachment-upload-preview-list'}
+ />
+ );
+};
+
+export type AttachmentUploadPreviewListProps = Partial;
+
+const MemoizedAttachmentUploadPreviewListWithContext = React.memo(
+ UnMemoizedAttachmentUploadPreviewList,
+);
+
+/**
+ * AttachmentUploadPreviewList
+ * UI Component to preview the files set for upload
+ */
+export const AttachmentUploadPreviewList = (props: AttachmentUploadPreviewListProps) => {
+ const {
+ AudioAttachmentUploadPreview,
+ FileAttachmentUploadPreview,
+ ImageAttachmentUploadPreview,
+ VideoAttachmentUploadPreview,
+ } = useMessageInputContext();
+ return (
+
+ );
+};
+
+const useStyles = () => {
+ const {
+ theme: { spacing },
+ } = useTheme();
+
+ return useMemo(
+ () =>
+ StyleSheet.create({
+ flatList: {
+ overflow: 'visible',
+ },
+ itemSeparator: {
+ width: spacing.xs,
+ },
+ wrapper: {},
+ }),
+ [spacing.xs],
+ );
+};
+
+AttachmentUploadPreviewList.displayName =
+ 'AttachmentUploadPreviewList{messageInput{attachmentUploadPreviewList}}';
diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx
index d1de2afb5c..1313d6ecaf 100644
--- a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx
+++ b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx
@@ -4,51 +4,25 @@ import { StyleSheet, View } from 'react-native';
import { FileReference, LocalAudioAttachment, LocalVoiceRecordingAttachment } from 'stream-chat';
+import { AttachmentRemoveControl } from './AttachmentRemoveControl';
import { AttachmentUnsupportedIndicator } from './AttachmentUnsupportedIndicator';
import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator';
-import { DismissAttachmentUpload } from './DismissAttachmentUpload';
import { AudioAttachment } from '../../../../components/Attachment/AudioAttachment';
import { useChatContext } from '../../../../contexts/chatContext/ChatContext';
import { useMessageComposer } from '../../../../contexts/messageInputContext/hooks/useMessageComposer';
-import { AudioConfig, UploadAttachmentPreviewProps } from '../../../../types/types';
+import { UploadAttachmentPreviewProps } from '../../../../types/types';
import { getIndicatorTypeForFileState, ProgressIndicatorTypes } from '../../../../utils/utils';
export type AudioAttachmentUploadPreviewProps> =
UploadAttachmentPreviewProps<
LocalAudioAttachment | LocalVoiceRecordingAttachment
- > & {
- /**
- * The audio attachment config
- *
- * @deprecated This is deprecated and will be removed in the future.
- */
- audioAttachmentConfig: AudioConfig;
- /**
- * Callback to be called when the audio is loaded
- * @deprecated This is deprecated and will be removed in the future.
- */
- onLoad: (index: string, duration: number) => void;
- /**
- * Callback to be called when the audio is played or paused
- * @deprecated This is deprecated and will be removed in the future.
- */
- onPlayPause: (index: string, pausedStatus?: boolean) => void;
- /**
- * Callback to be called when the audio progresses
- * @deprecated This is deprecated and will be removed in the future.
- */
- onProgress: (index: string, progress: number) => void;
- };
+ >;
export const AudioAttachmentUploadPreview = ({
attachment,
- audioAttachmentConfig,
handleRetry,
removeAttachments,
- onLoad,
- onPlayPause,
- onProgress,
}: AudioAttachmentUploadPreviewProps) => {
const { enableOfflineSupport } = useChatContext();
const indicatorType = getIndicatorTypeForFileState(
@@ -69,9 +43,8 @@ export const AudioAttachmentUploadPreview = ({
...attachment,
asset_url: assetUrl,
id: attachment.localMetadata.id,
- ...audioAttachmentConfig,
}),
- [attachment, assetUrl, audioAttachmentConfig],
+ [attachment, assetUrl],
);
const onRetryHandler = useCallback(() => {
@@ -93,15 +66,12 @@ export const AudioAttachmentUploadPreview = ({
hideProgressBar={true}
isPreview={true}
item={finalAttachment}
- onLoad={onLoad}
- onPlayPause={onPlayPause}
- onProgress={onProgress}
showSpeedSettings={false}
titleMaxLength={12}
/>
-
+
{indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? (
@@ -113,7 +83,7 @@ export const AudioAttachmentUploadPreview = ({
const styles = StyleSheet.create({
dismissWrapper: {
position: 'absolute',
- right: 8,
+ right: 0,
top: 0,
},
overlay: {
diff --git a/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx b/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx
deleted file mode 100644
index 43158d4b92..0000000000
--- a/package/src/components/MessageInput/components/AttachmentPreview/DismissAttachmentUpload.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react';
-
-import { Pressable, PressableProps, StyleSheet } from 'react-native';
-
-import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
-import { Close } from '../../../../icons';
-
-type DismissAttachmentUploadProps = PressableProps;
-
-export const DismissAttachmentUpload = ({ onPress }: DismissAttachmentUploadProps) => {
- const {
- theme: {
- colors: { overlay, white },
- messageInput: {
- dismissAttachmentUpload: { dismiss, dismissIcon, dismissIconColor },
- },
- },
- } = useTheme();
-
- return (
- [
- styles.dismiss,
- { backgroundColor: overlay, opacity: pressed ? 0.8 : 1 },
- dismiss,
- ]}
- testID='remove-upload-preview'
- >
-
-
- );
-};
-
-const styles = StyleSheet.create({
- dismiss: {
- borderRadius: 24,
- position: 'absolute',
- right: 8,
- top: 8,
- },
-});
diff --git a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx
index 856bc31e87..4a40d1d21d 100644
--- a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx
+++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx
@@ -1,12 +1,12 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
import { I18nManager, StyleSheet, Text, View } from 'react-native';
import { LocalAudioAttachment, LocalFileAttachment, LocalVideoAttachment } from 'stream-chat';
+import { AttachmentRemoveControl } from './AttachmentRemoveControl';
import { AttachmentUnsupportedIndicator } from './AttachmentUnsupportedIndicator';
import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator';
-import { DismissAttachmentUpload } from './DismissAttachmentUpload';
import { getFileSizeDisplayText } from '../../../../components/Attachment/FileAttachment';
import { WritingDirectionAwareText } from '../../../../components/RTLComponents/WritingDirectionAwareText';
@@ -14,7 +14,6 @@ import { useChatContext } from '../../../../contexts/chatContext/ChatContext';
import { useMessagesContext } from '../../../../contexts/messagesContext/MessagesContext';
import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
import { UploadAttachmentPreviewProps } from '../../../../types/types';
-import { getTrimmedAttachmentTitle } from '../../../../utils/getTrimmedAttachmentTitle';
import {
getDurationLabelFromDuration,
getIndicatorTypeForFileState,
@@ -26,13 +25,10 @@ export type FileAttachmentUploadPreviewProps
| LocalVideoAttachment
| LocalAudioAttachment
- > & {
- flatListWidth: number;
- };
+ >;
export const FileAttachmentUploadPreview = ({
attachment,
- flatListWidth,
handleRetry,
removeAttachments,
}: FileAttachmentUploadPreviewProps) => {
@@ -45,7 +41,6 @@ export const FileAttachmentUploadPreview = ({
const {
theme: {
- colors: { black, grey, grey_whisper },
messageInput: {
fileAttachmentUploadPreview: {
fileContainer,
@@ -58,6 +53,7 @@ export const FileAttachmentUploadPreview = ({
},
},
} = useTheme();
+ const styles = useStyles();
const onRetryHandler = useCallback(() => {
handleRetry(attachment);
@@ -71,47 +67,28 @@ export const FileAttachmentUploadPreview = ({
-
+
-
+
-
+
- {getTrimmedAttachmentTitle(attachment.title)}
+ {attachment.title}
{indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? (
) : (
-
+
{attachment.duration
? getDurationLabelFromDuration(attachment.duration)
: getFileSizeDisplayText(attachment.file_size)}
@@ -120,42 +97,60 @@ export const FileAttachmentUploadPreview = ({
-
+
+
+
);
};
-const styles = StyleSheet.create({
- fileContainer: {
- borderRadius: 12,
- borderWidth: 1,
- flexDirection: 'row',
- paddingHorizontal: 8,
- },
- fileIcon: {
- alignItems: 'center',
- alignSelf: 'center',
- justifyContent: 'center',
- },
- filenameText: {
- fontSize: 14,
- fontWeight: 'bold',
- },
- fileSizeText: {
- fontSize: 12,
- marginTop: 10,
- },
- fileTextContainer: {
- justifyContent: 'space-around',
- marginVertical: 10,
- paddingHorizontal: 10,
- },
- overlay: {
- borderRadius: 12,
- marginTop: 2,
- },
- wrapper: {
- flexDirection: 'row',
- marginHorizontal: 8,
- },
-});
+const useStyles = () => {
+ const {
+ theme: {
+ colors: { borderSurfaceSubtle, text },
+ radius,
+ spacing,
+ typography: { fontSize, fontWeight },
+ },
+ } = useTheme();
+ return useMemo(
+ () =>
+ StyleSheet.create({
+ dismissWrapper: { position: 'absolute', right: 0, top: 0 },
+ fileContainer: {
+ borderRadius: radius.lg,
+ borderColor: borderSurfaceSubtle,
+ borderWidth: 1,
+ flexDirection: 'row',
+ gap: spacing.sm,
+ maxWidth: 224, // TODO: Not sure how to omit this
+ padding: spacing.md,
+ },
+ fileContent: {
+ flexShrink: 1,
+ justifyContent: 'space-between',
+ },
+ fileIcon: {
+ alignItems: 'center',
+ alignSelf: 'center',
+ justifyContent: 'center',
+ },
+ filenameText: {
+ color: text.primary,
+ fontSize: fontSize.xs,
+ fontWeight: fontWeight.semibold,
+ },
+ fileSizeText: {
+ color: text.secondary,
+ fontSize: fontSize.xs,
+ },
+ overlay: {
+ borderRadius: radius.lg,
+ },
+ wrapper: {
+ padding: spacing.xxs,
+ },
+ }),
+ [radius, borderSurfaceSubtle, spacing, text, fontSize, fontWeight],
+ );
+};
diff --git a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx
index 7c29cc5715..a0ed819af0 100644
--- a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx
+++ b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx
@@ -1,19 +1,19 @@
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
import { Image, StyleSheet, View } from 'react-native';
import { LocalImageAttachment } from 'stream-chat';
+import { AttachmentRemoveControl } from './AttachmentRemoveControl';
import { AttachmentUnsupportedIndicator } from './AttachmentUnsupportedIndicator';
import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator';
-import { DismissAttachmentUpload } from './DismissAttachmentUpload';
import { useChatContext } from '../../../../contexts/chatContext/ChatContext';
import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
import { UploadAttachmentPreviewProps } from '../../../../types/types';
import { getIndicatorTypeForFileState, ProgressIndicatorTypes } from '../../../../utils/utils';
-const IMAGE_PREVIEW_SIZE = 100;
+const IMAGE_PREVIEW_SIZE = 72;
export type ImageAttachmentUploadPreviewProps> =
UploadAttachmentPreviewProps>;
@@ -32,10 +32,11 @@ export const ImageAttachmentUploadPreview = ({
const {
theme: {
messageInput: {
- imageAttachmentUploadPreview: { itemContainer, upload },
+ imageAttachmentUploadPreview: { container, upload, wrapper },
},
},
} = useTheme();
+ const styles = useStyles();
const onRetryHandler = useCallback(() => {
handleRetry(attachment);
@@ -54,10 +55,10 @@ export const ImageAttachmentUploadPreview = ({
}, []);
return (
-
+
+ {indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? (
+
+ ) : null}
-
- {indicatorType === ProgressIndicatorTypes.NOT_SUPPORTED ? (
-
- ) : null}
+
+
+
);
};
-const styles = StyleSheet.create({
- fileSizeText: {
- fontSize: 12,
- paddingHorizontal: 10,
- },
- flatList: { paddingBottom: 12 },
- itemContainer: {
- flexDirection: 'row',
- height: IMAGE_PREVIEW_SIZE,
- marginLeft: 8,
- },
- upload: {
- borderRadius: 10,
- height: IMAGE_PREVIEW_SIZE,
- width: IMAGE_PREVIEW_SIZE,
- },
-});
+const useStyles = () => {
+ const {
+ theme: { spacing, radius, colors },
+ } = useTheme();
+ return useMemo(
+ () =>
+ StyleSheet.create({
+ container: {
+ borderColor: colors.borderImage,
+ borderRadius: radius.lg,
+ borderWidth: 1,
+ flexDirection: 'row',
+ overflow: 'hidden',
+ },
+ dismissWrapper: { position: 'absolute', right: 0, top: 0 },
+ upload: {
+ height: IMAGE_PREVIEW_SIZE,
+ width: IMAGE_PREVIEW_SIZE,
+ },
+ wrapper: {
+ padding: spacing.xxs,
+ },
+ }),
+ [colors, radius, spacing],
+ );
+};
diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx
index 0186f0d571..39782431dd 100644
--- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx
+++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx
@@ -1,13 +1,13 @@
import React from 'react';
-import { Alert, Linking, Pressable, StyleSheet } from 'react-native';
+import { Alert, Linking } from 'react-native';
+import { IconButton } from '../../../../components/ui/IconButton';
import {
MessageInputContextValue,
useMessageInputContext,
} from '../../../../contexts/messageInputContext/MessageInputContext';
-import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext';
-import { Mic } from '../../../../icons/Mic';
+import { NewMic } from '../../../../icons/NewMic';
import { AudioRecordingReturnType, NativeHandlers } from '../../../../native';
export type AudioRecordingButtonProps = Partial<
@@ -45,7 +45,6 @@ export type AudioRecordingButtonProps = Partial<
export const AudioRecordingButton = (props: AudioRecordingButtonProps) => {
const {
asyncMessagesMinimumPressDuration: propAsyncMessagesMinimumPressDuration,
- buttonSize,
handleLongPress,
handlePress,
permissionsGranted,
@@ -58,14 +57,6 @@ export const AudioRecordingButton = (props: AudioRecordingButtonProps) => {
const asyncMessagesMinimumPressDuration =
propAsyncMessagesMinimumPressDuration || contextAsyncMessagesMinimumPressDuration;
- const {
- theme: {
- colors: { grey, light_gray, white },
- messageInput: {
- audioRecordingButton: { container, micIcon },
- },
- },
- } = useTheme();
const { t } = useTranslationContext();
const onPressHandler = () => {
@@ -103,33 +94,17 @@ export const AudioRecordingButton = (props: AudioRecordingButtonProps) => {
};
return (
- [
- styles.container,
- {
- backgroundColor: pressed ? light_gray : white,
- height: buttonSize || 40,
- width: buttonSize || 40,
- },
- container,
- ]}
- testID='audio-button'
- >
-
-
+ size='sm'
+ type='secondary'
+ />
);
};
-const styles = StyleSheet.create({
- container: {
- alignItems: 'center',
- borderRadius: 50,
- justifyContent: 'center',
- marginLeft: 8,
- },
-});
-
AudioRecordingButton.displayName = 'AudioRecordingButton{messageInput}';
diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx
index 1e5f378205..4bea5b3941 100644
--- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx
+++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx
@@ -4,7 +4,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native';
import dayjs from 'dayjs';
import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
-import { useAudioPlayerControl } from '../../../../hooks/useAudioPlayerControl';
+import { useAudioPlayer } from '../../../../hooks/useAudioPlayer';
import { useStateStore } from '../../../../hooks/useStateStore';
import { Pause, Play } from '../../../../icons';
@@ -22,34 +22,6 @@ export type AudioRecordingPreviewProps = {
* The waveform data to be presented to show the audio levels.
*/
waveformData: number[];
- /**
- * Boolean used to show the paused state of the player.
- *
- * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager.
- * FIXME: Remove this in the next major version.
- */
- paused: boolean;
- /**
- * Number used to show the current position of the audio being played.
- *
- * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager.
- * FIXME: Remove this in the next major version.
- */
- position: number;
- /**
- * Number used to show the percentage of progress of the audio being played. It should be in 0-1 range.
- *
- * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager.
- * FIXME: Remove this in the next major version.
- */
- progress: number;
- /**
- * Function to play or pause the audio player.
- *
- * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager.
- * FIXME: Remove this in the next major version.
- */
- onVoicePlayerPlayPause?: () => Promise;
};
const audioPlayerSelector = (state: AudioPlayerState) => ({
@@ -65,7 +37,7 @@ const audioPlayerSelector = (state: AudioPlayerState) => ({
export const AudioRecordingPreview = (props: AudioRecordingPreviewProps) => {
const { recordingDuration, uri, waveformData } = props;
- const audioPlayer = useAudioPlayerControl({
+ const audioPlayer = useAudioPlayer({
duration: recordingDuration / ONE_SECOND_IN_MILLISECONDS,
mimeType: 'audio/aac',
// This is a temporary flag to manage audio player for voice recording in preview as the one in message list uses react-native-video.
diff --git a/package/src/components/MessageInput/components/CommandInput.tsx b/package/src/components/MessageInput/components/CommandInput.tsx
index c789f657f1..f8c6c58f8b 100644
--- a/package/src/components/MessageInput/components/CommandInput.tsx
+++ b/package/src/components/MessageInput/components/CommandInput.tsx
@@ -43,8 +43,8 @@ export const CommandInput = ({
theme: {
colors: { accent_blue, grey, white },
messageInput: {
- autoCompleteInputContainer,
commandInput: { closeButton, container, text },
+ inputContainer,
},
},
} = useTheme();
@@ -61,13 +61,13 @@ export const CommandInput = ({
const commandName = (command.name ?? '').toUpperCase();
return (
-
+
{commandName}
-
+
{
} = props;
const {
theme: {
- colors: { accent_blue, grey },
messageInput: { attachButton },
},
} = useTheme();
@@ -84,15 +82,16 @@ const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => {
return (
<>
-
-
-
+ type='secondary'
+ />
{showAttachButtonPicker ? (
;
export type InputButtonsWithContextProps = Pick<
MessageInputContextValue,
| 'AttachButton'
- | 'CommandsButton'
| 'hasCameraPicker'
| 'hasCommands'
| 'hasFilePicker'
| 'hasImagePicker'
- | 'MoreOptionsButton'
| 'toggleAttachmentPicker'
> &
Pick &
Pick;
-const textComposerStateSelector = (state: TextComposerState) => ({
- command: state.command,
- hasText: !!state.text,
-});
-
export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => {
const {
AttachButton,
- CommandsButton,
hasCameraPicker,
hasCommands,
hasFilePicker,
hasImagePicker,
- MoreOptionsButton,
uploadFile: ownCapabilitiesUploadFile,
} = props;
- const { textComposer } = useMessageComposer();
- const { command, hasText } = useStateStore(textComposer.state, textComposerStateSelector);
-
- const [showMoreOptions, setShowMoreOptions] = useState(true);
- const { attachments } = useAttachmentManagerState();
-
- const shouldShowMoreOptions = hasText || attachments.length;
-
- useEffect(() => {
- setShowMoreOptions(!shouldShowMoreOptions);
- }, [shouldShowMoreOptions]);
const {
theme: {
@@ -68,36 +43,18 @@ export const InputButtonsWithContext = (props: InputButtonsWithContextProps) =>
},
} = useTheme();
- const handleShowMoreOptions = useCallback(() => {
- setShowMoreOptions(true);
- }, [setShowMoreOptions]);
-
const hasAttachmentUploadCapabilities =
(hasCameraPicker || hasFilePicker || hasImagePicker) && ownCapabilitiesUploadFile;
- const showCommandsButton = hasCommands && !hasText;
-
- if (command) {
- return null;
- }
if (!hasAttachmentUploadCapabilities && !hasCommands) {
return null;
}
- return !showMoreOptions ? (
-
- ) : (
- <>
- {hasAttachmentUploadCapabilities ? (
-
-
-
- ) : null}
- {showCommandsButton ? : null}
- >
- );
+ return hasAttachmentUploadCapabilities ? (
+
+
+
+ ) : null;
};
const areEqual = (
@@ -106,7 +63,6 @@ const areEqual = (
) => {
const {
hasCameraPicker: prevHasCameraPicker,
- hasCommands: prevHasCommands,
hasFilePicker: prevHasFilePicker,
hasImagePicker: prevHasImagePicker,
selectedPicker: prevSelectedPicker,
@@ -114,7 +70,6 @@ const areEqual = (
const {
hasCameraPicker: nextHasCameraPicker,
- hasCommands: nextHasCommands,
hasFilePicker: nextHasFilePicker,
hasImagePicker: nextHasImagePicker,
selectedPicker: nextSelectedPicker,
@@ -132,10 +87,6 @@ const areEqual = (
return false;
}
- if (prevHasCommands !== nextHasCommands) {
- return false;
- }
-
if (prevSelectedPicker !== nextSelectedPicker) {
return false;
}
@@ -151,12 +102,10 @@ const MemoizedInputButtonsWithContext = React.memo(
export const InputButtons = (props: InputButtonsProps) => {
const {
AttachButton,
- CommandsButton,
hasCameraPicker,
hasCommands,
hasFilePicker,
hasImagePicker,
- MoreOptionsButton,
toggleAttachmentPicker,
} = useMessageInputContext();
const { selectedPicker } = useAttachmentPickerContext();
@@ -166,12 +115,10 @@ export const InputButtons = (props: InputButtonsProps) => {
{
};
const styles = StyleSheet.create({
- attachButtonContainer: { paddingRight: 5 },
+ attachButtonContainer: {},
});
diff --git a/package/src/components/MessageInput/components/InputEditingStateHeader.tsx b/package/src/components/MessageInput/components/InputEditingStateHeader.tsx
deleted file mode 100644
index cb05599899..0000000000
--- a/package/src/components/MessageInput/components/InputEditingStateHeader.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import React, { useCallback } from 'react';
-import { Pressable, StyleSheet, Text, View } from 'react-native';
-
-import {
- MessageComposerAPIContextValue,
- useMessageComposerAPIContext,
-} from '../../../contexts/messageComposerContext/MessageComposerAPIContext';
-import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer';
-import { useTheme } from '../../../contexts/themeContext/ThemeContext';
-import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext';
-
-import { CircleClose, Edit } from '../../../icons';
-
-export type InputEditingStateHeaderProps = Partial<
- Pick
->;
-
-export const InputEditingStateHeader = ({
- clearEditingState: propClearEditingState,
-}: InputEditingStateHeaderProps) => {
- const messageComposer = useMessageComposer();
- const { t } = useTranslationContext();
- const { clearEditingState: contextClearEditingState } = useMessageComposerAPIContext();
-
- const clearEditingState = propClearEditingState || contextClearEditingState;
-
- const {
- theme: {
- colors: { black, grey, grey_gainsboro },
- messageInput: {
- editingStateHeader: { editingBoxHeader, editingBoxHeaderTitle },
- },
- },
- } = useTheme();
-
- const onCloseHandler = useCallback(() => {
- if (clearEditingState) {
- clearEditingState();
- }
- messageComposer.restore();
- }, [clearEditingState, messageComposer]);
-
- return (
-
-
-
- {t('Editing Message')}
-
- [{ opacity: pressed ? 0.8 : 1 }]}
- testID='close-button'
- >
-
-
-
- );
-};
-
-const styles = StyleSheet.create({
- editingBoxHeader: {
- alignItems: 'center',
- flexDirection: 'row',
- justifyContent: 'space-between',
- paddingBottom: 10,
- },
- editingBoxHeaderTitle: {
- fontSize: 14,
- fontWeight: 'bold',
- },
-});
-
-InputEditingStateHeader.displayName = 'EditingStateHeader{messageInput}';
diff --git a/package/src/components/MessageInput/components/InputReplyStateHeader.tsx b/package/src/components/MessageInput/components/InputReplyStateHeader.tsx
deleted file mode 100644
index 8fd09bf49d..0000000000
--- a/package/src/components/MessageInput/components/InputReplyStateHeader.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import React from 'react';
-import { Pressable, StyleSheet, Text, View } from 'react-native';
-
-import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer';
-import { useTheme } from '../../../contexts/themeContext/ThemeContext';
-import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext';
-
-import { CircleClose, CurveLineLeftUp } from '../../../icons';
-
-export const InputReplyStateHeader = () => {
- const { t } = useTranslationContext();
- const messageComposer = useMessageComposer();
- const {
- theme: {
- colors: { black, grey, grey_gainsboro },
- messageInput: {
- editingStateHeader: { editingBoxHeader, editingBoxHeaderTitle },
- },
- },
- } = useTheme();
-
- const onCloseHandler = () => {
- messageComposer.setQuotedMessage(null);
- };
-
- return (
-
-
-
- {t('Reply to Message')}
-
- [{ opacity: pressed ? 0.8 : 1 }]}
- testID='close-button'
- >
-
-
-
- );
-};
-
-const styles = StyleSheet.create({
- replyBoxHeader: {
- alignItems: 'center',
- flexDirection: 'row',
- justifyContent: 'space-between',
- paddingBottom: 8,
- },
- replyBoxHeaderTitle: {
- fontSize: 14,
- fontWeight: 'bold',
- },
-});
-
-InputReplyStateHeader.displayName = 'ReplyStateHeader{messageInput}';
diff --git a/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx b/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx
new file mode 100644
index 0000000000..ecf6105a2e
--- /dev/null
+++ b/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx
@@ -0,0 +1,49 @@
+import React, { useCallback } from 'react';
+import { StyleSheet, Text } from 'react-native';
+
+import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
+import { IconButton } from '../../../ui/IconButton';
+
+export type CooldownTimerProps = {
+ seconds: number;
+};
+
+/**
+ * Renders an amount of seconds left for a cooldown to finish.
+ *
+ * See `useCountdown` for an example of how to set a countdown
+ * to use as the source of `seconds`.
+ **/
+export const CooldownTimer = (props: CooldownTimerProps) => {
+ const { seconds } = props;
+ const {
+ theme: {
+ messageInput: {
+ cooldownTimer: { text },
+ },
+ },
+ } = useTheme();
+
+ const icon = useCallback(() => {
+ return (
+
+ {seconds}
+
+ );
+ }, [seconds, text]);
+
+ return (
+
+ );
+};
+
+const styles = StyleSheet.create({
+ text: { color: '#B8BEC4', fontSize: 16, fontWeight: '600' },
+});
diff --git a/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx b/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx
new file mode 100644
index 0000000000..48adc924f5
--- /dev/null
+++ b/package/src/components/MessageInput/components/OutputButtons/EditButton.tsx
@@ -0,0 +1,47 @@
+import React, { useCallback } from 'react';
+
+import {
+ MessageInputContextValue,
+ useMessageInputContext,
+} from '../../../../contexts/messageInputContext/MessageInputContext';
+import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
+import { NewTick } from '../../../../icons/NewTick';
+import { IconButton } from '../../../ui/IconButton';
+
+export type EditButtonProps = Partial> & {
+ /** Disables the button */
+ disabled: boolean;
+};
+
+export const EditButton = (props: EditButtonProps) => {
+ const { disabled = false, sendMessage: propsSendMessage } = props;
+ const { sendMessage: sendMessageFromContext } = useMessageInputContext();
+ const sendMessage = propsSendMessage || sendMessageFromContext;
+
+ const {
+ theme: {
+ messageInput: { editButton },
+ },
+ } = useTheme();
+
+ const onPressHandler = useCallback(() => {
+ if (disabled) {
+ return;
+ }
+ sendMessage();
+ }, [disabled, sendMessage]);
+
+ return (
+
+ );
+};
+
+EditButton.displayName = 'EditButton{messageInput}';
diff --git a/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx b/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx
new file mode 100644
index 0000000000..b0ccabad26
--- /dev/null
+++ b/package/src/components/MessageInput/components/OutputButtons/SendButton.tsx
@@ -0,0 +1,47 @@
+import React, { useCallback } from 'react';
+
+import {
+ MessageInputContextValue,
+ useMessageInputContext,
+} from '../../../../contexts/messageInputContext/MessageInputContext';
+import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
+import { SendRight } from '../../../../icons/SendRight';
+import { IconButton } from '../../../ui/IconButton';
+
+export type SendButtonProps = Partial> & {
+ /** Disables the button */
+ disabled: boolean;
+};
+
+export const SendButton = (props: SendButtonProps) => {
+ const { disabled = false, sendMessage: propsSendMessage } = props;
+ const { sendMessage: sendMessageFromContext } = useMessageInputContext();
+ const sendMessage = propsSendMessage || sendMessageFromContext;
+
+ const {
+ theme: {
+ messageInput: { sendButton },
+ },
+ } = useTheme();
+
+ const onPressHandler = useCallback(() => {
+ if (disabled) {
+ return;
+ }
+ sendMessage();
+ }, [disabled, sendMessage]);
+
+ return (
+
+ );
+};
+
+SendButton.displayName = 'SendButton{messageInput}';
diff --git a/package/src/components/MessageInput/components/OutputButtons/index.tsx b/package/src/components/MessageInput/components/OutputButtons/index.tsx
new file mode 100644
index 0000000000..dda8a74d56
--- /dev/null
+++ b/package/src/components/MessageInput/components/OutputButtons/index.tsx
@@ -0,0 +1,202 @@
+import React, { useCallback } from 'react';
+
+import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated';
+
+import { TextComposerState } from 'stream-chat';
+
+import { EditButton } from './EditButton';
+
+import {
+ ChannelContextValue,
+ ChatContextValue,
+ useChannelContext,
+ useChatContext,
+ useMessageComposerHasSendableData,
+ useTheme,
+} from '../../../../contexts';
+import { useAttachmentManagerState } from '../../../../contexts/messageInputContext/hooks/useAttachmentManagerState';
+import { useMessageComposer } from '../../../../contexts/messageInputContext/hooks/useMessageComposer';
+import {
+ MessageInputContextValue,
+ useMessageInputContext,
+} from '../../../../contexts/messageInputContext/MessageInputContext';
+import { useStateStore } from '../../../../hooks/useStateStore';
+import { AIStates, useAIState } from '../../../AITypingIndicatorView';
+import { AudioRecordingButton } from '../../components/AudioRecorder/AudioRecordingButton';
+import { useCountdown } from '../../hooks/useCountdown';
+
+export type OutputButtonsProps = Partial;
+
+export type OutputButtonsWithContextProps = Pick &
+ Pick &
+ Pick<
+ MessageInputContextValue,
+ | 'asyncMessagesMinimumPressDuration'
+ | 'asyncMessagesSlideToCancelDistance'
+ | 'asyncMessagesLockDistance'
+ | 'asyncMessagesMultiSendEnabled'
+ | 'audioRecordingEnabled'
+ | 'cooldownEndsAt'
+ | 'CooldownTimer'
+ | 'SendButton'
+ | 'StopMessageStreamingButton'
+ | 'StartAudioRecordingButton'
+ >;
+
+const textComposerStateSelector = (state: TextComposerState) => ({
+ command: state.command,
+ hasText: !!state.text,
+});
+
+export const OutputButtonsWithContext = (props: OutputButtonsWithContextProps) => {
+ const {
+ channel,
+ cooldownEndsAt,
+ CooldownTimer,
+ isOnline,
+ SendButton,
+ StopMessageStreamingButton,
+ } = props;
+ const {
+ theme: {
+ messageInput: {
+ audioRecordingButtonContainer,
+ cooldownButtonContainer,
+ editButtonContainer,
+ sendButtonContainer,
+ },
+ },
+ } = useTheme();
+
+ const messageComposer = useMessageComposer();
+ const editing = !!messageComposer.editedMessage;
+ const { textComposer } = messageComposer;
+ const { command, hasText } = useStateStore(textComposer.state, textComposerStateSelector);
+ const { attachments } = useAttachmentManagerState();
+ const hasSendableData = useMessageComposerHasSendableData();
+
+ const showSendingButton = hasText || attachments.length || command;
+
+ const { seconds: cooldownRemainingSeconds } = useCountdown(cooldownEndsAt);
+
+ const { aiState } = useAIState(channel);
+ const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]);
+ const shouldDisplayStopAIGeneration =
+ [AIStates.Thinking, AIStates.Generating].includes(aiState) && !!StopMessageStreamingButton;
+
+ if (shouldDisplayStopAIGeneration) {
+ return ;
+ }
+
+ if (editing) {
+ return (
+
+
+
+ );
+ }
+
+ if (cooldownRemainingSeconds) {
+ return (
+
+
+
+ );
+ }
+
+ if (showSendingButton) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
+
+const areEqual = (
+ prevProps: OutputButtonsWithContextProps,
+ nextProps: OutputButtonsWithContextProps,
+) => {
+ const { channel: prevChannel, cooldownEndsAt: prevCooldownEndsAt } = prevProps;
+
+ const { channel: nextChannel, cooldownEndsAt: nextCooldownEndsAt } = nextProps;
+
+ if (prevChannel?.cid !== nextChannel?.cid) {
+ return false;
+ }
+
+ const cooldownEndsAtEqual = prevCooldownEndsAt === nextCooldownEndsAt;
+ if (!cooldownEndsAtEqual) {
+ return false;
+ }
+
+ return true;
+};
+
+const MemoizedOutputButtonsWithContext = React.memo(
+ OutputButtonsWithContext,
+ areEqual,
+) as typeof OutputButtonsWithContext;
+
+export const OutputButtons = (props: OutputButtonsProps) => {
+ const { isOnline } = useChatContext();
+ const { channel } = useChannelContext();
+ const {
+ audioRecordingEnabled,
+ asyncMessagesMinimumPressDuration,
+ asyncMessagesSlideToCancelDistance,
+ asyncMessagesLockDistance,
+ asyncMessagesMultiSendEnabled,
+ cooldownEndsAt,
+ CooldownTimer,
+ SendButton,
+ StopMessageStreamingButton,
+ StartAudioRecordingButton,
+ } = useMessageInputContext();
+
+ return (
+
+ );
+};
diff --git a/package/src/components/MessageInput/hooks/useAudioController.tsx b/package/src/components/MessageInput/hooks/useAudioController.tsx
deleted file mode 100644
index f845176ce8..0000000000
--- a/package/src/components/MessageInput/hooks/useAudioController.tsx
+++ /dev/null
@@ -1,390 +0,0 @@
-import { useEffect, useRef, useState } from 'react';
-
-import { Alert, Platform } from 'react-native';
-
-import { LocalVoiceRecordingAttachment } from 'stream-chat';
-
-import { useActiveAudioPlayer } from '../../../contexts/audioPlayerContext/AudioPlayerContext';
-import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer';
-
-import { useMessageInputContext } from '../../../contexts/messageInputContext/MessageInputContext';
-import {
- AudioRecordingReturnType,
- NativeHandlers,
- PlaybackStatus,
- RecordingStatus,
- SoundReturnType,
-} from '../../../native';
-import type { File } from '../../../types/types';
-import { FileTypes } from '../../../types/types';
-import { generateRandomId } from '../../../utils/utils';
-import { resampleWaveformData } from '../utils/audioSampling';
-import { normalizeAudioLevel } from '../utils/normalizeAudioLevel';
-
-export type RecordingStatusStates = 'idle' | 'recording' | 'stopped';
-
-/**
- * The hook that controls all the async audio core features including start/stop or recording, player, upload/delete of the recorded audio.
- *
- * FIXME: Change the name to `useAudioRecorder` in the next major version as the hook will only be used for audio recording.
- */
-export const useAudioController = () => {
- const [micLocked, setMicLocked] = useState(false);
- const [permissionsGranted, setPermissionsGranted] = useState(true);
- /**
- * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager.
- * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager.
- * FIXME: Remove this in the next major version.
- */
- const [paused, setPaused] = useState(true);
- /**
- * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager.
- * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager.
- * FIXME: Remove this in the next major version.
- */
- const [position, setPosition] = useState(0);
- /**
- * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager.
- * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager.
- * FIXME: Remove this in the next major version.
- */
- const [progress, setProgress] = useState(0);
- const [waveformData, setWaveformData] = useState([]);
- const [isScheduledForSubmit, setIsScheduleForSubmit] = useState(false);
- const [recording, setRecording] = useState(undefined);
- const [recordingDuration, setRecordingDuration] = useState(0);
- const [recordingStatus, setRecordingStatus] = useState('idle');
- const { attachmentManager } = useMessageComposer();
- const activeAudioPlayer = useActiveAudioPlayer();
-
- const { sendMessage } = useMessageInputContext();
-
- /**
- * Reference to the sound object for playback support in Expo CLI apps
- *
- * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager.
- * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager.
- *
- * FIXME: Remove this in the next major version.
- */
- const soundRef = useRef(null);
-
- // This effect stop the player from playing and stops audio recording on
- // the audio SDK side on unmount.
- useEffect(
- () => () => {
- stopVoicePlayer();
- stopSDKVoiceRecording();
- },
- [],
- );
-
- useEffect(() => {
- if (isScheduledForSubmit) {
- sendMessage();
- setIsScheduleForSubmit(false);
- }
- }, [isScheduledForSubmit, sendMessage]);
-
- /**
- * Function to update the progress of the voice recording.
- *
- * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager.
- * FIXME: Remove this in the next major version.
- */
- const onVoicePlayerProgressHandler = (currentPosition: number, playbackDuration: number) => {
- const currentProgress = currentPosition / playbackDuration;
- if (currentProgress === 1) {
- setPaused(true);
- setProgress(0);
- } else {
- setProgress(currentProgress);
- }
- };
-
- /**
- * Function to update the playback status of the voice recording.
- *
- * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager.
- * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager.
- *
- * FIXME: Remove this in the next major version.
- */
- const onVoicePlayerPlaybackStatusUpdate = (status: PlaybackStatus) => {
- if (status.shouldPlay === undefined || status.shouldPlay === true) {
- setPosition(status?.currentPosition || status?.positionMillis);
- setRecordingDuration(status.duration || status.durationMillis);
-
- if (status.didJustFinish) {
- onVoicePlayerProgressHandler(status.durationMillis, status.durationMillis);
- } else {
- // For Native CLI
- if (status.currentPosition && status.duration) {
- onVoicePlayerProgressHandler(status.currentPosition, status.duration);
- }
- // For Expo CLI
- else if (status.positionMillis && status.durationMillis) {
- onVoicePlayerProgressHandler(status.positionMillis, status.durationMillis);
- }
- }
- }
- };
-
- /**
- * Function to play or pause voice recording.
- *
- * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager.
- * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager.
- *
- * FIXME: Remove this in the next major version.
- */
- const onVoicePlayerPlayPause = async () => {
- if (paused) {
- if (progress === 0) {
- await startVoicePlayer();
- } else {
- // For Native CLI
- if (NativeHandlers.Audio?.resumePlayer) {
- await NativeHandlers.Audio.resumePlayer();
- }
- // For Expo CLI
- if (soundRef.current?.playAsync) {
- await soundRef.current.playAsync();
- }
- }
- } else {
- // For Native CLI
- if (NativeHandlers.Audio?.pausePlayer) {
- await NativeHandlers.Audio.pausePlayer();
- }
- // For Expo CLI
- if (soundRef.current?.pauseAsync) {
- await soundRef.current.pauseAsync();
- }
- }
- setPaused(!paused);
- };
-
- /**
- * Function to start playing voice recording to preview it after recording.
- *
- * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager.
- * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager.
- *
- * FIXME: Remove this in the next major version.
- */
- const startVoicePlayer = async () => {
- if (!recording) {
- return;
- }
- // For Native CLI
- if (NativeHandlers.Audio?.startPlayer) {
- await NativeHandlers.Audio.startPlayer(recording, {}, onVoicePlayerPlaybackStatusUpdate);
- }
- // For Expo CLI
- if (recording && typeof recording !== 'string') {
- const uri = recording.getURI();
- if (uri && NativeHandlers.Sound?.initializeSound) {
- if (soundRef.current?.replayAsync) {
- await soundRef.current.replayAsync({});
- } else {
- soundRef.current = await NativeHandlers.Sound.initializeSound(
- { uri },
- { progressUpdateIntervalMillis: Platform.OS === 'android' ? 100 : 60 },
- onVoicePlayerPlaybackStatusUpdate,
- );
- if (soundRef.current?.playAsync) {
- await soundRef.current.playAsync();
- }
- }
- }
- }
- };
-
- /**
- * Function to stop playing voice recording.
- *
- * @deprecated This is deprecated and will be removed in the future in favour of the global audio manager.
- * Check: https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx on how to use the global audio manager.
- *
- * FIXME: Remove this in the next major version.
- */
- const stopVoicePlayer = async () => {
- // For Native CLI
- if (NativeHandlers.Audio?.stopPlayer) {
- await NativeHandlers.Audio.stopPlayer();
- }
- // For Expo CLI
- if (soundRef.current?.stopAsync && soundRef.current?.unloadAsync) {
- await soundRef.current?.stopAsync();
- await soundRef.current?.unloadAsync();
- soundRef.current = null;
- }
- };
-
- const onRecordingStatusUpdate = (status: RecordingStatus) => {
- if (status.isDoneRecording === true) {
- return;
- }
- setRecordingDuration(status?.currentPosition || status.durationMillis);
- // For expo android the lower bound is -120 so we need to normalize according to it. The `status.currentMetering` is undefined for Expo CLI apps, so we can use it.
- const lowerBound = Platform.OS === 'ios' || status.currentMetering ? -60 : -120;
- const normalizedAudioLevel = normalizeAudioLevel(
- status.currentMetering || status.metering,
- lowerBound,
- );
- setWaveformData((prev) => [...prev, normalizedAudioLevel]);
- };
-
- /**
- * Function to start voice recording.
- */
- const startVoiceRecording = async () => {
- if (!NativeHandlers.Audio) {
- return;
- }
- const recordingInfo = await NativeHandlers.Audio.startRecording(
- {
- isMeteringEnabled: true,
- },
- onRecordingStatusUpdate,
- );
- const accessGranted = recordingInfo.accessGranted;
- if (accessGranted) {
- setPermissionsGranted(true);
- const recording = recordingInfo.recording;
- if (recording && typeof recording !== 'string' && recording.setProgressUpdateInterval) {
- recording.setProgressUpdateInterval(Platform.OS === 'android' ? 100 : 60);
- }
- setRecording(recording);
- setRecordingStatus('recording');
- if (activeAudioPlayer?.isPlaying) {
- await activeAudioPlayer?.pause();
- }
- await stopVoicePlayer();
- } else {
- setPermissionsGranted(false);
- resetState();
- Alert.alert('Please allow Audio permissions in settings.');
- }
- };
-
- /**
- * A function that takes care of stopping the voice recording from the library's
- * side only. Meant to be used as a pure function (during unmounting for instance)
- * hence this approach.
- */
- const stopSDKVoiceRecording = async () => {
- if (!NativeHandlers.Audio) {
- return;
- }
- await NativeHandlers.Audio.stopRecording();
- };
-
- /**
- * Function to stop voice recording.
- */
- const stopVoiceRecording = async () => {
- await stopSDKVoiceRecording();
- setRecordingStatus('stopped');
- };
-
- /**
- * Function to reset the state of the message input for async voice messages.
- */
- const resetState = () => {
- setRecording(undefined);
- setRecordingStatus('idle');
- setMicLocked(false);
- setWaveformData([]);
- setPaused(true);
- setPosition(0);
- setProgress(0);
- };
-
- /**
- * Function to delete voice recording.
- */
- const deleteVoiceRecording = async () => {
- if (recordingStatus === 'recording') {
- await stopVoiceRecording();
- }
- await stopVoicePlayer();
- resetState();
- NativeHandlers.triggerHaptic('impactMedium');
- };
-
- /**
- * Function to upload or send voice recording.
- * @param multiSendEnabled boolean
- */
- const uploadVoiceRecording = async (multiSendEnabled: boolean) => {
- if (!paused) {
- await stopVoicePlayer();
- }
- if (recordingStatus === 'recording') {
- await stopVoiceRecording();
- }
-
- const durationInSeconds = parseFloat((recordingDuration / 1000).toFixed(3));
-
- const resampledWaveformData = resampleWaveformData(waveformData, 100);
-
- const clearFilter = new RegExp('[.:]', 'g');
- const date = new Date().toISOString().replace(clearFilter, '_');
-
- const file: File = {
- duration: durationInSeconds,
- name: `audio_recording_${date}.aac`,
- size: 0,
- type: 'audio/aac',
- uri: typeof recording !== 'string' ? (recording?.getURI() as string) : (recording as string),
- waveform_data: resampledWaveformData,
- };
-
- const audioFile: LocalVoiceRecordingAttachment = {
- asset_url:
- typeof recording !== 'string' ? (recording?.getURI() as string) : (recording as string),
- duration: durationInSeconds,
- file_size: 0,
- localMetadata: {
- file,
- id: generateRandomId(),
- uploadState: 'pending',
- },
- mime_type: 'audio/aac',
- title: `audio_recording_${date}.aac`,
- type: FileTypes.VoiceRecording,
- waveform_data: resampledWaveformData,
- };
-
- if (multiSendEnabled) {
- await attachmentManager.uploadAttachment(audioFile);
- } else {
- // FIXME: cannot call handleSubmit() directly as the function has stale reference to file uploads
- await attachmentManager.uploadAttachment(audioFile);
- setIsScheduleForSubmit(true);
- }
- resetState();
- };
-
- return {
- deleteVoiceRecording,
- micLocked,
- onVoicePlayerPlayPause,
- paused,
- permissionsGranted,
- position,
- progress,
- recording,
- recordingDuration,
- recordingStatus,
- setMicLocked,
- startVoicePlayer,
- startVoiceRecording,
- stopVoicePlayer,
- stopVoiceRecording,
- uploadVoiceRecording,
- waveformData,
- };
-};
diff --git a/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx b/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx
deleted file mode 100644
index 31af18f478..0000000000
--- a/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import { useCallback, useEffect, useState } from 'react';
-
-import { LocalAttachment } from 'stream-chat';
-
-import { AudioConfig } from '../../../types/types';
-
-/**
- * Manages the state of audio attachments for preview and playback.
- * @param files The audio files to manage.
- * @returns An object containing the state and handlers for audio attachments.
- *
- * @deprecated This is deprecated and will be removed in the future.
- */
-export const useAudioPreviewManager = (files: LocalAttachment[]) => {
- const [audioAttachmentsStateMap, setAudioAttachmentsStateMap] = useState<
- Record
- >({});
-
- useEffect(() => {
- setAudioAttachmentsStateMap((prevState) => {
- const updatedStateMap = Object.fromEntries(
- files.map((attachment) => {
- const id = attachment.localMetadata.id;
-
- const config: AudioConfig = {
- duration: attachment.duration ?? prevState[id]?.duration ?? 0,
- paused: prevState[id]?.paused ?? true,
- progress: prevState[id]?.progress ?? 0,
- };
-
- return [id, config];
- }),
- );
-
- return updatedStateMap;
- });
- }, [files]);
-
- /**
- * Handler triggered when an audio is loaded in the message input. The initial state is defined for the audio here
- * and the duration is set.
- * @param index - The index of the audio
- * @param duration - The duration of the audio
- *
- * @deprecated This is deprecated and will be removed in the future.
- * FIXME: Remove this in the next major version.
- */
- const onLoad = useCallback((index: string, duration: number) => {
- setAudioAttachmentsStateMap((prevState) => ({
- ...prevState,
- [index]: {
- ...prevState[index],
- duration,
- },
- }));
- }, []);
-
- /**
- * Handler which is triggered when the audio progresses/ the thumb is dragged in the progress control. The
- * progressed duration is set here.
- * @param index - The index of the audio
- * @param progress - The progress of the audio
- *
- * @deprecated This is deprecated and will be removed in the future.
- * FIXME: Remove this in the next major version.
- */
- const onProgress = useCallback((index: string, progress: number) => {
- setAudioAttachmentsStateMap((prevState) => ({
- ...prevState,
- [index]: {
- ...prevState[index],
- progress,
- },
- }));
- }, []);
-
- /**
- * Handler which controls or sets the paused/played state of the audio.
- * @param index - The index of the audio
- * @param pausedStatus - The paused status of the audio
- *
- * @deprecated This is deprecated and will be removed in the future.
- * FIXME: Remove this in the next major version.
- */
- const onPlayPause = useCallback((index: string, pausedStatus?: boolean) => {
- if (pausedStatus === false) {
- // In this case, all others except the index are set to paused.
- setAudioAttachmentsStateMap((prevState) => {
- const newState = { ...prevState };
- Object.keys(newState).forEach((key) => {
- if (key !== index) {
- newState[key].paused = true;
- }
- });
- return {
- ...newState,
- [index]: {
- ...newState[index],
- paused: false,
- },
- };
- });
- } else {
- setAudioAttachmentsStateMap((prevState) => ({
- ...prevState,
- [index]: {
- ...prevState[index],
- paused: true,
- },
- }));
- }
- }, []);
-
- return {
- audioAttachmentsStateMap,
- onLoad,
- onPlayPause,
- onProgress,
- setAudioAttachmentsStateMap,
- };
-};
diff --git a/package/src/components/MessageInput/hooks/useAudioRecorder.tsx b/package/src/components/MessageInput/hooks/useAudioRecorder.tsx
new file mode 100644
index 0000000000..86ca1c4866
--- /dev/null
+++ b/package/src/components/MessageInput/hooks/useAudioRecorder.tsx
@@ -0,0 +1,205 @@
+import { useEffect, useState } from 'react';
+
+import { Alert, Platform } from 'react-native';
+
+import { LocalVoiceRecordingAttachment } from 'stream-chat';
+
+import { useActiveAudioPlayer } from '../../../contexts/audioPlayerContext/AudioPlayerContext';
+import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer';
+
+import { useMessageInputContext } from '../../../contexts/messageInputContext/MessageInputContext';
+import { AudioRecordingReturnType, NativeHandlers, RecordingStatus } from '../../../native';
+import type { File } from '../../../types/types';
+import { FileTypes } from '../../../types/types';
+import { generateRandomId } from '../../../utils/utils';
+import { resampleWaveformData } from '../utils/audioSampling';
+import { normalizeAudioLevel } from '../utils/normalizeAudioLevel';
+
+export type RecordingStatusStates = 'idle' | 'recording' | 'stopped';
+
+/**
+ * The hook that controls all the async audio core features including start/stop or recording, player, upload/delete of the recorded audio.
+ *
+ * FIXME: Change the name to `useAudioRecorder` in the next major version as the hook will only be used for audio recording.
+ */
+export const useAudioRecorder = () => {
+ const [micLocked, setMicLocked] = useState(false);
+ const [permissionsGranted, setPermissionsGranted] = useState(true);
+ const [waveformData, setWaveformData] = useState([]);
+ const [isScheduledForSubmit, setIsScheduleForSubmit] = useState(false);
+ const [recording, setRecording] = useState(undefined);
+ const [recordingDuration, setRecordingDuration] = useState(0);
+ const [recordingStatus, setRecordingStatus] = useState('idle');
+ const { attachmentManager } = useMessageComposer();
+ const activeAudioPlayer = useActiveAudioPlayer();
+
+ const { sendMessage } = useMessageInputContext();
+
+ // This effect stop the player from playing and stops audio recording on
+ // the audio SDK side on unmount.
+ useEffect(
+ () => () => {
+ stopSDKVoiceRecording();
+ },
+ [],
+ );
+
+ useEffect(() => {
+ if (isScheduledForSubmit) {
+ sendMessage();
+ setIsScheduleForSubmit(false);
+ }
+ }, [isScheduledForSubmit, sendMessage]);
+
+ const onRecordingStatusUpdate = (status: RecordingStatus) => {
+ if (status.isDoneRecording === true) {
+ return;
+ }
+ setRecordingDuration(status?.currentPosition || status.durationMillis);
+ // For expo android the lower bound is -120 so we need to normalize according to it. The `status.currentMetering` is undefined for Expo CLI apps, so we can use it.
+ const lowerBound = Platform.OS === 'ios' || status.currentMetering ? -60 : -120;
+ const normalizedAudioLevel = normalizeAudioLevel(
+ status.currentMetering || status.metering,
+ lowerBound,
+ );
+ setWaveformData((prev) => [...prev, normalizedAudioLevel]);
+ };
+
+ /**
+ * Function to start voice recording.
+ */
+ const startVoiceRecording = async () => {
+ if (!NativeHandlers.Audio) {
+ return;
+ }
+ const recordingInfo = await NativeHandlers.Audio.startRecording(
+ {
+ isMeteringEnabled: true,
+ },
+ onRecordingStatusUpdate,
+ );
+ const accessGranted = recordingInfo.accessGranted;
+ if (accessGranted) {
+ setPermissionsGranted(true);
+ const recording = recordingInfo.recording;
+ if (recording && typeof recording !== 'string' && recording.setProgressUpdateInterval) {
+ recording.setProgressUpdateInterval(Platform.OS === 'android' ? 100 : 60);
+ }
+ setRecording(recording);
+ setRecordingStatus('recording');
+ if (activeAudioPlayer?.isPlaying) {
+ await activeAudioPlayer?.pause();
+ }
+ } else {
+ setPermissionsGranted(false);
+ resetState();
+ Alert.alert('Please allow Audio permissions in settings.');
+ }
+ };
+
+ /**
+ * A function that takes care of stopping the voice recording from the library's
+ * side only. Meant to be used as a pure function (during unmounting for instance)
+ * hence this approach.
+ */
+ const stopSDKVoiceRecording = async () => {
+ if (!NativeHandlers.Audio) {
+ return;
+ }
+ await NativeHandlers.Audio.stopRecording();
+ };
+
+ /**
+ * Function to stop voice recording.
+ */
+ const stopVoiceRecording = async () => {
+ await stopSDKVoiceRecording();
+ setRecordingStatus('stopped');
+ };
+
+ /**
+ * Function to reset the state of the message input for async voice messages.
+ */
+ const resetState = () => {
+ setRecording(undefined);
+ setRecordingStatus('idle');
+ setMicLocked(false);
+ setWaveformData([]);
+ };
+
+ /**
+ * Function to delete voice recording.
+ */
+ const deleteVoiceRecording = async () => {
+ if (recordingStatus === 'recording') {
+ await stopVoiceRecording();
+ }
+ resetState();
+ NativeHandlers.triggerHaptic('impactMedium');
+ };
+
+ /**
+ * Function to upload or send voice recording.
+ * @param multiSendEnabled boolean
+ */
+ const uploadVoiceRecording = async (multiSendEnabled: boolean) => {
+ if (recordingStatus === 'recording') {
+ await stopVoiceRecording();
+ }
+
+ const durationInSeconds = parseFloat((recordingDuration / 1000).toFixed(3));
+
+ const resampledWaveformData = resampleWaveformData(waveformData, 100);
+
+ const clearFilter = new RegExp('[.:]', 'g');
+ const date = new Date().toISOString().replace(clearFilter, '_');
+
+ const file: File = {
+ duration: durationInSeconds,
+ name: `audio_recording_${date}.aac`,
+ size: 0,
+ type: 'audio/aac',
+ uri: typeof recording !== 'string' ? (recording?.getURI() as string) : (recording as string),
+ waveform_data: resampledWaveformData,
+ };
+
+ const audioFile: LocalVoiceRecordingAttachment = {
+ asset_url:
+ typeof recording !== 'string' ? (recording?.getURI() as string) : (recording as string),
+ duration: durationInSeconds,
+ file_size: 0,
+ localMetadata: {
+ file,
+ id: generateRandomId(),
+ uploadState: 'pending',
+ },
+ mime_type: 'audio/aac',
+ title: `audio_recording_${date}.aac`,
+ type: FileTypes.VoiceRecording,
+ waveform_data: resampledWaveformData,
+ };
+
+ if (multiSendEnabled) {
+ await attachmentManager.uploadAttachment(audioFile);
+ } else {
+ // FIXME: cannot call handleSubmit() directly as the function has stale reference to file uploads
+ await attachmentManager.uploadAttachment(audioFile);
+ setIsScheduleForSubmit(true);
+ }
+ resetState();
+ };
+
+ return {
+ deleteVoiceRecording,
+ micLocked,
+ permissionsGranted,
+ recording,
+ recordingDuration,
+ recordingStatus,
+ setMicLocked,
+ startVoiceRecording,
+ stopVoiceRecording,
+ uploadVoiceRecording,
+ waveformData,
+ };
+};
diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx
index 842a3f355a..02c618be86 100644
--- a/package/src/components/MessageList/MessageFlashList.tsx
+++ b/package/src/components/MessageList/MessageFlashList.tsx
@@ -8,6 +8,8 @@ import {
ViewToken,
} from 'react-native';
+import Animated, { LinearTransition } from 'react-native-reanimated';
+
import type { FlashListProps, FlashListRef } from '@shopify/flash-list';
import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat';
@@ -30,6 +32,10 @@ import {
ImageGalleryContextValue,
useImageGalleryContext,
} from '../../contexts/imageGalleryContext/ImageGalleryContext';
+import {
+ MessageInputContextValue,
+ useMessageInputContext,
+} from '../../contexts/messageInputContext/MessageInputContext';
import {
MessageListItemContextValue,
MessageListItemProvider,
@@ -49,7 +55,11 @@ import {
import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext';
import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext';
-import { useStableCallback } from '../../hooks';
+import { useStableCallback, useStateStore } from '../../hooks';
+import {
+ MessageInputHeightState,
+ messageInputHeightStore,
+} from '../../state-store/message-input-height-store';
import { FileTypes } from '../../types/types';
import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper';
@@ -96,6 +106,10 @@ const getPreviousLastMessage = (messages: LocalMessage[], newMessage?: MessageRe
return previousLastMessage;
};
+const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({
+ height: state.height,
+});
+
type MessageFlashListPropsWithContext = Pick<
AttachmentPickerContextValue,
'closePicker' | 'selectedPicker' | 'setSelectedPicker'
@@ -104,7 +118,6 @@ type MessageFlashListPropsWithContext = Pick<
Pick<
ChannelContextValue,
| 'channel'
- | 'channelUnreadState'
| 'channelUnreadStateStore'
| 'disabled'
| 'EmptyStateIndicator'
@@ -125,6 +138,7 @@ type MessageFlashListPropsWithContext = Pick<
| 'maximumMessageLimit'
> &
Pick &
+ Pick &
Pick &
Pick &
Pick<
@@ -288,6 +302,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
loadMoreThread,
markRead,
maximumMessageLimit,
+ messageInputFloating,
myMessageTheme,
readEvents,
NetworkDownIndicator,
@@ -313,10 +328,16 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
} = props;
const flashListRef = useRef | null>(null);
+ const { height: messageInputHeight } = useStateStore(
+ messageInputHeightStore,
+ messageInputHeightStoreSelector,
+ );
+
const [hasMoved, setHasMoved] = useState(false);
const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false);
const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false);
const [stickyHeaderDate, setStickyHeaderDate] = useState();
+ const [scrollEnabled, setScrollEnabled] = useState(true);
const stickyHeaderDateRef = useRef(undefined);
/**
@@ -339,7 +360,14 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
const {
colors: { white_snow },
- messageList: { container, contentContainer, listContainer },
+ messageList: {
+ container,
+ contentContainer,
+ listContainer,
+ scrollToBottomButtonContainer,
+ stickyHeaderContainer,
+ unreadMessagesNotificationContainer,
+ },
} = theme;
const myMessageThemeString = useMemo(() => JSON.stringify(myMessageTheme), [myMessageTheme]);
@@ -358,7 +386,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
} = useMessageList({
isFlashList: true,
isLiveStreaming,
- noGroupByUser,
threadList,
});
@@ -719,6 +746,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
[],
);
+ const setNativeScrollability = useStableCallback((value: boolean) => {
+ // FlashList does not have setNativeProps exposed, hence we cannot use that.
+ // Instead, we resort to state.
+ setScrollEnabled(value);
+ });
+
const messageListItemContextValue: MessageListItemContextValue = useMemo(
() => ({
goToMessage,
@@ -726,6 +759,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
modifiedTheme,
noGroupByUser,
onThreadSelect,
+ setNativeScrollability,
}),
[
goToMessage,
@@ -733,6 +767,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
modifiedTheme,
noGroupByUser,
onThreadSelect,
+ setNativeScrollability,
],
);
@@ -928,8 +963,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
const visibleLength = nativeEvent.layoutMeasurement.height;
const contentLength = nativeEvent.contentSize.height;
- // Show scrollToBottom button once scroll position goes beyond 150.
- const isScrollAtStart = contentLength - visibleLength - offset < 150;
+ const isScrollAtStart = contentLength - visibleLength - offset < messageInputHeight;
const notLatestSet = channel.state.messages !== channel.state.latestMessages;
@@ -1018,8 +1052,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
);
const flatListContentContainerStyle = useMemo(
- () => [styles.contentContainer, contentContainer],
- [contentContainer],
+ () => [
+ styles.contentContainer,
+ { paddingBottom: messageInputFloating ? messageInputHeight : 0 },
+ contentContainer,
+ ],
+ [contentContainer, messageInputFloating, messageInputHeight],
);
const currentListHeightRef = useRef(undefined);
@@ -1085,6 +1123,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
onViewableItemsChanged={stableOnViewableItemsChanged}
ref={refCallback}
renderItem={renderItem}
+ scrollEnabled={scrollEnabled}
scrollEventThrottle={isLiveStreaming ? 16 : undefined}
showsVerticalScrollIndicator={false}
style={flatListStyle}
@@ -1094,7 +1133,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
/>
)}
-
+
{messageListLengthAfterUpdate && StickyHeader ? (
) : null}
@@ -1104,14 +1143,27 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
)}
-
+
+
+
{isUnreadNotificationOpen && !threadList ? (
-
+
+
+
) : null}
);
@@ -1129,7 +1181,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => {
const { closePicker, selectedPicker, setSelectedPicker } = useAttachmentPickerContext();
const {
channel,
- channelUnreadState,
channelUnreadStateStore,
disabled,
EmptyStateIndicator,
@@ -1173,12 +1224,12 @@ export const MessageFlashList = (props: MessageFlashListProps) => {
const { loadMore, loadMoreRecent } = usePaginatedMessageListContext();
const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext();
const { readEvents } = useOwnCapabilitiesContext();
+ const { messageInputFloating } = useMessageInputContext();
return (
{
markRead,
maximumMessageLimit,
Message,
+ messageInputFloating,
MessageSystem,
myMessageTheme,
NetworkDownIndicator,
@@ -1235,7 +1287,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => {
const styles = StyleSheet.create({
container: {
- alignItems: 'center',
flex: 1,
width: '100%',
},
@@ -1253,8 +1304,20 @@ const styles = StyleSheet.create({
flex: 1,
width: '100%',
},
- stickyHeader: {
+ scrollToBottomButtonContainer: {
+ bottom: 8,
position: 'absolute',
+ right: 24,
+ },
+ stickyHeaderContainer: {
+ left: 0,
+ position: 'absolute',
+ right: 0,
top: 0,
},
+ unreadMessagesNotificationContainer: {
+ alignSelf: 'center',
+ position: 'absolute',
+ top: 8,
+ },
});
diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx
index a86fd62e47..7960e11735 100644
--- a/package/src/components/MessageList/MessageList.tsx
+++ b/package/src/components/MessageList/MessageList.tsx
@@ -9,6 +9,8 @@ import {
ViewToken,
} from 'react-native';
+import Animated, { LinearTransition } from 'react-native-reanimated';
+
import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat';
import { useMessageList } from './hooks/useMessageList';
@@ -32,6 +34,10 @@ import {
ImageGalleryContextValue,
useImageGalleryContext,
} from '../../contexts/imageGalleryContext/ImageGalleryContext';
+import {
+ MessageInputContextValue,
+ useMessageInputContext,
+} from '../../contexts/messageInputContext/MessageInputContext';
import {
MessageListItemContextValue,
MessageListItemProvider,
@@ -52,6 +58,11 @@ import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext'
import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext';
import { useStableCallback } from '../../hooks';
+import { useStateStore } from '../../hooks/useStateStore';
+import {
+ MessageInputHeightState,
+ messageInputHeightStore,
+} from '../../state-store/message-input-height-store';
import { FileTypes } from '../../types/types';
import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper';
@@ -61,7 +72,6 @@ const WAIT_FOR_SCROLL_TIMEOUT = 0;
const MAX_RETRIES_AFTER_SCROLL_FAILURE = 10;
const styles = StyleSheet.create({
container: {
- alignItems: 'center',
flex: 1,
width: '100%',
},
@@ -79,10 +89,22 @@ const styles = StyleSheet.create({
flex: 1,
width: '100%',
},
- stickyHeader: {
+ scrollToBottomButtonContainer: {
+ bottom: 8,
position: 'absolute',
+ right: 24,
+ },
+ stickyHeaderContainer: {
+ left: 0,
+ position: 'absolute',
+ right: 0,
top: 0,
},
+ unreadMessagesNotificationContainer: {
+ alignSelf: 'center',
+ position: 'absolute',
+ top: 8,
+ },
});
const keyExtractor = (item: LocalMessage) => {
@@ -127,7 +149,6 @@ type MessageListPropsWithContext = Pick<
Pick<
ChannelContextValue,
| 'channel'
- | 'channelUnreadState'
| 'channelUnreadStateStore'
| 'disabled'
| 'EmptyStateIndicator'
@@ -161,6 +182,7 @@ type MessageListPropsWithContext = Pick<
| 'TypingIndicatorContainer'
| 'UnreadMessagesNotification'
> &
+ Pick &
Pick<
ThreadContextValue,
'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadInstance'
@@ -237,6 +259,10 @@ const renderItem = ({ item: message }: { item: LocalMessage }) => {
return ;
};
+const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({
+ height: state.height,
+});
+
/**
* The message list component renders a list of messages. It consumes the following contexts:
*
@@ -277,6 +303,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
loadMoreThread,
markRead,
maximumMessageLimit,
+ messageInputFloating,
myMessageTheme,
NetworkDownIndicator,
noGroupByUser,
@@ -302,10 +329,21 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
} = props;
const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false);
const { theme } = useTheme();
+ const { height: messageInputHeight } = useStateStore(
+ messageInputHeightStore,
+ messageInputHeightStoreSelector,
+ );
const {
colors: { white_snow },
- messageList: { container, contentContainer, listContainer },
+ messageList: {
+ container,
+ contentContainer,
+ listContainer,
+ stickyHeaderContainer,
+ scrollToBottomButtonContainer,
+ unreadMessagesNotificationContainer,
+ },
} = theme;
const myMessageThemeString = useMemo(() => JSON.stringify(myMessageTheme), [myMessageTheme]);
@@ -327,7 +365,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
viewabilityChangedCallback,
} = useMessageList({
isLiveStreaming,
- noGroupByUser,
threadList,
});
const messageListLengthBeforeUpdate = useRef(0);
@@ -768,6 +805,12 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetedMessage]);
+ const setNativeScrollability = useStableCallback((value: boolean) => {
+ if (flatListRef.current) {
+ flatListRef.current.setNativeProps({ scrollEnabled: value });
+ }
+ });
+
const messageListItemContextValue: MessageListItemContextValue = useMemo(
() => ({
goToMessage,
@@ -775,6 +818,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
modifiedTheme,
noGroupByUser,
onThreadSelect,
+ setNativeScrollability,
}),
[
goToMessage,
@@ -782,6 +826,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
modifiedTheme,
noGroupByUser,
onThreadSelect,
+ setNativeScrollability,
],
);
@@ -909,8 +954,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
const handleScroll: ScrollViewProps['onScroll'] = useStableCallback((event) => {
const messageListHasMessages = processedMessageList.length > 0;
const offset = event.nativeEvent.contentOffset.y;
- // Show scrollToBottom button once scroll position goes beyond 150.
- const isScrollAtBottom = offset <= 150;
+ const isScrollAtBottom = offset <= messageInputHeight;
const notLatestSet = channel.state.messages !== channel.state.latestMessages;
@@ -1116,11 +1160,16 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
const flatListContentContainerStyle = useMemo(
() => [
- styles.contentContainer,
+ { paddingTop: messageInputFloating ? messageInputHeight : 0 },
+ additionalFlatListProps?.contentContainerStyle,
+ contentContainer,
+ ],
+ [
additionalFlatListProps?.contentContainerStyle,
contentContainer,
+ messageInputHeight,
+ messageInputFloating,
],
- [additionalFlatListProps?.contentContainerStyle, contentContainer],
);
if (!FlatList) {
@@ -1185,7 +1234,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
/>
)}
-
+
{messageListLengthAfterUpdate && StickyHeader ? (
) : null}
@@ -1195,14 +1244,30 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
)}
-
+ {scrollToBottomButtonVisible ? (
+
+
+
+ ) : null}
+
{isUnreadNotificationOpen && !threadList ? (
-
+
+
+
) : null}
);
@@ -1214,7 +1279,6 @@ export const MessageList = (props: MessageListProps) => {
const { closePicker, selectedPicker, setSelectedPicker } = useAttachmentPickerContext();
const {
channel,
- channelUnreadState,
channelUnreadStateStore,
disabled,
EmptyStateIndicator,
@@ -1256,6 +1320,7 @@ export const MessageList = (props: MessageListProps) => {
TypingIndicatorContainer,
UnreadMessagesNotification,
} = useMessagesContext();
+ const { messageInputFloating } = useMessageInputContext();
const { loadMore, loadMoreRecent } = usePaginatedMessageListContext();
const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext();
@@ -1263,7 +1328,6 @@ export const MessageList = (props: MessageListProps) => {
{
markRead,
maximumMessageLimit,
Message,
+ messageInputFloating,
MessageSystem,
myMessageTheme,
NetworkDownIndicator,
diff --git a/package/src/components/MessageList/ScrollToBottomButton.tsx b/package/src/components/MessageList/ScrollToBottomButton.tsx
index 8c15cb851c..5e7c732a70 100644
--- a/package/src/components/MessageList/ScrollToBottomButton.tsx
+++ b/package/src/components/MessageList/ScrollToBottomButton.tsx
@@ -1,29 +1,13 @@
import React from 'react';
-import { GestureResponderEvent, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { StyleSheet, Text, View } from 'react-native';
+
+import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated';
import { useTheme } from '../../contexts/themeContext/ThemeContext';
-import { Down } from '../../icons';
+import { NewDown } from '../../icons/NewDown';
+import { IconButton } from '../ui/IconButton';
const styles = StyleSheet.create({
- container: {
- alignItems: 'center',
- borderRadius: 20,
- elevation: 5,
- height: 40,
- justifyContent: 'center',
- shadowOffset: {
- height: 2,
- width: 0,
- },
- shadowOpacity: 0.25,
- shadowRadius: 4,
- width: 40,
- },
- touchable: {
- bottom: 20,
- position: 'absolute',
- right: 20,
- },
unreadCountNotificationContainer: {
alignItems: 'center',
borderRadius: 10,
@@ -40,16 +24,11 @@ const styles = StyleSheet.create({
textAlign: 'center',
textAlignVertical: 'center',
},
- wrapper: {
- alignItems: 'center',
- height: 50,
- justifyContent: 'flex-end',
- },
});
export type ScrollToBottomButtonProps = {
/** onPress handler */
- onPress: (event: GestureResponderEvent) => void;
+ onPress: () => void;
/** If we should show the notification or not */
showNotification?: boolean;
unreadCount?: number;
@@ -60,15 +39,12 @@ export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => {
const {
theme: {
- colors: { accent_blue, black, white },
+ colors: { accent_blue, white },
messageList: {
scrollToBottomButton: {
- chevronColor,
container,
- touchable,
unreadCountNotificationContainer,
unreadCountNotificationText,
- wrapper,
},
},
},
@@ -79,37 +55,42 @@ export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => {
}
return (
-
-
-
-
-
- {!!unreadCount && (
-
+
+
+
+ {!!unreadCount && (
+
+
-
- {unreadCount}
-
-
- )}
-
-
+ {unreadCount}
+
+
+ )}
+ >
);
};
diff --git a/package/src/components/MessageList/UnreadMessagesNotification.tsx b/package/src/components/MessageList/UnreadMessagesNotification.tsx
index 47192ee192..0872d8be42 100644
--- a/package/src/components/MessageList/UnreadMessagesNotification.tsx
+++ b/package/src/components/MessageList/UnreadMessagesNotification.tsx
@@ -4,7 +4,7 @@ import { Pressable, StyleSheet, Text } from 'react-native';
import { useChannelContext } from '../../contexts/channelContext/ChannelContext';
import { useTheme } from '../../contexts/themeContext/ThemeContext';
import { useTranslationContext } from '../../contexts/translationContext/TranslationContext';
-import { Close } from '../../icons';
+import { NewClose } from '../../icons/NewClose';
export type UnreadMessagesNotificationProps = {
/**
@@ -76,7 +76,7 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp
closeButtonContainer,
]}
>
-
+
);
@@ -84,13 +84,11 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp
const styles = StyleSheet.create({
container: {
- alignItems: 'center',
borderRadius: 20,
elevation: 4,
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 8,
- position: 'absolute',
shadowColor: '#000',
shadowOffset: {
height: 2,
@@ -98,7 +96,6 @@ const styles = StyleSheet.create({
},
shadowOpacity: 0.23,
shadowRadius: 2.62,
- top: 8,
},
text: {
fontWeight: '500',
diff --git a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap
index 286fdbf9a2..eddcde61d4 100644
--- a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap
+++ b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap
@@ -1,136 +1,141 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`ScrollToBottomButton should render the message notification and match snapshot 1`] = `
-
-
-
-
-
-
-
+ }
+ strokeLinecap={1}
+ strokeLinejoin={1}
+ strokeWidth={1.5}
+ />
+
+
`;
diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts
index 75366278ea..e5ee25fe65 100644
--- a/package/src/components/MessageList/hooks/useMessageList.ts
+++ b/package/src/components/MessageList/hooks/useMessageList.ts
@@ -1,8 +1,7 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import type { LocalMessage } from 'stream-chat';
-import { useChannelContext } from '../../../contexts/channelContext/ChannelContext';
import { useChatContext } from '../../../contexts/chatContext/ChatContext';
import {
DeletedMessagesVisibilityType,
@@ -13,15 +12,8 @@ import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'
import { useRAFCoalescedValue } from '../../../hooks';
import { MessagePreviousAndNextMessageStore } from '../../../state-store/message-list-prev-next-state';
-import { DateSeparators, getDateSeparators } from '../utils/getDateSeparators';
-import { getGroupStyles } from '../utils/getGroupStyles';
export type UseMessageListParams = {
- deletedMessagesVisibilityType?: DeletedMessagesVisibilityType;
- /**
- * @deprecated
- */
- noGroupByUser?: boolean;
threadList?: boolean;
isLiveStreaming?: boolean;
isFlashList?: boolean;
@@ -62,11 +54,9 @@ export const shouldIncludeMessageInList = (
};
export const useMessageList = (params: UseMessageListParams) => {
- const { noGroupByUser, threadList, isLiveStreaming, isFlashList = false } = params;
+ const { threadList, isLiveStreaming, isFlashList = false } = params;
const { client } = useChatContext();
- const { hideDateSeparators, maxTimeBetweenGroupedMessages } = useChannelContext();
- const { deletedMessagesVisibilityType, getMessagesGroupStyles = getGroupStyles } =
- useMessagesContext();
+ const { deletedMessagesVisibilityType } = useMessagesContext();
const { messages, viewabilityChangedCallback } = usePaginatedMessageListContext();
const { threadMessages } = useThreadContext();
const messageList = threadList ? threadMessages : messages;
@@ -101,61 +91,10 @@ export const useMessageList = (params: UseMessageListParams) => {
});
}, [processedMessageList, messageListPreviousAndNextMessageStore, isFlashList]);
- /**
- * @deprecated use `useDateSeparator` hook instead directly in the Message.
- */
- const dateSeparators = useMemo(
- () =>
- getDateSeparators({
- hideDateSeparators,
- messages: processedMessageList,
- }),
- [hideDateSeparators, processedMessageList],
- );
-
- /**
- * @deprecated use `useDateSeparator` hook instead directly in the Message.
- */
- const dateSeparatorsRef = useRef(dateSeparators);
- dateSeparatorsRef.current = dateSeparators;
-
- /**
- * @deprecated use `useMessageGroupStyles` hook instead directly in the Message.
- */
- const messageGroupStyles = useMemo(
- () =>
- getMessagesGroupStyles({
- dateSeparators: dateSeparatorsRef.current,
- hideDateSeparators,
- maxTimeBetweenGroupedMessages,
- messages: processedMessageList,
- noGroupByUser,
- userId: client.userID,
- }),
- [
- getMessagesGroupStyles,
- hideDateSeparators,
- maxTimeBetweenGroupedMessages,
- processedMessageList,
- noGroupByUser,
- client.userID,
- ],
- );
-
- /**
- * @deprecated use `useMessageGroupStyles` hook instead directly in the Message.
- */
- const messageGroupStylesRef = useRef(messageGroupStyles);
- messageGroupStylesRef.current = messageGroupStyles;
-
const data = useRAFCoalescedValue(processedMessageList, isLiveStreaming);
return useMemo(
() => ({
- /** Date separators */
- dateSeparatorsRef,
- /** Message group styles */
- messageGroupStylesRef,
messageListPreviousAndNextMessageStore,
/** Messages enriched with dates/readby/groups and also reversed in order */
processedMessageList: data,
diff --git a/package/src/components/MessageList/utils/getDateSeparators.ts b/package/src/components/MessageList/utils/getDateSeparators.ts
deleted file mode 100644
index 575debf957..0000000000
--- a/package/src/components/MessageList/utils/getDateSeparators.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import type { DeletedMessagesVisibilityType } from '../../../contexts/messagesContext/MessagesContext';
-import type { PaginatedMessageListContextValue } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext';
-import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext';
-
-/**
- * @deprecated in favor of `useDateSeparator` hook instead directly in the Message.
- */
-export type GetDateSeparatorsParams = {
- messages: PaginatedMessageListContextValue['messages'] | ThreadContextValue['threadMessages'];
- /**
- * @deprecated The computations are done ahead of time in the useMessageList hook so this parameter is no longer needed.
- */
- deletedMessagesVisibilityType?: DeletedMessagesVisibilityType;
- hideDateSeparators?: boolean;
- /**
- * @deprecated The computations are done ahead of time in the useMessageList hook so this parameter is no longer needed.
- */
- userId?: string;
-};
-
-export type DateSeparators = {
- [key: string]: Date;
-};
-
-/**
- * @deprecated in favor of `useDateSeparator` hook instead directly in the Message.
- */
-export const getDateSeparators = (params: GetDateSeparatorsParams) => {
- const { hideDateSeparators, messages } = params;
- const dateSeparators: DateSeparators = {};
-
- if (hideDateSeparators) {
- return dateSeparators;
- }
-
- for (let i = 0; i < messages.length; i++) {
- const previousMessage = messages[i - 1];
- const message = messages[i];
-
- const messageDate = message.created_at.toDateString();
-
- const prevMessageDate = previousMessage
- ? previousMessage.created_at.toDateString()
- : messageDate;
-
- if (i === 0 || messageDate !== prevMessageDate) {
- dateSeparators[message.id] = message.created_at;
- }
- }
-
- return dateSeparators;
-};
diff --git a/package/src/components/MessageList/utils/getGroupStyles.ts b/package/src/components/MessageList/utils/getGroupStyles.ts
index 55630bd6f2..e3dec9e16a 100644
--- a/package/src/components/MessageList/utils/getGroupStyles.ts
+++ b/package/src/components/MessageList/utils/getGroupStyles.ts
@@ -1,12 +1,6 @@
import { LocalMessage } from 'stream-chat';
-import type { DateSeparators } from './getDateSeparators';
-
-import type { PaginatedMessageListContextValue } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext';
-import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext';
-
import { isEditedMessage } from '../../../utils/utils';
-import type { GroupType } from '../hooks/useMessageList';
export type MessageGroupStylesParams = {
message: LocalMessage;
@@ -17,24 +11,6 @@ export type MessageGroupStylesParams = {
nextMessageDateSeparatorDate?: Date;
};
-/**
- * @deprecated in favor of `useMessageGroupStyles` hook instead directly in the Message.
- */
-export type GetGroupStylesParams = {
- dateSeparators: DateSeparators;
- messages: PaginatedMessageListContextValue['messages'] | ThreadContextValue['threadMessages'];
- /**
- * @deprecated in favor of `useDateSeparator` hook instead directly in the Message.
- */
- hideDateSeparators?: boolean;
- maxTimeBetweenGroupedMessages?: number;
- noGroupByUser?: boolean;
- /**
- * @deprecated
- */
- userId?: string;
-};
-
export type GroupStyle = '' | 'middle' | 'top' | 'bottom' | 'single';
/**
@@ -118,34 +94,3 @@ export const getGroupStyle = ({
return groupStyles;
};
-
-/**
- * @deprecated in favor of `useMessageGroupStyles` hook instead directly in the Message.
- */
-export const getGroupStyles = (params: GetGroupStylesParams) => {
- const { dateSeparators, maxTimeBetweenGroupedMessages, messages, noGroupByUser } = params;
-
- if (noGroupByUser) {
- return {};
- }
-
- const messageGroupStyles: { [key: string]: GroupType[] } = {};
-
- for (let i = 0; i < messages.length; i++) {
- const previousMessage = messages[i - 1];
- const message = messages[i];
- const nextMessage = messages[i + 1];
-
- if (message.id) {
- messageGroupStyles[message.id] = getGroupStyle({
- dateSeparatorDate: dateSeparators[message.id],
- maxTimeBetweenGroupedMessages,
- message,
- nextMessage,
- previousMessage,
- });
- }
- }
-
- return messageGroupStyles;
-};
diff --git a/package/src/components/MessageMenu/MessageActionList.tsx b/package/src/components/MessageMenu/MessageActionList.tsx
index 6fc69abfe2..14dbd05024 100644
--- a/package/src/components/MessageMenu/MessageActionList.tsx
+++ b/package/src/components/MessageMenu/MessageActionList.tsx
@@ -24,6 +24,7 @@ export const MessageActionList = (props: MessageActionListProps) => {
const { MessageActionListItem, messageActions } = props;
const {
theme: {
+ colors: { white },
messageMenu: {
actionList: { container, contentContainer },
},
@@ -37,8 +38,12 @@ export const MessageActionList = (props: MessageActionListProps) => {
return (
{messageActions?.map((messageAction, index) => (
{
};
const styles = StyleSheet.create({
- container: {},
+ container: {
+ borderRadius: 16,
+ marginTop: 6,
+ },
contentContainer: {
- paddingHorizontal: 16,
+ borderRadius: 16,
+ flexGrow: 1,
+ minWidth: 250,
+ paddingHorizontal: 12,
+ paddingVertical: 4,
},
});
diff --git a/package/src/components/MessageMenu/MessageActionListItem.tsx b/package/src/components/MessageMenu/MessageActionListItem.tsx
index 47f8c26cf5..332dd0e6b4 100644
--- a/package/src/components/MessageMenu/MessageActionListItem.tsx
+++ b/package/src/components/MessageMenu/MessageActionListItem.tsx
@@ -2,6 +2,8 @@ import React from 'react';
import { Pressable, StyleProp, StyleSheet, Text, TextStyle, View } from 'react-native';
import { useTheme } from '../../contexts/themeContext/ThemeContext';
+import { useStableCallback } from '../../hooks';
+import { closeOverlay, scheduleActionOnClose } from '../../state-store';
export type ActionType =
| 'banUser'
@@ -62,8 +64,13 @@ export const MessageActionListItem = (props: MessageActionListItemProps) => {
},
} = useTheme();
+ const onActionPress = useStableCallback(() => {
+ closeOverlay();
+ scheduleActionOnClose(() => action());
+ });
+
return (
- [{ opacity: pressed ? 0.5 : 1 }]}>
+ [{ opacity: pressed ? 0.5 : 1 }]}>
-> &
- Partial> & {
- /**
- * Function to close the message actions bottom sheet
- * @returns void
- */
- dismissOverlay: () => void;
- /**
- * An array of message actions to render
- */
- messageActions: MessageActionType[];
- /**
- * Boolean to determine if there are message actions
- */
- showMessageReactions: boolean;
- /**
- * Boolean to determine if the overlay is visible.
- */
- visible: boolean;
- /**
- * Function to handle reaction on press
- * @param reactionType
- * @returns
- */
- handleReaction?: (reactionType: string) => Promise;
- /**
- * The selected reaction
- */
- selectedReaction?: string;
- };
+export type MessageMenuProps = PropsWithChildren<
+ Partial<
+ Pick<
+ MessagesContextValue,
+ | 'MessageActionList'
+ | 'MessageActionListItem'
+ | 'MessageReactionPicker'
+ | 'MessageUserReactions'
+ | 'MessageUserReactionsAvatar'
+ | 'MessageUserReactionsItem'
+ >
+ > &
+ Partial> & {
+ /**
+ * Function to close the message actions bottom sheet
+ * @returns void
+ */
+ dismissOverlay: () => void;
+ /**
+ * An array of message actions to render
+ */
+ messageActions: MessageActionType[];
+ /**
+ * Boolean to determine if there are message actions
+ */
+ showMessageReactions: boolean;
+ /**
+ * Boolean to determine if the overlay is visible.
+ */
+ visible: boolean;
+ /**
+ * Function to handle reaction on press
+ * @param reactionType
+ * @returns
+ */
+ handleReaction?: (reactionType: string) => Promise;
+ /**
+ * The selected reaction
+ */
+ selectedReaction?: string;
+
+ layout: {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ };
+ }
+>;
+// TODO: V9: Either remove this or refactor it so that it's useful again, as its logic
+// is offloaded to other components now.
export const MessageMenu = (props: MessageMenuProps) => {
const {
dismissOverlay,
- handleReaction,
- message: propMessage,
- MessageActionList: propMessageActionList,
- MessageActionListItem: propMessageActionListItem,
- messageActions,
- MessageReactionPicker: propMessageReactionPicker,
- MessageUserReactions: propMessageUserReactions,
- MessageUserReactionsAvatar: propMessageUserReactionsAvatar,
- MessageUserReactionsItem: propMessageUserReactionsItem,
- selectedReaction,
+ // handleReaction,
+ // message: propMessage,
+ // MessageActionList: propMessageActionList,
+ // MessageActionListItem: propMessageActionListItem,
+ // messageActions,
+ // MessageReactionPicker: propMessageReactionPicker,
+ // MessageUserReactions: propMessageUserReactions,
+ // MessageUserReactionsAvatar: propMessageUserReactionsAvatar,
+ // MessageUserReactionsItem: propMessageUserReactionsItem,
+ // selectedReaction,
showMessageReactions,
visible,
+ // layout,
+ children,
} = props;
const { height } = useWindowDimensions();
- const {
- MessageActionList: contextMessageActionList,
- MessageActionListItem: contextMessageActionListItem,
- MessageReactionPicker: contextMessageReactionPicker,
- MessageUserReactions: contextMessageUserReactions,
- MessageUserReactionsAvatar: contextMessageUserReactionsAvatar,
- MessageUserReactionsItem: contextMessageUserReactionsItem,
- } = useMessagesContext();
- const { message: contextMessage } = useMessageContext();
- const MessageActionList = propMessageActionList ?? contextMessageActionList;
- const MessageActionListItem = propMessageActionListItem ?? contextMessageActionListItem;
- const MessageReactionPicker = propMessageReactionPicker ?? contextMessageReactionPicker;
- const MessageUserReactions = propMessageUserReactions ?? contextMessageUserReactions;
- const MessageUserReactionsAvatar =
- propMessageUserReactionsAvatar ?? contextMessageUserReactionsAvatar;
- const MessageUserReactionsItem = propMessageUserReactionsItem ?? contextMessageUserReactionsItem;
- const message = propMessage ?? contextMessage;
+ // const {
+ // MessageActionList: contextMessageActionList,
+ // MessageActionListItem: contextMessageActionListItem,
+ // MessageReactionPicker: contextMessageReactionPicker,
+ // MessageUserReactions: contextMessageUserReactions,
+ // MessageUserReactionsAvatar: contextMessageUserReactionsAvatar,
+ // MessageUserReactionsItem: contextMessageUserReactionsItem,
+ // } = useMessagesContext();
+ // const { message: contextMessage } = useMessageContext();
+ // const MessageActionList = propMessageActionList ?? contextMessageActionList;
+ // const MessageActionListItem = propMessageActionListItem ?? contextMessageActionListItem;
+ // const MessageReactionPicker = propMessageReactionPicker ?? contextMessageReactionPicker;
+ // const MessageUserReactions = propMessageUserReactions ?? contextMessageUserReactions;
+ // const MessageUserReactionsAvatar =
+ // propMessageUserReactionsAvatar ?? contextMessageUserReactionsAvatar;
+ // const MessageUserReactionsItem = propMessageUserReactionsItem ?? contextMessageUserReactionsItem;
+ // const message = propMessage ?? contextMessage;
const {
theme: {
messageMenu: {
@@ -101,36 +108,12 @@ export const MessageMenu = (props: MessageMenuProps) => {
return (
- {showMessageReactions ? (
-
- ) : (
- <>
- reaction.type) || []}
- />
-
- >
- )}
+ {children}
);
};
diff --git a/package/src/components/MessageMenu/MessageReactionPicker.tsx b/package/src/components/MessageMenu/MessageReactionPicker.tsx
index 72131b94be..700ff4b50c 100644
--- a/package/src/components/MessageMenu/MessageReactionPicker.tsx
+++ b/package/src/components/MessageMenu/MessageReactionPicker.tsx
@@ -1,6 +1,8 @@
-import React from 'react';
-import { FlatList, StyleSheet, View } from 'react-native';
+import React, { useCallback, useMemo } from 'react';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+import { FlatList } from 'react-native-gesture-handler';
+import { emojis } from './emojis';
import { ReactionButton } from './ReactionButton';
import { MessageContextValue } from '../../contexts/messageContext/MessageContext';
@@ -11,9 +13,13 @@ import {
import { useOwnCapabilitiesContext } from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext';
import { useTheme } from '../../contexts/themeContext/ThemeContext';
+import { useStableCallback } from '../../hooks';
+import { Attach } from '../../icons';
import { NativeHandlers } from '../../native';
+import { scheduleActionOnClose } from '../../state-store';
import { ReactionData } from '../../utils/utils';
+import { BottomSheetModal } from '../UIComponents';
export type MessageReactionPickerProps = Pick &
Pick & {
@@ -28,6 +34,8 @@ export type ReactionPickerItemType = ReactionData & {
ownReactionTypes: string[];
};
+const keyExtractor = (item: ReactionPickerItemType) => item.type;
+
const renderItem = ({ index, item }: { index: number; item: ReactionPickerItemType }) => (
);
+const emojiKeyExtractor = (item: string) => `unicode-${item}`;
+
+// TODO: V9: Move this to utils and also clean it up a bit.
+// This was done quickly and in a bit of a hurry.
+export const toUnicodeScalarString = (emoji: string): string => {
+ const out: number[] = [];
+ for (const ch of emoji) out.push(ch.codePointAt(0)!);
+ return out.map((cp) => `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`).join('-');
+};
+
/**
* MessageReactionPicker - A high level component which implements all the logic required for a message overlay reaction list
*/
export const MessageReactionPicker = (props: MessageReactionPickerProps) => {
+ const [emojiViewerOpened, setEmojiViewerOpened] = React.useState(null);
const {
dismissOverlay,
handleReaction,
@@ -51,6 +70,7 @@ export const MessageReactionPicker = (props: MessageReactionPickerProps) => {
const { supportedReactions: contextSupportedReactions } = useMessagesContext();
const {
theme: {
+ colors: { white, grey },
messageMenu: {
reactionPicker: { container, contentContainer },
},
@@ -60,48 +80,116 @@ export const MessageReactionPicker = (props: MessageReactionPickerProps) => {
const supportedReactions = propSupportedReactions || contextSupportedReactions;
- const onSelectReaction = (type: string) => {
+ const onSelectReaction = useStableCallback((type: string) => {
NativeHandlers.triggerHaptic('impactLight');
+ setEmojiViewerOpened(false);
+ dismissOverlay();
if (handleReaction) {
- handleReaction(type);
+ scheduleActionOnClose(() => handleReaction(type));
}
- dismissOverlay();
- };
+ });
+
+ const onOpenEmojiViewer = useStableCallback(() => {
+ NativeHandlers.triggerHaptic('impactLight');
+ setEmojiViewerOpened(true);
+ });
+
+ const EmojiViewerButton = useCallback(
+ () => (
+
+
+
+ ),
+ [grey, onOpenEmojiViewer],
+ );
+
+ const reactions: ReactionPickerItemType[] = useMemo(
+ () =>
+ supportedReactions
+ ?.filter((reaction) => !reaction.isUnicode)
+ ?.map((reaction) => ({
+ ...reaction,
+ onSelectReaction,
+ ownReactionTypes,
+ })) ?? [],
+ [onSelectReaction, ownReactionTypes, supportedReactions],
+ );
+
+ const selectEmoji = useStableCallback((emoji: string) => {
+ const scalarString = toUnicodeScalarString(emoji);
+ onSelectReaction(scalarString);
+ });
+
+ const closeModal = useStableCallback(() => setEmojiViewerOpened(false));
+
+ const renderEmoji = useCallback(
+ ({ item }: { item: string }) => {
+ return (
+ selectEmoji(item)} style={styles.emojiContainer}>
+ {item}
+
+ );
+ },
+ [selectEmoji],
+ );
if (!own_capabilities.sendReaction) {
return null;
}
- const reactions: ReactionPickerItemType[] =
- supportedReactions?.map((reaction) => ({
- ...reaction,
- onSelectReaction,
- ownReactionTypes,
- })) ?? [];
-
return (
item.type}
+ keyExtractor={keyExtractor}
+ ListFooterComponent={EmojiViewerButton}
renderItem={renderItem}
/>
+ {emojiViewerOpened ? (
+
+
+
+ ) : null}
);
};
const styles = StyleSheet.create({
+ bottomSheet: { height: 300 },
+ bottomSheetColumnWrapper: {
+ alignItems: 'center',
+ justifyContent: 'space-evenly',
+ width: '100%',
+ },
+ bottomSheetContentContainer: { paddingVertical: 16 },
container: {
alignSelf: 'stretch',
},
contentContainer: {
+ borderRadius: 20,
flexGrow: 1,
justifyContent: 'space-around',
marginVertical: 8,
+ paddingHorizontal: 5,
},
+ emojiContainer: { height: 30 },
+ emojiText: { fontSize: 20, padding: 2 },
+ emojiViewerButton: { alignItems: 'flex-start', justifyContent: 'flex-start', paddingTop: 4 },
});
diff --git a/package/src/components/MessageMenu/MessageUserReactions.tsx b/package/src/components/MessageMenu/MessageUserReactions.tsx
index ee7dfe5da7..c8e3c3c743 100644
--- a/package/src/components/MessageMenu/MessageUserReactions.tsx
+++ b/package/src/components/MessageMenu/MessageUserReactions.tsx
@@ -1,11 +1,10 @@
-import React, { useCallback, useEffect, useMemo } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
+import React, { useCallback, useMemo } from 'react';
+import { Dimensions, StyleSheet, Text, View } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import { ReactionSortBase } from 'stream-chat';
import { useFetchReactions } from './hooks/useFetchReactions';
-import { ReactionButton } from './ReactionButton';
import { MessageContextValue } from '../../contexts/messageContext/MessageContext';
import {
@@ -15,7 +14,6 @@ import {
import { useTheme } from '../../contexts/themeContext/ThemeContext';
import { useTranslationContext } from '../../contexts/translationContext/TranslationContext';
import { Reaction } from '../../types/types';
-import { ReactionData } from '../../utils/utils';
export type MessageUserReactionsProps = Partial<
Pick<
@@ -32,26 +30,14 @@ export type MessageUserReactionsProps = Partial<
* The selected reaction
*/
selectedReaction?: string;
+ reactionFilterEnabled?: boolean;
};
const sort: ReactionSortBase = {
created_at: -1,
};
-export type ReactionSelectorItemType = ReactionData & {
- onSelectReaction: (type: string) => void;
- selectedReaction?: string;
-};
-
-const renderSelectorItem = ({ index, item }: { index: number; item: ReactionSelectorItemType }) => (
-
-);
+const keyExtractor = (item: Reaction) => item.id;
export const MessageUserReactions = (props: MessageUserReactionsProps) => {
const {
@@ -59,13 +45,8 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => {
MessageUserReactionsAvatar: propMessageUserReactionsAvatar,
MessageUserReactionsItem: propMessageUserReactionsItem,
reactions: propReactions,
- selectedReaction: propSelectedReaction,
supportedReactions: propSupportedReactions,
} = props;
- const reactionTypes = Object.keys(message?.reaction_groups ?? {});
- const [selectedReaction, setSelectedReaction] = React.useState(
- propSelectedReaction ?? reactionTypes[0],
- );
const {
MessageUserReactionsAvatar: contextMessageUserReactionsAvatar,
MessageUserReactionsItem: contextMessageUserReactionsItem,
@@ -76,51 +57,21 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => {
propMessageUserReactionsAvatar ?? contextMessageUserReactionsAvatar;
const MessageUserReactionsItem = propMessageUserReactionsItem ?? contextMessageUserReactionsItem;
- const onSelectReaction = (reactionType: string) => {
- setSelectedReaction(reactionType);
- };
-
- useEffect(() => {
- if (selectedReaction && reactionTypes.length > 0 && !reactionTypes.includes(selectedReaction)) {
- setSelectedReaction(reactionTypes[0]);
- }
- }, [reactionTypes, selectedReaction]);
-
- const messageReactions = useMemo(
- () =>
- reactionTypes.reduce((acc, reaction) => {
- const reactionData = supportedReactions?.find(
- (supportedReaction) => supportedReaction.type === reaction,
- );
- if (reactionData) {
- acc.push(reactionData);
- }
- return acc;
- }, []),
- [reactionTypes, supportedReactions],
- );
-
const {
loading,
loadNextPage,
reactions: fetchedReactions,
} = useFetchReactions({
message,
- reactionType: selectedReaction,
+ reactionType: undefined,
sort,
});
const {
theme: {
+ colors: { white },
messageMenu: {
- userReactions: {
- container,
- contentContainer,
- flatlistColumnContainer,
- flatlistContainer,
- reactionSelectorContainer,
- reactionsText,
- },
+ userReactions: { container, flatlistColumnContainer, flatlistContainer, reactionsText },
},
},
} = useTheme();
@@ -149,52 +100,34 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => {
[MessageUserReactionsAvatar, MessageUserReactionsItem, supportedReactions],
);
- const renderHeader = useCallback(
- () => {t('Message Reactions')},
- [t, reactionsText],
- );
-
- const selectorReactions: ReactionSelectorItemType[] = messageReactions.map((reaction) => ({
- ...reaction,
- onSelectReaction,
- selectedReaction,
- }));
-
- return (
+ return !loading ? (
-
- item.type}
- renderItem={renderSelectorItem}
- />
-
-
- {!loading ? (
+ <>
+ {t('Message Reactions')}
item.id}
- ListHeaderComponent={renderHeader}
+ keyExtractor={keyExtractor}
numColumns={4}
onEndReached={loadNextPage}
renderItem={renderItem}
/>
- ) : null}
+ >
- );
+ ) : null;
};
const styles = StyleSheet.create({
container: {
- flex: 1,
+ borderRadius: 16,
+ marginTop: 16,
+ maxHeight: 256,
+ width: Dimensions.get('window').width * 0.9,
},
contentContainer: {
flexGrow: 1,
@@ -206,6 +139,7 @@ const styles = StyleSheet.create({
},
flatListContainer: {
justifyContent: 'center',
+ paddingHorizontal: 8,
},
reactionSelectorContainer: {
flexDirection: 'row',
@@ -215,6 +149,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: 'bold',
marginVertical: 16,
+ paddingHorizontal: 8,
textAlign: 'center',
},
});
diff --git a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx
index 3a3ca771c0..181f4cef1a 100644
--- a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx
+++ b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx
@@ -108,7 +108,8 @@ export const MessageUserReactionsItem = ({
const styles = StyleSheet.create({
avatarContainer: {
- marginBottom: 8,
+ marginBottom: 24,
+ marginHorizontal: 8,
},
avatarInnerContainer: {
alignSelf: 'center',
diff --git a/package/src/components/MessageMenu/ReactionButton.tsx b/package/src/components/MessageMenu/ReactionButton.tsx
index b3d4ad8f92..63fb815d1b 100644
--- a/package/src/components/MessageMenu/ReactionButton.tsx
+++ b/package/src/components/MessageMenu/ReactionButton.tsx
@@ -29,14 +29,9 @@ export const ReactionButton = (props: ReactionButtonProps) => {
const { Icon, onPress, selected, type } = props;
const {
theme: {
- colors: { light_blue, accent_blue, white, grey },
+ colors: { accent_blue, grey },
messageMenu: {
- reactionButton: {
- filledBackgroundColor = light_blue,
- filledColor = accent_blue,
- unfilledBackgroundColor = white,
- unfilledColor = grey,
- },
+ reactionButton: { filledColor = accent_blue, unfilledColor = grey },
reactionPicker: { buttonContainer, reactionIconSize },
},
},
@@ -54,7 +49,7 @@ export const ReactionButton = (props: ReactionButtonProps) => {
onPress={onPressHandler}
style={({ pressed }) => [
styles.reactionButton,
- { backgroundColor: pressed || selected ? filledBackgroundColor : unfilledBackgroundColor },
+ { backgroundColor: 'transparent', opacity: pressed ? 0.5 : 1 },
buttonContainer,
]}
>
@@ -72,6 +67,8 @@ const styles = StyleSheet.create({
alignItems: 'center',
borderRadius: 8,
justifyContent: 'center',
- padding: 8,
+ overflow: 'hidden',
+ paddingHorizontal: 3,
+ paddingVertical: 8,
},
});
diff --git a/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx b/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx
index e93cefa59b..db623849bd 100644
--- a/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx
+++ b/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { Text } from 'react-native';
-import { fireEvent, render } from '@testing-library/react-native';
+import { render } from '@testing-library/react-native';
import { LocalMessage, ReactionResponse } from 'stream-chat';
@@ -85,30 +85,31 @@ describe('MessageUserReactions when the supportedReactions are defined', () => {
expect(getByText('Message Reactions')).toBeTruthy();
});
- it('renders reaction buttons', () => {
- const { getByLabelText } = renderComponent();
- const likeReactionButton = getByLabelText('reaction-button-like-selected');
- expect(likeReactionButton).toBeDefined();
- const loveReactionButton = getByLabelText('reaction-button-love-unselected');
- expect(loveReactionButton).toBeDefined();
- });
-
- it('selects the first reaction by default', () => {
- const { getAllByLabelText } = renderComponent();
- const reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/);
- expect(reactionButtons[0].props.accessibilityLabel).toBe('reaction-button-like-selected');
- expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-unselected');
- });
-
- it('changes selected reaction when a reaction button is pressed', () => {
- const { getAllByLabelText } = renderComponent();
- const reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/);
-
- fireEvent.press(reactionButtons[1]);
-
- expect(reactionButtons[0].props.accessibilityLabel).toBe('reaction-button-like-unselected');
- expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-selected');
- });
+ // TODO: V9: Remove these with V9, they are no longer relevant tests.
+ // it('renders reaction buttons', () => {
+ // const { getByLabelText } = renderComponent();
+ // const likeReactionButton = getByLabelText('reaction-button-like-selected');
+ // expect(likeReactionButton).toBeDefined();
+ // const loveReactionButton = getByLabelText('reaction-button-love-unselected');
+ // expect(loveReactionButton).toBeDefined();
+ // });
+ //
+ // it('selects the first reaction by default', () => {
+ // const { getAllByLabelText } = renderComponent();
+ // const reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/);
+ // expect(reactionButtons[0].props.accessibilityLabel).toBe('reaction-button-like-selected');
+ // expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-unselected');
+ // });
+ //
+ // it('changes selected reaction when a reaction button is pressed', () => {
+ // const { getAllByLabelText } = renderComponent();
+ // const reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/);
+ //
+ // fireEvent.press(reactionButtons[1]);
+ //
+ // expect(reactionButtons[0].props.accessibilityLabel).toBe('reaction-button-like-unselected');
+ // expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-selected');
+ // });
it('renders reactions list', () => {
const { getByText } = renderComponent();
diff --git a/package/src/components/MessageMenu/emojis.ts b/package/src/components/MessageMenu/emojis.ts
new file mode 100644
index 0000000000..7693c0c474
--- /dev/null
+++ b/package/src/components/MessageMenu/emojis.ts
@@ -0,0 +1,163 @@
+// TODO: V9: This should really come from emoji mart or something else.
+// No reason to pollute the SDK like this. It'll have to do for now though,
+// as for the purposes of a PoC it's fine.
+export const emojis = [
+ '😀',
+ '😃',
+ '😄',
+ '😁',
+ '😆',
+ '😅',
+ '🤣',
+ '😂',
+ '🙂',
+ '🙃',
+ '😉',
+ '😊',
+ '😇',
+ '🥰',
+ '😍',
+ '🤩',
+ '😘',
+ '😗',
+ '😚',
+ '😙',
+ '😋',
+ '😛',
+ '😜',
+ '🤪',
+ '😝',
+ '🤑',
+ '🤗',
+ '🤭',
+ '🤫',
+ '🤔',
+ '🤐',
+ '🤨',
+ '😐',
+ '😑',
+ '😶',
+ '😶🌫️',
+ '😏',
+ '😒',
+ '🙄',
+ '😬',
+ '🤥',
+ '😌',
+ '😔',
+ '😪',
+ '🤤',
+ '😴',
+ '😷',
+ '🤒',
+ '🤕',
+ '🤢',
+ '🤮',
+ '🤧',
+ '🥵',
+ '🥶',
+ '🥴',
+ '😵💫',
+ '🤯',
+ '🤠',
+ '🥳',
+ '😎',
+ '🤓',
+ '🧐',
+ '😕',
+ '😟',
+ '🙁',
+ '☹️',
+ '😮',
+ '😯',
+ '😲',
+ '😳',
+ '🥺',
+ '😦',
+ '😧',
+ '😨',
+ '😰',
+ '😥',
+ '😢',
+ '😭',
+ '😱',
+ '😖',
+ '😣',
+ '😞',
+ '😓',
+ '😩',
+ '😫',
+ '🥱',
+ '😤',
+ '😡',
+ '😠',
+ '🤬',
+ '😈',
+ '👿',
+ '💀',
+ '☠️',
+ '💩',
+ '🤡',
+ '👹',
+ '👺',
+ '👻',
+ '👽',
+ '👾',
+ '🤖',
+ '🎃',
+ '😺',
+ '😸',
+ '😹',
+ '😻',
+ '😼',
+ '😽',
+ '🙀',
+ '😿',
+ '😾',
+ '👍',
+ '👎',
+ '👌',
+ '🤌',
+ '🤏',
+ '✌️',
+ '🤞',
+ '🤟',
+ '🤘',
+ '🤙',
+ '👈',
+ '👉',
+ '👆',
+ '👇',
+ '☝️',
+ '✋',
+ '🤚',
+ '🖐️',
+ '🖖',
+ '👋',
+ '🤝',
+ '🙏',
+ '💪',
+ '👣',
+ '👀',
+ '🧠',
+ '🫶',
+ '💋',
+ '❤️',
+ '🧡',
+ '💛',
+ '💚',
+ '💙',
+ '💜',
+ '🖤',
+ '🤍',
+ '🤎',
+ '💔',
+ '❣️',
+ '💕',
+ '💞',
+ '💓',
+ '💗',
+ '💖',
+ '💘',
+ '💝',
+];
diff --git a/package/src/components/MessageMenu/hooks/useFetchReactions.ts b/package/src/components/MessageMenu/hooks/useFetchReactions.ts
index f67630a758..96683a101a 100644
--- a/package/src/components/MessageMenu/hooks/useFetchReactions.ts
+++ b/package/src/components/MessageMenu/hooks/useFetchReactions.ts
@@ -41,9 +41,19 @@ export const useFetchReactions = ({
if (response) {
setNext(response.next);
- setReactions((prevReactions) =>
- next ? [...prevReactions, ...response.reactions] : response.reactions,
- );
+ setReactions((prevReactions) => {
+ if (
+ prevReactions.length !== response.reactions.length ||
+ !prevReactions.every(
+ (r, index) =>
+ r.user_id === response.reactions[index].user_id &&
+ r.type === response.reactions[index].type,
+ )
+ ) {
+ return next ? [...prevReactions, ...response.reactions] : response.reactions;
+ }
+ return prevReactions;
+ });
setLoading(false);
}
} catch (error) {
@@ -82,7 +92,7 @@ export const useFetchReactions = ({
client.on('reaction.new', (event) => {
const { reaction } = event;
- if (reaction && reaction.type === reactionType) {
+ if (reaction && (reactionType ? reactionType === reaction.type : true)) {
setReactions((prevReactions) => [reaction, ...prevReactions]);
}
}),
@@ -92,14 +102,11 @@ export const useFetchReactions = ({
client.on('reaction.updated', (event) => {
const { reaction } = event;
- if (reaction) {
- if (reaction.type === reactionType) {
- setReactions((prevReactions) => [reaction, ...prevReactions]);
- } else {
- setReactions((prevReactions) =>
- prevReactions.filter((r) => r.user_id !== reaction.user_id),
- );
- }
+ if (reaction && (reactionType ? reactionType === reaction.type : true)) {
+ setReactions((prevReactions) => [
+ reaction,
+ ...prevReactions.filter((r) => r.user_id !== reaction.user_id),
+ ]);
}
}),
);
@@ -108,10 +115,12 @@ export const useFetchReactions = ({
client.on('reaction.deleted', (event) => {
const { reaction } = event;
- if (reaction && reaction.type === reactionType) {
- setReactions((prevReactions) =>
- prevReactions.filter((r) => r.user_id !== reaction.user_id),
- );
+ if (reaction && (reactionType ? reactionType === reaction.type : true)) {
+ setReactions((prevReactions) => {
+ return prevReactions.filter(
+ (r) => r.user_id !== reaction.user_id && r.type !== reaction.type,
+ );
+ });
}
}),
);
diff --git a/package/src/components/ProgressControl/ProgressControl.tsx b/package/src/components/ProgressControl/ProgressControl.tsx
index e4c53c8134..0c7ee0c49c 100644
--- a/package/src/components/ProgressControl/ProgressControl.tsx
+++ b/package/src/components/ProgressControl/ProgressControl.tsx
@@ -11,11 +11,6 @@ import Animated, {
import { useTheme } from '../../contexts/themeContext/ThemeContext';
export type ProgressControlProps = {
- /**
- * @deprecated unused prop.
- * The duration of the audio in seconds
- */
- duration: number;
/**
* The color of the filled progress bar
*/
@@ -32,16 +27,6 @@ export type ProgressControlProps = {
* The function to be called when the user ends dragging the progress bar
*/
onEndDrag?: (progress: number) => void;
- /**
- * The function to be called when the user plays or pauses the audio
- * @deprecated Use onStartDrag and onEndDrag instead
- */
- onPlayPause?: (status?: boolean) => void;
- /**
- * The function to be called when the user is dragging the progress bar
- * @deprecated This is not used anymore and is handled locally
- */
- onProgressDrag?: (progress: number) => void;
/**
* The function to be called when the user starts dragging the progress bar
*/
diff --git a/package/src/components/ProgressControl/WaveProgressBar.tsx b/package/src/components/ProgressControl/WaveProgressBar.tsx
index d258eb6ee6..3a509a9060 100644
--- a/package/src/components/ProgressControl/WaveProgressBar.tsx
+++ b/package/src/components/ProgressControl/WaveProgressBar.tsx
@@ -32,11 +32,6 @@ export type WaveProgressBarProps = {
* The function to be called when the user ends dragging the waveform
*/
onEndDrag?: (progress: number) => void;
- /**
- * The function to be called when the user plays or pauses the audio
- * @deprecated Use onStartDrag and onEndDrag instead
- */
- onPlayPause?: (status?: boolean) => void;
/**
* The function to be called when the user is dragging the waveform
*/
diff --git a/package/src/components/RTLComponents/WritingDirectionAwareText.tsx b/package/src/components/RTLComponents/WritingDirectionAwareText.tsx
index 0e7e0fa17b..e34ff46d31 100644
--- a/package/src/components/RTLComponents/WritingDirectionAwareText.tsx
+++ b/package/src/components/RTLComponents/WritingDirectionAwareText.tsx
@@ -1,11 +1,11 @@
import React from 'react';
-import { I18nManager, StyleSheet, Text, ViewProps } from 'react-native';
+import { I18nManager, StyleSheet, Text, TextProps } from 'react-native';
const styles = StyleSheet.create({
defaultStyle: { writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr' },
});
-export type WritingDirectionAwareTextProps = ViewProps;
+export type WritingDirectionAwareTextProps = TextProps;
export const WritingDirectionAwareText = (props: WritingDirectionAwareTextProps) => {
const { children, style, ...rest } = props;
diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx
index 1ad146941e..fe3af26126 100644
--- a/package/src/components/Reply/Reply.tsx
+++ b/package/src/components/Reply/Reply.tsx
@@ -1,405 +1,475 @@
-import React, { useMemo, useState } from 'react';
-
-import { Image, ImageStyle, StyleSheet, Text, View, ViewStyle } from 'react-native';
+import React, { useCallback, useMemo } from 'react';
+import { Image, StyleSheet, Text, View, ViewStyle } from 'react-native';
import dayjs from 'dayjs';
+import { LocalMessage, MessageComposerState, PollState } from 'stream-chat';
-import merge from 'lodash/merge';
-
-import type { Attachment, MessageComposerState, PollState } from 'stream-chat';
-
-import { useChatContext, useMessageComposer } from '../../contexts';
-import { useChatConfigContext } from '../../contexts/chatConfigContext/ChatConfigContext';
-import { useMessageContext } from '../../contexts/messageContext/MessageContext';
+import { useChatContext } from '../../contexts/chatContext/ChatContext';
import {
- MessagesContextValue,
- useMessagesContext,
-} from '../../contexts/messagesContext/MessagesContext';
+ MessageContextValue,
+ useMessageContext,
+} from '../../contexts/messageContext/MessageContext';
+import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer';
+import { MessagesContextValue } from '../../contexts/messagesContext/MessagesContext';
import { useTheme } from '../../contexts/themeContext/ThemeContext';
-import {
- TranslationContextValue,
- useTranslationContext,
-} from '../../contexts/translationContext/TranslationContext';
import { useStateStore } from '../../hooks';
+import { NewFile } from '../../icons/NewFile';
+import { NewLink } from '../../icons/NewLink';
+import { NewMapPin } from '../../icons/NewMapPin';
+import { NewMic } from '../../icons/NewMic';
+import { NewPhoto } from '../../icons/NewPhoto';
+import { NewPoll } from '../../icons/NewPoll';
+import { NewVideo } from '../../icons/NewVideo';
import { FileTypes } from '../../types/types';
-import { getResizedImageUrl } from '../../utils/getResizedImageUrl';
-import { getTrimmedAttachmentTitle } from '../../utils/getTrimmedAttachmentTitle';
-import { checkQuotedMessageEquality, hasOnlyEmojis } from '../../utils/utils';
+import { checkQuotedMessageEquality } from '../../utils/utils';
+import { FileIcon } from '../Attachment/FileIcon';
+import { AttachmentRemoveControl } from '../MessageInput/components/AttachmentPreview/AttachmentRemoveControl';
+import { VideoPlayIndicator } from '../ui/VideoPlayIndicator';
-import { FileIcon as FileIconDefault } from '../Attachment/FileIcon';
-import { VideoThumbnail } from '../Attachment/VideoThumbnail';
-import { MessageAvatar as MessageAvatarDefault } from '../Message/MessageSimple/MessageAvatar';
-import { MessageTextContainer } from '../Message/MessageSimple/MessageTextContainer';
-
-const styles = StyleSheet.create({
- container: {
- alignItems: 'flex-end',
- flexDirection: 'row',
- },
- fileAttachmentContainer: { paddingLeft: 8, paddingVertical: 8 },
- imageAttachment: {
- borderRadius: 8,
- height: 32,
- marginLeft: 8,
- marginVertical: 8,
- width: 32,
- },
- messageContainer: {
- alignItems: 'flex-start',
- borderBottomLeftRadius: 0,
- borderBottomRightRadius: 12,
- borderTopLeftRadius: 12,
- borderTopRightRadius: 12,
- flexDirection: 'row',
- flexGrow: 1,
- flexShrink: 1,
- },
- secondaryText: {
- paddingHorizontal: 8,
- },
- text: { fontSize: 12, fontWeight: 'bold', overflow: 'hidden' },
- textContainer: { maxWidth: undefined, paddingHorizontal: 8 },
- videoThumbnailContainerStyle: {
- borderRadius: 8,
- height: 50,
- marginLeft: 8,
- marginVertical: 8,
- width: 50,
- },
- videoThumbnailImageStyle: {
- borderRadius: 10,
- },
+const messageComposerStateStoreSelector = (state: MessageComposerState) => ({
+ quotedMessage: state.quotedMessage,
});
-export type ReplySelectorReturnType = {
- name?: string;
-};
-
-const selector = (nextValue: PollState): ReplySelectorReturnType => ({
+const selector = (nextValue: PollState) => ({
name: nextValue.name,
});
-const messageComposerStateStoreSelector = (state: MessageComposerState) => ({
- quotedMessage: state.quotedMessage,
+const RightContent = React.memo((props: { message: LocalMessage }) => {
+ const { message } = props;
+ const attachments = message?.attachments;
+
+ if (!attachments || attachments.length > 1) {
+ return null;
+ }
+
+ const attachment = attachments?.[0];
+
+ if (attachment?.type === FileTypes.Image) {
+ return (
+
+
+
+ );
+ }
+ if (attachment?.type === FileTypes.Video) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ if (attachment?.type === FileTypes.File) {
+ return ;
+ }
+
+ return null;
});
-type ReplyPropsWithContext = Pick<
- MessagesContextValue,
- 'FileAttachmentIcon' | 'MessageAvatar' | 'quotedMessage'
-> &
- Pick & {
- attachmentSize?: number;
- styles?: Partial<{
- container: ViewStyle;
- fileAttachmentContainer: ViewStyle;
- imageAttachment: ImageStyle;
- messageContainer: ViewStyle;
- textContainer: ViewStyle;
- }>;
- };
+const SubtitleText = React.memo(({ message }: { message?: LocalMessage | null }) => {
+ const { client } = useChatContext();
+ const poll = client.polls.fromState(message?.poll_id ?? '');
+ const { name: pollName } = useStateStore(poll?.state, selector) ?? {};
+ const {
+ theme: {
+ reply: { subtitle: subtitleStyle },
+ },
+ } = useTheme();
-const getMessageType = (lastAttachment: Attachment) => {
- let messageType;
+ const subtitle = useMemo(() => {
+ const attachments = message?.attachments;
+ const audioAttachments = attachments?.filter(
+ (attachment) => attachment.type === FileTypes.Audio,
+ );
+ const imageAttachments = attachments?.filter(
+ (attachment) => attachment.type === FileTypes.Image,
+ );
+ const videoAttachments = attachments?.filter(
+ (attachment) => attachment.type === FileTypes.Video,
+ );
+ const fileAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.File);
+ const voiceRecordingAttachments = attachments?.filter(
+ (attachment) => attachment.type === FileTypes.VoiceRecording,
+ );
+ const onlyImages = imageAttachments?.length && imageAttachments?.length === attachments?.length;
+ const onlyVideos = videoAttachments?.length && videoAttachments?.length === attachments?.length;
+ const onlyFiles = fileAttachments?.length && fileAttachments?.length === attachments?.length;
+ const onlyAudio = audioAttachments?.length === attachments?.length;
+ const onlyVoiceRecordings =
+ voiceRecordingAttachments?.length &&
+ voiceRecordingAttachments?.length === attachments?.length;
+
+ if (pollName) {
+ return pollName;
+ }
- const isLastAttachmentFile = lastAttachment.type === FileTypes.File;
+ if (message?.shared_location) {
+ if (
+ message?.shared_location?.end_at &&
+ new Date(message?.shared_location?.end_at) > new Date()
+ ) {
+ return 'Live Location';
+ }
+ return 'Location';
+ }
- const isLastAttachmentAudio = lastAttachment.type === FileTypes.Audio;
+ if (message?.text) {
+ return message?.text;
+ }
- const isLastAttachmentVoiceRecording = lastAttachment.type === FileTypes.VoiceRecording;
+ if (imageAttachments?.length && videoAttachments?.length) {
+ return `${imageAttachments?.length + videoAttachments.length} Media`;
+ }
- const isLastAttachmentVideo = lastAttachment.type === FileTypes.Video;
+ if (onlyImages) {
+ if (imageAttachments?.length === 1) {
+ return 'Photo';
+ } else {
+ return `${imageAttachments?.length} Photos`;
+ }
+ }
- const isLastAttachmentGiphy =
- lastAttachment?.type === FileTypes.Giphy || lastAttachment?.type === FileTypes.Imgur;
+ if (onlyVideos) {
+ if (videoAttachments?.length === 1) {
+ return 'Video';
+ } else {
+ return `${videoAttachments?.length} Videos`;
+ }
+ }
- const isLastAttachmentImageOrGiphy =
- lastAttachment?.type === FileTypes.Image &&
- !lastAttachment?.title_link &&
- !lastAttachment?.og_scrape_url;
+ if (onlyAudio) {
+ if (audioAttachments?.length === 1) {
+ return 'Audio';
+ } else {
+ return `${audioAttachments?.length} Audios`;
+ }
+ }
- const isLastAttachmentImage = lastAttachment?.image_url || lastAttachment?.thumb_url;
+ if (onlyVoiceRecordings) {
+ if (voiceRecordingAttachments?.length === 1) {
+ return `Voice message (${dayjs.duration(voiceRecordingAttachments?.[0]?.duration ?? 0, 'seconds').format('m:ss')})`;
+ } else {
+ return `${voiceRecordingAttachments?.length} Voice messages`;
+ }
+ }
- if (isLastAttachmentFile) {
- messageType = FileTypes.File;
- } else if (isLastAttachmentVideo) {
- messageType = FileTypes.Video;
- } else if (isLastAttachmentAudio) {
- messageType = FileTypes.Audio;
- } else if (isLastAttachmentVoiceRecording) {
- messageType = FileTypes.VoiceRecording;
- } else if (isLastAttachmentImageOrGiphy) {
- if (isLastAttachmentImage) {
- messageType = FileTypes.Image;
- } else {
- messageType = undefined;
+ if (onlyFiles && fileAttachments?.length === 1) {
+ return fileAttachments?.[0]?.title;
}
- } else if (isLastAttachmentGiphy) {
- messageType = FileTypes.Giphy;
- } else {
- messageType = 'other';
+
+ return `${attachments?.length} Files`;
+ }, [message?.attachments, message?.shared_location, message?.text, pollName]);
+
+ if (!subtitle) {
+ return null;
}
- return messageType;
-};
+ return (
+
+ {subtitle}
+
+ );
+});
-const ReplyWithContext = (props: ReplyPropsWithContext) => {
- const { client } = useChatContext();
+const SubtitleIcon = React.memo((props: { message?: LocalMessage | null }) => {
+ const { message } = props;
const {
- attachmentSize = 40,
- FileAttachmentIcon,
- MessageAvatar,
- quotedMessage,
- styles: stylesProp = {},
- t,
- } = props;
+ theme: {
+ reply: { pollIcon, locationIcon, linkIcon, audioIcon, fileIcon, videoIcon, photoIcon },
+ },
+ } = useTheme();
+ if (!message) {
+ return null;
+ }
+
+ const attachments = message?.attachments;
+ const audioAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.Audio);
+ const imageAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.Image);
+ const videoAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.Video);
+ const voiceRecordingAttachments = attachments?.filter(
+ (attachment) => attachment.type === FileTypes.VoiceRecording,
+ );
+ const fileAttachments = attachments?.filter((attachment) => attachment.type === FileTypes.File);
+ const onlyAudio = audioAttachments?.length && audioAttachments?.length === attachments?.length;
+ const onlyVideos = videoAttachments?.length && videoAttachments?.length === attachments?.length;
+ const onlyVoiceRecordings =
+ voiceRecordingAttachments?.length && voiceRecordingAttachments?.length === attachments?.length;
+ const hasLink = attachments?.some(
+ (attachment) => attachment.type === FileTypes.Image && attachment.og_scrape_url,
+ );
+
+ if (message.poll_id) {
+ return (
+
+ );
+ }
+
+ if (message.shared_location) {
+ return (
+
+ );
+ }
+
+ if (hasLink) {
+ return (
+
+ );
+ }
+
+ if (onlyAudio || onlyVoiceRecordings) {
+ return (
+
+ );
+ }
+
+ if (fileAttachments?.length) {
+ return (
+
+ );
+ }
+
+ if (onlyVideos) {
+ return (
+
+ );
+ }
+
+ if (imageAttachments?.length) {
+ return (
+
+ );
+ }
- const { resizableCDNHosts } = useChatConfigContext();
+ return null;
+});
- const [error, setError] = useState(false);
+export type ReplyPropsWithContext = Pick &
+ Pick & {
+ isMyMessage: boolean;
+ onDismiss: () => void;
+ mode: 'reply' | 'edit';
+ // This is temporary for the MessageContent Component to style the Reply component
+ style?: ViewStyle;
+ };
+export const ReplyWithContext = (props: ReplyPropsWithContext) => {
+ const { isMyMessage, message: messageFromContext, mode, onDismiss, quotedMessage, style } = props;
const {
theme: {
- colors: { blue_alice, border, grey, transparent, white },
- messageSimple: {
- content: { deletedText },
- },
+ colors: { grey_whisper },
reply: {
+ wrapper,
container,
- fileAttachmentContainer,
- imageAttachment,
- markdownStyles,
- messageContainer,
- secondaryText,
- textContainer,
- videoThumbnail: {
- container: videoThumbnailContainerStyle,
- image: videoThumbnailImageStyle,
- },
+ leftContainer,
+ rightContainer,
+ title: titleStyle,
+ subtitleContainer,
+ dismissWrapper,
},
},
} = useTheme();
- const poll = client.polls.fromState(quotedMessage?.poll_id ?? '');
- const { name: pollName }: ReplySelectorReturnType = useStateStore(poll?.state, selector) ?? {};
-
- const messageText = quotedMessage ? quotedMessage.text : '';
-
- const emojiOnlyText = useMemo(() => {
- if (!messageText) {
- return false;
- }
- return hasOnlyEmojis(messageText);
- }, [messageText]);
+ const title = useMemo(
+ () =>
+ mode === 'edit'
+ ? 'Edit Message'
+ : isMyMessage
+ ? 'You'
+ : `Reply to ${quotedMessage?.user?.name}`,
+ [mode, isMyMessage, quotedMessage?.user?.name],
+ );
if (!quotedMessage) {
return null;
}
- const lastAttachment = quotedMessage.attachments?.slice(-1)[0] as Attachment;
- const messageType = lastAttachment && getMessageType(lastAttachment);
-
- const trimmedLastAttachmentTitle = getTrimmedAttachmentTitle(lastAttachment?.title);
-
- const hasImage =
- !error &&
- lastAttachment &&
- messageType !== FileTypes.File &&
- messageType !== FileTypes.Video &&
- messageType !== FileTypes.Audio &&
- messageType !== FileTypes.VoiceRecording &&
- (lastAttachment.image_url || lastAttachment.thumb_url || lastAttachment.og_scrape_url);
-
- const onlyEmojis = !lastAttachment && emojiOnlyText;
-
return (
-
-
+
- {!error && lastAttachment ? (
- messageType === FileTypes.File ||
- messageType === FileTypes.Audio ||
- messageType === FileTypes.VoiceRecording ? (
-
-
-
- ) : hasImage ? (
- setError(true)}
- source={{
- uri: getResizedImageUrl({
- height:
- (stylesProp.imageAttachment?.height as number) ||
- (imageAttachment?.height as number) ||
- styles.imageAttachment.height,
- resizableCDNHosts,
- url: (lastAttachment.image_url ||
- lastAttachment.thumb_url ||
- lastAttachment.og_scrape_url) as string,
- width:
- (stylesProp.imageAttachment?.width as number) ||
- (imageAttachment?.width as number) ||
- styles.imageAttachment.width,
- }),
- }}
- style={[styles.imageAttachment, imageAttachment, stylesProp.imageAttachment]}
- />
- ) : null
- ) : null}
- {messageType === FileTypes.Video && !lastAttachment.og_scrape_url ? (
-
- ) : null}
-
- 170
- ? `${quotedMessage.text.slice(0, 170)}...`
- : quotedMessage.text
- : messageType === FileTypes.Image
- ? t('Photo')
- : messageType === FileTypes.Video
- ? t('Video')
- : messageType === FileTypes.File ||
- messageType === FileTypes.Audio ||
- messageType === FileTypes.VoiceRecording
- ? trimmedLastAttachmentTitle || ''
- : '',
- }}
- onlyEmojis={onlyEmojis}
- styles={{
- textContainer: [
- {
- marginRight:
- hasImage || messageType === FileTypes.Video
- ? Number(
- stylesProp.imageAttachment?.height ||
- imageAttachment.height ||
- styles.imageAttachment.height,
- ) +
- Number(
- stylesProp.imageAttachment?.marginLeft ||
- imageAttachment.marginLeft ||
- styles.imageAttachment.marginLeft,
- )
- : messageType === FileTypes.File ||
- messageType === FileTypes.Audio ||
- messageType === FileTypes.VoiceRecording
- ? attachmentSize +
- Number(
- stylesProp.fileAttachmentContainer?.paddingLeft ||
- fileAttachmentContainer.paddingLeft ||
- styles.fileAttachmentContainer.paddingLeft,
- )
- : undefined,
- },
- styles.textContainer,
- textContainer,
- stylesProp.textContainer,
- ],
- }}
- />
- {messageType === FileTypes.Audio || messageType === FileTypes.VoiceRecording ? (
-
- {lastAttachment.duration
- ? dayjs.duration(lastAttachment.duration, 'second').format('mm:ss')
- : ''}
-
- ) : null}
+
+
+ {title}
+
+
+
+
+
+
+
+
+ {!messageFromContext?.quoted_message ? (
+
+
+
+ ) : null}
);
};
const areEqual = (prevProps: ReplyPropsWithContext, nextProps: ReplyPropsWithContext) => {
- const { quotedMessage: prevQuotedMessage } = prevProps;
- const { quotedMessage: nextQuotedMessage } = nextProps;
+ const {
+ isMyMessage: prevIsMyMessage,
+ mode: prevMode,
+ quotedMessage: prevQuotedMessage,
+ } = prevProps;
+ const {
+ isMyMessage: nextIsMyMessage,
+ mode: nextMode,
+ quotedMessage: nextQuotedMessage,
+ } = nextProps;
- const quotedMessageEqual =
- !!prevQuotedMessage &&
- !!nextQuotedMessage &&
- checkQuotedMessageEquality(prevQuotedMessage, nextQuotedMessage);
+ const isMyMessageEqual = prevIsMyMessage === nextIsMyMessage;
- const quotedMessageAttachmentsEqual =
- prevQuotedMessage?.attachments?.length === nextQuotedMessage?.attachments?.length;
+ if (!isMyMessageEqual) {
+ return false;
+ }
- if (!quotedMessageAttachmentsEqual) {
+ const modeEqual = prevMode === nextMode;
+ if (!modeEqual) {
return false;
}
- if (!quotedMessageEqual) {
+ const messageEqual =
+ prevQuotedMessage &&
+ nextQuotedMessage &&
+ checkQuotedMessageEquality(prevQuotedMessage, nextQuotedMessage);
+ if (!messageEqual) {
return false;
}
return true;
};
-const MemoizedReply = React.memo(ReplyWithContext, areEqual) as typeof ReplyWithContext;
+export const MemoizedReply = React.memo(ReplyWithContext, areEqual) as typeof ReplyWithContext;
export type ReplyProps = Partial;
-/**
- * UI Component for reply
- */
export const Reply = (props: ReplyProps) => {
- const { message } = useMessageContext();
-
- const { FileAttachmentIcon = FileIconDefault, MessageAvatar = MessageAvatarDefault } =
- useMessagesContext();
+ const { message: messageFromContext } = useMessageContext();
+ const { client } = useChatContext();
const messageComposer = useMessageComposer();
- const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateStoreSelector);
+ const { quotedMessage: quotedMessageFromComposer } = useStateStore(
+ messageComposer.state,
+ messageComposerStateStoreSelector,
+ );
+
+ const onDismiss = useCallback(() => {
+ messageComposer.setQuotedMessage(null);
+ }, [messageComposer]);
+
+ const quotedMessage = messageFromContext
+ ? (messageFromContext.quoted_message as MessagesContextValue['quotedMessage'])
+ : quotedMessageFromComposer;
+
+ const isMyMessage = client.user?.id === quotedMessage?.user?.id;
- const { t } = useTranslationContext();
+ const mode = messageComposer.editedMessage ? 'edit' : 'reply';
return (
);
};
-Reply.displayName = 'Reply{reply}';
+const styles = StyleSheet.create({
+ attachmentContainer: {
+ alignItems: 'center',
+ flex: 1,
+ justifyContent: 'center',
+ },
+ container: {
+ borderRadius: 12,
+ flexDirection: 'row',
+ padding: 8,
+ },
+ contentWrapper: {
+ backgroundColor: 'white',
+ borderColor: '#E2E6EA',
+ borderRadius: 8,
+ borderWidth: 1,
+ height: 40,
+ overflow: 'hidden',
+ width: 40,
+ },
+ dismissWrapper: {
+ position: 'absolute',
+ right: 0,
+ top: 0,
+ },
+ iconStyle: {},
+ imageAttachment: {},
+ leftContainer: {
+ borderLeftColor: '#B8BEC4',
+ borderLeftWidth: 2,
+ flex: 1,
+ justifyContent: 'center',
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ },
+ playIconContainer: {
+ alignItems: 'center',
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ borderRadius: 10,
+ height: 20,
+ justifyContent: 'center',
+ width: 20,
+ },
+ rightContainer: {},
+ subtitle: {
+ color: '#384047',
+ flexShrink: 1,
+ fontSize: 12,
+ includeFontPadding: false,
+ lineHeight: 16,
+ },
+ subtitleContainer: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ gap: 4,
+ paddingTop: 4,
+ },
+ title: {
+ color: '#384047',
+ fontSize: 12,
+ fontWeight: 'bold',
+ includeFontPadding: false,
+ lineHeight: 16,
+ },
+ wrapper: {
+ padding: 4,
+ },
+});
diff --git a/package/src/components/Reply/__tests__/Reply.test.tsx b/package/src/components/Reply/__tests__/Reply.test.tsx
index 7a20c757d7..ed31d5e161 100644
--- a/package/src/components/Reply/__tests__/Reply.test.tsx
+++ b/package/src/components/Reply/__tests__/Reply.test.tsx
@@ -1,5 +1,7 @@
import React from 'react';
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
+
import { render, waitFor } from '@testing-library/react-native';
import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider';
@@ -24,13 +26,15 @@ describe('', () => {
await channel.watch();
const TestComponent = () => (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
try {
diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap
index a917135ec3..36267f673e 100644
--- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap
+++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap
@@ -21,7 +21,6 @@ exports[`Thread should match thread snapshot 1`] = `
style={
[
{
- "alignItems": "center",
"flex": 1,
"width": "100%",
},
@@ -39,7 +38,7 @@ exports[`Thread should match thread snapshot 1`] = `
contentContainerStyle={
[
{
- "paddingBottom": 4,
+ "paddingTop": 0,
},
undefined,
{},
@@ -262,322 +261,329 @@ exports[`Thread should match thread snapshot 1`] = `
}
testID="message-wrapper"
>
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
-
-
- Message6
-
-
+
+
+ Message6
+
+
+
+
+
+
+ 2:50 PM
+
+
+ ⦁
+
+
+ Edited
+
+
-
-
- 2:50 PM
-
-
- ⦁
-
-
- Edited
-
-
+
@@ -632,322 +638,329 @@ exports[`Thread should match thread snapshot 1`] = `
}
testID="message-wrapper"
>
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
-
-
- Message5
-
-
+
+
+ Message5
+
+
+
+
+
+
+ 2:50 PM
+
+
+ ⦁
+
+
+ Edited
+
+
-
-
- 2:50 PM
-
-
- ⦁
-
-
- Edited
-
-
+
@@ -1040,322 +1053,329 @@ exports[`Thread should match thread snapshot 1`] = `
}
testID="message-wrapper"
>
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
-
-
- Message4
-
-
+
+
+ Message4
+
+
+
+
+
+
+ 2:50 PM
+
+
+ ⦁
+
+
+ Edited
+
+
-
-
- 2:50 PM
-
-
- ⦁
-
-
- Edited
-
-
+
@@ -1411,325 +1431,332 @@ exports[`Thread should match thread snapshot 1`] = `
}
testID="message-wrapper"
>
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
-
-
-
- Message3
-
-
-
-
-
-
-
-
-
-
-
- 2:50 PM
-
-
- ⦁
-
-
- Edited
-
+ onLayout={[Function]}
+ style={{}}
+ >
+
+
+
+
+
+ Message3
+
+
+
+
+
+
+
+
+
+
+
+ 2:50 PM
+
+
+ ⦁
+
+
+ Edited
+
+
+
+
@@ -1838,24 +1865,32 @@ exports[`Thread should match thread snapshot 1`] = `
-
-
-
-
-
-
-
-
+ }
+ strokeLinecap={1}
+ strokeLinejoin={1}
+ strokeWidth={1.5}
+ />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
-
-
-
+ accessible={true}
+ collapsable={false}
+ focusable={true}
+ onBlur={[Function]}
+ onClick={[Function]}
+ onFocus={[Function]}
+ onResponderGrant={[Function]}
+ onResponderMove={[Function]}
+ onResponderRelease={[Function]}
+ onResponderTerminate={[Function]}
+ onResponderTerminationRequest={[Function]}
+ onStartShouldSetResponder={[Function]}
+ style={
+ [
+ {
+ "alignItems": "center",
+ "justifyContent": "center",
+ },
+ {
+ "borderRadius": 16,
+ "height": 32,
+ "width": 32,
+ },
+ {
+ "backgroundColor": "#FFFFFF",
+ "borderColor": "#E2E6EA",
+ "borderWidth": 0,
+ },
+ undefined,
+ ]
+ }
+ >
+
+
+
+
+
+
+
+
+
@@ -2410,6 +2406,9 @@ exports[`Thread should match thread snapshot 1`] = `
) => {
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
- const { children, height = windowHeight / 2, onClose, visible } = props;
+ const { children, height = windowHeight / 2, onClose, visible, lazy = false } = props;
+
const {
theme: {
bottomSheetModal: { container, contentContainer, handle, overlay: overlayTheme, wrapper },
@@ -50,102 +59,229 @@ export const BottomSheetModal = (props: PropsWithChildren
},
} = useTheme();
- const translateY = useMemo(() => new Animated.Value(height), [height]);
+ const translateY = useSharedValue(height);
+ const keyboardOffset = useSharedValue(0);
- const openAnimation = useMemo(
- () =>
- Animated.timing(translateY, {
- duration: 200,
- toValue: 0,
- useNativeDriver: true,
- }),
- [translateY],
- );
+ const isOpen = useSharedValue(false);
+ const isOpening = useSharedValue(false);
+
+ const panStartY = useSharedValue(0);
+
+ const [renderContent, setRenderContent] = useState(!lazy);
+
+ const showContent = useStableCallback(() => {
+ if (lazy) {
+ setRenderContent(true);
+ }
+ });
+
+ const hideContent = useStableCallback(() => {
+ if (lazy) {
+ setRenderContent(false);
+ }
+ });
+
+ const close = useStableCallback(() => {
+ // hide content immediately
+ hideContent();
+
+ isOpen.value = false;
+ isOpening.value = false;
+
+ cancelAnimation(translateY);
- const closeAnimation = Animated.timing(translateY, {
- duration: 50,
- toValue: height,
- useNativeDriver: true,
+ translateY.value = withTiming(
+ height,
+ { duration: 180, easing: Easing.out(Easing.cubic) },
+ (finished) => {
+ if (finished) runOnJS(onClose)();
+ },
+ );
});
- const handleDismiss = () => {
- closeAnimation.start(() => onClose());
- };
+ // modal opening layout effect - we make sure to only show the content
+ // after the animation has finished if `lazy` has been set to true
+ useLayoutEffect(() => {
+ if (!visible) return;
+
+ isOpen.value = true;
+ isOpening.value = true;
+
+ cancelAnimation(translateY);
+
+ // start from closed
+ translateY.value = height;
+ // Snapshot current keyboard offset as the open target.
+ // If keyboard changes during opening, we’ll adjust after.
+ const initialTarget = keyboardOffset.value;
+
+ translateY.value = withTiming(
+ initialTarget,
+ { duration: 220, easing: Easing.out(Easing.cubic) },
+ (finished) => {
+ if (!finished) return;
+
+ // opening the modal has now truly finished
+ isOpening.value = false;
+
+ // reveal the content if we want to load it lazily
+ runOnJS(showContent)();
+
+ // if keyboard offset changed while we were opening, we do a
+ // follow-up adjustment (we do not gate the content however)
+ const latestTarget = keyboardOffset.value;
+ if (latestTarget !== initialTarget && isOpen.value) {
+ cancelAnimation(translateY);
+ translateY.value = withTiming(latestTarget, {
+ duration: 200,
+ easing: Easing.inOut(Easing.ease),
+ });
+ }
+ },
+ );
+ }, [visible, height, hideContent, isOpen, isOpening, keyboardOffset, showContent, translateY]);
+
+ // if `visible` gets hard changed, we force a cleanup
useEffect(() => {
- if (visible) {
- openAnimation.start();
- }
- }, [visible, openAnimation]);
+ if (visible) return;
+
+ isOpen.value = false;
+ isOpening.value = false;
+ keyboardOffset.value = 0;
+
+ cancelAnimation(translateY);
+ translateY.value = height;
+ }, [visible, height, isOpen, isOpening, keyboardOffset, translateY]);
+
+ const keyboardDidShowRN = useStableCallback((event: KeyboardEvent) => {
+ const offset = -event.endCoordinates.height;
+ keyboardOffset.value = offset;
+
+ // We just record the offset, but we avoid cancelling the animation
+ // if it's in the process of opening. The same logic applies to all
+ // other keyboard related callbacks in this specific conditional.
+ if (!isOpen.value || isOpening.value) return;
+
+ cancelAnimation(translateY);
+ translateY.value = withTiming(offset, { duration: 250, easing: Easing.inOut(Easing.ease) });
+ });
+
+ const keyboardDidHide = useStableCallback(() => {
+ keyboardOffset.value = 0;
+
+ if (!isOpen.value || isOpening.value) return;
+
+ cancelAnimation(translateY);
+ translateY.value = withTiming(0, { duration: 250, easing: Easing.inOut(Easing.ease) });
+ });
useEffect(() => {
- const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', keyboardDidShow);
- const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', keyboardDidHide);
-
- return () => {
- keyboardDidShowListener.remove();
- keyboardDidHideListener.remove();
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- const keyboardDidShow = (event: KeyboardEvent) => {
- Animated.timing(translateY, {
- duration: 250,
- toValue: -event.endCoordinates.height,
- useNativeDriver: true,
- }).start();
- };
-
- const keyboardDidHide = () => {
- Animated.timing(translateY, {
- duration: 250,
- toValue: 0,
- useNativeDriver: true,
- }).start();
- };
-
- const handleUpdate = (event: GestureUpdateEvent) => {
- const translationY = Math.max(event.translationY, 0);
- translateY.setValue(translationY);
- };
-
- const gesture = Gesture.Pan()
- .onUpdate((event) => {
- runOnJS(handleUpdate)(event);
- })
- .onEnd((event) => {
- if (event.velocityY > 500 || event.translationY > height / 2) {
- runOnJS(handleDismiss)();
- } else {
- runOnJS(openAnimation.start)();
- }
- });
+ if (!visible) return;
+
+ const listeners: EventSubscription[] = [];
+
+ if (KeyboardControllerPackage?.KeyboardEvents) {
+ const keyboardDidShowKC = (event: KeyboardEventData) => {
+ const offset = -event.height;
+ keyboardOffset.value = offset;
+
+ if (!isOpen.value || isOpening.value) return;
+
+ cancelAnimation(translateY);
+ translateY.value = withTiming(offset, { duration: 250, easing: Easing.inOut(Easing.ease) });
+ };
+
+ listeners.push(
+ KeyboardControllerPackage.KeyboardEvents.addListener('keyboardDidShow', keyboardDidShowKC),
+ KeyboardControllerPackage.KeyboardEvents.addListener('keyboardDidHide', keyboardDidHide),
+ );
+ } else {
+ listeners.push(Keyboard.addListener('keyboardDidShow', keyboardDidShowRN));
+ listeners.push(Keyboard.addListener('keyboardDidHide', keyboardDidHide));
+ }
+
+ return () => listeners.forEach((l) => l.remove());
+ }, [visible, keyboardDidHide, keyboardDidShowRN, keyboardOffset, isOpen, isOpening, translateY]);
+
+ const sheetAnimatedStyle = useAnimatedStyle(() => ({
+ transform: [{ translateY: translateY.value }],
+ }));
+
+ const gesture = useMemo(
+ () =>
+ Gesture.Pan()
+ // disable pan until content is rendered (prevents canceling the opening timing).
+ .enabled(renderContent)
+ .onBegin(() => {
+ cancelAnimation(translateY);
+ panStartY.value = translateY.value;
+ })
+ .onUpdate((event) => {
+ const minY = keyboardOffset.value;
+ const next = panStartY.value + event.translationY;
+ translateY.value = Math.max(next, minY);
+ })
+ .onEnd((event) => {
+ const openY = keyboardOffset.value;
+ const draggedDown = Math.max(translateY.value - openY, 0);
+ const shouldClose = event.velocityY > 500 || draggedDown > height / 2;
+
+ cancelAnimation(translateY);
+
+ if (shouldClose) {
+ isOpen.value = false;
+ isOpening.value = false;
+
+ translateY.value = withTiming(
+ height,
+ { duration: 140, easing: Easing.out(Easing.cubic) },
+ (finished) => {
+ if (finished) runOnJS(onClose)();
+ },
+ );
+ } else {
+ isOpen.value = true;
+ translateY.value = withTiming(openY, {
+ duration: 200,
+ easing: Easing.inOut(Easing.ease),
+ });
+ }
+ }),
+ [height, isOpen, isOpening, keyboardOffset, onClose, panStartY, renderContent, translateY],
+ );
return (
-
+
-
+
+
- {children}
+
+ {renderContent ? (
+
+ {children}
+
+ ) : null}
+
@@ -160,10 +296,6 @@ const styles = StyleSheet.create({
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
},
- content: {
- flex: 1,
- padding: 16,
- },
contentContainer: {
flex: 1,
marginTop: 8,
@@ -178,6 +310,9 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'flex-end',
},
+ sheetContentContainer: {
+ flex: 1,
+ },
wrapper: {
alignItems: 'center',
flex: 1,
diff --git a/package/src/components/index.ts b/package/src/components/index.ts
index 344c7e144f..152a0849e7 100644
--- a/package/src/components/index.ts
+++ b/package/src/components/index.ts
@@ -118,14 +118,12 @@ export * from './Message/MessageSimple/utils/renderText';
export * from './Message/utils/messageActions';
export * from '../utils/removeReservedFields';
-export * from './MessageInput/AttachButton';
-export * from './MessageInput/AttachmentUploadPreviewList';
-export * from './MessageInput/CommandsButton';
-export * from './MessageInput/CooldownTimer';
-export * from './MessageInput/InputButtons';
+export * from './MessageInput/components/InputButtons/AttachButton';
+export * from './MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList';
+export * from './MessageInput/components/OutputButtons/CooldownTimer';
+export * from './MessageInput/components/InputButtons';
export * from './MessageInput/MessageInput';
-export * from './MessageInput/MoreOptionsButton';
-export * from './MessageInput/SendButton';
+export * from './MessageInput/components/OutputButtons/SendButton';
export * from './MessageInput/SendMessageDisallowedIndicator';
export * from './MessageInput/ShowThreadMessageInChannelButton';
export * from './MessageInput/StopMessageStreamingButton';
@@ -142,7 +140,7 @@ export * from './MessageInput/components/AttachmentPreview/AttachmentUploadProgr
export * from './MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview';
export * from './MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview';
export * from './MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview';
-export * from './MessageInput/hooks/useAudioController';
+export * from './MessageInput/hooks/useAudioRecorder';
export * from './MessageList/DateHeader';
export * from './MessageList/hooks/useMessageList';
@@ -158,7 +156,6 @@ export * from './MessageList/NetworkDownIndicator';
export * from './MessageList/ScrollToBottomButton';
export * from './MessageList/TypingIndicator';
export * from './MessageList/TypingIndicatorContainer';
-export * from './MessageList/utils/getDateSeparators';
export * from './MessageList/utils/getGroupStyles';
export * from './MessageList/utils/getLastReceivedMessage';
export * from './Message/hooks/useMessageDeliveryData';
diff --git a/package/src/components/ui/IconButton.tsx b/package/src/components/ui/IconButton.tsx
new file mode 100644
index 0000000000..be00bc919f
--- /dev/null
+++ b/package/src/components/ui/IconButton.tsx
@@ -0,0 +1,109 @@
+import React from 'react';
+import { Pressable, PressableProps, StyleProp, StyleSheet, View, ViewStyle } from 'react-native';
+
+import { useTheme } from '../../contexts/themeContext/ThemeContext';
+import { IconProps } from '../../icons/utils/base';
+
+export type IconButtonProps = PressableProps & {
+ Icon: React.FC | React.ReactNode;
+ iconColor?: string;
+ onPress?: () => void;
+ size?: 'sm' | 'md' | 'lg';
+ status?: 'disabled' | 'pressed' | 'selected' | 'enabled';
+ type?: 'primary' | 'secondary' | 'destructive';
+ category?: 'ghost' | 'filled' | 'outline';
+};
+
+const sizes = {
+ lg: { borderRadius: 24, height: 48, width: 48 },
+ md: { borderRadius: 20, height: 40, width: 40 },
+ sm: {
+ borderRadius: 16,
+ height: 32,
+ width: 32,
+ },
+};
+
+const getBackgroundColor = ({
+ type,
+ status,
+}: {
+ type: IconButtonProps['type'];
+ status: IconButtonProps['status'];
+}) => {
+ if (type === 'primary') {
+ if (status === 'disabled') {
+ return '#E2E6EA';
+ } else {
+ return '#005FFF';
+ }
+ } else if (type === 'secondary') {
+ return '#FFFFFF';
+ }
+ return {
+ destructive: '#D92F26',
+ primary: '#005FFF',
+ secondary: '#FFFFFF',
+ }[type ?? 'primary'];
+};
+
+export const IconButton = (props: IconButtonProps) => {
+ const {
+ category = 'filled',
+ status = 'enabled',
+ Icon,
+ iconColor,
+ onPress,
+ size = 'md',
+ style,
+ type = 'primary',
+ ...rest
+ } = props;
+ const {
+ theme: {
+ colors: { selected: selectedColor },
+ },
+ } = useTheme();
+ return (
+ [
+ styles.container,
+ sizes[size],
+ {
+ backgroundColor:
+ status === 'selected'
+ ? selectedColor
+ : pressed
+ ? '#F5F6F7'
+ : getBackgroundColor({ status, type }),
+ borderColor: '#E2E6EA',
+ borderWidth: category === 'outline' || category === 'filled' ? 1 : 0,
+ },
+ style as StyleProp,
+ ]}
+ {...rest}
+ >
+ {typeof Icon === 'function' ? (
+
+ ) : (
+ {Icon}
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
diff --git a/package/src/components/ui/OnlineIndicator.tsx b/package/src/components/ui/OnlineIndicator.tsx
new file mode 100644
index 0000000000..2dda8b0c3f
--- /dev/null
+++ b/package/src/components/ui/OnlineIndicator.tsx
@@ -0,0 +1,57 @@
+import React, { useMemo } from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import { useTheme } from '../../contexts/themeContext/ThemeContext';
+
+export type OnlineIndicatorProps = {
+ online: boolean;
+ size: 'lg' | 'sm' | 'md';
+};
+
+const sizes = {
+ lg: {
+ borderWidth: 2,
+ height: 14,
+ width: 14,
+ },
+ md: {
+ borderWidth: 2,
+ height: 12,
+ width: 12,
+ },
+ sm: {
+ borderWidth: 1,
+ height: 8,
+ width: 8,
+ },
+};
+
+export const OnlineIndicator = ({ online, size = 'md' }: OnlineIndicatorProps) => {
+ const styles = useStyles();
+ return ;
+};
+
+const useStyles = () => {
+ const {
+ theme: {
+ colors: { accent, presence },
+ radius,
+ },
+ } = useTheme();
+ return useMemo(
+ () =>
+ StyleSheet.create({
+ indicator: {
+ borderColor: presence.border,
+ borderRadius: radius.full,
+ },
+ online: {
+ backgroundColor: accent.success,
+ },
+ offline: {
+ backgroundColor: accent.neutral,
+ },
+ }),
+ [accent, presence, radius],
+ );
+};
diff --git a/package/src/components/ui/VideoPlayIndicator.tsx b/package/src/components/ui/VideoPlayIndicator.tsx
new file mode 100644
index 0000000000..3bffc0dbaf
--- /dev/null
+++ b/package/src/components/ui/VideoPlayIndicator.tsx
@@ -0,0 +1,58 @@
+import React, { useMemo } from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import { useTheme } from '../../contexts/themeContext/ThemeContext';
+import { NewPlayIcon } from '../../icons/NewPlayIcon';
+import { palette } from '../../theme/primitives/palette';
+
+const sizes = {
+ lg: {
+ height: 48,
+ width: 48,
+ },
+ md: {
+ height: 40,
+ width: 40,
+ },
+ sm: {
+ height: 20,
+ width: 20,
+ },
+};
+
+const iconSizes = {
+ lg: 20,
+ md: 16,
+ sm: 10,
+};
+
+export type VideoPlayIndicatorProps = {
+ size: 'sm' | 'md' | 'lg';
+};
+
+export const VideoPlayIndicator = (props: VideoPlayIndicatorProps) => {
+ const { size = 'md' } = props;
+ const styles = useStyles();
+
+ return (
+
+
+
+ );
+};
+
+const useStyles = () => {
+ const {
+ theme: { radius },
+ } = useTheme();
+ return useMemo(() => {
+ return StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ backgroundColor: palette.black,
+ borderRadius: radius.full,
+ justifyContent: 'center',
+ },
+ });
+ }, [radius]);
+};
diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx
index a41c90e312..36e3c63ed1 100644
--- a/package/src/contexts/channelContext/ChannelContext.tsx
+++ b/package/src/contexts/channelContext/ChannelContext.tsx
@@ -10,7 +10,6 @@ import {
ChannelUnreadStateStore,
ChannelUnreadStateStoreType,
} from '../../state-store/channel-unread-state';
-import { ChannelUnreadState } from '../../types/types';
import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue';
import { isTestEnvironment } from '../utils/isTestEnvironment';
@@ -135,11 +134,6 @@ export type ChannelContextValue = {
* Its a map of filename and AbortController
*/
uploadAbortControllerRef: React.MutableRefObject