mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-05-08 08:58:38 +01:00
Add receive support for pin/unpin message
This commit is contained in:
+1
-1
@@ -49,7 +49,7 @@
|
|||||||
"test": "run-s test-node test-electron test-lint-intl test-eslint",
|
"test": "run-s test-node test-electron test-lint-intl test-eslint",
|
||||||
"test-electron": "node ts/scripts/test-electron.node.js",
|
"test-electron": "node ts/scripts/test-electron.node.js",
|
||||||
"test-release": "node ts/scripts/test-release.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": "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-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",
|
"test-eslint": "mocha .eslint/rules/**/*.test.js --ignore-leaks",
|
||||||
|
|||||||
@@ -394,8 +394,8 @@ message DataMessage {
|
|||||||
optional bytes targetAuthorAciBinary = 1; // 16-byte UUID
|
optional bytes targetAuthorAciBinary = 1; // 16-byte UUID
|
||||||
optional uint64 targetSentTimestamp = 2;
|
optional uint64 targetSentTimestamp = 2;
|
||||||
oneof pinDuration {
|
oneof pinDuration {
|
||||||
uint32 seconds = 3;
|
uint32 pinDurationSeconds = 3;
|
||||||
bool forever = 4;
|
bool pinDurationForever = 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const ScalarKeys = [
|
|||||||
'global.messageQueueTimeInSeconds',
|
'global.messageQueueTimeInSeconds',
|
||||||
'global.nicknames.max',
|
'global.nicknames.max',
|
||||||
'global.nicknames.min',
|
'global.nicknames.min',
|
||||||
|
'global.pinned_message_limit',
|
||||||
'global.textAttachmentLimitBytes',
|
'global.textAttachmentLimitBytes',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
+67
-39
@@ -162,6 +162,7 @@ import * as Deletes from './messageModifiers/Deletes.preload.js';
|
|||||||
import * as Edits from './messageModifiers/Edits.preload.js';
|
import * as Edits from './messageModifiers/Edits.preload.js';
|
||||||
import * as MessageReceipts from './messageModifiers/MessageReceipts.preload.js';
|
import * as MessageReceipts from './messageModifiers/MessageReceipts.preload.js';
|
||||||
import * as MessageRequests from './messageModifiers/MessageRequests.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 Polls from './messageModifiers/Polls.preload.js';
|
||||||
import * as Reactions from './messageModifiers/Reactions.preload.js';
|
import * as Reactions from './messageModifiers/Reactions.preload.js';
|
||||||
import * as ViewOnceOpenSyncs from './messageModifiers/ViewOnceOpenSyncs.preload.js';
|
import * as ViewOnceOpenSyncs from './messageModifiers/ViewOnceOpenSyncs.preload.js';
|
||||||
@@ -281,6 +282,7 @@ import {
|
|||||||
} from './types/Message2.preload.js';
|
} from './types/Message2.preload.js';
|
||||||
import { JobCancelReason } from './jobs/types.std.js';
|
import { JobCancelReason } from './jobs/types.std.js';
|
||||||
import { itemStorage } from './textsecure/Storage.preload.js';
|
import { itemStorage } from './textsecure/Storage.preload.js';
|
||||||
|
import { isPinnedMessagesReceiveEnabled } from './util/isPinnedMessagesEnabled.std.js';
|
||||||
|
|
||||||
const { isNumber, throttle } = lodash;
|
const { isNumber, throttle } = lodash;
|
||||||
|
|
||||||
@@ -2373,6 +2375,13 @@ export async function startApp(): Promise<void> {
|
|||||||
async function onMessageReceived(event: MessageEvent): Promise<void> {
|
async function onMessageReceived(event: MessageEvent): Promise<void> {
|
||||||
const { data, confirm } = event;
|
const { data, confirm } = event;
|
||||||
|
|
||||||
|
const { conversation: fromConversation } =
|
||||||
|
window.ConversationController.maybeMergeContacts({
|
||||||
|
e164: data.source,
|
||||||
|
aci: data.sourceAci,
|
||||||
|
reason: 'onMessageReceived',
|
||||||
|
});
|
||||||
|
|
||||||
const messageDescriptor = getMessageDescriptor({
|
const messageDescriptor = getMessageDescriptor({
|
||||||
// 'message' event: for 1:1 converations, the conversation is same as sender
|
// 'message' event: for 1:1 converations, the conversation is same as sender
|
||||||
destinationE164: data.source,
|
destinationE164: data.source,
|
||||||
@@ -2447,13 +2456,6 @@ export async function startApp(): Promise<void> {
|
|||||||
reaction.targetTimestamp,
|
reaction.targetTimestamp,
|
||||||
'Reaction without 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);
|
log.info('Queuing incoming reaction for', reaction.targetTimestamp);
|
||||||
const attributes: ReactionAttributesType = {
|
const attributes: ReactionAttributesType = {
|
||||||
@@ -2474,6 +2476,23 @@ export async function startApp(): Promise<void> {
|
|||||||
return;
|
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 (data.message.pollVote) {
|
||||||
if (!isPollReceiveEnabled()) {
|
if (!isPollReceiveEnabled()) {
|
||||||
log.warn('Dropping PollVote because the flag is disabled');
|
log.warn('Dropping PollVote because the flag is disabled');
|
||||||
@@ -2498,14 +2517,6 @@ export async function startApp(): Promise<void> {
|
|||||||
'DataMessage.PollVote.targetAuthorAci'
|
'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);
|
log.info('Queuing incoming poll vote for', pollVote.targetTimestamp);
|
||||||
const attributes: PollVoteAttributesType = {
|
const attributes: PollVoteAttributesType = {
|
||||||
envelopeId: data.envelopeId,
|
envelopeId: data.envelopeId,
|
||||||
@@ -2542,14 +2553,6 @@ export async function startApp(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { conversation: fromConversation } =
|
|
||||||
window.ConversationController.maybeMergeContacts({
|
|
||||||
e164: data.source,
|
|
||||||
aci: data.sourceAci,
|
|
||||||
reason: 'onMessageReceived:pollTerminate',
|
|
||||||
});
|
|
||||||
strictAssert(fromConversation, 'PollTerminate without fromConversation');
|
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
'Queuing incoming poll termination for',
|
'Queuing incoming poll termination for',
|
||||||
pollTerminate.targetTimestamp
|
pollTerminate.targetTimestamp
|
||||||
@@ -2579,13 +2582,6 @@ export async function startApp(): Promise<void> {
|
|||||||
'Delete missing targetSentTimestamp'
|
'Delete missing targetSentTimestamp'
|
||||||
);
|
);
|
||||||
strictAssert(data.serverTimestamp, 'Delete missing serverTimestamp');
|
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 = {
|
const attributes: DeleteAttributesType = {
|
||||||
envelopeId: data.envelopeId,
|
envelopeId: data.envelopeId,
|
||||||
@@ -2603,13 +2599,6 @@ export async function startApp(): Promise<void> {
|
|||||||
const { editedMessageTimestamp } = data.message;
|
const { editedMessageTimestamp } = data.message;
|
||||||
|
|
||||||
strictAssert(editedMessageTimestamp, 'Edit missing targetSentTimestamp');
|
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', {
|
log.info('Queuing incoming edit for', {
|
||||||
editedMessageTimestamp,
|
editedMessageTimestamp,
|
||||||
@@ -2631,6 +2620,21 @@ export async function startApp(): Promise<void> {
|
|||||||
return;
|
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)) {
|
if (handleGroupCallUpdateMessage(data.message, messageDescriptor)) {
|
||||||
confirm();
|
confirm();
|
||||||
return;
|
return;
|
||||||
@@ -2715,7 +2719,7 @@ export async function startApp(): Promise<void> {
|
|||||||
descriptor: MessageDescriptor
|
descriptor: MessageDescriptor
|
||||||
) {
|
) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const timestamp = data.timestamp || now;
|
const { timestamp } = data;
|
||||||
const logId = `createSentMessage(${timestamp})`;
|
const logId = `createSentMessage(${timestamp})`;
|
||||||
|
|
||||||
const ourId = window.ConversationController.getOurConversationIdOrThrow();
|
const ourId = window.ConversationController.getOurConversationIdOrThrow();
|
||||||
@@ -2893,8 +2897,9 @@ export async function startApp(): Promise<void> {
|
|||||||
const { data, confirm } = event;
|
const { data, confirm } = event;
|
||||||
|
|
||||||
const source = itemStorage.user.getNumber();
|
const source = itemStorage.user.getNumber();
|
||||||
|
strictAssert(source, 'Missing user number');
|
||||||
const sourceServiceId = itemStorage.user.getAci();
|
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
|
// Make sure destination conversation is created before we hit getMessageDescriptor
|
||||||
if (
|
if (
|
||||||
@@ -2977,6 +2982,19 @@ export async function startApp(): Promise<void> {
|
|||||||
return;
|
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 (data.message.pollVote) {
|
||||||
if (!isPollReceiveEnabled()) {
|
if (!isPollReceiveEnabled()) {
|
||||||
log.warn('Dropping PollVote because the flag is disabled');
|
log.warn('Dropping PollVote because the flag is disabled');
|
||||||
@@ -3108,6 +3126,16 @@ export async function startApp(): Promise<void> {
|
|||||||
return;
|
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)) {
|
if (handleGroupCallUpdateMessage(data.message, messageDescriptor)) {
|
||||||
event.confirm();
|
event.confirm();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import React, { type ReactNode } from 'react';
|
import React, { type ReactNode } from 'react';
|
||||||
import type { LocalizerType } from '../../types/I18N.std.js';
|
import type { LocalizerType } from '../../types/I18N.std.js';
|
||||||
import { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.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<{
|
type MessageContextMenuProps = Readonly<{
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
@@ -99,7 +99,7 @@ export function MessageContextMenu({
|
|||||||
{i18n('icu:copy')}
|
{i18n('icu:copy')}
|
||||||
</AxoMenuBuilder.Item>
|
</AxoMenuBuilder.Item>
|
||||||
)}
|
)}
|
||||||
{isPinnedMessagesEnabled() && onPinMessage && (
|
{isPinnedMessagesReceiveEnabled() && onPinMessage && (
|
||||||
<AxoMenuBuilder.Item symbol="pin" onSelect={onPinMessage}>
|
<AxoMenuBuilder.Item symbol="pin" onSelect={onPinMessage}>
|
||||||
{i18n('icu:MessageContextMenu__PinMessage')}
|
{i18n('icu:MessageContextMenu__PinMessage')}
|
||||||
</AxoMenuBuilder.Item>
|
</AxoMenuBuilder.Item>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
deliveryReceiptBatcher,
|
deliveryReceiptBatcher,
|
||||||
} from '../util/deliveryReceipt.preload.js';
|
} from '../util/deliveryReceipt.preload.js';
|
||||||
import { getSenderIdentifier } from '../util/getSenderIdentifier.dom.js';
|
import { getSenderIdentifier } from '../util/getSenderIdentifier.dom.js';
|
||||||
import { isNormalNumber } from '../util/isNormalNumber.std.js';
|
|
||||||
import { upgradeMessageSchema } from '../util/migrations.preload.js';
|
import { upgradeMessageSchema } from '../util/migrations.preload.js';
|
||||||
import { getOwn } from '../util/getOwn.std.js';
|
import { getOwn } from '../util/getOwn.std.js';
|
||||||
import {
|
import {
|
||||||
@@ -184,10 +183,7 @@ export async function handleDataMessage(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedAt: number =
|
const updatedAt: number = data?.timestamp ?? Date.now();
|
||||||
data && isNormalNumber(data.timestamp)
|
|
||||||
? data.timestamp
|
|
||||||
: Date.now();
|
|
||||||
|
|
||||||
const previousSendState = getOwn(
|
const previousSendState = getOwn(
|
||||||
sendStateByConversationId,
|
sendStateByConversationId,
|
||||||
|
|||||||
@@ -3848,6 +3848,7 @@ export class ConversationModel {
|
|||||||
return buildGroupLink(this.attributes);
|
return buildGroupLink(this.attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(DESKTOP-9497): This will not include `ourAci` in 1:1 chats
|
||||||
getMembers(
|
getMembers(
|
||||||
options: { includePendingMembers?: boolean } = {}
|
options: { includePendingMembers?: boolean } = {}
|
||||||
): Array<ConversationModel> {
|
): Array<ConversationModel> {
|
||||||
@@ -5258,6 +5259,7 @@ export class ConversationModel {
|
|||||||
await DataWriter.updateConversation(this.attributes);
|
await DataWriter.updateConversation(this.attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(DESKTOP-9497): This will return false for `ourAci` in 1:1 chats
|
||||||
hasMember(serviceId: ServiceIdString): boolean {
|
hasMember(serviceId: ServiceIdString): boolean {
|
||||||
const members = this.getMembers();
|
const members = this.getMembers();
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import type {
|
|||||||
PinnedMessageId,
|
PinnedMessageId,
|
||||||
PinnedMessageParams,
|
PinnedMessageParams,
|
||||||
} from '../types/PinnedMessage.std.js';
|
} from '../types/PinnedMessage.std.js';
|
||||||
|
import type { AppendPinnedMessageResult } from './server/pinnedMessages.std.js';
|
||||||
|
|
||||||
export type ReadableDB = Database & { __readable_db: never };
|
export type ReadableDB = Database & { __readable_db: never };
|
||||||
export type WritableDB = ReadableDB & { __writable_db: never };
|
export type WritableDB = ReadableDB & { __writable_db: never };
|
||||||
@@ -1334,10 +1335,11 @@ type WritableInterface = {
|
|||||||
messageQueueTime: number
|
messageQueueTime: number
|
||||||
) => ReadonlyArray<ChatFolderId>;
|
) => ReadonlyArray<ChatFolderId>;
|
||||||
|
|
||||||
createPinnedMessage: (
|
appendPinnedMessage: (
|
||||||
|
pinnedMessagesLimit: number,
|
||||||
pinnedMessageParams: PinnedMessageParams
|
pinnedMessageParams: PinnedMessageParams
|
||||||
) => PinnedMessage;
|
) => AppendPinnedMessageResult;
|
||||||
deletePinnedMessage: (pinnedMessageId: PinnedMessageId) => void;
|
deletePinnedMessageByMessageId: (messageId: string) => PinnedMessageId | null;
|
||||||
deleteAllExpiredPinnedMessagesBefore: (
|
deleteAllExpiredPinnedMessagesBefore: (
|
||||||
beforeTimestamp: number
|
beforeTimestamp: number
|
||||||
) => ReadonlyArray<PinnedMessageId>;
|
) => ReadonlyArray<PinnedMessageId>;
|
||||||
|
|||||||
@@ -260,8 +260,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
getPinnedMessagesForConversation,
|
getPinnedMessagesForConversation,
|
||||||
getNextExpiringPinnedMessageAcrossConversations,
|
getNextExpiringPinnedMessageAcrossConversations,
|
||||||
createPinnedMessage,
|
appendPinnedMessage,
|
||||||
deletePinnedMessage,
|
deletePinnedMessageByMessageId,
|
||||||
deleteAllExpiredPinnedMessagesBefore,
|
deleteAllExpiredPinnedMessagesBefore,
|
||||||
} from './server/pinnedMessages.std.js';
|
} from './server/pinnedMessages.std.js';
|
||||||
import { INITIAL_EXPIRE_TIMER_VERSION } from '../util/expirationTimer.std.js';
|
import { INITIAL_EXPIRE_TIMER_VERSION } from '../util/expirationTimer.std.js';
|
||||||
@@ -735,8 +735,8 @@ export const DataWriter: ServerWritableInterface = {
|
|||||||
markChatFolderDeleted,
|
markChatFolderDeleted,
|
||||||
deleteExpiredChatFolders,
|
deleteExpiredChatFolders,
|
||||||
|
|
||||||
createPinnedMessage,
|
appendPinnedMessage,
|
||||||
deletePinnedMessage,
|
deletePinnedMessageByMessageId,
|
||||||
deleteAllExpiredPinnedMessagesBefore,
|
deleteAllExpiredPinnedMessagesBefore,
|
||||||
|
|
||||||
removeAll,
|
removeAll,
|
||||||
@@ -988,6 +988,9 @@ export function setupTests(db: WritableDB): void {
|
|||||||
const silentLogger = {
|
const silentLogger = {
|
||||||
...consoleLogger,
|
...consoleLogger,
|
||||||
info: noop,
|
info: noop,
|
||||||
|
child() {
|
||||||
|
return silentLogger;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
logger = silentLogger;
|
logger = silentLogger;
|
||||||
|
|
||||||
@@ -3395,7 +3398,7 @@ function getUnreadByConversationAndMarkRead(
|
|||||||
conversationId = ${conversationId} AND
|
conversationId = ${conversationId} AND
|
||||||
${storyReplyFilter} AND
|
${storyReplyFilter} AND
|
||||||
type IN ('incoming', 'poll-terminate') AND
|
type IN ('incoming', 'poll-terminate') AND
|
||||||
hasExpireTimer IS 1 AND
|
hasExpireTimer IS 1 AND
|
||||||
received_at <= ${readMessageReceivedAt}
|
received_at <= ${readMessageReceivedAt}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -133,6 +133,7 @@ import updateToSchemaVersion1540 from './1540-partial-expiring-index.std.js';
|
|||||||
import updateToSchemaVersion1550 from './1550-has-link-preview.std.js';
|
import updateToSchemaVersion1550 from './1550-has-link-preview.std.js';
|
||||||
import updateToSchemaVersion1560 from './1560-pinned-messages.std.js';
|
import updateToSchemaVersion1560 from './1560-pinned-messages.std.js';
|
||||||
import updateToSchemaVersion1561 from './1561-cleanup-polls.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';
|
import { DataWriter } from '../Server.node.js';
|
||||||
|
|
||||||
@@ -1625,6 +1626,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray<SchemaUpdateType> = [
|
|||||||
{ version: 1560, update: updateToSchemaVersion1560 },
|
{ version: 1560, update: updateToSchemaVersion1560 },
|
||||||
// 1561, 1551, and 1541 all refer to the same migration
|
// 1561, 1551, and 1541 all refer to the same migration
|
||||||
{ version: 1561, update: updateToSchemaVersion1561 },
|
{ version: 1561, update: updateToSchemaVersion1561 },
|
||||||
|
{ version: 1570, update: updateToSchemaVersion1570 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export class DBVersionFromFutureError extends Error {
|
export class DBVersionFromFutureError extends Error {
|
||||||
|
|||||||
@@ -15,14 +15,25 @@ export function getPinnedMessagesForConversation(
|
|||||||
conversationId: string
|
conversationId: string
|
||||||
): ReadonlyArray<PinnedMessage> {
|
): ReadonlyArray<PinnedMessage> {
|
||||||
const [query, params] = sql`
|
const [query, params] = sql`
|
||||||
SELECT * FROM pins
|
SELECT * FROM pinnedMessages
|
||||||
WHERE conversationId = ${conversationId}
|
WHERE conversationId = ${conversationId}
|
||||||
ORDER BY pinnedAt DESC
|
ORDER BY pinnedAt DESC
|
||||||
`;
|
`;
|
||||||
return db.prepare(query).all<PinnedMessage>(params);
|
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,
|
db: WritableDB,
|
||||||
pinnedMessageParams: PinnedMessageParams
|
pinnedMessageParams: PinnedMessageParams
|
||||||
): PinnedMessage {
|
): PinnedMessage {
|
||||||
@@ -30,17 +41,11 @@ export function createPinnedMessage(
|
|||||||
INSERT INTO pinnedMessages (
|
INSERT INTO pinnedMessages (
|
||||||
conversationId,
|
conversationId,
|
||||||
messageId,
|
messageId,
|
||||||
messageSentAt,
|
|
||||||
messageSenderAci,
|
|
||||||
pinnedByAci,
|
|
||||||
pinnedAt,
|
pinnedAt,
|
||||||
expiresAt
|
expiresAt
|
||||||
) VALUES (
|
) VALUES (
|
||||||
${pinnedMessageParams.conversationId},
|
${pinnedMessageParams.conversationId},
|
||||||
${pinnedMessageParams.messageId},
|
${pinnedMessageParams.messageId},
|
||||||
${pinnedMessageParams.messageSentAt},
|
|
||||||
${pinnedMessageParams.messageSenderAci},
|
|
||||||
${pinnedMessageParams.pinnedByAci},
|
|
||||||
${pinnedMessageParams.pinnedAt},
|
${pinnedMessageParams.pinnedAt},
|
||||||
${pinnedMessageParams.expiresAt}
|
${pinnedMessageParams.expiresAt}
|
||||||
)
|
)
|
||||||
@@ -52,13 +57,10 @@ export function createPinnedMessage(
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deletePinnedMessage(
|
function _deletePinnedMessageById(db: WritableDB, id: PinnedMessageId): void {
|
||||||
db: WritableDB,
|
|
||||||
pinnedMessageId: PinnedMessageId
|
|
||||||
): void {
|
|
||||||
const [query, params] = sql`
|
const [query, params] = sql`
|
||||||
DELETE FROM pinnedMessages
|
DELETE FROM pinnedMessages
|
||||||
WHERE id = ${pinnedMessageId}
|
WHERE id = ${id}
|
||||||
`;
|
`;
|
||||||
const result = db.prepare(query).run(params);
|
const result = db.prepare(query).run(params);
|
||||||
strictAssert(
|
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(
|
export function getNextExpiringPinnedMessageAcrossConversations(
|
||||||
db: ReadableDB
|
db: ReadableDB
|
||||||
): PinnedMessage | null {
|
): PinnedMessage | null {
|
||||||
const [query, params] = sql`
|
const [query, params] = sql`
|
||||||
SELECT * FROM pinnedMessages
|
SELECT * FROM pinnedMessages
|
||||||
|
WHERE expiresAt IS NOT null
|
||||||
ORDER BY expiresAt ASC
|
ORDER BY expiresAt ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import type { AciString } from '@signalapp/mock-server/src/types.js';
|
|
||||||
import { getIntl } from '../selectors/user.std.js';
|
import { getIntl } from '../selectors/user.std.js';
|
||||||
import { getConversationByIdSelector } from '../selectors/conversations.dom.js';
|
import { getConversationByIdSelector } from '../selectors/conversations.dom.js';
|
||||||
import { strictAssert } from '../../util/assert.std.js';
|
import { strictAssert } from '../../util/assert.std.js';
|
||||||
@@ -17,8 +16,6 @@ import type {
|
|||||||
PinnedMessageId,
|
PinnedMessageId,
|
||||||
} from '../../types/PinnedMessage.std.js';
|
} from '../../types/PinnedMessage.std.js';
|
||||||
import type { StateType } from '../reducer.preload.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<{
|
export type SmartPinnedMessagesPanelProps = Readonly<{
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
@@ -41,8 +38,6 @@ const mockSelectPinnedMessages: StateSelector<ReadonlyArray<PinnedMessage>> =
|
|||||||
conversations.messagesByConversation[selectedConversationId]
|
conversations.messagesByConversation[selectedConversationId]
|
||||||
?.messageIds ?? [];
|
?.messageIds ?? [];
|
||||||
|
|
||||||
const ourAci = itemStorage.user.getCheckedAci();
|
|
||||||
|
|
||||||
return messageIds
|
return messageIds
|
||||||
.map(messageId => {
|
.map(messageId => {
|
||||||
return conversations.messagesLookup[messageId] ?? null;
|
return conversations.messagesLookup[messageId] ?? null;
|
||||||
@@ -52,24 +47,10 @@ const mockSelectPinnedMessages: StateSelector<ReadonlyArray<PinnedMessage>> =
|
|||||||
})
|
})
|
||||||
.slice(-10)
|
.slice(-10)
|
||||||
.map((message, messageIndex): PinnedMessage => {
|
.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 {
|
return {
|
||||||
id: messageIndex as PinnedMessageId,
|
id: messageIndex as PinnedMessageId,
|
||||||
conversationId: selectedConversationId,
|
conversationId: selectedConversationId,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
messageSentAt: message.sent_at,
|
|
||||||
messageSenderAci,
|
|
||||||
pinnedByAci: ourAci,
|
|
||||||
pinnedAt: Date.now(),
|
pinnedAt: Date.now(),
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2101,12 +2101,14 @@ export default class MessageReceiver
|
|||||||
log.warn(`${logId}: Dropping too-long message. Length: ${length}`);
|
log.warn(`${logId}: Dropping too-long message. Length: ${length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
strictAssert(timestamp, 'Missing sent timestamp');
|
||||||
|
|
||||||
const ev = new SentEvent(
|
const ev = new SentEvent(
|
||||||
{
|
{
|
||||||
envelopeId: envelope.id,
|
envelopeId: envelope.id,
|
||||||
destinationE164: dropNull(destinationE164),
|
destinationE164: dropNull(destinationE164),
|
||||||
destinationServiceId,
|
destinationServiceId,
|
||||||
timestamp: timestamp?.toNumber(),
|
timestamp: timestamp.toNumber(),
|
||||||
serverTimestamp: envelope.serverTimestamp,
|
serverTimestamp: envelope.serverTimestamp,
|
||||||
device: envelope.sourceDevice,
|
device: envelope.sourceDevice,
|
||||||
unidentifiedStatus,
|
unidentifiedStatus,
|
||||||
|
|||||||
Vendored
+13
@@ -185,6 +185,12 @@ export type ProcessedReaction = {
|
|||||||
targetTimestamp?: number;
|
targetTimestamp?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProcessedPinMessage = Readonly<{
|
||||||
|
targetAuthorAci: AciString;
|
||||||
|
targetSentTimestamp: number;
|
||||||
|
pinDuration: DurationInSeconds | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type ProcessedPollCreate = {
|
export type ProcessedPollCreate = {
|
||||||
question?: string;
|
question?: string;
|
||||||
options?: Array<string>;
|
options?: Array<string>;
|
||||||
@@ -218,6 +224,11 @@ export type ProcessedGiftBadge = {
|
|||||||
state: GiftBadgeStates;
|
state: GiftBadgeStates;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProcessedUnpinMessage = Readonly<{
|
||||||
|
targetAuthorAci: AciString;
|
||||||
|
targetSentTimestamp: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type ProcessedStoryContext = {
|
export type ProcessedStoryContext = {
|
||||||
authorAci: AciString | undefined;
|
authorAci: AciString | undefined;
|
||||||
sentTimestamp: number;
|
sentTimestamp: number;
|
||||||
@@ -243,6 +254,7 @@ export type ProcessedDataMessage = {
|
|||||||
isStory?: boolean;
|
isStory?: boolean;
|
||||||
isViewOnce: boolean;
|
isViewOnce: boolean;
|
||||||
reaction?: ProcessedReaction;
|
reaction?: ProcessedReaction;
|
||||||
|
pinMessage?: ProcessedPinMessage;
|
||||||
pollCreate?: ProcessedPollCreate;
|
pollCreate?: ProcessedPollCreate;
|
||||||
pollVote?: ProcessedPollVote;
|
pollVote?: ProcessedPollVote;
|
||||||
pollTerminate?: ProcessedPollTerminate;
|
pollTerminate?: ProcessedPollTerminate;
|
||||||
@@ -251,6 +263,7 @@ export type ProcessedDataMessage = {
|
|||||||
groupCallUpdate?: ProcessedGroupCallUpdate;
|
groupCallUpdate?: ProcessedGroupCallUpdate;
|
||||||
storyContext?: ProcessedStoryContext;
|
storyContext?: ProcessedStoryContext;
|
||||||
giftBadge?: ProcessedGiftBadge;
|
giftBadge?: ProcessedGiftBadge;
|
||||||
|
unpinMessage?: ProcessedUnpinMessage;
|
||||||
canReplyToStory?: boolean;
|
canReplyToStory?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export type SentEventData = Readonly<{
|
|||||||
envelopeId: string;
|
envelopeId: string;
|
||||||
destinationE164?: string;
|
destinationE164?: string;
|
||||||
destinationServiceId?: ServiceIdString;
|
destinationServiceId?: ServiceIdString;
|
||||||
timestamp?: number;
|
timestamp: number;
|
||||||
serverTimestamp: number;
|
serverTimestamp: number;
|
||||||
device: number | undefined;
|
device: number | undefined;
|
||||||
unidentifiedStatus: ProcessedSent['unidentifiedStatus'];
|
unidentifiedStatus: ProcessedSent['unidentifiedStatus'];
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import lodash from 'lodash';
|
|||||||
|
|
||||||
import { assertDev, strictAssert } from '../util/assert.std.js';
|
import { assertDev, strictAssert } from '../util/assert.std.js';
|
||||||
import { dropNull, shallowDropNull } from '../util/dropNull.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 { getTimestampFromLong } from '../util/timestampLongUtils.std.js';
|
||||||
import { SignalService as Proto } from '../protobuf/index.std.js';
|
import { SignalService as Proto } from '../protobuf/index.std.js';
|
||||||
import { deriveGroupFields } from '../groups.preload.js';
|
import { deriveGroupFields } from '../groups.preload.js';
|
||||||
@@ -28,6 +31,8 @@ import type {
|
|||||||
ProcessedDelete,
|
ProcessedDelete,
|
||||||
ProcessedGiftBadge,
|
ProcessedGiftBadge,
|
||||||
ProcessedStoryContext,
|
ProcessedStoryContext,
|
||||||
|
ProcessedPinMessage,
|
||||||
|
ProcessedUnpinMessage,
|
||||||
} from './Types.d.ts';
|
} from './Types.d.ts';
|
||||||
import { GiftBadgeStates } from '../types/GiftBadgeStates.std.js';
|
import { GiftBadgeStates } from '../types/GiftBadgeStates.std.js';
|
||||||
import {
|
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(
|
export function processPollCreate(
|
||||||
pollCreate?: Proto.DataMessage.IPollCreate | null
|
pollCreate?: Proto.DataMessage.IPollCreate | null
|
||||||
): ProcessedPollCreate | undefined {
|
): 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(
|
export function processDataMessage(
|
||||||
message: Proto.IDataMessage,
|
message: Proto.IDataMessage,
|
||||||
envelopeTimestamp: number,
|
envelopeTimestamp: number,
|
||||||
@@ -462,6 +514,7 @@ export function processDataMessage(
|
|||||||
requiredProtocolVersion: dropNull(message.requiredProtocolVersion),
|
requiredProtocolVersion: dropNull(message.requiredProtocolVersion),
|
||||||
isViewOnce: Boolean(message.isViewOnce),
|
isViewOnce: Boolean(message.isViewOnce),
|
||||||
reaction: processReaction(message.reaction),
|
reaction: processReaction(message.reaction),
|
||||||
|
pinMessage: processPinMessage(message.pinMessage),
|
||||||
pollCreate: processPollCreate(message.pollCreate),
|
pollCreate: processPollCreate(message.pollCreate),
|
||||||
pollVote: processPollVote(message.pollVote),
|
pollVote: processPollVote(message.pollVote),
|
||||||
pollTerminate: processPollTerminate(message.pollTerminate),
|
pollTerminate: processPollTerminate(message.pollTerminate),
|
||||||
@@ -470,6 +523,7 @@ export function processDataMessage(
|
|||||||
groupCallUpdate: dropNull(message.groupCallUpdate),
|
groupCallUpdate: dropNull(message.groupCallUpdate),
|
||||||
storyContext: processStoryContext(message.storyContext),
|
storyContext: processStoryContext(message.storyContext),
|
||||||
giftBadge: processGiftBadge(message.giftBadge),
|
giftBadge: processGiftBadge(message.giftBadge),
|
||||||
|
unpinMessage: processUnpinMessage(message.unpinMessage),
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEndSession = Boolean(result.flags & FLAGS.END_SESSION);
|
const isEndSession = Boolean(result.flags & FLAGS.END_SESSION);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
import type { MessageAttributesType } from '../model-types.js';
|
import type { MessageAttributesType } from '../model-types.js';
|
||||||
import type { ConversationType } from '../state/ducks/conversations.preload.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 PinnedMessageId = number & { PinnedMessageId: never };
|
||||||
|
|
||||||
@@ -11,9 +10,6 @@ export type PinnedMessage = Readonly<{
|
|||||||
id: PinnedMessageId;
|
id: PinnedMessageId;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
messageSentAt: number;
|
|
||||||
messageSenderAci: AciString;
|
|
||||||
pinnedByAci: AciString;
|
|
||||||
pinnedAt: number;
|
pinnedAt: number;
|
||||||
expiresAt: number | null;
|
expiresAt: number | null;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { isDirectConversation } from './whatTypeOfConversation.dom.js';
|
|||||||
|
|
||||||
const { compact } = lodash;
|
const { compact } = lodash;
|
||||||
|
|
||||||
|
// TODO(DESKTOP-9497): This will not include `ourAci` in 1:1 chats
|
||||||
export function getConversationMembers(
|
export function getConversationMembers(
|
||||||
conversationAttrs: ConversationAttributesType,
|
conversationAttrs: ConversationAttributesType,
|
||||||
options: { includePendingMembers?: boolean } = {}
|
options: { includePendingMembers?: boolean } = {}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
isMockEnvironment,
|
isMockEnvironment,
|
||||||
} from '../environment.std.js';
|
} from '../environment.std.js';
|
||||||
|
|
||||||
export function isPinnedMessagesEnabled(): boolean {
|
function isDevEnv(): boolean {
|
||||||
const env = getEnvironment();
|
const env = getEnvironment();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -22,3 +22,11 @@ export function isPinnedMessagesEnabled(): boolean {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPinnedMessagesReceiveEnabled(): boolean {
|
||||||
|
return isDevEnv();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPinnedMessagesSendEnabled(): boolean {
|
||||||
|
return isDevEnv();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user