Add conversation unread marker and jump-to-unread button

This commit is contained in:
Jack Kingsman
2026-03-12 10:54:25 -07:00
parent 30f6f95d8e
commit 0a20929df6
6 changed files with 560 additions and 149 deletions
+54 -1
View File
@@ -13,12 +13,16 @@ import type {
RadioConfig,
} from '../types';
const mocks = vi.hoisted(() => ({
messageList: vi.fn(() => <div data-testid="message-list" />),
}));
vi.mock('../components/ChatHeader', () => ({
ChatHeader: () => <div data-testid="chat-header" />,
}));
vi.mock('../components/MessageList', () => ({
MessageList: () => <div data-testid="message-list" />,
MessageList: mocks.messageList,
}));
vi.mock('../components/MessageInput', () => ({
@@ -107,6 +111,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
unreadMarkerLastReadAt: undefined,
targetMessageId: null,
hasNewerMessages: false,
loadingNewer: false,
@@ -124,6 +129,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
onTargetReached: vi.fn(),
onLoadNewer: vi.fn(async () => {}),
onJumpToBottom: vi.fn(),
onDismissUnreadMarker: vi.fn(),
onSendMessage: vi.fn(async () => {}),
onToggleNotifications: vi.fn(),
...overrides,
@@ -133,6 +139,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
describe('ConversationPane', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.messageList.mockImplementation(() => <div data-testid="message-list" />);
});
it('renders the empty state when no conversation is active', () => {
@@ -197,6 +204,52 @@ describe('ConversationPane', () => {
});
});
it('passes unread marker props to MessageList only for channel conversations', async () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'channel',
id: channel.key,
name: channel.name,
},
unreadMarkerLastReadAt: 1700000000,
})}
/>
);
await waitFor(() => {
expect(mocks.messageList).toHaveBeenCalled();
});
const channelCallArgs = mocks.messageList.mock.calls[
mocks.messageList.mock.calls.length - 1
] as unknown[] | undefined;
const channelCall = channelCallArgs?.[0] as Record<string, unknown> | undefined;
expect(channelCall?.unreadMarkerLastReadAt).toBe(1700000000);
expect(channelCall?.onDismissUnreadMarker).toBeTypeOf('function');
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'contact',
id: 'cc'.repeat(32),
name: 'Alice',
},
unreadMarkerLastReadAt: 1700000000,
})}
/>
);
const contactCallArgs = mocks.messageList.mock.calls[
mocks.messageList.mock.calls.length - 1
] as unknown[] | undefined;
const contactCall = contactCallArgs?.[0] as Record<string, unknown> | undefined;
expect(contactCall?.unreadMarkerLastReadAt).toBeUndefined();
expect(contactCall?.onDismissUnreadMarker).toBeUndefined();
});
it('shows a warning but keeps input for full-key contacts without an advert', async () => {
render(
<ConversationPane
+76 -1
View File
@@ -1,9 +1,13 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MessageList } from '../components/MessageList';
import type { Message } from '../types';
const scrollIntoViewMock = vi.fn();
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 1,
@@ -24,6 +28,15 @@ function createMessage(overrides: Partial<Message> = {}): Message {
}
describe('MessageList channel sender rendering', () => {
beforeEach(() => {
scrollIntoViewMock.mockReset();
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoViewMock,
writable: true,
});
});
it('renders explicit corrupt placeholder and warning avatar for unnamed corrupt channel packets', () => {
render(
<MessageList
@@ -80,4 +93,66 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument();
});
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
const user = userEvent.setup();
const messages = [
createMessage({ id: 1, received_at: 1700000001, text: 'Alice: older' }),
createMessage({ id: 2, received_at: 1700000010, text: 'Alice: newer' }),
];
function DismissibleUnreadMarkerList() {
const [unreadMarkerLastReadAt, setUnreadMarkerLastReadAt] = useState<number | undefined>(
1700000005
);
return (
<MessageList
messages={messages}
contacts={[]}
loading={false}
unreadMarkerLastReadAt={unreadMarkerLastReadAt}
onDismissUnreadMarker={() => setUnreadMarkerLastReadAt(undefined)}
/>
);
}
render(<DismissibleUnreadMarkerList />);
const marker = screen.getByRole('button', { name: /Unread messages/i });
expect(marker).toBeInTheDocument();
expect(screen.getByText('older')).toBeInTheDocument();
expect(screen.getByText('newer')).toBeInTheDocument();
await user.click(marker);
expect(screen.queryByRole('button', { name: /Unread messages/i })).not.toBeInTheDocument();
});
it('shows a jump-to-unread button and dismisses it after use without hiding the marker', async () => {
const user = userEvent.setup();
const messages = [
createMessage({ id: 1, received_at: 1700000001, text: 'Alice: older' }),
createMessage({ id: 2, received_at: 1700000010, text: 'Alice: newer' }),
];
render(
<MessageList
messages={messages}
contacts={[]}
loading={false}
unreadMarkerLastReadAt={1700000005}
/>
);
const jumpButton = screen.getByRole('button', { name: 'Jump to unread' });
expect(jumpButton).toBeInTheDocument();
expect(screen.getByText('Unread messages')).toBeInTheDocument();
await user.click(jumpButton);
expect(screen.queryByRole('button', { name: 'Jump to unread' })).not.toBeInTheDocument();
expect(screen.getByText('Unread messages')).toBeInTheDocument();
expect(scrollIntoViewMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { getUnreadBoundaryBackfillKey } from '../App';
import type { Conversation, Message } from '../types';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 1,
type: 'CHAN',
conversation_key: 'channel-1',
text: 'Alice: hello',
sender_timestamp: 1700000000,
received_at: 1700000001,
paths: null,
txt_type: 0,
signature: null,
sender_key: null,
outgoing: false,
acked: 0,
sender_name: 'Alice',
...overrides,
};
}
const channelConversation: Conversation = {
type: 'channel',
id: 'channel-1',
name: 'Busy room',
};
describe('getUnreadBoundaryBackfillKey', () => {
it('returns a fetch key when the unread boundary is older than the loaded window', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: 1700000000,
},
messages: [
createMessage({ id: 20, received_at: 1700000200 }),
createMessage({ id: 21, received_at: 1700000300 }),
],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: true,
})
).toBe('channel-1:1700000000:20');
});
it('does not backfill when the loaded window already reaches the unread boundary', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: 1700000200,
},
messages: [
createMessage({ id: 20, received_at: 1700000200 }),
createMessage({ id: 21, received_at: 1700000300 }),
],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: true,
})
).toBeNull();
});
it('does not backfill when there is no older history to fetch', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: 1700000000,
},
messages: [createMessage({ id: 20, received_at: 1700000200 })],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
})
).toBeNull();
});
it('does not backfill for channels where everything is unread', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: null,
},
messages: [createMessage({ id: 20, received_at: 1700000200 })],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: true,
})
).toBeNull();
});
});