Sync and update UI when pinned messages expire

This commit is contained in:
Jamie
2026-01-14 11:12:32 -08:00
committed by GitHub
parent d938215b07
commit da279446c4
11 changed files with 117 additions and 42 deletions

View File

@@ -8,12 +8,14 @@ import { AxoRadioGroup } from '../../../axo/AxoRadioGroup.dom.js';
import { DurationInSeconds } from '../../../util/durations/duration-in-seconds.std.js';
import { strictAssert } from '../../../util/assert.std.js';
import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.js';
import { isInternalFeaturesEnabled } from '../../../util/isInternalFeaturesEnabled.dom.js';
enum DurationOption {
TIME_24_HOURS = 'TIME_24_HOURS',
TIME_7_DAYS = 'TIME_7_DAYS',
TIME_30_DAYS = 'TIME_30_DAYS',
FOREVER = 'FOREVER',
DEBUG_10_SECONDS = 'DEBUG_10_SECONDS',
}
const DURATION_OPTIONS: Record<DurationOption, DurationInSeconds | null> = {
@@ -21,6 +23,7 @@ const DURATION_OPTIONS: Record<DurationOption, DurationInSeconds | null> = {
[DurationOption.TIME_7_DAYS]: DurationInSeconds.fromDays(7),
[DurationOption.TIME_30_DAYS]: DurationInSeconds.fromDays(30),
[DurationOption.FOREVER]: null,
[DurationOption.DEBUG_10_SECONDS]: DurationInSeconds.fromSeconds(10),
};
function isValidDurationOption(value: string): value is DurationOption {
@@ -152,6 +155,14 @@ export const PinMessageDialog = memo(function PinMessageDialog(
{i18n('icu:PinMessageDialog__Option--FOREVER')}
</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
{isInternalFeaturesEnabled() && (
<AxoRadioGroup.Item value={DurationOption.DEBUG_10_SECONDS}>
<AxoRadioGroup.Indicator />
<AxoRadioGroup.Label>
10 seconds (Internal)
</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
)}
</AxoRadioGroup.Root>
</AxoDialog.Body>
<AxoDialog.Footer>

View File

@@ -292,6 +292,7 @@ const unpinMessageJobDataSchema = z.object({
targetAuthorAci: aciSchema,
targetSentTimestamp: z.number(),
unpinnedAt: z.number(),
isSyncOnly: z.boolean(),
});
export type UnpinMessageJobData = z.infer<typeof unpinMessageJobDataSchema>;

View File

@@ -25,6 +25,7 @@ import { itemStorage } from '../../textsecure/Storage.preload.js';
export type SendMessageJobOptions<Data> = Readonly<{
sendName: string; // ex: 'sendExampleMessage'
sendType: SendTypesType;
isSyncOnly: (data: Data) => boolean;
getMessageId: (data: Data) => string | null;
getMessageOptions: (
data: Data
@@ -43,6 +44,7 @@ export function createSendMessageJob<Data>(
const {
sendName,
sendType,
isSyncOnly,
getMessageId,
getMessageOptions,
getExpirationStartTimestamp,
@@ -60,7 +62,9 @@ export function createSendMessageJob<Data>(
getSendRecipientLists({
log,
conversation,
conversationIds: Array.from(conversation.getMemberConversationIds()),
conversationIds: isSyncOnly(data)
? [window.ConversationController.getOurConversationIdOrThrow()]
: Array.from(conversation.getMemberConversationIds()),
});
if (untrustedServiceIds.length > 0) {

View File

@@ -6,6 +6,9 @@ import { createSendMessageJob } from './createSendMessageJob.preload.js';
export const sendPinMessage = createSendMessageJob<PinMessageJobData>({
sendName: 'sendPinMessage',
sendType: 'pinMessage',
isSyncOnly() {
return false;
},
getMessageId(data) {
return data.targetMessageId;
},

View File

@@ -7,6 +7,9 @@ import { createSendMessageJob } from './createSendMessageJob.preload.js';
export const sendUnpinMessage = createSendMessageJob<UnpinMessageJobData>({
sendName: 'sendUnpinMessage',
sendType: 'unpinMessage',
isSyncOnly(data) {
return data.isSyncOnly;
},
getMessageId(data) {
return data.targetMessageId;
},

View File

@@ -4,6 +4,12 @@
import { DataReader, DataWriter } from '../../sql/Client.preload.js';
import { createExpiringEntityCleanupService } from './createExpiringEntityCleanupService.std.js';
import { strictAssert } from '../../util/assert.std.js';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../../jobs/conversationJobQueue.preload.js';
import { getPinnedMessageTarget } from '../../util/getPinMessageTarget.preload.js';
import { drop } from '../../util/drop.std.js';
export const pinnedMessagesCleanupService = createExpiringEntityCleanupService({
logPrefix: 'PinnedMessages',
@@ -23,11 +29,39 @@ export const pinnedMessagesCleanupService = createExpiringEntityCleanupService({
};
},
cleanupExpiredEntities: async () => {
const deletedPinnedMessagesIds =
const deletedPinnedMessages =
await DataWriter.deleteAllExpiredPinnedMessagesBefore(Date.now());
const unpinnedAt = Date.now();
const deletedPinnedMessagesIds = [];
const changedConversationIds = new Set<string>();
for (const pinnedMessage of deletedPinnedMessages) {
deletedPinnedMessagesIds.push(pinnedMessage.id);
changedConversationIds.add(pinnedMessage.conversationId);
// Add to conversation queue without waiting
drop(sendUnpinSync(pinnedMessage.messageId, unpinnedAt));
}
for (const conversationId of changedConversationIds) {
window.reduxActions.conversations.onPinnedMessagesChanged(conversationId);
}
return deletedPinnedMessagesIds;
},
subscribeToTriggers: () => {
return () => null;
},
});
async function sendUnpinSync(targetMessageId: string, unpinnedAt: number) {
const target = await getPinnedMessageTarget(targetMessageId);
if (target == null) {
return;
}
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.UnpinMessage,
...target,
unpinnedAt,
isSyncOnly: true,
});
}

View File

@@ -1399,7 +1399,7 @@ type WritableInterface = {
deletePinnedMessageByMessageId: (messageId: string) => PinnedMessageId | null;
deleteAllExpiredPinnedMessagesBefore: (
beforeTimestamp: number
) => ReadonlyArray<PinnedMessageId>;
) => ReadonlyArray<PinnedMessage>;
removeAll: () => void;
removeAllConfiguration: () => void;

View File

@@ -232,11 +232,11 @@ export function getNextExpiringPinnedMessageAcrossConversations(
export function deleteAllExpiredPinnedMessagesBefore(
db: WritableDB,
beforeTimestamp: number
): ReadonlyArray<PinnedMessageId> {
): ReadonlyArray<PinnedMessage> {
const [query, params] = sql`
DELETE FROM pinnedMessages
WHERE expiresAt <= ${beforeTimestamp}
RETURNING id
RETURNING *
`;
return db.prepare(query, { pluck: true }).all<PinnedMessageId>(params);
return db.prepare(query).all<PinnedMessage>(params);
}

View File

@@ -253,6 +253,7 @@ import type { StateThunk } from '../types.std.js';
import { getPinnedMessagesLimit } from '../../util/pinnedMessages.dom.js';
import { getPinnedMessageExpiresAt } from '../../util/pinnedMessages.std.js';
import { pinnedMessagesCleanupService } from '../../services/expiring/pinnedMessagesCleanupService.preload.js';
import { getPinnedMessageTarget } from '../../util/getPinMessageTarget.preload.js';
const {
chunk,
@@ -5126,41 +5127,6 @@ function startAvatarDownload(
};
}
function getMessageAuthorAci(
message: ReadonlyMessageAttributesType
): AciString {
if (isIncoming(message)) {
strictAssert(
isAciString(message.sourceServiceId),
'Message sourceServiceId must be an ACI'
);
return message.sourceServiceId;
}
return itemStorage.user.getCheckedAci();
}
type PinnedMessageTarget = ReadonlyDeep<{
conversationId: string;
targetMessageId: string;
targetAuthorAci: AciString;
targetSentTimestamp: number;
}>;
async function getPinnedMessageTarget(
targetMessageId: string
): Promise<PinnedMessageTarget> {
const message = await DataReader.getMessageById(targetMessageId);
if (message == null) {
throw new Error('getPinnedMessageTarget: Target message not found');
}
return {
conversationId: message.conversationId,
targetMessageId: message.id,
targetAuthorAci: getMessageAuthorAci(message),
targetSentTimestamp: message.sent_at,
};
}
function onPinnedMessagesChanged(
conversationId: string
): StateThunk<PinnedMessagesReplace> {
@@ -5194,6 +5160,10 @@ function onPinnedMessageAdd(
): StateThunk {
return async dispatch => {
const target = await getPinnedMessageTarget(targetMessageId);
if (target == null) {
throw new Error('onPinnedMessageAdd: Missing target message');
}
const targetConversation = window.ConversationController.get(
target.conversationId
);
@@ -5239,10 +5209,14 @@ function onPinnedMessageAdd(
function onPinnedMessageRemove(targetMessageId: string): StateThunk {
return async dispatch => {
const target = await getPinnedMessageTarget(targetMessageId);
if (target == null) {
throw new Error('onPinnedMessageRemove: Missing target message');
}
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.UnpinMessage,
...target,
unpinnedAt: Date.now(),
isSyncOnly: false,
});
await DataWriter.deletePinnedMessageByMessageId(targetMessageId);
drop(pinnedMessagesCleanupService.trigger('onPinnedMessageRemove'));

View File

@@ -298,7 +298,7 @@ describe('sql/server/pinnedMessages', () => {
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]);
assert.deepEqual(result, [row1, row2]);
});
});
});

View File

@@ -0,0 +1,45 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isIncoming } from '../messages/helpers.std.js';
import type { ReadonlyMessageAttributesType } from '../model-types.js';
import { DataReader } from '../sql/Client.preload.js';
import { itemStorage } from '../textsecure/Storage.preload.js';
import type { AciString } from '../types/ServiceId.std.js';
import { strictAssert } from './assert.std.js';
import { isAciString } from './isAciString.std.js';
export type PinnedMessageTarget = Readonly<{
conversationId: string;
targetMessageId: string;
targetAuthorAci: AciString;
targetSentTimestamp: number;
}>;
function getMessageAuthorAci(
message: ReadonlyMessageAttributesType
): AciString {
if (isIncoming(message)) {
strictAssert(
isAciString(message.sourceServiceId),
'Message sourceServiceId must be an ACI'
);
return message.sourceServiceId;
}
return itemStorage.user.getCheckedAci();
}
export async function getPinnedMessageTarget(
targetMessageId: string
): Promise<PinnedMessageTarget | null> {
const message = await DataReader.getMessageById(targetMessageId);
if (message == null) {
return null;
}
return {
conversationId: message.conversationId,
targetMessageId: message.id,
targetAuthorAci: getMessageAuthorAci(message),
targetSentTimestamp: message.sent_at,
};
}