diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index cbb3cc526..eeeae8b18 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -265,7 +265,9 @@ const App = () => { - + + + 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..0967ac604 100644 --- a/src/components/ChannelPreview/__tests__/utils.test.js +++ b/src/components/ChannelPreview/__tests__/utils.test.js @@ -14,7 +14,11 @@ import { useMockedApis, } from 'mock-builders'; -import { getDisplayImage, getDisplayTitle, getLatestMessagePreview } from '../utils'; +import { + getChannelDisplayImage, + getGroupChannelDisplayInfo, + getLatestMessagePreview, +} from '../utils'; import { generateStaticLocationResponse } from '../../../mock-builders'; import { render } from '@testing-library/react'; @@ -107,7 +111,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 +124,7 @@ describe('ChannelPreview utils', () => { ], }), ); - expect(getDisplayTitle(channel, chatClient.user)).toBe(otherUser.name); + expect(channel.getDisplayName()).toBe(otherUser.name); }); }); @@ -131,10 +135,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 +148,74 @@ 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(); + }); + }); + + describe('getChannelDisplayImage (utils)', () => { + it('returns channel.data.image when set', async () => { + const image = nanoid(); + const channel = await getQueriedChannelInstance( + generateChannel({ channel: { image } }), + ); + expect(getChannelDisplayImage(channel)).toBe(image); + }); + + it('returns other member user.image for DM (2 members) when channel has no image', async () => { + const otherUser = generateUser({ image: 'https://other-avatar.jpg' }); + const channel = await getQueriedChannelInstance( + generateChannel({ + members: [ + generateMember({ user: otherUser }), + generateMember({ user: clientUser }), + ], + }), + ); + expect(getChannelDisplayImage(channel)).toBe('https://other-avatar.jpg'); + }); + + it('returns undefined for DM when other member has no image', async () => { + const otherUser = generateUser({ image: undefined }); + const channel = await getQueriedChannelInstance( + generateChannel({ + members: [ + generateMember({ user: otherUser }), + generateMember({ user: clientUser }), + ], + }), + ); + expect(getChannelDisplayImage(channel)).toBeUndefined(); + }); + }); + + describe('getGroupChannelDisplayInfo (utils)', () => { + it('returns undefined for 2 or fewer members', async () => { + const channel = await getQueriedChannelInstance( + generateChannel({ + members: [ + generateMember({ user: generateUser() }), + generateMember({ user: clientUser }), + ], + }), + ); + expect(getGroupChannelDisplayInfo(channel)).toBeUndefined(); + }); + + it('returns members and overflowCount for 3+ members', async () => { + const channel = await getQueriedChannelInstance( + generateChannel({ + members: [ + generateMember({ user: generateUser({ image: 'a.jpg', name: 'A' }) }), + generateMember({ user: generateUser({ image: 'b.jpg', name: 'B' }) }), + generateMember({ user: clientUser }), + ], + }), + ); + const info = getGroupChannelDisplayInfo(channel); + expect(info).toBeDefined(); + expect(info.members.length).toBeGreaterThanOrEqual(2); + expect(info.members.every((m) => 'imageUrl' in m && 'userName' in m)).toBe(true); }); }); }); diff --git a/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewInfo.test.js b/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewInfo.test.js new file mode 100644 index 000000000..76894c63c --- /dev/null +++ b/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewInfo.test.js @@ -0,0 +1,209 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; + +import { ChatContext } from '../../../../context/ChatContext'; +import { TranslationProvider } from '../../../../context/TranslationContext'; +import { + generateChannel, + generateMember, + generateUser, + getOrCreateChannelApi, + getTestClientWithUser, + useMockedApis, +} from '../../../../mock-builders'; +import { useChannelPreviewInfo } from '../useChannelPreviewInfo'; + +const clientUser = generateUser({ id: 'current-user' }); + +const getClientAndChannel = async (channelOverrides = {}) => { + const client = await getTestClientWithUser(clientUser); + const mockedChannel = generateChannel({ + members: [ + generateMember({ user: clientUser }), + generateMember({ user: generateUser() }), + ], + ...channelOverrides, + }); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); + + const channel = client.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + return { channel, client }; +}; + +const createWrapper = (client) => + function Wrapper({ children }) { + return ( + + key }}>{children} + + ); + }; + +describe('useChannelPreviewInfo', () => { + describe('without channel', () => { + it('returns undefined displayTitle and displayImage, empty groupChannelDisplayInfo', () => { + const client = { off: jest.fn(), on: jest.fn() }; + const { result } = renderHook(() => useChannelPreviewInfo({}), { + wrapper: createWrapper(client), + }); + + expect(result.current.displayTitle).toBeUndefined(); + expect(result.current.displayImage).toBeUndefined(); + expect(result.current.groupChannelDisplayInfo).toEqual({ + members: [], + overflowCount: undefined, + }); + expect(client.on).not.toHaveBeenCalled(); + }); + }); + + describe('with channel', () => { + it('returns displayTitle from channel (via useChannelDisplayName)', async () => { + const channelName = 'Test Channel'; + const { channel, client } = await getClientAndChannel({ + channel: { name: channelName }, + }); + + const { result } = renderHook(() => useChannelPreviewInfo({ channel }), { + wrapper: createWrapper(client), + }); + + expect(result.current.displayTitle).toBe(channelName); + }); + + it('returns displayImage from channel.data.image', async () => { + const imageUrl = 'https://channel-image.jpg'; + const { channel, client } = await getClientAndChannel({ + channel: { image: imageUrl }, + }); + + const { result } = renderHook(() => useChannelPreviewInfo({ channel }), { + wrapper: createWrapper(client), + }); + + expect(result.current.displayImage).toBe(imageUrl); + }); + + it('returns groupChannelDisplayInfo with empty members for 2-member channel', async () => { + const { channel, client } = await getClientAndChannel(); + + const { result } = renderHook(() => useChannelPreviewInfo({ channel }), { + wrapper: createWrapper(client), + }); + + expect(result.current.groupChannelDisplayInfo).toEqual({ + members: [], + overflowCount: undefined, + }); + }); + + it('returns groupChannelDisplayInfo with members for 3+ member channel', async () => { + const { channel, client } = await getClientAndChannel({ + members: [ + generateMember({ user: generateUser({ image: 'a.jpg', name: 'A' }) }), + generateMember({ user: generateUser({ image: 'b.jpg', name: 'B' }) }), + generateMember({ user: clientUser }), + ], + }); + + const { result } = renderHook(() => useChannelPreviewInfo({ channel }), { + wrapper: createWrapper(client), + }); + + expect( + result.current.groupChannelDisplayInfo.members.length, + ).toBeGreaterThanOrEqual(2); + expect( + result.current.groupChannelDisplayInfo.members.every( + (m) => 'imageUrl' in m && 'userName' in m, + ), + ).toBe(true); + }); + + it('uses overrideTitle over channel display title', async () => { + const { channel, client } = await getClientAndChannel({ + channel: { name: 'Channel Name' }, + }); + + const { result } = renderHook( + () => useChannelPreviewInfo({ channel, overrideTitle: 'Custom Title' }), + { wrapper: createWrapper(client) }, + ); + + expect(result.current.displayTitle).toBe('Custom Title'); + }); + + it('uses overrideImage over channel display image', async () => { + const { channel, client } = await getClientAndChannel({ + channel: { image: 'https://channel.jpg' }, + }); + + const { result } = renderHook( + () => useChannelPreviewInfo({ channel, overrideImage: 'https://override.jpg' }), + { wrapper: createWrapper(client) }, + ); + + expect(result.current.displayImage).toBe('https://override.jpg'); + }); + + it('subscribes to user.updated and updates displayImage and groupChannelDisplayInfo', async () => { + const imageUrl = 'https://initial.jpg'; + const { channel, client } = await getClientAndChannel({ + channel: { image: imageUrl }, + }); + + const { result } = renderHook(() => useChannelPreviewInfo({ channel }), { + wrapper: createWrapper(client), + }); + + expect(result.current.displayImage).toBe(imageUrl); + expect(client.on).toHaveBeenCalledWith('user.updated', expect.any(Function)); + + const updateInfo = client.on.mock.calls.find((c) => c[0] === 'user.updated')?.[1]; + expect(updateInfo).toBeDefined(); + + act(() => { + updateInfo(); + }); + + expect(result.current.displayImage).toBe(imageUrl); + }); + + it('does not subscribe to user.updated when overrideImage is set', async () => { + const { channel, client } = await getClientAndChannel({ + channel: { image: 'https://channel.jpg' }, + }); + + renderHook( + () => useChannelPreviewInfo({ channel, overrideImage: 'https://override.jpg' }), + { wrapper: createWrapper(client) }, + ); + + expect(client.on).not.toHaveBeenCalled(); + }); + + it('unsubscribes from user.updated on unmount', async () => { + const { channel, client } = await getClientAndChannel(); + + const { unmount } = renderHook(() => useChannelPreviewInfo({ channel }), { + wrapper: createWrapper(client), + }); + + expect(client.on).toHaveBeenCalledWith('user.updated', expect.any(Function)); + const updateInfo = client.on.mock.calls[0][1]; + + unmount(); + + expect(client.off).toHaveBeenCalledWith('user.updated', updateInfo); + }); + }); +}); diff --git a/src/components/ChannelPreview/hooks/index.ts b/src/components/ChannelPreview/hooks/index.ts index 16eec9ba2..c7a445d5d 100644 --- a/src/components/ChannelPreview/hooks/index.ts +++ b/src/components/ChannelPreview/hooks/index.ts @@ -1,2 +1,3 @@ +export { useChannelDisplayName } from './useChannelDisplayName'; export { useChannelPreviewInfo } from './useChannelPreviewInfo'; export { MessageDeliveryStatus } from './useMessageDeliveryStatus'; diff --git a/src/components/ChannelPreview/hooks/useChannelDisplayName.ts b/src/components/ChannelPreview/hooks/useChannelDisplayName.ts new file mode 100644 index 000000000..2eacad35b --- /dev/null +++ b/src/components/ChannelPreview/hooks/useChannelDisplayName.ts @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import type { Channel } from 'stream-chat'; + +import { useChatContext } from '../../../context'; +import { useTranslationContext } from '../../../context/TranslationContext'; + +/** + * 1. channel.data.name + * 2. DM (exactly 2 members): other member's name, then directMessageLabel + * 3. Group (3+ members): comma-separated list of 2 other members' names (no ellipsis) + * 4. undefined otherwise + */ +function computeChannelDisplayName( + channel: Channel, + directMessageLabel: string, +): string | undefined { + const data = channel.data as { name?: string } | undefined; + if (data?.name && typeof data.name === 'string') return data.name; + + const memberList = Object.values(channel.state.members); + const currentUserId = channel.getClient().userID ?? undefined; + const otherMembers = memberList.filter((m) => m.user?.id !== currentUserId); + + if (memberList.length === 2 && otherMembers.length === 1) { + const name = otherMembers[0].user?.name; + return name || directMessageLabel; + } + if (otherMembers.length >= 2) { + const names = otherMembers + .map((m) => m.user?.name) + .filter(Boolean) + .slice(0, 2) as string[]; + if (names.length > 0) return names.join(', '); + } + return undefined; +} + +/** + * Channel display name with translation context. + * 1. channel.data.name + * 2. DM (exactly 2 members): other member's name, then translated "Direct message" + * 3. Group (3+ members): comma-separated list of 2 other members' names (no ellipsis) + * 4. undefined otherwise + */ +export const useChannelDisplayName = ( + channel: Channel | undefined, +): string | undefined => { + const { client } = useChatContext('useChannelDisplayName'); + const { t } = useTranslationContext('useChannelDisplayName'); + const directMessageLabel = t('Direct message'); + + const [displayName, setDisplayName] = useState(() => + channel ? computeChannelDisplayName(channel, directMessageLabel) : undefined, + ); + + useEffect(() => { + if (!channel) { + setDisplayName(undefined); + return; + } + const updateDisplayName = () => + setDisplayName(computeChannelDisplayName(channel, directMessageLabel)); + updateDisplayName(); + client.on('user.updated', updateDisplayName); + return () => { + client.off('user.updated', updateDisplayName); + }; + }, [channel, channel?.data, client, directMessageLabel]); + + return displayName; +}; diff --git a/src/components/ChannelPreview/hooks/useChannelPreviewInfo.ts b/src/components/ChannelPreview/hooks/useChannelPreviewInfo.ts index 0563377ef..16de21824 100644 --- a/src/components/ChannelPreview/hooks/useChannelPreviewInfo.ts +++ b/src/components/ChannelPreview/hooks/useChannelPreviewInfo.ts @@ -1,11 +1,22 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import type { Channel } from 'stream-chat'; -import { getDisplayImage, getDisplayTitle, getGroupChannelDisplayInfo } from '../utils'; import { useChatContext } from '../../../context'; +import { + getChannelDisplayImage, + getGroupChannelDisplayInfo, + type GroupChannelDisplayInfo, +} from '../utils'; +import { useChannelDisplayName } from './useChannelDisplayName'; + +const emptyGroupInfo: GroupChannelDisplayInfo = { + members: [], + overflowCount: undefined, +}; 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 */ @@ -14,41 +25,41 @@ export type ChannelPreviewInfoParams = { export const useChannelPreviewInfo = (props: ChannelPreviewInfoParams) => { const { channel, overrideImage, overrideTitle } = props; + const { client } = useChatContext(); - const { client } = useChatContext('useChannelPreviewInfo'); - const [displayTitle, setDisplayTitle] = useState( - () => overrideTitle || getDisplayTitle(channel, client.user), - ); - const [displayImage, setDisplayImage] = useState( - () => overrideImage || getDisplayImage(channel, client.user), - ); + const channelDisplayName = useChannelDisplayName(channel); + const displayTitle = overrideTitle ?? channelDisplayName; - const [groupChannelDisplayInfo, setGroupDisplayChannelInfo] = useState< - ReturnType - >(() => getGroupChannelDisplayInfo(channel)); + const [displayImage, setDisplayImage] = useState(() => + channel ? (overrideImage ?? getChannelDisplayImage(channel)) : undefined, + ); + const [groupChannelDisplayInfo, setGroupChannelDisplayInfo] = + useState(() => + channel ? (getGroupChannelDisplayInfo(channel) ?? emptyGroupInfo) : emptyGroupInfo, + ); useEffect(() => { - if (overrideTitle && overrideImage) return; + if (!channel) return; + if (overrideImage) return; const updateInfo = () => { - if (!overrideTitle) setDisplayTitle(getDisplayTitle(channel, client.user)); - if (!overrideImage) { - setDisplayImage(getDisplayImage(channel, client.user)); - setGroupDisplayChannelInfo(getGroupChannelDisplayInfo(channel)); - } + setDisplayImage(getChannelDisplayImage(channel)); + setGroupChannelDisplayInfo(getGroupChannelDisplayInfo(channel) ?? emptyGroupInfo); }; updateInfo(); - client.on('user.updated', updateInfo); return () => { client.off('user.updated', updateInfo); }; - }, [channel, channel.data, client, overrideImage, overrideTitle]); + }, [channel, channel?.data, client, overrideImage]); - return { - displayImage: overrideImage || displayImage, - displayTitle: overrideTitle || displayTitle, - groupChannelDisplayInfo, - }; + return useMemo( + () => ({ + displayImage: overrideImage ?? displayImage, + displayTitle, + groupChannelDisplayInfo, + }), + [displayImage, displayTitle, groupChannelDisplayInfo, overrideImage], + ); }; diff --git a/src/components/ChannelPreview/utils.tsx b/src/components/ChannelPreview/utils.tsx index dc4d8abd5..fa52a4669 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,33 @@ 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; +}; + +/** + * Channel display image: channel.data.image, or for DM (2 members) the other member's user.image. + */ +export const getChannelDisplayImage = (channel: Channel): string | undefined => { + const data = channel.data as { image?: string } | undefined; + if (data?.image && typeof data.image === 'string') return data.image; + + const memberList = Object.values(channel.state.members); + const currentUserId = channel.getClient().userID ?? undefined; + if (memberList.length === 2) { + const other = memberList.find((m) => m.user?.id !== currentUserId); + const image = other?.user?.image; + if (image && typeof image === 'string') return image; + } + return undefined; +}; export const getGroupChannelDisplayInfo = ( channel: Channel, @@ -116,32 +142,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? - - ) : ( - - )} + ({ replyCount }); + +/** Fallback when channel has no display title: parent message author (name only). */ +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(); + const { channel } = useChannelStateContext('ThreadHeader'); + const { displayTitle: channelDisplayTitle } = useChannelPreviewInfo({ channel }); + + const threadInstance = useThreadContext(); + const { replyCount: replyCountThreadInstance } = + useStateStore(threadInstance?.state, threadStateSelector) ?? {}; + + const replyCount = threadInstance + ? replyCountThreadInstance + : thread + ? (thread.reply_count ?? 0) + : 0; - const { t } = useTranslationContext('ThreadHeader'); - const { channel } = useChannelStateContext(''); - const { displayTitle } = useChannelPreviewInfo({ - channel, - overrideImage, - overrideTitle, - }); + // Subtitle: channel display title (from parent or hook), with override and fallback to parent message author + const threadDisplayName = + overrideTitle ?? + channelDisplayTitle ?? + 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/ThreadStart.tsx b/src/components/Thread/ThreadStart.tsx index 35a37647e..a800093f7 100644 --- a/src/components/Thread/ThreadStart.tsx +++ b/src/components/Thread/ThreadStart.tsx @@ -2,16 +2,30 @@ import React from 'react'; import { useChannelStateContext } from '../../context/ChannelStateContext'; import { useTranslationContext } from '../../context/TranslationContext'; +import { useThreadContext } from '../Threads'; +import { useStateStore } from '../../store'; +import type { ThreadState } from 'stream-chat'; + +const threadStateSelector = ({ replyCount }: ThreadState) => ({ + replyCount, +}); export const ThreadStart = () => { const { thread } = useChannelStateContext('ThreadStart'); const { t } = useTranslationContext('ThreadStart'); + const threadInstance = useThreadContext(); + const { replyCount: replyCountThreadInstance } = + useStateStore(threadInstance?.state, threadStateSelector) ?? {}; + + const replyCount = threadInstance + ? replyCountThreadInstance + : thread + ? (thread.reply_count ?? 0) + : 0; - if (!thread?.reply_count) return null; + if (!replyCount) return null; return ( -
- {t('replyCount', { count: thread.reply_count })} -
+
{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 diff --git a/src/i18n/de.json b/src/i18n/de.json index a391a74fe..c3a5476c3 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -123,6 +123,7 @@ "Delete for me": "Für mich löschen", "Delete message": "Nachricht löschen", "Delivered": "Zugestellt", + "Direct message": "Direktnachricht", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Möchten Sie diese Umfrage jetzt beenden? Niemand wird mehr in dieser Umfrage abstimmen können.", "Download attachment {{ name }}": "Anhang {{ name }} herunterladen", "Drag your files here": "Ziehen Sie Ihre Dateien hierher", diff --git a/src/i18n/en.json b/src/i18n/en.json index 0d2e736a5..a9d090101 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -123,6 +123,7 @@ "Delete for me": "Delete for me", "Delete message": "Delete message", "Delivered": "Delivered", + "Direct message": "Direct message", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.", "Download attachment {{ name }}": "Download attachment {{ name }}", "Drag your files here": "Drag your files here", diff --git a/src/i18n/es.json b/src/i18n/es.json index 12f8a8171..0de68dc8b 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -130,6 +130,7 @@ "Delete for me": "Eliminar para mí", "Delete message": "Eliminar mensaje", "Delivered": "Entregado", + "Direct message": "Mensaje directo", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "¿Quieres terminar esta encuesta ahora? Nadie podrá votar más en esta encuesta.", "Download attachment {{ name }}": "Descargar adjunto {{ name }}", "Drag your files here": "Arrastra tus archivos aquí", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 5db7711b9..83a4e7f75 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -130,6 +130,7 @@ "Delete for me": "Supprimer pour moi", "Delete message": "Supprimer le message", "Delivered": "Publié", + "Direct message": "Message direct", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Voulez-vous terminer ce sondage maintenant ? Personne ne pourra plus voter dans ce sondage.", "Download attachment {{ name }}": "Télécharger la pièce jointe {{ name }}", "Drag your files here": "Glissez vos fichiers ici", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 87c30d4cd..430b66e3c 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -123,6 +123,7 @@ "Delete for me": "मेरे लिए डिलीट करें", "Delete message": "संदेश हटाएं", "Delivered": "पहुंच गया", + "Direct message": "प्रत्यक्ष संदेश", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "क्या आप अभी इस पोल को समाप्त करना चाहते हैं? इस पोल में अब कोई भी वोट नहीं कर पाएगा।", "Download attachment {{ name }}": "अनुलग्नक {{ name }} डाउनलोड करें", "Drag your files here": "अपनी फ़ाइलें यहाँ खींचें", diff --git a/src/i18n/it.json b/src/i18n/it.json index d84e06cf8..33a13b551 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -130,6 +130,7 @@ "Delete for me": "Elimina per me", "Delete message": "Elimina messaggio", "Delivered": "Consegnato", + "Direct message": "Messaggio diretto", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Vuoi terminare questo sondaggio ora? Nessuno potrà più votare in questo sondaggio.", "Download attachment {{ name }}": "Scarica l'allegato {{ name }}", "Drag your files here": "Trascina i tuoi file qui", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 6ee3da900..61aab5711 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -122,6 +122,7 @@ "Delete for me": "自分用に削除", "Delete message": "メッセージを削除", "Delivered": "配信しました", + "Direct message": "ダイレクトメッセージ", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "このアンケートを今終了しますか?終了すると、誰も投票できなくなります。", "Download attachment {{ name }}": "添付ファイル {{ name }} をダウンロード", "Drag your files here": "ここにファイルをドラッグ", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 4db0921be..d7f585959 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -122,6 +122,7 @@ "Delete for me": "나만 삭제", "Delete message": "메시지 삭제", "Delivered": "배달됨", + "Direct message": "다이렉트 메시지", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "지금 이 투표를 종료하시겠습니까? 종료 후에는 더 이상 투표할 수 없습니다.", "Download attachment {{ name }}": "첨부 파일 {{ name }} 다운로드", "Drag your files here": "여기로 파일을 끌어다 놓으세요", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index d11cf4952..991d99287 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -123,6 +123,7 @@ "Delete for me": "Voor mij verwijderen", "Delete message": "Bericht verwijderen", "Delivered": "Afgeleverd", + "Direct message": "Direct bericht", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Wil je deze peiling nu beëindigen? Niemand kan daarna meer op deze peiling stemmen.", "Download attachment {{ name }}": "Bijlage {{ name }} downloaden", "Drag your files here": "Sleep je bestanden hier naartoe", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 5793f3e65..83d874a24 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -130,6 +130,7 @@ "Delete for me": "Excluir para mim", "Delete message": "Excluir mensagem", "Delivered": "Entregue", + "Direct message": "Mensagem direta", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Deseja encerrar esta enquete agora? Ninguém poderá mais votar nesta enquete.", "Download attachment {{ name }}": "Baixar anexo {{ name }}", "Drag your files here": "Arraste seus arquivos aqui", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 891ed56b8..d20fdbf95 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -137,6 +137,7 @@ "Delete for me": "Удалить для меня", "Delete message": "Удалить сообщение", "Delivered": "Отправлено", + "Direct message": "Личное сообщение", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Вы хотите завершить этот опрос сейчас? После этого никто не сможет голосовать в этом опросе.", "Download attachment {{ name }}": "Скачать вложение {{ name }}", "Drag your files here": "Перетащите ваши файлы сюда", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 9d22ab412..2b72a1335 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -123,6 +123,7 @@ "Delete for me": "Benim için sil", "Delete message": "Mesajı sil", "Delivered": "İletildi", + "Direct message": "Doğrudan mesaj", "Do you want to end this poll now? Nobody will be able to vote in this poll anymore.": "Bu anketi şimdi sonlandırmak istiyor musunuz? Sonlandırdıktan sonra bu ankette kimse oy kullanamayacak.", "Download attachment {{ name }}": "Ek {{ name }}'i indir", "Drag your files here": "Dosyalarınızı buraya sürükleyin",