mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-30 21:43:14 +02:00
Optimize jump to unread to reduce flicker on large backlog load.
This commit is contained in:
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: {},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user