mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-09-29 00:14:16 +01:00
Support unread reminders
Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
co-authored by
trevor-signal
parent
d5bcb1ba30
commit
4d737568c3
@@ -0,0 +1,375 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import assert from 'node:assert/strict';
|
||||
import { cwd } from 'node:process';
|
||||
|
||||
import { ReadStatus } from '../../../messages/MessageReadStatus.std.ts';
|
||||
import { SeenStatus } from '../../../MessageSeenStatus.std.ts';
|
||||
import type { WritableDB } from '../../../sql/Interface.std.ts';
|
||||
import { setupTests } from '../../../sql/Server.node.ts';
|
||||
import type { UnreadReminderFilter } from '../../../sql/server/unreadReminders.std.ts';
|
||||
import {
|
||||
getUnremindedUnreadMessageTimeRanges,
|
||||
getUnremindedUnreadMessageTimeRangesQuery,
|
||||
getUnreadReminderCountQuery,
|
||||
getUnreadReminderSendersQuery,
|
||||
getUnreadReminderSummaryData,
|
||||
} from '../../../sql/server/unreadReminders.std.ts';
|
||||
import { createDB, explain, insertData } from '../helpers.node.ts';
|
||||
import { generateAci } from '../../../test-helpers/serviceIdUtils.std.ts';
|
||||
import { DAY } from '../../../util/durations/constants.std.ts';
|
||||
|
||||
const OUR_ACI = generateAci();
|
||||
const ALICE_ACI = generateAci();
|
||||
const BOB_ACI = generateAci();
|
||||
|
||||
const yesterday = Date.now() - DAY;
|
||||
|
||||
let messageCounter = 0;
|
||||
|
||||
type MessageOverrides = {
|
||||
conversationId: string;
|
||||
receivedAtMs?: number | null;
|
||||
readStatus?: ReadStatus;
|
||||
sourceServiceId?: string;
|
||||
mentionsMe?: 0 | 1;
|
||||
quoteAuthorAci?: string;
|
||||
storyId?: string | null;
|
||||
};
|
||||
|
||||
function composeMessage(overrides: MessageOverrides) {
|
||||
messageCounter += 1;
|
||||
const {
|
||||
conversationId,
|
||||
receivedAtMs = yesterday,
|
||||
readStatus = ReadStatus.Unread,
|
||||
sourceServiceId = ALICE_ACI,
|
||||
mentionsMe = 0,
|
||||
quoteAuthorAci,
|
||||
storyId = null,
|
||||
} = overrides;
|
||||
|
||||
const json: Record<string, unknown> = { id: `m${messageCounter}` };
|
||||
if (quoteAuthorAci != null) {
|
||||
json.quote = { authorAci: quoteAuthorAci };
|
||||
}
|
||||
|
||||
return {
|
||||
id: `m${messageCounter}`,
|
||||
conversationId,
|
||||
type: 'incoming',
|
||||
readStatus,
|
||||
seenStatus: SeenStatus.Unseen,
|
||||
received_at: messageCounter,
|
||||
received_at_ms: receivedAtMs,
|
||||
sent_at: receivedAtMs ?? yesterday,
|
||||
sourceServiceId,
|
||||
mentionsMe,
|
||||
storyId,
|
||||
json: JSON.stringify(json),
|
||||
};
|
||||
}
|
||||
|
||||
describe('sql/server/unreadReminders', () => {
|
||||
let db: WritableDB;
|
||||
beforeEach(() => {
|
||||
messageCounter = 0;
|
||||
db = createDB();
|
||||
setupTests(db, { userDataPath: cwd() });
|
||||
insertData(db, 'conversations', [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }]);
|
||||
});
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
describe('getUnremindedUnreadMessageTimeRanges', () => {
|
||||
it('returns the oldest and newest unread arrival for a conversation', () => {
|
||||
insertData(db, 'messages', [
|
||||
composeMessage({ conversationId: 'c1', receivedAtMs: yesterday }),
|
||||
composeMessage({
|
||||
conversationId: 'c1',
|
||||
receivedAtMs: yesterday + 5_000,
|
||||
}),
|
||||
composeMessage({
|
||||
conversationId: 'c1',
|
||||
receivedAtMs: yesterday + 1_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
getUnremindedUnreadMessageTimeRanges(db, [
|
||||
{
|
||||
conversationId: 'c1',
|
||||
lastRemindedAt: 0,
|
||||
includeStoryReplies: true,
|
||||
},
|
||||
]),
|
||||
[
|
||||
{
|
||||
conversationId: 'c1',
|
||||
oldestUnremindedReceivedAtMs: yesterday,
|
||||
newestUnreadReceivedAtMs: yesterday + 5_000,
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes activity before lastRemindedAt', () => {
|
||||
insertData(db, 'messages', [
|
||||
composeMessage({ conversationId: 'c1', receivedAtMs: yesterday }),
|
||||
composeMessage({
|
||||
conversationId: 'c1',
|
||||
receivedAtMs: yesterday + 10_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = getUnremindedUnreadMessageTimeRanges(db, [
|
||||
{
|
||||
conversationId: 'c1',
|
||||
lastRemindedAt: yesterday + 5_000,
|
||||
includeStoryReplies: true,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{
|
||||
conversationId: 'c1',
|
||||
oldestUnremindedReceivedAtMs: yesterday + 10_000,
|
||||
newestUnreadReceivedAtMs: yesterday + 10_000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits a conversation whose unread all predates the cutoff', () => {
|
||||
insertData(db, 'messages', [
|
||||
composeMessage({ conversationId: 'c1', receivedAtMs: yesterday }),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
getUnremindedUnreadMessageTimeRanges(db, [
|
||||
{
|
||||
conversationId: 'c1',
|
||||
lastRemindedAt: yesterday + 1,
|
||||
includeStoryReplies: true,
|
||||
},
|
||||
]),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores read messages', () => {
|
||||
insertData(db, 'messages', [
|
||||
composeMessage({
|
||||
conversationId: 'c1',
|
||||
receivedAtMs: yesterday,
|
||||
readStatus: ReadStatus.Read,
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
getUnremindedUnreadMessageTimeRanges(db, [
|
||||
{
|
||||
conversationId: 'c1',
|
||||
lastRemindedAt: 0,
|
||||
includeStoryReplies: true,
|
||||
},
|
||||
]),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
it('respects includeStoryReplies', () => {
|
||||
insertData(db, 'messages', [
|
||||
composeMessage({
|
||||
conversationId: 'c1',
|
||||
receivedAtMs: yesterday,
|
||||
storyId: 'story1',
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
getUnremindedUnreadMessageTimeRanges(db, [
|
||||
{
|
||||
conversationId: 'c1',
|
||||
lastRemindedAt: 0,
|
||||
includeStoryReplies: false,
|
||||
},
|
||||
]),
|
||||
[]
|
||||
);
|
||||
assert.equal(
|
||||
getUnremindedUnreadMessageTimeRanges(db, [
|
||||
{
|
||||
conversationId: 'c1',
|
||||
lastRemindedAt: 0,
|
||||
includeStoryReplies: true,
|
||||
},
|
||||
]).length,
|
||||
1
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnreadReminderSummaryData', () => {
|
||||
const options = { includeStoryReplies: true, ourAci: OUR_ACI };
|
||||
|
||||
it('counts unread messages and their senders, most recent first', () => {
|
||||
insertData(db, 'messages', [
|
||||
composeMessage({
|
||||
conversationId: 'c1',
|
||||
receivedAtMs: yesterday,
|
||||
sourceServiceId: ALICE_ACI,
|
||||
}),
|
||||
composeMessage({
|
||||
conversationId: 'c1',
|
||||
receivedAtMs: yesterday + 5_000,
|
||||
sourceServiceId: BOB_ACI,
|
||||
}),
|
||||
composeMessage({
|
||||
conversationId: 'c1',
|
||||
receivedAtMs: yesterday + 1_000,
|
||||
sourceServiceId: ALICE_ACI,
|
||||
}),
|
||||
]);
|
||||
|
||||
const data = getUnreadReminderSummaryData(db, 'c1', options);
|
||||
assert.equal(data.unreadMessageCount, 3);
|
||||
assert.deepEqual(
|
||||
data.senders.map(({ sourceServiceId }) => sourceServiceId),
|
||||
[BOB_ACI, ALICE_ACI]
|
||||
);
|
||||
});
|
||||
|
||||
it('counts mentions of us and who made them', () => {
|
||||
insertData(db, 'messages', [
|
||||
composeMessage({ conversationId: 'c1', mentionsMe: 1 }),
|
||||
composeMessage({ conversationId: 'c1', mentionsMe: 0 }),
|
||||
]);
|
||||
|
||||
const data = getUnreadReminderSummaryData(db, 'c1', options);
|
||||
assert.equal(data.mentionCount, 1);
|
||||
assert.deepEqual(
|
||||
data.mentioners.map(({ sourceServiceId }) => sourceServiceId),
|
||||
[ALICE_ACI]
|
||||
);
|
||||
});
|
||||
|
||||
it('counts only replies quoting our own ACI', () => {
|
||||
insertData(db, 'messages', [
|
||||
composeMessage({ conversationId: 'c1', quoteAuthorAci: OUR_ACI }),
|
||||
composeMessage({ conversationId: 'c1', quoteAuthorAci: BOB_ACI }),
|
||||
composeMessage({ conversationId: 'c1' }),
|
||||
]);
|
||||
|
||||
const data = getUnreadReminderSummaryData(db, 'c1', options);
|
||||
assert.equal(data.replyCount, 1);
|
||||
assert.deepEqual(
|
||||
data.repliers.map(({ sourceServiceId }) => sourceServiceId),
|
||||
[ALICE_ACI]
|
||||
);
|
||||
});
|
||||
|
||||
it('does not count a reply that has already been read', () => {
|
||||
insertData(db, 'messages', [
|
||||
composeMessage({
|
||||
conversationId: 'c1',
|
||||
quoteAuthorAci: OUR_ACI,
|
||||
readStatus: ReadStatus.Read,
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(
|
||||
getUnreadReminderSummaryData(db, 'c1', options).replyCount,
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it('is scoped to a single conversation', () => {
|
||||
insertData(db, 'messages', [
|
||||
composeMessage({ conversationId: 'c1' }),
|
||||
composeMessage({ conversationId: 'c2' }),
|
||||
composeMessage({ conversationId: 'c2' }),
|
||||
]);
|
||||
|
||||
assert.equal(
|
||||
getUnreadReminderSummaryData(db, 'c1', options).unreadMessageCount,
|
||||
1
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('query plans', () => {
|
||||
it('getUnremindedUnreadMessageTimeRangesQuery uses messages_unread_no_story_id index', () => {
|
||||
const details = explain(
|
||||
db,
|
||||
getUnremindedUnreadMessageTimeRangesQuery([
|
||||
{
|
||||
conversationId: 'c1',
|
||||
lastRemindedAt: 0,
|
||||
includeStoryReplies: true,
|
||||
},
|
||||
{
|
||||
conversationId: 'c2',
|
||||
lastRemindedAt: yesterday,
|
||||
includeStoryReplies: false,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
details.includes(
|
||||
'SEARCH messages USING INDEX messages_unread_no_story_id ' +
|
||||
'(conversationId=? AND readStatus=? AND isStory=?)'
|
||||
),
|
||||
details
|
||||
);
|
||||
assert.ok(!details.includes('SCAN messages'), details);
|
||||
});
|
||||
|
||||
describe('summaryData', () => {
|
||||
for (const includeStoryReplies of [true, false]) {
|
||||
for (const filter of ['all', 'mentions', 'replies']) {
|
||||
// oxlint-disable-next-line no-loop-func
|
||||
it(`getUnreadReminderSendersQuery: ${filter}, ${includeStoryReplies}`, () => {
|
||||
const details = explain(
|
||||
db,
|
||||
getUnreadReminderSendersQuery(
|
||||
'c1',
|
||||
filter as UnreadReminderFilter,
|
||||
{
|
||||
includeStoryReplies,
|
||||
ourAci: OUR_ACI,
|
||||
}
|
||||
)
|
||||
);
|
||||
assert.ok(
|
||||
details.includes(`SEARCH messages USING INDEX messages_unread`),
|
||||
`${details} uses messages_unread* index`
|
||||
);
|
||||
assert.ok(!details.includes('SCAN'), `${details} does not scan`);
|
||||
});
|
||||
// oxlint-disable-next-line no-loop-func
|
||||
it(`getUnreadReminderCountQuery: ${filter}, ${includeStoryReplies}`, () => {
|
||||
const details = explain(
|
||||
db,
|
||||
getUnreadReminderCountQuery(
|
||||
'c1',
|
||||
filter as UnreadReminderFilter,
|
||||
{
|
||||
includeStoryReplies,
|
||||
ourAci: OUR_ACI,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
details.includes(`SEARCH messages USING INDEX messages_unread`),
|
||||
`${details} uses messages_unread* index`
|
||||
);
|
||||
assert.ok(!details.includes('SCAN'), `${details} does not scan`);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user