Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/actions/android-sdk-packages/action.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 9 additions & 1 deletion .github/workflows/e2e-test-cron.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions examples/E2ETestApp/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

<!-- gwpAsanMode: Android 14 samples allocations with GWP-ASan in every app, and on the x86_64
emulator its stack recorder (android_unsafe_frame_pointer_chase) segfaults on a Hermes
free right after launch, which Detox reports as "The app has unexpectedly disconnected". -->
<application
android:name=".MainApplication"
android:label="@string/app_name"
Expand All @@ -15,6 +18,7 @@
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:networkSecurityConfig="@xml/network_security_config"
android:gwpAsanMode="never"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
Expand Down
50 changes: 43 additions & 7 deletions examples/E2ETestApp/e2e/mock-server/MockServer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
import { createServer } from 'net';

declare const process: { env: Record<string, string | undefined> };

/** A port free on the host's IPv4 and IPv6 loopback: `::` listens on both (dual stack). */
const freePort = () =>
new Promise<number>((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.
Expand Down Expand Up @@ -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<void> {
const response = await this.execute(
`${MockServer.driverUrl}/start/${encodeURIComponent(testName)}?api_version=v2&timestamps=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<void> {
for (let attempt = 1; ; attempt++) {
const port = await freePort();
const response = await this.execute(
`${MockServer.driverUrl}/start/${encodeURIComponent(testName)}` +
`?api_version=v2&timestamps=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<void> {
Expand Down
19 changes: 11 additions & 8 deletions examples/E2ETestApp/e2e/robots/UserRobot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>,
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -353,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);
Expand Down
26 changes: 26 additions & 0 deletions examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
};
Expand Down
71 changes: 31 additions & 40 deletions package/src/components/Message/MessageItemView/MessageWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -135,6 +123,11 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW
<InlineDateSeparator date={dateSeparatorDate} />
</View>
) : null;
const renderUnreadIndicator = showUnreadUnderlay ? (
<View style={styles.unreadUnderlayContainer}>
<InlineUnreadIndicator unreadCount={unreadCount} />
</View>
) : null;

const renderMessage = (
<Message
Expand All @@ -154,24 +147,22 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW
{message.type === 'system' ? (
<>
{allowDateSeparatorForSystemMessages ? renderDateSeperator : null}
{renderUnreadIndicator}
<MessageSystem message={message} style={messageContainer} />
</>
) : wrapMessageInTheme ? (
<ThemeProvider mergedStyle={modifiedTheme}>
{renderDateSeperator}
{renderUnreadIndicator}
{renderMessage}
</ThemeProvider>
) : (
<>
{renderDateSeperator}
{renderUnreadIndicator}
{renderMessage}
</>
)}
{showUnreadUnderlay && (
<View style={styles.unreadUnderlayContainer}>
<InlineUnreadIndicator unreadCount={unreadCount} />
</View>
)}
</View>
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
});
Loading
Loading