diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index 55da70831..ff369eee6 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -47,9 +47,12 @@ body { } .str-chat__channel-list { - flex: 0 0 300px; - max-width: 300px; height: 100%; + + &.str-chat__channel-list--open { + flex: 0 0 300px; + max-width: 300px; + } } .str-chat__main-panel { @@ -89,42 +92,57 @@ body { //max-width: none; } - .str-chat__dropzone-root--thread, - .str-chat__thread-list-container, - .str-chat__thread-container { - //flex: 0 0 360px; - width: 100%; - max-width: 360px; - } - - .str-chat__chat-view__threads { + /* Threads view: thread detail takes all space (higher specificity than channel 360px rules). */ + .str-chat__chat-view .str-chat__chat-view__threads { .str-chat__dropzone-root--thread, .str-chat__thread-container { flex: 1 1 auto; - //min-width: 360px; + min-width: 0; max-width: none; + width: 100%; } } - @media (max-width: 1100px) { - .str-chat__container:has(.str-chat__thread-container) > .str-chat__main-panel { - width: 0; - min-width: 0; - flex: 0 0 0; - overflow: hidden; + .str-chat__thread-list-container .str-chat__thread-container { + flex: 1 1 auto; + min-width: 0; + width: 100%; + max-width: none; + } + + /* Desktop (≥768px): in channel view only, thread fixed 360px next to channel. */ + @media (min-width: 768px) { + .str-chat__chat-view__channels .str-chat__container:has(.str-chat__thread-container) { + > .str-chat__main-panel, + > .str-chat__dropzone-root:not(.str-chat__dropzone-root--thread) { + flex: 1 1 auto; + min-width: 0; + } + + > .str-chat__thread-container, + > .str-chat__dropzone-root--thread { + flex: 0 0 360px; + width: 360px; + max-width: 360px; + } } - .str-chat__container:has(.str-chat__thread-container) > .str-chat__thread-container { - flex: 1 1 auto; - min-width: 360px; - max-width: none; + .str-chat__chat-view__channels .str-chat__container .str-chat__dropzone-root--thread, + .str-chat__chat-view__channels .str-chat__container .str-chat__thread-container { + width: 100%; + max-width: 360px; + } + + .str-chat__thread-list-container.str-chat__thread-list-container--open { + width: 100%; + max-width: 360px; } } @container (max-width: 860px) { - .str-chat__channel-list, - .str-chat__chat-view__selector { - display: none; + .str-chat__thread-container { + width: 100%; + max-width: initial; } } } diff --git a/src/components/Button/ToggleSidebarButton.tsx b/src/components/Button/ToggleSidebarButton.tsx new file mode 100644 index 000000000..e14d3904d --- /dev/null +++ b/src/components/Button/ToggleSidebarButton.tsx @@ -0,0 +1,35 @@ +import { useIsMobileViewport } from '../ChannelHeader/hooks/useIsMobileViewport'; +import { useChatContext, useTranslationContext } from '../../context'; +import { Button, type ButtonProps } from './Button'; + +type ToggleSidebarButtonProps = ButtonProps & { + /** expand mode is usually assigned to button, whose task is to show the sidebar, and collapse vice versa */ + mode: 'expand' | 'collapse'; + /** usually can collapse if an item from sidebar was selected */ + canCollapse?: boolean; +}; + +export const ToggleSidebarButton = ({ + canCollapse, + mode, + ...props +}: ToggleSidebarButtonProps) => { + const { closeMobileNav, navOpen, openMobileNav } = useChatContext('ChannelHeader'); + const { t } = useTranslationContext('ChannelHeader'); + const toggleNav = navOpen ? closeMobileNav : openMobileNav; + const isMobileViewport = useIsMobileViewport(); + const showButton = mode === 'expand' ? isMobileViewport || !navOpen : canCollapse; + + return showButton ? ( + + + +
{displayTitle}
{onlineStatusText != null && ( diff --git a/src/components/ChannelHeader/hooks/useIsMobileViewport.ts b/src/components/ChannelHeader/hooks/useIsMobileViewport.ts new file mode 100644 index 000000000..1ad58ce3c --- /dev/null +++ b/src/components/ChannelHeader/hooks/useIsMobileViewport.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; + +import { NAV_SIDEBAR_DESKTOP_BREAKPOINT } from '../../Chat/hooks/useChat'; + +const mobileQuery = () => + typeof window !== 'undefined' + ? window.matchMedia(`(max-width: ${NAV_SIDEBAR_DESKTOP_BREAKPOINT - 1}px)`) + : null; + +/** True when viewport width is below NAV_SIDEBAR_DESKTOP_BREAKPOINT (768px). */ +export const useIsMobileViewport = (): boolean => { + const [isMobile, setIsMobile] = useState(() => mobileQuery()?.matches ?? false); + + useEffect(() => { + const mql = mobileQuery(); + if (!mql) return; + const handler = () => setIsMobile(mql.matches); + handler(); + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); + }, []); + + return isMobile; +}; diff --git a/src/components/ChannelHeader/styling/ChannelHeader.scss b/src/components/ChannelHeader/styling/ChannelHeader.scss index 217e2c0b9..73666a651 100644 --- a/src/components/ChannelHeader/styling/ChannelHeader.scss +++ b/src/components/ChannelHeader/styling/ChannelHeader.scss @@ -1,6 +1,8 @@ @use '../../../styling/utils'; .str-chat { + --str-chat__channel-header-height: 72px; + /* The border radius used for the borders of the component */ --str-chat__channel-header-border-radius: 0; @@ -37,6 +39,7 @@ align-items: center; flex: 1; min-width: 0; + height: var(--str-chat__channel-header-height); .str-chat__channel-header__data { @include utils.header-text-layout; diff --git a/src/components/ChannelList/ChannelList.tsx b/src/components/ChannelList/ChannelList.tsx index a1aebbcc5..3c355c492 100644 --- a/src/components/ChannelList/ChannelList.tsx +++ b/src/components/ChannelList/ChannelList.tsx @@ -43,6 +43,7 @@ import type { ChannelAvatarProps } from '../Avatar'; import type { TranslationContextValue } from '../../context/TranslationContext'; import type { PaginatorProps } from '../../types/types'; import type { LoadingErrorIndicatorProps } from '../Loading'; +import { ChannelListHeader } from './ChannelListHeader'; const DEFAULT_FILTERS = {}; const DEFAULT_OPTIONS = {}; @@ -210,7 +211,7 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { client, closeMobileNav, customClasses, - navOpen = false, + navOpen = true, searchController, setActiveChannel, theme, @@ -382,6 +383,7 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { value={{ channels, hasNextPage, loadNextPage, setChannels }} >
+ {showChannelSearch && (Search ? ( { + const { t } = useTranslationContext(); + const { channel, navOpen } = useChatContext(); + return ( +
+
{t('Chats')}
+ + + +
+ ); +}; diff --git a/src/components/ChannelList/hooks/useMobileNavigation.ts b/src/components/ChannelList/hooks/useMobileNavigation.ts index 0c0b15543..cfec09c3e 100644 --- a/src/components/ChannelList/hooks/useMobileNavigation.ts +++ b/src/components/ChannelList/hooks/useMobileNavigation.ts @@ -1,5 +1,7 @@ import { useEffect } from 'react'; +const MOBILE_NAV_BREAKPOINT = 768; + export const useMobileNavigation = ( channelListRef: React.RefObject, navOpen: boolean, @@ -7,6 +9,9 @@ export const useMobileNavigation = ( ) => { useEffect(() => { const handleClickOutside = (event: MouseEvent) => { + if (typeof window !== 'undefined' && window.innerWidth >= MOBILE_NAV_BREAKPOINT) { + return; + } if ( closeMobileNav && channelListRef.current && diff --git a/src/components/ChannelList/styling/ChannelList.scss b/src/components/ChannelList/styling/ChannelList.scss index a62f3707b..ece55962a 100644 --- a/src/components/ChannelList/styling/ChannelList.scss +++ b/src/components/ChannelList/styling/ChannelList.scss @@ -139,4 +139,31 @@ @include utils.empty-theme('channel-list'); color: var(--str-chat__channel-list-empty-indicator-color); } + + /* Mobile: hide when nav closed; when open show as overlay. */ + @media (max-width: 767px) { + display: none; + + &.str-chat__channel-list--open { + display: flex; + position: absolute; + top: 0; + bottom: 0; + z-index: 1; + min-width: 280px; + max-width: 100%; + box-shadow: var(--str-chat__channel-list-box-shadow); + } + } + + /* Desktop (≥768px): collapse when nav closed so main content uses space. */ + @media (min-width: 768px) { + &:not(.str-chat__channel-list--open) { + flex: 0 0 0; + width: 0; + min-width: 0; + max-width: 0; + overflow: hidden; + } + } } diff --git a/src/components/ChannelList/styling/ChannelListHeader.scss b/src/components/ChannelList/styling/ChannelListHeader.scss new file mode 100644 index 000000000..34084e3eb --- /dev/null +++ b/src/components/ChannelList/styling/ChannelListHeader.scss @@ -0,0 +1,22 @@ +.str-chat__channel-list__header { + display: flex; + align-items: center; + padding: var(--spacing-md); + height: var(--str-chat__channel-header-height); + width: 100%; + + .str-chat__channel-list__header__title { + flex: 1; + font: var(--str-chat__heading-lg-text); + color: var(--text-primary); + } + + &.str-chat__channel-list__header--sidebar-collapsed { + flex: 0 0 0; + width: 0; + min-width: 0; + max-width: 0; + overflow: hidden; + padding: 0; + } +} diff --git a/src/components/ChannelList/styling/index.scss b/src/components/ChannelList/styling/index.scss index 89bd774b3..9ef1240c5 100644 --- a/src/components/ChannelList/styling/index.scss +++ b/src/components/ChannelList/styling/index.scss @@ -1 +1,2 @@ @use 'ChannelList'; +@use 'ChannelListHeader'; diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 3f42b32d8..b452b6486 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -27,8 +27,10 @@ export type ChatProps = { defaultLanguage?: SupportedTranslations; /** Instance of Stream i18n */ i18nInstance?: Streami18n; - /** Initial status of mobile navigation */ + /** Initial status of mobile navigation. Ignored when initialNavOpenResponsive is true. */ initialNavOpen?: boolean; + /** When true, sidebar (ChannelList/ThreadList + selector) is open on load; it closes when a channel or thread is selected. */ + initialNavOpenResponsive?: boolean; /** Instance of SearchController class that allows to control all the search operations. */ searchController?: SearchController; /** Used for injecting className/s to the Channel and ChannelList components */ @@ -55,6 +57,7 @@ export const Chat = (props: PropsWithChildren) => { defaultLanguage, i18nInstance, initialNavOpen = true, + initialNavOpenResponsive = true, isMessageAIGenerated, searchController: customChannelSearchController, theme = 'messaging light', @@ -71,7 +74,13 @@ export const Chat = (props: PropsWithChildren) => { openMobileNav, setActiveChannel, translators, - } = useChat({ client, defaultLanguage, i18nInstance, initialNavOpen }); + } = useChat({ + client, + defaultLanguage, + i18nInstance, + initialNavOpen, + initialNavOpenResponsive, + }); const channelsQueryState = useChannelsQueryState(); diff --git a/src/components/Chat/hooks/useChat.ts b/src/components/Chat/hooks/useChat.ts index 92497101f..c4cd406e8 100644 --- a/src/components/Chat/hooks/useChat.ts +++ b/src/components/Chat/hooks/useChat.ts @@ -18,11 +18,20 @@ import type { StreamChat, } from 'stream-chat'; +/** Viewport width (px) above which the sidebar is open by default when using responsive initial nav state. */ +export const NAV_SIDEBAR_DESKTOP_BREAKPOINT = 768; + +/** With responsive nav: sidebar is open on load (so ChannelList/ThreadList + selector visible); close on channel/thread selection. */ +const getDefaultNavOpenFromViewport = (): boolean => true; + export type UseChatParams = { client: StreamChat; defaultLanguage?: SupportedTranslations; i18nInstance?: Streami18n; + /** Initial open state of the sidebar. Ignored when initialNavOpenResponsive is true. */ initialNavOpen?: boolean; + /** When true, initial nav state is open so sidebar (ChannelList/ThreadList + selector) is visible; close on channel/thread selection. */ + initialNavOpenResponsive?: boolean; }; export const useChat = ({ @@ -30,6 +39,7 @@ export const useChat = ({ defaultLanguage = 'en', i18nInstance, initialNavOpen, + initialNavOpenResponsive = false, }: UseChatParams) => { const [translators, setTranslators] = useState({ t: defaultTranslatorFunction, @@ -39,7 +49,10 @@ export const useChat = ({ const [channel, setChannel] = useState(); const [mutes, setMutes] = useState>([]); - const [navOpen, setNavOpen] = useState(initialNavOpen); + const [navOpen, setNavOpen] = useState(() => { + if (initialNavOpenResponsive) return getDefaultNavOpenFromViewport() ?? true; + return initialNavOpen === false ? false : true; + }); const [latestMessageDatesByChannels, setLatestMessageDatesByChannels] = useState({}); const clientMutes = (client.user as OwnUserResponse)?.mutes ?? []; @@ -132,7 +145,10 @@ export const useChat = ({ } setChannel(activeChannel); - closeMobileNav(); + const isMobileViewport = + typeof window !== 'undefined' && + window.innerWidth < NAV_SIDEBAR_DESKTOP_BREAKPOINT; + if (isMobileViewport) closeMobileNav(); }, [], ); diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index d72e15c58..94c1511a8 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -228,6 +228,7 @@ export const ChatViewChannelsSelectorButton = ({ iconOnly = true, }: ChatViewSelectorItemProps) => { const { activeChatView, setActiveChatView } = useChatViewContext(); + const { openMobileNav } = useChatContext('ChatViewChannelsSelectorButton'); const { t } = useTranslationContext(); return ( @@ -237,7 +238,10 @@ export const ChatViewChannelsSelectorButton = ({ Icon={IconBubble3ChatMessage} iconOnly={iconOnly} isActive={activeChatView === 'channels'} - onPointerDown={() => setActiveChatView('channels')} + onPointerDown={() => { + openMobileNav(); + setActiveChatView('channels'); + }} text={t('Channels')} /> ); @@ -246,7 +250,7 @@ export const ChatViewChannelsSelectorButton = ({ export const ChatViewThreadsSelectorButton = ({ iconOnly = true, }: ChatViewSelectorItemProps) => { - const { client } = useChatContext(); + const { client, openMobileNav } = useChatContext(); const { unreadThreadCount } = useStateStore( client.threads.state, unreadThreadCountSelector, @@ -260,7 +264,10 @@ export const ChatViewThreadsSelectorButton = ({ setActiveChatView('threads')} + onPointerDown={() => { + openMobileNav(); + setActiveChatView('threads'); + }} text={t('Threads')} > @@ -296,13 +303,21 @@ export const defaultChatViewSelectorItemSet: ChatViewSelectorEntry[] = [ const ChatViewSelector = ({ iconOnly = true, itemSet = defaultChatViewSelectorItemSet, -}: ChatViewSelectorProps) => ( -
- {itemSet.map(({ Component, type }) => ( - - ))} -
-); +}: ChatViewSelectorProps) => { + const { navOpen } = useChatContext('ChatView.Selector'); + return ( +
+ {itemSet.map(({ Component, type }) => ( + + ))} +
+ ); +}; ChatView.Channels = ChannelsView; ChatView.Threads = ThreadsView; diff --git a/src/components/ChatView/styling/ChatView.scss b/src/components/ChatView/styling/ChatView.scss index 5c60cd9d1..c04d6f310 100644 --- a/src/components/ChatView/styling/ChatView.scss +++ b/src/components/ChatView/styling/ChatView.scss @@ -1,6 +1,6 @@ .str-chat { --str-chat-selector-background-color: var(--str-chat__secondary-background-color); - --str-chat-selector-border-color: var(--str-chat__surface-color); + --str-chat-selector-border-color: var(--border-core-subtle); --str-chat-selector-button-color-default: var(--str-chat__text-low-emphasis-color); --str-chat-selector-button-color-selected: var(--str-chat__text-color); @@ -22,6 +22,30 @@ border-right: 1px solid var(--str-chat-selector-border-color); background-color: var(--str-chat-selector-background-color); + /* Mobile: hide when nav closed, show when nav open. */ + @media (max-width: 767px) { + &.str-chat__chat-view__selector--nav-closed { + display: none; + } + + &.str-chat__chat-view__selector--nav-open { + display: flex; + } + } + + /* Desktop (≥768px): collapse when nav closed so main content uses space. */ + @media (min-width: 768px) { + &.str-chat__chat-view__selector--nav-closed { + width: 0; + min-width: 0; + overflow: hidden; + padding-inline: 0; + padding-block: 0; + gap: 0; + border-inline-end: none; + } + } + .str-chat__chat-view__selector-button-container { display: flex; position: relative; diff --git a/src/components/MessageInput/styling/MessageComposer.scss b/src/components/MessageInput/styling/MessageComposer.scss index 65a313b5a..8c482e1d4 100644 --- a/src/components/MessageInput/styling/MessageComposer.scss +++ b/src/components/MessageInput/styling/MessageComposer.scss @@ -39,6 +39,7 @@ .str-chat__message-composer-container { width: 100%; + min-height: fit-content; display: flex; flex-direction: column; align-items: center; @@ -83,7 +84,9 @@ align-items: end; width: 100%; gap: var(--spacing-xs); - padding: calc(var(--spacing-sm) - 1px); // compensate for the 1px border of the composer container + padding: calc( + var(--spacing-sm) - 1px + ); // compensate for the 1px border of the composer container $controls-containers-min-height: 26px; diff --git a/src/components/MessageInput/styling/SendToChannelCheckbox.scss b/src/components/MessageInput/styling/SendToChannelCheckbox.scss index ec545cf42..fe3bf3275 100644 --- a/src/components/MessageInput/styling/SendToChannelCheckbox.scss +++ b/src/components/MessageInput/styling/SendToChannelCheckbox.scss @@ -36,16 +36,19 @@ .str-chat__send-to-channel-checkbox__visual { width: 20px; height: 20px; - border: 1px solid var(--control-checkbox-border, #D5DBE1); + border: 1px solid var(--control-checkbox-border, #d5dbe1); border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; background: transparent; - transition: background-color 0.15s ease, border-color 0.15s ease; + transition: + background-color 0.15s ease, + border-color 0.15s ease; } - .str-chat__send-to-channel-checkbox__input:checked + .str-chat__send-to-channel-checkbox__visual { + .str-chat__send-to-channel-checkbox__input:checked + + .str-chat__send-to-channel-checkbox__visual { background-color: var(--control-radiocheck-bg-selected, var(--accent-primary)); border-color: var(--control-radiocheck-bg-selected, var(--accent-primary)); } @@ -64,14 +67,17 @@ } } - .str-chat__send-to-channel-checkbox__input:checked + .str-chat__send-to-channel-checkbox__visual .str-chat__send-to-channel-checkbox__checkmark { + .str-chat__send-to-channel-checkbox__input:checked + + .str-chat__send-to-channel-checkbox__visual + .str-chat__send-to-channel-checkbox__checkmark { opacity: 1; } .str-chat__send-to-channel-checkbox__label { font: var(--str-chat__metadata-default-text); - transition: color 0.15s ease, border-color 0.15s ease; + transition: + color 0.15s ease, + border-color 0.15s ease; } - } -} \ No newline at end of file +} diff --git a/src/components/Notifications/notificationOrigin.ts b/src/components/Notifications/notificationOrigin.ts new file mode 100644 index 000000000..beeb652ca --- /dev/null +++ b/src/components/Notifications/notificationOrigin.ts @@ -0,0 +1,5 @@ +/** + * Panel where the notification was registered (channel vs thread). + * Use in origin.context.panel when publishing so NotificationList can filter by panel. + */ +export type NotificationOriginPanel = 'channel' | 'thread'; diff --git a/src/components/ResizableContainer/styling/ResizableContainer.scss b/src/components/ResizableContainer/styling/ResizableContainer.scss new file mode 100644 index 000000000..b4eec3d47 --- /dev/null +++ b/src/components/ResizableContainer/styling/ResizableContainer.scss @@ -0,0 +1,81 @@ +.str-chat__resize-container { + // layout only; panels and handles are children + + .str-chat__resize-container__center { + display: flex; + flex-direction: column; + } +} + +.str-chat__resize-panel { + .str-chat__resize-panel__content { + display: flex; + flex-direction: column; + } + + .str-chat__resize-panel__handle { + position: relative; + z-index: 1; + background: transparent; + transition: background-color 0.15s ease; + + &:hover, + &:focus-visible { + background: var(--str-chat__primary-color-10, rgba(0 0 0 / 0.06)); + } + + &:active { + background: var(--str-chat__primary-color-15, rgba(0 0 0 / 0.09)); + } + } + + .str-chat__resize-panel__handle-line { + display: flex; + align-items: center; + justify-content: center; + width: 1px; + height: 100%; + min-height: 24px; + background: var(--str-chat__border-color, rgba(0 0 0 / 0.12)); + } + + .str-chat__resize-panel__handle-icon { + width: 16px; + height: 16px; + color: var(--str-chat__secondary-color, rgba(0 0 0 / 0.5)); + pointer-events: none; + } + + .str-chat__resize-panel__expand-tab { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-width: 20px; + height: 100%; + min-height: 48px; + padding: 0; + border: none; + background: transparent; + color: var(--str-chat__secondary-color, rgba(0 0 0 / 0.5)); + cursor: col-resize; + transition: + background-color 0.15s ease, + color 0.15s ease; + + &:hover { + background: var(--str-chat__primary-color-10, rgba(0 0 0 / 0.06)); + color: var(--str-chat__primary-color, #006cff); + } + + &:focus-visible { + outline: 2px solid var(--str-chat__primary-color, #006cff); + outline-offset: 2px; + } + + svg { + width: 16px; + height: 16px; + } + } +} diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx index 93bcad276..5ff6e9338 100644 --- a/src/components/Thread/Thread.tsx +++ b/src/components/Thread/Thread.tsx @@ -8,6 +8,7 @@ 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 { @@ -22,6 +23,7 @@ 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) */ @@ -86,7 +88,7 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { messageActions = Object.keys(MESSAGE_ACTIONS), virtualized, } = props; - + const { activeChatView } = useChatViewContext(); const threadInstance = useThreadContext(); const { @@ -178,7 +180,12 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { }} >
- + {activeChatView === 'threads' ? ( + // todo: add ThreadHeaderMain alongisde ThreadHeader property to ComponentContext? + + ) : ( + + )} ({ replyCount }); + +export type ThreadHeaderMainProps = { + /** UI component to display menu icon, defaults to IconLayoutAlignLeft*/ + MenuIcon?: React.ComponentType; + /** Set title manually */ + title?: string; +}; + +/** + * This header is the default header rendered for Thread in 'threads' chat view. + * It provides layout control capabilities - toggling sidebar open / close. + * The purpose is to provide layout control for the main message list in threads view. + */ +export const ThreadHeaderMain = ({ + MenuIcon = IconLayoutAlignLeft, + title, +}: ThreadHeaderMainProps) => { + const { t } = useTranslationContext('ThreadHeader'); + const thread = useThreadContext(); + + const { replyCount } = useStateStore(thread?.state, threadStateSelector) ?? { + replyCount: 0, + }; + + return ( +
+ + + +
+
{title ?? t('Thread')}
+
+ {t('replyCount', { count: replyCount })} +
+
+
+ ); +}; diff --git a/src/components/Thread/styling/ThreadHeaderMain.scss b/src/components/Thread/styling/ThreadHeaderMain.scss new file mode 100644 index 000000000..e8fce37bd --- /dev/null +++ b/src/components/Thread/styling/ThreadHeaderMain.scss @@ -0,0 +1,25 @@ +.str-chat__thread-header--main { + display: flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-md); + border-bottom: 1px solid var(--border-core-default, #d5dbe1); + background: var(--background-elevation-elevation-1, #fff); + + .str-chat__thread-header--main__details { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + + .str-chat__thread-header--main__title { + font: var(--str-chat__heading-sm-text); + color: var(--text-primary); + } + + .str-chat__thread-header--main__subtitle { + font: var(--str-chat__caption-default-text); + color: var(--text-secondary); + } + } +} diff --git a/src/components/Thread/styling/index.scss b/src/components/Thread/styling/index.scss index 99a8b1908..7cbe56f54 100644 --- a/src/components/Thread/styling/index.scss +++ b/src/components/Thread/styling/index.scss @@ -1 +1,2 @@ @use 'Thread'; +@use 'ThreadHeaderMain'; diff --git a/src/components/Threads/ThreadList/ThreadList.tsx b/src/components/Threads/ThreadList/ThreadList.tsx index 6820fa560..422d11754 100644 --- a/src/components/Threads/ThreadList/ThreadList.tsx +++ b/src/components/Threads/ThreadList/ThreadList.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from 'react'; import type { ComputeItemKey, VirtuosoProps } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso'; +import clsx from 'clsx'; import type { Thread, ThreadManagerState } from 'stream-chat'; @@ -10,6 +11,7 @@ import { ThreadListUnseenThreadsBanner as DefaultThreadListUnseenThreadsBanner } import { ThreadListLoadingIndicator as DefaultThreadListLoadingIndicator } from './ThreadListLoadingIndicator'; import { useChatContext, useComponentContext } from '../../../context'; import { useStateStore } from '../../../store'; +import { ThreadListHeader } from './ThreadListHeader'; const selector = (nextValue: ThreadManagerState) => ({ threads: nextValue.threads }); @@ -43,7 +45,7 @@ export const useThreadList = () => { }; export const ThreadList = ({ virtuosoProps }: ThreadListProps) => { - const { client } = useChatContext(); + const { client, navOpen = true } = useChatContext(); const { ThreadListEmptyPlaceholder = DefaultThreadListEmptyPlaceholder, ThreadListItem = DefaultThreadListItem, @@ -55,7 +57,12 @@ export const ThreadList = ({ virtuosoProps }: ThreadListProps) => { useThreadList(); return ( -
+
+ {/* TODO: allow re-load on stale ThreadManager state */} { + const { t } = useTranslationContext(); + const { navOpen } = useChatContext(); + const { activeThread } = useThreadsViewContext(); + return ( +
+
{t('Threads')}
+ + + +
+ ); +}; diff --git a/src/components/Threads/ThreadList/ThreadListItemUI.tsx b/src/components/Threads/ThreadList/ThreadListItemUI.tsx index 75473b7f9..f2e9095b9 100644 --- a/src/components/Threads/ThreadList/ThreadListItemUI.tsx +++ b/src/components/Threads/ThreadList/ThreadListItemUI.tsx @@ -12,6 +12,7 @@ import { useThreadListItemContext } from './ThreadListItem'; import { useStateStore } from '../../../store'; import { Badge } from '../../Badge'; import { SummarizedMessagePreview } from '../../SummarizedMessagePreview'; +import { NAV_SIDEBAR_DESKTOP_BREAKPOINT } from '../../Chat'; export type ThreadListItemUIProps = ComponentPropsWithoutRef<'button'>; @@ -47,6 +48,7 @@ export const ThreadListItemUI = (props: ThreadListItemUIProps) => { const { displayTitle: channelDisplayTitle } = useChannelPreviewInfo({ channel }); const { t } = useTranslationContext('ThreadListItemUI'); + const { closeMobileNav } = useChatContext('ThreadListItemUI'); const { activeThread, setActiveThread } = useThreadsViewContext(); const avatarProps: Partial | undefined = deletedAt @@ -73,7 +75,15 @@ export const ThreadListItemUI = (props: ThreadListItemUIProps) => { aria-pressed={activeThread === thread} className='str-chat__thread-list-item' data-thread-id={thread.id} - onClick={() => setActiveThread(thread)} + onClick={() => { + if ( + typeof window !== 'undefined' && + window.innerWidth < NAV_SIDEBAR_DESKTOP_BREAKPOINT + ) { + closeMobileNav(); + } + setActiveThread(thread); + }} role='option' {...props} > diff --git a/src/components/Threads/ThreadList/styling/ThreadList.scss b/src/components/Threads/ThreadList/styling/ThreadList.scss index 980fcf660..df6d46370 100644 --- a/src/components/Threads/ThreadList/styling/ThreadList.scss +++ b/src/components/Threads/ThreadList/styling/ThreadList.scss @@ -16,6 +16,33 @@ display: flex; flex-direction: column; height: 100%; + + /* Mobile: hide when nav closed; when open show as overlay. */ + @media (max-width: 767px) { + display: none; + + &.str-chat__thread-list-container--open { + display: flex; + position: absolute; + top: 0; + bottom: 0; + z-index: 1; + min-width: 280px; + max-width: 100%; + box-shadow: var(--str-chat__thread-list-box-shadow); + } + } + + /* Desktop (≥768px): collapse when nav closed so main content uses space. */ + @media (min-width: 768px) { + &:not(.str-chat__thread-list-container--open) { + flex: 0 0 0; + width: 0; + min-width: 0; + max-width: 0; + overflow: hidden; + } + } } .str-chat__thread-list { diff --git a/src/components/Threads/ThreadList/styling/ThreadListHeader.scss b/src/components/Threads/ThreadList/styling/ThreadListHeader.scss new file mode 100644 index 000000000..cbbb6b5af --- /dev/null +++ b/src/components/Threads/ThreadList/styling/ThreadListHeader.scss @@ -0,0 +1,26 @@ +.str-chat__thread-list__header { + display: flex; + align-items: center; + padding: var(--spacing-md); + height: var(--str-chat__channel-header-height); + width: 100%; + + .str-chat__thread-list__header__title { + flex: 1; + font: var(--str-chat__heading-lg-text); + color: var(--text-primary); + } + + &.str-chat__thread-list__header--sidebar-collapsed { + flex: 0 0 0; + width: 0; + min-width: 0; + max-width: 0; + overflow: hidden; + padding: 0; + + .str-chat__header-sidebar-toggle { + // Compact styling when sidebar collapsed + } + } +} diff --git a/src/components/Threads/ThreadList/styling/ThreadListItemUI.scss b/src/components/Threads/ThreadList/styling/ThreadListItemUI.scss index dc6b1f7e5..c684008e0 100644 --- a/src/components/Threads/ThreadList/styling/ThreadListItemUI.scss +++ b/src/components/Threads/ThreadList/styling/ThreadListItemUI.scss @@ -1,6 +1,9 @@ +@use '../../../../styling/utils'; + .str-chat__thread-list-item-container { border-bottom: 1px solid var(--border-core-subtle); padding: var(--spacing-xxs); + max-width: 100%; } .str-chat__thread-list-item { @@ -13,10 +16,10 @@ border: none; cursor: pointer; text-align: start; - font-family: var(--typography-font-family-sans); background: var(--background-elevation-elevation-1); border-radius: var(--radius-lg); width: 100%; + max-width: 100%; background: var(--background-elevation-elevation-1); &:not(:disabled):hover { @@ -106,6 +109,6 @@ line-height: var(--typography-line-height-normal); color: var(--text-tertiary); white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + min-width: 0; + @include utils.ellipsis-text; } diff --git a/src/components/Threads/ThreadList/styling/index.scss b/src/components/Threads/ThreadList/styling/index.scss index 17e7b4008..9c1e08d05 100644 --- a/src/components/Threads/ThreadList/styling/index.scss +++ b/src/components/Threads/ThreadList/styling/index.scss @@ -1,2 +1,3 @@ @use 'ThreadList'; +@use 'ThreadListHeader'; @use 'ThreadListItemUI'; diff --git a/src/i18n/de.json b/src/i18n/de.json index 498a4aba1..3bb26268a 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -54,6 +54,7 @@ "aria/Channel list": "Kanalliste", "aria/Channel search results": "Kanalsuchergebnisse", "aria/Close thread": "Thread schließen", + "aria/Collapse sidebar": "Seitenleiste einklappen", "aria/Copy Message Text": "Nachrichtentext kopieren", "aria/Delete Message": "Nachricht löschen", "aria/Download attachment": "Anhang herunterladen", @@ -107,6 +108,7 @@ "Cannot seek in the recording": "In der Aufnahme kann nicht gesucht werden", "Channel Missing": "Kanal fehlt", "Channels": "Kanäle", + "Chats": "Chats", "Choose between 2 to 10 options": "Wähle zwischen 2 und 10 Optionen", "Close": "Schließen", "Close emoji picker": "Emoji-Auswahl schließen", diff --git a/src/i18n/en.json b/src/i18n/en.json index 925dab652..c4572511e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -54,6 +54,7 @@ "aria/Channel list": "Channel list", "aria/Channel search results": "Channel search results", "aria/Close thread": "Close thread", + "aria/Collapse sidebar": "Collapse sidebar", "aria/Copy Message Text": "Copy Message Text", "aria/Delete Message": "Delete Message", "aria/Download attachment": "Download attachment", @@ -107,6 +108,7 @@ "Cannot seek in the recording": "Cannot seek in the recording", "Channel Missing": "Channel Missing", "Channels": "Channels", + "Chats": "Chats", "Choose between 2 to 10 options": "Choose between 2 to 10 options", "Close": "Close", "Close emoji picker": "Close emoji picker", diff --git a/src/i18n/es.json b/src/i18n/es.json index 01d8c9a9f..cf68f55c1 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -61,6 +61,7 @@ "aria/Channel list": "Lista de canales", "aria/Channel search results": "Resultados de búsqueda de canales", "aria/Close thread": "Cerrar hilo", + "aria/Collapse sidebar": "Contraer barra lateral", "aria/Copy Message Text": "Copiar texto del mensaje", "aria/Delete Message": "Eliminar mensaje", "aria/Download attachment": "Descargar adjunto", @@ -114,6 +115,7 @@ "Cannot seek in the recording": "No se puede buscar en la grabación", "Channel Missing": "Falta canal", "Channels": "Canales", + "Chats": "Chats", "Choose between 2 to 10 options": "Elige entre 2 y 10 opciones", "Close": "Cerrar", "Close emoji picker": "Cerrar el selector de emojis", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 5011207e5..b0535612c 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -61,6 +61,7 @@ "aria/Channel list": "Liste des canaux", "aria/Channel search results": "Résultats de recherche de canaux", "aria/Close thread": "Fermer le fil", + "aria/Collapse sidebar": "Réduire la barre latérale", "aria/Copy Message Text": "Copier le texte du message", "aria/Delete Message": "Supprimer le message", "aria/Download attachment": "Télécharger la pièce jointe", @@ -114,6 +115,7 @@ "Cannot seek in the recording": "Impossible de rechercher dans l'enregistrement", "Channel Missing": "Canal Manquant", "Channels": "Canaux", + "Chats": "Discussions", "Choose between 2 to 10 options": "Choisir entre 2 et 10 options", "Close": "Fermer", "Close emoji picker": "Fermer le sélecteur d'émojis", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 58228da14..1c4f16b2e 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -54,6 +54,7 @@ "aria/Channel list": "चैनल सूची", "aria/Channel search results": "चैनल खोज परिणाम", "aria/Close thread": "थ्रेड बंद करें", + "aria/Collapse sidebar": "साइडबार संक्षिप्त करें", "aria/Copy Message Text": "संदेश की टेक्स्ट कॉपी करें", "aria/Delete Message": "संदेश डिलीट करें", "aria/Download attachment": "अनुलग्नक डाउनलोड करें", @@ -107,6 +108,7 @@ "Cannot seek in the recording": "रेकॉर्डिंग में खोज नहीं की जा सकती", "Channel Missing": "चैनल उपलब्ध नहीं है", "Channels": "चैनल", + "Chats": "चैट", "Choose between 2 to 10 options": "2 से 10 विकल्प चुनें", "Close": "बंद करे", "Close emoji picker": "इमोजी पिकर बंद करें", diff --git a/src/i18n/it.json b/src/i18n/it.json index ca1e9ada5..f37d1ff46 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -61,6 +61,7 @@ "aria/Channel list": "Elenco dei canali", "aria/Channel search results": "Risultati della ricerca dei canali", "aria/Close thread": "Chiudi discussione", + "aria/Collapse sidebar": "Comprimi barra laterale", "aria/Copy Message Text": "Copia testo messaggio", "aria/Delete Message": "Elimina messaggio", "aria/Download attachment": "Scarica l'allegato", @@ -114,6 +115,7 @@ "Cannot seek in the recording": "Impossibile cercare nella registrazione", "Channel Missing": "Il canale non esiste", "Channels": "Canali", + "Chats": "Chat", "Choose between 2 to 10 options": "Scegli tra 2 e 10 opzioni", "Close": "Chiudi", "Close emoji picker": "Chiudi il selettore di emoji", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 965e352f5..1da12e4db 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -53,6 +53,7 @@ "aria/Channel list": "チャンネル一覧", "aria/Channel search results": "チャンネル検索結果", "aria/Close thread": "スレッドを閉じる", + "aria/Collapse sidebar": "サイドバーを折りたたむ", "aria/Copy Message Text": "メッセージテキストをコピー", "aria/Delete Message": "メッセージを削除", "aria/Download attachment": "添付ファイルをダウンロード", @@ -106,6 +107,7 @@ "Cannot seek in the recording": "録音中にシークできません", "Channel Missing": "チャネルがありません", "Channels": "チャンネル", + "Chats": "チャット", "Choose between 2 to 10 options": "2〜10の選択肢から選ぶ", "Close": "閉める", "Close emoji picker": "絵文字ピッカーを閉める", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index aad1e6788..2f0bd0fcc 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -53,6 +53,7 @@ "aria/Channel list": "채널 목록", "aria/Channel search results": "채널 검색 결과", "aria/Close thread": "스레드 닫기", + "aria/Collapse sidebar": "사이드바 접기", "aria/Copy Message Text": "메시지 텍스트 복사", "aria/Delete Message": "메시지 삭제", "aria/Download attachment": "첨부 파일 다운로드", @@ -106,6 +107,7 @@ "Cannot seek in the recording": "녹음에서 찾을 수 없습니다", "Channel Missing": "채널 누락", "Channels": "채널", + "Chats": "채팅", "Choose between 2 to 10 options": "2~10개의 선택지 중에서 선택", "Close": "닫기", "Close emoji picker": "이모티콘 선택기 닫기", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 41f55471e..c22b16dd6 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -54,6 +54,7 @@ "aria/Channel list": "Kanaallijst", "aria/Channel search results": "Zoekresultaten voor kanalen", "aria/Close thread": "Draad sluiten", + "aria/Collapse sidebar": "Zijbalk samenklappen", "aria/Copy Message Text": "Berichttekst kopiëren", "aria/Delete Message": "Bericht verwijderen", "aria/Download attachment": "Bijlage downloaden", @@ -107,6 +108,7 @@ "Cannot seek in the recording": "Kan niet zoeken in de opname", "Channel Missing": "Kanaal niet gevonden", "Channels": "Kanalen", + "Chats": "Chats", "Choose between 2 to 10 options": "Kies tussen 2 en 10 opties", "Close": "Sluit", "Close emoji picker": "Sluit de emoji-kiezer", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index da9fa8e85..20038a7dd 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -61,6 +61,7 @@ "aria/Channel list": "Lista de canais", "aria/Channel search results": "Resultados de pesquisa de canais", "aria/Close thread": "Fechar tópico", + "aria/Collapse sidebar": "Recolher barra lateral", "aria/Copy Message Text": "Copiar texto da mensagem", "aria/Delete Message": "Excluir mensagem", "aria/Download attachment": "Baixar anexo", @@ -114,6 +115,7 @@ "Cannot seek in the recording": "Não é possível buscar na gravação", "Channel Missing": "Canal ausente", "Channels": "Canais", + "Chats": "Conversas", "Choose between 2 to 10 options": "Escolha entre 2 a 10 opções", "Close": "Fechar", "Close emoji picker": "Fechar seletor de emoji", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index fd963839f..3597f6e17 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -68,6 +68,7 @@ "aria/Channel list": "Список каналов", "aria/Channel search results": "Результаты поиска по каналам", "aria/Close thread": "Закрыть тему", + "aria/Collapse sidebar": "Свернуть боковую панель", "aria/Copy Message Text": "Копировать текст сообщения", "aria/Delete Message": "Удалить сообщение", "aria/Download attachment": "Скачать вложение", @@ -121,6 +122,7 @@ "Cannot seek in the recording": "Невозможно осуществить поиск в записи", "Channel Missing": "Канал не найден", "Channels": "Каналы", + "Chats": "Чаты", "Choose between 2 to 10 options": "Выберите от 2 до 10 вариантов", "Close": "Закрыть", "Close emoji picker": "Закрыть окно выбора смайлов", @@ -180,8 +182,8 @@ "File too large": "Файл слишком большой", "fileCount_four": "{{ count }} файла", "fileCount_one": "{{ count }} файл", - "fileCount_few": "", - "fileCount_many": "", + "fileCount_few": "{{ count }} файла", + "fileCount_many": "{{ count }} файлов", "fileCount_other": "{{ count }} файлов", "fileCount_three": "{{ count }} файла", "fileCount_two": "{{ count }} файла", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index aed0805ae..6f32a41b0 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -54,6 +54,7 @@ "aria/Channel list": "Kanal listesi", "aria/Channel search results": "Kanal arama sonuçları", "aria/Close thread": "Konuyu kapat", + "aria/Collapse sidebar": "Kenar çubuğunu daralt", "aria/Copy Message Text": "Mesaj metnini kopyala", "aria/Delete Message": "Mesajı sil", "aria/Download attachment": "Ek indir", @@ -107,6 +108,7 @@ "Cannot seek in the recording": "Kayıtta arama yapılamıyor", "Channel Missing": "Kanal bulunamıyor", "Channels": "Kanallar", + "Chats": "Sohbetler", "Choose between 2 to 10 options": "2 ile 10 seçenek arasından seçin", "Close": "Kapat", "Close emoji picker": "Emoji seçiciyi kapat", diff --git a/src/styling/_global-theme-variables.scss b/src/styling/_global-theme-variables.scss index 540d1d3b3..3c176cb1b 100644 --- a/src/styling/_global-theme-variables.scss +++ b/src/styling/_global-theme-variables.scss @@ -65,6 +65,11 @@ var(--typography-font-size-md) / var(--typography-line-height-normal) var(--str-chat__font-family); + --str-chat__heading-lg-text: normal var(--typography-font-weight-semi-bold) + var(--typography-font-size-xl) / var(--typography-line-height-relaxed) + var(--str-chat__font-family); + color: var(--text-primary, #1a1b25); + // todo: adapt the old text variables to so that they use the new semantic text variables /* The font used for caption texts */ --str-chat__caption-text: 0.75rem/1.3 var(--str-chat__font-family);