diff --git a/package.json b/package.json index d6b583d62c..04341228f9 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "test": "run-s test-node test-electron test-lint-intl test-eslint", "test-electron": "node ts/scripts/test-electron.node.js", "test-release": "node ts/scripts/test-release.node.js", - "test-node": "cross-env LANG=en-us electron-mocha --timeout 10000 --file test/setup-test-node.js --recursive ts/test-node", + "test-node": "cross-env NODE_ENV=test LANG=en-us electron-mocha --timeout 10000 --file test/setup-test-node.js --recursive ts/test-node", "test-mock": "node ts/scripts/mocha-separator.node.js --require ts/test-mock/setup-ci.node.js -- ts/test-mock/**/*_test.node.js", "test-mock-docker": "mocha --require ts/test-mock/setup-ci.node.js ts/test-mock/**/*_test.docker.node.js", "test-eslint": "mocha .eslint/rules/**/*.test.js --ignore-leaks", diff --git a/protos/SignalService.proto b/protos/SignalService.proto index a59d90830b..b353b124e8 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -394,8 +394,8 @@ message DataMessage { optional bytes targetAuthorAciBinary = 1; // 16-byte UUID optional uint64 targetSentTimestamp = 2; oneof pinDuration { - uint32 seconds = 3; - bool forever = 4; + uint32 pinDurationSeconds = 3; + bool pinDurationForever = 4; } } diff --git a/ts/RemoteConfig.dom.ts b/ts/RemoteConfig.dom.ts index 6c41984eae..b31627a376 100644 --- a/ts/RemoteConfig.dom.ts +++ b/ts/RemoteConfig.dom.ts @@ -63,6 +63,7 @@ const ScalarKeys = [ 'global.messageQueueTimeInSeconds', 'global.nicknames.max', 'global.nicknames.min', + 'global.pinned_message_limit', 'global.textAttachmentLimitBytes', ] as const; diff --git a/ts/background.preload.ts b/ts/background.preload.ts index 9cd57c43a2..e5c33e3e00 100644 --- a/ts/background.preload.ts +++ b/ts/background.preload.ts @@ -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 { async function onMessageReceived(event: MessageEvent): Promise { 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 { 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 { 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 { '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 { 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 { '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 { 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 { 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 { 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 { 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 { 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 { 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; diff --git a/ts/components/conversation/MessageContextMenu.dom.tsx b/ts/components/conversation/MessageContextMenu.dom.tsx index 7b0189ce42..fd3404016b 100644 --- a/ts/components/conversation/MessageContextMenu.dom.tsx +++ b/ts/components/conversation/MessageContextMenu.dom.tsx @@ -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')} )} - {isPinnedMessagesEnabled() && onPinMessage && ( + {isPinnedMessagesReceiveEnabled() && onPinMessage && ( {i18n('icu:MessageContextMenu__PinMessage')} diff --git a/ts/messageModifiers/PinnedMessages.preload.ts b/ts/messageModifiers/PinnedMessages.preload.ts new file mode 100644 index 0000000000..5aefe96b5c --- /dev/null +++ b/ts/messageModifiers/PinnedMessages.preload.ts @@ -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 { + 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 { + 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); +} diff --git a/ts/messageModifiers/helpers/findMessageModifierTarget.preload.ts b/ts/messageModifiers/helpers/findMessageModifierTarget.preload.ts new file mode 100644 index 0000000000..3cb8dd7a3a --- /dev/null +++ b/ts/messageModifiers/helpers/findMessageModifierTarget.preload.ts @@ -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 { + 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 }; +} diff --git a/ts/messageModifiers/helpers/isValidSenderAciForConversation.preload.ts b/ts/messageModifiers/helpers/isValidSenderAciForConversation.preload.ts new file mode 100644 index 0000000000..80e401d8a6 --- /dev/null +++ b/ts/messageModifiers/helpers/isValidSenderAciForConversation.preload.ts @@ -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); +} diff --git a/ts/messages/handleDataMessage.preload.ts b/ts/messages/handleDataMessage.preload.ts index f1860843bc..5ef524c0d6 100644 --- a/ts/messages/handleDataMessage.preload.ts +++ b/ts/messages/handleDataMessage.preload.ts @@ -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, diff --git a/ts/models/conversations.preload.ts b/ts/models/conversations.preload.ts index 4ca1f1461f..ba534b0882 100644 --- a/ts/models/conversations.preload.ts +++ b/ts/models/conversations.preload.ts @@ -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 { @@ -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(); diff --git a/ts/sql/Interface.std.ts b/ts/sql/Interface.std.ts index dd3f05539c..c0444e12a8 100644 --- a/ts/sql/Interface.std.ts +++ b/ts/sql/Interface.std.ts @@ -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; - createPinnedMessage: ( + appendPinnedMessage: ( + pinnedMessagesLimit: number, pinnedMessageParams: PinnedMessageParams - ) => PinnedMessage; - deletePinnedMessage: (pinnedMessageId: PinnedMessageId) => void; + ) => AppendPinnedMessageResult; + deletePinnedMessageByMessageId: (messageId: string) => PinnedMessageId | null; deleteAllExpiredPinnedMessagesBefore: ( beforeTimestamp: number ) => ReadonlyArray; diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index bc9abef74b..53ee22d738 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -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} `; diff --git a/ts/sql/migrations/1570-pinned-messages-updates.std.ts b/ts/sql/migrations/1570-pinned-messages-updates.std.ts new file mode 100644 index 0000000000..58e9f06793 --- /dev/null +++ b/ts/sql/migrations/1570-pinned-messages-updates.std.ts @@ -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); +} diff --git a/ts/sql/migrations/index.node.ts b/ts/sql/migrations/index.node.ts index 3b8120cca6..78de22ac5a 100644 --- a/ts/sql/migrations/index.node.ts +++ b/ts/sql/migrations/index.node.ts @@ -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 = [ { 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 { diff --git a/ts/sql/server/pinnedMessages.std.ts b/ts/sql/server/pinnedMessages.std.ts index aaecc019b7..29bff26c7f 100644 --- a/ts/sql/server/pinnedMessages.std.ts +++ b/ts/sql/server/pinnedMessages.std.ts @@ -15,14 +15,25 @@ export function getPinnedMessagesForConversation( conversationId: string ): ReadonlyArray { const [query, params] = sql` - SELECT * FROM pins + SELECT * FROM pinnedMessages WHERE conversationId = ${conversationId} ORDER BY pinnedAt DESC `; return db.prepare(query).all(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(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 { + 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(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; +}>; + +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(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 `; diff --git a/ts/state/smart/PinnedMessagesPanel.preload.tsx b/ts/state/smart/PinnedMessagesPanel.preload.tsx index f8c6420592..17d5f84005 100644 --- a/ts/state/smart/PinnedMessagesPanel.preload.tsx +++ b/ts/state/smart/PinnedMessagesPanel.preload.tsx @@ -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> = 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> = }) .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, }; diff --git a/ts/test-node/sql/server/pinnedMessages_test.node.ts b/ts/test-node/sql/server/pinnedMessages_test.node.ts new file mode 100644 index 0000000000..066b80aa6f --- /dev/null +++ b/ts/test-node/sql/server/pinnedMessages_test.node.ts @@ -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) { + 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]); + }); + }); +}); diff --git a/ts/textsecure/MessageReceiver.preload.ts b/ts/textsecure/MessageReceiver.preload.ts index a204c12384..e2e73d6a9a 100644 --- a/ts/textsecure/MessageReceiver.preload.ts +++ b/ts/textsecure/MessageReceiver.preload.ts @@ -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, diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index a6dcf5d395..6d12defda4 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -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; @@ -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; }; diff --git a/ts/textsecure/messageReceiverEvents.std.ts b/ts/textsecure/messageReceiverEvents.std.ts index 3c9b4c13bc..3c34cda260 100644 --- a/ts/textsecure/messageReceiverEvents.std.ts +++ b/ts/textsecure/messageReceiverEvents.std.ts @@ -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']; diff --git a/ts/textsecure/processDataMessage.preload.ts b/ts/textsecure/processDataMessage.preload.ts index 126d21f4de..fd1d14400b 100644 --- a/ts/textsecure/processDataMessage.preload.ts +++ b/ts/textsecure/processDataMessage.preload.ts @@ -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); diff --git a/ts/types/PinnedMessage.std.ts b/ts/types/PinnedMessage.std.ts index 6068235779..902ae95aa7 100644 --- a/ts/types/PinnedMessage.std.ts +++ b/ts/types/PinnedMessage.std.ts @@ -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; }>; diff --git a/ts/util/getConversationMembers.dom.ts b/ts/util/getConversationMembers.dom.ts index 77dfc4499d..0c88c4f379 100644 --- a/ts/util/getConversationMembers.dom.ts +++ b/ts/util/getConversationMembers.dom.ts @@ -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 } = {} diff --git a/ts/util/isPinnedMessagesEnabled.std.ts b/ts/util/isPinnedMessagesEnabled.std.ts index 407daec866..3ee4941adb 100644 --- a/ts/util/isPinnedMessagesEnabled.std.ts +++ b/ts/util/isPinnedMessagesEnabled.std.ts @@ -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(); +}