diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5c2f474..344fd53 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [rawPackets, setRawPackets] = useState([]); + const [channelUnreadMarker, setChannelUnreadMarker] = useState(null); + const lastUnreadBackfillAttemptRef = useRef(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: diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index ca0af70..42780d9 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -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; onJumpToBottom: () => void; + onDismissUnreadMarker: () => void; onSendMessage: (text: string) => Promise; 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={ diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index da0c0dc..e547261 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -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>(new Set()); const resendTimersRef = useRef>>(new Map()); const [highlightedMessageId, setHighlightedMessageId] = useState(null); + const [showJumpToUnread, setShowJumpToUnread] = useState(false); const targetScrolledRef = useRef(false); + const unreadMarkerRef = useRef(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( @@ -612,102 +634,102 @@ export function MessageList({ : `View info for ${avatarKey.slice(0, 12)}`; return ( -
- {!msg.outgoing && ( -
- {showAvatar && - avatarKey && - (onOpenContactInfo ? ( - - ) : ( - - - - ))} -
- )} + + {unreadMarkerIndex === index && + (onDismissUnreadMarker ? ( + + ) : ( +
+ + + Unread messages + + +
+ ))}
- {showAvatar && ( -
- {canClickSender ? ( - onSenderClick(displaySender)} - title={`Mention ${displaySender}`} - > - {displaySender} - - ) : ( - displaySender - )} - - {formatTime(msg.sender_timestamp || msg.received_at)} - - {!msg.outgoing && msg.paths && msg.paths.length > 0 && ( - - setSelectedPath({ - paths: msg.paths!, - senderInfo: getSenderInfo(msg, contact, sender), - }) - } - /> - )} + {!msg.outgoing && ( +
+ {showAvatar && + avatarKey && + (onOpenContactInfo ? ( + + ) : ( + + + + ))}
)} -
- {content.split('\n').map((line, i, arr) => ( - - {renderTextWithMentions(line, radioName)} - {i < arr.length - 1 &&
} -
- ))} - {!showAvatar && ( - <> - +
+ {showAvatar && ( +
+ {canClickSender ? ( + onSenderClick(displaySender)} + title={`Mention ${displaySender}`} + > + {displaySender} + + ) : ( + displaySender + )} + {formatTime(msg.sender_timestamp || msg.received_at)} {!msg.outgoing && msg.paths && msg.paths.length > 0 && ( setSelectedPath({ paths: msg.paths!, @@ -716,11 +738,58 @@ export function MessageList({ } /> )} - +
)} - {msg.outgoing && - (msg.acked > 0 ? ( - msg.paths && msg.paths.length > 0 ? ( +
+ {content.split('\n').map((line, i, arr) => ( + + {renderTextWithMentions(line, radioName)} + {i < arr.length - 1 &&
} +
+ ))} + {!showAvatar && ( + <> + + {formatTime(msg.sender_timestamp || msg.received_at)} + + {!msg.outgoing && msg.paths && msg.paths.length > 0 && ( + + setSelectedPath({ + paths: msg.paths!, + senderInfo: getSenderInfo(msg, contact, sender), + }) + } + /> + )} + + )} + {msg.outgoing && + (msg.acked > 0 ? ( + msg.paths && msg.paths.length > 0 ? ( + { + 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 : ''}`} + ) : ( + {` ✓${msg.acked > 1 ? msg.acked : ''}`} + ) + ) : onResendChannelMessage && msg.type === 'CHAN' ? ( { 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 : ''}`} + title="Message status" + aria-label="No echoes yet — view message status" + > + {' '} + ? + ) : ( - {` ✓${msg.acked > 1 ? msg.acked : ''}`} - ) - ) : onResendChannelMessage && msg.type === 'CHAN' ? ( - { - e.stopPropagation(); - setSelectedPath({ - paths: [], - senderInfo: selfSenderInfo, - messageId: msg.id, - isOutgoingChan: true, - }); - }} - title="Message status" - aria-label="No echoes yet — view message status" - > - {' '} - ? - - ) : ( - - {' '} - ? - - ))} + + {' '} + ? + + ))} +
-
+ ); })} {loadingNewer && ( @@ -786,28 +835,44 @@ export function MessageList({
{/* Scroll to bottom button */} - {showScrollToBottom && ( - + {(showJumpToUnread || showScrollToBottom) && ( +
+ {showJumpToUnread && ( + + )} + {showScrollToBottom && ( + + )} +
)} {/* Path modal */} diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx index 86f1115..5f421dd 100644 --- a/frontend/src/test/conversationPane.test.tsx +++ b/frontend/src/test/conversationPane.test.tsx @@ -13,12 +13,16 @@ import type { RadioConfig, } from '../types'; +const mocks = vi.hoisted(() => ({ + messageList: vi.fn(() =>
), +})); + vi.mock('../components/ChatHeader', () => ({ ChatHeader: () =>
, })); vi.mock('../components/MessageList', () => ({ - MessageList: () =>
, + MessageList: mocks.messageList, })); vi.mock('../components/MessageInput', () => ({ @@ -107,6 +111,7 @@ function createProps(overrides: Partial {}), onJumpToBottom: vi.fn(), + onDismissUnreadMarker: vi.fn(), onSendMessage: vi.fn(async () => {}), onToggleNotifications: vi.fn(), ...overrides, @@ -133,6 +139,7 @@ function createProps(overrides: Partial { beforeEach(() => { vi.clearAllMocks(); + mocks.messageList.mockImplementation(() =>
); }); 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( + + ); + + 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 | undefined; + expect(channelCall?.unreadMarkerLastReadAt).toBe(1700000000); + expect(channelCall?.onDismissUnreadMarker).toBeTypeOf('function'); + + render( + + ); + + const contactCallArgs = mocks.messageList.mock.calls[ + mocks.messageList.mock.calls.length - 1 + ] as unknown[] | undefined; + const contactCall = contactCallArgs?.[0] as Record | 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( = {}): Message { return { id: 1, @@ -24,6 +28,15 @@ function createMessage(overrides: Partial = {}): 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( { 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( + 1700000005 + ); + + return ( + setUnreadMarkerLastReadAt(undefined)} + /> + ); + } + + render(); + + 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( + + ); + + 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(); + }); }); diff --git a/frontend/src/test/unreadMarkerBackfill.test.ts b/frontend/src/test/unreadMarkerBackfill.test.ts new file mode 100644 index 0000000..4b4b312 --- /dev/null +++ b/frontend/src/test/unreadMarkerBackfill.test.ts @@ -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 { + 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(); + }); +});