diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 9f5a27d0e6..955c5609f8 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -40,6 +40,7 @@ import { ReactionOptions, mapEmojiMartData, useStateStore, + TypingIndicator, } from 'stream-chat-react'; import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis'; import { init, SearchIndex } from 'emoji-mart'; @@ -318,31 +319,35 @@ const App = () => { sort={sort} showChannelSearch /> - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + + + 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/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} -
- )} +
{ {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/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/Reactions/MessageReactionsDetail.tsx b/src/components/Reactions/MessageReactionsDetail.tsx index d07ab37180..61b654c65d 100644 --- a/src/components/Reactions/MessageReactionsDetail.tsx +++ b/src/components/Reactions/MessageReactionsDetail.tsx @@ -3,8 +3,8 @@ import React, { useMemo } from 'react'; import type { ReactionDetailsComparator, ReactionSummary, ReactionType } from './types'; import { useFetchReactions } from './hooks/useFetchReactions'; -import { LoadingIndicator as DefaultLoadingIndicator } from '../Loading'; import { Avatar as DefaultAvatar } from '../Avatar'; +import type { MessageContextValue } from '../../context'; import { useChatContext, useComponentContext, @@ -12,7 +12,6 @@ import { useTranslationContext, } from '../../context'; import type { ReactionSort } from 'stream-chat'; -import type { MessageContextValue } from '../../context'; export type MessageReactionsDetailProps = Partial< Pick @@ -38,8 +37,7 @@ export function MessageReactionsDetail({ totalReactionCount, }: MessageReactionsDetailProps) { const { client } = useChatContext(); - const { Avatar = DefaultAvatar, LoadingIndicator = DefaultLoadingIndicator } = - useComponentContext(MessageReactionsDetail.name); + const { Avatar = DefaultAvatar } = useComponentContext(MessageReactionsDetail.name); const { t } = useTranslationContext(); const { diff --git a/src/components/Thread/ThreadHeader.tsx b/src/components/Thread/ThreadHeader.tsx index 5197338450..b82ee6d24f 100644 --- a/src/components/Thread/ThreadHeader.tsx +++ b/src/components/Thread/ThreadHeader.tsx @@ -4,7 +4,10 @@ import { useChannelStateContext } from '../../context/ChannelStateContext'; import { useTranslationContext } from '../../context/TranslationContext'; import { useStateStore } from '../../store'; import { useChannelPreviewInfo } from '../ChannelPreview/hooks/useChannelPreviewInfo'; +import { TypingIndicatorHeader } from '../TypingIndicator/TypingIndicatorHeader'; import { useThreadContext } from '../Threads'; +import { useChatContext } from '../../context/ChatContext'; +import { useTypingContext } from '../../context/TypingContext'; import type { LocalMessage } from 'stream-chat'; import type { ThreadState } from 'stream-chat'; @@ -17,6 +20,44 @@ const threadStateSelector = ({ replyCount }: ThreadState) => ({ 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 && (