Add receive support for pin/unpin message

Co-authored-by: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com>
This commit is contained in:
automated-signal
2025-12-05 13:21:03 -06:00
committed by GitHub
parent ddb8ed18b9
commit 19a4fec1f2
24 changed files with 861 additions and 97 deletions

View File

@@ -70,6 +70,7 @@ import type {
PinnedMessageId,
PinnedMessageParams,
} from '../types/PinnedMessage.std.js';
import type { AppendPinnedMessageResult } from './server/pinnedMessages.std.js';
export type ReadableDB = Database & { __readable_db: never };
export type WritableDB = ReadableDB & { __writable_db: never };
@@ -1334,10 +1335,11 @@ type WritableInterface = {
messageQueueTime: number
) => ReadonlyArray<ChatFolderId>;
createPinnedMessage: (
appendPinnedMessage: (
pinnedMessagesLimit: number,
pinnedMessageParams: PinnedMessageParams
) => PinnedMessage;
deletePinnedMessage: (pinnedMessageId: PinnedMessageId) => void;
) => AppendPinnedMessageResult;
deletePinnedMessageByMessageId: (messageId: string) => PinnedMessageId | null;
deleteAllExpiredPinnedMessagesBefore: (
beforeTimestamp: number
) => ReadonlyArray<PinnedMessageId>;

View File

