From 60e6113f8701adce7e668012680ee2146f60b70d Mon Sep 17 00:00:00 2001 From: Mark Hahnenberg Date: Wed, 22 Mar 2017 14:34:03 -0700 Subject: [PATCH] [client-sync] Implement the /contacts/rankings endpoint Summary: Prior to Nylas Mail, the Nylas Cloud API provided an endpoint that returned rankings for contacts which it computed based on how frequently and how recently a user sent mail to a recipient. This diff reimplements that functionality in Nylas Mail. This should improve contact auto-complete when composing emails to frequently contacted recipients. Test Plan: Run locally, verify that frequent contacts are suggested earlier Reviewers: spang, evan, juan Reviewed By: evan, juan Maniphest Tasks: T7948 Differential Revision: https://phab.nylas.com/D4253 --- .../lib/contact-rankings-cache.es6 | 62 +++++++++++++- .../contact-rankings/lib/main.es6 | 7 +- .../lib/refreshing-json-cache.coffee | 5 +- .../src/local-api/routes/contacts.js | 80 +++++++++++++++++-- 4 files changed, 142 insertions(+), 12 deletions(-) diff --git a/packages/client-app/internal_packages/contact-rankings/lib/contact-rankings-cache.es6 b/packages/client-app/internal_packages/contact-rankings/lib/contact-rankings-cache.es6 index 32f8fd888..c21eae551 100644 --- a/packages/client-app/internal_packages/contact-rankings/lib/contact-rankings-cache.es6 +++ b/packages/client-app/internal_packages/contact-rankings/lib/contact-rankings-cache.es6 @@ -1,21 +1,35 @@ +import _ from 'underscore' +import moment from 'moment-timezone' import { + AccountStore, NylasAPI, NylasAPIRequest, } from 'nylas-exports' import RefreshingJSONCache from './refreshing-json-cache' - // Stores contact rankings class ContactRankingsCache extends RefreshingJSONCache { constructor(accountId) { super({ key: `ContactRankingsFor${accountId}`, version: 1, - refreshInterval: 60 * 60 * 1000 * 24, // one day + refreshInterval: moment.duration(60, 'seconds').asMilliseconds(), + maxRefreshInterval: moment.duration(24, 'hours').asMilliseconds(), }) this._accountId = accountId } + _nextRefreshInterval() { + // For the first 15 minutes, refresh roughly once every minute so that the + // experience of composing drafts during initial is less annoying. + const initialLimit = (60 * 1000) + 15; + if (this.refreshInterval < initialLimit) { + return this.refreshInterval + 1; + } + // After the first 15 minutes, refresh twice as long each time up to the max. + return Math.min(this.refreshInterval * 2, this.maxRefreshInterval); + } + fetchData = (callback) => { if (NylasEnv.inSpecMode()) return @@ -37,6 +51,8 @@ class ContactRankingsCache extends RefreshingJSONCache { rankings[email.toLowerCase()] = rank } callback(rankings) + + this.refreshInterval = this._nextRefreshInterval(); }) .catch((err) => { console.warn(`Request for Contact Rankings failed for @@ -45,4 +61,44 @@ class ContactRankingsCache extends RefreshingJSONCache { } } -export default ContactRankingsCache +class ContactRankingsCacheManager { + constructor() { + this.accountCaches = {}; + this.unsubscribers = []; + this.onAccountsChanged = _.debounce(this.onAccountsChanged, 100); + } + + activate() { + this.onAccountsChanged(); + this.unsubscribers = [AccountStore.listen(this.onAccountsChanged)]; + } + + deactivate() { + this.unsubscribers.forEach(unsub => unsub()); + } + + onAccountsChanged = () => { + const previousIDs = Object.keys(this.accountCaches); + const latestIDs = AccountStore.accounts().map(a => a.id); + if (_.isEqual(previousIDs, latestIDs)) { + return; + } + + const newIDs = _.difference(latestIDs, previousIDs); + const removedIDs = _.difference(previousIDs, latestIDs); + + console.log(`ContactRankingsCache: Updating contact rankings; added = ${latestIDs}, removed = ${removedIDs}`); + + for (const newID of newIDs) { + this.accountCaches[newID] = new ContactRankingsCache(newID); + this.accountCaches[newID].start(); + } + + for (const removedID of removedIDs) { + this.accountCaches[removedID].end(); + this.accountCaches[removedID] = null; + } + } +} + +export default new ContactRankingsCacheManager(); diff --git a/packages/client-app/internal_packages/contact-rankings/lib/main.es6 b/packages/client-app/internal_packages/contact-rankings/lib/main.es6 index 19829edaa..67e5d5a6c 100644 --- a/packages/client-app/internal_packages/contact-rankings/lib/main.es6 +++ b/packages/client-app/internal_packages/contact-rankings/lib/main.es6 @@ -1,9 +1,10 @@ +import ContactRankingsCache from './contact-rankings-cache' + export function activate() { - // TODO This package only contains ContactRankingsCache, which should be - // restored somewhere when we restore the contact rankings feature + ContactRankingsCache.activate(); } export function deactivate() { - + ContactRankingsCache.deactivate(); } diff --git a/packages/client-app/internal_packages/contact-rankings/lib/refreshing-json-cache.coffee b/packages/client-app/internal_packages/contact-rankings/lib/refreshing-json-cache.coffee index c72881f8b..80a48b6b5 100644 --- a/packages/client-app/internal_packages/contact-rankings/lib/refreshing-json-cache.coffee +++ b/packages/client-app/internal_packages/contact-rankings/lib/refreshing-json-cache.coffee @@ -4,7 +4,7 @@ _ = require 'underscore' class RefreshingJSONCache - constructor: ({@key, @version, @refreshInterval}) -> + constructor: ({@key, @version, @refreshInterval, @maxRefreshInterval}) -> @_timeoutId = null start: -> @@ -13,6 +13,8 @@ class RefreshingJSONCache # Look up existing data from db DatabaseStore.findJSONBlob(@key).then (json) => + if json? and json.refreshInterval + @refreshInterval = json.refreshInterval # Refresh immediately if json is missing or version is outdated. Otherwise, # compute next refresh time and schedule @@ -44,6 +46,7 @@ class RefreshingJSONCache version: @version time: Date.now() value: newValue + refreshInterval: @refreshInterval }) fetchData: (callback) => diff --git a/packages/client-sync/src/local-api/routes/contacts.js b/packages/client-sync/src/local-api/routes/contacts.js index f8cc38f34..6f14081bf 100644 --- a/packages/client-sync/src/local-api/routes/contacts.js +++ b/packages/client-sync/src/local-api/routes/contacts.js @@ -1,5 +1,26 @@ const Joi = require('joi'); const Serialization = require('../serialization'); +const moment = require('moment-timezone'); + +const LOOKBACK_TIME = moment.duration(2, 'years').asMilliseconds(); +const MIN_MESSAGE_WEIGHT = 0.01; + +const getMessageWeight = (message, now) => { + const timeDiff = now - message.date.getTime(); + const weight = 1.0 - (timeDiff / LOOKBACK_TIME); + return Math.max(weight, MIN_MESSAGE_WEIGHT); +}; + +const calculateContactScores = (messages, result) => { + const now = Date.now(); + for (const message of messages) { + const weight = getMessageWeight(message, now); + for (const recipient of message.getRecipients()) { + const email = recipient.email.toLowerCase(); + result[email] = result[email] ? (result[email] + weight) : weight; + } + } +}; module.exports = (server) => { server.route({ @@ -75,12 +96,61 @@ module.exports = (server) => { description: 'Returns contact rankings.', notes: 'Notes go here', tags: ['contacts'], - response: { - schema: Serialization.jsonSchema('Contact'), - }, }, - handler: (request, reply) => { - reply('{}'); + handler: async (request, reply) => { + const db = await request.getAccountDatabase() + const {Message, Label, Folder} = db; + + const result = {}; + let lastID = 0; + + while (true) { + const messages = await Message.findAll({ + attributes: ['rowid', 'id', 'to', 'cc', 'bcc', 'date'], + // We mark both Label and Folder as not required because we don't know + // whether the account uses folders or labels. + include: [ + { + model: Label, + attributes: ['id', 'role'], + where: {role: 'sent'}, + required: false, // This triggers a left outer join. + }, + { + model: Folder, + attributes: ['id', 'role'], + where: {role: 'sent'}, + required: false, // This triggers a left outer join. + }, + ], + where: { + 'isDraft': false, // Don't include unsent things. + + // Because we did a left outer join on the Folder and Label tables, + // we can get back records that have null for both the Folder and the + // Label (i.e. all things that aren't in the Sent folder). So we need + // to require that either the label ID or the folder ID is not null + // (i.e. that the message is in the Sent folder) + '$or': [ + {'$labels.id$': {$ne: null}}, + {'$folder.id$': {$ne: null}}, + ], + '$message.rowid$': {$gt: lastID}, + }, + // We don't use the `limit` option here because it causes sequelize + // to generate a subquery that doesn't have access to the joined + // labels/folders, causing a SQLite error to be thrown. + order: 'message.rowid ASC LIMIT 500', + }); + + if (messages.length === 0) { + break; + } + + calculateContactScores(messages, result); + lastID = Math.max(...messages.map(m => m.dataValues.rowid)); + } + reply(JSON.stringify(Object.entries(result))); }, }) }