Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 14 additions & 16 deletions examples/vite/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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: <T extends (...args: never[]) => void>(fn: T) => {
const throttledBase = (...args: Parameters<T>) => 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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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', () => {
Expand All @@ -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,
Expand All @@ -96,6 +96,7 @@ describe('useFloatingDateSeparator', () => {
]);
});

expect(result.current.showFloatingDate).toBe(false);
expect(result.current.showFloatingDate).toBe(true);
expect(result.current.floatingDate).toEqual(jan1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down
Loading