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/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 3f1a115e4c..9eea84db68 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;
}
}
@@ -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);
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);
}
};
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/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/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();
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,
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,