From 86de0baf1af94b32fe4b7c77f19de6c052f44629 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Sat, 17 Oct 2026 01:00:17 +0200 Subject: [PATCH 1/5] fix: remove dead v15 surface and restore v14 behaviors found by the API audit Removes props and slots that v15 types still declared but nothing read: MessageList's messages, hasMore, jumpToLatestMessage, loadingMoreNewer and messageLimit, VirtualizedMessageList's messages, CustomClasses.chatContainer, ComponentContext.LoadingErrorIndicator, the channel-list-inner SCSS rules and commented-out ChannelUnreadUiState leftovers. Restores behavior lost in the move to stream-chat v10: - MessageList shows its loading indicator while an older page loads - ChannelNavigation carries the str-chat theme scope and customClasses.channelList - channel-detail DM and pinned-message actions open via workspace navigation - the reply-count button honors Message.openThread - the gallery header renders a MessageTimestamp override - navigation without a provider warns once per operation Exports useIsCooldownActive and useMessagePaginator from the package root. --- src/components/Attachment/ModalGallery.tsx | 3 +- .../hooks/useChannelContainerClasses.ts | 4 - src/components/ChannelList/ChannelList.tsx | 2 +- .../ChannelList/ChannelNavigation.tsx | 18 +++- .../__tests__/ChannelNavigation.test.tsx | 67 +++++++++++++++ .../ChannelList/styling/ChannelList.scss | 14 --- src/components/Gallery/GalleryContext.tsx | 7 +- src/components/Gallery/GalleryHeader.tsx | 13 ++- .../Gallery/__tests__/GalleryUI.test.tsx | 61 +++++++++++++ .../Message/MessageRepliesCountButton.tsx | 10 ++- src/components/Message/MessageTimestamp.tsx | 8 +- .../MessageRepliesCountButton.test.tsx | 69 ++++++++++++++- .../__tests__/useIsCooldownActive.test.tsx | 50 +++++++++++ src/components/MessageComposer/hooks/index.ts | 1 + src/components/MessageList/MessageList.tsx | 34 ++------ .../MessageList/VirtualizedMessageList.tsx | 43 ---------- .../__tests__/MessageList.test.tsx | 85 ++++++++++++++++++- src/components/MessageList/renderMessages.tsx | 1 - src/context/ChannelListContext.tsx | 2 +- src/context/ChatContext.tsx | 1 - src/context/ComponentContext.tsx | 3 - src/context/WorkspaceNavigationContext.tsx | 36 +++++++- .../WorkspaceNavigationContext.test.tsx | 54 ++++++++++++ .../__tests__/useMessagePaginator.test.tsx | 53 ++++++++++++ src/index.ts | 1 + .../ChannelMediaView.utils.ts | 4 + .../ChannelMemberActions.defaults.tsx | 15 ++-- .../SendDirectMessageAction.test.tsx | 19 ++--- .../PinnedMessagesView/PinnedMessagesView.tsx | 10 +-- .../__tests__/PinnedMessagesView.test.tsx | 25 ++++++ src/types/index.ts | 1 - src/types/types.ts | 2 - 32 files changed, 581 insertions(+), 135 deletions(-) create mode 100644 src/components/ChannelList/__tests__/ChannelNavigation.test.tsx create mode 100644 src/components/MessageComposer/hooks/__tests__/useIsCooldownActive.test.tsx create mode 100644 src/hooks/__tests__/useMessagePaginator.test.tsx diff --git a/src/components/Attachment/ModalGallery.tsx b/src/components/Attachment/ModalGallery.tsx index 3e64782ada..92615d337d 100644 --- a/src/components/Attachment/ModalGallery.tsx +++ b/src/components/Attachment/ModalGallery.tsx @@ -71,9 +71,10 @@ export const ModalGallery = ({ items.map((item) => ({ ...item, createdAt: item.createdAt ?? convertTimestampToDate(message?.created_at), + message: item.message ?? message, user: item.user ?? message?.user ?? undefined, })), - [items, message?.created_at, message?.user], + [items, message], ); const usesDefaultBaseImage = BaseImage === DefaultBaseImage; diff --git a/src/components/Channel/hooks/useChannelContainerClasses.ts b/src/components/Channel/hooks/useChannelContainerClasses.ts index 14fceaa45b..91804a46d6 100644 --- a/src/components/Channel/hooks/useChannelContainerClasses.ts +++ b/src/components/Channel/hooks/useChannelContainerClasses.ts @@ -8,9 +8,6 @@ export const useImageFlagEmojisOnWindowsClass = () => { : ''; }; -export const getChatContainerClass = (customClass?: string) => - customClass ?? 'str-chat__container'; - export const useChannelContainerClasses = ({ customClasses, }: Pick) => { @@ -18,7 +15,6 @@ export const useChannelContainerClasses = ({ return { channelClass: customClasses?.channel ?? 'str-chat__channel', chatClass: customClasses?.chat ?? 'str-chat', - chatContainerClass: getChatContainerClass(customClasses?.chatContainer), windowsEmojiClass, }; }; diff --git a/src/components/ChannelList/ChannelList.tsx b/src/components/ChannelList/ChannelList.tsx index 3c99d1fd0a..d6b802c93c 100644 --- a/src/components/ChannelList/ChannelList.tsx +++ b/src/components/ChannelList/ChannelList.tsx @@ -92,7 +92,7 @@ export const ChannelList = ({ ListItem={ListItem as ComponentType<{ item: Channel }>} LoadingNextPageIndicator={LoadingNextPageIndicator} loadNextDebounceMs={loadMoreDebounceMs} - loadNextOnScrollToBottom={paginator.next} + loadNextOnScrollToBottom={paginator.toTail} onClickCapture={onClickCapture} onKeyDown={onKeyDown} paginator={paginator} diff --git a/src/components/ChannelList/ChannelNavigation.tsx b/src/components/ChannelList/ChannelNavigation.tsx index 27bbc63e5f..d05d94b0af 100644 --- a/src/components/ChannelList/ChannelNavigation.tsx +++ b/src/components/ChannelList/ChannelNavigation.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import React from 'react'; import type { SearchControllerState } from 'stream-chat'; @@ -6,6 +7,7 @@ import { ChannelLists } from './ChannelLists'; import { NotificationList as DefaultNotificationList } from '../Notifications'; import { Search as DefaultSearch } from '../Search'; import { useChatContext, useComponentContext } from '../../context'; +import { useImageFlagEmojisOnWindowsClass } from '../Channel/hooks/useChannelContainerClasses'; import { useStateStore } from '../../store'; const searchControllerStateSelector = (state: SearchControllerState) => ({ @@ -18,6 +20,10 @@ const searchControllerStateSelector = (state: SearchControllerState) => ({ * always; results while active), then the paginator-driven `ChannelLists` only while * search is inactive. Mapped to the channel-navigation slot; `Search` is customizable * via the `ComponentContext` `Search` slot. + * + * Its root carries the `str-chat` theme scope itself (like `Channel`), so the design tokens apply + * when it is rendered outside `ChatView`. `customClasses.chat` / `customClasses.channelList` + * replace the `str-chat` / `str-chat__channel-list` classes. */ export const ChannelNavigation = () => { const { @@ -25,14 +31,22 @@ export const ChannelNavigation = () => { NotificationList = DefaultNotificationList, Search = DefaultSearch, } = useComponentContext(); - const { searchController } = useChatContext(); + const { customClasses, searchController, theme } = useChatContext(); + const windowsEmojiClass = useImageFlagEmojisOnWindowsClass(); const { isActive } = useStateStore( searchController.state, searchControllerStateSelector, ); return ( -
+
{!isActive && } diff --git a/src/components/ChannelList/__tests__/ChannelNavigation.test.tsx b/src/components/ChannelList/__tests__/ChannelNavigation.test.tsx new file mode 100644 index 0000000000..db850b778c --- /dev/null +++ b/src/components/ChannelList/__tests__/ChannelNavigation.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { StreamChat } from 'stream-chat'; + +import { Chat } from '../../Chat'; +import type { ChatProps } from '../../Chat'; +import { ChannelNavigation } from '../ChannelNavigation'; +import { getTestClientWithUser } from '../../../mock-builders'; +import { axe } from '../../../../axe-helper'; + +const setupClient = async () => { + const client = await getTestClientWithUser({ id: 'user_x' }); + // no paginator is registered, so nothing should query + vi.spyOn(client, 'queryChannelsAndHydrate').mockRejectedValue( + new Error('unexpected channel query'), + ); + return client; +}; + +const renderNavigation = ( + client: StreamChat, + chatProps: Partial> = {}, +) => + render( + + + , + ); + +describe('ChannelNavigation', () => { + afterEach(cleanup); + + it('carries the str-chat theme scope on its root outside ChatView', async () => { + const client = await setupClient(); + + const { container } = renderNavigation(client, { theme: 'str-chat__theme-dark' }); + + const root = container.firstElementChild; + expect(root).toHaveClass( + 'str-chat', + 'str-chat__theme-dark', + 'str-chat__channel-list', + ); + }); + + it('applies customClasses.chat and customClasses.channelList to its root', async () => { + const client = await setupClient(); + + const { container } = renderNavigation(client, { + customClasses: { channelList: 'custom-channel-list', chat: 'custom-chat' }, + }); + + const root = container.firstElementChild; + expect(root).toHaveClass('custom-chat', 'custom-channel-list'); + expect(root).not.toHaveClass('str-chat'); + expect(root).not.toHaveClass('str-chat__channel-list'); + }); + + it('has no axe violations', async () => { + const client = await setupClient(); + + const { container } = renderNavigation(client); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/components/ChannelList/styling/ChannelList.scss b/src/components/ChannelList/styling/ChannelList.scss index 23ac4d1614..fe1c5eb5c9 100644 --- a/src/components/ChannelList/styling/ChannelList.scss +++ b/src/components/ChannelList/styling/ChannelList.scss @@ -45,20 +45,6 @@ color: var(--str-chat__text-primary); border-inline-end: 1px solid var(--str-chat__border-core-default); - .str-chat__channel-list-inner { - flex: 1; - min-height: 0; - overflow: hidden; - padding-bottom: calc(var(--str-chat__space-8) + var(--str-chat__space-2)); - - .str-chat__channel-list-inner__main { - height: 100%; - overflow-y: auto; - scrollbar-gutter: stable both-edges; - scrollbar-width: thin; - } - } - .str-chat__load-more-button { display: flex; justify-content: center; diff --git a/src/components/Gallery/GalleryContext.tsx b/src/components/Gallery/GalleryContext.tsx index a03adb1ba2..b2edce519b 100644 --- a/src/components/Gallery/GalleryContext.tsx +++ b/src/components/Gallery/GalleryContext.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from 'react'; -import type { UserResponse } from 'stream-chat'; +import type { LocalMessage, UserResponse } from 'stream-chat'; import { requireContext } from '../../context/requireContext'; import { toBaseImageDescriptors } from '../BaseImage'; @@ -11,6 +11,11 @@ export type GalleryItem = Omit & { createdAt?: string | Date; dimensions?: Dimensions; imageUrl?: string; + /** + * The message the media was shared in. Lets the gallery header render a `MessageTimestamp` + * override from `ComponentContext` for it. + */ + message?: LocalMessage; /** User who shared the media; drives the gallery header title. */ user?: UserResponse; videoThumbnailUrl?: string; diff --git a/src/components/Gallery/GalleryHeader.tsx b/src/components/Gallery/GalleryHeader.tsx index 8d684fe899..811017ae2d 100644 --- a/src/components/Gallery/GalleryHeader.tsx +++ b/src/components/Gallery/GalleryHeader.tsx @@ -6,6 +6,7 @@ import { Button } from '../Button'; import { ModalContext, useChatContext, + useComponentContext, useComponentContextIcons, useTranslationContext, } from '../../context'; @@ -50,10 +51,11 @@ const GalleryTimestamp = ({ createdAt }: Pick) => { export const GalleryHeader = ({ currentItem }: GalleryHeaderProps) => { const { IconArrowDownCircle, IconXmark } = useComponentContextIcons(); + const { MessageTimestamp } = useComponentContext(); const { t } = useTranslationContext(); const { client } = useChatContext(); const modalContext = useContext(ModalContext); - const { createdAt, user } = currentItem; + const { createdAt, message, user } = currentItem; const headerTitle = (!!user?.id && user.id === client.userID && t('common.you.label', 'You')) || @@ -77,7 +79,14 @@ export const GalleryHeader = ({ currentItem }: GalleryHeaderProps) => {