Fix message dual render and get the jump to unread link out of the way on visible unread boundaries. Closes #57.

This commit is contained in:
Jack Kingsman
2026-03-12 16:31:47 -07:00
parent 07fd88a4d6
commit 74c13d194c
4 changed files with 327 additions and 71 deletions

View File

@@ -7,6 +7,7 @@ import { MessageList } from '../components/MessageList';
import type { Message } from '../types';
const scrollIntoViewMock = vi.fn();
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
function createMessage(overrides: Partial<Message> = {}): Message {
return {
@@ -35,6 +36,11 @@ describe('MessageList channel sender rendering', () => {
value: scrollIntoViewMock,
writable: true,
});
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
configurable: true,
value: originalGetBoundingClientRect,
writable: true,
});
});
it('renders explicit corrupt placeholder and warning avatar for unnamed corrupt channel packets', () => {
@@ -155,4 +161,68 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByText('Unread messages')).toBeInTheDocument();
expect(scrollIntoViewMock).toHaveBeenCalled();
});
it('hides the jump-to-unread button when the unread marker is already visible', () => {
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
configurable: true,
writable: true,
value: function () {
const element = this as HTMLElement;
if (element.textContent?.includes('Unread messages')) {
return {
top: 200,
bottom: 240,
left: 0,
right: 300,
width: 300,
height: 40,
x: 0,
y: 200,
toJSON: () => '',
};
}
if (element.className.includes('overflow-y-auto')) {
return {
top: 100,
bottom: 500,
left: 0,
right: 400,
width: 400,
height: 400,
x: 0,
y: 100,
toJSON: () => '',
};
}
return {
top: 0,
bottom: 0,
left: 0,
right: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => '',
};
},
});
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}
/>
);
expect(screen.getByText('Unread messages')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Jump to unread' })).not.toBeInTheDocument();
});
});

View File

@@ -323,6 +323,102 @@ describe('useConversationMessages background reconcile ordering', () => {
});
});
describe('useConversationMessages older-page dedup and reentry', () => {
beforeEach(() => {
mockGetMessages.mockReset();
messageCache.clear();
});
it('prevents duplicate overlapping older-page fetches in the same tick', async () => {
const conv: Conversation = { type: 'contact', id: 'conv_a', name: 'Contact A' };
const fullPage = Array.from({ length: 200 }, (_, i) =>
createMessage({
id: i + 1,
conversation_key: 'conv_a',
text: `msg-${i + 1}`,
sender_timestamp: 1700000000 + i,
received_at: 1700000000 + i,
})
);
mockGetMessages.mockResolvedValueOnce(fullPage);
const olderDeferred = createDeferred<Message[]>();
mockGetMessages.mockReturnValueOnce(olderDeferred.promise);
const { result } = renderHook(() => useConversationMessages(conv));
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.messages).toHaveLength(200);
expect(result.current.hasOlderMessages).toBe(true);
act(() => {
void result.current.fetchOlderMessages();
void result.current.fetchOlderMessages();
});
expect(mockGetMessages).toHaveBeenCalledTimes(2); // initial page + one older fetch
olderDeferred.resolve([
createMessage({
id: 0,
conversation_key: 'conv_a',
text: 'older-msg',
sender_timestamp: 1699999999,
received_at: 1699999999,
}),
]);
await waitFor(() => expect(result.current.loadingOlder).toBe(false));
expect(result.current.messages).toHaveLength(201);
expect(result.current.messages.filter((msg) => msg.id === 0)).toHaveLength(1);
});
it('does not append duplicate messages from an overlapping older page', async () => {
const conv: Conversation = { type: 'contact', id: 'conv_a', name: 'Contact A' };
const fullPage = Array.from({ length: 200 }, (_, i) =>
createMessage({
id: i + 1,
conversation_key: 'conv_a',
text: `msg-${i + 1}`,
sender_timestamp: 1700000000 + i,
received_at: 1700000000 + i,
})
);
mockGetMessages.mockResolvedValueOnce(fullPage);
mockGetMessages.mockResolvedValueOnce([
createMessage({
id: 1,
conversation_key: 'conv_a',
text: 'msg-1',
sender_timestamp: 1700000000,
received_at: 1700000000,
}),
createMessage({
id: 0,
conversation_key: 'conv_a',
text: 'older-msg',
sender_timestamp: 1699999999,
received_at: 1699999999,
}),
]);
const { result } = renderHook(() => useConversationMessages(conv));
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.messages).toHaveLength(200);
await act(async () => {
await result.current.fetchOlderMessages();
});
expect(result.current.messages.filter((msg) => msg.id === 1)).toHaveLength(1);
expect(result.current.messages.filter((msg) => msg.id === 0)).toHaveLength(1);
expect(result.current.messages).toHaveLength(201);
});
});
describe('useConversationMessages forward pagination', () => {
beforeEach(() => {
mockGetMessages.mockReset();