-
-
- {!isActive &&
}
-
+
+
+
+
+ {!isActive && }
+
+
);
};
diff --git a/src/components/ChannelList/__tests__/ChannelList.test.tsx b/src/components/ChannelList/__tests__/ChannelList.test.tsx
index 10e0f22071..f55e937141 100644
--- a/src/components/ChannelList/__tests__/ChannelList.test.tsx
+++ b/src/components/ChannelList/__tests__/ChannelList.test.tsx
@@ -6,6 +6,7 @@ import { ChannelPaginator } from 'stream-chat';
import { Chat } from '../../Chat';
import { ChannelList } from '../ChannelList';
+import { ThemeScopeProvider } from '../../../context/ThemeScopeContext';
import { getTestClientWithUser } from '../../../mock-builders';
const setupClient = async () => {
@@ -28,6 +29,34 @@ const renderList = (client: StreamChat, paginator: ChannelPaginator) => {
describe('ChannelList', () => {
afterEach(cleanup);
+ it('brings a layout-neutral theme scope when rendered on its own', async () => {
+ const { client } = await setupClient();
+ const paginator = new ChannelPaginator({ client, id: 'channels:a' });
+ paginator.setItems({ isLastPage: true, valueOrFactory: [] });
+
+ const { container } = renderList(client, paginator);
+
+ const scope = container.firstElementChild;
+ expect(scope).toHaveClass('str-chat', 'messaging', 'light', 'str-chat__theme-scope');
+ });
+
+ it('adds no theme scope inside an existing one', async () => {
+ const { client } = await setupClient();
+ const paginator = new ChannelPaginator({ client, id: 'channels:a' });
+ paginator.setItems({ isLastPage: true, valueOrFactory: [] });
+ client.channelManager.insertPaginator({ paginator });
+
+ const { container } = render(
+
+
+
+
+ ,
+ );
+
+ expect(container.querySelector('.str-chat')).not.toBeInTheDocument();
+ });
+
it('queries the first page of a list that has never been queried', async () => {
const { client, queryChannels } = await setupClient();
const paginator = new ChannelPaginator({ client, id: 'channels:a' });
diff --git a/src/components/ChannelList/__tests__/ChannelNavigation.test.tsx b/src/components/ChannelList/__tests__/ChannelNavigation.test.tsx
new file mode 100644
index 0000000000..36ea46f09c
--- /dev/null
+++ b/src/components/ChannelList/__tests__/ChannelNavigation.test.tsx
@@ -0,0 +1,87 @@
+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 { ThemeScopeProvider } from '../../../context/ThemeScopeContext';
+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('inherits an enclosing theme scope (e.g. ChatView) instead of nesting one', async () => {
+ // `.str-chat` declares the whole token set: a nested one would reset tokens an app overrides
+ // on the ChatView root, and pick up app CSS written for `.str-chat` roots.
+ const client = await setupClient();
+
+ const { container } = render(
+
+
+
+
+ ,
+ );
+
+ const root = container.firstElementChild;
+ expect(root).toHaveClass('custom-channel-list');
+ expect(root).not.toHaveClass('str-chat');
+ expect(container.querySelector('.str-chat')).not.toBeInTheDocument();
+ });
+
+ 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/ChannelListItem/ChannelListItem.tsx b/src/components/ChannelListItem/ChannelListItem.tsx
index 9542058d34..7ac951bbe3 100644
--- a/src/components/ChannelListItem/ChannelListItem.tsx
+++ b/src/components/ChannelListItem/ChannelListItem.tsx
@@ -45,7 +45,7 @@ export type ChannelListItemUIProps = ChannelListItemProps & {
};
export type ChannelListItemProps = {
- /** Comes from either the `channelRenderFilterFn` or `usePaginatedChannels` call from [ChannelList](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelList/ChannelList.tsx) */
+ /** The channel to preview: one of the items of the `ChannelPaginator` that [ChannelList](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelList/ChannelList.tsx) renders, or any channel when used on its own (e.g. in search results) */
channel: Channel;
/** If the component's channel is the active (selected) Channel */
active?: boolean;
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) => {
{headerTitle}
-
+ {MessageTimestamp && message ? (
+
+ ) : (
+
+ )}
{downloadUrl ? (
diff --git a/src/components/Gallery/__tests__/GalleryUI.test.tsx b/src/components/Gallery/__tests__/GalleryUI.test.tsx
index 72d8fce22e..c51a7ef7f4 100644
--- a/src/components/Gallery/__tests__/GalleryUI.test.tsx
+++ b/src/components/Gallery/__tests__/GalleryUI.test.tsx
@@ -7,7 +7,11 @@ import {
ChatProvider,
ModalContextProvider,
TranslationProvider,
+ WithComponents,
} from '../../../context';
+import { MessageTimestamp as DefaultMessageTimestamp } from '../../Message/MessageTimestamp';
+import type { MessageTimestampProps } from '../../Message/MessageTimestamp';
+import { generateMessage } from '../../../mock-builders';
import {
mockChatContext,
mockTranslationContextValue,
@@ -49,10 +53,12 @@ const getSlideContainer = (container: HTMLElement) => {
const renderGalleryUI = (
items: GalleryItem[],
{
+ components,
galleryProps,
initialIndex = 0,
modalContext,
}: {
+ components?: React.ComponentProps
['overrides'];
galleryProps?: Partial;
initialIndex?: number;
modalContext?: ModalContextValue;
@@ -73,6 +79,10 @@ const renderGalleryUI = (
);
}
+ if (components) {
+ children = {children};
+ }
+
return render(
@@ -270,6 +280,57 @@ describe('GalleryUI', () => {
expect(screen.queryByText('You')).not.toBeInTheDocument();
});
+ it('should render the MessageTimestamp override for the item message', () => {
+ const message = generateMessage({ user: { id: 'jenny', name: 'Jenny' } });
+ const CustomMessageTimestamp = (props: MessageTimestampProps) => (
+
+ {props.message?.id}
+
+ );
+ const items = [
+ makeImageItem({
+ createdAt: new Date('2025-01-01T12:34:56.000Z'),
+ message,
+ user: { id: 'jenny', name: 'Jenny' },
+ }),
+ ];
+
+ renderGalleryUI(items, {
+ components: { MessageTimestamp: CustomMessageTimestamp },
+ });
+
+ const timestamp = screen.getByTestId('custom-message-timestamp');
+ expect(timestamp).toHaveTextContent(message.id);
+ expect(timestamp).toHaveClass('str-chat__gallery__timestamp');
+ });
+
+ it('should render the default MessageTimestamp from the item message outside a message', () => {
+ // The gallery renders outside any MessageProvider; the timestamp reads the item's message.
+ const message = generateMessage({ user: { id: 'jenny', name: 'Jenny' } });
+ const items = [makeImageItem({ message, user: { id: 'jenny', name: 'Jenny' } })];
+
+ const { container } = renderGalleryUI(items, {
+ components: { MessageTimestamp: DefaultMessageTimestamp },
+ });
+
+ expect(
+ container.querySelector('.str-chat__gallery__timestamp'),
+ ).toBeInTheDocument();
+ });
+
+ it('should render its own timestamp when MessageTimestamp is not overridden', () => {
+ const message = generateMessage();
+ const items = [
+ makeImageItem({ createdAt: new Date('2025-01-01T12:34:56.000Z'), message }),
+ ];
+
+ const { container } = renderGalleryUI(items);
+
+ expect(
+ container.querySelector('time.str-chat__gallery__timestamp'),
+ ).toHaveAttribute('datetime', '2025-01-01T12:34:56.000Z');
+ });
+
it('should fall back to the item title and omit the timestamp when the item has no sender', () => {
const items = [makeImageItem({ title: 'beach.png' })];
diff --git a/src/components/Message/MessageRepliesCountButton.tsx b/src/components/Message/MessageRepliesCountButton.tsx
index d9063580af..ff35c984ec 100644
--- a/src/components/Message/MessageRepliesCountButton.tsx
+++ b/src/components/Message/MessageRepliesCountButton.tsx
@@ -38,7 +38,7 @@ function UnMemoizedMessageRepliesCountButton(props: MessageRepliesCountButtonPro
thread_participants: threadParticipantsFromProps = [],
} = props;
// reply counts also render outside a message, from props alone
- const { message: contextMessage } = useContext(MessageContext) ?? {};
+ const { handleOpenThread, message: contextMessage } = useContext(MessageContext) ?? {};
const channel = useChannel();
const { openThread } = useWorkspaceNavigation();
const replyMetadataSelector = useMemo(
@@ -75,9 +75,15 @@ function UnMemoizedMessageRepliesCountButton(props: MessageRepliesCountButtonPro
}
if (!contextMessage) return;
+ // The message's own handler honors a `Message.openThread` override before falling back to
+ // workspace navigation.
+ if (handleOpenThread) {
+ handleOpenThread(event);
+ return;
+ }
void openThread({ channel, message: contextMessage });
},
- [channel, contextMessage, onClick, openThread],
+ [channel, contextMessage, handleOpenThread, onClick, openThread],
);
if (!replyCount) return null;
diff --git a/src/components/Message/MessageTimestamp.tsx b/src/components/Message/MessageTimestamp.tsx
index a91c3d7410..9fc59b96cf 100644
--- a/src/components/Message/MessageTimestamp.tsx
+++ b/src/components/Message/MessageTimestamp.tsx
@@ -1,8 +1,9 @@
import { convertTimestampToDate } from 'stream-chat';
-import React from 'react';
-import { useMessageContext } from '../../context/MessageContext';
+import React, { useContext } from 'react';
+import { MessageContext } from '../../context/MessageContext';
import { Timestamp as DefaultTimestamp } from './Timestamp';
import { useComponentContext } from '../../context';
+import { requireContext } from '../../context/requireContext';
import type { LocalMessage } from 'stream-chat';
import type { TimestampFormatterOptions } from '../../i18n/types';
@@ -16,9 +17,13 @@ export type MessageTimestampProps = TimestampFormatterOptions & {
const UnMemoizedMessageTimestamp = (props: MessageTimestampProps) => {
const { message: propMessage, ...timestampProps } = props;
- const { message: contextMessage } = useMessageContext();
+ // With a `message` prop it also renders outside a message (e.g. the gallery header). Without one
+ // it needs the surrounding message, and says so like `useMessageContext()` does.
+ const messageContext = useContext(MessageContext);
const { Timestamp = DefaultTimestamp } = useComponentContext();
- const message = propMessage || contextMessage;
+ const message =
+ propMessage ||
+ requireContext(messageContext, 'MessageTimestamp', 'MessageProvider').message;
return (
{
expect(queryByTestId('reply-icon')).not.toBeInTheDocument();
});
+ describe('inside a Message, without an onClick prop', () => {
+ // A MessageUI that renders the button the way the default one does: without `onClick`.
+ const ReplyCountOnlyMessageUI = () => ;
+
+ const renderInMessage = ({
+ openThread,
+ workspaceOpenThread = vi.fn(),
+ }: {
+ openThread?: (...args: unknown[]) => void;
+ workspaceOpenThread?: (...args: unknown[]) => void;
+ }) => {
+ const message = generateMessage({ reply_count: 2 });
+ const result = render(
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+ return { message, ...result };
+ };
+
+ it("calls the Message's openThread override", () => {
+ const openThread = vi.fn();
+ const workspaceOpenThread = vi.fn();
+ const { getByTestId, message } = renderInMessage({
+ openThread,
+ workspaceOpenThread,
+ });
+
+ fireEvent.click(getByTestId('replies-count-button'));
+
+ expect(openThread).toHaveBeenCalledWith(
+ expect.objectContaining({ id: message.id }),
+ expect.anything(),
+ );
+ expect(workspaceOpenThread).not.toHaveBeenCalled();
+ });
+
+ it('opens the thread through workspace navigation without an override', () => {
+ const workspaceOpenThread = vi.fn();
+ const { getByTestId, message } = renderInMessage({ workspaceOpenThread });
+
+ fireEvent.click(getByTestId('replies-count-button'));
+
+ expect(workspaceOpenThread).toHaveBeenCalledWith({
+ channel,
+ message: expect.objectContaining({ id: message.id }),
+ });
+ });
+ });
+
// MERGE-RECONCILE (test migration): the reply-count button no longer gates on the
// 'send-reply' channel capability (permission gating was removed from this component in
// the slot-layout refactor). The button now always renders enabled.
diff --git a/src/components/Message/__tests__/MessageTimestamp.test.tsx b/src/components/Message/__tests__/MessageTimestamp.test.tsx
index 11e562cb6f..6f49716a66 100644
--- a/src/components/Message/__tests__/MessageTimestamp.test.tsx
+++ b/src/components/Message/__tests__/MessageTimestamp.test.tsx
@@ -243,6 +243,33 @@ describe('', () => {
expect(container.children).toHaveLength(0);
});
+ it('should render from the message prop outside a MessageProvider', async () => {
+ let result: RenderResult;
+ await act(() => {
+ result = render(
+
+
+ ,
+ );
+ });
+ expect(result.container.querySelector('time.outside-message')).toHaveAttribute(
+ 'datetime',
+ '2019-04-03T14:42:47.087Z',
+ );
+ });
+
+ it('should throw outside a MessageProvider without a message prop', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ expect(() =>
+ render(
+
+
+ ,
+ ),
+ ).toThrow(/MessageTimestamp was called outside of MessageProvider/);
+ vi.mocked(console.error).mockRestore();
+ });
+
it('should render message with a custom css class when one is set', async () => {
const customClass = 'custom-css-class';
const { container } = await renderComponent({ props: { customClass } });
diff --git a/src/components/MessageComposer/hooks/__tests__/useIsCooldownActive.test.tsx b/src/components/MessageComposer/hooks/__tests__/useIsCooldownActive.test.tsx
new file mode 100644
index 0000000000..48503d313e
--- /dev/null
+++ b/src/components/MessageComposer/hooks/__tests__/useIsCooldownActive.test.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import { act, renderHook, type RenderHookResult } from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
+
+// Imported through the package entry: the hook is public API, named in the migration guide.
+import { useIsCooldownActive } from '../../../../index';
+
+import { Chat } from '../../../Chat';
+import { Channel } from '../../../Channel';
+import { initClientWithChannels } from '../../../../mock-builders';
+
+describe('useIsCooldownActive', () => {
+ const setup = async () => {
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels();
+
+ const wrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ let result: RenderHookResult;
+ await act(() => {
+ result = renderHook(() => useIsCooldownActive(), { wrapper });
+ });
+ return { channel, ...result };
+ };
+
+ it('is false when no cooldown is active', async () => {
+ const { result } = await setup();
+ expect(result.current).toBe(false);
+ });
+
+ it('follows the channel cooldown timer', async () => {
+ const { channel, result } = await setup();
+
+ await act(() => {
+ channel.cooldownTimer.state.next(fromPartial({ cooldownRemaining: 10 }));
+ });
+ expect(result.current).toBe(true);
+
+ await act(() => {
+ channel.cooldownTimer.state.next(fromPartial({ cooldownRemaining: 0 }));
+ });
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/src/components/MessageComposer/hooks/index.ts b/src/components/MessageComposer/hooks/index.ts
index a387e28eb7..e4cd3d4629 100644
--- a/src/components/MessageComposer/hooks/index.ts
+++ b/src/components/MessageComposer/hooks/index.ts
@@ -2,6 +2,7 @@ export * from './useAttachmentManagerState';
export * from './useAttachmentsForPreview';
export * from './useCanCreatePoll';
export * from './useCooldownRemaining';
+export * from './useIsCooldownActive';
export * from './useMessageComposerBindings';
export * from './useMessageComposerCommands';
export * from './useMessageComposerController';
diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx
index 242957bcfc..e66d4e2063 100644
--- a/src/components/MessageList/MessageList.tsx
+++ b/src/components/MessageList/MessageList.tsx
@@ -36,7 +36,6 @@ import { useThreadContext } from '../Threads';
import { useThreadHead } from './hooks/useThreadHead';
import type {
- LocalMessage,
MessageFocusSignalState,
MessagePaginatorState,
TimestampNS,
@@ -57,7 +56,6 @@ import { useCanPaginateReplies } from './hooks/useCanPaginateReplies';
type MessageListWithContextProps = MessageListProps;
const messagePaginatorStateSelector = (state: MessagePaginatorState) => ({
- // hasMore: state.hasMoreTail,
hasMoreNewer: state.hasMoreHead,
isLoading: state.isLoading,
messages: state.items ?? [],
@@ -94,7 +92,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
} = {},
maxTimeBetweenGroupedMessages,
noGroupByUser = false,
- // messageLimit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE,
reactionDetailsSort,
renderMessages = defaultRenderMessages,
returnAllReadData = false,
@@ -107,10 +104,8 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
} = props;
const thread = useThreadContext();
const isThreadList = !!thread;
- const [suppressAutoscrollWhileLoadingOlder, setSuppressAutoscrollWhileLoadingOlder] =
- React.useState(false);
- const suppressAutoscroll =
- suppressAutoscrollFromProps || suppressAutoscrollWhileLoadingOlder;
+ const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
+ const suppressAutoscroll = suppressAutoscrollFromProps || isLoadingOlder;
const loadingOlderRef = React.useRef(false);
const [listElement, setListElement] = React.useState(null);
@@ -236,14 +231,23 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
const canPaginateReplies = useCanPaginateReplies();
const loadOlderMessages = React.useCallback(async () => {
- if (loadingOlderRef.current) return;
+ // The paginator's own gate for a tailward page. Checked here too so that `isLoadingOlder`, which
+ // shows the older-page indicator, is never raised while another load (a jump, a newer page)
+ // holds `isLoading` and `toTail()` would not query.
+ if (
+ loadingOlderRef.current ||
+ messagePaginator.isLoading ||
+ !messagePaginator.hasMoreTail
+ ) {
+ return;
+ }
loadingOlderRef.current = true;
- setSuppressAutoscrollWhileLoadingOlder(true);
+ setIsLoadingOlder(true);
try {
await messagePaginator.toTail();
} finally {
loadingOlderRef.current = false;
- setSuppressAutoscrollWhileLoadingOlder(false);
+ setIsLoadingOlder(false);
}
}, [messagePaginator]);
@@ -396,7 +400,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
{threadHead}
{isLoading && (
- {props.loadingMore && }
+ {(props.loadingMore ?? isLoadingOlder) && }
)}
@@ -465,8 +469,6 @@ export type MessageListProps = Partial
noGroupByUser: boolean,
maxTimeBetweenGroupedMessages?: number,
) => GroupStyle;
- /** Whether the list has more items to load */
- hasMore?: boolean;
/**
* Position to render HeaderComponent, as a timestamp in the same unit as `message.created_at` —
* i.e. unix nanoseconds. Was milliseconds while `created_at` was a `Date`.
@@ -480,22 +482,13 @@ export type MessageListProps = Partial
hideNewMessageSeparator?: boolean;
/** Overrides the default props passed to [InfiniteScrollPaginator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx) */
internalInfiniteScrollProps?: InternalPaginatorProps;
- /** Function called when latest messages should be loaded, after the list has jumped at an earlier message set */
- jumpToLatestMessage?: () => Promise;
- /** Whether or not the list is currently loading more items */
+ /**
+ * Whether to show the `LoadingIndicator` in the row above the messages while the message
+ * paginator is loading. Defaults to showing it while an older page loads.
+ */
loadingMore?: boolean;
- /** Whether or not the list is currently loading newer items */
- loadingMoreNewer?: boolean;
- /** Function called when more messages are to be loaded. */
- // loadMore?: () => Promise;
- /** Function called when newer messages are to be loaded. */
- // loadMoreNewer?: () => Promise;
/** Maximum time in milliseconds that should occur between messages to still consider them grouped together */
maxTimeBetweenGroupedMessages?: number;
- /** The limit to use when paginating messages */
- messageLimit?: number;
- /** The messages to render in the list; defaults to the active message-paginator items. */
- messages?: LocalMessage[];
/** If true, turns off message UI grouping by user */
noGroupByUser?: boolean;
/** Overrides the way MessageList renders messages */
diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx
index 7f0c8c3886..84f77665b7 100644
--- a/src/components/MessageList/VirtualizedMessageList.tsx
+++ b/src/components/MessageList/VirtualizedMessageList.tsx
@@ -146,11 +146,6 @@ export type VirtuosoContext = Required<
type VirtualizedMessageListWithContextProps = VirtualizedMessageListProps & {
channel: Channel;
- // hasMore: boolean;
- // hasMoreNewer: boolean;
- // jumpToLatestMessage: () => Promise;
- // loadingMore: boolean;
- // loadingMoreNewer: boolean;
read?: StreamChannelState['read'];
};
@@ -218,23 +213,15 @@ const VirtualizedMessageListWithContext = (
additionalMessageComposerProps,
additionalVirtuosoProps = {},
channel,
- // channelUnreadUiState,
closeReactionSelectorOnClick,
customMessageRenderer,
defaultItemHeight,
formatDate,
groupStyles,
hideDeletedMessages = false,
- // hasMoreNewer,
hideNewMessageSeparator = false,
maxTimeBetweenGroupedMessages,
- // jumpToLatestMessage,
- // loadingMore,
- // loadMore,
- // loadMoreNewer,
overscan = 0,
- // messageLimit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE,
- // messages,
// TODO: refactor to scrollSeekPlaceHolderConfiguration and components.ScrollSeekPlaceholder, like the Virtuoso Component
reactionDetailsSort,
renderText,
@@ -489,8 +476,6 @@ const VirtualizedMessageListWithContext = (
setNewMessagesNotification,
// processedMessages were incorrectly rebuilt with a new object identity at some point, hence the .length usage
// processedMessages.length,
- // hasMoreNewer,
- // jumpToLatestMessage,
]);
useScrollToBottomOnNewMessage({
@@ -550,7 +535,6 @@ const VirtualizedMessageListWithContext = (
// An empty thread is at both ends at once, so Virtuoso reports both on mount — see
// `useCanPaginateReplies` for why that must not become a request.
if (canPaginateReplies) messagePaginator.toHead();
- // loadMoreNewer?.(messageLimit);
setNewMessagesNotification?.(false);
}
};
@@ -733,28 +717,12 @@ export type VirtualizedMessageListProps = Partial<
noGroupByUser: boolean,
maxTimeBetweenGroupedMessages?: number,
) => GroupStyle;
- // /** Whether or not the list has more items to load */
- // hasMore?: boolean;
- // /** Whether or not the list has newer items to load */
- // hasMoreNewer?: boolean;
/** Hides the `MessageDeleted` components from the list, defaults to `false` */
hideDeletedMessages?: boolean;
/** Hides the `DateSeparator` component when new messages are received in a channel that's watched but not active, defaults to false */
hideNewMessageSeparator?: boolean;
- // /** Whether or not the list is currently loading more items */
- // loadingMore?: boolean;
- // /** Whether or not the list is currently loading newer items */
- // loadingMoreNewer?: boolean;
- /** Function called when more messages are to be loaded. */
- // loadMore?: () => Promise;
- /** Function called when new messages are to be loaded. */
- // loadMoreNewer?: () => Promise;
/** Maximum time in milliseconds that should occur between messages to still consider them grouped together */
maxTimeBetweenGroupedMessages?: number;
- // /** The limit to use when paginating messages */
- // messageLimit?: number;
- /** Optional prop to override the messages available from the active message paginator. */
- messages?: LocalMessage[];
/**
* @deprecated Use additionalVirtuosoProps.overscan instead. Will be removed with next major release - `v11.0.0`.
* The amount of extra content the list should render in addition to what's necessary to fill in the viewport
@@ -818,8 +786,6 @@ export function VirtualizedMessageList(props: VirtualizedMessageListProps) {
const { read } = useStateStore(channel?.state, channelReadSelector) ?? {};
- const messages = props.messages; // || contextMessages;
-
// See the note in `MessageList`: the panel and the notification area sit above the key, so a
// notification keeps its element, its countdown and its box across a switch. Here it also puts
// them above `VirtualizedMessageListWithContext`'s `if (!processedMessages) return null`, which
@@ -832,15 +798,6 @@ export function VirtualizedMessageList(props: VirtualizedMessageListProps) {
// See the note in MessageList: this list's local state -- Virtuoso's scroll offset above all
// -- is scoped to whatever it is showing, a thread's replies or a channel's messages.
key={getMessageSourceKey({ channel, thread })}
- // channelUnreadUiState={props.channelUnreadUiState ?? channelUnreadUiState}
- // hasMore={!!hasMore}
- // hasMoreNewer={!!hasMoreNewer}
- // jumpToLatestMessage={jumpToLatestMessage}
- // loadingMore={!!loadingMore}
- // loadingMoreNewer={!!loadingMoreNewer}
- // loadMore={loadMore}
- // loadMoreNewer={loadMoreNewer}
- messages={messages}
read={read}
{...props}
/>
diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx
index 3949b4b774..a26b5dadc5 100644
--- a/src/components/MessageList/__tests__/MessageList.test.tsx
+++ b/src/components/MessageList/__tests__/MessageList.test.tsx
@@ -266,6 +266,119 @@ describe('MessageList', () => {
// expect(results).toHaveNoViolations();
});
+ describe('older-page loading indicator', () => {
+ // Scrolls the list to its top (jsdom has no layout, so `offsetParent` is stubbed) and holds
+ // `toTail` open, so the list stays in the "loading an older page" state.
+ const startLoadingOlder = async (msgListProps?: Partial) => {
+ const pageDeferred = createDeferred();
+ const toTail = vi
+ .spyOn(channel.messagePaginator, 'toTail')
+ .mockImplementation(async () => {
+ channel.messagePaginator.state.partialNext({ isLoading: true });
+ await pageDeferred.promise;
+ channel.messagePaginator.state.partialNext({ isLoading: false });
+ });
+ const { container } = renderComponent({
+ channelProps: { channel },
+ chatClient,
+ msgListProps: {
+ ...msgListProps,
+ internalInfiniteScrollProps: { loadNextDebounceMs: 0 },
+ },
+ });
+ act(() => {
+ channel.messagePaginator.state.partialNext({ hasMoreTail: true });
+ });
+ const scroller = await screen.findByTestId('reverse-infinite-scroll');
+ Object.defineProperty(scroller, 'offsetParent', {
+ configurable: true,
+ get: () => document.body,
+ });
+ fireEvent.scroll(scroller);
+ await waitFor(() => expect(toTail).toHaveBeenCalled());
+ return { container, pageDeferred };
+ };
+
+ it('shows the loading indicator while an older page loads', async () => {
+ const { container, pageDeferred } = await startLoadingOlder();
+
+ await waitFor(() => {
+ expect(
+ container.querySelector(
+ '.str-chat__list__loading .str-chat__loading-indicator',
+ ),
+ ).toBeInTheDocument();
+ });
+
+ await act(() => {
+ pageDeferred.resolve();
+ });
+ await waitFor(() => {
+ expect(
+ container.querySelector('.str-chat__list__loading'),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it('hides the loading indicator when loadingMore is false', async () => {
+ const { container, pageDeferred } = await startLoadingOlder({ loadingMore: false });
+
+ await waitFor(() => {
+ expect(container.querySelector('.str-chat__list__loading')).toBeInTheDocument();
+ });
+ expect(
+ container.querySelector('.str-chat__list__loading .str-chat__loading-indicator'),
+ ).not.toBeInTheDocument();
+
+ await act(() => {
+ pageDeferred.resolve();
+ });
+ });
+
+ it('does not request an older page while another load is in flight', async () => {
+ const toTail = vi.spyOn(channel.messagePaginator, 'toTail');
+ const { container } = renderComponent({
+ channelProps: { channel },
+ chatClient,
+ msgListProps: { internalInfiniteScrollProps: { loadNextDebounceMs: 0 } },
+ });
+ const scroller = await screen.findByTestId('reverse-infinite-scroll');
+ Object.defineProperty(scroller, 'offsetParent', {
+ configurable: true,
+ get: () => document.body,
+ });
+ act(() => {
+ // e.g. a jump to a message, or the newer page `scrollToBottom` asked for
+ channel.messagePaginator.state.partialNext({
+ hasMoreTail: true,
+ isLoading: true,
+ });
+ });
+
+ fireEvent.scroll(scroller);
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ expect(toTail).not.toHaveBeenCalled();
+ expect(
+ container.querySelector('.str-chat__list__loading .str-chat__loading-indicator'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not show the loading indicator while a newer page loads', async () => {
+ const { container } = renderComponent({ channelProps: { channel }, chatClient });
+ await screen.findByTestId('reverse-infinite-scroll');
+
+ await act(() => {
+ channel.messagePaginator.state.partialNext({ isLoading: true });
+ });
+
+ expect(container.querySelector('.str-chat__list__loading')).toBeInTheDocument();
+ expect(
+ container.querySelector('.str-chat__list__loading .str-chat__loading-indicator'),
+ ).not.toBeInTheDocument();
+ });
+ });
+
it('should render a ThreadHead override above the replies', async () => {
const CustomThreadHead = (props: { message: Pick }) => (
{props.message.text}
@@ -607,7 +720,7 @@ describe('MessageList', () => {
-
+
,
);
@@ -1689,7 +1802,7 @@ describe('MessageList', () => {
hasMoreNewer={canLoadNewer}
messages={renderedMessages}
>
-
+
diff --git a/src/components/MessageList/renderMessages.tsx b/src/components/MessageList/renderMessages.tsx
index 65d246114d..5438e0a52a 100644
--- a/src/components/MessageList/renderMessages.tsx
+++ b/src/components/MessageList/renderMessages.tsx
@@ -14,7 +14,6 @@ import type {
UserResponse,
} from 'stream-chat';
import type { ComponentContextValue, CustomClasses } from '../../context';
-// import type { ChannelUnreadUiState } from '../../types';
export interface RenderMessagesOptions {
channel: Channel;
diff --git a/src/context/ChannelListContext.tsx b/src/context/ChannelListContext.tsx
index 88e9b94abc..46898a9106 100644
--- a/src/context/ChannelListContext.tsx
+++ b/src/context/ChannelListContext.tsx
@@ -7,7 +7,7 @@ export type ChannelListContextValue = {
/**
* The primary channel paginator held by the `ChannelManager` on `ChatContext`.
* Read the loaded channels reactively with `useStateStore(paginator.state, …)`, load the next
- * page with `paginator.next()`, and mutate the loaded list (e.g. prepend a just-opened channel)
+ * page with `paginator.toTail()`, and mutate the loaded list (e.g. prepend a just-opened channel)
* via `paginator.setItems({ valueOrFactory })`. Undefined when rendered outside a channel list.
*/
paginator?: ChannelPaginator;
diff --git a/src/context/ChatContext.tsx b/src/context/ChatContext.tsx
index 0a997f1770..90ad1e08b0 100644
--- a/src/context/ChatContext.tsx
+++ b/src/context/ChatContext.tsx
@@ -12,7 +12,6 @@ import { requireContext } from './requireContext';
type CSSClasses =
| 'chat'
- | 'chatContainer'
| 'channel'
| 'channelList'
| 'message'
diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx
index 4eda3c6699..ac7525fbd1 100644
--- a/src/context/ComponentContext.tsx
+++ b/src/context/ComponentContext.tsx
@@ -20,7 +20,6 @@ import {
type GalleryProps,
type GiphyPreviewMessageProps,
type ImagePlaceholderProps,
- type LoadingErrorIndicatorProps,
type LoadingIndicatorProps,
type MessageBouncePromptProps,
type MessageComposerProps,
@@ -177,8 +176,6 @@ export type ComponentContextValue = {
MessageComposerUI?: React.ComponentType;
/** Custom component to render link previews in message composer **/
LinkPreviewList?: React.ComponentType;
- /** Custom UI component to be shown if the channel query fails, defaults to and accepts same props as: [LoadingErrorIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/LoadingErrorIndicator.tsx) */
- 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?: React.ComponentType;
/** Custom UI component for determinate progress (0–100), defaults to and accepts same props as: [ProgressIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/progress-indicators.tsx) */
diff --git a/src/context/ThemeScopeContext.tsx b/src/context/ThemeScopeContext.tsx
new file mode 100644
index 0000000000..0554436ba5
--- /dev/null
+++ b/src/context/ThemeScopeContext.tsx
@@ -0,0 +1,44 @@
+import clsx from 'clsx';
+import React, { createContext, useContext } from 'react';
+import type { PropsWithChildren } from 'react';
+
+import { useChatContext } from './ChatContext';
+
+/**
+ * Whether the subtree already renders inside an element carrying the `str-chat ` scope that
+ * the design tokens hang off. `.str-chat` declares the whole token set, so a second one nested
+ * inside the first would reset every token an app overrides on the outer element. Components that
+ * also render standalone (the channel navigation, a channel list) therefore apply the scope only
+ * when nothing above them has.
+ */
+const ThemeScopeContext = createContext(false);
+
+/** Marks its subtree as rendering inside the `str-chat ` scope. */
+export const ThemeScopeProvider = ({ children }: PropsWithChildren) => (
+ {children}
+);
+
+export const useIsInThemeScope = () => useContext(ThemeScopeContext);
+
+/** The scope's classes: `customClasses.chat` (default `str-chat`) and the `Chat` theme. */
+export const useThemeScopeClassName = () => {
+ const { customClasses, theme } = useChatContext();
+ return clsx(customClasses?.chat ?? 'str-chat', theme);
+};
+
+/**
+ * Renders its children inside the `str-chat ` scope, adding a layout-neutral
+ * (`display: contents`) wrapper only when no ancestor provides the scope.
+ */
+export const ThemeScope = ({ children }: PropsWithChildren) => {
+ const isInThemeScope = useIsInThemeScope();
+ const themeScopeClassName = useThemeScopeClassName();
+
+ if (isInThemeScope) return <>{children}>;
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/context/WorkspaceNavigationContext.tsx b/src/context/WorkspaceNavigationContext.tsx
index cf669d4c0a..1cce89fff4 100644
--- a/src/context/WorkspaceNavigationContext.tsx
+++ b/src/context/WorkspaceNavigationContext.tsx
@@ -158,8 +158,41 @@ export const defaultWorkspaceNavigation: WorkspaceNavigation = {
openThreads: NOOP_OPEN_THREADS,
};
+type WorkspaceNavigationAction =
+ | 'closeChannel'
+ | 'closeThread'
+ | 'goBack'
+ | 'openChannel'
+ | 'openThread';
+
+const warnedUnprovidedActions = new Set();
+
+const warnUnprovided = (action: WorkspaceNavigationAction) => {
+ if (warnedUnprovidedActions.has(action)) return;
+ warnedUnprovidedActions.add(action);
+ console.warn(
+ `stream-chat-react: WorkspaceNavigation.${action}() did nothing because no workspace navigation is provided. ` +
+ 'Render the component inside ChatView (stream-chat-react/slot-layout), or provide your own navigation with WorkspaceNavigationProvider.',
+ );
+};
+
+/**
+ * What `useWorkspaceNavigation()` returns with no provider: {@link defaultWorkspaceNavigation}, but
+ * its operations warn (once each) that nothing happened, since a click that silently does nothing
+ * is hard to trace back to a missing `ChatView`. `defaultWorkspaceNavigation` itself stays silent,
+ * as it is the base an integrator spreads into their own provider value.
+ */
+const unprovidedWorkspaceNavigation: WorkspaceNavigation = {
+ ...defaultWorkspaceNavigation,
+ closeChannel: () => warnUnprovided('closeChannel'),
+ closeThread: () => warnUnprovided('closeThread'),
+ goBack: () => warnUnprovided('goBack'),
+ openChannel: () => warnUnprovided('openChannel'),
+ openThread: () => warnUnprovided('openThread'),
+};
+
const WorkspaceNavigationContext = createContext(
- defaultWorkspaceNavigation,
+ unprovidedWorkspaceNavigation,
);
export const WorkspaceNavigationProvider = ({
@@ -174,6 +207,7 @@ export const WorkspaceNavigationProvider = ({
/**
* Access the workspace navigation abstraction. Returns the inert no-op default outside a
* `slot-layout` provider, so callers can navigate unconditionally without guarding for a provider.
+ * Without a provider, each operation logs a one-time console warning when called.
*/
export const useWorkspaceNavigation = (): WorkspaceNavigation =>
useContext(WorkspaceNavigationContext);
diff --git a/src/context/__tests__/WorkspaceNavigationContext.test.tsx b/src/context/__tests__/WorkspaceNavigationContext.test.tsx
index efd94396d7..a6d05a6731 100644
--- a/src/context/__tests__/WorkspaceNavigationContext.test.tsx
+++ b/src/context/__tests__/WorkspaceNavigationContext.test.tsx
@@ -35,6 +35,60 @@ describe('WorkspaceNavigationContext', () => {
).not.toThrow();
expect(() => result.current.closeThread('thread-1')).not.toThrow();
});
+
+ it('warns once per operation that nothing happened', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ const { result } = renderHook(() => useWorkspaceNavigation());
+
+ result.current.closeChannel('messaging:x');
+ result.current.closeChannel('messaging:y');
+ result.current.goBack('messaging:x');
+
+ const closeChannelWarnings = warn.mock.calls.filter(([message]) =>
+ String(message).includes('WorkspaceNavigation.closeChannel()'),
+ );
+ expect(closeChannelWarnings).toHaveLength(1);
+ expect(String(closeChannelWarnings[0][0])).toContain('ChatView');
+ expect(
+ warn.mock.calls.some(([message]) =>
+ String(message).includes('WorkspaceNavigation.goBack()'),
+ ),
+ ).toBe(true);
+
+ warn.mockRestore();
+ });
+
+ it('does not warn from reads or from the exported default', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ const { result } = renderHook(() => useWorkspaceNavigation());
+
+ result.current.isChannelActive('messaging:x');
+ result.current.canGoBack('messaging:x');
+ defaultWorkspaceNavigation.openChannel(
+ fromPartial({ cid: 'messaging:x' }),
+ );
+ defaultWorkspaceNavigation.closeThread('thread-1');
+
+ expect(warn).not.toHaveBeenCalled();
+ warn.mockRestore();
+ });
+ });
+
+ describe('with a provider', () => {
+ it('does not warn when the provider leaves an operation at the default', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+ const { result } = renderHook(() => useWorkspaceNavigation(), { wrapper });
+
+ result.current.openThread(fromPartial({ id: 'thread-1' }));
+
+ expect(warn).not.toHaveBeenCalled();
+ warn.mockRestore();
+ });
});
describe('with a provider', () => {
diff --git a/src/hooks/__tests__/useMessagePaginator.test.tsx b/src/hooks/__tests__/useMessagePaginator.test.tsx
new file mode 100644
index 0000000000..f86c8fea43
--- /dev/null
+++ b/src/hooks/__tests__/useMessagePaginator.test.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import { renderHook } from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
+import type { Thread } from 'stream-chat';
+
+// Imported through the package entry: the hook is public API.
+import { useMessagePaginator } from '../../index';
+
+import { Chat } from '../../components/Chat';
+import { Channel } from '../../components/Channel';
+import { ThreadProvider } from '../../components/Threads';
+import { initClientWithChannels } from '../../mock-builders';
+
+describe('useMessagePaginator', () => {
+ it("returns the channel's paginator inside Channel", async () => {
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels();
+
+ const { result } = renderHook(() => useMessagePaginator(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+
+ expect(result.current).toBe(channel.messagePaginator);
+ });
+
+ it("prefers the thread's paginator inside a thread", async () => {
+ const {
+ channels: [channel],
+ client,
+ } = await initClientWithChannels();
+ const thread = fromPartial({
+ messagePaginator: fromPartial({}),
+ });
+
+ const { result } = renderHook(() => useMessagePaginator(), {
+ wrapper: ({ children }) => (
+
+
+ {children}
+
+
+ ),
+ });
+
+ expect(result.current).toBe(thread.messagePaginator);
+ });
+});
diff --git a/src/index.ts b/src/index.ts
index b86ce062dc..01b498b37b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,5 +1,6 @@
export * from './components';
export * from './context';
+export { useMessagePaginator } from './hooks/useMessagePaginator';
export * from './i18n';
export * from './store';
export * from './types';
diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts
index e7744463a3..faa4c9376b 100644
--- a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts
+++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts
@@ -1,6 +1,7 @@
import {
type Attachment,
convertTimestampToDate,
+ formatMessage,
isImageAttachment,
isVideoAttachment,
type LocalMessage,
@@ -47,6 +48,8 @@ export const toChannelMediaItems = (
const items: ChannelMediaItem[] = [];
messages.forEach((message) => {
+ // the gallery header hands it to a `MessageTimestamp` override, which takes a `LocalMessage`
+ let localMessage: LocalMessage | undefined;
message.attachments?.forEach((attachment, index) => {
const type = getMediaAttachmentType(attachment);
if (!type) return;
@@ -70,6 +73,7 @@ export const toChannelMediaItems = (
...descriptor,
// the gallery header reads sender and timestamp off the item
createdAt: convertTimestampToDate(message.created_at),
+ message: (localMessage ??= formatMessage(message)),
user: message.user ?? undefined,
},
id: `${message.id}-${index}`,
diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx
index 31736754e4..1feb4cbd70 100644
--- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx
+++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx
@@ -16,8 +16,8 @@ import {
useComponentContextIcons,
useModalContext,
useTranslationContext,
+ useWorkspaceNavigation,
} from '../../../../context';
-import { useChatViewNavigation } from '../../../SlotLayout';
import { useStableCallback } from '../../../../utils';
import { useStateStore } from '../../../../store';
import { Alert } from '../../../../components/Dialog';
@@ -219,7 +219,7 @@ export const useBaseChannelMemberActionSetFilter = (
const SendDirectMessageAction = () => {
const { channelManager, client } = useChatContext();
- const { open } = useChatViewNavigation();
+ const { openChannel } = useWorkspaceNavigation();
const { close } = useModalContext();
const { channel } = useChannelDetailContext();
const { addNotification } = useNotificationApi();
@@ -240,13 +240,10 @@ const SendDirectMessageAction = () => {
});
// the stored instance, if another one took the DM's cid while the watch was in flight
const dm = await directMessageChannel.ensureWatched();
- // Selection is one navigation model: open the DM into a layout slot, then route it into
+ // Selection is one navigation model: open the DM in the workspace (a layout slot inside
+ // `ChatView`, or whatever a custom `WorkspaceNavigationProvider` does), then route it into
// the channel list(s) that should own it so it appears without a full re-query.
- open({
- key: dm.cid ?? undefined,
- kind: 'channel',
- source: dm,
- });
+ openChannel(dm);
channelManager.ingestChannel(dm);
close();
} catch (error) {
@@ -271,7 +268,7 @@ const SendDirectMessageAction = () => {
close,
channelManager,
isSending,
- open,
+ openChannel,
t,
targetUserId,
]);
diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx
index 21eb56d5e8..39f0330412 100644
--- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx
+++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx
@@ -9,6 +9,7 @@ import {
useComponentContextIcons,
useModalContext,
useTranslationContext,
+ useWorkspaceNavigation,
} from '../../../../../context';
import * as DEFAULT_ICONS from '../../../../../components/Icons/icons';
import { ChannelDetailProvider } from '../../../ChannelDetailContext';
@@ -20,10 +21,7 @@ import { mockT } from '../../../../../mock-builders/translator';
vi.mock('../../../../../context');
-const mockOpen = vi.fn();
-vi.mock('../../../../SlotLayout', () => ({
- useChatViewNavigation: () => ({ open: mockOpen }),
-}));
+const mockOpenChannel = vi.fn();
vi.mock('../../../../../components/Notifications', () => ({
useNotificationApi: () => ({ addNotification: vi.fn() }),
@@ -65,6 +63,11 @@ describe('SendDirectMessage action', () => {
vi.mocked(useComponentContext).mockReturnValue(
{} as ReturnType,
);
+ // Workspace navigation, not ChatView's slot API, so a custom WorkspaceNavigationProvider
+ // outside ChatView receives the direct message too.
+ vi.mocked(useWorkspaceNavigation).mockReturnValue(
+ fromPartial({ openChannel: mockOpenChannel }),
+ );
});
it('opens and lists the direct message once it is watched', async () => {
@@ -78,9 +81,7 @@ describe('SendDirectMessage action', () => {
await Promise.resolve();
});
- expect(mockOpen).toHaveBeenCalledWith(
- expect.objectContaining({ key: 'messaging:dm', source: created }),
- );
+ expect(mockOpenChannel).toHaveBeenCalledWith(created);
expect(ingestChannel).toHaveBeenCalledWith(created);
});
@@ -97,9 +98,7 @@ describe('SendDirectMessage action', () => {
await Promise.resolve();
});
- expect(mockOpen).toHaveBeenCalledWith(
- expect.objectContaining({ key: 'messaging:dm', source: stored }),
- );
+ expect(mockOpenChannel).toHaveBeenCalledWith(stored);
expect(ingestChannel).toHaveBeenCalledWith(stored);
});
});
diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx
index 5c1f4e6e92..bb81f5d5c8 100644
--- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx
+++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx
@@ -5,8 +5,8 @@ import {
useComponentContext,
useModalContext,
useTranslationContext,
+ useWorkspaceNavigation,
} from '../../../../context';
-import { useChatViewNavigation } from '../../../SlotLayout';
import { getDateString } from '../../../../i18n/utils';
import { Avatar as DefaultAvatar } from '../../../../components/Avatar';
import { extractDisplayInfo as defaultExtractDisplayInfo } from '../../../../components/Avatar/utils';
@@ -140,7 +140,7 @@ export type PinnedMessagesViewProps = SectionNavigatorSectionContentProps & {
export const PinnedMessagesView: React.ComponentType = ({
searchSource,
}) => {
- const { open } = useChatViewNavigation();
+ const { openChannel } = useWorkspaceNavigation();
const { t } = useTranslationContext();
const { close } = useModalContext();
const { channel } = useChannelDetailContext();
@@ -154,14 +154,14 @@ export const PinnedMessagesView: React.ComponentType =
const handleSelectMessage = useCallback(
(message: PinnedMessage) => {
- // Selection is one navigation model: open the channel into a layout slot.
- open({ key: channel.cid ?? undefined, kind: 'channel', source: channel });
+ // Selection is one navigation model: open the channel in the workspace.
+ openChannel(channel);
// MERGE-RECONCILE: the deleted ChannelActionContext.jumpToMessage was replaced by the
// channel's messagePaginator (PR #2909 / stream-chat message-paginator API).
void channel.messagePaginator.jumpToMessage(message.id);
close();
},
- [channel, close, open],
+ [channel, close, openChannel],
);
const renderItem = useCallback(
diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx
index d713dd12da..131bd3609c 100644
--- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx
+++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx
@@ -19,6 +19,7 @@ import {
useComponentContextIcons,
useModalContext,
useTranslationContext,
+ useWorkspaceNavigation,
} from '../../../../../context';
import * as DEFAULT_ICONS from '../../../../../components/Icons/icons';
import { mockT } from '../../../../../mock-builders/translator';
@@ -253,9 +254,33 @@ describe('PinnedMessagesView', () => {
close: vi.fn(),
} as ReturnType);
+ vi.mocked(useWorkspaceNavigation).mockReturnValue(
+ fromPartial({ openChannel: vi.fn() }),
+ );
+
mockSearchSourceState();
});
+ it('opens the channel through workspace navigation and jumps to the selected message', () => {
+ // Workspace navigation, not ChatView's slot API, so a custom WorkspaceNavigationProvider
+ // outside ChatView receives the selection too.
+ const openChannel = vi.fn();
+ const close = vi.fn();
+ vi.mocked(useWorkspaceNavigation).mockReturnValue(fromPartial({ openChannel }));
+ vi.mocked(useModalContext).mockReturnValue({
+ close,
+ } as unknown as ReturnType);
+ mockSearchSourceState({ messages: pinnedMessages });
+ const channel = createChannel();
+
+ renderWithChannel(, channel);
+ fireEvent.click(screen.getByText('Release timeline: Code freeze March 18'));
+
+ expect(openChannel).toHaveBeenCalledWith(channel);
+ expect(channel.messagePaginator.jumpToMessage).toHaveBeenCalledWith('message-1');
+ expect(close).toHaveBeenCalled();
+ });
+
it('configures MessageSearchSource for pinned messages in the current channel', () => {
renderWithChannel();
diff --git a/src/plugins/SlotLayout/ChatView.tsx b/src/plugins/SlotLayout/ChatView.tsx
index 2d66b05fad..f430a49437 100644
--- a/src/plugins/SlotLayout/ChatView.tsx
+++ b/src/plugins/SlotLayout/ChatView.tsx
@@ -69,6 +69,7 @@ import type {
} from './slotBinding';
import { getLayoutViewState } from './hooks';
import { requireContext } from '../../context/requireContext';
+import { ThemeScopeProvider } from '../../context/ThemeScopeContext';
// Re-export the binding primitives + renderer types from their leaf modules so
// existing `stream-chat-react` imports keep resolving through ChatView.
@@ -460,9 +461,11 @@ export const ChatView = ({
view content (context menus, member actions, …) portal here and inherit the
`.str-chat`-scoped dialog CSS. Nested managers (e.g. MessageList's) still win
where present. */}
-
- {content}
-
+
+
+ {content}
+
+
{channelSlots.map(({ bindingKey, channel, slot }) => (
{
const client = await getTestClientWithUser();
@@ -63,6 +64,40 @@ const renderSelectorWithPanels = async (selectorProps?: any) => {
);
};
+describe('ChatView theme scope', () => {
+ afterEach(cleanup);
+
+ it('tells its content it already renders inside the str-chat theme scope', async () => {
+ const client = await getTestClientWithUser();
+ const ThemeScopeProbe = () => (
+
+ );
+
+ render(
+
+
+
+
+
+
+ ,
+ );
+
+ const probe = screen.getByTestId('probe');
+ expect(probe).toHaveAttribute('data-in-theme-scope', 'true');
+ expect(probe.closest('.str-chat')).toHaveClass('str-chat__chat-view');
+ });
+});
+
describe('ChatView.Selector', () => {
afterEach(() => {
cleanup();
diff --git a/src/styling/base.scss b/src/styling/base.scss
index e753d2101e..03350900f2 100644
--- a/src/styling/base.scss
+++ b/src/styling/base.scss
@@ -33,3 +33,9 @@
color: var(--str-chat__input-text-placeholder);
}
}
+
+// The wrapper a standalone component adds to get the `.str-chat` scope (tokens, font) when nothing
+// above it provides one. It must not take part in layout.
+.str-chat__theme-scope {
+ display: contents;
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index b4b90d1229..a476dd8cb6 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,2 +1 @@
-// export type { ChannelUnreadUiState } from './types';
export type * from './defaultDataInterfaces';
diff --git a/src/types/types.ts b/src/types/types.ts
index cf4589d438..d55a29a770 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -31,8 +31,6 @@ export interface IconProps {
export type Dimensions = { height?: string; width?: string };
-// export type ChannelUnreadUiState = Omit, 'user'>;
-
export type Readable = {
[key in keyof T]: T[key];
} & {};