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 { ProtobufUtils, SignalService } from '../protobuf'; import { ConversationModel } from '../models/conversation'; import { MessageModel } from '../models/message'; import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime'; import { ReleasedFeatures } from './releaseFeature'; // NOTE this must match Content.ExpirationType in the protobuf // TODO double check this export const DisappearingMessageMode = ['unknown', 'deleteAfterRead', 'deleteAfterSend'] as const; export type DisappearingMessageType = typeof DisappearingMessageMode[number]; export type DisappearAfterSendOnly = Exclude; // NOTE these cannot be imported in the nodejs side yet. We need to move the types to the own file with no window imports // TODO legacy messages support will be removed in a future release // TODO NOTE legacy is strictly used in the UI and is not a valid disappearing message mode export const DisappearingMessageConversationSetting = [ 'off', DisappearingMessageMode[1], // deleteAfterRead DisappearingMessageMode[2], // deleteAfterSend 'legacy', ] as const; export type DisappearingMessageConversationType = typeof DisappearingMessageConversationSetting[number]; // TODO we should make this type a bit more hardcoded than being just resolved as a string export const DEFAULT_TIMER_OPTION = { DELETE_AFTER_READ: 43200, // 12 hours DELETE_AFTER_SEND: 86400, // 1 day LEGACY: 86400, // 1 day }; 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++) { /* eslint-disable no-await-in-loop */ const message = await Data.getMessageById(messageIds[i]); await message?.cleanup(); /* eslint-enable no-await-in-loop */ } // 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.forEach(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.get('conversationId'), messageId: m.id, })); messages.forEach(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); } // eslint-disable-next-line @typescript-eslint/no-misused-promises timeout = global.setTimeout(async () => 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(); // eslint-disable-next-line @typescript-eslint/no-misused-promises initWallClockListener(async () => 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: DisappearingMessageConversationType, timestamp?: number ): number | undefined { let expirationStartTimestamp: number | undefined = GetNetworkTime.getNowWithNetworkOffset(); // TODO legacy messages support will be removed in a future release if (timestamp) { window.log.debug( `WIP: We compare 2 timestamps for a disappearing message (${mode}): 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( `WIP: We set the start timestamp for a delete after read message to ${new Date( expirationStartTimestamp ).toLocaleTimeString()}` ); } else if (mode === 'deleteAfterSend') { window.log.debug( `WIP: We set the start timestamp for a delete after send message to ${new Date( expirationStartTimestamp ).toLocaleTimeString()}` ); // TODO needs improvement } else if (mode === 'legacy') { window.log.debug( `WIP: We set the start timestamp for a legacy message to ${new Date( expirationStartTimestamp ).toLocaleTimeString()}` ); } else if (mode === 'off') { window.log.debug('Disappearing message mode has been turned off. We can safely ignore this.'); expirationStartTimestamp = undefined; } else { window.log.debug(`WIP: Invalid disappearing message mode "${mode}" set. Ignoring`); expirationStartTimestamp = undefined; } return expirationStartTimestamp; } // TODO legacy messages support will be removed in a future release export function isLegacyDisappearingModeEnabled( expirationType: DisappearingMessageConversationType | DisappearingMessageType | undefined ): boolean { return Boolean( expirationType && expirationType !== 'off' && !ReleasedFeatures.isDisappearMessageV2FeatureReleasedCached() ); } // TODO legacy messages support will be removed in a future release /** * Converts DisappearingMessageConversationType to DisappearingMessageType * * NOTE Used for sending or receiving data messages (protobuf) * * @param convo Conversation we want to set * @param expirationType DisappearingMessageConversationType * @returns Disappearing mode we should use */ export function changeToDisappearingMessageType( convo: ConversationModel, expirationType?: DisappearingMessageConversationType ): DisappearingMessageType { if (expirationType === 'off' || expirationType === 'legacy') { // NOTE we would want this to be undefined but because of an issue with the protobuf implement we need to have a value return 'unknown'; } if (convo.isMe() || convo.isClosedGroup()) { return 'deleteAfterSend'; } return 'deleteAfterRead'; } // TODO legacy messages support will be removed in a future release /** * Converts DisappearingMessageType to DisappearingMessageConversationType * * NOTE Used for the UI * * @param convo Conversation we want to set * @param expirationType DisappearingMessageType * @param expireTimer * @returns */ export function changeToDisappearingMessageConversationType( convo: ConversationModel, expirationType?: DisappearingMessageType, expireTimer?: number ): DisappearingMessageConversationType { if (expirationType === 'unknown') { return expireTimer && expireTimer > 0 ? 'legacy' : 'off'; } if (convo.isMe() || convo.isClosedGroup()) { return 'deleteAfterSend'; } return 'deleteAfterRead'; } // 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 checkForExpireUpdateInContentMessage( convoToUpdate: ConversationModel, content: SignalService.Content ): Promise { 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 ReleasedFeatures.checkIsDisappearMessageV2FeatureReleased(); // debugger; 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; // NOTE This starts are a DisappearingMessageConversationType but we will convert it to a DisappearingMessageType let expirationType: any = expirationTimer > 0 ? DisappearingMessageConversationSetting[ !isDisappearingMessagesV2Released || isLegacyContentMessage ? changeToDisappearingMessageType(convoToUpdate) === 'deleteAfterRead' ? 1 : 2 : 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); expirationTimer = convoToUpdate.get('expireTimer'); expirationType = changeToDisappearingMessageType( convoToUpdate, convoToUpdate.get('expirationType') ); } 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 (!expireUpdate) { return messageModel; } 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(); return; } if (expireUpdate.isLegacyDataMessage || expireUpdate.isLegacyConversationSettingMessage) { convoToUpdate.set({ hasOutdatedClient: outdatedSender, }); await convoToUpdate.commit(); } }