From 723e209f9583b4900d443b65f05843c51d628657 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 12 Mar 2026 09:39:09 +0100 Subject: [PATCH 1/5] feat: redesign TypingIndicator --- examples/vite/src/stream-imports-layout.scss | 2 +- examples/vite/src/stream-imports-theme.scss | 2 +- src/components/Avatar/AvatarStack.tsx | 13 ++- .../Avatar/styling/AvatarStack.scss | 41 +++------ src/components/Badge/Badge.tsx | 18 ++-- src/components/Badge/styling/Badge.scss | 36 +++++++- src/components/MessageList/MessageList.tsx | 6 +- .../MessageList/VirtualizedMessageList.tsx | 11 ++- .../Poll/styling/PollAnswerList.scss | 14 +-- src/components/Thread/styling/Thread.scss | 2 +- src/components/Thread/styling/ThreadHead.scss | 2 +- .../Thread/styling/ThreadHeader.scss | 3 +- .../ThreadList/styling/ThreadListHeader.scss | 4 +- .../TypingIndicator/TypingIndicator.tsx | 84 +++++++++-------- .../TypingIndicator/TypingIndicatorDots.tsx | 17 ++++ .../__tests__/TypingIndicator.test.js | 29 ++++-- .../styling/TypingIndicator.scss | 90 +++++++++++++++++++ .../TypingIndicator/styling/index.scss | 1 + src/styling/_global-theme-variables.scss | 6 ++ src/styling/index.scss | 5 +- 20 files changed, 279 insertions(+), 107 deletions(-) create mode 100644 src/components/TypingIndicator/TypingIndicatorDots.tsx create mode 100644 src/components/TypingIndicator/styling/TypingIndicator.scss create mode 100644 src/components/TypingIndicator/styling/index.scss diff --git a/examples/vite/src/stream-imports-layout.scss b/examples/vite/src/stream-imports-layout.scss index be04cc6ac9..8fb5397f4c 100644 --- a/examples/vite/src/stream-imports-layout.scss +++ b/examples/vite/src/stream-imports-layout.scss @@ -42,7 +42,7 @@ //@use 'stream-chat-react/dist/scss/v2/Thread/Thread-layout'; //@use 'stream-chat-react/dist/scss/v2/Search/Search-layout'; @use 'stream-chat-react/dist/scss/v2/Tooltip/Tooltip-layout'; -@use 'stream-chat-react/dist/scss/v2/TypingIndicator/TypingIndicator-layout'; +//@use 'stream-chat-react/dist/scss/v2/TypingIndicator/TypingIndicator-layout'; // @use 'stream-chat-react/dist/scss/v2/ThreadList/ThreadList-layout'; //@use 'stream-chat-react/dist/scss/v2/ChatView/ChatView-layout'; //@use 'stream-chat-react/dist/scss/v2/UnreadCountBadge/UnreadCountBadge-layout'; diff --git a/examples/vite/src/stream-imports-theme.scss b/examples/vite/src/stream-imports-theme.scss index e65b35a73e..f563d5017a 100644 --- a/examples/vite/src/stream-imports-theme.scss +++ b/examples/vite/src/stream-imports-theme.scss @@ -36,7 +36,7 @@ //@use 'stream-chat-react/dist/scss/v2/Thread/Thread-theme'; //@use 'stream-chat-react/dist/scss/v2/Search/Search-theme'; @use 'stream-chat-react/dist/scss/v2/Tooltip/Tooltip-theme'; -@use 'stream-chat-react/dist/scss/v2/TypingIndicator/TypingIndicator-theme'; +//@use 'stream-chat-react/dist/scss/v2/TypingIndicator/TypingIndicator-theme'; // @use 'stream-chat-react/dist/scss/v2/ThreadList/ThreadList-theme'; //@use 'stream-chat-react/dist/scss/v2/ChatView/ChatView-theme'; //@use 'stream-chat-react/dist/scss/v2/UnreadCountBadge/UnreadCountBadge-theme'; diff --git a/src/components/Avatar/AvatarStack.tsx b/src/components/Avatar/AvatarStack.tsx index 7a06abcb23..ebdf179cd0 100644 --- a/src/components/Avatar/AvatarStack.tsx +++ b/src/components/Avatar/AvatarStack.tsx @@ -2,8 +2,10 @@ import { type ComponentProps, type ElementType } from 'react'; import { useComponentContext } from '../../context'; import { type AvatarProps, Avatar as DefaultAvatar } from './Avatar'; import clsx from 'clsx'; +import { Badge, type BadgeSize } from '../Badge'; export function AvatarStack({ + badgeSize, component: Component = 'div', displayInfo = [], overflowCount, @@ -12,7 +14,8 @@ export function AvatarStack({ component?: ElementType; displayInfo?: (Pick & { id?: string })[]; overflowCount?: number; - size: 'sm' | 'xs' | null; + size: 'md' | 'sm' | 'xs' | null; + badgeSize?: BadgeSize; }) { const { Avatar = DefaultAvatar } = useComponentContext(AvatarStack.name); @@ -35,7 +38,13 @@ export function AvatarStack({ /> ))} {typeof overflowCount === 'number' && overflowCount > 0 && ( -
{overflowCount}
+ + +{overflowCount} + )} ); diff --git a/src/components/Avatar/styling/AvatarStack.scss b/src/components/Avatar/styling/AvatarStack.scss index 4ea122395f..5a734a2c88 100644 --- a/src/components/Avatar/styling/AvatarStack.scss +++ b/src/components/Avatar/styling/AvatarStack.scss @@ -2,44 +2,27 @@ display: flex; align-items: center; - & > .str-chat__avatar:not(:first-child), .str-chat__avatar-stack__count-badge { - margin-left: calc(var(--spacing-xs) * -1); + position: relative; } - - &.str-chat__avatar-stack--size-sm { - --avatar-stack-count-badge-size: 24px; - // FIXME?: should be sm but it looks way too big - --avatar-stack-count-badge-font-size: var(--typography-font-size-xs); - + &.str-chat__avatar-stack--size-xs { + & > .str-chat__avatar:not(:first-child), .str-chat__avatar-stack__count-badge { - padding-inline: var(--spacing-xs); + margin-left: calc(var(--spacing-xs) * -1); } } - &.str-chat__avatar-stack--size-xs { - --avatar-stack-count-badge-size: 20px; - --avatar-stack-count-badge-font-size: var(--typography-font-size-xxs); - + &.str-chat__avatar-stack--size-sm { + & > .str-chat__avatar:not(:first-child), .str-chat__avatar-stack__count-badge { - padding-inline: var(--spacing-xxs); + margin-left: calc(var(--spacing-sm) * -1); } } - .str-chat__avatar-stack__count-badge { - font-size: var(--avatar-stack-count-badge-font-size); - font-weight: var(--typography-font-weight-bold); - display: flex; - justify-content: center; - align-items: center; - height: var(--avatar-stack-count-badge-size); - min-width: var(--avatar-stack-count-badge-size); - min-height: var(--avatar-stack-count-badge-size); - border-radius: var(--radius-max); - border: 1px solid var(--border-core-subtle); - background: var(--badge-bg-default); - box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.14); - line-height: 1; - position: relative; + &.str-chat__avatar-stack--size-md { + & > .str-chat__avatar:not(:first-child), + .str-chat__avatar-stack__count-badge { + margin-left: calc(var(--spacing-sm) * -1); + } } } diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index f68b2f0cd2..305ce10fbe 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -1,11 +1,17 @@ import clsx from 'clsx'; import React, { type ComponentProps } from 'react'; -export type BadgeVariant = 'default' | 'primary' | 'error' | 'neutral' | 'inverse'; +export type BadgeVariant = + | 'default' + | 'primary' + | 'error' + | 'neutral' + | 'counter' + | 'inverse'; -export type BadgeSize = 'sm' | 'md' | 'lg'; +export type BadgeSize = 'xs' | 'sm' | 'md' | 'lg' | null; -export type BadgeProps = ComponentProps<'span'> & { +export type BadgeProps = ComponentProps<'div'> & { /** Visual variant mapping to design tokens */ variant?: BadgeVariant; /** Size preset (typography and padding) */ @@ -23,15 +29,15 @@ export const Badge = ({ variant = 'default', ...spanProps }: BadgeProps) => ( - {children} - + ); diff --git a/src/components/Badge/styling/Badge.scss b/src/components/Badge/styling/Badge.scss index 9740739101..e5c8cdf878 100644 --- a/src/components/Badge/styling/Badge.scss +++ b/src/components/Badge/styling/Badge.scss @@ -2,7 +2,7 @@ // Figma: Badge Notification (scroll-to-bottom unread count) .str-chat__badge { - display: inline-flex; + display: flex; align-items: center; justify-content: center; font-weight: var(--typography-font-weight-bold); @@ -69,3 +69,37 @@ border-width: 2px; box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.14); } + +.str-chat__badge--variant-counter { + border-radius: var(--radius-max); + border: 1px solid var(--border-core-subtle); + background: var(--badge-bg-default); + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.14); + font: var(--str-chat__numeric-xl-text); + + &.str-chat__badge--size-xs { + min-width: 20px; + min-height: 20px; + padding-inline: var(--spacing-xxs); + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.14); + font: var(--str-chat__numeric-md-text); + } + + &.str-chat__badge--size-sm { + min-width: 24px; + min-height: 24px; + padding-inline: var(--spacing-xs); + } + + &.str-chat__badge--size-md { + min-width: 32px; + min-height: 32px; + padding-inline: var(--spacing-xs); + } + + &.str-chat__badge--size-lg { + min-width: 40px; + min-height: 40px; + padding-inline: var(--spacing-sm); + } +} diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index e27c5c61d1..589936a7d5 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -283,7 +283,11 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { {elements} - +
diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 201b13c16e..8391610ba8 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -515,6 +515,16 @@ const VirtualizedMessageListWithContext = ( EmptyPlaceholder, Header, Item, + ...(TypingIndicator && { + Footer: () => + isMessageListScrolledToBottom ? ( + + ) : null, + }), ...virtuosoComponentsFromProps, }} computeItemKey={computeItemKey} @@ -584,7 +594,6 @@ const VirtualizedMessageListWithContext = ( />
- {TypingIndicator && } {giphyPreviewMessage && } diff --git a/src/components/Poll/styling/PollAnswerList.scss b/src/components/Poll/styling/PollAnswerList.scss index 2dd9c031aa..b3b3ea3957 100644 --- a/src/components/Poll/styling/PollAnswerList.scss +++ b/src/components/Poll/styling/PollAnswerList.scss @@ -22,13 +22,13 @@ } .str-chat__poll-answer { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--spacing-xxs); - align-self: stretch; - border-radius: var(--radius-lg); - background: var(--background-core-surface-card); + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-xxs); + align-self: stretch; + border-radius: var(--radius-lg); + background: var(--background-core-surface-card); .str-chat__poll-answer__data { display: flex; diff --git a/src/components/Thread/styling/Thread.scss b/src/components/Thread/styling/Thread.scss index b0bce7c971..0d0feda27b 100644 --- a/src/components/Thread/styling/Thread.scss +++ b/src/components/Thread/styling/Thread.scss @@ -49,4 +49,4 @@ .str-chat__main-panel-inner { height: 100%; } -} \ No newline at end of file +} diff --git a/src/components/Thread/styling/ThreadHead.scss b/src/components/Thread/styling/ThreadHead.scss index 38bce5f725..2a4c8a8a82 100644 --- a/src/components/Thread/styling/ThreadHead.scss +++ b/src/components/Thread/styling/ThreadHead.scss @@ -20,4 +20,4 @@ color: var(--chat-text-system); font: var(--str-chat__metadata-emphasis-text); } -} \ No newline at end of file +} diff --git a/src/components/Thread/styling/ThreadHeader.scss b/src/components/Thread/styling/ThreadHeader.scss index 7ef2ca1bbf..367ee86325 100644 --- a/src/components/Thread/styling/ThreadHeader.scss +++ b/src/components/Thread/styling/ThreadHeader.scss @@ -26,7 +26,6 @@ --str-chat__thread-header-box-shadow: none; } - .str-chat__thread-header { @include utils.header-layout; @include utils.component-layer-overrides('thread-header'); @@ -74,4 +73,4 @@ fill: var(--str-chat__thread-color); } } -} \ No newline at end of file +} diff --git a/src/components/Threads/ThreadList/styling/ThreadListHeader.scss b/src/components/Threads/ThreadList/styling/ThreadListHeader.scss index 096407ae12..a60e2cd744 100644 --- a/src/components/Threads/ThreadList/styling/ThreadListHeader.scss +++ b/src/components/Threads/ThreadList/styling/ThreadListHeader.scss @@ -21,7 +21,9 @@ &.str-chat__thread-list__header--sidebar-collapsed { opacity: 0; pointer-events: none; - transform: translateX(calc(0px - var(--str-chat__channel-list-transition-offset, 8px))); + transform: translateX( + calc(0px - var(--str-chat__channel-list-transition-offset, 8px)) + ); .str-chat__header-sidebar-toggle { // Compact styling when sidebar collapsed diff --git a/src/components/TypingIndicator/TypingIndicator.tsx b/src/components/TypingIndicator/TypingIndicator.tsx index 6097286b7f..b929eb239d 100644 --- a/src/components/TypingIndicator/TypingIndicator.tsx +++ b/src/components/TypingIndicator/TypingIndicator.tsx @@ -1,46 +1,29 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import clsx from 'clsx'; +import { AvatarStack } from '../Avatar'; +import { TypingIndicatorDots } from './TypingIndicatorDots'; import { useChannelStateContext } from '../../context/ChannelStateContext'; import { useChatContext } from '../../context/ChatContext'; import { useTypingContext } from '../../context/TypingContext'; -import { useTranslationContext } from '../../context/TranslationContext'; + +const MAX_AVATARS = 3; export type TypingIndicatorProps = { + /** When false, the indicator is not rendered (e.g. when list is not scrolled to bottom). Omit or true to show when typing. */ + isMessageListScrolledToBottom?: boolean; + scrollToBottom: () => void; /** Whether the typing indicator is in a thread */ threadList?: boolean; }; -const useJoinTypingUsers = (names: string[]) => { - const { t } = useTranslationContext(); - - if (!names.length) return null; - - const [name, ...rest] = names; - - if (names.length === 1) - return t('{{ user }} is typing...', { - user: name, - }); - - const MAX_JOINED_USERS = 3; - - if (names.length > MAX_JOINED_USERS) - return t('{{ users }} and more are typing...', { - users: names.slice(0, MAX_JOINED_USERS).join(', ').trim(), - }); - - return t('{{ users }} and {{ user }} are typing...', { - user: name, - users: rest.join(', ').trim(), - }); -}; - /** - * TypingIndicator lists users currently typing, it needs to be a child of Channel component + * TypingIndicator shows avatars of users currently typing and a bubble with animated dots. + * Renders only for other participants (never the current user), only when scrolled to latest message if isMessageListScrolledToBottom is provided. + * It must be a child of Channel component. */ const UnMemoizedTypingIndicator = (props: TypingIndicatorProps) => { - const { threadList } = props; + const { isMessageListScrolledToBottom = true, scrollToBottom, threadList } = props; const { channelConfig, thread } = useChannelStateContext('TypingIndicator'); const { client } = useChatContext('TypingIndicator'); @@ -58,20 +41,30 @@ const UnMemoizedTypingIndicator = (props: TypingIndicatorProps) => { ) : []; - const typingUserList = (threadList ? typingInThread : typingInChannel) - .map(({ user }) => user?.name || user?.id) - .filter(Boolean) as string[]; + const typingUsers = threadList ? typingInThread : typingInChannel; - const joinedTypingUsers = useJoinTypingUsers(typingUserList); + const isTypingActive = typingUsers.length > 0; + const displayInfo = typingUsers.slice(0, MAX_AVATARS).map(({ user }) => ({ + id: user?.id, + imageUrl: user?.image, + userName: user?.name || user?.id || '', + })); - const isTypingActive = - (threadList && typingInThread.length) || (!threadList && typingInChannel.length); + useEffect(() => { + if (isTypingActive && isMessageListScrolledToBottom) scrollToBottom(); + }, [scrollToBottom, isMessageListScrolledToBottom, isTypingActive]); if (channelConfig?.typing_events === false) { return null; } - if (!isTypingActive) return null; + if (!isTypingActive || !isMessageListScrolledToBottom) { + return null; + } + + const overflowCount = + typingUsers.length > MAX_AVATARS ? typingUsers.length - MAX_AVATARS : 0; + return (
{ })} data-testid='typing-indicator' > -
- - - -
-
- {joinedTypingUsers} + {displayInfo.length > 0 && ( + 0 ? overflowCount : undefined} + size='md' + /> + )} +
+
+ +
); diff --git a/src/components/TypingIndicator/TypingIndicatorDots.tsx b/src/components/TypingIndicator/TypingIndicatorDots.tsx new file mode 100644 index 0000000000..c88d04e4cc --- /dev/null +++ b/src/components/TypingIndicator/TypingIndicatorDots.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +/** Three dots for typing indicator; fill and opacity animation come from TypingIndicator.scss (--chat-text-typing-indicator). */ +export const TypingIndicatorDots = () => ( + + + + + +); diff --git a/src/components/TypingIndicator/__tests__/TypingIndicator.test.js b/src/components/TypingIndicator/__tests__/TypingIndicator.test.js index 283da34ba3..8e96c6b82a 100644 --- a/src/components/TypingIndicator/__tests__/TypingIndicator.test.js +++ b/src/components/TypingIndicator/__tests__/TypingIndicator.test.js @@ -23,7 +23,12 @@ expect.extend(toHaveNoViolations); const me = generateUser(); -async function renderComponent(typing = {}, threadList, value = {}) { +async function renderComponent( + typing = {}, + threadList, + value = {}, + typingIndicatorProps = {}, +) { const client = await getTestClientWithUser(me); return render( @@ -31,7 +36,7 @@ async function renderComponent(typing = {}, threadList, value = {}) { - + @@ -84,7 +89,7 @@ describe('TypingIndicator', () => { }); expect(container.firstChild).toHaveClass('str-chat__typing-indicator--typing'); - expect(screen.getByText('{{ user }} is typing...')).toBeInTheDocument(); + expect(screen.getByTestId('typing-indicator')).toBeInTheDocument(); const results = await axe(container); expect(results).toHaveNoViolations(); }); @@ -97,7 +102,7 @@ describe('TypingIndicator', () => { }); expect(container.firstChild).toHaveClass('str-chat__typing-indicator--typing'); - expect(screen.getByText('{{ user }} is typing...')).toBeInTheDocument(); + expect(screen.getByTestId('typing-indicator')).toBeInTheDocument(); const results = await axe(container); expect(results).toHaveNoViolations(); }); @@ -109,9 +114,7 @@ describe('TypingIndicator', () => { joris: { user: { id: 'joris', image: 'joris.jpg' } }, margriet: { user: { id: 'margriet', image: 'margriet.jpg' } }, }); - expect( - screen.getByText('{{ users }} and {{ user }} are typing...'), - ).toBeInTheDocument(); + expect(screen.getByTestId('typing-indicator')).toBeInTheDocument(); const results = await axe(container); expect(results).toHaveNoViolations(); }); @@ -124,11 +127,21 @@ describe('TypingIndicator', () => { joris: { user: { id: 'joris', image: 'joris.jpg' } }, margriet: { user: { id: 'margriet', image: 'margriet.jpg' } }, }); - expect(screen.getByText('{{ users }} and more are typing...')).toBeInTheDocument(); + expect(screen.getByTestId('typing-indicator')).toBeInTheDocument(); const results = await axe(container); expect(results).toHaveNoViolations(); }); + it('should render null when isMessageListScrolledToBottom is false', async () => { + const { container } = await renderComponent( + { jessica: { user: { id: 'jessica', image: 'jessica.jpg' } } }, + false, + {}, + { isMessageListScrolledToBottom: false }, + ); + expect(container).toBeEmptyDOMElement(); + }); + it('should render null if typing_events is disabled', async () => { const client = await getTestClientWithUser(); const ch = generateChannel({ config: { typing_events: false } }); diff --git a/src/components/TypingIndicator/styling/TypingIndicator.scss b/src/components/TypingIndicator/styling/TypingIndicator.scss new file mode 100644 index 0000000000..0f60383fe3 --- /dev/null +++ b/src/components/TypingIndicator/styling/TypingIndicator.scss @@ -0,0 +1,90 @@ +/* Inline typing indicator: avatar stack + bubble with animated dots (message-row layout) */ +.str-chat__typing-indicator { + display: flex; + align-items: flex-end; + gap: var(--spacing-xs); + padding-block: var(--spacing-xs); + padding-inline: 0; +} + +.str-chat__typing-indicator__bubble { + display: inline-flex; + align-items: center; + min-height: 36px; + max-height: 36px; + padding-block: var(--spacing-xs); + padding-inline: var(--spacing-sm); + border-radius: var(--message-bubble-radius-group-bottom) + var(--message-bubble-radius-group-bottom) var(--message-bubble-radius-group-bottom) + var(--message-bubble-radius-tail); + background: var(--chat-bg-incoming); + + // variable --chat-bg-typing-indicator has incorrect color value? + //background: var(--chat-bg-typing-indicator); + border: 1px solid var(--chat-bg-incoming); + background: var(--chat-bg-incoming, #ebeef1); +} + +.str-chat__typing-indicator__dots { + display: flex; + align-items: center; + column-gap: var(--spacing-xxs); + + .str-chat__typing-indicator__dot { + width: 5px; + height: 5px; + border-radius: 50%; + background-color: var(--chat-text-typing-indicator); + animation: str-chat__typing-indicator-dot 1.2s ease-in-out infinite both; + + &:nth-child(1) { + animation-delay: 0s; + } + + &:nth-child(2) { + animation-delay: 0.15s; + } + + &:nth-child(3) { + animation-delay: 0.3s; + } + } +} + +/* SVG dots (TypingIndicatorDots component) */ +.str-chat__typing-indicator__dots svg { + display: block; + + circle:nth-child(1) { + fill: var(--chat-text-typing-indicator); + animation: str-chat__typing-indicator-dot 1.2s ease-in-out infinite both; + animation-delay: 0s; + } + + circle:nth-child(2) { + fill: var(--chat-text-typing-indicator); + animation: str-chat__typing-indicator-dot 1.2s ease-in-out infinite both; + animation-delay: 0.15s; + } + + circle:nth-child(3) { + fill: var(--chat-text-typing-indicator); + animation: str-chat__typing-indicator-dot 1.2s ease-in-out infinite both; + animation-delay: 0.3s; + } +} + +@keyframes str-chat__typing-indicator-dot { + 0%, + 100% { + opacity: 1; + } + + 33% { + opacity: 0.75; + } + + 66% { + opacity: 0.5; + } +} diff --git a/src/components/TypingIndicator/styling/index.scss b/src/components/TypingIndicator/styling/index.scss new file mode 100644 index 0000000000..265362a69b --- /dev/null +++ b/src/components/TypingIndicator/styling/index.scss @@ -0,0 +1 @@ +@use 'TypingIndicator'; diff --git a/src/styling/_global-theme-variables.scss b/src/styling/_global-theme-variables.scss index 73fbab07cd..e748847d1f 100644 --- a/src/styling/_global-theme-variables.scss +++ b/src/styling/_global-theme-variables.scss @@ -77,6 +77,12 @@ var(--typography-font-size-xl) / var(--typography-line-height-relaxed) var(--str-chat__font-family); + --str-chat__numeric-md-text: normal var(--typography-font-weight-bold) + var(--typography-font-size-xxs) / 100% var(--str-chat__font-family); + + --str-chat__numeric-xl-text: normal var(--typography-font-weight-bold) + var(--typography-font-size-sm) / 100% var(--str-chat__font-family); + color: var(--text-primary, #1a1b25); // todo: adapt the old text variables to so that they use the new semantic text variables diff --git a/src/styling/index.scss b/src/styling/index.scss index ddfe09f9ad..111cb67a8a 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -45,13 +45,14 @@ @use '../components/MessageList/styling' as MessageList; @use '../components/Poll/styling' as Poll; @use '../components/Reactions/styling' as Reactions; +@use '../experimental/Search/styling' as Search; +@use '../components/SummarizedMessagePreview/styling' as SummarizedMessagePreview; @use '../components/TextareaComposer/styling' as TextareaComposer; @use '../components/Thread/styling' as Thread; @use '../components/Threads/styling' as Threads; @use '../components/Threads/ThreadList/styling' as ThreadList; +@use '../components/TypingIndicator/styling' as TypingIndicator; @use '../components/VideoPlayer/styling' as VideoPlayer; -@use '../components/SummarizedMessagePreview/styling' as SummarizedMessagePreview; -@use '../experimental/Search/styling/' as Search; // Layers have to be kept the last @import 'modern-normalize' layer(css-reset); From 7afbcbbdca03440f4eaa99c575a894a0b5088d8a Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 12 Mar 2026 12:00:59 +0100 Subject: [PATCH 2/5] fix: align messages in thread with the ThreadHead message --- src/components/MessageList/styling/MessageList.scss | 13 +++++++++++++ src/components/Thread/styling/ThreadHead.scss | 5 +++++ src/styling/_utils.scss | 5 +---- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/components/MessageList/styling/MessageList.scss b/src/components/MessageList/styling/MessageList.scss index b9bc802e69..8d5cbe0c6a 100644 --- a/src/components/MessageList/styling/MessageList.scss +++ b/src/components/MessageList/styling/MessageList.scss @@ -40,6 +40,19 @@ } } +.str-chat__thread { + .str-chat__message-list .str-chat__message-list-scroll { + padding-inline: 0; + /* Max container 800px, 16px padding → 768px readable content; matches composer width + padding */ + max-width: var(--str-chat__message-list-scroll-max-width); + .str-chat__ul { + list-style: none; + padding: 0; + margin: 0; + } + } +} + .str-chat__main-panel { .str-chat__ul { .str-chat__li:first-of-type { diff --git a/src/components/Thread/styling/ThreadHead.scss b/src/components/Thread/styling/ThreadHead.scss index 2a4c8a8a82..5196181d06 100644 --- a/src/components/Thread/styling/ThreadHead.scss +++ b/src/components/Thread/styling/ThreadHead.scss @@ -2,7 +2,12 @@ padding-block-start: var(--spacing-sm); .str-chat__message { + max-width: calc( + var(--str-chat__message-composer-max-width) + + var(--str-chat__message-composer-padding) + ); padding-block: var(--spacing-xs); + margin-inline: auto; } .str-chat__thread-start { diff --git a/src/styling/_utils.scss b/src/styling/_utils.scss index 3b19eb0385..e473e13890 100644 --- a/src/styling/_utils.scss +++ b/src/styling/_utils.scss @@ -130,14 +130,11 @@ } @mixin message-list-spacing { - // 800px container, 16px padding, 768px content — align with composer - $spacing: var(--str-chat__message-composer-padding, 16px); padding: 0; - padding-inline: $spacing; // Need this trick to be able to apply full-width background color on hover to messages / full-width separator to thread header .str-chat__li { - margin-inline: calc(-1 * #{$spacing}); + max-width: 100%; } } From fd7bede3b5eefc37a925c3ece58e9e983767ec95 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 12 Mar 2026 16:09:04 +0100 Subject: [PATCH 3/5] feat: add TypingIndicatorHeader --- .../ChannelHeader/ChannelHeader.tsx | 33 ++++++-- src/components/Thread/ThreadHeader.tsx | 49 +++++++++++- .../TypingIndicator/TypingIndicator.tsx | 29 ++++--- .../TypingIndicator/TypingIndicatorHeader.tsx | 76 +++++++++++++++++++ .../useDebouncedTypingActive.test.js | 61 +++++++++++++++ src/components/TypingIndicator/hooks/index.ts | 2 + .../hooks/useDebouncedTypingActive.ts | 52 +++++++++++++ src/components/TypingIndicator/index.ts | 1 + .../styling/TypingIndicator.scss | 40 +++++++++- src/i18n/de.json | 5 ++ src/i18n/en.json | 5 ++ src/i18n/es.json | 5 ++ src/i18n/fr.json | 5 ++ src/i18n/hi.json | 5 ++ src/i18n/it.json | 5 ++ src/i18n/ja.json | 5 ++ src/i18n/ko.json | 5 ++ src/i18n/nl.json | 5 ++ src/i18n/pt.json | 5 ++ src/i18n/ru.json | 6 ++ src/i18n/tr.json | 5 ++ src/styling/_utils.scss | 2 +- 22 files changed, 383 insertions(+), 23 deletions(-) create mode 100644 src/components/TypingIndicator/TypingIndicatorHeader.tsx create mode 100644 src/components/TypingIndicator/hooks/__tests__/useDebouncedTypingActive.test.js create mode 100644 src/components/TypingIndicator/hooks/index.ts create mode 100644 src/components/TypingIndicator/hooks/useDebouncedTypingActive.ts diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index bedd89580a..5d85003977 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -2,13 +2,39 @@ import React from 'react'; import { IconLayoutAlignLeft } from '../Icons/icons'; import { type ChannelAvatarProps, ChannelAvatar as DefaultAvatar } from '../Avatar'; +import { TypingIndicatorHeader } from '../TypingIndicator/TypingIndicatorHeader'; import { useChannelHeaderOnlineStatus } from './hooks/useChannelHeaderOnlineStatus'; import { useChannelPreviewInfo } from '../ChannelPreview/hooks/useChannelPreviewInfo'; import { useChannelStateContext } from '../../context/ChannelStateContext'; import { useChatContext } from '../../context/ChatContext'; +import { useTypingContext } from '../../context/TypingContext'; import clsx from 'clsx'; import { ToggleSidebarButton } from '../Button/ToggleSidebarButton'; +const ChannelHeaderSubtitle = () => { + const { channelConfig } = useChannelStateContext('ChannelHeaderSubtitle'); + const { client } = useChatContext('ChannelHeaderSubtitle'); + const { typing = {} } = useTypingContext('ChannelHeaderSubtitle'); + const onlineStatusText = useChannelHeaderOnlineStatus(); + const typingInChannel = Object.values(typing).filter( + ({ parent_id, user }) => user?.id !== client.user?.id && !parent_id, + ); + const hasTyping = channelConfig?.typing_events !== false && typingInChannel.length > 0; + + if (!hasTyping && !onlineStatusText) return null; + + return ( +
+ + {hasTyping ? : onlineStatusText} + +
+ ); +}; + export type ChannelHeaderProps = { /** UI component to display an avatar, defaults to [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) component and accepts the same props as: [ChannelAvatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/ChannelAvatar.tsx) */ Avatar?: React.ComponentType; @@ -38,7 +64,6 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { overrideImage, overrideTitle, }); - const onlineStatusText = useChannelHeaderOnlineStatus(); return (
{
{displayTitle}
- {onlineStatusText != null && ( -
- {onlineStatusText} -
- )} +
({ replyCount }); const displayNameFromParentMessage = (message: LocalMessage): string | undefined => message.user?.name ?? undefined; +/** Subtitle: replyCount, threadDisplayName, defaultSubtitle (name · reply count), and when typing also TypingIndicatorHeader. */ +const ThreadHeaderSubtitle = ({ + replyCount, + threadDisplayName, + threadList, +}: { + replyCount: number | undefined; + threadDisplayName: string | undefined; + threadList: boolean; +}) => { + const { t } = useTranslationContext(); + const { channelConfig, thread } = useChannelStateContext('ThreadHeaderSubtitle'); + const threadInstance = useThreadContext(); + const parentId = threadInstance?.id ?? thread?.id; + const { client } = useChatContext('ThreadHeaderSubtitle'); + const { typing = {} } = useTypingContext('ThreadHeaderSubtitle'); + const typingInThread = Object.values(typing).filter( + ({ parent_id, user }) => user?.id !== client.user?.id && parent_id === parentId, + ); + const hasTyping = channelConfig?.typing_events !== false && typingInThread.length > 0; + const defaultSubtitle = + threadDisplayName + ' · ' + t('replyCount', { count: replyCount ?? 0 }); + return ( +
+ + {hasTyping ? ( + + ) : ( + <>{defaultSubtitle} + )} + +
+ ); +}; + export type ThreadHeaderProps = { /** Callback for closing the thread */ closeThread: (event?: React.BaseSyntheticEvent) => void; @@ -54,9 +95,11 @@ export const ThreadHeader = (props: ThreadHeaderProps) => {
{t('Thread')}
-
- {threadDisplayName + ' · ' + t('replyCount', { count: replyCount })} -
+
{!threadInstance && (