diff --git a/examples/ExpoMessaging/app/channel/[cid]/index.tsx b/examples/ExpoMessaging/app/channel/[cid]/index.tsx index 88b35f33f4..de3f5ca3f7 100644 --- a/examples/ExpoMessaging/app/channel/[cid]/index.tsx +++ b/examples/ExpoMessaging/app/channel/[cid]/index.tsx @@ -1,6 +1,6 @@ import React, { useContext } from 'react'; import { SafeAreaView, View } from 'react-native'; -import { Channel, MessageInput, MessageList } from 'stream-chat-expo'; +import { Channel, MessageInput, MessageFlashList } from 'stream-chat-expo'; import { Stack, useRouter } from 'expo-router'; import { AuthProgressLoader } from '../../../components/AuthProgressLoader'; import { AppContext } from '../../../context/AppContext'; @@ -45,7 +45,7 @@ export default function ChannelScreen() { thread={thread} > - { setThread(thread); router.push(`/channel/${channel.cid}/thread/${thread.cid}`); diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index 1358d04b80..b75a6845e7 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -13,6 +13,7 @@ "@op-engineering/op-sqlite": "^14.0.4", "@react-native-community/netinfo": "11.4.1", "@react-navigation/elements": "^1.3.31", + "@shopify/flash-list": "^2.0.3", "expo": "53.0.20", "expo-audio": "~0.4.8", "expo-clipboard": "~7.1.5", diff --git a/examples/ExpoMessaging/yarn.lock b/examples/ExpoMessaging/yarn.lock index f53ce0cd11..2b7801e5d5 100644 --- a/examples/ExpoMessaging/yarn.lock +++ b/examples/ExpoMessaging/yarn.lock @@ -2371,6 +2371,13 @@ read-yaml-file "^2.1.0" strip-json-comments "^3.1.1" +"@shopify/flash-list@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-2.0.3.tgz#222427d1e09bf5cdd8a219d0a5a80f6f1d20465d" + integrity sha512-jUlHuZFoPdqRCDvOqsb2YkTttRPyV8Tb/EjCx3gE2wjr4UTM+fE0Ltv9bwBg0K7yo/SxRNXaW7xu5utusRb0xA== + dependencies: + tslib "2.8.1" + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" @@ -6458,6 +6465,11 @@ ts-interface-checker@^0.1.9: resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== +tslib@2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tslib@^2.4.0: version "2.5.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.2.tgz#1b6f07185c881557b0ffa84b111a0106989e8338" diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 4b22343f7d..852b4490e4 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { DevSettings, LogBox, Platform, useColorScheme } from 'react-native'; import { createDrawerNavigator } from '@react-navigation/drawer'; import { DarkTheme, DefaultTheme, NavigationContainer } from '@react-navigation/native'; @@ -58,6 +58,7 @@ Geolocation.setRNConfiguration({ import type { LocalMessage, StreamChat, TextComposerMiddleware } from 'stream-chat'; import { Toast } from './src/components/ToastComponent/Toast'; import { useClientNotificationsToastHandler } from './src/hooks/useClientNotificationsToastHandler'; +import AsyncStore from './src/utils/AsyncStore.ts'; init({ data }); @@ -90,6 +91,7 @@ const Stack = createStackNavigator(); const UserSelectorStack = createStackNavigator(); const App = () => { const { chatClient, isConnecting, loginUser, logout, switchUser } = useChatClient(); + const [messageListImplementation, setMessageListImplementation] = useState<'flashlist' | 'flatlist' | null>(null); const colorScheme = useColorScheme(); const streamChatTheme = useStreamChatTheme(); @@ -131,6 +133,14 @@ const App = () => { } } }); + const getMessageListImplementation = async () => { + const storedValue = await AsyncStore.getItem( + '@stream-rn-sampleapp-messagelist-implementation', + { id: 'flashlist' } + ); + setMessageListImplementation(storedValue?.id as ('flashlist' | 'flatlist')); + } + getMessageListImplementation(); return () => { unsubscribeOnNotificationOpen(); unsubscribeForegroundEvent(); @@ -162,6 +172,10 @@ const App = () => { }); }, [chatClient]); + if (!messageListImplementation) { + return; + } + return ( { dark: colorScheme === 'dark', }} > - + {isConnecting && !chatClient ? ( ) : chatClient ? ( diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index bd448d4b78..d2f38c4db0 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -155,7 +155,7 @@ PODS: - nanopb/encode (= 3.30910.0) - nanopb/decode (3.30910.0) - nanopb/encode (3.30910.0) - - op-sqlite (14.0.4): + - op-sqlite (14.1.4): - boost - DoubleConversion - fast_float @@ -3455,7 +3455,7 @@ SPEC CHECKSUMS: hermes-engine: bbc1152da7d2d40f9e59c28acc6576fcf5d28e2a libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - op-sqlite: 17d9566d723ad870c33588ba54a98a5dcac60e7e + op-sqlite: a7e46cfdaebeef219fd0e939332967af9fe6d406 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f @@ -3465,90 +3465,90 @@ SPEC CHECKSUMS: React: e7a4655b09d0e17e54be188cc34c2f3e2087318a React-callinvoker: 62192daaa2f30c3321fc531e4f776f7b09cf892b React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a - React-Core: c400b068fdb6172177f3b3fae00c10d1077244d7 - React-CoreModules: 8e911a5a504b45824374eec240a78de7a6db8ca2 - React-cxxreact: 06a91f55ac5f842219d6ca47e0f77187a5b5f4ac + React-Core: b23cdaaa9d76389d958c06af3c57aa6ad611c542 + React-CoreModules: 8e0f562e5695991e455abbebe1e968af71d52553 + React-cxxreact: 6ccbe0cc2c652b29409b14b23cfb3cd74e084691 React-debug: ab52e07f7148480ea61c5e9b68408d749c6e2b8f - React-defaultsnativemodule: fab7bf1b5ce1f3ed252aa4e949ec48a8df67d624 - React-domnativemodule: 735fa5238cceebceeecc18f9f4321016461178cf - React-Fabric: c75719fc8818049c3cf9071f0619af988b020c9f - React-FabricComponents: 74a381cc0dfaf2ec3ee29f39ef8533a7fd864b83 - React-FabricImage: 9a3ff143b1ac42e077c0b33ec790f3674ace5783 - React-featureflags: e1eca0727563a61c919131d57bbd0019c3bdb0f0 - React-featureflagsnativemodule: 692211fd48283b2ddee3817767021010e2f1788e - React-graphics: 759b02bde621f12426c1dc1ae2498aaa6b441cd7 - React-hermes: b6e33fcd21aa7523dc76e62acd7a547e68c28a5b - React-idlecallbacksnativemodule: 731552efc0815fc9d65a6931da55e722b1b3a397 - React-ImageManager: 2c510a480f2c358f56a82df823c66d5700949c96 - React-jserrorhandler: 411e18cbdcbdf546f8f8707091faeb00703527c1 - React-jsi: 3fde19aaf675c0607a0824c4d6002a4943820fd9 - React-jsiexecutor: 4f898228240cf261a02568e985dfa7e1d7ad1dfb - React-jsinspector: 2f0751e6a4fb840f4ed325384d0795a9e9afaf39 - React-jsinspectorcdp: 71c48944d97f5f20e8e144e419ddf04ffa931e93 - React-jsinspectornetwork: 102f347669b278644cc9bb4ebf2f90422bd5ccef - React-jsinspectortracing: 0f6f2ec7f3faa9dc73d591b24b460141612515eb - React-jsitooling: b557f8e12efdaf16997e43b0d07dbd8a3fce3a5b - React-jsitracing: f9a77561d99c0cd053a8230bab4829b100903949 - React-logger: ea80169d826e0cd112fa4d68f58b2b3b968f1ecb - React-Mapbuffer: 230c34b1cabd1c4815726c711b9df221c3d3fbfb - React-microtasksnativemodule: 29d62f132e4aba34ebb7f2b936dde754eb08971b - react-native-blob-util: cbd6b292d0f558f09dce85e6afe68074cd031f3e - react-native-cameraroll: 00057cc0ec595fdbdf282ecfb931d484b240565f - react-native-document-picker: c5fa18e9fc47b34cfbab3b0a4447d0df918a5621 - react-native-geolocation: eb39c815c9b58ddc3efb552cafdd4b035e4cf682 - react-native-image-picker: e479ec8884df9d99a62c1f53f2307055ad43ea85 - react-native-maps: ee1e65647460c3d41e778071be5eda10e3da6225 - react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac - react-native-safe-area-context: 7fd4c2c8023da8e18eaa3424cb49d52f626debee - react-native-video: 71973843c2c9ac154c54f95a5a408fd8b041790e - React-NativeModulesApple: d061f458c3febdf0ac99b1b0faf23b7305974b25 + React-defaultsnativemodule: 291d2b0a93c399056121f4f0acc7f46d155a38ec + React-domnativemodule: c4968302e857bd422df8eec50a3cd4d078bd4ac0 + React-Fabric: 7e3ba48433b87a416052c4077d5965aff64cb8c9 + React-FabricComponents: 21de255cf52232644d12d3288cced1f0c519b25d + React-FabricImage: 15a0961a0ab34178f1c803aa0a7d28f21322ffc3 + React-featureflags: 4e5dad365d57e3c3656447dfdad790f75878d9f4 + React-featureflagsnativemodule: 5eac59389131c2b87d165dac4094b5e86067fabb + React-graphics: 2f9b3db89f156afd793da99f23782f400f58c1ee + React-hermes: cc8c77acee1406c258622cd8abbee9049f6b5761 + React-idlecallbacksnativemodule: 1d7e1f73b624926d16db956e87c4885ef485664a + React-ImageManager: 8b6066f6638fba7d4a94fbd0b39b477ea8aced58 + React-jserrorhandler: e5a4626d65b0eda9a11c43a9f14d0423d8a7080d + React-jsi: ea5c640ea63c127080f158dac7f4f393d13d415c + React-jsiexecutor: cf7920f82e46fe9a484c15c9f31e67d7179aa826 + React-jsinspector: 094e3cb99952a0024fa977fa04706e68747cba18 + React-jsinspectorcdp: dca545979146e3ecbdc999c0789dab55beecc140 + React-jsinspectornetwork: 0a105fe74b0b1a93f70409d955c8a0556dc17c59 + React-jsinspectortracing: 76088dd78a2de3ea637a860cdf39a6d9c2637d6b + React-jsitooling: a2e1e87382aae2294bc94a6e9682b9bc83da1d36 + React-jsitracing: 45827be59e673f4c54175c150891301138846906 + React-logger: 7cfc7b1ae1f8e5fe5097f9c746137cc3a8fad4ce + React-Mapbuffer: 8f620d1794c6b59a8c3862c3ae820a2e9e6c9bb0 + React-microtasksnativemodule: dcf5321c9a41659a6718df8a5f202af1577c6825 + react-native-blob-util: a511afccff6511544ebf56928e6afdf837b037a7 + react-native-cameraroll: 8c3ba9b6f511cf645778de19d5039b61d922fdfb + react-native-document-picker: b37cf6660ad9087b782faa78a1e67687fac15bfd + react-native-geolocation: b7f68b8c04e36ee669c630dbc48dd42cf93a0a41 + react-native-image-picker: 9bceb747cd6cde22a3416ffdc819d11b5b11f156 + react-native-maps: 9febd31278b35cd21e4fad2cf6fa708993be5dab + react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 + react-native-safe-area-context: 32293dc61d1b92ccf892499ab6f8acfd609f9aef + react-native-video: 4da16bfca01a02aa2095e40683d74f2d6563207c + React-NativeModulesApple: 342e280bb9fc2aa5f61f6c257b309a86b995e12d React-oscompat: 56d6de59f9ae95cd006a1c40be2cde83bc06a4e1 - React-perflogger: 0633844e495d8b34798c9bf0cb32ce315f1d5c9f - React-performancetimeline: 53bdf62ff49a9b0c4bd4d66329fdcf28d77c1c9d + React-perflogger: 4008bd05a8b6c157b06608c0ea0b8bd5d9c5e6c9 + React-performancetimeline: 3ac316a346fe3d48801a746b754dd8f5b5146838 React-RCTActionSheet: 49138012280ec3bbb35193d8d09adb8bc61c982e - React-RCTAnimation: c7ed4a9d5a4e43c9b10f68bb43cd238c4a2e7e89 - React-RCTAppDelegate: ea2ab6f4aef1489f72025b7128d8ab645b40eafb - React-RCTBlob: c052799460b245e1fffe3d1dddea36fa88e998a0 - React-RCTFabric: fad6230640c42fb8cda29b1d0759f7a1fb8cc677 - React-RCTFBReactNativeSpec: ffb22c3ee3d359ae9245ca94af203845da9371ec - React-RCTImage: 59fc2571f4f109a77139924f5babee8f9cd639c9 - React-RCTLinking: a045cb58c08188dce6c6f4621de105114b1b16ce - React-RCTNetwork: fc7115a2f5e15ae0aa05e9a9be726817feefb482 - React-RCTRuntime: c69b86dc60dcc7297318097fc60bd8e40b050f74 - React-RCTSettings: 30d7dd7eae66290467a1e72bf42d927fa78c3884 - React-RCTText: 755d59284e66c7d33bb4f0ccc428fe69110c3e74 - React-RCTVibration: ffe019e588815df226f6f8ccdc65979f8b2bc440 + React-RCTAnimation: ebfe7c62016d4c17b56b2cab3a221908ae46288d + React-RCTAppDelegate: 0108657ba9a19f6a1cd62dcd19c2c0485b3fc251 + React-RCTBlob: 6cc309d1623f3c2679125a04a7425685b7219e6b + React-RCTFabric: 04d1cf11ee3747a699260492e319e92649d7ac88 + React-RCTFBReactNativeSpec: ff3e37e2456afc04211334e86d07bf20488df0ae + React-RCTImage: bb98a59aeed953a48be3f917b9b745b213b340ab + React-RCTLinking: d6e9795d4d75d154c1dd821fd0746cc3e05d6670 + React-RCTNetwork: 5c8a7a2dd26728323189362f149e788548ac72bc + React-RCTRuntime: 52b28e281aba881e1f94ee8b16611823b730d1c5 + React-RCTSettings: b6a02d545ce10dd936b39914b32674db6e865307 + React-RCTText: c7d9232da0e9b5082a99a617483d9164a9cd46e9 + React-RCTVibration: fe636c985c1bf25e4a5b5b4d9315a3b882468a72 React-rendererconsistency: aba18fa58a4d037004f6bed6bb201eb368016c56 - React-renderercss: c7c140782f5f21103b638abfde7b3f11d6a5fd7e - React-rendererdebug: 111519052db9610f1b93baf7350c800621df3d0c + React-renderercss: b490bd53486a6bae1e9809619735d1f2b2cabd7f + React-rendererdebug: 8db25b276b64d5a1dbf05677de0c4ff1039d5184 React-rncore: 22f344c7f9109b68c3872194b0b5081ca1aee655 - React-RuntimeApple: 30d20d804a216eb297ccc9ce1dc9e931738c03b1 - React-RuntimeCore: 6e1facd50e0b7aed1bc36b090015f33133958bb6 + React-RuntimeApple: 19bdabedda0eeb70acb71e68bfc42d61bbcbacd9 + React-RuntimeCore: 11bf03bdbd6e72857481c46d0f4eb9c19b14c754 React-runtimeexecutor: b35de9cb7f5d19c66ea9b067235f95b947697ba5 - React-RuntimeHermes: 222268a5931a23f095565c4d60e2673c04e2178a - React-runtimescheduler: aea93219348ba3069fe6c7685a84fe17d3a4b4ee + React-RuntimeHermes: d8f736d0a2d38233c7ec7bd36040eb9b0a3ccd8c + React-runtimescheduler: 0c95966d030c8ebbebddaab49630cda2889ca073 React-timing: 42e8212c479d1e956d3024be0a07f205a2e34d9d - React-utils: 0ebf25dd4eb1b5497933f4d8923b862d0fe9566f - ReactAppDependencyProvider: 6c9197c1f6643633012ab646d2bfedd1b0d25989 - ReactCodegen: f8ae44cfcb65af88f409f4b094b879591232f12c - ReactCommon: a237545f08f598b8b37dc3a9163c1c4b85817b0b - RNAudioRecorderPlayer: 8a1c6ee5080aa83c3f2ccc75d1a43b2ce82b366d - RNCAsyncStorage: afe7c3711dc256e492aa3a50dcac43eecebd0ae5 - RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8 - RNFBApp: df5caad9f64b6bc87f8a0b110e6bc411fb00a12b - RNFBMessaging: 6586f18ab3411aeb3349088c19fe54283d39e529 - RNGestureHandler: 4d36eb583264375d9f7ece09a2efd918ebc85605 - RNNotifee: 4a6ee5c7deaf00e005050052d73ee6315dff7ec9 - RNReactNativeHapticFeedback: 8bd4a2ba7c3daeb5d2acfceb8b61743f203076d0 - RNReanimated: 408767d090bcbfe3877cfbcc9dc9d29f5e878203 - RNScreens: 5ca475eb30f4362c5808f3ff4a1c7b786bcd878e - RNShare: 083f05646b9534305999cf20452bd87ca0e8b0b0 - RNSVG: fd433fe5da0f7fee8c78f5865f29ab37321dbd7f - RNWorklets: 7d34d4c80edec50bb1eec6bd034e7686db26da8e + React-utils: a185f723baa0c525c361e6c281a846d919623dbe + ReactAppDependencyProvider: 8df342c127fd0c1e30e8b9f71ff814c22414a7c0 + ReactCodegen: 4928682e20747464165effacc170019a18da953c + ReactCommon: ec1cdf708729338070f8c4ad746768a782fd9eb1 + RNAudioRecorderPlayer: 5d5aac7a0e0f159861736ef2b433770342da7197 + RNCAsyncStorage: f30b3a83064e28b0fc46f1fbd3834589ed64c7b9 + RNFastImage: 462a183c4b0b6b26fdfd639e1ed6ba37536c3b87 + RNFBApp: db9c2e6d36fe579ab19b82c0a4a417ff7569db7e + RNFBMessaging: de62448d205095171915d622ed5fb45c2be5e075 + RNGestureHandler: b2fccd493292b4904794460fa80d76a8f29df961 + RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168 + RNReactNativeHapticFeedback: d39b9a5b334ce26f49ca6abe9eea8b3938532aee + RNReanimated: be0bc51a01858c195f6df763ec2334b8bfe6f408 + RNScreens: 6a2d1ff4d263d29d3d3db9f3c19aad2f99fdd162 + RNShare: 9d801eafd9ae835f51bcae6b5c8de9bf3389075b + RNSVG: bc7ccfe884848ac924d2279d9025d41b5f05cb0c + RNWorklets: 18d2a9a10588e4d51f42116f19e650d296ab8dbc SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - stream-chat-react-native: 7a042480d22a8a87aaee6186bf2f1013af017d3a + stream-chat-react-native: f42e234640869e0eafcdd354441414ad1818b9fe Yoga: ce248fb32065c9b00451491b06607f1c25b2f1ed PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 21a0cb00ed..e1ecfaea7c 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -37,6 +37,7 @@ "@react-navigation/drawer": "7.4.1", "@react-navigation/native": "^7.1.10", "@react-navigation/stack": "^7.3.3", + "@shopify/flash-list": "^2.0.3", "emoji-mart": "^5.6.0", "lodash.mergewith": "^4.6.2", "react": "19.1.0", diff --git a/examples/SampleApp/src/components/SecretMenu.tsx b/examples/SampleApp/src/components/SecretMenu.tsx index 0b41680492..95762b85eb 100644 --- a/examples/SampleApp/src/components/SecretMenu.tsx +++ b/examples/SampleApp/src/components/SecretMenu.tsx @@ -35,7 +35,10 @@ export const SlideInView = ({ const animatedStyle = useAnimatedStyle( () => ({ - height: withSpring(visible ? animatedHeight.value : 0, { damping: 10 }), + height: withSpring(visible ? animatedHeight.value : 0, { + damping: 20, + overshootClamping: true, + }), opacity: withTiming(visible ? 1 : 0, { duration: 500 }), }), [visible], @@ -55,6 +58,7 @@ export const SlideInView = ({ const isAndroid = Platform.OS === 'android'; type NotificationConfigItem = { label: string; name: string; id: string }; +type MessageListImplementationConfigItem = { label: string; id: string }; const SecretMenuNotificationConfigItem = ({ notificationConfigItem, @@ -120,6 +124,27 @@ const SecretMenuNotificationConfigItem = ({ ); }; +const SecretMenuMessageListConfigItem = ({ + messageListImplementationConfigItem, + storeMessageListImplementation, + isSelected, +}: { + messageListImplementationConfigItem: MessageListImplementationConfigItem; + storeMessageListImplementation: (item: MessageListImplementationConfigItem) => void; + isSelected: boolean; +}) => ( + storeMessageListImplementation(messageListImplementationConfigItem)} +> + {messageListImplementationConfigItem.label} + +); + +/* +* TODO: Please rewrite this entire component. +*/ + export const SecretMenu = ({ close, visible, @@ -130,6 +155,9 @@ export const SecretMenu = ({ chatClient: StreamChat; }) => { const [selectedProvider, setSelectedProvider] = useState(null); + const [selectedMessageListImplementation, setSelectedMessageListImplementation] = useState< + string | null +>(null); const { theme: { colors: { black, grey }, @@ -144,22 +172,43 @@ export const SecretMenu = ({ [], ); + const messageListImplementationConfigItems = useMemo( + () => [ + { label: 'FlashList', id: 'flashlist' }, + { label: 'FlatList', id: 'flatlist' }, + ], + [], + ); + useEffect(() => { - const getSelectedProvider = async () => { - const provider = await AsyncStore.getItem( + const getSelectedConfig = async () => { + const notificationProvider = await AsyncStore.getItem( '@stream-rn-sampleapp-push-provider', notificationConfigItems[0], ); - setSelectedProvider(provider?.id ?? 'firebase'); + const messageListImplementation = await AsyncStore.getItem( + '@stream-rn-sampleapp-messagelist-implementation', + messageListImplementationConfigItems[0], + ); + setSelectedProvider(notificationProvider?.id ?? 'firebase'); + setSelectedMessageListImplementation(messageListImplementation?.id ?? 'flashlist'); }; - getSelectedProvider(); - }, [notificationConfigItems]); + getSelectedConfig(); + }, [notificationConfigItems, messageListImplementationConfigItems]); const storeProvider = useCallback(async (item: NotificationConfigItem) => { await AsyncStore.setItem('@stream-rn-sampleapp-push-provider', item); setSelectedProvider(item.id); }, []); + const storeMessageListImplementation = useCallback( + async (item: MessageListImplementationConfigItem) => { + await AsyncStore.setItem('@stream-rn-sampleapp-messagelist-implementation', item); + setSelectedMessageListImplementation(item.id); + }, + [], + ); + const removeAllDevices = useCallback(async () => { const { devices } = await chatClient.getDevices(chatClient.userID); for (const device of devices ?? []) { @@ -169,12 +218,7 @@ export const SecretMenu = ({ return ( - + + + + + Message List implementation + + {messageListImplementationConfigItems.map((item) => ( + + ))} + + + void; logout: () => void; switchUser: (userId?: string) => void; + messageListImplementation: 'flatlist' | 'flashlist'; }; export const AppContext = React.createContext({} as AppContextType); diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 4a98a4317e..e4a0c06653 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -5,7 +5,7 @@ import { Channel, ChannelAvatar, MessageInput, - MessageList, + MessageFlashList, ThreadContextValue, useAttachmentPickerContext, useChannelPreviewDisplayName, @@ -32,6 +32,7 @@ import { channelMessageActions } from '../utils/messageActions.tsx'; import { MessageLocation } from '../components/LocationSharing/MessageLocation.tsx'; import { useStreamChatContext } from '../context/StreamChatContext.tsx'; import { CustomAttachmentPickerSelectionBar } from '../components/AttachmentPickerSelectionBar.tsx'; +import { MessageList } from 'stream-chat-react-native-core'; export type ChannelScreenNavigationProp = StackNavigationProp< StackNavigatorParamList, @@ -118,7 +119,7 @@ export const ChannelScreen: React.FC = ({ params: { channel: channelFromProp, channelId, messageId }, }, }) => { - const { chatClient } = useAppContext(); + const { chatClient, messageListImplementation } = useAppContext(); const navigation = useNavigation(); const { bottom } = useSafeAreaInsets(); const { @@ -198,7 +199,7 @@ export const ChannelScreen: React.FC = ({ } return ( - + = ({ thread={selectedThread} > - + {messageListImplementation === 'flashlist' ? ( + + ) : ( + + )} diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index bfa85f29d1..ff8c1c7832 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -2084,9 +2084,9 @@ integrity sha512-Az/dueoPerJsbbjRxu8a558wKY+gONUrfoy3Hs++5OqbeMsR0dYe6P+4oN6twrLFyzAhEA1tEoZRvQTFDRmvQg== "@op-engineering/op-sqlite@^14.0.4": - version "14.0.4" - resolved "https://registry.yarnpkg.com/@op-engineering/op-sqlite/-/op-sqlite-14.0.4.tgz#a86951f98e65be2f66d3f17d5bc27796d614e023" - integrity sha512-WNWsEY+ZLbOUJ6EuhB4vGqE+99NTJJkdwW+7XKdg8lN7QMnbsM7z7LGWQ9Cqp5JKvWwItBpjaxlHB2wbywsSJA== + version "14.1.4" + resolved "https://registry.yarnpkg.com/@op-engineering/op-sqlite/-/op-sqlite-14.1.4.tgz#3f0c60b0c577842406a2637850c69d67bbe6652e" + integrity sha512-ZIZAqfHUKIjSxhaxWovEz4kCp6Gtoi8RPnJ36lPwTr73c7pEFNidE2vFm0dMBEj2ikm9wfYkab1/boW98SkVKA== "@pkgjs/parseargs@^0.11.0": version "0.11.0" @@ -2620,6 +2620,13 @@ read-yaml-file "^2.1.0" strip-json-comments "^3.1.1" +"@shopify/flash-list@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-2.0.3.tgz#222427d1e09bf5cdd8a219d0a5a80f6f1d20465d" + integrity sha512-jUlHuZFoPdqRCDvOqsb2YkTttRPyV8Tb/EjCx3gE2wjr4UTM+fE0Ltv9bwBg0K7yo/SxRNXaW7xu5utusRb0xA== + dependencies: + tslib "2.8.1" + "@sideway/address@^4.1.5": version "4.1.5" resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5" @@ -8443,16 +8450,16 @@ ts-api-utils@^2.1.0: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== +tslib@2.8.1, tslib@^2.1.0, tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.1.0, tslib@^2.4.0: - version "2.8.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" - integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== - tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" diff --git a/examples/TypeScriptMessaging/ios/Podfile.lock b/examples/TypeScriptMessaging/ios/Podfile.lock index 434ce2f244..622d093c3b 100644 --- a/examples/TypeScriptMessaging/ios/Podfile.lock +++ b/examples/TypeScriptMessaging/ios/Podfile.lock @@ -3161,91 +3161,91 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: bbc1152da7d2d40f9e59c28acc6576fcf5d28e2a - op-sqlite: 17d9566d723ad870c33588ba54a98a5dcac60e7e - RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f + op-sqlite: dc2477f170ae9af9117b8543870989572b08280e + RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: 300c5eb91114d4339b0bb39505d0f4824d7299b7 RCTRequired: e0446b01093475b7082fbeee5d1ef4ad1fe20ac4 RCTTypeSafety: cb974efcdc6695deedf7bf1eb942f2a0603a063f React: e7a4655b09d0e17e54be188cc34c2f3e2087318a React-callinvoker: 62192daaa2f30c3321fc531e4f776f7b09cf892b React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a - React-Core: c400b068fdb6172177f3b3fae00c10d1077244d7 - React-CoreModules: 8e911a5a504b45824374eec240a78de7a6db8ca2 - React-cxxreact: 06a91f55ac5f842219d6ca47e0f77187a5b5f4ac + React-Core: b23cdaaa9d76389d958c06af3c57aa6ad611c542 + React-CoreModules: 8e0f562e5695991e455abbebe1e968af71d52553 + React-cxxreact: 6ccbe0cc2c652b29409b14b23cfb3cd74e084691 React-debug: 1834225a63b420b16e9b8b01ba5870aee96d0610 - React-defaultsnativemodule: 260aa990a9617c58df46c00321f396ad6ea7cc7f - React-domnativemodule: 9b3456a614c325da986867f27ca0eb34cb86828c - React-Fabric: fc7bcbac28989e6025ca6ae0988bff61bb78e5d3 - React-FabricComponents: ae4a9c82bedf7c95bace1b215caf8685bcb32e23 - React-FabricImage: c9cd4786180c150bb2a3841d65d360fd52be9ef8 - React-featureflags: 534cd678e05848fbfc8c7288d4b14bcd8894b696 - React-featureflagsnativemodule: bf7419f4d81226a3c4dd792445a03a6d703ce9a4 - React-graphics: 18296c3559d54a42baaf7f2ae9c137a2e0fe9d51 - React-hermes: b6e33fcd21aa7523dc76e62acd7a547e68c28a5b - React-idlecallbacksnativemodule: da8696a714ab16adb56bbfc9e0dfb4de7a713340 - React-ImageManager: 052ccce122e4fd4e09c5d4f30e56381704dac439 - React-jserrorhandler: 4c037384a32f57332abfa64181aeea915f9e0f0d - React-jsi: 3fde19aaf675c0607a0824c4d6002a4943820fd9 - React-jsiexecutor: 4f898228240cf261a02568e985dfa7e1d7ad1dfb - React-jsinspector: 4ad0cdfa25a45d1362e2ddd06c78727d7964b34f - React-jsinspectorcdp: a649cc98a448e0fd8d54ac2a9e3e53177a1d8bd3 - React-jsinspectornetwork: 2d701b6b152be202342f8269223046ec664c7d47 - React-jsinspectortracing: cd898b3d7ea89f3e0ae10020fe3504bb4b327dd8 - React-jsitooling: feca163583c69ba642cebb6b8ccd2f5e6732fed8 - React-jsitracing: 1965307a468987b20d2a020f8fe782efa591ded7 - React-logger: ea80169d826e0cd112fa4d68f58b2b3b968f1ecb - React-Mapbuffer: a5d550d1add940ed2bc65b20dc1413407bf1a63f - React-microtasksnativemodule: 5d00fefc19f0bc9a6432e5533683d6fc9c3da4e1 - react-native-blob-util: a8487513233d9b7c24e1a0184cb7a611cb397c76 - react-native-document-picker: 04b3863a470b34b59f860e5881cd10279511a304 - react-native-image-picker: df98fd6bf821b49ae97a383fd4adb1430f659a67 - react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac - react-native-safe-area-context: 6775aa9089fa84b77abd7ebdcf45e224a2a2ad3e - react-native-video: 56f7fa97175e9ca4c195c3d2f0a43405f4e03e12 - React-NativeModulesApple: b22e6abb44d78270dfdfc7d85efe29e35e0333a7 + React-defaultsnativemodule: dd88d445d542d58ab61a8a29a7c1d2272dfed577 + React-domnativemodule: fc3c24f4d3bb92770727ea48b4133dab77ded7f7 + React-Fabric: 00fe76339e568da0d0497cc72daeeb01e463871a + React-FabricComponents: 7bb179ee55db68f88c007800b0ac62c930115a85 + React-FabricImage: 21e01118011dd1e4ff3cdab20dbf57839cff52ee + React-featureflags: 6e67f2e252bc8ebb1d538c2ae8c14df432fe5fc0 + React-featureflagsnativemodule: eff5216a5cde5df5d09243d15db1bc401474deef + React-graphics: 8539372da8754118a565251ed08a88fc70f69340 + React-hermes: cc8c77acee1406c258622cd8abbee9049f6b5761 + React-idlecallbacksnativemodule: 7349675d1ccbec876c29b0e206ac08c762baaa36 + React-ImageManager: 4089d8ad52c86a8ae1d7591282fff1665ff5518b + React-jserrorhandler: 89a7a5fa8d04791e729119d1db03bf0ee85a9e29 + React-jsi: ea5c640ea63c127080f158dac7f4f393d13d415c + React-jsiexecutor: cf7920f82e46fe9a484c15c9f31e67d7179aa826 + React-jsinspector: 69e974b6313dbbb635ba503f2f4f2c389b30edbf + React-jsinspectorcdp: 231ddd5b7164c37589dcde3b8b6960136c891d6d + React-jsinspectornetwork: ff74911f79cf0a407a7f0ad0eeb0be64687ed815 + React-jsinspectortracing: df2aa2d944bb3fa280d9c920b9a06664bca8a7e8 + React-jsitooling: 77849c27e374a028ed8106e434a35267f6c6600b + React-jsitracing: 0dc6978e5b38c6e5e01e6aed484e4aec3f5f581b + React-logger: 7cfc7b1ae1f8e5fe5097f9c746137cc3a8fad4ce + React-Mapbuffer: 7018c5b7da5b13ed22fe55dae51d50187a00b2d7 + React-microtasksnativemodule: 8ff9cb220a8efa625b5885996bd69e69db9edf02 + react-native-blob-util: a9a07801b63e97d1bbdcf4eba3b98ff16c249bd5 + react-native-document-picker: 0b9e7c2103ae0ce974944f0a85044adba41a2311 + react-native-image-picker: e9d833df19e87e25e38ddc0be3bad92f57307765 + react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 + react-native-safe-area-context: c98c858bd57e01b4047f957934f0a34b173028fb + react-native-video: ad705a78b4873d4e591e0419e617ce6b294b51f2 + React-NativeModulesApple: 37c08c3c54db55854de816b0df0f3683832be35a React-oscompat: 56d6de59f9ae95cd006a1c40be2cde83bc06a4e1 - React-perflogger: 0633844e495d8b34798c9bf0cb32ce315f1d5c9f - React-performancetimeline: a04dae9154c32eda1891fcfa51cb2680a0421b3e + React-perflogger: 4008bd05a8b6c157b06608c0ea0b8bd5d9c5e6c9 + React-performancetimeline: 9321ba7605abcfb3a2b497fd7cbaf5cfd8c7cf67 React-RCTActionSheet: 49138012280ec3bbb35193d8d09adb8bc61c982e - React-RCTAnimation: c7ed4a9d5a4e43c9b10f68bb43cd238c4a2e7e89 - React-RCTAppDelegate: ea2ab6f4aef1489f72025b7128d8ab645b40eafb - React-RCTBlob: c052799460b245e1fffe3d1dddea36fa88e998a0 - React-RCTFabric: e7acf005f8ed58d09f755b980ff83703b3af9fcf - React-RCTFBReactNativeSpec: ffb22c3ee3d359ae9245ca94af203845da9371ec - React-RCTImage: 59fc2571f4f109a77139924f5babee8f9cd639c9 - React-RCTLinking: a045cb58c08188dce6c6f4621de105114b1b16ce - React-RCTNetwork: fc7115a2f5e15ae0aa05e9a9be726817feefb482 - React-RCTRuntime: a7bca9be4f571586b2a9d4b57cf605421ffb6335 - React-RCTSettings: 30d7dd7eae66290467a1e72bf42d927fa78c3884 - React-RCTText: 755d59284e66c7d33bb4f0ccc428fe69110c3e74 - React-RCTVibration: ffe019e588815df226f6f8ccdc65979f8b2bc440 + React-RCTAnimation: ebfe7c62016d4c17b56b2cab3a221908ae46288d + React-RCTAppDelegate: 0108657ba9a19f6a1cd62dcd19c2c0485b3fc251 + React-RCTBlob: 6cc309d1623f3c2679125a04a7425685b7219e6b + React-RCTFabric: 0a9ff5c9d1e1d7fc026bda6671180cbf56861c15 + React-RCTFBReactNativeSpec: ff3e37e2456afc04211334e86d07bf20488df0ae + React-RCTImage: bb98a59aeed953a48be3f917b9b745b213b340ab + React-RCTLinking: d6e9795d4d75d154c1dd821fd0746cc3e05d6670 + React-RCTNetwork: 5c8a7a2dd26728323189362f149e788548ac72bc + React-RCTRuntime: 96808e8fdce300a26c82d8c24174e33ba5210a7c + React-RCTSettings: b6a02d545ce10dd936b39914b32674db6e865307 + React-RCTText: c7d9232da0e9b5082a99a617483d9164a9cd46e9 + React-RCTVibration: fe636c985c1bf25e4a5b5b4d9315a3b882468a72 React-rendererconsistency: d20fcb77173861cc7d8356239823e3b36966fc31 - React-renderercss: 63c720c32aaabd4788ac4136a071d49a052d8002 - React-rendererdebug: a25ddddc73cabf50d814d8dfbc60d257b3d854c4 + React-renderercss: 56461d1e18db6a325048fdd04a51d68bd7ddb5a8 + React-rendererdebug: fcd44d3eb8a02d74beee778bb142e724016c7375 React-rncore: bafb76fc01b78757a9592e92dbc227f9260bf0ac - React-RuntimeApple: 45f8ef1b220a91b4fa4a79820b81990bffd95aa5 - React-RuntimeCore: a0e095493b22ee3f6c639df4258cc5185674f0b8 + React-RuntimeApple: 01e3ad08793efaa54cf85276457fa4a1f103d5b4 + React-RuntimeCore: 5c4bec5bf402a99b134e55972f2f4e676c70b9ab React-runtimeexecutor: b35de9cb7f5d19c66ea9b067235f95b947697ba5 - React-RuntimeHermes: 5b8126fffd1531475861dc0294a10b5f9793271a - React-runtimescheduler: 44fa97351d105afd0ffaecc4ed11cadad562deb6 + React-RuntimeHermes: ba549a5834a6592d243b9a605530ecd7b6f5e79c + React-runtimescheduler: 9a9914d58caec7976aaae381cd2d997408f2260f React-timing: 4f97958cc918f0af9444f93e4a7083415e6f5daf - React-utils: 3c4b0b7788e4dc132d1bf918bc0615e2b21f36b3 - ReactAppDependencyProvider: 6c9197c1f6643633012ab646d2bfedd1b0d25989 - ReactCodegen: 9ea66ee246511816b72e9d6e380f884b7b3b99d7 - ReactCommon: 7aca047f2f453a7d7f0adeccb63810d61829235a - RNAudioRecorderPlayer: 8a1c6ee5080aa83c3f2ccc75d1a43b2ce82b366d - RNCClipboard: ac87e4ae80acbf6b405a17b9e7ada68d7270ac7f - RNGestureHandler: 9d04ec6e1379b595222c2467f5e8d1c44157fcc9 - RNReactNativeHapticFeedback: 7ab0232cc103ac7d928635410fa0df7b11c53ada - RNReanimated: 8551defecb5f76b38e1b16a3345822da4c259de0 - RNScreens: 45a4564413205e2a1695d40bbc0297f6eefc9b74 - RNShare: 56dc9ea9692d7c8c455463f91dee012c846763e1 - RNSVG: c73af7848d94ca3e8136a5191d055e3c1d6fedab - RNWorklets: 7d34d4c80edec50bb1eec6bd034e7686db26da8e + React-utils: f491e2726eb8ced8af13893e1f77317f0fa9a954 + ReactAppDependencyProvider: 8df342c127fd0c1e30e8b9f71ff814c22414a7c0 + ReactCodegen: 439c427ccc115d71d16cc84256e5fbdc7fcef57a + ReactCommon: 592ef441605638b95e533653259254b4bd35ff4f + RNAudioRecorderPlayer: 5d5aac7a0e0f159861736ef2b433770342da7197 + RNCClipboard: 54ff19965d7c816febbafe5f520c2c3e7b677a49 + RNGestureHandler: eeb622199ef1fb3a076243131095df1c797072f0 + RNReactNativeHapticFeedback: 8eb91a6f48567d02ec8026e515102e18c41030cf + RNReanimated: 028d25ae4031eb5a9aeb5febbe2c2cd0c744aa9c + RNScreens: ee2abe7e0c548eed14e92742e81ed991165c56aa + RNShare: df2cab72f87b02ff50690341d1a2c61763154c02 + RNSVG: 341f555dbcd83a34d1f058e88df387de7bbc3347 + RNWorklets: 18d2a9a10588e4d51f42116f19e650d296ab8dbc SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - stream-chat-react-native: 439c3f7e6cc0487d41ef0000201f39b9d8135357 + stream-chat-react-native: d9c1c7f19b8bc25b6a7e8ff57063be30d1e3fa3b Yoga: a742cc68e8366fcfc681808162492bc0aa7a9498 PODFILE CHECKSUM: 6b7a4b74915b42bfe4ffddaf67cbf5e7a2bfeab3 -COCOAPODS: 1.14.3 +COCOAPODS: 1.16.2 diff --git a/package/jest-setup.js b/package/jest-setup.js index 68601b89d5..5b2987f3d0 100644 --- a/package/jest-setup.js +++ b/package/jest-setup.js @@ -61,3 +61,7 @@ jest.mock('react-native/Libraries/Components/RefreshControl/RefreshControl', () __esModule: true, default: require('./__mocks__/RefreshControlMock'), })); + +jest.mock('@shopify/flash-list', () => ({ + FlashList: undefined, +})); diff --git a/package/package.json b/package/package.json index af967192c7..5076d1b189 100644 --- a/package/package.json +++ b/package/package.json @@ -85,6 +85,7 @@ "@emoji-mart/data": ">=1.1.0", "@op-engineering/op-sqlite": ">=14.0.0", "@react-native-community/netinfo": ">=11.3.1", + "@shopify/flash-list": ">=2.0.3", "emoji-mart": ">=5.4.0", "react-native": ">=0.73.0", "react-native-gesture-handler": ">=2.18.0", @@ -95,6 +96,9 @@ "@op-engineering/op-sqlite": { "optional": true }, + "@shopify/flash-list": { + "optional": true + }, "emoji-mart": { "optional": true }, @@ -106,6 +110,7 @@ "@babel/core": "^7.27.4", "@babel/runtime": "^7.27.6", "@op-engineering/op-sqlite": "^14.0.3", + "@shopify/flash-list": "^2.0.3", "@react-native-community/eslint-config": "3.2.0", "@react-native-community/eslint-plugin": "1.3.0", "@react-native-community/netinfo": "^11.4.1", diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx new file mode 100644 index 0000000000..9fff0ef9d6 --- /dev/null +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -0,0 +1,1327 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + LayoutChangeEvent, + ScrollViewProps, + StyleSheet, + View, + ViewabilityConfig, + ViewToken, +} from 'react-native'; + +import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; +import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; + +import { useMessageList } from './hooks/useMessageList'; +import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollToRecentOnNewOwnMessage'; +import { InlineLoadingMoreIndicator } from './InlineLoadingMoreIndicator'; +import { InlineLoadingMoreRecentIndicator } from './InlineLoadingMoreRecentIndicator'; +import { InlineLoadingMoreRecentThreadIndicator } from './InlineLoadingMoreRecentThreadIndicator'; + +import { getLastReceivedMessageFlashList } from './utils/getLastReceivedMessageFlashList'; + +import { + AttachmentPickerContextValue, + useAttachmentPickerContext, +} from '../../contexts/attachmentPickerContext/AttachmentPickerContext'; +import { + ChannelContextValue, + useChannelContext, +} from '../../contexts/channelContext/ChannelContext'; +import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; +import { + ImageGalleryContextValue, + useImageGalleryContext, +} from '../../contexts/imageGalleryContext/ImageGalleryContext'; +import { + MessagesContextValue, + useMessagesContext, +} from '../../contexts/messagesContext/MessagesContext'; +import { + PaginatedMessageListContextValue, + usePaginatedMessageListContext, +} from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; +import { mergeThemes, ThemeProvider, useTheme } from '../../contexts/themeContext/ThemeContext'; +import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; + +import { useStableCallback } from '../../hooks'; +import { FileTypes } from '../../types/types'; + +let FlashList; + +try { + FlashList = require('@shopify/flash-list').FlashList; +} catch { + FlashList = undefined; +} + +const keyExtractor = (item: LocalMessage) => { + if (item.id) { + return item.id; + } + if (item.created_at) { + return typeof item.created_at === 'string' ? item.created_at : item.created_at.toISOString(); + } + return Date.now().toString(); +}; + +const flatListViewabilityConfig: ViewabilityConfig = { + viewAreaCoveragePercentThreshold: 1, +}; + +const hasReadLastMessage = (channel: Channel, userId: string) => { + const latestMessageIdInChannel = + channel.state.latestMessages[channel.state.latestMessages.length - 1]?.id; + const lastReadMessageIdServer = channel.state.read[userId]?.last_read_message_id; + return latestMessageIdInChannel === lastReadMessageIdServer; +}; + +const getPreviousLastMessage = (messages: LocalMessage[], newMessage?: MessageResponse) => { + if (!newMessage) return; + let previousLastMessage; + for (let i = messages.length - 1; i>= 0; i--) { + const msg = messages[i]; + if (!msg?.id) break; + if (msg.id !== newMessage.id) { + previousLastMessage = msg; + break; + } + } + return previousLastMessage; +}; + +type MessageFlashListPropsWithContext = Pick< + AttachmentPickerContextValue, + 'closePicker' | 'selectedPicker' | 'setSelectedPicker' +> & + Pick< + ChannelContextValue, + | 'channel' + | 'channelUnreadState' + | 'disabled' + | 'EmptyStateIndicator' + | 'hideStickyDateHeader' + | 'highlightedMessageId' + | 'loadChannelAroundMessage' + | 'loading' + | 'LoadingIndicator' + | 'markRead' + | 'NetworkDownIndicator' + | 'reloadChannel' + | 'scrollToFirstUnreadThreshold' + | 'setChannelUnreadState' + | 'setTargetedMessage' + | 'StickyHeader' + | 'targetedMessage' + | 'threadList' +> & + Pick & + Pick & + Pick & + Pick< + MessagesContextValue, + | 'DateHeader' + | 'disableTypingIndicator' + | 'FlatList' + | 'InlineDateSeparator' + | 'InlineUnreadIndicator' + | 'legacyImageViewerSwipeBehaviour' + | 'Message' + | 'ScrollToBottomButton' + | 'MessageSystem' + | 'myMessageTheme' + | 'shouldShowUnreadUnderlay' + | 'TypingIndicator' + | 'TypingIndicatorContainer' + | 'UnreadMessagesNotification' +> & + Pick< + ThreadContextValue, + 'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadInstance' +> & { + /** + * Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want + * to attach some additional props to underlying FlatList, you can add it to following prop. + * + * You can find list of all the available FlatList props here - https://facebook.github.io/react-native/docs/flatlist#props + * + * **NOTE** Don't use `additionalFlatListProps` to get access to ref of flatlist. Use `setFlatListRef` instead. + * + * e.g. + * ```js + * + * ``` + */ + additionalFlashListProps?: Partial>; + /** + * UI component for footer of message list. By default message list will use `InlineLoadingMoreIndicator` + * as FooterComponent. If you want to implement your own inline loading indicator, you can access `loadingMore` + * from context. + * + * This is a [ListHeaderComponent](https://facebook.github.io/react-native/docs/flatlist#listheadercomponent) of FlatList + * used in MessageList. Should be used for header by default if inverted is true or defaulted + */ + FooterComponent?: React.ComponentType; + /** + * UI component for header of message list. By default message list will use `InlineLoadingMoreRecentIndicator` + * as HeaderComponent. If you want to implement your own inline loading indicator, you can access `loadingMoreRecent` + * from context. + * + * This is a [ListFooterComponent](https://facebook.github.io/react-native/docs/flatlist#listheadercomponent) of FlatList + * used in MessageList. Should be used for header if inverted is false + */ + HeaderComponent?: React.ComponentType; + /** Whether or not the FlatList is inverted. Defaults to true */ + inverted?: boolean; + isListActive?: boolean; + /** Turn off grouping of messages by user */ + noGroupByUser?: boolean; + onListScroll?: ScrollViewProps['onScroll']; + /** + * Handler to open the thread on message. This is callback for touch event for replies button. + * + * @param message A message object to open the thread upon. + */ + onThreadSelect?: (message: ThreadContextValue['thread']) => void; + /** + * Use `setFlatListRef` to get access to ref to inner FlatList. + * + * e.g. + * ```js + * { + * // Use ref for your own good + * }} + * ``` + */ + setFlatListRef?: (ref: FlashListRef | null) => void; + /** + * If true, the message list will be used in a live-streaming scenario. + * This flag is used to make sure that the auto scroll behaves well, if multiple messages are received. + * + * This flag is experimental and is subject to change. Please test thoroughly before using it. + * + * @experimental + */ + isLiveStreaming?: boolean; + }; + +const WAIT_FOR_SCROLL_TIMEOUT = 0; + +const getItemTypeInternal = (message: LocalMessage) => { + if (message.type === 'regular') { + if ((message.attachments?.length ?? 0)> 0) { + return 'message-with-attachments'; + } + + if (message.poll_id) { + return 'message-with-poll'; + } + + if (message.quoted_message_id) { + return 'message-with-quote'; + } + + if (message.shared_location) { + return 'message-with-shared-location'; + } + + if (message.text) { + return 'message-with-text'; + } + + return 'message-with-nothing'; + } + + if (message.type === 'deleted') { + return 'deleted-message'; + } + + if (message.type === 'system') { + return 'system-message'; + } + + return 'generic-message'; +}; + +const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => { + const LoadingMoreRecentIndicator = props.threadList + ? InlineLoadingMoreRecentThreadIndicator + : InlineLoadingMoreRecentIndicator; + const { + additionalFlashListProps, + channel, + channelUnreadState, + client, + closePicker, + DateHeader, + disabled, + disableTypingIndicator, + EmptyStateIndicator, + // FlatList, + FooterComponent = LoadingMoreRecentIndicator, + HeaderComponent = InlineLoadingMoreIndicator, + hideStickyDateHeader, + highlightedMessageId, + InlineDateSeparator, + InlineUnreadIndicator, + isListActive = false, + isLiveStreaming = false, + legacyImageViewerSwipeBehaviour, + loadChannelAroundMessage, + loading, + LoadingIndicator, + loadMore, + loadMoreRecent, + loadMoreRecentThread, + loadMoreThread, + markRead, + Message, + MessageSystem, + myMessageTheme, + NetworkDownIndicator, + noGroupByUser, + onListScroll, + onThreadSelect, + reloadChannel, + ScrollToBottomButton, + selectedPicker, + setChannelUnreadState, + setFlatListRef, + setMessages, + setSelectedPicker, + setTargetedMessage, + shouldShowUnreadUnderlay, + StickyHeader, + targetedMessage, + thread, + threadInstance, + threadList = false, + TypingIndicator, + TypingIndicatorContainer, + UnreadMessagesNotification, + } = props; + const flashListRef = useRef | null>(null); + + const [hasMoved, setHasMoved] = useState(false); + const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); + const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); + const [stickyHeaderDate, setStickyHeaderDate] = useState(); + const [autoScrollToRecent, setAutoScrollToRecent] = useState(false); + + const stickyHeaderDateRef = useRef(undefined); + /** + * We want to call onEndReached and onStartReached only once, per content length. + * We keep track of calls to these functions per content length, with following trackers. + */ + const onStartReachedTracker = useRef>({}); + const onEndReachedTracker = useRef>({}); + + const onStartReachedInPromise = useRef | null>(null); + const onEndReachedInPromise = useRef | null>(null); + + /** + * The timeout id used to debounce our scrollToIndex calls on messageList updates + */ + const scrollToDebounceTimeoutRef = useRef>(undefined); + + const channelResyncScrollSet = useRef(true); + const { theme } = useTheme(); + + const { + colors: { white_snow }, + messageList: { container, contentContainer, listContainer }, + } = theme; + + const myMessageThemeString = useMemo(() => JSON.stringify(myMessageTheme), [myMessageTheme]); + + const modifiedTheme = useMemo( + () => mergeThemes({ style: myMessageTheme, theme }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [myMessageThemeString, theme], + ); + + const { dateSeparatorsRef, messageGroupStylesRef, processedMessageList, rawMessageList } = + useMessageList({ + isFlashList: true, + isLiveStreaming, + noGroupByUser, + threadList, + }); + + /** + * We need topMessage and channelLastRead values to set the initial scroll position. + * So these values only get used if `initialScrollToFirstUnreadMessage` prop is true. + */ + const topMessageBeforeUpdate = useRef(undefined); + const topMessageAfterUpdate: LocalMessage | undefined = rawMessageList[0]; + + const latestNonCurrentMessageBeforeUpdateRef = useRef(undefined); + + const messageListLengthBeforeUpdate = useRef(0); + const messageListLengthAfterUpdate = processedMessageList.length; + + const shouldScrollToRecentOnNewOwnMessageRef = useShouldScrollToRecentOnNewOwnMessage( + rawMessageList, + client.userID, + ); + + const lastReceivedId = useMemo( + () => getLastReceivedMessageFlashList(processedMessageList)?.id, + [processedMessageList], + ); + + const maintainVisibleContentPosition = useMemo(() => { + return { + animateAutoscrollToBottom: true, + autoscrollToBottomThreshold: isLiveStreaming + ? 64 + : autoScrollToRecent || threadList + ? 10 + : undefined, + startRenderingFromBottom: true, + }; + }, [autoScrollToRecent, threadList, isLiveStreaming]); + + useEffect(() => { + if (disabled) { + setScrollToBottomButtonVisible(false); + } + }, [disabled]); + + /** + * Check if a messageId needs to be scrolled to after list loads, and scroll to it + * Note: This effect fires on every list change with a small debounce so that scrolling isnt abrupted by an immediate rerender + */ + useEffect(() => { + if (!targetedMessage) { + return; + } + + const indexOfParentInMessageList = processedMessageList.findIndex( + (message) => message?.id === targetedMessage, + ); + + // the message we want to scroll to has not been loaded in the state yet + if (indexOfParentInMessageList === -1) { + loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage }); + } else { + scrollToDebounceTimeoutRef.current = setTimeout(() => { + clearTimeout(scrollToDebounceTimeoutRef.current); + + // now scroll to it + flashListRef.current?.scrollToIndex({ + animated: true, + index: indexOfParentInMessageList, + viewPosition: 0.5, + }); + setTargetedMessage(undefined); + }, WAIT_FOR_SCROLL_TIMEOUT); + } + }, [loadChannelAroundMessage, processedMessageList, setTargetedMessage, targetedMessage]); + + const goToMessage = useStableCallback(async (messageId: string) => { + const indexOfParentInMessageList = processedMessageList.findIndex( + (message) => message?.id === messageId, + ); + if (indexOfParentInMessageList !== -1) { + flashListRef.current?.scrollToIndex({ + animated: true, + index: indexOfParentInMessageList, + viewPosition: 0.5, + }); + setTargetedMessage(messageId); + return; + } + try { + if (indexOfParentInMessageList === -1) { + clearTimeout(scrollToDebounceTimeoutRef.current); + await loadChannelAroundMessage({ messageId }); + setTargetedMessage(messageId); + + // now scroll to it with animated=true + flashListRef.current?.scrollToIndex({ + animated: true, + index: indexOfParentInMessageList, + viewPosition: 0.5, // try to place message in the center of the screen + }); + return; + } + } catch (e) { + console.warn('Error while scrolling to message', e); + } + }); + + useEffect(() => { + /** + * Condition to check if a message is removed from MessageList. + * Eg: This would happen when giphy search is cancelled, message is deleted with visibility "never" etc. + * If such a case arises, we scroll to bottom. + */ + const isMessageRemovedFromMessageList = + messageListLengthBeforeUpdate.current - messageListLengthAfterUpdate === 1; + + /** + * Scroll down when + * created_at timestamp of top message before update is lesser than created_at timestamp of top message after update - channel has resynced + */ + const scrollToBottomIfNeeded = () => { + if (!client || !channel || processedMessageList.length === 0) { + return; + } + + if ( + isMessageRemovedFromMessageList || + (topMessageBeforeUpdate.current?.created_at && + topMessageAfterUpdate?.created_at && + topMessageBeforeUpdate.current.created_at < topMessageAfterUpdate.created_at) + ) { + channelResyncScrollSet.current = false; + setScrollToBottomButtonVisible(false); + resetPaginationTrackersRef.current(); + + setAutoScrollToRecent(true); + setTimeout(() => { + channelResyncScrollSet.current = true; + if (channel.countUnread()> 0) { + markRead(); + } + setAutoScrollToRecent(false); + }, WAIT_FOR_SCROLL_TIMEOUT); + } + }; + + if (isMessageRemovedFromMessageList) { + scrollToBottomIfNeeded(); + } + + messageListLengthBeforeUpdate.current = messageListLengthAfterUpdate; + topMessageBeforeUpdate.current = topMessageAfterUpdate; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messageListLengthAfterUpdate, topMessageAfterUpdate?.id]); + + useEffect(() => { + if (!processedMessageList.length) { + return; + } + + const notLatestSet = channel.state.messages !== channel.state.latestMessages; + if (notLatestSet) { + latestNonCurrentMessageBeforeUpdateRef.current = + channel.state.latestMessages[channel.state.latestMessages.length - 1]; + setAutoScrollToRecent(false); + setScrollToBottomButtonVisible(true); + return; + } + const latestNonCurrentMessageBeforeUpdate = latestNonCurrentMessageBeforeUpdateRef.current; + latestNonCurrentMessageBeforeUpdateRef.current = undefined; + + const latestCurrentMessageAfterUpdate = processedMessageList[processedMessageList.length - 1]; + if (!latestCurrentMessageAfterUpdate) { + setAutoScrollToRecent(true); + return; + } + const didMergeMessageSetsWithNoUpdates = + latestNonCurrentMessageBeforeUpdate?.id === latestCurrentMessageAfterUpdate.id; + + if (!didMergeMessageSetsWithNoUpdates) { + const shouldScrollToRecentOnNewOwnMessage = shouldScrollToRecentOnNewOwnMessageRef.current(); + // we should scroll to bottom where ever we are now + // as we have sent a new own message + if (shouldScrollToRecentOnNewOwnMessage) { + flashListRef.current?.scrollToEnd({ + animated: true, + }); + } + } + }, [channel, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef, threadList]); + + /** + * Effect to scroll to the bottom of the message list when a new message is received if the scroll to bottom button is not visible. + */ + useEffect(() => { + const handleEvent = (event: Event) => { + if (event.message?.user?.id !== client.userID) { + if (!scrollToBottomButtonVisible) { + flashListRef.current?.scrollToEnd({ + animated: true, + }); + } else { + setAutoScrollToRecent(false); + } + } + }; + const listener: ReturnType = channel.on('message.new', handleEvent); + + return () => { + listener?.unsubscribe(); + }; + }, [channel, client.userID, scrollToBottomButtonVisible]); + + /** + * Effect to mark the channel as read when the user scrolls to the bottom of the message list. + */ + useEffect(() => { + const shouldMarkRead = () => { + return ( + !channelUnreadState?.first_unread_message_id && + !scrollToBottomButtonVisible && + client.user?.id && + !hasReadLastMessage(channel, client.user?.id) + ); + }; + + const handleEvent = async (event: Event) => { + const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; + const isMyOwnMessage = event.message?.user?.id === client.user?.id; + // When the scrollToBottomButtonVisible is true, we need to manually update the channelUnreadState when its a received message. + if ( + (scrollToBottomButtonVisible || channelUnreadState?.first_unread_message_id) && + !isMyOwnMessage + ) { + setChannelUnreadState((prev) => { + const previousUnreadCount = prev?.unread_messages ?? 0; + const previousLastMessage = getPreviousLastMessage(channel.state.messages, event.message); + return { + ...(prev || {}), + last_read: + prev?.last_read ?? + (previousUnreadCount === 0 && previousLastMessage?.created_at + ? new Date(previousLastMessage.created_at) + : new Date(0)), // not having information about the last read message means the whole channel is unread, + unread_messages: previousUnreadCount + 1, + }; + }); + } else if (mainChannelUpdated && shouldMarkRead()) { + await markRead(); + } + }; + + const listener: ReturnType = channel.on('message.new', handleEvent); + + return () => { + listener?.unsubscribe(); + }; + }, [ + channel, + channelUnreadState?.first_unread_message_id, + client.user?.id, + markRead, + scrollToBottomButtonVisible, + setChannelUnreadState, + threadList, + ]); + + const updateStickyHeaderDateIfNeeded = useStableCallback((viewableItems: ViewToken[]) => { + if (!viewableItems.length) { + return; + } + + const lastItem = viewableItems[0]; + + if (!lastItem) return; + + if ( + !channel.state.messagePagination.hasPrev && + processedMessageList[0].id === lastItem.item.id + ) { + setStickyHeaderDate(undefined); + return; + } + const isMessageTypeDeleted = lastItem.item.type === 'deleted'; + + if ( + lastItem?.item?.created_at && + !isMessageTypeDeleted && + typeof lastItem.item.created_at !== 'string' && + lastItem.item.created_at.toDateString() !== stickyHeaderDateRef.current?.toDateString() + ) { + stickyHeaderDateRef.current = lastItem.item.created_at; + setStickyHeaderDate(lastItem.item.created_at); + } + }); + + /** + * This function should show or hide the unread indicator depending on the + */ + const updateStickyUnreadIndicator = useStableCallback((viewableItems: ViewToken[]) => { + if (!viewableItems.length) { + setIsUnreadNotificationOpen(false); + return; + } + + if (selectedPicker === 'images') { + setIsUnreadNotificationOpen(false); + return; + } + + const lastItem = viewableItems[0]; + + if (!lastItem) return; + + const lastItemMessage = lastItem.item; + const lastItemCreatedAt = lastItemMessage.created_at; + + const unreadIndicatorDate = channelUnreadState?.last_read.getTime(); + const lastItemDate = lastItemCreatedAt.getTime(); + + if ( + !channel.state.messagePagination.hasPrev && + processedMessageList[0].id === lastItemMessage.id + ) { + setIsUnreadNotificationOpen(false); + return; + } + /** + * This is a special case where there is a single long message by the sender. + * When a message is sent, we mark it as read before it actually has a `created_at` timestamp. + * This is a workaround to prevent the unread indicator from showing when the message is sent. + */ + if ( + viewableItems.length === 1 && + channel.countUnread() === 0 && + lastItemMessage.user.id === client.userID + ) { + setIsUnreadNotificationOpen(false); + return; + } + if (unreadIndicatorDate && lastItemDate> unreadIndicatorDate) { + setIsUnreadNotificationOpen(true); + } else { + setIsUnreadNotificationOpen(false); + } + }); + + /** + * FlatList doesn't accept changeable function for onViewableItemsChanged prop. + * Thus useRef. + */ + const unstableOnViewableItemsChanged = ({ + viewableItems, + }: { + viewableItems: ViewToken[] | undefined; + }) => { + if (!viewableItems) { + return; + } + if (!hideStickyDateHeader) { + updateStickyHeaderDateIfNeeded(viewableItems); + } + updateStickyUnreadIndicator(viewableItems); + }; + + const onViewableItemsChanged = useRef(unstableOnViewableItemsChanged); + onViewableItemsChanged.current = unstableOnViewableItemsChanged; + + const stableOnViewableItemsChanged = useCallback( + ({ viewableItems }: { viewableItems: ViewToken[] | undefined }) => { + onViewableItemsChanged.current({ viewableItems }); + }, + [], + ); + + const renderItem = useCallback( + ({ index, item: message }: { index: number; item: LocalMessage }) => { + if (!channel || channel.disconnected || (!channel.initialized && !channel.offlineMode)) { + return null; + } + + const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); + const lastReadTimestamp = channelUnreadState?.last_read.getTime(); + const isNewestMessage = index === 0; + const isLastReadMessage = + channelUnreadState?.last_read_message_id === message.id || + (!channelUnreadState?.unread_messages && createdAtTimestamp === lastReadTimestamp); + + const showUnreadSeparator = + isLastReadMessage && + !isNewestMessage && + // The `channelUnreadState?.first_unread_message_id` is here for sent messages unread label + (!!channelUnreadState?.first_unread_message_id || !!channelUnreadState?.unread_messages); + + const showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator; + + const wrapMessageInTheme = client.userID === message.user?.id && !!myMessageTheme; + const renderDateSeperator = dateSeparatorsRef.current[message.id] && ( + + ); + + const renderMessage = ( + + ); + + return ( + + {message.type === 'system' ? ( + + ) : wrapMessageInTheme ? ( + + + {renderDateSeperator} + {renderMessage} + + + ) : ( + + {renderDateSeperator} + {renderMessage} + + )} + {showUnreadUnderlay && } + + ); + }, + [ + InlineDateSeparator, + InlineUnreadIndicator, + Message, + MessageSystem, + channel, + channelUnreadState?.first_unread_message_id, + channelUnreadState?.last_read, + channelUnreadState?.last_read_message_id, + channelUnreadState?.unread_messages, + client.userID, + dateSeparatorsRef, + goToMessage, + highlightedMessageId, + lastReceivedId, + messageGroupStylesRef, + modifiedTheme, + myMessageTheme, + onThreadSelect, + shouldShowUnreadUnderlay, + threadList, + ], + ); + + const messagesWithImages = + legacyImageViewerSwipeBehaviour && + processedMessageList.filter((message) => { + const isMessageTypeDeleted = message.type === 'deleted'; + if (!isMessageTypeDeleted && message.attachments) { + return message.attachments.some( + (attachment) => + attachment.type === FileTypes.Image && + !attachment.title_link && + !attachment.og_scrape_url && + (attachment.image_url || attachment.thumb_url), + ); + } + return false; + }); + + /** + * This is for the useEffect to run again in the case that a message + * gets edited with more or the same number of images + */ + const imageString = + legacyImageViewerSwipeBehaviour && + messagesWithImages && + messagesWithImages + .map((message) => + message.attachments + ?.map((attachment) => attachment.image_url || attachment.thumb_url || '') + .join(), + ) + .join(); + + const numberOfMessagesWithImages = + legacyImageViewerSwipeBehaviour && messagesWithImages && messagesWithImages.length; + const threadExists = !!thread; + + useEffect(() => { + if ( + legacyImageViewerSwipeBehaviour && + isListActive && + ((threadList && thread) || (!threadList && !thread)) + ) { + setMessages(messagesWithImages as LocalMessage[]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + imageString, + isListActive, + legacyImageViewerSwipeBehaviour, + numberOfMessagesWithImages, + threadExists, + threadList, + ]); + + /** + * We are keeping full control on message pagination, and not relying on react-native for it. + * The reasons being, + * 1. FlatList doesn't support onStartReached prop + * 2. `onEndReached` function prop available on react-native, gets executed + * once per content length (and thats actually a nice optimization strategy). + * But it also means, we always need to prioritize onEndReached above our + * logic for `onStartReached`. + * 3. `onEndReachedThreshold` prop decides - at which scroll position to call `onEndReached`. + * Its a factor of content length (which is necessary for "real" infinite scroll). But on + * the other hand, it also makes calls to `onEndReached` (and this `channel.query`) way + * too early during scroll, which we don't really need. So we are going to instead + * keep some fixed offset distance, to decide when to call `loadMore` or `loadMoreRecent`. + * + * We are still gonna keep the optimization, which react-native does - only call onEndReached + * once per content length. + */ + + /** + * 1. Makes a call to `loadMore` function, which queries more older messages. + * 2. Ensures that we call `loadMore`, once per content length + * 3. If the call to `loadMoreRecent` is in progress, we wait for it to finish to make sure scroll doesn't jump. + */ + const maybeCallOnStartReached = useStableCallback(async () => { + // If onEndReached has already been called for given messageList length, then ignore. + if ( + processedMessageList?.length && + onStartReachedTracker.current[processedMessageList.length] + ) { + return; + } + + if (processedMessageList?.length) { + onStartReachedTracker.current[processedMessageList.length] = true; + } + + const callback = () => { + onStartReachedInPromise.current = null; + return Promise.resolve(); + }; + + const onError = () => { + /** Release the onEndReachedTracker trigger after 2 seconds, to try again */ + setTimeout(() => { + onStartReachedTracker.current = {}; + }, 2000); + }; + + // If onStartReached is in progress, better to wait for it to finish for smooth UX + if (onEndReachedInPromise.current) { + await onEndReachedInPromise.current; + } + onStartReachedInPromise.current = ( + threadList && !!threadInstance && loadMoreRecentThread + ? loadMoreRecentThread({}) + : loadMoreRecent() + ) + .then(callback) + .catch(onError); + }); + + /** + * 1. Makes a call to `loadMoreRecent` function, which queries more recent messages. + * 2. Ensures that we call `loadMoreRecent`, once per content length + * 3. If the call to `loadMore` is in progress, we wait for it to finish to make sure scroll doesn't jump. + */ + const maybeCallOnEndReached = useStableCallback(async () => { + // If onStartReached has already been called for given data length, then ignore. + if (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) { + return; + } + + if (processedMessageList?.length) { + onEndReachedTracker.current[processedMessageList.length] = true; + } + + const callback = () => { + onEndReachedInPromise.current = null; + + return Promise.resolve(); + }; + + const onError = () => { + /** Release the onStartReached trigger after 2 seconds, to try again */ + setTimeout(() => { + onEndReachedTracker.current = {}; + }, 2000); + }; + + // If onEndReached is in progress, better to wait for it to finish for smooth UX + if (onStartReachedInPromise.current) { + await onStartReachedInPromise.current; + } + + onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) + .then(callback) + .catch(onError); + }); + + const onUserScrollEvent: NonNullable = useStableCallback((event) => { + const nativeEvent = event.nativeEvent; + const offset = nativeEvent.contentOffset.y; + const visibleLength = nativeEvent.layoutMeasurement.height; + const contentLength = nativeEvent.contentSize.height; + if (!channel || !channelResyncScrollSet.current) { + return; + } + + // Check if scroll has reached either start of end of list. + const isScrollAtEnd = offset < 100; + const isScrollAtStart = contentLength - visibleLength - offset < 100; + + if (isScrollAtEnd) { + maybeCallOnEndReached(); + } + + if (isScrollAtStart) { + maybeCallOnStartReached(); + } + }); + + /** + * Resets the pagination trackers, doing so cancels currently scheduled loading more calls + */ + const resetPaginationTrackersRef = useRef(() => { + onStartReachedTracker.current = {}; + onEndReachedTracker.current = {}; + }); + + const currentScrollOffsetRef = useRef(0); + + const handleScroll: ScrollViewProps['onScroll'] = useStableCallback((event) => { + const messageListHasMessages = processedMessageList.length> 0; + const nativeEvent = event.nativeEvent; + const offset = nativeEvent.contentOffset.y; + currentScrollOffsetRef.current = offset; + 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 notLatestSet = channel.state.messages !== channel.state.latestMessages; + + const showScrollToBottomButton = + messageListHasMessages && ((!threadList && notLatestSet) || !isScrollAtStart); + + /** + * 1. If I scroll up -> show scrollToBottom button. + * 2. If I scroll to bottom of screen + * |-> hide scrollToBottom button. + * |-> if channel is unread, call markRead(). + */ + setScrollToBottomButtonVisible(showScrollToBottomButton); + + if (onListScroll) { + onListScroll(event); + } + }); + + const goToNewMessages = useStableCallback(async () => { + const isNotLatestSet = channel.state.messages !== channel.state.latestMessages; + + if (isNotLatestSet) { + resetPaginationTrackersRef.current(); + await reloadChannel(); + } else if (flashListRef.current) { + flashListRef.current.scrollToEnd({ + animated: true, + }); + } + + setScrollToBottomButtonVisible(false); + /** + * When we are not in the bottom of the list, and we receive new messages, we need to mark the channel as read. + We would still need to show the unread label, where the first unread message appeared so we don't update the channelUnreadState. + */ + await markRead({ + updateChannelUnreadState: false, + }); + }); + + const dismissImagePicker = useStableCallback(() => { + if (selectedPicker) { + setSelectedPicker(undefined); + closePicker(); + } + }); + + const onScrollBeginDrag: ScrollViewProps['onScrollBeginDrag'] = useStableCallback((event) => { + !hasMoved && selectedPicker && setHasMoved(true); + onUserScrollEvent(event); + }); + + const onScrollEndDrag: ScrollViewProps['onScrollEndDrag'] = useStableCallback((event) => { + hasMoved && selectedPicker && setHasMoved(false); + onUserScrollEvent(event); + }); + + const refCallback = useStableCallback((ref: FlashListRef) => { + flashListRef.current = ref; + + if (setFlatListRef) { + setFlatListRef(ref); + } + }); + + const onUnreadNotificationClose = useStableCallback(async () => { + await markRead(); + setIsUnreadNotificationOpen(false); + }); + + // We need to omit the style related props from the additionalFlatListProps and add them directly instead of spreading + let additionalFlashListPropsExcludingStyle: + | Omit, 'style' | 'contentContainerStyle'> + | undefined; + + if (additionalFlashListProps) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { contentContainerStyle, style, ...rest } = additionalFlashListProps; + additionalFlashListPropsExcludingStyle = rest; + } + + const flatListStyle = useMemo( + () => ({ ...styles.listContainer, ...listContainer, ...additionalFlashListProps?.style }), + [additionalFlashListProps?.style, listContainer], + ); + + const flatListContentContainerStyle = useMemo( + () => ({ + ...styles.contentContainer, + ...contentContainer, + }), + [contentContainer], + ); + + const getItemType = useStableCallback((item: LocalMessage) => { + const type = getItemTypeInternal(item); + return client.userID === item.user?.id ? `own-${type}` : type; + }); + + const currentListHeightRef = useRef(undefined); + + const onLayout = useStableCallback((e: LayoutChangeEvent) => { + const { height } = e.nativeEvent.layout; + if (!currentListHeightRef.current) { + currentListHeightRef.current = height; + return; + } + + const changedBy = currentListHeightRef.current - height; + flashListRef.current?.scrollToOffset({ + offset: currentScrollOffsetRef.current + changedBy, + }); + currentListHeightRef.current = height; + }); + + if (loading) { + return ( + + + + ); + } + + if (!FlashList) { + throw new Error( + 'The package @shopify/flash-list is not installed. Installing this package will enable the use of the FlashList component.', + ); + } + + return ( + + {processedMessageList.length === 0 && !thread ? ( + + {EmptyStateIndicator ? : null} + + ) : ( + + )} + + {messageListLengthAfterUpdate && StickyHeader ? ( + + ) : null} + + {!disableTypingIndicator && TypingIndicator && ( + + + + )} + + + {isUnreadNotificationOpen && !threadList ? ( + + ) : null} + + ); +}; + +export type MessageFlashListProps = Partial; + +/** + * This is a @experimental component. + * It is implemented using @shopify/flash-list package to optimize the performance of the MessageList component. + * The implementation is experimental and is subject to change. + * Please feel free to report any issues or suggestions. + */ +export const MessageFlashList = (props: MessageFlashListProps) => { + const { closePicker, selectedPicker, setSelectedPicker } = useAttachmentPickerContext(); + const { + channel, + channelUnreadState, + disabled, + EmptyStateIndicator, + enableMessageGroupingByUser, + error, + hideStickyDateHeader, + highlightedMessageId, + isChannelActive, + loadChannelAroundMessage, + loading, + LoadingIndicator, + markRead, + NetworkDownIndicator, + reloadChannel, + scrollToFirstUnreadThreshold, + setChannelUnreadState, + setTargetedMessage, + StickyHeader, + targetedMessage, + threadList, + } = useChannelContext(); + const { client } = useChatContext(); + const { setMessages } = useImageGalleryContext(); + const { + DateHeader, + disableTypingIndicator, + FlatList, + InlineDateSeparator, + InlineUnreadIndicator, + legacyImageViewerSwipeBehaviour, + Message, + MessageSystem, + myMessageTheme, + ScrollToBottomButton, + shouldShowUnreadUnderlay, + TypingIndicator, + TypingIndicatorContainer, + UnreadMessagesNotification, + } = useMessagesContext(); + const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); + const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); + + return ( + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flex: 1, + width: '100%', + }, + contentContainer: { + /** + * paddingBottom is set to 4 to account for the default date + * header and inline indicator alignment. The top margin is 8 + * on the header but 4 on the inline date, this adjusts the spacing + * to allow the "first" inline date to align with the date header. + */ + paddingBottom: 4, + }, + flex: { flex: 1 }, + listContainer: { + flex: 1, + width: '100%', + }, + stickyHeader: { + position: 'absolute', + top: 0, + }, +}); diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 2ebde3f6cf..9fcf6170bf 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -67,11 +67,6 @@ const styles = StyleSheet.create({ paddingBottom: 4, }, flex: { flex: 1 }, - invertAndroid: { - // Invert the Y AND X axis to prevent a react native issue that can lead to ANRs on android 13 - // details: https://github.com/Expensify/App/pull/12820 - transform: [{ scaleX: -1 }, { scaleY: -1 }], - }, listContainer: { flex: 1, width: '100%', diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index 53c6e14665..088e6e2ac9 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -20,6 +20,7 @@ export type UseMessageListParams = { noGroupByUser?: boolean; threadList?: boolean; isLiveStreaming?: boolean; + isFlashList?: boolean; }; export type GroupType = string; @@ -50,7 +51,7 @@ export const shouldIncludeMessageInList = ( }; export const useMessageList = (params: UseMessageListParams) => { - const { noGroupByUser, threadList, isLiveStreaming } = params; + const { noGroupByUser, threadList, isLiveStreaming, isFlashList } = params; const { client } = useChatContext(); const { hideDateSeparators, maxTimeBetweenGroupedMessages } = useChannelContext(); const { deletedMessagesVisibilityType, getMessagesGroupStyles = getGroupStyles } = @@ -106,11 +107,15 @@ export const useMessageList = (params: UseMessageListParams) => { userId: client.userID, }) ) { - newMessageList.unshift(message); + if (isFlashList) { + newMessageList.push(message); + } else { + newMessageList.unshift(message); + } } } return newMessageList; - }, [client.userID, deletedMessagesVisibilityType, messageList]); + }, [client.userID, deletedMessagesVisibilityType, isFlashList, messageList]); const data = useRAFCoalescedValue(processedMessageList, isLiveStreaming); diff --git a/package/src/components/MessageList/hooks/useShouldScrollToRecentOnNewOwnMessage.ts b/package/src/components/MessageList/hooks/useShouldScrollToRecentOnNewOwnMessage.ts index 43c722d87f..3293ba5d84 100644 --- a/package/src/components/MessageList/hooks/useShouldScrollToRecentOnNewOwnMessage.ts +++ b/package/src/components/MessageList/hooks/useShouldScrollToRecentOnNewOwnMessage.ts @@ -31,7 +31,7 @@ export function useShouldScrollToRecentOnNewOwnMessage( if (rawMessageList && rawMessageList.length) { if (!initialFocusRegistered.current) { initialFocusRegistered.current = true; - const lastMessage = rawMessageList[0]; + const lastMessage = rawMessageList[rawMessageList.length - 1]; if (lastMessage && lastMessage.user?.id === currentUserId) { lastFocusedOwnMessageId.current = lastMessage.id; } diff --git a/package/src/components/MessageList/utils/getLastReceivedMessageFlashList.ts b/package/src/components/MessageList/utils/getLastReceivedMessageFlashList.ts new file mode 100644 index 0000000000..ecf1a50e2e --- /dev/null +++ b/package/src/components/MessageList/utils/getLastReceivedMessageFlashList.ts @@ -0,0 +1,20 @@ +import { LocalMessage } from 'stream-chat'; + +import { MessageStatusTypes } from '../../../utils/utils'; + +export const getLastReceivedMessageFlashList = (messages: LocalMessage[]) => { + /** + * There are no status on dates so they will be skipped + */ + for (let i = messages.length - 1; i>= 0; i--) { + const message = messages[i]; + if ( + message?.status === MessageStatusTypes.RECEIVED || + message?.status === MessageStatusTypes.SENDING + ) { + return message; + } + } + + return; +}; diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index e040f8812e..93c177ccea 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -14,7 +14,16 @@ import { MessageInput as DefaultMessageInput, MessageInputProps, } from '../MessageInput/MessageInput'; -import type { MessageListProps } from '../MessageList/MessageList'; +import { MessageFlashList, MessageFlashListProps } from '../MessageList/MessageFlashList'; +import { MessageListProps } from '../MessageList/MessageList'; + +let FlashList; + +try { + FlashList = require('@shopify/flash-list').FlashList; +} catch { + FlashList = undefined; +} type ThreadPropsWithContext = Pick & Pick & @@ -37,6 +46,13 @@ type ThreadPropsWithContext = Pick & * Available props - https://getstream.io/chat/docs/sdk/reactnative/ui-components/message-list/#props * */ additionalMessageListProps?: Partial; + /** + * @experimental This prop is experimental and is subject to change. + * + * Additional props for underlying MessageListFlashList component. + * Available props - https://shopify.github.io/flash-list/docs/usage + */ + additionalMessageFlashListProps?: Partial; /** Make input focus on mounting thread */ autoFocus?: boolean; /** Closes thread on dismount, defaults to true */ @@ -58,6 +74,7 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { const { additionalMessageInputProps, additionalMessageListProps, + additionalMessageFlashListProps, autoFocus = true, closeThread, closeThreadOnDismount = true, @@ -108,11 +125,19 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { return ( - + {FlashList ? ( + + ) : ( + + )}

AltStyle によって変換されたページ (->オリジナル) /