Mailspring/app/internal_packages/unread-notifications/lib/main.ts

250 lines
7 KiB
TypeScript

import _ from 'underscore';
import {
Thread,
Actions,
AccountStore,
Message,
SoundRegistry,
NativeNotifications,
DatabaseStore,
localized,
} from 'mailspring-exports';
const WAIT_FOR_CHANGES_DELAY = 400;
export class Notifier {
activationTime = Date.now();
unnotifiedQueue = [];
hasScheduledNotify = false;
unlisteners: Array<() => void>;
activeNotifications = {};
constructor() {
this.unlisteners = [DatabaseStore.listen(this._onDatabaseChanged, this)];
}
unlisten() {
for (const unlisten of this.unlisteners) {
unlisten();
}
}
// async for testing
async _onDatabaseChanged({ objectClass, objects, objectsRawJSON }) {
if (AppEnv.config.get('core.notifications.enabled') === false) {
return;
}
if (objectClass === Thread.name) {
this._onThreadsChanged(objects);
}
if (objectClass === Message.name) {
const newIds = objectsRawJSON.filter(json => json.headersSyncComplete).map(json => json.id);
if (!newIds.length) return;
this._onMessagesChanged(objects, newIds);
}
}
// async for testing
async _onMessagesChanged(msgs, newIds: string[]) {
const notifworthy = {};
for (const msg of msgs) {
// ensure the message is unread
if (msg.unread !== true) continue;
// ensure the message was just created (eg: this is not a modification).
// The sync engine attaches a JSON key to let us know that this is the first
// message emitted about this Message. (Hooray hacks around reactive patterns)
if (!newIds.includes(msg.id)) continue;
// ensure the message was received after the app launched (eg: not syncing an old email)
if (!msg.date || msg.date.valueOf() < this.activationTime) continue;
// ensure the message is not a loopback
const account = msg.from[0] && AccountStore.accountForEmail(msg.from[0].email);
if (msg.accountId === (account || { id: undefined }).id) continue;
notifworthy[msg.id] = msg;
}
if (Object.keys(notifworthy).length === 0) {
return;
}
if (!AppEnv.inSpecMode()) {
await new Promise(resolve => {
// wait a couple hundred milliseconds and collect any updates to these
// new messages. This gets us message bodies, messages impacted by mail rules, etc.
// while ensuring notifications are never too delayed.
const unlisten = DatabaseStore.listen(({ objectClass, objects }) => {
if (objectClass !== Message.name) {
return;
}
for (const msg of objects) {
if (notifworthy[msg.id]) {
notifworthy[msg.id] = msg;
if (msg.unread === false) {
delete notifworthy[msg.id];
}
}
}
});
setTimeout(() => {
unlisten();
resolve();
}, WAIT_FOR_CHANGES_DELAY);
});
}
await this._onNewMessagesReceived(Object.values(notifworthy));
}
_onThreadsChanged(threads) {
// Ensure notifications are dismissed when the user reads a thread
threads.forEach(({ id, unread }) => {
if (!unread && this.activeNotifications[id]) {
this.activeNotifications[id].forEach(n => n.close());
delete this.activeNotifications[id];
}
});
}
_notifyAll() {
NativeNotifications.displayNotification({
title: `${this.unnotifiedQueue.length} ${localized('Unread Messages')}`,
tag: 'unread-update',
onActivate: () => {
AppEnv.displayWindow();
},
});
this.unnotifiedQueue = [];
}
_notifyOne({ message, thread }) {
const from = message.from[0] ? message.from[0].displayName() : 'Unknown';
const title = from;
let subtitle = null;
let body = null;
if (message.subject && message.subject.length > 0) {
subtitle = message.subject;
body = message.snippet;
} else {
subtitle = message.snippet;
body = null;
}
const notification = NativeNotifications.displayNotification({
title: title,
subtitle: subtitle,
body: body,
canReply: true,
tag: 'unread-update',
onActivate: ({ response, activationType }) => {
if (activationType === 'replied' && response && typeof response === 'string') {
Actions.sendQuickReply({ thread, message }, response);
} else {
AppEnv.displayWindow();
}
if (!thread) {
AppEnv.showErrorDialog(`Can't find that thread`);
return;
}
Actions.ensureCategoryIsFocused('inbox', thread.accountId);
Actions.setFocus({ collection: 'thread', item: thread });
},
});
if (notification) {
if (!this.activeNotifications[thread.id]) {
this.activeNotifications[thread.id] = [notification];
} else {
this.activeNotifications[thread.id].push(notification);
}
}
}
_notifyMessages() {
if (this.unnotifiedQueue.length >= 5) {
this._notifyAll();
} else if (this.unnotifiedQueue.length > 0) {
this._notifyOne(this.unnotifiedQueue.shift());
}
this.hasScheduledNotify = false;
if (this.unnotifiedQueue.length > 0) {
setTimeout(() => this._notifyMessages(), 2000);
this.hasScheduledNotify = true;
}
}
_playNewMailSound = _.debounce(
() => {
if (!AppEnv.config.get('core.notifications.sounds')) return;
if (NativeNotifications.doNotDisturb()) return;
SoundRegistry.playSound('new-mail');
},
5000,
true
);
_onNewMessagesReceived(newMessages) {
if (newMessages.length === 0) {
return Promise.resolve();
}
// For each message, find it's corresponding thread. First, look to see
// if it's already in the `incoming` payload (sent via delta sync
// at the same time as the message.) If it's not, try loading it from
// the local cache.
const threadIds = {};
for (const { threadId } of newMessages) {
threadIds[threadId] = true;
}
// TODO: Use xGMLabels + folder on message to identify which ones
// are in the inbox to avoid needing threads here.
return DatabaseStore.findAll<Thread>(
Thread,
Thread.attributes.id.in(Object.keys(threadIds))
).then(threadsArray => {
const threads = {};
for (const t of threadsArray) {
threads[t.id] = t;
}
// Filter new messages to just the ones in the inbox
const newMessagesInInbox = newMessages.filter(({ threadId }) => {
return threads[threadId] && threads[threadId].categories.find(c => c.role === 'inbox');
});
if (newMessagesInInbox.length === 0) {
return;
}
for (const msg of newMessagesInInbox) {
this.unnotifiedQueue.push({ message: msg, thread: threads[msg.threadId] });
}
if (!this.hasScheduledNotify) {
this._playNewMailSound();
this._notifyMessages();
}
});
}
}
export const config = {
enabled: {
type: 'boolean',
default: true,
},
};
export function activate() {
this.notifier = new Notifier();
}
export function deactivate() {
this.notifier.unlisten();
}