diff --git a/CHANGELOG.md b/CHANGELOG.md index 486657b981..b36b1fa4be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## [12.11.1](https://github.com/GetStream/stream-chat-react/compare/v12.11.0...v12.11.1) (2025-02-11) + + +### Bug Fixes + +* **handleMemberUpdated:** consider both pinned and archived channels ([#2638](https://github.com/GetStream/stream-chat-react/issues/2638)) ([ae66de8](https://github.com/GetStream/stream-chat-react/commit/ae66de8d3c5ed78fbe5aa5388ebf8a1b8e90a9e9)) + +## [12.11.0](https://github.com/GetStream/stream-chat-react/compare/v12.10.0...v12.11.0) (2025-02-07) + + +### Bug Fixes + +* change useStateStore to use useSyncExternalStore ([#2573](https://github.com/GetStream/stream-chat-react/issues/2573)) ([6f2de4e](https://github.com/GetStream/stream-chat-react/commit/6f2de4ebd8e8a5b9668b44bb0204ea695c48ff01)) +* make `channel.visible` respect archived and pinned channels ([#2633](https://github.com/GetStream/stream-chat-react/issues/2633)) ([2d2e2e5](https://github.com/GetStream/stream-chat-react/commit/2d2e2e56e6aff92a00d3dd2e6da8c5f7b64698e1)) + + +### Features + +* allow custom ReactionsListModal ([#2632](https://github.com/GetStream/stream-chat-react/issues/2632)) ([a428dc9](https://github.com/GetStream/stream-chat-react/commit/a428dc938388947b1f7e51fde3dd579eba76ea2f)) +* allow to search for channels only ([#2625](https://github.com/GetStream/stream-chat-react/issues/2625)) ([a4d6d83](https://github.com/GetStream/stream-chat-react/commit/a4d6d83cf7c63f3284f0e0c3d0689f64ae725323)) + + +### Chores + +* **deps:** upgrade @stream-io/stream-chat-css to version 5.7.0 ([#2636](https://github.com/GetStream/stream-chat-react/issues/2636)) ([8b7cfba](https://github.com/GetStream/stream-chat-react/commit/8b7cfba7140588f5fd1d727b48b7e6c7da5d99fe)) + ## [12.10.0](https://github.com/GetStream/stream-chat-react/compare/v12.9.0...v12.10.0) (2025-01-28) diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index ba3a21b0da..710545526c 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -150,6 +150,7 @@ type ChannelPropsForwardedToComponentContext< | 'reactionOptions' | 'ReactionSelector' | 'ReactionsList' + | 'ReactionsListModal' | 'SendButton' | 'StartRecordingAudioButton' | 'ThreadHead' @@ -1358,6 +1359,7 @@ const ChannelInner = < reactionOptions: props.reactionOptions, ReactionSelector: props.ReactionSelector, ReactionsList: props.ReactionsList, + ReactionsListModal: props.ReactionsListModal, SendButton: props.SendButton, StartRecordingAudioButton: props.StartRecordingAudioButton, StopAIGenerationButton: props.StopAIGenerationButton, @@ -1417,6 +1419,7 @@ const ChannelInner = < props.QuotedPoll, props.ReactionSelector, props.ReactionsList, + props.ReactionsListModal, props.SendButton, props.StartRecordingAudioButton, props.ThreadHead, diff --git a/src/components/ChannelList/hooks/useChannelListShape.ts b/src/components/ChannelList/hooks/useChannelListShape.ts index 8ecfcb5d0e..b9208f6a5d 100644 --- a/src/components/ChannelList/hooks/useChannelListShape.ts +++ b/src/components/ChannelList/hooks/useChannelListShape.ts @@ -2,7 +2,6 @@ import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef } from 'react'; import { Channel, Event, ExtendableGenerics } from 'stream-chat'; -import uniqBy from 'lodash.uniqby'; import { extractSortValue, @@ -68,7 +67,9 @@ type HandleChannelHiddenParameters = BaseParamet RepeatedParameters; type HandleChannelVisibleParameters = - BaseParameters & RepeatedParameters; + BaseParameters & + RepeatedParameters & + Required, 'sort' | 'filters'>>; type HandleChannelTruncatedParameters = BaseParameters & RepeatedParameters; @@ -194,7 +195,6 @@ export const useChannelListShapeDefaults = () => moveChannelUpwards({ channels, channelToMove: channel, - channelToMoveIndexWithinChannels: -1, sort, }), ); @@ -239,7 +239,6 @@ export const useChannelListShapeDefaults = () => moveChannelUpwards({ channels, channelToMove: channel, - channelToMoveIndexWithinChannels: -1, sort, }), ); @@ -286,6 +285,11 @@ export const useChannelListShapeDefaults = () => const considerPinnedChannels = shouldConsiderPinnedChannels(sort); const considerArchivedChannels = shouldConsiderArchivedChannels(filters); + // `pinned_at` nor `archived` properties are set or channel list order is locked, return early + if ((!considerPinnedChannels && !considerArchivedChannels) || lockChannelOrder) { + return; + } + const pinnedAtSort = extractSortValue({ atIndex: 0, sort, targetKey: 'pinned_at' }); setChannels((currentChannels) => { @@ -297,9 +301,6 @@ export const useChannelListShapeDefaults = () => const isTargetChannelArchived = isChannelArchived(targetChannel); const isTargetChannelPinned = isChannelPinned(targetChannel); - // handle pinning - if (!considerPinnedChannels || lockChannelOrder) return currentChannels; - const newChannels = [...currentChannels]; if (targetChannelExistsWithinList) { @@ -353,20 +354,36 @@ export const useChannelListShapeDefaults = () => async ({ customHandler, event, + filters, setChannels, + sort, }: HandleChannelVisibleParameters) => { if (typeof customHandler === 'function') { return customHandler(setChannels, event); } - if (event.type && event.channel_type && event.channel_id) { - const channel = await getChannel({ - client, - id: event.channel_id, - type: event.channel_type, - }); - setChannels((channels) => uniqBy([channel, ...channels], 'cid')); + if (!event.channel) { + return; + } + + const channel = await getChannel({ + client, + id: event.channel.id, + type: event.channel.type, + }); + + const considerArchivedChannels = shouldConsiderArchivedChannels(filters); + if (isChannelArchived(channel) && considerArchivedChannels && !filters.archived) { + return; } + + setChannels((channels) => + moveChannelUpwards({ + channels, + channelToMove: channel, + sort, + }), + ); }, [client], ); @@ -586,7 +603,9 @@ export const usePrepareShapeHandlers = ({ defaults.handleChannelVisible({ customHandler: onChannelVisible, event, + filters, setChannels, + sort, }); break; case 'channel.truncated': diff --git a/src/components/MediaRecorder/classes/MediaRecorderController.ts b/src/components/MediaRecorder/classes/MediaRecorderController.ts index 7b7c804db9..684e5687d6 100644 --- a/src/components/MediaRecorder/classes/MediaRecorderController.ts +++ b/src/components/MediaRecorder/classes/MediaRecorderController.ts @@ -17,25 +17,18 @@ import { } from '../../ReactFileUtilities'; import { TranslationContextValue } from '../../../context'; import { defaultTranslatorFunction } from '../../../i18n'; -import { isSafari } from '../../../utils/browsers'; import { mergeDeepUndefined } from '../../../utils/mergeDeep'; import type { LocalVoiceRecordingAttachment } from '../../MessageInput'; import type { DefaultStreamChatGenerics } from '../../../types'; -const RECORDED_MIME_TYPE_BY_BROWSER = { +export const RECORDED_MIME_TYPE_BY_BROWSER = { audio: { others: 'audio/webm', safari: 'audio/mp4;codecs=mp4a.40.2', }, } as const; -export const DEFAULT_MEDIA_RECORDER_CONFIG: MediaRecorderConfig = { - mimeType: isSafari() - ? RECORDED_MIME_TYPE_BY_BROWSER.audio.safari - : RECORDED_MIME_TYPE_BY_BROWSER.audio.others, -} as const; - export const DEFAULT_AUDIO_TRANSCODER_CONFIG: TranscoderConfig = { sampleRate: 16000, } as const; @@ -120,7 +113,11 @@ export class MediaRecorderController< this.mediaRecorderConfig = mergeDeepUndefined( { ...config?.mediaRecorderConfig }, - DEFAULT_MEDIA_RECORDER_CONFIG, + { + mimeType: MediaRecorder.isTypeSupported('audio/webm') + ? RECORDED_MIME_TYPE_BY_BROWSER.audio.others + : RECORDED_MIME_TYPE_BY_BROWSER.audio.safari, + }, ); this.transcoderConfig = mergeDeepUndefined( diff --git a/src/components/MediaRecorder/classes/__tests__/MediaRecorderController.test.js b/src/components/MediaRecorder/classes/__tests__/MediaRecorderController.test.js index 7d20432acd..27cafb2ca2 100644 --- a/src/components/MediaRecorder/classes/__tests__/MediaRecorderController.test.js +++ b/src/components/MediaRecorder/classes/__tests__/MediaRecorderController.test.js @@ -3,9 +3,9 @@ import * as transcoder from '../../transcode'; import * as wavTranscoder from '../../transcode/wav'; import { DEFAULT_AUDIO_TRANSCODER_CONFIG, - DEFAULT_MEDIA_RECORDER_CONFIG, MediaRecorderController, MediaRecordingState, + RECORDED_MIME_TYPE_BY_BROWSER, RecordingAttachmentType, } from '../MediaRecorderController'; import { @@ -92,10 +92,27 @@ describe('MediaRecorderController', () => { }); afterEach(jest.clearAllMocks); - it('provides defaults on initiation', () => { + it('provides defaults on initiation (non-Safari)', () => { const controller = new MediaRecorderController(); expect(controller.mediaRecorderConfig).toStrictEqual( - expect.objectContaining(DEFAULT_MEDIA_RECORDER_CONFIG), + expect.objectContaining({ mimeType: RECORDED_MIME_TYPE_BY_BROWSER.audio.others }), + ); + expect(controller.transcoderConfig).toStrictEqual( + expect.objectContaining(DEFAULT_AUDIO_TRANSCODER_CONFIG), + ); + expect(controller.amplitudeRecorderConfig).toStrictEqual( + expect.objectContaining(DEFAULT_AMPLITUDE_RECORDER_CONFIG), + ); + expect(controller.t).toStrictEqual(defaultTranslatorFunction); + expect(controller.mediaType).toStrictEqual('audio'); + expect(controller.customGenerateRecordingTitle).toBeUndefined(); + }); + + it('provides defaults on initiation (Safari)', () => { + MediaRecorder.isTypeSupported.mockReturnValueOnce(false); + const controller = new MediaRecorderController(); + expect(controller.mediaRecorderConfig).toStrictEqual( + expect.objectContaining({ mimeType: RECORDED_MIME_TYPE_BY_BROWSER.audio.safari }), ); expect(controller.transcoderConfig).toStrictEqual( expect.objectContaining(DEFAULT_AUDIO_TRANSCODER_CONFIG), @@ -151,7 +168,7 @@ describe('MediaRecorderController', () => { const controller = new MediaRecorderController({ generateRecordingTitle }); expect(controller.customGenerateRecordingTitle).toStrictEqual(generateRecordingTitle); expect(controller.mediaRecorderConfig).toStrictEqual( - expect.objectContaining(DEFAULT_MEDIA_RECORDER_CONFIG), + expect.objectContaining({ mimeType: RECORDED_MIME_TYPE_BY_BROWSER.audio.others }), ); expect(controller.transcoderConfig).toStrictEqual( expect.objectContaining(DEFAULT_AUDIO_TRANSCODER_CONFIG), diff --git a/src/components/MediaRecorder/hooks/__tests__/useMediaRecorder.test.js b/src/components/MediaRecorder/hooks/__tests__/useMediaRecorder.test.js index 4cfbfba97b..b68849e3a0 100644 --- a/src/components/MediaRecorder/hooks/__tests__/useMediaRecorder.test.js +++ b/src/components/MediaRecorder/hooks/__tests__/useMediaRecorder.test.js @@ -2,12 +2,14 @@ import { TranslationProvider } from '../../../../context'; import { renderHook } from '@testing-library/react'; import React from 'react'; import { useMediaRecorder } from '../useMediaRecorder'; -import { EventEmitterMock } from '../../../../mock-builders/browser'; +import { EventEmitterMock, MediaRecorderMock } from '../../../../mock-builders/browser'; import { act } from '@testing-library/react'; import { DEFAULT_AMPLITUDE_RECORDER_CONFIG } from '../../classes/AmplitudeRecorder'; import { DEFAULT_AUDIO_TRANSCODER_CONFIG } from '../../classes'; import { generateVoiceRecordingAttachment } from '../../../../mock-builders'; +window.MediaRecorder = MediaRecorderMock; + const handleSubmit = jest.fn(); const uploadAttachment = jest.fn(); @@ -27,8 +29,8 @@ const render = async (params = {}) => { {children} ); let result; - await act(() => { - result = renderHook(() => useMediaRecorder({ enabled: true, ...params }), { + await act(async () => { + result = await renderHook(() => useMediaRecorder({ enabled: true, ...params }), { wrapper, }); }); diff --git a/src/components/Reactions/ReactionsList.tsx b/src/components/Reactions/ReactionsList.tsx index 14e2445fc0..9c388a3384 100644 --- a/src/components/Reactions/ReactionsList.tsx +++ b/src/components/Reactions/ReactionsList.tsx @@ -1,9 +1,20 @@ import React, { useState } from 'react'; import clsx from 'clsx'; -import type { ReactionGroupResponse, ReactionResponse } from 'stream-chat'; - +import { + ReactionsListModal as DefaultReactionsListModal, + ReactionsListModalProps, +} from './ReactionsListModal'; import { useProcessReactions } from './hooks/useProcessReactions'; +import { + MessageContextValue, + useComponentContext, + useTranslationContext, +} from '../../context'; + +import { MAX_MESSAGE_REACTIONS_TO_FETCH } from '../Message/hooks'; + +import type { ReactionGroupResponse, ReactionResponse, ReactionSort } from 'stream-chat'; import type { DefaultStreamChatGenerics } from '../../types/types'; import type { ReactionOptions } from './reactionOptions'; import type { @@ -11,9 +22,6 @@ import type { ReactionsComparator, ReactionType, } from './types'; -import { ReactionsListModal } from './ReactionsListModal'; -import { MessageContextValue, useTranslationContext } from '../../context'; -import { MAX_MESSAGE_REACTIONS_TO_FETCH } from '../Message/hooks'; export type ReactionsListProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, @@ -44,7 +52,7 @@ export type ReactionsListProps< /** Comparator function to sort the list of reacted users * @deprecated use `reactionDetailsSort` instead */ - sortReactionDetails?: ReactionDetailsComparator; + sortReactionDetails?: ReactionDetailsComparator; /** Comparator function to sort reactions, defaults to chronological order */ sortReactions?: ReactionsComparator; }; @@ -67,6 +75,7 @@ const UnMemoizedReactionsList = < const [selectedReactionType, setSelectedReactionType] = useState | null>(null); const { t } = useTranslationContext('ReactionsList'); + const { ReactionsListModal = DefaultReactionsListModal } = useComponentContext(); const handleReactionButtonClick = (reactionType: string) => { if (totalReactionCount > MAX_MESSAGE_REACTIONS_TO_FETCH) { @@ -126,13 +135,25 @@ const UnMemoizedReactionsList = < {selectedReactionType !== null && ( , + ) => Promise>> + } onClose={() => setSelectedReactionType(null)} - onSelectedReactionTypeChange={setSelectedReactionType} + onSelectedReactionTypeChange={ + setSelectedReactionType as ReactionsListModalProps['onSelectedReactionTypeChange'] + } open={selectedReactionType !== null} reactions={existingReactions} selectedReactionType={selectedReactionType} - sortReactionDetails={sortReactionDetails} + sortReactionDetails={ + sortReactionDetails as ( + a: ReactionResponse, + b: ReactionResponse, + ) => number + } /> )} diff --git a/src/components/Reactions/ReactionsListModal.tsx b/src/components/Reactions/ReactionsListModal.tsx index dfaf91c693..ad218350fb 100644 --- a/src/components/Reactions/ReactionsListModal.tsx +++ b/src/components/Reactions/ReactionsListModal.tsx @@ -11,7 +11,7 @@ import { MessageContextValue, useMessageContext } from '../../context'; import { DefaultStreamChatGenerics } from '../../types/types'; import { ReactionSort } from 'stream-chat'; -type ReactionsListModalProps< +export type ReactionsListModalProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, > = ModalProps & Partial< diff --git a/src/components/Reactions/__tests__/ReactionsList.test.js b/src/components/Reactions/__tests__/ReactionsList.test.js index cade672e4d..539106988f 100644 --- a/src/components/Reactions/__tests__/ReactionsList.test.js +++ b/src/components/Reactions/__tests__/ReactionsList.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import { toHaveNoViolations } from 'jest-axe'; @@ -16,7 +16,11 @@ import { defaultReactionOptions } from '../reactionOptions'; const USER_ID = 'mark'; -const renderComponent = ({ reaction_groups = {}, ...props }) => { +const renderComponent = ({ + reaction_groups = {}, + ReactionsListModal = undefined, + ...props +}) => { const reactions = Object.entries(reaction_groups).flatMap(([type, { count }]) => Array.from({ length: count }, (_, i) => generateReaction({ type, user: { id: `${USER_ID}-${i}` } }), @@ -24,7 +28,9 @@ const renderComponent = ({ reaction_groups = {}, ...props }) => { ); return render( - + { // disable warnings (unreachable context) jest.spyOn(console, 'warn').mockImplementation(null); + it('renders custom ReactionsListModal', async () => { + const CUSTOM_MODAL_TEST_ID = 'custom-reaction-list-modal'; + const ReactionsListModal = () =>
; + renderComponent({ + reaction_groups: { + haha: { count: 2 }, + love: { count: 5 }, + }, + ReactionsListModal, + }); + + await act(() => { + fireEvent.click(screen.getByTestId('reactions-list-button-haha')); + }); + expect(screen.getByTestId(CUSTOM_MODAL_TEST_ID)).toBeInTheDocument(); + }); + it('should render the total reaction count', async () => { const { container, getByText } = renderComponent({ reaction_groups: { diff --git a/src/components/Reactions/index.ts b/src/components/Reactions/index.ts index 891aa1addd..d17fd6d927 100644 --- a/src/components/Reactions/index.ts +++ b/src/components/Reactions/index.ts @@ -1,5 +1,6 @@ export * from './ReactionSelector'; export * from './ReactionsList'; +export * from './ReactionsListModal'; export * from './SimpleReactionsList'; export * from './SpriteImage'; export * from './StreamEmoji'; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 38218607ca..35f16dd55c 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -34,6 +34,7 @@ import { QuotedMessagePreviewProps, ReactionOptions, ReactionSelectorProps, + ReactionsListModalProps, ReactionsListProps, RecordingPermissionDeniedNotificationProps, SendButtonProps, @@ -182,6 +183,8 @@ export type ComponentContextValue< >; /** Custom UI component to display the list of reactions on a message, defaults to and accepts same props as: [ReactionsList](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Reactions/ReactionsList.tsx) */ ReactionsList?: React.ComponentType>; + /** Custom UI component to display the reactions modal, defaults to and accepts same props as: [ReactionsListModal](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Reactions/ReactionsListModal.tsx) */ + ReactionsListModal?: React.ComponentType>; RecordingPermissionDeniedNotification?: React.ComponentType; /** Custom component to display the search UI, defaults to and accepts same props as: [Search](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Search/Search.tsx) */ Search?: React.ComponentType; diff --git a/src/mock-builders/browser/MediaRecorder.js b/src/mock-builders/browser/MediaRecorder.js index 95906b3c75..55167da41b 100644 --- a/src/mock-builders/browser/MediaRecorder.js +++ b/src/mock-builders/browser/MediaRecorder.js @@ -3,10 +3,11 @@ import { EventEmitterMock } from './EventEmitter'; export class MediaRecorderMock extends EventEmitterMock { constructor() { super(); + this.start = jest.fn(); + this.pause = jest.fn(); + this.resume = jest.fn(); + this.stop = jest.fn(); } - start = jest.fn(); - pause = jest.fn(); - resume = jest.fn(); - stop = jest.fn(); + static isTypeSupported = jest.fn().mockReturnValue(true); } diff --git a/src/store/hooks/useStateStore.ts b/src/store/hooks/useStateStore.ts index a74bfe9d1f..cc7a326d40 100644 --- a/src/store/hooks/useStateStore.ts +++ b/src/store/hooks/useStateStore.ts @@ -1,7 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useMemo } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import type { StateStore } from 'stream-chat'; +// eslint-disable-next-line @typescript-eslint/no-empty-function +const noop = () => {}; + export function useStateStore< T extends Record, O extends Readonly | Readonly>, @@ -14,18 +18,48 @@ export function useStateStore< T extends Record, O extends Readonly | Readonly>, >(store: StateStore | undefined, selector: (v: T) => O) { - const [state, setState] = useState(() => { - if (!store) return undefined; - return selector(store.getLatestValue()); - }); + const wrappedSubscription = useCallback( + (onStoreChange: () => void) => { + const unsubscribe = store?.subscribeWithSelector(selector, onStoreChange); + return unsubscribe ?? noop; + }, + [store, selector], + ); + + const wrappedSnapshot = useMemo(() => { + let cachedTuple: [T, O]; + + return () => { + const currentValue = store?.getLatestValue(); + + if (!currentValue) return undefined; - useEffect(() => { - if (!store) return; + // store value hasn't changed, no need to compare individual values + if (cachedTuple && cachedTuple[0] === currentValue) { + return cachedTuple[1]; + } - const unsubscribe = store.subscribeWithSelector(selector, setState); + const newlySelected = selector(currentValue); - return unsubscribe; + // store value changed but selected values wouldn't have to, double-check selected + if (cachedTuple) { + let selectededAreEqualToCached = true; + + for (const key in cachedTuple[1]) { + if (cachedTuple[1][key] === newlySelected[key]) continue; + selectededAreEqualToCached = false; + break; + } + + if (selectededAreEqualToCached) return cachedTuple[1]; + } + + cachedTuple = [currentValue, newlySelected]; + return cachedTuple[1]; + }; }, [store, selector]); + const state = useSyncExternalStore(wrappedSubscription, wrappedSnapshot); + return state; }