mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-10-05 15:30:06 +01:00
Add receive support for pin/unpin message
This commit is contained in:
1 parent
1b03cc4b9b
commit
efe2c8de71
24 files changed
+861
-97
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user