You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-desktop/ts/util/expiringMessages.ts

429 lines
14 KiB
TypeScript

import { throttle, uniq } from 'lodash';
import moment from 'moment';
import { messagesExpired } from '../state/ducks/conversations';
import { TimerOptionsArray } from '../state/ducks/timerOptions';
import { LocalizerKeys } from '../types/LocalizerKeys';
import { initWallClockListener } from './wallClockListener';
import { Data } from '../data/data';
import { getConversationController } from '../session/conversations';
import { getNowWithNetworkOffset } from '../session/apis/snode_api/SNodeAPI';
import { ProtobufUtils, SignalService } from '../protobuf';
import { ConversationModel } from '../models/conversation';
import { checkIsFeatureReleased } from './releaseFeature';
import { MessageModel } from '../models/message';
// TODO Might need to be improved by using an enum
// TODO do we need to add legacy here now that it's explicitly in the protbuf?
export const DisappearingMessageMode = ['deleteAfterRead', 'deleteAfterSend'];
export type DisappearingMessageType = typeof DisappearingMessageMode[number] | null;
// TODO legacy messages support will be removed in a future release
export const DisappearingMessageConversationSetting = ['off', ...DisappearingMessageMode, 'legacy'];
export type DisappearingMessageConversationType = typeof DisappearingMessageConversationSetting[number];
export const DEFAULT_TIMER_OPTION = {
PRIVATE_CONVERSATION: 86400, // 1 day
GROUP: 43200, // 12 hours
};
export type DisappearingMessageUpdate = {
expirationType: DisappearingMessageType;
expirationTimer: number;
// This is used for the expirationTimerUpdate
lastDisappearingMessageChangeTimestamp?: number;
// TODO legacy messages support will be removed in a future release
isLegacyConversationSettingMessage?: boolean;
isLegacyDataMessage?: boolean;
isDisappearingMessagesV2Released?: boolean;
};
export async function destroyMessagesAndUpdateRedux(
messages: Array<{
conversationKey: string;
messageId: string;
}>
) {
if (!messages.length) {
return;
}
const conversationWithChanges = uniq(messages.map(m => m.conversationKey));
try {
const messageIds = messages.map(m => m.messageId);
// Delete any attachments
// tslint:disable-next-line: prefer-for-of
for (let i = 0; i < messageIds.length; i++) {
const message = await Data.getMessageById(messageIds[i]);
await message?.cleanup();
}
// Delete all those messages in a single sql call
await Data.removeMessagesByIds(messageIds);
} catch (e) {
window.log.error('destroyMessages: removeMessagesByIds failed', e && e.message ? e.message : e);
}
// trigger a redux update if needed for all those messages
window.inboxStore?.dispatch(messagesExpired(messages));
// trigger a refresh the last message for all those uniq conversation
conversationWithChanges.map(convoIdToUpdate => {
getConversationController()
.get(convoIdToUpdate)
?.updateLastMessage();
});
}
async function destroyExpiredMessages() {
try {
window.log.info('destroyExpiredMessages: Loading messages...');
const messages = await Data.getExpiredMessages();
const messagesExpiredDetails: Array<{
conversationKey: string;
messageId: string;
}> = messages.map(m => ({
conversationKey: m.attributes.conversationId,
messageId: m.id,
}));
messages.map(expired => {
window.log.info('Message expired', {
sentAt: expired.get('sent_at'),
});
});
await destroyMessagesAndUpdateRedux(messagesExpiredDetails);
} catch (error) {
window.log.error(
'destroyExpiredMessages: Error deleting expired messages',
error && error.stack ? error.stack : error
);
}
window.log.info('destroyExpiredMessages: complete');
void checkExpiringMessages();
}
let timeout: NodeJS.Timeout | undefined;
async function checkExpiringMessages() {
// Look up the next expiring message and set a timer to destroy it
const messages = await Data.getNextExpiringMessage();
const next = messages.at(0);
if (!next) {
return;
}
const expiresAt = next.get('expires_at');
if (!expiresAt) {
return;
}
window.log.info('next message expires', new Date(expiresAt).toISOString());
window.log.info('next message expires in ', (expiresAt - Date.now()) / 1000);
let wait = expiresAt - Date.now();
// In the past
if (wait < 0) {
wait = 0;
}
// Too far in the future, since it's limited to a 32-bit value
if (wait > 2147483647) {
wait = 2147483647;
}
if (timeout) {
global.clearTimeout(timeout);
}
timeout = global.setTimeout(destroyExpiredMessages, wait);
}
const throttledCheckExpiringMessages = throttle(checkExpiringMessages, 1000);
let isInit = false;
const initExpiringMessageListener = () => {
if (isInit) {
throw new Error('expiring messages listener is already init');
}
void checkExpiringMessages();
initWallClockListener(throttledCheckExpiringMessages);
isInit = true;
};
const updateExpiringMessagesCheck = () => {
void throttledCheckExpiringMessages();
};
function getTimerOptionName(time: number, unit: moment.DurationInputArg2) {
return (
window.i18n(['timerOption', time, unit].join('_') as LocalizerKeys) ||
moment.duration(time, unit).humanize()
);
}
function getTimerOptionAbbreviated(time: number, unit: string) {
return window.i18n(['timerOption', time, unit, 'abbreviated'].join('_') as LocalizerKeys);
}
const timerOptionsDurations: Array<{
time: number;
unit: moment.DurationInputArg2;
seconds: number;
}> = [
{ time: 0, unit: 'seconds' as moment.DurationInputArg2 },
{ time: 5, unit: 'seconds' as moment.DurationInputArg2 },
{ time: 10, unit: 'seconds' as moment.DurationInputArg2 },
{ time: 30, unit: 'seconds' as moment.DurationInputArg2 },
{ time: 1, unit: 'minute' as moment.DurationInputArg2 },
{ time: 5, unit: 'minutes' as moment.DurationInputArg2 },
{ time: 30, unit: 'minutes' as moment.DurationInputArg2 },
{ time: 1, unit: 'hour' as moment.DurationInputArg2 },
{ time: 6, unit: 'hours' as moment.DurationInputArg2 },
{ time: 12, unit: 'hours' as moment.DurationInputArg2 },
{ time: 1, unit: 'day' as moment.DurationInputArg2 },
{ time: 1, unit: 'week' as moment.DurationInputArg2 },
{ time: 2, unit: 'weeks' as moment.DurationInputArg2 },
].map(o => {
const duration = moment.duration(o.time, o.unit); // 5, 'seconds'
return {
time: o.time,
unit: o.unit,
seconds: duration.asSeconds(),
};
});
function getName(seconds = 0) {
const o = timerOptionsDurations.find(m => m.seconds === seconds);
if (o) {
return getTimerOptionName(o.time, o.unit);
}
return [seconds, 'seconds'].join(' ');
}
function getAbbreviated(seconds = 0) {
const o = timerOptionsDurations.find(m => m.seconds === seconds);
if (o) {
return getTimerOptionAbbreviated(o.time, o.unit);
}
return [seconds, 's'].join('');
}
function getTimerSecondsWithName(): TimerOptionsArray {
return timerOptionsDurations.map(t => {
return { name: getName(t.seconds), value: t.seconds };
});
}
export const ExpirationTimerOptions = {
getName,
getAbbreviated,
updateExpiringMessagesCheck,
initExpiringMessageListener,
getTimerSecondsWithName,
};
export function setExpirationStartTimestamp(
mode: DisappearingMessageType,
timestamp?: number,
isLegacyMode?: boolean
): number | undefined {
let expirationStartTimestamp: number | undefined = getNowWithNetworkOffset();
// TODO legacy messages support will be removed in a future release
if (timestamp) {
window.log.debug(
`We compare 2 timestamps for a disappear ${
isLegacyMode ? 'legacy' : mode === 'deleteAfterRead' ? 'after read' : 'after send'
} message: \expirationStartTimestamp `,
new Date(expirationStartTimestamp).toLocaleTimeString(),
'\ntimestamp ',
new Date(timestamp).toLocaleTimeString()
);
expirationStartTimestamp = Math.min(expirationStartTimestamp, timestamp);
}
// TODO legacy messages support will be removed in a future release
if (mode === 'deleteAfterRead') {
window.log.debug(
`We set the start timestamp for a ${
isLegacyMode ? 'legacy ' : ''
}delete after read message to ${new Date(expirationStartTimestamp).toLocaleTimeString()}`
);
} else if (mode === 'deleteAfterSend') {
window.log.debug(
`We set the start timestamp for a ${
isLegacyMode ? 'legacy ' : ''
}delete after send message to ${new Date(expirationStartTimestamp).toLocaleTimeString()}`
);
} else if (mode === 'off') {
window.log.debug(`Disappearing message mode "${mode}" set. We can safely ignore this.`);
expirationStartTimestamp = undefined;
} else {
window.log.debug(`Invalid disappearing message mode "${mode}" set. Ignoring`);
expirationStartTimestamp = undefined;
}
return expirationStartTimestamp;
}
// TODO legacy messages support will be removed in a future release
// NOTE We need this to check for legacy disappearing messages where the expirationType and expireTimer should be undefined on the ContentMessage
function checkIsLegacyContentMessage(contentMessage: SignalService.Content): boolean {
return (
(contentMessage.expirationType === SignalService.Content.ExpirationType.UNKNOWN ||
!ProtobufUtils.hasDefinedProperty(contentMessage, 'expirationType')) &&
!ProtobufUtils.hasDefinedProperty(contentMessage, 'expirationTimer')
);
}
function checkIsLegacyDataMessage(dataMessage: SignalService.DataMessage): boolean {
return (
ProtobufUtils.hasDefinedProperty(dataMessage, 'expireTimer') && dataMessage.expireTimer > -1
);
}
// TODO legacy messages support will be removed in a future release
export async function checkForExpireUpdate(
convoToUpdate: ConversationModel,
content: SignalService.Content
): Promise<DisappearingMessageUpdate | undefined> {
const dataMessage = content.dataMessage as SignalService.DataMessage;
// We will only support legacy disappearing messages for a short period before disappearing messages v2 is unlocked
const isDisappearingMessagesV2Released = await checkIsFeatureReleased('Disappearing Messages V2');
const isLegacyContentMessage = checkIsLegacyContentMessage(content);
const isLegacyDataMessage = Boolean(
isLegacyContentMessage && checkIsLegacyDataMessage(dataMessage as SignalService.DataMessage)
);
const isLegacyConversationSettingMessage = isDisappearingMessagesV2Released
? isLegacyContentMessage &&
isLegacyDataMessage &&
dataMessage.flags === SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE
: isLegacyContentMessage &&
dataMessage.flags === SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
let expirationTimer = isLegacyDataMessage
? Number(dataMessage.expireTimer)
: content.expirationTimer;
let expirationType =
expirationTimer > 0
? DisappearingMessageConversationSetting[
!isDisappearingMessagesV2Released || isLegacyContentMessage ? 3 : content.expirationType
]
: DisappearingMessageConversationSetting[0];
const lastDisappearingMessageChangeTimestamp = content.lastDisappearingMessageChangeTimestamp
? Number(content.lastDisappearingMessageChangeTimestamp)
: undefined;
// TODO should review this
const shouldDisappearButIsntMessage =
dataMessage.flags !== SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE &&
expirationType === 'off' &&
expirationTimer === 0 &&
convoToUpdate.get('expirationType') !== 'off' &&
convoToUpdate.get('expireTimer') !== 0;
// If it is a legacy message and disappearing messages v2 is released then we ignore it and use the local client's conversation settings
if (
isDisappearingMessagesV2Released &&
(isLegacyDataMessage || isLegacyConversationSettingMessage || shouldDisappearButIsntMessage)
) {
window.log.warn('Received a legacy disappearing message after v2 was released.', content);
expirationType = convoToUpdate.get('expirationType');
expirationTimer = convoToUpdate.get('expireTimer');
}
const expireUpdate: DisappearingMessageUpdate = {
expirationType,
expirationTimer,
lastDisappearingMessageChangeTimestamp,
isLegacyConversationSettingMessage,
isLegacyDataMessage,
isDisappearingMessagesV2Released,
};
return expireUpdate;
}
// TODO legacy messages support will be removed in a future release
export function handleExpireUpdate(
converationModel: ConversationModel,
messageModel: MessageModel,
expireUpdate: DisappearingMessageUpdate
) {
if (converationModel.isPublic()) {
window.log.warn("updateExpireTimer() Disappearing messages aren't supported in communities");
return messageModel;
}
const {
expirationType,
// TODO renamed expireTimer to expirationTimer
expirationTimer: expireTimer,
lastDisappearingMessageChangeTimestamp,
isLegacyConversationSettingMessage,
isDisappearingMessagesV2Released,
} = expireUpdate;
messageModel.set({
expirationType,
expireTimer,
});
// This message is conversation setting change message
if (lastDisappearingMessageChangeTimestamp || isLegacyConversationSettingMessage) {
const expirationTimerUpdate = {
expirationType,
expireTimer,
lastDisappearingMessageChangeTimestamp: isLegacyConversationSettingMessage
? isDisappearingMessagesV2Released
? converationModel.get('lastDisappearingMessageChangeTimestamp')
: Date.now()
: Number(lastDisappearingMessageChangeTimestamp),
source: messageModel.get('source'),
};
messageModel.set({
expirationTimerUpdate,
});
}
return messageModel;
}
export async function checkHasOutdatedClient(
convoToUpdate: ConversationModel,
sender: ConversationModel,
expireUpdate: DisappearingMessageUpdate
) {
const outdatedSender =
sender.get('nickname') || sender.get('displayNameInProfile') || sender.get('id');
if (convoToUpdate.get('hasOutdatedClient')) {
// trigger notice banner
if (expireUpdate.isLegacyDataMessage || expireUpdate.isLegacyConversationSettingMessage) {
if (convoToUpdate.get('hasOutdatedClient') !== outdatedSender) {
convoToUpdate.set({
hasOutdatedClient: outdatedSender,
});
}
} else {
convoToUpdate.set({
hasOutdatedClient: undefined,
});
}
await convoToUpdate.commit();
} else {
if (expireUpdate.isLegacyDataMessage || expireUpdate.isLegacyConversationSettingMessage) {
convoToUpdate.set({
hasOutdatedClient: outdatedSender,
});
await convoToUpdate.commit();
}
}
}