From 51ba09f608990cdc922ef05387d8e6f10d3a36ba Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 6 Mar 2026 13:03:13 +0100 Subject: [PATCH 1/2] feat: update empty states across chat views --- examples/vite/src/stream-imports-layout.scss | 2 +- examples/vite/src/stream-imports-theme.scss | 2 +- package.json | 2 +- scripts/watch-styling.mjs | 229 ++++++++++++++++++ src/components/Button/Button.tsx | 3 +- src/components/Channel/Channel.tsx | 24 +- .../Channel/__tests__/Channel.test.js | 62 +++-- .../__snapshots__/Channel.test.js.snap | 2 +- .../ChannelList/ChannelListMessenger.tsx | 4 +- .../ChannelList/styling/ChannelList.scss | 142 +++++++++++ src/components/ChannelList/styling/index.scss | 1 + src/components/ChatView/ChatView.tsx | 37 ++- .../ChatView/__tests__/ChatView.test.js | 123 ++++++++++ .../EmptyStateIndicator.tsx | 19 +- .../__tests__/EmptyStateIndicator.test.js | 2 +- src/components/EmptyStateIndicator/icons.tsx | 17 -- src/components/Icons/BaseIcon.tsx | 2 +- src/components/Icons/createIcon.tsx | 2 +- src/components/Icons/icons.tsx | 21 ++ src/components/Message/MessageBubble.tsx | 2 +- .../useFloatingDateSeparatorMessageList.ts | 8 + .../ThreadList/ThreadListEmptyPlaceholder.tsx | 21 +- .../ThreadList/styling/ThreadList.scss | 53 ++++ .../Threads/ThreadList/styling/index.scss | 1 + src/i18n/de.json | 4 +- src/i18n/en.json | 4 +- src/i18n/es.json | 4 +- src/i18n/fr.json | 4 +- src/i18n/hi.json | 4 +- src/i18n/it.json | 4 +- src/i18n/ja.json | 4 +- src/i18n/ko.json | 4 +- src/i18n/nl.json | 4 +- src/i18n/pt.json | 4 +- src/i18n/ru.json | 4 +- src/i18n/tr.json | 4 +- src/styling/_utils.scss | 20 +- src/styling/index.scss | 1 + 38 files changed, 750 insertions(+), 100 deletions(-) create mode 100644 scripts/watch-styling.mjs create mode 100644 src/components/ChannelList/styling/ChannelList.scss create mode 100644 src/components/ChannelList/styling/index.scss create mode 100644 src/components/ChatView/__tests__/ChatView.test.js delete mode 100644 src/components/EmptyStateIndicator/icons.tsx create mode 100644 src/components/Threads/ThreadList/styling/ThreadList.scss diff --git a/examples/vite/src/stream-imports-layout.scss b/examples/vite/src/stream-imports-layout.scss index 486b8a1b76..5cba100f86 100644 --- a/examples/vite/src/stream-imports-layout.scss +++ b/examples/vite/src/stream-imports-layout.scss @@ -10,7 +10,7 @@ //@use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-layout'; //@use 'stream-chat-react/dist/scss/v2/Channel/Channel-layout'; //@use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-layout'; -@use 'stream-chat-react/dist/scss/v2/ChannelList/ChannelList-layout'; +//@use 'stream-chat-react/dist/scss/v2/ChannelList/ChannelList-layout'; //@use 'stream-chat-react/dist/scss/v2/ChannelPreview/ChannelPreview-layout'; @use 'stream-chat-react/dist/scss/v2/ChannelSearch/ChannelSearch-layout'; @use 'stream-chat-react/dist/scss/v2/common/CTAButton/CTAButton-layout'; diff --git a/examples/vite/src/stream-imports-theme.scss b/examples/vite/src/stream-imports-theme.scss index 19c8e02102..fde9e31141 100644 --- a/examples/vite/src/stream-imports-theme.scss +++ b/examples/vite/src/stream-imports-theme.scss @@ -10,7 +10,7 @@ //@use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-theme'; //@use 'stream-chat-react/dist/scss/v2/Channel/Channel-theme.scss'; //@use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-theme'; -@use 'stream-chat-react/dist/scss/v2/ChannelList/ChannelList-theme'; +//@use 'stream-chat-react/dist/scss/v2/ChannelList/ChannelList-theme'; //@use 'stream-chat-react/dist/scss/v2/ChannelPreview/ChannelPreview-theme'; @use 'stream-chat-react/dist/scss/v2/ChannelSearch/ChannelSearch-theme'; //@use 'stream-chat-react/dist/scss/v2/Dialog/Dialog-theme'; diff --git a/package.json b/package.json index deedd6691a..9259c8466d 100644 --- a/package.json +++ b/package.json @@ -232,7 +232,7 @@ "prettier-fix": "yarn prettier --write", "fix-staged": "lint-staged --config .lintstagedrc.fix.json --concurrent 1", "start": "tsc -p tsconfig.lib.json -w", - "start:css": "sass --watch src/styling:dist/css", + "start:css": "node scripts/watch-styling.mjs", "prepare": "husky install", "preversion": "yarn install", "test": "jest", diff --git a/scripts/watch-styling.mjs b/scripts/watch-styling.mjs new file mode 100644 index 0000000000..9d429edb78 --- /dev/null +++ b/scripts/watch-styling.mjs @@ -0,0 +1,229 @@ +import { watch } from 'node:fs'; +import { mkdir, readdir, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { compileAsync } from 'sass'; + +const SRC_DIR = path.resolve('src'); +const ENTRY_FILE = path.join(SRC_DIR, 'styling/index.scss'); +const OUTPUT_FILE = path.resolve('dist/css/index.css'); +const SCSS_EXTENSION = '.scss'; +const BUILD_DELAY_MS = 150; +const SCAN_INTERVAL_MS = 500; + +let activeBuild = false; +let buildQueued = false; +let buildQueuedTrigger = 'queued changes'; +let buildTimer; +let lastTrigger = 'initial startup'; +let knownScssState = new Map(); +let pollingFallbackActive = false; +let scanInProgress = false; +let stopWatching = () => undefined; + +const log = (message) => { + const time = new Date().toLocaleTimeString('en-US', { hour12: false }); + console.log(`[watch-styling ${time}] ${message}`); +}; + +const isScssFile = (filename) => filename.endsWith(SCSS_EXTENSION); + +const toOutputRelativePath = (source) => + path + .relative(path.dirname(OUTPUT_FILE), fileURLToPath(source)) + .split(path.sep) + .join('/'); + +const collectScssState = async () => { + const scssState = new Map(); + const entries = await readdir(SRC_DIR, { recursive: true, withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isFile() || !isScssFile(entry.name)) continue; + + const filePath = path.resolve( + path.join(entry.parentPath ?? entry.path ?? SRC_DIR, entry.name), + ); + + try { + const { mtimeMs } = await stat(filePath); + scssState.set(filePath, mtimeMs); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + + return scssState; +}; + +const findChangedFile = (previousState, nextState) => { + for (const [filePath, mtimeMs] of nextState) { + if (!previousState.has(filePath)) { + return `added ${path.relative(process.cwd(), filePath)}`; + } + + if (previousState.get(filePath) !== mtimeMs) { + return `changed ${path.relative(process.cwd(), filePath)}`; + } + } + + for (const filePath of previousState.keys()) { + if (!nextState.has(filePath)) { + return `removed ${path.relative(process.cwd(), filePath)}`; + } + } + + return null; +}; + +const flushQueuedBuild = () => { + if (!buildQueued) return; + + const trigger = buildQueuedTrigger; + buildQueued = false; + buildQueuedTrigger = 'queued changes'; + void runBuild(trigger); +}; + +const buildStyling = async () => { + const { css, sourceMap } = await compileAsync(ENTRY_FILE, { + sourceMap: true, + style: 'expanded', + }); + const sourceMapFile = `${path.basename(OUTPUT_FILE)}.map`; + const normalizedSourceMap = { + ...sourceMap, + file: path.basename(OUTPUT_FILE), + sources: sourceMap.sources.map((source) => + source.startsWith('file://') ? toOutputRelativePath(source) : source, + ), + }; + + await mkdir(path.dirname(OUTPUT_FILE), { recursive: true }); + await writeFile(OUTPUT_FILE, `${css}\n\n/*# sourceMappingURL=${sourceMapFile} */\n`); + await writeFile(`${OUTPUT_FILE}.map`, JSON.stringify(normalizedSourceMap)); +}; + +const runBuild = async (trigger) => { + if (activeBuild) { + buildQueued = true; + buildQueuedTrigger = trigger; + return; + } + + activeBuild = true; + log(`running build-styling (${trigger})`); + + try { + await buildStyling(); + log('build-styling completed'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`build-styling failed: ${message}`); + } finally { + activeBuild = false; + flushQueuedBuild(); + } +}; + +const scheduleBuild = (trigger) => { + lastTrigger = trigger; + clearTimeout(buildTimer); + buildTimer = setTimeout(() => { + void runBuild(lastTrigger); + }, BUILD_DELAY_MS); +}; + +const formatNativeWatchTrigger = (filename) => { + if (!filename) return 'filesystem event'; + + const normalizedFilename = String(filename); + if (!isScssFile(normalizedFilename)) return null; + + return `changed ${path.join(path.relative(process.cwd(), SRC_DIR), normalizedFilename)}`; +}; + +const scanForChanges = async () => { + if (scanInProgress) return; + scanInProgress = true; + + try { + const nextState = await collectScssState(); + const trigger = findChangedFile(knownScssState, nextState); + knownScssState = nextState; + + if (trigger) { + scheduleBuild(trigger); + } + } finally { + scanInProgress = false; + } +}; + +const startPollingWatcher = async () => { + if (pollingFallbackActive) return; + + pollingFallbackActive = true; + stopWatching(); + knownScssState = await collectScssState(); + + const scanInterval = setInterval(() => { + void scanForChanges(); + }, SCAN_INTERVAL_MS); + + stopWatching = () => clearInterval(scanInterval); + log( + `watching ${path.relative(process.cwd(), SRC_DIR)}/**/*.scss for changes (polling fallback)`, + ); +}; + +const startNativeWatcher = () => { + try { + const watcher = watch(SRC_DIR, { recursive: true }, (_eventType, filename) => { + const trigger = formatNativeWatchTrigger(filename); + if (!trigger) return; + + scheduleBuild(trigger); + }); + + watcher.on('error', (error) => { + if (pollingFallbackActive) return; + + log(`native watcher failed (${error.message}), falling back to polling`); + watcher.close(); + void startPollingWatcher(); + }); + + stopWatching = () => watcher.close(); + log( + `watching ${path.relative(process.cwd(), SRC_DIR)}/**/*.scss for changes (native recursive watch)`, + ); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`native recursive watch unavailable (${message}), falling back to polling`); + return false; + } +}; + +const shutdown = () => { + clearTimeout(buildTimer); + stopWatching(); +}; + +process.on('SIGINT', () => { + shutdown(); + process.exit(0); +}); + +process.on('SIGTERM', () => { + shutdown(); + process.exit(0); +}); + +await runBuild('initial startup'); + +if (!startNativeWatcher()) { + await startPollingWatcher(); +} diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index f213cf260b..09ab52920f 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,5 +1,4 @@ -import type { ComponentProps } from 'react'; -import { forwardRef } from 'react'; +import React, { type ComponentProps, forwardRef } from 'react'; import clsx from 'clsx'; export type ButtonVariant = 'primary' | 'secondary' | 'danger'; diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 4117ccde11..4bfe523c11 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -45,6 +45,7 @@ import { LoadingErrorIndicator as DefaultLoadingErrorIndicator, LoadingChannel as DefaultLoadingIndicator, } from '../Loading'; +import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator'; import type { ChannelActionContextValue, @@ -125,8 +126,8 @@ export type ChannelProps = { updatedMessage: LocalMessage | MessageResponse, options?: UpdateMessageOptions, ) => ReturnType; - /** Custom UI component to be shown if no active channel is set, defaults to null and skips rendering the Channel component */ - EmptyPlaceholder?: React.ReactElement; + /** Custom UI component to be shown if no active channel is set, defaults to the message empty state indicator. Pass `null` to suppress rendering. */ + EmptyPlaceholder?: React.ReactElement | null; /** The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default */ giphyVersion?: GiphyVersions; /** A custom function to provide size configuration for image attachments */ @@ -169,13 +170,20 @@ const ChannelContainer = ({ }; const UnMemoizedChannel = (props: PropsWithChildren) => { - const { channel: propsChannel, EmptyPlaceholder = null } = props; - const { LoadingErrorIndicator, LoadingIndicator = DefaultLoadingIndicator } = - useComponentContext(); + const { channel: propsChannel, EmptyPlaceholder } = props; + const { + EmptyStateIndicator = DefaultEmptyStateIndicator, + LoadingErrorIndicator, + LoadingIndicator = DefaultLoadingIndicator, + } = useComponentContext('Channel'); const { channel: contextChannel, channelsQueryState } = useChatContext('Channel'); const channel = propsChannel || contextChannel; + const emptyPlaceholder = + 'EmptyPlaceholder' in props + ? EmptyPlaceholder + : EmptyStateIndicator && ; if (channelsQueryState.queryInProgress === 'reload' && LoadingIndicator) { return ( @@ -193,8 +201,12 @@ const UnMemoizedChannel = (props: PropsWithChildren) => { ); } + if (channelsQueryState.error) { + return ; + } + if (!channel?.cid) { - return {EmptyPlaceholder}; + return {emptyPlaceholder}; } return ; diff --git a/src/components/Channel/__tests__/Channel.test.js b/src/components/Channel/__tests__/Channel.test.js index 7266ddeda4..47bcf8a064 100644 --- a/src/components/Channel/__tests__/Channel.test.js +++ b/src/components/Channel/__tests__/Channel.test.js @@ -188,24 +188,58 @@ describe('Channel', () => { }); it('should render the EmptyPlaceholder prop if the channel is not provided by the ChatContext', async () => { + const DefaultEmptyStateIndicator = () =>
default empty state
; + // get rid of console warnings as they are expected - Channel reaches to ChatContext jest.spyOn(console, 'warn').mockImplementationOnce(() => null); render( - - empty} /> - , + + + empty} /> + + , ); await waitFor(() => expect(screen.getByText('empty')).toBeInTheDocument()); + expect(screen.queryByText('default empty state')).not.toBeInTheDocument(); + }); + + it('should render the message empty state if the channel is not provided by the ChatContext', async () => { + const DefaultEmptyStateIndicator = ({ listType }) => ( +
{`${listType} empty state`}
+ ); + + // get rid of console warnings as they are expected - Channel reaches to ChatContext + jest.spyOn(console, 'warn').mockImplementationOnce(() => null); + render( + + + + + , + ); + + await waitFor(() => + expect(screen.getByText('message empty state')).toBeInTheDocument(), + ); }); it('should render channel content if channels query loads more channels', async () => { @@ -249,7 +283,7 @@ describe('Channel', () => { await waitFor(() => expect(asFragment()).toMatchSnapshot()); }); - it('should render empty channel container if channel does not have cid', async () => { + it('should render empty channel container if channel does not have cid and EmptyPlaceholder is null', async () => { const childrenContent = 'Channel children'; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { cid, ...channelWithoutCID } = channel; @@ -265,7 +299,7 @@ describe('Channel', () => { }, }} > - {childrenContent} + {childrenContent} , ); await waitFor(() => expect(asFragment()).toMatchSnapshot()); diff --git a/src/components/Channel/__tests__/__snapshots__/Channel.test.js.snap b/src/components/Channel/__tests__/__snapshots__/Channel.test.js.snap index 6818c4fe8b..30516c0647 100644 --- a/src/components/Channel/__tests__/__snapshots__/Channel.test.js.snap +++ b/src/components/Channel/__tests__/__snapshots__/Channel.test.js.snap @@ -26,7 +26,7 @@ exports[`Channel should render default loading indicator if channels query is in `; -exports[`Channel should render empty channel container if channel does not have cid 1`] = ` +exports[`Channel should render empty channel container if channel does not have cid and EmptyPlaceholder is null 1`] = `
+
{children} diff --git a/src/components/ChannelList/styling/ChannelList.scss b/src/components/ChannelList/styling/ChannelList.scss new file mode 100644 index 0000000000..a62f3707b2 --- /dev/null +++ b/src/components/ChannelList/styling/ChannelList.scss @@ -0,0 +1,142 @@ +@use '../../../styling/utils'; + +.str-chat { + /* The border radius used for the borders of the component */ + --str-chat__channel-list-border-radius: 0; + + /* The text/icon color of the component */ + --str-chat__channel-list-color: var(--str-chat__text-color); + + /* The background color of the component */ + --str-chat__channel-list-background-color: var(--str-chat__secondary-background-color); + + /* Box shadow applied to the component */ + --str-chat__channel-list-box-shadow: none; + + /* Top border of the component */ + --str-chat__channel-list-border-block-start: none; + + /* Bottom border of the component */ + --str-chat__channel-list-border-block-end: none; + + /* Left (right in RTL layout) border of the component */ + --str-chat__channel-list-border-inline-start: none; + + /* Right (left in RTL layout) border of the component */ + --str-chat__channel-list-border-inline-end: 1px solid var(--str-chat__surface-color); + + /* The border radius used for the borders of the load more button */ + --str-chat__channel-list-load-more-border-radius: var( + --str-chat__cta-button-border-radius + ); + + /* The text/icon color of the load more button */ + --str-chat__channel-list-load-more-color: var(--str-chat__cta-button-color); + + /* The background color of the load more button */ + --str-chat__channel-list-load-more-background-color: var( + --str-chat__cta-button-background-color + ); + + /* Box shadow applied to the load more button */ + --str-chat__channel-list-load-more-box-shadow: var(--str-chat__cta-button-box-shadow); + + /* Top border of the load more button */ + --str-chat__channel-list-load-more-border-block-start: var( + --str-chat__cta-button-border-block-start + ); + + /* Bottom border of the load more button */ + --str-chat__channel-list-load-more-border-block-end: var( + --str-chat__cta-button-border-block-end + ); + + /* Left (right in RTL layout) border of the load more button */ + --str-chat__channel-list-load-more-border-inline-start: var( + --str-chat__cta-button-border-inline-start + ); + + /* Right (left in RTL layout) border of the load more button */ + --str-chat__channel-list-load-more-border-inline-end: var( + --str-chat__cta-button-border-inline-end + ); + + /* The background color of the load more button in pressed state */ + --str-chat__channel-list-load-more-pressed-background-color: var( + --str-chat__cta-button-pressed-background-color + ); + + /* The background color of the load more button in disabled state */ + --str-chat__channel-list-load-more-disabled-background-color: var( + --str-chat__cta-button-disabled-background-color + ); + + /* The text/icon color of the load more button in disabled state */ + --str-chat__channel-list-load-more-disabled-color: var( + --str-chat__cta-button-disabled-color + ); + + /* The text/icon color for the empty list state */ + --str-chat__channel-list-empty-indicator-color: var( + --str-chat__text-low-emphasis-color + ); +} + +.str-chat__channel-list { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + @include utils.component-layer-overrides('channel-list'); + + .str-chat__channel-list-messenger { + height: 100%; + overflow: hidden; + padding-bottom: var(--str-chat__spacing-2_5); + + .str-chat__channel-list-messenger__main { + height: 100%; + overflow-y: auto; + + .str-chat__channel-list-empty { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--str-chat__spacing-3); + + p { + color: var(--text-secondary); + font: var(--str-chat__caption-default-text); + } + } + } + } + + .str-chat__load-more-button { + display: flex; + justify-content: center; + margin: var(--str-chat__spacing-2) 0; + + .str-chat__load-more-button__button { + @include utils.flex-row-center; + } + } + + .stream-chat__paginated-list { + gap: 0; + } + + .str-chat__load-more-button__button { + @include utils.component-layer-overrides('channel-list-load-more'); + width: 80%; + } + + .str-chat__channel-list-empty { + --str-chat-icon-color: var(--str-chat__channel-list-empty-indicator-color); + @include utils.empty-theme('channel-list'); + color: var(--str-chat__channel-list-empty-indicator-color); + } +} diff --git a/src/components/ChannelList/styling/index.scss b/src/components/ChannelList/styling/index.scss new file mode 100644 index 0000000000..89bd774b36 --- /dev/null +++ b/src/components/ChannelList/styling/index.scss @@ -0,0 +1 @@ +@use 'ChannelList'; diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index 7e46393cc9..ddf8bc0858 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -9,10 +9,15 @@ import React, { } from 'react'; import { Button, type ButtonProps } from '../Button'; +import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator'; import { ThreadProvider } from '../Threads'; import { Icon } from '../Threads/icons'; import { UnreadCountBadge } from '../Threads/UnreadCountBadge'; -import { useChatContext, useTranslationContext } from '../../context'; +import { + useChatContext, + useComponentContext, + useTranslationContext, +} from '../../context'; import { useStateStore } from '../../store'; import type { PropsWithChildren } from 'react'; @@ -127,10 +132,28 @@ export const useActiveThread = ({ activeThread }: { activeThread?: Thread }) => * */ const ThreadAdapter = ({ children }: PropsWithChildren) => { + const { client } = useChatContext('ThreadAdapter'); + const { EmptyStateIndicator = DefaultEmptyStateIndicator } = + useComponentContext('ThreadAdapter'); const { activeThread } = useThreadsViewContext(); + const { isLoading, ready } = useStateStore( + client.threads.state, + threadAdapterSelector, + ) ?? { + isLoading: false, + ready: false, + }; useActiveThread({ activeThread }); + if (!activeThread && ready && !isLoading && EmptyStateIndicator) { + return ( +
+ +
+ ); + } + return {children}; }; @@ -159,7 +182,12 @@ export const ChatViewSelectorButton = ({ ); -const selector = ({ unreadThreadCount }: ThreadManagerState) => ({ +const threadAdapterSelector = ({ pagination, ready }: ThreadManagerState) => ({ + isLoading: pagination.isLoading, + ready, +}); + +const unreadThreadCountSelector = ({ unreadThreadCount }: ThreadManagerState) => ({ unreadThreadCount, }); @@ -179,7 +207,10 @@ export const ChatViewChannelsSelectorButton = () => { export const ChatViewThreadsSelectorButton = () => { const { client } = useChatContext(); - const { unreadThreadCount } = useStateStore(client.threads.state, selector) ?? { + const { unreadThreadCount } = useStateStore( + client.threads.state, + unreadThreadCountSelector, + ) ?? { unreadThreadCount: 0, }; const { activeChatView, setActiveChatView } = useChatViewContext(); diff --git a/src/components/ChatView/__tests__/ChatView.test.js b/src/components/ChatView/__tests__/ChatView.test.js new file mode 100644 index 0000000000..cdcd12825d --- /dev/null +++ b/src/components/ChatView/__tests__/ChatView.test.js @@ -0,0 +1,123 @@ +import '@testing-library/jest-dom'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import React, { useEffect } from 'react'; + +import { ChatProvider, TranslationProvider } from '../../../context'; +import { getTestClientWithUser } from '../../../mock-builders'; +import { ChatView, useChatViewContext } from '../ChatView'; + +jest.mock('../../Threads', () => { + const React = require('react'); + + return { + ThreadProvider: ({ children }) =>
{children}
, + }; +}); + +const ActivateThreadsView = () => { + const { setActiveChatView } = useChatViewContext(); + + useEffect(() => { + setActiveChatView('threads'); + }, [setActiveChatView]); + + return null; +}; + +const renderComponent = async (threadManagerState) => { + const client = await getTestClientWithUser(); + const currentThreadManagerState = client.threads.state.getLatestValue(); + + client.threads.state.next({ + ...currentThreadManagerState, + ...threadManagerState, + pagination: { + ...currentThreadManagerState.pagination, + ...threadManagerState.pagination, + }, + }); + + return render( + + key, + tDateTimeParser: jest.fn(), + userLanguage: 'en', + }} + > + + + +
+ +
+ + + + + , + ); +}; + +describe('ChatView.ThreadAdapter', () => { + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + it('renders the empty message state when no thread is selected after loading completes', async () => { + await renderComponent({ + ready: true, + threads: [{ id: 'thread-1' }], + pagination: { isLoading: false }, + }); + + expect( + await screen.findByText('Send a message to start the conversation'), + ).toBeInTheDocument(); + expect(screen.queryByTestId('thread-provider')).not.toBeInTheDocument(); + }); + + it('renders the empty message state when the thread list is empty after loading completes', async () => { + await renderComponent({ + ready: true, + threads: [], + pagination: { isLoading: false }, + }); + + expect( + await screen.findByText('Send a message to start the conversation'), + ).toBeInTheDocument(); + expect(screen.queryByTestId('thread-provider')).not.toBeInTheDocument(); + }); + + it('does not render the empty message state while threads are still loading', async () => { + await renderComponent({ + ready: false, + threads: [], + pagination: { isLoading: true }, + }); + + await waitFor(() => { + expect( + screen.queryByText('Send a message to start the conversation'), + ).not.toBeInTheDocument(); + }); + expect(screen.getByTestId('thread-provider')).toBeInTheDocument(); + }); +}); diff --git a/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx b/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx index 95c93cd097..5265d4c82c 100644 --- a/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx +++ b/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { useTranslationContext } from '../../context/TranslationContext'; -import { ChatBubble } from './icons'; -import { IconBubble3ChatMessage } from '../Icons'; +import { IconBubble2ChatMessage, IconBubbles } from '../Icons'; export type EmptyStateIndicatorProps = { /** List Type: channel | message */ @@ -17,14 +16,12 @@ const UnMemoizedEmptyStateIndicator = (props: EmptyStateIndicatorProps) => { if (listType === 'thread') return null; if (listType === 'channel') { - const text = t('You have no channels currently'); + const text = t('No conversations yet'); return ( - <> -
- -

{text}

-
- +
+ +

{text}

+
); } @@ -32,7 +29,7 @@ const UnMemoizedEmptyStateIndicator = (props: EmptyStateIndicatorProps) => { const text = t('Send a message to start the conversation'); return (
- +

{text}

@@ -40,7 +37,7 @@ const UnMemoizedEmptyStateIndicator = (props: EmptyStateIndicatorProps) => { ); } - return

No items exist

; + return

{t('No items exist')}

; }; export const EmptyStateIndicator = React.memo( diff --git a/src/components/EmptyStateIndicator/__tests__/EmptyStateIndicator.test.js b/src/components/EmptyStateIndicator/__tests__/EmptyStateIndicator.test.js index 58bde27d9a..56be000725 100644 --- a/src/components/EmptyStateIndicator/__tests__/EmptyStateIndicator.test.js +++ b/src/components/EmptyStateIndicator/__tests__/EmptyStateIndicator.test.js @@ -29,7 +29,7 @@ describe('EmptyStateIndicator', () => { it('should display correct text when listType is channel', () => { render(); - expect(screen.queryByText('You have no channels currently')).toBeInTheDocument(); + expect(screen.queryByText('No conversations yet')).toBeInTheDocument(); }); it('should return null if listType is thread', () => { diff --git a/src/components/EmptyStateIndicator/icons.tsx b/src/components/EmptyStateIndicator/icons.tsx deleted file mode 100644 index b6abb4cc98..0000000000 --- a/src/components/EmptyStateIndicator/icons.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -export const ChatBubble = () => ( - - - -); diff --git a/src/components/Icons/BaseIcon.tsx b/src/components/Icons/BaseIcon.tsx index daeebfd574..4b116a46e1 100644 --- a/src/components/Icons/BaseIcon.tsx +++ b/src/components/Icons/BaseIcon.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps } from 'react'; +import React, { type ComponentProps } from 'react'; import clsx from 'clsx'; export const BaseIcon = ({ className, ...props }: ComponentProps<'svg'>) => ( diff --git a/src/components/Icons/createIcon.tsx b/src/components/Icons/createIcon.tsx index 3fd7067b82..3f7eaa1fba 100644 --- a/src/components/Icons/createIcon.tsx +++ b/src/components/Icons/createIcon.tsx @@ -1,4 +1,4 @@ -import { type ComponentProps, type ReactNode } from 'react'; +import React, { type ComponentProps, type ReactNode } from 'react'; import clsx from 'clsx'; import { BaseIcon } from './BaseIcon'; diff --git a/src/components/Icons/icons.tsx b/src/components/Icons/icons.tsx index b11d611a9d..6008ef19d7 100644 --- a/src/components/Icons/icons.tsx +++ b/src/components/Icons/icons.tsx @@ -142,6 +142,27 @@ export const IconBrowserAISparkle = createIcon( , ); +export const IconBubbles = createIcon( + 'IconBubbles', + , +); + +export const IconBubble2ChatMessage = createIcon( + 'IconBubble2ChatMessage', + , +); + export const IconBubble3ChatMessage = createIcon( 'IconBubble3ChatMessage', , diff --git a/src/components/Message/MessageBubble.tsx b/src/components/Message/MessageBubble.tsx index 45ff05a70e..3d795c06ce 100644 --- a/src/components/Message/MessageBubble.tsx +++ b/src/components/Message/MessageBubble.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps } from 'react'; +import React, { type ComponentProps } from 'react'; import clsx from 'clsx'; export const MessageBubble = ({ className, ...props }: ComponentProps<'div'>) => ( diff --git a/src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts b/src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts index fafc5430a8..7434b479f6 100644 --- a/src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts +++ b/src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts @@ -82,6 +82,14 @@ export const useFloatingDateSeparatorMessageList = ({ throttled(); listElement.addEventListener('scroll', throttled); + + if (typeof ResizeObserver === 'undefined') { + return () => { + listElement.removeEventListener('scroll', throttled); + throttled.cancel(); + }; + } + const resizeObserver = new ResizeObserver(throttled); resizeObserver.observe(listElement); diff --git a/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx b/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx index 258185ceda..d536c7a032 100644 --- a/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx +++ b/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx @@ -1,10 +1,15 @@ import React from 'react'; -import { Icon } from '../icons'; -export const ThreadListEmptyPlaceholder = () => ( -
- - {/* TODO: translate */} - No threads here yet... -
-); +import { useTranslationContext } from '../../../context'; +import { IconBubbles } from '../../Icons'; + +export const ThreadListEmptyPlaceholder = () => { + const { t } = useTranslationContext('ThreadListEmptyPlaceholder'); + + return ( +
+ +

{t('Reply to a message to start a thread')}

+
+ ); +}; diff --git a/src/components/Threads/ThreadList/styling/ThreadList.scss b/src/components/Threads/ThreadList/styling/ThreadList.scss new file mode 100644 index 0000000000..980fcf6601 --- /dev/null +++ b/src/components/Threads/ThreadList/styling/ThreadList.scss @@ -0,0 +1,53 @@ +@use '../../../../styling/utils'; + +.str-chat { + --str-chat__thread-list-border-radius: 0; + --str-chat__thread-list-color: var(--str-chat__text-color); + --str-chat__thread-list-background-color: var(--str-chat__secondary-background-color); + --str-chat__thread-list-border-block-start: none; + --str-chat__thread-list-border-block-end: none; + --str-chat__thread-list-border-inline-start: none; + --str-chat__thread-list-border-inline-end: 1px solid var(--str-chat__surface-color); + --str-chat__thread-list-box-shadow: none; +} + +.str-chat__thread-list-container { + @include utils.component-layer-overrides('thread-list'); + display: flex; + flex-direction: column; + height: 100%; +} + +.str-chat__thread-list { + flex: 1; + + [data-virtuoso-scroller='true'] { + height: 100% !important; + } + + [data-viewport-type='element'] { + height: 100% !important; + } +} + +.str-chat__thread-list-empty-placeholder { + --str-chat-icon-color: var(--text-secondary); + @include utils.empty-theme('thread-list'); + + height: 100%; + min-height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--spacing-xl); + color: var(--text-secondary); + + p { + margin: 0; + color: var(--text-secondary); + font-size: var(--typography-font-size-sm); + font-weight: var(--typography-font-weight-regular); + line-height: var(--typography-line-height-normal); + } +} diff --git a/src/components/Threads/ThreadList/styling/index.scss b/src/components/Threads/ThreadList/styling/index.scss index 1e9f171527..ab312588f5 100644 --- a/src/components/Threads/ThreadList/styling/index.scss +++ b/src/components/Threads/ThreadList/styling/index.scss @@ -1 +1,2 @@ +@use 'ThreadList'; @use 'ThreadListItem'; diff --git a/src/i18n/de.json b/src/i18n/de.json index 7b58b3ab6d..0242f300e3 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -261,6 +261,8 @@ "New Messages!": "Neue Nachrichten!", "Next image": "Nächstes Bild", "No chats here yet…": "Noch keine Chats hier...", + "No conversations yet": "Noch keine Unterhaltungen", + "No items exist": "Keine Elemente vorhanden", "No results found": "Keine Ergebnisse gefunden", "Nobody will be able to vote in this poll anymore.": "Niemand kann mehr in dieser Umfrage abstimmen.", "Nothing yet...": "Noch nichts...", @@ -300,6 +302,7 @@ "Replied to a thread": "In einem Thread geantwortet", "Reply": "Antworten", "Reply to {{ authorName }}": "Antwort an {{ authorName }}", + "Reply to a message to start a thread": "Antworte auf eine Nachricht, um einen Thread zu starten", "Reply to Message": "Auf Nachricht antworten", "replyCount_one": "1 Antwort", "replyCount_other": "{{ count }} Antworten", @@ -400,6 +403,5 @@ "Vote ended": "Abstimmung beendet", "Wait until all attachments have uploaded": "Bitte warten, bis alle Anhänge hochgeladen wurden", "You": "Du", - "You have no channels currently": "Du hast momentan noch keine Kanäle", "You've reached the maximum number of files": "Die maximale Anzahl an Dateien ist erreicht" } diff --git a/src/i18n/en.json b/src/i18n/en.json index 4601cb628d..9e3c003146 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -261,6 +261,8 @@ "New Messages!": "New Messages!", "Next image": "Next image", "No chats here yet…": "No chats here yet…", + "No conversations yet": "No conversations yet", + "No items exist": "No items exist", "No results found": "No results found", "Nobody will be able to vote in this poll anymore.": "Nobody will be able to vote in this poll anymore.", "Nothing yet...": "Nothing yet...", @@ -300,6 +302,7 @@ "Replied to a thread": "Replied to a thread", "Reply": "Reply", "Reply to {{ authorName }}": "Reply to {{ authorName }}", + "Reply to a message to start a thread": "Reply to a message to start a thread", "Reply to Message": "Reply to Message", "replyCount_one": "1 reply", "replyCount_other": "{{ count }} replies", @@ -400,6 +403,5 @@ "Vote ended": "Vote ended", "Wait until all attachments have uploaded": "Wait until all attachments have uploaded", "You": "You", - "You have no channels currently": "You have no channels currently", "You've reached the maximum number of files": "You've reached the maximum number of files" } diff --git a/src/i18n/es.json b/src/i18n/es.json index b6334e26cb..e096777638 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -269,6 +269,8 @@ "New Messages!": "¡Nuevos mensajes!", "Next image": "Siguiente imagen", "No chats here yet…": "Aún no hay mensajes aquí...", + "No conversations yet": "Aún no hay conversaciones", + "No items exist": "No existen elementos", "No results found": "No se han encontrado resultados", "Nobody will be able to vote in this poll anymore.": "Nadie podrá votar en esta encuesta.", "Nothing yet...": "Nada aún...", @@ -308,6 +310,7 @@ "Replied to a thread": "Respondió en un hilo", "Reply": "Responder", "Reply to {{ authorName }}": "Responder a {{ authorName }}", + "Reply to a message to start a thread": "Responde a un mensaje para iniciar un hilo", "Reply to Message": "Responder al mensaje", "replyCount_one": "1 respuesta", "replyCount_many": "{{ count }} respuestas", @@ -413,6 +416,5 @@ "Vote ended": "Votación finalizada", "Wait until all attachments have uploaded": "Espere hasta que se hayan cargado todos los archivos adjuntos", "You": "Tú", - "You have no channels currently": "Actualmente no tienes canales", "You've reached the maximum number of files": "Has alcanzado el número máximo de archivos" } diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 28089ecabd..2cf8158fbc 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -269,6 +269,8 @@ "New Messages!": "Nouveaux Messages!", "Next image": "Image suivante", "No chats here yet…": "Pas encore de messages ici...", + "No conversations yet": "Aucune conversation pour le moment", + "No items exist": "Aucun élément", "No results found": "Aucun résultat trouvé", "Nobody will be able to vote in this poll anymore.": "Personne ne pourra plus voter dans ce sondage.", "Nothing yet...": "Rien pour l'instant...", @@ -308,6 +310,7 @@ "Replied to a thread": "A répondu à un fil", "Reply": "Répondre", "Reply to {{ authorName }}": "Répondre à {{ authorName }}", + "Reply to a message to start a thread": "Répondez à un message pour démarrer un fil", "Reply to Message": "Répondre au message", "replyCount_one": "1 réponse", "replyCount_many": "{{ count }} réponses", @@ -413,6 +416,5 @@ "Vote ended": "Vote terminé", "Wait until all attachments have uploaded": "Attendez que toutes les pièces jointes soient téléchargées", "You": "Vous", - "You have no channels currently": "Vous n'avez actuellement aucun canal", "You've reached the maximum number of files": "Vous avez atteint le nombre maximal de fichiers" } diff --git a/src/i18n/hi.json b/src/i18n/hi.json index e5363b9568..dbdb91fe14 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -262,6 +262,8 @@ "New Messages!": "नए मैसेज!", "Next image": "अगली छवि", "No chats here yet…": "यहां अभी तक कोई चैट नहीं...", + "No conversations yet": "अभी तक कोई बातचीत नहीं है", + "No items exist": "कोई आइटम मौजूद नहीं है", "No results found": "कोई परिणाम नहीं मिला", "Nobody will be able to vote in this poll anymore.": "अब कोई भी इस मतदान में मतदान नहीं कर सकेगा।", "Nothing yet...": "कोई मैसेज नहीं है", @@ -301,6 +303,7 @@ "Replied to a thread": "थ्रेड में जवाब दिया", "Reply": "जवाब दे दो", "Reply to {{ authorName }}": "{{ authorName }} को जवाब दें", + "Reply to a message to start a thread": "थ्रेड शुरू करने के लिए किसी संदेश का जवाब दें", "Reply to Message": "संदेश का जवाब दें", "replyCount_one": "1 रिप्लाई", "replyCount_other": "{{ count }} रिप्लाई", @@ -401,6 +404,5 @@ "Vote ended": "मतदान समाप्त", "Wait until all attachments have uploaded": "सभी अटैचमेंट अपलोड होने तक प्रतीक्षा करें", "You": "आप", - "You have no channels currently": "आपके पास कोई चैनल नहीं है", "You've reached the maximum number of files": "आप अधिकतम फ़ाइलों तक पहुँच गए हैं" } diff --git a/src/i18n/it.json b/src/i18n/it.json index dc97f4369d..d00bb56c17 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -269,6 +269,8 @@ "New Messages!": "Nuovi messaggi!", "Next image": "Immagine successiva", "No chats here yet…": "Non ci sono ancora messaggi qui...", + "No conversations yet": "Ancora nessuna conversazione", + "No items exist": "Nessun elemento presente", "No results found": "Nessun risultato trovato", "Nobody will be able to vote in this poll anymore.": "Nessuno potrà più votare in questo sondaggio.", "Nothing yet...": "Ancora niente...", @@ -308,6 +310,7 @@ "Replied to a thread": "Ha risposto in un thread", "Reply": "Rispondi", "Reply to {{ authorName }}": "Rispondi a {{ authorName }}", + "Reply to a message to start a thread": "Rispondi a un messaggio per avviare un thread", "Reply to Message": "Rispondi al messaggio", "replyCount_one": "Una risposta", "replyCount_many": "{{ count }} risposte", @@ -413,6 +416,5 @@ "Vote ended": "Voto terminato", "Wait until all attachments have uploaded": "Attendi il caricamento di tutti gli allegati", "You": "Tu", - "You have no channels currently": "Al momento non sono presenti canali", "You've reached the maximum number of files": "Hai raggiunto il numero massimo di file" } diff --git a/src/i18n/ja.json b/src/i18n/ja.json index ad0bbb4218..423836200f 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -259,6 +259,8 @@ "New Messages!": "新しいメッセージ!", "Next image": "次の画像", "No chats here yet…": "ここにはまだチャットはありません…", + "No conversations yet": "まだ会話はありません", + "No items exist": "項目がありません", "No results found": "結果が見つかりません", "Nobody will be able to vote in this poll anymore.": "この投票では、誰も投票できなくなります。", "Nothing yet...": "まだ何もありません...", @@ -298,6 +300,7 @@ "Replied to a thread": "スレッドに返信しました", "Reply": "返事", "Reply to {{ authorName }}": "{{ authorName }} に返信", + "Reply to a message to start a thread": "メッセージに返信してスレッドを開始してください", "Reply to Message": "メッセージに返信", "replyCount_one": "1件の返信", "replyCount_other": "{{ count }} 返信", @@ -398,6 +401,5 @@ "Vote ended": "投票が終了しました", "Wait until all attachments have uploaded": "すべての添付ファイルがアップロードされるまでお待ちください", "You": "あなた", - "You have no channels currently": "現在チャンネルはありません", "You've reached the maximum number of files": "ファイルの最大数に達しました" } diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 6d81eed1d6..86c72bb59d 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -259,6 +259,8 @@ "New Messages!": "새 메시지!", "Next image": "다음 이미지", "No chats here yet…": "아직 채팅이 없습니다...", + "No conversations yet": "아직 대화가 없습니다.", + "No items exist": "항목이 없습니다.", "No results found": "검색 결과가 없습니다", "Nobody will be able to vote in this poll anymore.": "이 투표에 더 이상 아무도 투표할 수 없습니다.", "Nothing yet...": "아직 아무것도...", @@ -298,6 +300,7 @@ "Replied to a thread": "스레드에 답글을 남겼습니다", "Reply": "답장", "Reply to {{ authorName }}": "{{ authorName }}님에게 답장", + "Reply to a message to start a thread": "스레드를 시작하려면 메시지에 답장하세요", "Reply to Message": "메시지에 답장", "replyCount_one": "답장 1개", "replyCount_other": "{{ count }} 답장", @@ -398,6 +401,5 @@ "Vote ended": "투표 종료", "Wait until all attachments have uploaded": "모든 첨부 파일이 업로드될 때까지 기다립니다.", "You": "당신", - "You have no channels currently": "현재 채널이 없습니다.", "You've reached the maximum number of files": "최대 파일 수에 도달했습니다." } diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 8f7c01223b..0f78ecc10b 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -261,6 +261,8 @@ "New Messages!": "Nieuwe Berichten!", "Next image": "Volgende afbeelding", "No chats here yet…": "Nog geen chats hier...", + "No conversations yet": "Nog geen gesprekken", + "No items exist": "Er zijn geen items", "No results found": "Geen resultaten gevonden", "Nobody will be able to vote in this poll anymore.": "Niemand kan meer stemmen in deze peiling.", "Nothing yet...": "Nog niets ...", @@ -300,6 +302,7 @@ "Replied to a thread": "Heeft gereageerd in een thread", "Reply": "Antwoord", "Reply to {{ authorName }}": "Antwoord aan {{ authorName }}", + "Reply to a message to start a thread": "Beantwoord een bericht om een thread te starten", "Reply to Message": "Antwoord op bericht", "replyCount_one": "1 antwoord", "replyCount_other": "{{ count }} antwoorden", @@ -402,6 +405,5 @@ "Vote ended": "Stemmen beëindigd", "Wait until all attachments have uploaded": "Wacht tot alle bijlagen zijn geüpload", "You": "Jij", - "You have no channels currently": "Er zijn geen chats beschikbaar", "You've reached the maximum number of files": "Je hebt het maximale aantal bestanden bereikt" } diff --git a/src/i18n/pt.json b/src/i18n/pt.json index d24fa027ba..491b4cd374 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -269,6 +269,8 @@ "New Messages!": "Novas Mensagens!", "Next image": "Próxima imagem", "No chats here yet…": "Ainda não há conversas aqui...", + "No conversations yet": "Ainda não há conversas", + "No items exist": "Não existem itens", "No results found": "Nenhum resultado encontrado", "Nobody will be able to vote in this poll anymore.": "Ninguém mais poderá votar nesta pesquisa.", "Nothing yet...": "Nada ainda...", @@ -308,6 +310,7 @@ "Replied to a thread": "Respondeu em um tópico", "Reply": "Responder", "Reply to {{ authorName }}": "Responder a {{ authorName }}", + "Reply to a message to start a thread": "Responda a uma mensagem para iniciar um thread", "Reply to Message": "Responder à mensagem", "replyCount_one": "1 resposta", "replyCount_many": "{{ count }} respostas", @@ -413,6 +416,5 @@ "Vote ended": "Votação encerrada", "Wait until all attachments have uploaded": "Espere até que todos os anexos tenham sido carregados", "You": "Você", - "You have no channels currently": "Você não tem canais atualmente", "You've reached the maximum number of files": "Você atingiu o número máximo de arquivos" } diff --git a/src/i18n/ru.json b/src/i18n/ru.json index e802097bd8..974805e511 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -280,6 +280,8 @@ "New Messages!": "Новые сообщения!", "Next image": "Следующее изображение", "No chats here yet…": "Здесь еще нет чатов...", + "No conversations yet": "Пока нет бесед", + "No items exist": "Элементов нет", "No results found": "Результаты не найдены", "Nobody will be able to vote in this poll anymore.": "Никто больше не сможет голосовать в этом опросе.", "Nothing yet...": "Пока ничего нет...", @@ -319,6 +321,7 @@ "Replied to a thread": "Ответил в ветке", "Reply": "Ответить", "Reply to {{ authorName }}": "Ответить {{ authorName }}", + "Reply to a message to start a thread": "Ответьте на сообщение, чтобы начать тред", "Reply to Message": "Ответить на сообщение", "replyCount_one": "1 ответ", "replyCount_few": "{{ count }} ответов", @@ -429,6 +432,5 @@ "Vote ended": "Голосование завершено", "Wait until all attachments have uploaded": "Подождите, пока все вложения загрузятся", "You": "Вы", - "You have no channels currently": "У вас нет каналов в данный момент", "You've reached the maximum number of files": "Вы достигли максимального количества файлов" } diff --git a/src/i18n/tr.json b/src/i18n/tr.json index a930be85f7..21eb680ec8 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -261,6 +261,8 @@ "New Messages!": "Yeni Mesajlar!", "Next image": "Sonraki görsel", "No chats here yet…": "Henüz burada sohbet yok...", + "No conversations yet": "Henüz konuşma yok", + "No items exist": "Hiç öğe yok", "No results found": "Sonuç bulunamadı", "Nobody will be able to vote in this poll anymore.": "Artık bu ankette kimse oy kullanamayacak.", "Nothing yet...": "Şimdilik hiçbir şey...", @@ -300,6 +302,7 @@ "Replied to a thread": "Bir iş parçacığına yanıt verdi", "Reply": "Cevapla", "Reply to {{ authorName }}": "{{ authorName }} kişisine yanıt ver", + "Reply to a message to start a thread": "Bir thread başlatmak için bir mesaja yanıt verin", "Reply to Message": "Mesaja Cevapla", "replyCount_one": "1 cevap", "replyCount_other": "{{ count }} cevap", @@ -400,6 +403,5 @@ "Vote ended": "Oylama sona erdi", "Wait until all attachments have uploaded": "Tüm ekler yüklenene kadar bekleyin", "You": "Sen", - "You have no channels currently": "Henüz kanalınız yok", "You've reached the maximum number of files": "Maksimum dosya sayısına ulaştınız" } diff --git a/src/styling/_utils.scss b/src/styling/_utils.scss index 34c72b717d..43792dc4af 100644 --- a/src/styling/_utils.scss +++ b/src/styling/_utils.scss @@ -106,26 +106,12 @@ row-gap: var(--str-chat__spacing-1_5); } -@mixin empty-layout { - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--str-chat__spacing-4); - - svg { - width: calc(var(--str-chat__spacing-px) * 136); - height: calc(var(--str-chat__spacing-px) * 136); - } -} - @mixin empty-theme($component-name) { - font: var(--str-chat__headline-text); text-align: center; - svg path { - fill: var(--str-chat__#{$component-name}-empty-indicator-color); + svg { + width: 32px; + height: 32px; } } diff --git a/src/styling/index.scss b/src/styling/index.scss index f25b1d879e..0139e6474f 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -16,6 +16,7 @@ // Base components @use '../components/Badge/styling' as Badge; @use '../components/Button/styling' as Button; +@use '../components/ChannelList/styling' as ChannelList; @use '../components/Form/styling' as Form; @use '../components/Dialog/styling' as Dialog; @use '../components/Modal/styling' as Modal; From 0666f8dece123efcf0cafd5fc48bfcfa392bd591 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 6 Mar 2026 13:28:01 +0100 Subject: [PATCH 2/2] feat: refine chat view empty states --- src/components/ChatView/ChatView.tsx | 65 +++++++++++------ .../ChatView/__tests__/ChatView.test.js | 6 +- .../EmptyStateIndicator.tsx | 5 +- .../__tests__/EmptyStateIndicator.test.js | 12 ++++ .../styling/EmptyStateIndicator.scss | 2 +- .../ThreadListUnseenThreadsBanner.tsx | 4 +- src/components/Threads/icons.tsx | 70 ------------------- src/i18n/de.json | 1 + src/i18n/en.json | 1 + src/i18n/es.json | 1 + src/i18n/fr.json | 1 + src/i18n/hi.json | 1 + src/i18n/it.json | 1 + src/i18n/ja.json | 1 + src/i18n/ko.json | 1 + src/i18n/nl.json | 1 + src/i18n/pt.json | 1 + src/i18n/ru.json | 1 + src/i18n/tr.json | 1 + 19 files changed, 76 insertions(+), 100 deletions(-) delete mode 100644 src/components/Threads/icons.tsx diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index ddf8bc0858..e3c475cc39 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -10,8 +10,12 @@ import React, { import { Button, type ButtonProps } from '../Button'; import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator'; +import { + IconBubble3ChatMessage, + IconBubble3Solid, + IconBubbleText6ChatMessage, +} from '../Icons'; import { ThreadProvider } from '../Threads'; -import { Icon } from '../Threads/icons'; import { UnreadCountBadge } from '../Threads/UnreadCountBadge'; import { useChatContext, @@ -136,6 +140,7 @@ const ThreadAdapter = ({ children }: PropsWithChildren) => { const { EmptyStateIndicator = DefaultEmptyStateIndicator } = useComponentContext('ThreadAdapter'); const { activeThread } = useThreadsViewContext(); + const { t } = useTranslationContext('ThreadAdapter'); const { isLoading, ready } = useStateStore( client.threads.state, threadAdapterSelector, @@ -149,7 +154,10 @@ const ThreadAdapter = ({ children }: PropsWithChildren) => { if (!activeThread && ready && !isLoading && EmptyStateIndicator) { return (
- +
); } @@ -158,29 +166,40 @@ const ThreadAdapter = ({ children }: PropsWithChildren) => { }; export const ChatViewSelectorButton = ({ + ActiveIcon, children, className, Icon, + isActive, text, ...props -}: ButtonProps & { Icon?: ComponentType; text?: string }) => ( - -); +}: ButtonProps & { + ActiveIcon?: ComponentType; + Icon?: ComponentType; + isActive?: boolean; + text?: string; +}) => { + const SelectorIcon = isActive && ActiveIcon ? ActiveIcon : Icon; + + return ( + + ); +}; const threadAdapterSelector = ({ pagination, ready }: ThreadManagerState) => ({ isLoading: pagination.isLoading, @@ -197,8 +216,10 @@ export const ChatViewChannelsSelectorButton = () => { return ( setActiveChatView('channels')} text={t('Channels')} /> @@ -222,7 +243,7 @@ export const ChatViewThreadsSelectorButton = () => { onPointerDown={() => setActiveChatView('threads')} > - +
{t('Threads')}
diff --git a/src/components/ChatView/__tests__/ChatView.test.js b/src/components/ChatView/__tests__/ChatView.test.js index cdcd12825d..a0ecb5ecda 100644 --- a/src/components/ChatView/__tests__/ChatView.test.js +++ b/src/components/ChatView/__tests__/ChatView.test.js @@ -88,7 +88,7 @@ describe('ChatView.ThreadAdapter', () => { }); expect( - await screen.findByText('Send a message to start the conversation'), + await screen.findByText('Select a thread to continue the conversation'), ).toBeInTheDocument(); expect(screen.queryByTestId('thread-provider')).not.toBeInTheDocument(); }); @@ -101,7 +101,7 @@ describe('ChatView.ThreadAdapter', () => { }); expect( - await screen.findByText('Send a message to start the conversation'), + await screen.findByText('Select a thread to continue the conversation'), ).toBeInTheDocument(); expect(screen.queryByTestId('thread-provider')).not.toBeInTheDocument(); }); @@ -115,7 +115,7 @@ describe('ChatView.ThreadAdapter', () => { await waitFor(() => { expect( - screen.queryByText('Send a message to start the conversation'), + screen.queryByText('Select a thread to continue the conversation'), ).not.toBeInTheDocument(); }); expect(screen.getByTestId('thread-provider')).toBeInTheDocument(); diff --git a/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx b/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx index 5265d4c82c..ab97550cd3 100644 --- a/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx +++ b/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx @@ -6,10 +6,11 @@ import { IconBubble2ChatMessage, IconBubbles } from '../Icons'; export type EmptyStateIndicatorProps = { /** List Type: channel | message */ listType?: 'channel' | 'message' | 'thread'; + messageText?: string; }; const UnMemoizedEmptyStateIndicator = (props: EmptyStateIndicatorProps) => { - const { listType } = props; + const { listType, messageText } = props; const { t } = useTranslationContext('EmptyStateIndicator'); @@ -26,7 +27,7 @@ const UnMemoizedEmptyStateIndicator = (props: EmptyStateIndicatorProps) => { } if (listType === 'message') { - const text = t('Send a message to start the conversation'); + const text = t(messageText || 'Send a message to start the conversation'); return (
diff --git a/src/components/EmptyStateIndicator/__tests__/EmptyStateIndicator.test.js b/src/components/EmptyStateIndicator/__tests__/EmptyStateIndicator.test.js index 56be000725..0bb51c3f53 100644 --- a/src/components/EmptyStateIndicator/__tests__/EmptyStateIndicator.test.js +++ b/src/components/EmptyStateIndicator/__tests__/EmptyStateIndicator.test.js @@ -27,6 +27,18 @@ describe('EmptyStateIndicator', () => { ).toBeInTheDocument(); }); + it('should display custom message text when provided', () => { + render( + , + ); + expect( + screen.queryByText('Select a thread to continue the conversation'), + ).toBeInTheDocument(); + }); + it('should display correct text when listType is channel', () => { render(); expect(screen.queryByText('No conversations yet')).toBeInTheDocument(); diff --git a/src/components/EmptyStateIndicator/styling/EmptyStateIndicator.scss b/src/components/EmptyStateIndicator/styling/EmptyStateIndicator.scss index 2f18457a25..51cd4ee68a 100644 --- a/src/components/EmptyStateIndicator/styling/EmptyStateIndicator.scss +++ b/src/components/EmptyStateIndicator/styling/EmptyStateIndicator.scss @@ -33,6 +33,6 @@ .str-chat__empty-channel-text { margin: 0; - max-width: 160px; + max-width: 230px; } } diff --git a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx index 3c5d290fef..910018e646 100644 --- a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx +++ b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx @@ -2,7 +2,7 @@ import React from 'react'; import type { ThreadManagerState } from 'stream-chat'; -import { Icon } from '../icons'; +import { IconArrowRotateClockwise } from '../../Icons'; import { useChatContext } from '../../../context'; import { useStateStore } from '../../../store'; @@ -24,7 +24,7 @@ export const ThreadListUnseenThreadsBanner = () => { className='str-chat__unseen-threads-banner__button' onClick={() => client.threads.reload()} > - +
); diff --git a/src/components/Threads/icons.tsx b/src/components/Threads/icons.tsx deleted file mode 100644 index c0a238144f..0000000000 --- a/src/components/Threads/icons.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react'; -import type { ComponentPropsWithoutRef } from 'react'; - -// TODO: unify icons across SDK -export const Icon = { - MessageBubble: (props: ComponentPropsWithoutRef<'svg'>) => ( - - - - ), - MessageBubbleEmpty: (props: ComponentPropsWithoutRef<'svg'>) => ( - - - - ), - Reload: (props: ComponentPropsWithoutRef<'svg'>) => ( - - - - ), - User: (props: ComponentPropsWithoutRef<'svg'>) => ( - - - - ), -}; diff --git a/src/i18n/de.json b/src/i18n/de.json index 0242f300e3..34ecc4f9b3 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -321,6 +321,7 @@ "searchResultsCount_other": "{{ count }} Ergebnisse", "See all options ({{count}})_one": "Alle Optionen anzeigen ({{count}})", "See all options ({{count}})_other": "Alle Optionen anzeigen ({{count}})", + "Select a thread to continue the conversation": "Wähle einen Thread aus, um die Unterhaltung fortzusetzen", "Select more than one option": "Mehr als eine Option auswählen", "Select one": "Eine auswählen", "Select one or more": "Eine oder mehrere auswählen", diff --git a/src/i18n/en.json b/src/i18n/en.json index 9e3c003146..6dba06dc7d 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -321,6 +321,7 @@ "searchResultsCount_other": "{{ count }} results", "See all options ({{count}})_one": "See all options ({{count}})", "See all options ({{count}})_other": "See all options ({{count}})", + "Select a thread to continue the conversation": "Select a thread to continue the conversation", "Select more than one option": "Select more than one option", "Select one": "Select one", "Select one or more": "Select one or more", diff --git a/src/i18n/es.json b/src/i18n/es.json index e096777638..a3375dcc38 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -332,6 +332,7 @@ "See all options ({{count}})_one": "Ver todas las opciones ({{count}})", "See all options ({{count}})_many": "Ver todas las opciones ({{count}})", "See all options ({{count}})_other": "Ver todas las opciones ({{count}})", + "Select a thread to continue the conversation": "Selecciona un hilo para continuar la conversación", "Select more than one option": "Seleccionar más de una opción", "Select one": "Seleccionar uno", "Select one or more": "Seleccionar uno o más", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 2cf8158fbc..e3c37daccf 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -332,6 +332,7 @@ "See all options ({{count}})_one": "Voir toutes les options ({{count}})", "See all options ({{count}})_many": "Voir toutes les options ({{count}})", "See all options ({{count}})_other": "Voir toutes les options ({{count}})", + "Select a thread to continue the conversation": "Sélectionnez un fil pour continuer la conversation", "Select more than one option": "Sélectionner plus d'une option", "Select one": "Sélectionner un", "Select one or more": "Sélectionner un ou plusieurs", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index dbdb91fe14..ebc66cb807 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -322,6 +322,7 @@ "searchResultsCount_other": "{{ count }} परिणाम", "See all options ({{count}})_one": "सभी विकल्प देखें ({{count}})", "See all options ({{count}})_other": "सभी विकल्प देखें ({{count}})", + "Select a thread to continue the conversation": "बातचीत जारी रखने के लिए एक थ्रेड चुनें", "Select more than one option": "एक से अधिक विकल्प चुनें", "Select one": "एक चुनें", "Select one or more": "एक या अधिक चुनें", diff --git a/src/i18n/it.json b/src/i18n/it.json index d00bb56c17..316daa4cf4 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -332,6 +332,7 @@ "See all options ({{count}})_one": "Vedi tutte le opzioni ({{count}})", "See all options ({{count}})_many": "Vedi tutte le opzioni ({{count}})", "See all options ({{count}})_other": "Vedi tutte le opzioni ({{count}})", + "Select a thread to continue the conversation": "Seleziona un thread per continuare la conversazione", "Select more than one option": "Seleziona più di un'opzione", "Select one": "Seleziona uno", "Select one or more": "Seleziona uno o più", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 423836200f..d1f97cef61 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -319,6 +319,7 @@ "searchResultsCount_other": "{{ count }}件の結果", "See all options ({{count}})_one": "すべてのオプションを見る ({{count}})", "See all options ({{count}})_other": "すべてのオプションを見る ({{count}})", + "Select a thread to continue the conversation": "会話を続けるにはスレッドを選択してください", "Select more than one option": "複数の選択肢を選ぶ", "Select one": "1つ選択", "Select one or more": "1つ以上選択", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 86c72bb59d..9294c4ba2c 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -319,6 +319,7 @@ "searchResultsCount_other": "{{ count }}개 결과", "See all options ({{count}})_one": "모든 옵션 보기 ({{count}})", "See all options ({{count}})_other": "모든 옵션 보기 ({{count}})", + "Select a thread to continue the conversation": "대화를 계속하려면 스레드를 선택하세요", "Select more than one option": "하나 이상의 선택지 선택", "Select one": "하나 선택", "Select one or more": "하나 이상 선택", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 0f78ecc10b..e9ad1ac54e 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -321,6 +321,7 @@ "searchResultsCount_other": "{{ count }} resultaten", "See all options ({{count}})_one": "Bekijk alle opties ({{count}})", "See all options ({{count}})_other": "Bekijk alle opties ({{count}})", + "Select a thread to continue the conversation": "Selecteer een thread om het gesprek voort te zetten", "Select more than one option": "Selecteer meer dan één optie", "Select one": "Selecteer er een", "Select one or more": "Selecteer een of meer", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 491b4cd374..6e3cb2e0ea 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -332,6 +332,7 @@ "See all options ({{count}})_one": "Ver todas as opções ({{count}})", "See all options ({{count}})_many": "Ver todas as opções ({{count}})", "See all options ({{count}})_other": "Ver todas as opções ({{count}})", + "Select a thread to continue the conversation": "Selecione uma thread para continuar a conversa", "Select more than one option": "Selecionar mais de uma opção", "Select one": "Selecionar um", "Select one or more": "Selecionar um ou mais", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 974805e511..4a4de33dca 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -346,6 +346,7 @@ "See all options ({{count}})_few": "Посмотреть все варианты ({{count}})", "See all options ({{count}})_many": "Посмотреть все варианты ({{count}})", "See all options ({{count}})_other": "Посмотреть все варианты ({{count}})", + "Select a thread to continue the conversation": "Выберите тред, чтобы продолжить беседу", "Select more than one option": "Выберите более одного варианта", "Select one": "Выберите один", "Select one or more": "Выберите один или несколько", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 21eb680ec8..95907391a2 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -321,6 +321,7 @@ "searchResultsCount_other": "{{ count }} sonuç", "See all options ({{count}})_one": "Tüm seçenekleri göster ({{count}})", "See all options ({{count}})_other": "Tüm seçenekleri göster ({{count}})", + "Select a thread to continue the conversation": "Sohbeti sürdürmek için bir ileti dizisi seçin", "Select more than one option": "Birden fazla seçenek seçin", "Select one": "Birini seçin", "Select one or more": "Bir veya daha fazlasını seçin",