Optimize jump to unread to reduce flicker on large backlog load.

This commit is contained in:
Jack Kingsman
2026-07-25 17:53:34 -07:00
parent 1584151000
commit f2006e15ba
12 changed files with 163 additions and 228 deletions
+7
View File
@@ -960,6 +960,13 @@ class UnreadCounts(BaseModel):
last_message_times: dict[str, int] = Field(
default_factory=dict, description="Map of stateKey -> last message timestamp"
)
first_unread_ids: dict[str, int | None] = Field(
default_factory=dict,
description=(
"Map of stateKey -> id of the oldest unread message. Lets the client place "
"the unread divider (and jump to it) without paging back through history."
),
)
last_read_ats: dict[str, int | None] = Field(
default_factory=dict, description="Map of stateKey -> server-side last_read_at boundary"
)
+17 -3
View File
@@ -720,12 +720,19 @@ class MessageRepository:
blocked_names: Display names whose messages should be excluded from counts.
Returns:
Dict with 'counts', 'mentions', 'last_message_times', and 'last_read_ats' keys.
Dict with 'counts', 'mentions', 'last_message_times', 'last_read_ats',
and 'first_unread_ids' keys.
"""
counts: dict[str, int] = {}
mention_flags: dict[str, bool] = {}
last_message_times: dict[str, int] = {}
last_read_ats: dict[str, int | None] = {}
# id of the oldest unread message per conversation. Rides the aggregate
# queries below via SQLite's bare-column rule: with exactly one MIN() in
# the query, bare columns come from the row that produced the minimum.
# Deliberately not MIN(id) — historical decryption inserts old messages
# with new ids, so id order and received_at order can disagree.
first_unread_ids: dict[str, int | None] = {}
mention_token = f"@[{name}]" if name else None
@@ -753,7 +760,9 @@ class MessageRepository:
SUM(CASE
WHEN ? <> '' AND INSTR(LOWER(m.text), LOWER(?)) > 0 THEN 1
ELSE 0
END) > 0 as has_mention
END) > 0 as has_mention,
MIN(m.received_at) as first_unread_at,
m.id as first_unread_id
FROM messages m
JOIN channels c ON m.conversation_key = c.key
WHERE m.type = 'CHAN' AND m.outgoing = 0
@@ -768,6 +777,7 @@ class MessageRepository:
for row in rows:
state_key = f"channel-{row['conversation_key']}"
counts[state_key] = row["unread_count"]
first_unread_ids[state_key] = row["first_unread_id"]
if mention_token and row["has_mention"]:
mention_flags[state_key] = True
@@ -779,7 +789,9 @@ class MessageRepository:
SUM(CASE
WHEN ? <> '' AND INSTR(LOWER(m.text), LOWER(?)) > 0 THEN 1
ELSE 0
END) > 0 as has_mention
END) > 0 as has_mention,
MIN(m.received_at) as first_unread_at,
m.id as first_unread_id
FROM messages m
LEFT JOIN contacts ct ON m.conversation_key = ct.public_key
WHERE m.type = 'PRIV' AND m.outgoing = 0
@@ -793,6 +805,7 @@ class MessageRepository:
for row in rows:
state_key = f"contact-{row['conversation_key']}"
counts[state_key] = row["unread_count"]
first_unread_ids[state_key] = row["first_unread_id"]
if mention_token and row["has_mention"]:
mention_flags[state_key] = True
@@ -841,6 +854,7 @@ class MessageRepository:
"mentions": mention_flags,
"last_message_times": last_message_times,
"last_read_ats": last_read_ats,
"first_unread_ids": first_unread_ids,
}
@staticmethod
+9 -82
View File
@@ -27,19 +27,14 @@ import { RichPayloadProvider } from './contexts/RichPayloadContext';
import { usePush } from './contexts/PushSubscriptionContext';
import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState';
import type {
BulkCreateHashtagChannelsResult,
Channel,
Conversation,
Message,
RawPacket,
} from './types';
import type { BulkCreateHashtagChannelsResult, Channel, Conversation, RawPacket } from './types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
import { shouldAutoFocusInput } from './utils/autoFocusInput';
interface ChannelUnreadMarker {
channelId: string;
lastReadAt: number | null;
/** Id of the oldest unread message, straight from the server. */
messageId: number | null;
}
interface NewMessagePrefillRequest {
@@ -48,44 +43,6 @@ interface NewMessagePrefillRequest {
nonce: number;
}
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) => {
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
@@ -100,7 +57,6 @@ export function App() {
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
const [repeaterAutoLoginKey, setRepeaterAutoLoginKey] = useState<string | null>(null);
const [visibilityVersion, setVisibilityVersion] = useState(0);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const {
notificationsSupported,
notificationsPermission,
@@ -355,7 +311,7 @@ export function App() {
unreadCounts,
mentions,
lastMessageTimes,
unreadLastReadAts,
firstUnreadIds,
recordMessageEvent,
renameConversationState,
removeConversationState,
@@ -397,40 +353,10 @@ export function App() {
}
return {
channelId: activeChannelId,
lastReadAt: unreadLastReadAts[getStateKey('channel', activeChannelId)] ?? null,
messageId: firstUnreadIds[getStateKey('channel', activeChannelId)] ?? null,
};
});
}, [activeConversation, unreadCounts, unreadLastReadAts]);
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,
]);
}, [activeConversation, unreadCounts, firstUnreadIds]);
const wsHandlers = useRealtimeAppState({
prevHealthRef,
@@ -610,11 +536,12 @@ export function App() {
messagesLoading,
loadingOlder,
hasOlderMessages,
unreadMarkerLastReadAt:
unreadMarkerMessageId:
activeConversation?.type === 'channel' &&
channelUnreadMarker?.channelId === activeConversation.id
? channelUnreadMarker.lastReadAt
? channelUnreadMarker.messageId
: undefined,
onNavigateToUnread: (messageId: number) => setTargetMessageId(messageId),
targetMessageId,
hasNewerMessages,
loadingNewer,
+9 -4
View File
@@ -50,7 +50,8 @@ interface ConversationPaneProps {
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
unreadMarkerLastReadAt?: number | null;
unreadMarkerMessageId?: number | null;
onNavigateToUnread?: (messageId: number) => void;
targetMessageId: number | null;
hasNewerMessages: boolean;
loadingNewer: boolean;
@@ -137,7 +138,8 @@ export function ConversationPane({
messagesLoading,
loadingOlder,
hasOlderMessages,
unreadMarkerLastReadAt,
unreadMarkerMessageId,
onNavigateToUnread,
targetMessageId,
hasNewerMessages,
loadingNewer,
@@ -348,8 +350,11 @@ export function ConversationPane({
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
unreadMarkerLastReadAt={
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
unreadMarkerMessageId={
activeConversation.type === 'channel' ? unreadMarkerMessageId : undefined
}
onNavigateToUnread={
activeConversation.type === 'channel' ? onNavigateToUnread : undefined
}
onDismissUnreadMarker={
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
+35 -11
View File
@@ -40,8 +40,11 @@ interface MessageListProps {
loading: boolean;
loadingOlder?: boolean;
hasOlderMessages?: boolean;
unreadMarkerLastReadAt?: number | null;
/** Id of the oldest unread message, from the server. Null when nothing is unread. */
unreadMarkerMessageId?: number | null;
onDismissUnreadMarker?: () => void;
/** Called when the unread boundary is not in loaded history and must be jumped to. */
onNavigateToUnread?: (messageId: number) => void;
onSenderClick?: (sender: string) => void;
onLoadOlder?: () => void;
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
@@ -387,8 +390,9 @@ export function MessageList({
loading,
loadingOlder = false,
hasOlderMessages = false,
unreadMarkerLastReadAt,
unreadMarkerMessageId,
onDismissUnreadMarker,
onNavigateToUnread,
onSenderClick,
onLoadOlder,
onResendChannelMessage,
@@ -714,17 +718,32 @@ export function MessageList({
};
}, [messages, onResendChannelMessage]);
/**
* Located by message id, not by timestamp. The previous `received_at > boundary`
* scan returned 0 the top of the loaded window whenever the real boundary was
* further back than anything loaded, so the divider silently pointed at the wrong
* message. Matching on identity returns -1 in that case, which is the truth: the
* boundary is elsewhere, and `boundaryOutsideWindow` below offers to go to it.
*/
const unreadMarkerIndex = useMemo(() => {
if (unreadMarkerLastReadAt === undefined) {
return -1;
}
if (unreadMarkerMessageId == null) return -1;
return sortedMessages.findIndex((msg) => msg.id === unreadMarkerMessageId);
}, [sortedMessages, unreadMarkerMessageId]);
const boundary = unreadMarkerLastReadAt ?? 0;
return sortedMessages.findIndex((msg) => !msg.outgoing && msg.received_at > boundary);
}, [sortedMessages, unreadMarkerLastReadAt]);
// Unread exists, but the message it starts at has not been loaded.
const boundaryOutsideWindow = unreadMarkerMessageId != null && unreadMarkerIndex === -1;
const syncJumpToUnreadVisibility = useCallback(() => {
if (unreadMarkerIndex === -1 || jumpToUnreadDismissed) {
if (jumpToUnreadDismissed) {
setShowJumpToUnread(false);
return;
}
// Boundary is real but out of the loaded window: always offer the jump.
if (boundaryOutsideWindow) {
setShowJumpToUnread(true);
return;
}
if (unreadMarkerIndex === -1) {
setShowJumpToUnread(false);
return;
}
@@ -756,7 +775,7 @@ export function MessageList({
markerRect.right <= listRect.right;
setShowJumpToUnread(!markerVisible);
}, [jumpToUnreadDismissed, unreadMarkerIndex]);
}, [jumpToUnreadDismissed, unreadMarkerIndex, boundaryOutsideWindow]);
// Refs for scroll handler to read without causing callback recreation
const onLoadOlderRef = useRef(onLoadOlder);
@@ -1355,7 +1374,12 @@ export function MessageList({
<button
type="button"
onClick={() => {
if (unreadMarkerRef.current?.scrollIntoView) {
if (boundaryOutsideWindow && unreadMarkerMessageId != null) {
// Not in loaded history: hand off to the jump-to-message path,
// which loads a window around the boundary instead of paging
// everything between here and there.
onNavigateToUnread?.(unreadMarkerMessageId);
} else if (unreadMarkerRef.current?.scrollIntoView) {
unreadMarkerRef.current.scrollIntoView({ block: 'center' });
} else {
// The marker row is outside the rendered window — scroll by index.
+5
View File
@@ -24,6 +24,8 @@ interface UseUnreadCountsResult {
mentions: Record<string, boolean>;
lastMessageTimes: ConversationTimes;
unreadLastReadAts: Record<string, number | null>;
/** stateKey -> id of the oldest unread message, for placing the unread divider. */
firstUnreadIds: Record<string, number | null>;
recordMessageEvent: (args: {
msg: Message;
activeConversation: boolean;
@@ -45,6 +47,7 @@ export function useUnreadCounts(
const [mentions, setMentions] = useState<Record<string, boolean>>({});
const [lastMessageTimes, setLastMessageTimes] = useState<ConversationTimes>(getLastMessageTimes);
const [unreadLastReadAts, setUnreadLastReadAts] = useState<Record<string, number | null>>({});
const [firstUnreadIds, setFirstUnreadIds] = useState<Record<string, number | null>>({});
// Track active conversation via ref so applyUnreads can filter without
// destabilizing the callback chain (avoids re-creating fetchUnreads on
@@ -71,6 +74,7 @@ export function useUnreadCounts(
}
setUnreadLastReadAts(data.last_read_ats);
setFirstUnreadIds(data.first_unread_ids ?? {});
if (Object.keys(data.last_message_times).length > 0) {
for (const [key, ts] of Object.entries(data.last_message_times)) {
@@ -269,6 +273,7 @@ export function useUnreadCounts(
mentions,
lastMessageTimes,
unreadLastReadAts,
firstUnreadIds,
recordMessageEvent,
renameConversationState,
removeConversationState,
+4 -4
View File
@@ -132,7 +132,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
unreadMarkerLastReadAt: undefined,
unreadMarkerMessageId: undefined,
targetMessageId: null,
hasNewerMessages: false,
loadingNewer: false,
@@ -311,7 +311,7 @@ describe('ConversationPane', () => {
id: channel.key,
name: channel.name,
},
unreadMarkerLastReadAt: 1700000000,
unreadMarkerMessageId: 4242,
})}
/>
);
@@ -324,7 +324,7 @@ describe('ConversationPane', () => {
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?.unreadMarkerMessageId).toBe(4242);
expect(channelCall?.onDismissUnreadMarker).toBeTypeOf('function');
render(
@@ -335,7 +335,7 @@ describe('ConversationPane', () => {
id: 'cc'.repeat(32),
name: 'Alice',
},
unreadMarkerLastReadAt: 1700000000,
unreadMarkerMessageId: 4242,
})}
/>
);
+47 -23
View File
@@ -324,6 +324,47 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByText('TEST1: TEST2')).toBeInTheDocument();
});
it('offers a jump instead of a divider when the unread boundary is not loaded', async () => {
const user = userEvent.setup();
const onNavigateToUnread = vi.fn();
// Boundary id 999 is not among the loaded messages: the real first-unread is
// further back than this window. The divider must not be invented at the top.
render(
<MessageList
messages={[
createMessage({ id: 1, received_at: 1700000001, text: 'Alice: older' }),
createMessage({ id: 2, received_at: 1700000010, text: 'Alice: newer' }),
]}
contacts={[]}
loading={false}
unreadMarkerMessageId={999}
onNavigateToUnread={onNavigateToUnread}
/>
);
expect(screen.queryByText('Unread messages')).not.toBeInTheDocument();
const jump = await screen.findByRole('button', { name: 'Jump to unread' });
await user.click(jump);
// Hands off to the jump-to-message path rather than scrolling to a wrong row.
expect(onNavigateToUnread).toHaveBeenCalledWith(999);
});
it('shows no unread affordance at all when nothing is unread', () => {
render(
<MessageList
messages={[createMessage({ id: 1, received_at: 1700000001, text: 'Alice: hi' })]}
contacts={[]}
loading={false}
unreadMarkerMessageId={null}
/>
);
expect(screen.queryByText('Unread messages')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Jump to unread' })).not.toBeInTheDocument();
});
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
const user = userEvent.setup();
const messages = [
@@ -332,17 +373,15 @@ describe('MessageList channel sender rendering', () => {
];
function DismissibleUnreadMarkerList() {
const [unreadMarkerLastReadAt, setUnreadMarkerLastReadAt] = useState<number | undefined>(
1700000005
);
const [unreadMarkerMessageId, setUnreadMarkerMessageId] = useState<number | undefined>(2);
return (
<MessageList
messages={messages}
contacts={[]}
loading={false}
unreadMarkerLastReadAt={unreadMarkerLastReadAt}
onDismissUnreadMarker={() => setUnreadMarkerLastReadAt(undefined)}
unreadMarkerMessageId={unreadMarkerMessageId}
onDismissUnreadMarker={() => setUnreadMarkerMessageId(undefined)}
/>
);
}
@@ -367,12 +406,7 @@ describe('MessageList channel sender rendering', () => {
];
render(
<MessageList
messages={messages}
contacts={[]}
loading={false}
unreadMarkerLastReadAt={1700000005}
/>
<MessageList messages={messages} contacts={[]} loading={false} unreadMarkerMessageId={2} />
);
const jumpButton = screen.getByRole('button', { name: 'Jump to unread' });
@@ -394,12 +428,7 @@ describe('MessageList channel sender rendering', () => {
];
render(
<MessageList
messages={messages}
contacts={[]}
loading={false}
unreadMarkerLastReadAt={1700000005}
/>
<MessageList messages={messages} contacts={[]} loading={false} unreadMarkerMessageId={2} />
);
await user.click(screen.getByRole('button', { name: 'Dismiss jump to unread' }));
@@ -461,12 +490,7 @@ describe('MessageList channel sender rendering', () => {
];
render(
<MessageList
messages={messages}
contacts={[]}
loading={false}
unreadMarkerLastReadAt={1700000005}
/>
<MessageList messages={messages} contacts={[]} loading={false} unreadMarkerMessageId={2} />
);
expect(screen.getByText('Unread messages')).toBeInTheDocument();
@@ -1,101 +0,0 @@
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();
});
});
+11
View File
@@ -103,6 +103,7 @@ describe('useUnreadCounts', () => {
counts: {},
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
mocks.markChannelRead.mockResolvedValue({ status: 'ok', key: '' });
@@ -134,6 +135,7 @@ describe('useUnreadCounts', () => {
counts: { [`channel-${CHANNEL_KEY}`]: 5 },
mentions: { [`channel-${CHANNEL_KEY}`]: true },
last_message_times: {},
first_unread_ids: {},
last_read_ats: { [`channel-${CHANNEL_KEY}`]: 1234 },
});
@@ -159,6 +161,7 @@ describe('useUnreadCounts', () => {
counts: { [`contact-${CONTACT_KEY}`]: 3 },
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: { [`contact-${CONTACT_KEY}`]: 2345 },
});
@@ -185,6 +188,7 @@ describe('useUnreadCounts', () => {
},
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
@@ -235,6 +239,7 @@ describe('useUnreadCounts', () => {
[getStateKey('channel', CHANNEL_KEY)]: true,
},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
@@ -277,6 +282,7 @@ describe('useUnreadCounts', () => {
counts: {},
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
@@ -291,6 +297,7 @@ describe('useUnreadCounts', () => {
counts: { [`channel-${CHANNEL_KEY}`]: 7 },
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: { [`channel-${CHANNEL_KEY}`]: 3456 },
});
@@ -318,6 +325,7 @@ describe('useUnreadCounts', () => {
counts: { [`channel-${addedChannelKey}`]: 2 },
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
@@ -347,6 +355,7 @@ describe('useUnreadCounts', () => {
counts: { [`contact-${addedContactKey}`]: 1 },
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
@@ -367,6 +376,7 @@ describe('useUnreadCounts', () => {
counts: { [`channel-${CHANNEL_KEY}`]: 5 },
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
@@ -385,6 +395,7 @@ describe('useUnreadCounts', () => {
counts: { [`channel-${CHANNEL_KEY}`]: 5 },
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
+2
View File
@@ -642,6 +642,8 @@ export interface UnreadCounts {
mentions: Record<string, boolean>;
last_message_times: Record<string, number>;
last_read_ats: Record<string, number | null>;
/** stateKey -> id of the oldest unread message. Locates the unread divider. */
first_unread_ids: Record<string, number | null>;
}
interface BusyChannel {
+17
View File
@@ -1005,6 +1005,23 @@ class TestReadStateEndpoints:
assert result["last_read_ats"][f"channel-{chan_key}"] == 1000
assert result["last_read_ats"][f"contact-{contact_key}"] == 1000
# The oldest unread message per conversation, used by the client to place
# the unread divider without paging back through history.
first_chan = await MessageRepository.get_by_content(
msg_type="CHAN",
conversation_key=chan_key,
text="Bob: hello",
sender_timestamp=1001,
)
assert result["first_unread_ids"][f"channel-{chan_key}"] == first_chan.id
first_dm = await MessageRepository.get_by_content(
msg_type="PRIV",
conversation_key=contact_key,
text="hi @[TeStUsEr] there",
sender_timestamp=1005,
)
assert result["first_unread_ids"][f"contact-{contact_key}"] == first_dm.id
@pytest.mark.asyncio
async def test_get_unreads_no_name_skips_mentions(self, test_db):
"""Unreads without a radio name returns counts but no mention flags."""