Skip to content

Commit 397fadd

Browse files
authored
fix: prevent hiding floating date separator in message lists (#3119)
1 parent d7870bb commit 397fadd

5 files changed

Lines changed: 148 additions & 54 deletions

File tree

examples/vite/src/App.tsx

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,33 @@ import { type CSSProperties, useCallback, useEffect, useMemo, useRef } from 'rea
22
import {
33
ChannelFilters,
44
ChannelOptions,
5-
ChannelSort,
6-
LocalMessage,
7-
TextComposerMiddleware,
8-
SearchController,
95
ChannelSearchSource,
10-
UserSearchSource,
6+
ChannelSort,
117
createActiveCommandGuardMiddleware,
128
createCommandInjectionMiddleware,
139
createCommandStringExtractionMiddleware,
1410
createDraftCommandInjectionMiddleware,
11+
LocalMessage,
12+
SearchController,
13+
TextComposerMiddleware,
14+
UserSearchSource,
1515
} from 'stream-chat';
1616
import {
1717
Attachment,
1818
type AttachmentProps,
19-
Button,
2019
Chat,
2120
ChatView,
21+
defaultReactionOptions,
2222
DialogManagerProvider,
23+
mapEmojiMartData,
2324
MessageReactions,
24-
type NotificationListProps,
2525
NotificationList,
26-
Streami18n,
27-
WithComponents,
28-
defaultReactionOptions,
26+
type NotificationListProps,
2927
type ReactionOptions,
30-
mapEmojiMartData,
31-
useCreateChatClient,
32-
useTranslationContext,
3328
Search,
29+
Streami18n,
30+
useCreateChatClient,
31+
WithComponents,
3432
} from 'stream-chat-react';
3533
import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis';
3634
import { init, SearchIndex } from 'emoji-mart';
@@ -40,7 +38,7 @@ import { humanId } from 'human-id';
4038
import { appSettingsStore, useAppSettingsSelector } from './AppSettings';
4139
import { DESKTOP_LAYOUT_BREAKPOINT } from './ChatLayout/constants.ts';
4240
import { ChannelsPanels, ThreadsPanels } from './ChatLayout/Panels.tsx';
43-
import { SidebarProvider, useSidebar } from './ChatLayout/SidebarContext.tsx';
41+
import { SidebarProvider } from './ChatLayout/SidebarContext.tsx';
4442
import {
4543
ChatViewSelectorWidthSync,
4644
PanelLayoutStyleSync,
@@ -56,15 +54,15 @@ import { SystemNotification } from './SystemNotification/SystemNotification.tsx'
5654
import { chatViewSelectorItemSet } from './Sidebar/ChatViewSelectorItemSet.tsx';
5755
import {
5856
CustomAttachmentActions,
59-
CustomSystemMessage,
60-
SegmentedReactionsList,
6157
customReactionOptions,
6258
customReactionOptionsUpvote,
59+
CustomSystemMessage,
6360
getAttachmentActionsVariant,
6461
getMessageUiComponent,
6562
getMessageUiVariant,
6663
getReactionsVariant,
6764
getSystemMessageVariant,
65+
SegmentedReactionsList,
6866
} from './CustomMessageUi';
6967
import { ConfigurableMessageActions } from './CustomMessageActions';
7068
import { SidebarToggle } from './Sidebar/SidebarToggle.tsx';
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { useFloatingDateSeparatorMessageList } from '../useFloatingDateSeparatorMessageList';
5+
import type { RenderedMessage } from '../../../utils';
6+
7+
vi.mock('lodash.throttle', () => ({
8+
default: <T extends (...args: never[]) => void>(fn: T) => {
9+
const throttledBase = (...args: Parameters<T>) => fn(...args);
10+
const throttled = Object.assign(throttledBase, {
11+
cancel: vi.fn(),
12+
}) as T & { cancel: () => void };
13+
return throttled;
14+
},
15+
}));
16+
17+
const mockRect = (element: HTMLElement, top: number, bottom: number) => {
18+
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
19+
bottom,
20+
height: bottom - top,
21+
left: 0,
22+
right: 0,
23+
toJSON: () => ({}),
24+
top,
25+
width: 0,
26+
x: 0,
27+
y: top,
28+
});
29+
};
30+
31+
const makeListElement = () => {
32+
const listElement = document.createElement('div');
33+
34+
mockRect(listElement, 100, 500);
35+
36+
return listElement;
37+
};
38+
39+
const makeSeparator = (date: Date, top: number, bottom: number) => {
40+
const separator = document.createElement('div');
41+
separator.className = 'str-chat__date-separator';
42+
separator.setAttribute('data-date', date.toISOString());
43+
mockRect(separator, top, bottom);
44+
return separator;
45+
};
46+
47+
const processedMessages = [{} as RenderedMessage];
48+
49+
describe('useFloatingDateSeparatorMessageList', () => {
50+
afterEach(() => {
51+
vi.restoreAllMocks();
52+
});
53+
54+
it('returns hidden state when date separators are disabled', () => {
55+
const listElement = makeListElement();
56+
listElement.appendChild(makeSeparator(new Date('2025-01-01T12:00:00Z'), 100, 120));
57+
58+
const { result } = renderHook(() =>
59+
useFloatingDateSeparatorMessageList({
60+
disableDateSeparator: true,
61+
listElement,
62+
processedMessages,
63+
}),
64+
);
65+
66+
expect(result.current.showFloatingDate).toBe(false);
67+
expect(result.current.floatingDate).toBeNull();
68+
});
69+
70+
it('uses the separator that reached the top boundary', () => {
71+
const jan1 = new Date('2025-01-01T12:00:00Z');
72+
const jan2 = new Date('2025-01-02T12:00:00Z');
73+
const listElement = makeListElement();
74+
75+
listElement.appendChild(makeSeparator(jan1, 40, 60));
76+
listElement.appendChild(makeSeparator(jan2, 100, 120));
77+
78+
const { result } = renderHook(() =>
79+
useFloatingDateSeparatorMessageList({
80+
disableDateSeparator: false,
81+
listElement,
82+
processedMessages,
83+
}),
84+
);
85+
86+
expect(result.current.showFloatingDate).toBe(true);
87+
expect(result.current.floatingDate).toEqual(jan2);
88+
});
89+
90+
it('stays hidden before the first inline separator reaches the top', () => {
91+
const jan1 = new Date('2025-01-01T12:00:00Z');
92+
const listElement = makeListElement();
93+
94+
listElement.appendChild(makeSeparator(jan1, 120, 140));
95+
96+
const { result } = renderHook(() =>
97+
useFloatingDateSeparatorMessageList({
98+
disableDateSeparator: false,
99+
listElement,
100+
processedMessages,
101+
}),
102+
);
103+
104+
expect(result.current.showFloatingDate).toBe(false);
105+
expect(result.current.floatingDate).toBeNull();
106+
});
107+
});

