diff --git a/README.md b/README.md index 67323d9a49..077582b73e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![NPM](https://img.shields.io/npm/v/stream-chat-react-native.svg)](https://www.npmjs.com/package/stream-chat-react-native) [![Build Status](https://github.com/GetStream/stream-chat-react-native/actions/workflows/release.yml/badge.svg)](https://github.com/GetStream/stream-chat-react-native/actions) [![Component Reference](https://img.shields.io/badge/docs-component%20reference-blue.svg)](https://getstream.io/chat/docs/sdk/reactnative) -![JS Bundle Size](https://img.shields.io/badge/js_bundle_size-469%20KB-blue) +![JS Bundle Size](https://img.shields.io/badge/js_bundle_size-466%20KB-blue) diff --git a/examples/ExpoMessaging/yarn.lock b/examples/ExpoMessaging/yarn.lock index 2f4353d0cf..08f8124803 100644 --- a/examples/ExpoMessaging/yarn.lock +++ b/examples/ExpoMessaging/yarn.lock @@ -7427,9 +7427,24 @@ stream-chat-react-native-core@7.0.0: uid "" stream-chat@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.0.0.tgz#cb22dcb8b7f070c623a13b6b75b212d560534d6c" - integrity sha512-I4+/DEp7dP3WBgRmqHaLswL+Y2fyQkUWJhYBS5zx4bpu1cYM6WEir9HYjToDNuJjltqa/FFIEF/tMPWr7iTc0A== + version "9.1.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.1.1.tgz#c81ffa84a7ca579d9812065bc159010191b59090" + integrity sha512-7Y23aIVQMppNZgRj/rTFwIx9pszxgDcS99idkSXJSgdV8C7FlyDtiF1yQSdP0oiNFAt7OUP/xSqmbJTljrm24Q== + dependencies: + "@types/jsonwebtoken" "^9.0.8" + "@types/ws" "^8.5.14" + axios "^1.6.0" + base64-js "^1.5.1" + form-data "^4.0.0" + isomorphic-ws "^5.0.0" + jsonwebtoken "^9.0.2" + linkifyjs "^4.2.0" + ws "^8.18.1" + +stream-chat@^9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.2.0.tgz#f3109891ca27f17b6fd0aa6ebcf66be12df1f88c" + integrity sha512-inz3CA5tuqqSrla7qjRTCKs+coRKOYROWf0wEWYgbCu0tAUuiBTRtu1PJL1isEXIaPLiWi00BuRrBEIFon9Kng== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 5da03a4244..a054cfd1b8 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -2578,91 +2578,91 @@ SPEC CHECKSUMS: hermes-engine: b417d2b2aee3b89b58e63e23a51e02be91dc876d libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - op-sqlite: 2e34a191af7e843608357671c94a6e2befd4b986 + op-sqlite: c33561ea312a2ae38aae032fd3a42635dc6b57e8 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82 + RCT-Folly: 36fe2295e44b10d831836cc0d1daec5f8abcf809 RCTDeprecation: b2eecf2d60216df56bc5e6be5f063826d3c1ee35 RCTRequired: 78522de7dc73b81f3ed7890d145fa341f5bb32ea RCTTypeSafety: c135dd2bf50402d87fd12884cbad5d5e64850edd React: b229c49ed5898dab46d60f61ed5a0bfa2ee2fadb React-callinvoker: 2ac508e92c8bd9cf834cc7d7787d94352e4af58f React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a - React-Core: 325b4f6d9162ae8b9a6ff42fe78e260eb124180d - React-CoreModules: 558041e5258f70cd1092f82778d07b8b2ff01897 - React-cxxreact: 8fff17cbe76e6a8f9991b59552e1235429f9c74b + React-Core: 13cdd1558d0b3f6d9d5a22e14d89150280e79f02 + React-CoreModules: b07a6744f48305405e67c845ebf481b6551b712a + React-cxxreact: 1055a86c66ac35b4e80bd5fb766aed5f494dfff4 React-debug: c76e92776a86622209279fe6d24a0147584444ed - React-defaultsnativemodule: 111fb1efc95c2bd0ee18e38e9f7b57d678e6f932 - React-domnativemodule: d5154a815306fd6050ee9346a1490d2fb17eb0e5 - React-Fabric: 51ac32f0a6790b1d3b14d90c6870e5ce5bb3854a - React-FabricComponents: 1094d6a3c2566b3c56951331c44d7d3960570ac8 - React-FabricImage: 6b210ad3c72704a9ad60dde66c397ce6257333f4 + React-defaultsnativemodule: c2e3ac39909241374c3322eb2be33f4c15fe6be4 + React-domnativemodule: 240b3c95b5300cc6537594e73ebc6e8e77585b74 + React-Fabric: 3b403ca25f74d54454b31d1d2627050e0777d42c + React-FabricComponents: 154740cfcd57943709a9d0343769d17173c0ac9c + React-FabricImage: 0863e39cea98f3ca2f8c3d92984660795cec84ae React-featureflags: efb93a998907e4ad5b88f6ed77cc140914d5c36d - React-featureflagsnativemodule: a74b09429c2e7a57412d78cc159ab86ae4f15db9 - React-graphics: 17ef0ee3ef4a4c1774cc82f1f477ecef4d67c73f - React-hermes: a9a0c8377627b5506ef9a7b6f60a805c306e3f51 - React-idlecallbacksnativemodule: 0711ec5eb53c7f790641fa00e5f6ec0355d3159b - React-ImageManager: 23b4701408390428724f0e0ebb2cbed7b37c2b24 - React-jserrorhandler: e21b438ef8b99ea8bf070ff35f00bc0215b5f769 - React-jsi: f3f51595cc4c089037b536368f016d4742bf9cf7 - React-jsiexecutor: cca6c232db461e2fd213a11e9364cfa6fdaa20eb - React-jsinspector: 8a3c2637b84ebec478f46a43432a522d7489410f - React-jsinspectortracing: ee0215d2db753cc10f45fc9aa86557718d0b16fb - React-jsitracing: 258be1fd259141f6aa43012c20c70ebc02e32087 - React-logger: 018826bfd51b9f18e87f67db1590bc510ad20664 - React-Mapbuffer: 9fbb496e7d6f7c34d5e617365ee778bf96d14eae - React-microtasksnativemodule: 36adde22631838680d1be62776e8ccb83186c06a - react-native-blob-util: d03eaad9fd1bbe90bd0eedb5bad3333215976086 - react-native-cameraroll: 10054f480dfd6e0bd02fdf08fb6d82f80b362575 - react-native-document-picker: 78c262a7f9f77df2380378aa4b3413b8646ce91b - react-native-image-picker: 4c10603e9b253c7f48f296034c04a09fe9c8d427 - react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 - react-native-safe-area-context: 02e0f487c16ccf1acc8a666bed2318ceeb5dc14c - react-native-video: 16b5c395d05f8af23e16bfe3dc0794a5514c882f - React-NativeModulesApple: ec44c21ae0bbb5f9a2df72db00294e33a00e07f0 - React-perflogger: 9e8d3c0dc0194eb932162812a168aa5dc662f418 - React-performancetimeline: 350424518f433dd43f063dc5f2cf3195c1a5b60f + React-featureflagsnativemodule: 51116d72aafea30860f315702d17eb76bbb725a3 + React-graphics: 91d9920451f633d64d31948da3ba0377b6eda8de + React-hermes: 71186f872c932e4574d5feb3ed754dda63a0b3bd + React-idlecallbacksnativemodule: 19bf1fa4b2b66fe1898ac1d185129cdcc3221c7c + React-ImageManager: 7dc7bfca8e9ecb9a7436b8a89a143a193ef5adcf + React-jserrorhandler: d8640792495ac2d78e73acbcc77a8439d1eedfef + React-jsi: 0775a66820496769ad83e629f0f5cce621a57fc7 + React-jsiexecutor: 2cf5ba481386803f3c88b85c63fa102cba5d769e + React-jsinspector: d1d9f215c7431b286acc12e83cdf0d90c265f0ed + React-jsinspectortracing: c4c1cceb9a9c266ce849c82332e35cc57ee9dae9 + React-jsitracing: 267618eec9c362658a4587c5ddcfb41b2e00c403 + React-logger: 795cd5055782db394f187f9db0477d4b25b44291 + React-Mapbuffer: 0df2a235bd0182f5cbed6c5f095e66deca12e335 + React-microtasksnativemodule: b31e56a980634f383221bfefd5111d04c14c110b + react-native-blob-util: 875bbeee07e4ada135e4edf9fc7b22acf8d9721d + react-native-cameraroll: cdc91c4c953d1a18aa3ce88b5a25698025c8c4d2 + react-native-document-picker: 19be73c0423e4bc886cef74ec282eff750698013 + react-native-image-picker: 1c620a65f900a47d6d12ec94874c6a1820ebea7d + react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac + react-native-safe-area-context: 0b43456abcaaa3c8323bbfafe9c5f0f9511219d2 + react-native-video: a225b4d4d3286f3253dc7b00a62e7c8e59d04d51 + React-NativeModulesApple: b74b4e3004104429461593fe460ad790cc4928c2 + React-perflogger: ab51b7592532a0ea45bf6eed7e6cae14a368b678 + React-performancetimeline: 37192fd1019c3b3b597a877dff12f3af68305c34 React-RCTActionSheet: 592674cf61142497e0e820688f5a696e41bf16dd - React-RCTAnimation: e6d669872f9b3b4ab9527aab283b7c49283236b7 - React-RCTAppDelegate: de2343fe08be4c945d57e0ecce44afcc7dd8fc03 - React-RCTBlob: 3e2dce94c56218becc4b32b627fc2293149f798d - React-RCTFabric: adad07a08efb186bc1046041207527927524170d - React-RCTFBReactNativeSpec: d10ca5e0ccbfeac8c047361fedf8e4ac653887b6 - React-RCTImage: dc04b176c022d12a8f55ae7a7279b1e091066ae0 - React-RCTLinking: 88f5e37fe4f26fbc80791aa2a5f01baf9b9a3fd5 - React-RCTNetwork: f213693565efbd698b8e9c18d700a514b49c0c8e - React-RCTSettings: a2d32a90c45a3575568cad850abc45924999b8a5 - React-RCTText: 54cdcd1cbf6f6a91dc6317f5d2c2b7fc3f6bf7a0 - React-RCTVibration: 11dae0e7f577b5807bb7d31e2e881eb46f854fd4 + React-RCTAnimation: 8fbb8dba757b49c78f4db403133ab6399a4ce952 + React-RCTAppDelegate: 7f88baa8cb4e5d6c38bb4d84339925c70c9ac864 + React-RCTBlob: f89b162d0fe6b570a18e755eb16cbe356d3c6d17 + React-RCTFabric: f2151588dc1dc884b34b8660d72ef5237aa4b10e + React-RCTFBReactNativeSpec: 8c29630c2f379c729300e4c1e540f3d1b78d1936 + React-RCTImage: ccac9969940f170503857733f9a5f63578e106e1 + React-RCTLinking: d82427bbf18415a3732105383dff119131cadd90 + React-RCTNetwork: 12ad4d0fbde939e00251ca5ca890da2e6825cc3c + React-RCTSettings: e7865bf9f455abf427da349c855f8644b5c39afa + React-RCTText: 2cdfd88745059ec3202a0842ea75a956c7d6f27d + React-RCTVibration: a3a1458e6230dfd64b3768ebc0a4aac430d9d508 React-rendererconsistency: aa476d937c91886dd8b2ddde3191c775585ae47a - React-rendererdebug: df10d858ac7709b9c8349d952474b0746092c690 + React-rendererdebug: 5a2219e0ceb78f4ffe9ee2d80fa260bb5bac50b2 React-rncore: 517c6c3647d45de81a7920b6959adf14fed2a5a5 - React-RuntimeApple: 6922a0861c3fc4c7d544fc7d1d5cb38c779d1264 - React-RuntimeCore: 41a95876d16630ce00946eaaee7ffd5222242b44 + React-RuntimeApple: 40809bf5975c265b990dec2725f2cfb61f1afc75 + React-RuntimeCore: 375c2645e924fdca875918f07ed987653c517edc React-runtimeexecutor: a188df372373baf5066e6e229177836488799f80 - React-RuntimeHermes: f2ca409c03c36bb3dcbf61bdfa2636501f9faebd - React-runtimescheduler: 7ae10fa81428c2479e0a5534943dacb8e34c9d52 + React-RuntimeHermes: 2de8d61ec25d950ae4aebcab1a895e0bb8b18c95 + React-runtimescheduler: e8b49a60eca68a3513c259879a352ed010fed255 React-timing: e56b95cb12c6fb9146be7ba3d671cf6b5d17b2e0 - React-utils: 6eabecc0e7d7bcf21b6b33357bc1fe8ae13c7c4c - ReactAppDependencyProvider: a1fb08dfdc7ebc387b2e54cfc9decd283ed821d8 - ReactCodegen: 0f8899ac1bad260bf3b362ee848ef67a70b5a306 - ReactCommon: a30b578194de911fbe1698efb8247bfe4cb6abff - RNAudioRecorderPlayer: 11df0c7b614e9767ef24d896465c3a758c592de7 - RNCAsyncStorage: 849b77e6ab3eb838361a902b492993056924faab - RNFastImage: 462a183c4b0b6b26fdfd639e1ed6ba37536c3b87 - RNFBApp: b5626640e0f4b4fe5be4f44375df703c0d62ee4b - RNFBMessaging: 8e38f5ca846497f8a9c91d33f311c00ca52d119c - RNGestureHandler: f7b3a72c099e1e29b5b81688678bc9108d44057c - RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168 - RNReactNativeHapticFeedback: eb5395b503c7a8f10de5e6722ef8afd3c61bc4f5 - RNReanimated: fe5c52894886953248a81a10b2a9b6eeb5398d61 - RNScreens: fc78b9b5a1274426d7a59b7d07c272bba13604fa - RNShare: dcef43a8864fcc114fd582edba7832a906fd318d - RNSVG: 71e35e78add645b84b52b0c6f203f91028e1ab5e + React-utils: 8ad62100a8780798a380b769e968c4764bad1f4b + ReactAppDependencyProvider: f2e81d80afd71a8058589e19d8a134243fa53f17 + ReactCodegen: 299e99fc57c93edc7c5396ef1a39a3a4d494f25d + ReactCommon: c8fdbc582b98a07daf201cd95c1da75dd029f3ee + RNAudioRecorderPlayer: 224c7de87722938aedce04000d09baa633148f5b + RNCAsyncStorage: dac011cac81189c2b3b8654f3db97d2b6362d165 + RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8 + RNFBApp: 60366dd9d6bb01327607e1561a32508592d76db9 + RNFBMessaging: 9465c2e3adb5e02cae8d40048306a30aea7f55cf + RNGestureHandler: 0a16f3f13829c01268ae55610a40b57b713c8161 + RNNotifee: 4a6ee5c7deaf00e005050052d73ee6315dff7ec9 + RNReactNativeHapticFeedback: a49e613d48d721c99cad9689a490554104c22154 + RNReanimated: c9f295fb1679867288d238bfaf3ea39225c95e1b + RNScreens: 77f93ec55b749c49549b447527ebf78e990125f3 + RNShare: 12d13ebc179faf22534c605d17b2c2fa40191850 + RNSVG: 05776cf3f0d52d3f8e7ebee34b2189da7b8638ff SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - stream-chat-react-native: 655e616ec1738f03ab9796eb12693bb1a91bb65e + stream-chat-react-native: ba870d69df921790816046ea3cdf32fbd5973a73 Yoga: be02ca501b03c79d7027a6bbbd0a8db985034f11 PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index f34685141a..3e2c2added 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -124,19 +124,23 @@ export const ChannelScreen: React.FC = ({ useEffect(() => { const initChannel = async () => { - if (!chatClient || !channelId) { + if (!chatClient || !channelId || channelFromProp) { return; } const newChannel = chatClient?.channel('messaging', channelId); - if (!newChannel?.initialized) { - await newChannel?.watch(); + try { + if (!newChannel?.initialized) { + await newChannel?.watch(); + } + } catch(error) { + console.log('An error has occurred while watching the channel: ', error); } setChannel(newChannel); }; initChannel(); - }, [channelId, chatClient]); + }, [channelFromProp, channelId, chatClient]); useFocusEffect(() => { setSelectedThread(undefined); diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 04687f5183..33d8f8a708 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -7568,9 +7568,24 @@ stream-chat-react-native-core@7.0.0: uid "" stream-chat@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.0.0.tgz#cb22dcb8b7f070c623a13b6b75b212d560534d6c" - integrity sha512-I4+/DEp7dP3WBgRmqHaLswL+Y2fyQkUWJhYBS5zx4bpu1cYM6WEir9HYjToDNuJjltqa/FFIEF/tMPWr7iTc0A== + version "9.0.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.0.1.tgz#5c556f7212811b0216db745478e2c7dd8b72162a" + integrity sha512-v5jPrvFeZ+mT1r+4Xbw6o/rYe36BYoMVKEscBR12zj+edXS3sw1xgbrbxOfNPPSNKQF6euXya/TmzxC5whisgQ== + dependencies: + "@types/jsonwebtoken" "^9.0.8" + "@types/ws" "^8.5.14" + axios "^1.6.0" + base64-js "^1.5.1" + form-data "^4.0.0" + isomorphic-ws "^5.0.0" + jsonwebtoken "^9.0.2" + linkifyjs "^4.2.0" + ws "^8.18.1" + +stream-chat@^9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.2.0.tgz#f3109891ca27f17b6fd0aa6ebcf66be12df1f88c" + integrity sha512-inz3CA5tuqqSrla7qjRTCKs+coRKOYROWf0wEWYgbCu0tAUuiBTRtu1PJL1isEXIaPLiWi00BuRrBEIFon9Kng== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/examples/TypeScriptMessaging/ios/Podfile.lock b/examples/TypeScriptMessaging/ios/Podfile.lock index d9be70d483..fed861c54d 100644 --- a/examples/TypeScriptMessaging/ios/Podfile.lock +++ b/examples/TypeScriptMessaging/ios/Podfile.lock @@ -2323,88 +2323,88 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 + DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 FBLazyVector: 6fe148afcef2e3213e484758e3459609d40d57f5 fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd - glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a + glog: eb93e2f488219332457c3c4eafd2738ddc7e80b8 hermes-engine: b417d2b2aee3b89b58e63e23a51e02be91dc876d - op-sqlite: 2e34a191af7e843608357671c94a6e2befd4b986 - RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82 + op-sqlite: c33561ea312a2ae38aae032fd3a42635dc6b57e8 + RCT-Folly: 36fe2295e44b10d831836cc0d1daec5f8abcf809 RCTDeprecation: b2eecf2d60216df56bc5e6be5f063826d3c1ee35 RCTRequired: 78522de7dc73b81f3ed7890d145fa341f5bb32ea RCTTypeSafety: c135dd2bf50402d87fd12884cbad5d5e64850edd React: b229c49ed5898dab46d60f61ed5a0bfa2ee2fadb React-callinvoker: 2ac508e92c8bd9cf834cc7d7787d94352e4af58f React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a - React-Core: 325b4f6d9162ae8b9a6ff42fe78e260eb124180d - React-CoreModules: 558041e5258f70cd1092f82778d07b8b2ff01897 - React-cxxreact: 8fff17cbe76e6a8f9991b59552e1235429f9c74b + React-Core: 13cdd1558d0b3f6d9d5a22e14d89150280e79f02 + React-CoreModules: b07a6744f48305405e67c845ebf481b6551b712a + React-cxxreact: 1055a86c66ac35b4e80bd5fb766aed5f494dfff4 React-debug: 0a5fcdbacc6becba0521e910c1bcfdb20f32a3f6 - React-defaultsnativemodule: 618dc50a0fad41b489997c3eb7aba3a74479fd14 - React-domnativemodule: 7ba599afb6c2a7ec3eb6450153e2efe0b8747e9a - React-Fabric: 252112089d2c63308f4cbfade4010b6606db67d1 - React-FabricComponents: 3c0f75321680d14d124438ab279c64ec2a3d13c4 - React-FabricImage: 728b8061cdec2857ca885fd605ee03ad43ffca98 + React-defaultsnativemodule: 4bb28fc97fee5be63a9ebf8f7a435cfe8ba69459 + React-domnativemodule: b36a11c2597243d7563985028c51ece988d8ae33 + React-Fabric: afc561718f25b2cd800b709d934101afe376a12c + React-FabricComponents: f4e0a4e18a27bf6d39cbf2a0b42f37a92fa4e37f + React-FabricImage: 37d8e8b672eda68a19d71143eb65148084efb325 React-featureflags: 19682e02ef5861d96b992af16a19109c3dfc1200 - React-featureflagsnativemodule: 23528c7e7d50782b7ef0804168ba40bbaf1e86ab - React-graphics: fefe48f71bfe6f48fd037f59e8277b12e91b6be1 - React-hermes: a9a0c8377627b5506ef9a7b6f60a805c306e3f51 - React-idlecallbacksnativemodule: 7e2b6a3b70e042f89cd91dbd73c479bb39a72a7e - React-ImageManager: e3300996ac2e2914bf821f71e2f2c92ae6e62ae2 - React-jserrorhandler: fa75876c662e5d7e79d6efc763fc9f4c88e26986 - React-jsi: f3f51595cc4c089037b536368f016d4742bf9cf7 - React-jsiexecutor: cca6c232db461e2fd213a11e9364cfa6fdaa20eb - React-jsinspector: 2bd4c9fddf189d6ec2abf4948461060502582bef - React-jsinspectortracing: a417d8a0ad481edaa415734b4dac81e3e5ee7dc6 - React-jsitracing: 1ff7172c5b0522cbf6c98d82bdbb160e49b5804e - React-logger: 018826bfd51b9f18e87f67db1590bc510ad20664 - React-Mapbuffer: 3c11cee7737609275c7b66bd0b1de475f094cedf - React-microtasksnativemodule: 843f352b32aacbe13a9c750190d34df44c3e6c2c - react-native-blob-util: f82bbc6f071231ae76e1c03b77290de1781df313 - react-native-document-picker: 8663632f183816c420ea0c462711d1abc19ac936 - react-native-image-picker: df2b20cdfa981f7288f4019774d9baa26a4772c1 - react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 - react-native-safe-area-context: 7e513d737b0b5c1d10bbe0e5fcc9f925a7be144c - react-native-video: b0fc63469ac8cab3bae8e0e7368ba940e425f8a4 - React-NativeModulesApple: 88433b6946778bea9c153e27b671de15411bf225 - React-perflogger: 9e8d3c0dc0194eb932162812a168aa5dc662f418 - React-performancetimeline: 5a2d6efef52bdcefac079c7baa30934978acd023 + React-featureflagsnativemodule: d7cddf6d907b4e5ab84f9e744b7e88461656e48c + React-graphics: b0f78580cdaf5800d25437e3d41cc6c3d83b7aea + React-hermes: 71186f872c932e4574d5feb3ed754dda63a0b3bd + React-idlecallbacksnativemodule: dd2af19cdd3bc55149d17a2409ed72b694dfbe9c + React-ImageManager: a77dde8d5aa6a2b6962c702bf3a47695ef0aa32b + React-jserrorhandler: 9c14e89f12d5904257a79aaf84a70cd2e5ac07ba + React-jsi: 0775a66820496769ad83e629f0f5cce621a57fc7 + React-jsiexecutor: 2cf5ba481386803f3c88b85c63fa102cba5d769e + React-jsinspector: 8052d532bb7a98b6e021755674659802fb140cc5 + React-jsinspectortracing: bdd8fd0adcb4813663562e7874c5842449df6d8a + React-jsitracing: 2bab3bf55de3d04baf205def375fa6643c47c794 + React-logger: 795cd5055782db394f187f9db0477d4b25b44291 + React-Mapbuffer: 0502faf46cab8fb89cfc7bf3e6c6109b6ef9b5de + React-microtasksnativemodule: 663bc64e3a96c5fc91081923ae7481adc1359a78 + react-native-blob-util: fa67658b21ee53bf62a54741a74c441c0e3f2c90 + react-native-document-picker: 5cb1c6615796389f4f1b7fe2e4f103e38e4d6398 + react-native-image-picker: 0452fc9efc21101946746663bf34e0db5741f00c + react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac + react-native-safe-area-context: 9c33120e9eac7741a5364cc2d9f74665049b76b3 + react-native-video: c95c10cdac8541f74bd92194dd6a8137ebe6a19d + React-NativeModulesApple: 16fbd5b040ff6c492dacc361d49e63cba7a6a7a1 + React-perflogger: ab51b7592532a0ea45bf6eed7e6cae14a368b678 + React-performancetimeline: bc2e48198ec814d578ac8401f65d78a574358203 React-RCTActionSheet: 592674cf61142497e0e820688f5a696e41bf16dd - React-RCTAnimation: e6d669872f9b3b4ab9527aab283b7c49283236b7 - React-RCTAppDelegate: de2343fe08be4c945d57e0ecce44afcc7dd8fc03 - React-RCTBlob: 3e2dce94c56218becc4b32b627fc2293149f798d - React-RCTFabric: cac2c033381d79a5956e08550b0220cb2d78ea93 - React-RCTFBReactNativeSpec: d10ca5e0ccbfeac8c047361fedf8e4ac653887b6 - React-RCTImage: dc04b176c022d12a8f55ae7a7279b1e091066ae0 - React-RCTLinking: 88f5e37fe4f26fbc80791aa2a5f01baf9b9a3fd5 - React-RCTNetwork: f213693565efbd698b8e9c18d700a514b49c0c8e - React-RCTSettings: a2d32a90c45a3575568cad850abc45924999b8a5 - React-RCTText: 54cdcd1cbf6f6a91dc6317f5d2c2b7fc3f6bf7a0 - React-RCTVibration: 11dae0e7f577b5807bb7d31e2e881eb46f854fd4 + React-RCTAnimation: 8fbb8dba757b49c78f4db403133ab6399a4ce952 + React-RCTAppDelegate: 7f88baa8cb4e5d6c38bb4d84339925c70c9ac864 + React-RCTBlob: f89b162d0fe6b570a18e755eb16cbe356d3c6d17 + React-RCTFabric: 8ad6d875abe6e87312cef90e4b15ef7f6bed72e6 + React-RCTFBReactNativeSpec: 8c29630c2f379c729300e4c1e540f3d1b78d1936 + React-RCTImage: ccac9969940f170503857733f9a5f63578e106e1 + React-RCTLinking: d82427bbf18415a3732105383dff119131cadd90 + React-RCTNetwork: 12ad4d0fbde939e00251ca5ca890da2e6825cc3c + React-RCTSettings: e7865bf9f455abf427da349c855f8644b5c39afa + React-RCTText: 2cdfd88745059ec3202a0842ea75a956c7d6f27d + React-RCTVibration: a3a1458e6230dfd64b3768ebc0a4aac430d9d508 React-rendererconsistency: 64e897e00d2568fd8dfe31e2496f80e85c0aaad1 - React-rendererdebug: 41ce452460c44bba715d9e41d5493a96de277764 + React-rendererdebug: a3f6d3ae7d2fa0035885026756281c07ee32479e React-rncore: 58748c2aa445f56b99e5118dad0aedb51c40ce9f - React-RuntimeApple: 7785ed0d8ae54da65a88736bb63ca97608a6d933 - React-RuntimeCore: 6029ea70bc77f98cfd43ebe69217f14e93ba1f12 + React-RuntimeApple: f0fda7bacabd32daa099cfda8f07466c30acd149 + React-RuntimeCore: 683ee0b6a76d4b4bf6fbf83a541895b4887cc636 React-runtimeexecutor: a188df372373baf5066e6e229177836488799f80 - React-RuntimeHermes: a264609c28b796edfffc8ae4cb8fad1773ab948b - React-runtimescheduler: 23ec3a1e0fb1ec752d1a9c1fb15258c30bfc7222 + React-RuntimeHermes: 907c8e9bec13ea6466b94828c088c24590d4d0b6 + React-runtimescheduler: a2e2a39125dd6426b5d8b773f689d660cd7c5f60 React-timing: bb220a53a795ed57976a4855c521f3de2f298fe5 - React-utils: 3b054aaebe658fc710a8d239d0e4b9fd3e0b78f9 - ReactAppDependencyProvider: a1fb08dfdc7ebc387b2e54cfc9decd283ed821d8 - ReactCodegen: 008c319179d681a6a00966edfc67fda68f9fbb2e - ReactCommon: 0c097b53f03d6bf166edbcd0915da32f3015dd90 - RNAudioRecorderPlayer: 11df0c7b614e9767ef24d896465c3a758c592de7 - RNCClipboard: f13dd3ceae005858e137ae9e70f3c414e174ff81 - RNGestureHandler: 8b1080a6db0be82dbca18550d6212b885bfab6b2 - RNReactNativeHapticFeedback: 66c6b0cf19f5d9dae8be36b2336e1fe2a2e42566 - RNReanimated: 6383cd0d805e48768b97bd65bcb1d06f0e69ab8e - RNScreens: 0d4cb9afe052607ad0aa71f645a88bb7c7f2e64c - RNShare: 56b5431c60e1e9ee167191f4f327471af1c2941a - RNSVG: 8126581b369adf6a0004b6a6cab1a55e3002d5b0 + React-utils: 300d8bbb6555dcffaca71e7a0663201b5c7edbbc + ReactAppDependencyProvider: f2e81d80afd71a8058589e19d8a134243fa53f17 + ReactCodegen: a63a0ab6ae824aef2e8c744981edd718b16eb9f2 + ReactCommon: 3d39389f8e2a2157d5c999f8fba57bd1c8f226f0 + RNAudioRecorderPlayer: 224c7de87722938aedce04000d09baa633148f5b + RNCClipboard: 7659a79c651d0e889bbd533dcc8bc8ff1e98ed70 + RNGestureHandler: 9b05fab9a0b48fe48c968de7dbb9ca38a2b4f7ab + RNReactNativeHapticFeedback: 5fdbbaedabc1698dc3bb2a72105fadf63136a451 + RNReanimated: ff71ce0443c71c8a77501ace7b9738ede83cfa55 + RNScreens: 991214b4e69016c1ae32830d9cea31c9c9422367 + RNShare: 6b1ee93f4fce1346c7c299c3107af20af05120b4 + RNSVG: 8588ee1ca9b2e6fd2c99466e35b3db0e9f81bb40 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - stream-chat-react-native: ad7b0924fab4b907f3ac17210322f8b435affe80 + stream-chat-react-native: 65658c637042b7223460891d7be6491d46d6cf0c Yoga: afd04ff05ebe0121a00c468a8a3c8080221cb14c PODFILE CHECKSUM: 6b7a4b74915b42bfe4ffddaf67cbf5e7a2bfeab3 diff --git a/examples/TypeScriptMessaging/yarn.lock b/examples/TypeScriptMessaging/yarn.lock index 7076242572..74b4ebdc7c 100644 --- a/examples/TypeScriptMessaging/yarn.lock +++ b/examples/TypeScriptMessaging/yarn.lock @@ -6954,9 +6954,24 @@ stream-chat-react-native-core@7.0.0: uid "" stream-chat@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.0.0.tgz#cb22dcb8b7f070c623a13b6b75b212d560534d6c" - integrity sha512-I4+/DEp7dP3WBgRmqHaLswL+Y2fyQkUWJhYBS5zx4bpu1cYM6WEir9HYjToDNuJjltqa/FFIEF/tMPWr7iTc0A== + version "9.1.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.1.1.tgz#c81ffa84a7ca579d9812065bc159010191b59090" + integrity sha512-7Y23aIVQMppNZgRj/rTFwIx9pszxgDcS99idkSXJSgdV8C7FlyDtiF1yQSdP0oiNFAt7OUP/xSqmbJTljrm24Q== + dependencies: + "@types/jsonwebtoken" "^9.0.8" + "@types/ws" "^8.5.14" + axios "^1.6.0" + base64-js "^1.5.1" + form-data "^4.0.0" + isomorphic-ws "^5.0.0" + jsonwebtoken "^9.0.2" + linkifyjs "^4.2.0" + ws "^8.18.1" + +stream-chat@^9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.2.0.tgz#f3109891ca27f17b6fd0aa6ebcf66be12df1f88c" + integrity sha512-inz3CA5tuqqSrla7qjRTCKs+coRKOYROWf0wEWYgbCu0tAUuiBTRtu1PJL1isEXIaPLiWi00BuRrBEIFon9Kng== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/package/package.json b/package/package.json index 4af7491795..8025fec589 100644 --- a/package/package.json +++ b/package/package.json @@ -77,7 +77,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^1.3.0", - "stream-chat": "^9.0.0", + "stream-chat": "^9.2.0", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { @@ -149,10 +149,11 @@ "react-test-renderer": "19.0.0", "rimraf": "^6.0.1", "typescript": "5.8.2", - "typescript-eslint": "^8.25.0", + "typescript-eslint": "^8.29.0", "uuid": "^11.1.0" }, "resolutions": { - "@types/react": "^19.0.0" + "@types/react": "^19.0.0", + "@babel/runtime": "^7.26.9" } } diff --git a/package/src/__tests__/offline-support/offline-feature.js b/package/src/__tests__/offline-support/offline-feature.js index ce995646de..5bc46f9ca6 100644 --- a/package/src/__tests__/offline-support/offline-feature.js +++ b/package/src/__tests__/offline-support/offline-feature.js @@ -13,8 +13,11 @@ import { useChannelsContext } from '../../contexts/channelsContext/ChannelsConte import { getOrCreateChannelApi } from '../../mock-builders/api/getOrCreateChannel'; import { queryChannelsApi } from '../../mock-builders/api/queryChannels'; import { useMockedApis } from '../../mock-builders/api/useMockedApis'; +import dispatchChannelDeletedEvent from '../../mock-builders/event/channelDeleted'; +import dispatchChannelHiddenEvent from '../../mock-builders/event/channelHidden'; import dispatchChannelTruncatedEvent from '../../mock-builders/event/channelTruncated'; import dispatchChannelUpdatedEvent from '../../mock-builders/event/channelUpdated'; +import dispatchChannelVisibleEvent from '../../mock-builders/event/channelVisible'; import dispatchConnectionChangedEvent from '../../mock-builders/event/connectionChanged'; import dispatchMemberAddedEvent from '../../mock-builders/event/memberAdded'; import dispatchMemberRemovedEvent from '../../mock-builders/event/memberRemoved'; @@ -23,6 +26,7 @@ import dispatchMessageNewEvent from '../../mock-builders/event/messageNew'; import dispatchMessageReadEvent from '../../mock-builders/event/messageRead'; import dispatchMessageUpdatedEvent from '../../mock-builders/event/messageUpdated'; import dispatchNotificationAddedToChannel from '../../mock-builders/event/notificationAddedToChannel'; +import dispatchNotificationMarkUnread from '../../mock-builders/event/notificationMarkUnread'; import dispatchNotificationMessageNewEvent from '../../mock-builders/event/notificationMessageNew'; import dispatchNotificationRemovedFromChannel from '../../mock-builders/event/notificationRemovedFromChannel'; import dispatchReactionDeletedEvent from '../../mock-builders/event/reactionDeleted'; @@ -124,8 +128,10 @@ export const Generic = () => { const createChannel = (messagesOverride) => { const id = uuidv4(); const cid = `messaging:${id}`; - const begin = getRandomInt(0, allUsers.length - 2); // begin shouldn't be the end of users.length - const end = getRandomInt(begin + 1, allUsers.length - 1); + // always guarantee at least 2 members for ease of use; cases that need to test specific behaviour + // for 1 or 0 member channels should explicitly generate them. + const begin = getRandomInt(0, allUsers.length - 3); // begin shouldn't be the end of users.length + const end = getRandomInt(begin + 2, allUsers.length - 1); const usersForMembers = allUsers.slice(begin, end); const members = usersForMembers.map((user) => generateMember({ @@ -133,6 +139,8 @@ export const Generic = () => { user, }), ); + members.push(generateMember({ cid, user: chatClient.user })); + const messages = messagesOverride || Array(10) @@ -162,8 +170,9 @@ export const Generic = () => { }); const reads = members.map((member) => ({ + cid, last_read: new Date(new Date().setDate(new Date().getDate() - getRandomInt(0, 20))), - unread_messages: getRandomInt(0, messages.length), + unread_messages: 0, user: member.user, })); @@ -176,21 +185,23 @@ export const Generic = () => { id, members, messages, + read: reads, }); }; beforeEach(async () => { jest.clearAllMocks(); + chatClient = await getTestClientWithUser({ id: 'dan' }); allUsers = Array(20).fill(1).map(generateUser); + allUsers.push(chatClient.user); allMessages = []; allMembers = []; allReactions = []; allReads = []; + channels = Array(10) .fill(1) .map(() => createChannel()); - - chatClient = await getTestClientWithUser({ id: 'dan' }); await BetterSqlite.openDB(); BetterSqlite.dropAllTables(); }); @@ -282,12 +293,7 @@ export const Generic = () => { ); readsRows.forEach((row) => expect( - allReads.filter( - (r) => - r.last_read === row.lastRead && - r.user.id === row.userId && - r.unread_messages === row.unreadMessages, - ), + allReads.filter((r) => r.user.id === row.userId && r.cid === row.cid), ).toHaveLength(1), ); }); @@ -316,6 +322,7 @@ export const Generic = () => { await act(() => dispatchConnectionChangedEvent(chatClient, false)); // await waiter(); await act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); await waitFor(async () => { expect(screen.getByTestId('channel-list')).toBeTruthy(); @@ -328,8 +335,10 @@ export const Generic = () => { renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(async () => { - act(() => dispatchConnectionChangedEvent(chatClient)); expect(screen.getByTestId('channel-list')).toBeTruthy(); await expectAllChannelsWithStateToBeInDB(screen.queryAllByLabelText); }); @@ -342,8 +351,11 @@ export const Generic = () => { renderComponent(); - await waitFor(() => { + await waitFor(async () => { act(() => dispatchConnectionChangedEvent(chatClient)); + await act( + async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true), + ); expect(screen.getByTestId('channel-list')).toBeTruthy(); expect(screen.getByTestId(emptyChannel.cid)).toBeTruthy(); expect(chatClient.hydrateActiveChannels).toHaveBeenCalled(); @@ -356,18 +368,151 @@ export const Generic = () => { renderComponent(); act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + const targetChannel = channels[0].channel; const newMessage = generateMessage({ - cid: channels[0].channel.cid, + cid: targetChannel.cid, user: generateUser(), }); - act(() => dispatchMessageNewEvent(chatClient, newMessage, channels[0].channel)); + act(() => dispatchMessageNewEvent(chatClient, newMessage, targetChannel)); await waitFor(async () => { const messagesRows = await BetterSqlite.selectFromTable('messages'); - const matchingRows = messagesRows.filter((m) => m.id === newMessage.id); - expect(matchingRows.length).toBe(1); - expect(matchingRows[0].id).toBe(newMessage.id); + const readRows = await BetterSqlite.selectFromTable('reads'); + const matchingMessageRows = messagesRows.filter((m) => m.id === newMessage.id); + const matchingReadRows = readRows.filter( + (r) => targetChannel.cid === r.cid && chatClient.userID === r.userId, + ); + + expect(matchingMessageRows.length).toBe(1); + expect(matchingMessageRows[0].id).toBe(newMessage.id); + expect(matchingReadRows.length).toBe(1); + expect(matchingReadRows[0].unreadMessages).toBe(1); + }); + }); + + it('should correctly handle multiple new messages and add them to the database', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + + renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + const targetChannel = channels[0].channel; + + // check if the reads state is correct first + await waitFor(async () => { + const readRows = await BetterSqlite.selectFromTable('reads'); + const matchingReadRows = readRows.filter( + (r) => targetChannel.cid === r.cid && chatClient.userID === r.userId, + ); + + expect(matchingReadRows.length).toBe(1); + expect(matchingReadRows[0].unreadMessages).toBe(0); + }); + + const newMessages = [ + generateMessage({ + cid: targetChannel.cid, + user: generateUser(), + }), + generateMessage({ + cid: targetChannel.cid, + user: generateUser(), + }), + generateMessage({ + cid: targetChannel.cid, + user: generateUser(), + }), + ]; + + newMessages.forEach((newMessage) => { + act(() => dispatchMessageNewEvent(chatClient, newMessage, targetChannel)); + }); + + await waitFor(async () => { + const messagesRows = await BetterSqlite.selectFromTable('messages'); + const readRows = await BetterSqlite.selectFromTable('reads'); + const matchingMessageRows = messagesRows.filter((m) => + newMessages.some((newMessage) => newMessage.id === m.id), + ); + const matchingReadRows = readRows.filter( + (r) => targetChannel.cid === r.cid && chatClient.userID === r.userId, + ); + + expect(matchingMessageRows.length).toBe(3); + newMessages.forEach((newMessage) => { + expect( + matchingMessageRows.some( + (matchingMessageRow) => matchingMessageRow.id === newMessage.id, + ), + ).toBe(true); + }); + expect(matchingReadRows.length).toBe(1); + expect(matchingReadRows[0].unreadMessages).toBe(3); + }); + }); + + it('should correctly handle multiple new messages from our own user', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + + renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + const targetChannel = channels[0].channel; + + // check if the reads state is correct first + await waitFor(async () => { + const readRows = await BetterSqlite.selectFromTable('reads'); + const matchingReadRows = readRows.filter( + (r) => targetChannel.cid === r.cid && chatClient.userID === r.userId, + ); + + expect(matchingReadRows.length).toBe(1); + expect(matchingReadRows[0].unreadMessages).toBe(0); + }); + + const newMessages = [ + generateMessage({ + cid: targetChannel.cid, + user: chatClient.user, + }), + generateMessage({ + cid: targetChannel.cid, + user: chatClient.user, + }), + generateMessage({ + cid: targetChannel.cid, + user: chatClient.user, + }), + ]; + + newMessages.forEach((newMessage) => { + act(() => dispatchMessageNewEvent(chatClient, newMessage, targetChannel)); + }); + + await waitFor(async () => { + const messagesRows = await BetterSqlite.selectFromTable('messages'); + const readRows = await BetterSqlite.selectFromTable('reads'); + const matchingMessageRows = messagesRows.filter((m) => + newMessages.some((newMessage) => newMessage.id === m.id), + ); + const matchingReadRows = readRows.filter( + (r) => targetChannel.cid === r.cid && chatClient.userID === r.userId, + ); + + expect(matchingMessageRows.length).toBe(3); + newMessages.forEach((newMessage) => { + expect( + matchingMessageRows.some( + (matchingMessageRow) => matchingMessageRow.id === newMessage.id, + ), + ).toBe(true); + }); + expect(matchingReadRows.length).toBe(1); + expect(matchingReadRows[0].unreadMessages).toBe(0); }); }); @@ -375,8 +520,11 @@ export const Generic = () => { useMockedApis(chatClient, [queryChannelsApi(channels)]); renderComponent(); - await waitFor(() => { + await waitFor(async () => { act(() => dispatchConnectionChangedEvent(chatClient)); + await act( + async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true), + ); expect(screen.getByTestId('channel-list')).toBeTruthy(); }); @@ -399,6 +547,7 @@ export const Generic = () => { renderComponent(); act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); const updatedMessage = { ...channels[0].messages[0] }; @@ -420,6 +569,7 @@ export const Generic = () => { renderComponent(); act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); const removedChannel = channels[getRandomInt(0, channels.length - 1)].channel; act(() => dispatchNotificationRemovedFromChannel(chatClient, removedChannel)); @@ -441,11 +591,122 @@ export const Generic = () => { }); }); + it('should remove the channel from DB if the channel is deleted', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + + renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + const removedChannel = channels[getRandomInt(0, channels.length - 1)].channel; + act(() => dispatchChannelDeletedEvent(chatClient, removedChannel)); + await waitFor(async () => { + const channelIdsOnUI = screen + .queryAllByLabelText('list-item') + .map((node) => node._fiber.pendingProps.testID); + expect(channelIdsOnUI.includes(removedChannel.cid)).toBeFalsy(); + await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); + + const channelsRows = await BetterSqlite.selectFromTable('channels'); + const matchingRows = channelsRows.filter((c) => c.id === removedChannel.id); + + const messagesRows = await BetterSqlite.selectFromTable('messages'); + const matchingMessagesRows = messagesRows.filter((m) => m.cid === removedChannel.cid); + + expect(matchingRows.length).toBe(0); + expect(matchingMessagesRows.length).toBe(0); + }); + }); + + it('should correctly mark the channel as hidden in the db', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + + renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + const hiddenChannel = channels[getRandomInt(0, channels.length - 1)].channel; + act(() => dispatchChannelHiddenEvent(chatClient, hiddenChannel)); + await waitFor(async () => { + const channelIdsOnUI = screen + .queryAllByLabelText('list-item') + .map((node) => node._fiber.pendingProps.testID); + expect(channelIdsOnUI.includes(hiddenChannel.cid)).toBeFalsy(); + await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); + + const channelsRows = await BetterSqlite.selectFromTable('channels'); + const matchingRows = channelsRows.filter((c) => c.id === hiddenChannel.id); + + const messagesRows = await BetterSqlite.selectFromTable('messages'); + const matchingMessagesRows = messagesRows.filter((m) => m.cid === hiddenChannel.cid); + + expect(matchingRows.length).toBe(1); + expect(matchingRows[0].hidden).toBeTruthy(); + expect(matchingMessagesRows.length).toBe( + chatClient.activeChannels[hiddenChannel.cid].state.messages.length, + ); + }); + }); + + it('should correctly mark the channel as visible if it was hidden before in the db', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + + renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + const hiddenChannel = channels[getRandomInt(0, channels.length - 1)].channel; + // first, we mark it as hidden + act(() => dispatchChannelHiddenEvent(chatClient, hiddenChannel)); + await waitFor(async () => { + const channelIdsOnUI = screen + .queryAllByLabelText('list-item') + .map((node) => node._fiber.pendingProps.testID); + expect(channelIdsOnUI.includes(hiddenChannel.cid)).toBeFalsy(); + await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); + + const channelsRows = await BetterSqlite.selectFromTable('channels'); + const matchingRows = channelsRows.filter((c) => c.id === hiddenChannel.id); + + const messagesRows = await BetterSqlite.selectFromTable('messages'); + const matchingMessagesRows = messagesRows.filter((m) => m.cid === hiddenChannel.cid); + + expect(matchingRows.length).toBe(1); + expect(matchingRows[0].hidden).toBeTruthy(); + expect(matchingMessagesRows.length).toBe( + chatClient.activeChannels[hiddenChannel.cid].state.messages.length, + ); + }); + + // then, we make it visible after waiting for everything to finish + act(() => dispatchChannelVisibleEvent(chatClient, hiddenChannel)); + await waitFor(async () => { + const channelIdsOnUI = screen + .queryAllByLabelText('list-item') + .map((node) => node._fiber.pendingProps.testID); + expect(channelIdsOnUI.includes(hiddenChannel.cid)).toBeFalsy(); + await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); + + const channelsRows = await BetterSqlite.selectFromTable('channels'); + const matchingRows = channelsRows.filter((c) => c.id === hiddenChannel.id); + + const messagesRows = await BetterSqlite.selectFromTable('messages'); + const matchingMessagesRows = messagesRows.filter((m) => m.cid === hiddenChannel.cid); + + expect(matchingRows.length).toBe(1); + expect(matchingRows[0].hidden).toBeFalsy(); + expect(matchingMessagesRows.length).toBe( + chatClient.activeChannels[hiddenChannel.cid].state.messages.length, + ); + }); + }); + it('should add the channel to DB when user is added as member', async () => { useMockedApis(chatClient, [queryChannelsApi(channels)]); renderComponent(); act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); const newChannel = createChannel(); @@ -476,11 +737,130 @@ export const Generic = () => { renderComponent(); act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); const channelToTruncate = channels[getRandomInt(0, channels.length - 1)].channel; act(() => dispatchChannelTruncatedEvent(chatClient, channelToTruncate)); + await waitFor(async () => { + const channelIdsOnUI = screen + .queryAllByLabelText('list-item') + .map((node) => node._fiber.pendingProps.testID); + expect(channelIdsOnUI.includes(channelToTruncate.cid)).toBeTruthy(); + expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); + + const messagesRows = await BetterSqlite.selectFromTable('messages'); + const matchingMessagesRows = messagesRows.filter((m) => m.cid === channelToTruncate.cid); + + const readsRows = await BetterSqlite.selectFromTable('reads'); + const matchingReadRows = readsRows.filter( + (r) => r.userId === chatClient.userID && r.cid === channelToTruncate.cid, + ); + + expect(matchingMessagesRows.length).toBe(0); + expect(matchingReadRows.length).toBe(1); + expect(matchingReadRows[0].unreadMessages).toBe(0); + }); + }); + + it('should truncate the correct messages if channel.truncated arrives with truncated_at', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + + renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + + const channelResponse = channels[getRandomInt(0, channels.length - 1)]; + const channelToTruncate = channelResponse.channel; + const messages = channelResponse.messages; + messages.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + // truncate at the middle + const truncatedAt = messages[Number(messages.length / 2)].created_at; + act(() => + dispatchChannelTruncatedEvent(chatClient, { + ...channelToTruncate, + truncated_at: truncatedAt, + }), + ); + + await waitFor(async () => { + const channelIdsOnUI = screen + .queryAllByLabelText('list-item') + .map((node) => node._fiber.pendingProps.testID); + expect(channelIdsOnUI.includes(channelToTruncate.cid)).toBeTruthy(); + expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); + + const messagesRows = await BetterSqlite.selectFromTable('messages'); + const matchingMessagesRows = messagesRows.filter((m) => m.cid === channelToTruncate.cid); + + const readsRows = await BetterSqlite.selectFromTable('reads'); + const matchingReadRows = readsRows.filter( + (r) => r.userId === chatClient.userID && r.cid === channelToTruncate.cid, + ); + + const messagesLeft = messages.length / 2 - 1; + + expect(matchingMessagesRows.length).toBe(messagesLeft); + expect(matchingReadRows.length).toBe(1); + expect(matchingReadRows[0].unreadMessages).toBe(messagesLeft); + }); + }); + + it('should gracefully handle a truncated_at date before each message', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + + renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + + const channelResponse = channels[getRandomInt(0, channels.length - 1)]; + const channelToTruncate = channelResponse.channel; + const truncatedAt = new Date(0).toISOString(); + act(() => + dispatchChannelTruncatedEvent(chatClient, { + ...channelToTruncate, + truncated_at: truncatedAt, + }), + ); + + await waitFor(async () => { + const channelIdsOnUI = screen + .queryAllByLabelText('list-item') + .map((node) => node._fiber.pendingProps.testID); + expect(channelIdsOnUI.includes(channelToTruncate.cid)).toBeTruthy(); + expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); + + const messagesRows = await BetterSqlite.selectFromTable('messages'); + const matchingMessagesRows = messagesRows.filter((m) => m.cid === channelToTruncate.cid); + + expect(matchingMessagesRows.length).toBe(channelResponse.messages.length); + }); + }); + + it('should gracefully handle a truncated_at date after each message', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + + renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + + const channelResponse = channels[getRandomInt(0, channels.length - 1)]; + const channelToTruncate = channelResponse.channel; + const messages = channelResponse.messages; + const latestTimestamp = Math.max(...messages.map((m) => new Date(m.created_at).getTime())); + // truncate at the middle + const truncatedAt = new Date(latestTimestamp + 1).toISOString(); + act(() => + dispatchChannelTruncatedEvent(chatClient, { + ...channelToTruncate, + truncated_at: truncatedAt, + }), + ); + await waitFor(async () => { const channelIdsOnUI = screen .queryAllByLabelText('list-item') @@ -499,6 +879,7 @@ export const Generic = () => { useMockedApis(chatClient, [queryChannelsApi(channels)]); renderComponent(); act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); const targetChannel = channels[getRandomInt(0, channels.length - 1)]; @@ -539,11 +920,161 @@ export const Generic = () => { }); }); + it('should correctly add multiple reactions to the DB', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + + const targetChannel = channels[getRandomInt(0, channels.length - 1)]; + const targetMessage = + targetChannel.messages[getRandomInt(0, targetChannel.messages.length - 1)]; + const reactionMember = + targetChannel.members[getRandomInt(0, targetChannel.members.length - 1)]; + const someOtherMember = targetChannel.members.filter( + (member) => reactionMember.user.id !== member.user.id, + )[getRandomInt(0, targetChannel.members.length - 2)]; + + const newReactions = [ + generateReaction({ + message_id: targetMessage.id, + type: 'wow', + user: reactionMember.user, + }), + generateReaction({ + message_id: targetMessage.id, + type: 'wow', + user: someOtherMember.user, + }), + generateReaction({ + message_id: targetMessage.id, + type: 'love', + user: reactionMember.user, + }), + ]; + const messageWithNewReactionBase = { + ...targetMessage, + latest_reactions: [...targetMessage.latest_reactions], + }; + const newLatestReactions = []; + + newReactions.forEach((newReaction) => { + newLatestReactions.push(newReaction); + const messageWithNewReaction = { + ...messageWithNewReactionBase, + latest_reactions: [...messageWithNewReactionBase.latest_reactions, ...newLatestReactions], + }; + act(() => + dispatchReactionNewEvent( + chatClient, + newReaction, + messageWithNewReaction, + targetChannel.channel, + ), + ); + }); + + const finalReactionCount = + messageWithNewReactionBase.latest_reactions.length + + newReactions.filter( + (newReaction) => + !messageWithNewReactionBase.latest_reactions.some( + (initialReaction) => + initialReaction.type === newReaction.type && + initialReaction.user.id === newReaction.user.id, + ), + ).length; + + await waitFor(async () => { + const reactionsRows = await BetterSqlite.selectFromTable('reactions'); + const matchingReactionsRows = reactionsRows.filter( + (r) => r.messageId === messageWithNewReactionBase.id, + ); + + expect(matchingReactionsRows.length).toBe(finalReactionCount); + newReactions.forEach((newReaction) => { + expect( + matchingReactionsRows.filter( + (reaction) => + reaction.type === newReaction.type && reaction.userId === newReaction.user.id, + ).length, + ).toBe(1); + }); + }); + }); + + it('should gracefully handle multiple reaction.new events of the same type for the same user', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + + const targetChannel = channels[getRandomInt(0, channels.length - 1)]; + const targetMessage = + targetChannel.messages[getRandomInt(0, targetChannel.messages.length - 1)]; + const reactionMember = + targetChannel.members[getRandomInt(0, targetChannel.members.length - 1)]; + + const newReactions = [ + generateReaction({ + message_id: targetMessage.id, + type: 'wow', + user: reactionMember.user, + }), + generateReaction({ + message_id: targetMessage.id, + type: 'wow', + user: reactionMember.user, + }), + generateReaction({ + message_id: targetMessage.id, + type: 'wow', + user: reactionMember.user, + }), + ]; + const messageWithNewReactionBase = { + ...targetMessage, + latest_reactions: [...targetMessage.latest_reactions], + }; + const newLatestReactions = []; + + newReactions.forEach((newReaction) => { + newLatestReactions.push(newReaction); + const messageWithNewReaction = { + ...messageWithNewReactionBase, + latest_reactions: [...messageWithNewReactionBase.latest_reactions, ...newLatestReactions], + }; + act(() => + dispatchReactionNewEvent( + chatClient, + newReaction, + messageWithNewReaction, + targetChannel.channel, + ), + ); + }); + + await waitFor(async () => { + const reactionsRows = await BetterSqlite.selectFromTable('reactions'); + const matchingReactionsRows = reactionsRows.filter( + (r) => + r.type === 'wow' && + r.userId === reactionMember.user.id && + r.messageId === messageWithNewReactionBase.id, + ); + + expect(matchingReactionsRows.length).toBe(1); + }); + }); + it('should remove a reaction from DB when reaction is deleted', async () => { useMockedApis(chatClient, [queryChannelsApi(channels)]); renderComponent(); act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); const targetChannel = channels[getRandomInt(0, channels.length - 1)]; @@ -597,6 +1128,7 @@ export const Generic = () => { renderComponent(); act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); const targetChannel = channels[getRandomInt(0, channels.length - 1)]; @@ -629,24 +1161,301 @@ export const Generic = () => { }); }); - it('should add a member to DB when a new member is added to channel', async () => { + it('should correctly upsert reactions when enforce_unique is true', async () => { useMockedApis(chatClient, [queryChannelsApi(channels)]); + renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + const targetChannel = channels[getRandomInt(0, channels.length - 1)]; + const targetMessage = + targetChannel.messages[getRandomInt(0, targetChannel.messages.length - 1)]; + const reactionMember = + targetChannel.members[getRandomInt(0, targetChannel.members.length - 1)]; + + const newReactions = [ + generateReaction({ + message_id: targetMessage.id, + type: 'wow', + user: reactionMember.user, + }), + generateReaction({ + message_id: targetMessage.id, + type: 'love', + user: reactionMember.user, + }), + ]; + const messageWithNewReactionBase = { + ...targetMessage, + latest_reactions: [...targetMessage.latest_reactions], + }; + const newLatestReactions = []; + + newReactions.forEach((newReaction) => { + newLatestReactions.push(newReaction); + const messageWithNewReaction = { + ...messageWithNewReactionBase, + latest_reactions: [...messageWithNewReactionBase.latest_reactions, ...newLatestReactions], + }; + act(() => + dispatchReactionNewEvent( + chatClient, + newReaction, + messageWithNewReaction, + targetChannel.channel, + ), + ); + }); + + await waitFor(async () => { + const reactionsRows = await BetterSqlite.selectFromTable('reactions'); + const matchingReactionsRows = reactionsRows.filter( + (r) => + r.messageId === messageWithNewReactionBase.id && r.userId === reactionMember.user.id, + ); + + expect(matchingReactionsRows.length).toBe(2); + newReactions.forEach((newReaction) => { + expect( + matchingReactionsRows.filter( + (reaction) => + reaction.type === newReaction.type && reaction.userId === newReaction.user.id, + ).length, + ).toBe(1); + }); + }); + + const uniqueReaction = generateReaction({ + message_id: targetMessage.id, + type: 'like', + user: reactionMember.user, + }); + const messageWithNewReaction = { + ...targetMessage, + latest_reactions: [...targetMessage.latest_reactions, uniqueReaction], + }; + + act(() => + dispatchReactionUpdatedEvent( + chatClient, + uniqueReaction, + messageWithNewReaction, + targetChannel.channel, + ), + ); + + await waitFor(async () => { + const reactionsRows = await BetterSqlite.selectFromTable('reactions'); + const matchingReactionsRows = reactionsRows.filter( + (r) => + r.type === uniqueReaction.type && + r.userId === reactionMember.user.id && + r.messageId === messageWithNewReaction.id, + ); + + expect(matchingReactionsRows.length).toBe(1); + }); + }); + + it('should also update the corresponding message.reaction_groups with reaction.new', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); renderComponent(); act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); const targetChannel = channels[getRandomInt(0, channels.length - 1)]; + const targetMessage = + targetChannel.messages[getRandomInt(0, targetChannel.messages.length - 1)]; + const reactionMember = + targetChannel.members[getRandomInt(0, targetChannel.members.length - 1)]; + + const newReaction = generateReaction({ + message_id: targetMessage.id, + type: 'wow', + user: reactionMember.user, + }); + const newDate = new Date().toISOString(); + // the actual content of the reaction_groups does not matter, as we just want to know if it updates to it + // anything impossible given the scenarios is fine + const messageWithNewReaction = { + ...targetMessage, + latest_reactions: [...targetMessage.latest_reactions, newReaction], + reaction_groups: { + ...targetMessage.reaction_groups, + [newReaction.type]: { + count: 999, + first_reaction_at: newDate, + last_reaction_at: newDate, + sum_scores: 999, + }, + }, + }; + + act(() => + dispatchReactionNewEvent( + chatClient, + newReaction, + messageWithNewReaction, + targetChannel.channel, + ), + ); + + await waitFor(async () => { + const messageRows = await BetterSqlite.selectFromTable('messages'); + const messageWithReactionRow = messageRows.filter( + (m) => m.id === messageWithNewReaction.id, + )[0]; + + const reactionGroups = JSON.parse(messageWithReactionRow.reactionGroups); + + expect(reactionGroups[newReaction.type]?.count).toBe(999); + expect(reactionGroups[newReaction.type]?.sum_scores).toBe(999); + expect(reactionGroups[newReaction.type]?.first_reaction_at).toBe(newDate); + expect(reactionGroups[newReaction.type]?.last_reaction_at).toBe(newDate); + }); + }); + + it('should also update the corresponding message.reaction_groups with reaction.updated', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + + const targetChannel = channels[getRandomInt(0, channels.length - 1)]; + const targetMessage = + targetChannel.messages[getRandomInt(0, targetChannel.messages.length - 1)]; + const reactionMember = + targetChannel.members[getRandomInt(0, targetChannel.members.length - 1)]; + + const newReaction = generateReaction({ + message_id: targetMessage.id, + type: 'wow', + user: reactionMember.user, + }); + const newDate = new Date().toISOString(); + const messageWithNewReaction = { + ...targetMessage, + latest_reactions: [...targetMessage.latest_reactions, newReaction], + reaction_groups: { + ...targetMessage.reaction_groups, + [newReaction.type]: { + count: 999, + first_reaction_at: newDate, + last_reaction_at: newDate, + sum_scores: 999, + }, + }, + }; + + act(() => + dispatchReactionUpdatedEvent( + chatClient, + newReaction, + messageWithNewReaction, + targetChannel.channel, + ), + ); + + await waitFor(async () => { + const messageRows = await BetterSqlite.selectFromTable('messages'); + const messageWithReactionRow = messageRows.filter( + (m) => m.id === messageWithNewReaction.id, + )[0]; + + const reactionGroups = JSON.parse(messageWithReactionRow.reactionGroups); + + expect(reactionGroups[newReaction.type]?.count).toBe(999); + expect(reactionGroups[newReaction.type]?.sum_scores).toBe(999); + expect(reactionGroups[newReaction.type]?.first_reaction_at).toBe(newDate); + expect(reactionGroups[newReaction.type]?.last_reaction_at).toBe(newDate); + }); + }); + + it('should also update the corresponding message.reaction_groups with reaction.deleted', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + + const targetChannel = channels[getRandomInt(0, channels.length - 1)]; + const targetMessage = + targetChannel.messages[getRandomInt(0, targetChannel.messages.length - 1)]; + const reactionMember = + targetChannel.members[getRandomInt(0, targetChannel.members.length - 1)]; + + const newReaction = generateReaction({ + message_id: targetMessage.id, + type: 'wow', + user: reactionMember.user, + }); + const newDate = new Date().toISOString(); + const messageWithNewReaction = { + ...targetMessage, + latest_reactions: [...targetMessage.latest_reactions, newReaction], + reaction_groups: { + ...targetMessage.reaction_groups, + [newReaction.type]: { + count: 999, + first_reaction_at: newDate, + last_reaction_at: newDate, + sum_scores: 999, + }, + }, + }; + + act(() => + dispatchReactionDeletedEvent( + chatClient, + newReaction, + messageWithNewReaction, + targetChannel.channel, + ), + ); + + await waitFor(async () => { + const messageRows = await BetterSqlite.selectFromTable('messages'); + const messageWithReactionRow = messageRows.filter( + (m) => m.id === messageWithNewReaction.id, + )[0]; + + const reactionGroups = JSON.parse(messageWithReactionRow.reactionGroups); + + expect(reactionGroups[newReaction.type]?.count).toBe(999); + expect(reactionGroups[newReaction.type]?.sum_scores).toBe(999); + expect(reactionGroups[newReaction.type]?.first_reaction_at).toBe(newDate); + expect(reactionGroups[newReaction.type]?.last_reaction_at).toBe(newDate); + }); + }); + + it('should add a member to DB when a new member is added to channel', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + renderComponent(); + + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + const targetChannel = channels[getRandomInt(0, channels.length - 1)]; + + const oldMemberCount = targetChannel.channel.member_count; const newMember = generateMember(); act(() => dispatchMemberAddedEvent(chatClient, newMember, targetChannel.channel)); await waitFor(async () => { const membersRows = await BetterSqlite.selectFromTable('members'); + const channelRows = await BetterSqlite.selectFromTable('channels'); const matchingMembersRows = membersRows.filter( (m) => m.cid === targetChannel.channel.cid && m.userId === newMember.user_id, ); + const targetChannelFromDb = channelRows.filter( + (c) => c.cid === targetChannel.channel.cid, + )[0]; expect(matchingMembersRows.length).toBe(1); + expect(targetChannelFromDb.memberCount).toBe(oldMemberCount + 1); }); }); @@ -655,19 +1464,26 @@ export const Generic = () => { renderComponent(); act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); const targetChannel = channels[getRandomInt(0, channels.length - 1)]; const targetMember = targetChannel.members[getRandomInt(0, targetChannel.members.length - 1)]; + const oldMemberCount = targetChannel.channel.member_count; act(() => dispatchMemberRemovedEvent(chatClient, targetMember, targetChannel.channel)); await waitFor(async () => { const membersRows = await BetterSqlite.selectFromTable('members'); + const channelRows = await BetterSqlite.selectFromTable('channels'); const matchingMembersRows = membersRows.filter( (m) => m.cid === targetChannel.channel.cid && m.userId === targetMember.user_id, ); + const targetChannelFromDb = channelRows.filter( + (c) => c.cid === targetChannel.channel.cid, + )[0]; expect(matchingMembersRows.length).toBe(0); + expect(targetChannelFromDb.memberCount).toBe(oldMemberCount - 1); }); }); @@ -676,6 +1492,7 @@ export const Generic = () => { renderComponent(); act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); const targetChannel = channels[getRandomInt(0, channels.length - 1)]; @@ -702,6 +1519,7 @@ export const Generic = () => { renderComponent(); act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); const targetChannel = channels[getRandomInt(0, channels.length - 1)]; @@ -727,12 +1545,19 @@ export const Generic = () => { renderComponent(); act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); const targetChannel = channels[getRandomInt(0, channels.length - 1)]; const targetMember = targetChannel.members[getRandomInt(0, targetChannel.members.length - 1)]; + const readTimestamp = new Date().toISOString(); + act(() => { - dispatchMessageReadEvent(chatClient, targetMember.user, targetChannel.channel); + dispatchMessageReadEvent(chatClient, targetMember.user, targetChannel.channel, { + first_unread_message_id: '123', + last_read: readTimestamp, + last_read_message_id: '321', + }); }); await waitFor(async () => { @@ -743,6 +1568,54 @@ export const Generic = () => { expect(matchingReadRows.length).toBe(1); expect(matchingReadRows[0].unreadMessages).toBe(0); + expect(matchingReadRows[0].lastReadMessageId).toBe('321'); + // FIXME: Currently missing from the DB, uncomment when added. + // expect(matchingReadRows[0].firstUnreadMessageId).toBe('123'); + expect(matchingReadRows[0].lastRead).toBe(readTimestamp); + }); + }); + + it('should update reads in DB when a channel is marked as unread', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + + renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); + await waitFor(() => expect(screen.getByTestId('channel-list')).toBeTruthy()); + const targetChannel = channels[getRandomInt(0, channels.length - 1)]; + const targetMember = targetChannel.members[getRandomInt(0, targetChannel.members.length - 1)]; + + chatClient.userID = targetMember.user.id; + chatClient.user = targetMember.user; + + const readTimestamp = new Date().toISOString(); + + act(() => { + dispatchNotificationMarkUnread( + chatClient, + targetChannel.channel, + { + first_unread_message_id: '123', + last_read: readTimestamp, + last_read_message_id: '321', + unread_messages: 5, + }, + targetMember.user, + ); + }); + + await waitFor(async () => { + const readsRows = await BetterSqlite.selectFromTable('reads'); + const matchingReadRows = readsRows.filter( + (r) => r.userId === targetMember.user_id && r.cid === targetChannel.cid, + ); + + expect(matchingReadRows.length).toBe(1); + expect(matchingReadRows[0].unreadMessages).toBe(5); + expect(matchingReadRows[0].lastReadMessageId).toBe('321'); + // FIXME: Currently missing from the DB, uncomment when added. + // expect(matchingReadRows[0].firstUnreadMessageId).toBe('123'); + expect(matchingReadRows[0].lastRead).toBe(readTimestamp); }); }); }); diff --git a/package/src/__tests__/offline-support/optimistic-update.js b/package/src/__tests__/offline-support/optimistic-update.js index 68f65bef7e..7cf6fc00b8 100644 --- a/package/src/__tests__/offline-support/optimistic-update.js +++ b/package/src/__tests__/offline-support/optimistic-update.js @@ -7,11 +7,12 @@ import { v4 as uuidv4 } from 'uuid'; import { Channel } from '../../components/Channel/Channel'; import { Chat } from '../../components/Chat/Chat'; -import { MessagesContext } from '../../contexts'; +import { MessageInputContext, MessagesContext } from '../../contexts'; import { deleteMessageApi } from '../../mock-builders/api/deleteMessage'; import { deleteReactionApi } from '../../mock-builders/api/deleteReaction'; import { erroredDeleteApi, erroredPostApi } from '../../mock-builders/api/error'; import { getOrCreateChannelApi } from '../../mock-builders/api/getOrCreateChannel'; +import { sendMessageApi } from '../../mock-builders/api/sendMessage'; import { sendReactionApi } from '../../mock-builders/api/sendReaction'; import { useMockedApis } from '../../mock-builders/api/useMockedApis'; import dispatchConnectionChangedEvent from '../../mock-builders/event/connectionChanged'; @@ -112,6 +113,7 @@ export const OptimisticUpdates = () => { channels: [channelResponse], isLatestMessagesSet: true, }); + chatClient.wsConnection = { isHealthy: true, onlineStatusChanged: jest.fn() }; }); afterEach(() => { @@ -177,7 +179,7 @@ export const OptimisticUpdates = () => { }); }); - it('pending task should be cleared if deleteMessage request is succesful', async () => { + it('pending task should be cleared if deleteMessage request is successful', async () => { const message = generateMessage(); render( @@ -237,7 +239,7 @@ export const OptimisticUpdates = () => { }); }); - it('pending task should be cleared if sendReaction request is succesful', async () => { + it('pending task should be cleared if sendReaction request is successful', async () => { const reaction = generateReaction(); const targetMessage = channel.state.messages[0]; @@ -264,6 +266,69 @@ export const OptimisticUpdates = () => { }); }); + describe('send message', () => { + it('pending task should exist if sendMessage request fails', async () => { + const newMessage = generateMessage(); + + render( + + + { + useMockedApis(chatClient, [erroredPostApi()]); + try { + await sendMessage({ customMessageData: newMessage }); + } catch (e) { + // do nothing + } + }} + context={MessageInputContext} + > + + + + , + ); + await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy()); + await waitFor(async () => { + const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks'); + const pendingTaskType = pendingTasksRows?.[0]?.type; + const pendingTaskPayload = JSON.parse(pendingTasksRows?.[0]?.payload || '{}'); + expect(pendingTaskType).toBe('send-message'); + expect(pendingTaskPayload[0].id).toEqual(newMessage.id); + expect(pendingTaskPayload[0].text).toEqual(newMessage.text); + }); + }); + + it('pending task should be cleared if sendMessage request is successful', async () => { + const newMessage = generateMessage(); + + // initialValue is needed as a prop to trick the message input ctx into thinking + // we are sending a message. + render( + + + { + useMockedApis(chatClient, [sendMessageApi(newMessage)]); + await sendMessage({ customMessageData: newMessage }); + }} + context={MessageInputContext} + > + + + + , + ); + await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy()); + + await waitFor(async () => { + const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks'); + expect(pendingTasksRows.length).toBe(0); + }); + }); + }); + describe('delete reaction', () => { it('pending task should exist if deleteReaction request fails', async () => { const reaction = generateReaction(); @@ -298,7 +363,7 @@ export const OptimisticUpdates = () => { }); }); - it('pending task should be cleared if deleteReaction request is succesful', async () => { + it('pending task should be cleared if deleteReaction request is successful', async () => { const reaction = generateReaction(); const targetMessage = channel.state.messages[0]; @@ -326,52 +391,97 @@ export const OptimisticUpdates = () => { }); }); - it('pending task should be executed after connection is recovered', async () => { - const message = channel.state.messages[0]; - const reaction = generateReaction(); - - render( - - - { - useMockedApis(chatClient, [erroredDeleteApi()]); - try { - await deleteMessage(reaction); - } catch (e) { - // do nothing - } - - useMockedApis(chatClient, [erroredPostApi()]); - try { - await sendReaction(reaction.type, message.id); - } catch (e) { - // do nothing - } - }} - context={MessagesContext} - > - - - - , - ); - await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy()); + describe('pending task execution', () => { + it('pending task should be executed after connection is recovered', async () => { + const message = channel.state.messages[0]; + const reaction = generateReaction(); + + render( + + + { + useMockedApis(chatClient, [erroredDeleteApi()]); + try { + await deleteMessage(message); + } catch (e) { + // do nothing + } + + useMockedApis(chatClient, [erroredPostApi()]); + try { + await sendReaction(reaction.type, message.id); + } catch (e) { + // do nothing + } + }} + context={MessagesContext} + > + + + + , + ); + await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy()); + + await waitFor(async () => { + const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks'); + + expect(pendingTasksRows.length).toBe(2); + }); + + const deleteMessageSpy = jest.spyOn(chatClient, '_deleteMessage').mockImplementation(); + const sendReactionSpy = jest.spyOn(channel, '_sendReaction').mockImplementation(); - await waitFor(async () => { - const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks'); + act(() => dispatchConnectionChangedEvent(chatClient, true)); - expect(pendingTasksRows.length).toBe(2); + await waitFor(() => { + expect(deleteMessageSpy).toHaveBeenCalled(); + expect(sendReactionSpy).toHaveBeenCalled(); + }); }); - jest.spyOn(chatClient, 'deleteMessage').mockImplementation(); - jest.spyOn(channel, 'sendReaction').mockImplementation(); + // This is a separate test so CallbackEffectWithContext does not need to be modified in order + // to accept multiple contexts. It can be improved in the future. + it('send message pending task should be executed after connection is recovered', async () => { + const newMessage = generateMessage(); - act(() => dispatchConnectionChangedEvent(chatClient, true)); + // initialValue is needed as a prop to trick the message input ctx into thinking + // we are sending a message. + render( + + + { + useMockedApis(chatClient, [erroredPostApi()]); + try { + await sendMessage({ customMessageData: newMessage }); + } catch (e) { + // do nothing + } + }} + context={MessageInputContext} + > + + + + , + ); + await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy()); + + await waitFor(async () => { + const pendingTasksRows = await BetterSqlite.selectFromTable('pendingTasks'); + + expect(pendingTasksRows.length).toBe(1); + }); - await waitFor(() => { - expect(chatClient.deleteMessage).toHaveBeenCalled(); - expect(channel.sendReaction).toHaveBeenCalled(); + const sendMessageSpy = jest.spyOn(channel, '_sendMessage').mockImplementation(); + + act(() => dispatchConnectionChangedEvent(chatClient, true)); + + await waitFor(() => { + expect(sendMessageSpy).toHaveBeenCalled(); + }); }); }); }); diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index d8200a5fcd..8d25180e7c 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -95,9 +95,7 @@ import * as dbApi from '../../store/apis'; import { ChannelUnreadState, FileTypes } from '../../types/types'; import { addReactionToLocalState } from '../../utils/addReactionToLocalState'; import { compressedImageURI } from '../../utils/compressImage'; -import { DBSyncManager } from '../../utils/DBSyncManager'; import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand'; -import { removeReactionFromLocalState } from '../../utils/removeReactionFromLocalState'; import { removeReservedFields } from '../../utils/removeReservedFields'; import { defaultEmojiSearchIndex, @@ -260,7 +258,7 @@ export type ChannelPropsWithContext = Pick & | 'StickyHeader' > > & - Pick & + Pick & Partial< Omit< InputMessageInputContextValue, @@ -672,6 +670,7 @@ const ChannelWithContext = (props: PropsWithChildren) = UploadProgressIndicator = UploadProgressIndicatorDefault, UrlPreview = CardDefault, VideoThumbnail = VideoThumbnailDefault, + isOnline, } = props; const { thread: threadProps, threadInstance } = threadFromProps; @@ -1039,14 +1038,6 @@ const ChannelWithContext = (props: PropsWithChildren) = syncingChannelRef.current = true; setError(false); - if (channelMessagesState?.messages) { - await channel?.watch({ - messages: { - limit: channelMessagesState.messages.length + 30, - }, - }); - } - const parseMessage = (message: LocalMessage) => ({ ...message, @@ -1056,6 +1047,14 @@ const ChannelWithContext = (props: PropsWithChildren) = }) as unknown as MessageResponse; try { + if (channelMessagesState?.messages) { + await channel?.watch({ + messages: { + limit: channelMessagesState.messages.length + 30, + }, + }); + } + if (!thread) { copyChannelState(); @@ -1102,12 +1101,14 @@ const ChannelWithContext = (props: PropsWithChildren) = }; let connectionChangedSubscription: ReturnType; - if (enableOfflineSupport) { - connectionChangedSubscription = DBSyncManager.onSyncStatusChange((statusChanged) => { - if (statusChanged) { - connectionChangedHandler(); - } - }); + if (enableOfflineSupport && client.offlineDb) { + connectionChangedSubscription = client.offlineDb.syncManager.onSyncStatusChange( + (statusChanged) => { + if (statusChanged) { + connectionChangedHandler(); + } + }, + ); } else { connectionChangedSubscription = client.on('connection.changed', (event) => { if (event.online) { @@ -1118,8 +1119,7 @@ const ChannelWithContext = (props: PropsWithChildren) = return () => { connectionChangedSubscription.unsubscribe(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enableOfflineSupport, shouldSyncChannel]); + }, [enableOfflineSupport, client, shouldSyncChannel]); // In case the channel is disconnected which may happen when channel is deleted, // underlying js client throws an error. Following function ensures that Channel component @@ -1346,7 +1346,33 @@ const ChannelWithContext = (props: PropsWithChildren) = const sendMessageRequest = useStableCallback( async (message: MessageResponse, retrying?: boolean) => { + let failedMessageUpdated = false; + const handleFailedMessage = async () => { + if (!failedMessageUpdated) { + const updatedMessage = { + ...message, + cid: channel.cid, + status: MessageStatusTypes.FAILED, + }; + updateMessage(updatedMessage); + threadInstance?.upsertReplyLocally?.({ message: updatedMessage }); + optimisticallyUpdatedNewMessages.delete(message.id); + + if (enableOfflineSupport) { + await dbApi.updateMessage({ + message: updatedMessage, + }); + } + + failedMessageUpdated = true; + } + }; + try { + if (!isOnline) { + await handleFailedMessage(); + } + const updatedMessage = await uploadPendingAttachments(message); const extraFields = omit(updatedMessage, [ '__html', @@ -1386,39 +1412,33 @@ const ChannelWithContext = (props: PropsWithChildren) = } as StreamMessage; let messageResponse = {} as SendMessageAPIResponse; + if (doSendMessageRequest) { messageResponse = await doSendMessageRequest(channel?.cid || '', messageData); } else if (channel) { messageResponse = await channel.sendMessage(messageData); } - if (messageResponse.message) { - messageResponse.message.status = MessageStatusTypes.RECEIVED; + if (messageResponse?.message) { + const newMessageResponse = { + ...messageResponse.message, + status: MessageStatusTypes.RECEIVED, + }; if (enableOfflineSupport) { await dbApi.updateMessage({ - message: { ...messageResponse.message, cid: channel.cid }, + message: { ...newMessageResponse, cid: channel.cid }, }); } if (retrying) { - replaceMessage(message, messageResponse.message); + replaceMessage(message, newMessageResponse); } else { - updateMessage(messageResponse.message, {}, true); + updateMessage(newMessageResponse, {}, true); } } } catch (err) { console.log(err); - message.status = MessageStatusTypes.FAILED; - const updatedMessage = { ...message, cid: channel.cid }; - updateMessage(updatedMessage); - threadInstance?.upsertReplyLocally?.({ message: updatedMessage }); - optimisticallyUpdatedNewMessages.delete(message.id); - - if (enableOfflineSupport) { - await dbApi.updateMessage({ - message: { ...message, cid: channel.cid }, - }); - } + await handleFailedMessage(); } }, ); @@ -1512,10 +1532,8 @@ const ChannelWithContext = (props: PropsWithChildren) = } } - if (enableOfflineSupport) { - await dbApi.deleteMessage({ - id: message.id, - }); + if (client.offlineDb) { + await client.offlineDb.handleRemoveMessage({ messageId: message.id }); } }, ); @@ -1533,79 +1551,49 @@ const ChannelWithContext = (props: PropsWithChildren) = { enforce_unique: enforceUniqueReaction }, ]; - if (!enableOfflineSupport) { - await channel.sendReaction(...payload); - return; - } + if (enableOfflineSupport) { + await addReactionToLocalState({ + channel, + enforceUniqueReaction, + messageId, + reactionType: type, + user: client.user, + }); - addReactionToLocalState({ - channel, - enforceUniqueReaction, - messageId, - reactionType: type, - user: client.user, - }); + copyMessagesStateFromChannel(channel); + } - copyMessagesStateFromChannel(channel); + const sendReactionResponse = await channel.sendReaction(...payload); - const sendReactionResponse = await DBSyncManager.queueTask({ - client, - task: { - channelId: channel.id, - channelType: channel.type, - messageId, - payload, - type: 'send-reaction', - }, - }); if (sendReactionResponse?.message) { threadInstance?.upsertReplyLocally?.({ message: sendReactionResponse.message }); } }); const deleteMessage: MessagesContextValue['deleteMessage'] = useStableCallback( - async (message) => { + async (message, hardDelete = false) => { if (!channel.id) { throw new Error('Channel has not been initialized yet'); } - if (!enableOfflineSupport) { - if (message.status === MessageStatusTypes.FAILED) { - await removeMessage(message); - return; - } - await client.deleteMessage(message.id); + if (message.status === MessageStatusTypes.FAILED) { + await removeMessage(message); return; } + const updatedMessage = { + ...message, + cid: channel.cid, + deleted_at: new Date(), + type: 'deleted' as MessageLabel, + }; + updateMessage(updatedMessage); - if (message.status === MessageStatusTypes.FAILED) { - await DBSyncManager.dropPendingTasks({ messageId: message.id }); - await removeMessage(message); - } else { - const updatedMessage = { - ...message, - cid: channel.cid, - deleted_at: new Date(), - type: 'deleted' as MessageLabel, - }; - updateMessage(updatedMessage); - - threadInstance?.upsertReplyLocally({ message: updatedMessage }); - - const data = await DBSyncManager.queueTask({ - client, - task: { - channelId: channel.id, - channelType: channel.type, - messageId: message.id, - payload: [message.id], - type: 'delete-message', - }, - }); + threadInstance?.upsertReplyLocally({ message: updatedMessage }); - if (data?.message) { - updateMessage({ ...data.message }); - } + const data = await client.deleteMessage(message.id, hardDelete); + + if (data?.message) { + updateMessage({ ...data.message }); } }, ); @@ -1618,30 +1606,18 @@ const ChannelWithContext = (props: PropsWithChildren) = const payload: Parameters = [messageId, type]; - if (!enableOfflineSupport) { - await channel.deleteReaction(...payload); - return; - } - - removeReactionFromLocalState({ - channel, - messageId, - reactionType: type, - user: client.user, - }); + if (enableOfflineSupport) { + channel.state.removeReaction({ + created_at: '', + message_id: messageId, + type, + updated_at: '', + }); - copyMessagesStateFromChannel(channel); + copyMessagesStateFromChannel(channel); + } - await DBSyncManager.queueTask({ - client, - task: { - channelId: channel.id, - channelType: channel.type, - messageId, - payload, - type: 'delete-reaction', - }, - }); + await channel.deleteReaction(...payload); }, ); @@ -2050,7 +2026,7 @@ export type ChannelProps = Partial) => { - const { client, enableOfflineSupport, isMessageAIGenerated } = useChatContext(); + const { client, enableOfflineSupport, isOnline, isMessageAIGenerated } = useChatContext(); const { t } = useTranslationContext(); const threadFromProps = props?.thread; @@ -2082,6 +2058,7 @@ export const Channel = (props: PropsWithChildren) => { shouldSyncChannel={shouldSyncChannel} {...{ isMessageAIGenerated, + isOnline, setThreadMessages, thread, threadMessages, diff --git a/package/src/components/ChannelList/ChannelList.tsx b/package/src/components/ChannelList/ChannelList.tsx index 083af190b8..d141040f76 100644 --- a/package/src/components/ChannelList/ChannelList.tsx +++ b/package/src/components/ChannelList/ChannelList.tsx @@ -19,7 +19,6 @@ import { ChannelsProvider, } from '../../contexts/channelsContext/ChannelsContext'; import { useChatContext } from '../../contexts/chatContext/ChatContext'; -import { upsertCidsForQuery } from '../../store/apis/upsertCidsForQuery'; import type { ChannelListEventListenerOptions } from '../../types/types'; import { ChannelPreviewMessenger } from '../ChannelPreview/ChannelPreviewMessenger'; import { EmptyStateIndicator as EmptyStateIndicatorDefault } from '../Indicators/EmptyStateIndicator'; @@ -321,7 +320,7 @@ export const ChannelList = (props: ChannelListProps) => { ]); useEffect(() => { - channelManager.setOptions({ abortInFlightQuery: true, lockChannelOrder }); + channelManager.setOptions({ abortInFlightQuery: false, lockChannelOrder }); }, [channelManager, lockChannelOrder]); useEffect(() => { @@ -343,7 +342,6 @@ export const ChannelList = (props: ChannelListProps) => { refreshing, refreshList, reloadList, - staticChannelsActive, } = usePaginatedChannels({ channelManager, enableOfflineSupport, @@ -358,26 +356,6 @@ export const ChannelList = (props: ChannelListProps) => { setChannels: channelManager.setChannels, }); - const channelIdsStr = channels?.reduce((acc, channel) => `${acc}${channel.cid}`, ''); - - useEffect(() => { - if ( - channels == null || - !channelListInitialized || - staticChannelsActive || - !enableOfflineSupport - ) { - return; - } - - upsertCidsForQuery({ - cids: channels.map((c) => c.cid), - filters, - sort, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channelIdsStr, staticChannelsActive]); - const channelsContext = useCreateChannelsContext({ additionalFlatListProps, channelListInitialized, diff --git a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts index 7c75acbbdd..4fd5b1ffaf 100644 --- a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts +++ b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts @@ -13,17 +13,8 @@ import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useStateStore } from '../../../hooks'; import { useIsMountedRef } from '../../../hooks/useIsMountedRef'; -import { getChannelsForFilterSort } from '../../../store/apis/getChannelsForFilterSort'; - -import { ONE_SECOND_IN_MS } from '../../../utils/date'; -import { DBSyncManager } from '../../../utils/DBSyncManager'; import { MAX_QUERY_CHANNELS_LIMIT } from '../utils'; -const waitSeconds = (seconds: number) => - new Promise((resolve) => { - setTimeout(resolve, seconds * ONE_SECOND_IN_MS); - }); - type Parameters = { channelManager: ChannelManager; enableOfflineSupport: boolean; @@ -37,7 +28,6 @@ const DEFAULT_OPTIONS = { message_limit: 10, }; -const MAX_NUMBER_OF_RETRIES = 3; const RETRY_INTERVAL_IN_MS = 5000; type QueryType = 'queryLocalDB' | 'reload' | 'refresh' | 'loadChannels'; @@ -48,6 +38,7 @@ const selector = (nextValue: ChannelManagerState) => ({ channelListInitialized: nextValue.initialized, channels: nextValue.channels, + error: nextValue.error, pagination: nextValue.pagination, }) as const; @@ -56,16 +47,14 @@ export const usePaginatedChannels = ({ enableOfflineSupport, filters = {}, options = DEFAULT_OPTIONS, - setForceUpdate, sort = {}, }: Parameters) => { - const [error, setError] = useState(undefined); const [staticChannelsActive, setStaticChannelsActive] = useState(false); const [activeQueryType, setActiveQueryType] = useState('queryLocalDB'); const activeChannels = useActiveChannelsRefContext(); const isMountedRef = useIsMountedRef(); const { client } = useChatContext(); - const { channelListInitialized, channels, pagination } = + const { channelListInitialized, channels, pagination, error } = useStateStore(channelManager?.state, selector) ?? {}; const hasNextPage = pagination?.hasNext; @@ -77,7 +66,6 @@ export const usePaginatedChannels = ({ const queryChannels: QueryChannels = async ( queryType: QueryType = 'loadChannels', - retryCount = 0, ): Promise => { if (!client || !isMountedRef.current) { return; @@ -106,7 +94,6 @@ export const usePaginatedChannels = ({ filtersRef.current = filters; sortRef.current = sort; isQueryingRef.current = true; - setError(undefined); activeRequestId.current++; const currentRequestId = activeRequestId.current; setActiveQueryType(queryType); @@ -138,27 +125,12 @@ export const usePaginatedChannels = ({ isQueryingRef.current = false; } catch (err: unknown) { isQueryingRef.current = false; - await waitSeconds(2); if (isQueryStale()) { return; } - // querying.current check is needed in order to make sure the next query call doesnt flick an error - // state and then succeed (reconnect case) - if (retryCount === MAX_NUMBER_OF_RETRIES && !isQueryingRef.current) { - setActiveQueryType(null); - console.warn(err); - - setError( - new Error( - `Maximum number of retries reached in queryChannels. Last error message is: ${err}`, - ), - ); - return; - } - - return queryChannels(queryType, retryCount + 1); + console.warn(err); } setActiveQueryType(null); @@ -198,71 +170,15 @@ export const usePaginatedChannels = ({ const sortStr = useMemo(() => JSON.stringify(sort), [sort]); useEffect(() => { - const loadOfflineChannels = async () => { - if (!client?.user?.id) { - return; - } - - try { - const channelsFromDB = await getChannelsForFilterSort({ - currentUserId: client.user.id, - filters, - sort, - }); - - if (channelsFromDB) { - const offlineChannels = client.hydrateActiveChannels(channelsFromDB, { - offlineMode: true, - skipInitialization: [], // passing empty array will clear out the existing messages from channel state, this removes the possibility of duplicate messages - }); - - channelManager.setChannels(offlineChannels); - setStaticChannelsActive(true); - } - } catch (e) { - console.warn('Failed to get channels from database: ', e); - return false; - } - - setActiveQueryType(null); - - return true; - }; - - let listener: ReturnType; - if (enableOfflineSupport) { - // Any time DB is synced, we need to update the UI with local DB channels first, - // and then call queryChannels to ensure any new channels are added to UI. - listener = DBSyncManager.onSyncStatusChange(async (syncStatus) => { - if (syncStatus) { - const loadingChannelsSucceeded = await loadOfflineChannels(); - if (loadingChannelsSucceeded) { - await reloadList(); - setForceUpdate((u) => u + 1); - } - } - }); - // On start, load the channels from local db. - loadOfflineChannels().then((success) => { - // If db is already synced (sync api and pending api calls), then - // right away call queryChannels. - if (success) { - const dbSyncStatus = DBSyncManager.getSyncStatus(); - if (dbSyncStatus) { - reloadList(); - } - } - }); - } else { - listener = client.on('connection.changed', async (event) => { + const listener: ReturnType = client.on( + 'connection.changed', + async (event) => { if (event.online) { await refreshList(); - setForceUpdate((u) => u + 1); } - }); - - reloadList(); - } + }, + ); + reloadList(); return () => listener?.unsubscribe?.(); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/package/src/components/ChannelPreview/hooks/useIsChannelMuted.ts b/package/src/components/ChannelPreview/hooks/useIsChannelMuted.ts index 366271361b..9dd063d088 100644 --- a/package/src/components/ChannelPreview/hooks/useIsChannelMuted.ts +++ b/package/src/components/ChannelPreview/hooks/useIsChannelMuted.ts @@ -17,11 +17,29 @@ export const useIsChannelMuted = (channel: Channel) => { useEffect(() => { const handleEvent = () => { + const newMuteStatus = channel.muteStatus(); + if ( + newMuteStatus.muted === muted.muted && + newMuteStatus.createdAt?.getTime?.() === muted.createdAt?.getTime?.() && + newMuteStatus.expiresAt?.getTime?.() === muted.expiresAt?.getTime?.() + ) { + return; + } + setMuted(channel.muteStatus()); }; - client.on('notification.channel_mutes_updated', handleEvent); - return () => client.off('notification.channel_mutes_updated', handleEvent); + const listeners = [ + client.on('notification.channel_mutes_updated', handleEvent), + client.on('health.check', (event) => { + if (event.me) { + handleEvent(); + } + }), + ]; + return () => { + listeners.forEach((listener) => listener.unsubscribe()); + }; }, [channel, client, muted]); return muted ?? defaultMuteStatus; diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index d8fe2e261b..cbb6b8f1a7 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -1,15 +1,13 @@ import React, { PropsWithChildren, useEffect, useState } from 'react'; import { Image, Platform } from 'react-native'; -import type { Channel, StreamChat } from 'stream-chat'; +import { Channel, OfflineDBState } from 'stream-chat'; import { useAppSettings } from './hooks/useAppSettings'; import { useCreateChatContext } from './hooks/useCreateChatContext'; import { useIsOnline } from './hooks/useIsOnline'; import { useMutedUsers } from './hooks/useMutedUsers'; -import { useSyncDatabase } from './hooks/useSyncDatabase'; - import { ChannelsStateProvider } from '../../contexts/channelsStateContext/ChannelsStateContext'; import { ChatContextValue, ChatProvider } from '../../contexts/chatContext/ChatContext'; import { useDebugContext } from '../../contexts/debugContext/DebugContext'; @@ -20,13 +18,13 @@ import { DEFAULT_USER_LANGUAGE, TranslationProvider, } from '../../contexts/translationContext/TranslationContext'; +import { useStateStore } from '../../hooks'; import { useStreami18n } from '../../hooks/useStreami18n'; import init from '../../init'; import { NativeHandlers } from '../../native'; -import { SqliteClient } from '../../store/SqliteClient'; +import { OfflineDB } from '../../store/OfflineDB'; -import { DBSyncManager } from '../../utils/DBSyncManager'; import type { Streami18n } from '../../utils/i18n/Streami18n'; import { version } from '../../version.json'; @@ -134,6 +132,12 @@ export type ChatProps = Pick & style?: DeepPartial; }; +const selector = (nextValue: OfflineDBState) => + ({ + initialized: nextValue.initialized, + userId: nextValue.userId, + }) as const; + const ChatWithContext = (props: PropsWithChildren) => { const { children, @@ -157,13 +161,8 @@ const ChatWithContext = (props: PropsWithChildren) => { */ const { connectionRecovering, isOnline } = useIsOnline(client, closeConnectionOnBackground); - const [initialisedDatabaseConfig, setInitialisedDatabaseConfig] = useState<{ - initialised: boolean; - userID?: string; - }>({ - initialised: false, - userID: client.userID, - }); + const { initialized: offlineDbInitialized, userId: offlineDbUserId } = + useStateStore(client.offlineDb?.state, selector) ?? {}; /** * Setup muted user listener @@ -212,30 +211,18 @@ const ChatWithContext = (props: PropsWithChildren) => { return; } - const initializeDatabase = () => { - // This acts as a lock for some very rare occurrences of concurrency - // issues we've encountered before with the QuickSqliteClient being - // uninitialized before it's being invoked. - setInitialisedDatabaseConfig({ initialised: false, userID }); - SqliteClient.initializeDatabase() - .then(async () => { - setInitialisedDatabaseConfig({ initialised: true, userID }); - await DBSyncManager.init(client as unknown as StreamChat); - }) - .catch((error) => { - console.log('Error Initializing DB:', error); - }); - }; - - initializeDatabase(); + const initializeDatabase = async () => { + if (!client.offlineDb) { + client.setOfflineDBApi(new OfflineDB({ client })); + } - return () => { - if (userID && enableOfflineSupport) { - SqliteClient.closeDB(); + if (client.offlineDb) { + await client.offlineDb.init(userID); } }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [userID, enableOfflineSupport]); + + initializeDatabase(); + }, [userID, enableOfflineSupport, client]); useEffect(() => { if (!client) { @@ -251,12 +238,7 @@ const ChatWithContext = (props: PropsWithChildren) => { }; }, [client]); - // In case something went wrong, make sure to also unsubscribe the listener - // on unmount if it exists to prevent a memory leak. - useEffect(() => () => DBSyncManager.connectionChangedListener?.unsubscribe(), []); - - const initialisedDatabase = - initialisedDatabaseConfig.initialised && userID === initialisedDatabaseConfig.userID; + const initialisedDatabase = !!offlineDbInitialized && userID === offlineDbUserId; const appSettings = useAppSettings(client, isOnline, enableOfflineSupport, initialisedDatabase); @@ -273,12 +255,6 @@ const ChatWithContext = (props: PropsWithChildren) => { setActiveChannel, }); - useSyncDatabase({ - client, - enableOfflineSupport, - initialisedDatabase, - }); - if (userID && enableOfflineSupport && !initialisedDatabase) { // if user id has been set and offline support is enabled, we need to wait for database to be initialised return LoadingIndicator ? : null; diff --git a/package/src/components/Chat/__tests__/Chat.test.js b/package/src/components/Chat/__tests__/Chat.test.js index ca5b1172ff..44d1c049db 100644 --- a/package/src/components/Chat/__tests__/Chat.test.js +++ b/package/src/components/Chat/__tests__/Chat.test.js @@ -11,7 +11,6 @@ import { useTranslationContext } from '../../../contexts/translationContext/Tran import dispatchConnectionChangedEvent from '../../../mock-builders/event/connectionChanged'; import dispatchConnectionRecoveredEvent from '../../../mock-builders/event/connectionRecovered'; import { getTestClient, getTestClientWithUser, setUser } from '../../../mock-builders/mock'; -import { DBSyncManager } from '../../../utils/DBSyncManager'; import { Streami18n } from '../../../utils/i18n/Streami18n'; import { Chat } from '../Chat'; @@ -132,10 +131,6 @@ describe('ChatContext', () => { }); describe('TranslationContext', () => { - beforeEach(() => { - jest.spyOn(DBSyncManager, 'init'); - }); - afterEach(() => { jest.clearAllMocks(); cleanup(); @@ -240,10 +235,14 @@ describe('TranslationContext', () => { let unsubscribeSpy; let listenersAfterInitialMount; + const initSpy = jest.spyOn(chatClientWithUser.offlineDb.syncManager, 'init'); await waitFor(() => { // the unsubscribe fn changes during init(), so we keep a reference to the spy - unsubscribeSpy = jest.spyOn(DBSyncManager.connectionChangedListener, 'unsubscribe'); + unsubscribeSpy = jest.spyOn( + chatClientWithUser.offlineDb.syncManager.connectionChangedListener, + 'unsubscribe', + ); listenersAfterInitialMount = chatClientWithUser.listeners['connection.changed']; }); @@ -251,8 +250,8 @@ describe('TranslationContext', () => { rerender(); await waitFor(() => { - expect(DBSyncManager.init).toHaveBeenCalledTimes(2); - expect(unsubscribeSpy).toHaveBeenCalledTimes(2); + expect(initSpy).toHaveBeenCalledTimes(1); + expect(unsubscribeSpy).toHaveBeenCalledTimes(0); expect(chatClientWithUser.listeners['connection.changed'].length).toBe( listenersAfterInitialMount.length, ); @@ -267,10 +266,14 @@ describe('TranslationContext', () => { let unsubscribeSpy; let listenersAfterInitialMount; + const initSpy = jest.spyOn(chatClientWithUser.offlineDb.syncManager, 'init'); await waitFor(() => { // the unsubscribe fn changes during init(), so we keep a reference to the spy - unsubscribeSpy = jest.spyOn(DBSyncManager.connectionChangedListener, 'unsubscribe'); + unsubscribeSpy = jest.spyOn( + chatClientWithUser.offlineDb.syncManager.connectionChangedListener, + 'unsubscribe', + ); listenersAfterInitialMount = chatClientWithUser.listeners['connection.changed']; }); @@ -282,7 +285,7 @@ describe('TranslationContext', () => { rerender(); await waitFor(() => { - expect(DBSyncManager.init).toHaveBeenCalledTimes(2); + expect(initSpy).toHaveBeenCalledTimes(2); expect(unsubscribeSpy).toHaveBeenCalledTimes(1); expect(chatClientWithUser.listeners['connection.changed'].length).toBe( listenersAfterInitialMount.length, @@ -297,9 +300,14 @@ describe('TranslationContext', () => { const { rerender } = render(); let unsubscribeSpy; + const initSpy = jest.spyOn(chatClientWithUser.offlineDb.syncManager, 'init'); + await waitFor(() => { // the unsubscribe fn changes during init(), so we keep a reference to the spy - unsubscribeSpy = jest.spyOn(DBSyncManager.connectionChangedListener, 'unsubscribe'); + unsubscribeSpy = jest.spyOn( + chatClientWithUser.offlineDb.syncManager.connectionChangedListener, + 'unsubscribe', + ); }); const listenersAfterInitialMount = chatClientWithUser.listeners['connection.changed']; @@ -308,8 +316,8 @@ describe('TranslationContext', () => { rerender(); await waitFor(() => { - expect(DBSyncManager.init).toHaveBeenCalledTimes(1); - expect(unsubscribeSpy).toHaveBeenCalledTimes(1); + expect(initSpy).toHaveBeenCalledTimes(1); + expect(unsubscribeSpy).toHaveBeenCalledTimes(0); expect(chatClientWithUser.listeners['connection.changed'].length).toBe( listenersAfterInitialMount.length, ); diff --git a/package/src/components/Chat/hooks/handleEventToSyncDB.ts b/package/src/components/Chat/hooks/handleEventToSyncDB.ts deleted file mode 100644 index 8f90918a46..0000000000 --- a/package/src/components/Chat/hooks/handleEventToSyncDB.ts +++ /dev/null @@ -1,290 +0,0 @@ -import type { Event, StreamChat } from 'stream-chat'; - -import { deleteChannel } from '../../../store/apis/deleteChannel'; -import { deleteMember } from '../../../store/apis/deleteMember'; -import { deleteMessagesForChannel } from '../../../store/apis/deleteMessagesForChannel'; -import { updateMessage } from '../../../store/apis/updateMessage'; -import { updatePollMessage } from '../../../store/apis/updatePollMessage'; -import { upsertChannelData } from '../../../store/apis/upsertChannelData'; -import { upsertChannelDataFromChannel } from '../../../store/apis/upsertChannelDataFromChannel'; -import { upsertChannels } from '../../../store/apis/upsertChannels'; -import { upsertMembers } from '../../../store/apis/upsertMembers'; -import { upsertMessages } from '../../../store/apis/upsertMessages'; -import { upsertReads } from '../../../store/apis/upsertReads'; -import { createSelectQuery } from '../../../store/sqlite-utils/createSelectQuery'; -import { SqliteClient } from '../../../store/SqliteClient'; -import { PreparedQueries } from '../../../store/types'; - -export const handleEventToSyncDB = async (event: Event, client: StreamChat, flush?: boolean) => { - const { type } = event; - - // This function is used to guard the queries that require channel to be present in the db first - // If channel is not present in the db, we first fetch the channel data from the channel object - // and then add the queries with a channel create query first - const queriesWithChannelGuard = async ( - createQueries: (flushOverride?: boolean) => Promise, - ) => { - const cid = event.cid || event.channel?.cid; - - if (!cid) { - return await createQueries(flush); - } - const channels = await SqliteClient.executeSql.apply( - null, - createSelectQuery('channels', ['cid'], { - cid, - }), - ); - // a channel is not present in the db, we first fetch the channel data from the channel object. - // this can happen for example when a message.new event is received for a channel that is not in the db due to a channel being hidden. - if (channels.length === 0) { - const channel = - event.channel_type && event.channel_id - ? client.channel(event.channel_type, event.channel_id) - : undefined; - if (channel && channel.initialized && !channel.disconnected) { - const channelQuery = await upsertChannelDataFromChannel({ - channel, - flush, - }); - if (channelQuery) { - const createdQueries = await createQueries(false); - const newQueries = [...channelQuery, ...createdQueries]; - if (flush !== false) { - await SqliteClient.executeSqlBatch(newQueries); - } - return newQueries; - } else { - console.warn( - `Couldnt create channel queries on ${type} event for an initialized channel that is not in DB, skipping event`, - { event }, - ); - return []; - } - } else { - console.warn( - `Received ${type} event for a non initialized channel that is not in DB, skipping event`, - { event }, - ); - return []; - } - } - return createQueries(flush); - }; - - if (type === 'message.read' || type === 'notification.mark_read') { - const cid = event.cid; - const user = event.user; - if (user?.id && cid) { - return await queriesWithChannelGuard((flushOverride) => - upsertReads({ - cid, - flush: flushOverride, - reads: [ - { - last_read: event.received_at as string, - last_read_message_id: event.last_read_message_id, - unread_messages: 0, - user, - }, - ], - }), - ); - } - } - - if (type === 'notification.mark_unread') { - const cid = event.cid; - const user = event.user; - if (user?.id && cid) { - return await queriesWithChannelGuard((flushOverride) => - upsertReads({ - cid, - flush: flushOverride, - reads: [ - { - last_read: event.received_at as string, - last_read_message_id: event.last_read_message_id, - unread_messages: event.unread_messages, - user, - }, - ], - }), - ); - } - } - - if (type === 'message.new') { - const { cid, message, user } = event; - - if (message && (!message.parent_id || message.show_in_channel)) { - return await queriesWithChannelGuard(async (flushOverride) => { - let queries = await upsertMessages({ - flush: flushOverride, - messages: [message], - }); - if (cid && client.user && client.user.id !== user?.id) { - const userId = client.user.id; - const channel = client.activeChannels[cid]; - if (channel) { - const ownReads = channel.state.read[userId]; - const unreadCount = channel.countUnread(); - const upsertReadsQueries = await upsertReads({ - cid, - flush: flushOverride, - reads: [ - { - last_read: ownReads.last_read.toString() as string, - last_read_message_id: ownReads.last_read_message_id, - unread_messages: unreadCount, - user: client.user, - }, - ], - }); - queries = [...queries, ...upsertReadsQueries]; - } - } - return queries; - }); - } - } - - if (type === 'message.updated' || type === 'message.deleted') { - const message = event.message; - if (message && !message.parent_id) { - // Update only if it exists, otherwise event could be related - // to a message which is not in database. - return await queriesWithChannelGuard((flushOverride) => - updateMessage({ - flush: flushOverride, - message, - }), - ); - } - } - - if (type === 'reaction.updated') { - const message = event.message; - if (message && event.reaction) { - // We update the entire message to make sure we also update reaction_groups - return await queriesWithChannelGuard((flushOverride) => - updateMessage({ - flush: flushOverride, - message, - }), - ); - } - } - - if (type === 'reaction.new' || type === 'reaction.deleted') { - const message = event.message; - if (message && !message.parent_id) { - // Here we are relying on the fact message.latest_reactions always includes - // the new reaction. So we first delete all the existing reactions and populate - // the reactions table with message.latest_reactions - return await queriesWithChannelGuard((flushOverride) => - updateMessage({ - flush: flushOverride, - message, - }), - ); - } - } - - if ( - type === 'channel.updated' || - type === 'channel.visible' || - type === 'notification.added_to_channel' || - type === 'notification.message_new' - ) { - if (event.channel) { - return upsertChannelData({ - channel: event.channel, - flush, - }); - } - } - - if ( - type === 'channel.hidden' || - type === 'channel.deleted' || - type === 'notification.removed_from_channel' - ) { - if (event.channel) { - return deleteChannel({ - cid: event.channel.cid, - flush, - }); - } - } - - if (type === 'channel.truncated') { - if (event.channel) { - return deleteMessagesForChannel({ - cid: event.channel.cid, - flush, - }); - } - } - - if (type === 'channels.queried') { - if (event.queriedChannels?.channels?.length) { - return upsertChannels({ - channels: event.queriedChannels?.channels, - flush, - isLatestMessagesSet: event.queriedChannels?.isLatestMessageSet, - }); - } - } - - if (type === 'member.added' || type === 'member.updated') { - const member = event.member; - const cid = event.cid; - if (member && cid) { - return await queriesWithChannelGuard((flushOverride) => - upsertMembers({ - cid, - flush: flushOverride, - members: [member], - }), - ); - } - } - - if (type === 'member.removed') { - const member = event.member; - const cid = event.cid; - if (member && cid) { - return await queriesWithChannelGuard((flushOverride) => - deleteMember({ - cid, - flush: flushOverride, - member, - }), - ); - } - } - - if ( - [ - 'poll.closed', - 'poll.updated', - 'poll.vote_casted', - 'poll.vote_changed', - 'poll.vote_removed', - ].includes(type) - ) { - const { poll, poll_vote, type } = event; - if (poll) { - return updatePollMessage({ - eventType: type, - flush, - poll, - poll_vote, - userID: client?.userID || '', - }); - } - } - - return []; -}; diff --git a/package/src/components/Chat/hooks/useAppSettings.ts b/package/src/components/Chat/hooks/useAppSettings.ts index ce61f8f0df..eae91005fa 100644 --- a/package/src/components/Chat/hooks/useAppSettings.ts +++ b/package/src/components/Chat/hooks/useAppSettings.ts @@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from 'react'; import type { AppSettingsAPIResponse, StreamChat } from 'stream-chat'; import { useIsMountedRef } from '../../../hooks/useIsMountedRef'; -import * as dbApi from '../../../store/apis'; export const useAppSettings = ( client: StreamChat, @@ -17,6 +16,10 @@ export const useAppSettings = ( const isMounted = useIsMountedRef(); useEffect(() => { + if (fetchedAppSettings.current) { + return; + } + const fetchAppSettings = () => { if (appSettingsPromise.current) { return appSettingsPromise.current; @@ -24,43 +27,16 @@ export const useAppSettings = ( appSettingsPromise.current = client.getAppSettings(); return appSettingsPromise.current; }; - /** - * Fetches app settings from the backend when offline support is disabled. - */ - const enforceAppSettingsWithoutOfflineSupport = async () => { - if (!client.userID) { - return; - } - try { - const appSettings = await fetchAppSettings(); - if (isMounted.current) { - setAppSettings(appSettings); - fetchedAppSettings.current = true; - } - } catch (error: unknown) { - if (error instanceof Error) { - console.error(`An error occurred while getting app settings: ${error}`); - } - } - }; + const enforceAppSettings = async () => { + if (!client.userID) return; - /** - * Fetches app settings from the local database when offline support is enabled if internet is off else fetches from the backend. - * Note: We need to set the app settings from the local database when offline as the client will not have the app settings in memory. For this we store it for the `client.userID`. - * - * TODO: Remove client.userID usage for offline support case. - */ - const enforceAppSettingsWithOfflineSupport = async () => { - if (!client.userID) { - return; - } - if (!initialisedDatabase) { - return; - } + if (enableOfflineSupport && !initialisedDatabase) return; + + const userId = client.userID as string; - if (!isOnline) { - const appSettings = await dbApi.getAppSettings({ currentUserId: client.userID }); + if (!isOnline && client.offlineDb) { + const appSettings = await client.offlineDb.getAppSettings({ userId }); setAppSettings(appSettings); return; } @@ -70,10 +46,10 @@ export const useAppSettings = ( if (isMounted.current && appSettings) { setAppSettings(appSettings); fetchedAppSettings.current = true; - await dbApi.upsertAppSettings({ - appSettings, - currentUserId: client.userID as string, - }); + client.offlineDb?.executeQuerySafely( + (db) => db.upsertAppSettings({ appSettings, userId }), + { method: 'upsertAppSettings' }, + ); } } catch (error: unknown) { if (error instanceof Error) { @@ -82,21 +58,8 @@ export const useAppSettings = ( } }; - async function enforeAppSettings() { - if (fetchedAppSettings.current) { - return; - } - - if (enableOfflineSupport) { - await enforceAppSettingsWithOfflineSupport(); - } else { - await enforceAppSettingsWithoutOfflineSupport(); - } - } - - enforeAppSettings(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [client, isOnline, initialisedDatabase]); + enforceAppSettings(); + }, [client, isOnline, initialisedDatabase, isMounted, enableOfflineSupport]); return appSettings; }; diff --git a/package/src/components/Chat/hooks/useSyncDatabase.ts b/package/src/components/Chat/hooks/useSyncDatabase.ts index 10401a2f07..22645d7f37 100644 --- a/package/src/components/Chat/hooks/useSyncDatabase.ts +++ b/package/src/components/Chat/hooks/useSyncDatabase.ts @@ -1,25 +1,19 @@ -import { useEffect } from 'react'; - import type { StreamChat } from 'stream-chat'; -import { handleEventToSyncDB } from './handleEventToSyncDB'; - type Params = { client: StreamChat; enableOfflineSupport: boolean; initialisedDatabase: boolean; }; -export const useSyncDatabase = ({ client, enableOfflineSupport, initialisedDatabase }: Params) => { - useEffect(() => { - let listener: ReturnType | undefined; - if (enableOfflineSupport && initialisedDatabase) { - listener = client?.on((event) => handleEventToSyncDB(event, client)); - } - - return () => { - listener?.unsubscribe(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [client, initialisedDatabase]); -}; +/** + * @deprecated + * With the recent rework of the Offline Support feature, the handling of events has been moved + * to the stream-chat client instead of the SDK. This hook now does nothing and you can safely + * remove it from your code if you were using it. It will be removed in the next major release. + * @param client + * @param initialisedDatabase + */ +// @ts-ignore +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const useSyncDatabase = ({ client, initialisedDatabase }: Params) => {}; diff --git a/package/src/components/MessageMenu/MessageUserReactions.tsx b/package/src/components/MessageMenu/MessageUserReactions.tsx index 8f53b5c6c8..4132e1a608 100644 --- a/package/src/components/MessageMenu/MessageUserReactions.tsx +++ b/package/src/components/MessageMenu/MessageUserReactions.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; @@ -80,6 +80,12 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { setSelectedReaction(reactionType); }; + useEffect(() => { + if (selectedReaction && reactionTypes.length > 0 && !reactionTypes.includes(selectedReaction)) { + setSelectedReaction(reactionTypes[0]); + } + }, [reactionTypes, selectedReaction]); + const messageReactions = useMemo( () => reactionTypes.reduce((acc, reaction) => { diff --git a/package/src/components/MessageMenu/hooks/useFetchReactions.ts b/package/src/components/MessageMenu/hooks/useFetchReactions.ts index c91411c637..b7f068847b 100644 --- a/package/src/components/MessageMenu/hooks/useFetchReactions.ts +++ b/package/src/components/MessageMenu/hooks/useFetchReactions.ts @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { LocalMessage, ReactionResponse, ReactionSort } from 'stream-chat'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; -import { getReactionsForFilterSort } from '../../../store/apis/getReactionsforFilterSort'; export type UseFetchReactionParams = { limit?: number; @@ -28,25 +27,10 @@ export const useFetchReactions = ({ const sortString = useMemo(() => JSON.stringify(sort), [sort]); const fetchReactions = useCallback(async () => { - const loadOfflineReactions = async () => { - if (!messageId) { - return; - } - const reactionsFromDB = await getReactionsForFilterSort({ - currentMessageId: messageId, - filters: reactionType ? { type: reactionType } : {}, - sort, - }); - if (reactionsFromDB) { - setReactions(reactionsFromDB); - setLoading(false); - } - }; - - const loadOnlineReactions = async () => { - if (!messageId) { - return; - } + if (!messageId) { + return; + } + try { const response = await client.queryReactions( messageId, reactionType ? { type: reactionType } : {}, @@ -54,38 +38,87 @@ export const useFetchReactions = ({ { limit, next }, ); if (response) { + setReactions((prevReactions) => + next ? [...prevReactions, ...response.reactions] : response.reactions, + ); setNext(response.next); - setReactions((prevReactions) => [...prevReactions, ...response.reactions]); setLoading(false); } - }; - - try { - // TODO: Threads are not supported for the offline use case as we don't store the thread messages currently, and this will change in the future. - if (enableOfflineSupport && !message?.parent_id) { - await loadOfflineReactions(); - } else { - await loadOnlineReactions(); - } } catch (error) { console.log('Error fetching reactions: ', error); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [client, messageId, reactionType, sortString, next, enableOfflineSupport]); + }, [client, messageId, reactionType, sortString, next, limit, enableOfflineSupport]); const loadNextPage = useCallback(async () => { if (next) { await fetchReactions(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fetchReactions]); + }, [fetchReactions, next]); useEffect(() => { setReactions([]); setNext(undefined); fetchReactions(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [messageId, reactionType, sortString]); + }, [fetchReactions, messageId, reactionType, sortString]); + + useEffect(() => { + const listeners: ReturnType[] = []; + listeners.push( + client.on('offline_reactions.queried', (event) => { + const { offlineReactions } = event; + if (offlineReactions) { + setReactions(offlineReactions); + setLoading(false); + setNext(undefined); + } + }), + ); + + listeners.push( + client.on('reaction.new', (event) => { + const { reaction } = event; + + if (reaction && reaction.type === reactionType) { + setReactions((prevReactions) => [reaction, ...prevReactions]); + } + }), + ); + + listeners.push( + client.on('reaction.updated', (event) => { + const { reaction } = event; + + if (reaction) { + if (reaction.type === reactionType) { + setReactions((prevReactions) => [reaction, ...prevReactions]); + } else { + setReactions((prevReactions) => + prevReactions.filter((r) => r.user_id !== reaction.user_id), + ); + } + } + }), + ); + + listeners.push( + client.on('reaction.deleted', (event) => { + const { reaction } = event; + + if (reaction && reaction.type === reactionType) { + setReactions((prevReactions) => + prevReactions.filter((r) => r.user_id !== reaction.user_id), + ); + } + }), + ); + + return () => { + listeners.forEach((listener) => { + listener?.unsubscribe(); + }); + }; + }, [client, reactionType]); return { loading, loadNextPage, reactions }; }; diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 2442ca713c..624e1c77ca 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -107,7 +107,7 @@ export type MessagesContextValue = Pick; - deleteMessage: (message: LocalMessage) => Promise; + deleteMessage: (message: LocalMessage, hardDelete?: boolean) => Promise; deleteReaction: (type: string, messageId: string) => Promise; /** Should keyboard be dismissed when messaged is touched */ diff --git a/package/src/mock-builders/event/channelVisible.js b/package/src/mock-builders/event/channelVisible.js new file mode 100644 index 0000000000..c74df7eed3 --- /dev/null +++ b/package/src/mock-builders/event/channelVisible.js @@ -0,0 +1,7 @@ +export default (client, channel = {}) => { + client.dispatchEvent({ + channel, + cid: channel.cid, + type: 'channel.visible', + }); +}; diff --git a/package/src/mock-builders/event/memberAdded.js b/package/src/mock-builders/event/memberAdded.js index 853a6f7b8a..b9281f98ef 100644 --- a/package/src/mock-builders/event/memberAdded.js +++ b/package/src/mock-builders/event/memberAdded.js @@ -1,8 +1,10 @@ export default (client, member, channel = {}) => { client.dispatchEvent({ - channel, + channel_id: channel.id, + channel_type: channel.type, cid: channel.cid, member, type: 'member.added', + user: member.user, }); }; diff --git a/package/src/mock-builders/event/memberRemoved.js b/package/src/mock-builders/event/memberRemoved.js index a7ed76556a..174f7758c0 100644 --- a/package/src/mock-builders/event/memberRemoved.js +++ b/package/src/mock-builders/event/memberRemoved.js @@ -4,5 +4,6 @@ export default (client, member, channel = {}) => { cid: channel.cid, member, type: 'member.removed', + user: member.user, }); }; diff --git a/package/src/mock-builders/event/memberUpdated.js b/package/src/mock-builders/event/memberUpdated.js index e4562a553d..a337633f57 100644 --- a/package/src/mock-builders/event/memberUpdated.js +++ b/package/src/mock-builders/event/memberUpdated.js @@ -4,5 +4,6 @@ export default (client, member, channel = {}) => { cid: channel.cid, member, type: 'member.updated', + user: member.user, }); }; diff --git a/package/src/mock-builders/event/messageNew.js b/package/src/mock-builders/event/messageNew.js index 391e2896a0..0453a41d52 100644 --- a/package/src/mock-builders/event/messageNew.js +++ b/package/src/mock-builders/event/messageNew.js @@ -6,5 +6,6 @@ export default (client, newMessage, channel = {}) => { cid: channel.cid, message: newMessage, type: 'message.new', + ...(newMessage.user ? { user: newMessage.user } : {}), }); }; diff --git a/package/src/mock-builders/event/messageRead.js b/package/src/mock-builders/event/messageRead.js index 75fa523cfe..9edbab30f2 100644 --- a/package/src/mock-builders/event/messageRead.js +++ b/package/src/mock-builders/event/messageRead.js @@ -1,10 +1,13 @@ -export default (client, user, channel = {}) => { +export default (client, user, channel = {}, payload = {}) => { + const newDate = new Date(); const event = { channel, cid: channel.cid, - received_at: new Date(), + created_at: newDate, + received_at: newDate, type: 'message.read', user, + ...payload, }; client.dispatchEvent(event); diff --git a/package/src/mock-builders/event/notificationMarkUnread.js b/package/src/mock-builders/event/notificationMarkUnread.js index 862802cf18..50dd0255c7 100644 --- a/package/src/mock-builders/event/notificationMarkUnread.js +++ b/package/src/mock-builders/event/notificationMarkUnread.js @@ -1,7 +1,10 @@ export default (client, channel = {}, payload = {}, user = {}) => { + const newDate = new Date(); client.dispatchEvent({ channel, cid: channel.cid, + created_at: newDate, + received_at: newDate, type: 'notification.mark_unread', user, ...payload, diff --git a/package/src/mock-builders/generator/channel.ts b/package/src/mock-builders/generator/channel.ts index 8fde5071fb..542a2f285a 100644 --- a/package/src/mock-builders/generator/channel.ts +++ b/package/src/mock-builders/generator/channel.ts @@ -97,10 +97,20 @@ export const generateChannelResponse = ( channel?: Record; id?: string; messages?: Record[]; + members?: Record[]; + read?: Record[]; type?: string; - } = { channel: {}, id: uuidv4(), messages: [], type: 'messaging' }, + } = { channel: {}, id: uuidv4(), members: [], messages: [], read: [], type: 'messaging' }, ) => { - const { channel = {}, id = uuidv4(), messages = [], type = 'messaging', ...rest } = customValues; + const { + channel = {}, + id = uuidv4(), + messages = [], + members = [], + read, + type = 'messaging', + ...rest + } = customValues; const defaults = getChannelDefaults(); return { @@ -110,12 +120,14 @@ export const generateChannelResponse = ( cid: `${type}:${id}`, ...channel, id, + member_count: members.length, type, user: generateUser(), }, }, - members: [], + members, messages, + read, ...rest, }; }; diff --git a/package/src/store/OfflineDB.ts b/package/src/store/OfflineDB.ts new file mode 100644 index 0000000000..ee99feac04 --- /dev/null +++ b/package/src/store/OfflineDB.ts @@ -0,0 +1,93 @@ +import { AbstractOfflineDB, StreamChat } from 'stream-chat'; +import type { + DBGetAppSettingsType, + DBGetChannelsForQueryType, + DBGetChannelsType, + DBGetLastSyncedAtType, + DBUpsertAppSettingsType, + DBUpsertUserSyncStatusType, +} from 'stream-chat'; + +import * as api from './apis'; +import { SqliteClient } from './SqliteClient'; + +export class OfflineDB extends AbstractOfflineDB { + constructor({ client }: { client: StreamChat }) { + super({ client }); + } + + upsertCidsForQuery = api.upsertCidsForQuery; + + upsertChannels = api.upsertChannels; + + // TODO: Rename currentUserId -> userId in the next major version as it is technically breaking. + upsertUserSyncStatus = ({ userId, lastSyncedAt, execute }: DBUpsertUserSyncStatusType) => + api.upsertUserSyncStatus({ currentUserId: userId, execute, lastSyncedAt }); + + // TODO: Rename currentUserId -> userId in the next major version as it is technically breaking. + upsertAppSettings = ({ appSettings, userId, execute }: DBUpsertAppSettingsType) => + api.upsertAppSettings({ appSettings, currentUserId: userId, execute }); + + upsertPoll = api.upsertPoll; + + upsertChannelData = api.upsertChannelData; + + upsertReads = api.upsertReads; + + upsertMessages = api.upsertMessages; + + upsertMembers = api.upsertMembers; + + updateMessage = api.updateMessage; + + // TODO: Rename currentUserId -> userId in the next major version as it is technically breaking. + getChannels = ({ cids, userId }: DBGetChannelsType) => + api.getChannels({ channelIds: cids, currentUserId: userId }); + + // TODO: Rename currentUserId -> userId in the next major version as it is technically breaking. + getChannelsForQuery = ({ userId, filters, sort }: DBGetChannelsForQueryType) => + api.getChannelsForFilterSort({ currentUserId: userId, filters, sort }); + + getAllChannelCids = api.getAllChannelIds; + + // TODO: Rename currentUserId -> userId in the next major version as it is technically breaking. + getLastSyncedAt = ({ userId }: DBGetLastSyncedAtType) => + api.getLastSyncedAt({ currentUserId: userId }); + + getAppSettings = ({ userId }: DBGetAppSettingsType) => + api.getAppSettings({ currentUserId: userId }); + + getReactions = api.getReactionsForFilterSort; + + addPendingTask = api.addPendingTask; + + deletePendingTask = api.deletePendingTask; + + deleteReaction = api.deleteReaction; + + deleteMember = api.deleteMember; + + deleteChannel = api.deleteChannel; + + deleteMessagesForChannel = api.deleteMessagesForChannel; + + dropPendingTasks = api.dropPendingTasks; + + hardDeleteMessage = api.deleteMessage; + + softDeleteMessage = api.softDeleteMessage; + + getPendingTasks = api.getPendingTasks; + + updateReaction = api.updateReaction; + + insertReaction = api.insertReaction; + + channelExists = api.channelExists; + + resetDB = SqliteClient.resetDB; + + executeSqlBatch = SqliteClient.executeSqlBatch; + + initializeDB = SqliteClient.initializeDatabase; +} diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index 50ff2f1870..d507c7a3d7 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -28,7 +28,7 @@ import type { PreparedBatchQueries, PreparedQueries, Scalar, Table } from './typ * This way usage @op-engineering/op-sqlite package is scoped to a single class/file. */ export class SqliteClient { - static dbVersion = 8; + static dbVersion = 9; static dbName = DB_NAME; static dbLocation = DB_LOCATION; @@ -96,6 +96,7 @@ export class SqliteClient { }); await this.db.executeBatch(finalQueries); } catch (e) { + this.db?.execute('ROLLBACK'); this.logger?.('error', 'SqlBatch queries failed', { error: e, queries, @@ -164,6 +165,7 @@ export class SqliteClient { await SqliteClient.dropTables(); await SqliteClient.updateUserPragmaVersion(SqliteClient.dbVersion); } + SqliteClient.logger?.('info', 'create tables if not exists', { tables: Object.keys(tables), }); @@ -176,6 +178,8 @@ export class SqliteClient { ); await SqliteClient.executeSqlBatch(q); + + return true; } catch (e) { console.log('Error initializing DB', e); this.logger?.('error', 'Error initializing DB', { @@ -183,6 +187,8 @@ export class SqliteClient { dbname: SqliteClient.dbName, error: e, }); + + return false; } }; diff --git a/package/src/store/apis/addPendingTask.ts b/package/src/store/apis/addPendingTask.ts index 25207fc30a..5d2cbc8290 100644 --- a/package/src/store/apis/addPendingTask.ts +++ b/package/src/store/apis/addPendingTask.ts @@ -1,8 +1,9 @@ +import type { PendingTask } from 'stream-chat'; + import { mapTaskToStorable } from '../mappers/mapTaskToStorable'; import { createDeleteQuery } from '../sqlite-utils/createDeleteQuery'; import { createUpsertQuery } from '../sqlite-utils/createUpsertQuery'; import { SqliteClient } from '../SqliteClient'; -import type { PendingTask } from '../types'; /* * addPendingTask - Adds a pending task to the database diff --git a/package/src/store/apis/channelExists.ts b/package/src/store/apis/channelExists.ts new file mode 100644 index 0000000000..6c4a264b63 --- /dev/null +++ b/package/src/store/apis/channelExists.ts @@ -0,0 +1,14 @@ +import { SqliteClient } from '../SqliteClient'; + +export const channelExists = async ({ cid }: { cid: string }) => { + const channels = await SqliteClient.executeSql( + 'SELECT EXISTS(SELECT 1 FROM channels WHERE cid = ?)', + [cid], + ); + + SqliteClient.logger?.('info', 'channelExists', { + cid, + }); + + return channels.length > 0; +}; diff --git a/package/src/store/apis/deleteChannel.ts b/package/src/store/apis/deleteChannel.ts index 32e2986525..bbf4d85319 100644 --- a/package/src/store/apis/deleteChannel.ts +++ b/package/src/store/apis/deleteChannel.ts @@ -1,17 +1,23 @@ import { createDeleteQuery } from '../sqlite-utils/createDeleteQuery'; import { SqliteClient } from '../SqliteClient'; -export const deleteChannel = async ({ cid, flush = true }: { cid: string; flush?: boolean }) => { +export const deleteChannel = async ({ + cid, + execute = true, +}: { + cid: string; + execute?: boolean; +}) => { const query = createDeleteQuery('channels', { cid, }); SqliteClient.logger?.('info', 'deleteChannel', { cid, - flush, + execute, }); - if (flush) { + if (execute) { await SqliteClient.executeSql.apply(null, query); } diff --git a/package/src/store/apis/deleteMember.ts b/package/src/store/apis/deleteMember.ts index b43bae049e..30c60315dc 100644 --- a/package/src/store/apis/deleteMember.ts +++ b/package/src/store/apis/deleteMember.ts @@ -5,12 +5,12 @@ import { SqliteClient } from '../SqliteClient'; export const deleteMember = async ({ cid, - flush = true, + execute = true, member, }: { cid: string; member: ChannelMemberResponse; - flush?: boolean; + execute?: boolean; }) => { const query = createDeleteQuery('members', { cid, @@ -19,11 +19,11 @@ export const deleteMember = async ({ SqliteClient.logger?.('info', 'deleteMember', { cid, - flush, + execute, userId: member.user_id, }); - if (flush) { + if (execute) { await SqliteClient.executeSql.apply(null, query); } diff --git a/package/src/store/apis/deleteMessage.ts b/package/src/store/apis/deleteMessage.ts index 4d4b1acaae..df209c373a 100644 --- a/package/src/store/apis/deleteMessage.ts +++ b/package/src/store/apis/deleteMessage.ts @@ -1,19 +1,29 @@ import { createDeleteQuery } from '../sqlite-utils/createDeleteQuery'; import { SqliteClient } from '../SqliteClient'; -export const deleteMessage = async ({ flush = true, id }: { id: string; flush?: boolean }) => { - const query = createDeleteQuery('messages', { - id, - }); +export const deleteMessage = async ({ execute = true, id }: { id: string; execute?: boolean }) => { + const queries = []; + + queries.push( + createDeleteQuery('reactions', { + messageId: id, + }), + ); + + queries.push( + createDeleteQuery('messages', { + id, + }), + ); SqliteClient.logger?.('info', 'deleteMessage', { - flush, + execute, id, }); - if (flush) { - await SqliteClient.executeSql.apply(null, query); + if (execute) { + await SqliteClient.executeSqlBatch(queries); } - return [query]; + return queries; }; diff --git a/package/src/store/apis/deleteMessagesForChannel.ts b/package/src/store/apis/deleteMessagesForChannel.ts index 0be52db13e..99de66178a 100644 --- a/package/src/store/apis/deleteMessagesForChannel.ts +++ b/package/src/store/apis/deleteMessagesForChannel.ts @@ -1,23 +1,27 @@ -import { createDeleteQuery } from '../sqlite-utils/createDeleteQuery'; import { SqliteClient } from '../SqliteClient'; export const deleteMessagesForChannel = async ({ cid, - flush = true, + truncated_at, + execute = true, }: { cid: string; - flush?: boolean; + truncated_at?: string; + execute?: boolean; }) => { - const query = createDeleteQuery('messages', { - cid, - }); + const timestamp = truncated_at ? new Date(truncated_at).toISOString() : new Date().toISOString(); + const query: [string, (string | number)[]] = [ + `DELETE FROM messages WHERE cid = ? AND createdAt <= ?`, + [cid, timestamp], + ]; SqliteClient.logger?.('info', 'deleteMessagesForChannel', { cid, - flush, + execute, + truncated_at, }); - if (flush) { + if (execute) { await SqliteClient.executeSql.apply(null, query); } diff --git a/package/src/store/apis/deleteReaction.ts b/package/src/store/apis/deleteReaction.ts index 646d735357..b00789c6a8 100644 --- a/package/src/store/apis/deleteReaction.ts +++ b/package/src/store/apis/deleteReaction.ts @@ -1,32 +1,52 @@ +import { FormatMessageResponse, MessageResponse, ReactionResponse } from 'stream-chat'; + import { createDeleteQuery } from '../sqlite-utils/createDeleteQuery'; +import { createUpdateQuery } from '../sqlite-utils/createUpdateQuery'; import { SqliteClient } from '../SqliteClient'; +import { PreparedQueries } from '../types'; export const deleteReaction = async ({ - flush = true, - messageId, - reactionType, - userId, + execute = true, + message, + reaction, }: { - messageId: string; - reactionType: string; - userId: string; - flush?: boolean; + reaction: ReactionResponse; + message?: MessageResponse | FormatMessageResponse; + execute?: boolean; }) => { - const query = createDeleteQuery('reactions', { - messageId, - type: reactionType, - userId, - }); + const queries: PreparedQueries[] = []; + + if (!message) { + return []; + } + + queries.push( + createDeleteQuery('reactions', { + messageId: reaction.message_id, + type: reaction.type, + userId: reaction.user_id, + }), + ); + + const stringifiedNewReactionGroups = JSON.stringify(message.reaction_groups); + + queries.push( + createUpdateQuery( + 'messages', + { + reactionGroups: stringifiedNewReactionGroups, + }, + { id: message.id }, + ), + ); SqliteClient.logger?.('info', 'deleteReaction', { - messageId, - type: reactionType, - userId, + reaction, }); - if (flush) { - await SqliteClient.executeSql.apply(null, query); + if (execute) { + await SqliteClient.executeSqlBatch(queries); } - return [query]; + return queries; }; diff --git a/package/src/store/apis/deleteReactions.ts b/package/src/store/apis/deleteReactions.ts index 4715679171..61a949b483 100644 --- a/package/src/store/apis/deleteReactions.ts +++ b/package/src/store/apis/deleteReactions.ts @@ -2,20 +2,20 @@ import { createDeleteQuery } from '../sqlite-utils/createDeleteQuery'; import { SqliteClient } from '../SqliteClient'; export const deleteReactionsForMessage = async ({ - flush = true, + execute = true, messageId, }: { messageId: string; - flush?: boolean; + execute?: boolean; }) => { const query = createDeleteQuery('reactions', { messageId, }); console.log('deleteReactionsForMessage', { - flush, + execute, messageId, }); - if (flush) { + if (execute) { await SqliteClient.executeSql.apply(null, query); } diff --git a/package/src/store/apis/dropPendingTasks.ts b/package/src/store/apis/dropPendingTasks.ts new file mode 100644 index 0000000000..f9aaa2be1e --- /dev/null +++ b/package/src/store/apis/dropPendingTasks.ts @@ -0,0 +1,32 @@ +import { createDeleteQuery } from '../sqlite-utils/createDeleteQuery'; +import { SqliteClient } from '../SqliteClient'; + +/** + * dropPendingTasks - Drops all pending tasks from the DB given a specific messageId. + * Useful for when we do some message actions on a failed message and then we decide to + * delete it afterwards, removing it from the state. + * + * @param {Object} param + * @param {string} param.messageId The messageId for which we want to remove the pending tasks. + * @param {boolean} param.execute Whether we should immediately execute the query or return it as a prepared one. + * + * @return {() => void} - A function that can be called to remove the task from the database + */ +export const dropPendingTasks = async ({ + messageId, + execute = true, +}: { + messageId: string; + execute?: boolean; +}) => { + const queries = [createDeleteQuery('pendingTasks', { messageId })]; + SqliteClient.logger?.('info', 'dropPendingTasks', { + messageId, + }); + + if (execute) { + await SqliteClient.executeSqlBatch(queries); + } + + return queries; +}; diff --git a/package/src/store/apis/getChannelMessages.ts b/package/src/store/apis/getChannelMessages.ts index d7d34b3614..5302045e23 100644 --- a/package/src/store/apis/getChannelMessages.ts +++ b/package/src/store/apis/getChannelMessages.ts @@ -25,7 +25,7 @@ export const getChannelMessages = async ({ const messageIds = messageRows.map(({ id }) => id); // Populate the message reactions. - const reactionRows = await selectReactionsForMessages(messageIds); + const reactionRows = await selectReactionsForMessages(messageIds, null); const messageIdVsReactions: Record[]> = {}; reactionRows.forEach((reaction) => { if (!messageIdVsReactions[reaction.messageId]) { diff --git a/package/src/store/apis/getChannels.ts b/package/src/store/apis/getChannels.ts index 1c06f3c453..72a94e1d33 100644 --- a/package/src/store/apis/getChannels.ts +++ b/package/src/store/apis/getChannels.ts @@ -25,19 +25,22 @@ export const getChannels = async ({ currentUserId: string; }): Promise[]> => { SqliteClient.logger?.('info', 'getChannels', { channelIds, currentUserId }); - const channels = await selectChannels({ channelIds }); - const cidVsMembers = await getMembers({ channelIds }); - const cidVsReads = await getReads({ channelIds }); - const cidVsMessages = await getChannelMessages({ - channelIds, - currentUserId, - }); + const [channels, cidVsMembers, cidVsReads, cidVsMessages] = await Promise.all([ + selectChannels({ channelIds }), + getMembers({ channelIds }), + getReads({ channelIds }), + getChannelMessages({ + channelIds, + currentUserId, + }), + ]); // Enrich the channels with state return channels.map((c) => ({ ...mapStorableToChannel(c), members: cidVsMembers[c.cid] || [], + membership: (cidVsMembers[c.cid] || []).find((member) => member.user_id === currentUserId), messages: cidVsMessages[c.cid] || [], pinned_messages: [], read: cidVsReads[c.cid] || [], diff --git a/package/src/store/apis/getLastSyncedAt.ts b/package/src/store/apis/getLastSyncedAt.ts index f4f81d0e2c..27fbb1e412 100644 --- a/package/src/store/apis/getLastSyncedAt.ts +++ b/package/src/store/apis/getLastSyncedAt.ts @@ -5,7 +5,7 @@ export const getLastSyncedAt = async ({ currentUserId, }: { currentUserId: string; -}): Promise => { +}): Promise => { SqliteClient.logger?.('info', 'getLastSyncedAt', { currentUserId }); const result = await SqliteClient.executeSql.apply( null, @@ -14,8 +14,5 @@ export const getLastSyncedAt = async ({ }), ); - if (typeof result[0]?.lastSyncedAt === 'number') { - return result[0]?.lastSyncedAt; - } - return undefined; + return result[0]?.lastSyncedAt; }; diff --git a/package/src/store/apis/getReactionsforFilterSort.ts b/package/src/store/apis/getReactionsforFilterSort.ts index ef7592ce0c..629ec450a5 100644 --- a/package/src/store/apis/getReactionsforFilterSort.ts +++ b/package/src/store/apis/getReactionsforFilterSort.ts @@ -10,15 +10,18 @@ import { SqliteClient } from '../SqliteClient'; * @param currentMessageId The message ID for which reactions are to be fetched. * @param filters The filters to be applied while fetching reactions. * @param sort The sort to be applied while fetching reactions. + * @param limit The limit of how many reactions should be returned. */ export const getReactionsForFilterSort = async ({ - currentMessageId, + messageId, filters, sort, + limit, }: { - currentMessageId: string; - filters?: ReactionFilters; + messageId: string; + filters?: Pick; sort?: ReactionSort; + limit?: number; }): Promise => { if (!filters && !sort) { console.warn('Please provide the query (filters/sort) to fetch channels from DB'); @@ -27,7 +30,7 @@ export const getReactionsForFilterSort = async ({ SqliteClient.logger?.('info', 'getReactionsForFilterSort', { filters, sort }); - const reactions = await selectReactionsForMessages([currentMessageId]); + const reactions = await selectReactionsForMessages([messageId], limit, filters, sort); if (!reactions) { return null; @@ -37,7 +40,5 @@ export const getReactionsForFilterSort = async ({ return []; } - const filteredReactions = reactions.filter((reaction) => reaction.type === filters?.type); - - return getReactions({ reactions: filteredReactions }); + return getReactions({ reactions }); }; diff --git a/package/src/store/apis/index.ts b/package/src/store/apis/index.ts index ae2740705a..a3201a5c69 100644 --- a/package/src/store/apis/index.ts +++ b/package/src/store/apis/index.ts @@ -8,11 +8,14 @@ export * from './getAppSettings'; export * from './getChannelMessages'; export * from './getChannels'; export * from './getChannelsForFilterSort'; +export * from './getReactionsforFilterSort'; export * from './getLastSyncedAt'; export * from './getMembers'; export * from './getReads'; export * from './updateMessage'; export * from './updateReaction'; +export * from './insertReaction'; +export * from './deleteReaction'; export * from './upsertAppSettings'; export * from './upsertChannelData'; export * from './upsertChannels'; @@ -21,4 +24,10 @@ export * from './upsertUserSyncStatus'; export * from './upsertMembers'; export * from './upsertMessages'; export * from './upsertReads'; -export * from './updatePollMessage'; +export * from './upsertPoll'; +export * from './addPendingTask'; +export * from './deletePendingTask'; +export * from './getPendingTasks'; +export * from './softDeleteMessage'; +export * from './channelExists'; +export * from './dropPendingTasks'; diff --git a/package/src/store/apis/insertReaction.ts b/package/src/store/apis/insertReaction.ts index 1ec6eb4111..da1c2006af 100644 --- a/package/src/store/apis/insertReaction.ts +++ b/package/src/store/apis/insertReaction.ts @@ -7,13 +7,13 @@ import { SqliteClient } from '../SqliteClient'; import type { PreparedQueries } from '../types'; export const insertReaction = async ({ - flush = true, + execute = true, message, reaction, }: { message: MessageResponse | LocalMessage; reaction: ReactionResponse; - flush?: boolean; + execute?: boolean; }) => { const queries: PreparedQueries[] = []; @@ -34,11 +34,11 @@ export const insertReaction = async ({ ); SqliteClient.logger?.('info', 'insertReaction', { - flush, + execute, reaction: storableReaction, }); - if (flush) { + if (execute) { await SqliteClient.executeSqlBatch(queries); } diff --git a/package/src/store/apis/queries/selectMessagesForChannels.ts b/package/src/store/apis/queries/selectMessagesForChannels.ts index c020ec2376..71bf431f10 100644 --- a/package/src/store/apis/queries/selectMessagesForChannels.ts +++ b/package/src/store/apis/queries/selectMessagesForChannels.ts @@ -30,7 +30,7 @@ export const selectMessagesForChannels = async ( *, ROW_NUMBER() OVER ( PARTITION BY cid - ORDER BY cast(strftime('%s', createdAt) AS INTEGER) DESC + ORDER BY createdAt DESC ) RowNum FROM messages WHERE cid in (${questionMarks}) @@ -39,7 +39,7 @@ export const selectMessagesForChannels = async ( users b ON b.id = a.userId WHERE RowNum < 200 - ORDER BY cast(strftime('%s', a.createdAt) AS INTEGER) ASC`, + ORDER BY a.createdAt ASC`, cids, ); diff --git a/package/src/store/apis/queries/selectReactionsForMessages.ts b/package/src/store/apis/queries/selectReactionsForMessages.ts index 67f2feb219..02908582ee 100644 --- a/package/src/store/apis/queries/selectReactionsForMessages.ts +++ b/package/src/store/apis/queries/selectReactionsForMessages.ts @@ -1,3 +1,5 @@ +import type { ReactionFilters, ReactionSort } from 'stream-chat'; + import { tables } from '../../schema'; import { SqliteClient } from '../../SqliteClient'; import type { TableRowJoinedUser } from '../../types'; @@ -5,9 +7,15 @@ import type { TableRowJoinedUser } from '../../types'; /** * Fetches reactions for a message from the database for messageIds. * @param messageIds The message IDs for which reactions are to be fetched. + * @param limit The limit of how many reactions should be returned. + * @param filters A ReactionFilter for the reactions we want to fetch. Only type is currently supported. + * @param sort A sort for reactions to be used when querying. Custom data is currently not supported for sorting. */ export const selectReactionsForMessages = async ( messageIds: string[], + limit: number | null = 25, + filters?: Pick, + sort?: ReactionSort, ): Promise[]> => { const questionMarks = Array(messageIds.length).fill('?').join(','); const reactionsColumnNames = Object.keys(tables.reactions.columns) @@ -16,6 +24,15 @@ export const selectReactionsForMessages = async ( const userColumnNames = Object.keys(tables.users.columns) .map((name) => `'${name}', b.${name}`) .join(', '); + const filterValue = filters?.type + ? [typeof filters.type === 'string' ? filters.type : filters.type.$eq] + : []; + const createdAtSort = Array.isArray(sort) + ? sort.find((s) => !!s.created_at)?.created_at + : sort?.created_at; + const orderByClause = createdAtSort + ? `ORDER BY cast(strftime('%s', a.createdAt) AS INTEGER) ${createdAtSort === 1 ? 'ASC' : 'DESC'}` + : ''; SqliteClient.logger?.('info', 'selectReactionsForMessages', { messageIds, @@ -33,8 +50,10 @@ export const selectReactionsForMessages = async ( LEFT JOIN users b ON b.id = a.userId - WHERE a.messageId in (${questionMarks})`, - messageIds, + WHERE a.messageId in (${questionMarks}) ${filters?.type ? `AND a.type = ?` : ''} + ${orderByClause} + ${limit ? 'LIMIT ?' : ''}`, + [...messageIds, ...filterValue, ...(limit ? [limit] : [])], ); return result.map((r) => JSON.parse(r.value)); diff --git a/package/src/store/apis/softDeleteMessage.ts b/package/src/store/apis/softDeleteMessage.ts new file mode 100644 index 0000000000..e88964f358 --- /dev/null +++ b/package/src/store/apis/softDeleteMessage.ts @@ -0,0 +1,32 @@ +import { MessageLabel } from 'stream-chat'; + +import { createUpdateQuery } from '../sqlite-utils/createUpdateQuery'; +import { SqliteClient } from '../SqliteClient'; + +export const softDeleteMessage = async ({ + execute = true, + id, +}: { + id: string; + execute?: boolean; +}) => { + const query = createUpdateQuery( + 'messages', + { + deletedAt: new Date().toISOString(), + type: 'deleted' as MessageLabel, + }, + { id }, + ); + + SqliteClient.logger?.('info', 'softDeleteMessage', { + execute, + id, + }); + + if (execute) { + await SqliteClient.executeSql.apply(null, query); + } + + return [query]; +}; diff --git a/package/src/store/apis/updateMessage.ts b/package/src/store/apis/updateMessage.ts index 290a6fc794..3de2d8ba84 100644 --- a/package/src/store/apis/updateMessage.ts +++ b/package/src/store/apis/updateMessage.ts @@ -3,7 +3,6 @@ import type { LocalMessage, MessageResponse } from 'stream-chat'; import { mapMessageToStorable } from '../mappers/mapMessageToStorable'; import { mapReactionToStorable } from '../mappers/mapReactionToStorable'; import { mapUserToStorable } from '../mappers/mapUserToStorable'; -import { createDeleteQuery } from '../sqlite-utils/createDeleteQuery'; import { createSelectQuery } from '../sqlite-utils/createSelectQuery'; import { createUpdateQuery } from '../sqlite-utils/createUpdateQuery'; import { createUpsertQuery } from '../sqlite-utils/createUpsertQuery'; @@ -11,11 +10,11 @@ import { SqliteClient } from '../SqliteClient'; import type { PreparedQueries } from '../types'; export const updateMessage = async ({ - flush = true, + execute = true, message, }: { message: MessageResponse | LocalMessage; - flush?: boolean; + execute?: boolean; }) => { const queries: PreparedQueries[] = []; @@ -48,12 +47,6 @@ export const updateMessage = async ({ queries.push(createUpsertQuery('users', storableUser)); } - queries.push( - createDeleteQuery('reactions', { - messageId: message.id, - }), - ); - const latestReactions = message.latest_reactions || []; const ownReactions = message.own_reactions || []; @@ -77,7 +70,7 @@ export const updateMessage = async ({ users: storableUsers, }); - if (flush) { + if (execute) { await SqliteClient.executeSqlBatch(queries); } diff --git a/package/src/store/apis/updatePollMessage.ts b/package/src/store/apis/updatePollMessage.ts deleted file mode 100644 index 1263a4f9bb..0000000000 --- a/package/src/store/apis/updatePollMessage.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { isVoteAnswer, PollAnswer, PollResponse, PollVote } from 'stream-chat'; - -import { mapPollToStorable } from '../mappers/mapPollToStorable'; -import { mapStorableToPoll } from '../mappers/mapStorableToPoll'; -import { createSelectQuery } from '../sqlite-utils/createSelectQuery'; -import { createUpdateQuery } from '../sqlite-utils/createUpdateQuery'; -import { SqliteClient } from '../SqliteClient'; -import type { PreparedQueries, TableRow } from '../types'; - -export const updatePollMessage = async ({ - eventType, - flush = true, - poll, - poll_vote, - userID, -}: { - eventType: string; - poll: PollResponse; - userID: string; - flush?: boolean; - poll_vote?: PollVote | PollAnswer; -}) => { - const queries: PreparedQueries[] = []; - - const pollsFromDB = await SqliteClient.executeSql.apply( - null, - createSelectQuery('poll', ['*'], { - id: poll.id, - }), - ); - - for (const pollFromDB of pollsFromDB) { - const serializedPoll = mapStorableToPoll(pollFromDB as unknown as TableRow<'poll'>); - const { latest_answers = [], own_votes = [] } = serializedPoll; - let newOwnVotes = own_votes; - if (poll_vote && poll_vote.user?.id === userID) { - newOwnVotes = - eventType === 'poll.vote_removed' - ? newOwnVotes.filter((vote) => vote.id !== poll_vote.id) - : [poll_vote, ...newOwnVotes.filter((vote) => vote.id !== poll_vote.id)]; - } - let newLatestAnswers = latest_answers; - if (poll_vote && isVoteAnswer(poll_vote)) { - newLatestAnswers = - eventType === 'poll.vote_removed' - ? newLatestAnswers.filter((answer) => answer.id !== poll_vote?.id) - : [poll_vote, ...newLatestAnswers.filter((answer) => answer.id !== poll_vote?.id)]; - } - - const storablePoll = mapPollToStorable({ - ...poll, - latest_answers: newLatestAnswers, - own_votes: newOwnVotes, - }); - - queries.push( - createUpdateQuery('poll', storablePoll, { - id: poll.id, - }), - ); - SqliteClient.logger?.('info', 'updatePoll', { - poll: storablePoll, - }); - } - - if (flush) { - SqliteClient.executeSqlBatch(queries); - } - - return queries; -}; diff --git a/package/src/store/apis/updateReaction.ts b/package/src/store/apis/updateReaction.ts index ddd6ad9e50..ef919bb5c6 100644 --- a/package/src/store/apis/updateReaction.ts +++ b/package/src/store/apis/updateReaction.ts @@ -3,19 +3,20 @@ import type { LocalMessage, MessageResponse, ReactionResponse } from 'stream-cha import { mapMessageToStorable } from '../mappers/mapMessageToStorable'; import { mapReactionToStorable } from '../mappers/mapReactionToStorable'; import { mapUserToStorable } from '../mappers/mapUserToStorable'; +import { createDeleteQuery } from '../sqlite-utils/createDeleteQuery'; import { createUpdateQuery } from '../sqlite-utils/createUpdateQuery'; import { createUpsertQuery } from '../sqlite-utils/createUpsertQuery'; import { SqliteClient } from '../SqliteClient'; import type { PreparedQueries } from '../types'; export const updateReaction = async ({ - flush = true, + execute = true, message, reaction, }: { message: MessageResponse | LocalMessage; reaction: ReactionResponse; - flush?: boolean; + execute?: boolean; }) => { const queries: PreparedQueries[] = []; let storableUser: ReturnType | undefined; @@ -28,11 +29,12 @@ export const updateReaction = async ({ const storableReaction = mapReactionToStorable(reaction); queries.push( - createUpdateQuery('reactions', storableReaction, { + createDeleteQuery('reactions', { messageId: reaction.message_id, userId: reaction.user_id, }), ); + queries.push(createUpsertQuery('reactions', storableReaction)); let updatedReactionGroups: string | undefined; if (message.reaction_groups) { @@ -43,12 +45,12 @@ export const updateReaction = async ({ SqliteClient.logger?.('info', 'updateReaction', { addedUser: storableUser, - flush, + execute, updatedReaction: storableReaction, updatedReactionGroups, }); - if (flush) { + if (execute) { await SqliteClient.executeSqlBatch(queries); } diff --git a/package/src/store/apis/upsertAppSettings.ts b/package/src/store/apis/upsertAppSettings.ts index fd43e0e69f..dd51536958 100644 --- a/package/src/store/apis/upsertAppSettings.ts +++ b/package/src/store/apis/upsertAppSettings.ts @@ -6,25 +6,29 @@ import { SqliteClient } from '../SqliteClient'; export const upsertAppSettings = async ({ appSettings, currentUserId, - flush = true, + execute = true, }: { appSettings: AppSettingsAPIResponse; currentUserId: string; - flush?: boolean; + execute?: boolean; }) => { const storableAppSettings = JSON.stringify(appSettings); - const query = createUpsertQuery('userSyncStatus', { - appSettings: storableAppSettings, - userId: currentUserId, - }); + const queries = [ + createUpsertQuery('userSyncStatus', { + appSettings: storableAppSettings, + userId: currentUserId, + }), + ]; SqliteClient.logger?.('info', 'upsertAppSettings', { appSettings: storableAppSettings, - flush, + execute, userId: currentUserId, }); - if (flush) { - await SqliteClient.executeSql.apply(null, query); + if (execute) { + await SqliteClient.executeSqlBatch(queries); } + + return queries; }; diff --git a/package/src/store/apis/upsertChannelData.ts b/package/src/store/apis/upsertChannelData.ts index b551171032..79ae222907 100644 --- a/package/src/store/apis/upsertChannelData.ts +++ b/package/src/store/apis/upsertChannelData.ts @@ -6,19 +6,19 @@ import { SqliteClient } from '../SqliteClient'; export const upsertChannelData = async ({ channel, - flush = true, + execute = true, }: { channel: ChannelResponse; - flush?: boolean; + execute?: boolean; }) => { const storableChannel = mapChannelDataToStorable(channel); const query = createUpsertQuery('channels', storableChannel); SqliteClient.logger?.('info', 'upsertChannelData', { channel: storableChannel, - flush, + execute, }); - if (flush) { + if (execute) { await SqliteClient.executeSqlBatch([query]); } diff --git a/package/src/store/apis/upsertChannelDataFromChannel.ts b/package/src/store/apis/upsertChannelDataFromChannel.ts index c65d1ca126..002395ed1f 100644 --- a/package/src/store/apis/upsertChannelDataFromChannel.ts +++ b/package/src/store/apis/upsertChannelDataFromChannel.ts @@ -6,17 +6,17 @@ import { SqliteClient } from '../SqliteClient'; export const upsertChannelDataFromChannel = async ({ channel, - flush = true, + execute = true, }: { channel: Channel; - flush?: boolean; + execute?: boolean; }) => { const storableChannel = mapChannelToStorable(channel); if (!storableChannel) { return; } const query = createUpsertQuery('channels', storableChannel); - if (flush) { + if (execute) { await SqliteClient.executeSqlBatch([query]); } diff --git a/package/src/store/apis/upsertChannels.ts b/package/src/store/apis/upsertChannels.ts index 31a158197a..26cc87bf77 100644 --- a/package/src/store/apis/upsertChannels.ts +++ b/package/src/store/apis/upsertChannels.ts @@ -1,6 +1,5 @@ -import type { ChannelAPIResponse, ChannelFilters, ChannelSort } from 'stream-chat'; +import type { ChannelAPIResponse, ChannelMemberResponse } from 'stream-chat'; -import { upsertCidsForQuery } from './upsertCidsForQuery'; import { upsertMembers } from './upsertMembers'; import { upsertMessages } from './upsertMessages'; @@ -13,16 +12,12 @@ import type { PreparedQueries } from '../types'; export const upsertChannels = async ({ channels, - filters, - flush = true, + execute = true, isLatestMessagesSet, - sort, }: { channels: ChannelAPIResponse[]; - filters?: ChannelFilters; - flush?: boolean; + execute?: boolean; isLatestMessagesSet?: boolean; - sort?: ChannelSort; }) => { // Update the database only if the query is provided. let queries: PreparedQueries[] = []; @@ -33,25 +28,20 @@ export const upsertChannels = async ({ channelIds, }); - if (filters || sort) { - queries = queries.concat( - await upsertCidsForQuery({ - cids: channelIds, - filters, - flush: false, - sort, - }), - ); - } - for (const channel of channels) { queries.push(createUpsertQuery('channels', mapChannelDataToStorable(channel.channel))); - const { members, messages, read } = channel; + const { members, membership, messages, read } = channel; + if ( + membership && + !members.includes((m: ChannelMemberResponse) => m.user?.id === membership.user?.id) + ) { + members.push({ ...membership, user_id: membership.user?.id }); + } queries = queries.concat( await upsertMembers({ cid: channel.channel.cid, - flush: false, + execute: false, members, }), ); @@ -60,7 +50,7 @@ export const upsertChannels = async ({ queries = queries.concat( await upsertReads({ cid: channel.channel.cid, - flush: false, + execute: false, reads: read, }), ); @@ -69,14 +59,14 @@ export const upsertChannels = async ({ if (isLatestMessagesSet) { queries = queries.concat( await upsertMessages({ - flush: false, + execute: false, messages, }), ); } } - if (flush) { + if (execute) { await SqliteClient.executeSqlBatch(queries); } diff --git a/package/src/store/apis/upsertCidsForQuery.ts b/package/src/store/apis/upsertCidsForQuery.ts index 4bc4b784c5..3d388a2978 100644 --- a/package/src/store/apis/upsertCidsForQuery.ts +++ b/package/src/store/apis/upsertCidsForQuery.ts @@ -8,12 +8,12 @@ import { SqliteClient } from '../SqliteClient'; export const upsertCidsForQuery = async ({ cids, filters, - flush = true, + execute = true, sort, }: { cids: string[]; filters?: ChannelFilters; - flush?: boolean; + execute?: boolean; sort?: ChannelSort; }) => { // Update the database only if the query is provided. @@ -26,11 +26,11 @@ export const upsertCidsForQuery = async ({ SqliteClient.logger?.('info', 'upsertCidsForQuery', { cids: cidsString, - flush, + execute, id, }); - if (flush) { + if (execute) { await SqliteClient.executeSql.apply(null, query); } diff --git a/package/src/store/apis/upsertMembers.ts b/package/src/store/apis/upsertMembers.ts index 7047390979..6a48205417 100644 --- a/package/src/store/apis/upsertMembers.ts +++ b/package/src/store/apis/upsertMembers.ts @@ -8,12 +8,12 @@ import type { PreparedQueries } from '../types'; export const upsertMembers = async ({ cid, - flush = true, + execute = true, members, }: { cid: string; members: ChannelMemberResponse[]; - flush?: boolean; + execute?: boolean; }) => { const queries: PreparedQueries[] = []; @@ -34,12 +34,12 @@ export const upsertMembers = async ({ SqliteClient.logger?.('info', 'upsertMembers', { cid, - flush, + execute, storableMembers, storableUsers, }); - if (flush) { + if (execute) { await SqliteClient.executeSqlBatch(queries); } diff --git a/package/src/store/apis/upsertMessages.ts b/package/src/store/apis/upsertMessages.ts index 7bcc007a51..84a13e22aa 100644 --- a/package/src/store/apis/upsertMessages.ts +++ b/package/src/store/apis/upsertMessages.ts @@ -8,11 +8,11 @@ import { createUpsertQuery } from '../sqlite-utils/createUpsertQuery'; import { SqliteClient } from '../SqliteClient'; export const upsertMessages = async ({ - flush = true, + execute = true, messages, }: { messages: MessageResponse[]; - flush?: boolean; + execute?: boolean; }) => { const storableMessages: Array> = []; const storableUsers: Array> = []; @@ -45,14 +45,14 @@ export const upsertMessages = async ({ ]; SqliteClient.logger?.('info', 'upsertMessages', { - flush, + execute, messages: storableMessages, polls: storablePolls, reactions: storableReactions, users: storableUsers, }); - if (flush) { + if (execute) { await SqliteClient.executeSqlBatch(finalQueries); } diff --git a/package/src/store/apis/upsertPoll.ts b/package/src/store/apis/upsertPoll.ts new file mode 100644 index 0000000000..0cd43900fc --- /dev/null +++ b/package/src/store/apis/upsertPoll.ts @@ -0,0 +1,29 @@ +import { PollResponse } from 'stream-chat'; + +import { mapPollToStorable } from '../mappers/mapPollToStorable'; +import { createUpsertQuery } from '../sqlite-utils/createUpsertQuery'; +import { SqliteClient } from '../SqliteClient'; +import type { PreparedQueries } from '../types'; + +export const upsertPoll = async ({ + execute = true, + poll, +}: { + poll: PollResponse; + execute?: boolean; +}) => { + const queries: PreparedQueries[] = []; + + const storablePoll = mapPollToStorable(poll); + + queries.push(createUpsertQuery('poll', storablePoll)); + SqliteClient.logger?.('info', 'upsertPoll', { + poll: storablePoll, + }); + + if (execute) { + await SqliteClient.executeSqlBatch(queries); + } + + return queries; +}; diff --git a/package/src/store/apis/upsertReads.ts b/package/src/store/apis/upsertReads.ts index 7d3fbd13cb..92d4c15145 100644 --- a/package/src/store/apis/upsertReads.ts +++ b/package/src/store/apis/upsertReads.ts @@ -8,12 +8,12 @@ import type { PreparedQueries } from '../types'; export const upsertReads = async ({ cid, - flush = true, + execute = true, reads, }: { cid: string; reads: ReadResponse[]; - flush?: boolean; + execute?: boolean; }) => { const queries: PreparedQueries[] = []; @@ -31,12 +31,12 @@ export const upsertReads = async ({ queries.push(...storableReads.map((storableRead) => createUpsertQuery('reads', storableRead))); SqliteClient.logger?.('info', 'upsertReads', { - flush, + execute, reads: storableReads, users: storableUsers, }); - if (flush) { + if (execute) { await SqliteClient.executeSqlBatch(queries); } diff --git a/package/src/store/apis/upsertUserSyncStatus.ts b/package/src/store/apis/upsertUserSyncStatus.ts index f4473d8d60..9e19cfc531 100644 --- a/package/src/store/apis/upsertUserSyncStatus.ts +++ b/package/src/store/apis/upsertUserSyncStatus.ts @@ -4,19 +4,27 @@ import { SqliteClient } from '../SqliteClient'; export const upsertUserSyncStatus = async ({ currentUserId, lastSyncedAt, + execute = true, }: { currentUserId: string; lastSyncedAt: string; + execute?: boolean; }) => { - const query = createUpsertQuery('userSyncStatus', { - lastSyncedAt, - userId: currentUserId, - }); + const queries = [ + createUpsertQuery('userSyncStatus', { + lastSyncedAt, + userId: currentUserId, + }), + ]; SqliteClient.logger?.('info', 'upsertUserSyncStatus', { lastSyncedAt, userId: currentUserId, }); - await SqliteClient.executeSql.apply(null, query); + if (execute) { + await SqliteClient.executeSqlBatch(queries); + } + + return queries; }; diff --git a/package/src/store/mappers/mapMemberToStorable.ts b/package/src/store/mappers/mapMemberToStorable.ts index ed1b492daf..1b90e4bc29 100644 --- a/package/src/store/mappers/mapMemberToStorable.ts +++ b/package/src/store/mappers/mapMemberToStorable.ts @@ -12,6 +12,7 @@ export const mapMemberToStorable = ({ member: ChannelMemberResponse; }): TableRow<'members'> => { const { + archived_at, banned, channel_role, created_at, @@ -23,9 +24,11 @@ export const mapMemberToStorable = ({ shadow_banned, updated_at, user_id, + pinned_at, } = member; return { + archivedAt: mapDateTimeToStorable(archived_at), banned, channelRole: channel_role, cid, @@ -34,6 +37,7 @@ export const mapMemberToStorable = ({ invited, inviteRejectedAt: invite_rejected_at, isModerator: is_moderator, + pinnedAt: mapDateTimeToStorable(pinned_at), role, shadowBanned: shadow_banned, updatedAt: mapDateTimeToStorable(updated_at), diff --git a/package/src/store/mappers/mapReadToStorable.ts b/package/src/store/mappers/mapReadToStorable.ts index 0e261be04d..42e6f01bbd 100644 --- a/package/src/store/mappers/mapReadToStorable.ts +++ b/package/src/store/mappers/mapReadToStorable.ts @@ -11,11 +11,12 @@ export const mapReadToStorable = ({ cid: string; read: ReadResponse; }): TableRow<'reads'> => { - const { last_read, unread_messages, user } = read; + const { last_read, unread_messages, user, last_read_message_id } = read; return { cid, lastRead: mapDateTimeToStorable(last_read), + lastReadMessageId: last_read_message_id, unreadMessages: unread_messages, userId: user?.id, }; diff --git a/package/src/store/mappers/mapStorableToMember.ts b/package/src/store/mappers/mapStorableToMember.ts index ee5233547f..35c26259b4 100644 --- a/package/src/store/mappers/mapStorableToMember.ts +++ b/package/src/store/mappers/mapStorableToMember.ts @@ -8,6 +8,7 @@ export const mapStorableToMember = ( memberRow: TableRowJoinedUser<'members'>, ): ChannelMemberResponse => { const { + archivedAt, banned, channelRole, createdAt, @@ -15,6 +16,7 @@ export const mapStorableToMember = ( invited, inviteRejectedAt, isModerator, + pinnedAt, role, shadowBanned, updatedAt, @@ -23,6 +25,7 @@ export const mapStorableToMember = ( } = memberRow; return { + archived_at: archivedAt, banned, channel_role: channelRole, created_at: createdAt, @@ -30,6 +33,7 @@ export const mapStorableToMember = ( invite_rejected_at: inviteRejectedAt, invited, is_moderator: isModerator, + pinned_at: pinnedAt, role, shadow_banned: shadowBanned, updated_at: updatedAt, diff --git a/package/src/store/mappers/mapStorableToRead.ts b/package/src/store/mappers/mapStorableToRead.ts index 448aecea5b..17c8fe1ddc 100644 --- a/package/src/store/mappers/mapStorableToRead.ts +++ b/package/src/store/mappers/mapStorableToRead.ts @@ -5,10 +5,11 @@ import { mapStorableToUser } from './mapStorableToUser'; import type { TableRowJoinedUser } from '../types'; export const mapStorableToRead = (row: TableRowJoinedUser<'reads'>): ReadResponse => { - const { lastRead, unreadMessages, user } = row; + const { lastRead, unreadMessages, user, lastReadMessageId } = row; return { last_read: lastRead, + last_read_message_id: lastReadMessageId, unread_messages: unreadMessages, user: mapStorableToUser(user), }; diff --git a/package/src/store/mappers/mapStorableToTask.ts b/package/src/store/mappers/mapStorableToTask.ts index cf55a66418..c7eff1c117 100644 --- a/package/src/store/mappers/mapStorableToTask.ts +++ b/package/src/store/mappers/mapStorableToTask.ts @@ -1,4 +1,6 @@ -import type { PendingTask, TableRowJoinedUser } from '../types'; +import { PendingTask } from 'stream-chat'; + +import type { TableRowJoinedUser } from '../types'; export const mapStorableToTask = (row: TableRowJoinedUser<'pendingTasks'>): PendingTask => { const { channelId, channelType, id, messageId, type } = row; diff --git a/package/src/store/mappers/mapTaskToStorable.ts b/package/src/store/mappers/mapTaskToStorable.ts index 3f448370ce..3baf24fceb 100644 --- a/package/src/store/mappers/mapTaskToStorable.ts +++ b/package/src/store/mappers/mapTaskToStorable.ts @@ -1,4 +1,4 @@ -import type { PendingTask } from '../types'; +import type { PendingTask } from 'stream-chat'; export const mapTaskToStorable = (task: PendingTask) => ({ ...task, diff --git a/package/src/store/schema.ts b/package/src/store/schema.ts index bcfbf723f1..43b451e5e8 100644 --- a/package/src/store/schema.ts +++ b/package/src/store/schema.ts @@ -1,6 +1,4 @@ -import type { MessageLabel, Role } from 'stream-chat'; - -import type { PendingTaskTypes } from './types'; +import type { MessageLabel, PendingTaskTypes, Role } from 'stream-chat'; import type { ValueOf } from '../types/types'; @@ -64,6 +62,7 @@ export const tables: Tables = { }, members: { columns: { + archivedAt: 'TEXT', banned: 'BOOLEAN DEFAULT FALSE', channelRole: 'TEXT', cid: 'TEXT NOT NULL', @@ -72,6 +71,7 @@ export const tables: Tables = { invited: 'BOOLEAN', inviteRejectedAt: 'TEXT', isModerator: 'BOOLEAN', + pinnedAt: 'TEXT', role: 'TEXT', shadowBanned: 'BOOLEAN DEFAULT FALSE', updatedAt: 'TEXT', @@ -270,6 +270,7 @@ export type Schema = { updatedAt?: string; }; members: { + archivedAt?: string; cid: string; banned?: boolean; channelRole?: Role; @@ -282,6 +283,7 @@ export type Schema = { shadowBanned?: boolean; updatedAt?: string; userId?: string; + pinnedAt?: string; }; messages: { attachments: string; diff --git a/package/src/utils/DBSyncManager.ts b/package/src/utils/DBSyncManager.ts deleted file mode 100644 index d4daffe65b..0000000000 --- a/package/src/utils/DBSyncManager.ts +++ /dev/null @@ -1,242 +0,0 @@ -import type { AxiosError } from 'axios'; -import dayjs from 'dayjs'; -import type { APIErrorResponse, StreamChat } from 'stream-chat'; - -import { handleEventToSyncDB } from '../components/Chat/hooks/handleEventToSyncDB'; -import { getAllChannelIds, getLastSyncedAt, upsertUserSyncStatus } from '../store/apis'; - -import { addPendingTask } from '../store/apis/addPendingTask'; - -import { deletePendingTask } from '../store/apis/deletePendingTask'; -import { getPendingTasks } from '../store/apis/getPendingTasks'; -import { SqliteClient } from '../store/SqliteClient'; -import type { PendingTask } from '../store/types'; - -/** - * DBSyncManager has the responsibility to sync the channel states - * within local database whenever possible. - * - * Components can get the current sync status using DBSyncManager.getCurrentStatus(). - * Or components can attach a listener for status change as following: - * - * ```tsx - * useEffect(() => { - * const unsubscribe = DBSyncManager.onSyncStatusChange((syncStatus) => { - * if (syncStatus) { - * doSomething(); - * } - * }) - * - * return () => unsubscribe(); - * }) - * ``` - */ -const restBeforeNextTask = () => new Promise((resolve) => setTimeout(resolve, 500)); - -export class DBSyncManager { - static syncStatus = false; - static listeners: Array<(status: boolean) => void> = []; - static client: StreamChat | null = null; - static connectionChangedListener: { unsubscribe: () => void } | null = null; - - /** - * Returns weather channel states in local DB are synced with backend or not. - * @returns boolean - */ - static getSyncStatus = () => this.syncStatus; - - /** - * Initializes the DBSyncManager. This function should be called only once - * throughout the lifetime of SDK. - * - * @param client - */ - static init = async (client: StreamChat) => { - try { - this.client = client; - // If the websocket connection is already active, then straightaway - // call the sync api and also execute pending api calls. - // Otherwise wait for `connection.changed` event. - if (client.user?.id && client.wsConnection?.isHealthy) { - await this.syncAndExecutePendingTasks(); - this.syncStatus = true; - this.listeners.forEach((l) => l(true)); - } - - // If a listener has already been registered, unsubscribe from it so - // that it can be reinstated. This can happen if we reconnect with a - // different user or the component invoking the init() function gets - // unmounted and then remounted again. This part of the code makes - // sure the stale listener doesn't produce a memory leak. - if (this.connectionChangedListener) { - this.connectionChangedListener.unsubscribe(); - } - - this.connectionChangedListener = this.client.on('connection.changed', async (event) => { - if (event.online) { - await this.syncAndExecutePendingTasks(); - this.syncStatus = true; - this.listeners.forEach((l) => l(true)); - } else { - this.syncStatus = false; - this.listeners.forEach((l) => l(false)); - } - }); - } catch (error) { - console.log('Error in DBSyncManager.init: ', error); - } - }; - - /** - * Subscribes a listener for sync status change. - * - * @param listener {function} - * @returns {function} to unsubscribe the listener. - */ - static onSyncStatusChange = (listener: (status: boolean) => void) => { - this.listeners.push(listener); - - return { - unsubscribe: () => { - this.listeners = this.listeners.filter((el) => el !== listener); - }, - }; - }; - - static sync = async (client: StreamChat) => { - if (!this.client?.user) { - return; - } - const cids = await getAllChannelIds(); - // If there are no channels, then there is no need to sync. - if (cids.length === 0) { - return; - } - - const lastSyncedAt = await getLastSyncedAt({ - currentUserId: this.client.user.id, - }); - - if (lastSyncedAt) { - const lastSyncedAtDate = new Date(lastSyncedAt); - const lastSyncedAtDayJs = dayjs(lastSyncedAtDate); - const nowDayJs = dayjs(); - const diff = nowDayJs.diff(lastSyncedAtDayJs, 'days'); - if (diff > 30) { - // stream backend will send an error if we try to sync after 30 days. - // In that case reset the entire DB and start fresh. - await SqliteClient.resetDB(); - } else { - try { - const result = await this.client.sync(cids, lastSyncedAtDate.toISOString()); - const queryPromises = result.events.map( - async (event) => await handleEventToSyncDB(event, client), - ); - const queriesArray = await Promise.all(queryPromises); - const queries = queriesArray.flat(); - - if (queries.length) { - await SqliteClient.executeSqlBatch(queries); - } - } catch (e) { - // Error will be raised by the sync API if there are too many events. - // In that case reset the entire DB and start fresh. - await SqliteClient.resetDB(); - } - } - } - await upsertUserSyncStatus({ - currentUserId: this.client.user.id, - lastSyncedAt: new Date().toString(), - }); - }; - - static syncAndExecutePendingTasks = async () => { - if (!this.client) { - return; - } - - await this.executePendingTasks(this.client); - await this.sync(this.client); - }; - - static queueTask = async ({ client, task }: { client: StreamChat; task: PendingTask }) => { - const removeFromApi = await addPendingTask(task); - - let response; - try { - response = await this.executeTask({ client, task }); - } catch (e) { - if ((e as AxiosError)?.response?.data?.code === 4) { - // Error code 16 - message already exists - // ignore - } else { - throw e; - } - } - - await removeFromApi(); - - return response; - }; - - static executeTask = async ({ client, task }: { client: StreamChat; task: PendingTask }) => { - const channel = client.channel(task.channelType, task.channelId); - - if (task.type === 'send-reaction') { - return await channel.sendReaction(...task.payload); - } - - if (task.type === 'delete-reaction') { - return await channel.deleteReaction(...task.payload); - } - - if (task.type === 'delete-message') { - return await client.deleteMessage(...task.payload); - } - - throw new Error('Invalid task type'); - }; - - static executePendingTasks = async (client: StreamChat) => { - const queue = await getPendingTasks(); - for (const task of queue) { - if (!task.id) { - continue; - } - - try { - await this.executeTask({ - client, - task, - }); - } catch (e) { - if ((e as AxiosError)?.response?.data?.code === 4) { - // Error code 16 - message already exists - // ignore - } else { - throw e; - } - } - - await deletePendingTask({ - id: task.id, - }); - await restBeforeNextTask(); - } - }; - - static dropPendingTasks = async (conditions: { messageId: string }) => { - const tasks = await getPendingTasks(conditions); - - for (const task of tasks) { - if (!task.id) { - continue; - } - - await deletePendingTask({ - id: task.id, - }); - } - }; -} diff --git a/package/src/utils/addReactionToLocalState.ts b/package/src/utils/addReactionToLocalState.ts index 694dc42b21..bf99946fd2 100644 --- a/package/src/utils/addReactionToLocalState.ts +++ b/package/src/utils/addReactionToLocalState.ts @@ -1,9 +1,8 @@ import type { Channel, ReactionResponse, UserResponse } from 'stream-chat'; -import { updateReaction } from '../store/apis'; -import { insertReaction } from '../store/apis/insertReaction'; +import { insertReaction, updateReaction } from '../store/apis'; -export const addReactionToLocalState = ({ +export const addReactionToLocalState = async ({ channel, enforceUniqueReaction, messageId, @@ -32,74 +31,21 @@ export const addReactionToLocalState = ({ }; const hasOwnReaction = message.own_reactions && message.own_reactions.length > 0; - if (!message.own_reactions) { - message.own_reactions = []; - } - - if (!message.latest_reactions) { - message.latest_reactions = []; - } - if (enforceUniqueReaction) { - const currentReaction = message.own_reactions[0]; - message.own_reactions = []; - if (!message.latest_reactions) { - message.latest_reactions = []; - } - message.latest_reactions = message.latest_reactions.filter((r) => r.user_id !== user.id); + const messageWithReaction = channel.state.addReaction(reaction, undefined, enforceUniqueReaction); - if ( - currentReaction && - message.reaction_groups && - message.reaction_groups[currentReaction.type] && - message.reaction_groups[currentReaction.type].count > 0 && - message.reaction_groups[currentReaction.type].sum_scores > 0 - ) { - message.reaction_groups[currentReaction.type].count = - message.reaction_groups[currentReaction.type].count - 1; - message.reaction_groups[currentReaction.type].sum_scores = - message.reaction_groups[currentReaction.type].sum_scores - 1; - } - - if (!message.reaction_groups) { - message.reaction_groups = { - [reactionType]: { - count: 1, - first_reaction_at: new Date().toISOString(), - last_reaction_at: new Date().toISOString(), - sum_scores: 1, - }, - }; - } else { - if (!message.reaction_groups[reactionType]) { - message.reaction_groups[reactionType] = { - count: 1, - first_reaction_at: new Date().toISOString(), - last_reaction_at: new Date().toISOString(), - sum_scores: 1, - }; - } else { - message.reaction_groups[reactionType] = { - ...message.reaction_groups[reactionType], - count: message.reaction_groups[reactionType].count + 1, - last_reaction_at: new Date().toISOString(), - sum_scores: message.reaction_groups[reactionType].sum_scores + 1, - }; - } - } + if (!messageWithReaction) { + return; } - message.own_reactions = [...message.own_reactions, reaction]; - message.latest_reactions = [...message.latest_reactions, reaction]; - if (enforceUniqueReaction && hasOwnReaction) { - updateReaction({ - message, + await updateReaction({ + message: messageWithReaction, reaction, }); } else { - insertReaction({ - message, + await insertReaction({ + message: messageWithReaction, reaction, }); } diff --git a/package/src/utils/removeReactionFromLocalState.ts b/package/src/utils/removeReactionFromLocalState.ts index b5cdba3913..2caf00be1a 100644 --- a/package/src/utils/removeReactionFromLocalState.ts +++ b/package/src/utils/removeReactionFromLocalState.ts @@ -1,7 +1,5 @@ import type { Channel, UserResponse } from 'stream-chat'; -import { deleteReaction } from '../store/apis/deleteReaction'; - export const removeReactionFromLocalState = ({ channel, messageId, @@ -48,10 +46,4 @@ export const removeReactionFromLocalState = ({ delete message.reaction_groups[reactionType]; } } - - deleteReaction({ - messageId, - reactionType, - userId: user.id, - }); }; diff --git a/package/yarn.lock b/package/yarn.lock index d52740d3f6..9547afacf7 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -2112,16 +2112,16 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@8.25.0": - version "8.25.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.25.0.tgz#5e1d56f067e5808fa82d1b75bced82396e868a14" - integrity sha512-VM7bpzAe7JO/BFf40pIT1lJqS/z1F8OaSsUB3rpFJucQA4cOSuH2RVVVkFULN+En0Djgr29/jb4EQnedUo95KA== +"@typescript-eslint/eslint-plugin@8.29.0": + version "8.29.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.0.tgz#151c4878700a5ad229ce6713d2674d58b626b3d9" + integrity sha512-PAIpk/U7NIS6H7TEtN45SPGLQaHNgB7wSjsQV/8+KYokAb2T/gloOA/Bee2yd4/yKVhPKe5LlaUGhAZk5zmSaQ== dependencies: "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "8.25.0" - "@typescript-eslint/type-utils" "8.25.0" - "@typescript-eslint/utils" "8.25.0" - "@typescript-eslint/visitor-keys" "8.25.0" + "@typescript-eslint/scope-manager" "8.29.0" + "@typescript-eslint/type-utils" "8.29.0" + "@typescript-eslint/utils" "8.29.0" + "@typescript-eslint/visitor-keys" "8.29.0" graphemer "^1.4.0" ignore "^5.3.1" natural-compare "^1.4.0" @@ -2143,15 +2143,15 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/parser@8.25.0": - version "8.25.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.25.0.tgz#58fb81c7b7a35184ba17583f3d7ac6c4f3d95be8" - integrity sha512-4gbs64bnbSzu4FpgMiQ1A+D+urxkoJk/kqlDJ2W//5SygaEiAP2B4GoS7TEdxgwol2el03gckFV9lJ4QOMiiHg== +"@typescript-eslint/parser@8.29.0": + version "8.29.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.29.0.tgz#b98841e0a8099728cb8583da92326fcb7f5be1d2" + integrity sha512-8C0+jlNJOwQso2GapCVWWfW/rzaq7Lbme+vGUFKE31djwNncIpgXD7Cd4weEsDdkoZDjH0lwwr3QDQFuyrMg9g== dependencies: - "@typescript-eslint/scope-manager" "8.25.0" - "@typescript-eslint/types" "8.25.0" - "@typescript-eslint/typescript-estree" "8.25.0" - "@typescript-eslint/visitor-keys" "8.25.0" + "@typescript-eslint/scope-manager" "8.29.0" + "@typescript-eslint/types" "8.29.0" + "@typescript-eslint/typescript-estree" "8.29.0" + "@typescript-eslint/visitor-keys" "8.29.0" debug "^4.3.4" "@typescript-eslint/parser@^5.30.5": @@ -2180,6 +2180,14 @@ "@typescript-eslint/types" "8.25.0" "@typescript-eslint/visitor-keys" "8.25.0" +"@typescript-eslint/scope-manager@8.29.0": + version "8.29.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.29.0.tgz#8fd9872823aef65ff71d3f6d1ec9316ace0b6bf3" + integrity sha512-aO1PVsq7Gm+tcghabUpzEnVSFMCU4/nYIgC2GOatJcllvWfnhrgW0ZEbnTxm36QsikmCN1K/6ZgM7fok2I7xNw== + dependencies: + "@typescript-eslint/types" "8.29.0" + "@typescript-eslint/visitor-keys" "8.29.0" + "@typescript-eslint/type-utils@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a" @@ -2190,13 +2198,13 @@ debug "^4.3.4" tsutils "^3.21.0" -"@typescript-eslint/type-utils@8.25.0": - version "8.25.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.25.0.tgz#ee0d2f67c80af5ae74b5d6f977e0f8ded0059677" - integrity sha512-d77dHgHWnxmXOPJuDWO4FDWADmGQkN5+tt6SFRZz/RtCWl4pHgFl3+WdYCn16+3teG09DY6XtEpf3gGD0a186g== +"@typescript-eslint/type-utils@8.29.0": + version "8.29.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.29.0.tgz#98dcfd1193cb4e2b2d0294a8656ce5eb58c443a9" + integrity sha512-ahaWQ42JAOx+NKEf5++WC/ua17q5l+j1GFrbbpVKzFL/tKVc0aYY8rVSYUpUvt2hUP1YBr7mwXzx+E/DfUWI9Q== dependencies: - "@typescript-eslint/typescript-estree" "8.25.0" - "@typescript-eslint/utils" "8.25.0" + "@typescript-eslint/typescript-estree" "8.29.0" + "@typescript-eslint/utils" "8.29.0" debug "^4.3.4" ts-api-utils "^2.0.1" @@ -2210,6 +2218,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.25.0.tgz#f91512c2f532b1d6a8826cadd0b0e5cd53cf97e0" integrity sha512-+vUe0Zb4tkNgznQwicsvLUJgZIRs6ITeWSCclX1q85pR1iOiaj+4uZJIUp//Z27QWu5Cseiw3O3AR8hVpax7Aw== +"@typescript-eslint/types@8.29.0": + version "8.29.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.29.0.tgz#65add70ab4ef66beaa42a5addf87dab2b05b1f33" + integrity sha512-wcJL/+cOXV+RE3gjCyl/V2G877+2faqvlgtso/ZRbTCnZazh0gXhe+7gbAnfubzN2bNsBtZjDvlh7ero8uIbzg== + "@typescript-eslint/typescript-estree@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b" @@ -2237,6 +2250,20 @@ semver "^7.6.0" ts-api-utils "^2.0.1" +"@typescript-eslint/typescript-estree@8.29.0": + version "8.29.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.29.0.tgz#d201a4f115327ec90496307c9958262285065b00" + integrity sha512-yOfen3jE9ISZR/hHpU/bmNvTtBW1NjRbkSFdZOksL1N+ybPEE7UVGMwqvS6CP022Rp00Sb0tdiIkhSCe6NI8ow== + dependencies: + "@typescript-eslint/types" "8.29.0" + "@typescript-eslint/visitor-keys" "8.29.0" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^2.0.1" + "@typescript-eslint/utils@5.62.0", "@typescript-eslint/utils@^5.10.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86" @@ -2251,7 +2278,17 @@ eslint-scope "^5.1.1" semver "^7.3.7" -"@typescript-eslint/utils@8.25.0", "@typescript-eslint/utils@^6.0.0 || ^7.0.0 || ^8.0.0": +"@typescript-eslint/utils@8.29.0": + version "8.29.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.29.0.tgz#d6d22b19c8c4812a874f00341f686b45b9fe895f" + integrity sha512-gX/A0Mz9Bskm8avSWFcK0gP7cZpbY4AIo6B0hWYFCaIsz750oaiWR4Jr2CI+PQhfW1CpcQr9OlfPS+kMFegjXA== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@typescript-eslint/scope-manager" "8.29.0" + "@typescript-eslint/types" "8.29.0" + "@typescript-eslint/typescript-estree" "8.29.0" + +"@typescript-eslint/utils@^6.0.0 || ^7.0.0 || ^8.0.0": version "8.25.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.25.0.tgz#3ea2f9196a915ef4daa2c8eafd44adbd7d56d08a" integrity sha512-syqRbrEv0J1wywiLsK60XzHnQe/kRViI3zwFALrNEgnntn1l24Ra2KvOAWwWbWZ1lBZxZljPDGOq967dsl6fkA== @@ -2277,6 +2314,14 @@ "@typescript-eslint/types" "8.25.0" eslint-visitor-keys "^4.2.0" +"@typescript-eslint/visitor-keys@8.29.0": + version "8.29.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.29.0.tgz#2356336c9efdc3597ffcd2aa1ce95432852b743d" + integrity sha512-Sne/pVz8ryR03NFK21VpN88dZ2FdQXOlq3VIklbrTYEt8yXtRFr9tvUhqvCeKjqYk5FSim37sHbooT6vzBTZcg== + dependencies: + "@typescript-eslint/types" "8.29.0" + eslint-visitor-keys "^4.2.0" + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -4380,9 +4425,9 @@ glob-stream@^8.0.0: streamx "^2.12.5" glob@^11.0.0: - version "11.0.1" - resolved "https://registry.yarnpkg.com/glob/-/glob-11.0.1.tgz#1c3aef9a59d680e611b53dcd24bb8639cef064d9" - integrity sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw== + version "11.0.2" + resolved "https://registry.yarnpkg.com/glob/-/glob-11.0.2.tgz#3261e3897bbc603030b041fd77ba636022d51ce0" + integrity sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ== dependencies: foreground-child "^3.1.0" jackspeak "^4.0.1" @@ -5827,9 +5872,9 @@ lru-cache@^10.2.0: integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== lru-cache@^11.0.0: - version "11.0.2" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.0.2.tgz#fbd8e7cf8211f5e7e5d91905c415a3f55755ca39" - integrity sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA== + version "11.1.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.1.0.tgz#afafb060607108132dbc1cf8ae661afb69486117" + integrity sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A== lru-cache@^5.1.1: version "5.1.1" @@ -7772,10 +7817,10 @@ statuses@~1.5.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -stream-chat@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.0.0.tgz#cb22dcb8b7f070c623a13b6b75b212d560534d6c" - integrity sha512-I4+/DEp7dP3WBgRmqHaLswL+Y2fyQkUWJhYBS5zx4bpu1cYM6WEir9HYjToDNuJjltqa/FFIEF/tMPWr7iTc0A== +stream-chat@^9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.2.0.tgz#f3109891ca27f17b6fd0aa6ebcf66be12df1f88c" + integrity sha512-inz3CA5tuqqSrla7qjRTCKs+coRKOYROWf0wEWYgbCu0tAUuiBTRtu1PJL1isEXIaPLiWi00BuRrBEIFon9Kng== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" @@ -8209,14 +8254,14 @@ typed-array-length@^1.0.7: possible-typed-array-names "^1.0.0" reflect.getprototypeof "^1.0.6" -typescript-eslint@^8.25.0: - version "8.25.0" - resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.25.0.tgz#73047c157cd70ee93cf2f9243f1599d21cf60239" - integrity sha512-TxRdQQLH4g7JkoFlYG3caW5v1S6kEkz8rqt80iQJZUYPq1zD1Ra7HfQBJJ88ABRaMvHAXnwRvRB4V+6sQ9xN5Q== +typescript-eslint@^8.29.0: + version "8.29.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.29.0.tgz#fc059b4c840889e5180dd822594eb46fa4619093" + integrity sha512-ep9rVd9B4kQsZ7ZnWCVxUE/xDLUUUsRzE0poAeNu+4CkFErLfuvPt/qtm2EpnSyfvsR0S6QzDFSrPCFBwf64fg== dependencies: - "@typescript-eslint/eslint-plugin" "8.25.0" - "@typescript-eslint/parser" "8.25.0" - "@typescript-eslint/utils" "8.25.0" + "@typescript-eslint/eslint-plugin" "8.29.0" + "@typescript-eslint/parser" "8.29.0" + "@typescript-eslint/utils" "8.29.0" typescript@5.8.2, typescript@^5.0.4: version "5.8.2"