diff --git a/ai-docs/ai-migration-v9-to-v10.md b/ai-docs/ai-migration-v9-to-v10.md index f3f642a354..959a09e155 100644 --- a/ai-docs/ai-migration-v9-to-v10.md +++ b/ai-docs/ai-migration-v9-to-v10.md @@ -143,7 +143,11 @@ means changed. Details in the linked section. | `useChannelContext().markRead()` | `useMarkRead(channel)()` — or `channel.markRead()` | §5 | | `` | `client.config.set({ channel: { requestHandlers: { markReadRequest } } })` | §13.1 | | `` | `…{ requestHandlers: { updateMessageRequest } }` | §13.1 | +| `` | `…{ requestHandlers: { sendMessageRequest } }` (retry: `retrySendMessageRequest`) | §13.1 | | `` | `client.config.set({ messageComposer: { attachments: { doUploadRequest } } })` | §13.1 | +| `attachment.custom?.localId` | `isLocalUploadAttachment(a) ? a.localMetadata.id : undefined` | §17.6 | +| `attachment.custom?.originalFile?.uri` | `getAttachmentPreviewUrl(a, a.asset_url, a.image_url)` (`stream-chat`); for video playback `getPlayableVideoUrl(a)` (SDK) | §17.6 | +| `createAttachmentsCompositionMiddleware` (RN) | the same-named export from `stream-chat` | §17.6 | | `` | `…{ channel: { messagePaginator: { stateThrottleMs } } }` | §13.1 | | `channel.getConfig()` | `channel.serverConfig` (getter) — or `channel.config` for resolved gates | §13.1 | | `client.configs[cid]` | `client.channelServerConfigs[cid]` | §13.1 | @@ -756,10 +760,10 @@ Also removed, and covered in §13.1: The two throttle props were declared but never read in v10 — they are now deleted outright rather than left inert. `stateThrottleMs` is the real, reactive control. -`doSendMessageRequest` is the one `do*Request` prop that **remains**. The SDK itself -occupies that handler slot to run the attachment-upload step inside the send pipeline, -so it wraps your handler rather than being replaced by it. Its `message` argument is -now typed `MessageRequest` (rename only; no shape change). +`doSendMessageRequest` is removed like every other `do*Request` prop — register a +`sendMessageRequest` through `client.config` instead (§13.1). It used to stay because the SDK +itself occupied that handler slot to run the attachment-upload step; `stream-chat` now awaits +uploads before any handler runs, so the slot is yours alone. ## 13.1 Instance configuration → `client.config` @@ -826,7 +830,28 @@ Three things change with it: `localMessage.cid` you receive. - **Thread-scoped handlers** go under the `thread` key with the same shape. -`doSendMessageRequest` stays a prop — see §13. +The send prop moves the same way. `sendMessageRequest` also receives the wire-ready `message` +(`MessageRequest`, attachment uploads already resolved), and `retrySendMessageRequest` is a +separate slot — register the same function in both to keep v9's behaviour, where one prop +covered send and retry: + +```tsx +// v9 + mySend(channelId, message, options)}> + +// v10 +const sendMessageRequest = async ({ localMessage, message, options }) => { + const response = await mySend(localMessage.cid, message, options); + return { message: response.message }; +}; +client.config.set({ + channel: { requestHandlers: { retrySendMessageRequest: sendMessageRequest, sendMessageRequest } }, +}); +``` + +If your handler resolves without a `message`, nothing is sent a second time — v9 fell back to +`channel.sendMessage`, which sent the message twice. Return the server's response to update the +message straight away; otherwise the `message.new` event reconciles it. ### Behaviour, not values → setup functions @@ -1016,11 +1041,13 @@ root. Relevant only if you authored a custom component/override that reads them SDK's own reads are already migrated. - `channel.data.name` / `channel.data.image` → `channel.data.custom?.name` / `.custom?.image` -- Attachment metadata: `attachment.mime_type` / `file_size` / `duration` / `originalFile` → +- Attachment metadata: `attachment.mime_type` / `file_size` / `duration` → `attachment.custom?.` (`duration` is now `voiceRecording`-only) +- `attachment.originalFile` → **gone entirely** — an attachment whose upload has not resolved + carries `localMetadata` instead, see §17.6 -The RN SDK augments `CustomChannelData` (`name`, `image`) and `CustomAttachmentData` -(`originalFile`, `localId`). Add your own custom keys the same way (`declare module 'stream-chat'`). +The RN SDK augments `CustomChannelData` (`name`, `image`); `mime_type` and `file_size` come from +`stream-chat` itself. Add your own custom keys the same way (`declare module 'stream-chat'`). ## 17.2 `deleteMessage` options are snake_case @@ -1044,8 +1071,7 @@ params object rather than the prop's positional arguments: If you need the old `{ id, message }` request shape inside your handler, derive it with `localMessageToNewMessagePayload(localMessage)` — that is what the SDK's adapter used to do. -`doSendMessageRequest` remains a prop; its `message` argument is now typed `MessageRequest` -(rename only; no shape change). +`doSendMessageRequest` is removed too — see §13.1 for its `sendMessageRequest` replacement. ## 17.4 `message.moderation_details` → `message.moderation` @@ -1112,6 +1138,74 @@ Highlights that hit integrator code: `createAbortControllerForNextRequest` moved to `client.api`. - **Sort is `SortParamRequest[]`** — `{ last_message_at: -1 }` → `[{ field: 'last_message_at', direction: -1 }]`. +## 17.6 Attachments mid-upload carry `localMetadata`, not `custom.*` + +Only relevant if you override a component that renders message attachments, or read an attachment +off a message that is still sending (`messageComposer.attachments.pendingUploadsEnabled`, on by +default whenever `enableOfflineSupport` is set — see §O.3). + +v9 flattened an unresolved attachment into a plain `Attachment`: the local file URI was written into +`image_url` / `asset_url`, and the upload id and file handle were smuggled through +`custom.localId` / `custom.originalFile`. v10 leaves this to `stream-chat`: +the switch is the composer's own `attachments.pendingUploadsEnabled` (§O.3), and with it on, `stream-chat`'s `createAttachmentsCompositionMiddleware` keeps the real +`LocalUploadAttachment` on the local message while the API payload carries only what already +resolved to a URL. `MessageOperations` then awaits the remaining uploads and fills in their URLs +before the request goes out: + +```ts +// v9 +const localId = attachment.custom?.localId; +const uri = attachment.image_url ?? attachment.custom?.originalFile?.uri; + +// v10 +import { getAttachmentPreviewUrl, isLocalUploadAttachment } from 'stream-chat'; +import { getPlayableVideoUrl } from 'stream-chat-react-native'; // or 'stream-chat-expo' + +const localId = isLocalUploadAttachment(attachment) ? attachment.localMetadata.id : undefined; +const uri = getAttachmentPreviewUrl(attachment, attachment.asset_url, attachment.image_url); // image, file, audio +const videoSource = getPlayableVideoUrl(attachment); // video playback only +``` + +There is **no URL at all** on a pending attachment until its upload resolves — `localMetadata` +holds `id` (the `client.uploadManager` key), `file` (the handle a retry needs), `previewUri` (what +to render meanwhile) and `uploadState`. Two helpers encode the distinction, and overrides should +use them rather than reading the fields directly: + +- `getAttachmentPreviewUrl(attachment, ...urls)` from `stream-chat` — the default for every type. + Returns the first of `urls` that is set (e.g. `a.asset_url, a.image_url`), else + `localMetadata.previewUri`. For images, files and audio `previewUri` is the picked file's own + URI, so the same call renders an image, opens a file and plays audio. +- `getPlayableVideoUrl(attachment)` from `stream-chat-react-native` / `stream-chat-expo` — **video + playback only**. It returns `asset_url` / `image_url`, else the local file URI, skipping + `previewUri`, which for a video is the thumbnail image + (`setupVideoAttachmentPreviewMiddleware` puts it there). + +`getUrlOfImageAttachment` already applies both — the preview fallback for images, the playable +source for videos — so the gallery, the image gallery and the channel-details media list need no +change. + +Removed exports (they existed only to produce the v9 shape): + +- `createAttachmentsCompositionMiddleware` — use `stream-chat`'s export of the same name, which the + composer installs by default. The SDK no longer replaces it; `` only supplies a + default for `attachments.pendingUploadsEnabled` (§O.3) +- `createDraftAttachmentsCompositionMiddleware` — the client's default applies now, which keeps + **successful uploads only**. A draft is sent to the server, so an unresolved attachment no longer + persists a local file URI no other device can read +- `localAttachmentToAttachment` +- `DefaultAttachmentData.originalFile` / `.localId` + +`setupVideoAttachmentPreviewMiddleware` stays — a video file is not renderable, so its preview is +still the thumbnail the picker extracted. + +Two exported URL utilities changed behaviour, because a pending attachment is rendered from a +local URI: + +- `isLocalUrl(url?)` checks the scheme instead of searching for `http` anywhere in the string. A + `content://` or `ph://` URI containing that substring now counts as local, where v9 called it + remote. It also accepts `undefined` and returns `false` for it; v9 threw. +- `makeImageCompatibleUrl(url?)` accepts `undefined` and returns it unchanged; v9 threw. + --- # Part J — `ChannelList` & `ChannelManager` (orchestrator) @@ -1999,6 +2093,86 @@ What a prune does now, which is worth knowing if you build on the paginator: A value below the list's `pageSize` is raised to it: a cap smaller than a page would prune away the page a "load older" query had just fetched, and the list would immediately ask for it again. +## O.3 `` removed; the switch is composer configuration + +```diff +- ++ +``` + +```diff ++ // before handing the client to ++ client.config.set({ messageComposer: { attachments: { pendingUploadsEnabled: false } } }); +``` + +Whether a message can be sent while its attachments are still uploading is now +`stream-chat`'s own composer setting, `messageComposer.attachments.pendingUploadsEnabled`. The prop +only ever forwarded to it, and it did so with an imperative `updateConfig` on every composer, which +outranks `client.config`. So a value you registered on the client could never win. + +Removed with it: + +- `ChannelProps.allowSendBeforeAttachmentsUpload` +- `MessageInputContextValue.allowSendBeforeAttachmentsUpload` +- `ChannelProps.enableOfflineSupport`. It existed only to seed the prop's default, and it is read + from ``. + +Read the value with `usePendingUploadsEnabled()` in a component inside `` (it answers for +the current composer, so a thread composer answers for itself), or read +`messageComposer.config.attachments.pendingUploadsEnabled` directly. `MessageList` / +`MessageFlashList` take it as a `pendingUploadsEnabled` prop, which defaults to the composer's +value. + +### The default + +`` writes `pendingUploadsEnabled: enableOfflineSupport` into `client.config`, **only if +nothing is registered there yet**. That's the same default the prop had. If `enableOfflineSupport` +changes later, the SDK-written value follows it. A value anyone else registered is never +overwritten. + +### Turning it on or off for every channel + +Register it on the client, ideally before the client reaches ``, so the SDK never writes at +all: + +```ts +client.config.set({ messageComposer: { attachments: { pendingUploadsEnabled: false } } }); +// or at construction +new StreamChat(apiKey, { + config: { messageComposer: { attachments: { pendingUploadsEnabled: false } } }, +}); +``` + +A `client.config.set` made after `` has mounted also takes effect. It deep-merges over the +SDK's default, and live composers re-derive. + +### Per channel (or per channel type): a `messageComposer` setup function + +```ts +client.config.setSetupFunction('messageComposer', ({ composer }) => { + if (composer.channel.type !== 'livestream') return; // everything else keeps the default + composer.updateConfig({ attachments: { pendingUploadsEnabled: false } }); +}); +``` + +It runs against every composer the client has, existing and future: a channel's own composer, each +of its threads' composers, and message-scoped edit composers. `composer.channel` is the parent +channel for all of them, so one check covers a channel and its threads. Branch on +`composer.channel.cid` for a single channel, or on `composer.threadId` for threads only. +`updateConfig` is a retained imperative patch, so it wins over both ``'s default and +`client.config`, whichever runs first. + +- **One setup function per key.** Setting another replaces it. If you already use one (for + example, to insert composition middleware), put this branch into that same function. +- `client.config.reset()` clears the setup function and every patch it made. +- `channel.messageComposer.updateConfig({ attachments: { pendingUploadsEnabled: false } })` from a + channel screen also works, but only for that channel's own composer. It misses the channel's + thread composers, and the patch stays on the cached channel after the screen unmounts. + +**Affects:** anyone passing `allowSendBeforeAttachmentsUpload` or `enableOfflineSupport` to +``, or reading `allowSendBeforeAttachmentsUpload` from `useMessageInputContext()`. The +props are **removed, not deprecated**, so TypeScript flags them. + --- # Part I — i18n diff --git a/package/src/__tests__/instanceConfiguration.test.tsx b/package/src/__tests__/instanceConfiguration.test.tsx index 6e3966d95d..3c68878e35 100644 --- a/package/src/__tests__/instanceConfiguration.test.tsx +++ b/package/src/__tests__/instanceConfiguration.test.tsx @@ -2,13 +2,14 @@ import React from 'react'; import { renderHook } from '@testing-library/react-native'; -import { ChannelPaginator } from 'stream-chat'; +import { ChannelPaginator, Thread } from 'stream-chat'; import type { Channel, StreamChat } from 'stream-chat'; -import { useChannelRequestHandlers } from '../components/Channel/hooks/useChannelRequestHandlers'; +import { usePendingUploadsDefault } from '../components/Chat/hooks/usePendingUploadsDefault'; import { useMarkRead } from '../components/MessageList/hooks/useMarkRead'; import { ChatProvider } from '../contexts/chatContext/ChatContext'; import { initiateClientWithChannels } from '../mock-builders/api/initiateClientWithChannels'; +import { generateMessage } from '../mock-builders/generator/message'; /** * Contract tests for the SDK's use of the LLC instance-configuration API (`client.config`). @@ -110,6 +111,137 @@ describe('instance configuration contract', () => { }); }); + describe('pendingUploadsEnabled defaults from , and only when unset', () => { + const pendingUploadsEnabled = (channel: Channel) => + channel.messageComposer.config.attachments.pendingUploadsEnabled; + + it('turns it on with enableOfflineSupport when nothing is registered', async () => { + const { + channels: [channel], + client, + } = await initiateClientWithChannels(); + channel.messageComposer.registerSubscriptions(); + + renderHook(() => usePendingUploadsDefault(client, true)); + + expect(pendingUploadsEnabled(channel)).toBe(true); + }); + + it('writes nothing without enableOfflineSupport', async () => { + const { + channels: [channel], + client, + } = await initiateClientWithChannels(); + channel.messageComposer.registerSubscriptions(); + + renderHook(() => usePendingUploadsDefault(client, false)); + + expect(client.config.getConfig('messageComposer')).toBeNull(); + expect(pendingUploadsEnabled(channel)).toBe(false); + }); + + it('keeps it on after enableOfflineSupport is switched off', async () => { + const { + channels: [channel], + client, + } = await initiateClientWithChannels(); + channel.messageComposer.registerSubscriptions(); + + const { rerender } = renderHook( + ({ offline }: { offline: boolean }) => usePendingUploadsDefault(client, offline), + { initialProps: { offline: true } }, + ); + rerender({ offline: false }); + + expect(pendingUploadsEnabled(channel)).toBe(true); + }); + + it('never overwrites the same value registered by the integrator after the SDK wrote it', async () => { + const { + channels: [channel], + client, + } = await initiateClientWithChannels(); + channel.messageComposer.registerSubscriptions(); + + const { rerender } = renderHook( + ({ offline }: { offline: boolean }) => usePendingUploadsDefault(client, offline), + { initialProps: { offline: true } }, + ); + // Indistinguishable from the SDK's own write, which is why the SDK never takes it back. + client.config.set({ messageComposer: { attachments: { pendingUploadsEnabled: true } } }); + rerender({ offline: false }); + + expect(pendingUploadsEnabled(channel)).toBe(true); + }); + + it('never overwrites a value registered before ', async () => { + const { + channels: [channel], + client, + } = await initiateClientWithChannels(); + client.config.set({ messageComposer: { attachments: { pendingUploadsEnabled: false } } }); + channel.messageComposer.registerSubscriptions(); + + const { rerender } = renderHook( + ({ offline }: { offline: boolean }) => usePendingUploadsDefault(client, offline), + { initialProps: { offline: true } }, + ); + expect(pendingUploadsEnabled(channel)).toBe(false); + + rerender({ offline: false }); + rerender({ offline: true }); + expect(pendingUploadsEnabled(channel)).toBe(false); + }); + + it('never overwrites a value the integrator registers after mount', async () => { + const { + channels: [channel], + client, + } = await initiateClientWithChannels(); + channel.messageComposer.registerSubscriptions(); + + const { rerender } = renderHook( + ({ offline }: { offline: boolean }) => usePendingUploadsDefault(client, offline), + { initialProps: { offline: false } }, + ); + client.config.set({ messageComposer: { attachments: { pendingUploadsEnabled: true } } }); + rerender({ offline: false }); + rerender({ offline: true }); + rerender({ offline: false }); + + expect(pendingUploadsEnabled(channel)).toBe(true); + }); + + it('lets a messageComposer setup function override it per channel type, threads included', async () => { + const { + channels: [messagingChannel], + client, + } = await initiateClientWithChannels(); + const livestream = client.channel('livestream', 'pending-uploads-test'); + const thread = new Thread({ + channel: livestream, + client, + parentMessage: livestream.state.formatMessage(generateMessage({})), + }); + + client.config.setSetupFunction('messageComposer', ({ composer }) => { + if (composer.channel.type !== 'livestream') return; + composer.updateConfig({ attachments: { pendingUploadsEnabled: false } }); + }); + [ + messagingChannel.messageComposer, + livestream.messageComposer, + thread.messageComposer, + ].forEach((composer) => composer.registerSubscriptions()); + + renderHook(() => usePendingUploadsDefault(client, true)); + + expect(pendingUploadsEnabled(messagingChannel)).toBe(true); + expect(livestream.messageComposer.config.attachments.pendingUploadsEnabled).toBe(false); + expect(thread.messageComposer.config.attachments.pendingUploadsEnabled).toBe(false); + }); + }); + describe('paginator configuration is written through updateConfig', () => { it('persists lockItemOrder and doRequest on the channel-list paginator', async () => { const { client } = await initiateClientWithChannels(); @@ -139,27 +271,6 @@ describe('instance configuration contract', () => { }); }); - describe('channel.configState is a prototype getter', () => { - it('does not throw for a spread copy of a channel, which no longer carries it', async () => { - const { - channels: [channel], - client, - } = await initiateClientWithChannels(); - - // `configState` moved from an own field to a getter on `Channel.prototype`, so `{...channel}` - // silently drops it. Tests and integrator code both make such copies; this is the crash that - // took out `Channel.test.tsx` during the migration. - const spreadCopy = { ...channel } as Channel; - expect(spreadCopy.configState).toBeUndefined(); - - expect(() => - renderHook(() => useChannelRequestHandlers({ channel: spreadCopy }), { - wrapper: chatWrapper(client), - }), - ).not.toThrow(); - }); - }); - describe('the mock builder seeds server configuration where the LLC reads it', () => { it('makes serverConfig readable and folds it into the resolved config', async () => { const { diff --git a/package/src/__tests__/offline-support/optimistic-update.tsx b/package/src/__tests__/offline-support/optimistic-update.tsx index 3f98e62477..67c5b276fb 100644 --- a/package/src/__tests__/offline-support/optimistic-update.tsx +++ b/package/src/__tests__/offline-support/optimistic-update.tsx @@ -4,9 +4,11 @@ import { View } from 'react-native'; import { act, cleanup, render, screen, waitFor } from '@testing-library/react-native'; import type { + Attachment, Channel as ChannelLLC, ChannelMemberResponse, LocalMessage, + LocalUploadAttachment, ReactionResponse, StreamChat, UserResponse, @@ -102,9 +104,7 @@ const markConnectionHealthy = (client: StreamChat) => { }; // React flushes passive effects child-first, so the test-callback effect below runs BEFORE `Channel`'s -// own mount effects — verifiably: without this wait, `channel.configState.requestHandlers` at edit time -// holds only the declaratively-registered `updateMessageRequest`, with no `sendMessageRequest`, because -// `useChannelRequestHandlers` has not run yet. +// own mount effects. // // An operation fired from that window races `Channel`'s own offline-DB persistence of the initial query // result. The in-memory paginator is fine either way; the DB row is not. Observed across repeat runs of @@ -625,7 +625,7 @@ export const OptimisticUpdates = () => { { const localUri = 'file://edited-attachment.png'; const editedAttachments = [ { - asset_url: localUri, - custom: { - originalFile: generateFileReference({ + localMetadata: { + file: generateFileReference({ name: 'edited-attachment.png', type: 'image/png', uri: localUri, }), + id: 'edited-attachment-upload', + previewUri: localUri, + uploadState: 'uploading', }, type: 'file', }, - ]; + ] as unknown as Attachment[]; // Registered declaratively — the `` prop is gone. The LLC // resolves this into `channel.configState.requestHandlers` as part of the channel's own @@ -840,9 +842,7 @@ export const OptimisticUpdates = () => { { @@ -878,11 +878,15 @@ export const OptimisticUpdates = () => { const dbMessage = dbMessages.find((row) => row.id === message.id); const storedAttachments = JSON.parse(dbMessage!.attachments as string); + const { localMetadata } = updatedMessage!.attachments![0] as LocalUploadAttachment; + expect(updatedMessage!.text).toBe(editedText); - expect(updatedMessage!.attachments![0].asset_url).toBe(localUri); + expect(localMetadata.previewUri).toBe(localUri); + expect(localMetadata.file).toEqual(expect.objectContaining({ uri: localUri })); expect(pendingTasksRows).toHaveLength(0); expect(dbMessage!.text).toBe(editedText); - expect(storedAttachments[0].asset_url).toBe(localUri); + expect(storedAttachments[0].localMetadata.file.uri).toBe(localUri); + expect(storedAttachments[0].localMetadata.previewUri).toBe(localUri); }); }); }); diff --git a/package/src/components/Attachment/Attachment.tsx b/package/src/components/Attachment/Attachment.tsx index d4c65a5da8..3fef9be4f4 100644 --- a/package/src/components/Attachment/Attachment.tsx +++ b/package/src/components/Attachment/Attachment.tsx @@ -3,11 +3,14 @@ import React, { useMemo } from 'react'; import { StyleSheet } from 'react-native'; import { + getAttachmentPreviewUrl, isAudioAttachment, isFileAttachment, isImageAttachment, + isLocalUploadAttachment, isVideoAttachment, isVoiceRecordingAttachment, + resolveAttachmentFullByteSize, type Attachment as AttachmentType, type LocalMessage, } from 'stream-chat'; @@ -121,7 +124,9 @@ const areEqual = (prevProps: AttachmentPropsWithContext, nextProps: AttachmentPr prevAttachment.actions?.length === nextAttachment.actions?.length && prevAttachment.image_url === nextAttachment.image_url && prevAttachment.thumb_url === nextAttachment.thumb_url && - prevAttachment.type === nextAttachment.type; + prevAttachment.type === nextAttachment.type && + getAttachmentPreviewUrl(prevAttachment, prevAttachment.asset_url, prevAttachment.image_url) === + getAttachmentPreviewUrl(nextAttachment, nextAttachment.asset_url, nextAttachment.image_url); if (!attachmentEqual) { return false; } @@ -188,15 +193,15 @@ const MessageAudioAttachment = ({ index, message, }: MessageAudioAttachmentProps) => { - const localId = attachment.custom?.localId; - const sourceUrl = attachment.asset_url ?? attachment.custom?.originalFile?.uri; - const shouldTrackPendingUpload = !!localId && !!sourceUrl && isLocalUrl(sourceUrl); + const localId = isLocalUploadAttachment(attachment) ? attachment.localMetadata.id : undefined; + const sourceUrl = getAttachmentPreviewUrl(attachment, attachment.asset_url, attachment.image_url); + const shouldTrackPendingUpload = !!localId && isLocalUrl(sourceUrl); const pendingUpload = usePendingAttachmentUpload(shouldTrackPendingUpload ? localId : undefined); const indicator = pendingUpload.isUploading ? ( ) : undefined; diff --git a/package/src/components/Attachment/Audio/AudioAttachment.tsx b/package/src/components/Attachment/Audio/AudioAttachment.tsx index f47a7ba8eb..c4bed3cf47 100644 --- a/package/src/components/Attachment/Audio/AudioAttachment.tsx +++ b/package/src/components/Attachment/Audio/AudioAttachment.tsx @@ -5,6 +5,7 @@ import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import { + getAttachmentPreviewUrl, isLocalVoiceRecordingAttachment, isVoiceRecordingAttachment, LocalMessage, @@ -117,7 +118,7 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { ? 'preview' : message?.id && `${message?.parent_id ?? message?.id}${message?.id}`, type: isVoiceRecording ? 'voiceRecording' : 'audio', - uri: item.asset_url ?? '', + uri: getAttachmentPreviewUrl(item, item.asset_url) ?? '', }); const { duration, isPlaying, position, progress, currentPlaybackRate } = useStateStore( audioPlayer.state, @@ -295,7 +296,7 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { rate={currentPlaybackRate} soundRef={soundRef as RefObject} testID='sound-player' - uri={item.asset_url} + uri={getAttachmentPreviewUrl(item, item.asset_url)} /> )} diff --git a/package/src/components/Attachment/FileAttachment.tsx b/package/src/components/Attachment/FileAttachment.tsx index 77826db002..99b619cb90 100644 --- a/package/src/components/Attachment/FileAttachment.tsx +++ b/package/src/components/Attachment/FileAttachment.tsx @@ -1,7 +1,12 @@ import React, { useMemo } from 'react'; import { Pressable, StyleProp, StyleSheet, TextStyle, View, ViewStyle } from 'react-native'; -import type { Attachment } from 'stream-chat'; +import { + type Attachment, + getAttachmentPreviewUrl, + isLocalUploadAttachment, + resolveAttachmentFullByteSize, +} from 'stream-chat'; import { AttachmentFileUploadProgressIndicator } from './AttachmentFileUploadProgressIndicator'; import { openUrlSafely } from './utils/openUrlSafely'; @@ -51,7 +56,7 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => { } = props; const { FilePreview } = useComponentsContext(); - const localId = attachment.custom?.localId; + const localId = isLocalUploadAttachment(attachment) ? attachment.localMetadata.id : undefined; const defaultOnPress = () => openUrlSafely(attachment.asset_url); @@ -97,8 +102,12 @@ const FileAttachmentWithContext = (props: FileAttachmentPropsWithContext) => { indicator={ } styles={stylesProp} diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx index 3b83ff700a..43c30c27d6 100644 --- a/package/src/components/Attachment/Gallery.tsx +++ b/package/src/components/Attachment/Gallery.tsx @@ -40,6 +40,7 @@ import { isVideoPlayerAvailable } from '../../native'; import { primitives } from '../../theme'; import type { ViewRef } from '../../types/react-native-compat'; import { FileTypes } from '../../types/types'; +import { getUrlOfImageAttachment } from '../../utils/getUrlOfImageAttachment'; import { getUrlWithoutParams } from '../../utils/utils'; export type GalleryPropsWithContext = Pick & @@ -103,9 +104,10 @@ const GalleryWithContext = (props: GalleryPropsWithContext) => { minWidth, }; const imagesAndVideos = [...(images || []), ...(videos || [])]; + const galleryItemKey = (i: Attachment) => `${getUrlOfImageAttachment(i)}${i.thumb_url}`; const imagesAndVideosValue = `${images?.length}${videos?.length}${images - ?.map((i) => `${i.image_url}${i.thumb_url}`) - .join('')}${videos?.map((i) => `${i.image_url}${i.thumb_url}`).join('')}`; + ?.map(galleryItemKey) + .join('')}${videos?.map(galleryItemKey).join('')}`; const { height, invertedDirections, thumbnailGrid, width } = useMemo( () => buildGallery({ diff --git a/package/src/components/Attachment/__tests__/Attachment.test.tsx b/package/src/components/Attachment/__tests__/Attachment.test.tsx index 588d3b6d3c..e68370287f 100644 --- a/package/src/components/Attachment/__tests__/Attachment.test.tsx +++ b/package/src/components/Attachment/__tests__/Attachment.test.tsx @@ -3,7 +3,7 @@ import { StyleSheet, View } from 'react-native'; import type { ReactTestInstance } from 'react-test-renderer'; -import { render, waitFor } from '@testing-library/react-native'; +import { render, screen, waitFor } from '@testing-library/react-native'; import type { Attachment as AttachmentType } from 'stream-chat'; import { v4 as uuidv4 } from 'uuid'; @@ -13,6 +13,8 @@ import { MessageProvider } from '../../../contexts/messageContext/MessageContext import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; import { MessagesProvider } from '../../../contexts/messagesContext/MessagesContext'; import { mergeThemes, ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { usePendingAttachmentUpload } from '../../../hooks/usePendingAttachmentUpload'; +import { generateFileReference } from '../../../mock-builders/attachments'; import { generateAudioAttachment, generateFileAttachment, @@ -50,6 +52,10 @@ jest.mock('../../../hooks/usePendingAttachmentUpload', () => ({ })), })); +const mockedUsePendingAttachmentUpload = jest.mocked(usePendingAttachmentUpload); + +const idle = { isUploading: false, uploadProgress: undefined }; + const getAttachmentComponent = ( props: ComponentProps, messageContextValue: Partial = {}, @@ -283,4 +289,72 @@ describe('Attachment', () => { expect(getByTestId('gallery-container')).toBeTruthy(); }); }); + + // A message can be sent while its attachments are still uploading + // (`messageComposer.attachments.pendingUploadsEnabled`). Such an attachment has no URL of its own — everything + // needed to render it and to follow its upload lives in `localMetadata`. + describe('attachment whose upload has not resolved', () => { + // No `asset_url`: the upload has not resolved, so the file handle in `localMetadata` is the + // only source there is. `uploadState` is what makes it a local upload attachment at all. + const pendingFileAttachment = () => + ({ + custom: { file_size: 2000000, mime_type: 'application/pdf' }, + localMetadata: { + file: generateFileReference({ name: 'report.pdf', uri: 'file://local/report.pdf' }), + id: 'upload-1', + previewUri: 'file://local/report.pdf', + uploadState: 'uploading', + }, + title: 'report.pdf', + type: 'file', + }) as unknown as AttachmentType; + + afterEach(() => { + mockedUsePendingAttachmentUpload.mockReturnValue(idle); + }); + + it('follows the upload by its localMetadata id', () => { + mockedUsePendingAttachmentUpload.mockReturnValue(idle); + + render(getAttachmentComponent({ attachment: pendingFileAttachment() })); + + // Reading this from anywhere else (v9 used `custom.localId`) means no indicator can ever + // render, because the upload manager is keyed by exactly this id. + expect(mockedUsePendingAttachmentUpload).toHaveBeenCalledWith('upload-1'); + }); + + it('shows upload progress in place of the file size while the upload is in flight', () => { + mockedUsePendingAttachmentUpload.mockReturnValue({ isUploading: true, uploadProgress: 50 }); + + render(getAttachmentComponent({ attachment: pendingFileAttachment() })); + + // The uploaded/total readout replaces the plain size label — only it contains a slash. + expect(screen.getByText(/ \/ /)).toBeTruthy(); + }); + + it('takes the progress total from the held file when file_size is not set yet', () => { + mockedUsePendingAttachmentUpload.mockReturnValue({ isUploading: true, uploadProgress: 50 }); + const attachment = pendingFileAttachment() as unknown as { + custom: Record; + localMetadata: { file: Record }; + }; + delete attachment.custom.file_size; + attachment.localMetadata.file.size = 2000000; + + render(getAttachmentComponent({ attachment: attachment as unknown as AttachmentType })); + + // Without a total the indicator shows no `uploaded / total` readout at all. + expect(screen.getByText(/ \/ /)).toBeTruthy(); + }); + + it('shows no progress readout once no upload is in flight', () => { + // A message rehydrated from the offline DB keeps a frozen `uploadState`, so the payload alone + // must not be enough to render progress — only a live upload record is. + mockedUsePendingAttachmentUpload.mockReturnValue(idle); + + render(getAttachmentComponent({ attachment: pendingFileAttachment() })); + + expect(screen.queryByText(/ \/ /)).toBeNull(); + }); + }); }); diff --git a/package/src/components/Attachment/__tests__/openUrlSafely.test.ts b/package/src/components/Attachment/__tests__/openUrlSafely.test.ts index b732ea85c8..b089976dbe 100644 --- a/package/src/components/Attachment/__tests__/openUrlSafely.test.ts +++ b/package/src/components/Attachment/__tests__/openUrlSafely.test.ts @@ -45,4 +45,17 @@ describe('openUrlSafely', () => { expect(Linking.canOpenURL).toHaveBeenCalledWith('http://google.com'); }); }); + + it.each([ + ['undefined', undefined], + ['an empty string', ''], + ])('should do nothing when the url is %s', async (_, url) => { + jest.spyOn(Linking, 'canOpenURL').mockImplementation(jest.fn().mockResolvedValue(true)); + jest.spyOn(Linking, 'openURL').mockImplementation(jest.fn()); + + await openUrlSafely(url); + + expect(Linking.canOpenURL).not.toHaveBeenCalled(); + expect(Linking.openURL).not.toHaveBeenCalled(); + }); }); diff --git a/package/src/components/Attachment/utils/buildGallery/buildThumbnail.ts b/package/src/components/Attachment/utils/buildGallery/buildThumbnail.ts index 15029adf2e..763b5bbfef 100644 --- a/package/src/components/Attachment/utils/buildGallery/buildThumbnail.ts +++ b/package/src/components/Attachment/utils/buildGallery/buildThumbnail.ts @@ -1,6 +1,6 @@ import type { ImageResizeMode } from 'react-native'; -import type { Attachment } from 'stream-chat'; +import { type Attachment, isLocalUploadAttachment } from 'stream-chat'; import type { Thumbnail } from './types'; @@ -32,8 +32,9 @@ export function buildThumbnail({ originalImageHeight && originalImageWidth ? originalImageHeight + originalImageWidth > height + width : true; + // Falls back to the local preview while the upload is in flight — see `getUrlOfImageAttachment`. const imageUrl = getUrlOfImageAttachment(image) as string; - const localId = image.custom?.localId; + const localId = isLocalUploadAttachment(image) ? image.localMetadata.id : undefined; return { flex, diff --git a/package/src/components/Attachment/utils/openUrlSafely.ts b/package/src/components/Attachment/utils/openUrlSafely.ts index 08697d3ba6..c147475a18 100644 --- a/package/src/components/Attachment/utils/openUrlSafely.ts +++ b/package/src/components/Attachment/utils/openUrlSafely.ts @@ -1,7 +1,13 @@ import { Linking } from 'react-native'; export const openUrlSafely = async (url?: string) => { - let finalUrl = url as string; + // An attachment whose upload hasn't settled yet has no remote URL. Without this guard the + // scheme fallback below would turn it into `http://undefined`, which `canOpenURL` accepts. + if (!url) { + return; + } + + let finalUrl = url; const pattern = new RegExp(/^\S+:\/\//); if (!pattern.test(finalUrl)) { diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 4ed8aa16fe..a09e677ec5 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -5,14 +5,12 @@ import { ChannelConfig, LocalMessage, MessageComposerConfig, - SendMessageAPIResponse, SendMessageOptions, Event as StreamEvent, MessageRequest as StreamMessage, Thread, } from 'stream-chat'; -import { useChannelRequestHandlers } from './hooks/useChannelRequestHandlers'; import { useCreateChannelContext } from './hooks/useCreateChannelContext'; import { useCreateInputMessageInputContext } from './hooks/useCreateInputMessageInputContext'; @@ -75,10 +73,8 @@ import { import { MessageInputHeightStore } from '../../state-store/message-input-height-store'; import { primitives } from '../../theme'; -import { FileTypes } from '../../types/types'; -import { compressedImageURI } from '../../utils/compressImage'; import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand'; -import { getFileNameFromPath, isLocalUrl, ReactionData } from '../../utils/utils'; +import { ReactionData } from '../../utils/utils'; import { NotificationAnnouncer } from '../Accessibility/NotificationAnnouncer'; import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker'; import { useSettledWSConnectionHealth } from '../Chat/hooks/useWSConnectionState'; @@ -168,11 +164,10 @@ export type ChannelPropsWithContext = Pick & | 'maxTimeBetweenGroupedMessages' > > & - Pick & { isOnline: boolean } & Partial< + Pick & { isOnline: boolean } & Partial< Pick< InputMessageInputContextValue, | 'additionalTextInputProps' - | 'allowSendBeforeAttachmentsUpload' | 'asyncMessagesLockDistance' | 'asyncMessagesMinimumPressDuration' | 'audioRecordingSendOnComplete' @@ -259,17 +254,6 @@ export type ChannelPropsWithContext = Pick & * KeyboardAvoidingView works well when your component occupies 100% of screen height, otherwise it may raise some issues. */ disableKeyboardCompatibleView?: boolean; - /** - * Overrides the Stream default send message request (Advanced usage only) - * @param channelId - * @param messageData Message object - */ - doSendMessageRequest?: ( - channelId: string, - messageData: StreamMessage, - options?: SendMessageOptions, - ) => Promise; - /** * A method invoked just after the first optimistic update of a new message, * but before any other HTTP requests happen. Can be used to do extra work @@ -368,11 +352,8 @@ const ChannelWithContext = (props: PropsWithChildren) = disableKeyboardCompatibleView = false, disableTypingIndicator, dismissKeyboardOnMessageTouch = true, - doSendMessageRequest, preSendMessageRequest, enableMessageGroupingByUser = true, - enableOfflineSupport, - allowSendBeforeAttachmentsUpload = enableOfflineSupport, enableSwipeToReply = true, enforceUniqueReaction = false, FlatList = NativeHandlers.FlatList, @@ -687,77 +668,6 @@ const ChannelWithContext = (props: PropsWithChildren) = /** * MESSAGE METHODS */ - // Async attachment-upload orchestration. Wired into the registered `sendMessageRequest` handler - // (see the useChannelRequestHandlers call below) so it runs INSIDE the stream-chat send pipeline — - // after the LLC's optimistic ingest (message already shows pending), before the POST — awaiting - // `client.uploadManager` to finish the in-flight uploads and swapping local preview URLs for the - // returned CDN URLs. It lives here for now because native image compression (`compressedImageURI`) - // and a custom uploader registered through `client.config` must remain reachable, and the - // sendMessageRequest seam lets it run in the right place without a pre-ingest or any LLC change. - // It IS slated to move into the LLC, just not yet — and that move is what lets the - // `doSendMessageRequest` prop and its wrapper in `useChannelRequestHandlers` go. - const uploadPendingAttachments = useStableCallback(async (message: LocalMessage) => { - if (!message.attachments?.length || !channel?.cid) { - return; - } - const channelCid = channel.cid; - - const uploadOne = async (attachment: NonNullable[number]) => { - // Already uploaded to a remote (CDN) URL — nothing to wait for. - if ( - (attachment.image_url && !isLocalUrl(attachment.image_url)) || - (attachment.asset_url && !isLocalUrl(attachment.asset_url)) - ) { - return; - } - - const originalFile = attachment.custom?.originalFile; - const localId = attachment.custom?.localId; - if (!originalFile?.uri || !localId) { - return; - } - - let fileForUpload = originalFile; - const hasCustomUploader = !!channel.messageComposer.config.attachments.doUploadRequest; - if (attachment.type === FileTypes.Image && !hasCustomUploader) { - const filename = originalFile.name ?? getFileNameFromPath(originalFile.uri); - const compressedUri = await compressedImageURI(originalFile, compressImageQuality); - fileForUpload = { ...originalFile, name: filename, uri: compressedUri }; - } - - // Idempotent by `id`: awaits the picker's in-flight upload for this attachment (or starts one). - const response = await client.uploadManager.upload({ - channelCid, - file: fileForUpload, - id: localId, - }); - - if (attachment.type === FileTypes.Image) { - attachment.image_url = response.file; - } else { - attachment.asset_url = response.file; - if (response.thumb_url) { - attachment.thumb_url = response.thumb_url; - } - } - - if (attachment.custom) { - delete attachment.custom.originalFile; - delete attachment.custom.localId; - } - }; - - await Promise.all(message.attachments.map(uploadOne)); - }); - - // Register the integrator's custom message-request overrides into channel.configState so the - // stream-chat message-operations engine (send/retry/update via *WithLocalUpdate) honors them. - useChannelRequestHandlers({ - channel, - uploadPendingAttachments, - doSendMessageRequest, - }); - const sendMessage: InputMessageInputContextValue['sendMessage'] = useStableCallback( async ({ localMessage, message, options }) => { if (preSendMessageRequest) { @@ -776,8 +686,8 @@ const ChannelWithContext = (props: PropsWithChildren) = // received/failed), offline-DB persistence and paginator ingest — for both channel messages // (channel.messagePaginator) and thread replies (thread.messagePaginator, which the thread // instance ingests into directly). Its single optimistic ingest shows the message (pending) - // instantly; the registered sendMessageRequest handler then awaits any attachment uploads and - // POSTs (see useChannelRequestHandlers). It throws on failure, which the MessageInput send flow + // instantly, then it awaits any attachment uploads still in flight and POSTs — through a + // `sendMessageRequest` registered via `client.config.set(...)`, if any. It throws on failure, which the MessageInput send flow // catches to surface a notification. await (threadInstance ?? channel).sendMessageWithLocalUpdate({ localMessage, @@ -871,7 +781,6 @@ const ChannelWithContext = (props: PropsWithChildren) = const inputMessageInputContext = useCreateInputMessageInputContext({ additionalTextInputProps, - allowSendBeforeAttachmentsUpload, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, audioRecordingSendOnComplete, @@ -1033,7 +942,7 @@ export type ChannelProps = Partial) => { - const { client, enableOfflineSupport, isMessageAIGenerated } = useChatContext(); + const { client, isMessageAIGenerated } = useChatContext(); const isOnline = useSettledWSConnectionHealth(); const { t } = useTranslationContext(); const notificationHostId = @@ -1057,7 +966,6 @@ export const Channel = (props: PropsWithChildren) => { { allOwnCapabilities.sendMessage, allOwnCapabilities.sendLinks, ]); - const mockFn = jest.fn(); - const { queryByTestId } = render( - getComponent({ - doSendMessageRequest: (() => { - mockFn(); - return sendMessageApi(); - }) as unknown as React.ComponentProps['doSendMessageRequest'], - }), - ); + // The LLC's default send goes through `channel.sendMessage`. + const sendMessage = jest.fn().mockResolvedValue({}); + channel.sendMessage = sendMessage; + const { queryByTestId } = render(getComponent()); await act(async () => { const text = 'Awesome repository https://github.com/GetStream/stream-chat-react-native'; @@ -432,6 +426,6 @@ describe('Own capabilities', () => { fireEvent(queryByTestId('send-button')!, 'onPress'); }); - await waitFor(() => expect(mockFn).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1)); }); }); diff --git a/package/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts b/package/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts deleted file mode 100644 index 0b577c8319..0000000000 --- a/package/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { renderHook } from '@testing-library/react-native'; -import type { Channel, LocalMessage, MessageRequest as Message } from 'stream-chat'; - -import { useChannelRequestHandlers } from '../useChannelRequestHandlers'; - -// NOTE: `stream-chat` is portaled to a local checkout during this migration; a runtime -// (value) import of it breaks jest resolution. Everything from `stream-chat` here is a -// type-only import (erased at compile time), and `configState` is faked, so no runtime -// `require('stream-chat')` happens. - -type FakeRequestHandlers = Record unknown>; -type FakeConfig = { requestHandlers?: FakeRequestHandlers }; - -const createChannel = ( - sendMessage: jest.Mock = jest.fn().mockResolvedValue({ message: { id: 'fallback' } }), -) => { - let config: FakeConfig = {}; - const listeners = new Set<(value: FakeConfig) => void>(); - const notify = () => listeners.forEach((listener) => listener(config)); - const configState = { - getLatestValue: (): FakeConfig => config, - partialNext: (patch: FakeConfig) => { - config = { ...config, ...patch }; - notify(); - }, - // Mirrors `StateStore.subscribe`: replays the current value immediately and notifies on every write. - // The hook relies on both — the immediate replay must be a no-op (its handler is already installed), - // and the write notification is what lets it re-apply after a re-derivation dropped its handlers. - subscribe: (listener: (value: FakeConfig) => void) => { - listeners.add(listener); - listener(config); - return () => listeners.delete(listener); - }, - }; - const channel = { cid: 'messaging:test', configState, sendMessage } as unknown as Channel; - return { - channel, - configState, - getHandlers: () => config.requestHandlers, - /** - * Stands in for `Channel.initializeConfig`, which *replaces* `requestHandlers` from the declarative - * tree rather than merging — what happens on any `client.config.set()` touching `channel`, - * `messagePaginator` or `messageOperations`. - */ - simulateReDerivation: () => { - config = { ...config, requestHandlers: undefined }; - notify(); - }, - sendMessage, - }; -}; - -const localMessage = { id: 'm1', text: 'hi' } as unknown as LocalMessage; -const message = { text: 'hi' } as unknown as Message; - -describe('useChannelRequestHandlers', () => { - it('always registers the send + retry handler, even with no overrides', () => { - const { channel, getHandlers } = createChannel(); - - renderHook(() => useChannelRequestHandlers({ channel })); - - // The send handler is unconditional (it also drives attachment uploads); send and retry share it. - expect(getHandlers()?.sendMessageRequest).toBeDefined(); - expect(getHandlers()?.retrySendMessageRequest).toBe(getHandlers()?.sendMessageRequest); - // update / mark-read stay unset until their overrides are provided. - expect(getHandlers()?.updateMessageRequest).toBeUndefined(); - expect(getHandlers()?.markReadRequest).toBeUndefined(); - }); - - it('awaits uploadPendingAttachments before the default send', async () => { - const { channel, getHandlers, sendMessage } = createChannel(); - const order: string[] = []; - const uploadPendingAttachments = jest.fn(() => { - order.push('upload'); - return Promise.resolve(); - }); - sendMessage.mockImplementation(() => { - order.push('send'); - return Promise.resolve({ message: { id: 'fallback' } }); - }); - - renderHook(() => useChannelRequestHandlers({ channel, uploadPendingAttachments })); - - const result = await getHandlers()?.sendMessageRequest?.({ localMessage, message }); - - expect(uploadPendingAttachments).toHaveBeenCalledWith(localMessage); - expect(order).toEqual(['upload', 'send']); - expect(result).toEqual({ message: { id: 'fallback' } }); - }); - - it('registers send + retry from doSendMessageRequest and returns the override response', async () => { - const { channel, getHandlers } = createChannel(); - const doSendMessageRequest = jest.fn().mockResolvedValue({ message: { id: 'override' } }); - - renderHook(() => useChannelRequestHandlers({ channel, doSendMessageRequest })); - - expect(getHandlers()?.sendMessageRequest).toBeDefined(); - // send and retry share the same wrapper. - expect(getHandlers()?.retrySendMessageRequest).toBe(getHandlers()?.sendMessageRequest); - - const result = await getHandlers()?.sendMessageRequest?.({ localMessage, message }); - expect(doSendMessageRequest).toHaveBeenCalledWith('messaging:test', message, undefined); - expect(result).toEqual({ message: { id: 'override' } }); - }); - - it('falls back to channel.sendMessage when the override resolves without a message', async () => { - const { channel, getHandlers, sendMessage } = createChannel(); - const doSendMessageRequest = jest.fn().mockResolvedValue(undefined); - - renderHook(() => useChannelRequestHandlers({ channel, doSendMessageRequest })); - - const result = await getHandlers()?.sendMessageRequest?.({ localMessage, message }); - expect(sendMessage).toHaveBeenCalledWith({ message }); - expect(result).toEqual({ message: { id: 'fallback' } }); - }); - - it('leaves handlers it does not own alone', () => { - const { channel, configState, getHandlers } = createChannel(); - - // Registered elsewhere — in production by `client.config.set({ channel: { requestHandlers } })`, - // which the LLC resolves into `configState`. The hook no longer manages mark-read or delete, so - // both must survive its writes; it used to `delete` markRead unconditionally, which silently - // dropped a declaratively-registered handler. - const deleteMessageRequest = jest.fn(); - const markReadRequest = jest.fn(); - configState.partialNext({ requestHandlers: { deleteMessageRequest, markReadRequest } }); - - renderHook(() => useChannelRequestHandlers({ channel })); - - expect(getHandlers()?.deleteMessageRequest).toBe(deleteMessageRequest); - expect(getHandlers()?.markReadRequest).toBe(markReadRequest); - // ...while the send/retry pair it does own is registered. - expect(getHandlers()?.sendMessageRequest).toBeDefined(); - expect(getHandlers()?.retrySendMessageRequest).toBe(getHandlers()?.sendMessageRequest); - }); - it('re-applies its handlers after a re-derivation drops them', () => { - const { channel, getHandlers, simulateReDerivation } = createChannel(); - - renderHook(() => useChannelRequestHandlers({ channel })); - const original = getHandlers()?.sendMessageRequest; - expect(original).toBeDefined(); - - // `Channel.initializeConfig` replaces `requestHandlers` wholesale, so any `client.config.set()` - // touching `channel` / `messagePaginator` / `messageOperations` wipes what this hook wrote. Without - // the re-apply the attachment-upload step would go with it, silently. - simulateReDerivation(); - - expect(getHandlers()?.sendMessageRequest).toBeDefined(); - expect(getHandlers()?.retrySendMessageRequest).toBe(getHandlers()?.sendMessageRequest); - }); - - it('re-applies a doSendMessageRequest override after a re-derivation', async () => { - const { channel, getHandlers, simulateReDerivation } = createChannel(); - const doSendMessageRequest = jest.fn().mockResolvedValue({ message: { id: 'from-override' } }); - - renderHook(() => useChannelRequestHandlers({ channel, doSendMessageRequest })); - simulateReDerivation(); - - const result = await getHandlers()?.sendMessageRequest?.({ localMessage, message }); - expect(doSendMessageRequest).toHaveBeenCalled(); - expect(result).toEqual({ message: { id: 'from-override' } }); - }); - - it('does not loop when its own write re-enters the subscription', () => { - const { channel, configState } = createChannel(); - const partialNext = jest.spyOn(configState, 'partialNext'); - - renderHook(() => useChannelRequestHandlers({ channel })); - - // One write for the initial apply. The subscription's immediate replay sees our own handler and - // short-circuits, so it must not write again. - expect(partialNext).toHaveBeenCalledTimes(1); - }); -}); diff --git a/package/src/components/Channel/hooks/useChannelRequestHandlers.ts b/package/src/components/Channel/hooks/useChannelRequestHandlers.ts deleted file mode 100644 index 582edca318..0000000000 --- a/package/src/components/Channel/hooks/useChannelRequestHandlers.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { useEffect } from 'react'; - -import { - Channel, - ChannelConfig, - LocalMessage, - MessageRequest as Message, - SendMessageAPIResponse, - SendMessageOptions, -} from 'stream-chat'; - -type RequestHandlers = NonNullable; - -export type ChannelRequestHandlersParams = { - channel: Channel; - /** - * Awaits any in-flight attachment uploads for the outgoing message and swaps its local preview - * URLs for the resolved CDN URLs. Invoked by the always-registered send handler, inside the - * stream-chat send pipeline (after the optimistic ingest, before the POST). - */ - uploadPendingAttachments?: (message: LocalMessage) => Promise; - /** Overrides the default send/retry request. Mirrors the `` prop. */ - doSendMessageRequest?: ( - channelId: string, - message: Message, - options?: SendMessageOptions, - ) => Promise; -}; - -/** - * Registers message-request handlers into `channel.configState.requestHandlers` so the `stream-chat` - * message-operations engine (`channel.sendMessageWithLocalUpdate` / `retrySendMessageWithLocalUpdate` - * / `updateMessageWithLocalUpdate`) honors them. - * - * Only the send/retry pair is managed here, and it is registered unconditionally because it also drives - * the attachment-upload step (see `uploadPendingAttachments`); it defers the actual POST to the - * integrator's `doSendMessageRequest` when provided, and otherwise to `channel.sendMessage` (the client - * default). - * - * `markReadRequest`, `updateMessageRequest` and `deleteMessageRequest` are **not** managed here — those - * are registered declaratively through `client.config.set({ channel: { requestHandlers } })` and the LLC - * resolves them per instance. The `` / `doUpdateMessageRequest` props that - * used to feed them are removed. - * - * Nothing this hook does not own is deleted from the slot map, which matters: `delete`-ing a handler it - * merely *might* own is what silently dropped a declaratively-registered one, sending the operation down - * the LLC's default path — an unmocked request that simply hangs. - * - * Re-applied whenever the channel re-derives its configuration. `Channel.initializeConfig` *replaces* - * `requestHandlers` from the declarative tree rather than merging into it, and it runs on every change - * to the `channel`, `messagePaginator` or `messageOperations` keys (a `Channel` declares the latter two - * as `alsoWatch`). A write made here is not one of those inputs, so any `client.config.set()` touching - * them would otherwise drop our send handler — and with it the attachment-upload step, silently. The - * subscription below is the re-apply the LLC's `initializeConfig` doc asks direct writers to perform. - */ -export const useChannelRequestHandlers = ({ - channel, - uploadPendingAttachments, - doSendMessageRequest, -}: ChannelRequestHandlersParams) => { - useEffect(() => { - // `configState` is a getter on `Channel.prototype` now (it delegates to the channel's - // `ConfigController`), where it used to be an own field. A spread copy of a channel — which tests - // and integrator code both make — therefore no longer carries it, so this cannot be assumed. - const configState = channel?.configState; - if (!configState) return; - - // Always register a send handler. It runs INSIDE the stream-chat send pipeline — after the - // optimistic ingest (the message already shows as pending), before the POST — so it is where we - // await any in-flight attachment uploads and swap local preview URLs for CDN URLs. When the - // integrator supplied doSendMessageRequest we defer the actual POST to it; otherwise we fall back - // to channel.sendMessage, which is byte-identical to the client default for messages with no - // pending uploads. retrySendMessageRequest reuses it, so retries re-await uploads too. - // - // Built once per effect run rather than inside `applyRequestHandlers`, so its identity is stable - // across re-applies — that identity is what the subscription below uses to tell "still ours" from - // "dropped by a re-derivation". - // - // TODO: discuss with the team — request a `next`-shaped `sendMessageRequest` slot in `stream-chat`, - // then delete this wrapper and the `doSendMessageRequest` prop with it. - // - // Every other `do*Request` prop is gone; this one stays only because the SDK needs the slot itself - // (the upload step must run after the optimistic ingest, before the POST) so it has to wrap rather - // than be replaced. Two consequences: we hand-copy the LLC's default send below and nothing catches - // it drifting, and a declaratively-registered handler is never reached. - // - // `MessageOperations.send` already builds the fallback (`requestFn ?? handlers.send ?? - // defaults.send`) — it just does not pass it to the handler. With `next` available this collapses to - // "await uploads, call next". - // Typed by its own parameter shape rather than by either slot. `send` and `retry` take - // identical parameters, but stream-chat keys them as `OperationRequestFn<'send'>` and - // `OperationRequestFn<'retry'>`, and TypeScript measures that alias as invariant in the operation - // kind — so neither is assignable to the other however identical the shapes. One function - // annotated this way satisfies both slots, which the identity check below relies on. - const sendMessageRequest = async ({ - localMessage, - message, - options, - }: { - localMessage: LocalMessage; - message?: Message; - options?: SendMessageOptions; - }) => { - await uploadPendingAttachments?.(localMessage); - - if (doSendMessageRequest) { - const response = await doSendMessageRequest(channel.cid, message as Message, options); - if (response?.message) { - return { message: response.message }; - } - } - - const fallback = await channel.sendMessage({ message: message as Message, ...options }); - return { message: fallback.message }; - }; - - const applyRequestHandlers = () => { - const currentRequestHandlers = configState.getLatestValue().requestHandlers; - const nextRequestHandlers: RequestHandlers = { ...(currentRequestHandlers ?? {}) }; - - // Only the send/retry pair is ours. `markReadRequest`, `updateMessageRequest` and - // `deleteMessageRequest` are left untouched so a handler registered through - // `client.config.set({ channel: { requestHandlers } })` survives — deleting them here is what - // used to drop an integrator's declaratively-registered handler on the floor. - delete nextRequestHandlers.retrySendMessageRequest; - delete nextRequestHandlers.sendMessageRequest; - - nextRequestHandlers.sendMessageRequest = sendMessageRequest; - nextRequestHandlers.retrySendMessageRequest = sendMessageRequest; - - configState.partialNext({ - requestHandlers: - Object.keys(nextRequestHandlers).length > 0 ? nextRequestHandlers : undefined, - }); - }; - - applyRequestHandlers(); - - // Subscribed after the first apply, so the immediate replay `subscribe` performs already sees our - // handler and short-circuits. Our own `partialNext` re-enters here for the same reason, so there is - // no write loop: the guard is satisfied by the write that triggered it. - return configState.subscribe(({ requestHandlers }) => { - if (requestHandlers?.sendMessageRequest === sendMessageRequest) return; - applyRequestHandlers(); - }); - }, [channel, uploadPendingAttachments, doSendMessageRequest]); -}; diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index 68cc3aa288..520935f5b7 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -4,7 +4,6 @@ import type { InputMessageInputContextValue } from '../../../contexts/messageInp export const useCreateInputMessageInputContext = ({ additionalTextInputProps, - allowSendBeforeAttachmentsUpload, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesSlideToCancelDistance, @@ -37,7 +36,6 @@ export const useCreateInputMessageInputContext = ({ const inputMessageInputContext: InputMessageInputContextValue = useMemo( () => ({ additionalTextInputProps, - allowSendBeforeAttachmentsUpload, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesSlideToCancelDistance, @@ -62,7 +60,7 @@ export const useCreateInputMessageInputContext = ({ showPollCreationDialog, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [compressImageQuality, channelId, showPollCreationDialog, allowSendBeforeAttachmentsUpload], + [compressImageQuality, channelId, showPollCreationDialog], ); return inputMessageInputContext; diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index b902cfb3ab..fb75ef7cff 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -8,6 +8,7 @@ import { useAppSettings } from './hooks/useAppSettings'; import { useCreateChatContext } from './hooks/useCreateChatContext'; import { useInitializeOfflineDb } from './hooks/useInitializeOfflineDb'; import { useIsOnline } from './hooks/useIsOnline'; +import { usePendingUploadsDefault } from './hooks/usePendingUploadsDefault'; import { ChatContextValue, ChatProvider } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; @@ -259,6 +260,7 @@ const ChatWithContext = (props: PropsWithChildren) => { * Setup connection event listeners */ useIsOnline(client, closeConnectionOnBackground); + usePendingUploadsDefault(client, enableOfflineSupport); // The device's network, for the one consumer that needs it before the context exists. const isNetworkOnline = useStateStore(client.networkConnection?.state, networkSelector)?.isOnline; diff --git a/package/src/components/Chat/hooks/usePendingUploadsDefault.ts b/package/src/components/Chat/hooks/usePendingUploadsDefault.ts new file mode 100644 index 0000000000..dbe35edcc3 --- /dev/null +++ b/package/src/components/Chat/hooks/usePendingUploadsDefault.ts @@ -0,0 +1,43 @@ +import { useEffect } from 'react'; + +import type { StreamChat } from 'stream-chat'; + +/** + * Supplies the SDK's default for `messageComposer.attachments.pendingUploadsEnabled` — whether a + * message can be sent while its attachments are still uploading — and nothing more. + * + * Offline support depends on the local message keeping its pending attachments, which is exactly + * what this switch does, so enabling `enableOfflineSupport` turns it on. With offline support off + * nothing is written and `stream-chat`'s own default (`false`) applies. + * + * Written only when the integrator has not registered a value. Set it on the client to take the + * decision yourself — ideally before handing the client to ``, so this never writes at all: + * + * ```ts + * client.config.set({ messageComposer: { attachments: { pendingUploadsEnabled: false } } }); + * ``` + * + * Once written, the value is never taken back: the hook cannot tell its own write from an + * integrator's later write of the same value, so a later `enableOfflineSupport={false}` leaves + * pending uploads on rather than risk overwriting a value the integrator chose. + */ +export const usePendingUploadsDefault = (client: StreamChat, enableOfflineSupport: boolean) => { + useEffect(() => { + if (!client || !enableOfflineSupport) { + return; + } + + const registered = + client.config.getConfig('messageComposer')?.attachments?.pendingUploadsEnabled; + if (registered !== undefined) { + return; + } + + client.config.setConfig('messageComposer', { + attachments: { pendingUploadsEnabled: true }, + }); + + // Deliberately no teardown, for the same reason as the network reporter in `useIsOnline`: the + // configuration lives as long as the client, which outlives ``. + }, [client, enableOfflineSupport]); +}; diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 0c430930b1..00ac5b90c8 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -11,7 +11,13 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Portal } from 'react-native-teleport'; -import type { Attachment, LocalMessage, MentionEntity, UserResponse } from 'stream-chat'; +import { + type Attachment, + getAttachmentPreviewUrl, + type LocalMessage, + type MentionEntity, + type UserResponse, +} from 'stream-chat'; import { useCreateMessageContext } from './hooks/useCreateMessageContext'; import { useMessageActionHandlers } from './hooks/useMessageActionHandlers'; @@ -68,6 +74,7 @@ import { import { primitives } from '../../theme'; import type { ViewRef } from '../../types/react-native-compat'; import { FileTypes } from '../../types/types'; +import { getPlayableVideoUrl } from '../../utils/attachmentUrls'; import { checkMessageEquality, generateRandomId, @@ -466,7 +473,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { ) { acc.videos.push({ ...cur, - image_url: cur.asset_url, + image_url: getPlayableVideoUrl(cur), thumb_url: cur.thumb_url, type: FileTypes.Video, }); @@ -481,7 +488,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { * this next if is not combined with the above one for cases where we have * an image with no url links at all falling back to being an attachment */ - if (cur.image_url || cur.thumb_url) { + if (getAttachmentPreviewUrl(cur, cur.image_url, cur.thumb_url)) { acc.images.push(cur); acc.other = []; // remove other attachments if an image exists } diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx index 0e30d82d14..33ee6da094 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview.tsx @@ -2,7 +2,12 @@ import React, { useCallback, useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; -import { FileReference, LocalAudioAttachment, LocalVoiceRecordingAttachment } from 'stream-chat'; +import { + FileReference, + LocalAudioAttachment, + LocalVoiceRecordingAttachment, + resolveAttachmentFullByteSize, +} from 'stream-chat'; import { AttachmentRemoveControl } from './AttachmentRemoveControl'; import { @@ -14,7 +19,7 @@ import { import { AudioAttachment } from '../../../../components/Attachment/Audio'; import { useTheme } from '../../../../contexts'; import { useMessageComposer } from '../../../../contexts/messageInputContext/hooks/useMessageComposer'; -import { useMessageInputContext } from '../../../../contexts/messageInputContext/MessageInputContext'; +import { usePendingUploadsEnabled } from '../../../../contexts/messageInputContext/hooks/usePendingUploadsEnabled'; import { primitives } from '../../../../theme'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; import { getIndicatorTypeForFileState, ProgressIndicatorTypes } from '../../../../utils/utils'; @@ -30,10 +35,10 @@ export const AudioAttachmentUploadPreview = ({ removeAttachments, }: AudioAttachmentUploadPreviewProps) => { const styles = useStyles(); - const { allowSendBeforeAttachmentsUpload } = useMessageInputContext(); + const pendingUploadsEnabled = usePendingUploadsEnabled(); const indicatorType = getIndicatorTypeForFileState( attachment.localMetadata.uploadState, - !!allowSendBeforeAttachmentsUpload, + pendingUploadsEnabled, ); const messageComposer = useMessageComposer(); const isDraft = messageComposer.draftId; @@ -67,7 +72,7 @@ export const AudioAttachmentUploadPreview = ({ ); } @@ -78,13 +83,7 @@ export const AudioAttachmentUploadPreview = ({ return ; } return null; - }, [ - assetUrl, - attachment.custom?.file_size, - attachment.localMetadata, - indicatorType, - onRetryHandler, - ]); + }, [assetUrl, attachment, indicatorType, onRetryHandler]); return ( diff --git a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx index 9ccc0fd0ce..d433e85d2d 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/FileAttachmentUploadPreview.tsx @@ -7,13 +7,14 @@ import { LocalAudioAttachment, LocalFileAttachment, LocalVideoAttachment, + resolveAttachmentFullByteSize, } from 'stream-chat'; import { AttachmentRemoveControl } from './AttachmentRemoveControl'; import { FilePreview } from '../../../../components/Attachment/FilePreview'; import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; -import { useMessageInputContext } from '../../../../contexts/messageInputContext/MessageInputContext'; +import { usePendingUploadsEnabled } from '../../../../contexts/messageInputContext/hooks/usePendingUploadsEnabled'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { primitives } from '../../../../theme'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; @@ -39,10 +40,10 @@ export const FileAttachmentUploadPreview = ({ FileUploadRetryIndicator, FileUploadNotSupportedIndicator, } = useComponentsContext(); - const { allowSendBeforeAttachmentsUpload } = useMessageInputContext(); + const pendingUploadsEnabled = usePendingUploadsEnabled(); const indicatorType = getIndicatorTypeForFileState( attachment.localMetadata.uploadState, - !!allowSendBeforeAttachmentsUpload, + pendingUploadsEnabled, ); const { @@ -67,7 +68,7 @@ export const FileAttachmentUploadPreview = ({ ); } @@ -82,8 +83,7 @@ export const FileAttachmentUploadPreview = ({ FileUploadInProgressIndicator, FileUploadNotSupportedIndicator, FileUploadRetryIndicator, - attachment.localMetadata, - attachment.custom?.file_size, + attachment, indicatorType, onRetryHandler, sourceUrl, diff --git a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx index df2807ccdd..7c7928dd14 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/ImageAttachmentUploadPreview.tsx @@ -7,7 +7,7 @@ import { LocalImageAttachment } from 'stream-chat'; import { AttachmentRemoveControl } from './AttachmentRemoveControl'; import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; -import { useMessageInputContext } from '../../../../contexts/messageInputContext/MessageInputContext'; +import { usePendingUploadsEnabled } from '../../../../contexts/messageInputContext/hooks/usePendingUploadsEnabled'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { primitives } from '../../../../theme'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; @@ -24,7 +24,7 @@ export const ImageAttachmentUploadPreview = ({ removeAttachments, }: ImageAttachmentUploadPreviewProps) => { const [loading, setLoading] = useState(true); - const { allowSendBeforeAttachmentsUpload } = useMessageInputContext(); + const pendingUploadsEnabled = usePendingUploadsEnabled(); const { ImageComponent, ImageLoadingIndicator, @@ -34,7 +34,7 @@ export const ImageAttachmentUploadPreview = ({ } = useComponentsContext(); const indicatorType = getIndicatorTypeForFileState( attachment.localMetadata.uploadState, - !!allowSendBeforeAttachmentsUpload, + pendingUploadsEnabled, ); const previewUri = attachment.localMetadata.previewUri ?? attachment.image_url; const shouldShowImageLoadingIndicator = diff --git a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx index d8cdc54632..dcd3e69f31 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx @@ -7,8 +7,8 @@ import { LocalImageAttachment, LocalVideoAttachment } from 'stream-chat'; import { FileAttachmentUploadPreview } from './FileAttachmentUploadPreview'; import { ImageAttachmentUploadPreview } from './ImageAttachmentUploadPreview'; -import { useMessageInputContext } from '../../../../contexts'; import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; +import { usePendingUploadsEnabled } from '../../../../contexts/messageInputContext/hooks/usePendingUploadsEnabled'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { primitives } from '../../../../theme'; import { UploadAttachmentPreviewProps } from '../../../../types/types'; @@ -23,9 +23,9 @@ export const VideoAttachmentUploadPreview = ({ removeAttachments, }: VideoAttachmentUploadPreviewProps) => { const previewUri = attachment.thumb_url ?? attachment.localMetadata.previewUri; - const { allowSendBeforeAttachmentsUpload } = useMessageInputContext(); + const pendingUploadsEnabled = usePendingUploadsEnabled(); const shouldShowMetadataPill = - allowSendBeforeAttachmentsUpload || attachment.localMetadata.uploadState !== 'uploading'; + pendingUploadsEnabled || attachment.localMetadata.uploadState !== 'uploading'; // `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; diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index e339a9235a..dee752bf6c 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -13,7 +13,7 @@ import Animated from 'react-native-reanimated'; import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; import type { Channel, EventPayload, LocalMessage } from 'stream-chat'; -import { convertTimestampToDate } from 'stream-chat'; +import { convertTimestampToDate, getAttachmentPreviewUrl } from 'stream-chat'; import { useMarkRead } from './hooks/useMarkRead'; import { useMessageList } from './hooks/useMessageList'; @@ -36,6 +36,7 @@ import { } from '../../contexts/channelContext/ChannelContext'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; +import { usePendingUploadsEnabled } from '../../contexts/messageInputContext/hooks/usePendingUploadsEnabled'; import { MessageInputContextValue, useMessageInputContext, @@ -120,10 +121,12 @@ type MessageFlashListPropsWithContext = Pick< | 'threadList' > & Pick & - Pick< - MessageInputContextValue, - 'allowSendBeforeAttachmentsUpload' | 'messageInputFloating' | 'messageInputHeightStore' - > & { + Pick & { + /** + * Whether the composer lets a message be sent while its attachments are still uploading + * (`messageComposer.attachments.pendingUploadsEnabled`). Read from the composer by default. + */ + pendingUploadsEnabled: boolean; loadMore: () => Promise; loadMoreRecent: () => Promise; markRead: (options?: MarkReadFunctionOptions) => void; @@ -220,7 +223,7 @@ const getAttachmentItemType = (message: LocalMessage) => { attachment.type === FileTypes.Image && !attachment.og_scrape_url && !attachment.title_link && - (!!attachment.image_url || !!attachment.thumb_url); + !!getAttachmentPreviewUrl(attachment, attachment.image_url, attachment.thumb_url); const isGalleryVideo = attachment.type === FileTypes.Video && !attachment.og_scrape_url && isVideoPlayerAvailable(); if (isGalleryImage || isGalleryVideo) { @@ -307,7 +310,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => ? InlineLoadingMoreRecentThreadIndicator : InlineLoadingMoreRecentIndicator; const { - allowSendBeforeAttachmentsUpload, attachmentPickerStore, additionalFlashListProps, channel, @@ -328,6 +330,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => messageInputFloating, messageInputHeightStore, myMessageTheme, + pendingUploadsEnabled, readEvents, noGroupByUser, onListScroll, @@ -1268,7 +1271,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => ); @@ -1348,13 +1351,12 @@ export const MessageFlashList = (props: MessageFlashListProps) => { } = useMessageListPagination({ channel }); const { threadInstance } = useThreadContext(); const { readEvents } = useOwnCapabilitiesContext(); - const { allowSendBeforeAttachmentsUpload, messageInputFloating, messageInputHeightStore } = - useMessageInputContext(); + const pendingUploadsEnabled = usePendingUploadsEnabled(); + const { messageInputFloating, messageInputHeightStore } = useMessageInputContext(); return ( { messageInputFloating, messageInputHeightStore, myMessageTheme, + pendingUploadsEnabled, readEvents, scrollToFirstUnreadThreshold, hasPendingInitialTargetLoad, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 13600fbb96..8249830f2b 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -48,6 +48,7 @@ import { ChatContextValue, useChatContext } from '../../contexts/chatContext/Cha import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useDebugContext } from '../../contexts/debugContext/DebugContext'; +import { usePendingUploadsEnabled } from '../../contexts/messageInputContext/hooks/usePendingUploadsEnabled'; import { MessageInputContextValue, useMessageInputContext, @@ -211,11 +212,13 @@ type MessageListPropsWithContext = Pick< MessagesContextValue, 'disableTypingIndicator' | 'FlatList' | 'myMessageTheme' | 'shouldShowUnreadUnderlay' > & - Pick< - MessageInputContextValue, - 'allowSendBeforeAttachmentsUpload' | 'messageInputFloating' | 'messageInputHeightStore' - > & + Pick & Pick & { + /** + * Whether the composer lets a message be sent while its attachments are still uploading + * (`messageComposer.attachments.pendingUploadsEnabled`). Read from the composer by default. + */ + pendingUploadsEnabled: boolean; /** * Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want * to attach some additional props to underlying FlatList, you can add it to following prop. @@ -314,7 +317,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { ? InlineLoadingMoreRecentThreadIndicator : InlineLoadingMoreRecentIndicator; const { - allowSendBeforeAttachmentsUpload, animateLayout = true, attachmentPickerStore, additionalFlatListProps, @@ -337,6 +339,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { messageInputFloating, messageInputHeightStore, myMessageTheme, + pendingUploadsEnabled, noGroupByUser, onListScroll, onThreadSelect, @@ -1411,7 +1414,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { ); @@ -1434,8 +1437,8 @@ export const MessageList = (props: MessageListProps) => { const { readEvents } = useOwnCapabilitiesContext(); const { disableTypingIndicator, FlatList, myMessageTheme, shouldShowUnreadUnderlay } = useMessagesContext(); - const { allowSendBeforeAttachmentsUpload, messageInputFloating, messageInputHeightStore } = - useMessageInputContext(); + const pendingUploadsEnabled = usePendingUploadsEnabled(); + const { messageInputFloating, messageInputHeightStore } = useMessageInputContext(); const { loadMore, loadMoreRecent, @@ -1446,7 +1449,6 @@ export const MessageList = (props: MessageListProps) => { return ( { messageInputFloating, messageInputHeightStore, myMessageTheme, + pendingUploadsEnabled, readEvents, scrollToFirstUnreadThreshold, shouldShowUnreadUnderlay, diff --git a/package/src/components/MessageList/hooks/useMarkRead.ts b/package/src/components/MessageList/hooks/useMarkRead.ts index 5084859db2..9f4c8b8455 100644 --- a/package/src/components/MessageList/hooks/useMarkRead.ts +++ b/package/src/components/MessageList/hooks/useMarkRead.ts @@ -52,8 +52,8 @@ export const useMarkRead = (channel: Channel) => { } // Canonical v10 read reporting: the reporter throttles + coordinates the `/read` calls and honors - // any custom markReadRequest handler registered in channel.configState (see - // useChannelRequestHandlers). + // any custom markReadRequest handler registered through + // `client.config.set({ channel: { requestHandlers } })`. client.messageDeliveryReporter.throttledMarkRead(channel); // Reset the paginator's unread snapshot so the "N new messages" banner and the unread separator diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index 3c8b15d8cb..fc09a5d5fe 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -12,6 +12,7 @@ import { } from 'react-native'; import { + getAttachmentPreviewUrl, isFileAttachment, isImageAttachment, isVideoAttachment, @@ -56,7 +57,7 @@ const RightContent = React.memo( } const attachment = attachments?.[0]; - const uri = attachment?.image_url || attachment?.thumb_url; + const uri = getAttachmentPreviewUrl(attachment, attachment?.image_url, attachment?.thumb_url); if ( attachment && diff --git a/package/src/contexts/index.ts b/package/src/contexts/index.ts index c59653016b..9e2b31f371 100644 --- a/package/src/contexts/index.ts +++ b/package/src/contexts/index.ts @@ -15,6 +15,7 @@ export * from './messageInputContext/MessageInputContext'; export * from './messageInputContext/hooks/useMessageComposer'; export * from './messageInputContext/hooks/useAttachmentManagerState'; export * from './messageInputContext/hooks/useMessageComposerHasSendableData'; +export * from './messageInputContext/hooks/usePendingUploadsEnabled'; export * from './messageComposerContext/MessageComposerAPIContext'; export * from './messagesContext/MessagesContext'; export * from './overlayContext/OverlayContext'; diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index da7015845e..cfaf7a5ccf 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -26,11 +26,7 @@ import { parseLinksFromText } from '../../components/Message/MessageItemView/uti import { useAudioRecorder } from '../../components/MessageInput/hooks/useAudioRecorder'; import { useNotificationApi } from '../../components/Notifications'; import { useStableCallback } from '../../hooks/useStableCallback'; -import { - createAttachmentsCompositionMiddleware, - createDraftAttachmentsCompositionMiddleware, - setupVideoAttachmentPreviewMiddleware, -} from '../../middlewares/attachments'; +import { setupVideoAttachmentPreviewMiddleware } from '../../middlewares/attachments'; import { isDocumentPickerAvailable, MediaTypes, NativeHandlers } from '../../native'; import { AudioRecorderManager } from '../../state-store/audio-recorder-manager'; @@ -155,7 +151,6 @@ export type InputMessageInputContextValue = { * @see See https://reactnative.dev/docs/textinput#reference */ additionalTextInputProps?: TextInputProps; - allowSendBeforeAttachmentsUpload?: boolean; closePollCreationDialog?: () => void; /** * Compress image with quality (from 0 to 1, where 1 is best quality). @@ -224,32 +219,25 @@ export const MessageInputProvider = ({ const defaultOpenPollCreationDialog = useCallback(() => setShowPollCreationDialog(true), []); const closePollCreationDialog = useCallback(() => setShowPollCreationDialog(false), []); - const { - openPollCreationDialog: openPollCreationDialogFromContext, - allowSendBeforeAttachmentsUpload, - } = value; + const { openPollCreationDialog: openPollCreationDialogFromContext } = value; const messageComposer = useMessageComposer(); const { attachmentManager, editedMessage } = messageComposer; /** - * These are the RN SDK specific middlewares that are added to the message composer to provide the default behaviour. - * TODO: Discuss and decide if we provide them by default in the SDK or leave it to the user to add them if they want - * the feature. + * Composer middleware this SDK owns. + * + * Sending before uploads finish is deliberately not configured here: it is the composer's own + * `attachments.pendingUploadsEnabled`, defaulted by `` through `client.config` (see + * `usePendingUploadsDefault`). An imperative `updateConfig` here would outrank the integrator's + * `client.config` and setup function, leaving them no way to change it. + * + * The draft composition is deliberately left alone: it keeps successful uploads only, and a + * draft is sent to the server, where a local file URI no other device can read has no business. */ useEffect(() => { setupVideoAttachmentPreviewMiddleware(messageComposer); - - if (allowSendBeforeAttachmentsUpload) { - messageComposer.compositionMiddlewareExecutor.replace([ - createAttachmentsCompositionMiddleware(messageComposer), - ]); - - messageComposer.draftCompositionMiddlewareExecutor.replace([ - createDraftAttachmentsCompositionMiddleware(messageComposer), - ]); - } - }, [allowSendBeforeAttachmentsUpload, messageComposer, attachmentManager]); + }, [messageComposer]); /** * Function for capturing a photo and uploading it diff --git a/package/src/contexts/messageInputContext/__tests__/useMessageComposerHasSendableData.test.tsx b/package/src/contexts/messageInputContext/__tests__/useMessageComposerHasSendableData.test.tsx new file mode 100644 index 0000000000..21c79a7800 --- /dev/null +++ b/package/src/contexts/messageInputContext/__tests__/useMessageComposerHasSendableData.test.tsx @@ -0,0 +1,44 @@ +import { StateStore } from '@stream-io/state-store'; +import { act, renderHook } from '@testing-library/react-native'; + +import { useMessageComposer } from '../hooks/useMessageComposer'; +import { useMessageComposerHasSendableData } from '../hooks/useMessageComposerHasSendableData'; + +jest.mock('../hooks/useMessageComposer', () => ({ + useMessageComposer: jest.fn(), +})); + +type Config = { attachments: { pendingUploadsEnabled: boolean } }; + +/** + * A composer holding one attachment that is still uploading: whether it is sendable depends only + * on `pendingUploadsEnabled`, exactly as `MessageComposer.hasSendableData` decides it. + */ +const createComposer = () => { + const configState = new StateStore({ attachments: { pendingUploadsEnabled: false } }); + const composer = { + configState, + editingAuditState: new StateStore({ lastChange: {} }), + get hasSendableData() { + return configState.getLatestValue().attachments.pendingUploadsEnabled; + }, + }; + jest.mocked(useMessageComposer).mockReturnValue(composer as never); + return composer; +}; + +describe('useMessageComposerHasSendableData', () => { + it('re-renders when pendingUploadsEnabled changes, with nothing else changing', () => { + const { configState } = createComposer(); + + const { result } = renderHook(() => useMessageComposerHasSendableData()); + expect(result.current).toBe(false); + + // What `attachments.pendingUploadsEnabled` flipping at runtime does — config, not composer state. + act(() => { + configState.partialNext({ attachments: { pendingUploadsEnabled: true } }); + }); + + expect(result.current).toBe(true); + }); +}); diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index 7b1c34996d..ae30d44802 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -5,7 +5,6 @@ import type { MessageInputContextValue } from '../MessageInputContext'; export const useCreateMessageInputContext = ({ additionalTextInputProps, - allowSendBeforeAttachmentsUpload, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesSlideToCancelDistance, @@ -49,7 +48,6 @@ export const useCreateMessageInputContext = ({ const messageInputContext: MessageInputContextValue = useMemo( () => ({ additionalTextInputProps, - allowSendBeforeAttachmentsUpload, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, asyncMessagesSlideToCancelDistance, @@ -88,7 +86,7 @@ export const useCreateMessageInputContext = ({ stopVoiceRecording, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [threadId, showPollCreationDialog, allowSendBeforeAttachmentsUpload], + [threadId, showPollCreationDialog], ); return messageInputContext; diff --git a/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts b/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts index e4de4689d7..acaf77a582 100644 --- a/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts +++ b/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts @@ -1,17 +1,17 @@ import type { EditingAuditState } from 'stream-chat'; import { useMessageComposer } from './useMessageComposer'; +import { pendingUploadsEnabledSelector } from './usePendingUploadsEnabled'; import { useStateStore } from '../../../hooks/useStateStore'; -import { useMessageInputContext } from '../MessageInputContext'; const editingAuditStateStateSelector = (state: EditingAuditState) => state; export const useMessageComposerHasSendableData = () => { - const { allowSendBeforeAttachmentsUpload } = useMessageInputContext(); const messageComposer = useMessageComposer(); useStateStore(messageComposer.editingAuditState, editingAuditStateStateSelector); - return allowSendBeforeAttachmentsUpload - ? !messageComposer.contentIsEmpty - : messageComposer.hasSendableData; + // `hasSendableData` also depends on whether pending uploads may be sent, which is composer config + // rather than composer state — so toggling `attachments.pendingUploadsEnabled` re-renders too. + useStateStore(messageComposer.configState, pendingUploadsEnabledSelector); + return messageComposer.hasSendableData; }; diff --git a/package/src/contexts/messageInputContext/hooks/usePendingUploadsEnabled.ts b/package/src/contexts/messageInputContext/hooks/usePendingUploadsEnabled.ts new file mode 100644 index 0000000000..1ff2595e18 --- /dev/null +++ b/package/src/contexts/messageInputContext/hooks/usePendingUploadsEnabled.ts @@ -0,0 +1,20 @@ +import type { MessageComposerConfig } from 'stream-chat'; + +import { useMessageComposer } from './useMessageComposer'; + +import { useStateStore } from '../../../hooks/useStateStore'; + +export const pendingUploadsEnabledSelector = (config: MessageComposerConfig) => ({ + pendingUploadsEnabled: config.attachments.pendingUploadsEnabled, +}); + +/** + * Whether the current composer lets a message be sent while its attachments are still uploading — + * `messageComposer.attachments.pendingUploadsEnabled`, resolved per composer, so a thread composer + * answers for itself. + */ +export const usePendingUploadsEnabled = () => { + const messageComposer = useMessageComposer(); + return useStateStore(messageComposer.configState, pendingUploadsEnabledSelector) + .pendingUploadsEnabled; +}; diff --git a/package/src/index.ts b/package/src/index.ts index 70f840a57b..8aa3184b17 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -20,6 +20,7 @@ export * from './middlewares'; export * from './types/react-native-compat'; export * from './types/types'; +export * from './utils/attachmentUrls'; export * from './utils/patchMessageTextCommand'; export * from './utils/i18n/Streami18n'; export * from './i18n'; diff --git a/package/src/middlewares/__tests__/attachments.test.ts b/package/src/middlewares/__tests__/attachments.test.ts index 06b26cb668..395fd00635 100644 --- a/package/src/middlewares/__tests__/attachments.test.ts +++ b/package/src/middlewares/__tests__/attachments.test.ts @@ -1,75 +1,95 @@ -import { LocalAttachment, MessageComposer } from 'stream-chat'; +import { AttachmentPreUploadMiddleware, LocalAttachment, MessageComposer } from 'stream-chat'; -import { createAttachmentsCompositionMiddleware } from '../attachments'; +import { setupVideoAttachmentPreviewMiddleware } from '../attachments'; -type AttachmentUploadState = 'finished' | 'blocked' | 'pending' | 'uploading' | 'failed'; - -const createLocalImageAttachment = ( - id: string, - uploadState: AttachmentUploadState, -): LocalAttachment => - ({ - image_url: `file://local/${id}`, - localMetadata: { - file: { name: id, uri: `file://local/${id}` }, - id, - uploadState, - }, - // custom marker that survives localAttachmentToAttachment mapping, used to - // identify which attachments ended up in the composed message - title: id, - type: 'image', - }) as unknown as LocalAttachment; +type InsertedMiddleware = { + middleware: AttachmentPreUploadMiddleware[]; + position: { after?: string; before?: string }; + unique?: boolean; +}; -const runComposeHandler = (attachments: LocalAttachment[]) => { +const install = () => { + const insert = jest.fn(); const composer = { - attachmentManager: { attachments }, + attachmentManager: { preUploadMiddlewareExecutor: { insert } }, } as unknown as MessageComposer; - const middleware = createAttachmentsCompositionMiddleware(composer); + setupVideoAttachmentPreviewMiddleware(composer); + + return { inserted: insert.mock.calls[0][0] as InsertedMiddleware }; +}; +const runPrepare = (attachment?: LocalAttachment) => { + const { inserted } = install(); const next = jest.fn((value: unknown) => value); const forward = jest.fn(); - const state = { - localMessage: { attachments: [] }, - message: { attachments: [] }, - }; - middleware.handlers.compose({ + inserted.middleware[0].handlers.prepare({ forward, next, - state, - } as unknown as Parameters[0]); + state: { attachment }, + } as unknown as Parameters[0]); return { forward, next }; }; -describe('createAttachmentsCompositionMiddleware', () => { - it('excludes blocked attachments from the composed message', () => { - const { forward, next } = runComposeHandler([ - createLocalImageAttachment('finished-attachment', 'finished'), - createLocalImageAttachment('blocked-attachment', 'blocked'), - createLocalImageAttachment('pending-attachment', 'pending'), - ]); +const localVideoAttachment = (overrides: Record = {}) => + ({ + localMetadata: { + file: { name: 'clip.mp4', uri: 'file://local/clip.mp4' }, + id: 'clip', + uploadState: 'uploading', + }, + thumb_url: 'file://local/clip-thumb.jpg', + type: 'video', + ...overrides, + }) as unknown as LocalAttachment; + +describe('setupVideoAttachmentPreviewMiddleware', () => { + it('inserts the preview middleware after the upload-config check, without duplicating it', () => { + const { inserted } = install(); + + expect(inserted.middleware).toHaveLength(1); + expect(inserted.middleware[0].id).toBe( + 'stream-io/message-composer-ui-middleware/video-attachment-preview', + ); + // After the config check, so a blocked file is rejected on its real size rather than the + // thumbnail's, and `unique` so a re-render cannot stack copies of it. + expect(inserted.position).toEqual({ + after: 'stream-io/attachment-manager-middleware/file-upload-config-check', + }); + expect(inserted.unique).toBe(true); + }); + + it('uses the thumbnail as the preview uri of a local video', () => { + const { forward, next } = runPrepare(localVideoAttachment()); expect(forward).not.toHaveBeenCalled(); expect(next).toHaveBeenCalledTimes(1); - const composedState = next.mock.calls[0][0] as { - message: { attachments: { title?: string }[] }; - }; - const titles = composedState.message.attachments.map((attachment) => attachment.title); + const state = next.mock.calls[0][0] as { attachment: LocalAttachment }; + expect(state.attachment.localMetadata.previewUri).toBe('file://local/clip-thumb.jpg'); + // Everything else is carried through untouched. + expect(state.attachment.localMetadata.id).toBe('clip'); + expect(state.attachment.thumb_url).toBe('file://local/clip-thumb.jpg'); + }); + + it('leaves a video without a thumbnail without a preview uri', () => { + const { next } = runPrepare(localVideoAttachment({ thumb_url: undefined })); - expect(titles).toEqual(['finished-attachment', 'pending-attachment']); + const state = next.mock.calls[0][0] as { attachment: LocalAttachment }; + expect(state.attachment.localMetadata.previewUri).toBeUndefined(); }); - it('forwards without attachments when every attachment is blocked', () => { - const { forward, next } = runComposeHandler([ - createLocalImageAttachment('blocked-1', 'blocked'), - createLocalImageAttachment('blocked-2', 'blocked'), - ]); + it('forwards anything that is not a local video', () => { + const image = runPrepare( + localVideoAttachment({ image_url: 'file://local/photo.jpg', type: 'image' }), + ); + expect(image.next).not.toHaveBeenCalled(); + expect(image.forward).toHaveBeenCalledTimes(1); - expect(next).not.toHaveBeenCalled(); - expect(forward).toHaveBeenCalledTimes(1); + const none = runPrepare(undefined); + expect(none.next).not.toHaveBeenCalled(); + expect(none.forward).toHaveBeenCalledTimes(1); }); }); diff --git a/package/src/middlewares/attachments.ts b/package/src/middlewares/attachments.ts index 8869e8197c..00bbeb7766 100644 --- a/package/src/middlewares/attachments.ts +++ b/package/src/middlewares/attachments.ts @@ -1,125 +1,16 @@ import { - Attachment, AttachmentPreUploadMiddleware, - FileReference, - isLocalImageAttachment, isLocalVideoAttachment, - LocalAttachment, MessageComposer, - MessageComposerMiddlewareState, - MessageCompositionMiddleware, - MessageDraftComposerMiddlewareValueState, - MessageDraftCompositionMiddleware, - MiddlewareHandlerParams, } from 'stream-chat'; -import { isLocalUrl } from '../utils/utils'; - -export const localAttachmentToAttachment = (localAttachment: LocalAttachment) => { - const { localMetadata, ...attachment } = localAttachment; - - if (isLocalImageAttachment(localAttachment)) { - const isRemoteUri = !!attachment.image_url && !isLocalUrl(attachment.image_url); - - if (isRemoteUri) return attachment as Attachment; - - return { - ...attachment, - custom: { - ...attachment.custom, - localId: localMetadata?.id, - originalFile: localMetadata.file, - }, - image_url: localMetadata?.previewUri, - } as Attachment; - } else { - const isRemoteUri = !!attachment.asset_url && !isLocalUrl(attachment.asset_url); - if (isRemoteUri) return attachment as Attachment; - - return { - ...attachment, - asset_url: (localMetadata.file as FileReference).uri, - custom: { - ...attachment.custom, - localId: localMetadata?.id, - originalFile: localMetadata.file, - }, - } as Attachment; - } -}; - -export const createAttachmentsCompositionMiddleware = ( - composer: MessageComposer, -): MessageCompositionMiddleware => ({ - handlers: { - compose: ({ - state, - next, - forward, - }: MiddlewareHandlerParams) => { - const { attachmentManager } = composer; - if (!attachmentManager) return forward(); - - const attachments = (state.message.attachments ?? []).concat( - attachmentManager.attachments - // Blocked attachments (e.g. over the size limit) are permanent - // validation failures the server will reject, so never serialize - // them into the outgoing message. - .filter((attachment) => attachment.localMetadata.uploadState !== 'blocked') - .map(localAttachmentToAttachment), - ); - - // prevent introducing attachments array into the payload sent to the server - if (!attachments.length) return forward(); - - return next({ - ...state, - localMessage: { - ...state.localMessage, - attachments: [...attachments], - }, - message: { - ...state.message, - attachments: [...attachments], - }, - }); - }, - }, - id: 'stream-io/message-composer-middleware/attachments', -}); - -export const createDraftAttachmentsCompositionMiddleware = ( - composer: MessageComposer, -): MessageDraftCompositionMiddleware => ({ - handlers: { - compose: ({ - state, - next, - forward, - }: MiddlewareHandlerParams) => { - const { attachmentManager } = composer; - if (!attachmentManager) return forward(); - - const attachments = (state.draft.attachments ?? []).concat( - attachmentManager.attachments - // Don't persist blocked attachments (e.g. over the size limit) into - // the draft — they are permanent validation failures. - .filter((attachment) => attachment.localMetadata.uploadState !== 'blocked') - .map(localAttachmentToAttachment), - ); - - return next({ - ...state, - draft: { - ...state.draft, - attachments, - }, - }); - }, - }, - id: 'stream-io/message-composer-middleware/draft-attachments', -}); - +/** + * Supplies a local video's preview URI. + * + * Every other attachment type gets `localMetadata.previewUri` from `toLocalUploadAttachment`, + * which points it at the picked file itself. A video file is not renderable, so the preview has + * to be the thumbnail the native picker extracted (`thumb_url`) instead. + */ const createVideoAttachmentPreviewMiddleware = (): AttachmentPreUploadMiddleware => ({ id: 'stream-io/message-composer-ui-middleware/video-attachment-preview', handlers: { diff --git a/package/src/state-store/__tests__/image-gallery-state-store.test.ts b/package/src/state-store/__tests__/image-gallery-state-store.test.ts index 0b35cff349..53833bde17 100644 --- a/package/src/state-store/__tests__/image-gallery-state-store.test.ts +++ b/package/src/state-store/__tests__/image-gallery-state-store.test.ts @@ -5,6 +5,7 @@ import { generateVideoAttachment, } from '../../mock-builders/generator/attachment'; import { generateMessage } from '../../mock-builders/generator/message'; +import { getPlayableVideoUrl } from '../../utils/attachmentUrls'; import { getUrlOfImageAttachment } from '../../utils/getUrlOfImageAttachment'; import { ImageGalleryStateStore } from '../image-gallery-state-store'; import { VideoPlayerPool } from '../video-player-pool'; @@ -494,6 +495,52 @@ describe('ImageGalleryStateStore', () => { ); }); + // An attachment whose upload has not resolved: no URL of its own, only `localMetadata`. + const pendingAttachment = (type: 'image' | 'video') => + ({ + localMetadata: { + file: { name: `clip.${type}`, uri: `file://local/${type}` }, + id: `upload-${type}`, + // What `setupVideoAttachmentPreviewMiddleware` leaves for a video: the thumbnail. + previewUri: type === 'video' ? 'file://local/video-thumb.jpg' : `file://local/${type}`, + uploadState: 'uploading', + }, + thumb_url: type === 'video' ? 'file://local/video-thumb.jpg' : undefined, + type, + }) as unknown as Attachment; + + it('plays the local video file, not its thumbnail, while the upload is in flight', () => { + const store = new ImageGalleryStateStore(); + store.messages = [generateMessage({ attachments: [pendingAttachment('video')], id: '1' })]; + + expect(store.assets[0].uri).toBe('file://local/video'); + expect(store.assets[0].thumb_url).toBe('file://local/video-thumb.jpg'); + }); + + it('shows the local preview of an image while the upload is in flight', () => { + const store = new ImageGalleryStateStore(); + store.messages = [generateMessage({ attachments: [pendingAttachment('image')], id: '1' })]; + + expect(store.assets[0].uri).toBe('file://local/image'); + }); + + it('selects a pending video by the url its gallery tile opens with', () => { + const store = new ImageGalleryStateStore(); + const video = pendingAttachment('video'); + store.messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/a.jpg' }), video], + id: '1', + }), + ]; + + // The tile is built from the attachment as `Message` hands it to the gallery — `image_url` + // rewritten to the playable source — while the store reads the raw one. Both must resolve to + // the same URL, or the tapped video is not the slide the gallery opens on. + const tileUrl = getUrlOfImageAttachment({ ...video, image_url: getPlayableVideoUrl(video) }); + expect(store.assets.findIndex((asset) => asset.uri === tileUrl)).toBe(1); + }); + it('should handle messages with multiple attachments', () => { const store = new ImageGalleryStateStore(); const message1 = generateMessage({ diff --git a/package/src/types/types.ts b/package/src/types/types.ts index eadacca8c5..05d9fde427 100644 --- a/package/src/types/types.ts +++ b/package/src/types/types.ts @@ -42,11 +42,17 @@ export type UploadAttachmentPreviewProps = { removeAttachments: (ids: string[]) => void; }; -export interface DefaultAttachmentData { - originalFile?: File; - /** Matches `LocalAttachment.localMetadata.id` / `uploadManager` record id for pending uploads */ - localId?: string; -} +/** + * This SDK adds nothing of its own to an attachment's custom data. + * + * An attachment whose upload has not resolved keeps `localMetadata` (`id`, `file`, `previewUri`) + * from `stream-chat`'s own composition, which is what the message list joins to the live + * `uploadManager` record and what the send path needs to upload or retry. The SDK used to smuggle + * that through `custom.localId` / `custom.originalFile` instead, squatting in data that belongs to + * the integrator. `mime_type` and `file_size` come from `stream-chat` itself. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface DefaultAttachmentData {} export interface DefaultUserData { image?: string; diff --git a/package/src/utils/attachmentUrls.ts b/package/src/utils/attachmentUrls.ts new file mode 100644 index 0000000000..d1f51a8075 --- /dev/null +++ b/package/src/utils/attachmentUrls.ts @@ -0,0 +1,19 @@ +import { Attachment, FileReference, isLocalUploadAttachment, LocalAttachment } from 'stream-chat'; + +const localFileUri = (attachment: Attachment | LocalAttachment) => + isLocalUploadAttachment(attachment) + ? (attachment.localMetadata.file as FileReference | undefined)?.uri + : undefined; + +/** + * The URL to play a video from — `asset_url` / `image_url`, or the local file it has yet to be + * uploaded from. + * + * Only videos need this. For them `setupVideoAttachmentPreviewMiddleware` replaces + * `localMetadata.previewUri` with the thumbnail the picker extracted, so `stream-chat`'s + * `getAttachmentPreviewUrl` would play the thumbnail. For every other type the LLC's + * `toLocalUploadAttachment` sets `previewUri` to the picked file's own `uri`, so use + * `getAttachmentPreviewUrl` there. + */ +export const getPlayableVideoUrl = (attachment: Attachment | LocalAttachment) => + attachment.asset_url ?? attachment.image_url ?? localFileUri(attachment); diff --git a/package/src/utils/getUrlOfImageAttachment.ts b/package/src/utils/getUrlOfImageAttachment.ts index ec979d74b4..8f5de10c36 100644 --- a/package/src/utils/getUrlOfImageAttachment.ts +++ b/package/src/utils/getUrlOfImageAttachment.ts @@ -1,4 +1,6 @@ -import type { Attachment } from 'stream-chat'; +import { type Attachment, getAttachmentPreviewUrl } from 'stream-chat'; + +import { getPlayableVideoUrl } from './attachmentUrls'; import { FileTypes } from '../types/types'; @@ -15,5 +17,10 @@ export function getUrlOfImageAttachment( return image.giphy?.[giphyVersion]?.url || image.thumb_url; } - return image.image_url || image.asset_url; + // Videos open in the player, so resolve the playable file rather than the thumbnail + if (image.type === FileTypes.Video) { + return getPlayableVideoUrl(image); + } + + return getAttachmentPreviewUrl(image, image.image_url, image.asset_url); } diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index 81e71c7e5e..5ba107cff0 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -52,14 +52,14 @@ type IndicatorStatesMap = Record; export const getIndicatorTypeForFileState = ( fileState: AttachmentLoadingState, - allowSendBeforeAttachmentsUpload: boolean, + pendingUploadsEnabled: boolean, ): Progress | undefined => { const indicatorMap: IndicatorStatesMap = { - [FileState.UPLOADING]: allowSendBeforeAttachmentsUpload + [FileState.UPLOADING]: pendingUploadsEnabled ? ProgressIndicatorTypes.INACTIVE : ProgressIndicatorTypes.IN_PROGRESS, [FileState.BLOCKED]: ProgressIndicatorTypes.NOT_SUPPORTED, - [FileState.FAILED]: allowSendBeforeAttachmentsUpload + [FileState.FAILED]: pendingUploadsEnabled ? ProgressIndicatorTypes.INACTIVE : ProgressIndicatorTypes.RETRY, [FileState.PENDING]: ProgressIndicatorTypes.PENDING, @@ -93,8 +93,8 @@ export const isBouncedMessage = (message: LocalMessage) => */ export const isEditedMessage = (message: LocalMessage) => message.message_text_updated_at != null; -export const makeImageCompatibleUrl = (url: string) => - (url.indexOf('//') === 0 ? `https:${url}` : url).trim(); +export const makeImageCompatibleUrl = (url?: string) => + url ? (url.indexOf('//') === 0 ? `https:${url}` : url).trim() : url; export const getUrlWithoutParams = (url?: string) => { if (!url) { @@ -109,7 +109,14 @@ export const getUrlWithoutParams = (url?: string) => { return url.substring(0, url.indexOf('?')); }; -export const isLocalUrl = (url: string) => !url.includes('http'); +/** + * Whether this URL points at a file on the device rather than something fetchable. + * + * Anchored on the scheme rather than searching for `http` anywhere in the string: a + * `content://`/`ph://` URI is free to contain that substring, and an attachment mid-upload is + * rendered from exactly such a URI. Undefined is "not local" — there is no local file to read. + */ +export const isLocalUrl = (url?: string) => !!url && !/^https?:\/\//i.test(url.trim()); export const generateRandomId = (a = ''): string => a