diff --git a/.github/workflows/sample-distribution.yml b/.github/workflows/sample-distribution.yml index 363f9efd8e..67097ad844 100644 --- a/.github/workflows/sample-distribution.yml +++ b/.github/workflows/sample-distribution.yml @@ -80,7 +80,7 @@ jobs: - uses: actions/setup-java@v5 with: distribution: 'zulu' - java-version: '17' + java-version: '21' check-latest: true - name: Setup Android SDK uses: amyu/setup-android@v5 diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index db1c4b4629..5eb853cec1 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -18,7 +18,7 @@ "@react-native-community/netinfo": "12.0.1", "@react-native-firebase/app": "^24.0.0", "@react-native-firebase/messaging": "^24.0.0", - "@shopify/flash-list": "^2.3.2", + "@shopify/flash-list": "^2.3.3", "expo": "^57.0.7", "expo-asset": "~57.0.6", "expo-audio": "patch:expo-audio@npm%3A57.0.2#~/.yarn/patches/expo-audio-npm-57.0.2-7a40cb0b0a.patch", @@ -51,7 +51,7 @@ "react-native-teleport": "^1.1.12", "react-native-web": "^0.21.2", "react-native-worklets": "0.11.1", - "stream-chat": "^9.51.0", + "stream-chat": "^9.54.0", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/android/gradle/gradle-daemon-jvm.properties b/examples/SampleApp/android/gradle/gradle-daemon-jvm.properties deleted file mode 100644 index 6c1139ec06..0000000000 --- a/examples/SampleApp/android/gradle/gradle-daemon-jvm.properties +++ /dev/null @@ -1,12 +0,0 @@ -#This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect -toolchainVersion=21 diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index d223b78687..4de2187ad1 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -2640,7 +2640,7 @@ PODS: - SDWebImage/Core (~> 5.17) - SocketRocket (0.7.1): - ReactNativeDependencies - - stream-chat-react-native (9.8.0): + - stream-chat-react-native (9.9.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3223,7 +3223,7 @@ SPEC CHECKSUMS: SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 SocketRocket: 37aec555668fb852ec12e3c0de59a86ca58f0871 - stream-chat-react-native: de5922b681460d780a4d47c58b2b024622954aa7 + stream-chat-react-native: bb3a2c7f8949f4ebb735d83f549f43ae37c3ae7b Teleport: c42718cf1f14dd53b250c0fa29b9235a42f861ec Yoga: d1c536142c5ff8ec8cd856ab2a7c227a1d875c8e diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 796e919724..4ccf1e6633 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -43,7 +43,7 @@ "@react-navigation/drawer": "^7.13.2", "@react-navigation/native": "^7.3.11", "@react-navigation/native-stack": "^7.18.3", - "@shopify/flash-list": "^2.3.2", + "@shopify/flash-list": "^2.3.3", "dayjs": "1.11.13", "emoji-mart": "^5.6.0", "lodash.mergewith": "^4.6.2", @@ -64,7 +64,7 @@ "react-native-teleport": "^1.1.12", "react-native-video": "^6.19.2", "react-native-worklets": "^0.12.1", - "stream-chat": "^9.51.0", + "stream-chat": "^9.54.0", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package/package.json b/package/package.json index bbf7e55b30..e6a75f1c56 100644 --- a/package/package.json +++ b/package/package.json @@ -78,7 +78,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.51.0", + "stream-chat": "^9.54.0", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { @@ -115,7 +115,7 @@ "@react-native-community/netinfo": "^12.0.1", "@react-native/babel-preset": "0.87.0", "@react-native/jest-preset": "0.87.0", - "@shopify/flash-list": "^2.3.2", + "@shopify/flash-list": "^2.3.3", "@stream-io/typescript-config": "workspace:^", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-native": "13.2.0", diff --git a/package/src/components/Poll/hooks/usePollState.ts b/package/src/components/Poll/hooks/usePollState.ts index 78d7b2ff8c..26fd646569 100644 --- a/package/src/components/Poll/hooks/usePollState.ts +++ b/package/src/components/Poll/hooks/usePollState.ts @@ -39,7 +39,7 @@ export type UsePollStateSelectorReturnType = { export type UsePollStateReturnType = UsePollStateSelectorReturnType & { addComment: (answerText: string) => Promise; addOption: (optionText: string) => Promise; - endVote: () => Promise; + endVote: () => Promise<(APIResponse & UpdatePollAPIResponse) | undefined>; }; const selector = (nextValue: PollState): UsePollStateSelectorReturnType => ({ diff --git a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx index 96e3d523db..d5b1a2029e 100644 --- a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx +++ b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useRef } from 'react'; import { + BackHandler, I18nManager, Platform, Pressable, @@ -101,6 +102,19 @@ export const MessageOverlayHostLayer = () => { } }, [isActive, overlayOpenHint, announce]); + useEffect(() => { + if (!isActive) { + return; + } + + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + closeOverlay(); + return true; + }); + + return () => subscription.remove(); + }, [isActive]); + // When orientation changes, we dismiss the overlay. While it is possible // (and I have a working PoC of) a repositioning solution, it is pretty // complex and probably bug prone. This is anyway how the iOS native contextual diff --git a/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx b/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx index 8d0cb06efe..299b17f8a9 100644 --- a/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx +++ b/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StyleSheet, Text } from 'react-native'; +import { BackHandler, StyleSheet, Text } from 'react-native'; import Animated from 'react-native-reanimated'; @@ -179,6 +179,63 @@ describe('MessageOverlayHostLayer', () => { expect(overlayStore.getLatestValue().closing).toBe(true); }); + it('closes the overlay on Android back press instead of letting navigation handle it', () => { + type BackPressListener = Parameters[1]; + const listeners: BackPressListener[] = []; + const addEventListenerSpy = jest + .spyOn(BackHandler, 'addEventListener') + .mockImplementation((_eventName, handler) => { + listeners.push(handler); + return { + remove: () => { + listeners.splice(listeners.indexOf(handler), 1); + }, + }; + }); + const pressBack = () => + listeners[listeners.length - 1]?.({ timeStamp: Date.now(), type: 'hardwareBackPress' }); + + render( + + + , + ); + + // Nothing is registered while the overlay is closed, so back goes to navigation. + expect(listeners).toHaveLength(0); + + act(() => { + openOverlay('message-1'); + }); + + let handled: boolean | null | undefined; + act(() => { + handled = pressBack(); + }); + flushAnimationFrameQueue(); + + expect(handled).toBe(true); + expect(overlayStore.getLatestValue().closing).toBe(true); + + // Still consumed while the close animation runs, so the screen doesn't pop underneath. + act(() => { + handled = pressBack(); + }); + flushAnimationFrameQueue(); + expect(handled).toBe(true); + expect(overlayStore.getLatestValue().closing).toBe(true); + + act(() => { + finalizeCloseOverlay(); + }); + + expect(listeners).toHaveLength(0); + // One registration for the whole open -> closing -> closed cycle. + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + + addEventListenerSpy.mockRestore(); + }); + it('positions and translates the top, message, and bottom hosts using the registered rects', () => { const renderTree = () => ( diff --git a/package/src/state-store/__tests__/message-overlay-store.test.tsx b/package/src/state-store/__tests__/message-overlay-store.test.tsx index b18aef459a..cdd8c903eb 100644 --- a/package/src/state-store/__tests__/message-overlay-store.test.tsx +++ b/package/src/state-store/__tests__/message-overlay-store.test.tsx @@ -9,6 +9,7 @@ import { finalizeCloseOverlay, openOverlay, overlayStore, + scheduleActionOnClose, setClosingPortalLayout, useClosingPortalHostBlacklist, useClosingPortalHostBlacklistState, @@ -28,6 +29,14 @@ const flushAnimationFrameQueue = () => { }); }; +// Runs `fn` and lets the async close-action flush (`await action()` per queued action) settle. +const actAndFlushPromises = async (fn: () => void) => { + await act(async () => { + fn(); + await Promise.resolve(); + }); +}; + const BlacklistRegistrar = ({ enabled = true, hostNames, @@ -125,6 +134,77 @@ describe('message overlay store portal hooks', () => { second.unmount(); }); + it('keeps running queued actions when one of them fails and does not rerun it on the next close', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const failingAction = jest.fn(() => Promise.reject(new Error('action failed'))); + const throwingAction = jest.fn(() => { + throw new Error('sync action failed'); + }); + const followingAction = jest.fn(); + + act(() => { + openOverlay('message-1'); + }); + scheduleActionOnClose(failingAction); + scheduleActionOnClose(throwingAction); + scheduleActionOnClose(followingAction); + + await actAndFlushPromises(finalizeCloseOverlay); + + expect(failingAction).toHaveBeenCalledTimes(1); + expect(throwingAction).toHaveBeenCalledTimes(1); + expect(followingAction).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledTimes(2); + + const nextAction = jest.fn(); + + act(() => { + openOverlay('message-2'); + }); + scheduleActionOnClose(nextAction); + + await actAndFlushPromises(finalizeCloseOverlay); + + expect(nextAction).toHaveBeenCalledTimes(1); + expect(failingAction).toHaveBeenCalledTimes(1); + expect(throwingAction).toHaveBeenCalledTimes(1); + + consoleWarnSpy.mockRestore(); + }); + + it('defers actions scheduled during a flush to the next close instead of running them twice', async () => { + let resolveSlowAction: () => void = () => {}; + const slowAction = jest.fn( + () => + new Promise((resolve) => { + resolveSlowAction = resolve; + }), + ); + const laterAction = jest.fn(); + + act(() => { + openOverlay('message-1'); + }); + scheduleActionOnClose(slowAction); + + await actAndFlushPromises(finalizeCloseOverlay); + expect(slowAction).toHaveBeenCalledTimes(1); + + // The overlay reopens and an action is scheduled while the first flush is still awaiting. + act(() => { + openOverlay('message-2'); + }); + scheduleActionOnClose(laterAction); + + await actAndFlushPromises(resolveSlowAction); + expect(laterAction).not.toHaveBeenCalled(); + + await actAndFlushPromises(finalizeCloseOverlay); + + expect(laterAction).toHaveBeenCalledTimes(1); + expect(slowAction).toHaveBeenCalledTimes(1); + }); + it('does not enter closing when closeOverlay is called without an active overlay id', () => { act(() => { closeOverlay(); diff --git a/package/src/state-store/message-overlay-store.ts b/package/src/state-store/message-overlay-store.ts index 9fcd969efc..4334fbb421 100644 --- a/package/src/state-store/message-overlay-store.ts +++ b/package/src/state-store/message-overlay-store.ts @@ -216,12 +216,17 @@ const actionQueueSelector = (nextState: OverlayState) => ({ active: !!nextState. // integrations causing UI issues. overlayStore.subscribeWithSelector(actionQueueSelector, async ({ active }) => { if (!active) { + const actions = actionQueue; + actionQueue = []; + // flush the queue - for (const action of actionQueue) { - await action(); + for (const action of actions) { + try { + await action(); + } catch (error) { + console.warn('A message overlay action failed after the overlay closed:', error); + } } - - actionQueue = []; } }); diff --git a/yarn.lock b/yarn.lock index 42801841f4..fd910f97f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5951,14 +5951,14 @@ __metadata: languageName: node linkType: hard -"@shopify/flash-list@npm:^2.3.2": - version: 2.3.2 - resolution: "@shopify/flash-list@npm:2.3.2" +"@shopify/flash-list@npm:^2.3.3": + version: 2.3.3 + resolution: "@shopify/flash-list@npm:2.3.3" peerDependencies: "@babel/runtime": "*" react: "*" react-native: "*" - checksum: 10c0/3e58913506b1dc7f02dd0696c478fd963ede413d9d7121705a08ff505d5c21e611813ce9370068238b8983ff582ed7b8ee94f68e94f99a1ac8f600c01fff118e + checksum: 10c0/c94af22f8b034d9f24e8ea3628ac20597cdb00f8c250d24a8e7adb86bc2a41cf10234c2fd87315bf7a34211db87d7eba40bddda610847cb6afedd2d6a1a3ffe5 languageName: node linkType: hard @@ -6971,7 +6971,7 @@ __metadata: "@react-native-firebase/messaging": "npm:^24.0.0" "@rnx-kit/metro-config": "npm:^2.1.2" "@rnx-kit/metro-resolver-symlinks": "npm:^0.2.6" - "@shopify/flash-list": "npm:^2.3.2" + "@shopify/flash-list": "npm:^2.3.3" "@stream-io/typescript-config": "workspace:^" "@types/react": "npm:~19.2.10" expo: "npm:^57.0.7" @@ -7006,7 +7006,7 @@ __metadata: react-native-teleport: "npm:^1.1.12" react-native-web: "npm:^0.21.2" react-native-worklets: "npm:0.11.1" - stream-chat: "npm:^9.51.0" + stream-chat: "npm:^9.54.0" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -18387,7 +18387,7 @@ __metadata: "@react-navigation/native": "npm:^7.3.11" "@react-navigation/native-stack": "npm:^7.18.3" "@rnx-kit/metro-config": "npm:^2.1.0" - "@shopify/flash-list": "npm:^2.3.2" + "@shopify/flash-list": "npm:^2.3.3" "@stream-io/typescript-config": "workspace:^" "@types/lodash.mergewith": "npm:^4.6.9" "@types/react": "npm:^19.2.0" @@ -18412,7 +18412,7 @@ __metadata: react-native-teleport: "npm:^1.1.12" react-native-video: "npm:^6.19.2" react-native-worklets: "npm:^0.12.1" - stream-chat: "npm:^9.51.0" + stream-chat: "npm:^9.54.0" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -19119,7 +19119,7 @@ __metadata: "@react-native-community/netinfo": "npm:^12.0.1" "@react-native/babel-preset": "npm:0.87.0" "@react-native/jest-preset": "npm:0.87.0" - "@shopify/flash-list": "npm:^2.3.2" + "@shopify/flash-list": "npm:^2.3.3" "@stream-io/typescript-config": "workspace:^" "@testing-library/jest-native": "npm:^5.4.3" "@testing-library/react-native": "npm:13.2.0" @@ -19164,7 +19164,7 @@ __metadata: react-native-worklets: "npm:^0.12.1" react-test-renderer: "npm:19.2.3" rimraf: "npm:^6.0.1" - stream-chat: "npm:^9.51.0" + stream-chat: "npm:^9.54.0" typescript: "npm:6.0.3" use-sync-external-store: "npm:^1.5.0" uuid: "npm:^11.1.0" @@ -19238,9 +19238,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.51.0": - version: 9.51.0 - resolution: "stream-chat@npm:9.51.0" +"stream-chat@npm:^9.54.0": + version: 9.54.0 + resolution: "stream-chat@npm:9.54.0" dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -19256,7 +19256,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/a2888b1dad9496f8ba35e5afb44f88a6ce6b64785780b0244eba349ee10c52594f43c08a16a23924b14640a50849ce835773f016435725378bf0bbcfe0d636ed + checksum: 10c0/f8c02372ffa672e759970223811667b8263bbded048a85adbdd6c3ed60a16f72c882d9700538b94a3148a8e4bfa259eafae5effdc0d8e7ffd2525e61f27a190e languageName: node linkType: hard