From 38afef995f3925b97fcff5b8c6800fdfdd19e41a Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:51:10 +0100 Subject: [PATCH 1/5] fix: show the video duration when editing messages (#3972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket: https://linear.app/stream/issue/RN-523 ## ๐ŸŽฏ Goal Editing a message with a video showed no duration pill in the composer. Since the OpenAPI migration the duration was never sent with the message, so once the local file is gone there was nothing to show. ## ๐Ÿ›  Implementation details - The video pre-upload middleware now sends the picked file's duration along as `custom.duration`. - `VideoAttachmentUploadPreview` falls back to `custom.duration` when there is no local file. - Videos sent from v9 work too, since the v2 API returns their old top-level `duration` under `custom`. Videos sent from v10 before this fix have no duration stored and still show no pill. ## ๐Ÿงช Testing - Unit tests for the middleware and the edit preview. - Tested on the iOS simulator: picked, sent and edited a video, and the pill shows in the edit view. --- .../AttachmentUploadPreviewList.test.tsx | 24 +++++++++++++++++++ .../VideoAttachmentUploadPreview.tsx | 8 ++++++- .../middlewares/__tests__/attachments.test.ts | 23 ++++++++++++++++++ package/src/middlewares/attachments.ts | 10 +++++++- 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx index 0f1053a760..0556e70e2f 100644 --- a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx @@ -652,5 +652,29 @@ describe('AttachmentUploadPreviewList', () => { expect(screen.queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); }); }); + + it('shows the duration a sent video carries in custom.duration', async () => { + const custom = { duration: 125000, mime_type: 'video/mp4' }; + + act(() => { + channel.messageComposer.initState({ + composition: generateMessage({ + attachments: [ + generateVideoAttachmentBase({ + asset_url: 'https://video-attachment.mp4', + custom, + thumb_url: 'https://video-attachment-thumb.png', + }), + ], + }), + }); + }); + + renderComponent({ channel, client, props: {} }); + + await waitFor(() => { + expect(screen.getByText('2m')).toBeTruthy(); + }); + }); }); }); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx index 7a74540a12..77e862d119 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx @@ -29,7 +29,13 @@ export const VideoAttachmentUploadPreview = ({ // `localMetadata.file` is `File | FileReference`; only the RN `FileReference` carries a // `duration` (and a required `uri` to discriminate on), so read it through a guard. const localFile = attachment.localMetadata.file; - const duration = localFile && 'uri' in localFile ? localFile.duration : undefined; + const { custom } = attachment; + const duration = + localFile && 'uri' in localFile + ? localFile.duration + : custom && 'duration' in custom && typeof custom.duration === 'number' + ? custom.duration + : undefined; return previewUri ? ( <> diff --git a/package/src/middlewares/__tests__/attachments.test.ts b/package/src/middlewares/__tests__/attachments.test.ts index 395fd00635..51927cdf99 100644 --- a/package/src/middlewares/__tests__/attachments.test.ts +++ b/package/src/middlewares/__tests__/attachments.test.ts @@ -81,6 +81,29 @@ describe('setupVideoAttachmentPreviewMiddleware', () => { expect(state.attachment.localMetadata.previewUri).toBeUndefined(); }); + it('sends the picked file duration along as custom.duration', () => { + const { next } = runPrepare( + localVideoAttachment({ + custom: { mime_type: 'video/mp4' }, + localMetadata: { + file: { duration: 125000, name: 'clip.mp4', uri: 'file://local/clip.mp4' }, + id: 'clip', + uploadState: 'uploading', + }, + }), + ); + + const state = next.mock.calls[0][0] as { attachment: LocalAttachment }; + expect(state.attachment.custom).toEqual({ duration: 125000, mime_type: 'video/mp4' }); + }); + + it('leaves custom untouched when the picked file has no duration', () => { + const { next } = runPrepare(localVideoAttachment({ custom: { mime_type: 'video/mp4' } })); + + const state = next.mock.calls[0][0] as { attachment: LocalAttachment }; + expect(state.attachment.custom).toEqual({ mime_type: 'video/mp4' }); + }); + it('forwards anything that is not a local video', () => { const image = runPrepare( localVideoAttachment({ image_url: 'file://local/photo.jpg', type: 'image' }), diff --git a/package/src/middlewares/attachments.ts b/package/src/middlewares/attachments.ts index 41d5e7c1cb..00dea363a8 100644 --- a/package/src/middlewares/attachments.ts +++ b/package/src/middlewares/attachments.ts @@ -6,7 +6,9 @@ import { /** * Sets a local video's `localMetadata.previewUri` to its `thumb_url`, because the picked video - * file itself cannot be rendered as a preview (other attachments preview the file URI). + * file itself cannot be rendered as a preview (other attachments preview the file URI). Also sends + * the picked file's duration along as `custom.duration`, since the local file is gone once the + * message is sent (e.g. when editing it). */ const createVideoAttachmentPreviewMiddleware = (): AttachmentPreUploadMiddleware => ({ id: 'stream-io/message-composer-ui-middleware/video-attachment-preview', @@ -18,10 +20,16 @@ const createVideoAttachmentPreviewMiddleware = (): AttachmentPreUploadMiddleware return forward(); } + // Only the RN `FileReference` carries a `duration` (and a required `uri` to discriminate on). + const { file } = attachment.localMetadata; + const duration = file && 'uri' in file ? file.duration : undefined; + const custom = duration ? { ...attachment.custom, duration } : attachment.custom; + return next({ ...state, attachment: { ...attachment, + custom, localMetadata: { ...attachment.localMetadata, previewUri: attachment.thumb_url, From 3fadeac28f5103922a6696cea5008b66c84967c9 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Wed, 11 Nov 2026 11:37:17 +0000 Subject: [PATCH 2/5] ci(e2e): keep the emulator logcat in the nightly Android jobs (#3973) --- .../actions/android-sdk-packages/action.yml | 29 +++++++++++++++++++ .github/workflows/e2e-test-cron.yml | 10 ++++++- .github/workflows/e2e-test.yml | 4 +++ examples/E2ETestApp/e2e/robots/UserRobot.ts | 13 ++++----- 4 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 .github/actions/android-sdk-packages/action.yml diff --git a/.github/actions/android-sdk-packages/action.yml b/.github/actions/android-sdk-packages/action.yml new file mode 100644 index 0000000000..a65482a565 --- /dev/null +++ b/.github/actions/android-sdk-packages/action.yml @@ -0,0 +1,29 @@ +name: Android SDK packages +description: >- + Installs the SDK packages reactivecircus/android-emulator-runner would download, with retries. + The action downloads the emulator and the system image on every run and doesn't retry: a + corrupted download ("Error reading Zip content from a SeekableByteChannel") fails the job before + the emulator boots. sdkmanager skips packages that are installed and current, so the action's own + install becomes a no-op. +inputs: + api-level: + description: The system image API level, as passed to android-emulator-runner + required: true +runs: + using: composite + steps: + - name: Install the emulator and the system image + shell: bash + env: + API_LEVEL: ${{ inputs.api-level }} + run: | + sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + packages=(emulator platform-tools "platforms;android-$API_LEVEL" "system-images;android-$API_LEVEL;default;x86_64") + yes | "$sdkmanager" --licenses > /dev/null || true + for attempt in 1 2 3; do + "$sdkmanager" --install "${packages[@]}" --channel=0 > /dev/null && exit 0 + echo "::warning::sdkmanager failed (attempt $attempt of 3); retrying in 30 s" + sleep 30 + done + echo "::error::Could not install ${packages[*]}" + exit 1 diff --git a/.github/workflows/e2e-test-cron.yml b/.github/workflows/e2e-test-cron.yml index 8af4ead87a..bf6ce67f9b 100644 --- a/.github/workflows/e2e-test-cron.yml +++ b/.github/workflows/e2e-test-cron.yml @@ -352,6 +352,10 @@ jobs: working-directory: examples/SampleApp run: bundle exec fastlane allure_launch cron:true continue-on-error: true + # The emulator runner downloads these without retrying; a corrupted download fails the job. + - uses: ./.github/actions/android-sdk-packages + with: + api-level: ${{ matrix.api-level }} - name: Run e2e tests (Android emulator) id: tests uses: reactivecircus/android-emulator-runner@v2 @@ -362,7 +366,10 @@ jobs: emulator-options: ${{ env.ANDROID_EMULATOR_OPTIONS }} disable-animations: true working-directory: examples/SampleApp - script: bundle exec fastlane test_e2e platform:android device:att skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH" coverage:${{ matrix.coverage == true }} batch:${{ matrix.batch }} batch_count:${{ matrix.batch_count }} + # Logcat is written on the runner, so it survives the emulator dying mid-run (RN-513). + script: | + nohup "$ANDROID_HOME/platform-tools/adb" -s emulator-5554 logcat -b all -v threadtime > "$RUNNER_TEMP/logcat.txt" 2>&1 < /dev/null & + bundle exec fastlane test_e2e platform:android device:att skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH" coverage:${{ matrix.coverage == true }} batch:${{ matrix.batch }} batch_count:${{ matrix.batch_count }} # Also when tests failed: those keep their coverage from the previous map. Not when they never # ran: a batch without its failed-tests file keeps the whole previous map. @@ -407,6 +414,7 @@ jobs: examples/E2ETestApp/stream-chat-test-mock-server/logs examples/E2ETestApp/artifacts /tmp/android-runner + ${{ runner.temp }}/logcat.txt coverage_map: name: Build e2e coverage map diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 4a7af02577..398fe3e59b 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -386,6 +386,10 @@ jobs: with: name: e2e-selection path: examples/E2ETestApp + # The emulator runner downloads these without retrying; a corrupted download fails the job. + - uses: ./.github/actions/android-sdk-packages + with: + api-level: ${{ env.ANDROID_API_LEVEL }} - name: Run e2e tests (Android emulator) uses: reactivecircus/android-emulator-runner@v2 with: diff --git a/examples/E2ETestApp/e2e/robots/UserRobot.ts b/examples/E2ETestApp/e2e/robots/UserRobot.ts index 3f1a115e4c..26b206445f 100644 --- a/examples/E2ETestApp/e2e/robots/UserRobot.ts +++ b/examples/E2ETestApp/e2e/robots/UserRobot.ts @@ -57,9 +57,10 @@ const tapInContextMenu = async (selector: Detox.NativeMatcher) => { }; /** - * Long-presses a message until its menu opens. iOS runs without Detox's idle sync, so a long press - * that lands while the list is still settling after a scroll is cancelled by the movement, and the - * menu never opens. `target` is re-resolved on each attempt, since the frames may have moved. + * Long-presses a message until its menu opens. A long press that lands while the list is still + * settling after a scroll is cancelled by the movement, and the menu never opens: on iOS, which runs + * without Detox's idle sync, and now and then on Android too. `target` is re-resolved on each + * attempt, since the frames may have moved. */ const longPressUntilMenuOpens = async ( target: () => Promise<{ index: number; selector: Detox.NativeMatcher }>, @@ -68,11 +69,7 @@ const longPressUntilMenuOpens = async ( for (let attempt = 1; ; attempt++) { const { index, selector } = await target(); await waitToAppearAndLongPress(selector, { index }); - if ( - device.getPlatform() !== 'ios' || - attempt >= attempts || - (await waitForExistence(ContextMenu.backdrop, { timeout: 3000 })) - ) { + if (attempt >= attempts || (await waitForExistence(ContextMenu.backdrop, { timeout: 3000 }))) { return; } } From a932e5d00404c2390b8d9acc991ba1db0ce35e62 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:59:18 +0100 Subject: [PATCH 3/5] fix: render the date separator above the unread indicator (#3980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket: https://linear.app/stream/issue/RN-526 ## ๐ŸŽฏ Goal When the first unread message starts a new day, the unread indicator showed up above its date separator. The date separator now comes first. ## ๐Ÿ›  Implementation details The indicator was rendered at the bottom of the last read message's row, and the date separator at the top of the next row. It now renders in the first unread message's row, between the date separator and the message. The selector that finds the boundary checks the previous message instead of the next one. The rule itself is unchanged. ## ๐ŸŽจ UI Changes
iOS
Before After
## ๐Ÿงช Testing Open a channel where the first unread message is on a different day than the last read one. The date separator should be above "N unread messages", with the same spacing as before. Updated the two `MessageList` tests that expected the indicator on the last read row and added one for the order. ## โ˜‘๏ธ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [x] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android --- .../MessageItemView/MessageWrapper.tsx | 71 ++++++++----------- .../__tests__/MessageList.test.tsx | 59 ++++++++++++++- 2 files changed, 87 insertions(+), 43 deletions(-) diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx index f9d9921325..f5585d280b 100644 --- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx +++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx @@ -56,63 +56,51 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW // `created_at` is a numeric wire timestamp, directly comparable (`new Date(ns)` yields NaN). const createdAtTimestamp = message.created_at; const messageId = message.id; - const nextMessageId = nextMessage?.id; - const nextMessageIsOwn = nextMessage?.user?.id === client.userId; - const nextMessageCreatedAt = nextMessage?.created_at ?? undefined; + const isOwnMessage = message.user?.id === client.userId; + const previousMessageId = previousMessage?.id; + const previousMessageCreatedAt = previousMessage?.created_at ?? undefined; const isThreadList = !!messagePaginator.parentMessageId; - // The unread separator goes on the row above the first unread message from another user. It is - // located per message inside the selector, so the flag stays `false` for every other row and a - // mark-read only re-renders the boundary rows whose flag flips, not the whole list. + // The unread separator sits on the first unread row, between its date separator and the message. + // Resolved per row in the selector, so only the boundary rows re-render when it moves. const showUnreadSeparatorSelector = useCallback( (snapshot: UnreadSnapshotState) => { let showUnreadSeparator: boolean; - if (isThreadList) { + if (isThreadList || !previousMessageId) { showUnreadSeparator = false; } else if (snapshot.firstUnreadMessageId) { - // Frozen boundary (channel open or mark-unread): anchor to the known first unread ID. - showUnreadSeparator = nextMessageId === snapshot.firstUnreadMessageId; + // Channel open and mark-unread freeze the boundary at a known first unread ID. + showUnreadSeparator = messageId === snapshot.firstUnreadMessageId; } else if (snapshot.unreadCount) { - // Live boundary: the separator goes on the last read row (created at or before - // `lastReadAt`) whose newer neighbor is the first unread from another user. Requiring this - // row to be read keeps it to one separator when own messages are interleaved among the - // unreads, as an own message sent after the boundary is not read. + // The live boundary is the first unread from another user whose previous message is read. + // Own messages among the unreads are not read, so this matches only once. const lastReadAt = snapshot.lastReadAt ?? 0; - const nextIsUnreadFromOther = - !!nextMessageId && - !nextMessageIsOwn && - nextMessageCreatedAt !== undefined && - nextMessageCreatedAt > lastReadAt; - const thisIsRead = - typeof createdAtTimestamp === 'number' && createdAtTimestamp <= lastReadAt; - // Own messages sent elsewhere after the boundary leave no read row above the first unread, - // so the rule above never fires. Anchor on the last read message then. - const isLastReadBeforeOwnUnread = - thisIsRead && - messageId === snapshot.lastReadMessageId && - nextMessageIsOwn && - nextMessageCreatedAt !== undefined && - nextMessageCreatedAt > lastReadAt; - showUnreadSeparator = (nextIsUnreadFromOther && thisIsRead) || isLastReadBeforeOwnUnread; + const thisIsUnread = + typeof createdAtTimestamp === 'number' && createdAtTimestamp > lastReadAt; + const previousIsRead = + previousMessageCreatedAt !== undefined && previousMessageCreatedAt <= lastReadAt; + // When own messages follow the last read one, anchor on the last read message instead. + const isOwnUnreadAfterLastRead = + isOwnMessage && previousMessageId === snapshot.lastReadMessageId; + showUnreadSeparator = + thisIsUnread && previousIsRead && (!isOwnMessage || isOwnUnreadAfterLastRead); } else { showUnreadSeparator = false; } return { showUnreadSeparator, - // Only the boundary row needs the unread count (for its inline indicator). Gating it on - // `showUnreadSeparator` keeps it `undefined` elsewhere, so a changing channel-wide count - // does not re-render the whole list. + // Only the boundary row reads the count, so count changes don't re-render the other rows. unreadCount: showUnreadSeparator ? snapshot.unreadCount : undefined, }; }, [ createdAtTimestamp, + isOwnMessage, isThreadList, messageId, - nextMessageCreatedAt, - nextMessageId, - nextMessageIsOwn, + previousMessageCreatedAt, + previousMessageId, ], ); const { showUnreadSeparator, unreadCount } = useStateStore( @@ -135,6 +123,11 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW ) : null; + const renderUnreadIndicator = showUnreadUnderlay ? ( + + + + ) : null; const renderMessage = ( {allowDateSeparatorForSystemMessages ? renderDateSeperator : null} + {renderUnreadIndicator} ) : wrapMessageInTheme ? ( {renderDateSeperator} + {renderUnreadIndicator} {renderMessage} ) : ( <> {renderDateSeperator} + {renderUnreadIndicator} {renderMessage} )} - {showUnreadUnderlay && ( - - - - )} ); }); diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index 406127e83f..4ff9472703 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -564,7 +564,7 @@ describe('MessageList', () => { ); }; - it('should render the InlineUnreadIndicator after the last read message when own messages sent elsewhere follow it', async () => { + it('should render the InlineUnreadIndicator above the first unread message when own messages sent elsewhere follow the last read one', async () => { const { getAllByLabelText, getByTestId } = await renderWithUnreadAfterOwn([ 'own', 'other', @@ -574,7 +574,7 @@ describe('MessageList', () => { await waitFor(() => { expect(getAllByLabelText('Inline unread indicator')).toHaveLength(1); expect( - within(getByTestId('message-list-item-5')).queryByLabelText('Inline unread indicator'), + within(getByTestId('message-list-item-6')).queryByLabelText('Inline unread indicator'), ).toBeTruthy(); }); }); @@ -589,11 +589,64 @@ describe('MessageList', () => { await waitFor(() => { expect(getAllByLabelText('Inline unread indicator')).toHaveLength(1); expect( - within(getByTestId('message-list-item-5')).queryByLabelText('Inline unread indicator'), + within(getByTestId('message-list-item-6')).queryByLabelText('Inline unread indicator'), ).toBeTruthy(); }); }); + it('should render the InlineUnreadIndicator below the date separator of the first unread message', async () => { + const user1 = generateUser(); + const user2 = generateUser(); + // Messages 0-5 (read) on one day, 6-9 (unread) on the next, so the first unread row carries a + // date separator. + const dayOne = new Date('2020-01-01T00:00:00.000Z').getTime(); + const dayTwo = new Date('2020-01-02T00:00:00.000Z').getTime(); + const messages = Array.from({ length: 10 }, (_, i) => + generateMessage({ + id: `${i}`, + text: `message-${i}`, + timestamp: new Date((i < 6 ? dayOne : dayTwo) + i * 1000), + user: user2, + }), + ); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: user1 }), generateMember({ user: user2 })], + messages, + read: [ + { + user: user1, + last_read: msToNs(dayOne + 5000), + last_read_message_id: '5', + unread_messages: 4, + }, + ] as unknown as NonNullable[0]>['read'], + }); + + const chatClient = await getTestClientWithUser({ id: user1.id } as UserResponse); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + const { getAllByLabelText, getByTestId } = render( + + + + + + + , + ); + + await waitFor(() => { + expect(getAllByLabelText('Inline unread indicator')).toHaveLength(1); + expect( + within(getByTestId('message-list-item-6')) + .queryAllByTestId(/^(date-separator|inline-unread-indicator|message-wrapper)$/) + .map((node) => node.props.testID), + ).toEqual(['date-separator', 'inline-unread-indicator', 'message-wrapper']); + }); + }); + it("should not render the UnreadMessagesIndicator when there's no unread messages", async () => { const user1 = generateUser(); const user2 = generateUser(); From d66c30b6bec67eec7fecf0a9ce10af3bb730abf2 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:59:18 +0100 Subject: [PATCH 4/5] fix: keep the poll max votes stepper buttons in place (#3982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket: https://linear.app/stream/issue/RN-527 Stacked on #3980. ## ๐ŸŽฏ Goal In poll creation, the + button of the max votes stepper moved when the value went from 9 to 10, because the number input grew with its text. ## ๐Ÿ›  Implementation details The input gets a 32pt minimum width, no horizontal padding and centered text, so it stays the same width from 2 to 10. The row gap goes from `spacingXxs` to `spacingXs` to match the design. Vertical padding is unchanged. ## ๐ŸŽจ UI Changes
iOS
Before After
## ๐Ÿงช Testing Create a poll, turn on "Multiple votes" and "Limit votes per person", and step between 9 and 10. The + button should stay in place. ## โ˜‘๏ธ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android --- .../components/Poll/components/MultipleVotesSettings.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/package/src/components/Poll/components/MultipleVotesSettings.tsx b/package/src/components/Poll/components/MultipleVotesSettings.tsx index dab00f2030..d86851dcfc 100644 --- a/package/src/components/Poll/components/MultipleVotesSettings.tsx +++ b/package/src/components/Poll/components/MultipleVotesSettings.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { I18nManager, Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; +import { Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; import Animated, { LinearTransition, StretchInY, StretchOutY } from 'react-native-reanimated'; @@ -238,14 +238,15 @@ const useStyles = () => { row: { alignItems: 'center', flexDirection: 'row', - gap: primitives.spacingXxs, + gap: primitives.spacingXs, }, input: { color: semantics.textPrimary, fontSize: primitives.typographyFontSizeMd, - paddingHorizontal: primitives.spacingSm, + minWidth: 32, + paddingHorizontal: 0, paddingVertical: primitives.spacingMd, - textAlign: I18nManager.isRTL ? 'right' : 'left', + textAlign: 'center', }, buttonEnabled: { borderColor: semantics.borderCoreOnSurface, From b74e7c809c2fc0f46e348f62ddafefe5860722d8 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Thu, 12 Nov 2026 12:09:06 +0000 Subject: [PATCH 5/5] test(e2e): fix the nightly iOS 17 and Android 14 failures (#3981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket: https://linear.app/stream/issue/RN-520 ## ๐ŸŽฏ Goal Fix the failures in nightly [43138494994](https://github.com/GetStream/stream-chat-react-native/actions/runs/43138494994). Three jobs failed, for four separate reasons. None of them is a regression in the SDK. ## ๐Ÿ›  Implementation details **iOS 17.5: `test_messageListScrollsDown_whenMessageListIsScrolledDown_andUserReceivesNewMessage`** (fails in every nightly) - When a message arrives while the list is at the bottom, the scroll-to-bottom button shows for a moment while the list scrolls to it, then hides. The "button is hidden" assert could pass before the button appeared, and its single follow-up check then caught it on screen. - `assertVisibility(false)` now waits until the element has stayed hidden for 1 s. **Android API 34: "The app has unexpectedly disconnected from Detox server"** - The app crashes natively right after launch (`SIGSEGV` in `android_unsafe_frame_pointer_chase`). Android 14 turns on GWP-ASan memory sampling for every app. On the x86_64 emulator, GWP-ASan crashes while recording the stack of a Hermes memory free. Both crashes had the same fault address. - The E2E app's manifest now sets `android:gwpAsanMode="never"`. **Android API 34: "MockServer did not start within 5000ms"** - Both failures, an hour apart, got the same port (41215) from the driver, and nothing ever answered on it. - The harness now picks a port itself that is free on both IPv4 and IPv6. `localhost` resolves to `::1` on CI, as the mock server logs show. It passes the port to `/start`, and retries the start on a new port up to 3 times. **Android API 36 (coverage batch 1): Polls tests** - The keyboard was still open from the composer and covered the option field that appears below the filled one, so that field never counted as visible. - On Android, `createPoll` now taps the "Questions" label before each option field to hide the keyboard. Not addressed: `test_userLeavesGroupChannel` failed on an empty channel actions sheet, which is the known SDK bug RN-489. ## ๐Ÿงช Testing - iOS 17.5, iPhone 15 Pro, locally: the failing test failed 5/5 before the fix and passed 4/4 after it. - Android (Pixel API 37 emulator, release build with the new manifest): `PollsTests` 7/7. The built APK contains `gwpAsanMode=0`. The API 34 crash is random, so the nightly has to confirm that fix. - eslint, prettier and `tsc -p e2e` are clean. ## โ˜‘๏ธ Checklist - [x] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [x] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android --- .../android/app/src/main/AndroidManifest.xml | 4 ++ .../E2ETestApp/e2e/mock-server/MockServer.ts | 50 ++++++++++++++++--- examples/E2ETestApp/e2e/robots/UserRobot.ts | 6 +++ .../e2e/robots/UserRobotMessageListAsserts.ts | 26 ++++++++++ 4 files changed, 79 insertions(+), 7 deletions(-) diff --git a/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml b/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml index dcd82215a6..32f558c6ef 100644 --- a/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml +++ b/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,9 @@ + }; +/** A port free on the host's IPv4 and IPv6 loopback: `::` listens on both (dual stack). */ +const freePort = () => + new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen({ host: '::', port: 0 }, () => { + const address = server.address(); + server.close(() => + address && typeof address === 'object' + ? resolve(address.port) + : reject(new Error('No free port')), + ); + }); + }); + /** * Client for `stream-chat-test-mock-server`. The driver (`driver.rb`) spawns one mock server per * test on its own port; `start` asks it for one and `stop` shuts it down. @@ -27,13 +44,32 @@ export class MockServer { return this.url.replace('localhost', host); } - /** Never retried: every call to the driver's `/start` spawns a new server on a new port. */ - async start(testName: string): Promise { - const response = await this.execute( - `${MockServer.driverUrl}/start/${encodeURIComponent(testName)}?api_version=v2×tamps=ns`, - ); - this.port = (await response.text()).trim(); - await this.waitUntilReady(); + /** + * Every call to the driver's `/start` spawns a new server, so a start is only retried after the + * server failed to answer, and always on a new port. + * + * The port is picked here rather than by the driver: on CI the driver's probe twice handed out a + * port the server then never answered on (41215, an hour apart, nightly 43138494994). This probe + * also checks IPv6, which `localhost` may resolve to. + */ + async start(testName: string, attempts = 3): Promise { + for (let attempt = 1; ; attempt++) { + const port = await freePort(); + const response = await this.execute( + `${MockServer.driverUrl}/start/${encodeURIComponent(testName)}` + + `?api_version=v2×tamps=ns&port=${port}`, + ); + this.port = (await response.text()).trim(); + try { + await this.waitUntilReady(); + return; + } catch (error) { + await this.stop(); + if (attempt >= attempts) { + throw error; + } + } + } } async stop(): Promise { diff --git a/examples/E2ETestApp/e2e/robots/UserRobot.ts b/examples/E2ETestApp/e2e/robots/UserRobot.ts index 26b206445f..9eea84db68 100644 --- a/examples/E2ETestApp/e2e/robots/UserRobot.ts +++ b/examples/E2ETestApp/e2e/robots/UserRobot.ts @@ -350,6 +350,12 @@ export class UserRobot extends Robot { .perform(async () => { await (await waitToAppearAndClick(CreatePoll.fields, { index: 0 })).replaceText(question); for (const [index, option] of options.entries()) { + if (device.getPlatform() === 'android') { + // The keyboard, often already open from the composer, covers the option field that + // appears below the last filled one, so it never counts as visible. A tap outside + // the fields hides it (see below). + await element(CreatePoll.questionLabel).tap(); + } await ( await waitToAppearAndClick(CreatePoll.fields, { index: index + 1 }) ).replaceText(option); diff --git a/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts index 278e33824c..aece96ded9 100644 --- a/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts +++ b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts @@ -104,6 +104,31 @@ declare module './UserRobot' { } } +/** + * Waits until `selector` has been hidden for `stableMs` in a row. A single check can land before an + * element briefly shows: on iOS 17 the scroll-to-bottom button flashes while the list catches up + * with a message that just arrived, so "hidden" read once was a coin toss (nightly 43138494994). + */ +const waitUntilStaysHidden = async ( + selector: Detox.NativeMatcher, + timeout: number, + stableMs = 1000, +) => { + const deadline = Date.now() + timeout; + let hiddenSince: number | undefined; + while (Date.now() < deadline) { + if (await isDisplayed(selector, 0)) { + hiddenSince = undefined; + } else { + hiddenSince ??= Date.now(); + if (Date.now() - hiddenSince >= stableMs) { + return; + } + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } +}; + export const assertVisibility = async ( selector: Detox.NativeMatcher, shouldBeDisplayed: boolean, @@ -123,6 +148,7 @@ export const assertVisibility = async ( } } else { await waitToDisappear(selector, { index: 0, timeout }); + await waitUntilStaysHidden(selector, timeout); jestExpect(await isDisplayed(selector, 0)).toBe(false); } };