Add receive support for pin/unpin message

This commit is contained in:
Jamie
2025-12-04 12:47:19 -08:00
committed by GitHub
parent 1b03cc4b9b
commit efe2c8de71
24 changed files with 861 additions and 97 deletions

View File

@@ -63,6 +63,7 @@ const ScalarKeys = [
'global.messageQueueTimeInSeconds',
'global.nicknames.max',
'global.nicknames.min',
'global.pinned_message_limit',
'global.textAttachmentLimitBytes',
] as const;

View File

@@ -162,6 +162,7 @@ import * as Deletes from './messageModifiers/Deletes.preload.js';
import * as Edits from './messageModifiers/Edits.preload.js';
import * as MessageReceipts from './messageModifiers/MessageReceipts.preload.js';
import * as MessageRequests from './messageModifiers/MessageRequests.preload.js';
import * as PinnedMessages from './messageModifiers/PinnedMessages.preload.js';
import * as Polls from './messageModifiers/Polls.preload.js';
import * as Reactions from './messageModifiers/Reactions.preload.js';
import * as ViewOnceOpenSyncs from './messageModifiers/ViewOnceOpenSyncs.preload.js';
@@ -281,6 +282,7 @@ import {
} from './types/Message2.preload.js';
import { JobCancelReason } from './jobs/types.std.js';
import { itemStorage } from './textsecure/Storage.preload.js';
import { isPinnedMessagesReceiveEnabled } from './util/isPinnedMessagesEnabled.std.js';
const { isNumber, throttle } = lodash;
@@ -2373,6 +2375,13 @@ export async function startApp(): Promise<void> {
async function onMessageReceived(event: MessageEvent): Promise<void> {
const { data, confirm } = event;
const { conversation: fromConversation } =
window.ConversationController.maybeMergeContacts({
e164: data.source,
aci: data.sourceAci,
reason: 'onMessageReceived',
});
const messageDescriptor = getMessageDescriptor({
// 'message' event: for 1:1 converations, the conversation is same as sender
destinationE164: data.source,
@@ -2447,13 +2456,6 @@ export async function startApp(): Promise<void> {
reaction.targetTimestamp,
'Reaction without targetTimestamp'
);
const { conversation: fromConversation } =
window.ConversationController.maybeMergeContacts({
e164: data.source,
aci: data.sourceAci,
reason: 'onMessageReceived:reaction',
});
strictAssert(fromConversation, 'Reaction without fromConversation');
log.info('Queuing incoming reaction for', reaction.targetTimestamp);
const attributes: ReactionAttributesType = {
@@ -2474,6 +2476,23 @@ export async function startApp(): Promise<void> {
return;
}
if (data.message.pinMessage != null) {
if (!isPinnedMessagesReceiveEnabled()) {
log.warn('Dropping PinMessage because the flag is disabled');
confirm();
return;
}
await PinnedMessages.onPinnedMessageAdd({
targetSentTimestamp: data.message.pinMessage.targetSentTimestamp,
targetAuthorAci: data.message.pinMessage.targetAuthorAci,
pinDuration: data.message.pinMessage.pinDuration,
pinnedByAci: data.sourceAci,
receivedAtTimestamp: data.receivedAtDate,
});
confirm();
return;
}
if (data.message.pollVote) {
if (!isPollReceiveEnabled()) {
log.warn('Dropping PollVote because the flag is disabled');
@@ -2498,14 +2517,6 @@ export async function startApp(): Promise<void> {
'DataMessage.PollVote.targetAuthorAci'
);
const { conversation: fromConversation } =
window.ConversationController.maybeMergeContacts({
e164: data.source,
aci: data.sourceAci,
reason: 'onMessageReceived:pollVote',
});
strictAssert(fromConversation, 'PollVote without fromConversation');
log.info('Queuing incoming poll vote for', pollVote.targetTimestamp);
const attributes: PollVoteAttributesType = {
envelopeId: data.envelopeId,
@@ -2542,14 +2553,6 @@ export async function startApp(): Promise<void> {
return;
}
const { conversation: fromConversation } =
window.ConversationController.maybeMergeContacts({
e164: data.source,
aci: data.sourceAci,
reason: 'onMessageReceived:pollTerminate',
});
strictAssert(fromConversation, 'PollTerminate without fromConversation');
log.info(
'Queuing incoming poll termination for',
pollTerminate.targetTimestamp
@@ -2579,13 +2582,6 @@ export async function startApp(): Promise<void> {
'Delete missing targetSentTimestamp'
);
strictAssert(data.serverTimestamp, 'Delete missing serverTimestamp');
const { conversation: fromConversation } =
window.ConversationController.maybeMergeContacts({
e164: data.source,
aci: data.sourceAci,
reason: 'onMessageReceived:delete',
});
strictAssert(fromConversation, 'Delete missing fromConversation');
const attributes: DeleteAttributesType = {
envelopeId: data.envelopeId,
@@ -2603,13 +2599,6 @@ export async function startApp(): Promise<void> {
const { editedMessageTimestamp } = data.message;
strictAssert(editedMessageTimestamp, 'Edit missing targetSentTimestamp');
const { conversation: fromConversation } =
window.ConversationController.maybeMergeContacts({
aci: data.sourceAci,
e164: data.source,
reason: 'onMessageReceived:edit',
});
strictAssert(fromConversation, 'Edit missing fromConversation');
log.info('Queuing incoming edit for', {
editedMessageTimestamp,
@@ -2631,6 +2620,21 @@ export async function startApp(): Promise<void> {
return;
}
if (data.message.unpinMessage != null) {
if (!isPinnedMessagesReceiveEnabled()) {
log.warn('Dropping UnpinMessage because the flag is disabled');
confirm();
return;
}
await PinnedMessages.onPinnedMessageRemove({
targetSentTimestamp: data.message.unpinMessage.targetSentTimestamp,
targetAuthorAci: data.message.unpinMessage.targetAuthorAci,
unpinnedByAci: data.sourceAci,
});
confirm();
return;
}
if (handleGroupCallUpdateMessage(data.message, messageDescriptor)) {
confirm();
return;
@@ -2715,7 +2719,7 @@ export async function startApp(): Promise<void> {
descriptor: MessageDescriptor
) {
const now = Date.now();
const timestamp = data.timestamp || now;
const { timestamp } = data;
const logId = `createSentMessage(${timestamp})`;
const ourId = window.ConversationController.getOurConversationIdOrThrow();
@@ -2893,8 +2897,9 @@ export async function startApp(): Promise<void> {
const { data, confirm } = event;
const source = itemStorage.user.getNumber();
strictAssert(source, 'Missing user number');
const sourceServiceId = itemStorage.user.getAci();
strictAssert(source && sourceServiceId, 'Missing user number and uuid');
strictAssert(sourceServiceId, 'Missing user aci');
// Make sure destination conversation is created before we hit getMessageDescriptor
if (
@@ -2977,6 +2982,19 @@ export async function startApp(): Promise<void> {
return;
}
if (data.message.pinMessage != null) {
strictAssert(data.timestamp != null, 'Missing sent timestamp');
await PinnedMessages.onPinnedMessageAdd({
targetSentTimestamp: data.message.pinMessage.targetSentTimestamp,
targetAuthorAci: data.message.pinMessage.targetAuthorAci,
pinDuration: data.message.pinMessage.pinDuration,
pinnedByAci: sourceServiceId,
receivedAtTimestamp: data.receivedAtDate,
});
confirm();
return;
}
if (data.message.pollVote) {
if (!isPollReceiveEnabled()) {
log.warn('Dropping PollVote because the flag is disabled');
@@ -3108,6 +3126,16 @@ export async function startApp(): Promise<void> {
return;
}
if (data.message.unpinMessage != null) {
await PinnedMessages.onPinnedMessageRemove({
targetSentTimestamp: data.message.unpinMessage.targetSentTimestamp,
targetAuthorAci: data.message.unpinMessage.targetAuthorAci,
unpinnedByAci: sourceServiceId,
});
confirm();
return;
}
if (handleGroupCallUpdateMessage(data.message, messageDescriptor)) {
event.confirm();
return;

View File

@@ -4,7 +4,7 @@
import React, { type ReactNode } from 'react';
import type { LocalizerType } from '../../types/I18N.std.js';
import { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
import { isPinnedMessagesEnabled } from '../../util/isPinnedMessagesEnabled.std.js';
import { isPinnedMessagesReceiveEnabled } from '../../util/isPinnedMessagesEnabled.std.js';
type MessageContextMenuProps = Readonly<{
i18n: LocalizerType;
@@ -99,7 +99,7 @@ export function MessageContextMenu({
{i18n('icu:copy')}
</AxoMenuBuilder.Item>
)}
{isPinnedMessagesEnabled() && onPinMessage && (
{isPinnedMessagesReceiveEnabled() && onPinMessage && (
<AxoMenuBuilder.Item symbol="pin" onSelect={onPinMessage}>
{i18n('icu:MessageContextMenu__PinMessage')}
</AxoMenuBuilder.Item>

View File

@@ -0,0 +1,193 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { DataWriter } from '../sql/Client.preload.js';
import type { AciString } from '../types/ServiceId.std.js';
import { DurationInSeconds } from '../util/durations/duration-in-seconds.std.js';
import * as RemoteConfig from '../RemoteConfig.dom.js';
import { parseIntWithFallback } from '../util/parseIntWithFallback.std.js';
import { createLogger } from '../logging/log.std.js';
import type { MessageModifierTarget } from './helpers/findMessageModifierTarget.preload.js';
import { findMessageModifierTarget } from './helpers/findMessageModifierTarget.preload.js';
import { isValidSenderAciForConversation } from './helpers/isValidSenderAciForConversation.preload.js';
import { isGroupV2 } from '../util/whatTypeOfConversation.dom.js';
import { SignalService as Proto } from '../protobuf/index.std.js';
import type { ConversationModel } from '../models/conversations.preload.js';
const { AccessRequired } = Proto.AccessControl;
const { Role } = Proto.Member;
const parentLog = createLogger('PinnedMessages');
export type PinnedMessageAddProps = Readonly<{
targetSentTimestamp: number;
targetAuthorAci: AciString;
pinDuration: DurationInSeconds | null;
pinnedByAci: AciString;
receivedAtTimestamp: number;
}>;
export type PinnedMessageRemoveProps = Readonly<{
targetSentTimestamp: number;
targetAuthorAci: AciString;
unpinnedByAci: AciString;
}>;
export async function onPinnedMessageAdd(
props: PinnedMessageAddProps
): Promise<void> {
const log = parentLog.child(
`onPinnedMessageAdd(timestamp=${props.targetSentTimestamp}, aci=${props.targetAuthorAci})`
);
const target = await findMessageModifierTarget(
props.targetSentTimestamp,
props.targetAuthorAci
);
if (target == null) {
// Could potentially happen with out-of-order processing,
// or when the targetted message was before we joined a group
log.warn('Missing target message, dropping');
return;
}
const invalid = validatePinnedMessageTarget(target, props.pinnedByAci);
if (invalid != null) {
log.info(`Message is invalid target (error: ${invalid.error}), dropping`);
return;
}
const { targetMessage, targetConversation } = target;
const expiresAt = getPinnedMessageExpiresAt(
props.receivedAtTimestamp,
props.pinDuration
);
const pinnedMessagesLimit = getPinnedMessagesLimit();
const result = await DataWriter.appendPinnedMessage(pinnedMessagesLimit, {
conversationId: targetConversation.id,
messageId: targetMessage.id,
expiresAt,
pinnedAt: props.receivedAtTimestamp,
});
if (result.change == null) {
log.warn('Skipped pinning message, existing message may have been newer');
} else if (result.change.replaced != null) {
log.info(
`Replaced pinned message ${result.change.replaced} with ${result.change.inserted.id} for target message ${targetMessage.id}`
);
} else {
log.info(
`Created pinned message ${result.change.inserted.id} for target message ${targetMessage.id}`
);
}
for (const pinnedMessageId of result.truncated) {
if (pinnedMessageId === result.change?.inserted.id) {
log.warn(`Pinned message ${pinnedMessageId} was immediately truncated`);
} else {
log.info(`Truncated older pinned message ${pinnedMessageId}`);
}
}
}
export async function onPinnedMessageRemove(
props: PinnedMessageRemoveProps
): Promise<void> {
const log = parentLog.child(
`onPinnedMessageRemove(timestamp=${props.targetSentTimestamp}, aci=${props.targetAuthorAci})`
);
const target = await findMessageModifierTarget(
props.targetSentTimestamp,
props.targetAuthorAci
);
if (target == null) {
// Could potentially happen with out-of-order processing,
// or when the targetted message was before we joined a group
log.warn('Missing target message, dropping');
return;
}
const invalid = validatePinnedMessageTarget(target, props.unpinnedByAci);
if (invalid != null) {
log.warn(`Message is invalid target: ${invalid.error}, dropping`);
return;
}
const targetMessageId = target.targetMessage.id;
const deletedPinnedMessageId =
await DataWriter.deletePinnedMessageByMessageId(targetMessageId);
if (deletedPinnedMessageId == null) {
log.warn(`Target message ${targetMessageId} was not pinned, dropping`);
return;
}
log.info(
`Deleted pinned message ${deletedPinnedMessageId} for messageId ${targetMessageId}`
);
}
function canSenderEditGroupAttributes(
conversation: ConversationModel,
sourceAci: AciString
): boolean {
if (!isGroupV2(conversation.attributes)) {
// Just ignore direct conversations
return true;
}
const membersV2 = conversation.get('membersV2') ?? [];
const member = membersV2.find(m => m.aci === sourceAci);
if (member == null) {
return false;
}
const accessControl = conversation.get('accessControl');
if (accessControl == null) {
return false;
}
if (member.role === Role.ADMINISTRATOR) {
return true;
}
return accessControl.attributes === AccessRequired.MEMBER;
}
function validatePinnedMessageTarget(
target: MessageModifierTarget,
sourceAci: AciString
): { error: string } | null {
if (!isValidSenderAciForConversation(target.targetConversation, sourceAci)) {
return { error: 'Sender cannot send to target conversation' };
}
if (!canSenderEditGroupAttributes(target.targetConversation, sourceAci)) {
return { error: 'Sender does not have access to edit group attributes' };
}
return null;
}
function getPinnedMessageExpiresAt(
receivedAtTimestamp: number,
pinDuration: DurationInSeconds | null
): number | null {
if (pinDuration == null) {
return null;
}
const pinDurationMs = DurationInSeconds.toMillis(pinDuration);
return receivedAtTimestamp + pinDurationMs;
}
function getPinnedMessagesLimit(): number {
const remoteValue = RemoteConfig.getValue('global.pinned_message_limit');
return parseIntWithFallback(remoteValue, 3);
}

View File

@@ -0,0 +1,45 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AciString } from '../../types/ServiceId.std.js';
import { getAuthorId } from '../../messages/sources.preload.js';
import type { ConversationModel } from '../../models/conversations.preload.js';
import { strictAssert } from '../../util/assert.std.js';
import type { MessageModel } from '../../models/messages.preload.js';
export type MessageModifierTarget = Readonly<{
targetMessage: MessageModel;
targetConversation: ConversationModel;
}>;
export async function findMessageModifierTarget(
targetSentTimestamp: number,
targetAuthorAci: AciString
): Promise<MessageModifierTarget | null> {
const authorConversation = window.ConversationController.lookupOrCreate({
serviceId: targetAuthorAci,
reason: 'findTargetMessageBySentAtAndAuthorAci',
});
if (authorConversation == null) {
return null;
}
const targetMessage = await window.MessageCache.findBySentAt(
targetSentTimestamp,
message => {
return getAuthorId(message.attributes) === authorConversation.id;
}
);
if (targetMessage == null) {
return null;
}
const targetConversation = window.ConversationController.get(
targetMessage.get('conversationId')
);
strictAssert(targetConversation, 'Missing conversation for target message');
return { targetMessage, targetConversation };
}

View File

@@ -0,0 +1,19 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AciString } from '@signalapp/mock-server/src/types';
import type { ConversationModel } from '../../models/conversations.preload.js';
import { itemStorage } from '../../textsecure/Storage.preload.js';
export function isValidSenderAciForConversation(
conversation: ConversationModel,
senderAci: AciString
): boolean {
const ourAci = itemStorage.user.getCheckedAci();
if (senderAci === ourAci) {
return true;
}
return conversation.hasMember(senderAci);
}

View File

@@ -17,7 +17,6 @@ import {
deliveryReceiptBatcher,
} from '../util/deliveryReceipt.preload.js';
import { getSenderIdentifier } from '../util/getSenderIdentifier.dom.js';
import { isNormalNumber } from '../util/isNormalNumber.std.js';
import { upgradeMessageSchema } from '../util/migrations.preload.js';
import { getOwn } from '../util/getOwn.std.js';
import {
@@ -184,10 +183,7 @@ export async function handleDataMessage(
return;
}
const updatedAt: number =
data && isNormalNumber(data.timestamp)
? data.timestamp
: Date.now();
const updatedAt: number = data?.timestamp ?? Date.now();
const previousSendState = getOwn(
sendStateByConversationId,

View File

@@ -3848,6 +3848,7 @@ export class ConversationModel {
return buildGroupLink(this.attributes);
}
// TODO(DESKTOP-9497): This will not include `ourAci` in 1:1 chats
getMembers(
options: { includePendingMembers?: boolean } = {}
): Array<ConversationModel> {
@@ -5258,6 +5259,7 @@ export class ConversationModel {
await DataWriter.updateConversation(this.attributes);
}
// TODO(DESKTOP-9497): This will return false for `ourAci` in 1:1 chats
hasMember(serviceId: ServiceIdString): boolean {
const members = this.getMembers();

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
`;

View File

@@ -4,7 +4,6 @@
import React, { memo } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import type { AciString } from '@signalapp/mock-server/src/types.js';
import { getIntl } from '../selectors/user.std.js';
import { getConversationByIdSelector } from '../selectors/conversations.dom.js';
import { strictAssert } from '../../util/assert.std.js';
@@ -17,8 +16,6 @@ import type {
PinnedMessageId,
} from '../../types/PinnedMessage.std.js';
import type { StateType } from '../reducer.preload.js';
import { itemStorage } from '../../textsecure/Storage.preload.js';
import { isAciString } from '../../util/isAciString.std.js';
export type SmartPinnedMessagesPanelProps = Readonly<{
conversationId: string;
@@ -41,8 +38,6 @@ const mockSelectPinnedMessages: StateSelector<ReadonlyArray<PinnedMessage>> =
conversations.messagesByConversation[selectedConversationId]
?.messageIds ?? [];
const ourAci = itemStorage.user.getCheckedAci();
return messageIds
.map(messageId => {
return conversations.messagesLookup[messageId] ?? null;
@@ -52,24 +47,10 @@ const mockSelectPinnedMessages: StateSelector<ReadonlyArray<PinnedMessage>> =
})
.slice(-10)
.map((message, messageIndex): PinnedMessage => {
let messageSenderAci: AciString;
if (message.type === 'outgoing') {
messageSenderAci = ourAci;
} else {
strictAssert(
isAciString(message.sourceServiceId),
'sourceServiceId must be aci string for incoming message'
);
messageSenderAci = message.sourceServiceId;
}
return {
id: messageIndex as PinnedMessageId,
conversationId: selectedConversationId,
messageId: message.id,
messageSentAt: message.sent_at,
messageSenderAci,
pinnedByAci: ourAci,
pinnedAt: Date.now(),
expiresAt: null,
};

View File

@@ -0,0 +1,304 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import assert from 'node:assert/strict';
import type { WritableDB } from '../../../sql/Interface.std.js';
import { setupTests } from '../../../sql/Server.node.js';
import type { AppendPinnedMessageResult } from '../../../sql/server/pinnedMessages.std.ts';
import {
appendPinnedMessage,
deletePinnedMessageByMessageId,
getNextExpiringPinnedMessageAcrossConversations,
deleteAllExpiredPinnedMessagesBefore,
} from '../../../sql/server/pinnedMessages.std.js';
import { createDB, insertData } from '../helpers.node.js';
import type {
PinnedMessage,
PinnedMessageParams,
} from '../../../types/PinnedMessage.std.js';
function setupData(db: WritableDB) {
insertData(db, 'conversations', [{ id: 'c1' }, { id: 'c2' }]);
insertData(db, 'messages', [
// conversation: c1
{ id: 'c1-m1', conversationId: 'c1' },
{ id: 'c1-m2', conversationId: 'c1' },
{ id: 'c1-m3', conversationId: 'c1' },
{ id: 'c1-m4', conversationId: 'c1' },
// conversation: c2
{ id: 'c2-m1', conversationId: 'c2' },
{ id: 'c2-m2', conversationId: 'c2' },
]);
}
function getParams(
conversationId: string,
messageId: string,
pinnedAt: number,
expiresAt: number | null = null
): PinnedMessageParams {
return {
messageId,
conversationId,
pinnedAt,
expiresAt,
};
}
describe('sql/server/pinnedMessages', () => {
let db: WritableDB;
beforeEach(() => {
db = createDB();
setupTests(db);
setupData(db);
});
afterEach(() => {
db.close();
});
function assertRows(expected: ReadonlyArray<PinnedMessage>) {
const rows = db.prepare('SELECT * FROM pinnedMessages').all();
assert.deepEqual(rows, expected);
}
function expectInserted(result: AppendPinnedMessageResult): PinnedMessage {
const inserted = result.change?.inserted;
assert(inserted != null, 'Append should have inserted a row');
return inserted;
}
describe('appendPinnedMessage', () => {
it('insert new pinned message', () => {
const params = getParams('c1', 'c1-m1', 1);
const result = appendPinnedMessage(db, 3, params);
const row = expectInserted(result);
assertRows([row]);
assert.deepEqual(result, {
change: {
inserted: { id: 1, ...params },
replaced: null,
},
truncated: [],
});
});
it('replace existing pinned message', () => {
const initial = getParams('c1', 'c1-m1', 1);
const updated = getParams('c1', 'c1-m1', 2);
appendPinnedMessage(db, 3, initial);
const result = appendPinnedMessage(db, 3, updated);
const row = expectInserted(result);
assertRows([row]);
assert.deepEqual(result, {
change: {
inserted: { id: 2, ...updated },
replaced: 1,
},
truncated: [],
});
});
it('truncates pinned messages to limit', () => {
const pin1 = getParams('c1', 'c1-m1', 1);
const pin2 = getParams('c1', 'c1-m2', 2);
const pin3 = getParams('c1', 'c1-m3', 3);
const pin4 = getParams('c1', 'c1-m4', 4);
const row1 = expectInserted(appendPinnedMessage(db, 3, pin1));
const row2 = expectInserted(appendPinnedMessage(db, 3, pin2));
const row3 = expectInserted(appendPinnedMessage(db, 3, pin3));
assertRows([row1, row2, row3]);
const result = appendPinnedMessage(db, 3, pin4);
const row4 = expectInserted(result);
assertRows([row2, row3, row4]);
assert.deepEqual(result, {
change: {
inserted: { id: 4, ...pin4 },
replaced: null,
},
truncated: [1],
});
});
it('doesnt truncate on top of replacing existing', () => {
const pin1 = getParams('c1', 'c1-m1', 1);
const pin2 = getParams('c1', 'c1-m2', 2);
const pin3 = getParams('c1', 'c1-m3', 3);
const updated = { ...pin3, pinnedAt: 4 };
const row1 = expectInserted(appendPinnedMessage(db, 3, pin1));
const row2 = expectInserted(appendPinnedMessage(db, 3, pin2));
const row3 = expectInserted(appendPinnedMessage(db, 3, pin3));
assertRows([row1, row2, row3]);
const result = appendPinnedMessage(db, 3, updated);
const row4 = expectInserted(result);
assertRows([row1, row2, row4]);
assert.deepEqual(result, {
change: {
inserted: { id: 4, ...updated },
replaced: 3,
},
truncated: [],
});
});
it('truncates multiple past limit', () => {
const pin1 = getParams('c1', 'c1-m1', 1);
const pin2 = getParams('c1', 'c1-m2', 2);
const pin3 = getParams('c1', 'c1-m3', 3);
const pin4 = getParams('c1', 'c1-m4', 4);
let limit = 3;
const row1 = expectInserted(appendPinnedMessage(db, limit, pin1));
const row2 = expectInserted(appendPinnedMessage(db, limit, pin2));
const row3 = expectInserted(appendPinnedMessage(db, limit, pin3));
assertRows([row1, row2, row3]);
limit = 2;
const result = appendPinnedMessage(db, limit, pin4);
const row4 = expectInserted(result);
assertRows([row3, row4]);
assert.deepEqual(result, {
change: {
inserted: { id: 4, ...pin4 },
replaced: null,
},
truncated: [1, 2],
});
});
it('truncates based on pinnedAt (not insert order) to handle out-of-order messages', () => {
const pin1 = getParams('c1', 'c1-m1', 1);
const pin2 = getParams('c1', 'c1-m2', 2);
const pin3 = getParams('c1', 'c1-m3', 3);
const pin4 = getParams('c1', 'c1-m4', 3);
const row2 = expectInserted(appendPinnedMessage(db, 3, pin2));
const row3 = expectInserted(appendPinnedMessage(db, 3, pin3));
const row4 = expectInserted(appendPinnedMessage(db, 3, pin4));
const result = appendPinnedMessage(db, 3, pin1);
assertRows([row2, row3, row4]);
assert.deepEqual(result, {
change: {
// Note: New row was immediately truncated
inserted: { id: 4, ...pin1 },
replaced: null,
},
truncated: [4],
});
});
it('should only truncate for the same conversation', () => {
const pin1 = getParams('c1', 'c1-m1', 1);
const pin2 = getParams('c1', 'c1-m2', 2);
const pin3 = getParams('c1', 'c1-m3', 3);
const pin4 = getParams('c2', 'c2-m1', 4); // other chat
const row1 = expectInserted(appendPinnedMessage(db, 3, pin2));
const row2 = expectInserted(appendPinnedMessage(db, 3, pin3));
const row3 = expectInserted(appendPinnedMessage(db, 3, pin4));
const result = appendPinnedMessage(db, 3, pin1);
const row4 = expectInserted(result);
assertRows([row1, row2, row3, row4]);
assert.deepEqual(result, {
change: {
inserted: { id: 4, ...pin1 },
replaced: null,
},
truncated: [],
});
});
});
describe('deletePinnedMessageByMessageId', () => {
it('should return null if theres no matching pinned message', () => {
const result = deletePinnedMessageByMessageId(db, 'c1-m1');
assert.equal(result, null);
});
it('should return the deleted pinned message id', () => {
appendPinnedMessage(db, 3, getParams('c1', 'c1-m1', 1));
const result = deletePinnedMessageByMessageId(db, 'c1-m1');
assert.equal(result, 1);
});
});
describe('getNextExpiringPinnedMessageAcrossConversations', () => {
it('should return null if theres no pinned messages', () => {
const result = getNextExpiringPinnedMessageAcrossConversations(db);
assert.equal(result, null);
});
it('should return null if the pinned messages have no expiration', () => {
appendPinnedMessage(db, 3, getParams('c1', 'c1-m1', 1, null));
const result = getNextExpiringPinnedMessageAcrossConversations(db);
assert.equal(result, null);
});
it('should return the pinned message with the earliest expiration date', () => {
const pin1 = getParams('c1', 'c1-m1', 1, 4);
const pin2 = getParams('c1', 'c1-m1', 2, 3);
const pin3 = getParams('c2', 'c2-m1', 3, 2);
const pin4 = getParams('c2', 'c2-m2', 4, 1); // expires next
appendPinnedMessage(db, 3, pin1);
appendPinnedMessage(db, 3, pin2);
appendPinnedMessage(db, 3, pin3);
appendPinnedMessage(db, 3, pin4);
const result = getNextExpiringPinnedMessageAcrossConversations(db);
assert.deepEqual(result, {
id: 4,
...pin4,
});
});
});
describe('deleteAllExpiredPinnedMessagesBefore', () => {
function insertPin(params: PinnedMessageParams) {
return expectInserted(appendPinnedMessage(db, 3, params));
}
it('should return an empty array if theres no pinned messages', () => {
const result = deleteAllExpiredPinnedMessagesBefore(db, 1);
assert.deepEqual(result, []);
});
it('should not delete pinned messages that have no expiration', () => {
const row = insertPin(getParams('c1', 'c1-m1', 1, null)); // no expiration
const result = deleteAllExpiredPinnedMessagesBefore(db, 1);
assertRows([row]);
assert.deepEqual(result, []);
});
it('should not delete pinned messages that have not expired yet ', () => {
const row = insertPin(getParams('c1', 'c1-m1', 1, 2)); // not expired yet
const result = deleteAllExpiredPinnedMessagesBefore(db, 1);
assertRows([row]);
assert.deepEqual(result, []);
});
it('should delete pinned messages that have expired', () => {
const row1 = insertPin(getParams('c1', 'c1-m1', 1, 1)); // expired
const row2 = insertPin(getParams('c1', 'c1-m2', 2, 2)); // expired
const row3 = insertPin(getParams('c1', 'c1-m3', 3, 3)); // not expired yet
const result = deleteAllExpiredPinnedMessagesBefore(db, 2);
assertRows([row3]);
assert.deepEqual(result, [row1.id, row2.id]);
});
});
});

View File

@@ -2101,12 +2101,14 @@ export default class MessageReceiver
log.warn(`${logId}: Dropping too-long message. Length: ${length}`);
}
strictAssert(timestamp, 'Missing sent timestamp');
const ev = new SentEvent(
{
envelopeId: envelope.id,
destinationE164: dropNull(destinationE164),
destinationServiceId,
timestamp: timestamp?.toNumber(),
timestamp: timestamp.toNumber(),
serverTimestamp: envelope.serverTimestamp,
device: envelope.sourceDevice,
unidentifiedStatus,

View File

@@ -185,6 +185,12 @@ export type ProcessedReaction = {
targetTimestamp?: number;
};
export type ProcessedPinMessage = Readonly<{
targetAuthorAci: AciString;
targetSentTimestamp: number;
pinDuration: DurationInSeconds | null;
}>;
export type ProcessedPollCreate = {
question?: string;
options?: Array<string>;
@@ -218,6 +224,11 @@ export type ProcessedGiftBadge = {
state: GiftBadgeStates;
};
export type ProcessedUnpinMessage = Readonly<{
targetAuthorAci: AciString;
targetSentTimestamp: number;
}>;
export type ProcessedStoryContext = {
authorAci: AciString | undefined;
sentTimestamp: number;
@@ -243,6 +254,7 @@ export type ProcessedDataMessage = {
isStory?: boolean;
isViewOnce: boolean;
reaction?: ProcessedReaction;
pinMessage?: ProcessedPinMessage;
pollCreate?: ProcessedPollCreate;
pollVote?: ProcessedPollVote;
pollTerminate?: ProcessedPollTerminate;
@@ -251,6 +263,7 @@ export type ProcessedDataMessage = {
groupCallUpdate?: ProcessedGroupCallUpdate;
storyContext?: ProcessedStoryContext;
giftBadge?: ProcessedGiftBadge;
unpinMessage?: ProcessedUnpinMessage;
canReplyToStory?: boolean;
};

View File

@@ -197,7 +197,7 @@ export type SentEventData = Readonly<{
envelopeId: string;
destinationE164?: string;
destinationServiceId?: ServiceIdString;
timestamp?: number;
timestamp: number;
serverTimestamp: number;
device: number | undefined;
unidentifiedStatus: ProcessedSent['unidentifiedStatus'];

View File

@@ -7,7 +7,10 @@ import lodash from 'lodash';
import { assertDev, strictAssert } from '../util/assert.std.js';
import { dropNull, shallowDropNull } from '../util/dropNull.std.js';
import { fromAciUuidBytesOrString } from '../util/ServiceId.node.js';
import {
fromAciUuidBytes,
fromAciUuidBytesOrString,
} from '../util/ServiceId.node.js';
import { getTimestampFromLong } from '../util/timestampLongUtils.std.js';
import { SignalService as Proto } from '../protobuf/index.std.js';
import { deriveGroupFields } from '../groups.preload.js';
@@ -28,6 +31,8 @@ import type {
ProcessedDelete,
ProcessedGiftBadge,
ProcessedStoryContext,
ProcessedPinMessage,
ProcessedUnpinMessage,
} from './Types.d.ts';
import { GiftBadgeStates } from '../types/GiftBadgeStates.std.js';
import {
@@ -317,6 +322,34 @@ export function processReaction(
};
}
export function processPinMessage(
pinMessage?: Proto.DataMessage.IPinMessage | null
): ProcessedPinMessage | undefined {
if (pinMessage == null) {
return undefined;
}
const targetSentTimestamp = pinMessage.targetSentTimestamp?.toNumber();
strictAssert(targetSentTimestamp, 'Missing targetSentTimestamp');
const targetAuthorAci = fromAciUuidBytes(pinMessage.targetAuthorAciBinary);
strictAssert(targetAuthorAci, 'Missing targetAuthorAciBinary');
let pinDuration: DurationInSeconds | null;
if (pinMessage.pinDurationForever) {
pinDuration = null;
} else {
strictAssert(pinMessage.pinDurationSeconds, 'Missing pinDurationSeconds');
pinDuration = DurationInSeconds.fromSeconds(pinMessage.pinDurationSeconds);
}
return {
targetSentTimestamp,
targetAuthorAci,
pinDuration,
};
}
export function processPollCreate(
pollCreate?: Proto.DataMessage.IPollCreate | null
): ProcessedPollCreate | undefined {
@@ -402,6 +435,25 @@ export function processGiftBadge(
};
}
export function processUnpinMessage(
unpinMessage?: Proto.DataMessage.IUnpinMessage | null
): ProcessedUnpinMessage | undefined {
if (unpinMessage == null) {
return undefined;
}
const targetSentTimestamp = unpinMessage.targetSentTimestamp?.toNumber();
strictAssert(targetSentTimestamp, 'Missing targetSentTimestamp');
const targetAuthorAci = fromAciUuidBytes(unpinMessage.targetAuthorAciBinary);
strictAssert(targetAuthorAci, 'Missing targetAuthorAciBinary');
return {
targetSentTimestamp,
targetAuthorAci,
};
}
export function processDataMessage(
message: Proto.IDataMessage,
envelopeTimestamp: number,
@@ -462,6 +514,7 @@ export function processDataMessage(
requiredProtocolVersion: dropNull(message.requiredProtocolVersion),
isViewOnce: Boolean(message.isViewOnce),
reaction: processReaction(message.reaction),
pinMessage: processPinMessage(message.pinMessage),
pollCreate: processPollCreate(message.pollCreate),
pollVote: processPollVote(message.pollVote),
pollTerminate: processPollTerminate(message.pollTerminate),
@@ -470,6 +523,7 @@ export function processDataMessage(
groupCallUpdate: dropNull(message.groupCallUpdate),
storyContext: processStoryContext(message.storyContext),
giftBadge: processGiftBadge(message.giftBadge),
unpinMessage: processUnpinMessage(message.unpinMessage),
};
const isEndSession = Boolean(result.flags & FLAGS.END_SESSION);

View File

@@ -3,7 +3,6 @@
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 };
@@ -11,9 +10,6 @@ export type PinnedMessage = Readonly<{
id: PinnedMessageId;
conversationId: string;
messageId: string;
messageSentAt: number;
messageSenderAci: AciString;
pinnedByAci: AciString;
pinnedAt: number;
expiresAt: number | null;
}>;

View File

@@ -8,6 +8,7 @@ import { isDirectConversation } from './whatTypeOfConversation.dom.js';
const { compact } = lodash;
// TODO(DESKTOP-9497): This will not include `ourAci` in 1:1 chats
export function getConversationMembers(
conversationAttrs: ConversationAttributesType,
options: { includePendingMembers?: boolean } = {}

View File

@@ -9,7 +9,7 @@ import {
isMockEnvironment,
} from '../environment.std.js';
export function isPinnedMessagesEnabled(): boolean {
function isDevEnv(): boolean {
const env = getEnvironment();
if (
@@ -22,3 +22,11 @@ export function isPinnedMessagesEnabled(): boolean {
return false;
}
export function isPinnedMessagesReceiveEnabled(): boolean {
return isDevEnv();
}
export function isPinnedMessagesSendEnabled(): boolean {
return isDevEnv();
}