mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-08 01:33:01 +02:00
Add conversation unread marker and jump-to-unread button
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user