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

View File

@@ -18,7 +18,51 @@ import {
import { AppShell } from './components/AppShell';
import type { MessageInputHandle } from './components/MessageInput';
import { messageContainsMention } from './utils/messageParser';
import type { Conversation, RawPacket } from './types';
import { getStateKey } from './utils/conversationState';
import type { Conversation, Message, RawPacket } from './types';
interface ChannelUnreadMarker {
channelId: string;
lastReadAt: number | null;
}
interface UnreadBoundaryBackfillParams {
activeConversation: Conversation | null;
unreadMarker: ChannelUnreadMarker | null;
messages: Message[];
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
}
export function getUnreadBoundaryBackfillKey({
activeConversation,
unreadMarker,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
}: UnreadBoundaryBackfillParams): string | null {
if (activeConversation?.type !== 'channel') return null;
if (!unreadMarker || unreadMarker.channelId !== activeConversation.id) return null;
if (unreadMarker.lastReadAt === null) return null;
if (messagesLoading || loadingOlder || !hasOlderMessages || messages.length === 0) return null;
const oldestLoadedMessage = messages.reduce(
(oldest, msg) => {
if (!oldest) return msg;
if (msg.received_at < oldest.received_at) return msg;
if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg;
return oldest;
},
null as Message | null
);
if (!oldestLoadedMessage) return null;
if (oldestLoadedMessage.received_at <= unreadMarker.lastReadAt) return null;
return `${activeConversation.id}:${unreadMarker.lastReadAt}:${oldestLoadedMessage.id}`;
}
export function App() {
const quoteSearchOperatorValue = useCallback((value: string) => {
@@ -27,6 +71,8 @@ export function App() {
const messageInputRef = useRef<MessageInputHandle>(null);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const {
notificationsSupported,
notificationsPermission,
@@ -198,6 +244,61 @@ export function App() {
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
useEffect(() => {
if (activeConversation?.type !== 'channel') {
setChannelUnreadMarker(null);
return;
}
const activeChannelId = activeConversation.id;
const activeChannelUnreadCount = unreadCounts[getStateKey('channel', activeChannelId)] ?? 0;
setChannelUnreadMarker((prev) => {
if (prev?.channelId === activeChannelId) {
return prev;
}
if (activeChannelUnreadCount <= 0) {
return null;
}
const activeChannel = channels.find((channel) => channel.key === activeChannelId);
return {
channelId: activeChannelId,
lastReadAt: activeChannel?.last_read_at ?? null,
};
});
}, [activeConversation, channels, unreadCounts]);
useEffect(() => {
lastUnreadBackfillAttemptRef.current = null;
}, [activeConversation?.id, channelUnreadMarker?.channelId, channelUnreadMarker?.lastReadAt]);
useEffect(() => {
const backfillKey = getUnreadBoundaryBackfillKey({
activeConversation,
unreadMarker: channelUnreadMarker,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
});
if (!backfillKey || lastUnreadBackfillAttemptRef.current === backfillKey) {
return;
}
lastUnreadBackfillAttemptRef.current = backfillKey;
void fetchOlderMessages();
}, [
activeConversation,
channelUnreadMarker,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
fetchOlderMessages,
]);
const wsHandlers = useRealtimeAppState({
prevHealthRef,
setHealth,
@@ -294,6 +395,11 @@ export function App() {
messagesLoading,
loadingOlder,
hasOlderMessages,
unreadMarkerLastReadAt:
activeConversation?.type === 'channel' &&
channelUnreadMarker?.channelId === activeConversation.id
? channelUnreadMarker.lastReadAt
: undefined,
targetMessageId,
hasNewerMessages,
loadingNewer,
@@ -312,6 +418,7 @@ export function App() {
onLoadNewer: fetchNewerMessages,
onJumpToBottom: jumpToBottom,
onSendMessage: handleSendMessage,
onDismissUnreadMarker: () => setChannelUnreadMarker(null),
notificationsSupported,
notificationsPermission,
notificationsEnabled:

View File

@@ -40,6 +40,7 @@ interface ConversationPaneProps {
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
unreadMarkerLastReadAt?: number | null;
targetMessageId: number | null;
hasNewerMessages: boolean;
loadingNewer: boolean;
@@ -57,6 +58,7 @@ interface ConversationPaneProps {
onTargetReached: () => void;
onLoadNewer: () => Promise<void>;
onJumpToBottom: () => void;
onDismissUnreadMarker: () => void;
onSendMessage: (text: string) => Promise<void>;
onToggleNotifications: () => void;
}
@@ -101,6 +103,7 @@ export function ConversationPane({
messagesLoading,
loadingOlder,
hasOlderMessages,
unreadMarkerLastReadAt,
targetMessageId,
hasNewerMessages,
loadingNewer,
@@ -118,6 +121,7 @@ export function ConversationPane({
onTargetReached,
onLoadNewer,
onJumpToBottom,
onDismissUnreadMarker,
onSendMessage,
onToggleNotifications,
}: ConversationPaneProps) {
@@ -242,6 +246,12 @@ export function ConversationPane({
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
unreadMarkerLastReadAt={
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
}
onDismissUnreadMarker={
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
}
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
onLoadOlder={onLoadOlder}
onResendChannelMessage={

View File

@@ -1,4 +1,5 @@
import {
Fragment,
useEffect,
useLayoutEffect,
useRef,
@@ -22,6 +23,8 @@ interface MessageListProps {
loading: boolean;
loadingOlder?: boolean;
hasOlderMessages?: boolean;
unreadMarkerLastReadAt?: number | null;
onDismissUnreadMarker?: () => void;
onSenderClick?: (sender: string) => void;
onLoadOlder?: () => void;
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
@@ -172,6 +175,8 @@ export function MessageList({
loading,
loadingOlder = false,
hasOlderMessages = false,
unreadMarkerLastReadAt,
onDismissUnreadMarker,
onSenderClick,
onLoadOlder,
onResendChannelMessage,
@@ -198,7 +203,12 @@ export function MessageList({
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
const [showJumpToUnread, setShowJumpToUnread] = useState(false);
const targetScrolledRef = useRef(false);
const unreadMarkerRef = useRef<HTMLButtonElement | HTMLDivElement | null>(null);
const setUnreadMarkerElement = useCallback((node: HTMLButtonElement | HTMLDivElement | null) => {
unreadMarkerRef.current = node;
}, []);
// Capture scroll state in the scroll handler BEFORE any state updates
const scrollStateRef = useRef({
@@ -389,6 +399,18 @@ export function MessageList({
() => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
[messages]
);
const unreadMarkerIndex = useMemo(() => {
if (unreadMarkerLastReadAt === undefined) {
return -1;
}
const boundary = unreadMarkerLastReadAt ?? 0;
return sortedMessages.findIndex((msg) => !msg.outgoing && msg.received_at > boundary);
}, [sortedMessages, unreadMarkerLastReadAt]);
useEffect(() => {
setShowJumpToUnread(unreadMarkerIndex !== -1);
}, [unreadMarkerIndex]);
// Sender info for outgoing messages (used by path modal on own messages)
const selfSenderInfo = useMemo<SenderInfo>(
@@ -612,102 +634,102 @@ export function MessageList({
: `View info for ${avatarKey.slice(0, 12)}`;
return (
<div
key={msg.id}
data-message-id={msg.id}
className={cn(
'flex items-start max-w-[85%]',
msg.outgoing && 'flex-row-reverse self-end',
isFirstInGroup && !isFirstMessage && 'mt-3'
)}
>
{!msg.outgoing && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar &&
avatarKey &&
(onOpenContactInfo ? (
<button
type="button"
className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={avatarActionLabel}
onClick={() => onOpenContactInfo(avatarKey, msg.type === 'CHAN')}
>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
clickable
variant={avatarVariant}
/>
</button>
) : (
<span>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
variant={avatarVariant}
/>
</span>
))}
</div>
)}
<Fragment key={msg.id}>
{unreadMarkerIndex === index &&
(onDismissUnreadMarker ? (
<button
ref={setUnreadMarkerElement}
type="button"
className="my-2 flex w-full items-center gap-3 text-left text-xs font-medium text-primary transition-colors hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onDismissUnreadMarker}
>
<span className="h-px flex-1 bg-border" />
<span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1">
Unread messages
</span>
<span className="h-px flex-1 bg-border" />
</button>
) : (
<div
ref={setUnreadMarkerElement}
className="my-2 flex w-full items-center gap-3 text-xs font-medium text-primary"
>
<span className="h-px flex-1 bg-border" />
<span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1">
Unread messages
</span>
<span className="h-px flex-1 bg-border" />
</div>
))}
<div
data-message-id={msg.id}
className={cn(
'py-1.5 px-3 rounded-lg min-w-0',
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
highlightedMessageId === msg.id && 'message-highlight'
'flex items-start max-w-[85%]',
msg.outgoing && 'flex-row-reverse self-end',
isFirstInGroup && !isFirstMessage && 'mt-3'
)}
>
{showAvatar && (
<div className="text-[13px] font-semibold text-foreground mb-0.5">
{canClickSender ? (
<span
className="cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => onSenderClick(displaySender)}
title={`Mention ${displaySender}`}
>
{displaySender}
</span>
) : (
displaySender
)}
<span className="font-normal text-muted-foreground ml-2 text-[11px]">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
paths={msg.paths}
variant="header"
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, sender),
})
}
/>
)}
{!msg.outgoing && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar &&
avatarKey &&
(onOpenContactInfo ? (
<button
type="button"
className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={avatarActionLabel}
onClick={() => onOpenContactInfo(avatarKey, msg.type === 'CHAN')}
>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
clickable
variant={avatarVariant}
/>
</button>
) : (
<span>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
variant={avatarVariant}
/>
</span>
))}
</div>
)}
<div className="break-words whitespace-pre-wrap">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName)}
{i < arr.length - 1 && <br />}
</span>
))}
{!showAvatar && (
<>
<span className="text-[10px] text-muted-foreground ml-2">
<div
className={cn(
'py-1.5 px-3 rounded-lg min-w-0',
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
highlightedMessageId === msg.id && 'message-highlight'
)}
>
{showAvatar && (
<div className="text-[13px] font-semibold text-foreground mb-0.5">
{canClickSender ? (
<span
className="cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => onSenderClick(displaySender)}
title={`Mention ${displaySender}`}
>
{displaySender}
</span>
) : (
displaySender
)}
<span className="font-normal text-muted-foreground ml-2 text-[11px]">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
paths={msg.paths}
variant="inline"
variant="header"
onClick={() =>
setSelectedPath({
paths: msg.paths!,
@@ -716,11 +738,58 @@ export function MessageList({
}
/>
)}
</>
</div>
)}
{msg.outgoing &&
(msg.acked > 0 ? (
msg.paths && msg.paths.length > 0 ? (
<div className="break-words whitespace-pre-wrap">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName)}
{i < arr.length - 1 && <br />}
</span>
))}
{!showAvatar && (
<>
<span className="text-[10px] text-muted-foreground ml-2">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
paths={msg.paths}
variant="inline"
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, sender),
})
}
/>
)}
</>
)}
{msg.outgoing &&
(msg.acked > 0 ? (
msg.paths && msg.paths.length > 0 ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
paths: msg.paths!,
senderInfo: selfSenderInfo,
messageId: msg.id,
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
});
}}
title="View echo paths"
aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`}
>{`${msg.acked > 1 ? msg.acked : ''}`}</span>
) : (
<span className="text-muted-foreground">{`${msg.acked > 1 ? msg.acked : ''}`}</span>
)
) : onResendChannelMessage && msg.type === 'CHAN' ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
@@ -729,48 +798,28 @@ export function MessageList({
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
paths: msg.paths!,
paths: [],
senderInfo: selfSenderInfo,
messageId: msg.id,
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
isOutgoingChan: true,
});
}}
title="View echo paths"
aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`}
>{`${msg.acked > 1 ? msg.acked : ''}`}</span>
title="Message status"
aria-label="No echoes yet — view message status"
>
{' '}
?
</span>
) : (
<span className="text-muted-foreground">{`${msg.acked > 1 ? msg.acked : ''}`}</span>
)
) : onResendChannelMessage && msg.type === 'CHAN' ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
paths: [],
senderInfo: selfSenderInfo,
messageId: msg.id,
isOutgoingChan: true,
});
}}
title="Message status"
aria-label="No echoes yet — view message status"
>
{' '}
?
</span>
) : (
<span className="text-muted-foreground" title="No repeats heard yet">
{' '}
?
</span>
))}
<span className="text-muted-foreground" title="No repeats heard yet">
{' '}
?
</span>
))}
</div>
</div>
</div>
</div>
</Fragment>
);
})}
{loadingNewer && (
@@ -786,28 +835,44 @@ export function MessageList({
</div>
{/* Scroll to bottom button */}
{showScrollToBottom && (
<button
onClick={scrollToBottom}
className="absolute bottom-4 right-4 w-9 h-9 rounded-full bg-card hover:bg-accent border border-border flex items-center justify-center shadow-lg transition-all hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title="Scroll to bottom"
aria-label="Scroll to bottom"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{(showJumpToUnread || showScrollToBottom) && (
<div className="absolute bottom-4 right-4 flex items-center gap-2">
{showJumpToUnread && (
<button
type="button"
onClick={() => {
unreadMarkerRef.current?.scrollIntoView?.({ block: 'center' });
setShowJumpToUnread(false);
}}
className="h-9 rounded-full bg-card hover:bg-accent border border-border px-3 text-sm font-medium shadow-lg transition-all hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Jump to unread
</button>
)}
{showScrollToBottom && (
<button
onClick={scrollToBottom}
className="w-9 h-9 rounded-full bg-card hover:bg-accent border border-border flex items-center justify-center shadow-lg transition-all hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title="Scroll to bottom"
aria-label="Scroll to bottom"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
)}
</div>
)}
{/* Path modal */}

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

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();
});
});

View File

@@ -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();
});
});