From 172ed3d5fbdd56a0fcc4826c780f58e846330aa5 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 24 Feb 2026 17:44:27 +0100 Subject: [PATCH 1/7] feat: render - Render FloatingDateSeparator - Add NewMessageNotification as standalone component at bottom of message list - Style NewMessageNotification per Figma (pill, design tokens) - Remove MessageNotification from MessageListNotifications; simplify props to { notifications } - Move isNotAtLatestMessageSet visibility logic into ScrollToLatestMessageButton - Rename ScrollToBottomButton to ScrollToLatestMessageButton - Replace ComponentContext.MessageNotification with NewMessageNotification - Remove "Latest Messages" string and related i18n entries --- examples/vite/src/stream-imports-layout.scss | 4 +- examples/vite/src/stream-imports-theme.scss | 4 +- .../Attachment/styling/Attachment.scss | 1 + src/components/Badge/Badge.tsx | 37 ++++++ src/components/Badge/__tests__/Badge.test.tsx | 35 ++++++ src/components/Badge/index.ts | 1 + src/components/Badge/styling/Badge.scss | 71 +++++++++++ src/components/Badge/styling/index.scss | 1 + src/components/Button/styling/index.scss | 1 + .../DateSeparator/DateSeparator.tsx | 17 ++- .../DateSeparator/styling/DateSeparator.scss | 11 ++ .../styling/UnreadMessageNotification.scss | 13 +- .../styling/UnreadMessagesSeparator.scss | 3 +- .../MessageList/FloatingDateSeparator.tsx | 68 +++++++++++ src/components/MessageList/MessageList.tsx | 32 +++-- .../MessageList/MessageListNotifications.tsx | 30 +---- .../MessageList/MessageNotification.tsx | 36 ------ .../MessageList/NewMessageNotification.tsx | 48 ++++++++ ...on.tsx => ScrollToLatestMessageButton.tsx} | 87 +++++++------ .../MessageList/VirtualizedMessageList.tsx | 55 ++++++--- .../MessageList/__tests__/MessageList.test.js | 34 +++--- ...js => ScrollToLatestMessageButton.test.js} | 49 +++++--- .../MessageList/hooks/MessageList/index.ts | 1 + .../useFloatingDateSeparatorMessageList.ts | 99 +++++++++++++++ .../useFloatingDateSeparator.test.ts | 101 ++++++++++++++++ .../hooks/VirtualizedMessageList/index.ts | 1 + .../useFloatingDateSeparator.ts | 114 ++++++++++++++++++ src/components/MessageList/icons.tsx | 17 --- src/components/MessageList/index.ts | 4 +- .../MessageList/styling/MessageList.scss | 86 +------------ .../styling/NewMessageNotification.scss | 24 ++++ .../styling/ScrollToLatestMessageButton.scss | 29 +++++ src/components/MessageList/styling/index.scss | 2 + src/components/index.ts | 1 + src/context/ComponentContext.tsx | 6 +- src/i18n/de.json | 3 +- src/i18n/en.json | 3 +- src/i18n/es.json | 4 +- src/i18n/fr.json | 4 +- src/i18n/hi.json | 3 +- src/i18n/it.json | 4 +- src/i18n/ja.json | 3 +- src/i18n/ko.json | 3 +- src/i18n/nl.json | 3 +- src/i18n/pt.json | 4 +- src/i18n/ru.json | 5 +- src/i18n/tr.json | 3 +- src/styling/index.scss | 3 +- 48 files changed, 878 insertions(+), 290 deletions(-) create mode 100644 src/components/Badge/Badge.tsx create mode 100644 src/components/Badge/__tests__/Badge.test.tsx create mode 100644 src/components/Badge/index.ts create mode 100644 src/components/Badge/styling/Badge.scss create mode 100644 src/components/Badge/styling/index.scss create mode 100644 src/components/Button/styling/index.scss create mode 100644 src/components/MessageList/FloatingDateSeparator.tsx delete mode 100644 src/components/MessageList/MessageNotification.tsx create mode 100644 src/components/MessageList/NewMessageNotification.tsx rename src/components/MessageList/{ScrollToBottomButton.tsx => ScrollToLatestMessageButton.tsx} (55%) rename src/components/MessageList/__tests__/{ScrollToBottomButton.test.js => ScrollToLatestMessageButton.test.js} (86%) create mode 100644 src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts create mode 100644 src/components/MessageList/hooks/VirtualizedMessageList/__tests__/useFloatingDateSeparator.test.ts create mode 100644 src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts create mode 100644 src/components/MessageList/styling/NewMessageNotification.scss create mode 100644 src/components/MessageList/styling/ScrollToLatestMessageButton.scss diff --git a/examples/vite/src/stream-imports-layout.scss b/examples/vite/src/stream-imports-layout.scss index 7d4b8d4468..2550fc7651 100644 --- a/examples/vite/src/stream-imports-layout.scss +++ b/examples/vite/src/stream-imports-layout.scss @@ -7,7 +7,7 @@ //@use 'stream-chat-react/dist/scss/v2/AttachmentPreviewList/AttachmentPreviewList-layout'; // X //@use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-layout'; //@use 'stream-chat-react/dist/scss/v2/AudioRecorder/AudioRecorder-layout'; -@use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-layout'; +//@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'; @@ -35,7 +35,7 @@ // @use 'stream-chat-react/dist/scss/v2/MessageReactions/MessageReactions-layout'; @use 'stream-chat-react/dist/scss/v2/MessageReactions/MessageReactionsSelector-layout'; //@use 'stream-chat-react/dist/scss/v2/Modal/Modal-layout'; -@use 'stream-chat-react/dist/scss/v2/Notification/MessageNotification-layout'; +//@use 'stream-chat-react/dist/scss/v2/Notification/MessageNotification-layout'; @use 'stream-chat-react/dist/scss/v2/Notification/NotificationList-layout'; @use 'stream-chat-react/dist/scss/v2/Notification/Notification-layout'; //@use 'stream-chat-react/dist/scss/v2/Poll/Poll-layout'; diff --git a/examples/vite/src/stream-imports-theme.scss b/examples/vite/src/stream-imports-theme.scss index 6d284451e1..54c3fa97ad 100644 --- a/examples/vite/src/stream-imports-theme.scss +++ b/examples/vite/src/stream-imports-theme.scss @@ -7,7 +7,7 @@ //@use 'stream-chat-react/dist/scss/v2/AttachmentList/AttachmentList-theme'; //@use 'stream-chat-react/dist/scss/v2/AudioRecorder/AudioRecorder-theme'; //@use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-theme'; -@use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-theme'; +//@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'; @@ -29,7 +29,7 @@ // @use 'stream-chat-react/dist/scss/v2/MessageReactions/MessageReactions-theme'; @use 'stream-chat-react/dist/scss/v2/MessageReactions/MessageReactionsSelector-theme'; //@use 'stream-chat-react/dist/scss/v2/Modal/Modal-theme'; -@use 'stream-chat-react/dist/scss/v2/Notification/MessageNotification-theme'; +//@use 'stream-chat-react/dist/scss/v2/Notification/MessageNotification-theme'; @use 'stream-chat-react/dist/scss/v2/Notification/NotificationList-theme'; @use 'stream-chat-react/dist/scss/v2/Notification/Notification-theme'; //@use 'stream-chat-react/dist/scss/v2/Poll/Poll-theme'; diff --git a/src/components/Attachment/styling/Attachment.scss b/src/components/Attachment/styling/Attachment.scss index be31090349..7f55d9d795 100644 --- a/src/components/Attachment/styling/Attachment.scss +++ b/src/components/Attachment/styling/Attachment.scss @@ -269,6 +269,7 @@ /* Right (left in RTL layout) border of audio widget's play / pause button */ --str-chat__audio-attachment-controls-button-border-inline-end: none; + // todo: we need to solve whether we want to keep the CSS variables. E.g. --str-chat__circle-fab-box-shadow is not declared. /* Box shadow applied to audio widget's play / pause button */ --str-chat__audio-attachment-controls-button-box-shadow: var( --str-chat__circle-fab-box-shadow diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx new file mode 100644 index 0000000000..1b13762721 --- /dev/null +++ b/src/components/Badge/Badge.tsx @@ -0,0 +1,37 @@ +import clsx from 'clsx'; +import React, { type ComponentProps } from 'react'; + +export type BadgeVariant = 'default' | 'primary' | 'error' | 'neutral' | 'inverse'; + +export type BadgeSize = 'sm' | 'md' | 'lg'; + +export type BadgeProps = ComponentProps<'span'> & { + /** Visual variant mapping to design tokens */ + variant?: BadgeVariant; + /** Size preset (typography and padding) */ + size?: BadgeSize; +}; + +/** + * Compact pill/circle badge for counts and labels. + * Uses design tokens: --badge-bg-*, --badge-text-*, --badge-border. + */ +export const Badge = ({ + children, + className, + size = 'md', + variant = 'primary', + ...spanProps +}: BadgeProps) => ( + + {children} + +); diff --git a/src/components/Badge/__tests__/Badge.test.tsx b/src/components/Badge/__tests__/Badge.test.tsx new file mode 100644 index 0000000000..0df4dd9386 --- /dev/null +++ b/src/components/Badge/__tests__/Badge.test.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { Badge } from '../Badge'; + +describe('Badge', () => { + it('renders children', () => { + render(17); + expect(screen.getByText('17')).toBeInTheDocument(); + }); + + it('applies variant class', () => { + const { container } = render(1); + expect(container.firstChild).toHaveClass('str-chat__badge--variant-primary'); + }); + + it('applies size class', () => { + const { container } = render(1); + expect(container.firstChild).toHaveClass('str-chat__badge--size-md'); + }); + + it('passes data-testid', () => { + render(99); + expect(screen.getByTestId('custom-badge')).toHaveTextContent('99'); + }); + + it('merges className', () => { + const { container } = render( + 5, + ); + expect(container.firstChild).toHaveClass('str-chat__badge'); + expect(container.firstChild).toHaveClass('str-chat__jump-to-latest__unread-count'); + }); +}); diff --git a/src/components/Badge/index.ts b/src/components/Badge/index.ts new file mode 100644 index 0000000000..9c8edca28a --- /dev/null +++ b/src/components/Badge/index.ts @@ -0,0 +1 @@ +export * from './Badge'; diff --git a/src/components/Badge/styling/Badge.scss b/src/components/Badge/styling/Badge.scss new file mode 100644 index 0000000000..9740739101 --- /dev/null +++ b/src/components/Badge/styling/Badge.scss @@ -0,0 +1,71 @@ +// Badge: compact pill for counts/labels. Design tokens: badge-bg-*, badge-text-*, badge-border. +// Figma: Badge Notification (scroll-to-bottom unread count) + +.str-chat__badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: var(--typography-font-weight-bold); + border-radius: var(--radius-max); + line-height: 1; + border-style: solid; +} + +// Variants: map to design tokens +.str-chat__badge--variant-default { + background: var(--badge-bg-default); + color: var(--badge-text); + border-color: var(--badge-border); +} + +.str-chat__badge--variant-primary { + background: var(--badge-bg-primary); + color: var(--badge-text-on-accent); + border-color: var(--badge-border); +} + +.str-chat__badge--variant-error { + background: var(--badge-bg-error); + color: var(--badge-text-on-accent); + border-color: var(--badge-border); +} + +.str-chat__badge--variant-neutral { + background: var(--badge-bg-neutral); + color: var(--badge-text-on-accent); + border-color: var(--badge-border); +} + +.str-chat__badge--variant-inverse { + background: var(--badge-bg-inverse); + color: var(--badge-text-inverse); + border-color: var(--badge-border); +} + +// Sizes: caption-numeric equivalents (sm/12px, md/14px, lg/16px) +.str-chat__badge--size-sm { + font-size: var(--typography-font-size-xxs); + min-width: 16px; + min-height: 16px; + padding-inline: var(--spacing-xxxs); + border-width: 1px; + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.14); +} + +.str-chat__badge--size-md { + font-size: var(--typography-font-size-xs); + min-width: 20px; + min-height: 20px; + padding-inline: var(--spacing-xxs); + border-width: 2px; + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.14); +} + +.str-chat__badge--size-lg { + font-size: var(--typography-font-size-sm); + min-width: 24px; + min-height: 24px; + padding-inline: var(--spacing-xs); + border-width: 2px; + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.14); +} diff --git a/src/components/Badge/styling/index.scss b/src/components/Badge/styling/index.scss new file mode 100644 index 0000000000..529a29758b --- /dev/null +++ b/src/components/Badge/styling/index.scss @@ -0,0 +1 @@ +@use 'Badge'; diff --git a/src/components/Button/styling/index.scss b/src/components/Button/styling/index.scss new file mode 100644 index 0000000000..7514bf3556 --- /dev/null +++ b/src/components/Button/styling/index.scss @@ -0,0 +1 @@ +@use 'Button'; diff --git a/src/components/DateSeparator/DateSeparator.tsx b/src/components/DateSeparator/DateSeparator.tsx index 148426bd54..f7bf166c56 100644 --- a/src/components/DateSeparator/DateSeparator.tsx +++ b/src/components/DateSeparator/DateSeparator.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import React from 'react'; import { useTranslationContext } from '../../context/TranslationContext'; @@ -6,8 +7,12 @@ import { getDateString } from '../../i18n/utils'; import type { TimestampFormatterOptions } from '../../i18n/types'; export type DateSeparatorProps = TimestampFormatterOptions & { + /** Optional className for the root element */ + className?: string; /** The date to format */ date: Date; + /** When true, applies floating positioning (fixed at top when scrolling) */ + floating?: boolean; /** Override the default formatting of the date. This is a function that has access to the original date object. */ formatDate?: (date: Date) => string; // todo: position and unread are not necessary anymore @@ -20,7 +25,9 @@ export type DateSeparatorProps = TimestampFormatterOptions & { const UnMemoizedDateSeparator = (props: DateSeparatorProps) => { const { calendar, + className, date: messageCreatedAt, + floating, formatDate, ...restTimestampFormatterOptions } = props; @@ -38,7 +45,15 @@ const UnMemoizedDateSeparator = (props: DateSeparatorProps) => { }); return ( -
+
{formattedDate}
); diff --git a/src/components/DateSeparator/styling/DateSeparator.scss b/src/components/DateSeparator/styling/DateSeparator.scss index 2b83e822fa..ed29b9ef57 100644 --- a/src/components/DateSeparator/styling/DateSeparator.scss +++ b/src/components/DateSeparator/styling/DateSeparator.scss @@ -12,6 +12,17 @@ --str-chat__date-separator-box-shadow: none; } +.str-chat__date-separator--floating { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 1; + display: flex; + justify-content: center; + pointer-events: none; +} + .str-chat__date-separator { @include utils.component-layer-overrides('date-separator'); display: flex; diff --git a/src/components/Message/styling/UnreadMessageNotification.scss b/src/components/Message/styling/UnreadMessageNotification.scss index 8104752f33..763071b06f 100644 --- a/src/components/Message/styling/UnreadMessageNotification.scss +++ b/src/components/Message/styling/UnreadMessageNotification.scss @@ -1,9 +1,10 @@ .str-chat__unread-messages-notification { - --str-chat-icon-color: var(--str-chat__grey50); + --str-chat-icon-color: var(--str-chat__on-primary-color); background-color: var(--str-chat__text-low-emphasis-color); - border-radius: 1.125rem; + border-radius: var(--str-chat__border-radius-2xl); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); position: absolute; - top: 0.75rem; + top: var(--str-chat__spacing-3); z-index: 2; display: flex; align-items: center; @@ -15,18 +16,18 @@ width: 100%; white-space: nowrap; cursor: pointer; - color: var(--str-chat__grey50); + color: var(--str-chat__on-primary-color); border: none; background-color: transparent; } button:first-of-type { - padding-inline: 0.75rem 0.375rem; + padding-inline: var(--str-chat__spacing-3) var(--str-chat__spacing-2); font: var(--str-chat__caption-text); } button:last-of-type { - padding-inline: 0.375rem 0.75rem; + padding-inline: var(--str-chat__spacing-2) var(--str-chat__spacing-3); svg { width: 0.875rem; diff --git a/src/components/Message/styling/UnreadMessagesSeparator.scss b/src/components/Message/styling/UnreadMessagesSeparator.scss index 356e1cebfc..db822b1de3 100644 --- a/src/components/Message/styling/UnreadMessagesSeparator.scss +++ b/src/components/Message/styling/UnreadMessagesSeparator.scss @@ -6,10 +6,11 @@ align-items: center; justify-content: center; width: 100%; - padding: var(--str-chat__spacing-2); + padding: var(--str-chat__spacing-2) var(--str-chat__spacing-3); background-color: var(--str-chat__secondary-surface-color); color: var(--str-chat__text-low-emphasis-color); text-transform: uppercase; font: var(--str-chat__caption-strong-text); + letter-spacing: 0.02em; } } diff --git a/src/components/MessageList/FloatingDateSeparator.tsx b/src/components/MessageList/FloatingDateSeparator.tsx new file mode 100644 index 0000000000..cb8ff21452 --- /dev/null +++ b/src/components/MessageList/FloatingDateSeparator.tsx @@ -0,0 +1,68 @@ +import React, { useLayoutEffect } from 'react'; +import type { RefObject } from 'react'; + +import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; +import { useComponentContext } from '../../context/ComponentContext'; +import { useFloatingDateSeparatorMessageList } from './hooks/MessageList'; +import { useFloatingDateSeparator } from './hooks/VirtualizedMessageList'; +import type { RenderedMessage } from './utils'; + +type BaseProps = { + disableDateSeparator: boolean; + processedMessages: RenderedMessage[]; +}; + +export type FloatingDateSeparatorProps = BaseProps & + ( + | { + /** For VirtualizedMessageList: ref the parent calls with visible items */ + itemsRenderedRef: RefObject<((rendered: RenderedMessage[]) => void) | null>; + } + | { + /** For MessageList: scroll container to query DOM for date separators */ + listElement: HTMLDivElement | null; + } + ); + +/** + * Renders a floating date separator when the user has scrolled past the in-flow date. + * State is internal so MessageList/VirtualizedMessageList do not re-render when it changes. + * Use itemsRenderedRef for Virtuoso, listElement for non-virtualized MessageList. + */ +export const FloatingDateSeparator = (props: FloatingDateSeparatorProps) => { + const { DateSeparator = DefaultDateSeparator } = useComponentContext( + 'FloatingDateSeparator', + ); + const { disableDateSeparator, processedMessages } = props; + + const listElement = 'listElement' in props ? props.listElement : null; + const useDomMode = listElement != null; + + const virtuosoResult = useFloatingDateSeparator({ + disableDateSeparator, + processedMessages, + }); + const domResult = useFloatingDateSeparatorMessageList({ + disableDateSeparator, + listElement, + processedMessages, + }); + + const floatingDate = useDomMode ? domResult.floatingDate : virtuosoResult.floatingDate; + const showFloatingDate = useDomMode + ? domResult.showFloatingDate + : virtuosoResult.showFloatingDate; + + const itemsRenderedRef = 'itemsRenderedRef' in props ? props.itemsRenderedRef : null; + useLayoutEffect(() => { + if (!itemsRenderedRef) return; + itemsRenderedRef.current = virtuosoResult.onItemsRendered; + return () => { + itemsRenderedRef.current = null; + }; + }, [itemsRenderedRef, virtuosoResult.onItemsRendered]); + + if (!showFloatingDate || !floatingDate || !DateSeparator) return null; + + return ; +}; diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 750eb0b1d3..099db38dc6 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -9,8 +9,8 @@ import { } from './hooks/MessageList'; import { useMarkRead } from './hooks/useMarkRead'; -import { MessageNotification as DefaultMessageNotification } from './MessageNotification'; import { MessageListNotifications as DefaultMessageListNotifications } from './MessageListNotifications'; +import { NewMessageNotification as DefaultNewMessageNotification } from './NewMessageNotification'; import { UnreadMessagesNotification as DefaultUnreadMessagesNotification } from './UnreadMessagesNotification'; import type { ChannelActionContextValue } from '../../context/ChannelActionContext'; @@ -29,6 +29,7 @@ import { defaultPinPermissions, MESSAGE_ACTIONS } from '../Message/utils'; import { TypingIndicator as DefaultTypingIndicator } from '../TypingIndicator'; import { MessageListMainPanel as DefaultMessageListMainPanel } from './MessageListMainPanel'; +import { FloatingDateSeparator } from './FloatingDateSeparator'; import { defaultRenderMessages } from './renderMessages'; import { useStableId } from '../UtilityComponents/useStableId'; @@ -43,6 +44,7 @@ import { DEFAULT_NEXT_CHANNEL_PAGE_SIZE, } from '../../constants/limits'; import { useLastOwnMessage } from './hooks/useLastOwnMessage'; +import { ScrollToLatestMessageButton } from './ScrollToLatestMessageButton'; type MessageListWithContextProps = Omit< ChannelStateContextValue, @@ -97,7 +99,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { MessageListMainPanel = DefaultMessageListMainPanel, MessageListNotifications = DefaultMessageListNotifications, MessageListWrapper = 'ul', - MessageNotification = DefaultMessageNotification, + NewMessageNotification = DefaultNewMessageNotification, TypingIndicator = DefaultTypingIndicator, UnreadMessagesNotification = DefaultUnreadMessagesNotification, } = useComponentContext('MessageList'); @@ -246,6 +248,11 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { unreadCount={channelUnreadUiState?.unread_messages} /> )} +
{
)} + +
- + ); diff --git a/src/components/MessageList/MessageListNotifications.tsx b/src/components/MessageList/MessageListNotifications.tsx index 2abc0106cb..1ade8f98c0 100644 --- a/src/components/MessageList/MessageListNotifications.tsx +++ b/src/components/MessageList/MessageListNotifications.tsx @@ -5,7 +5,6 @@ import { CustomNotification } from './CustomNotification'; import { useTranslationContext } from '../../context/TranslationContext'; import { useNotifications } from '../Notifications/hooks/useNotifications'; -import type { MessageNotificationProps } from './MessageNotification'; import type { ChannelNotifications } from '../../context/ChannelStateContext'; const ClientNotifications = () => { @@ -28,29 +27,11 @@ const ClientNotifications = () => { }; export type MessageListNotificationsProps = { - hasNewMessages: boolean; - isMessageListScrolledToBottom: boolean; - isNotAtLatestMessageSet: boolean; - MessageNotification: React.ComponentType; notifications: ChannelNotifications; - scrollToBottom: () => void; - threadList?: boolean; - unreadCount?: number; }; export const MessageListNotifications = (props: MessageListNotificationsProps) => { - const { - hasNewMessages, - isMessageListScrolledToBottom, - isNotAtLatestMessageSet, - MessageNotification, - notifications, - scrollToBottom, - threadList, - unreadCount, - } = props; - - const { t } = useTranslationContext('MessageListNotifications'); + const { notifications } = props; return (
@@ -61,15 +42,6 @@ export const MessageListNotifications = (props: MessageListNotificationsProps) = ))} - - {isNotAtLatestMessageSet ? t('Latest Messages') : t('New Messages!')} -
); }; diff --git a/src/components/MessageList/MessageNotification.tsx b/src/components/MessageList/MessageNotification.tsx deleted file mode 100644 index a777a7ae3d..0000000000 --- a/src/components/MessageList/MessageNotification.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { PropsWithChildren } from 'react'; -import React from 'react'; - -export type MessageNotificationProps = PropsWithChildren<{ - /** button click event handler */ - onClick: React.MouseEventHandler; - /** signals whether the message list is considered (below a threshold) to be scrolled to the bottom. Prop used only by [ScrollToBottomButton](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/ScrollToBottomButton.tsx) */ - isMessageListScrolledToBottom?: boolean; - /** Whether or not to show notification. Prop used only by [MessageNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/MessageNotification.tsx) */ - showNotification?: boolean; - /** informs the component whether it is rendered inside a thread message list */ - threadList?: boolean; - /** */ - unreadCount?: number; -}>; - -const UnMemoizedMessageNotification = (props: MessageNotificationProps) => { - const { children, onClick, showNotification = true } = props; - - if (!showNotification) return null; - - return ( - - ); -}; - -export const MessageNotification = React.memo( - UnMemoizedMessageNotification, -) as typeof UnMemoizedMessageNotification; diff --git a/src/components/MessageList/NewMessageNotification.tsx b/src/components/MessageList/NewMessageNotification.tsx new file mode 100644 index 0000000000..1169e00b8b --- /dev/null +++ b/src/components/MessageList/NewMessageNotification.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import { useComponentContext } from '../../context/ComponentContext'; +import { useTranslationContext } from '../../context/TranslationContext'; + +export type NewMessageNotificationProps = { + /** Pre-computed label text (passed to custom override) */ + label?: string; + /** Controls the notification display */ + showNotification?: boolean; + /** Number of new messages since user scrolled up (optional; when provided shows "{{count}} new messages") */ + newMessageCount?: number; +}; + +const UnMemoizedNewMessageNotification = (props: NewMessageNotificationProps) => { + const { newMessageCount = 0, showNotification } = props; + + const { t } = useTranslationContext(); + + const { NewMessageNotification: CustomNewMessageNotification } = useComponentContext(); + + if (!showNotification) return null; + + const label = + newMessageCount > 0 + ? t('{{count}} new messages', { count: newMessageCount }) + : t('New Messages!'); + + if (CustomNewMessageNotification) { + return ; + } + + return ( +
+
+ {label} +
+
+ ); +}; + +export const NewMessageNotification = React.memo( + UnMemoizedNewMessageNotification, +) as typeof UnMemoizedNewMessageNotification; diff --git a/src/components/MessageList/ScrollToBottomButton.tsx b/src/components/MessageList/ScrollToLatestMessageButton.tsx similarity index 55% rename from src/components/MessageList/ScrollToBottomButton.tsx rename to src/components/MessageList/ScrollToLatestMessageButton.tsx index d8508867c8..25cb47687c 100644 --- a/src/components/MessageList/ScrollToBottomButton.tsx +++ b/src/components/MessageList/ScrollToLatestMessageButton.tsx @@ -1,20 +1,30 @@ import React, { useEffect, useState } from 'react'; import clsx from 'clsx'; -import { ArrowDown } from './icons'; - import { useChannelStateContext, useChatContext } from '../../context'; import type { Event } from 'stream-chat'; -import type { MessageNotificationProps } from './MessageNotification'; +import { Badge } from '../Badge'; +import { Button } from '../Button'; +import { IconArrowDown } from '../Icons'; + +type ScrollToLatestMessageButtonProps = { + /** When true, user has jumped to an older message set and newer messages can be loaded */ + isNotAtLatestMessageSet?: boolean; + isMessageListScrolledToBottom?: boolean; + onClick: React.MouseEventHandler; + threadList?: boolean; +}; -const UnMemoizedScrollToBottomButton = ( - props: Pick< - MessageNotificationProps, - 'isMessageListScrolledToBottom' | 'onClick' | 'threadList' - >, +const UnMemoizedScrollToLatestMessageButton = ( + props: ScrollToLatestMessageButtonProps, ) => { - const { isMessageListScrolledToBottom, onClick, threadList } = props; + const { + isMessageListScrolledToBottom, + isNotAtLatestMessageSet = false, + onClick, + threadList, + } = props; const { channel: activeChannel, client } = useChatContext(); const { thread } = useChannelStateContext(); @@ -54,8 +64,15 @@ const UnMemoizedScrollToBottomButton = ( return () => { client.off(observedEvent, handleEvent); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeChannel, isMessageListScrolledToBottom, observedEvent, replyCount, thread]); + }, [ + activeChannel, + client, + isMessageListScrolledToBottom, + observedEvent, + replyCount, + thread, + threadList, + ]); useEffect(() => { if (isMessageListScrolledToBottom) { @@ -64,36 +81,38 @@ const UnMemoizedScrollToBottomButton = ( } }, [isMessageListScrolledToBottom, thread]); - if (isMessageListScrolledToBottom) return null; + if (isMessageListScrolledToBottom && !isNotAtLatestMessageSet) return null; return (
- + + + {countUnread > 0 && ( + + {countUnread} + + )}
); }; -export const ScrollToBottomButton = React.memo( - UnMemoizedScrollToBottomButton, -) as typeof UnMemoizedScrollToBottomButton; +export const ScrollToLatestMessageButton = React.memo( + UnMemoizedScrollToLatestMessageButton, +) as typeof UnMemoizedScrollToLatestMessageButton; diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 8c63b16adc..201b13c16e 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -1,5 +1,7 @@ import type { RefObject } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { FloatingDateSeparator } from './FloatingDateSeparator'; import type { ComputeItemKey, ScrollSeekConfiguration, @@ -22,8 +24,8 @@ import { } from './hooks/VirtualizedMessageList'; import { useMarkRead } from './hooks/useMarkRead'; -import { MessageNotification as DefaultMessageNotification } from './MessageNotification'; import { MessageListNotifications as DefaultMessageListNotifications } from './MessageListNotifications'; +import { NewMessageNotification as DefaultNewMessageNotification } from './NewMessageNotification'; import { MessageListMainPanel as DefaultMessageListMainPanel } from './MessageListMainPanel'; import type { GroupStyle, ProcessMessagesParams, RenderedMessage } from './utils'; import { getGroupStyles, getLastReceived, processMessages } from './utils'; @@ -40,7 +42,10 @@ import { messageRenderer, } from './VirtualizedMessageListComponents'; -import { UnreadMessagesSeparator as DefaultUnreadMessagesSeparator } from '../MessageList'; +import { + UnreadMessagesSeparator as DefaultUnreadMessagesSeparator, + ScrollToLatestMessageButton, +} from '../MessageList'; import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; import { EventComponent as DefaultMessageSystem } from '../EventComponent'; @@ -243,8 +248,8 @@ const VirtualizedMessageListWithContext = ( GiphyPreviewMessage = DefaultGiphyPreviewMessage, MessageListMainPanel = DefaultMessageListMainPanel, MessageListNotifications = DefaultMessageListNotifications, - MessageNotification = DefaultMessageNotification, MessageSystem = DefaultMessageSystem, + NewMessageNotification = DefaultNewMessageNotification, TypingIndicator, UnreadMessagesNotification = DefaultUnreadMessagesNotification, UnreadMessagesSeparator = DefaultUnreadMessagesSeparator, @@ -303,6 +308,16 @@ const VirtualizedMessageListWithContext = ( client.userID, ]); + /** + * This indirection via a ref is needed because Virtuoso’s itemsRendered is set at render time, + * while FloatingDateSeparator lives in a child component that may run its hooks in a different order. + * Using a ref lets the parent provide the callback to Virtuoso while the child keeps ownership + * of the actual handler implementation. + */ + const floatingDateItemsRenderedRef = useRef< + ((rendered: RenderedMessage[]) => void) | null + >(null); + const lastOwnMessage = useLastOwnMessage({ messages, ownUserId: client.user?.id }); // get the mapping of own messages to array of users who read them @@ -405,7 +420,13 @@ const VirtualizedMessageListWithContext = ( const handleItemsRendered = useMemo( () => - makeItemsRenderedHandler([toggleShowUnreadMessagesNotification], processedMessages), + makeItemsRenderedHandler( + [ + toggleShowUnreadMessagesNotification, + (rendered) => floatingDateItemsRenderedRef.current?.(rendered), + ], + processedMessages, + ), [processedMessages, toggleShowUnreadMessagesNotification], ); const followOutput = (isAtBottom: boolean) => { @@ -479,6 +500,11 @@ const VirtualizedMessageListWithContext = ( customClasses?.virtualizedMessageList || 'str-chat__virtual-list' } > + atBottomStateChange={atBottomStateChange} atBottomThreshold={100} @@ -546,20 +572,21 @@ const VirtualizedMessageListWithContext = ( {...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})} {...(defaultItemHeight ? { defaultItemHeight } : {})} /> + +
{TypingIndicator && } - + {giphyPreviewMessage && } diff --git a/src/components/MessageList/__tests__/MessageList.test.js b/src/components/MessageList/__tests__/MessageList.test.js index d4dba32932..bc3a81b754 100644 --- a/src/components/MessageList/__tests__/MessageList.test.js +++ b/src/components/MessageList/__tests__/MessageList.test.js @@ -26,8 +26,6 @@ import { WithComponents, } from '../../../context'; import { EmptyStateIndicator as EmptyStateIndicatorMock } from '../../EmptyStateIndicator'; -import { ScrollToBottomButton } from '../ScrollToBottomButton'; -import { MessageListNotifications } from '../MessageListNotifications'; import { mockedApiResponse } from '../../../mock-builders/api/utils'; import { nanoid } from 'nanoid'; @@ -795,14 +793,12 @@ describe('MessageList', () => { }); }); - describe('ScrollToBottomButton', () => { - const BUTTON_TEST_ID = 'message-notification'; + describe('ScrollToLatestMessageButton and NewMessageNotification', () => { + const NEW_MESSAGE_NOTIFICATION_TEST_ID = 'message-notification'; + const SCROLL_TO_LATEST_MESSAGE_TEST_ID = 'scroll-to-latest-message-button'; const NEW_MESSAGE_COUNTER_TEST_ID = 'unread-message-notification-counter'; - const MockMessageListNotifications = (props) => ( - - ); - it('does not reflect the channel unread UI state', async () => { + it('ScrollToLatestMessageButton does not reflect the channel unread UI state', async () => { const { channels: [channel], client, @@ -814,15 +810,15 @@ describe('MessageList', () => { channel, }, chatClient: client, - components: { - MessageListNotifications: MockMessageListNotifications, - MessageNotification: ScrollToBottomButton, - }, msgListProps: { messages }, }); }); - expect(screen.queryByTestId(BUTTON_TEST_ID)).toBeInTheDocument(); + const scrollButton = screen.queryByTestId(SCROLL_TO_LATEST_MESSAGE_TEST_ID); + const newMessageNotification = screen.queryByTestId( + NEW_MESSAGE_NOTIFICATION_TEST_ID, + ); + expect(scrollButton || newMessageNotification).toBeTruthy(); expect(screen.queryByTestId(NEW_MESSAGE_COUNTER_TEST_ID)).not.toBeInTheDocument(); await act(() => { @@ -832,7 +828,7 @@ describe('MessageList', () => { expect(screen.queryByTestId(NEW_MESSAGE_COUNTER_TEST_ID)).not.toBeInTheDocument(); }); - it('does not reflect the channel unread state in a thread', async () => { + it('ScrollToLatestMessageButton does not reflect the channel unread state in a thread', async () => { const { channels: [channel], client, @@ -844,15 +840,15 @@ describe('MessageList', () => { channel, }, chatClient: client, - components: { - MessageListNotifications: MockMessageListNotifications, - MessageNotification: ScrollToBottomButton, - }, msgListProps: { messages, threadList: true }, }); }); - expect(screen.queryByTestId(BUTTON_TEST_ID)).toBeInTheDocument(); + const scrollButton = screen.queryByTestId(SCROLL_TO_LATEST_MESSAGE_TEST_ID); + const newMessageNotification = screen.queryByTestId( + NEW_MESSAGE_NOTIFICATION_TEST_ID, + ); + expect(scrollButton || newMessageNotification).toBeTruthy(); expect(screen.queryByTestId(NEW_MESSAGE_COUNTER_TEST_ID)).not.toBeInTheDocument(); await act(() => { diff --git a/src/components/MessageList/__tests__/ScrollToBottomButton.test.js b/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.js similarity index 86% rename from src/components/MessageList/__tests__/ScrollToBottomButton.test.js rename to src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.js index 10b18bb074..bbf0005b67 100644 --- a/src/components/MessageList/__tests__/ScrollToBottomButton.test.js +++ b/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.js @@ -2,7 +2,7 @@ import React from 'react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { ScrollToBottomButton } from '../ScrollToBottomButton'; +import { ScrollToLatestMessageButton } from '../ScrollToLatestMessageButton'; import { ChannelStateProvider, ChatProvider } from '../../../context'; import { createClientWithChannel, @@ -11,7 +11,7 @@ import { generateMessage, } from '../../../mock-builders'; -const BUTTON_TEST_ID = 'message-notification'; +const BUTTON_TEST_ID = 'scroll-to-latest-message-button'; const NEW_MESSAGE_COUNTER_TEST_ID = 'unread-message-notification-counter'; const mainList = 'the main message list'; @@ -42,7 +42,7 @@ const dispatchMessageEvents = ({ channel, client, newMessage, parentMsg, user }) describe.each([ [mainList, threadList], [threadList, mainList], -])('ScrollToBottomButton in %s', (containerMsgList, otherMsgList) => { +])('ScrollToLatestMessageButton in %s', (containerMsgList, otherMsgList) => { beforeEach(async () => { const result = await createClientWithChannel(); client = result.client; @@ -64,7 +64,7 @@ describe.each([ const { container } = render( - + , ); @@ -75,7 +75,10 @@ describe.each([ render( - + , ); @@ -86,7 +89,10 @@ describe.each([ render( - + , ); @@ -105,7 +111,7 @@ describe.each([ render( - + , ); @@ -131,7 +137,10 @@ describe.each([ render( - + , ); @@ -158,7 +167,10 @@ describe.each([ render( - + , ); @@ -187,7 +199,10 @@ describe.each([ render( - + , ); @@ -217,7 +232,10 @@ describe.each([ render( - + , ); @@ -241,7 +259,10 @@ describe.each([ render( - + , ); @@ -280,13 +301,13 @@ describe.each([
-
- { + const [state, setState] = useState<{ date: Date | null; visible: boolean }>({ + date: null, + visible: false, + }); + + const update = useCallback(() => { + if (disableDateSeparator || !listElement || processedMessages.length === 0) { + setState({ date: null, visible: false }); + return; + } + + const separators = listElement.querySelectorAll(DATE_SEPARATOR_SELECTOR); + if (separators.length === 0) { + setState({ date: null, visible: false }); + return; + } + + const containerRect = listElement.getBoundingClientRect(); + let bestDate: Date | null = null; + let bestBottom = -Infinity; + let anyVisible = false; + + for (const el of separators) { + const rect = el.getBoundingClientRect(); + const dataDate = el.getAttribute('data-date'); + if (!dataDate) continue; + + const isAboveViewport = rect.bottom < containerRect.top; + const isVisible = + rect.top < containerRect.bottom && rect.bottom > containerRect.top; + + if (isVisible) { + anyVisible = true; + } + + if (isAboveViewport && rect.bottom > bestBottom) { + bestBottom = rect.bottom; + const d = new Date(dataDate); + if (!isNaN(d.getTime())) bestDate = d; + } + } + + setState({ + date: anyVisible ? null : bestDate, + visible: !anyVisible && bestDate !== null, + }); + }, [disableDateSeparator, listElement, processedMessages]); + + useEffect(() => { + if (!listElement) return; + + const throttled = throttle(update, THROTTLE_MS); + + throttled(); + listElement.addEventListener('scroll', throttled); + const resizeObserver = new ResizeObserver(throttled); + resizeObserver.observe(listElement); + + return () => { + listElement.removeEventListener('scroll', throttled); + resizeObserver.disconnect(); + throttled.cancel(); + }; + }, [listElement, update]); + + return { + floatingDate: state.date, + showFloatingDate: state.visible, + }; +}; diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/__tests__/useFloatingDateSeparator.test.ts b/src/components/MessageList/hooks/VirtualizedMessageList/__tests__/useFloatingDateSeparator.test.ts new file mode 100644 index 0000000000..049d07e186 --- /dev/null +++ b/src/components/MessageList/hooks/VirtualizedMessageList/__tests__/useFloatingDateSeparator.test.ts @@ -0,0 +1,101 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useFloatingDateSeparator } from '../useFloatingDateSeparator'; +import type { RenderedMessage } from '../../../utils'; +import { CUSTOM_MESSAGE_TYPE } from '../../../../../constants/messageTypes'; + +const makeDateSeparator = (date: Date): RenderedMessage => + ({ + customType: CUSTOM_MESSAGE_TYPE.date, + date, + id: `date-${date.toISOString()}`, + type: 'date', + unread: false, + }) as unknown as RenderedMessage; + +const makeMessage = (id: string, createdAt: Date): RenderedMessage => + ({ + created_at: createdAt, + id, + type: 'regular', + user: { id: 'user' }, + }) as unknown as RenderedMessage; + +describe('useFloatingDateSeparator', () => { + const jan1 = new Date('2025-01-01T12:00:00Z'); + const jan2 = new Date('2025-01-02T12:00:00Z'); + const processedMessages: RenderedMessage[] = [ + makeDateSeparator(jan1), + makeMessage('m1', jan1), + makeMessage('m2', jan1), + makeDateSeparator(jan2), + makeMessage('m3', jan2), + ]; + + it('returns visible false when date separators are disabled', () => { + const { result } = renderHook(() => + useFloatingDateSeparator({ + disableDateSeparator: true, + processedMessages, + }), + ); + + act(() => { + result.current.onItemsRendered([makeMessage('m2', jan1)]); + }); + + expect(result.current.showFloatingDate).toBe(false); + expect(result.current.floatingDate).toBeNull(); + }); + + it('hides floating when first visible item is a date separator', () => { + const { result } = renderHook(() => + useFloatingDateSeparator({ + disableDateSeparator: false, + processedMessages, + }), + ); + + act(() => { + result.current.onItemsRendered([makeDateSeparator(jan1), makeMessage('m1', jan1)]); + }); + + expect(result.current.showFloatingDate).toBe(false); + expect(result.current.floatingDate).toBeNull(); + }); + + it('shows floating with correct date when first visible is a message', () => { + const { result } = renderHook(() => + useFloatingDateSeparator({ + disableDateSeparator: false, + processedMessages, + }), + ); + + act(() => { + result.current.onItemsRendered([makeMessage('m2', jan1), makeMessage('m3', jan2)]); + }); + + expect(result.current.showFloatingDate).toBe(true); + expect(result.current.floatingDate).toEqual(jan1); + }); + + it('hides when any date separator is in visible set', () => { + const { result } = renderHook(() => + useFloatingDateSeparator({ + disableDateSeparator: false, + processedMessages, + }), + ); + + act(() => { + result.current.onItemsRendered([ + makeMessage('m2', jan1), + makeDateSeparator(jan2), + makeMessage('m3', jan2), + ]); + }); + + expect(result.current.showFloatingDate).toBe(false); + }); +}); diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/index.ts b/src/components/MessageList/hooks/VirtualizedMessageList/index.ts index 2574aa4c0c..99d4112e16 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/index.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/index.ts @@ -1,3 +1,4 @@ +export * from './useFloatingDateSeparator'; export * from './useGiphyPreview'; export * from './useMessageSetKey'; export * from './useNewMessageNotification'; diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts new file mode 100644 index 0000000000..2730a8db81 --- /dev/null +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts @@ -0,0 +1,114 @@ +import { useCallback, useState } from 'react'; + +import type { RenderedMessage } from '../../utils'; +import { isDateSeparatorMessage, isIntroMessage } from '../../utils'; +import type { LocalMessage } from 'stream-chat'; + +export type UseFloatingDateSeparatorParams = { + disableDateSeparator: boolean; + processedMessages: RenderedMessage[]; +}; + +export type UseFloatingDateSeparatorResult = { + floatingDate: Date | null; + onItemsRendered: (rendered: RenderedMessage[]) => void; + showFloatingDate: boolean; +}; + +/** + * Returns the date to show in the floating date separator based on currently visible messages. + * When the first visible item is a message (not a date separator), we've scrolled past its + * date separator — find that separator's date. + */ +function getFloatingDateForFirstMessage( + firstMessage: RenderedMessage, + processedMessages: RenderedMessage[], + firstMessageIndex: number, +): Date | null { + if (isIntroMessage(firstMessage)) return null; + + // Walk backwards to find the last date separator before this message + for (let i = firstMessageIndex - 1; i >= 0; i -= 1) { + const item = processedMessages[i]; + if (isDateSeparatorMessage(item)) { + return item.date; + } + } + + // No preceding date separator; use message's created_at + const msg = firstMessage as LocalMessage; + const created = msg.created_at; + if (created) { + const d = new Date(created); + return isNaN(d.getTime()) ? null : d; + } + return null; +} + +/** + * Controls when to show the floating date separator (Slack-like: fixed at top when scrolling). + * Show when no in-flow date separator is visible and we've scrolled past one. + */ +const HIDDEN_STATE = { date: null, visible: false } as const; + +export const useFloatingDateSeparator = ({ + disableDateSeparator, + processedMessages, +}: UseFloatingDateSeparatorParams): UseFloatingDateSeparatorResult => { + const [state, setState] = useState<{ + date: Date | null; + visible: boolean; + }>(HIDDEN_STATE); + + const onItemsRendered = useCallback( + (rendered: RenderedMessage[]) => { + if (disableDateSeparator || processedMessages.length === 0) { + setState(HIDDEN_STATE); + return; + } + + const valid = rendered.filter((m): m is RenderedMessage => m != null); + if (valid.length === 0) { + setState(HIDDEN_STATE); + return; + } + + const first = valid[0]; + + // If first visible item is a date separator, it's in view — hide floating + if (isDateSeparatorMessage(first)) { + setState(HIDDEN_STATE); + return; + } + + // Check if any date separator is visible — if so, hide floating + const hasVisibleDateSeparator = valid.some(isDateSeparatorMessage); + if (hasVisibleDateSeparator) { + setState(HIDDEN_STATE); + return; + } + + // First visible is a message; find its date + const firstIndex = processedMessages.findIndex((m) => m.id === first.id); + const date = + firstIndex >= 0 + ? getFloatingDateForFirstMessage(first, processedMessages, firstIndex) + : null; + + const visible = date !== null; + setState((prev) => { + const prevTime = prev.date?.getTime() ?? null; + const nextTime = date?.getTime() ?? null; + if (prev.visible === visible && prevTime === nextTime) return prev; + return { date, visible }; + }); + }, + [disableDateSeparator, processedMessages], + ); + + return { + floatingDate: state.date, + onItemsRendered, + showFloatingDate: !!state.date && state.visible, + }; +}; diff --git a/src/components/MessageList/icons.tsx b/src/components/MessageList/icons.tsx index 39f56c048b..68b52c3d68 100644 --- a/src/components/MessageList/icons.tsx +++ b/src/components/MessageList/icons.tsx @@ -22,23 +22,6 @@ export const ArrowUp = ({ className, color }: ArrowProps) => ( ); -export const ArrowDown = ({ className, color }: ArrowProps) => ( - - - -); - export const CloseIcon = () => ( diff --git a/src/components/MessageList/index.ts b/src/components/MessageList/index.ts index 745c2d593e..985ddbec2c 100644 --- a/src/components/MessageList/index.ts +++ b/src/components/MessageList/index.ts @@ -2,8 +2,8 @@ export * from './ConnectionStatus'; // TODO: export this under its own folder export * from './GiphyPreviewMessage'; export * from './MessageList'; export * from './MessageListNotifications'; -export * from './MessageNotification'; -export * from './ScrollToBottomButton'; +export * from './NewMessageNotification'; +export * from './ScrollToLatestMessageButton'; export * from './UnreadMessagesNotification'; export * from './UnreadMessagesSeparator'; export * from './VirtualizedMessageList'; diff --git a/src/components/MessageList/styling/MessageList.scss b/src/components/MessageList/styling/MessageList.scss index b0bafdbddd..42f0417e97 100644 --- a/src/components/MessageList/styling/MessageList.scss +++ b/src/components/MessageList/styling/MessageList.scss @@ -12,6 +12,7 @@ .str-chat__message-list { @include utils.scrollable; + position: relative; overscroll-behavior: none; width: 100%; /* Max container 800px, 16px padding → 768px readable content; matches composer width + padding */ @@ -51,20 +52,6 @@ } } -.str-chat__jump-to-latest-message { - position: absolute; - inset-block-end: var(--str-chat__spacing-4); - inset-inline-end: var(--str-chat__spacing-2); - z-index: 2; - - .str-chat__jump-to-latest-unread-count { - position: absolute; - padding: var(--str-chat__spacing-0_5) var(--str-chat__spacing-2); - left: 50%; - transform: translateX(-50%) translateY(-100%); - } -} - .str-chat__main-panel { .str-chat__ul { .str-chat__li:first-of-type { @@ -103,80 +90,9 @@ /* Right (left in RTL layout) border of the component */ --str-chat__message-list-border-inline-end: none; - /* The border radius used for the borders of the jump to latest message button */ - --str-chat__jump-to-latest-message-border-radius: var( - --str-chat__circle-fab-border-radius - ); - - /* The text/icon color of the jump to latest message button */ - --str-chat__jump-to-latest-message-color: var(--str-chat__circle-fab-color); - - /* The background color of the jump to latest message button */ - --str-chat__jump-to-latest-message-background-color: var( - --str-chat__circle-fab-background-color - ); - - /* The background color of the jump to latest message button in pressed state */ - --str-chat__jump-to-latest-message-pressed-background-color: var( - --str-chat__circle-fab-pressed-background-color - ); - - /* Box shadow applied to the jump to latest message button */ - --str-chat__jump-to-latest-message-box-shadow: var(--str-chat__circle-fab-box-shadow); - - /* Top border of the jump to latest message button */ - --str-chat__jump-to-latest-message-border-block-start: var( - --str-chat__circle-fab-border-block-start - ); - - /* Bottom border of the jump to latest message button */ - --str-chat__jump-to-latest-message-border-block-end: var( - --str-chat__circle-fab-border-block-end - ); - - /* Left (right in RTL layout) border of the jump to latest message button */ - --str-chat__jump-to-latest-message-border-inline-start: var( - --str-chat__circle-fab-border-inline-start - ); - - /* Right (left in RTL layout) border of the jump to latest message button */ - --str-chat__jump-to-latest-message-border-inline-end: var( - --str-chat__circle-fab-border-inline-end - ); - - /* The background color of the unread messages count on the jump to latest message button */ - --str-chat__jump-to-latest-message-unread-count-background-color: var( - --str-chat__jump-to-latest-message-color - ); - - /* The text/icon color of the unread messages count on the jump to latest message button */ - --str-chat__jump-to-latest-message-unread-count-color: var( - --str-chat__jump-to-latest-message-background-color - ); - /* The color used for displaying thread replies and thread separator at the start of a thread */ --str-chat__thread-head-start-color: var(--str-chat__text-low-emphasis-color); /* The color used for the separator below the first message in a thread */ --str-chat__thread-head-start-border-block-end-color: var(--str-chat__surface-color); } - -.str-chat__jump-to-latest-message { - --str-chat-icon-color: var( - --str-chat__jump-to-latest-message-unread-count-background-color - ); - - .str-chat__circle-fab { - @include utils.component-layer-overrides('jump-to-latest-message'); - @include utils.circle-fab-overrides('jump-to-latest-message'); - - .str-chat__jump-to-latest-unread-count { - background-color: var( - --str-chat__jump-to-latest-message-unread-count-background-color - ); - color: var(--str-chat__jump-to-latest-message-unread-count-color); - border-radius: var(--str-chat__jump-to-latest-message-border-radius); - font: var(--str-chat__caption-text); - } - } -} diff --git a/src/components/MessageList/styling/NewMessageNotification.scss b/src/components/MessageList/styling/NewMessageNotification.scss new file mode 100644 index 0000000000..75e3331a05 --- /dev/null +++ b/src/components/MessageList/styling/NewMessageNotification.scss @@ -0,0 +1,24 @@ +@use '../../../styling/utils'; + +.str-chat__new-message-notification { + position: absolute; + inset-block-end: 16px; + inset-inline-start: 0; + inset-inline-end: 0; + z-index: 2; + display: flex; + justify-content: center; + + .str-chat__message-notification__label { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-xxs) var(--spacing-sm); + background-color: var(--background-core-surface-subtle); + border-radius: var(--radius-max); + font: var(--str-chat__metadata-emphasis-text); + color: var(--chat-text-system); + } +} + + diff --git a/src/components/MessageList/styling/ScrollToLatestMessageButton.scss b/src/components/MessageList/styling/ScrollToLatestMessageButton.scss new file mode 100644 index 0000000000..6b251c6e56 --- /dev/null +++ b/src/components/MessageList/styling/ScrollToLatestMessageButton.scss @@ -0,0 +1,29 @@ +.str-chat__jump-to-latest-message { + height: 40px; + width: 40px; + position: absolute; + inset-block-end: var(--spacing-md); + inset-inline-end: var(--spacing-md); + z-index: 2; + border-radius: var(--radius-max); + background-color: var(--background-elevation-elevation-1); + // todo - we ned to have the shadows in variables that are supported in light and dark mode too + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.05), + 0 2px 4px 0 rgba(0, 0, 0, 0.12), + 0 6px 16px 0 rgba(0, 0, 0, 0.06); + + .str-chat__jump-to-latest-message__button { + height: 40px; + width: 40px; + position: static; + } + + .str-chat__jump-to-latest__unread-count { + position: absolute; + inset-block-end: 100%; + margin-block-end: var(--str-chat__spacing-1); + right: -15%; + top: -15%; + } +} diff --git a/src/components/MessageList/styling/index.scss b/src/components/MessageList/styling/index.scss index 71982d251d..8b516031a7 100644 --- a/src/components/MessageList/styling/index.scss +++ b/src/components/MessageList/styling/index.scss @@ -1,2 +1,4 @@ @use 'MessageList'; +@use 'NewMessageNotification'; +@use 'ScrollToLatestMessageButton'; @use 'VirtualizedMessageList'; diff --git a/src/components/index.ts b/src/components/index.ts index e8605f9424..59e71bd5f5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,6 +2,7 @@ export * from './AIStateIndicator'; export * from './Attachment'; export * from './AudioPlayback'; export * from './Avatar'; +export * from './Badge'; export * from './Button'; export * from './Channel'; export * from './ChannelHeader'; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index c6d1c44989..c649b62ab3 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -22,7 +22,6 @@ import { type MessageDeletedProps, type MessageInputProps, type MessageListNotificationsProps, - type MessageNotificationProps, type MessageProps, type MessageRepliesCountButtonProps, type MessageStatusProps, @@ -30,6 +29,7 @@ import { type MessageUIComponentProps, type ModalGalleryProps, type ModalProps, + type NewMessageNotificationProps, type PinIndicatorProps, type PollCreationDialogProps, type PollOptionSelectorProps, @@ -148,8 +148,8 @@ export type ComponentContextValue = { MessageListMainPanel?: React.ComponentType; /** Custom UI component that displays message and connection status notifications in the `MessageList`, defaults to and accepts same props as [DefaultMessageListNotifications](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/MessageListNotifications.tsx) */ MessageListNotifications?: React.ComponentType; - /** Custom UI component to display a notification when scrolled up the list and new messages arrive, defaults to and accepts same props as [MessageNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/MessageNotification.tsx) */ - MessageNotification?: React.ComponentType; + /** Custom UI component to display a notification when scrolled up the list and new messages arrive, defaults to and accepts same props as [NewMessageNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/NewMessageNotification.tsx) */ + NewMessageNotification?: React.ComponentType; /** Custom UI component to display message replies, defaults to and accepts same props as: [MessageRepliesCountButton](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageRepliesCountButton.tsx) */ MessageRepliesCountButton?: React.ComponentType; /** Custom UI component to display message delivery status, defaults to and accepts same props as: [MessageStatus](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageStatus.tsx) */ diff --git a/src/i18n/de.json b/src/i18n/de.json index cc83aceded..b692127759 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -16,6 +16,8 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} und {{ user }} tippen...", "{{ users }} and more are typing...": "{{ users }} und mehr tippen...", "{{ watcherCount }} online": "{{ watcherCount }} online", + "{{count}} new messages_one": "{{count}} neue Nachricht", + "{{count}} new messages_other": "{{count}} neue Nachrichten", "{{count}} unread_one": "{{count}} ungelesen", "{{count}} unread_other": "{{count}} ungelesen", "{{count}} votes_one": "{{count}} Stimme", @@ -221,7 +223,6 @@ "language/vi": "Vietnamesisch", "language/zh": "Chinesisch (Vereinfacht)", "language/zh-TW": "Chinesisch (Traditionell)", - "Latest Messages": "Neueste Nachrichten", "Let others add options": "Andere Optionen hinzufügen lassen", "Limit votes per person": "Stimmen pro Person begrenzen", "live": "live", diff --git a/src/i18n/en.json b/src/i18n/en.json index 140266656c..b691697f19 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -16,6 +16,8 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} and {{ user }} are typing...", "{{ users }} and more are typing...": "{{ users }} and more are typing...", "{{ watcherCount }} online": "{{ watcherCount }} online", + "{{count}} new messages_one": "{{count}} new message", + "{{count}} new messages_other": "{{count}} new messages", "{{count}} unread_one": "{{count}} unread", "{{count}} unread_other": "{{count}} unread", "{{count}} votes_one": "{{count}} vote", @@ -221,7 +223,6 @@ "language/vi": "Vietnamese", "language/zh": "Chinese (Simplified)", "language/zh-TW": "Chinese (Traditional)", - "Latest Messages": "Latest Messages", "Let others add options": "Let others add options", "Limit votes per person": "Limit votes per person", "live": "live", diff --git a/src/i18n/es.json b/src/i18n/es.json index de808da572..2b73bf53e6 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -19,6 +19,9 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} y {{ user }} están escribiendo...", "{{ users }} and more are typing...": "{{ users }} y más están escribiendo...", "{{ watcherCount }} online": "{{ watcherCount }} en línea", + "{{count}} new messages_one": "{{count}} nuevo mensaje", + "{{count}} new messages_many": "{{count}} nuevos mensajes", + "{{count}} new messages_other": "{{count}} nuevos mensajes", "{{count}} unread_one": "{{count}} no leído", "{{count}} unread_many": "{{count}} no leídos", "{{count}} unread_other": "{{count}} no leídos", @@ -226,7 +229,6 @@ "language/vi": "Vietnamita", "language/zh": "Chino (simplificado)", "language/zh-TW": "Chino (tradicional)", - "Latest Messages": "Últimos mensajes", "Let others add options": "Permitir que otros añadan opciones", "Limit votes per person": "Limitar votos por persona", "live": "En vivo", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 86e5293a06..1135025b48 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -19,6 +19,9 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} et {{ user }} sont en train d'écrire...", "{{ users }} and more are typing...": "{{ users }} et plus sont en train d'écrire...", "{{ watcherCount }} online": "{{ watcherCount }} en ligne", + "{{count}} new messages_one": "{{count}} nouveau message", + "{{count}} new messages_many": "{{count}} nouveaux messages", + "{{count}} new messages_other": "{{count}} nouveaux messages", "{{count}} unread_one": "{{count}} non lu", "{{count}} unread_many": "{{count}} non lus", "{{count}} unread_other": "{{count}} non lus", @@ -226,7 +229,6 @@ "language/vi": "Vietnamien", "language/zh": "Chinois (simplifié)", "language/zh-TW": "Chinois (traditionnel)", - "Latest Messages": "Derniers messages", "Let others add options": "Permettre à d'autres d'ajouter des options", "Limit votes per person": "Limiter les votes par personne", "live": "en direct", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index b76e5642f7..4778c5559a 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -16,6 +16,8 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} और {{ user }} टाइप कर रहे हैं...", "{{ users }} and more are typing...": "{{ users }} और अधिक टाइप कर रहे हैं...", "{{ watcherCount }} online": "{{ watcherCount }} ऑनलाइन", + "{{count}} new messages_one": "{{count}} नया संदेश", + "{{count}} new messages_other": "{{count}} नए संदेश", "{{count}} unread_one": "{{count}} अपठित", "{{count}} unread_other": "{{count}} अपठित", "{{count}} votes_one": "{{count}} वोट", @@ -222,7 +224,6 @@ "language/vi": "वियतनामी", "language/zh": "चीनी (सरलीकृत)", "language/zh-TW": "चीनी (पारंपरिक)", - "Latest Messages": "नवीनतम संदेश", "Let others add options": "दूसरों को विकल्प जोड़ने दें", "Limit votes per person": "प्रति व्यक्ति वोट सीमित करें", "live": "लाइव", diff --git a/src/i18n/it.json b/src/i18n/it.json index a13d07d2e1..4c41c15b71 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -19,6 +19,9 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} e {{ user }} stanno digitando...", "{{ users }} and more are typing...": "{{ users }} e altri stanno digitando...", "{{ watcherCount }} online": "{{ watcherCount }} online", + "{{count}} new messages_one": "{{count}} nuovo messaggio", + "{{count}} new messages_many": "{{count}} nuovi messaggi", + "{{count}} new messages_other": "{{count}} nuovi messaggi", "{{count}} unread_one": "{{count}} non letto", "{{count}} unread_many": "{{count}} non letti", "{{count}} unread_other": "{{count}} non letti", @@ -226,7 +229,6 @@ "language/vi": "Vietnamita", "language/zh": "Cinese (semplificato)", "language/zh-TW": "Cinese (tradizionale)", - "Latest Messages": "Ultimi messaggi", "Let others add options": "Lascia che altri aggiungano opzioni", "Limit votes per person": "Limita i voti per persona", "live": "live", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index e27e0352ab..718d7d675b 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -16,6 +16,8 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} と {{ user }} が入力中...", "{{ users }} and more are typing...": "{{ users }} とその他が入力中...", "{{ watcherCount }} online": "{{ watcherCount }} オンライン", + "{{count}} new messages_one": "{{count}}件の新しいメッセージ", + "{{count}} new messages_other": "{{count}}件の新しいメッセージ", "{{count}} unread_one": "{{count}} 未読", "{{count}} unread_other": "{{count}} 未読", "{{count}} votes_one": "{{count}} 票", @@ -221,7 +223,6 @@ "language/vi": "ベトナム語", "language/zh": "中国語(簡体字)", "language/zh-TW": "中国語(繁体字)", - "Latest Messages": "最新のメッセージ", "Let others add options": "他の人が選択肢を追加できるようにする", "Limit votes per person": "1人あたりの投票数を制限する", "live": "ライブ", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index b06e8e4871..144e355386 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -16,6 +16,8 @@ "{{ users }} and {{ user }} are typing...": "{{ users }}와(과) {{ user }}이(가) 입력 중입니다...", "{{ users }} and more are typing...": "{{ users }}와(과) 더 많은 사람들이 입력 중입니다...", "{{ watcherCount }} online": "{{ watcherCount }} 온라인", + "{{count}} new messages_one": "{{count}}개의 새 메시지", + "{{count}} new messages_other": "{{count}}개의 새 메시지", "{{count}} unread_one": "{{count}} 읽지 않음", "{{count}} unread_other": "{{count}} 읽지 않음", "{{count}} votes_one": "{{count}} 투표", @@ -221,7 +223,6 @@ "language/vi": "베트남어", "language/zh": "중국어(간체)", "language/zh-TW": "중국어(번체)", - "Latest Messages": "최신 메시지", "Let others add options": "다른 사람이 선택지를 추가할 수 있도록 허용", "Limit votes per person": "1인당 투표 수 제한", "live": "라이브", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 91937effcc..2d6b13e92b 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -16,6 +16,8 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} en {{ user }} zijn aan het typen...", "{{ users }} and more are typing...": "{{ users }} en meer zijn aan het typen...", "{{ watcherCount }} online": "{{ watcherCount }} online", + "{{count}} new messages_one": "{{count}} nieuw bericht", + "{{count}} new messages_other": "{{count}} nieuwe berichten", "{{count}} unread_one": "{{count}} ongelezen", "{{count}} unread_other": "{{count}} ongelezen", "{{count}} votes_one": "{{count}} stem", @@ -221,7 +223,6 @@ "language/vi": "Vietnamees", "language/zh": "Chinees (vereenvoudigd)", "language/zh-TW": "Chinees (traditioneel)", - "Latest Messages": "Laatste berichten", "Let others add options": "Laat anderen opties toevoegen", "Limit votes per person": "Stemmen per persoon beperken", "live": "live", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 169f0557bd..e357840fde 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -19,6 +19,9 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} e {{ user }} estão digitando...", "{{ users }} and more are typing...": "{{ users }} e mais estão digitando...", "{{ watcherCount }} online": "{{ watcherCount }} online", + "{{count}} new messages_one": "{{count}} nova mensagem", + "{{count}} new messages_many": "{{count}} novas mensagens", + "{{count}} new messages_other": "{{count}} novas mensagens", "{{count}} unread_one": "{{count}} não lido", "{{count}} unread_many": "{{count}} não lidos", "{{count}} unread_other": "{{count}} não lidos", @@ -226,7 +229,6 @@ "language/vi": "Vietnamita", "language/zh": "Chinês (simplificado)", "language/zh-TW": "Chinês (tradicional)", - "Latest Messages": "Mensagens mais recentes", "Let others add options": "Permitir que outros adicionem opções", "Limit votes per person": "Limitar votos por pessoa", "live": "ao vivo", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 45d91a152b..9a99574f22 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -22,6 +22,10 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} и {{ user }} печатают...", "{{ users }} and more are typing...": "{{ users }} и другие печатают...", "{{ watcherCount }} online": "{{ watcherCount }} в сети", + "{{count}} new messages_one": "{{count}} новое сообщение", + "{{count}} new messages_few": "{{count}} новых сообщения", + "{{count}} new messages_many": "{{count}} новых сообщений", + "{{count}} new messages_other": "{{count}} новых сообщений", "{{count}} unread_one": "{{count}} непрочитанное", "{{count}} unread_few": "{{count}} непрочитанных", "{{count}} unread_many": "{{count}} непрочитанных", @@ -231,7 +235,6 @@ "language/vi": "Вьетнамский", "language/zh": "Китайский (упрощённый)", "language/zh-TW": "Китайский (традиционный)", - "Latest Messages": "Последние сообщения", "Let others add options": "Разрешить другим добавлять варианты", "Limit votes per person": "Ограничить голоса на человека", "live": "В прямом эфире", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index ad73d5a64a..b0f4492333 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -16,6 +16,8 @@ "{{ users }} and {{ user }} are typing...": "{{ users }} ve {{ user }} yazıyor...", "{{ users }} and more are typing...": "{{ users }} ve diğerleri yazıyor...", "{{ watcherCount }} online": "{{ watcherCount }} çevrimiçi", + "{{count}} new messages_one": "{{count}} yeni mesaj", + "{{count}} new messages_other": "{{count}} yeni mesaj", "{{count}} unread_one": "{{count}} okunmamış", "{{count}} unread_other": "{{count}} okunmamış", "{{count}} votes_one": "{{count}} oy", @@ -221,7 +223,6 @@ "language/vi": "Vietnamca", "language/zh": "Çince (basitleştirilmiş)", "language/zh-TW": "Çince (geleneksel)", - "Latest Messages": "Son Mesajlar", "Let others add options": "Başkalarının seçenek eklemesine izin ver", "Limit votes per person": "Kişi başına oy sınırı", "live": "canlı", diff --git a/src/styling/index.scss b/src/styling/index.scss index db4b0db85c..d754e5b0d8 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -14,7 +14,8 @@ @use '../components/FileIcon/styling/FileIcon'; // Base components -@use '../components/Button/styling/Button'; +@use '../components/Badge/styling' as Badge; +@use '../components/Button/styling' as Button; @use '../components/Form/styling' as Form; @use '../components/Dialog/styling' as Dialog; @use '../components/Modal/styling' as Modal; From 67827f817ac8e8fd52eb6f8ce4021fce1ac23cd6 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 24 Feb 2026 17:45:11 +0100 Subject: [PATCH 2/7] chore: update dev_patterns skill --- .cursor/skills/dev-patterns/SKILL.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.cursor/skills/dev-patterns/SKILL.md b/.cursor/skills/dev-patterns/SKILL.md index 38b1e52d5f..0cd2930922 100644 --- a/.cursor/skills/dev-patterns/SKILL.md +++ b/.cursor/skills/dev-patterns/SKILL.md @@ -15,6 +15,7 @@ Apply when generating or modifying UI code in this repo. - **Location:** `src/components//styling/`. - **Required:** Each component styling folder has an `index.scss`. - **Registration:** Each `src/components//styling/index.scss` is imported in `src/styling/index.scss` with an alias. +- **Specificity:** Each component has own `.scss` file in the `src/components//styling` folder **Import order in `src/styling/index.scss`:** @@ -35,6 +36,17 @@ Apply when generating or modifying UI code in this repo. Source: `.ai/DEV_PATTERNS.md`. +## Translating quantities (plurals) + +- **Use plural suffixes only:** `_one`, `_other`, and `_few`, `_many` where the locale requires them. +- **Do not** add a standalone key (e.g. `"{{count}} new messages"`). Only add quantified variants: `"{{count}} new messages_one"`, `"{{count}} new messages_other"`, etc. +- Follow existing patterns in `src/i18n/` (e.g. `{{count}} unread_one`, `unreadMessagesSeparatorText_other`). +- Locale plural rules (CLDR): `en`, `de`, `nl`, `tr`, `hi`, `ko`, `ja` use `_one` + `_other`; `es`, `fr`, `it`, `pt` add `_many`; `ru` uses `_one`, `_few`, `_many`, `_other`. + ## Imports When importing from 'stream-chat' library, always import by library name (from 'stream-chat'), not relative path (from '..path/to/from 'stream-chat-js/src'). + +## React components + +Try to avoid inline `style` attribute and prefer adding styles to `.scss` files. From ed3a06bec50826e6e0efd7cf012f9d5ff09a93b2 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 25 Feb 2026 10:56:39 +0100 Subject: [PATCH 3/7] feat: redesign UnreadMessagesNotification and UnreadMessagesSeparator --- src/components/Button/styling/Button.scss | 2 +- .../styling/UnreadMessageNotification.scss | 36 ---------------- .../styling/UnreadMessagesSeparator.scss | 16 ------- src/components/Message/styling/index.scss | 2 - .../UnreadMessagesNotification.tsx | 35 ++++++++++------ .../MessageList/UnreadMessagesSeparator.tsx | 29 ++++++++++--- src/components/MessageList/icons.tsx | 29 ------------- .../styling/NewMessageNotification.scss | 2 - .../styling/UnreadMessageNotification.scss | 42 +++++++++++++++++++ .../styling/UnreadMessagesSeparator.scss | 34 +++++++++++++++ src/components/MessageList/styling/index.scss | 2 + src/i18n/de.json | 2 - src/i18n/en.json | 2 - src/i18n/es.json | 3 -- src/i18n/fr.json | 3 -- src/i18n/hi.json | 2 - src/i18n/it.json | 3 -- src/i18n/ja.json | 2 - src/i18n/ko.json | 2 - src/i18n/nl.json | 2 - src/i18n/pt.json | 3 -- src/i18n/ru.json | 4 -- src/i18n/tr.json | 2 - 23 files changed, 124 insertions(+), 135 deletions(-) delete mode 100644 src/components/Message/styling/UnreadMessageNotification.scss delete mode 100644 src/components/Message/styling/UnreadMessagesSeparator.scss delete mode 100644 src/components/MessageList/icons.tsx create mode 100644 src/components/MessageList/styling/UnreadMessageNotification.scss create mode 100644 src/components/MessageList/styling/UnreadMessagesSeparator.scss diff --git a/src/components/Button/styling/Button.scss b/src/components/Button/styling/Button.scss index 68082c5a42..4d331e6774 100644 --- a/src/components/Button/styling/Button.scss +++ b/src/components/Button/styling/Button.scss @@ -5,7 +5,7 @@ @include utils.button-reset; position: relative; /* creates positioning context for pseudo ::after overlay */ overflow: hidden; - + white-space: nowrap; cursor: pointer; display: flex; diff --git a/src/components/Message/styling/UnreadMessageNotification.scss b/src/components/Message/styling/UnreadMessageNotification.scss deleted file mode 100644 index 763071b06f..0000000000 --- a/src/components/Message/styling/UnreadMessageNotification.scss +++ /dev/null @@ -1,36 +0,0 @@ -.str-chat__unread-messages-notification { - --str-chat-icon-color: var(--str-chat__on-primary-color); - background-color: var(--str-chat__text-low-emphasis-color); - border-radius: var(--str-chat__border-radius-2xl); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - position: absolute; - top: var(--str-chat__spacing-3); - z-index: 2; - display: flex; - align-items: center; - overflow: clip; - - button { - padding-block: var(--str-chat__spacing-2); - height: 100%; - width: 100%; - white-space: nowrap; - cursor: pointer; - color: var(--str-chat__on-primary-color); - border: none; - background-color: transparent; - } - - button:first-of-type { - padding-inline: var(--str-chat__spacing-3) var(--str-chat__spacing-2); - font: var(--str-chat__caption-text); - } - - button:last-of-type { - padding-inline: var(--str-chat__spacing-2) var(--str-chat__spacing-3); - - svg { - width: 0.875rem; - } - } -} diff --git a/src/components/Message/styling/UnreadMessagesSeparator.scss b/src/components/Message/styling/UnreadMessagesSeparator.scss deleted file mode 100644 index db822b1de3..0000000000 --- a/src/components/Message/styling/UnreadMessagesSeparator.scss +++ /dev/null @@ -1,16 +0,0 @@ -.str-chat__unread-messages-separator-wrapper { - padding-block: var(--str-chat__spacing-2); - - .str-chat__unread-messages-separator { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - padding: var(--str-chat__spacing-2) var(--str-chat__spacing-3); - background-color: var(--str-chat__secondary-surface-color); - color: var(--str-chat__text-low-emphasis-color); - text-transform: uppercase; - font: var(--str-chat__caption-strong-text); - letter-spacing: 0.02em; - } -} diff --git a/src/components/Message/styling/index.scss b/src/components/Message/styling/index.scss index dcf4ae3d8f..026268bbdf 100644 --- a/src/components/Message/styling/index.scss +++ b/src/components/Message/styling/index.scss @@ -7,6 +7,4 @@ @use 'MessageTranslationIndicator'; @use 'QuotedMessage'; @use 'ReminderNotification'; -@use 'UnreadMessageNotification'; -@use 'UnreadMessagesSeparator'; @use 'MessageRepliesCountButton'; diff --git a/src/components/MessageList/UnreadMessagesNotification.tsx b/src/components/MessageList/UnreadMessagesNotification.tsx index 0e4536820a..c2e675dab1 100644 --- a/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/src/components/MessageList/UnreadMessagesNotification.tsx @@ -1,6 +1,8 @@ import React from 'react'; -import { CloseIcon } from './icons'; import { useChannelActionContext, useTranslationContext } from '../../context'; +import { Button } from '../Button'; +import { IconArrowUp, IconCrossMedium } from '../Icons'; +import clsx from 'clsx'; export type UnreadMessagesNotificationProps = { /** @@ -8,7 +10,7 @@ export type UnreadMessagesNotificationProps = { */ queryMessageLimit?: number; /** - * Configuration parameter to determine, whether the unread count is to be shown on the component. Disabled by default. + * Configuration parameter to determine, whether the unread count is to be shown on the component. Enabled by default. */ showCount?: boolean; /** @@ -19,27 +21,34 @@ export type UnreadMessagesNotificationProps = { export const UnreadMessagesNotification = ({ queryMessageLimit, - showCount, + showCount = true, unreadCount, }: UnreadMessagesNotificationProps) => { - const { jumpToFirstUnreadMessage, markRead } = useChannelActionContext( - 'UnreadMessagesNotification', - ); + const { jumpToFirstUnreadMessage, markRead } = useChannelActionContext(); const { t } = useTranslationContext('UnreadMessagesNotification'); return (
- - + +
); }; diff --git a/src/components/MessageList/UnreadMessagesSeparator.tsx b/src/components/MessageList/UnreadMessagesSeparator.tsx index 0d13e6c54e..245481ed39 100644 --- a/src/components/MessageList/UnreadMessagesSeparator.tsx +++ b/src/components/MessageList/UnreadMessagesSeparator.tsx @@ -1,11 +1,14 @@ import React from 'react'; -import { useTranslationContext } from '../../context'; +import { useChannelActionContext, useTranslationContext } from '../../context'; +import { Button } from '../Button'; +import clsx from 'clsx'; +import { IconCrossMedium } from '../Icons'; export const UNREAD_MESSAGE_SEPARATOR_CLASS = 'str-chat__unread-messages-separator'; export type UnreadMessagesSeparatorProps = { /** - * Configuration parameter to determine, whether the unread count is to be shown on the component. Disabled by default. + * Configuration parameter to determine, whether the unread count is to be shown on the component. Enabled by default. */ showCount?: boolean; /** @@ -15,18 +18,32 @@ export type UnreadMessagesSeparatorProps = { }; export const UnreadMessagesSeparator = ({ - showCount, + showCount = true, unreadCount, }: UnreadMessagesSeparatorProps) => { const { t } = useTranslationContext('UnreadMessagesSeparator'); + const { markRead } = useChannelActionContext(); return (
- {unreadCount && showCount - ? t('unreadMessagesSeparatorText', { count: unreadCount }) - : t('Unread messages')} +
+ {unreadCount && showCount + ? t('{{count}} unread', { count: unreadCount }) + : t('Unread messages')} +
+
); }; diff --git a/src/components/MessageList/icons.tsx b/src/components/MessageList/icons.tsx deleted file mode 100644 index 68b52c3d68..0000000000 --- a/src/components/MessageList/icons.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; - -interface ArrowProps { - className?: string; - color?: string; -} - -export const ArrowUp = ({ className, color }: ArrowProps) => ( - - - -); - -export const CloseIcon = () => ( - - - -); diff --git a/src/components/MessageList/styling/NewMessageNotification.scss b/src/components/MessageList/styling/NewMessageNotification.scss index 75e3331a05..459878fa10 100644 --- a/src/components/MessageList/styling/NewMessageNotification.scss +++ b/src/components/MessageList/styling/NewMessageNotification.scss @@ -20,5 +20,3 @@ color: var(--chat-text-system); } } - - diff --git a/src/components/MessageList/styling/UnreadMessageNotification.scss b/src/components/MessageList/styling/UnreadMessageNotification.scss new file mode 100644 index 0000000000..9e572b80c8 --- /dev/null +++ b/src/components/MessageList/styling/UnreadMessageNotification.scss @@ -0,0 +1,42 @@ +.str-chat { + .str-chat__unread-messages-notification { + display: flex; + align-items: center; + background: var(--background-elevation-elevation-1); + border-radius: var(--button-radius-lg); + border: 1px solid var(--button-secondary-border); + /* shadow/web/light/elevation-2 */ + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 2px 4px 0 rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.06); + position: absolute; + top: 40px; + z-index: 2; + overflow: clip; + + &.str-chat__unread-messages-notification--with-count button { + text-transform: lowercase; + } + + button.str-chat__button--secondary, button.button.str-chat__button--outline { + border: none; + } + + button:first-of-type { + display: flex; + align-items: center; + gap: var(--spacing-xs); + border-radius: var(--button-radius-lg) 0 0 var(--button-radius-lg); + padding: var(--button-padding-y-md) var(--spacing-xxs) var(--button-padding-y-md) var(--button-padding-x-with-label-md); + font: var(--str-chat__caption-emphasis-text); + } + + button:last-of-type { + border-radius: 0 var(--button-radius-lg) var(--button-radius-lg) 0; + padding: var(--button-padding-y-md) var(--button-padding-x-with-label-md) var(--button-padding-y-md) var(--spacing-xxs); + + svg { + height: 16px; + width: 16px; + } + } + } +} diff --git a/src/components/MessageList/styling/UnreadMessagesSeparator.scss b/src/components/MessageList/styling/UnreadMessagesSeparator.scss new file mode 100644 index 0000000000..9a1aeb041a --- /dev/null +++ b/src/components/MessageList/styling/UnreadMessagesSeparator.scss @@ -0,0 +1,34 @@ +.str-chat__unread-messages-separator-wrapper { + padding-block: var(--spacing-xs); + display: flex; + justify-content: center; + + .str-chat__unread-messages-separator { + display: flex; + align-items: center; + width: fit-content; + padding: var(--spacing-xxs) var(--spacing-xs); + background: var(--background-elevation-elevation-1); + border-radius: var(--button-radius-lg); + border: 1px solid var(--button-secondary-border); + overflow: clip; + + .str-chat__unread-messages-separator__text { + padding-inline: var(--spacing-xs) var(--spacing-xxs); + border-radius: var(--button-radius-lg) 0 0 var(--button-radius-lg); + font: var(--str-chat__caption-emphasis-text); + text-transform: lowercase; + } + + button.str-chat__button--secondary, button.button.str-chat__button--outline { + border: none; + } + + button { + svg { + height: 16px; + width: 16px; + } + } + } +} diff --git a/src/components/MessageList/styling/index.scss b/src/components/MessageList/styling/index.scss index 8b516031a7..927c8d2039 100644 --- a/src/components/MessageList/styling/index.scss +++ b/src/components/MessageList/styling/index.scss @@ -1,4 +1,6 @@ @use 'MessageList'; @use 'NewMessageNotification'; @use 'ScrollToLatestMessageButton'; +@use 'UnreadMessageNotification'; +@use 'UnreadMessagesSeparator'; @use 'VirtualizedMessageList'; diff --git a/src/i18n/de.json b/src/i18n/de.json index b692127759..59c4ad73ec 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -365,8 +365,6 @@ "unmute-command-description": "Stummschaltung eines Benutzers aufheben", "Unpin": "Anheftung aufheben", "Unread messages": "Ungelesene Nachrichten", - "unreadMessagesSeparatorText_one": "1 ungelesene Nachricht", - "unreadMessagesSeparatorText_other": "{{count}} ungelesene Nachrichten", "Unsupported attachment": "Nicht unterstützter Anhang", "unsupported file type": "Nicht unterstützter Dateityp", "Update your comment": "Ihren Kommentar aktualisieren", diff --git a/src/i18n/en.json b/src/i18n/en.json index b691697f19..5cd958a7a9 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -365,8 +365,6 @@ "unmute-command-description": "Unmute a user", "Unpin": "Unpin", "Unread messages": "Unread messages", - "unreadMessagesSeparatorText_one": "1 unread message", - "unreadMessagesSeparatorText_other": "{{count}} unread messages", "Unsupported attachment": "Unsupported attachment", "unsupported file type": "unsupported file type", "Update your comment": "Update your comment", diff --git a/src/i18n/es.json b/src/i18n/es.json index 2b73bf53e6..ea26aae3de 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -375,9 +375,6 @@ "unmute-command-description": "Desactivar el silencio de un usuario", "Unpin": "Desfijar", "Unread messages": "Mensajes no leídos", - "unreadMessagesSeparatorText_one": "1 mensaje no leído", - "unreadMessagesSeparatorText_many": "{{count}} mensajes no leídos", - "unreadMessagesSeparatorText_other": "{{count}} mensajes no leídos", "Unsupported attachment": "Adjunto no compatible", "unsupported file type": "tipo de archivo no compatible", "Update your comment": "Actualizar tu comentario", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 1135025b48..8acf3f7183 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -375,9 +375,6 @@ "unmute-command-description": "Démuter un utilisateur", "Unpin": "Détacher", "Unread messages": "Messages non lus", - "unreadMessagesSeparatorText_one": "1 message non lu", - "unreadMessagesSeparatorText_many": "{{count}} messages non lus", - "unreadMessagesSeparatorText_other": "{{count}} messages non lus", "Unsupported attachment": "Pièce jointe non prise en charge", "unsupported file type": "type de fichier non pris en charge", "Update your comment": "Mettre à jour votre commentaire", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 4778c5559a..97968505cb 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -366,8 +366,6 @@ "unmute-command-description": "एक उपयोगकर्ता को अनम्यूट करें", "Unpin": "अनपिन", "Unread messages": "अपठित संदेश", - "unreadMessagesSeparatorText_one": "1 अपठित संदेश", - "unreadMessagesSeparatorText_other": "{{count}} अपठित संदेश", "Unsupported attachment": "असमर्थित अटैचमेंट", "unsupported file type": "असमर्थित फ़ाइल प्रकार", "Update your comment": "अपने टिप्पणी को अपडेट करें", diff --git a/src/i18n/it.json b/src/i18n/it.json index 4c41c15b71..f3cc7a0a67 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -375,9 +375,6 @@ "unmute-command-description": "Togliere il silenzio a un utente", "Unpin": "Sblocca", "Unread messages": "Messaggi non letti", - "unreadMessagesSeparatorText_one": "1 messaggio non letto", - "unreadMessagesSeparatorText_many": "{{count}} messaggi non letti", - "unreadMessagesSeparatorText_other": "{{count}} messaggi non letti", "Unsupported attachment": "Allegato non supportato", "unsupported file type": "tipo di file non supportato", "Update your comment": "Aggiorna il tuo commento", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 718d7d675b..34653fc63c 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -365,8 +365,6 @@ "unmute-command-description": "ユーザーのミュートを解除する", "Unpin": "ピンを解除する", "Unread messages": "未読メッセージ", - "unreadMessagesSeparatorText_one": "未読メッセージ 1 件", - "unreadMessagesSeparatorText_other": "未読メッセージ {{count}} 件", "Unsupported attachment": "サポートされていない添付ファイル", "unsupported file type": "サポートされていないファイル形式", "Update your comment": "コメントを更新", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 144e355386..8ae0294b8f 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -365,8 +365,6 @@ "unmute-command-description": "사용자 음소거 해제", "Unpin": "핀 해제", "Unread messages": "읽지 않은 메시지", - "unreadMessagesSeparatorText_one": "읽지 않은 메시지 1개", - "unreadMessagesSeparatorText_other": "읽지 않은 메시지 {{count}}개", "Unsupported attachment": "지원되지 않는 첨부 파일", "unsupported file type": "지원되지 않는 파일 형식", "Update your comment": "댓글 업데이트", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 2d6b13e92b..2d90092c1b 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -367,8 +367,6 @@ "unmute-command-description": "Een gebruiker niet meer dempen", "Unpin": "Losmaken", "Unread messages": "Ongelezen berichten", - "unreadMessagesSeparatorText_one": "1 ongelezen bericht", - "unreadMessagesSeparatorText_other": "{{count}} ongelezen berichten", "Unsupported attachment": "Niet-ondersteunde bijlage", "unsupported file type": "niet-ondersteund bestandstype", "Update your comment": "Werk je opmerking bij", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index e357840fde..eb72ab646c 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -375,9 +375,6 @@ "unmute-command-description": "Retirar o silenciamento de um usuário", "Unpin": "Desfixar", "Unread messages": "Mensagens não lidas", - "unreadMessagesSeparatorText_one": "1 mensagem não lida", - "unreadMessagesSeparatorText_many": "{{count}} mensagens não lidas", - "unreadMessagesSeparatorText_other": "{{count}} mensagens não lidas", "Unsupported attachment": "Anexo não suportado", "unsupported file type": "tipo de arquivo não suportado", "Update your comment": "Atualizar seu comentário", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 9a99574f22..052bf84446 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -385,10 +385,6 @@ "unmute-command-description": "Включить микрофон у пользователя", "Unpin": "Открепить", "Unread messages": "Непрочитанные сообщения", - "unreadMessagesSeparatorText_one": "1 непрочитанное сообщение", - "unreadMessagesSeparatorText_few": "1 непрочитанное сообщения", - "unreadMessagesSeparatorText_many": "{{count}} непрочитанных сообщений", - "unreadMessagesSeparatorText_other": "{{count}} непрочитанных сообщений", "Unsupported attachment": "Неподдерживаемое вложение", "unsupported file type": "неподдерживаемый тип файла", "Update your comment": "Обновите ваш комментарий", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index b0f4492333..546b9a9ff5 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -365,8 +365,6 @@ "unmute-command-description": "Bir kullanıcının sesini aç", "Unpin": "Sabitlemeyi kaldır", "Unread messages": "Okunmamış mesajlar", - "unreadMessagesSeparatorText_one": "1 okunmamış mesaj", - "unreadMessagesSeparatorText_other": "{{count}} okunmamış mesaj", "Unsupported attachment": "Desteklenmeyen ek", "unsupported file type": "desteklenmeyen dosya türü", "Update your comment": "Yorumunuzu güncelleyin", From 034186944c75d4fe047899347641fca04b4f89d4 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 25 Feb 2026 11:46:15 +0100 Subject: [PATCH 4/7] fix: display the UnreadMessageNotification reliably in MessageList --- src/components/MessageList/MessageList.tsx | 1 + .../useUnreadMessagesNotification.ts | 46 ++++++++++++++----- .../styling/UnreadMessageNotification.scss | 14 ++++-- .../styling/UnreadMessagesSeparator.scss | 5 +- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 099db38dc6..240364bae3 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -121,6 +121,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { const { show: showUnreadMessagesNotification } = useUnreadMessagesNotification({ isMessageListScrolledToBottom, + listElement, showAlways: !!showUnreadNotificationAlways, unreadCount: channelUnreadUiState?.unread_messages, }); diff --git a/src/components/MessageList/hooks/MessageList/useUnreadMessagesNotification.ts b/src/components/MessageList/hooks/MessageList/useUnreadMessagesNotification.ts index 466e360999..17dafb7b15 100644 --- a/src/components/MessageList/hooks/MessageList/useUnreadMessagesNotification.ts +++ b/src/components/MessageList/hooks/MessageList/useUnreadMessagesNotification.ts @@ -3,9 +3,13 @@ import { useEffect, useRef, useState } from 'react'; import { MESSAGE_LIST_MAIN_PANEL_CLASS } from '../../MessageListMainPanel'; import { UNREAD_MESSAGE_SEPARATOR_CLASS } from '../../UnreadMessagesSeparator'; -const targetScrolledAboveVisibleContainerArea = (element: Element) => { +const targetScrolledAboveVisibleContainerArea = ( + element: Element, + container?: Element, +) => { const { bottom: targetBottom } = element.getBoundingClientRect(); - return targetBottom < 0; + const containerTop = container?.getBoundingClientRect().top ?? 0; + return targetBottom < containerTop; }; const targetScrolledBelowVisibleContainerArea = ( @@ -13,11 +17,13 @@ const targetScrolledBelowVisibleContainerArea = ( container: Element, ) => { const { top: targetTop } = element.getBoundingClientRect(); - const { top: containerBottom } = container.getBoundingClientRect(); + const { bottom: containerBottom } = container.getBoundingClientRect(); return targetTop > containerBottom; }; export type UseUnreadMessagesNotificationParams = { + /** Scroll container (the element with overflow that actually scrolls). When provided, used as IntersectionObserver root and for initial visibility. */ + listElement: HTMLDivElement | null; isMessageListScrolledToBottom: boolean; showAlways: boolean; unreadCount?: number; @@ -25,6 +31,7 @@ export type UseUnreadMessagesNotificationParams = { export const useUnreadMessagesNotification = ({ isMessageListScrolledToBottom, + listElement, showAlways, unreadCount, }: UseUnreadMessagesNotificationParams) => { @@ -39,8 +46,20 @@ export const useUnreadMessagesNotification = ({ return; } - const [msgListPanel] = document.getElementsByClassName(MESSAGE_LIST_MAIN_PANEL_CLASS); - if (!msgListPanel) return; + const scrollRoot = listElement ?? null; + if (!scrollRoot) { + const [msgListPanel] = document.getElementsByClassName( + MESSAGE_LIST_MAIN_PANEL_CLASS, + ); + if (!msgListPanel) return; + const [observedTarget] = document.getElementsByClassName( + UNREAD_MESSAGE_SEPARATOR_CLASS, + ); + if (!observedTarget) { + setShow(true); + } + return; + } const [observedTarget] = document.getElementsByClassName( UNREAD_MESSAGE_SEPARATOR_CLASS, @@ -50,11 +69,13 @@ export const useUnreadMessagesNotification = ({ return; } - const scrolledBelowSeparator = - targetScrolledAboveVisibleContainerArea(observedTarget); + const scrolledBelowSeparator = targetScrolledAboveVisibleContainerArea( + observedTarget, + scrollRoot, + ); const scrolledAboveSeparator = targetScrolledBelowVisibleContainerArea( observedTarget, - msgListPanel, + scrollRoot, ); setShow( @@ -66,16 +87,18 @@ export const useUnreadMessagesNotification = ({ const observer = new IntersectionObserver( (elements) => { if (!elements.length) return; - const { boundingClientRect, isIntersecting } = elements[0]; + const entry = elements[0]; + const { boundingClientRect, isIntersecting, rootBounds } = entry; if (isIntersecting) { setShow(false); return; } - const separatorIsAboveContainerTop = boundingClientRect.bottom < 0; + const rootTop = rootBounds?.top ?? 0; + const separatorIsAboveContainerTop = boundingClientRect.bottom < rootTop; setShow(showAlways || separatorIsAboveContainerTop); isScrolledAboveTargetTop.current = separatorIsAboveContainerTop; }, - { root: msgListPanel }, + { root: scrollRoot }, ); observer.observe(observedTarget); @@ -84,6 +107,7 @@ export const useUnreadMessagesNotification = ({ }; }, [ intersectionObserverIsSupported, + listElement, isMessageListScrolledToBottom, messages, showAlways, diff --git a/src/components/MessageList/styling/UnreadMessageNotification.scss b/src/components/MessageList/styling/UnreadMessageNotification.scss index 9e572b80c8..d8c4c49926 100644 --- a/src/components/MessageList/styling/UnreadMessageNotification.scss +++ b/src/components/MessageList/styling/UnreadMessageNotification.scss @@ -6,7 +6,10 @@ border-radius: var(--button-radius-lg); border: 1px solid var(--button-secondary-border); /* shadow/web/light/elevation-2 */ - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 2px 4px 0 rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.06); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.05), + 0 2px 4px 0 rgba(0, 0, 0, 0.12), + 0 6px 16px 0 rgba(0, 0, 0, 0.06); position: absolute; top: 40px; z-index: 2; @@ -16,7 +19,8 @@ text-transform: lowercase; } - button.str-chat__button--secondary, button.button.str-chat__button--outline { + button.str-chat__button--secondary, + button.button.str-chat__button--outline { border: none; } @@ -25,13 +29,15 @@ align-items: center; gap: var(--spacing-xs); border-radius: var(--button-radius-lg) 0 0 var(--button-radius-lg); - padding: var(--button-padding-y-md) var(--spacing-xxs) var(--button-padding-y-md) var(--button-padding-x-with-label-md); + padding: var(--button-padding-y-md) var(--spacing-xxs) var(--button-padding-y-md) + var(--button-padding-x-with-label-md); font: var(--str-chat__caption-emphasis-text); } button:last-of-type { border-radius: 0 var(--button-radius-lg) var(--button-radius-lg) 0; - padding: var(--button-padding-y-md) var(--button-padding-x-with-label-md) var(--button-padding-y-md) var(--spacing-xxs); + padding: var(--button-padding-y-md) var(--button-padding-x-with-label-md) + var(--button-padding-y-md) var(--spacing-xxs); svg { height: 16px; diff --git a/src/components/MessageList/styling/UnreadMessagesSeparator.scss b/src/components/MessageList/styling/UnreadMessagesSeparator.scss index 9a1aeb041a..f1306fde7a 100644 --- a/src/components/MessageList/styling/UnreadMessagesSeparator.scss +++ b/src/components/MessageList/styling/UnreadMessagesSeparator.scss @@ -7,7 +7,7 @@ display: flex; align-items: center; width: fit-content; - padding: var(--spacing-xxs) var(--spacing-xs); + padding: var(--spacing-xxs) var(--spacing-xs); background: var(--background-elevation-elevation-1); border-radius: var(--button-radius-lg); border: 1px solid var(--button-secondary-border); @@ -20,7 +20,8 @@ text-transform: lowercase; } - button.str-chat__button--secondary, button.button.str-chat__button--outline { + button.str-chat__button--secondary, + button.button.str-chat__button--outline { border: none; } From 80b6989ed26fd59bb834d2dbec1ff903cadf9677 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 25 Feb 2026 14:39:05 +0100 Subject: [PATCH 5/7] feat(Button): add variant, appearance, circular, and size props Co-authored-by: Cursor --- src/components/Button/Button.tsx | 44 +++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 8637f1d209..f213cf260b 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -2,10 +2,41 @@ import type { ComponentProps } from 'react'; import { forwardRef } from 'react'; import clsx from 'clsx'; -export type ButtonProps = ComponentProps<'button'>; +export type ButtonVariant = 'primary' | 'secondary' | 'danger'; +export type ButtonAppearance = 'solid' | 'outline' | 'ghost'; +export type ButtonSize = 'lg' | 'md' | 'sm'; + +export type ButtonProps = ComponentProps<'button'> & { + /** Semantic variant: primary, secondary, or danger (maps to destructive in styles). */ + variant?: ButtonVariant; + /** Visual style: solid, outline, or ghost. */ + appearance?: ButtonAppearance; + /** When true, uses full border-radius for icon-only/pill shape. */ + circular?: boolean; + /** Size: lg, md, or sm. */ + size?: ButtonSize; +}; + +const variantToClass: Record = { + danger: 'str-chat__button--destructive', + primary: 'str-chat__button--primary', + secondary: 'str-chat__button--secondary', +}; + +const appearanceToClass: Record = { + ghost: 'str-chat__button--ghost', + outline: 'str-chat__button--outline', + solid: 'str-chat__button--solid', +}; + +const sizeToClass: Record = { + lg: 'str-chat__button--size-lg', + md: 'str-chat__button--size-md', + sm: 'str-chat__button--size-sm', +}; export const Button = forwardRef(function Button( - { className, ...props }, + { appearance, circular, className, size, variant, ...props }, ref, ) { return ( @@ -13,7 +44,14 @@ export const Button = forwardRef(function Button ref={ref} type='button' {...props} - className={clsx('str-chat__button', className)} + className={clsx( + 'str-chat__button', + variant != null && variantToClass[variant], + appearance != null && appearanceToClass[appearance], + circular && 'str-chat__button--circular', + size != null && sizeToClass[size], + className, + )} /> ); }); From 6c0c0eaeeb2d4b8edb8c1ff5003c9d85287c5419 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 25 Feb 2026 16:25:48 +0100 Subject: [PATCH 6/7] refactor(Button): use variant, appearance, size, circular props in consumers Co-authored-by: Cursor --- src/components/MessageInput/SendButton.tsx | 13 ++++------- .../Modal/CloseButtonOnModalOverlay.tsx | 9 +++----- .../Poll/PollActions/PollAction.tsx | 13 +++++------ .../PollCreationDialogControls.tsx | 1 - src/components/Reactions/ReactionSelector.tsx | 11 +++++++-- src/plugins/Emojis/EmojiPicker.tsx | 23 ++++++++++--------- 6 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/components/MessageInput/SendButton.tsx b/src/components/MessageInput/SendButton.tsx index b71fbb209d..4e750fe43d 100644 --- a/src/components/MessageInput/SendButton.tsx +++ b/src/components/MessageInput/SendButton.tsx @@ -3,7 +3,6 @@ import { useMessageComposerHasSendableData } from './hooks'; import { useTranslationContext } from '../../context'; import { IconPaperPlane } from '../Icons'; import { Button } from '../Button'; -import clsx from 'clsx'; export type SendButtonProps = { sendMessage: (event: React.BaseSyntheticEvent) => void; @@ -14,18 +13,16 @@ export const SendButton = ({ children, sendMessage, ...rest }: SendButtonProps) const hasSendableData = useMessageComposerHasSendableData(); return ( diff --git a/src/components/Poll/PollActions/PollAction.tsx b/src/components/Poll/PollActions/PollAction.tsx index e84a5096e2..34d5341eb8 100644 --- a/src/components/Poll/PollActions/PollAction.tsx +++ b/src/components/Poll/PollActions/PollAction.tsx @@ -31,14 +31,13 @@ export const PollAction = ({ return ( <> diff --git a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx index 1daa055d68..5385be8958 100644 --- a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx +++ b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx @@ -43,7 +43,6 @@ export const PollCreationDialogControls = ({ }) .catch(console.error); }} - type='submit' > {t('Send poll')} diff --git a/src/components/Reactions/ReactionSelector.tsx b/src/components/Reactions/ReactionSelector.tsx index d66e1fe30f..1ce9d519ce 100644 --- a/src/components/Reactions/ReactionSelector.tsx +++ b/src/components/Reactions/ReactionSelector.tsx @@ -5,6 +5,7 @@ import { useDialog } from '../Dialog'; import { defaultReactionOptions } from './reactionOptions'; import { useComponentContext } from '../../context/ComponentContext'; import { useMessageContext } from '../../context/MessageContext'; +import { Button } from '../Button'; import { IconPlusLarge } from '../Icons'; import type { ReactionResponse } from 'stream-chat'; @@ -73,9 +74,15 @@ const UnMemoizedReactionSelector = (props: ReactionSelectorProps) => { ))} - +
); }; diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index 3af1815c4c..974e74b9b6 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -9,7 +9,6 @@ import { useMessageComposer, } from '../../components'; import { usePopoverPosition } from '../../components/Dialog/hooks/usePopoverPosition'; -import clsx from 'clsx'; import { useIsCooldownActive } from '../../components/MessageInput/hooks/useIsCooldownActive'; const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; @@ -35,14 +34,12 @@ export type EmojiPickerProps = { popperOptions?: Partial<{ placement: PopperLikePlacement }>; }; -const classNames: EmojiPickerProps = { - buttonClassName: clsx( - 'str-chat__emoji-picker-button', - 'str-chat__button--ghost', - 'str-chat__button--secondary', - 'str-chat__button--size-sm', - 'str-chat__button--circular', - ), +const defaultButtonClassName = 'str-chat__emoji-picker-button'; + +const classNames: Pick< + EmojiPickerProps, + 'pickerContainerClassName' | 'wrapperClassName' +> = { pickerContainerClassName: 'str-chat__message-textarea-emoji-picker-container', wrapperClassName: 'str-chat__message-textarea-emoji-picker', }; @@ -68,7 +65,7 @@ export const EmojiPicker = (props: EmojiPickerProps) => { refs.setFloating(popperElement); }, [popperElement, refs]); - const { buttonClassName, pickerContainerClassName, wrapperClassName } = classNames; + const { pickerContainerClassName, wrapperClassName } = classNames; const { ButtonIconComponent = IconEmojiSmile } = props; @@ -118,13 +115,17 @@ export const EmojiPicker = (props: EmojiPickerProps) => {
)} From 3f8c19b3d9b72d819c8d85aa84ce496818fa956e Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 25 Feb 2026 16:59:24 +0100 Subject: [PATCH 7/7] feat: introduce button variant props --- .../Attachment/AttachmentActions.tsx | 4 +- .../components/PlaybackRateButton.tsx | 1 - src/components/Button/PlayButton.tsx | 15 +++--- src/components/ChatView/ChatView.tsx | 11 ++--- src/components/Dialog/components/Callout.tsx | 13 ++---- src/components/Dialog/components/Prompt.tsx | 46 ++++++++----------- src/components/Form/NumericInput.tsx | 14 +++--- .../AudioRecorderRecordingControls.tsx | 37 ++++++--------- .../AudioRecordingButtonWithNotification.tsx | 13 ++---- .../AudioRecorder/AudioRecordingPlayback.tsx | 12 ++--- .../MessageActions/DeleteMessageAlert.tsx | 21 ++++----- .../MessageActions/MessageActions.tsx | 10 ++-- .../QuickMessageActionButton.tsx | 11 ++--- .../MessageBounce/MessageBouncePrompt.tsx | 31 +++++-------- .../MediaAttachmentPreview.tsx | 12 ++--- .../AttachmentSelector/AttachmentSelector.tsx | 13 ++---- .../RemoveAttachmentPreviewButton.tsx | 12 ++--- src/components/MessageInput/SendButton.tsx | 1 - .../ScrollToLatestMessageButton.tsx | 13 ++---- .../UnreadMessagesNotification.tsx | 8 ++-- .../MessageList/UnreadMessagesSeparator.tsx | 11 ++--- .../PollCreationDialog/OptionFieldSet.tsx | 12 ++--- .../PollCreationDialogControls.tsx | 1 + src/components/VideoPlayer/VideoThumbnail.tsx | 8 ++-- 24 files changed, 132 insertions(+), 198 deletions(-) diff --git a/src/components/Attachment/AttachmentActions.tsx b/src/components/Attachment/AttachmentActions.tsx index 938cd031ec..ced98d4cd9 100644 --- a/src/components/Attachment/AttachmentActions.tsx +++ b/src/components/Attachment/AttachmentActions.tsx @@ -70,10 +70,9 @@ const UnMemoizedAttachmentActions = (props: AttachmentActionsProps) => { {text} {actions.map((action, index) => ( diff --git a/src/components/Attachment/components/PlaybackRateButton.tsx b/src/components/Attachment/components/PlaybackRateButton.tsx index 008037cb69..503b5aee34 100644 --- a/src/components/Attachment/components/PlaybackRateButton.tsx +++ b/src/components/Attachment/components/PlaybackRateButton.tsx @@ -9,7 +9,6 @@ export const PlaybackRateButton = ({ children, onClick }: PlaybackRateButtonProp className={clsx('str-chat__message_attachment__playback-rate-button')} data-testid='playback-rate-button' onClick={onClick} - type='button' > {children} diff --git a/src/components/Button/PlayButton.tsx b/src/components/Button/PlayButton.tsx index 459f79014d..3e467a34f2 100644 --- a/src/components/Button/PlayButton.tsx +++ b/src/components/Button/PlayButton.tsx @@ -9,16 +9,13 @@ export type PlayButtonProps = ComponentProps<'button'> & { export const PlayButton = ({ className, isPlaying, ...props }: PlayButtonProps) => ( diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index 51053d7126..7e46393cc9 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -142,14 +142,11 @@ export const ChatViewSelectorButton = ({ ...props }: ButtonProps & { Icon?: ComponentType; text?: string }) => ( diff --git a/src/components/Dialog/components/Prompt.tsx b/src/components/Dialog/components/Prompt.tsx index 42ff6870fc..96de6b8b3e 100644 --- a/src/components/Dialog/components/Prompt.tsx +++ b/src/components/Dialog/components/Prompt.tsx @@ -27,14 +27,12 @@ const PromptHeader = ({
{goBack && ( @@ -47,14 +45,12 @@ const PromptHeader = ({
{close && ( @@ -88,27 +84,21 @@ const PromptFooterControls = ({ children, className }: PromptFooterControlsProps const PromptFooterControlsButtonSecondary = ({ className, ...props }: ButtonProps) => ( diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx index c2ccc4d71a..42f07c4efb 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx @@ -4,7 +4,6 @@ import React from 'react'; import { useMessageInputContext } from '../../../context'; import { isRecording } from './recordingStateIdentity'; import { Button } from '../../Button'; -import clsx from 'clsx'; const ToggleRecordingButton = () => { const { @@ -13,16 +12,14 @@ const ToggleRecordingButton = () => { return ( @@ -41,31 +38,27 @@ export const AudioRecorderRecordingControls = () => {
{!isRecording(recordingState) && ( )} diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx index 47581d3fd1..e7ba6596a5 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx @@ -5,7 +5,6 @@ import { useAttachmentManagerState } from '../../MessageInput'; import { useComponentContext, useMessageInputContext } from '../../../context'; import { Callout, useDialogOnNearestManager } from '../../Dialog'; import { Button } from '../../Button'; -import clsx from 'clsx'; import { IconMicrophone } from '../../Icons'; const dialogId = 'recording-permission-denied-notification'; @@ -66,15 +65,13 @@ export const DefaultStartRecordingAudioButton = forwardRef< >(function StartRecordingAudioButton(props, ref) { return ( diff --git a/src/components/MessageActions/DeleteMessageAlert.tsx b/src/components/MessageActions/DeleteMessageAlert.tsx index 254a72ef8d..deebb4dea0 100644 --- a/src/components/MessageActions/DeleteMessageAlert.tsx +++ b/src/components/MessageActions/DeleteMessageAlert.tsx @@ -1,6 +1,5 @@ import { Alert } from '../Dialog'; import { Button } from '../Button'; -import clsx from 'clsx'; import React from 'react'; import { useTranslationContext } from '../../context'; import type { ModalProps } from '../Modal'; @@ -22,26 +21,22 @@ export const DeleteMessageAlert = ({ onClose, onDelete }: DeleteMessageAlertProp /> diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx index dab95c2ffd..7e767d812f 100644 --- a/src/components/MessageActions/MessageActions.tsx +++ b/src/components/MessageActions/MessageActions.tsx @@ -98,20 +98,18 @@ export const MessageActions = ({ {dropdownActionSet.length > 0 && ( <> diff --git a/src/components/MessageActions/QuickMessageActionButton.tsx b/src/components/MessageActions/QuickMessageActionButton.tsx index 1cfe3d8195..249da7034b 100644 --- a/src/components/MessageActions/QuickMessageActionButton.tsx +++ b/src/components/MessageActions/QuickMessageActionButton.tsx @@ -4,13 +4,10 @@ import React from 'react'; export const QuickMessageActionsButton = ({ className, ...props }: ButtonProps) => ( diff --git a/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx index 4ace7b6f46..8cead490ea 100644 --- a/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/MediaAttachmentPreview.tsx @@ -109,16 +109,14 @@ export const MediaAttachmentPreview = ({ {hasRetriableError && ( diff --git a/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx b/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx index f0f71b2180..287972e604 100644 --- a/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx +++ b/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx @@ -65,15 +65,12 @@ export const AttachmentSelectorButton = forwardRef diff --git a/src/components/MessageInput/RemoveAttachmentPreviewButton.tsx b/src/components/MessageInput/RemoveAttachmentPreviewButton.tsx index aeee2f4d42..ae3964bf72 100644 --- a/src/components/MessageInput/RemoveAttachmentPreviewButton.tsx +++ b/src/components/MessageInput/RemoveAttachmentPreviewButton.tsx @@ -15,17 +15,15 @@ export const RemoveAttachmentPreviewButton = ({ const { t } = useTranslationContext(); return ( diff --git a/src/components/MessageInput/SendButton.tsx b/src/components/MessageInput/SendButton.tsx index 4e750fe43d..9c60655b88 100644 --- a/src/components/MessageInput/SendButton.tsx +++ b/src/components/MessageInput/SendButton.tsx @@ -21,7 +21,6 @@ export const SendButton = ({ children, sendMessage, ...rest }: SendButtonProps) disabled={!hasSendableData} onClick={sendMessage} size='sm' - type='button' variant='primary' {...rest} > diff --git a/src/components/MessageList/ScrollToLatestMessageButton.tsx b/src/components/MessageList/ScrollToLatestMessageButton.tsx index 25cb47687c..6d4b4cc9be 100644 --- a/src/components/MessageList/ScrollToLatestMessageButton.tsx +++ b/src/components/MessageList/ScrollToLatestMessageButton.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState } from 'react'; -import clsx from 'clsx'; import { useChannelStateContext, useChatContext } from '../../context'; @@ -86,16 +85,14 @@ const UnMemoizedScrollToLatestMessageButton = ( return (
diff --git a/src/components/MessageList/UnreadMessagesNotification.tsx b/src/components/MessageList/UnreadMessagesNotification.tsx index c2e675dab1..c49dcb7d26 100644 --- a/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/src/components/MessageList/UnreadMessagesNotification.tsx @@ -35,18 +35,16 @@ export const UnreadMessagesNotification = ({ data-testid='unread-messages-notification' > -
diff --git a/src/components/MessageList/UnreadMessagesSeparator.tsx b/src/components/MessageList/UnreadMessagesSeparator.tsx index 245481ed39..5dcc158d46 100644 --- a/src/components/MessageList/UnreadMessagesSeparator.tsx +++ b/src/components/MessageList/UnreadMessagesSeparator.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { useChannelActionContext, useTranslationContext } from '../../context'; import { Button } from '../Button'; -import clsx from 'clsx'; import { IconCrossMedium } from '../Icons'; export const UNREAD_MESSAGE_SEPARATOR_CLASS = 'str-chat__unread-messages-separator'; @@ -34,13 +33,11 @@ export const UnreadMessagesSeparator = ({ : t('Unread messages')}
diff --git a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx index afc0b56da1..712ffacb4f 100644 --- a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx +++ b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx @@ -114,13 +114,11 @@ export const OptionFieldSet = () => { const RemoveOptionButton = ({ className, ...props }: ButtonProps) => (