Files
Desktop/ts/messageModifiers/PinnedMessages.preload.ts
2025-12-16 09:51:03 -08:00

199 lines
6.2 KiB
TypeScript

// 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 type { DurationInSeconds } from '../util/durations/duration-in-seconds.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';
import { getPinnedMessagesLimit } from '../util/pinnedMessages.dom.js';
import { getPinnedMessageExpiresAt } from '../util/pinnedMessages.std.js';
import { pinnedMessagesCleanupService } from '../services/expiring/pinnedMessagesCleanupService.preload.js';
import { drop } from '../util/drop.std.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}`);
}
}
drop(pinnedMessagesCleanupService.trigger('onPinnedMessageAdd'));
if (result.change?.inserted) {
await targetConversation.addNotification('pinned-message-notification', {
pinnedMessageId: targetMessage.id,
sourceServiceId: props.pinnedByAci,
});
}
window.reduxActions.pinnedMessages.onPinnedMessagesChanged(
targetConversation.id
);
}
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 targetConversationId = target.targetConversation.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}`
);
drop(pinnedMessagesCleanupService.trigger('onPinnedMessageRemove'));
window.reduxActions.pinnedMessages.onPinnedMessagesChanged(
targetConversationId
);
}
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;
}