diff --git a/protos/SignalService.proto b/protos/SignalService.proto index f180e62255..a59d90830b 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -390,6 +390,20 @@ message DataMessage { optional uint32 voteCount = 4; } + message PinMessage { + optional bytes targetAuthorAciBinary = 1; // 16-byte UUID + optional uint64 targetSentTimestamp = 2; + oneof pinDuration { + uint32 seconds = 3; + bool forever = 4; + } + } + + message UnpinMessage { + optional bytes targetAuthorAciBinary = 1; // 16-byte UUID + optional uint64 targetSentTimestamp = 2; + } + optional string body = 1; repeated AttachmentPointer attachments = 2; reserved /*groupV1*/ 3; @@ -415,7 +429,9 @@ message DataMessage { optional PollCreate pollCreate = 24; optional PollTerminate pollTerminate = 25; optional PollVote pollVote = 26; - // NEXT ID: 27 + optional PinMessage pinMessage = 27; + optional UnpinMessage unpinMessage = 28; + // NEXT ID: 29 } message NullMessage { diff --git a/ts/sql/Interface.std.ts b/ts/sql/Interface.std.ts index e1395bbc03..dd3f05539c 100644 --- a/ts/sql/Interface.std.ts +++ b/ts/sql/Interface.std.ts @@ -65,6 +65,11 @@ import type { DonationReceipt } from '../types/Donations.std.js'; import type { InsertOrUpdateCallLinkFromSyncResult } from './server/callLinks.node.js'; import type { ChatFolderId, ChatFolder } from '../types/ChatFolder.std.js'; import type { CurrentChatFolder } from '../types/CurrentChatFolders.std.js'; +import type { + PinnedMessage, + PinnedMessageId, + PinnedMessageParams, +} from '../types/PinnedMessage.std.js'; export type ReadableDB = Database & { __readable_db: never }; export type WritableDB = ReadableDB & { __writable_db: never }; @@ -965,6 +970,11 @@ type ReadableInterface = { hasAllChatsChatFolder: () => boolean; getOldestDeletedChatFolder: () => ChatFolder | null; + getPinnedMessagesForConversation: ( + conversationId: string + ) => ReadonlyArray; + getNextExpiringPinnedMessageAcrossConversations: () => PinnedMessage | null; + getMessagesNeedingUpgrade: ( limit: number, options: { maxVersion: number } @@ -1324,6 +1334,14 @@ type WritableInterface = { messageQueueTime: number ) => ReadonlyArray; + createPinnedMessage: ( + pinnedMessageParams: PinnedMessageParams + ) => PinnedMessage; + deletePinnedMessage: (pinnedMessageId: PinnedMessageId) => void; + deleteAllExpiredPinnedMessagesBefore: ( + beforeTimestamp: number + ) => ReadonlyArray; + removeAll: () => void; removeAllConfiguration: () => void; eraseStorageServiceState: () => void; diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index 9df266e3ac..4629536242 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -257,6 +257,13 @@ import { updateChatFolderDeletedAtTimestampMsFromSync, deleteExpiredChatFolders, } from './server/chatFolders.std.js'; +import { + getPinnedMessagesForConversation, + getNextExpiringPinnedMessageAcrossConversations, + createPinnedMessage, + deletePinnedMessage, + deleteAllExpiredPinnedMessagesBefore, +} from './server/pinnedMessages.std.js'; import { INITIAL_EXPIRE_TIMER_VERSION } from '../util/expirationTimer.std.js'; import type { GifType } from '../components/fun/panels/FunPanelGifs.dom.js'; import type { NotificationProfileType } from '../types/NotificationProfile.std.js'; @@ -471,6 +478,9 @@ export const DataReader: ServerReadableInterface = { hasAllChatsChatFolder, getOldestDeletedChatFolder, + getPinnedMessagesForConversation, + getNextExpiringPinnedMessageAcrossConversations, + callLinkExists, defunctCallLinkExists, getAllCallLinks, @@ -725,6 +735,10 @@ export const DataWriter: ServerWritableInterface = { markChatFolderDeleted, deleteExpiredChatFolders, + createPinnedMessage, + deletePinnedMessage, + deleteAllExpiredPinnedMessagesBefore, + removeAll, removeAllConfiguration, eraseStorageServiceState, @@ -8136,6 +8150,7 @@ function removeAll(db: WritableDB): void { DELETE FROM messages_fts; DELETE FROM messages; DELETE FROM notificationProfiles; + DELETE FROM pinnedMessages; DELETE FROM preKeys; DELETE FROM reactions; DELETE FROM recentGifs; diff --git a/ts/sql/migrations/1550-has-link-preview.std.ts b/ts/sql/migrations/1550-has-link-preview.std.ts index 5c4bb413c0..ea5269837f 100644 --- a/ts/sql/migrations/1550-has-link-preview.std.ts +++ b/ts/sql/migrations/1550-has-link-preview.std.ts @@ -3,7 +3,7 @@ import type { WritableDB } from '../Interface.std.js'; -export default function updateToSchemaVersion1520(db: WritableDB): void { +export default function updateToSchemaVersion1550(db: WritableDB): void { db.exec(` ALTER TABLE messages ADD COLUMN hasPreviews INTEGER NOT NULL diff --git a/ts/sql/migrations/1560-pinned-messages.std.ts b/ts/sql/migrations/1560-pinned-messages.std.ts new file mode 100644 index 0000000000..8e25c7f8fd --- /dev/null +++ b/ts/sql/migrations/1560-pinned-messages.std.ts @@ -0,0 +1,35 @@ +// 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 updateToSchemaVersion1560(db: WritableDB): void { + const [query] = sql` + CREATE TABLE pinnedMessages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversationId TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + messageId TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + messageSentAt INTEGER NOT NULL, + messageSenderAci TEXT NOT NULL, + pinnedByAci TEXT NOT NULL, + pinnedAt INTEGER NOT NULL, + expiresAt INTEGER, + UNIQUE (conversationId, messageId) + ) STRICT; + + CREATE INDEX pinnedMessages_byConversation + ON pinnedMessages( + conversationId, + pinnedAt DESC, + messageId + ); + + CREATE INDEX pinnedMessages_byExpiresAt + ON pinnedMessages( + expiresAt ASC + ) + WHERE expiresAt IS NOT NULL; + `; + db.exec(query); +} diff --git a/ts/sql/migrations/index.node.ts b/ts/sql/migrations/index.node.ts index 740602ff9d..8d229da536 100644 --- a/ts/sql/migrations/index.node.ts +++ b/ts/sql/migrations/index.node.ts @@ -131,6 +131,7 @@ import updateToSchemaVersion1520 from './1520-poll-votes-unread.std.js'; import updateToSchemaVersion1530 from './1530-update-expiring-index.std.js'; 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 { DataWriter } from '../Server.node.js'; @@ -1620,6 +1621,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray = [ { version: 1530, update: updateToSchemaVersion1530 }, { version: 1540, update: updateToSchemaVersion1540 }, { version: 1550, update: updateToSchemaVersion1550 }, + { version: 1560, update: updateToSchemaVersion1560 }, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/sql/server/pinnedMessages.std.ts b/ts/sql/server/pinnedMessages.std.ts new file mode 100644 index 0000000000..aaecc019b7 --- /dev/null +++ b/ts/sql/server/pinnedMessages.std.ts @@ -0,0 +1,91 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { + PinnedMessage, + PinnedMessageId, + PinnedMessageParams, +} from '../../types/PinnedMessage.std.js'; +import { strictAssert } from '../../util/assert.std.js'; +import type { ReadableDB, WritableDB } from '../Interface.std.js'; +import { sql } from '../util.std.js'; + +export function getPinnedMessagesForConversation( + db: ReadableDB, + conversationId: string +): ReadonlyArray { + const [query, params] = sql` + SELECT * FROM pins + WHERE conversationId = ${conversationId} + ORDER BY pinnedAt DESC + `; + return db.prepare(query).all(params); +} + +export function createPinnedMessage( + db: WritableDB, + pinnedMessageParams: PinnedMessageParams +): PinnedMessage { + const [query, params] = sql` + 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} + ) + RETURNING *; + `; + + const row = db.prepare(query).get(params); + strictAssert(row != null, 'createPinnedMessage: Failed to insert'); + return row; +} + +export function deletePinnedMessage( + db: WritableDB, + pinnedMessageId: PinnedMessageId +): void { + const [query, params] = sql` + DELETE FROM pinnedMessages + WHERE id = ${pinnedMessageId} + `; + const result = db.prepare(query).run(params); + strictAssert( + result.changes === 1, + `deletePinnedMessage: Expected changes: 1, Actual: ${result.changes}` + ); +} + +export function getNextExpiringPinnedMessageAcrossConversations( + db: ReadableDB +): PinnedMessage | null { + const [query, params] = sql` + SELECT * FROM pinnedMessages + ORDER BY expiresAt ASC + LIMIT 1 + `; + return db.prepare(query).get(params) ?? null; +} + +export function deleteAllExpiredPinnedMessagesBefore( + db: WritableDB, + beforeTimestamp: number +): ReadonlyArray { + const [query, params] = sql` + DELETE FROM pinnedMessages + WHERE expiresAt <= ${beforeTimestamp} + RETURNING id + `; + return db.prepare(query, { pluck: true }).all(params); +} diff --git a/ts/types/PinnedMessage.std.ts b/ts/types/PinnedMessage.std.ts new file mode 100644 index 0000000000..6068235779 --- /dev/null +++ b/ts/types/PinnedMessage.std.ts @@ -0,0 +1,27 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { MessageAttributesType } from '../model-types.js'; +import type { ConversationType } from '../state/ducks/conversations.preload.js'; +import type { AciString } from './ServiceId.std.js'; + +export type PinnedMessageId = number & { PinnedMessageId: never }; + +export type PinnedMessage = Readonly<{ + id: PinnedMessageId; + conversationId: string; + messageId: string; + messageSentAt: number; + messageSenderAci: AciString; + pinnedByAci: AciString; + pinnedAt: number; + expiresAt: number | null; +}>; + +export type PinnedMessageParams = Omit; + +export type PinnedMessageRenderData = Readonly<{ + pinnedMessage: PinnedMessage; + sender: ConversationType; + message: MessageAttributesType; +}>;