diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b69600b77..72b30a4299 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,51 +23,6 @@ jobs: - name: ๐Ÿงช tsc run: yarn types --noEmit - e2e: - runs-on: ubuntu-latest - name: End-to-end tests - steps: - - uses: actions/checkout@v3 - - - name: ๐Ÿ’พ Cache Dependencies - uses: actions/cache@v3 - with: - path: ./node_modules - key: ${{ runner.os }}-${{ matrix.node }}-modules-${{ hashFiles('**/yarn.lock') }} - - - name: ๐Ÿ”จ Install Dependencies - run: yarn install --frozen-lockfile --ignore-engines --ignore-scripts - - - name: โš—๏ธ End-to-end tests - run: | - npx playwright install - npx playwright install-deps - yarn e2e-fixtures - # running with --browser=all causes failures - yarn e2e --browser=chromium - yarn e2e --browser=webkit - yarn e2e --browser=firefox - env: - E2E_JUMP_TO_MESSAGE_CHANNEL: jump-to-message - E2E_ADD_MESSAGE_CHANNEL: add-message - E2E_TEST_USER_1: test-user-1 - E2E_TEST_USER_2: test-user-2 - E2E_APP_KEY: ${{ secrets.E2E_APP_KEY }} - E2E_APP_SECRET: ${{ secrets.E2E_APP_SECRET }} - E2E_TEST_USER_1_TOKEN: ${{ secrets.E2E_TEST_USER_1_TOKEN }} - E2E_TEST_USER_2_TOKEN: ${{ secrets.E2E_TEST_USER_2_TOKEN }} - E2E_ADDITIONAL_CHANNELS: mr-channel-1, mr-channel-2, edit-message-channel, pin-message-channel - E2E_LONG_MESSAGE_LISTS_CHANNEL: navigate-long-message-lists - E2E_ATTACHMENT_SIZING_CHANNEL: attachment-sizing - - - name: ๐ŸŽฅ Upload Artifacts - uses: actions/upload-artifact@v3 - if: ${{ always() }} - with: - name: E2E_Artifacts - path: ./test-results - retention-days: 1 - test: runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000000..c6c20fd46e --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,49 @@ +name: E2E + +on: [push] + +jobs: + e2e: + runs-on: ubuntu-latest + name: End-to-end tests + steps: + - uses: actions/checkout@v3 + + - name: ๐Ÿ’พ Cache Dependencies + uses: actions/cache@v3 + with: + path: ./node_modules + key: ${{ runner.os }}-${{ matrix.node }}-modules-${{ hashFiles('**/yarn.lock') }} + + - name: ๐Ÿ”จ Install Dependencies + run: yarn install --frozen-lockfile --ignore-engines --ignore-scripts + + - name: โš—๏ธ End-to-end tests + run: | + npx playwright install + npx playwright install-deps + yarn e2e-fixtures + # running with --browser=all causes failures + yarn e2e --browser=chromium + yarn e2e --browser=webkit + yarn e2e --browser=firefox + env: + E2E_JUMP_TO_MESSAGE_CHANNEL: jump-to-message + E2E_ADD_MESSAGE_CHANNEL: add-message + E2E_TEST_USER_1: test-user-1 + E2E_TEST_USER_2: test-user-2 + E2E_APP_KEY: ${{ secrets.E2E_APP_KEY }} + E2E_APP_SECRET: ${{ secrets.E2E_APP_SECRET }} + E2E_TEST_USER_1_TOKEN: ${{ secrets.E2E_TEST_USER_1_TOKEN }} + E2E_TEST_USER_2_TOKEN: ${{ secrets.E2E_TEST_USER_2_TOKEN }} + E2E_ADDITIONAL_CHANNELS: mr-channel-1, mr-channel-2, edit-message-channel, pin-message-channel + E2E_LONG_MESSAGE_LISTS_CHANNEL: navigate-long-message-lists + E2E_ATTACHMENT_SIZING_CHANNEL: attachment-sizing + + - name: ๐ŸŽฅ Upload Artifacts + uses: actions/upload-artifact@v3 + if: ${{ always() }} + with: + name: E2E_Artifacts + path: ./test-results + retention-days: 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index e999442acc..e930aff2d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +## [10.20.1](https://github.com/GetStream/stream-chat-react/compare/v10.20.0...v10.20.1) (2023-11-20) + + +### Bug Fixes + +* calculate pagination stop from custom channel query message limit ([#2180](https://github.com/GetStream/stream-chat-react/issues/2180)) ([8374af1](https://github.com/GetStream/stream-chat-react/commit/8374af1048b81c307d0687d7730df6a96633b7e6)) + +## [10.20.0](https://github.com/GetStream/stream-chat-react/compare/v10.19.0...v10.20.0) (2023-11-16) + + +### Bug Fixes + +* lift notifications above modal overlay ([#2175](https://github.com/GetStream/stream-chat-react/issues/2175)) ([17d98f4](https://github.com/GetStream/stream-chat-react/commit/17d98f40eaea0a134a501deea14605b71d965871)) + + +### Features + +* allow to configure channel query options ([#2177](https://github.com/GetStream/stream-chat-react/issues/2177)) ([4f91d9a](https://github.com/GetStream/stream-chat-react/commit/4f91d9a65e752f4bcab2000f5d633b57ae4d6b0e)) + +## [10.19.0](https://github.com/GetStream/stream-chat-react/compare/v10.18.0...v10.19.0) (2023-11-14) + + +### Features + +* expose optional remark plugin to keep all line breaks and keep HTML in message text ([#2170](https://github.com/GetStream/stream-chat-react/issues/2170)) ([5b191c9](https://github.com/GetStream/stream-chat-react/commit/5b191c94de6ec4ff483be65b15ea00e754e0eb47)) +* introduce MessageListContext ([#2166](https://github.com/GetStream/stream-chat-react/issues/2166)) ([8dcb1ac](https://github.com/GetStream/stream-chat-react/commit/8dcb1acd5e8e3465d6b457b07f5de2923ac2daed)) + ## [10.18.0](https://github.com/GetStream/stream-chat-react/compare/v10.17.0...v10.18.0) (2023-11-07) diff --git a/README.md b/README.md index 4ee9294c9f..434a879328 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ > building chat applications. [![NPM](https://img.shields.io/npm/v/stream-chat-react.svg)](https://www.npmjs.com/package/stream-chat-react) -[![build](https://github.com/GetStream/stream-chat-react/workflows/test/badge.svg)](https://github.com/GetStream/stream-chat-react/actions) +[![build](https://github.com/GetStream/stream-chat-react/actions/workflows/ci.yml/badge.svg)](https://github.com/GetStream/stream-chat-react/actions) [![Component Reference](https://img.shields.io/badge/docs-component%20reference-blue.svg)](https://getstream.io/chat/docs/sdk/react/) [![codecov](https://codecov.io/gh/GetStream/stream-chat-react/branch/master/graph/badge.svg)](https://codecov.io/gh/GetStream/stream-chat-react) diff --git a/docusaurus/docs/React/components/core-components/channel.mdx b/docusaurus/docs/React/components/core-components/channel.mdx index 4ff40420c5..a858655bb7 100644 --- a/docusaurus/docs/React/components/core-components/channel.mdx +++ b/docusaurus/docs/React/components/core-components/channel.mdx @@ -176,6 +176,39 @@ Custom UI component to display a user's avatar. | --------- | ---------------------------------------------------------- | | component | | +### channelQueryOptions + +Optional configuration parameters used for the initial channel query. Applied only if the value of `channel.initialized` is false. If the channel instance has already been initialized (channel has been queried), then the channel query will be skipped and channelQueryOptions will not be applied. + +In the example below, we specify, that the first page of messages when a channel is queried should have 20 messages (the default is 100). Note that the `channel` prop has to be passed along `channelQueryOptions`. + +```tsx +import {ChannelQueryOptions} from "stream-chat"; +import {Channel, useChatContext} from "stream-chat-react"; + +const channelQueryOptions: ChannelQueryOptions = { + messages: { limit: 20 }, +}; + +type ChannelRendererProps = { + id: string; + type: string; +}; + +const ChannelRenderer = ({id, type}: ChannelRendererProps) => { + const { client } = useChatContext(); + return ( + + {/* Channel children */} + + ); +} +``` + +| Type | +|-----------------------| +| `ChannelQueryOptions` | + ### CooldownTimer Custom UI component to display the slow mode cooldown timer. diff --git a/package.json b/package.json index f8fe81178d..3d87a1b818 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dependencies": { "@braintree/sanitize-url": "^6.0.4", "@popperjs/core": "^2.11.5", - "@stream-io/stream-chat-css": "^3.13.0", + "@stream-io/stream-chat-css": "^3.14.2", "clsx": "^2.0.0", "dayjs": "^1.10.4", "emoji-mart": "3.0.1", diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 3a0e7915f8..1b8700a312 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -14,6 +14,7 @@ import throttle from 'lodash.throttle'; import { ChannelAPIResponse, ChannelMemberResponse, + ChannelQueryOptions, ChannelState, Event, logChatPromiseExecution, @@ -67,7 +68,7 @@ import { DEFAULT_THREAD_PAGE_SIZE, } from '../../constants/limits'; -import { hasMoreMessagesProbably, hasNotMoreMessages } from '../MessageList/utils'; +import { hasMoreMessagesProbably } from '../MessageList/utils'; import defaultEmojiData from '../../stream-emoji.json'; import { makeAddNotifications } from './utils'; import { getChannel } from '../../utils/getChannel'; @@ -93,14 +94,9 @@ import { } from '../Attachment/attachment-sizing'; import type { URLEnrichmentConfig } from '../MessageInput/hooks/useLinkPreviews'; -export type ChannelProps< - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, - V extends CustomTrigger = CustomTrigger +type ChannelPropsForwardedToComponentContext< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics > = { - /** List of accepted file types */ - acceptedFiles?: string[]; - /** Custom handler function that runs when the active channel has unread messages (i.e., when chat is running on a separate browser tab) */ - activeUnreadHandler?: (unread: number, documentTitle: string) => void; /** Custom UI component to display a message attachment, defaults to and accepts same props as: [Attachment](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/Attachment.tsx) */ Attachment?: ComponentContextValue['Attachment']; /** Custom UI component to display a attachment previews in MessageInput, defaults to and accepts same props as: [Attachment](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/AttachmentPreviewList.tsx) */ @@ -113,74 +109,22 @@ export type ChannelProps< AutocompleteSuggestionList?: ComponentContextValue['AutocompleteSuggestionList']; /** UI component to display a user's avatar, defaults to and accepts same props as: [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) */ Avatar?: ComponentContextValue['Avatar']; - /** The connected and active channel */ - channel?: StreamChannel; /** Custom UI component to display the slow mode cooldown timer, defaults to and accepts same props as: [CooldownTimer](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/hooks/useCooldownTimer.tsx) */ CooldownTimer?: ComponentContextValue['CooldownTimer']; /** Custom UI component for date separators, defaults to and accepts same props as: [DateSeparator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/DateSeparator.tsx) */ DateSeparator?: ComponentContextValue['DateSeparator']; - /** Custom action handler to override the default `client.deleteMessage(message.id)` function */ - doDeleteMessageRequest?: ( - message: StreamMessage, - ) => Promise>; - /** Custom action handler to override the default `channel.markRead` request function (advanced usage only) */ - doMarkReadRequest?: ( - channel: StreamChannel, - ) => Promise> | void; - /** Custom action handler to override the default `channel.sendMessage` request function (advanced usage only) */ - doSendMessageRequest?: ( - channelId: string, - message: Message, - options?: SendMessageOptions, - ) => ReturnType['sendMessage']> | void; - /** Custom action handler to override the default `client.updateMessage` request function (advanced usage only) */ - doUpdateMessageRequest?: ( - cid: string, - updatedMessage: UpdatedMessage, - options?: UpdateMessageOptions, - ) => ReturnType['updateMessage']>; - /** If true, chat users will be able to drag and drop file uploads to the entire channel window */ - dragAndDropWindow?: boolean; /** Custom UI component to override default edit message input, defaults to and accepts same props as: [EditMessageForm](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/EditMessageForm.tsx) */ EditMessageInput?: ComponentContextValue['EditMessageInput']; - /** Custom UI component to override default `NimbleEmoji` from `emoji-mart` */ - Emoji?: EmojiContextValue['Emoji']; - /** Custom prop to override default `facebook.json` emoji data set from `emoji-mart` */ - emojiData?: EmojiMartData; /** Custom UI component for emoji button in input, defaults to and accepts same props as: [EmojiIconSmall](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/icons.tsx) */ EmojiIcon?: ComponentContextValue['EmojiIcon']; - /** Custom UI component to override default `NimbleEmojiIndex` from `emoji-mart` */ - EmojiIndex?: EmojiContextValue['EmojiIndex']; - /** Custom UI component to override default `NimblePicker` from `emoji-mart` */ - EmojiPicker?: EmojiContextValue['EmojiPicker']; - /** Custom UI component to be shown if no active channel is set, defaults to null and skips rendering the Channel component */ - EmptyPlaceholder?: React.ReactElement; /** Custom UI component to be displayed when the `MessageList` is empty, defaults to and accepts same props as: [EmptyStateIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx) */ EmptyStateIndicator?: ComponentContextValue['EmptyStateIndicator']; - /** - * A global flag to toggle the URL enrichment and link previews in `MessageInput` components. - * By default, the feature is disabled. Can be overridden on Thread, MessageList level through additionalMessageInputProps - * or directly on MessageInput level through urlEnrichmentConfig. - */ - enrichURLForPreview?: URLEnrichmentConfig['enrichURLForPreview']; - /** Global configuration for link preview generation in all the MessageInput components */ - enrichURLForPreviewConfig?: Omit; /** Custom UI component for file upload icon, defaults to and accepts same props as: [FileUploadIcon](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/icons.tsx) */ FileUploadIcon?: ComponentContextValue['FileUploadIcon']; /** Custom UI component to render a Giphy preview in the `VirtualizedMessageList` */ GiphyPreviewMessage?: ComponentContextValue['GiphyPreviewMessage']; - /** The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default */ - giphyVersion?: GiphyVersions; /** Custom UI component to render at the top of the `MessageList` */ HeaderComponent?: ComponentContextValue['HeaderComponent']; - /** A custom function to provide size configuration for image attachments */ - imageAttachmentSizeHandler?: ImageAttachmentSizeHandler; - /** - * Allows to prevent triggering the channel.watch() call when mounting the component. - * That means that no channel data from the back-end will be received neither channel WS events will be delivered to the client. - * Preventing to initialize the channel on mount allows us to postpone the channel creation to a later point in time. - */ - initializeOnMount?: boolean; /** Custom UI component handling how the message input is rendered, defaults to and accepts the same props as [MessageInputFlat](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/MessageInputFlat.tsx) */ Input?: ComponentContextValue['Input']; /** Custom component to render link previews in message input **/ @@ -189,8 +133,6 @@ export type ChannelProps< LoadingErrorIndicator?: React.ComponentType; /** Custom UI component to render while the `MessageList` is loading new messages, defaults to and accepts same props as: [LoadingIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/LoadingIndicator.tsx) */ LoadingIndicator?: ComponentContextValue['LoadingIndicator']; - /** Maximum number of attachments allowed per message */ - maxNumberOfFiles?: number; /** Custom UI component to display a message in the standard `MessageList`, defaults to and accepts the same props as: [MessageSimple](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageSimple.tsx) */ Message?: ComponentContextValue['Message']; /** Custom UI component for a deleted message, defaults to and accepts same props as: [MessageDeleted](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageDeleted.tsx) */ @@ -211,14 +153,6 @@ export type ChannelProps< MessageTimestamp?: ComponentContextValue['MessageTimestamp']; /** Custom UI component for viewing message's image attachments, defaults to and accepts the same props as [ModalGallery](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Gallery/ModalGallery.tsx) */ ModalGallery?: ComponentContextValue['ModalGallery']; - /** Whether to allow multiple attachment uploads */ - multipleUploads?: boolean; - /** Custom action handler function to run on click of an @mention in a message */ - onMentionsClick?: OnMentionAction; - /** Custom action handler function to run on hover of an @mention in a message */ - onMentionsHover?: OnMentionAction; - /** If `dragAndDropWindow` prop is true, the props to pass to the MessageInput component (overrides props placed directly on MessageInput) */ - optionalMessageInputProps?: MessageInputProps; /** Custom UI component to override default pinned message indicator, defaults to and accepts same props as: [PinIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/icons.tsx) */ PinIndicator?: ComponentContextValue['PinIndicator']; /** Custom UI component to override quoted message UI on a sent message, defaults to and accepts same props as: [QuotedMessage](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/QuotedMessage.tsx) */ @@ -231,10 +165,6 @@ export type ChannelProps< ReactionsList?: ComponentContextValue['ReactionsList']; /** Custom UI component for send button, defaults to and accepts same props as: [SendButton](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/icons.tsx) */ SendButton?: ComponentContextValue['SendButton']; - /** You can turn on/off thumbnail generation for video attachments */ - shouldGenerateVideoThumbnail?: boolean; - /** If true, skips the message data string comparison used to memoize the current channel messages (helpful for channels with 1000s of messages) */ - skipMessageDataMemoization?: boolean; /** Custom UI component that displays thread's parent or other message at the top of the `MessageList`, defaults to and accepts same props as [MessageSimple](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageSimple.tsx) */ ThreadHead?: React.ComponentType>; /** Custom UI component to display the header of a `Thread`, defaults to and accepts same props as: [DefaultThreadHeader](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Thread/Thread.tsx) */ @@ -245,12 +175,99 @@ export type ChannelProps< TriggerProvider?: ComponentContextValue['TriggerProvider']; /** Custom UI component for the typing indicator, defaults to and accepts same props as: [TypingIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/TypingIndicator/TypingIndicator.tsx) */ TypingIndicator?: ComponentContextValue['TypingIndicator']; - /** A custom function to provide size configuration for video attachments */ - videoAttachmentSizeHandler?: VideoAttachmentSizeHandler; /** Custom UI component to display a message in the `VirtualizedMessageList`, does not have a default implementation */ VirtualMessage?: ComponentContextValue['VirtualMessage']; }; +type ChannelPropsForwardedToEmojiContext = { + /** Custom UI component to override default `NimbleEmoji` from `emoji-mart` */ + Emoji?: EmojiContextValue['Emoji']; + /** Custom prop to override default `facebook.json` emoji data set from `emoji-mart` */ + emojiData?: EmojiMartData; + /** Custom UI component to override default `NimbleEmojiIndex` from `emoji-mart` */ + EmojiIndex?: EmojiContextValue['EmojiIndex']; + /** Custom UI component to override default `NimblePicker` from `emoji-mart` */ + EmojiPicker?: EmojiContextValue['EmojiPicker']; +}; + +export type ChannelProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + V extends CustomTrigger = CustomTrigger +> = ChannelPropsForwardedToComponentContext & + ChannelPropsForwardedToEmojiContext & { + /** List of accepted file types */ + acceptedFiles?: string[]; + /** Custom handler function that runs when the active channel has unread messages (i.e., when chat is running on a separate browser tab) */ + activeUnreadHandler?: (unread: number, documentTitle: string) => void; + /** The connected and active channel */ + channel?: StreamChannel; + /** + * Optional configuration parameters used for the initial channel query. + * Applied only if the value of channel.initialized is false. + * If the channel instance has already been initialized (channel has been queried), + * then the channel query will be skipped and channelQueryOptions will not be applied. + */ + channelQueryOptions?: ChannelQueryOptions; + /** Custom action handler to override the default `client.deleteMessage(message.id)` function */ + doDeleteMessageRequest?: ( + message: StreamMessage, + ) => Promise>; + /** Custom action handler to override the default `channel.markRead` request function (advanced usage only) */ + doMarkReadRequest?: ( + channel: StreamChannel, + ) => Promise> | void; + /** Custom action handler to override the default `channel.sendMessage` request function (advanced usage only) */ + doSendMessageRequest?: ( + channelId: string, + message: Message, + options?: SendMessageOptions, + ) => ReturnType['sendMessage']> | void; + /** Custom action handler to override the default `client.updateMessage` request function (advanced usage only) */ + doUpdateMessageRequest?: ( + cid: string, + updatedMessage: UpdatedMessage, + options?: UpdateMessageOptions, + ) => ReturnType['updateMessage']>; + /** If true, chat users will be able to drag and drop file uploads to the entire channel window */ + dragAndDropWindow?: boolean; + /** Custom UI component to be shown if no active channel is set, defaults to null and skips rendering the Channel component */ + EmptyPlaceholder?: React.ReactElement; + /** + * A global flag to toggle the URL enrichment and link previews in `MessageInput` components. + * By default, the feature is disabled. Can be overridden on Thread, MessageList level through additionalMessageInputProps + * or directly on MessageInput level through urlEnrichmentConfig. + */ + enrichURLForPreview?: URLEnrichmentConfig['enrichURLForPreview']; + /** Global configuration for link preview generation in all the MessageInput components */ + enrichURLForPreviewConfig?: Omit; + /** The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default */ + giphyVersion?: GiphyVersions; + /** A custom function to provide size configuration for image attachments */ + imageAttachmentSizeHandler?: ImageAttachmentSizeHandler; + /** + * Allows to prevent triggering the channel.watch() call when mounting the component. + * That means that no channel data from the back-end will be received neither channel WS events will be delivered to the client. + * Preventing to initialize the channel on mount allows us to postpone the channel creation to a later point in time. + */ + initializeOnMount?: boolean; + /** Maximum number of attachments allowed per message */ + maxNumberOfFiles?: number; + /** Whether to allow multiple attachment uploads */ + multipleUploads?: boolean; + /** Custom action handler function to run on click of an @mention in a message */ + onMentionsClick?: OnMentionAction; + /** Custom action handler function to run on hover of an @mention in a message */ + onMentionsHover?: OnMentionAction; + /** If `dragAndDropWindow` prop is true, the props to pass to the MessageInput component (overrides props placed directly on MessageInput) */ + optionalMessageInputProps?: MessageInputProps; + /** You can turn on/off thumbnail generation for video attachments */ + shouldGenerateVideoThumbnail?: boolean; + /** If true, skips the message data string comparison used to memoize the current channel messages (helpful for channels with 1000s of messages) */ + skipMessageDataMemoization?: boolean; + /** A custom function to provide size configuration for video attachments */ + videoAttachmentSizeHandler?: VideoAttachmentSizeHandler; + }; + const UnMemoizedChannel = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, V extends CustomTrigger = CustomTrigger @@ -298,7 +315,6 @@ const UnMemoizedChannel = < return
{EmptyPlaceholder}
; } - // @ts-ignore return ; }; @@ -317,6 +333,7 @@ const ChannelInner = < acceptedFiles, activeUnreadHandler, channel, + channelQueryOptions, children, doDeleteMessageRequest, doMarkReadRequest, @@ -359,7 +376,7 @@ const ChannelInner = < const [state, dispatch] = useReducer>( channelReducer, - // channel.initialized === false if client.channels() was not called, e.g. ChannelList is not used + // channel.initialized === false if client.channel().query() was not called, e.g. ChannelList is not used // => Channel will call channel.watch() in useLayoutEffect => state.loading is used to signal the watch() call state { ...initialState, loading: !channel.initialized }, ); @@ -473,6 +490,7 @@ const ChannelInner = < /** * As the channel state is not normalized we re-fetch the channel data. Thus, we avoid having to search for user references in the channel state. */ + // FIXME: we should use channelQueryOptions if they are available await channel.query({ messages: { id_lt: oldestID, limit: DEFAULT_NEXT_CHANNEL_PAGE_SIZE }, watchers: { limit: DEFAULT_NEXT_CHANNEL_PAGE_SIZE }, @@ -512,7 +530,7 @@ const ChannelInner = < } } } - await getChannel({ channel, client, members }); + await getChannel({ channel, client, members, options: channelQueryOptions }); const config = channel.getConfig(); setChannelConfig(config); } catch (e) { @@ -525,7 +543,14 @@ const ChannelInner = < originalTitle.current = document.title; if (!errored) { - dispatch({ channel, type: 'initStateFromChannel' }); + dispatch({ + channel, + hasMore: hasMoreMessagesProbably( + channel.state.messages.length, + channelQueryOptions?.messages?.limit ?? DEFAULT_INITIAL_CHANNEL_PAGE_SIZE, + ), + type: 'initStateFromChannel', + }); if (channel.countUnread() > 0) markRead(); // The more complex sync logic is done in Chat document.addEventListener('visibilitychange', onVisibilityChange); @@ -547,7 +572,13 @@ const ChannelInner = < client.off('user.deleted', handleEvent); notificationTimeouts.forEach(clearTimeout); }; - }, [channel.cid, doMarkReadRequest, channelConfig?.read_events, initializeOnMount]); + }, [ + channel.cid, + channelQueryOptions, + doMarkReadRequest, + channelConfig?.read_events, + initializeOnMount, + ]); useEffect(() => { if (!state.thread) return; @@ -575,7 +606,7 @@ const ChannelInner = < ); const loadMore = async (limit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE) => { - if (!online.current || !window.navigator.onLine) return 0; + if (!online.current || !window.navigator.onLine || !state.hasMore) return 0; // prevent duplicate loading events... const oldestMessage = state?.messages?.[0]; @@ -584,16 +615,6 @@ const ChannelInner = < return 0; } - // initial state loads with up to 25 messages, so if less than 25 no need for additional query - const notHasMore = hasNotMoreMessages( - channel.state.messages.length, - DEFAULT_INITIAL_CHANNEL_PAGE_SIZE, - ); - if (notHasMore) { - loadMoreFinished(false, channel.state.messages); - return channel.state.messages.length; - } - dispatch({ loadingMore: true, type: 'setLoadingMore' }); const oldestID = oldestMessage?.id; @@ -678,6 +699,7 @@ const ChannelInner = < const jumpToLatestMessage = async () => { await channel.state.loadMessageIntoState('latest'); + // FIXME: we cannot rely on constant value 25 as the page size can be customized by integrators const hasMoreOlder = channel.state.messages.length >= 25; loadMoreFinished(hasMoreOlder, channel.state.messages); dispatch({ @@ -890,6 +912,7 @@ const ChannelInner = < ); const loadMoreThread = async (limit: number = DEFAULT_THREAD_PAGE_SIZE) => { + // FIXME: should prevent loading more, if state.thread.reply_count === channel.state.threads[parentID].length if (state.threadLoadingMore || !state.thread) return; dispatch({ type: 'startLoadingThread' }); diff --git a/src/components/Channel/__tests__/Channel.test.js b/src/components/Channel/__tests__/Channel.test.js index 38219cb87c..92a2d56780 100644 --- a/src/components/Channel/__tests__/Channel.test.js +++ b/src/components/Channel/__tests__/Channel.test.js @@ -33,6 +33,19 @@ jest.mock('../../Loading', () => ({ LoadingIndicator: jest.fn(() =>
loading
), })); +const queryChannelWithNewMessages = (newMessages, channel) => + // generate new channel mock from existing channel with new messages added + getOrCreateChannelApi( + generateChannel({ + channel: { + config: channel.getConfig(), + id: channel.id, + type: channel.type, + }, + messages: newMessages, + }), + ); + const MockAvatar = ({ name }) => (
{name} @@ -272,7 +285,103 @@ describe('Channel', () => { renderComponent({ channel, chatClient }); }); - await waitFor(() => expect(watchSpy).toHaveBeenCalledTimes(1)); + await waitFor(() => { + expect(watchSpy).toHaveBeenCalledTimes(1); + expect(watchSpy).toHaveBeenCalledWith(undefined); + }); + }); + + it('should apply channelQueryOptions to channel watch call', async () => { + const { channel, chatClient } = await initClient(); + const watchSpy = jest.spyOn(channel, 'watch'); + const channelQueryOptions = { + messages: { limit: 20 }, + }; + await act(() => { + renderComponent({ channel, channelQueryOptions, chatClient }); + }); + + await waitFor(() => { + expect(watchSpy).toHaveBeenCalledTimes(1); + expect(watchSpy).toHaveBeenCalledWith(channelQueryOptions); + }); + }); + + it('should set hasMore state to false if the initial channel query returns less messages than the default initial page size', async () => { + const { channel, chatClient } = await initClient(); + useMockedApis(chatClient, [queryChannelWithNewMessages([generateMessage()], channel)]); + let hasMore; + await act(() => { + renderComponent({ channel, chatClient }, ({ hasMore: contextHasMore }) => { + hasMore = contextHasMore; + }); + }); + + await waitFor(() => { + expect(hasMore).toBe(false); + }); + }); + + it('should set hasMore state to true if the initial channel query returns count of messages equal to the default initial page size', async () => { + const { channel, chatClient } = await initClient(); + useMockedApis(chatClient, [ + queryChannelWithNewMessages(Array.from({ length: 25 }, generateMessage), channel), + ]); + let hasMore; + await act(() => { + renderComponent({ channel, chatClient }, ({ hasMore: contextHasMore }) => { + hasMore = contextHasMore; + }); + }); + + await waitFor(() => { + expect(hasMore).toBe(true); + }); + }); + + it('should set hasMore state to false if the initial channel query returns less messages than the custom query channels options message limit', async () => { + const { channel, chatClient } = await initClient(); + useMockedApis(chatClient, [queryChannelWithNewMessages([generateMessage()], channel)]); + let hasMore; + const channelQueryOptions = { + messages: { limit: 10 }, + }; + await act(() => { + renderComponent( + { channel, channelQueryOptions, chatClient }, + ({ hasMore: contextHasMore }) => { + hasMore = contextHasMore; + }, + ); + }); + + await waitFor(() => { + expect(hasMore).toBe(false); + }); + }); + + it('should set hasMore state to true if the initial channel query returns count of messages equal custom query channels options message limit', async () => { + const { channel, chatClient } = await initClient(); + const equalCount = 10; + useMockedApis(chatClient, [ + queryChannelWithNewMessages(Array.from({ length: equalCount }, generateMessage), channel), + ]); + let hasMore; + const channelQueryOptions = { + messages: { limit: equalCount }, + }; + await act(() => { + renderComponent( + { channel, channelQueryOptions, chatClient }, + ({ hasMore: contextHasMore }) => { + hasMore = contextHasMore; + }, + ); + }); + + await waitFor(() => { + expect(hasMore).toBe(true); + }); }); it('should not call watch the current channel on mount if channel is initialized', async () => { @@ -375,7 +484,7 @@ describe('Channel', () => { // first, wait for the effect in which the channel is watched, // so we know the event listener is added to the document. - await waitFor(() => expect(watchSpy).toHaveBeenCalledWith()); + await waitFor(() => expect(watchSpy).toHaveBeenCalledWith(undefined)); setTimeout(() => fireEvent(document, new Event('visibilitychange')), 0); await waitFor(() => expect(markReadSpy).toHaveBeenCalledWith()); @@ -586,19 +695,6 @@ describe('Channel', () => { }); describe('loading more messages', () => { - const queryChannelWithNewMessages = (newMessages, channel) => - // generate new channel mock from existing channel with new messages added - getOrCreateChannelApi( - generateChannel({ - channel: { - config: channel.getConfig(), - id: channel.id, - type: channel.type, - }, - messages: newMessages, - }), - ); - const limit = 10; it('should be able to load more messages', async () => { const { channel, chatClient } = await initClient(); @@ -646,7 +742,7 @@ describe('Channel', () => { useMockedApis(chatClient, [queryChannelWithNewMessages(newMessages, channel)]); loadMore(limit); } else { - // If message has been added, set our checker variable so we can verify if hasMore is false. + // If message has been added, set our checker variable, so we can verify if hasMore is false. channelHasMore = hasMore; } }, @@ -694,6 +790,151 @@ describe('Channel', () => { }); await waitFor(() => expect(isLoadingMore).toBe(true)); }); + + it('should not load the second page, if the previous query has returned less then default limit messages', async () => { + const { channel, chatClient } = await initClient(); + const firstPageOfMessages = [generateMessage()]; + useMockedApis(chatClient, [queryChannelWithNewMessages(firstPageOfMessages, channel)]); + let queryNextPageSpy; + let contextMessageCount; + await act(() => { + renderComponent({ channel, chatClient }, ({ loadMore, messages: contextMessages }) => { + queryNextPageSpy = jest.spyOn(channel, 'query'); + contextMessageCount = contextMessages.length; + loadMore(); + }); + }); + + await waitFor(() => { + expect(queryNextPageSpy).not.toHaveBeenCalled(); + expect(chatClient.axiosInstance.post).toHaveBeenCalledTimes(1); + expect(chatClient.axiosInstance.post.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ data: {}, presence: false, state: true, watch: false }), + ); + expect(contextMessageCount).toBe(firstPageOfMessages.length); + }); + }); + it('should load the second page, if the previous query has returned message count equal default messages limit', async () => { + const { channel, chatClient } = await initClient(); + const firstPageMessages = Array.from({ length: 25 }, generateMessage); + const secondPageMessages = Array.from({ length: 15 }, generateMessage); + useMockedApis(chatClient, [queryChannelWithNewMessages(firstPageMessages, channel)]); + let queryNextPageSpy; + let contextMessageCount; + await act(() => { + renderComponent({ channel, chatClient }, ({ loadMore, messages: contextMessages }) => { + queryNextPageSpy = jest.spyOn(channel, 'query'); + contextMessageCount = contextMessages.length; + useMockedApis(chatClient, [queryChannelWithNewMessages(secondPageMessages, channel)]); + loadMore(); + }); + }); + + await waitFor(() => { + expect(queryNextPageSpy).toHaveBeenCalledTimes(1); + expect(chatClient.axiosInstance.post).toHaveBeenCalledTimes(2); + expect(chatClient.axiosInstance.post.mock.calls[0][1]).toMatchObject({ + data: {}, + presence: false, + state: true, + watch: false, + }); + expect(chatClient.axiosInstance.post.mock.calls[1][1]).toMatchObject( + expect.objectContaining({ + data: {}, + messages: { id_lt: firstPageMessages[0].id, limit: 100 }, + state: true, + watchers: { limit: 100 }, + }), + ); + expect(contextMessageCount).toBe(firstPageMessages.length + secondPageMessages.length); + }); + }); + it('should not load the second page, if the previous query has returned less then custom limit messages', async () => { + const { channel, chatClient } = await initClient(); + const channelQueryOptions = { + messages: { limit: 10 }, + }; + const firstPageOfMessages = [generateMessage()]; + useMockedApis(chatClient, [queryChannelWithNewMessages(firstPageOfMessages, channel)]); + let queryNextPageSpy; + let contextMessageCount; + await act(() => { + renderComponent( + { channel, channelQueryOptions, chatClient }, + ({ loadMore, messages: contextMessages }) => { + queryNextPageSpy = jest.spyOn(channel, 'query'); + contextMessageCount = contextMessages.length; + loadMore(channelQueryOptions.messages.limit); + }, + ); + }); + + await waitFor(() => { + expect(queryNextPageSpy).not.toHaveBeenCalled(); + expect(chatClient.axiosInstance.post).toHaveBeenCalledTimes(1); + expect(chatClient.axiosInstance.post.mock.calls[0][1]).toMatchObject({ + data: {}, + messages: { + limit: channelQueryOptions.messages.limit, + }, + presence: false, + state: true, + watch: false, + }); + expect(contextMessageCount).toBe(firstPageOfMessages.length); + }); + }); + it('should load the second page, if the previous query has returned message count equal custom messages limit', async () => { + const { channel, chatClient } = await initClient(); + const equalCount = 10; + const channelQueryOptions = { + messages: { limit: equalCount }, + }; + const firstPageMessages = Array.from({ length: equalCount }, generateMessage); + const secondPageMessages = Array.from({ length: equalCount - 1 }, generateMessage); + useMockedApis(chatClient, [queryChannelWithNewMessages(firstPageMessages, channel)]); + let queryNextPageSpy; + let contextMessageCount; + + await act(() => { + renderComponent( + { channel, channelQueryOptions, chatClient }, + ({ loadMore, messages: contextMessages }) => { + queryNextPageSpy = jest.spyOn(channel, 'query'); + contextMessageCount = contextMessages.length; + useMockedApis(chatClient, [queryChannelWithNewMessages(secondPageMessages, channel)]); + loadMore(channelQueryOptions.messages.limit); + }, + ); + }); + + await waitFor(() => { + expect(queryNextPageSpy).toHaveBeenCalledTimes(1); + expect(chatClient.axiosInstance.post).toHaveBeenCalledTimes(2); + expect(chatClient.axiosInstance.post.mock.calls[0][1]).toMatchObject({ + data: {}, + messages: { + limit: channelQueryOptions.messages.limit, + }, + presence: false, + state: true, + watch: false, + }); + expect(chatClient.axiosInstance.post.mock.calls[1][1]).toMatchObject( + expect.objectContaining({ + data: {}, + messages: { + id_lt: firstPageMessages[0].id, + limit: channelQueryOptions.messages.limit, + }, + state: true, + watchers: { limit: channelQueryOptions.messages.limit }, + }), + ); + expect(contextMessageCount).toBe(firstPageMessages.length + secondPageMessages.length); + }); + }); }); describe('Sending/removing/updating messages', () => { diff --git a/src/components/Channel/channelState.ts b/src/components/Channel/channelState.ts index 415d200ebc..44cd430fd1 100644 --- a/src/components/Channel/channelState.ts +++ b/src/components/Channel/channelState.ts @@ -30,6 +30,7 @@ export type ChannelStateReducerAction< } | { channel: Channel; + hasMore: boolean; type: 'initStateFromChannel'; } | { @@ -132,9 +133,10 @@ export const channelReducer = < } case 'initStateFromChannel': { - const { channel } = action; + const { channel, hasMore } = action; return { ...state, + hasMore, loading: false, members: { ...channel.state.members }, messages: [...channel.state.messages], diff --git a/src/components/InfiniteScrollPaginator/InfiniteScroll.tsx b/src/components/InfiniteScrollPaginator/InfiniteScroll.tsx index 445474d612..aad3c278f7 100644 --- a/src/components/InfiniteScrollPaginator/InfiniteScroll.tsx +++ b/src/components/InfiniteScrollPaginator/InfiniteScroll.tsx @@ -91,7 +91,7 @@ export const InfiniteScroll = (props: PropsWithChildren) => } if (isLoading) return; - + // FIXME: this triggers loadMore call when a user types messages in thread and the scroll container container expands if ( reverseOffset < Number(threshold) && typeof loadPreviousPageFn === 'function' && diff --git a/src/components/MML/__tests__/MML.test.js b/src/components/MML/__tests__/MML.test.js index ad41d2cd1f..932c36db55 100644 --- a/src/components/MML/__tests__/MML.test.js +++ b/src/components/MML/__tests__/MML.test.js @@ -41,7 +41,7 @@ describe('MML', () => { />
`); - }); + }, 10000); it('should render a basic mml', async () => { const tree = await renderComponent({ diff --git a/src/components/MessageInput/index.ts b/src/components/MessageInput/index.ts index 2a7cd048ee..bdb11a9f16 100644 --- a/src/components/MessageInput/index.ts +++ b/src/components/MessageInput/index.ts @@ -5,6 +5,7 @@ export * from './EditMessageForm'; export * from './EmojiPicker'; export * from './hooks'; export * from './icons'; +export * from './LinkPreviewList'; export * from './MessageInput'; export * from './MessageInputFlat'; export * from './MessageInputSmall'; diff --git a/src/components/MessageList/CustomNotification.tsx b/src/components/MessageList/CustomNotification.tsx index 6b6a58b21e..5afe9c809c 100644 --- a/src/components/MessageList/CustomNotification.tsx +++ b/src/components/MessageList/CustomNotification.tsx @@ -18,6 +18,7 @@ const UnMemoizedCustomNotification = (props: PropsWithChildren { expect(tree).toMatchInlineSnapshot(`
children diff --git a/src/components/MessageList/index.ts b/src/components/MessageList/index.ts index 81712a1eb0..065d3818a2 100644 --- a/src/components/MessageList/index.ts +++ b/src/components/MessageList/index.ts @@ -1,6 +1,7 @@ export * from './ConnectionStatus'; // TODO: export this under its own folder export * from './GiphyPreviewMessage'; export * from './MessageList'; +export * from './MessageListNotifications'; export * from './MessageNotification'; export * from './ScrollToBottomButton'; export * from './VirtualizedMessageList'; diff --git a/src/components/MessageList/utils.ts b/src/components/MessageList/utils.ts index 90d361fcb8..95cdc54b3c 100644 --- a/src/components/MessageList/utils.ts +++ b/src/components/MessageList/utils.ts @@ -318,5 +318,6 @@ export const getGroupStyles = < export const hasMoreMessagesProbably = (returnedCountMessages: number, limit: number) => returnedCountMessages === limit; +// @deprecated export const hasNotMoreMessages = (returnedCountMessages: number, limit: number) => returnedCountMessages < limit; diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx index ba9e7f67e5..2fe58fdeb0 100644 --- a/src/components/Thread/Thread.tsx +++ b/src/components/Thread/Thread.tsx @@ -125,6 +125,7 @@ const ThreadInner = < useEffect(() => { if (thread?.id && thread?.reply_count) { + // FIXME: integrators can customize channel query options but cannot customize channel.getReplies() options loadMoreThread(); } }, []); diff --git a/src/utils/getChannel.ts b/src/utils/getChannel.ts index 0a388f6bbc..00962c5711 100644 --- a/src/utils/getChannel.ts +++ b/src/utils/getChannel.ts @@ -1,4 +1,9 @@ -import type { Channel, QueryChannelAPIResponse, StreamChat } from 'stream-chat'; +import type { + Channel, + ChannelQueryOptions, + QueryChannelAPIResponse, + StreamChat, +} from 'stream-chat'; import type { DefaultStreamChatGenerics } from '../types/types'; /** @@ -17,12 +22,14 @@ type GetChannelParams< channel?: Channel; id?: string; members?: string[]; + options?: ChannelQueryOptions; type?: string; }; /** * Calls channel.watch() if it was not already recently called. Waits for watch promise to resolve even if it was invoked previously. * @param client * @param members + * @param options * @param type * @param id * @param channel @@ -34,6 +41,7 @@ export const getChannel = async < client, id, members, + options, type, }: GetChannelParams) => { if (!channel && !type) { @@ -60,7 +68,7 @@ export const getChannel = async < if (queryPromise) { await queryPromise; } else { - WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid] = theChannel.watch(); + WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid] = theChannel.watch(options); await WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid]; delete WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid]; } diff --git a/yarn.lock b/yarn.lock index c01091fba2..5f50534b94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2571,10 +2571,10 @@ crypto-browserify "^3.11.0" process-es6 "^0.11.2" -"@stream-io/stream-chat-css@^3.13.0": - version "3.13.0" - resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-3.13.0.tgz#3c49d17baeddf9d48b4fea377387eca23663b5fe" - integrity sha512-dF0VauSvAVeq+71z9zIru2Jpaj9D3yMK5S2+o1RGApYvGXkl07nS3vcPXv9btZ6c1RFskoVnzG/2xb42P0nleA== +"@stream-io/stream-chat-css@^3.14.2": + version "3.14.2" + resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-3.14.2.tgz#11674178b538e2a7630038a420a642d411ce03de" + integrity sha512-S54qkFRJ4GwQ0uR36z+m3uv1aYD+Ut/tNG0i/9FpjE/mcqQLcpV7GrTYTfZh0hr5w4m3xJ5N+Mruz/A3BHe5mw== "@stream-io/transliterate@^1.5.5": version "1.5.5" @@ -13799,9 +13799,9 @@ stream-browserify@^2.0.1: readable-stream "^2.0.2" stream-chat@^8.13.1: - version "8.14.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-8.14.0.tgz#ba96badaaf6c2d3025f31a6d5c0c5545d883c691" - integrity sha512-WEAssYcY/qSJXVK4B39JZJjyBzLSE4Wn+Gliywm8Nc2cmM0+fJF0853H5jZNy6AEeZhzxzRfxwq71r0FfZKudQ== + version "8.14.3" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-8.14.3.tgz#165402d2ed6fc4085f0cc0121b28c664159f8976" + integrity sha512-GYYf4bfpSdl4Itaw981D7R3OUiSBWUUOQypvUd6tvhs20O76Pu+gR/eOUkpl40jBfYSAFVkbhd/CnDFxJJafug== dependencies: "@babel/runtime" "^7.16.3" "@types/jsonwebtoken" "~9.0.0"