src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ export type UseFloatingDateSeparatorMessageListResult = {
1919
};
2020

2121
/**
22-
* For non-virtualized MessageList: uses scroll + DOM query to find which date
23-
* separator we've scrolled past. Shows floating date when none are visible.
22+
* For non-virtualized MessageList: keeps the floating date synced with the
23+
* separator currently pinned to the top boundary of the list viewport.
2424
*/
2525
export const useFloatingDateSeparatorMessageList = ({
2626
disableDateSeparator,
@@ -46,32 +46,25 @@ export const useFloatingDateSeparatorMessageList = ({
4646

4747
const containerRect = listElement.getBoundingClientRect();
4848
let bestDate: Date | null = null;
49-
let bestBottom = -Infinity;
50-
let anyVisible = false;
49+
let bestTop = -Infinity;
5150

5251
for (const el of separators) {
5352
const rect = el.getBoundingClientRect();
5453
const dataDate = el.getAttribute('data-date');
5554
if (!dataDate) continue;
5655

57-
const isAboveViewport = rect.bottom < containerRect.top;
58-
const isVisible =
59-
rect.top < containerRect.bottom && rect.bottom > containerRect.top;
56+
const isAtOrAboveTopBoundary = rect.top <= containerRect.top;
6057

61-
if (isVisible) {
62-
anyVisible = true;
63-
}
64-
65-
if (isAboveViewport && rect.bottom > bestBottom) {
66-
bestBottom = rect.bottom;
58+
if (isAtOrAboveTopBoundary && rect.top > bestTop) {
59+
bestTop = rect.top;
6760
const d = new Date(dataDate);
6861
if (!isNaN(d.getTime())) bestDate = d;
6962
}
7063
}
7164

7265
setState({
73-
date: anyVisible ? null : bestDate,
74-
visible: !anyVisible && bestDate !== null,
66+
date: bestDate,
67+
visible: bestDate !== null,
7568
});
7669
}, [disableDateSeparator, listElement, processedMessages]);
7770

src/components/MessageList/hooks/VirtualizedMessageList/__tests__/useFloatingDateSeparator.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ describe('useFloatingDateSeparator', () => {
4848
expect(result.current.floatingDate).toBeNull();
4949
});
5050

51-
it('hides floating when first visible item is a date separator', () => {
51+
it('shows floating with the first visible date separator value', () => {
5252
const { result } = renderHook(() =>
5353
useFloatingDateSeparator({
5454
disableDateSeparator: false,
@@ -60,8 +60,8 @@ describe('useFloatingDateSeparator', () => {
6060
result.current.onItemsRendered([makeDateSeparator(jan1), makeMessage('m1', jan1)]);
6161
});
6262

63-
expect(result.current.showFloatingDate).toBe(false);
64-
expect(result.current.floatingDate).toBeNull();
63+
expect(result.current.showFloatingDate).toBe(true);
64+
expect(result.current.floatingDate).toEqual(jan1);
6565
});
6666

6767
it('shows floating with correct date when first visible is a message', () => {
@@ -80,7 +80,7 @@ describe('useFloatingDateSeparator', () => {
8080
expect(result.current.floatingDate).toEqual(jan1);
8181
});
8282

83-
it('hides when any date separator is in visible set', () => {
83+
it('keeps top group date when a later date separator is also visible', () => {
8484
const { result } = renderHook(() =>
8585
useFloatingDateSeparator({
8686
disableDateSeparator: false,
@@ -96,6 +96,7 @@ describe('useFloatingDateSeparator', () => {
9696
]);
9797
});
9898

99-
expect(result.current.showFloatingDate).toBe(false);
99+
expect(result.current.showFloatingDate).toBe(true);
100+
expect(result.current.floatingDate).toEqual(jan1);
100101
});
101102
});

src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,19 @@ function getFloatingDateForFirstMessage(
4545
return null;
4646
}
4747

48+
function getFloatingDateForFirstItem(
49+
firstItem: RenderedMessage,
50+
processedMessages: RenderedMessage[],
51+
firstItemIndex: number,
52+
): Date | null {
53+
if (isDateSeparatorMessage(firstItem)) return firstItem.date;
54+
55+
return getFloatingDateForFirstMessage(firstItem, processedMessages, firstItemIndex);
56+
}
57+
4858
/**
49-
* Controls when to show the floating date separator (Slack-like: fixed at top when scrolling).
50-
* Show when no in-flow date separator is visible and we've scrolled past one.
59+
* Controls the floating date separator as a sticky "current section" label.
60+
* It follows the date separator represented by the first visible item.
5161
*/
5262
const HIDDEN_STATE = { date: null, visible: false } as const;
5363

@@ -74,25 +84,10 @@ export const useFloatingDateSeparator = ({
7484
}
7585

7686
const first = valid[0];
77-
78-
// If first visible item is a date separator, it's in view — hide floating
79-
if (isDateSeparatorMessage(first)) {
80-
setState(HIDDEN_STATE);
81-
return;
82-
}
83-
84-
// Check if any date separator is visible — if so, hide floating
85-
const hasVisibleDateSeparator = valid.some(isDateSeparatorMessage);
86-
if (hasVisibleDateSeparator) {
87-
setState(HIDDEN_STATE);
88-
return;
89-
}
90-
91-
// First visible is a message; find its date
9287
const firstIndex = processedMessages.findIndex((m) => m.id === first.id);
9388
const date =
9489
firstIndex >= 0
95-
? getFloatingDateForFirstMessage(first, processedMessages, firstIndex)
90+
? getFloatingDateForFirstItem(first, processedMessages, firstIndex)
9691
: null;
9792

9893
const visible = date !== null;

0 commit comments

Comments
 (0)