mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-24 12:19:41 +00:00
Add receive support for pin/unpin message
This commit is contained in:
@@ -63,6 +63,7 @@ const ScalarKeys = [
|
||||
'global.messageQueueTimeInSeconds',
|
||||
'global.nicknames.max',
|
||||
'global.nicknames.min',
|
||||
'global.pinned_message_limit',
|
||||
'global.textAttachmentLimitBytes',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -162,6 +162,7 @@ import * as Deletes from './messageModifiers/Deletes.preload.js';
|
||||
import * as Edits from './messageModifiers/Edits.preload.js';
|
||||
import * as MessageReceipts from './messageModifiers/MessageReceipts.preload.js';
|
||||
import * as MessageRequests from './messageModifiers/MessageRequests.preload.js';
|
||||
import * as PinnedMessages from './messageModifiers/PinnedMessages.preload.js';
|
||||
import * as Polls from './messageModifiers/Polls.preload.js';
|
||||
import * as Reactions from './messageModifiers/Reactions.preload.js';
|
||||
import * as ViewOnceOpenSyncs from './messageModifiers/ViewOnceOpenSyncs.preload.js';
|
||||
@@ -281,6 +282,7 @@ import {
|
||||
} from './types/Message2.preload.js';
|
||||
import { JobCancelReason } from './jobs/types.std.js';
|
||||
import { itemStorage } from './textsecure/Storage.preload.js';
|
||||
import { isPinnedMessagesReceiveEnabled } from './util/isPinnedMessagesEnabled.std.js';
|
||||
|
||||
const { isNumber, throttle } = lodash;
|
||||
|
||||
@@ -2373,6 +2375,13 @@ export async function startApp(): Promise<void> {
|
||||
async function onMessageReceived(event: MessageEvent): Promise<void> {
|
||||
const { data, confirm } = event;
|
||||
|
||||
const { conversation: fromConversation } =
|
||||
window.ConversationController.maybeMergeContacts({
|
||||
e164: data.source,
|
||||
aci: data.sourceAci,
|
||||
reason: 'onMessageReceived',
|
||||
});
|
||||
|
||||
const messageDescriptor = getMessageDescriptor({
|
||||
// 'message' event: for 1:1 converations, the conversation is same as sender
|
||||
destinationE164: data.source,
|
||||
@@ -2447,13 +2456,6 @@ export async function startApp(): Promise<void> {
|
||||
reaction.targetTimestamp,
|
||||
'Reaction without targetTimestamp'
|
||||
);
|
||||
const { conversation: fromConversation } =
|
||||
window.ConversationController.maybeMergeContacts({
|
||||
e164: data.source,
|
||||
aci: data.sourceAci,
|
||||
reason: 'onMessageReceived:reaction',
|
||||
});
|
||||
strictAssert(fromConversation, 'Reaction without fromConversation');
|
||||
|
||||
log.info('Queuing incoming reaction for', reaction.targetTimestamp);
|
||||
const attributes: ReactionAttributesType = {
|
||||
@@ -2474,6 +2476,23 @@ export async function startApp(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.message.pinMessage != null) {
|
||||
if (!isPinnedMessagesReceiveEnabled()) {
|
||||
log.warn('Dropping PinMessage because the flag is disabled');
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
await PinnedMessages.onPinnedMessageAdd({
|
||||
targetSentTimestamp: data.message.pinMessage.targetSentTimestamp,
|
||||
targetAuthorAci: data.message.pinMessage.targetAuthorAci,
|
||||
pinDuration: data.message.pinMessage.pinDuration,
|
||||
pinnedByAci: data.sourceAci,
|
||||
receivedAtTimestamp: data.receivedAtDate,
|
||||
});
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.message.pollVote) {
|
||||
if (!isPollReceiveEnabled()) {
|
||||
log.warn('Dropping PollVote because the flag is disabled');
|
||||
@@ -2498,14 +2517,6 @@ export async function startApp(): Promise<void> {
|
||||
'DataMessage.PollVote.targetAuthorAci'
|
||||
);
|
||||
|
||||
const { conversation: fromConversation } =
|
||||
window.ConversationController.maybeMergeContacts({
|
||||
e164: data.source,
|
||||
aci: data.sourceAci,
|
||||
reason: 'onMessageReceived:pollVote',
|
||||
});
|
||||
strictAssert(fromConversation, 'PollVote without fromConversation');
|
||||
|
||||
log.info('Queuing incoming poll vote for', pollVote.targetTimestamp);
|
||||
const attributes: PollVoteAttributesType = {
|
||||
envelopeId: data.envelopeId,
|
||||
@@ -2542,14 +2553,6 @@ export async function startApp(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const { conversation: fromConversation } =
|
||||
window.ConversationController.maybeMergeContacts({
|
||||
e164: data.source,
|
||||
aci: data.sourceAci,
|
||||
reason: 'onMessageReceived:pollTerminate',
|
||||
});
|
||||
strictAssert(fromConversation, 'PollTerminate without fromConversation');
|
||||
|
||||
log.info(
|
||||
'Queuing incoming poll termination for',
|
||||
pollTerminate.targetTimestamp
|
||||
@@ -2579,13 +2582,6 @@ export async function startApp(): Promise<void> {
|
||||
'Delete missing targetSentTimestamp'
|
||||
);
|
||||
strictAssert(data.serverTimestamp, 'Delete missing serverTimestamp');
|
||||
const { conversation: fromConversation } =
|
||||
window.ConversationController.maybeMergeContacts({
|
||||
e164: data.source,
|
||||
aci: data.sourceAci,
|
||||
reason: 'onMessageReceived:delete',
|
||||
});
|
||||
strictAssert(fromConversation, 'Delete missing fromConversation');
|
||||
|
||||
const attributes: DeleteAttributesType = {
|
||||
envelopeId: data.envelopeId,
|
||||
@@ -2603,13 +2599,6 @@ export async function startApp(): Promise<void> {
|
||||
const { editedMessageTimestamp } = data.message;
|
||||
|
||||
strictAssert(editedMessageTimestamp, 'Edit missing targetSentTimestamp');
|
||||
const { conversation: fromConversation } =
|
||||
window.ConversationController.maybeMergeContacts({
|
||||
aci: data.sourceAci,
|
||||
e164: data.source,
|
||||
reason: 'onMessageReceived:edit',
|
||||
});
|
||||
strictAssert(fromConversation, 'Edit missing fromConversation');
|
||||
|
||||
log.info('Queuing incoming edit for', {
|
||||
editedMessageTimestamp,
|
||||
@@ -2631,6 +2620,21 @@ export async function startApp(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.message.unpinMessage != null) {
|
||||
if (!isPinnedMessagesReceiveEnabled()) {
|
||||
log.warn('Dropping UnpinMessage because the flag is disabled');
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
await PinnedMessages.onPinnedMessageRemove({
|
||||
targetSentTimestamp: data.message.unpinMessage.targetSentTimestamp,
|
||||
targetAuthorAci: data.message.unpinMessage.targetAuthorAci,
|
||||
unpinnedByAci: data.sourceAci,
|
||||
});
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleGroupCallUpdateMessage(data.message, messageDescriptor)) {
|
||||
confirm();
|
||||
return;
|
||||
@@ -2715,7 +2719,7 @@ export async function startApp(): Promise<void> {
|
||||
descriptor: MessageDescriptor
|
||||
) {
|
||||
const now = Date.now();
|
||||
const timestamp = data.timestamp || now;
|
||||
const { timestamp } = data;
|
||||
const logId = `createSentMessage(${timestamp})`;
|
||||
|
||||
const ourId = window.ConversationController.getOurConversationIdOrThrow();
|
||||
@@ -2893,8 +2897,9 @@ export async function startApp(): Promise<void> {
|
||||
const { data, confirm } = event;
|
||||
|
||||
const source = itemStorage.user.getNumber();
|
||||
strictAssert(source, 'Missing user number');
|
||||
const sourceServiceId = itemStorage.user.getAci();
|
||||
strictAssert(source && sourceServiceId, 'Missing user number and uuid');
|
||||
strictAssert(sourceServiceId, 'Missing user aci');
|
||||
|
||||
// Make sure destination conversation is created before we hit getMessageDescriptor
|
||||
if (
|
||||
@@ -2977,6 +2982,19 @@ export async function startApp(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.message.pinMessage != null) {
|
||||
strictAssert(data.timestamp != null, 'Missing sent timestamp');
|
||||
await PinnedMessages.onPinnedMessageAdd({
|
||||
targetSentTimestamp: data.message.pinMessage.targetSentTimestamp,
|
||||
targetAuthorAci: data.message.pinMessage.targetAuthorAci,
|
||||
pinDuration: data.message.pinMessage.pinDuration,
|
||||
pinnedByAci: sourceServiceId,
|
||||
receivedAtTimestamp: data.receivedAtDate,
|
||||
});
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.message.pollVote) {
|
||||
if (!isPollReceiveEnabled()) {
|
||||
log.warn('Dropping PollVote because the flag is disabled');
|
||||
@@ -3108,6 +3126,16 @@ export async function startApp(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.message.unpinMessage != null) {
|
||||
await PinnedMessages.onPinnedMessageRemove({
|
||||
targetSentTimestamp: data.message.unpinMessage.targetSentTimestamp,
|
||||
targetAuthorAci: data.message.unpinMessage.targetAuthorAci,
|
||||
unpinnedByAci: sourceServiceId,
|
||||
});
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleGroupCallUpdateMessage(data.message, messageDescriptor)) {
|
||||
event.confirm();
|
||||
return;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import type { LocalizerType } from '../../types/I18N.std.js';
|
||||
import { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
|
||||
import { isPinnedMessagesEnabled } from '../../util/isPinnedMessagesEnabled.std.js';
|
||||
import { isPinnedMessagesReceiveEnabled } from '../../util/isPinnedMessagesEnabled.std.js';
|
||||
|
||||
type MessageContextMenuProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
@@ -99,7 +99,7 @@ export function MessageContextMenu({
|
||||
{i18n('icu:copy')}
|
||||
</AxoMenuBuilder.Item>
|
||||
)}
|
||||
{isPinnedMessagesEnabled() && onPinMessage && (
|
||||
{isPinnedMessagesReceiveEnabled() && onPinMessage && (
|
||||
<AxoMenuBuilder.Item symbol="pin" onSelect={onPinMessage}>
|
||||
{i18n('icu:MessageContextMenu__PinMessage')}
|
||||
</AxoMenuBuilder.Item>
|
||||
|
||||
193
ts/messageModifiers/PinnedMessages.preload.ts
Normal file
193
ts/messageModifiers/PinnedMessages.preload.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { DataWriter } from '../sql/Client.preload.js';
|
||||
import type { AciString } from '../types/ServiceId.std.js';
|
||||
import { DurationInSeconds } from '../util/durations/duration-in-seconds.std.js';
|
||||
import * as RemoteConfig from '../RemoteConfig.dom.js';
|
||||
import { parseIntWithFallback } from '../util/parseIntWithFallback.std.js';
|
||||
import { createLogger } from '../logging/log.std.js';
|
||||
import type { MessageModifierTarget } from './helpers/findMessageModifierTarget.preload.js';
|
||||
import { findMessageModifierTarget } from './helpers/findMessageModifierTarget.preload.js';
|
||||
import { isValidSenderAciForConversation } from './helpers/isValidSenderAciForConversation.preload.js';
|
||||
import { isGroupV2 } from '../util/whatTypeOfConversation.dom.js';
|
||||
import { SignalService as Proto } from '../protobuf/index.std.js';
|
||||
import type { ConversationModel } from '../models/conversations.preload.js';
|
||||
|
||||
const { AccessRequired } = Proto.AccessControl;
|
||||
const { Role } = Proto.Member;
|
||||
|
||||
const parentLog = createLogger('PinnedMessages');
|
||||
|
||||
export type PinnedMessageAddProps = Readonly<{
|
||||
targetSentTimestamp: number;
|
||||
targetAuthorAci: AciString;
|
||||
pinDuration: DurationInSeconds | null;
|
||||
pinnedByAci: AciString;
|
||||
receivedAtTimestamp: number;
|
||||
}>;
|
||||
|
||||
export type PinnedMessageRemoveProps = Readonly<{
|
||||
targetSentTimestamp: number;
|
||||
targetAuthorAci: AciString;
|
||||
unpinnedByAci: AciString;
|
||||
}>;
|
||||
|
||||
export async function onPinnedMessageAdd(
|
||||
props: PinnedMessageAddProps
|
||||
): Promise<void> {
|
||||
const log = parentLog.child(
|
||||
`onPinnedMessageAdd(timestamp=${props.targetSentTimestamp}, aci=${props.targetAuthorAci})`
|
||||
);
|
||||
|
||||
const target = await findMessageModifierTarget(
|
||||
props.targetSentTimestamp,
|
||||
props.targetAuthorAci
|
||||
);
|
||||
|
||||
if (target == null) {
|
||||
// Could potentially happen with out-of-order processing,
|
||||
// or when the targetted message was before we joined a group
|
||||
log.warn('Missing target message, dropping');
|
||||
return;
|
||||
}
|
||||
|
||||
const invalid = validatePinnedMessageTarget(target, props.pinnedByAci);
|
||||
if (invalid != null) {
|
||||
log.info(`Message is invalid target (error: ${invalid.error}), dropping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { targetMessage, targetConversation } = target;
|
||||
|
||||
const expiresAt = getPinnedMessageExpiresAt(
|
||||
props.receivedAtTimestamp,
|
||||
props.pinDuration
|
||||
);
|
||||
|
||||
const pinnedMessagesLimit = getPinnedMessagesLimit();
|
||||
|
||||
const result = await DataWriter.appendPinnedMessage(pinnedMessagesLimit, {
|
||||
conversationId: targetConversation.id,
|
||||
messageId: targetMessage.id,
|
||||
expiresAt,
|
||||
pinnedAt: props.receivedAtTimestamp,
|
||||
});
|
||||
|
||||
if (result.change == null) {
|
||||
log.warn('Skipped pinning message, existing message may have been newer');
|
||||
} else if (result.change.replaced != null) {
|
||||
log.info(
|
||||
`Replaced pinned message ${result.change.replaced} with ${result.change.inserted.id} for target message ${targetMessage.id}`
|
||||
);
|
||||
} else {
|
||||
log.info(
|
||||
`Created pinned message ${result.change.inserted.id} for target message ${targetMessage.id}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const pinnedMessageId of result.truncated) {
|
||||
if (pinnedMessageId === result.change?.inserted.id) {
|
||||
log.warn(`Pinned message ${pinnedMessageId} was immediately truncated`);
|
||||
} else {
|
||||
log.info(`Truncated older pinned message ${pinnedMessageId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function onPinnedMessageRemove(
|
||||
props: PinnedMessageRemoveProps
|
||||
): Promise<void> {
|
||||
const log = parentLog.child(
|
||||
`onPinnedMessageRemove(timestamp=${props.targetSentTimestamp}, aci=${props.targetAuthorAci})`
|
||||
);
|
||||
|
||||
const target = await findMessageModifierTarget(
|
||||
props.targetSentTimestamp,
|
||||
props.targetAuthorAci
|
||||
);
|
||||
|
||||
if (target == null) {
|
||||
// Could potentially happen with out-of-order processing,
|
||||
// or when the targetted message was before we joined a group
|
||||
log.warn('Missing target message, dropping');
|
||||
return;
|
||||
}
|
||||
|
||||
const invalid = validatePinnedMessageTarget(target, props.unpinnedByAci);
|
||||
if (invalid != null) {
|
||||
log.warn(`Message is invalid target: ${invalid.error}, dropping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetMessageId = target.targetMessage.id;
|
||||
|
||||
const deletedPinnedMessageId =
|
||||
await DataWriter.deletePinnedMessageByMessageId(targetMessageId);
|
||||
|
||||
if (deletedPinnedMessageId == null) {
|
||||
log.warn(`Target message ${targetMessageId} was not pinned, dropping`);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Deleted pinned message ${deletedPinnedMessageId} for messageId ${targetMessageId}`
|
||||
);
|
||||
}
|
||||
|
||||
function canSenderEditGroupAttributes(
|
||||
conversation: ConversationModel,
|
||||
sourceAci: AciString
|
||||
): boolean {
|
||||
if (!isGroupV2(conversation.attributes)) {
|
||||
// Just ignore direct conversations
|
||||
return true;
|
||||
}
|
||||
|
||||
const membersV2 = conversation.get('membersV2') ?? [];
|
||||
const member = membersV2.find(m => m.aci === sourceAci);
|
||||
if (member == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const accessControl = conversation.get('accessControl');
|
||||
if (accessControl == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (member.role === Role.ADMINISTRATOR) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return accessControl.attributes === AccessRequired.MEMBER;
|
||||
}
|
||||
|
||||
function validatePinnedMessageTarget(
|
||||
target: MessageModifierTarget,
|
||||
sourceAci: AciString
|
||||
): { error: string } | null {
|
||||
if (!isValidSenderAciForConversation(target.targetConversation, sourceAci)) {
|
||||
return { error: 'Sender cannot send to target conversation' };
|
||||
}
|
||||
|
||||
if (!canSenderEditGroupAttributes(target.targetConversation, sourceAci)) {
|
||||
return { error: 'Sender does not have access to edit group attributes' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPinnedMessageExpiresAt(
|
||||
receivedAtTimestamp: number,
|
||||
pinDuration: DurationInSeconds | null
|
||||
): number | null {
|
||||
if (pinDuration == null) {
|
||||
return null;
|
||||
}
|
||||
const pinDurationMs = DurationInSeconds.toMillis(pinDuration);
|
||||
return receivedAtTimestamp + pinDurationMs;
|
||||
}
|
||||
|
||||
function getPinnedMessagesLimit(): number {
|
||||
const remoteValue = RemoteConfig.getValue('global.pinned_message_limit');
|
||||
return parseIntWithFallback(remoteValue, 3);
|
||||
}
|
||||
@@ -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,
|
||||
} 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,
|
||||
|
||||
@@ -3848,6 +3848,7 @@ export class ConversationModel {
|
||||
return buildGroupLink(this.attributes);
|
||||
}
|
||||
|
||||
// TODO(DESKTOP-9497): This will not include `ourAci` in 1:1 chats
|
||||
getMembers(
|
||||
options: { includePendingMembers?: boolean } = {}
|
||||
): Array<ConversationModel> {
|
||||
@@ -5258,6 +5259,7 @@ export class ConversationModel {
|
||||
await DataWriter.updateConversation(this.attributes);
|
||||
}
|
||||
|
||||
// TODO(DESKTOP-9497): This will return false for `ourAci` in 1:1 chats
|
||||
hasMember(serviceId: ServiceIdString): boolean {
|
||||
const members = this.getMembers();
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ import type {
|
||||
PinnedMessageId,
|
||||
PinnedMessageParams,
|
||||
} from '../types/PinnedMessage.std.js';
|
||||
import type { AppendPinnedMessageResult } from './server/pinnedMessages.std.js';
|
||||
|
||||
export type ReadableDB = Database & { __readable_db: never };
|
||||
export type WritableDB = ReadableDB & { __writable_db: never };
|
||||
@@ -1334,10 +1335,11 @@ type WritableInterface = {
|
||||
messageQueueTime: number
|
||||
) => ReadonlyArray<ChatFolderId>;
|
||||
|
||||
createPinnedMessage: (
|
||||
appendPinnedMessage: (
|
||||
pinnedMessagesLimit: number,
|
||||
pinnedMessageParams: PinnedMessageParams
|
||||
) => PinnedMessage;
|
||||
deletePinnedMessage: (pinnedMessageId: PinnedMessageId) => void;
|
||||
) => AppendPinnedMessageResult;
|
||||
deletePinnedMessageByMessageId: (messageId: string) => PinnedMessageId | null;
|
||||
deleteAllExpiredPinnedMessagesBefore: (
|
||||
beforeTimestamp: number
|
||||
) => ReadonlyArray<PinnedMessageId>;
|
||||
|
||||
@@ -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}
|
||||
`;
|
||||
|
||||
|
||||
17
ts/sql/migrations/1570-pinned-messages-updates.std.ts
Normal file
17
ts/sql/migrations/1570-pinned-messages-updates.std.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { WritableDB } from '../Interface.std.js';
|
||||
import { sql } from '../util.std.js';
|
||||
|
||||
export default function updateToSchemaVersion1570(db: WritableDB): void {
|
||||
const [query] = sql`
|
||||
-- We only need the 'messageId' column
|
||||
ALTER TABLE pinnedMessages DROP COLUMN messageSentAt;
|
||||
ALTER TABLE pinnedMessages DROP COLUMN messageSenderAci;
|
||||
|
||||
-- We dont need to know who pinned the message
|
||||
ALTER TABLE pinnedMessages DROP COLUMN pinnedByAci;
|
||||
`;
|
||||
db.exec(query);
|
||||
}
|
||||
@@ -133,6 +133,7 @@ import updateToSchemaVersion1540 from './1540-partial-expiring-index.std.js';
|
||||
import updateToSchemaVersion1550 from './1550-has-link-preview.std.js';
|
||||
import updateToSchemaVersion1560 from './1560-pinned-messages.std.js';
|
||||
import updateToSchemaVersion1561 from './1561-cleanup-polls.std.js';
|
||||
import updateToSchemaVersion1570 from './1570-pinned-messages-updates.std.js';
|
||||
|
||||
import { DataWriter } from '../Server.node.js';
|
||||
|
||||
@@ -1625,6 +1626,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray<SchemaUpdateType> = [
|
||||
{ version: 1560, update: updateToSchemaVersion1560 },
|
||||
// 1561, 1551, and 1541 all refer to the same migration
|
||||
{ version: 1561, update: updateToSchemaVersion1561 },
|
||||
{ version: 1570, update: updateToSchemaVersion1570 },
|
||||
];
|
||||
|
||||
export class DBVersionFromFutureError extends Error {
|
||||
|
||||
@@ -15,14 +15,25 @@ export function getPinnedMessagesForConversation(
|
||||
conversationId: string
|
||||
): ReadonlyArray<PinnedMessage> {
|
||||
const [query, params] = sql`
|
||||
SELECT * FROM pins
|
||||
SELECT * FROM pinnedMessages
|
||||
WHERE conversationId = ${conversationId}
|
||||
ORDER BY pinnedAt DESC
|
||||
`;
|
||||
return db.prepare(query).all<PinnedMessage>(params);
|
||||
}
|
||||
|
||||
export function createPinnedMessage(
|
||||
function _getPinnedMessageByMessageId(
|
||||
db: ReadableDB,
|
||||
messageId: string
|
||||
): PinnedMessage | null {
|
||||
const [query, params] = sql`
|
||||
SELECT * FROM pinnedMessages
|
||||
WHERE messageId IS ${messageId}
|
||||
`;
|
||||
return db.prepare(query).get<PinnedMessage>(params) ?? null;
|
||||
}
|
||||
|
||||
function _insertPinnedMessage(
|
||||
db: WritableDB,
|
||||
pinnedMessageParams: PinnedMessageParams
|
||||
): PinnedMessage {
|
||||
@@ -30,17 +41,11 @@ export function createPinnedMessage(
|
||||
INSERT INTO pinnedMessages (
|
||||
conversationId,
|
||||
messageId,
|
||||
messageSentAt,
|
||||
messageSenderAci,
|
||||
pinnedByAci,
|
||||
pinnedAt,
|
||||
expiresAt
|
||||
) VALUES (
|
||||
${pinnedMessageParams.conversationId},
|
||||
${pinnedMessageParams.messageId},
|
||||
${pinnedMessageParams.messageSentAt},
|
||||
${pinnedMessageParams.messageSenderAci},
|
||||
${pinnedMessageParams.pinnedByAci},
|
||||
${pinnedMessageParams.pinnedAt},
|
||||
${pinnedMessageParams.expiresAt}
|
||||
)
|
||||
@@ -52,13 +57,10 @@ export function createPinnedMessage(
|
||||
return row;
|
||||
}
|
||||
|
||||
export function deletePinnedMessage(
|
||||
db: WritableDB,
|
||||
pinnedMessageId: PinnedMessageId
|
||||
): void {
|
||||
function _deletePinnedMessageById(db: WritableDB, id: PinnedMessageId): void {
|
||||
const [query, params] = sql`
|
||||
DELETE FROM pinnedMessages
|
||||
WHERE id = ${pinnedMessageId}
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
const result = db.prepare(query).run(params);
|
||||
strictAssert(
|
||||
@@ -67,11 +69,106 @@ export function deletePinnedMessage(
|
||||
);
|
||||
}
|
||||
|
||||
function _truncatePinnedMessagesByConversationId(
|
||||
db: WritableDB,
|
||||
conversationId: string,
|
||||
pinnedMessagesLimit: number
|
||||
): ReadonlyArray<PinnedMessageId> {
|
||||
const [query, params] = sql`
|
||||
DELETE FROM pinnedMessages
|
||||
WHERE conversationId = ${conversationId}
|
||||
AND id NOT IN (
|
||||
SELECT id FROM pinnedMessages
|
||||
WHERE conversationId = ${conversationId}
|
||||
ORDER BY pinnedAt DESC
|
||||
LIMIT ${pinnedMessagesLimit}
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
return db.prepare(query, { pluck: true }).all<PinnedMessageId>(params);
|
||||
}
|
||||
|
||||
export type AppendPinnedMessageChange = Readonly<{
|
||||
inserted: PinnedMessage;
|
||||
replaced: PinnedMessageId | null;
|
||||
}>;
|
||||
|
||||
export type AppendPinnedMessageResult = Readonly<{
|
||||
change: AppendPinnedMessageChange | null;
|
||||
// Note: The `inserted` pin may immediately be truncated
|
||||
truncated: ReadonlyArray<PinnedMessageId>;
|
||||
}>;
|
||||
|
||||
export function appendPinnedMessage(
|
||||
db: WritableDB,
|
||||
pinnedMessagesLimit: number,
|
||||
pinnedMessageParams: PinnedMessageParams
|
||||
): AppendPinnedMessageResult {
|
||||
return db.transaction(() => {
|
||||
const existing = _getPinnedMessageByMessageId(
|
||||
db,
|
||||
pinnedMessageParams.messageId
|
||||
);
|
||||
|
||||
let shouldInsertOrReplace: boolean;
|
||||
if (existing == null) {
|
||||
// Always insert if there's no existing
|
||||
shouldInsertOrReplace = true;
|
||||
} else if (pinnedMessageParams.pinnedAt > existing.pinnedAt) {
|
||||
// Only replace if the pin is newer
|
||||
shouldInsertOrReplace = true;
|
||||
} else {
|
||||
shouldInsertOrReplace = false;
|
||||
}
|
||||
|
||||
let change: AppendPinnedMessageChange | null = null;
|
||||
if (shouldInsertOrReplace) {
|
||||
let replaced: PinnedMessageId | null = null;
|
||||
|
||||
if (existing != null) {
|
||||
_deletePinnedMessageById(db, existing.id);
|
||||
replaced = existing.id;
|
||||
}
|
||||
|
||||
const inserted = _insertPinnedMessage(db, pinnedMessageParams);
|
||||
|
||||
change = { inserted, replaced };
|
||||
}
|
||||
|
||||
const truncated = _truncatePinnedMessagesByConversationId(
|
||||
db,
|
||||
pinnedMessageParams.conversationId,
|
||||
pinnedMessagesLimit
|
||||
);
|
||||
|
||||
return { change, truncated };
|
||||
})();
|
||||
}
|
||||
|
||||
export function deletePinnedMessageByMessageId(
|
||||
db: WritableDB,
|
||||
messageId: string
|
||||
): PinnedMessageId | null {
|
||||
const [query, params] = sql`
|
||||
DELETE FROM pinnedMessages
|
||||
WHERE messageId = ${messageId}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const result = db
|
||||
.prepare(query, { pluck: true })
|
||||
.get<PinnedMessageId>(params);
|
||||
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
export function getNextExpiringPinnedMessageAcrossConversations(
|
||||
db: ReadableDB
|
||||
): PinnedMessage | null {
|
||||
const [query, params] = sql`
|
||||
SELECT * FROM pinnedMessages
|
||||
WHERE expiresAt IS NOT null
|
||||
ORDER BY expiresAt ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import React, { memo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import type { AciString } from '@signalapp/mock-server/src/types.js';
|
||||
import { getIntl } from '../selectors/user.std.js';
|
||||
import { getConversationByIdSelector } from '../selectors/conversations.dom.js';
|
||||
import { strictAssert } from '../../util/assert.std.js';
|
||||
@@ -17,8 +16,6 @@ import type {
|
||||
PinnedMessageId,
|
||||
} from '../../types/PinnedMessage.std.js';
|
||||
import type { StateType } from '../reducer.preload.js';
|
||||
import { itemStorage } from '../../textsecure/Storage.preload.js';
|
||||
import { isAciString } from '../../util/isAciString.std.js';
|
||||
|
||||
export type SmartPinnedMessagesPanelProps = Readonly<{
|
||||
conversationId: string;
|
||||
@@ -41,8 +38,6 @@ const mockSelectPinnedMessages: StateSelector<ReadonlyArray<PinnedMessage>> =
|
||||
conversations.messagesByConversation[selectedConversationId]
|
||||
?.messageIds ?? [];
|
||||
|
||||
const ourAci = itemStorage.user.getCheckedAci();
|
||||
|
||||
return messageIds
|
||||
.map(messageId => {
|
||||
return conversations.messagesLookup[messageId] ?? null;
|
||||
@@ -52,24 +47,10 @@ const mockSelectPinnedMessages: StateSelector<ReadonlyArray<PinnedMessage>> =
|
||||
})
|
||||
.slice(-10)
|
||||
.map((message, messageIndex): PinnedMessage => {
|
||||
let messageSenderAci: AciString;
|
||||
if (message.type === 'outgoing') {
|
||||
messageSenderAci = ourAci;
|
||||
} else {
|
||||
strictAssert(
|
||||
isAciString(message.sourceServiceId),
|
||||
'sourceServiceId must be aci string for incoming message'
|
||||
);
|
||||
messageSenderAci = message.sourceServiceId;
|
||||
}
|
||||
|
||||
return {
|
||||
id: messageIndex as PinnedMessageId,
|
||||
conversationId: selectedConversationId,
|
||||
messageId: message.id,
|
||||
messageSentAt: message.sent_at,
|
||||
messageSenderAci,
|
||||
pinnedByAci: ourAci,
|
||||
pinnedAt: Date.now(),
|
||||
expiresAt: null,
|
||||
};
|
||||
|
||||
304
ts/test-node/sql/server/pinnedMessages_test.node.ts
Normal file
304
ts/test-node/sql/server/pinnedMessages_test.node.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import assert from 'node:assert/strict';
|
||||
import type { WritableDB } from '../../../sql/Interface.std.js';
|
||||
import { setupTests } from '../../../sql/Server.node.js';
|
||||
import type { AppendPinnedMessageResult } from '../../../sql/server/pinnedMessages.std.ts';
|
||||
import {
|
||||
appendPinnedMessage,
|
||||
deletePinnedMessageByMessageId,
|
||||
getNextExpiringPinnedMessageAcrossConversations,
|
||||
deleteAllExpiredPinnedMessagesBefore,
|
||||
} from '../../../sql/server/pinnedMessages.std.js';
|
||||
import { createDB, insertData } from '../helpers.node.js';
|
||||
import type {
|
||||
PinnedMessage,
|
||||
PinnedMessageParams,
|
||||
} from '../../../types/PinnedMessage.std.js';
|
||||
|
||||
function setupData(db: WritableDB) {
|
||||
insertData(db, 'conversations', [{ id: 'c1' }, { id: 'c2' }]);
|
||||
insertData(db, 'messages', [
|
||||
// conversation: c1
|
||||
{ id: 'c1-m1', conversationId: 'c1' },
|
||||
{ id: 'c1-m2', conversationId: 'c1' },
|
||||
{ id: 'c1-m3', conversationId: 'c1' },
|
||||
{ id: 'c1-m4', conversationId: 'c1' },
|
||||
// conversation: c2
|
||||
{ id: 'c2-m1', conversationId: 'c2' },
|
||||
{ id: 'c2-m2', conversationId: 'c2' },
|
||||
]);
|
||||
}
|
||||
|
||||
function getParams(
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
pinnedAt: number,
|
||||
expiresAt: number | null = null
|
||||
): PinnedMessageParams {
|
||||
return {
|
||||
messageId,
|
||||
conversationId,
|
||||
pinnedAt,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe('sql/server/pinnedMessages', () => {
|
||||
let db: WritableDB;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDB();
|
||||
setupTests(db);
|
||||
setupData(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
function assertRows(expected: ReadonlyArray<PinnedMessage>) {
|
||||
const rows = db.prepare('SELECT * FROM pinnedMessages').all();
|
||||
assert.deepEqual(rows, expected);
|
||||
}
|
||||
|
||||
function expectInserted(result: AppendPinnedMessageResult): PinnedMessage {
|
||||
const inserted = result.change?.inserted;
|
||||
assert(inserted != null, 'Append should have inserted a row');
|
||||
return inserted;
|
||||
}
|
||||
|
||||
describe('appendPinnedMessage', () => {
|
||||
it('insert new pinned message', () => {
|
||||
const params = getParams('c1', 'c1-m1', 1);
|
||||
const result = appendPinnedMessage(db, 3, params);
|
||||
const row = expectInserted(result);
|
||||
assertRows([row]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
change: {
|
||||
inserted: { id: 1, ...params },
|
||||
replaced: null,
|
||||
},
|
||||
truncated: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('replace existing pinned message', () => {
|
||||
const initial = getParams('c1', 'c1-m1', 1);
|
||||
const updated = getParams('c1', 'c1-m1', 2);
|
||||
|
||||
appendPinnedMessage(db, 3, initial);
|
||||
const result = appendPinnedMessage(db, 3, updated);
|
||||
const row = expectInserted(result);
|
||||
assertRows([row]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
change: {
|
||||
inserted: { id: 2, ...updated },
|
||||
replaced: 1,
|
||||
},
|
||||
truncated: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates pinned messages to limit', () => {
|
||||
const pin1 = getParams('c1', 'c1-m1', 1);
|
||||
const pin2 = getParams('c1', 'c1-m2', 2);
|
||||
const pin3 = getParams('c1', 'c1-m3', 3);
|
||||
const pin4 = getParams('c1', 'c1-m4', 4);
|
||||
|
||||
const row1 = expectInserted(appendPinnedMessage(db, 3, pin1));
|
||||
const row2 = expectInserted(appendPinnedMessage(db, 3, pin2));
|
||||
const row3 = expectInserted(appendPinnedMessage(db, 3, pin3));
|
||||
assertRows([row1, row2, row3]);
|
||||
|
||||
const result = appendPinnedMessage(db, 3, pin4);
|
||||
const row4 = expectInserted(result);
|
||||
|
||||
assertRows([row2, row3, row4]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
change: {
|
||||
inserted: { id: 4, ...pin4 },
|
||||
replaced: null,
|
||||
},
|
||||
truncated: [1],
|
||||
});
|
||||
});
|
||||
|
||||
it('doesnt truncate on top of replacing existing', () => {
|
||||
const pin1 = getParams('c1', 'c1-m1', 1);
|
||||
const pin2 = getParams('c1', 'c1-m2', 2);
|
||||
const pin3 = getParams('c1', 'c1-m3', 3);
|
||||
const updated = { ...pin3, pinnedAt: 4 };
|
||||
|
||||
const row1 = expectInserted(appendPinnedMessage(db, 3, pin1));
|
||||
const row2 = expectInserted(appendPinnedMessage(db, 3, pin2));
|
||||
const row3 = expectInserted(appendPinnedMessage(db, 3, pin3));
|
||||
assertRows([row1, row2, row3]);
|
||||
|
||||
const result = appendPinnedMessage(db, 3, updated);
|
||||
const row4 = expectInserted(result);
|
||||
assertRows([row1, row2, row4]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
change: {
|
||||
inserted: { id: 4, ...updated },
|
||||
replaced: 3,
|
||||
},
|
||||
truncated: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates multiple past limit', () => {
|
||||
const pin1 = getParams('c1', 'c1-m1', 1);
|
||||
const pin2 = getParams('c1', 'c1-m2', 2);
|
||||
const pin3 = getParams('c1', 'c1-m3', 3);
|
||||
const pin4 = getParams('c1', 'c1-m4', 4);
|
||||
|
||||
let limit = 3;
|
||||
|
||||
const row1 = expectInserted(appendPinnedMessage(db, limit, pin1));
|
||||
const row2 = expectInserted(appendPinnedMessage(db, limit, pin2));
|
||||
const row3 = expectInserted(appendPinnedMessage(db, limit, pin3));
|
||||
assertRows([row1, row2, row3]);
|
||||
|
||||
limit = 2;
|
||||
|
||||
const result = appendPinnedMessage(db, limit, pin4);
|
||||
const row4 = expectInserted(result);
|
||||
assertRows([row3, row4]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
change: {
|
||||
inserted: { id: 4, ...pin4 },
|
||||
replaced: null,
|
||||
},
|
||||
truncated: [1, 2],
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates based on pinnedAt (not insert order) to handle out-of-order messages', () => {
|
||||
const pin1 = getParams('c1', 'c1-m1', 1);
|
||||
const pin2 = getParams('c1', 'c1-m2', 2);
|
||||
const pin3 = getParams('c1', 'c1-m3', 3);
|
||||
const pin4 = getParams('c1', 'c1-m4', 3);
|
||||
|
||||
const row2 = expectInserted(appendPinnedMessage(db, 3, pin2));
|
||||
const row3 = expectInserted(appendPinnedMessage(db, 3, pin3));
|
||||
const row4 = expectInserted(appendPinnedMessage(db, 3, pin4));
|
||||
const result = appendPinnedMessage(db, 3, pin1);
|
||||
assertRows([row2, row3, row4]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
change: {
|
||||
// Note: New row was immediately truncated
|
||||
inserted: { id: 4, ...pin1 },
|
||||
replaced: null,
|
||||
},
|
||||
truncated: [4],
|
||||
});
|
||||
});
|
||||
|
||||
it('should only truncate for the same conversation', () => {
|
||||
const pin1 = getParams('c1', 'c1-m1', 1);
|
||||
const pin2 = getParams('c1', 'c1-m2', 2);
|
||||
const pin3 = getParams('c1', 'c1-m3', 3);
|
||||
const pin4 = getParams('c2', 'c2-m1', 4); // other chat
|
||||
|
||||
const row1 = expectInserted(appendPinnedMessage(db, 3, pin2));
|
||||
const row2 = expectInserted(appendPinnedMessage(db, 3, pin3));
|
||||
const row3 = expectInserted(appendPinnedMessage(db, 3, pin4));
|
||||
const result = appendPinnedMessage(db, 3, pin1);
|
||||
const row4 = expectInserted(result);
|
||||
assertRows([row1, row2, row3, row4]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
change: {
|
||||
inserted: { id: 4, ...pin1 },
|
||||
replaced: null,
|
||||
},
|
||||
truncated: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePinnedMessageByMessageId', () => {
|
||||
it('should return null if theres no matching pinned message', () => {
|
||||
const result = deletePinnedMessageByMessageId(db, 'c1-m1');
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it('should return the deleted pinned message id', () => {
|
||||
appendPinnedMessage(db, 3, getParams('c1', 'c1-m1', 1));
|
||||
const result = deletePinnedMessageByMessageId(db, 'c1-m1');
|
||||
assert.equal(result, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextExpiringPinnedMessageAcrossConversations', () => {
|
||||
it('should return null if theres no pinned messages', () => {
|
||||
const result = getNextExpiringPinnedMessageAcrossConversations(db);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it('should return null if the pinned messages have no expiration', () => {
|
||||
appendPinnedMessage(db, 3, getParams('c1', 'c1-m1', 1, null));
|
||||
const result = getNextExpiringPinnedMessageAcrossConversations(db);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it('should return the pinned message with the earliest expiration date', () => {
|
||||
const pin1 = getParams('c1', 'c1-m1', 1, 4);
|
||||
const pin2 = getParams('c1', 'c1-m1', 2, 3);
|
||||
const pin3 = getParams('c2', 'c2-m1', 3, 2);
|
||||
const pin4 = getParams('c2', 'c2-m2', 4, 1); // expires next
|
||||
|
||||
appendPinnedMessage(db, 3, pin1);
|
||||
appendPinnedMessage(db, 3, pin2);
|
||||
appendPinnedMessage(db, 3, pin3);
|
||||
appendPinnedMessage(db, 3, pin4);
|
||||
|
||||
const result = getNextExpiringPinnedMessageAcrossConversations(db);
|
||||
assert.deepEqual(result, {
|
||||
id: 4,
|
||||
...pin4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAllExpiredPinnedMessagesBefore', () => {
|
||||
function insertPin(params: PinnedMessageParams) {
|
||||
return expectInserted(appendPinnedMessage(db, 3, params));
|
||||
}
|
||||
|
||||
it('should return an empty array if theres no pinned messages', () => {
|
||||
const result = deleteAllExpiredPinnedMessagesBefore(db, 1);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
it('should not delete pinned messages that have no expiration', () => {
|
||||
const row = insertPin(getParams('c1', 'c1-m1', 1, null)); // no expiration
|
||||
const result = deleteAllExpiredPinnedMessagesBefore(db, 1);
|
||||
assertRows([row]);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
it('should not delete pinned messages that have not expired yet ', () => {
|
||||
const row = insertPin(getParams('c1', 'c1-m1', 1, 2)); // not expired yet
|
||||
const result = deleteAllExpiredPinnedMessagesBefore(db, 1);
|
||||
assertRows([row]);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
it('should delete pinned messages that have expired', () => {
|
||||
const row1 = insertPin(getParams('c1', 'c1-m1', 1, 1)); // expired
|
||||
const row2 = insertPin(getParams('c1', 'c1-m2', 2, 2)); // expired
|
||||
const row3 = insertPin(getParams('c1', 'c1-m3', 3, 3)); // not expired yet
|
||||
const result = deleteAllExpiredPinnedMessagesBefore(db, 2);
|
||||
assertRows([row3]);
|
||||
assert.deepEqual(result, [row1.id, row2.id]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
13
ts/textsecure/Types.d.ts
vendored
13
ts/textsecure/Types.d.ts
vendored
@@ -185,6 +185,12 @@ export type ProcessedReaction = {
|
||||
targetTimestamp?: number;
|
||||
};
|
||||
|
||||
export type ProcessedPinMessage = Readonly<{
|
||||
targetAuthorAci: AciString;
|
||||
targetSentTimestamp: number;
|
||||
pinDuration: DurationInSeconds | null;
|
||||
}>;
|
||||
|
||||
export type ProcessedPollCreate = {
|
||||
question?: string;
|
||||
options?: Array<string>;
|
||||
@@ -218,6 +224,11 @@ export type ProcessedGiftBadge = {
|
||||
state: GiftBadgeStates;
|
||||
};
|
||||
|
||||
export type ProcessedUnpinMessage = Readonly<{
|
||||
targetAuthorAci: AciString;
|
||||
targetSentTimestamp: number;
|
||||
}>;
|
||||
|
||||
export type ProcessedStoryContext = {
|
||||
authorAci: AciString | undefined;
|
||||
sentTimestamp: number;
|
||||
@@ -243,6 +254,7 @@ export type ProcessedDataMessage = {
|
||||
isStory?: boolean;
|
||||
isViewOnce: boolean;
|
||||
reaction?: ProcessedReaction;
|
||||
pinMessage?: ProcessedPinMessage;
|
||||
pollCreate?: ProcessedPollCreate;
|
||||
pollVote?: ProcessedPollVote;
|
||||
pollTerminate?: ProcessedPollTerminate;
|
||||
@@ -251,6 +263,7 @@ export type ProcessedDataMessage = {
|
||||
groupCallUpdate?: ProcessedGroupCallUpdate;
|
||||
storyContext?: ProcessedStoryContext;
|
||||
giftBadge?: ProcessedGiftBadge;
|
||||
unpinMessage?: ProcessedUnpinMessage;
|
||||
canReplyToStory?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
|
||||
@@ -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 } = {}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user