From 3362fee78f01ec66ea5cd2696cca0310aace0453 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 10 Mar 2026 17:14:04 +0100 Subject: [PATCH] feat: unify channel and thread display titles --- src/components/Avatar/ChannelAvatar.tsx | 12 +- src/components/Avatar/GroupAvatar.tsx | 25 ++-- .../ChannelHeader/ChannelHeader.tsx | 8 +- .../ChannelPreviewMessenger.tsx | 3 +- .../ChannelPreview/__tests__/utils.test.js | 13 ++- .../hooks/useChannelPreviewInfo.ts | 110 ++++++++++++------ src/components/ChannelPreview/utils.tsx | 43 +++---- .../Poll/styling/PollAnswerList.scss | 14 +-- src/components/Thread/Thread.tsx | 10 +- src/components/Thread/ThreadHeader.tsx | 79 ++++++++----- src/components/Thread/ThreadHeaderMain.tsx | 47 -------- src/components/Thread/styling/Thread.scss | 2 +- src/components/Thread/styling/ThreadHead.scss | 2 +- .../Thread/styling/ThreadHeader.scss | 11 +- .../ThreadList/styling/ThreadListHeader.scss | 4 +- 15 files changed, 202 insertions(+), 181 deletions(-) delete mode 100644 src/components/Thread/ThreadHeaderMain.tsx diff --git a/src/components/Avatar/ChannelAvatar.tsx b/src/components/Avatar/ChannelAvatar.tsx index 191720f7a..7a7eeb8ce 100644 --- a/src/components/Avatar/ChannelAvatar.tsx +++ b/src/components/Avatar/ChannelAvatar.tsx @@ -2,22 +2,28 @@ import React from 'react'; import { Avatar, GroupAvatar } from './'; import type { AvatarProps, GroupAvatarProps } from './'; +import type { GroupAvatarMember } from './GroupAvatar'; export type ChannelAvatarProps = Partial> & { size: GroupAvatarProps['size']; + /** When set with length >= 2, GroupAvatar is used. */ + displayMembers?: GroupAvatarMember[]; + overflowCount?: number; }; export const ChannelAvatar = ({ - groupChannelDisplayInfo, + displayMembers, imageUrl, + overflowCount, size, userName, ...sharedProps }: ChannelAvatarProps) => { - if (groupChannelDisplayInfo) { + if ((displayMembers?.length ?? 0) >= 2) { return ( diff --git a/src/components/Avatar/GroupAvatar.tsx b/src/components/Avatar/GroupAvatar.tsx index 23e6e33e2..b4efc0a6c 100644 --- a/src/components/Avatar/GroupAvatar.tsx +++ b/src/components/Avatar/GroupAvatar.tsx @@ -1,24 +1,29 @@ import clsx from 'clsx'; import React, { type ComponentPropsWithoutRef } from 'react'; import { Avatar, type AvatarProps } from './Avatar'; -import type { GroupChannelDisplayInfo } from '../ChannelPreview'; + +export type GroupAvatarMember = { + imageUrl?: string; + userName?: string; +}; export type GroupAvatarProps = ComponentPropsWithoutRef<'div'> & { - /** Mapping of image URLs to names which initials will be used as fallbacks in case image assets fail to load. */ - groupChannelDisplayInfo: GroupChannelDisplayInfo; + /** List of members to show as avatars; at most 2 when overflowCount is set, otherwise 4. Defaults to [] when omitted. */ + displayMembers?: GroupAvatarMember[]; + /** Optional count for the "+N" badge when there are more members than shown. */ + overflowCount?: number; size: '2xl' | 'xl' | 'lg' | null; isOnline?: boolean; - overflowCount?: number; }; /** - * Avatar component to display multiple users' avatars in a group channel, with a maximum of 4 avatars shown. - * Renders a single Avatar if only one user is provided. + * Avatar component to display multiple users' avatars in a group. + * Renders a single Avatar if fewer than 2 members. Otherwise, renders up to 2 avatars (when overflowCount is set) or 4, plus an optional +N badge. */ // TODO: rename to AvatarGroup export const GroupAvatar = ({ className, - groupChannelDisplayInfo, + displayMembers = [], isOnline, overflowCount, size, @@ -26,8 +31,8 @@ export const GroupAvatar = ({ }: GroupAvatarProps) => { const displayCountBadge = typeof overflowCount === 'number' && overflowCount > 0; - if (!groupChannelDisplayInfo || groupChannelDisplayInfo.length < 2) { - const [firstUser] = groupChannelDisplayInfo || []; + if (displayMembers.length < 2) { + const firstUser = displayMembers[0]; return ( - {groupChannelDisplayInfo + {displayMembers .slice(0, displayCountBadge ? 2 : 4) .map(({ imageUrl, userName }, index) => ( { } = props; const { channel } = useChannelStateContext(); - const { navOpen } = useChatContext('ChannelHeader'); + const { navOpen } = useChatContext(); const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ channel, overrideImage, @@ -60,8 +59,9 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { diff --git a/src/components/ChannelPreview/ChannelPreviewMessenger.tsx b/src/components/ChannelPreview/ChannelPreviewMessenger.tsx index bc50e80fa..fd6a54d4d 100644 --- a/src/components/ChannelPreview/ChannelPreviewMessenger.tsx +++ b/src/components/ChannelPreview/ChannelPreviewMessenger.tsx @@ -66,8 +66,9 @@ const UnMemoizedChannelPreviewMessenger = (props: ChannelPreviewUIComponentProps role='option' > diff --git a/src/components/ChannelPreview/__tests__/utils.test.js b/src/components/ChannelPreview/__tests__/utils.test.js index c71f345ae..be3a6ccc8 100644 --- a/src/components/ChannelPreview/__tests__/utils.test.js +++ b/src/components/ChannelPreview/__tests__/utils.test.js @@ -14,7 +14,7 @@ import { useMockedApis, } from 'mock-builders'; -import { getDisplayImage, getDisplayTitle, getLatestMessagePreview } from '../utils'; +import { getLatestMessagePreview } from '../utils'; import { generateStaticLocationResponse } from '../../../mock-builders'; import { render } from '@testing-library/react'; @@ -107,7 +107,7 @@ describe('ChannelPreview utils', () => { generateChannel({ channel: { name } }), ); - expect(getDisplayTitle(channel, chatClient.user)).toBe(name); + expect(channel.getDisplayName()).toBe(name); }); it('should return name of other member of conversation if only 2 members and channel name doesnot exist', async () => { @@ -120,7 +120,7 @@ describe('ChannelPreview utils', () => { ], }), ); - expect(getDisplayTitle(channel, chatClient.user)).toBe(otherUser.name); + expect(channel.getDisplayName()).toBe(otherUser.name); }); }); @@ -131,10 +131,10 @@ describe('ChannelPreview utils', () => { generateChannel({ channel: { image } }), ); - expect(getDisplayImage(channel, chatClient.user)).toBe(image); + expect(channel.getDisplayImage()).toBe(image); }); - it('should return picture of other member of conversation if only 2 members and channel name doesnot exist', async () => { + it('should return null when no image is available (image fallback removed)', async () => { const otherUser = generateUser(); const channel = await getQueriedChannelInstance( generateChannel({ @@ -144,7 +144,8 @@ describe('ChannelPreview utils', () => { ], }), ); - expect(getDisplayImage(channel, chatClient.user)).toBe(otherUser.image); + // getDisplayImage no longer falls back to member image, only channel.data.image + expect(channel.getDisplayImage()).toBeNull(); }); }); }); diff --git a/src/components/ChannelPreview/hooks/useChannelPreviewInfo.ts b/src/components/ChannelPreview/hooks/useChannelPreviewInfo.ts index 0563377ef..f2b982923 100644 --- a/src/components/ChannelPreview/hooks/useChannelPreviewInfo.ts +++ b/src/components/ChannelPreview/hooks/useChannelPreviewInfo.ts @@ -1,54 +1,90 @@ -import { useEffect, useState } from 'react'; -import type { Channel } from 'stream-chat'; +import { useMemo } from 'react'; +import type { Channel, StateStore } from 'stream-chat'; -import { getDisplayImage, getDisplayTitle, getGroupChannelDisplayInfo } from '../utils'; -import { useChatContext } from '../../../context'; +import { useStateStore } from '../../../store'; +import type { GroupChannelDisplayInfo } from '../utils'; export type ChannelPreviewInfoParams = { - channel: Channel; + /** Channel to read display info from; when undefined, returns undefined display title/image */ + channel?: Channel; /** Manually set the image to render, defaults to the Channel image */ overrideImage?: string; /** Set title manually */ overrideTitle?: string; }; -export const useChannelPreviewInfo = (props: ChannelPreviewInfoParams) => { - const { channel, overrideImage, overrideTitle } = props; +/** ChannelState with reactive display and members stores (stream-chat-js) */ +type ChannelStateWithStores = { + displayStore?: { + getLatestValue(): { displayName: string | null; displayImage: string | null }; + }; + membersStore?: { + getLatestValue(): { + members: Record; + }; + }; +}; - const { client } = useChatContext('useChannelPreviewInfo'); - const [displayTitle, setDisplayTitle] = useState( - () => overrideTitle || getDisplayTitle(channel, client.user), - ); - const [displayImage, setDisplayImage] = useState( - () => overrideImage || getDisplayImage(channel, client.user), - ); +const displayStoreSelector = (s: { + displayName: string | null; + displayImage: string | null; +}) => ({ + displayImage: s.displayImage, + displayName: s.displayName, +}); + +type MembersState = { + members: Record; +}; + +function buildGroupChannelDisplayInfo( + s: MembersState | undefined, +): GroupChannelDisplayInfo { + if (!s?.members) return { members: [], overflowCount: undefined }; + const memberList = (Object.values(s.members) as MembersState['members'][string][]) + .filter((m) => m.user?.name || m.user?.image) + .map((m) => ({ imageUrl: m.user?.image, userName: m.user?.name })); + if (memberList.length <= 2) return { members: [], overflowCount: undefined }; + return { + members: memberList, + overflowCount: memberList.length > 4 ? memberList.length - 2 : undefined, + }; +} - const [groupChannelDisplayInfo, setGroupDisplayChannelInfo] = useState< - ReturnType - >(() => getGroupChannelDisplayInfo(channel)); +export const useChannelPreviewInfo = (props: ChannelPreviewInfoParams) => { + const { channel, overrideImage, overrideTitle } = props; - useEffect(() => { - if (overrideTitle && overrideImage) return; + const channelState = (channel?.state ?? undefined) as + | ChannelStateWithStores + | undefined; - const updateInfo = () => { - if (!overrideTitle) setDisplayTitle(getDisplayTitle(channel, client.user)); - if (!overrideImage) { - setDisplayImage(getDisplayImage(channel, client.user)); - setGroupDisplayChannelInfo(getGroupChannelDisplayInfo(channel)); - } - }; + const displayFromStore = useStateStore( + (channelState?.displayStore ?? undefined) as + | StateStore<{ displayName: string | null; displayImage: string | null }> + | undefined, + displayStoreSelector, + ); - updateInfo(); + const groupChannelDisplayInfo = useStateStore( + (channelState?.membersStore ?? undefined) as + | StateStore<{ + members: Record; + }> + | undefined, + buildGroupChannelDisplayInfo, + ); - client.on('user.updated', updateInfo); - return () => { - client.off('user.updated', updateInfo); - }; - }, [channel, channel.data, client, overrideImage, overrideTitle]); + const displayTitleResolved = + overrideTitle ?? displayFromStore?.displayName ?? undefined; + const displayImageResolved = + overrideImage ?? displayFromStore?.displayImage ?? undefined; - return { - displayImage: overrideImage || displayImage, - displayTitle: overrideTitle || displayTitle, - groupChannelDisplayInfo, - }; + return useMemo( + () => ({ + displayImage: displayImageResolved, + displayTitle: displayTitleResolved, + groupChannelDisplayInfo, + }), + [displayImageResolved, displayTitleResolved, groupChannelDisplayInfo], + ); }; diff --git a/src/components/ChannelPreview/utils.tsx b/src/components/ChannelPreview/utils.tsx index dc4d8abd5..c772a2d8b 100644 --- a/src/components/ChannelPreview/utils.tsx +++ b/src/components/ChannelPreview/utils.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import React from 'react'; import ReactMarkdown from 'react-markdown'; -import type { Channel, PollVote, UserResponse } from 'stream-chat'; +import type { Channel, PollVote } from 'stream-chat'; import type { ChatContextValue } from '../../context'; import { getTranslatedMessageText } from '../../context/MessageTranslationViewContext'; @@ -108,7 +108,16 @@ export const getLatestMessagePreview = ( return t('Empty message...'); }; -export type GroupChannelDisplayInfo = { imageUrl?: string; userName?: string }[]; +export type GroupChannelDisplayInfoMember = { + imageUrl?: string; + userName?: string; +}; + +export type GroupChannelDisplayInfo = { + members: GroupChannelDisplayInfoMember[]; + /** When members.length > 4, count for the "+N" badge (members.length - 2). */ + overflowCount?: number; +}; export const getGroupChannelDisplayInfo = ( channel: Channel, @@ -116,32 +125,14 @@ export const getGroupChannelDisplayInfo = ( const members = Object.values(channel.state.members); if (members.length <= 2) return; - const data: GroupChannelDisplayInfo = []; + const memberList: GroupChannelDisplayInfoMember[] = []; for (const member of members) { const { user } = member; if (!user?.name && !user?.image) continue; - data.push({ imageUrl: user.image, userName: user.name }); - if (data.length === 4) break; + memberList.push({ imageUrl: user.image, userName: user.name }); } - return data; + return { + members: memberList, + overflowCount: memberList.length > 4 ? memberList.length - 2 : undefined, + }; }; - -const getChannelDisplayInfo = ( - info: 'name' | 'image', - channel: Channel, - currentUser?: UserResponse, -) => { - if (channel.data?.[info]) return channel.data[info]; - const members = Object.values(channel.state.members); - if (members.length !== 2) return; - const otherMember = members.find((member) => member.user?.id !== currentUser?.id); - return ( - otherMember?.user?.[info] || (info === 'name' ? otherMember?.user?.id : undefined) - ); -}; - -export const getDisplayTitle = (channel: Channel, currentUser?: UserResponse) => - getChannelDisplayInfo('name', channel, currentUser); - -export const getDisplayImage = (channel: Channel, currentUser?: UserResponse) => - getChannelDisplayInfo('image', channel, currentUser); diff --git a/src/components/Poll/styling/PollAnswerList.scss b/src/components/Poll/styling/PollAnswerList.scss index 2dd9c031a..b3b3ea395 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/Thread.tsx b/src/components/Thread/Thread.tsx index 5ff6e9338..031327953 100644 --- a/src/components/Thread/Thread.tsx +++ b/src/components/Thread/Thread.tsx @@ -8,7 +8,6 @@ import { MessageInput, MessageInputFlat } from '../MessageInput'; import type { MessageListProps, VirtualizedMessageListProps } from '../MessageList'; import { MessageList, VirtualizedMessageList } from '../MessageList'; import { ThreadHeader as DefaultThreadHeader } from './ThreadHeader'; -import { ThreadHeaderMain as DefaultThreadHeaderMain } from './ThreadHeaderMain'; import { ThreadHead as DefaultThreadHead } from '../Thread/ThreadHead'; import { @@ -23,7 +22,6 @@ import { useStateStore } from '../../store'; import type { MessageProps, MessageUIComponentProps } from '../Message/types'; import type { MessageActionsArray } from '../Message/utils'; import type { ThreadState } from 'stream-chat'; -import { useChatViewContext } from '../ChatView'; export type ThreadProps = { /** Additional props for `MessageInput` component: [available props](https://getstream.io/chat/docs/sdk/react/message-input-components/message_input/#props) */ @@ -88,7 +86,6 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { messageActions = Object.keys(MESSAGE_ACTIONS), virtualized, } = props; - const { activeChatView } = useChatViewContext(); const threadInstance = useThreadContext(); const { @@ -180,12 +177,7 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { }} >
- {activeChatView === 'threads' ? ( - // todo: add ThreadHeaderMain alongisde ThreadHeader property to ComponentContext? - - ) : ( - - )} + ({ + displayName, + replyCount, +}); + +/** Fallback when no Thread instance: use parent message author (name only, not id). */ +const displayNameFromParentMessage = (message: LocalMessage): string | undefined => + message.user?.name ?? undefined; + export type ThreadHeaderProps = { /** Callback for closing the thread */ closeThread: (event?: React.BaseSyntheticEvent) => void; /** The thread parent message */ thread: LocalMessage; + /** Override the thread display title */ + overrideTitle?: string; }; -export const ThreadHeader = ( - props: ThreadHeaderProps & - Pick, -) => { - const { closeThread, overrideImage, overrideTitle } = props; +export const ThreadHeader = (props: ThreadHeaderProps) => { + const { closeThread, overrideTitle, thread } = props; - const { t } = useTranslationContext('ThreadHeader'); - const { channel } = useChannelStateContext(''); - const { displayTitle } = useChannelPreviewInfo({ + const { t } = useTranslationContext(); + const { channel } = useChannelStateContext('ThreadHeader'); + const { displayTitle: channelDisplayTitle } = useChannelPreviewInfo({ channel, - overrideImage, - overrideTitle, }); + const threadInstance = useThreadContext(); + const { displayName, replyCount: replyCountThreadInstance } = + useStateStore(threadInstance?.state, threadStateSelector) ?? {}; + + const replyCount = threadInstance + ? replyCountThreadInstance + : thread + ? (thread.reply_count ?? 0) + : 0; + + const threadDisplayName = + overrideTitle ?? + displayName ?? + (threadInstance == null ? channelDisplayTitle : undefined) ?? + displayNameFromParentMessage(thread) ?? + undefined; return (
{t('Thread')}
-
{displayTitle}
+
+ {threadDisplayName + ' ยท ' + t('replyCount', { count: replyCount })} +
- + {!threadInstance && ( + + )}
); }; diff --git a/src/components/Thread/ThreadHeaderMain.tsx b/src/components/Thread/ThreadHeaderMain.tsx deleted file mode 100644 index 9a69b0bf8..000000000 --- a/src/components/Thread/ThreadHeaderMain.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useTranslationContext } from '../../context'; -import React from 'react'; -import { ToggleSidebarButton } from '../Button/ToggleSidebarButton'; -import { useThreadContext } from '../Threads'; -import { useStateStore } from '../../store'; -import type { ThreadState } from 'stream-chat'; -import { IconLayoutAlignLeft } from '../Icons'; - -const threadStateSelector = ({ replyCount }: ThreadState) => ({ replyCount }); - -export type ThreadHeaderMainProps = { - /** UI component to display menu icon, defaults to IconLayoutAlignLeft*/ - MenuIcon?: React.ComponentType; - /** Set title manually */ - title?: string; -}; - -/** - * This header is the default header rendered for Thread in 'threads' chat view. - * It provides layout control capabilities - toggling sidebar open / close. - * The purpose is to provide layout control for the main message list in threads view. - */ -export const ThreadHeaderMain = ({ - MenuIcon = IconLayoutAlignLeft, - title, -}: ThreadHeaderMainProps) => { - const { t } = useTranslationContext('ThreadHeader'); - const thread = useThreadContext(); - - const { replyCount } = useStateStore(thread?.state, threadStateSelector) ?? { - replyCount: 0, - }; - - return ( -
- - - -
-
{title ?? t('Thread')}
-
- {t('replyCount', { count: replyCount })} -
-
-
- ); -}; diff --git a/src/components/Thread/styling/Thread.scss b/src/components/Thread/styling/Thread.scss index b0bce7c97..0d0feda27 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 38bce5f72..2a4c8a8a8 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 7ef2ca1bb..23257bd5e 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,12 @@ fill: var(--str-chat__thread-color); } } -} \ No newline at end of file +} + +.str-chat__chat-view__threads { + .str-chat__thread-header { + .str-chat__thread-header-details { + align-items: center; + } + } +} diff --git a/src/components/Threads/ThreadList/styling/ThreadListHeader.scss b/src/components/Threads/ThreadList/styling/ThreadListHeader.scss index 096407ae1..a60e2cd74 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