mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-09 01:35:52 +08:00
Contact ranking now built into sync, remove separate cache
This commit is contained in:
parent
38c6ecea00
commit
ade70d32bd
9 changed files with 19 additions and 358 deletions
|
@ -1,112 +0,0 @@
|
|||
import _ from 'underscore'
|
||||
import moment from 'moment-timezone'
|
||||
import {
|
||||
IdentityStore,
|
||||
AccountStore,
|
||||
NylasAPI,
|
||||
NylasAPIRequest,
|
||||
FolderSyncProgressStore,
|
||||
} 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: 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 }
|
||||
if (!IdentityStore.identity()) { return }
|
||||
|
||||
const request = new NylasAPIRequest({
|
||||
api: NylasAPI,
|
||||
options: {
|
||||
accountId: this._accountId,
|
||||
path: "/contacts/rankings",
|
||||
},
|
||||
})
|
||||
|
||||
request.run()
|
||||
.then((json) => {
|
||||
if (!json || !(json instanceof Array)) return
|
||||
|
||||
// Convert rankings into the format needed for quick lookup
|
||||
const rankings = {}
|
||||
for (const [email, rank] of json) {
|
||||
rankings[email.toLowerCase()] = rank
|
||||
}
|
||||
callback(rankings)
|
||||
|
||||
this.refreshInterval = this._nextRefreshInterval();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(`Request for Contact Rankings failed for
|
||||
account ${this._accountId}. ${err}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 = async () => {
|
||||
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) {
|
||||
// Wait until the account has started syncing before trying to fetch
|
||||
// contact rankings
|
||||
await FolderSyncProgressStore.whenCategoryListSynced(newID)
|
||||
this.accountCaches[newID] = new ContactRankingsCache(newID);
|
||||
this.accountCaches[newID].start();
|
||||
}
|
||||
|
||||
for (const removedID of removedIDs) {
|
||||
if (this.accountCaches[removedID]) {
|
||||
this.accountCaches[removedID].end();
|
||||
this.accountCaches[removedID] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ContactRankingsCacheManager();
|
|
@ -1,10 +0,0 @@
|
|||
import ContactRankingsCache from './contact-rankings-cache'
|
||||
|
||||
|
||||
export function activate() {
|
||||
ContactRankingsCache.activate();
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
ContactRankingsCache.deactivate();
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
_ = require 'underscore'
|
||||
{NylasStore, DatabaseStore} = require 'nylas-exports'
|
||||
|
||||
|
||||
class RefreshingJSONCache
|
||||
|
||||
constructor: ({@key, @version, @refreshInterval, @maxRefreshInterval}) ->
|
||||
@_timeoutId = null
|
||||
|
||||
start: ->
|
||||
# Clear any scheduled actions
|
||||
@end()
|
||||
|
||||
# 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
|
||||
timeUntilRefresh = 0
|
||||
if json? and json.version is @version
|
||||
timeUntilRefresh = Math.max(0, @refreshInterval - (Date.now() - json.time))
|
||||
|
||||
@_timeoutId = setTimeout(@refresh, timeUntilRefresh)
|
||||
|
||||
reset: ->
|
||||
# Clear db value, turn off any scheduled actions
|
||||
DatabaseStore.inTransaction (t) => t.persistJSONBlob(@key, {})
|
||||
@end()
|
||||
|
||||
end: ->
|
||||
# Turn off any scheduled actions
|
||||
clearInterval(@_timeoutId) if @_timeoutId
|
||||
@_timeoutId = null
|
||||
|
||||
refresh: =>
|
||||
# Set up next refresh call
|
||||
clearTimeout(@_timeoutId) if @_timeoutId
|
||||
@_timeoutId = setTimeout(@refresh, @refreshInterval)
|
||||
|
||||
# Call fetch data function, save it to the database
|
||||
@fetchData (newValue) =>
|
||||
DatabaseStore.inTransaction (t) =>
|
||||
t.persistJSONBlob(@key, {
|
||||
version: @version
|
||||
time: Date.now()
|
||||
value: newValue
|
||||
refreshInterval: @refreshInterval
|
||||
})
|
||||
|
||||
fetchData: (callback) =>
|
||||
throw new Error("Subclasses should override this method.")
|
||||
|
||||
|
||||
|
||||
module.exports = RefreshingJSONCache
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"name": "deltas",
|
||||
"version": "0.1.0",
|
||||
"main": "./lib/main",
|
||||
"description": "Delta connection handling in Nylas Mail",
|
||||
"license": "GPL-3.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"nylas": "*"
|
||||
},
|
||||
"windowTypes": {
|
||||
"work": true
|
||||
}
|
||||
}
|
|
@ -5,7 +5,6 @@ Contact = require('../../src/flux/models/contact').default
|
|||
NylasAPI = require('../../src/flux/nylas-api').default
|
||||
NylasAPIRequest = require('../../src/flux/nylas-api-request').default
|
||||
ContactStore = require '../../src/flux/stores/contact-store'
|
||||
ContactRankingStore = require '../../src/flux/stores/contact-ranking-store'
|
||||
DatabaseStore = require('../../src/flux/stores/database-store').default
|
||||
AccountStore = require('../../src/flux/stores/account-store').default
|
||||
|
||||
|
@ -15,65 +14,23 @@ xdescribe "ContactStore", ->
|
|||
beforeEach ->
|
||||
spyOn(NylasEnv, "isMainWindow").andReturn true
|
||||
|
||||
@rankings = [
|
||||
["evanA@nylas.com", 10]
|
||||
["evanB@nylas.com", 1]
|
||||
["evanC@nylas.com", 0.1]
|
||||
]
|
||||
|
||||
spyOn(NylasAPIRequest.prototype, "run").andCallFake (options) =>
|
||||
if options.path is "/contacts/rankings"
|
||||
return Promise.resolve(@rankings)
|
||||
else
|
||||
throw new Error("Invalid request path!")
|
||||
|
||||
NylasEnv.testOrganizationUnit = "folder"
|
||||
ContactStore._contactCache = []
|
||||
ContactStore._fetchOffset = 0
|
||||
ContactStore._accountId = null
|
||||
ContactRankingStore.reset()
|
||||
|
||||
afterEach ->
|
||||
NylasEnv.testOrganizationUnit = null
|
||||
|
||||
describe "ranking contacts", ->
|
||||
beforeEach ->
|
||||
@accountId = TEST_ACCOUNT_ID
|
||||
@c1 = new Contact({name: "Evan A", email: "evanA@nylas.com", @accountId})
|
||||
@c2 = new Contact({name: "Evan B", email: "evanB@nylas.com", @accountId})
|
||||
@c3 = new Contact({name: "Evan C", email: "evanC@nylas.com", @accountId})
|
||||
@c4 = new Contact({name: "Ben", email: "ben@nylas.com"})
|
||||
@contacts = [@c3, @c1, @c2, @c4]
|
||||
|
||||
it "queries for, and sorts, contacts present in the rankings", ->
|
||||
spyOn(ContactRankingStore, 'valuesForAllAccounts').andReturn
|
||||
"evana@nylas.com": 10
|
||||
"evanb@nylas.com": 1
|
||||
"evanc@nylas.com": 0.1
|
||||
|
||||
spyOn(DatabaseStore, 'findAll').andCallFake =>
|
||||
return {background: => Promise.resolve([@c3, @c1, @c2, @c4])}
|
||||
|
||||
waitsForPromise =>
|
||||
ContactStore._updateRankedContactCache().then =>
|
||||
expect(ContactStore._rankedContacts).toEqual [@c1, @c2, @c3, @c4]
|
||||
|
||||
describe "when ContactRankings change", ->
|
||||
it "re-generates the ranked contact cache", ->
|
||||
spyOn(ContactStore, "_updateRankedContactCache")
|
||||
ContactRankingStore.trigger()
|
||||
expect(ContactStore._updateRankedContactCache).toHaveBeenCalled()
|
||||
|
||||
describe "when searching for a contact", ->
|
||||
beforeEach ->
|
||||
@c1 = new Contact(name: "", email: "1test@nylas.com")
|
||||
@c2 = new Contact(name: "First", email: "2test@nylas.com")
|
||||
@c3 = new Contact(name: "First Last", email: "3test@nylas.com")
|
||||
@c4 = new Contact(name: "Fit", email: "fit@nylas.com")
|
||||
@c5 = new Contact(name: "Fins", email: "fins@nylas.com")
|
||||
@c6 = new Contact(name: "Fill", email: "fill@nylas.com")
|
||||
@c7 = new Contact(name: "Fin", email: "fin@nylas.com")
|
||||
ContactStore._rankedContacts = [@c1,@c2,@c3,@c4,@c5,@c6,@c7]
|
||||
@c1 = new Contact(name: "", email: "1test@nylas.com", refs: 7)
|
||||
@c2 = new Contact(name: "First", email: "2test@nylas.com", refs: 6)
|
||||
@c3 = new Contact(name: "First Last", email: "3test@nylas.com", refs: 5)
|
||||
@c4 = new Contact(name: "Fit", email: "fit@nylas.com", refs: 4)
|
||||
@c5 = new Contact(name: "Fins", email: "fins@nylas.com", refs: 3)
|
||||
@c6 = new Contact(name: "Fill", email: "fill@nylas.com", refs: 2)
|
||||
@c7 = new Contact(name: "Fin", email: "fin@nylas.com", refs: 1)
|
||||
|
||||
it "can find by first name", ->
|
||||
waitsForPromise =>
|
||||
|
|
|
@ -54,6 +54,11 @@ export default class Contact extends Model {
|
|||
modelKey: 'email',
|
||||
}),
|
||||
|
||||
refs: Attributes.Number({
|
||||
queryable: true,
|
||||
modelKey: 'refs',
|
||||
}),
|
||||
|
||||
// Contains the raw thirdPartyData (keyed by the vendor name) about
|
||||
// this contact.
|
||||
thirdPartyData: Attributes.Object({
|
||||
|
|
|
@ -42,7 +42,7 @@ Section: Models
|
|||
class Thread extends ModelWithMetadata {
|
||||
|
||||
static attributes = _.extend({}, ModelWithMetadata.attributes, {
|
||||
snippet: Attributes.String({
|
||||
snippet: Attributes.String({ // TODO NONFUNCTIONAL
|
||||
modelKey: 'snippet',
|
||||
}),
|
||||
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
Rx = require 'rx-lite'
|
||||
NylasStore = require 'nylas-store'
|
||||
DatabaseStore = require('./database-store').default
|
||||
AccountStore = require('./account-store').default
|
||||
|
||||
class ContactRankingStore extends NylasStore
|
||||
|
||||
constructor: ->
|
||||
@_values = {}
|
||||
@_valuesAllAccounts = null
|
||||
@_disposables = {}
|
||||
@listenTo AccountStore, @_onAccountsChanged
|
||||
@_registerObservables(AccountStore.accounts())
|
||||
|
||||
_registerObservables: (accounts) =>
|
||||
nextDisposables = {}
|
||||
|
||||
# Create new observables, reusing existing ones when possible
|
||||
# (so they don't trigger with initial state unnecesarily)
|
||||
for acct in accounts
|
||||
if @_disposables[acct.id]
|
||||
nextDisposables[acct.id] = @_disposables[acct.id]
|
||||
delete @_disposables[acct.id]
|
||||
else
|
||||
query = DatabaseStore.findJSONBlob("ContactRankingsFor#{acct.id}")
|
||||
callback = @_onRankingsChanged.bind(@, acct.id)
|
||||
nextDisposables[acct.id] = Rx.Observable.fromQuery(query).subscribe(callback)
|
||||
|
||||
# Remove unused observables in the old set
|
||||
for key, disposable of @_disposables
|
||||
disposable.dispose()
|
||||
|
||||
@_disposables = nextDisposables
|
||||
|
||||
_onRankingsChanged: (accountId, json) =>
|
||||
@_values[accountId] = if json then json.value else null
|
||||
@_valuesAllAccounts = null
|
||||
@trigger()
|
||||
|
||||
_onAccountsChanged: =>
|
||||
@_registerObservables(AccountStore.accounts())
|
||||
|
||||
valueFor: (accountId) =>
|
||||
@_values[accountId]
|
||||
|
||||
valuesForAllAccounts: =>
|
||||
unless @_valuesAllAccounts
|
||||
combined = {}
|
||||
for acctId, values of @_values
|
||||
for email, score of values
|
||||
if combined[email]
|
||||
combined[email] = Math.max(combined[email], score)
|
||||
else
|
||||
combined[email] = score
|
||||
@_valuesAllAccounts = combined
|
||||
|
||||
return @_valuesAllAccounts
|
||||
|
||||
reset: =>
|
||||
@_valuesAllAccounts = null
|
||||
@_values = {}
|
||||
|
||||
module.exports = new ContactRankingStore()
|
|
@ -10,7 +10,6 @@ RegExpUtils = require '../../regexp-utils'
|
|||
DatabaseStore = require('./database-store').default
|
||||
AccountStore = require('./account-store').default
|
||||
ComponentRegistry = require('../../registries/component-registry')
|
||||
ContactRankingStore = require './contact-ranking-store'
|
||||
_ = require 'underscore'
|
||||
|
||||
WindowBridge = require '../../window-bridge'
|
||||
|
@ -24,11 +23,6 @@ Section: Stores
|
|||
###
|
||||
class ContactStore extends NylasStore
|
||||
|
||||
constructor: ->
|
||||
@_rankedContacts = []
|
||||
@listenTo ContactRankingStore, => @_updateRankedContactCache()
|
||||
@_updateRankedContactCache()
|
||||
|
||||
# Public: Search the user's contact list for the given search term.
|
||||
# This method compares the `search` string against each Contact's
|
||||
# `name` and `email`.
|
||||
|
@ -47,19 +41,13 @@ class ContactStore extends NylasStore
|
|||
|
||||
search = search.toLowerCase()
|
||||
accountCount = AccountStore.accounts().length
|
||||
extensions = ComponentRegistry.findComponentsMatching({
|
||||
role: "ContactSearchResults"
|
||||
})
|
||||
|
||||
if not search or search.length is 0
|
||||
return Promise.resolve([])
|
||||
|
||||
# Search ranked contacts which are stored in order in memory
|
||||
results = []
|
||||
for contact in @_rankedContacts
|
||||
if (contact.email.toLowerCase().indexOf(search) isnt -1 or
|
||||
contact.name.toLowerCase().indexOf(search) isnt -1)
|
||||
results.push(contact)
|
||||
if results.length is limit
|
||||
break
|
||||
|
||||
# If we haven't found enough items in memory, query for more from the
|
||||
# database. Note that we ask for LIMIT * accountCount because we want to
|
||||
# return contacts with distinct email addresses, and the same contact
|
||||
|
@ -68,18 +56,10 @@ class ContactStore extends NylasStore
|
|||
query = DatabaseStore.findAll(Contact)
|
||||
.search(search)
|
||||
.limit(limit * accountCount)
|
||||
query.then (queryResults) =>
|
||||
existingEmails = _.pluck(results, 'email')
|
||||
|
||||
.order(Contact.attributes.refs.descending())
|
||||
query.then (results) =>
|
||||
# remove query results that were already found in ranked contacts
|
||||
queryResults = _.reject queryResults, (c) -> c.email in existingEmails
|
||||
queryResults = @_distinctByEmail(queryResults)
|
||||
|
||||
results = results.concat(queryResults)
|
||||
|
||||
extensions = ComponentRegistry.findComponentsMatching({
|
||||
role: "ContactSearchResults"
|
||||
})
|
||||
results = @_distinctByEmail(results)
|
||||
return Promise.each extensions, (ext) =>
|
||||
return ext.findAdditionalContacts(search, results).then (contacts) =>
|
||||
results = contacts
|
||||
|
@ -141,26 +121,6 @@ class ContactStore extends NylasStore
|
|||
return match if match and match.email is contact.email
|
||||
return contact
|
||||
|
||||
_updateRankedContactCache: =>
|
||||
rankings = ContactRankingStore.valuesForAllAccounts()
|
||||
emails = Object.keys(rankings)
|
||||
|
||||
if emails.length is 0
|
||||
@_rankedContacts = []
|
||||
return
|
||||
|
||||
# Sort the emails by rank and then clip to 400 so that our ranked cache
|
||||
# has a bounded size.
|
||||
emails = _.sortBy emails, (email) ->
|
||||
(- (rankings[email.toLowerCase()] ? 0) / 1)
|
||||
emails.length = 400 if emails.length > 400
|
||||
|
||||
DatabaseStore.findAll(Contact, {email: emails}).background().then (contacts) =>
|
||||
contacts = @_distinctByEmail(contacts)
|
||||
for contact in contacts
|
||||
contact._rank = (- (rankings[contact.email.toLowerCase()] ? 0) / 1)
|
||||
@_rankedContacts = _.sortBy contacts, (contact) -> contact._rank
|
||||
|
||||
_distinctByEmail: (contacts) =>
|
||||
# remove query results that are duplicates, prefering ones that have names
|
||||
uniq = {}
|
||||
|
@ -172,9 +132,4 @@ class ContactStore extends NylasStore
|
|||
uniq[key] = contact
|
||||
_.values(uniq)
|
||||
|
||||
_resetCache: =>
|
||||
@_rankedContacts = []
|
||||
ContactRankingStore.reset()
|
||||
@trigger(@)
|
||||
|
||||
module.exports = new ContactStore()
|
||||
|
|
Loading…
Reference in a new issue