signal-desktop/js/background.js
Danny Lin c8261814fd
Add setting to toggle notification attention drawing (#4457)
In many GNU/Linux setups, drawing attention when a notification arrives
causes the Signal window to steal focus immediately and interrupt the
user from what they were doing before the notification arrived. GNOME
Shell is the most prominent example of this behavior, but there are
likely other cases as well. Suddenly stealing focus on external events
like this can even pose a security problem in some cases, e.g. if the
user is in the middle of a typing a sudo password on one monitor while a
notification arrives and focuses Signal on another monitor. See #4452
for more information.

Disabling attention drawing entirely for Linux is also problematic
because some users rely on it as the sole indication of a new message,
as seen in #3582 and #3611.

Commit f790694559 improved the situation
by adding a hidden "--disable-flash-frame" command-line argument, but
this argument is undocumented and manually adding command-line arguments
to the application's .desktop file is not user-friendly.

This commit adds a settings option for whether to draw attention when a
new notification arrives to make it easy for all Linux users to obtain
the appropriate behavior without relying on an undocumented
command-line argument.

Fixes #4452.
2020-08-24 14:45:31 -07:00

3003 lines
86 KiB
JavaScript

/* global
$,
_,
Backbone,
ConversationController,
MessageController,
getAccountManager,
Signal,
storage,
textsecure,
WebAPI,
Whisper,
*/
// eslint-disable-next-line func-names
(async function() {
'use strict';
const eventHandlerQueue = new window.PQueue({ concurrency: 1 });
Whisper.deliveryReceiptQueue = new window.PQueue({
concurrency: 1,
});
Whisper.deliveryReceiptQueue.pause();
Whisper.deliveryReceiptBatcher = window.Signal.Util.createBatcher({
wait: 500,
maxSize: 500,
processBatch: async items => {
const byConversationId = _.groupBy(items, item =>
ConversationController.ensureContactIds({
e164: item.source,
uuid: item.sourceUuid,
})
);
const ids = Object.keys(byConversationId);
for (let i = 0, max = ids.length; i < max; i += 1) {
const conversationId = ids[i];
const timestamps = byConversationId[conversationId].map(
item => item.timestamp
);
const c = ConversationController.get(conversationId);
const uuid = c.get('uuid');
const e164 = c.get('e164');
c.queueJob(async () => {
try {
const { wrap, sendOptions } = ConversationController.prepareForSend(
c.get('id')
);
// eslint-disable-next-line no-await-in-loop
await wrap(
textsecure.messaging.sendDeliveryReceipt(
e164,
uuid,
timestamps,
sendOptions
)
);
} catch (error) {
window.log.error(
`Failed to send delivery receipt to ${e164}/${uuid} for timestamps ${timestamps}:`,
error && error.stack ? error.stack : error
);
}
});
}
},
});
// Globally disable drag and drop
document.body.addEventListener(
'dragover',
e => {
e.preventDefault();
e.stopPropagation();
},
false
);
document.body.addEventListener(
'drop',
e => {
e.preventDefault();
e.stopPropagation();
},
false
);
// Idle timer - you're active for ACTIVE_TIMEOUT after one of these events
const ACTIVE_TIMEOUT = 15 * 1000;
const ACTIVE_EVENTS = [
'click',
'keydown',
'mousedown',
'mousemove',
// 'scroll', // this is triggered by Timeline re-renders, can't use
'touchstart',
'wheel',
];
const LISTENER_DEBOUNCE = 5 * 1000;
let activeHandlers = [];
let activeTimestamp = Date.now();
window.addEventListener('blur', () => {
// Force inactivity
activeTimestamp = Date.now() - ACTIVE_TIMEOUT;
});
window.resetActiveTimer = _.throttle(() => {
const previouslyActive = window.isActive();
activeTimestamp = Date.now();
if (!previouslyActive) {
activeHandlers.forEach(handler => handler());
}
}, LISTENER_DEBOUNCE);
ACTIVE_EVENTS.forEach(name => {
document.addEventListener(name, window.resetActiveTimer, true);
});
window.isActive = () => {
const now = Date.now();
return now <= activeTimestamp + ACTIVE_TIMEOUT;
};
window.registerForActive = handler => activeHandlers.push(handler);
window.unregisterForActive = handler => {
activeHandlers = activeHandlers.filter(item => item !== handler);
};
// Keyboard/mouse mode
let interactionMode = 'mouse';
$(document.body).addClass('mouse-mode');
window.enterKeyboardMode = () => {
if (interactionMode === 'keyboard') {
return;
}
interactionMode = 'keyboard';
$(document.body)
.addClass('keyboard-mode')
.removeClass('mouse-mode');
const { userChanged } = window.reduxActions.user;
const { clearSelectedMessage } = window.reduxActions.conversations;
if (clearSelectedMessage) {
clearSelectedMessage();
}
if (userChanged) {
userChanged({ interactionMode });
}
};
window.enterMouseMode = () => {
if (interactionMode === 'mouse') {
return;
}
interactionMode = 'mouse';
$(document.body)
.addClass('mouse-mode')
.removeClass('keyboard-mode');
const { userChanged } = window.reduxActions.user;
const { clearSelectedMessage } = window.reduxActions.conversations;
if (clearSelectedMessage) {
clearSelectedMessage();
}
if (userChanged) {
userChanged({ interactionMode });
}
};
document.addEventListener(
'keydown',
event => {
if (event.key === 'Tab') {
window.enterKeyboardMode();
}
},
true
);
document.addEventListener('wheel', window.enterMouseMode, true);
document.addEventListener('mousedown', window.enterMouseMode, true);
window.getInteractionMode = () => interactionMode;
// Load these images now to ensure that they don't flicker on first use
window.preloadedImages = [];
function preload(list) {
for (let index = 0, max = list.length; index < max; index += 1) {
const image = new Image();
image.src = `./images/${list[index]}`;
window.preloadedImages.push(image);
}
}
const builtInImages = await window.getBuiltInImages();
preload(builtInImages);
// We add this to window here because the default Node context is erased at the end
// of preload.js processing
window.setImmediate = window.nodeSetImmediate;
const { IdleDetector, MessageDataMigrator } = Signal.Workflow;
const {
removeDatabase: removeIndexedDB,
doesDatabaseExist,
} = Signal.IndexedDB;
const { Errors, Message } = window.Signal.Types;
const {
upgradeMessageSchema,
writeNewAttachmentData,
deleteAttachmentData,
doesAttachmentExist,
} = window.Signal.Migrations;
const { Views } = window.Signal;
window.log.info('background page reloaded');
window.log.info('environment:', window.getEnvironment());
let idleDetector;
let initialLoadComplete = false;
let newVersion = false;
window.owsDesktopApp = {};
window.document.title = window.getTitle();
Whisper.KeyChangeListener.init(textsecure.storage.protocol);
textsecure.storage.protocol.on('removePreKey', () => {
getAccountManager().refreshPreKeys();
});
let messageReceiver;
window.getSocketStatus = () => {
if (messageReceiver) {
return messageReceiver.getStatus();
}
return -1;
};
Whisper.events = _.clone(Backbone.Events);
let accountManager;
window.getAccountManager = () => {
if (!accountManager) {
const OLD_USERNAME = storage.get('number_id');
const USERNAME = storage.get('uuid_id');
const PASSWORD = storage.get('password');
accountManager = new textsecure.AccountManager(
USERNAME || OLD_USERNAME,
PASSWORD
);
accountManager.addEventListener('registration', () => {
const ourNumber = textsecure.storage.user.getNumber();
const ourUuid = textsecure.storage.user.getUuid();
const user = {
regionCode: window.storage.get('regionCode'),
ourNumber,
ourUuid,
ourConversationId: ConversationController.getOurConversationId(),
};
Whisper.events.trigger('userChanged', user);
window.Signal.Util.Registration.markDone();
window.log.info('dispatching registration event');
Whisper.events.trigger('registration_done');
});
}
return accountManager;
};
const cancelInitializationMessage = Views.Initialization.setMessage();
const version = await window.Signal.Data.getItemById('version');
if (!version) {
const isIndexedDBPresent = await doesDatabaseExist();
if (isIndexedDBPresent) {
window.log.info('Found IndexedDB database.');
try {
window.log.info('Confirming deletion of old data with user...');
try {
await new Promise((resolve, reject) => {
const dialog = new Whisper.ConfirmationDialogView({
message: window.i18n('deleteOldIndexedDBData'),
okText: window.i18n('deleteOldData'),
cancelText: window.i18n('quit'),
resolve,
reject,
});
document.body.append(dialog.el);
dialog.focusCancel();
});
} catch (error) {
window.log.info(
'User chose not to delete old data. Shutting down.',
error && error.stack ? error.stack : error
);
window.shutdown();
return;
}
window.log.info('Deleting all previously-migrated data in SQL...');
window.log.info('Deleting IndexedDB file...');
await Promise.all([
removeIndexedDB(),
window.Signal.Data.removeAll(),
window.Signal.Data.removeIndexedDBFiles(),
]);
window.log.info('Done with SQL deletion and IndexedDB file deletion.');
} catch (error) {
window.log.error(
'Failed to remove IndexedDB file or remove SQL data:',
error && error.stack ? error.stack : error
);
}
// Set a flag to delete IndexedDB on next startup if it wasn't deleted just now.
// We need to use direct data calls, since storage isn't ready yet.
await window.Signal.Data.createOrUpdateItem({
id: 'indexeddb-delete-needed',
value: true,
});
}
}
window.log.info('Storage fetch');
storage.fetch();
function mapOldThemeToNew(theme) {
switch (theme) {
case 'dark':
case 'light':
case 'system':
return theme;
case 'android-dark':
return 'dark';
case 'android':
case 'ios':
default:
return 'light';
}
}
// We need this 'first' check because we don't want to start the app up any other time
// than the first time. And storage.fetch() will cause onready() to fire.
let first = true;
storage.onready(async () => {
if (!first) {
return;
}
first = false;
// These make key operations available to IPC handlers created in preload.js
window.Events = {
getDeviceName: () => textsecure.storage.user.getDeviceName(),
getThemeSetting: () =>
storage.get(
'theme-setting',
window.platform === 'darwin' ? 'system' : 'light'
),
setThemeSetting: value => {
storage.put('theme-setting', value);
onChangeTheme();
},
getHideMenuBar: () => storage.get('hide-menu-bar'),
setHideMenuBar: value => {
storage.put('hide-menu-bar', value);
window.setAutoHideMenuBar(value);
window.setMenuBarVisibility(!value);
},
getNotificationSetting: () =>
storage.get('notification-setting', 'message'),
setNotificationSetting: value =>
storage.put('notification-setting', value),
getNotificationDrawAttention: () =>
storage.get('notification-draw-attention', true),
setNotificationDrawAttention: value =>
storage.put('notification-draw-attention', value),
getAudioNotification: () => storage.get('audio-notification'),
setAudioNotification: value => storage.put('audio-notification', value),
getCallRingtoneNotification: () =>
storage.get('call-ringtone-notification', true),
setCallRingtoneNotification: value =>
storage.put('call-ringtone-notification', value),
getCallSystemNotification: () =>
storage.get('call-system-notification', true),
setCallSystemNotification: value =>
storage.put('call-system-notification', value),
getIncomingCallNotification: () =>
storage.get('incoming-call-notification', true),
setIncomingCallNotification: value =>
storage.put('incoming-call-notification', value),
getSpellCheck: () => storage.get('spell-check', true),
setSpellCheck: value => {
storage.put('spell-check', value);
},
getAlwaysRelayCalls: () => storage.get('always-relay-calls'),
setAlwaysRelayCalls: value => storage.put('always-relay-calls', value),
// eslint-disable-next-line eqeqeq
isPrimary: () => textsecure.storage.user.getDeviceId() == '1',
getSyncRequest: () =>
new Promise((resolve, reject) => {
const syncRequest = window.getSyncRequest();
syncRequest.addEventListener('success', resolve);
syncRequest.addEventListener('timeout', reject);
}),
getLastSyncTime: () => storage.get('synced_at'),
setLastSyncTime: value => storage.put('synced_at', value),
addDarkOverlay: () => {
if ($('.dark-overlay').length) {
return;
}
$(document.body).prepend('<div class="dark-overlay"></div>');
$('.dark-overlay').on('click', () => $('.dark-overlay').remove());
},
removeDarkOverlay: () => $('.dark-overlay').remove(),
showKeyboardShortcuts: () => window.showKeyboardShortcuts(),
deleteAllData: () => {
const clearDataView = new window.Whisper.ClearDataView().render();
$('body').append(clearDataView.el);
},
shutdown: async () => {
window.log.info('background/shutdown');
// Stop background processing
window.Signal.AttachmentDownloads.stop();
if (idleDetector) {
idleDetector.stop();
}
// Stop processing incoming messages
if (messageReceiver) {
await messageReceiver.stopProcessing();
await window.waitForAllBatchers();
}
if (messageReceiver) {
messageReceiver.unregisterBatchers();
messageReceiver = null;
}
// A number of still-to-queue database queries might be waiting inside batchers.
// We wait for these to empty first, and then shut down the data interface.
await Promise.all([
window.waitForAllBatchers(),
window.waitForAllWaitBatchers(),
]);
// Shut down the data interface cleanly
await window.Signal.Data.shutdown();
},
showStickerPack: async (packId, key) => {
// We can get these events even if the user has never linked this instance.
if (!window.Signal.Util.Registration.everDone()) {
return;
}
// Kick off the download
window.Signal.Stickers.downloadEphemeralPack(packId, key);
const props = {
packId,
onClose: async () => {
stickerPreviewModalView.remove();
await window.Signal.Stickers.removeEphemeralPack(packId);
},
};
const stickerPreviewModalView = new Whisper.ReactWrapperView({
className: 'sticker-preview-modal-wrapper',
JSX: Signal.State.Roots.createStickerPreviewModal(
window.reduxStore,
props
),
});
},
installStickerPack: async (packId, key) => {
window.Signal.Stickers.downloadStickerPack(packId, key, {
finalStatus: 'installed',
});
},
};
// How long since we were last running?
const now = Date.now();
const lastHeartbeat = storage.get('lastHeartbeat');
await storage.put('lastStartup', Date.now());
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000;
if (lastHeartbeat > 0 && now - lastHeartbeat > THIRTY_DAYS) {
await unlinkAndDisconnect();
}
// Start heartbeat timer
storage.put('lastHeartbeat', Date.now());
const TWELVE_HOURS = 12 * 60 * 60 * 1000;
setInterval(() => storage.put('lastHeartbeat', Date.now()), TWELVE_HOURS);
const currentVersion = window.getVersion();
const lastVersion = storage.get('version');
newVersion = !lastVersion || currentVersion !== lastVersion;
await storage.put('version', currentVersion);
if (newVersion && lastVersion) {
window.log.info(
`New version detected: ${currentVersion}; previous: ${lastVersion}`
);
const themeSetting = window.Events.getThemeSetting();
const newThemeSetting = mapOldThemeToNew(themeSetting);
if (window.isBeforeVersion(lastVersion, 'v1.29.2-beta.1')) {
// Stickers flags
await Promise.all([
storage.put('showStickersIntroduction', true),
storage.put('showStickerPickerHint', true),
]);
}
if (window.isBeforeVersion(lastVersion, 'v1.26.0')) {
// Ensure that we re-register our support for sealed sender
await storage.put(
'hasRegisterSupportForUnauthenticatedDelivery',
false
);
}
if (
window.isBeforeVersion(lastVersion, 'v1.25.0') &&
window.platform === 'darwin' &&
newThemeSetting === window.systemTheme
) {
window.Events.setThemeSetting('system');
} else {
window.Events.setThemeSetting(newThemeSetting);
}
if (
window.isBeforeVersion(lastVersion, 'v1.35.0-beta.11') &&
window.isAfterVersion(lastVersion, 'v1.35.0-beta.1')
) {
await window.Signal.Util.eraseAllStorageServiceState();
}
// This one should always be last - it could restart the app
if (window.isBeforeVersion(lastVersion, 'v1.15.0-beta.5')) {
await window.Signal.Logs.deleteAll();
window.restart();
return;
}
}
Views.Initialization.setMessage(window.i18n('optimizingApplication'));
if (newVersion) {
await window.Signal.Data.cleanupOrphanedAttachments();
// Don't block on the following operation
window.Signal.Data.ensureFilePermissions();
}
Views.Initialization.setMessage(window.i18n('loading'));
idleDetector = new IdleDetector();
let isMigrationWithIndexComplete = false;
window.log.info(
`Starting background data migration. Target version: ${Message.CURRENT_SCHEMA_VERSION}`
);
idleDetector.on('idle', async () => {
const NUM_MESSAGES_PER_BATCH = 1;
if (!isMigrationWithIndexComplete) {
const batchWithIndex = await MessageDataMigrator.processNext({
BackboneMessage: Whisper.Message,
BackboneMessageCollection: Whisper.MessageCollection,
numMessagesPerBatch: NUM_MESSAGES_PER_BATCH,
upgradeMessageSchema,
getMessagesNeedingUpgrade:
window.Signal.Data.getMessagesNeedingUpgrade,
saveMessage: window.Signal.Data.saveMessage,
});
window.log.info('Upgrade message schema (with index):', batchWithIndex);
isMigrationWithIndexComplete = batchWithIndex.done;
}
if (isMigrationWithIndexComplete) {
window.log.info(
'Background migration complete. Stopping idle detector.'
);
idleDetector.stop();
}
});
window.Signal.conversationControllerStart();
// We start this up before ConversationController.load() to ensure that our feature
// flags are represented in the cached props we generate on load of each convo.
window.Signal.RemoteConfig.initRemoteConfig();
try {
await Promise.all([
ConversationController.load(),
Signal.Stickers.load(),
Signal.Emojis.load(),
textsecure.storage.protocol.hydrateCaches(),
]);
await ConversationController.checkForConflicts();
} catch (error) {
window.log.error(
'background.js: ConversationController failed to load:',
error && error.stack ? error.stack : error
);
} finally {
initializeRedux();
start();
window.Signal.Services.initializeNetworkObserver(
window.reduxActions.network
);
window.Signal.Services.initializeUpdateListener(
window.reduxActions.updates,
window.Whisper.events
);
window.Signal.Services.calling.initialize(window.reduxActions.calling);
window.reduxActions.expiration.hydrateExpirationStatus(
window.Signal.Util.hasExpired()
);
}
});
function initializeRedux() {
// Here we set up a full redux store with initial state for our LeftPane Root
const convoCollection = window.getConversations();
const conversations = convoCollection.map(
conversation => conversation.cachedProps
);
const ourNumber = textsecure.storage.user.getNumber();
const ourUuid = textsecure.storage.user.getUuid();
const ourConversationId = ConversationController.getOurConversationId();
const initialState = {
conversations: {
conversationLookup: Signal.Util.makeLookup(conversations, 'id'),
messagesByConversation: {},
messagesLookup: {},
selectedConversation: null,
selectedMessage: null,
selectedMessageCounter: 0,
showArchived: false,
},
emojis: Signal.Emojis.getInitialState(),
items: storage.getItemsState(),
stickers: Signal.Stickers.getInitialState(),
user: {
attachmentsPath: window.baseAttachmentsPath,
stickersPath: window.baseStickersPath,
tempPath: window.baseTempPath,
regionCode: window.storage.get('regionCode'),
ourConversationId,
ourNumber,
ourUuid,
platform: window.platform,
i18n: window.i18n,
interactionMode: window.getInteractionMode(),
},
};
const store = Signal.State.createStore(initialState);
window.reduxStore = store;
const actions = {};
window.reduxActions = actions;
// Binding these actions to our redux store and exposing them allows us to update
// redux when things change in the backbone world.
actions.calling = Signal.State.bindActionCreators(
Signal.State.Ducks.calling.actions,
store.dispatch
);
actions.conversations = Signal.State.bindActionCreators(
Signal.State.Ducks.conversations.actions,
store.dispatch
);
actions.emojis = Signal.State.bindActionCreators(
Signal.State.Ducks.emojis.actions,
store.dispatch
);
actions.expiration = Signal.State.bindActionCreators(
Signal.State.Ducks.expiration.actions,
store.dispatch
);
actions.items = Signal.State.bindActionCreators(
Signal.State.Ducks.items.actions,
store.dispatch
);
actions.network = Signal.State.bindActionCreators(
Signal.State.Ducks.network.actions,
store.dispatch
);
actions.updates = Signal.State.bindActionCreators(
Signal.State.Ducks.updates.actions,
store.dispatch
);
actions.user = Signal.State.bindActionCreators(
Signal.State.Ducks.user.actions,
store.dispatch
);
actions.search = Signal.State.bindActionCreators(
Signal.State.Ducks.search.actions,
store.dispatch
);
actions.stickers = Signal.State.bindActionCreators(
Signal.State.Ducks.stickers.actions,
store.dispatch
);
const {
conversationAdded,
conversationChanged,
conversationRemoved,
removeAllConversations,
messageExpired,
} = actions.conversations;
const { userChanged } = actions.user;
convoCollection.on('remove', conversation => {
const { id } = conversation || {};
conversationRemoved(id);
});
convoCollection.on('add', conversation => {
const { id, cachedProps } = conversation || {};
conversationAdded(id, cachedProps);
});
convoCollection.on('change', conversation => {
const { id, cachedProps } = conversation || {};
conversationChanged(id, cachedProps);
});
convoCollection.on('reset', removeAllConversations);
Whisper.events.on('messageExpired', messageExpired);
Whisper.events.on('userChanged', userChanged);
let shortcutGuideView = null;
window.showKeyboardShortcuts = () => {
if (!shortcutGuideView) {
shortcutGuideView = new Whisper.ReactWrapperView({
className: 'shortcut-guide-wrapper',
JSX: Signal.State.Roots.createShortcutGuideModal(window.reduxStore, {
close: () => {
if (shortcutGuideView) {
shortcutGuideView.remove();
shortcutGuideView = null;
}
},
}),
onClose: () => {
shortcutGuideView = null;
},
});
}
};
function getConversationByIndex(index) {
const state = store.getState();
const lists = Signal.State.Selectors.conversations.getLeftPaneLists(
state
);
const toSearch = state.conversations.showArchived
? lists.archivedConversations
: lists.conversations;
const target = toSearch[index];
if (target) {
return target.id;
}
return null;
}
function findConversation(conversationId, direction, unreadOnly) {
const state = store.getState();
const lists = Signal.State.Selectors.conversations.getLeftPaneLists(
state
);
const toSearch = state.conversations.showArchived
? lists.archivedConversations
: lists.conversations;
const increment = direction === 'up' ? -1 : 1;
let startIndex;
if (conversationId) {
const index = toSearch.findIndex(item => item.id === conversationId);
if (index >= 0) {
startIndex = index + increment;
}
} else {
startIndex = direction === 'up' ? toSearch.length - 1 : 0;
}
for (
let i = startIndex, max = toSearch.length;
i >= 0 && i < max;
i += increment
) {
const target = toSearch[i];
if (!unreadOnly) {
return target.id;
} else if (target.unreadCount > 0) {
return target.id;
}
}
return null;
}
const NUMBERS = {
'1': 1,
'2': 2,
'3': 3,
'4': 4,
'5': 5,
'6': 6,
'7': 7,
'8': 8,
'9': 9,
};
document.addEventListener('keydown', event => {
const { altKey, ctrlKey, key, metaKey, shiftKey } = event;
const optionOrAlt = altKey;
const commandKey = window.platform === 'darwin' && metaKey;
const controlKey = window.platform !== 'darwin' && ctrlKey;
const commandOrCtrl = commandKey || controlKey;
const commandAndCtrl = commandKey && ctrlKey;
const state = store.getState();
const selectedId = state.conversations.selectedConversation;
const conversation = ConversationController.get(selectedId);
const isSearching = Signal.State.Selectors.search.isSearching(state);
// NAVIGATION
// Show keyboard shortcuts - handled by Electron-managed keyboard shortcuts
// However, on linux Ctrl+/ selects all text, so we prevent that
if (commandOrCtrl && key === '/') {
window.showKeyboardShortcuts();
event.stopPropagation();
event.preventDefault();
return;
}
// Navigate by section
if (commandOrCtrl && !shiftKey && (key === 't' || key === 'T')) {
window.enterKeyboardMode();
const focusedElement = document.activeElement;
const targets = [
document.querySelector('.module-main-header .module-avatar-button'),
document.querySelector('.module-left-pane__to-inbox-button'),
document.querySelector('.module-main-header__search__input'),
document.querySelector('.module-left-pane__list'),
document.querySelector('.module-search-results'),
document.querySelector(
'.module-composition-area .public-DraftEditor-content'
),
];
const focusedIndex = targets.findIndex(target => {
if (!target || !focusedElement) {
return false;
}
if (target === focusedElement) {
return true;
}
if (target.contains(focusedElement)) {
return true;
}
return false;
});
const lastIndex = targets.length - 1;
let index;
if (focusedIndex < 0 || focusedIndex >= lastIndex) {
index = 0;
} else {
index = focusedIndex + 1;
}
while (!targets[index]) {
index += 1;
if (index > lastIndex) {
index = 0;
}
}
targets[index].focus();
}
// Cancel out of keyboard shortcut screen - has first precedence
if (shortcutGuideView && key === 'Escape') {
shortcutGuideView.remove();
shortcutGuideView = null;
event.preventDefault();
event.stopPropagation();
return;
}
// Escape is heavily overloaded - here we avoid clashes with other Escape handlers
if (key === 'Escape') {
// Check origin - if within a react component which handles escape, don't handle.
// Why? Because React's synthetic events can cause events to be handled twice.
const target = document.activeElement;
if (
target &&
target.attributes &&
target.attributes.class &&
target.attributes.class.value
) {
const className = target.attributes.class.value;
// These want to handle events internally
// CaptionEditor text box
if (className.includes('module-caption-editor__caption-input')) {
return;
}
// MainHeader search box
if (className.includes('module-main-header__search__input')) {
return;
}
}
// These add listeners to document, but we'll run first
const confirmationModal = document.querySelector(
'.module-confirmation-dialog__overlay'
);
if (confirmationModal) {
return;
}
const emojiPicker = document.querySelector('.module-emoji-picker');
if (emojiPicker) {
return;
}
const lightBox = document.querySelector('.module-lightbox');
if (lightBox) {
return;
}
const stickerPicker = document.querySelector('.module-sticker-picker');
if (stickerPicker) {
return;
}
const stickerPreview = document.querySelector(
'.module-sticker-manager__preview-modal__overlay'
);
if (stickerPreview) {
return;
}
const reactionViewer = document.querySelector(
'.module-reaction-viewer'
);
if (reactionViewer) {
return;
}
const reactionPicker = document.querySelector('module-reaction-picker');
if (reactionPicker) {
return;
}
}
// Close Backbone-based confirmation dialog
if (Whisper.activeConfirmationView && key === 'Escape') {
Whisper.activeConfirmationView.remove();
Whisper.activeConfirmationView = null;
event.preventDefault();
event.stopPropagation();
return;
}
// Send Escape to active conversation so it can close panels
if (conversation && key === 'Escape') {
conversation.trigger('escape-pressed');
event.preventDefault();
event.stopPropagation();
return;
}
// Change currently selected conversation by index
if (!isSearching && commandOrCtrl && NUMBERS[key]) {
const targetId = getConversationByIndex(NUMBERS[key] - 1);
if (targetId) {
window.Whisper.events.trigger('showConversation', targetId);
event.preventDefault();
event.stopPropagation();
return;
}
}
// Change currently selected conversation
// up/previous
if (
(!isSearching && optionOrAlt && !shiftKey && key === 'ArrowUp') ||
(!isSearching && ctrlKey && shiftKey && key === 'Tab')
) {
const unreadOnly = false;
const targetId = findConversation(
conversation ? conversation.id : null,
'up',
unreadOnly
);
if (targetId) {
window.Whisper.events.trigger('showConversation', targetId);
event.preventDefault();
event.stopPropagation();
return;
}
}
// down/next
if (
(!isSearching && optionOrAlt && !shiftKey && key === 'ArrowDown') ||
(!isSearching && ctrlKey && key === 'Tab')
) {
const unreadOnly = false;
const targetId = findConversation(
conversation ? conversation.id : null,
'down',
unreadOnly
);
if (targetId) {
window.Whisper.events.trigger('showConversation', targetId);
event.preventDefault();
event.stopPropagation();
return;
}
}
// previous unread
if (!isSearching && optionOrAlt && shiftKey && key === 'ArrowUp') {
const unreadOnly = true;
const targetId = findConversation(
conversation ? conversation.id : null,
'up',
unreadOnly
);
if (targetId) {
window.Whisper.events.trigger('showConversation', targetId);
event.preventDefault();
event.stopPropagation();
return;
}
}
// next unread
if (!isSearching && optionOrAlt && shiftKey && key === 'ArrowDown') {
const unreadOnly = true;
const targetId = findConversation(
conversation ? conversation.id : null,
'down',
unreadOnly
);
if (targetId) {
window.Whisper.events.trigger('showConversation', targetId);
event.preventDefault();
event.stopPropagation();
return;
}
}
// Preferences - handled by Electron-managed keyboard shortcuts
// Open the top-right menu for current conversation
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'l' || key === 'L')
) {
const button = document.querySelector(
'.module-conversation-header__more-button'
);
if (!button) {
return;
}
// Because the menu is shown at a location based on the initiating click, we need
// to fake up a mouse event to get the menu to show somewhere other than (0,0).
const { x, y, width, height } = button.getBoundingClientRect();
const mouseEvent = document.createEvent('MouseEvents');
mouseEvent.initMouseEvent(
'click',
true, // bubbles
false, // cancelable
null, // view
null, // detail
0, // screenX,
0, // screenY,
x + width / 2,
y + height / 2,
false, // ctrlKey,
false, // altKey,
false, // shiftKey,
false, // metaKey,
false, // button,
document.body
);
button.dispatchEvent(mouseEvent);
event.preventDefault();
event.stopPropagation();
return;
}
// Search
if (
commandOrCtrl &&
!commandAndCtrl &&
!shiftKey &&
(key === 'f' || key === 'F')
) {
const { startSearch } = actions.search;
startSearch();
event.preventDefault();
event.stopPropagation();
return;
}
// Search in conversation
if (
conversation &&
commandOrCtrl &&
!commandAndCtrl &&
shiftKey &&
(key === 'f' || key === 'F')
) {
const { searchInConversation } = actions.search;
const name = conversation.isMe()
? window.i18n('noteToSelf')
: conversation.getTitle();
searchInConversation(conversation.id, name);
event.preventDefault();
event.stopPropagation();
return;
}
// Focus composer field
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 't' || key === 'T')
) {
conversation.trigger('focus-composer');
event.preventDefault();
event.stopPropagation();
return;
}
// Open all media
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'm' || key === 'M')
) {
conversation.trigger('open-all-media');
event.preventDefault();
event.stopPropagation();
return;
}
// Open emoji picker - handled by component
// Open sticker picker - handled by component
// Begin recording voice note
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'v' || key === 'V')
) {
conversation.trigger('begin-recording');
event.preventDefault();
event.stopPropagation();
return;
}
// Archive or unarchive conversation
if (
conversation &&
!conversation.get('isArchived') &&
commandOrCtrl &&
shiftKey &&
(key === 'a' || key === 'A')
) {
conversation.setArchived(true);
conversation.trigger('unload', 'keyboard shortcut archive');
Whisper.ToastView.show(
Whisper.ConversationArchivedToast,
document.body
);
// It's very likely that the act of archiving a conversation will set focus to
// 'none,' or the top-level body element. This resets it to the left pane,
// whether in the normal conversation list or search results.
if (document.activeElement === document.body) {
const leftPaneEl = document.querySelector('.module-left-pane__list');
if (leftPaneEl) {
leftPaneEl.focus();
}
const searchResultsEl = document.querySelector(
'.module-search-results'
);
if (searchResultsEl) {
searchResultsEl.focus();
}
}
event.preventDefault();
event.stopPropagation();
return;
}
if (
conversation &&
conversation.get('isArchived') &&
commandOrCtrl &&
shiftKey &&
(key === 'u' || key === 'U')
) {
conversation.setArchived(false);
Whisper.ToastView.show(
Whisper.ConversationUnarchivedToast,
document.body
);
event.preventDefault();
event.stopPropagation();
return;
}
// Scroll to bottom of list - handled by component
// Scroll to top of list - handled by component
// Close conversation
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'c' || key === 'C')
) {
conversation.trigger('unload', 'keyboard shortcut close');
event.preventDefault();
event.stopPropagation();
return;
}
// MESSAGES
// Show message details
if (
conversation &&
commandOrCtrl &&
!shiftKey &&
(key === 'd' || key === 'D')
) {
const { selectedMessage } = state.conversations;
if (!selectedMessage) {
return;
}
conversation.trigger('show-message-details', selectedMessage);
event.preventDefault();
event.stopPropagation();
return;
}
// Toggle reply to message
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'r' || key === 'R')
) {
const { selectedMessage } = state.conversations;
conversation.trigger('toggle-reply', selectedMessage);
event.preventDefault();
event.stopPropagation();
return;
}
// Save attachment
if (
conversation &&
commandOrCtrl &&
!shiftKey &&
(key === 's' || key === 'S')
) {
const { selectedMessage } = state.conversations;
if (selectedMessage) {
conversation.trigger('save-attachment', selectedMessage);
event.preventDefault();
event.stopPropagation();
return;
}
}
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'd' || key === 'D')
) {
const { selectedMessage } = state.conversations;
if (selectedMessage) {
conversation.trigger('delete-message', selectedMessage);
event.preventDefault();
event.stopPropagation();
return;
}
}
// COMPOSER
// Create a newline in your message - handled by component
// Expand composer - handled by component
// Send in expanded composer - handled by component
// Attach file
if (
conversation &&
commandOrCtrl &&
!shiftKey &&
(key === 'u' || key === 'U')
) {
conversation.trigger('attach-file');
event.preventDefault();
event.stopPropagation();
return;
}
// Remove draft link preview
if (
conversation &&
commandOrCtrl &&
!shiftKey &&
(key === 'p' || key === 'P')
) {
conversation.trigger('remove-link-review');
event.preventDefault();
event.stopPropagation();
return;
}
// Attach file
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'p' || key === 'P')
) {
conversation.trigger('remove-all-draft-attachments');
event.preventDefault();
event.stopPropagation();
// Commented out because this is the last item
// return;
}
});
}
Whisper.events.on('setupAsNewDevice', () => {
const { appView } = window.owsDesktopApp;
if (appView) {
appView.openInstaller();
}
});
Whisper.events.on('setupAsStandalone', () => {
const { appView } = window.owsDesktopApp;
if (appView) {
appView.openStandalone();
}
});
async function start() {
window.dispatchEvent(new Event('storage_ready'));
window.log.info('Cleanup: starting...');
const messagesForCleanup = await window.Signal.Data.getOutgoingWithoutExpiresAt(
{
MessageCollection: Whisper.MessageCollection,
}
);
window.log.info(
`Cleanup: Found ${messagesForCleanup.length} messages for cleanup`
);
await Promise.all(
messagesForCleanup.map(async message => {
const delivered = message.get('delivered');
const sentAt = message.get('sent_at');
const expirationStartTimestamp = message.get(
'expirationStartTimestamp'
);
if (message.hasErrors()) {
return;
}
if (delivered) {
window.log.info(
`Cleanup: Starting timer for delivered message ${sentAt}`
);
message.set(
'expirationStartTimestamp',
expirationStartTimestamp || sentAt
);
await message.setToExpire();
return;
}
window.log.info(`Cleanup: Deleting unsent message ${sentAt}`);
await window.Signal.Data.removeMessage(message.id, {
Message: Whisper.Message,
});
const conversation = message.getConversation();
if (conversation) {
await conversation.updateLastMessage();
}
})
);
window.log.info('Cleanup: complete');
window.log.info('listening for registration events');
Whisper.events.on('registration_done', () => {
window.log.info('handling registration event');
connect(true);
});
cancelInitializationMessage();
const appView = new Whisper.AppView({
el: $('body'),
});
window.owsDesktopApp.appView = appView;
Whisper.WallClockListener.init(Whisper.events);
Whisper.ExpiringMessagesListener.init(Whisper.events);
Whisper.TapToViewMessagesListener.init(Whisper.events);
if (window.Signal.Util.Registration.everDone()) {
connect();
appView.openInbox({
initialLoadComplete,
});
} else {
appView.openInstaller();
}
Whisper.events.on('showDebugLog', () => {
appView.openDebugLog();
});
Whisper.events.on('unauthorized', () => {
appView.inboxView.networkStatusView.update();
});
Whisper.events.on('contactsync', () => {
if (appView.installView) {
appView.openInbox();
}
});
window.registerForActive(() => Whisper.Notifications.clear());
window.addEventListener('unload', () => Whisper.Notifications.fastClear());
Whisper.events.on('showConversation', (id, messageId) => {
if (appView) {
appView.openConversation(id, messageId);
}
});
Whisper.Notifications.on('click', (id, messageId) => {
window.showWindow();
if (id) {
appView.openConversation(id, messageId);
} else {
appView.openInbox({
initialLoadComplete,
});
}
});
// Maybe refresh remote configuration when we become active
window.registerForActive(async () => {
await window.Signal.RemoteConfig.maybeRefreshRemoteConfig();
});
// Listen for changes to the `desktop.messageRequests` remote configuration flag
const removeMessageRequestListener = window.Signal.RemoteConfig.onChange(
'desktop.messageRequests',
({ enabled }) => {
if (!enabled) {
return;
}
const conversations = window.getConversations();
conversations.forEach(conversation => {
conversation.set({
messageCountBeforeMessageRequests:
conversation.get('messageCount') || 0,
});
window.Signal.Data.updateConversation(conversation.attributes);
});
removeMessageRequestListener();
}
);
}
window.getSyncRequest = () =>
new textsecure.SyncRequest(textsecure.messaging, messageReceiver);
let disconnectTimer = null;
let reconnectTimer = null;
function onOffline() {
window.log.info('offline');
window.removeEventListener('offline', onOffline);
window.addEventListener('online', onOnline);
// We've received logs from Linux where we get an 'offline' event, then 30ms later
// we get an online event. This waits a bit after getting an 'offline' event
// before disconnecting the socket manually.
disconnectTimer = setTimeout(disconnect, 1000);
}
function onOnline() {
window.log.info('online');
window.removeEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
if (disconnectTimer && isSocketOnline()) {
window.log.warn('Already online. Had a blip in online/offline status.');
clearTimeout(disconnectTimer);
disconnectTimer = null;
return;
}
if (disconnectTimer) {
clearTimeout(disconnectTimer);
disconnectTimer = null;
}
connect();
}
function isSocketOnline() {
const socketStatus = window.getSocketStatus();
return (
socketStatus === WebSocket.CONNECTING || socketStatus === WebSocket.OPEN
);
}
function disconnect() {
window.log.info('disconnect');
// Clear timer, since we're only called when the timer is expired
disconnectTimer = null;
if (messageReceiver) {
messageReceiver.close();
}
window.Signal.AttachmentDownloads.stop();
}
let connectCount = 0;
async function connect(firstRun) {
window.log.info('connect', { firstRun, connectCount });
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// Bootstrap our online/offline detection, only the first time we connect
if (connectCount === 0 && navigator.onLine) {
window.addEventListener('offline', onOffline);
}
if (connectCount === 0 && !navigator.onLine) {
window.log.warn(
'Starting up offline; will connect when we have network access'
);
window.addEventListener('online', onOnline);
onEmpty(); // this ensures that the loading screen is dismissed
return;
}
if (!window.Signal.Util.Registration.everDone()) {
return;
}
if (messageReceiver) {
await messageReceiver.stopProcessing();
await window.waitForAllBatchers();
messageReceiver.unregisterBatchers();
messageReceiver = null;
}
const OLD_USERNAME = storage.get('number_id');
const USERNAME = storage.get('uuid_id');
const PASSWORD = storage.get('password');
const mySignalingKey = storage.get('signaling_key');
connectCount += 1;
const options = {
retryCached: connectCount === 1,
serverTrustRoot: window.getServerTrustRoot(),
};
Whisper.deliveryReceiptQueue.pause(); // avoid flood of delivery receipts until we catch up
Whisper.Notifications.disable(); // avoid notification flood until empty
// initialize the socket and start listening for messages
window.log.info('Initializing socket and listening for messages');
messageReceiver = new textsecure.MessageReceiver(
OLD_USERNAME,
USERNAME,
PASSWORD,
mySignalingKey,
options
);
window.textsecure.messageReceiver = messageReceiver;
function addQueuedEventListener(name, handler) {
messageReceiver.addEventListener(name, (...args) =>
eventHandlerQueue.add(async () => {
try {
await handler(...args);
} finally {
// message/sent: Message.handleDataMessage has its own queue and will trigger
// this event itself when complete.
// error: Error processing (below) also has its own queue and self-trigger.
if (name !== 'message' && name !== 'sent' && name !== 'error') {
Whisper.events.trigger('incrementProgress');
}
}
})
);
}
addQueuedEventListener('message', onMessageReceived);
addQueuedEventListener('delivery', onDeliveryReceipt);
addQueuedEventListener('contact', onContactReceived);
addQueuedEventListener('group', onGroupReceived);
addQueuedEventListener('sent', onSentMessage);
addQueuedEventListener('readSync', onReadSync);
addQueuedEventListener('read', onReadReceipt);
addQueuedEventListener('verified', onVerified);
addQueuedEventListener('error', onError);
addQueuedEventListener('empty', onEmpty);
addQueuedEventListener('reconnect', onReconnect);
addQueuedEventListener('configuration', onConfiguration);
addQueuedEventListener('typing', onTyping);
addQueuedEventListener('sticker-pack', onStickerPack);
addQueuedEventListener('viewSync', onViewSync);
addQueuedEventListener('messageRequestResponse', onMessageRequestResponse);
addQueuedEventListener('profileKeyUpdate', onProfileKeyUpdate);
addQueuedEventListener('fetchLatest', onFetchLatestSync);
addQueuedEventListener('keys', onKeysSync);
window.Signal.AttachmentDownloads.start({
getMessageReceiver: () => messageReceiver,
logger: window.log,
});
window.textsecure.messaging = new textsecure.MessageSender(
USERNAME || OLD_USERNAME,
PASSWORD
);
if (connectCount === 1) {
window.Signal.Stickers.downloadQueuedPacks();
await window.textsecure.messaging.sendRequestKeySyncMessage();
}
// On startup after upgrading to a new version, request a contact sync
// (but only if we're not the primary device)
if (
!firstRun &&
connectCount === 1 &&
newVersion &&
// eslint-disable-next-line eqeqeq
textsecure.storage.user.getDeviceId() != '1'
) {
window.log.info('Boot after upgrading. Requesting contact sync');
window.getSyncRequest();
try {
const manager = window.getAccountManager();
await Promise.all([
manager.maybeUpdateDeviceName(),
manager.maybeDeleteSignalingKey(),
]);
} catch (e) {
window.log.error(
'Problem with account manager updates after starting new version: ',
e && e.stack ? e.stack : e
);
}
}
const udSupportKey = 'hasRegisterSupportForUnauthenticatedDelivery';
if (!storage.get(udSupportKey)) {
const server = WebAPI.connect({
username: USERNAME || OLD_USERNAME,
password: PASSWORD,
});
try {
await server.registerSupportForUnauthenticatedDelivery();
storage.put(udSupportKey, true);
} catch (error) {
window.log.error(
'Error: Unable to register for unauthenticated delivery support.',
error && error.stack ? error.stack : error
);
}
}
// TODO: uncomment this once we want to start registering UUID support
// const hasRegisteredUuidSupportKey = 'hasRegisteredUuidSupport';
// if (
// !storage.get(hasRegisteredUuidSupportKey) &&
// textsecure.storage.user.getUuid()
// ) {
// const server = WebAPI.connect({
// username: USERNAME || OLD_USERNAME,
// password: PASSWORD,
// });
// try {
// await server.registerCapabilities({ uuid: true });
// storage.put(hasRegisteredUuidSupportKey, true);
// } catch (error) {
// window.log.error(
// 'Error: Unable to register support for UUID messages.',
// error && error.stack ? error.stack : error
// );
// }
// }
const deviceId = textsecure.storage.user.getDeviceId();
// If we didn't capture a UUID on registration, go get it from the server
if (!textsecure.storage.user.getUuid()) {
const server = WebAPI.connect({
username: OLD_USERNAME,
password: PASSWORD,
});
try {
const { uuid } = await server.whoami();
textsecure.storage.user.setUuidAndDeviceId(uuid, deviceId);
const ourNumber = textsecure.storage.user.getNumber();
const me = await ConversationController.getOrCreateAndWait(
ourNumber,
'private'
);
me.updateUuid(uuid);
} catch (error) {
window.log.error(
'Error: Unable to retrieve UUID from service.',
error && error.stack ? error.stack : error
);
}
}
if (firstRun === true && deviceId !== '1') {
const hasThemeSetting = Boolean(storage.get('theme-setting'));
if (!hasThemeSetting && textsecure.storage.get('userAgent') === 'OWI') {
storage.put('theme-setting', 'ios');
onChangeTheme();
}
const syncRequest = new textsecure.SyncRequest(
textsecure.messaging,
messageReceiver
);
Whisper.events.trigger('contactsync:begin');
syncRequest.addEventListener('success', () => {
window.log.info('sync successful');
storage.put('synced_at', Date.now());
Whisper.events.trigger('contactsync');
});
syncRequest.addEventListener('timeout', () => {
window.log.error('sync timed out');
Whisper.events.trigger('contactsync');
});
const ourId = ConversationController.getOurConversationId();
const { wrap, sendOptions } = ConversationController.prepareForSend(
ourId,
{
syncMessage: true,
}
);
const installedStickerPacks = window.Signal.Stickers.getInstalledStickerPacks();
if (installedStickerPacks.length) {
const operations = installedStickerPacks.map(pack => ({
packId: pack.id,
packKey: pack.key,
installed: true,
}));
wrap(
window.textsecure.messaging.sendStickerPackSync(
operations,
sendOptions
)
).catch(error => {
window.log.error(
'Failed to send installed sticker packs via sync message',
error && error.stack ? error.stack : error
);
});
}
}
storage.onready(async () => {
idleDetector.start();
});
}
function onChangeTheme() {
const view = window.owsDesktopApp.appView;
if (view) {
view.applyTheme();
}
}
async function onEmpty() {
await Promise.all([
window.waitForAllBatchers(),
window.waitForAllWaitBatchers(),
]);
window.log.info('onEmpty: All outstanding database requests complete');
initialLoadComplete = true;
window.readyForUpdates();
// Start listeners here, after we get through our queue.
Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion);
window.Signal.RefreshSenderCertificate.initialize({
events: Whisper.events,
storage,
navigator,
logger: window.log,
});
// Force a re-fetch here when we've processed our queue. Without this, we won't try
// again for two hours after our first attempt. Which might have been while we were
// offline or didn't have credentials.
window.Signal.RemoteConfig.refreshRemoteConfig();
let interval = setInterval(() => {
const view = window.owsDesktopApp.appView;
if (view) {
clearInterval(interval);
interval = null;
view.onEmpty();
}
}, 500);
Whisper.deliveryReceiptQueue.start();
Whisper.Notifications.enable();
}
function onReconnect() {
// We disable notifications on first connect, but the same applies to reconnect. In
// scenarios where we're coming back from sleep, we can get offline/online events
// very fast, and it looks like a network blip. But we need to suppress
// notifications in these scenarios too. So we listen for 'reconnect' events.
Whisper.deliveryReceiptQueue.pause();
Whisper.Notifications.disable();
}
let initialStartupCount = 0;
Whisper.events.on('incrementProgress', incrementProgress);
function incrementProgress() {
initialStartupCount += 1;
// Only update progress every 10 items
if (initialStartupCount % 10 !== 0) {
return;
}
window.log.info(
`incrementProgress: Message count is ${initialStartupCount}`
);
const view = window.owsDesktopApp.appView;
if (view) {
view.onProgress(initialStartupCount);
}
}
Whisper.events.on('manualConnect', manualConnect);
function manualConnect() {
connect();
}
function onConfiguration(ev) {
ev.confirm();
const { configuration } = ev;
const {
readReceipts,
typingIndicators,
unidentifiedDeliveryIndicators,
linkPreviews,
} = configuration;
storage.put('read-receipt-setting', readReceipts);
if (
unidentifiedDeliveryIndicators === true ||
unidentifiedDeliveryIndicators === false
) {
storage.put(
'unidentifiedDeliveryIndicators',
unidentifiedDeliveryIndicators
);
}
if (typingIndicators === true || typingIndicators === false) {
storage.put('typingIndicators', typingIndicators);
}
if (linkPreviews === true || linkPreviews === false) {
storage.put('linkPreviews', linkPreviews);
}
}
function onTyping(ev) {
// Note: this type of message is automatically removed from cache in MessageReceiver
const { typing, sender, senderUuid, senderDevice } = ev;
const { groupId, started } = typing || {};
// We don't do anything with incoming typing messages if the setting is disabled
if (!storage.get('typingIndicators')) {
return;
}
const senderId = ConversationController.ensureContactIds({
e164: sender,
uuid: senderUuid,
highTrust: true,
});
const conversation = ConversationController.get(groupId || senderId);
const ourId = ConversationController.getOurConversationId();
if (conversation) {
// We drop typing notifications in groups we're not a part of
if (!conversation.isPrivate() && !conversation.hasMember(ourId)) {
window.log.warn(
`Received typing indicator for group ${conversation.idForLogging()}, which we're not a part of. Dropping.`
);
return;
}
conversation.notifyTyping({
isTyping: started,
isMe: ourId === senderId,
sender,
senderUuid,
senderId,
senderDevice,
});
}
}
async function onStickerPack(ev) {
ev.confirm();
const packs = ev.stickerPacks || [];
packs.forEach(pack => {
const { id, key, isInstall, isRemove } = pack || {};
if (!id || !key || (!isInstall && !isRemove)) {
window.log.warn(
'Received malformed sticker pack operation sync message'
);
return;
}
const status = window.Signal.Stickers.getStickerPackStatus(id);
if (status === 'installed' && isRemove) {
window.reduxActions.stickers.uninstallStickerPack(id, key, {
fromSync: true,
});
} else if (isInstall) {
if (status === 'downloaded') {
window.reduxActions.stickers.installStickerPack(id, key, {
fromSync: true,
});
} else {
window.Signal.Stickers.downloadStickerPack(id, key, {
finalStatus: 'installed',
fromSync: true,
});
}
}
});
}
async function onContactReceived(ev) {
const details = ev.contactDetails;
if (
(details.number &&
details.number === textsecure.storage.user.getNumber()) ||
(details.uuid && details.uuid === textsecure.storage.user.getUuid())
) {
// special case for syncing details about ourselves
if (details.profileKey) {
window.log.info('Got sync message with our own profile key');
storage.put('profileKey', details.profileKey);
}
}
const c = new Whisper.Conversation({
e164: details.number,
uuid: details.uuid,
type: 'private',
});
const validationError = c.validate();
if (validationError) {
window.log.error(
'Invalid contact received:',
Errors.toLogFormat(validationError)
);
return;
}
try {
const detailsId = ConversationController.ensureContactIds({
e164: details.number,
uuid: details.uuid,
highTrust: true,
});
const conversation = ConversationController.get(detailsId);
let activeAt = conversation.get('active_at');
// The idea is to make any new contact show up in the left pane. If
// activeAt is null, then this contact has been purposefully hidden.
if (activeAt !== null) {
activeAt = activeAt || Date.now();
}
if (details.profileKey) {
const profileKey = window.Signal.Crypto.arrayBufferToBase64(
details.profileKey
);
conversation.setProfileKey(profileKey);
} else {
conversation.dropProfileKey();
}
if (typeof details.blocked !== 'undefined') {
const e164 = conversation.get('e164');
if (details.blocked && e164) {
storage.addBlockedNumber(e164);
} else {
storage.removeBlockedNumber(e164);
}
const uuid = conversation.get('uuid');
if (details.blocked && uuid) {
storage.addBlockedUuid(uuid);
} else {
storage.removeBlockedUuid(uuid);
}
}
conversation.set({
name: details.name,
color: details.color,
active_at: activeAt,
inbox_position: details.inboxPosition,
});
// Update the conversation avatar only if new avatar exists and hash differs
const { avatar } = details;
if (avatar && avatar.data) {
const newAttributes = await window.Signal.Types.Conversation.maybeUpdateAvatar(
conversation.attributes,
avatar.data,
{
writeNewAttachmentData,
deleteAttachmentData,
doesAttachmentExist,
}
);
conversation.set(newAttributes);
} else {
const { attributes } = conversation;
if (attributes.avatar && attributes.avatar.path) {
await deleteAttachmentData(attributes.avatar.path);
}
conversation.set({ avatar: null });
}
window.Signal.Data.updateConversation(conversation.attributes);
const { expireTimer } = details;
const isValidExpireTimer = typeof expireTimer === 'number';
if (isValidExpireTimer) {
const ourId = ConversationController.getOurConversationId();
const receivedAt = Date.now();
await conversation.updateExpirationTimer(
expireTimer,
ourId,
receivedAt,
{
fromSync: true,
}
);
}
if (details.verified) {
const { verified } = details;
const verifiedEvent = new Event('verified');
verifiedEvent.verified = {
state: verified.state,
destination: verified.destination,
destinationUuid: verified.destinationUuid,
identityKey: verified.identityKey.toArrayBuffer(),
};
verifiedEvent.viaContactSync = true;
await onVerified(verifiedEvent);
}
const { appView } = window.owsDesktopApp;
if (appView && appView.installView && appView.installView.didLink) {
window.log.info(
'onContactReceived: Adding the message history disclaimer on link'
);
await conversation.addMessageHistoryDisclaimer();
}
} catch (error) {
window.log.error('onContactReceived error:', Errors.toLogFormat(error));
}
}
async function onGroupReceived(ev) {
const details = ev.groupDetails;
const { id } = details;
const idBuffer = window.Signal.Crypto.fromEncodedBinaryToArrayBuffer(id);
const idBytes = idBuffer.byteLength;
if (idBytes !== 16) {
window.log.error(
`onGroupReceived: Id was ${idBytes} bytes, expected 16 bytes. Dropping group.`
);
return;
}
const conversation = await ConversationController.getOrCreateAndWait(
id,
'group'
);
const memberConversations = details.membersE164.map(e164 =>
ConversationController.getOrCreate(e164, 'private')
);
const members = memberConversations.map(c => c.get('id'));
const updates = {
name: details.name,
members,
color: details.color,
type: 'group',
inbox_position: details.inboxPosition,
};
if (details.active) {
const activeAt = conversation.get('active_at');
// The idea is to make any new group show up in the left pane. If
// activeAt is null, then this group has been purposefully hidden.
if (activeAt !== null) {
updates.active_at = activeAt || Date.now();
}
updates.left = false;
} else {
updates.left = true;
}
if (details.blocked) {
storage.addBlockedGroup(id);
} else {
storage.removeBlockedGroup(id);
}
conversation.set(updates);
// Update the conversation avatar only if new avatar exists and hash differs
const { avatar } = details;
if (avatar && avatar.data) {
const newAttributes = await window.Signal.Types.Conversation.maybeUpdateAvatar(
conversation.attributes,
avatar.data,
{
writeNewAttachmentData,
deleteAttachmentData,
doesAttachmentExist,
}
);
conversation.set(newAttributes);
}
window.Signal.Data.updateConversation(conversation.attributes);
const { appView } = window.owsDesktopApp;
if (appView && appView.installView && appView.installView.didLink) {
window.log.info(
'onGroupReceived: Adding the message history disclaimer on link'
);
await conversation.addMessageHistoryDisclaimer();
}
const { expireTimer } = details;
const isValidExpireTimer = typeof expireTimer === 'number';
if (!isValidExpireTimer) {
return;
}
const receivedAt = Date.now();
await conversation.updateExpirationTimer(
expireTimer,
ConversationController.getOurConversationId(),
receivedAt,
{
fromSync: true,
}
);
}
// Descriptors
const getGroupDescriptor = group => ({
type: Message.GROUP,
id: group.id,
});
// Matches event data from `libtextsecure` `MessageReceiver::handleSentMessage`:
const getDescriptorForSent = ({ message, destination, destinationUuid }) =>
message.group
? getGroupDescriptor(message.group)
: {
type: Message.PRIVATE,
id: ConversationController.ensureContactIds({
e164: destination,
uuid: destinationUuid,
}),
};
// Matches event data from `libtextsecure` `MessageReceiver::handleDataMessage`:
const getDescriptorForReceived = ({ message, source, sourceUuid }) =>
message.group
? getGroupDescriptor(message.group)
: {
type: Message.PRIVATE,
id: ConversationController.ensureContactIds({
e164: source,
uuid: sourceUuid,
highTrust: true,
}),
};
// Received:
async function handleMessageReceivedProfileUpdate({
data,
confirm,
messageDescriptor,
}) {
const profileKey = data.message.profileKey.toString('base64');
const sender = await ConversationController.get(messageDescriptor.id);
if (sender) {
// Will do the save for us
await sender.setProfileKey(profileKey);
}
return confirm();
}
// Note: We do very little in this function, since everything in handleDataMessage is
// inside a conversation-specific queue(). Any code here might run before an earlier
// message is processed in handleDataMessage().
function onMessageReceived(event) {
const { data, confirm } = event;
const messageDescriptor = getDescriptorForReceived(data);
const { PROFILE_KEY_UPDATE } = textsecure.protobuf.DataMessage.Flags;
// eslint-disable-next-line no-bitwise
const isProfileUpdate = Boolean(data.message.flags & PROFILE_KEY_UPDATE);
if (isProfileUpdate) {
return handleMessageReceivedProfileUpdate({
data,
confirm,
messageDescriptor,
});
}
const message = initIncomingMessage(data, messageDescriptor);
if (data.message.reaction) {
const { reaction } = data.message;
window.log.info('Queuing reaction for', reaction.targetTimestamp);
const reactionModel = Whisper.Reactions.add({
emoji: reaction.emoji,
remove: reaction.remove,
targetAuthorE164: reaction.targetAuthorE164,
targetAuthorUuid: reaction.targetAuthorUuid,
targetTimestamp: reaction.targetTimestamp.toNumber(),
timestamp: Date.now(),
fromId: ConversationController.ensureContactIds({
e164: data.source,
uuid: data.sourceUuid,
}),
});
// Note: We do not wait for completion here
Whisper.Reactions.onReaction(reactionModel);
confirm();
return Promise.resolve();
}
if (data.message.delete) {
const { delete: del } = data.message;
window.log.info('Queuing DOE for', del.targetSentTimestamp);
const deleteModel = Whisper.Deletes.add({
targetSentTimestamp: del.targetSentTimestamp,
serverTimestamp: data.serverTimestamp,
fromId: ConversationController.ensureContactIds({
e164: data.source,
uuid: data.sourceUuid,
}),
});
// Note: We do not wait for completion here
Whisper.Deletes.onDelete(deleteModel);
confirm();
return Promise.resolve();
}
// Don't wait for handleDataMessage, as it has its own per-conversation queueing
message.handleDataMessage(data.message, event.confirm);
return Promise.resolve();
}
async function onProfileKeyUpdate({ data, confirm }) {
const conversationId = ConversationController.ensureContactIds({
e164: data.source,
uuid: data.sourceUuid,
highTrust: true,
});
const conversation = ConversationController.get(conversationId);
if (!conversation) {
window.log.error(
'onProfileKeyUpdate: could not find conversation',
data.source,
data.sourceUuid
);
confirm();
return;
}
if (!data.profileKey) {
window.log.error(
'onProfileKeyUpdate: missing profileKey',
data.profileKey
);
confirm();
return;
}
window.log.info(
'onProfileKeyUpdate: updating profileKey',
data.source,
data.sourceUuid
);
await conversation.setProfileKey(data.profileKey);
confirm();
}
async function handleMessageSentProfileUpdate({
data,
confirm,
messageDescriptor,
}) {
// First set profileSharing = true for the conversation we sent to
const { id } = messageDescriptor;
const conversation = await ConversationController.get(id);
conversation.enableProfileSharing();
window.Signal.Data.updateConversation(conversation.attributes);
// Then we update our own profileKey if it's different from what we have
const ourId = ConversationController.getOurConversationId();
const me = ConversationController.get(ourId);
const profileKey = data.message.profileKey.toString('base64');
// Will do the save for us if needed
await me.setProfileKey(profileKey);
return confirm();
}
function createSentMessage(data, descriptor) {
const now = Date.now();
let sentTo = [];
if (data.unidentifiedStatus && data.unidentifiedStatus.length) {
sentTo = data.unidentifiedStatus.map(item => item.destination);
const unidentified = _.filter(data.unidentifiedStatus, item =>
Boolean(item.unidentified)
);
// eslint-disable-next-line no-param-reassign
data.unidentifiedDeliveries = unidentified.map(item => item.destination);
}
const isGroup = descriptor.type === Message.GROUP;
const conversationId = isGroup
? ConversationController.ensureGroup(descriptor.id)
: descriptor.id;
return new Whisper.Message({
source: textsecure.storage.user.getNumber(),
sourceUuid: textsecure.storage.user.getUuid(),
sourceDevice: data.device,
sent_at: data.timestamp,
serverTimestamp: data.serverTimestamp,
sent_to: sentTo,
received_at: now,
conversationId,
type: 'outgoing',
sent: true,
unidentifiedDeliveries: data.unidentifiedDeliveries || [],
expirationStartTimestamp: Math.min(
data.expirationStartTimestamp || data.timestamp || Date.now(),
Date.now()
),
});
}
// Note: We do very little in this function, since everything in handleDataMessage is
// inside a conversation-specific queue(). Any code here might run before an earlier
// message is processed in handleDataMessage().
function onSentMessage(event) {
const { data, confirm } = event;
const messageDescriptor = getDescriptorForSent(data);
const { PROFILE_KEY_UPDATE } = textsecure.protobuf.DataMessage.Flags;
// eslint-disable-next-line no-bitwise
const isProfileUpdate = Boolean(data.message.flags & PROFILE_KEY_UPDATE);
if (isProfileUpdate) {
return handleMessageSentProfileUpdate({
data,
confirm,
messageDescriptor,
});
}
const message = createSentMessage(data, messageDescriptor);
if (data.message.reaction) {
const { reaction } = data.message;
const reactionModel = Whisper.Reactions.add({
emoji: reaction.emoji,
remove: reaction.remove,
targetAuthorE164: reaction.targetAuthorE164,
targetAuthorUuid: reaction.targetAuthorUuid,
targetTimestamp: reaction.targetTimestamp.toNumber(),
timestamp: Date.now(),
fromId: ConversationController.getOurConversationId(),
fromSync: true,
});
// Note: We do not wait for completion here
Whisper.Reactions.onReaction(reactionModel);
event.confirm();
return Promise.resolve();
}
if (data.message.delete) {
const { delete: del } = data.message;
const deleteModel = Whisper.Deletes.add({
targetSentTimestamp: del.targetSentTimestamp,
serverTimestamp: del.serverTimestamp,
fromId: ConversationController.getOurConversationId(),
});
// Note: We do not wait for completion here
Whisper.Deletes.onDelete(deleteModel);
confirm();
return Promise.resolve();
}
// Don't wait for handleDataMessage, as it has its own per-conversation queueing
message.handleDataMessage(data.message, event.confirm, {
data,
});
return Promise.resolve();
}
function initIncomingMessage(data, descriptor) {
// Ensure that we have an accurate record for who this message is from
const fromContactId = ConversationController.ensureContactIds({
e164: data.source,
uuid: data.sourceUuid,
highTrust: true,
});
const isGroup = descriptor.type === Message.GROUP;
const conversationId = isGroup
? ConversationController.ensureGroup(descriptor.id, {
addedBy: fromContactId,
})
: fromContactId;
return new Whisper.Message({
source: data.source,
sourceUuid: data.sourceUuid,
sourceDevice: data.sourceDevice,
sent_at: data.timestamp,
serverTimestamp: data.serverTimestamp,
received_at: Date.now(),
conversationId,
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
type: 'incoming',
unread: 1,
});
}
async function unlinkAndDisconnect() {
Whisper.events.trigger('unauthorized');
if (messageReceiver) {
await messageReceiver.stopProcessing();
await window.waitForAllBatchers();
messageReceiver.unregisterBatchers();
messageReceiver = null;
}
onEmpty();
window.log.warn(
'Client is no longer authorized; deleting local configuration'
);
window.Signal.Util.Registration.remove();
const NUMBER_ID_KEY = 'number_id';
const VERSION_KEY = 'version';
const LAST_PROCESSED_INDEX_KEY = 'attachmentMigration_lastProcessedIndex';
const IS_MIGRATION_COMPLETE_KEY = 'attachmentMigration_isComplete';
const previousNumberId = textsecure.storage.get(NUMBER_ID_KEY);
const lastProcessedIndex = textsecure.storage.get(LAST_PROCESSED_INDEX_KEY);
const isMigrationComplete = textsecure.storage.get(
IS_MIGRATION_COMPLETE_KEY
);
try {
await textsecure.storage.protocol.removeAllConfiguration();
// These two bits of data are important to ensure that the app loads up
// the conversation list, instead of showing just the QR code screen.
window.Signal.Util.Registration.markEverDone();
textsecure.storage.put(NUMBER_ID_KEY, previousNumberId);
// These two are important to ensure we don't rip through every message
// in the database attempting to upgrade it after starting up again.
textsecure.storage.put(
IS_MIGRATION_COMPLETE_KEY,
isMigrationComplete || false
);
textsecure.storage.put(
LAST_PROCESSED_INDEX_KEY,
lastProcessedIndex || null
);
textsecure.storage.put(VERSION_KEY, window.getVersion());
window.log.info('Successfully cleared local configuration');
} catch (eraseError) {
window.log.error(
'Something went wrong clearing local configuration',
eraseError && eraseError.stack ? eraseError.stack : eraseError
);
}
}
function onError(ev) {
const { error } = ev;
window.log.error('background onError:', Errors.toLogFormat(error));
if (
error &&
error.name === 'HTTPError' &&
(error.code === 401 || error.code === 403)
) {
return unlinkAndDisconnect();
}
if (
error &&
error.name === 'HTTPError' &&
(error.code === -1 || error.code === 502)
) {
// Failed to connect to server
if (navigator.onLine) {
window.log.info('retrying in 1 minute');
reconnectTimer = setTimeout(connect, 60000);
Whisper.events.trigger('reconnectTimer');
}
return Promise.resolve();
}
if (ev.proto) {
if (error && error.name === 'MessageCounterError') {
if (ev.confirm) {
ev.confirm();
}
// Ignore this message. It is likely a duplicate delivery
// because the server lost our ack the first time.
return Promise.resolve();
}
const envelope = ev.proto;
const message = initIncomingMessage(envelope, {
type: Message.PRIVATE,
id: ConversationController.ensureContactIds({
e164: envelope.source,
uuid: envelope.sourceUuid,
}),
});
const conversationId = message.get('conversationId');
const conversation = ConversationController.get(conversationId);
// This matches the queueing behavior used in Message.handleDataMessage
conversation.queueJob(async () => {
const existingMessage = await window.Signal.Data.getMessageBySender(
message.attributes,
{
Message: Whisper.Message,
}
);
if (existingMessage) {
ev.confirm();
window.log.warn(
`Got duplicate error for message ${message.idForLogging()}`
);
return;
}
const model = new Whisper.Message({
...message.attributes,
id: window.getGuid(),
});
await model.saveErrors(error || new Error('Error was null'), {
skipSave: true,
});
MessageController.register(model.id, model);
await window.Signal.Data.saveMessage(model.attributes, {
Message: Whisper.Message,
forceSave: true,
});
conversation.set({
active_at: Date.now(),
unreadCount: conversation.get('unreadCount') + 1,
});
const conversationTimestamp = conversation.get('timestamp');
const messageTimestamp = model.get('timestamp');
if (
!conversationTimestamp ||
messageTimestamp > conversationTimestamp
) {
conversation.set({ timestamp: model.get('sent_at') });
}
conversation.trigger('newmessage', model);
conversation.notify(model);
Whisper.events.trigger('incrementProgress');
if (ev.confirm) {
ev.confirm();
}
window.Signal.Data.updateConversation(conversation.attributes);
});
}
throw error;
}
async function onViewSync(ev) {
ev.confirm();
const { source, sourceUuid, timestamp } = ev;
window.log.info(`view sync ${source} ${timestamp}`);
const sync = Whisper.ViewSyncs.add({
source,
sourceUuid,
timestamp,
});
Whisper.ViewSyncs.onSync(sync);
}
async function onFetchLatestSync(ev) {
ev.confirm();
const { eventType } = ev;
const FETCH_LATEST_ENUM = textsecure.protobuf.SyncMessage.FetchLatest.Type;
switch (eventType) {
case FETCH_LATEST_ENUM.LOCAL_PROFILE:
// Intentionally do nothing since we'll be receiving the storage manifest request
// and will update local profile along with that.
break;
case FETCH_LATEST_ENUM.STORAGE_MANIFEST:
window.log.info('onFetchLatestSync: fetching latest manifest');
await window.Signal.Util.runStorageServiceSyncJob();
break;
default:
window.log.info(
`onFetchLatestSync: Unknown type encountered ${eventType}`
);
}
}
async function onKeysSync(ev) {
ev.confirm();
const { storageServiceKey } = ev;
if (storageServiceKey) {
window.log.info('onKeysSync: received keys');
const storageServiceKeyBase64 = window.Signal.Crypto.arrayBufferToBase64(
storageServiceKey
);
storage.put('storageKey', storageServiceKeyBase64);
await window.Signal.Util.runStorageServiceSyncJob();
}
}
async function onMessageRequestResponse(ev) {
ev.confirm();
const { threadE164, threadUuid, groupId, messageRequestResponseType } = ev;
const args = {
threadE164,
threadUuid,
groupId,
type: messageRequestResponseType,
};
window.log.info('message request response', args);
const sync = Whisper.MessageRequests.add(args);
Whisper.MessageRequests.onResponse(sync);
}
function onReadReceipt(ev) {
const readAt = ev.timestamp;
const { envelopeTimestamp, timestamp, source, sourceUuid } = ev.read;
const reader = ConversationController.ensureContactIds({
e164: source,
uuid: sourceUuid,
highTrust: true,
});
window.log.info(
'read receipt',
source,
sourceUuid,
envelopeTimestamp,
reader,
'for sent message',
timestamp
);
ev.confirm();
if (!storage.get('read-receipt-setting') || !reader) {
return;
}
const receipt = Whisper.ReadReceipts.add({
reader,
timestamp,
read_at: readAt,
});
// Note: We do not wait for completion here
Whisper.ReadReceipts.onReceipt(receipt);
}
function onReadSync(ev) {
const readAt = ev.timestamp;
const { envelopeTimestamp, sender, senderUuid, timestamp } = ev.read;
const senderId = ConversationController.ensureContactIds({
e164: sender,
uuid: senderUuid,
});
window.log.info(
'read sync',
sender,
senderUuid,
envelopeTimestamp,
senderId,
'for message',
timestamp
);
const receipt = Whisper.ReadSyncs.add({
senderId,
sender,
senderUuid,
timestamp,
read_at: readAt,
});
receipt.on('remove', ev.confirm);
// Note: Here we wait, because we want read states to be in the database
// before we move on.
return Whisper.ReadSyncs.onReceipt(receipt);
}
async function onVerified(ev) {
const e164 = ev.verified.destination;
const uuid = ev.verified.destinationUuid;
const key = ev.verified.identityKey;
let state;
if (ev.confirm) {
ev.confirm();
}
const c = new Whisper.Conversation({
e164,
uuid,
type: 'private',
});
const error = c.validate();
if (error) {
window.log.error(
'Invalid verified sync received:',
e164,
uuid,
Errors.toLogFormat(error)
);
return;
}
switch (ev.verified.state) {
case textsecure.protobuf.Verified.State.DEFAULT:
state = 'DEFAULT';
break;
case textsecure.protobuf.Verified.State.VERIFIED:
state = 'VERIFIED';
break;
case textsecure.protobuf.Verified.State.UNVERIFIED:
state = 'UNVERIFIED';
break;
default:
window.log.error(`Got unexpected verified state: ${ev.verified.state}`);
}
window.log.info(
'got verified sync for',
e164,
uuid,
state,
ev.viaContactSync ? 'via contact sync' : ''
);
const verifiedId = ConversationController.ensureContactIds({
e164,
uuid,
highTrust: true,
});
const contact = await ConversationController.get(verifiedId, 'private');
const options = {
viaSyncMessage: true,
viaContactSync: ev.viaContactSync,
key,
};
if (state === 'VERIFIED') {
await contact.setVerified(options);
} else if (state === 'DEFAULT') {
await contact.setVerifiedDefault(options);
} else {
await contact.setUnverified(options);
}
}
function onDeliveryReceipt(ev) {
const { deliveryReceipt } = ev;
const {
envelopeTimestamp,
sourceUuid,
source,
sourceDevice,
timestamp,
} = deliveryReceipt;
ev.confirm();
const deliveredTo = ConversationController.ensureContactIds({
e164: source,
uuid: sourceUuid,
highTrust: true,
});
window.log.info(
'delivery receipt from',
source,
sourceUuid,
sourceDevice,
deliveredTo,
envelopeTimestamp,
'for sent message',
timestamp
);
if (!deliveredTo) {
window.log.info('no conversation for', source, sourceUuid);
return;
}
const receipt = Whisper.DeliveryReceipts.add({
timestamp,
deliveredTo,
});
// Note: We don't wait for completion here
Whisper.DeliveryReceipts.onReceipt(receipt);
}
})();