@@ -260,8 +260,8 @@ import {
import {
getPinnedMessagesForConversation,
getNextExpiringPinnedMessageAcrossConversations,
createPinnedMessage,
deletePinnedMessage,
appendPinnedMessage,
deletePinnedMessageByMessageId,
deleteAllExpiredPinnedMessagesBefore,
} from './server/pinnedMessages.std.js';
import { INITIAL_EXPIRE_TIMER_VERSION } from '../util/expirationTimer.std.js';
@@ -735,8 +735,8 @@ export const DataWriter: ServerWritableInterface = {
markChatFolderDeleted,
deleteExpiredChatFolders,
createPinnedMessage,
deletePinnedMessage,
appendPinnedMessage,
deletePinnedMessageByMessageId,
deleteAllExpiredPinnedMessagesBefore,
removeAll,
@@ -988,6 +988,9 @@ export function setupTests(db: WritableDB): void {
const silentLogger = {
...consoleLogger,
info: noop,
child() {
return silentLogger;
},
};
logger = silentLogger;
@@ -3395,7 +3398,7 @@ function getUnreadByConversationAndMarkRead(
conversationId = ${conversationId} AND
${storyReplyFilter} AND
type IN ('incoming', 'poll-terminate') AND
hasExpireTimer IS 1 AND
hasExpireTimer IS 1 AND
received_at <= ${readMessageReceivedAt}
`;

View File

@@ -0,0 +1,17 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { WritableDB } from '../Interface.std.js';
import { sql } from '../util.std.js';
export default function updateToSchemaVersion1570(db: WritableDB): void {
const [query] = sql`
-- We only need the 'messageId' column
ALTER TABLE pinnedMessages DROP COLUMN messageSentAt;
ALTER TABLE pinnedMessages DROP COLUMN messageSenderAci;
-- We dont need to know who pinned the message
ALTER TABLE pinnedMessages DROP COLUMN pinnedByAci;
`;
db.exec(query);
}

View File

@@ -133,6 +133,7 @@ import updateToSchemaVersion1540 from './1540-partial-expiring-index.std.js';
import updateToSchemaVersion1550 from './1550-has-link-preview.std.js';
import updateToSchemaVersion1560 from './1560-pinned-messages.std.js';
import updateToSchemaVersion1561 from './1561-cleanup-polls.std.js';
import updateToSchemaVersion1570 from './1570-pinned-messages-updates.std.js';
import { DataWriter } from '../Server.node.js';
@@ -1625,6 +1626,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray<SchemaUpdateType> = [
{ version: 1560, update: updateToSchemaVersion1560 },
// 1561, 1551, and 1541 all refer to the same migration
{ version: 1561, update: updateToSchemaVersion1561 },
{ version: 1570, update: updateToSchemaVersion1570 },
];
export class DBVersionFromFutureError extends Error {

View File

@@ -15,14 +15,25 @@ export function getPinnedMessagesForConversation(
conversationId: string
): ReadonlyArray<PinnedMessage> {
const [query, params] = sql`
SELECT * FROM pins
SELECT * FROM pinnedMessages
WHERE conversationId = ${conversationId}
ORDER BY pinnedAt DESC
`;
return db.prepare(query).all<PinnedMessage>(params);
}
export function createPinnedMessage(
function _getPinnedMessageByMessageId(
db: ReadableDB,
messageId: string
): PinnedMessage | null {
const [query, params] = sql`
SELECT * FROM pinnedMessages
WHERE messageId IS ${messageId}
`;
return db.prepare(query).get<PinnedMessage>(params) ?? null;
}
function _insertPinnedMessage(
db: WritableDB,
pinnedMessageParams: PinnedMessageParams
): PinnedMessage {
@@ -30,17 +41,11 @@ export function createPinnedMessage(
INSERT INTO pinnedMessages (
conversationId,
messageId,
messageSentAt,
messageSenderAci,
pinnedByAci,
pinnedAt,
expiresAt
) VALUES (
${pinnedMessageParams.conversationId},
${pinnedMessageParams.messageId},
${pinnedMessageParams.messageSentAt},
${pinnedMessageParams.messageSenderAci},
${pinnedMessageParams.pinnedByAci},
${pinnedMessageParams.pinnedAt},
${pinnedMessageParams.expiresAt}
)
@@ -52,13 +57,10 @@ export function createPinnedMessage(
return row;
}
export function deletePinnedMessage(
db: WritableDB,
pinnedMessageId: PinnedMessageId
): void {
function _deletePinnedMessageById(db: WritableDB, id: PinnedMessageId): void {
const [query, params] = sql`
DELETE FROM pinnedMessages
WHERE id = ${pinnedMessageId}
WHERE id = ${id}
`;
const result = db.prepare(query).run(params);
strictAssert(
@@ -67,11 +69,106 @@ export function deletePinnedMessage(
);
}
function _truncatePinnedMessagesByConversationId(
db: WritableDB,
conversationId: string,
pinnedMessagesLimit: number
): ReadonlyArray<PinnedMessageId> {
const [query, params] = sql`
DELETE FROM pinnedMessages
WHERE conversationId = ${conversationId}
AND id NOT IN (
SELECT id FROM pinnedMessages
WHERE conversationId = ${conversationId}
ORDER BY pinnedAt DESC
LIMIT ${pinnedMessagesLimit}
)
RETURNING id
`;
return db.prepare(query, { pluck: true }).all<PinnedMessageId>(params);
}
export type AppendPinnedMessageChange = Readonly<{
inserted: PinnedMessage;
replaced: PinnedMessageId | null;
}>;
export type AppendPinnedMessageResult = Readonly<{
change: AppendPinnedMessageChange | null;
// Note: The `inserted` pin may immediately be truncated
truncated: ReadonlyArray<PinnedMessageId>;
}>;
export function appendPinnedMessage(
db: WritableDB,
pinnedMessagesLimit: number,
pinnedMessageParams: PinnedMessageParams
): AppendPinnedMessageResult {
return db.transaction(() => {
const existing = _getPinnedMessageByMessageId(
db,
pinnedMessageParams.messageId
);
let shouldInsertOrReplace: boolean;
if (existing == null) {
// Always insert if there's no existing
shouldInsertOrReplace = true;
} else if (pinnedMessageParams.pinnedAt > existing.pinnedAt) {
// Only replace if the pin is newer
shouldInsertOrReplace = true;
} else {
shouldInsertOrReplace = false;
}
let change: AppendPinnedMessageChange | null = null;
if (shouldInsertOrReplace) {
let replaced: PinnedMessageId | null = null;
if (existing != null) {
_deletePinnedMessageById(db, existing.id);
replaced = existing.id;
}
const inserted = _insertPinnedMessage(db, pinnedMessageParams);
change = { inserted, replaced };
}
const truncated = _truncatePinnedMessagesByConversationId(
db,
pinnedMessageParams.conversationId,
pinnedMessagesLimit
);
return { change, truncated };
})();
}
export function deletePinnedMessageByMessageId(
db: WritableDB,
messageId: string
): PinnedMessageId | null {
const [query, params] = sql`
DELETE FROM pinnedMessages
WHERE messageId = ${messageId}
RETURNING id
`;
const result = db
.prepare(query, { pluck: true })
.get<PinnedMessageId>(params);
return result ?? null;
}
export function getNextExpiringPinnedMessageAcrossConversations(
db: ReadableDB
): PinnedMessage | null {
const [query, params] = sql`
SELECT * FROM pinnedMessages
WHERE expiresAt IS NOT null
ORDER BY expiresAt ASC
LIMIT 1
`;