From 081b8d0e6a1abf6c1cc615a571c7551bc8a78b5d Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 15 Apr 2026 11:06:21 +0200 Subject: [PATCH] fix: prevent hiding floating date separator in message lists --- examples/vite/src/App.tsx | 30 +++-- ...seFloatingDateSeparatorMessageList.test.ts | 107 ++++++++++++++++++ .../useFloatingDateSeparatorMessageList.ts | 23 ++-- .../useFloatingDateSeparator.test.ts | 11 +- .../useFloatingDateSeparator.ts | 31 +++-- 5 files changed, 148 insertions(+), 54 deletions(-) create mode 100644 src/components/MessageList/hooks/MessageList/__tests__/useFloatingDateSeparatorMessageList.test.ts diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index eb34c371dc..350063c761 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -2,35 +2,33 @@ import { type CSSProperties, useCallback, useEffect, useMemo, useRef } from 'rea import { ChannelFilters, ChannelOptions, - ChannelSort, - LocalMessage, - TextComposerMiddleware, - SearchController, ChannelSearchSource, - UserSearchSource, + ChannelSort, createActiveCommandGuardMiddleware, createCommandInjectionMiddleware, createCommandStringExtractionMiddleware, createDraftCommandInjectionMiddleware, + LocalMessage, + SearchController, + TextComposerMiddleware, + UserSearchSource, } from 'stream-chat'; import { Attachment, type AttachmentProps, - Button, Chat, ChatView, + defaultReactionOptions, DialogManagerProvider, + mapEmojiMartData, MessageReactions, - type NotificationListProps, NotificationList, - Streami18n, - WithComponents, - defaultReactionOptions, + type NotificationListProps, type ReactionOptions, - mapEmojiMartData, - useCreateChatClient, - useTranslationContext, Search, + Streami18n, + useCreateChatClient, + WithComponents, } from 'stream-chat-react'; import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis'; import { init, SearchIndex } from 'emoji-mart'; @@ -40,7 +38,7 @@ import { humanId } from 'human-id'; import { appSettingsStore, useAppSettingsSelector } from './AppSettings'; import { DESKTOP_LAYOUT_BREAKPOINT } from './ChatLayout/constants.ts'; import { ChannelsPanels, ThreadsPanels } from './ChatLayout/Panels.tsx'; -import { SidebarProvider, useSidebar } from './ChatLayout/SidebarContext.tsx'; +import { SidebarProvider } from './ChatLayout/SidebarContext.tsx'; import { ChatViewSelectorWidthSync, PanelLayoutStyleSync, @@ -56,15 +54,15 @@ import { SystemNotification } from './SystemNotification/SystemNotification.tsx' import { chatViewSelectorItemSet } from './Sidebar/ChatViewSelectorItemSet.tsx'; import { CustomAttachmentActions, - CustomSystemMessage, - SegmentedReactionsList, customReactionOptions, customReactionOptionsUpvote, + CustomSystemMessage, getAttachmentActionsVariant, getMessageUiComponent, getMessageUiVariant, getReactionsVariant, getSystemMessageVariant, + SegmentedReactionsList, } from './CustomMessageUi'; import { ConfigurableMessageActions } from './CustomMessageActions'; import { SidebarToggle } from './Sidebar/SidebarToggle.tsx'; diff --git a/src/components/MessageList/hooks/MessageList/__tests__/useFloatingDateSeparatorMessageList.test.ts b/src/components/MessageList/hooks/MessageList/__tests__/useFloatingDateSeparatorMessageList.test.ts new file mode 100644 index 0000000000..177fd7cebd --- /dev/null +++ b/src/components/MessageList/hooks/MessageList/__tests__/useFloatingDateSeparatorMessageList.test.ts @@ -0,0 +1,107 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useFloatingDateSeparatorMessageList } from '../useFloatingDateSeparatorMessageList'; +import type { RenderedMessage } from '../../../utils'; + +vi.mock('lodash.throttle', () => ({ + default: void>(fn: T) => { + const throttledBase = (...args: Parameters) => fn(...args); + const throttled = Object.assign(throttledBase, { + cancel: vi.fn(), + }) as T & { cancel: () => void }; + return throttled; + }, +})); + +const mockRect = (element: HTMLElement, top: number, bottom: number) => { + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + bottom, + height: bottom - top, + left: 0, + right: 0, + toJSON: () => ({}), + top, + width: 0, + x: 0, + y: top, + }); +}; + +const makeListElement = () => { + const listElement = document.createElement('div'); + + mockRect(listElement, 100, 500); + + return listElement; +}; + +const makeSeparator = (date: Date, top: number, bottom: number) => { + const separator = document.createElement('div'); + separator.className = 'str-chat__date-separator'; + separator.setAttribute('data-date', date.toISOString()); + mockRect(separator, top, bottom); + return separator; +}; + +const processedMessages = [{} as RenderedMessage]; + +describe('useFloatingDateSeparatorMessageList', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns hidden state when date separators are disabled', () => { + const listElement = makeListElement(); + listElement.appendChild(makeSeparator(new Date('2025-01-01T12:00:00Z'), 100, 120)); + + const { result } = renderHook(() => + useFloatingDateSeparatorMessageList({ + disableDateSeparator: true, + listElement, + processedMessages, + }), + ); + + expect(result.current.showFloatingDate).toBe(false); + expect(result.current.floatingDate).toBeNull(); + }); + + it('uses the separator that reached the top boundary', () => { + const jan1 = new Date('2025-01-01T12:00:00Z'); + const jan2 = new Date('2025-01-02T12:00:00Z'); + const listElement = makeListElement(); + + listElement.appendChild(makeSeparator(jan1, 40, 60)); + listElement.appendChild(makeSeparator(jan2, 100, 120)); + + const { result } = renderHook(() => + useFloatingDateSeparatorMessageList({ + disableDateSeparator: false, + listElement, + processedMessages, + }), + ); + + expect(result.current.showFloatingDate).toBe(true); + expect(result.current.floatingDate).toEqual(jan2); + }); + + it('stays hidden before the first inline separator reaches the top', () => { + const jan1 = new Date('2025-01-01T12:00:00Z'); + const listElement = makeListElement(); + + listElement.appendChild(makeSeparator(jan1, 120, 140)); + + const { result } = renderHook(() => + useFloatingDateSeparatorMessageList({ + disableDateSeparator: false, + listElement, + processedMessages, + }), + ); + + expect(result.current.showFloatingDate).toBe(false); + expect(result.current.floatingDate).toBeNull(); + }); +}); diff --git a/src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts b/src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts index 7434b479f6..13c70b8a62 100644 --- a/src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts +++ b/src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts @@ -19,8 +19,8 @@ export type UseFloatingDateSeparatorMessageListResult = { }; /** - * For non-virtualized MessageList: uses scroll + DOM query to find which date - * separator we've scrolled past. Shows floating date when none are visible. + * For non-virtualized MessageList: keeps the floating date synced with the + * separator currently pinned to the top boundary of the list viewport. */ export const useFloatingDateSeparatorMessageList = ({ disableDateSeparator, @@ -46,32 +46,25 @@ export const useFloatingDateSeparatorMessageList = ({ const containerRect = listElement.getBoundingClientRect(); let bestDate: Date | null = null; - let bestBottom = -Infinity; - let anyVisible = false; + let bestTop = -Infinity; 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; + const isAtOrAboveTopBoundary = rect.top <= containerRect.top; - if (isVisible) { - anyVisible = true; - } - - if (isAboveViewport && rect.bottom > bestBottom) { - bestBottom = rect.bottom; + if (isAtOrAboveTopBoundary && rect.top > bestTop) { + bestTop = rect.top; const d = new Date(dataDate); if (!isNaN(d.getTime())) bestDate = d; } } setState({ - date: anyVisible ? null : bestDate, - visible: !anyVisible && bestDate !== null, + date: bestDate, + visible: bestDate !== null, }); }, [disableDateSeparator, listElement, processedMessages]); diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/__tests__/useFloatingDateSeparator.test.ts b/src/components/MessageList/hooks/VirtualizedMessageList/__tests__/useFloatingDateSeparator.test.ts index 049d07e186..4d14265d02 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/__tests__/useFloatingDateSeparator.test.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/__tests__/useFloatingDateSeparator.test.ts @@ -48,7 +48,7 @@ describe('useFloatingDateSeparator', () => { expect(result.current.floatingDate).toBeNull(); }); - it('hides floating when first visible item is a date separator', () => { + it('shows floating with the first visible date separator value', () => { const { result } = renderHook(() => useFloatingDateSeparator({ disableDateSeparator: false, @@ -60,8 +60,8 @@ describe('useFloatingDateSeparator', () => { result.current.onItemsRendered([makeDateSeparator(jan1), makeMessage('m1', jan1)]); }); - expect(result.current.showFloatingDate).toBe(false); - expect(result.current.floatingDate).toBeNull(); + expect(result.current.showFloatingDate).toBe(true); + expect(result.current.floatingDate).toEqual(jan1); }); it('shows floating with correct date when first visible is a message', () => { @@ -80,7 +80,7 @@ describe('useFloatingDateSeparator', () => { expect(result.current.floatingDate).toEqual(jan1); }); - it('hides when any date separator is in visible set', () => { + it('keeps top group date when a later date separator is also visible', () => { const { result } = renderHook(() => useFloatingDateSeparator({ disableDateSeparator: false, @@ -96,6 +96,7 @@ describe('useFloatingDateSeparator', () => { ]); }); - expect(result.current.showFloatingDate).toBe(false); + expect(result.current.showFloatingDate).toBe(true); + expect(result.current.floatingDate).toEqual(jan1); }); }); diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts index 2730a8db81..7df9746a31 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts @@ -45,9 +45,19 @@ function getFloatingDateForFirstMessage( return null; } +function getFloatingDateForFirstItem( + firstItem: RenderedMessage, + processedMessages: RenderedMessage[], + firstItemIndex: number, +): Date | null { + if (isDateSeparatorMessage(firstItem)) return firstItem.date; + + return getFloatingDateForFirstMessage(firstItem, processedMessages, firstItemIndex); +} + /** - * 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. + * Controls the floating date separator as a sticky "current section" label. + * It follows the date separator represented by the first visible item. */ const HIDDEN_STATE = { date: null, visible: false } as const; @@ -74,25 +84,10 @@ export const useFloatingDateSeparator = ({ } 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) + ? getFloatingDateForFirstItem(first, processedMessages, firstIndex) : null; const visible = date !== null;