From 76a8fbd6690703761f336f16e15c59e9d5eb62cf Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 10 Sep 2025 10:42:47 +0530 Subject: [PATCH 01/29] feat: basic implementation --- .../ExpoMessaging/app/channel/[cid]/index.tsx | 4 +- examples/ExpoMessaging/yarn.lock | 12 + examples/SampleApp/App.tsx | 1 - .../SampleApp/src/screens/ChannelScreen.tsx | 6 +- examples/TypeScriptMessaging/ios/Podfile.lock | 140 +-- examples/TypeScriptMessaging/package.json | 1 + examples/TypeScriptMessaging/yarn.lock | 17 +- package/package.json | 1 + .../MessageList/MessageFlashList.tsx | 901 ++++++++++++++++++ .../MessageList/hooks/useMessageFlashList.ts | 123 +++ package/src/components/Thread/Thread.tsx | 8 +- package/src/components/index.ts | 1 + package/yarn.lock | 17 +- 13 files changed, 1142 insertions(+), 90 deletions(-) create mode 100644 package/src/components/MessageList/MessageFlashList.tsx create mode 100644 package/src/components/MessageList/hooks/useMessageFlashList.ts diff --git a/examples/ExpoMessaging/app/channel/[cid]/index.tsx b/examples/ExpoMessaging/app/channel/[cid]/index.tsx index 88b35f33f4..f5caab9a0f 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, MessageListFlashList } 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/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..0cd728bb88 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -223,7 +223,6 @@ const DrawerNavigatorWrapper: React.FC<{ = ({ } return ( - + = ({ thread={selectedThread} > - + 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/examples/TypeScriptMessaging/package.json b/examples/TypeScriptMessaging/package.json index 6194eaa056..72decc1988 100644 --- a/examples/TypeScriptMessaging/package.json +++ b/examples/TypeScriptMessaging/package.json @@ -18,6 +18,7 @@ "@react-native-documents/picker": "^10.1.3", "@react-navigation/native": "^7.1.10", "@react-navigation/stack": "^7.3.3", + "@shopify/flash-list": "^2.0.3", "react": "19.1.0", "react-native": "0.80.2", "react-native-audio-recorder-player": "^3.6.13", diff --git a/examples/TypeScriptMessaging/yarn.lock b/examples/TypeScriptMessaging/yarn.lock index 46cd8ec3b9..b423fe11a4 100644 --- a/examples/TypeScriptMessaging/yarn.lock +++ b/examples/TypeScriptMessaging/yarn.lock @@ -1904,6 +1904,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" @@ -7590,16 +7597,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.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.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/package/package.json b/package/package.json index af967192c7..288119d21b 100644 --- a/package/package.json +++ b/package/package.json @@ -67,6 +67,7 @@ }, "dependencies": { "@gorhom/bottom-sheet": "^5.1.8", + "@shopify/flash-list": "^2.0.3", "@ungap/structured-clone": "^1.3.0", "dayjs": "1.11.13", "emoji-regex": "^10.4.0", diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx new file mode 100644 index 0000000000..8593b04e72 --- /dev/null +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -0,0 +1,901 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + FlatListProps, + FlatList as FlatListType, + ScrollViewProps, + StyleSheet, + View, + ViewabilityConfig, + ViewStyle, + ViewToken, +} from 'react-native'; + +import { FlashList, FlashListProps, FlashListRef } from '@shopify/flash-list'; +import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; + +import { useMessageFlashList } from './hooks/useMessageFlashList'; + +import { useMessageList } from './hooks/useMessageList'; +import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollToRecentOnNewOwnMessage'; + +import { InlineLoadingMoreIndicator } from './InlineLoadingMoreIndicator'; +import { InlineLoadingMoreRecentIndicator } from './InlineLoadingMoreRecentIndicator'; +import { InlineLoadingMoreRecentThreadIndicator } from './InlineLoadingMoreRecentThreadIndicator'; +import { getLastReceivedMessage } from './utils/getLastReceivedMessage'; + +import { + AttachmentPickerContextValue, + useAttachmentPickerContext, +} from '../../contexts/attachmentPickerContext/AttachmentPickerContext'; +import { + ChannelContextValue, + useChannelContext, +} from '../../contexts/channelContext/ChannelContext'; +import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; +import { useDebugContext } from '../../contexts/debugContext/DebugContext'; +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'; + +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, +}; + +type MessageListFlashListPropsWithContext = 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 + * + * ``` + */ + additionalFlatListProps?: 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; + }; + +const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithContext) => { + const LoadingMoreRecentIndicator = props.threadList + ? InlineLoadingMoreRecentThreadIndicator + : InlineLoadingMoreRecentIndicator; + const { + additionalFlatListProps, + channel, + channelUnreadState, + client, + closePicker, + DateHeader, + disabled, + disableTypingIndicator, + EmptyStateIndicator, + // FlatList, + FooterComponent = LoadingMoreRecentIndicator, + HeaderComponent = InlineLoadingMoreIndicator, + hideStickyDateHeader, + highlightedMessageId, + InlineDateSeparator, + InlineUnreadIndicator, + isListActive = 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 { + theme: { + colors: { white_snow }, + messageList: { container, contentContainer, listContainer }, + }, + } = useTheme(); + const [hasMoved, setHasMoved] = useState(false); + + const flashListRef = useRef | null>(null); + /** + * The timeout id used to temporarily load the initial scroll set flag + */ + const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); + const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); + + const [stickyHeaderDate, setStickyHeaderDate] = useState(); + 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); + + const { dateSeparatorsRef, messageGroupStylesRef, processedMessageList } = useMessageFlashList({ + noGroupByUser, + threadList, + }); + const messageListLengthAfterUpdate = processedMessageList.length; + + const lastReceivedId = useMemo( + () => getLastReceivedMessage(processedMessageList)?.id, + [processedMessageList], + ); + + useEffect(() => { + if (disabled) { + setScrollToBottomButtonVisible(false); + } + }, [disabled]); + + const updateStickyHeaderDateIfNeeded = useStableCallback((viewableItems: ViewToken[]) => { + if (!viewableItems.length) { + return; + } + + const lastItem = viewableItems[viewableItems.length - 1]; + + if (lastItem) { + if ( + !channel.state.messagePagination.hasPrev && + processedMessageList[processedMessageList.length - 1].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); + } + } + }); + + const messagesLength = useRef(processedMessageList.length); + + /** + * This function should show or hide the unread indicator depending on the + */ + const updateStickyUnreadIndicator = useStableCallback((viewableItems: ViewToken[]) => { + // we need this check to make sure that regular list change do not trigger + // the unread notification to appear (for example if the old last read messages + // go out of the viewport). + if (processedMessageList.length !== messagesLength.current) { + return; + } + messagesLength.current = processedMessageList.length; + + if (!viewableItems.length) { + setIsUnreadNotificationOpen(false); + return; + } + + if (selectedPicker === 'images') { + setIsUnreadNotificationOpen(false); + return; + } + + const lastItem = viewableItems[viewableItems.length - 1]; + + if (lastItem) { + 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[processedMessageList.length - 1].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 goToMessage = useStableCallback(async (messageId: string) => { + const indexOfParentInMessageList = processedMessageList.findIndex( + (message) => message?.id === messageId, + ); + try { + if (indexOfParentInMessageList === -1) { + await loadChannelAroundMessage({ messageId }); + return; + } else { + if (!flashListRef.current) { + return; + } + 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); + } + }); + + 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, + myMessageTheme, + onThreadSelect, + shouldShowUnreadUnderlay, + threadList, + ], + ); + + /** + * 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); + }); + + /** + * 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); + }); + + 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) { + 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(); + } + }); + + const flatListStyle = useMemo( + () => ({ ...styles.listContainer, ...listContainer, ...additionalFlatListProps?.style }), + [additionalFlatListProps?.style, listContainer], + ); + + const flatListContentContainerStyle = useMemo( + () => ({ + ...styles.contentContainer, + ...contentContainer, + }), + [contentContainer], + ); + + const handleScroll: ScrollViewProps['onScroll'] = useStableCallback((event) => { + const messageListHasMessages = processedMessageList.length > 0; + const nativeEvent = event.nativeEvent; + const offset = nativeEvent.contentOffset.y; + 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 refCallback = useStableCallback((ref: FlashListRef) => { + flashListRef.current = ref; + + if (setFlatListRef) { + setFlatListRef(ref); + } + }); + + 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); + }); + + /** + * Resets the pagination trackers, doing so cancels currently scheduled loading more calls + */ + const resetPaginationTrackersRef = useRef(() => { + onStartReachedTracker.current = {}; + onEndReachedTracker.current = {}; + }); + + 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 onUnreadNotificationClose = useStableCallback(async () => { + await markRead(); + setIsUnreadNotificationOpen(false); + }); + + if (loading) { + return ( + + + + ); + } + + return ( + + {processedMessageList.length === 0 && !thread ? ( + + {EmptyStateIndicator ? : null} + + ) : ( + + )} + + {messageListLengthAfterUpdate && StickyHeader ? ( + + ) : null} + + {!disableTypingIndicator && TypingIndicator && ( + + + + )} + + + {isUnreadNotificationOpen && !threadList ? ( + + ) : null} + + ); +}; + +export type MessageListFlashListProps = Partial; + +export const MessageListFlashList = (props: MessageListFlashListProps) => { + 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/hooks/useMessageFlashList.ts b/package/src/components/MessageList/hooks/useMessageFlashList.ts new file mode 100644 index 0000000000..a96293e3fe --- /dev/null +++ b/package/src/components/MessageList/hooks/useMessageFlashList.ts @@ -0,0 +1,123 @@ +import { useMemo, useRef } from 'react'; + +import type { LocalMessage } from 'stream-chat'; + +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { + DeletedMessagesVisibilityType, + useMessagesContext, +} from '../../../contexts/messagesContext/MessagesContext'; +import { usePaginatedMessageListContext } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; +import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'; + +import { DateSeparators, getDateSeparators } from '../utils/getDateSeparators'; +import { getGroupStyles } from '../utils/getGroupStyles'; + +export type UseMessageListParams = { + deletedMessagesVisibilityType?: DeletedMessagesVisibilityType; + noGroupByUser?: boolean; + threadList?: boolean; +}; + +export type GroupType = string; + +export type MessageGroupStyles = { + [key: string]: string[]; +}; + +export const shouldIncludeMessageInList = ( + message: LocalMessage, + options: { deletedMessagesVisibilityType?: DeletedMessagesVisibilityType; userId?: string }, +) => { + const { deletedMessagesVisibilityType, userId } = options; + const isMessageTypeDeleted = message.type === 'deleted'; + switch (deletedMessagesVisibilityType) { + case 'sender': + return !isMessageTypeDeleted || message.user?.id === userId; + + case 'receiver': + return !isMessageTypeDeleted || message.user?.id !== userId; + + case 'never': + return !isMessageTypeDeleted; + + default: + return !!message; + } +}; + +export const useMessageFlashList = (params: UseMessageListParams) => { + const { noGroupByUser, threadList } = params; + const { client } = useChatContext(); + const { hideDateSeparators, maxTimeBetweenGroupedMessages } = useChannelContext(); + const { deletedMessagesVisibilityType, getMessagesGroupStyles = getGroupStyles } = + useMessagesContext(); + const { messages } = usePaginatedMessageListContext(); + const { threadMessages } = useThreadContext(); + const messageList = threadList ? threadMessages : messages; + + const dateSeparators = useMemo( + () => + getDateSeparators({ + deletedMessagesVisibilityType, + hideDateSeparators, + messages: messageList, + userId: client.userID, + }), + [deletedMessagesVisibilityType, hideDateSeparators, messageList, client.userID], + ); + + const dateSeparatorsRef = useRef(dateSeparators); + dateSeparatorsRef.current = dateSeparators; + + const messageGroupStyles = useMemo( + () => + getMessagesGroupStyles({ + dateSeparators: dateSeparatorsRef.current, + hideDateSeparators, + maxTimeBetweenGroupedMessages, + messages: messageList, + noGroupByUser, + userId: client.userID, + }), + [ + dateSeparatorsRef, + getMessagesGroupStyles, + hideDateSeparators, + maxTimeBetweenGroupedMessages, + messageList, + noGroupByUser, + client.userID, + ], + ); + + const messageGroupStylesRef = useRef(messageGroupStyles); + messageGroupStylesRef.current = messageGroupStyles; + + const processedMessageList = useMemo(() => { + const newMessageList = []; + for (const message of messageList) { + if ( + shouldIncludeMessageInList(message, { + deletedMessagesVisibilityType, + userId: client.userID, + }) + ) { + newMessageList.unshift(message); + } + } + return newMessageList; + }, [client.userID, deletedMessagesVisibilityType, messageList]); + + return { + /** Date separators */ + dateSeparatorsRef, + /** Message group styles */ + messageGroupStylesRef, + /** Messages enriched with dates/readby/groups and also reversed in order */ + processedMessageList: messageList, + /** Raw messages from the channel state */ + rawMessageList: messageList, + }; +}; diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index e040f8812e..11fee630af 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -14,7 +14,7 @@ import { MessageInput as DefaultMessageInput, MessageInputProps, } from '../MessageInput/MessageInput'; -import type { MessageListProps } from '../MessageList/MessageList'; +import { MessageListFlashList, MessageListFlashListProps } from '../MessageList/MessageFlashList'; type ThreadPropsWithContext = Pick & Pick & @@ -36,7 +36,7 @@ type ThreadPropsWithContext = Pick & * Additional props for underlying MessageList component. * Available props - https://getstream.io/chat/docs/sdk/reactnative/ui-components/message-list/#props * */ - additionalMessageListProps?: Partial; + additionalMessageListProps?: Partial; /** Make input focus on mounting thread */ autoFocus?: boolean; /** Closes thread on dismount, defaults to true */ @@ -108,8 +108,8 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { return ( - diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 30cc31bbdd..043acd4acf 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -145,6 +145,7 @@ export * from './MessageList/InlineLoadingMoreIndicator'; export * from './MessageList/InlineLoadingMoreRecentIndicator'; export * from './MessageList/InlineUnreadIndicator'; export * from './MessageList/MessageList'; +export * from './MessageList/MessageFlashList'; export * from './MessageList/MessageSystem'; export * from './MessageList/NetworkDownIndicator'; export * from './MessageList/ScrollToBottomButton'; diff --git a/package/yarn.lock b/package/yarn.lock index e3a00e8e8f..b2258ec83a 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -2120,6 +2120,13 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== +"@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" @@ -8687,16 +8694,16 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@2.8.1, 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.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" From 2a58a16efd12a2b56a8b2fda17de29d80ed325ac Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 16 Sep 2025 13:16:22 +0530 Subject: [PATCH 02/29] fix: issues in MessageFlashList component --- .../MessageList/MessageFlashList.tsx | 700 +++++++++++++----- .../components/MessageList/MessageList.tsx | 8 - .../MessageList/hooks/useMessageFlashList.ts | 10 +- ...dScrollToRecentOnNewOwnMessageFlashList.ts | 43 ++ package/src/components/Thread/Thread.tsx | 2 +- 5 files changed, 555 insertions(+), 208 deletions(-) create mode 100644 package/src/components/MessageList/hooks/useShouldScrollToRecentOnNewOwnMessageFlashList.ts diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 8593b04e72..2509f97fd0 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -1,23 +1,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - FlatListProps, - FlatList as FlatListType, - ScrollViewProps, - StyleSheet, - View, - ViewabilityConfig, - ViewStyle, - ViewToken, -} from 'react-native'; +import { ScrollViewProps, StyleSheet, View, ViewabilityConfig, ViewToken } from 'react-native'; import { FlashList, FlashListProps, FlashListRef } from '@shopify/flash-list'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; import { useMessageFlashList } from './hooks/useMessageFlashList'; -import { useMessageList } from './hooks/useMessageList'; -import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollToRecentOnNewOwnMessage'; - +import { useShouldScrollToRecentOnNewOwnMessageFlashList } from './hooks/useShouldScrollToRecentOnNewOwnMessageFlashList'; import { InlineLoadingMoreIndicator } from './InlineLoadingMoreIndicator'; import { InlineLoadingMoreRecentIndicator } from './InlineLoadingMoreRecentIndicator'; import { InlineLoadingMoreRecentThreadIndicator } from './InlineLoadingMoreRecentThreadIndicator'; @@ -32,7 +21,6 @@ import { useChannelContext, } from '../../contexts/channelContext/ChannelContext'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; -import { useDebugContext } from '../../contexts/debugContext/DebugContext'; import { ImageGalleryContextValue, useImageGalleryContext, @@ -45,10 +33,11 @@ import { PaginatedMessageListContextValue, usePaginatedMessageListContext, } from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; -import { mergeThemes, ThemeProvider, useTheme } from '../../contexts/themeContext/ThemeContext'; +import { ThemeProvider, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { useStableCallback } from '../../hooks'; +import { FileTypes } from '../../types/types'; const keyExtractor = (item: LocalMessage) => { if (item.id) { @@ -64,6 +53,26 @@ const flatListViewabilityConfig: ViewabilityConfig = { viewAreaCoveragePercentThreshold: 1, }; +const hasReadLastMessage = (channel: Channel, userId: string) => { + const latestMessageIdInChannel = channel.state.latestMessages.slice(-1)[0]?.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 MessageListFlashListPropsWithContext = Pick< AttachmentPickerContextValue, 'closePicker' | 'selectedPicker' | 'setSelectedPicker' @@ -127,7 +136,7 @@ type MessageListFlashListPropsWithContext = Pick< * additionalFlatListProps={{ bounces: true, keyboardDismissMode: true }} /> * ``` */ - additionalFlatListProps?: Partial>; + 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` @@ -172,12 +181,14 @@ type MessageListFlashListPropsWithContext = Pick< setFlatListRef?: (ref: FlashListRef | null) => void; }; +const WAIT_FOR_SCROLL_TIMEOUT = 200; + const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithContext) => { const LoadingMoreRecentIndicator = props.threadList ? InlineLoadingMoreRecentThreadIndicator : InlineLoadingMoreRecentIndicator; const { - additionalFlatListProps, + additionalFlashListProps, channel, channelUnreadState, client, @@ -228,24 +239,15 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon TypingIndicatorContainer, UnreadMessagesNotification, } = props; - const { - theme: { - colors: { white_snow }, - messageList: { container, contentContainer, listContainer }, - }, - } = useTheme(); - const [hasMoved, setHasMoved] = useState(false); - const flashListRef = useRef | null>(null); - /** - * The timeout id used to temporarily load the initial scroll set flag - */ + + const [hasMoved, setHasMoved] = useState(false); const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); - const [stickyHeaderDate, setStickyHeaderDate] = useState(); - const stickyHeaderDateRef = useRef(undefined); + 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. @@ -256,66 +258,320 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon const onStartReachedInPromise = useRef | null>(null); const onEndReachedInPromise = useRef | null>(null); - const { dateSeparatorsRef, messageGroupStylesRef, processedMessageList } = useMessageFlashList({ - noGroupByUser, - threadList, - }); + /** + * The timeout id used to debounce our scrollToIndex calls on messageList updates + */ + const scrollToDebounceTimeoutRef = useRef>(undefined); + + const messageListLengthBeforeUpdate = useRef(0); + const channelResyncScrollSet = useRef(true); + + const { + theme: { + colors: { white_snow }, + messageList: { container, contentContainer, listContainer }, + }, + } = useTheme(); + + const { dateSeparatorsRef, messageGroupStylesRef, processedMessageList, rawMessageList } = + useMessageFlashList({ + 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 messageListLengthAfterUpdate = processedMessageList.length; + const shouldScrollToRecentOnNewOwnMessageRef = useShouldScrollToRecentOnNewOwnMessageFlashList( + rawMessageList, + client.userID, + ); + const lastReceivedId = useMemo( () => getLastReceivedMessage(processedMessageList)?.id, [processedMessageList], ); + const maintainVisibleContentPosition = useMemo(() => { + console.log('autoScrollToRecent', autoScrollToRecent); + return { + autoscrollToBottomThreshold: autoScrollToRecent || threadList ? 10 : undefined, + startRenderingFromBottom: true, + }; + }, [autoScrollToRecent, threadList]); + useEffect(() => { if (disabled) { setScrollToBottomButtonVisible(false); } }, [disabled]); - const updateStickyHeaderDateIfNeeded = useStableCallback((viewableItems: ViewToken[]) => { - if (!viewableItems.length) { + /** + * 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 lastItem = viewableItems[viewableItems.length - 1]; + const indexOfParentInMessageList = processedMessageList.findIndex( + (message) => message?.id === targetedMessage, + ); - if (lastItem) { - if ( - !channel.state.messagePagination.hasPrev && - processedMessageList[processedMessageList.length - 1].id === lastItem.item.id - ) { - setStickyHeaderDate(undefined); + // 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(() => { + if (!flashListRef.current) { + return; + } + clearTimeout(scrollToDebounceTimeoutRef.current); + + // now scroll to it + flashListRef.current.scrollToIndex({ + animated: true, + index: indexOfParentInMessageList, + viewPosition: 0.5, + }); + setTargetedMessage(undefined); + }, WAIT_FOR_SCROLL_TIMEOUT); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [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) { + await loadChannelAroundMessage({ messageId }); + setTargetedMessage(messageId); + + setTimeout(() => { + // 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 + }); + }, WAIT_FOR_SCROLL_TIMEOUT); + } + } 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 = + messageListLengthAfterUpdate < messageListLengthBeforeUpdate.current; + + /** + * 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; } - 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() + isMessageRemovedFromMessageList || + (topMessageBeforeUpdate.current?.created_at && + topMessageAfterUpdate?.created_at && + topMessageBeforeUpdate.current.created_at < topMessageAfterUpdate.created_at) ) { - stickyHeaderDateRef.current = lastItem.item.created_at; - setStickyHeaderDate(lastItem.item.created_at); + channelResyncScrollSet.current = false; + setScrollToBottomButtonVisible(false); + resetPaginationTrackersRef.current(); + + setAutoScrollToRecent(true); + setTimeout(() => { + channelResyncScrollSet.current = true; + if (channel.countUnread() > 0) { + markRead(); + } + }, WAIT_FOR_SCROLL_TIMEOUT); } + }; + + if (isMessageRemovedFromMessageList) { + scrollToBottomIfNeeded(); } - }); - const messagesLength = useRef(processedMessageList.length); + 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; + + // TODO: FIX THIS + // setAutoScrollToRecent(!didMergeMessageSetsWithNoUpdates); + + 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) { + setAutoScrollToRecent(true); + } + } + }, [channel, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef, threadList]); + + useEffect(() => { + const handleEvent = (event: Event) => { + if (event.message?.user?.id === client.userID) { + setAutoScrollToRecent(true); + } else { + if (!scrollToBottomButtonVisible) { + setAutoScrollToRecent(true); + } else { + setAutoScrollToRecent(false); + } + } + }; + const listener: ReturnType = channel.on('message.new', handleEvent); + + return () => { + listener?.unsubscribe(); + }; + }, [channel, client.userID, scrollToBottomButtonVisible]); /** - * This function should show or hide the unread indicator depending on the + * Effect to mark the channel as read when the user scrolls to the bottom of the message list. */ - const updateStickyUnreadIndicator = useStableCallback((viewableItems: ViewToken[]) => { - // we need this check to make sure that regular list change do not trigger - // the unread notification to appear (for example if the old last read messages - // go out of the viewport). - if (processedMessageList.length !== messagesLength.current) { + 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; } - messagesLength.current = processedMessageList.length; + 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; @@ -326,40 +582,40 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon return; } - const lastItem = viewableItems[viewableItems.length - 1]; + const lastItem = viewableItems[0]; - if (lastItem) { - const lastItemMessage = lastItem.item; - const lastItemCreatedAt = lastItemMessage.created_at; + if (!lastItem) return; - const unreadIndicatorDate = channelUnreadState?.last_read.getTime(); - const lastItemDate = lastItemCreatedAt.getTime(); + const lastItemMessage = lastItem.item; + const lastItemCreatedAt = lastItemMessage.created_at; - if ( - !channel.state.messagePagination.hasPrev && - processedMessageList[processedMessageList.length - 1].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); - } + 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); } }); @@ -391,32 +647,6 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon [], ); - const goToMessage = useStableCallback(async (messageId: string) => { - const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === messageId, - ); - try { - if (indexOfParentInMessageList === -1) { - await loadChannelAroundMessage({ messageId }); - return; - } else { - if (!flashListRef.current) { - return; - } - 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); - } - }); - const renderItem = useCallback( ({ index, item: message }: { index: number; item: LocalMessage }) => { if (!channel || channel.disconnected || (!channel.initialized && !channel.offlineMode)) { @@ -502,43 +732,76 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon ], ); + 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; + }); + /** - * 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. + * 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 maybeCallOnEndReached = useStableCallback(async () => { - // If onStartReached has already been called for given data length, then ignore. - if (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) { - return; - } + 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; - 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; + 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, + ]); - onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) - .then(callback) - .catch(onError); - }); + /** + * 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. @@ -583,6 +846,44 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon .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; @@ -605,18 +906,13 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon } }); - const flatListStyle = useMemo( - () => ({ ...styles.listContainer, ...listContainer, ...additionalFlatListProps?.style }), - [additionalFlatListProps?.style, listContainer], - ); - - const flatListContentContainerStyle = useMemo( - () => ({ - ...styles.contentContainer, - ...contentContainer, - }), - [contentContainer], - ); + /** + * Resets the pagination trackers, doing so cancels currently scheduled loading more calls + */ + const resetPaginationTrackersRef = useRef(() => { + onStartReachedTracker.current = {}; + onEndReachedTracker.current = {}; + }); const handleScroll: ScrollViewProps['onScroll'] = useStableCallback((event) => { const messageListHasMessages = processedMessageList.length > 0; @@ -646,12 +942,26 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon } }); - const refCallback = useStableCallback((ref: FlashListRef) => { - flashListRef.current = ref; + const goToNewMessages = useStableCallback(async () => { + const isNotLatestSet = channel.state.messages !== channel.state.latestMessages; - if (setFlatListRef) { - setFlatListRef(ref); + 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(() => { @@ -671,34 +981,12 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon onUserScrollEvent(event); }); - /** - * Resets the pagination trackers, doing so cancels currently scheduled loading more calls - */ - const resetPaginationTrackersRef = useRef(() => { - onStartReachedTracker.current = {}; - onEndReachedTracker.current = {}; - }); - - const goToNewMessages = useStableCallback(async () => { - const isNotLatestSet = channel.state.messages !== channel.state.latestMessages; + const refCallback = useStableCallback((ref: FlashListRef) => { + flashListRef.current = ref; - if (isNotLatestSet) { - resetPaginationTrackersRef.current(); - await reloadChannel(); - } else if (flashListRef.current) { - flashListRef.current.scrollToEnd({ - animated: true, - }); + if (setFlatListRef) { + setFlatListRef(ref); } - - 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 onUnreadNotificationClose = useStableCallback(async () => { @@ -706,6 +994,30 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon 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], + ); + if (loading) { return ( @@ -731,10 +1043,7 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon keyExtractor={keyExtractor} ListFooterComponent={FooterComponent} ListHeaderComponent={HeaderComponent} - maintainVisibleContentPosition={{ - autoscrollToBottomThreshold: threadList ? 10 : undefined, - startRenderingFromBottom: true, - }} + maintainVisibleContentPosition={maintainVisibleContentPosition} onMomentumScrollEnd={onUserScrollEvent} onScroll={handleScroll} onScrollBeginDrag={onScrollBeginDrag} @@ -743,8 +1052,11 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon onViewableItemsChanged={stableOnViewableItemsChanged} ref={refCallback} renderItem={renderItem} + showsVerticalScrollIndicator={false} style={flatListStyle} + testID='message-flash-list' viewabilityConfig={flatListViewabilityConfig} + {...additionalFlashListPropsExcludingStyle} /> )} diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index bb5ff63ad5..4f74461c49 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%', @@ -425,9 +420,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { // we need this check to make sure that regular list change do not trigger // the unread notification to appear (for example if the old last read messages // go out of the viewport). - if (processedMessageList.length !== messagesLength.current) { - return; - } messagesLength.current = processedMessageList.length; if (!viewableItems.length) { diff --git a/package/src/components/MessageList/hooks/useMessageFlashList.ts b/package/src/components/MessageList/hooks/useMessageFlashList.ts index a96293e3fe..ef66ec434f 100644 --- a/package/src/components/MessageList/hooks/useMessageFlashList.ts +++ b/package/src/components/MessageList/hooks/useMessageFlashList.ts @@ -92,9 +92,6 @@ export const useMessageFlashList = (params: UseMessageListParams) => { ], ); - const messageGroupStylesRef = useRef(messageGroupStyles); - messageGroupStylesRef.current = messageGroupStyles; - const processedMessageList = useMemo(() => { const newMessageList = []; for (const message of messageList) { @@ -104,19 +101,22 @@ export const useMessageFlashList = (params: UseMessageListParams) => { userId: client.userID, }) ) { - newMessageList.unshift(message); + newMessageList.push(message); } } return newMessageList; }, [client.userID, deletedMessagesVisibilityType, messageList]); + const messageGroupStylesRef = useRef(messageGroupStyles); + messageGroupStylesRef.current = messageGroupStyles; + return { /** Date separators */ dateSeparatorsRef, /** Message group styles */ messageGroupStylesRef, /** Messages enriched with dates/readby/groups and also reversed in order */ - processedMessageList: messageList, + processedMessageList, /** Raw messages from the channel state */ rawMessageList: messageList, }; diff --git a/package/src/components/MessageList/hooks/useShouldScrollToRecentOnNewOwnMessageFlashList.ts b/package/src/components/MessageList/hooks/useShouldScrollToRecentOnNewOwnMessageFlashList.ts new file mode 100644 index 0000000000..0aa75bdccf --- /dev/null +++ b/package/src/components/MessageList/hooks/useShouldScrollToRecentOnNewOwnMessageFlashList.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef } from 'react'; + +import type { LocalMessage } from 'stream-chat'; + +export function useShouldScrollToRecentOnNewOwnMessageFlashList( + rawMessageList: LocalMessage[], + currentUserId?: string, +) { + const lastFocusedOwnMessageId = useRef(''); + const initialFocusRegistered = useRef(false); + const messagesRef = useRef(rawMessageList); + messagesRef.current = rawMessageList; + + const isMyOwnNewMessageRef = useRef(() => { + if (messagesRef.current && messagesRef.current.length > 0) { + const lastMessage = messagesRef.current[messagesRef.current.length - 1]; + + if ( + lastMessage && + lastMessage.user?.id === currentUserId && + lastFocusedOwnMessageId.current !== lastMessage.id + ) { + lastFocusedOwnMessageId.current = lastMessage.id; + return true; + } + } + return false; + }); + + useEffect(() => { + if (rawMessageList && rawMessageList.length) { + if (!initialFocusRegistered.current) { + initialFocusRegistered.current = true; + const lastMessage = rawMessageList[rawMessageList.length - 1]; + if (lastMessage && lastMessage.user?.id === currentUserId) { + lastFocusedOwnMessageId.current = lastMessage.id; + } + } + } + }, [currentUserId, rawMessageList]); + + return isMyOwnNewMessageRef; +} diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index 11fee630af..8ed6944437 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -64,7 +64,7 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { disabled, loadMoreThread, MessageInput = DefaultMessageInput, - MessageList, + // MessageList, onThreadDismount, parentMessagePreventPress = true, thread, From 350a99a15e51444868e52284abffd7c6c3919fca Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 17 Sep 2025 11:57:18 +0530 Subject: [PATCH 03/29] fix: auto scroll when needed --- package/src/components/MessageList/MessageFlashList.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 2509f97fd0..3eec9f9594 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -468,11 +468,11 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon useEffect(() => { const handleEvent = (event: Event) => { - if (event.message?.user?.id === client.userID) { - setAutoScrollToRecent(true); - } else { + if (event.message?.user?.id !== client.userID) { if (!scrollToBottomButtonVisible) { - setAutoScrollToRecent(true); + flashListRef.current?.scrollToEnd({ + animated: true, + }); } else { setAutoScrollToRecent(false); } From a8e38031649bc4a6ee69d8bdce1fcee1e8953b1c Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 17 Sep 2025 12:23:09 +0530 Subject: [PATCH 04/29] fix: hooks refactor --- .../MessageList/MessageFlashList.tsx | 30 +++++++------ .../useShouldScrollToRecentOnNewOwnMessage.ts | 2 +- ...dScrollToRecentOnNewOwnMessageFlashList.ts | 43 ------------------- 3 files changed, 18 insertions(+), 57 deletions(-) delete mode 100644 package/src/components/MessageList/hooks/useShouldScrollToRecentOnNewOwnMessageFlashList.ts diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 3eec9f9594..1a32b9b4f8 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -6,7 +6,7 @@ import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat' import { useMessageFlashList } from './hooks/useMessageFlashList'; -import { useShouldScrollToRecentOnNewOwnMessageFlashList } from './hooks/useShouldScrollToRecentOnNewOwnMessageFlashList'; +import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollToRecentOnNewOwnMessage'; import { InlineLoadingMoreIndicator } from './InlineLoadingMoreIndicator'; import { InlineLoadingMoreRecentIndicator } from './InlineLoadingMoreRecentIndicator'; import { InlineLoadingMoreRecentThreadIndicator } from './InlineLoadingMoreRecentThreadIndicator'; @@ -33,7 +33,7 @@ import { PaginatedMessageListContextValue, usePaginatedMessageListContext, } from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; -import { ThemeProvider, useTheme } from '../../contexts/themeContext/ThemeContext'; +import { mergeThemes, ThemeProvider, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { useStableCallback } from '../../hooks'; @@ -265,13 +265,20 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon const messageListLengthBeforeUpdate = useRef(0); const channelResyncScrollSet = useRef(true); + const { theme } = useTheme(); const { - theme: { - colors: { white_snow }, - messageList: { container, contentContainer, listContainer }, - }, - } = useTheme(); + 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 } = useMessageFlashList({ @@ -290,7 +297,7 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon const messageListLengthAfterUpdate = processedMessageList.length; - const shouldScrollToRecentOnNewOwnMessageRef = useShouldScrollToRecentOnNewOwnMessageFlashList( + const shouldScrollToRecentOnNewOwnMessageRef = useShouldScrollToRecentOnNewOwnMessage( rawMessageList, client.userID, ); @@ -301,7 +308,6 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon ); const maintainVisibleContentPosition = useMemo(() => { - console.log('autoScrollToRecent', autoScrollToRecent); return { autoscrollToBottomThreshold: autoScrollToRecent || threadList ? 10 : undefined, startRenderingFromBottom: true, @@ -452,9 +458,6 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon const didMergeMessageSetsWithNoUpdates = latestNonCurrentMessageBeforeUpdate?.id === latestCurrentMessageAfterUpdate.id; - // TODO: FIX THIS - // setAutoScrollToRecent(!didMergeMessageSetsWithNoUpdates); - if (!didMergeMessageSetsWithNoUpdates) { const shouldScrollToRecentOnNewOwnMessage = shouldScrollToRecentOnNewOwnMessageRef.current(); @@ -693,7 +696,7 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon {message.type === 'system' ? ( ) : wrapMessageInTheme ? ( - + {renderDateSeperator} {renderMessage} @@ -725,6 +728,7 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon highlightedMessageId, lastReceivedId, messageGroupStylesRef, + modifiedTheme, myMessageTheme, onThreadSelect, shouldShowUnreadUnderlay, 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/hooks/useShouldScrollToRecentOnNewOwnMessageFlashList.ts b/package/src/components/MessageList/hooks/useShouldScrollToRecentOnNewOwnMessageFlashList.ts deleted file mode 100644 index 0aa75bdccf..0000000000 --- a/package/src/components/MessageList/hooks/useShouldScrollToRecentOnNewOwnMessageFlashList.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useEffect, useRef } from 'react'; - -import type { LocalMessage } from 'stream-chat'; - -export function useShouldScrollToRecentOnNewOwnMessageFlashList( - rawMessageList: LocalMessage[], - currentUserId?: string, -) { - const lastFocusedOwnMessageId = useRef(''); - const initialFocusRegistered = useRef(false); - const messagesRef = useRef(rawMessageList); - messagesRef.current = rawMessageList; - - const isMyOwnNewMessageRef = useRef(() => { - if (messagesRef.current && messagesRef.current.length > 0) { - const lastMessage = messagesRef.current[messagesRef.current.length - 1]; - - if ( - lastMessage && - lastMessage.user?.id === currentUserId && - lastFocusedOwnMessageId.current !== lastMessage.id - ) { - lastFocusedOwnMessageId.current = lastMessage.id; - return true; - } - } - return false; - }); - - useEffect(() => { - if (rawMessageList && rawMessageList.length) { - if (!initialFocusRegistered.current) { - initialFocusRegistered.current = true; - const lastMessage = rawMessageList[rawMessageList.length - 1]; - if (lastMessage && lastMessage.user?.id === currentUserId) { - lastFocusedOwnMessageId.current = lastMessage.id; - } - } - } - }, [currentUserId, rawMessageList]); - - return isMyOwnNewMessageRef; -} From 366516ec2717d0f599a8243e923b6dac03527c54 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 17 Sep 2025 22:30:02 +0530 Subject: [PATCH 05/29] fix: implementation --- .../MessageList/MessageFlashList.tsx | 25 ++-- .../MessageList/hooks/useMessageFlashList.ts | 123 ------------------ .../MessageList/hooks/useMessageList.ts | 11 +- .../utils/getLastReceivedMessageFlashList.ts | 20 +++ 4 files changed, 40 insertions(+), 139 deletions(-) delete mode 100644 package/src/components/MessageList/hooks/useMessageFlashList.ts create mode 100644 package/src/components/MessageList/utils/getLastReceivedMessageFlashList.ts diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 1a32b9b4f8..115e254cd0 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -4,13 +4,13 @@ import { ScrollViewProps, StyleSheet, View, ViewabilityConfig, ViewToken } from import { FlashList, FlashListProps, FlashListRef } from '@shopify/flash-list'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; -import { useMessageFlashList } from './hooks/useMessageFlashList'; - +import { useMessageList } from './hooks/useMessageList'; import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollToRecentOnNewOwnMessage'; import { InlineLoadingMoreIndicator } from './InlineLoadingMoreIndicator'; import { InlineLoadingMoreRecentIndicator } from './InlineLoadingMoreRecentIndicator'; import { InlineLoadingMoreRecentThreadIndicator } from './InlineLoadingMoreRecentThreadIndicator'; -import { getLastReceivedMessage } from './utils/getLastReceivedMessage'; + +import { getLastReceivedMessageFlashList } from './utils/getLastReceivedMessageFlashList'; import { AttachmentPickerContextValue, @@ -263,7 +263,6 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon */ const scrollToDebounceTimeoutRef = useRef>(undefined); - const messageListLengthBeforeUpdate = useRef(0); const channelResyncScrollSet = useRef(true); const { theme } = useTheme(); @@ -281,7 +280,8 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon ); const { dateSeparatorsRef, messageGroupStylesRef, processedMessageList, rawMessageList } = - useMessageFlashList({ + useMessageList({ + isFlashList: true, noGroupByUser, threadList, }); @@ -295,6 +295,7 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon const latestNonCurrentMessageBeforeUpdateRef = useRef(undefined); + const messageListLengthBeforeUpdate = useRef(0); const messageListLengthAfterUpdate = processedMessageList.length; const shouldScrollToRecentOnNewOwnMessageRef = useShouldScrollToRecentOnNewOwnMessage( @@ -303,7 +304,7 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon ); const lastReceivedId = useMemo( - () => getLastReceivedMessage(processedMessageList)?.id, + () => getLastReceivedMessageFlashList(processedMessageList)?.id, [processedMessageList], ); @@ -338,13 +339,10 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage }); } else { scrollToDebounceTimeoutRef.current = setTimeout(() => { - if (!flashListRef.current) { - return; - } clearTimeout(scrollToDebounceTimeoutRef.current); // now scroll to it - flashListRef.current.scrollToIndex({ + flashListRef.current?.scrollToIndex({ animated: true, index: indexOfParentInMessageList, viewPosition: 0.5, @@ -352,8 +350,7 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon setTargetedMessage(undefined); }, WAIT_FOR_SCROLL_TIMEOUT); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [targetedMessage]); + }, [loadChannelAroundMessage, processedMessageList, setTargetedMessage, targetedMessage]); const goToMessage = useStableCallback(async (messageId: string) => { const indexOfParentInMessageList = processedMessageList.findIndex( @@ -460,7 +457,6 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon 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) { @@ -469,6 +465,9 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon } }, [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) { diff --git a/package/src/components/MessageList/hooks/useMessageFlashList.ts b/package/src/components/MessageList/hooks/useMessageFlashList.ts deleted file mode 100644 index ef66ec434f..0000000000 --- a/package/src/components/MessageList/hooks/useMessageFlashList.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { useMemo, useRef } from 'react'; - -import type { LocalMessage } from 'stream-chat'; - -import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; -import { useChatContext } from '../../../contexts/chatContext/ChatContext'; -import { - DeletedMessagesVisibilityType, - useMessagesContext, -} from '../../../contexts/messagesContext/MessagesContext'; -import { usePaginatedMessageListContext } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; -import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'; - -import { DateSeparators, getDateSeparators } from '../utils/getDateSeparators'; -import { getGroupStyles } from '../utils/getGroupStyles'; - -export type UseMessageListParams = { - deletedMessagesVisibilityType?: DeletedMessagesVisibilityType; - noGroupByUser?: boolean; - threadList?: boolean; -}; - -export type GroupType = string; - -export type MessageGroupStyles = { - [key: string]: string[]; -}; - -export const shouldIncludeMessageInList = ( - message: LocalMessage, - options: { deletedMessagesVisibilityType?: DeletedMessagesVisibilityType; userId?: string }, -) => { - const { deletedMessagesVisibilityType, userId } = options; - const isMessageTypeDeleted = message.type === 'deleted'; - switch (deletedMessagesVisibilityType) { - case 'sender': - return !isMessageTypeDeleted || message.user?.id === userId; - - case 'receiver': - return !isMessageTypeDeleted || message.user?.id !== userId; - - case 'never': - return !isMessageTypeDeleted; - - default: - return !!message; - } -}; - -export const useMessageFlashList = (params: UseMessageListParams) => { - const { noGroupByUser, threadList } = params; - const { client } = useChatContext(); - const { hideDateSeparators, maxTimeBetweenGroupedMessages } = useChannelContext(); - const { deletedMessagesVisibilityType, getMessagesGroupStyles = getGroupStyles } = - useMessagesContext(); - const { messages } = usePaginatedMessageListContext(); - const { threadMessages } = useThreadContext(); - const messageList = threadList ? threadMessages : messages; - - const dateSeparators = useMemo( - () => - getDateSeparators({ - deletedMessagesVisibilityType, - hideDateSeparators, - messages: messageList, - userId: client.userID, - }), - [deletedMessagesVisibilityType, hideDateSeparators, messageList, client.userID], - ); - - const dateSeparatorsRef = useRef(dateSeparators); - dateSeparatorsRef.current = dateSeparators; - - const messageGroupStyles = useMemo( - () => - getMessagesGroupStyles({ - dateSeparators: dateSeparatorsRef.current, - hideDateSeparators, - maxTimeBetweenGroupedMessages, - messages: messageList, - noGroupByUser, - userId: client.userID, - }), - [ - dateSeparatorsRef, - getMessagesGroupStyles, - hideDateSeparators, - maxTimeBetweenGroupedMessages, - messageList, - noGroupByUser, - client.userID, - ], - ); - - const processedMessageList = useMemo(() => { - const newMessageList = []; - for (const message of messageList) { - if ( - shouldIncludeMessageInList(message, { - deletedMessagesVisibilityType, - userId: client.userID, - }) - ) { - newMessageList.push(message); - } - } - return newMessageList; - }, [client.userID, deletedMessagesVisibilityType, messageList]); - - const messageGroupStylesRef = useRef(messageGroupStyles); - messageGroupStylesRef.current = messageGroupStyles; - - return { - /** Date separators */ - dateSeparatorsRef, - /** Message group styles */ - messageGroupStylesRef, - /** Messages enriched with dates/readby/groups and also reversed in order */ - processedMessageList, - /** Raw messages from the channel state */ - rawMessageList: messageList, - }; -}; 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/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; +}; From 1fc7782108433941f00cffd2c2c674058e356b50 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 17 Sep 2025 22:30:17 +0530 Subject: [PATCH 06/29] fix: implementation --- examples/SampleApp/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 0cd728bb88..4b22343f7d 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -223,6 +223,7 @@ const DrawerNavigatorWrapper: React.FC<{ Date: Thu, 18 Sep 2025 14:03:11 +0200 Subject: [PATCH 07/29] perf: try to improve performance --- .../MessageList/MessageFlashList.tsx | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 115e254cd0..57ccd98bc0 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -183,6 +183,43 @@ type MessageListFlashListPropsWithContext = Pick< const WAIT_FOR_SCROLL_TIMEOUT = 200; +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) { + const text = message.text; + if (text.length <= 20) { + return 'short-message-with-text'; + } + + if (text.length <= 100) { + return 'medium-message-with-text'; + } + + return 'message-with-text'; + } + + return 'message-with-nothing'; + } + + return 'unresolvable-type'; +}; + const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithContext) => { const LoadingMoreRecentIndicator = props.threadList ? InlineLoadingMoreRecentThreadIndicator @@ -310,6 +347,7 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon const maintainVisibleContentPosition = useMemo(() => { return { + animateAutoscrollToBottom: true, autoscrollToBottomThreshold: autoScrollToRecent || threadList ? 10 : undefined, startRenderingFromBottom: true, }; @@ -460,7 +498,9 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon // we should scroll to bottom where ever we are now // as we have sent a new own message if (shouldScrollToRecentOnNewOwnMessage) { - setAutoScrollToRecent(true); + flashListRef.current?.scrollToEnd({ + animated: true, + }); } } }, [channel, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef, threadList]); @@ -1021,6 +1061,11 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon [contentContainer], ); + const getItemType = useStableCallback((item: LocalMessage) => { + const type = getItemTypeInternal(item); + return client.userID === item.user?.id ? `own-${type}` : type; + }); + if (loading) { return ( @@ -1042,6 +1087,8 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon Date: Thu, 18 Sep 2025 19:11:33 +0530 Subject: [PATCH 08/29] fix: auto scroll animation behaviour --- examples/SampleApp/ios/Podfile.lock | 152 +++++++++--------- .../MessageList/MessageFlashList.tsx | 5 +- 2 files changed, 80 insertions(+), 77 deletions(-) diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index bd448d4b78..88fa6eca8e 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -1859,7 +1859,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-cameraroll (7.10.0): + - react-native-cameraroll (7.10.2): - boost - DoubleConversion - fast_float @@ -3455,7 +3455,7 @@ SPEC CHECKSUMS: hermes-engine: bbc1152da7d2d40f9e59c28acc6576fcf5d28e2a libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - op-sqlite: 17d9566d723ad870c33588ba54a98a5dcac60e7e + op-sqlite: dc2477f170ae9af9117b8543870989572b08280e 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: 5c5fb716af11f6178dca48271ae065cd786a0a02 + 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/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 115e254cd0..3bbeb03776 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -310,6 +310,7 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon const maintainVisibleContentPosition = useMemo(() => { return { + animateAutoscrollToBottom: true, autoscrollToBottomThreshold: autoScrollToRecent || threadList ? 10 : undefined, startRenderingFromBottom: true, }; @@ -460,7 +461,9 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon // we should scroll to bottom where ever we are now // as we have sent a new own message if (shouldScrollToRecentOnNewOwnMessage) { - setAutoScrollToRecent(true); + flashListRef.current?.scrollToEnd({ + animated: true, + }); } } }, [channel, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef, threadList]); From 837bb1671fdab0b22b68414b36b37a9c7f87c9ad Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 19 Sep 2025 16:31:00 +0200 Subject: [PATCH 09/29] fix: keyboard movement --- .../MessageList/MessageFlashList.tsx | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 57ccd98bc0..9d0b1eae02 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -1,5 +1,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ScrollViewProps, StyleSheet, View, ViewabilityConfig, ViewToken } from 'react-native'; +import { + LayoutChangeEvent, + ScrollViewProps, + StyleSheet, + View, + ViewabilityConfig, + ViewToken, +} from 'react-native'; import { FlashList, FlashListProps, FlashListRef } from '@shopify/flash-list'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; @@ -957,10 +964,13 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon 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; @@ -1066,6 +1076,22 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon 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 ( @@ -1076,6 +1102,7 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon return ( @@ -1087,7 +1114,7 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon Date: Sat, 20 Sep 2025 12:13:58 +0530 Subject: [PATCH 10/29] fix: refine getItemType --- package/src/components/MessageList/MessageFlashList.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 9d0b1eae02..83566570db 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -204,6 +204,10 @@ const getItemTypeInternal = (message: LocalMessage) => { return 'message-with-quote'; } + if (message.parent_id) { + return 'message-with-threads'; + } + if (message.shared_location) { return 'message-with-shared-location'; } From 18765617972a9d5168a12637abcde1ebbd62ec08 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Mon, 22 Sep 2025 18:18:44 +0530 Subject: [PATCH 11/29] fix: add few optimizations --- package/src/components/MessageList/MessageFlashList.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 83566570db..850f0b6cba 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -61,7 +61,8 @@ const flatListViewabilityConfig: ViewabilityConfig = { }; const hasReadLastMessage = (channel: Channel, userId: string) => { - const latestMessageIdInChannel = channel.state.latestMessages.slice(-1)[0]?.id; + const latestMessageIdInChannel = + channel.state.latestMessages[channel.state.latestMessages.length - 1]?.id; const lastReadMessageIdServer = channel.state.read[userId]?.last_read_message_id; return latestMessageIdInChannel === lastReadMessageIdServer; }; @@ -214,11 +215,11 @@ const getItemTypeInternal = (message: LocalMessage) => { if (message.text) { const text = message.text; - if (text.length <= 20) { + if (text.length <= 50) { return 'short-message-with-text'; } - if (text.length <= 100) { + if (text.length <= 200) { return 'medium-message-with-text'; } From 82dfa6b1865c54bce5a658ff867b6d3432b9e498 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 24 Sep 2025 13:04:56 +0530 Subject: [PATCH 12/29] fix: add more message types --- package/src/components/MessageList/MessageFlashList.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 850f0b6cba..64f359caf4 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -213,6 +213,10 @@ const getItemTypeInternal = (message: LocalMessage) => { return 'message-with-shared-location'; } + if (message.deleted_at) { + return 'deleted-message'; + } + if (message.text) { const text = message.text; if (text.length <= 50) { @@ -229,6 +233,10 @@ const getItemTypeInternal = (message: LocalMessage) => { return 'message-with-nothing'; } + if (message.type === 'system') { + return 'system-message'; + } + return 'unresolvable-type'; }; From 3562681d7b83d37252f97cbd7c5a5b543dbd2c10 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 24 Sep 2025 13:43:01 +0530 Subject: [PATCH 13/29] fix: make FlashList component optional --- examples/SampleApp/ios/Podfile.lock | 4 ++-- examples/SampleApp/package.json | 1 + examples/SampleApp/yarn.lock | 17 +++++++++++----- package/package.json | 6 +++++- .../MessageList/MessageFlashList.tsx | 20 ++++++++++++++++++- 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 88fa6eca8e..d097779dd9 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -1859,7 +1859,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-cameraroll (7.10.2): + - react-native-cameraroll (7.10.0): - boost - DoubleConversion - fast_float @@ -3493,7 +3493,7 @@ SPEC CHECKSUMS: React-Mapbuffer: 8f620d1794c6b59a8c3862c3ae820a2e9e6c9bb0 React-microtasksnativemodule: dcf5321c9a41659a6718df8a5f202af1577c6825 react-native-blob-util: a511afccff6511544ebf56928e6afdf837b037a7 - react-native-cameraroll: 5c5fb716af11f6178dca48271ae065cd786a0a02 + react-native-cameraroll: 8c3ba9b6f511cf645778de19d5039b61d922fdfb react-native-document-picker: b37cf6660ad9087b782faa78a1e67687fac15bfd react-native-geolocation: b7f68b8c04e36ee669c630dbc48dd42cf93a0a41 react-native-image-picker: 9bceb747cd6cde22a3416ffdc819d11b5b11f156 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/yarn.lock b/examples/SampleApp/yarn.lock index bfa85f29d1..48435b976c 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -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/package/package.json b/package/package.json index 288119d21b..5076d1b189 100644 --- a/package/package.json +++ b/package/package.json @@ -67,7 +67,6 @@ }, "dependencies": { "@gorhom/bottom-sheet": "^5.1.8", - "@shopify/flash-list": "^2.0.3", "@ungap/structured-clone": "^1.3.0", "dayjs": "1.11.13", "emoji-regex": "^10.4.0", @@ -86,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", @@ -96,6 +96,9 @@ "@op-engineering/op-sqlite": { "optional": true }, + "@shopify/flash-list": { + "optional": true + }, "emoji-mart": { "optional": true }, @@ -107,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 index 64f359caf4..37d8652b4c 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -8,7 +8,7 @@ import { ViewToken, } from 'react-native'; -import { FlashList, FlashListProps, FlashListRef } from '@shopify/flash-list'; +import { FlashListProps, FlashListRef } from '@shopify/flash-list'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; import { useMessageList } from './hooks/useMessageList'; @@ -46,6 +46,17 @@ import { ThreadContextValue, useThreadContext } from '../../contexts/threadConte import { useStableCallback } from '../../hooks'; import { FileTypes } from '../../types/types'; +// @ts-expect-error - FlashList is not defined in the global scope +let FlashList; + +try { + FlashList = require('@shopify/flash-list').FlashList; +} catch (e) { + console.error( + 'The package @shopify/flash-list is not installed. Installing this package will enable the use of the FlashList component.', + ); +} + const keyExtractor = (item: LocalMessage) => { if (item.id) { return item.id; @@ -1113,6 +1124,13 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon ); } + // @ts-expect-error - FlashList is not defined in the global scope + 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 ( Date: Wed, 24 Sep 2025 13:48:39 +0530 Subject: [PATCH 14/29] fix: add back message list code --- package/src/components/MessageList/MessageList.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index ecf978ca7e..9fcf6170bf 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -436,6 +436,9 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { // we need this check to make sure that regular list change do not trigger // the unread notification to appear (for example if the old last read messages // go out of the viewport). + if (processedMessageList.length !== messagesLength.current) { + return; + } messagesLength.current = processedMessageList.length; if (!viewableItems.length) { From 213cbd3b8b851fb7caae0c34aa0223fe3db9b564 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 24 Sep 2025 14:08:56 +0530 Subject: [PATCH 15/29] fix: make flashlist optional --- examples/TypeScriptMessaging/package.json | 1 - examples/TypeScriptMessaging/yarn.lock | 17 +++------ package/src/components/Thread/Thread.tsx | 43 +++++++++++++++++++---- 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/examples/TypeScriptMessaging/package.json b/examples/TypeScriptMessaging/package.json index 72decc1988..6194eaa056 100644 --- a/examples/TypeScriptMessaging/package.json +++ b/examples/TypeScriptMessaging/package.json @@ -18,7 +18,6 @@ "@react-native-documents/picker": "^10.1.3", "@react-navigation/native": "^7.1.10", "@react-navigation/stack": "^7.3.3", - "@shopify/flash-list": "^2.0.3", "react": "19.1.0", "react-native": "0.80.2", "react-native-audio-recorder-player": "^3.6.13", diff --git a/examples/TypeScriptMessaging/yarn.lock b/examples/TypeScriptMessaging/yarn.lock index b423fe11a4..46cd8ec3b9 100644 --- a/examples/TypeScriptMessaging/yarn.lock +++ b/examples/TypeScriptMessaging/yarn.lock @@ -1904,13 +1904,6 @@ 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" @@ -7597,16 +7590,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.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.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/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index 8ed6944437..4a7443385b 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -15,6 +15,18 @@ import { MessageInputProps, } from '../MessageInput/MessageInput'; import { MessageListFlashList, MessageListFlashListProps } from '../MessageList/MessageFlashList'; +import { MessageListProps } from '../MessageList/MessageList'; + +// @ts-expect-error - FlashList is not defined in the global scope +let FlashList; + +try { + FlashList = require('@shopify/flash-list').FlashList; +} catch (e) { + console.error( + 'The package @shopify/flash-list is not installed. Installing this package will enable the use of the FlashList component.', + ); +} type ThreadPropsWithContext = Pick & Pick & @@ -36,7 +48,14 @@ type ThreadPropsWithContext = Pick & * Additional props for underlying MessageList component. * Available props - https://getstream.io/chat/docs/sdk/reactnative/ui-components/message-list/#props * */ - additionalMessageListProps?: Partial; + 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 + */ + additionalMessageListFlashListProps?: Partial; /** Make input focus on mounting thread */ autoFocus?: boolean; /** Closes thread on dismount, defaults to true */ @@ -58,13 +77,14 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { const { additionalMessageInputProps, additionalMessageListProps, + additionalMessageListFlashListProps, autoFocus = true, closeThread, closeThreadOnDismount = true, disabled, loadMoreThread, MessageInput = DefaultMessageInput, - // MessageList, + MessageList, onThreadDismount, parentMessagePreventPress = true, thread, @@ -108,11 +128,20 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { return ( - + {/* @ts-expect-error - FlashList is not defined in the global scope */} + {FlashList ? ( + + ) : ( + + )} Date: Wed, 24 Sep 2025 15:26:54 +0530 Subject: [PATCH 16/29] fix: make flashlist optional --- examples/SampleApp/ios/Podfile.lock | 4 +-- examples/SampleApp/package.json | 1 - .../SampleApp/src/screens/ChannelScreen.tsx | 4 +-- examples/SampleApp/yarn.lock | 23 ++++++---------- .../MessageList/MessageFlashList.tsx | 27 +++++++------------ package/src/components/Thread/Thread.tsx | 25 +++++------------ package/src/native.ts | 13 +++++++++ 7 files changed, 41 insertions(+), 56 deletions(-) diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index d097779dd9..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: dc2477f170ae9af9117b8543870989572b08280e + op-sqlite: a7e46cfdaebeef219fd0e939332967af9fe6d406 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index e1ecfaea7c..21a0cb00ed 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -37,7 +37,6 @@ "@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/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 4e3a18ea74..585b0ca8fa 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -5,7 +5,7 @@ import { Channel, ChannelAvatar, MessageInput, - MessageListFlashList, + MessageFlashList, ThreadContextValue, useAttachmentPickerContext, useChannelPreviewDisplayName, @@ -216,7 +216,7 @@ export const ChannelScreen: React.FC = ({ thread={selectedThread} > - + diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 48435b976c..4e75793da1 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,13 +2620,6 @@ 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" @@ -8450,16 +8443,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/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 37d8652b4c..d52891f9b1 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -8,7 +8,7 @@ import { ViewToken, } from 'react-native'; -import { FlashListProps, FlashListRef } from '@shopify/flash-list'; +import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; import { useMessageList } from './hooks/useMessageList'; @@ -44,19 +44,9 @@ import { mergeThemes, ThemeProvider, useTheme } from '../../contexts/themeContex import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { useStableCallback } from '../../hooks'; +import { getFlashList } from '../../native'; import { FileTypes } from '../../types/types'; -// @ts-expect-error - FlashList is not defined in the global scope -let FlashList; - -try { - FlashList = require('@shopify/flash-list').FlashList; -} catch (e) { - console.error( - 'The package @shopify/flash-list is not installed. Installing this package will enable the use of the FlashList component.', - ); -} - const keyExtractor = (item: LocalMessage) => { if (item.id) { return item.id; @@ -92,7 +82,7 @@ const getPreviousLastMessage = (messages: LocalMessage[], newMessage?: MessageRe return previousLastMessage; }; -type MessageListFlashListPropsWithContext = Pick< +type MessageFlashListPropsWithContext = Pick< AttachmentPickerContextValue, 'closePicker' | 'selectedPicker' | 'setSelectedPicker' > & @@ -251,7 +241,7 @@ const getItemTypeInternal = (message: LocalMessage) => { return 'unresolvable-type'; }; -const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithContext) => { +const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => { const LoadingMoreRecentIndicator = props.threadList ? InlineLoadingMoreRecentThreadIndicator : InlineLoadingMoreRecentIndicator; @@ -1124,7 +1114,8 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon ); } - // @ts-expect-error - FlashList is not defined in the global scope + const FlashList = getFlashList(); + if (!FlashList) { throw new Error( 'The package @shopify/flash-list is not installed. Installing this package will enable the use of the FlashList component.', @@ -1190,9 +1181,9 @@ const MessageListFlashListWithContext = (props: MessageListFlashListPropsWithCon ); }; -export type MessageListFlashListProps = Partial; +export type MessageFlashListProps = Partial; -export const MessageListFlashList = (props: MessageListFlashListProps) => { +export const MessageFlashList = (props: MessageFlashListProps) => { const { closePicker, selectedPicker, setSelectedPicker } = useAttachmentPickerContext(); const { channel, @@ -1239,7 +1230,7 @@ export const MessageListFlashList = (props: MessageListFlashListProps) => { const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); return ( - & Pick & Pick< @@ -55,7 +45,7 @@ type ThreadPropsWithContext = Pick & * Additional props for underlying MessageListFlashList component. * Available props - https://shopify.github.io/flash-list/docs/usage */ - additionalMessageListFlashListProps?: Partial; + additionalMessageFlashListProps?: Partial; /** Make input focus on mounting thread */ autoFocus?: boolean; /** Closes thread on dismount, defaults to true */ @@ -77,7 +67,7 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { const { additionalMessageInputProps, additionalMessageListProps, - additionalMessageListFlashListProps, + additionalMessageFlashListProps, autoFocus = true, closeThread, closeThreadOnDismount = true, @@ -128,12 +118,11 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { return ( - {/* @ts-expect-error - FlashList is not defined in the global scope */} - {FlashList ? ( - ) : ( !!NativeHandlers.iOS14RefreshGallerySelection && !!NativeHandlers.oniOS14GalleryLibrarySelectionChange && !!NativeHandlers.getLocalAssetUri; + +// Use this function to get the FlashList component from the @shopify/flash-list package +export function getFlashList() { + try { + return require('@shopify/flash-list').FlashList; + } catch { + console.log( + 'You can also use the MessageFlashList component by installing the @shopify/flash-list package to optimize the performance of the MessageList component.', + ); + } +} + +export const isFlashListAvailable = () => !!getFlashList(); From fbb0bd84b880a9763bee9b269cf14bc5dc4fd59c Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 24 Sep 2025 18:20:26 +0530 Subject: [PATCH 17/29] fix: handle messgaeflash list in thread --- examples/SampleApp/package.json | 1 + examples/SampleApp/yarn.lock | 17 ++++++++++++----- package/jest-setup.js | 4 ++++ .../components/MessageList/MessageFlashList.tsx | 11 ++++++++--- package/src/components/Thread/Thread.tsx | 11 +++++++++-- package/src/native.ts | 13 ------------- 6 files changed, 34 insertions(+), 23 deletions(-) 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/yarn.lock b/examples/SampleApp/yarn.lock index 4e75793da1..ff8c1c7832 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -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/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/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index d52891f9b1..2e0723040c 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -44,9 +44,16 @@ import { mergeThemes, ThemeProvider, useTheme } from '../../contexts/themeContex import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { useStableCallback } from '../../hooks'; -import { getFlashList } from '../../native'; 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; @@ -1114,8 +1121,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => ); } - const FlashList = getFlashList(); - if (!FlashList) { throw new Error( 'The package @shopify/flash-list is not installed. Installing this package will enable the use of the FlashList component.', diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index b41ebf3c01..93c177ccea 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -10,7 +10,6 @@ import { } from '../../contexts/messagesContext/MessagesContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; -import { isFlashListAvailable } from '../../native'; import { MessageInput as DefaultMessageInput, MessageInputProps, @@ -18,6 +17,14 @@ import { 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 & Pick< @@ -118,7 +125,7 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { return ( - {isFlashListAvailable() ? ( + {FlashList ? ( !!NativeHandlers.iOS14RefreshGallerySelection && !!NativeHandlers.oniOS14GalleryLibrarySelectionChange && !!NativeHandlers.getLocalAssetUri; - -// Use this function to get the FlashList component from the @shopify/flash-list package -export function getFlashList() { - try { - return require('@shopify/flash-list').FlashList; - } catch { - console.log( - 'You can also use the MessageFlashList component by installing the @shopify/flash-list package to optimize the performance of the MessageList component.', - ); - } -} - -export const isFlashListAvailable = () => !!getFlashList(); From ddfaec8b0c69da9d5fb9a253ca299162ef9f16dc Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 25 Sep 2025 11:26:54 +0530 Subject: [PATCH 18/29] fix: flashlist import in expo app --- examples/ExpoMessaging/app/channel/[cid]/index.tsx | 4 ++-- package/src/components/MessageList/MessageFlashList.tsx | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/ExpoMessaging/app/channel/[cid]/index.tsx b/examples/ExpoMessaging/app/channel/[cid]/index.tsx index f5caab9a0f..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, MessageListFlashList } 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/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 2e0723040c..df35483363 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -1188,6 +1188,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => 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 { From e9681ac7605e29dc8bfef34a9df2655eedf09a7c Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 25 Sep 2025 11:27:03 +0530 Subject: [PATCH 19/29] fix: flashlist import in expo app --- examples/ExpoMessaging/package.json | 1 + 1 file changed, 1 insertion(+) 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", From dae84abaa6ec819a3ae5b4986aec5549350a7934 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 25 Sep 2025 12:36:00 +0530 Subject: [PATCH 20/29] fix: add improvements to scrolling --- .../MessageList/MessageFlashList.tsx | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index df35483363..4d827300c4 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -197,7 +197,7 @@ type MessageFlashListPropsWithContext = Pick< setFlatListRef?: (ref: FlashListRef | null) => void; }; -const WAIT_FOR_SCROLL_TIMEOUT = 200; +const WAIT_FOR_SCROLL_TIMEOUT = 0; const getItemTypeInternal = (message: LocalMessage) => { if (message.type === 'regular') { @@ -433,17 +433,17 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } try { if (indexOfParentInMessageList === -1) { + clearTimeout(scrollToDebounceTimeoutRef.current); await loadChannelAroundMessage({ messageId }); setTargetedMessage(messageId); - setTimeout(() => { - // 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 - }); - }, WAIT_FOR_SCROLL_TIMEOUT); + // 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); @@ -468,16 +468,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => return; } - if ( - isMessageRemovedFromMessageList || - (topMessageBeforeUpdate.current?.created_at && - topMessageAfterUpdate?.created_at && - topMessageBeforeUpdate.current.created_at < topMessageAfterUpdate.created_at) - ) { + if (isMessageRemovedFromMessageList) { channelResyncScrollSet.current = false; setScrollToBottomButtonVisible(false); resetPaginationTrackersRef.current(); + console.log('scrollToEnd 1'); setAutoScrollToRecent(true); setTimeout(() => { channelResyncScrollSet.current = true; @@ -960,7 +956,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const offset = nativeEvent.contentOffset.y; const visibleLength = nativeEvent.layoutMeasurement.height; const contentLength = nativeEvent.contentSize.height; - if (!channel) { + if (!channel || !channelResyncScrollSet.current) { return; } From 015eb37fad199d6e85fb17b62dba95743aadea91 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 25 Sep 2025 12:36:13 +0530 Subject: [PATCH 21/29] fix: add improvements to scrolling --- package/src/components/MessageList/MessageFlashList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 4d827300c4..977850d328 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -473,7 +473,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => setScrollToBottomButtonVisible(false); resetPaginationTrackersRef.current(); - console.log('scrollToEnd 1'); setAutoScrollToRecent(true); setTimeout(() => { channelResyncScrollSet.current = true; From 5da09e338920ed2c5e5806898c6c8f5ba5402279 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 25 Sep 2025 13:28:22 +0530 Subject: [PATCH 22/29] fix: add improvements to scrolling --- package/src/components/MessageList/MessageFlashList.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 977850d328..f384034364 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -468,7 +468,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => return; } - if (isMessageRemovedFromMessageList) { + if ( + isMessageRemovedFromMessageList || + (topMessageBeforeUpdate.current?.created_at && + topMessageAfterUpdate?.created_at && + topMessageBeforeUpdate.current.created_at < topMessageAfterUpdate.created_at) + ) { channelResyncScrollSet.current = false; setScrollToBottomButtonVisible(false); resetPaginationTrackersRef.current(); From eb561af4efb363d17beacbeca94dbf6cbfc7a50e Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 25 Sep 2025 14:03:19 +0530 Subject: [PATCH 23/29] fix: add improvements to scrolling --- package/src/components/MessageList/MessageFlashList.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index f384034364..f446dff5d9 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -457,7 +457,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => * If such a case arises, we scroll to bottom. */ const isMessageRemovedFromMessageList = - messageListLengthAfterUpdate < messageListLengthBeforeUpdate.current; + messageListLengthBeforeUpdate.current - messageListLengthAfterUpdate === 1; /** * Scroll down when @@ -484,6 +484,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => if (channel.countUnread() > 0) { markRead(); } + setAutoScrollToRecent(false); }, WAIT_FOR_SCROLL_TIMEOUT); } }; From bbb7e88d966d07acc66e5f6db96a24ed8836010c Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 29 Sep 2025 10:11:31 +0200 Subject: [PATCH 24/29] fix: use generic text message type --- package/src/components/MessageList/MessageFlashList.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index f446dff5d9..ee32430772 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -226,15 +226,6 @@ const getItemTypeInternal = (message: LocalMessage) => { } if (message.text) { - const text = message.text; - if (text.length <= 50) { - return 'short-message-with-text'; - } - - if (text.length <= 200) { - return 'medium-message-with-text'; - } - return 'message-with-text'; } From aca34a7fe7e3607269798453b8d5267f6bb9ec65 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 29 Sep 2025 11:05:57 +0200 Subject: [PATCH 25/29] fix: remove thread message type --- package/src/components/MessageList/MessageFlashList.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index ee32430772..c324290127 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -213,10 +213,6 @@ const getItemTypeInternal = (message: LocalMessage) => { return 'message-with-quote'; } - if (message.parent_id) { - return 'message-with-threads'; - } - if (message.shared_location) { return 'message-with-shared-location'; } From 0082352e8e76d1e8709305f0a20a909bbae58d50 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 29 Sep 2025 13:55:13 +0200 Subject: [PATCH 26/29] fix: deleted message type resolution --- .../src/components/MessageList/MessageFlashList.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index c324290127..8655fff322 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -217,10 +217,6 @@ const getItemTypeInternal = (message: LocalMessage) => { return 'message-with-shared-location'; } - if (message.deleted_at) { - return 'deleted-message'; - } - if (message.text) { return 'message-with-text'; } @@ -228,11 +224,15 @@ const getItemTypeInternal = (message: LocalMessage) => { return 'message-with-nothing'; } + if (message.type === 'deleted') { + return 'deleted-message'; + } + if (message.type === 'system') { return 'system-message'; } - return 'unresolvable-type'; + return 'generic-message'; }; const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => { From 1461ccc38a37bd6c1eb6d10ebdcd484576bb6747 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 29 Sep 2025 14:53:17 +0200 Subject: [PATCH 27/29] chore: add option to switch between lists --- .../SampleApp/src/components/SecretMenu.tsx | 80 ++++++++++++++++--- examples/SampleApp/src/context/AppContext.ts | 1 + .../SampleApp/src/screens/ChannelScreen.tsx | 9 ++- 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/examples/SampleApp/src/components/SecretMenu.tsx b/examples/SampleApp/src/components/SecretMenu.tsx index 0b41680492..66405defca 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,23 @@ const SecretMenuNotificationConfigItem = ({ ); }; +const SecretMenuMessageListConfigItem = ({ + messageListImplementationConfigItem, + storeMessageListImplementation, + isSelected, +}: { + messageListImplementationConfigItem: MessageListImplementationConfigItem; + storeMessageListImplementation: (item: MessageListImplementationConfigItem) => void; + isSelected: boolean; +}) => ( + storeMessageListImplementation(messageListImplementationConfigItem)} + > + {messageListImplementationConfigItem.label} + +); + export const SecretMenu = ({ close, visible, @@ -130,6 +151,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 +168,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 +214,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 585b0ca8fa..e4a0c06653 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -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 { @@ -216,7 +217,11 @@ export const ChannelScreen: React.FC = ({ thread={selectedThread} > - + {messageListImplementation === 'flashlist' ? ( + + ) : ( + + )} From b3a6039e798701d1b5569dd7ce36aa35a044e03a Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 29 Sep 2025 14:55:18 +0200 Subject: [PATCH 28/29] fix: add forgotten change --- examples/SampleApp/App.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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 ? ( From 9dd767edb70628eee37b95d68c323531863dd0a7 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 29 Sep 2025 14:56:41 +0200 Subject: [PATCH 29/29] chore: add remark --- examples/SampleApp/src/components/SecretMenu.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/SampleApp/src/components/SecretMenu.tsx b/examples/SampleApp/src/components/SecretMenu.tsx index 66405defca..95762b85eb 100644 --- a/examples/SampleApp/src/components/SecretMenu.tsx +++ b/examples/SampleApp/src/components/SecretMenu.tsx @@ -141,6 +141,10 @@ const SecretMenuMessageListConfigItem = ({ ); +/* +* TODO: Please rewrite this entire component. +*/ + export const SecretMenu = ({ close, visible,