mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-06 20:54:26 +08:00
I’ve 👏 had 👏 it 👏 with 👏 CoffeeScript
This commit is contained in:
parent
f2104324be
commit
a8c6095d15
43 changed files with 2465 additions and 2580 deletions
|
@ -1,52 +0,0 @@
|
|||
{ComponentRegistry,
|
||||
WorkspaceStore,
|
||||
Actions} = require "nylas-exports"
|
||||
{RetinaImg} = require 'nylas-component-kit'
|
||||
React = require "react"
|
||||
_ = require "underscore"
|
||||
|
||||
class ModeToggle extends React.Component
|
||||
@displayName: 'ModeToggle'
|
||||
|
||||
constructor: (@props) ->
|
||||
@column = WorkspaceStore.Location.MessageListSidebar
|
||||
@state = @_getStateFromStores()
|
||||
|
||||
componentDidMount: =>
|
||||
@_unsubscriber = WorkspaceStore.listen(@_onStateChanged)
|
||||
@_mounted = true
|
||||
|
||||
componentWillUnmount: =>
|
||||
@_mounted = false
|
||||
@_unsubscriber?()
|
||||
|
||||
render: =>
|
||||
<button
|
||||
className="btn btn-toolbar mode-toggle mode-#{@state.hidden}"
|
||||
style={order:500}
|
||||
title={if @state.hidden then "Show sidebar" else "Hide sidebar"}
|
||||
onClick={@_onToggleMode}>
|
||||
<RetinaImg
|
||||
name="toolbar-person-sidebar.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
|
||||
_onStateChanged: =>
|
||||
# We need to keep track of this because our parent unmounts us in the same
|
||||
# event listener cycle that we receive the event in. ie:
|
||||
#
|
||||
# for listener in listeners
|
||||
# # 1. workspaceView remove left column
|
||||
# # ---- Mode toggle unmounts, listeners array mutated in place
|
||||
# # 2. ModeToggle update
|
||||
return unless @_mounted
|
||||
@setState(@_getStateFromStores())
|
||||
|
||||
_getStateFromStores: =>
|
||||
{hidden: WorkspaceStore.isLocationHidden(@column)}
|
||||
|
||||
_onToggleMode: =>
|
||||
Actions.toggleWorkspaceLocationHidden(@column)
|
||||
|
||||
|
||||
module.exports = ModeToggle
|
|
@ -0,0 +1,68 @@
|
|||
import {ComponentRegistry,
|
||||
WorkspaceStore,
|
||||
Actions} from "nylas-exports"
|
||||
import {RetinaImg} from 'nylas-component-kit'
|
||||
import React from "react"
|
||||
import _ from "underscore"
|
||||
|
||||
export default class ModeToggle extends React.Component {
|
||||
static displayName = 'ModeToggle';
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.column = WorkspaceStore.Location.MessageListSidebar
|
||||
this.state = this._getStateFromStores()
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._unsubscriber = WorkspaceStore.listen(this._onStateChanged)
|
||||
this._mounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._mounted = false
|
||||
if (this._unsubscriber) {
|
||||
this._unsubscriber();
|
||||
}
|
||||
}
|
||||
|
||||
_getStateFromStores() {
|
||||
return {
|
||||
hidden: WorkspaceStore.isLocationHidden(this.column),
|
||||
};
|
||||
}
|
||||
|
||||
_onStateChanged = () => {
|
||||
// We need to keep track of this because our parent unmounts us in the same
|
||||
// event listener cycle that we receive the event in. ie:
|
||||
//
|
||||
// for listener in listeners
|
||||
// # 1. workspaceView remove left column
|
||||
// # ---- Mode toggle unmounts, listeners array mutated in place
|
||||
// # 2. ModeToggle update
|
||||
if (!this._mounted) {
|
||||
return;
|
||||
}
|
||||
this.setState(this._getStateFromStores());
|
||||
}
|
||||
|
||||
_onToggleMode = () => {
|
||||
Actions.toggleWorkspaceLocationHidden(this.column)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<button
|
||||
className="btn btn-toolbar mode-toggle mode-#{this.state.hidden}"
|
||||
style={{order: 500}}
|
||||
title={this.state.hidden ? "Show sidebar" : "Hide sidebar"}
|
||||
onClick={this._onToggleMode}
|
||||
>
|
||||
<RetinaImg
|
||||
name="toolbar-person-sidebar.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
MultiselectListInteractionHandler = require '../../src/components/multiselect-list-interaction-handler'
|
||||
WorkspaceStore = require '../../src/flux/stores/workspace-store'
|
||||
FocusedContentStore = require '../../src/flux/stores/focused-content-store'
|
||||
FocusedContentStore = require('../../src/flux/stores/focused-content-store').default
|
||||
Thread = require('../../src/flux/models/thread').default
|
||||
Actions = require('../../src/flux/actions').default
|
||||
_ = require 'underscore'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
MultiselectSplitInteractionHandler = require '../../src/components/multiselect-split-interaction-handler'
|
||||
WorkspaceStore = require '../../src/flux/stores/workspace-store'
|
||||
FocusedContentStore = require '../../src/flux/stores/focused-content-store'
|
||||
FocusedContentStore = require('../../src/flux/stores/focused-content-store').default
|
||||
Thread = require('../../src/flux/models/thread').default
|
||||
Actions = require('../../src/flux/actions').default
|
||||
_ = require 'underscore'
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
React = require 'react'
|
||||
ComponentRegistry = require '../../src/registries/component-registry'
|
||||
ComponentRegistry = require('../../src/registries/component-registry').default
|
||||
|
||||
class TestComponent extends React.Component
|
||||
@displayName: 'TestComponent'
|
||||
|
|
|
@ -1,289 +0,0 @@
|
|||
# This is modied from https://github.com/mko/emailreplyparser, which is a
|
||||
# JS port of GitHub's Ruby https://github.com/github/email_reply_parser
|
||||
|
||||
fs = require('fs')
|
||||
path = require 'path'
|
||||
_ = require('underscore')
|
||||
QuotedPlainTextParser = require('../../src/services/quoted-plain-text-transformer')
|
||||
|
||||
getParsedEmail = (name, format="plain") ->
|
||||
data = getRawEmail(name, format)
|
||||
reply = QuotedPlainTextParser.parse data, format
|
||||
reply._setHiddenState()
|
||||
return reply
|
||||
|
||||
getRawEmail = (name, format="plain") ->
|
||||
emailPath = path.resolve(__dirname, '..', 'fixtures', 'emails', "#{name}.txt")
|
||||
return fs.readFileSync(emailPath, "utf8")
|
||||
|
||||
deepEqual = (expected=[], test=[]) ->
|
||||
for toExpect, i in expected
|
||||
expect(test[i]).toBe toExpect
|
||||
|
||||
describe "QuotedPlainTextParser", ->
|
||||
it "reads_simple_body", ->
|
||||
reply = getParsedEmail('email_1_1')
|
||||
expect(reply.fragments.length).toBe 3
|
||||
deepEqual [
|
||||
false
|
||||
false
|
||||
false
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.quoted
|
||||
)
|
||||
deepEqual [
|
||||
false
|
||||
true
|
||||
true
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.signature
|
||||
)
|
||||
deepEqual [
|
||||
false
|
||||
true
|
||||
true
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.hidden
|
||||
)
|
||||
expect(reply.fragments[0].to_s()).toEqual 'Hi folks\n\nWhat is the best way to clear a Riak bucket of all key, values after\nrunning a test?\nI am currently using the Java HTTP API.'
|
||||
expect(reply.fragments[1].to_s()).toEqual '-Abhishek Kona'
|
||||
|
||||
it "reads_top_post", ->
|
||||
reply = getParsedEmail('email_1_3')
|
||||
expect(reply.fragments.length).toEqual 5
|
||||
|
||||
deepEqual [
|
||||
false
|
||||
false
|
||||
true
|
||||
false
|
||||
false
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.quoted
|
||||
)
|
||||
deepEqual [
|
||||
false
|
||||
true
|
||||
true
|
||||
true
|
||||
true
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.hidden
|
||||
)
|
||||
deepEqual [
|
||||
false
|
||||
true
|
||||
false
|
||||
false
|
||||
true
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.signature
|
||||
)
|
||||
expect(new RegExp('^Oh thanks.\n\nHaving').test(reply.fragments[0].to_s())).toBe true
|
||||
expect(new RegExp('^-A').test(reply.fragments[1].to_s())).toBe true
|
||||
expect(/^On [^\:]+\:/m.test(reply.fragments[2].to_s())).toBe true
|
||||
expect(new RegExp('^_').test(reply.fragments[4].to_s())).toBe true
|
||||
|
||||
it "reads_bottom_post", ->
|
||||
reply = getParsedEmail('email_1_2')
|
||||
expect(reply.fragments.length).toEqual 6
|
||||
deepEqual [
|
||||
false
|
||||
true
|
||||
false
|
||||
true
|
||||
false
|
||||
false
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.quoted
|
||||
)
|
||||
deepEqual [
|
||||
false
|
||||
false
|
||||
false
|
||||
false
|
||||
false
|
||||
true
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.signature
|
||||
)
|
||||
deepEqual [
|
||||
false
|
||||
false
|
||||
false
|
||||
true
|
||||
true
|
||||
true
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.hidden
|
||||
)
|
||||
expect(reply.fragments[0].to_s()).toEqual 'Hi,'
|
||||
expect(new RegExp('^On [^:]+:').test(reply.fragments[1].to_s())).toBe true
|
||||
expect(/^You can list/m.test(reply.fragments[2].to_s())).toBe true
|
||||
expect(/^> /m.test(reply.fragments[3].to_s())).toBe true
|
||||
expect(new RegExp('^_').test(reply.fragments[5].to_s())).toBe true
|
||||
|
||||
it "reads_inline_replies", ->
|
||||
reply = getParsedEmail('email_1_8')
|
||||
expect(reply.fragments.length).toEqual 7
|
||||
deepEqual [
|
||||
true
|
||||
false
|
||||
true
|
||||
false
|
||||
true
|
||||
false
|
||||
false
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.quoted
|
||||
)
|
||||
deepEqual [
|
||||
false
|
||||
false
|
||||
false
|
||||
false
|
||||
false
|
||||
false
|
||||
true
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.signature
|
||||
)
|
||||
deepEqual [
|
||||
false
|
||||
false
|
||||
false
|
||||
false
|
||||
true
|
||||
true
|
||||
true
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.hidden
|
||||
)
|
||||
expect(new RegExp('^On [^:]+:').test(reply.fragments[0].to_s())).toBe true
|
||||
expect(/^I will reply/m.test(reply.fragments[1].to_s())).toBe true
|
||||
expect(/^> /m.test(reply.fragments[2].to_s())).toBe true
|
||||
expect(/^and under this./m.test(reply.fragments[3].to_s())).toBe true
|
||||
expect(/^> /m.test(reply.fragments[4].to_s())).toBe true
|
||||
expect(reply.fragments[5].to_s().trim()).toEqual ''
|
||||
expect(new RegExp('^-').test(reply.fragments[6].to_s())).toBe true
|
||||
|
||||
it "recognizes_date_string_above_quote", ->
|
||||
reply = getParsedEmail('email_1_4')
|
||||
expect(/^Awesome/.test(reply.fragments[0].to_s())).toBe true
|
||||
expect(/^On/m.test(reply.fragments[1].to_s())).toBe true
|
||||
expect(/Loader/m.test(reply.fragments[1].to_s())).toBe true
|
||||
|
||||
it "a_complex_body_with_only_one_fragment", ->
|
||||
reply = getParsedEmail('email_1_5')
|
||||
expect(reply.fragments.length).toEqual 1
|
||||
|
||||
it "reads_email_with_correct_signature", ->
|
||||
reply = getParsedEmail('correct_sig')
|
||||
expect(reply.fragments.length).toEqual 2
|
||||
deepEqual [
|
||||
false
|
||||
false
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.quoted
|
||||
)
|
||||
deepEqual [
|
||||
false
|
||||
true
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.signature
|
||||
)
|
||||
deepEqual [
|
||||
false
|
||||
true
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.hidden
|
||||
)
|
||||
expect(new RegExp('^-- \nrick').test(reply.fragments[1].to_s())).toBe true
|
||||
|
||||
it "deals_with_multiline_reply_headers", ->
|
||||
reply = getParsedEmail('email_1_6')
|
||||
expect(new RegExp('^I get').test(reply.fragments[0].to_s())).toBe true
|
||||
expect(/^On/m.test(reply.fragments[1].to_s())).toBe true
|
||||
expect(new RegExp('Was this').test(reply.fragments[1].to_s())).toBe true
|
||||
|
||||
it "does_not_modify_input_string", ->
|
||||
original = 'The Quick Brown Fox Jumps Over The Lazy Dog'
|
||||
QuotedPlainTextParser.parse original
|
||||
expect(original).toEqual 'The Quick Brown Fox Jumps Over The Lazy Dog'
|
||||
|
||||
it "returns_only_the_visible_fragments_as_a_string", ->
|
||||
reply = getParsedEmail('email_2_1')
|
||||
|
||||
String::rtrim = ->
|
||||
@replace /\s*$/g, ''
|
||||
|
||||
fragments = _.select(reply.fragments, (f) ->
|
||||
!f.hidden
|
||||
)
|
||||
fragments = _.map(fragments, (f) ->
|
||||
f.to_s()
|
||||
)
|
||||
expect(reply.visibleText(format: "plain")).toEqual fragments.join('\n').rtrim()
|
||||
|
||||
it "parse_out_just_top_for_outlook_reply", ->
|
||||
body = getRawEmail('email_2_1')
|
||||
expect(QuotedPlainTextParser.visibleText(body, format: "plain")).toEqual 'Outlook with a reply'
|
||||
|
||||
it "parse_out_sent_from_iPhone", ->
|
||||
body = getRawEmail('email_iPhone')
|
||||
expect(QuotedPlainTextParser.visibleText(body, format: "plain")).toEqual 'Here is another email'
|
||||
|
||||
it "parse_out_sent_from_BlackBerry", ->
|
||||
body = getRawEmail('email_BlackBerry')
|
||||
expect(QuotedPlainTextParser.visibleText(body, format: "plain")).toEqual 'Here is another email'
|
||||
|
||||
it "parse_out_send_from_multiword_mobile_device", ->
|
||||
body = getRawEmail('email_multi_word_sent_from_my_mobile_device')
|
||||
expect(QuotedPlainTextParser.visibleText(body, format: "plain")).toEqual 'Here is another email'
|
||||
|
||||
it "do_not_parse_out_send_from_in_regular_sentence", ->
|
||||
body = getRawEmail('email_sent_from_my_not_signature')
|
||||
expect(QuotedPlainTextParser.visibleText(body, format: "plain")).toEqual 'Here is another email\n\nSent from my desk, is much easier then my mobile phone.'
|
||||
|
||||
it "retains_bullets", ->
|
||||
body = getRawEmail('email_bullets')
|
||||
expect(QuotedPlainTextParser.visibleText(body, format: "plain")).toEqual 'test 2 this should list second\n\nand have spaces\n\nand retain this formatting\n\n\n - how about bullets\n - and another'
|
||||
|
||||
it "visibleText", ->
|
||||
body = getRawEmail('email_1_2')
|
||||
expect(QuotedPlainTextParser.visibleText(body, format: "plain")).toEqual QuotedPlainTextParser.parse(body).visibleText(format: "plain")
|
||||
|
||||
it "correctly_reads_top_post_when_line_starts_with_On", ->
|
||||
reply = getParsedEmail('email_1_7')
|
||||
expect(reply.fragments.length).toEqual 5
|
||||
deepEqual [
|
||||
false
|
||||
false
|
||||
true
|
||||
false
|
||||
false
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.quoted
|
||||
)
|
||||
deepEqual [
|
||||
false
|
||||
true
|
||||
true
|
||||
true
|
||||
true
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.hidden
|
||||
)
|
||||
deepEqual [
|
||||
false
|
||||
true
|
||||
false
|
||||
false
|
||||
true
|
||||
], _.map(reply.fragments, (f) ->
|
||||
f.signature
|
||||
)
|
||||
expect(new RegExp('^Oh thanks.\n\nOn the').test(reply.fragments[0].to_s())).toBe true
|
||||
expect(new RegExp('^-A').test(reply.fragments[1].to_s())).toBe true
|
||||
expect(/^On [^\:]+\:/m.test(reply.fragments[2].to_s())).toBe true
|
||||
expect(new RegExp('^_').test(reply.fragments[4].to_s())).toBe true
|
|
@ -1,6 +1,6 @@
|
|||
_ = require 'underscore'
|
||||
Thread = require('../../src/flux/models/thread').default
|
||||
FocusedContentStore = require '../../src/flux/stores/focused-content-store'
|
||||
FocusedContentStore = require('../../src/flux/stores/focused-content-store').default
|
||||
Actions = require('../../src/flux/actions').default
|
||||
|
||||
testThread = new Thread(id: '123', accountId: TEST_ACCOUNT_ID)
|
||||
|
|
|
@ -2,9 +2,9 @@ _ = require 'underscore'
|
|||
|
||||
Actions = require('../../src/flux/actions').default
|
||||
Folder = require('../../src/flux/models/folder').default
|
||||
MailboxPerspective = require '../../src/mailbox-perspective'
|
||||
MailboxPerspective = require('../../src/mailbox-perspective').default
|
||||
|
||||
CategoryStore = require '../../src/flux/stores/category-store'
|
||||
CategoryStore = require('../../src/flux/stores/category-store').default
|
||||
AccountStore = require('../../src/flux/stores/account-store').default
|
||||
FocusedPerspectiveStore = require('../../src/flux/stores/focused-perspective-store').default
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ _ = require 'underscore'
|
|||
Thread = require('../../src/flux/models/thread').default
|
||||
Label = require('../../src/flux/models/label').default
|
||||
Message = require('../../src/flux/models/message').default
|
||||
FocusedContentStore = require '../../src/flux/stores/focused-content-store'
|
||||
FocusedContentStore = require('../../src/flux/stores/focused-content-store').default
|
||||
FocusedPerspectiveStore = require('../../src/flux/stores/focused-perspective-store').default
|
||||
MessageStore = require '../../src/flux/stores/message-store'
|
||||
DatabaseStore = require('../../src/flux/stores/database-store').default
|
||||
|
|
|
@ -1,130 +0,0 @@
|
|||
ThreadDragImage = document.createElement("img")
|
||||
ThreadDragImage.src = """data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAAJc0lEQVR42u2dSW8URxTHsY0XtgQTspySKIryBRCgALZIIPkA4RL5kkMuufAVcs2VIxKCAycuCBIBYjE7GGOx72bfwg628bAYA536VfpFL+Xume6ebnvkqZb+IswMXfX+v6rXr6pnOlOCIJjiNXHyJngAHoCXB+ABeHkAHoCXB+ABeHkAHoCXB+ABeHkAHoCXB+ABeHkAdQQg5dHg9T8lPrICKNd4Yx0rNZC0AMqZ3WQ0tc7VVAFIVQDGGN/e3v7lvHnzlnZ2di6LUkdHx/LJrLi458+fv3Tu3LlfxYDIBGCM+Q0NDQtWrVr167Nnz3rM518F/pBjZHBwsG/NmjW/NTY2LqwEIQkA13ym2WddXV0/PX/+fMD7HX2USqXhlStXdhmvPlepaQyENADEfLTgxo0bf718+TJ48eJF8P79e++4OvAEb+7du9eNV8q3xrQA3IutXGgXmgbuvXv3LhgeHg6GhoY8BHPggckKVnjz5s2bIbyKuED/ByENADG/2ejb0dHREo28ffs2GBgYCMy1wDZarwex4wFe4Al/BwheGbU4EFIBEGpifksIYJiGzJ/ByMhI8Pjx4+Dhw4f27/V2EDOxP3r0yHrB3wVCCKA19M6FkBiAjP6W8GSLzPSyM0AAvH79Orh//35w9+5dpt6ETH+t8TTf5HorPBAAagYsMmqLmQVlAbjpR0Y/J1ssACCN4TT+6tWr4M6dO8GtW7dsR6KMyVO0X0lFtc1B3MR6+/ZtGzse8JqTghYbTQu9a3bTUFIAkn5aw5Mt0QBkFtAJKoCbN28G165dsx0q0mTarqSigBDv9evXA1MN2piJndciACwxmu6koVQAmiIAdABADNKzgPLL1MAWwOXLl23Hko7WJNLmAr6S4kBkFbEQ55UrV4KrV6/aWIlZRr9OPyGADqMZcdeBSgDc/N8W0uygCtKjlIbpgMwCylM6eenSJdvBaiGkNb4IEMRAfMTU399vY9Sj3zU/+NdIARB5HcgMQM8ADUBmgawPLl68GJw7d86OlKRpo5zZtJFVLow0ou/ERSwXLlywsRGjjH4NIGIGzMwTwIyoGSBmCQRGBguSwcFB2+nTp09bCFlMz8N8DSALBOIhhrNnz9qYiM0d/Tr3qxnQWQSATncGuAD0LGCBcurUqeD48eO242nTSLXGx0FImrqIg74TA7HI6AeAzv06xakZIAAiK6G0AKa5APQM0BWRhsCIefr0qQ3i6NGjNoCkEPI0Py0EMb+vry84duyYjYFYonK/TlW6WhoXAOUgSCpiif7kyRMbTE9Pjw0kicGcJ28lhUS/6St9pu/EwGuk0iTmFw4gqlLRpuqyFMOZvmxZHDlyJDh48KANaDwMzwKB/h46dMgCoM/0XY9+ST1R6UenIQdAa1YAzUkAuBDiUhF7JocPHw727dtnAxtP45OAoZ/79++3AOirpB658ErVU878wgGYDpTKVTEagKwNCADDmc4PHjwIDhw4EOzZs8cGWAsQ6AOzkj4BgD7SV7nwSuqJAhBXuk4IgLhZoCEwrdm8I9Du7u5xgUA/xLw48+nL3r17bd8k9Yj5uuavZP6EA4iDQCBSmgoEUtGuXbvsaNNGiWTU5SHO5Z6PNukPfWD0u+a7C64k5hcOwHSmlLSMdFORvh6wj85WLoHv2LHDBq4h5Gl+FATaoh+0zeinL/SJ16TqkZLTBZCkjJ1wAEkhcB9h9+7dwfbt28dAKEJSRtI+bTL66UNe5tcUgDQQGIUbNmywaaBICJybNmlr586duZtfOADTsVLaFagGINcDvUij6mC5v27dumDjxo22BNQpIy9xTtrbtGlTsHbt2uDEiRORFQ99dAGkXXHXFIBKENg5ZX3An+TkLVu25A5BzN+6datNPWwr0+b58+dzNb9wAKaDpWrKQQEgqYibGyz5ucvEhZC7ahiUJwQxn3Nu27bNtkFb3NWibb3H7wLIuq6oSQAuBIwgDWAGo5BczL1ljMkLgms+56YNyfu0ffLkSTsA8jC/5gEIBG5qs8+OEe71wIXAZwRaGvFvqKxIO9p82pB6n7aBfObMGft+teYXDsBM01K1lQjfJsB8Atc3cPR2RbUQKpnv7vPwGhCYlXlUWjULAPMJFKMxiQsf+TdPCGnMp23Z6+F17nxVC6FmAWjz9T5RGgisE8pB4D1ApTFfLr70KQ8IhQIwHS5luRiS86PMdwFQDnI9wAjZMxIIrFqp4SlXZbGkxWu8t3nzZgtMzJc9Hlls0YYLIA5C1gt/TQEoZ34SCDITMISKBQjMBm7wcC1Bvb29dtTzHpUVn3VHfjnz84RQUwCSmJ8EgmzekcYY2Syg2EPCcMTs4FsYvMdn2GJIa35eEGoGQBrzK0HgHFRN8kVgTOb8mIPku5q8x2f4LP8mrfl5QCgUgAmglKQMlDqfEZjUfA1BQGCcXJhlNmAuoxuj5RvK/Dev8Z6MermfK3v7Sc2PgiDrhKQl8IQCqMb8KAh6NggIRjfn15IRLz+YcKudNOZXA2FCAeRhfjkImIq5AkNLXpdRX635WSHkCWBqGgB5mu9C0CAERpTkfW18NeZngVAoABNcKaqDXKTY08d8veOZl1wQ5ZSn8S4EWTEz0NjAi/tcYV/MigJQtPnlYESpyLaTQCgCQFscgPE2vxZUCUKRADo0gHo0PwkEB0C+P9AwDf5d7+ZXgmCKgcHCfiFjVoV/Uu3Uu/lxEKiO+vv7u0MA07MCiPuR3hfLly//ube394k3PxpCX1/f0IoVK34xXn0d8SvJpmp/psqUWrJ69erfzYLnvml8tN7NVxo1C8BH69ev/yPM/zOcNcDULL8Tdn+oDdFZId3vjL43Wmb0g9KPdSId87LQCzz5JvRIp59UP9SeEvOgDj0LaOBDo3ajj4zmGn0S6tM6kcT7cehBe+jJLGf0t1TzrAj3YR0yC2YqCLPDxueEHREgk1kS55ww9tnK/JkRoz8TgLhZIBBkJnwQNq5h1INmK+M/UCNfzI97UkpqAHEQpikQMiNmKSiTXbPUiBfjp5UxPxWAuEeW6XSkQQgMAVIvmh5hvJt2Mj2yLO6hfS4EASEw2hwok1U61lZlfCXzUwGYEvOwVv2g1mallhi1ThLFxac9mFom7aR+bGU5CO6McNU8yRX39NymJObn/ejiRqfxelSlZ0n7h3dPwIO7c314t398/Xg9vt7L/x80PAAvD8AD8PIAPAAvD8AD8PIAPAAvD8AD8CpO/wAnnXiPa3zSAAAAAABJRU5ErkJggg=="""
|
||||
|
||||
DragCanvas = document.createElement("canvas")
|
||||
DragCanvas.style.position = "absolute"
|
||||
DragCanvas.style.zIndex = 0
|
||||
document.body.appendChild(DragCanvas)
|
||||
|
||||
PercentLoadedCache = {}
|
||||
PercentLoadedCanvas = document.createElement("canvas")
|
||||
PercentLoadedCanvas.style.position = "absolute"
|
||||
PercentLoadedCanvas.style.zIndex = 0
|
||||
document.body.appendChild(PercentLoadedCanvas)
|
||||
|
||||
SystemTrayCanvas = document.createElement("canvas")
|
||||
|
||||
CanvasUtils =
|
||||
roundRect: (ctx, x, y, width, height, radius = 5, fill, stroke = true) ->
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + radius, y)
|
||||
ctx.lineTo(x + width - radius, y)
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
|
||||
ctx.lineTo(x + width, y + height - radius)
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
|
||||
ctx.lineTo(x + radius, y + height)
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius)
|
||||
ctx.lineTo(x, y + radius)
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y)
|
||||
ctx.closePath()
|
||||
ctx.stroke() if stroke
|
||||
ctx.fill() if fill
|
||||
|
||||
dataURIForLoadedPercent: (percent) ->
|
||||
percent = Math.floor(percent / 5.0) * 5.0
|
||||
cacheKey = "#{percent}%"
|
||||
if not PercentLoadedCache[cacheKey]
|
||||
canvas = PercentLoadedCanvas
|
||||
scale = window.devicePixelRatio
|
||||
canvas.width = 20 * scale
|
||||
canvas.height = 20 * scale
|
||||
canvas.style.width = "30px"
|
||||
canvas.style.height = "30px"
|
||||
|
||||
half = 10 * scale
|
||||
ctx = canvas.getContext('2d')
|
||||
ctx.strokeStyle = "#AAA"
|
||||
ctx.lineWidth = 3 * scale
|
||||
ctx.clearRect(0, 0, 20 * scale, 20 * scale)
|
||||
ctx.beginPath()
|
||||
ctx.arc(half, half, half - ctx.lineWidth, -0.5 * Math.PI, (-0.5 * Math.PI) + (2 * Math.PI) * percent / 100.0)
|
||||
ctx.stroke()
|
||||
PercentLoadedCache[cacheKey] = canvas.toDataURL()
|
||||
return PercentLoadedCache[cacheKey]
|
||||
|
||||
canvasWithThreadDragImage: (count) ->
|
||||
canvas = DragCanvas
|
||||
|
||||
# Make sure the canvas has a 2x pixel density on retina displays
|
||||
scale = window.devicePixelRatio
|
||||
canvas.width = 58 * scale
|
||||
canvas.height = 55 * scale
|
||||
canvas.style.width = "58px"
|
||||
canvas.style.height = "55px"
|
||||
|
||||
ctx = canvas.getContext('2d')
|
||||
|
||||
# mail background image
|
||||
if count > 1
|
||||
ctx.rotate(-20*Math.PI/180)
|
||||
ctx.drawImage(ThreadDragImage, -10*scale, 2*scale, 48*scale, 48*scale)
|
||||
ctx.rotate(20*Math.PI/180)
|
||||
ctx.drawImage(ThreadDragImage, 0, 0, 48*scale, 48*scale)
|
||||
|
||||
# count bubble
|
||||
dotGradient = ctx.createLinearGradient(0, 0, 0, 15 * scale)
|
||||
dotGradient.addColorStop(0, "rgb(116, 124, 143)")
|
||||
dotGradient.addColorStop(1, "rgb(67, 77, 104)")
|
||||
ctx.strokeStyle = "rgba(39, 48, 68, 0.6)"
|
||||
ctx.lineWidth = 1
|
||||
ctx.fillStyle = dotGradient
|
||||
|
||||
textX = 49
|
||||
text = "#{count}"
|
||||
|
||||
if (count < 10)
|
||||
CanvasUtils.roundRect(ctx, 41 * scale, 1 * scale, 16 * scale, 14 * scale, 7 * scale, true, true)
|
||||
else if (count < 100)
|
||||
CanvasUtils.roundRect(ctx, 37 * scale, 1 * scale, 20 * scale, 14 * scale, 7 * scale, true, true)
|
||||
textX = 46
|
||||
else
|
||||
CanvasUtils.roundRect(ctx, 33 * scale, 1 * scale, 25 * scale, 14 * scale, 7 * scale, true, true)
|
||||
text = "99+"
|
||||
textX = 46
|
||||
|
||||
# count text
|
||||
ctx.fillStyle = "rgba(255,255,255,0.9)"
|
||||
ctx.font = "#{11 * scale}px sans-serif"
|
||||
ctx.textAlign = "center"
|
||||
ctx.fillText(text, textX * scale, 12 * scale, 30 * scale)
|
||||
|
||||
return DragCanvas
|
||||
|
||||
measureTextInCanvas: (text, font) ->
|
||||
canvas = document.createElement('canvas')
|
||||
context = canvas.getContext('2d')
|
||||
context.font = font
|
||||
return Math.ceil(context.measureText(text).width)
|
||||
|
||||
canvasWithSystemTrayIconAndText: (img, text) ->
|
||||
canvas = SystemTrayCanvas
|
||||
w = img.width
|
||||
h = img.height
|
||||
font = '26px Nylas-Pro, sans-serif'
|
||||
|
||||
textWidth = if text.length > 0 then CanvasUtils.measureTextInCanvas(text, font) + 2 else 0
|
||||
canvas.width = w + textWidth
|
||||
canvas.height = h
|
||||
|
||||
context = canvas.getContext('2d')
|
||||
context.font = font
|
||||
context.fillStyle = 'black'
|
||||
context.textAlign = 'start'
|
||||
context.textBaseline = 'middle'
|
||||
|
||||
context.drawImage(img, 0, 0)
|
||||
# Place after img, vertically aligned
|
||||
context.fillText(text, w, h / 2)
|
||||
return canvas
|
||||
|
||||
module.exports = CanvasUtils
|
139
packages/client-app/src/canvas-utils.es6
Normal file
139
packages/client-app/src/canvas-utils.es6
Normal file
|
@ -0,0 +1,139 @@
|
|||
const ThreadDragImage = document.createElement("img")
|
||||
ThreadDragImage.src = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAAJc0lEQVR42u2dSW8URxTHsY0XtgQTspySKIryBRCgALZIIPkA4RL5kkMuufAVcs2VIxKCAycuCBIBYjE7GGOx72bfwg628bAYA536VfpFL+Xume6ebnvkqZb+IswMXfX+v6rXr6pnOlOCIJjiNXHyJngAHoCXB+ABeHkAHoCXB+ABeHkAHoCXB+ABeHkAHoCXB+ABeHkAdQQg5dHg9T8lPrICKNd4Yx0rNZC0AMqZ3WQ0tc7VVAFIVQDGGN/e3v7lvHnzlnZ2di6LUkdHx/LJrLi458+fv3Tu3LlfxYDIBGCM+Q0NDQtWrVr167Nnz3rM518F/pBjZHBwsG/NmjW/NTY2LqwEIQkA13ym2WddXV0/PX/+fMD7HX2USqXhlStXdhmvPlepaQyENADEfLTgxo0bf718+TJ48eJF8P79e++4OvAEb+7du9eNV8q3xrQA3IutXGgXmgbuvXv3LhgeHg6GhoY8BHPggckKVnjz5s2bIbyKuED/ByENADG/2ejb0dHREo28ffs2GBgYCMy1wDZarwex4wFe4Al/BwheGbU4EFIBEGpifksIYJiGzJ/ByMhI8Pjx4+Dhw4f27/V2EDOxP3r0yHrB3wVCCKA19M6FkBiAjP6W8GSLzPSyM0AAvH79Orh//35w9+5dpt6ETH+t8TTf5HorPBAAagYsMmqLmQVlAbjpR0Y/J1ssACCN4TT+6tWr4M6dO8GtW7dsR6KMyVO0X0lFtc1B3MR6+/ZtGzse8JqTghYbTQu9a3bTUFIAkn5aw5Mt0QBkFtAJKoCbN28G165dsx0q0mTarqSigBDv9evXA1MN2piJndciACwxmu6koVQAmiIAdABADNKzgPLL1MAWwOXLl23Hko7WJNLmAr6S4kBkFbEQ55UrV4KrV6/aWIlZRr9OPyGADqMZcdeBSgDc/N8W0uygCtKjlIbpgMwCylM6eenSJdvBaiGkNb4IEMRAfMTU399vY9Sj3zU/+NdIARB5HcgMQM8ADUBmgawPLl68GJw7d86OlKRpo5zZtJFVLow0ou/ERSwXLlywsRGjjH4NIGIGzMwTwIyoGSBmCQRGBguSwcFB2+nTp09bCFlMz8N8DSALBOIhhrNnz9qYiM0d/Tr3qxnQWQSATncGuAD0LGCBcurUqeD48eO242nTSLXGx0FImrqIg74TA7HI6AeAzv06xakZIAAiK6G0AKa5APQM0BWRhsCIefr0qQ3i6NGjNoCkEPI0Py0EMb+vry84duyYjYFYonK/TlW6WhoXAOUgSCpiif7kyRMbTE9Pjw0kicGcJ28lhUS/6St9pu/EwGuk0iTmFw4gqlLRpuqyFMOZvmxZHDlyJDh48KANaDwMzwKB/h46dMgCoM/0XY9+ST1R6UenIQdAa1YAzUkAuBDiUhF7JocPHw727dtnAxtP45OAoZ/79++3AOirpB658ErVU878wgGYDpTKVTEagKwNCADDmc4PHjwIDhw4EOzZs8cGWAsQ6AOzkj4BgD7SV7nwSuqJAhBXuk4IgLhZoCEwrdm8I9Du7u5xgUA/xLw48+nL3r17bd8k9Yj5uuavZP6EA4iDQCBSmgoEUtGuXbvsaNNGiWTU5SHO5Z6PNukPfWD0u+a7C64k5hcOwHSmlLSMdFORvh6wj85WLoHv2LHDBq4h5Gl+FATaoh+0zeinL/SJ16TqkZLTBZCkjJ1wAEkhcB9h9+7dwfbt28dAKEJSRtI+bTL66UNe5tcUgDQQGIUbNmywaaBICJybNmlr586duZtfOADTsVLaFagGINcDvUij6mC5v27dumDjxo22BNQpIy9xTtrbtGlTsHbt2uDEiRORFQ99dAGkXXHXFIBKENg5ZX3An+TkLVu25A5BzN+6datNPWwr0+b58+dzNb9wAKaDpWrKQQEgqYibGyz5ucvEhZC7ahiUJwQxn3Nu27bNtkFb3NWibb3H7wLIuq6oSQAuBIwgDWAGo5BczL1ljMkLgms+56YNyfu0ffLkSTsA8jC/5gEIBG5qs8+OEe71wIXAZwRaGvFvqKxIO9p82pB6n7aBfObMGft+teYXDsBM01K1lQjfJsB8Atc3cPR2RbUQKpnv7vPwGhCYlXlUWjULAPMJFKMxiQsf+TdPCGnMp23Z6+F17nxVC6FmAWjz9T5RGgisE8pB4D1ApTFfLr70KQ8IhQIwHS5luRiS86PMdwFQDnI9wAjZMxIIrFqp4SlXZbGkxWu8t3nzZgtMzJc9Hlls0YYLIA5C1gt/TQEoZ34SCDITMISKBQjMBm7wcC1Bvb29dtTzHpUVn3VHfjnz84RQUwCSmJ8EgmzekcYY2Syg2EPCcMTs4FsYvMdn2GJIa35eEGoGQBrzK0HgHFRN8kVgTOb8mIPku5q8x2f4LP8mrfl5QCgUgAmglKQMlDqfEZjUfA1BQGCcXJhlNmAuoxuj5RvK/Dev8Z6MermfK3v7Sc2PgiDrhKQl8IQCqMb8KAh6NggIRjfn15IRLz+YcKudNOZXA2FCAeRhfjkImIq5AkNLXpdRX635WSHkCWBqGgB5mu9C0CAERpTkfW18NeZngVAoABNcKaqDXKTY08d8veOZl1wQ5ZSn8S4EWTEz0NjAi/tcYV/MigJQtPnlYESpyLaTQCgCQFscgPE2vxZUCUKRADo0gHo0PwkEB0C+P9AwDf5d7+ZXgmCKgcHCfiFjVoV/Uu3Uu/lxEKiO+vv7u0MA07MCiPuR3hfLly//ube394k3PxpCX1/f0IoVK34xXn0d8SvJpmp/psqUWrJ69erfzYLnvml8tN7NVxo1C8BH69ev/yPM/zOcNcDULL8Tdn+oDdFZId3vjL43Wmb0g9KPdSId87LQCzz5JvRIp59UP9SeEvOgDj0LaOBDo3ajj4zmGn0S6tM6kcT7cehBe+jJLGf0t1TzrAj3YR0yC2YqCLPDxueEHREgk1kS55ww9tnK/JkRoz8TgLhZIBBkJnwQNq5h1INmK+M/UCNfzI97UkpqAHEQpikQMiNmKSiTXbPUiBfjp5UxPxWAuEeW6XSkQQgMAVIvmh5hvJt2Mj2yLO6hfS4EASEw2hwok1U61lZlfCXzUwGYEvOwVv2g1mallhi1ThLFxac9mFom7aR+bGU5CO6McNU8yRX39NymJObn/ejiRqfxelSlZ0n7h3dPwIO7c314t398/Xg9vt7L/x80PAAvD8AD8PIAPAAvD8AD8PIAPAAvD8AD8CpO/wAnnXiPa3zSAAAAAABJRU5ErkJggg==`;
|
||||
|
||||
const DragCanvas = document.createElement("canvas")
|
||||
DragCanvas.style.position = "absolute"
|
||||
DragCanvas.style.zIndex = 0
|
||||
document.body.appendChild(DragCanvas)
|
||||
|
||||
const PercentLoadedCache = {}
|
||||
const PercentLoadedCanvas = document.createElement("canvas")
|
||||
PercentLoadedCanvas.style.position = "absolute"
|
||||
PercentLoadedCanvas.style.zIndex = 0
|
||||
document.body.appendChild(PercentLoadedCanvas)
|
||||
|
||||
const SystemTrayCanvas = document.createElement("canvas")
|
||||
|
||||
export function roundRect(ctx, x, y, width, height, radius = 5, fill, stroke = true) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + radius, y)
|
||||
ctx.lineTo(x + width - radius, y)
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
|
||||
ctx.lineTo(x + width, y + height - radius)
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
|
||||
ctx.lineTo(x + radius, y + height)
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius)
|
||||
ctx.lineTo(x, y + radius)
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y)
|
||||
ctx.closePath()
|
||||
if (stroke) {
|
||||
ctx.stroke();
|
||||
}
|
||||
if (fill) {
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
export function dataURIForLoadedPercent(_percent) {
|
||||
const percent = Math.floor(_percent / 5.0) * 5.0
|
||||
const cacheKey = `${percent}%`;
|
||||
if (!PercentLoadedCache[cacheKey]) {
|
||||
const canvas = PercentLoadedCanvas
|
||||
const scale = window.devicePixelRatio
|
||||
canvas.width = 20 * scale
|
||||
canvas.height = 20 * scale
|
||||
canvas.style.width = "30px"
|
||||
canvas.style.height = "30px"
|
||||
|
||||
const half = 10 * scale
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.strokeStyle = "#AAA"
|
||||
ctx.lineWidth = 3 * scale
|
||||
ctx.clearRect(0, 0, 20 * scale, 20 * scale)
|
||||
ctx.beginPath()
|
||||
ctx.arc(half, half, half - ctx.lineWidth, -0.5 * Math.PI, (-0.5 * Math.PI) + (2 * Math.PI) * percent / 100.0)
|
||||
ctx.stroke()
|
||||
PercentLoadedCache[cacheKey] = canvas.toDataURL();
|
||||
}
|
||||
return PercentLoadedCache[cacheKey];
|
||||
}
|
||||
|
||||
export function canvasWithThreadDragImage(count) {
|
||||
const canvas = DragCanvas
|
||||
|
||||
// Make sure the canvas has a 2x pixel density on retina displays
|
||||
const scale = window.devicePixelRatio
|
||||
canvas.width = 58 * scale
|
||||
canvas.height = 55 * scale
|
||||
canvas.style.width = "58px"
|
||||
canvas.style.height = "55px"
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// mail background image
|
||||
if (count > 1) {
|
||||
ctx.rotate(-20 * Math.PI / 180)
|
||||
ctx.drawImage(ThreadDragImage, -10 * scale, 2 * scale, 48 * scale, 48 * scale)
|
||||
ctx.rotate(20 * Math.PI / 180)
|
||||
}
|
||||
ctx.drawImage(ThreadDragImage, 0, 0, 48 * scale, 48 * scale)
|
||||
|
||||
// count bubble
|
||||
const dotGradient = ctx.createLinearGradient(0, 0, 0, 15 * scale)
|
||||
dotGradient.addColorStop(0, "rgb(116, 124, 143)")
|
||||
dotGradient.addColorStop(1, "rgb(67, 77, 104)")
|
||||
ctx.strokeStyle = "rgba(39, 48, 68, 0.6)"
|
||||
ctx.lineWidth = 1
|
||||
ctx.fillStyle = dotGradient
|
||||
|
||||
let textX = 49
|
||||
let text = `${count}`;
|
||||
|
||||
if (count < 10) {
|
||||
roundRect(ctx, 41 * scale, 1 * scale, 16 * scale, 14 * scale, 7 * scale, true, true)
|
||||
} else if (count < 100) {
|
||||
roundRect(ctx, 37 * scale, 1 * scale, 20 * scale, 14 * scale, 7 * scale, true, true)
|
||||
textX = 46
|
||||
} else {
|
||||
roundRect(ctx, 33 * scale, 1 * scale, 25 * scale, 14 * scale, 7 * scale, true, true)
|
||||
text = "99+"
|
||||
textX = 46
|
||||
}
|
||||
|
||||
// count text
|
||||
ctx.fillStyle = "rgba(255,255,255,0.9)"
|
||||
ctx.font = `${11 * scale}px sans-serif`;
|
||||
ctx.textAlign = "center"
|
||||
ctx.fillText(text, textX * scale, 12 * scale, 30 * scale)
|
||||
|
||||
return DragCanvas
|
||||
}
|
||||
|
||||
export function measureTextInCanvas(text, font) {
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
context.font = font
|
||||
return Math.ceil(context.measureText(text).width)
|
||||
}
|
||||
|
||||
export function canvasWithSystemTrayIconAndText(img, text) {
|
||||
const canvas = SystemTrayCanvas
|
||||
const w = img.width
|
||||
const h = img.height
|
||||
const font = '26px Nylas-Pro, sans-serif'
|
||||
|
||||
const textWidth = text.length > 0 ? measureTextInCanvas(text, font) + 2 : 0;
|
||||
canvas.width = w + textWidth
|
||||
canvas.height = h
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
context.font = font
|
||||
context.fillStyle = 'black'
|
||||
context.textAlign = 'start'
|
||||
context.textBaseline = 'middle'
|
||||
|
||||
context.drawImage(img, 0, 0)
|
||||
// Place after img, vertically aligned
|
||||
context.fillText(text, w, h / 2)
|
||||
return canvas
|
||||
}
|
|
@ -1,10 +1,12 @@
|
|||
# This is the Chrome (Blink) default user-agent stylesheet. We need this
|
||||
# when we use `automatic/juice` to inline CSS since emails will be
|
||||
# assuming they're based off the default stylesheet instead of the Nylas
|
||||
# stylesheet.
|
||||
#
|
||||
# From: https://chromium.googlesource.com/chromium/blink/+/master/Source/core/css/html.css
|
||||
module.exports = """
|
||||
/**
|
||||
This is the Chrome (Blink) default user-agent stylesheet. We need this
|
||||
when we use `automatic/juice` to inline CSS since emails will be
|
||||
assuming they're based off the default stylesheet instead of the Nylas
|
||||
stylesheet.
|
||||
|
||||
From: https://chromium.googlesource.com/chromium/blink/+/master/Source/core/css/html.css
|
||||
*/
|
||||
export default `
|
||||
@namespace "http://www.w3.org/1999/xhtml";
|
||||
html {
|
||||
display: block
|
||||
|
@ -894,4 +896,4 @@ dialog::backdrop {
|
|||
@media print {
|
||||
* { -webkit-columns: auto !important; }
|
||||
}
|
||||
"""
|
||||
`
|
|
@ -29,7 +29,7 @@ collection name. To add an item to the bar created in the example above, registe
|
|||
|
||||
```coffee
|
||||
ComponentRegistry.register ThreadBulkTrashButton,
|
||||
role: 'thread:Toolbar'
|
||||
role: 'ThreadActionsToolbarButton'
|
||||
```
|
||||
|
||||
Section: Component Kit
|
||||
|
|
|
@ -1,170 +0,0 @@
|
|||
exec = require('child_process').exec
|
||||
fs = require('fs')
|
||||
{remote, shell} = require('electron')
|
||||
|
||||
bundleIdentifier = 'com.nylas.nylas-mail'
|
||||
|
||||
class Windows
|
||||
available: ->
|
||||
true
|
||||
|
||||
isRegisteredForURLScheme: (scheme, callback) ->
|
||||
throw new Error "isRegisteredForURLScheme is async, provide a callback" unless callback
|
||||
output = ""
|
||||
exec "reg.exe query HKCU\\SOFTWARE\\Microsoft\\Windows\\Roaming\\OpenWith\\UrlAssociations\\#{scheme}\\UserChoice", (err, stdout, stderr) ->
|
||||
output += stdout.toString()
|
||||
exec "reg.exe query HKCU\\SOFTWARE\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\#{scheme}\\UserChoice", (err, stdout, stderr) ->
|
||||
output += stdout.toString()
|
||||
return callback(err) if callback and err
|
||||
callback(stdout.includes('Nylas'))
|
||||
|
||||
resetURLScheme: (scheme, callback) ->
|
||||
remote.dialog.showMessageBox null, {
|
||||
type: 'info',
|
||||
buttons: ['Learn More'],
|
||||
message: "Visit Windows Settings to change your default mail client",
|
||||
detail: "You'll find Nylas Mail, along with other options, listed in Default Apps > Mail.",
|
||||
}, ->
|
||||
shell.openExternal('https://support.nylas.com/hc/en-us/articles/229277648')
|
||||
|
||||
registerForURLScheme: (scheme, callback) ->
|
||||
# Ensure that our registry entires are present
|
||||
WindowsUpdater = remote.require('./windows-updater')
|
||||
WindowsUpdater.createRegistryEntries({
|
||||
allowEscalation: true,
|
||||
registerDefaultIfPossible: true,
|
||||
}, (err, didMakeDefault) =>
|
||||
if err
|
||||
remote.dialog.showMessageBox(null, {
|
||||
type: 'error',
|
||||
buttons: ['OK'],
|
||||
message: 'Sorry, an error occurred.',
|
||||
detail: err.message,
|
||||
})
|
||||
|
||||
if not didMakeDefault
|
||||
remote.dialog.showMessageBox null, {
|
||||
type: 'info',
|
||||
buttons: ['Learn More'],
|
||||
defaultId: 1,
|
||||
message: "Visit Windows Settings to finish making Nylas Mail your mail client",
|
||||
detail: "Click 'Learn More' to view instructions in our knowledge base.",
|
||||
}, ->
|
||||
shell.openExternal('https://support.nylas.com/hc/en-us/articles/229277648')
|
||||
|
||||
callback(null, null) if callback
|
||||
)
|
||||
|
||||
class Linux
|
||||
available: ->
|
||||
true
|
||||
|
||||
isRegisteredForURLScheme: (scheme, callback) ->
|
||||
throw new Error "isRegisteredForURLScheme is async, provide a callback" unless callback
|
||||
exec "xdg-mime query default x-scheme-handler/#{scheme}", (err, stdout, stderr) ->
|
||||
return callback(err) if err
|
||||
callback(stdout.trim() is 'nylas.desktop')
|
||||
|
||||
resetURLScheme: (scheme, callback) ->
|
||||
exec "xdg-mime default thunderbird.desktop x-scheme-handler/#{scheme}", (err, stdout, stderr) ->
|
||||
return callback(err) if callback and err
|
||||
callback(null, null) if callback
|
||||
|
||||
registerForURLScheme: (scheme, callback) ->
|
||||
exec "xdg-mime default nylas.desktop x-scheme-handler/#{scheme}", (err, stdout, stderr) ->
|
||||
return callback(err) if callback and err
|
||||
callback(null, null) if callback
|
||||
|
||||
class Mac
|
||||
constructor: ->
|
||||
@secure = false
|
||||
|
||||
available: ->
|
||||
true
|
||||
|
||||
getLaunchServicesPlistPath: (callback) ->
|
||||
secure = "#{process.env.HOME}/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist"
|
||||
insecure = "#{process.env.HOME}/Library/Preferences/com.apple.LaunchServices.plist"
|
||||
|
||||
fs.exists secure, (exists) ->
|
||||
if exists
|
||||
callback(secure)
|
||||
else
|
||||
callback(insecure)
|
||||
|
||||
readDefaults: (callback) ->
|
||||
@getLaunchServicesPlistPath (plistPath) ->
|
||||
tmpPath = "#{plistPath}.#{Math.random()}"
|
||||
exec "plutil -convert json \"#{plistPath}\" -o \"#{tmpPath}\"", (err, stdout, stderr) ->
|
||||
return callback(err) if callback and err
|
||||
fs.readFile tmpPath, (err, data) ->
|
||||
return callback(err) if callback and err
|
||||
try
|
||||
data = JSON.parse(data)
|
||||
callback(data['LSHandlers'], data)
|
||||
fs.unlink(tmpPath)
|
||||
catch e
|
||||
callback(e) if callback and err
|
||||
|
||||
writeDefaults: (defaults, callback) ->
|
||||
@getLaunchServicesPlistPath (plistPath) ->
|
||||
tmpPath = "#{plistPath}.#{Math.random()}"
|
||||
exec "plutil -convert json \"#{plistPath}\" -o \"#{tmpPath}\"", (err, stdout, stderr) ->
|
||||
return callback(err) if callback and err
|
||||
try
|
||||
data = fs.readFileSync(tmpPath)
|
||||
data = JSON.parse(data)
|
||||
data['LSHandlers'] = defaults
|
||||
data = JSON.stringify(data)
|
||||
fs.writeFileSync(tmpPath, data)
|
||||
catch error
|
||||
return callback(error) if callback and error
|
||||
|
||||
exec "plutil -convert binary1 \"#{tmpPath}\" -o \"#{plistPath}\"", ->
|
||||
fs.unlink(tmpPath)
|
||||
exec "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -kill -r -domain local -domain system -domain user", (err, stdout, stderr) ->
|
||||
callback(err) if callback
|
||||
|
||||
isRegisteredForURLScheme: (scheme, callback) ->
|
||||
throw new Error "isRegisteredForURLScheme is async, provide a callback" unless callback
|
||||
@readDefaults (defaults) ->
|
||||
for def in defaults
|
||||
if def.LSHandlerURLScheme is scheme
|
||||
return callback(def.LSHandlerRoleAll is bundleIdentifier)
|
||||
callback(false)
|
||||
|
||||
resetURLScheme: (scheme, callback) ->
|
||||
@readDefaults (defaults) =>
|
||||
# Remove anything already registered for the scheme
|
||||
for ii in [defaults.length-1..0] by -1
|
||||
if defaults[ii].LSHandlerURLScheme is scheme
|
||||
defaults.splice(ii, 1)
|
||||
@writeDefaults(defaults, callback)
|
||||
|
||||
registerForURLScheme: (scheme, callback) ->
|
||||
@readDefaults (defaults) =>
|
||||
# Remove anything already registered for the scheme
|
||||
for ii in [defaults.length-1..0] by -1
|
||||
if defaults[ii].LSHandlerURLScheme is scheme
|
||||
defaults.splice(ii, 1)
|
||||
|
||||
# Add our scheme default
|
||||
defaults.push
|
||||
LSHandlerURLScheme: scheme
|
||||
LSHandlerRoleAll: bundleIdentifier
|
||||
|
||||
@writeDefaults(defaults, callback)
|
||||
|
||||
|
||||
if process.platform is 'darwin'
|
||||
module.exports = Mac
|
||||
else if process.platform is 'linux'
|
||||
module.exports = Linux
|
||||
else if process.platform is 'win32'
|
||||
module.exports = Windows
|
||||
else
|
||||
module.exports = {}
|
||||
|
||||
module.exports.Mac = Mac
|
||||
module.exports.Linux = Linux
|
||||
module.exports.Windows = Windows
|
227
packages/client-app/src/default-client-helper.es6
Normal file
227
packages/client-app/src/default-client-helper.es6
Normal file
|
@ -0,0 +1,227 @@
|
|||
import {exec} from 'child_process';
|
||||
import fs from 'fs';
|
||||
import {remote, shell} from 'electron';
|
||||
|
||||
const bundleIdentifier = 'com.nylas.nylas-mail';
|
||||
|
||||
class Windows {
|
||||
available() {
|
||||
return true
|
||||
}
|
||||
|
||||
isRegisteredForURLScheme(scheme, callback) {
|
||||
if (!callback) {
|
||||
throw new Error("isRegisteredForURLScheme is async, provide a callback");
|
||||
}
|
||||
let output = "";
|
||||
exec(`reg.exe query HKCU\\SOFTWARE\\Microsoft\\Windows\\Roaming\\OpenWith\\UrlAssociations\\${scheme}\\UserChoice`, (err1, stdout1) => {
|
||||
output += stdout1.toString();
|
||||
exec(`reg.exe query HKCU\\SOFTWARE\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\${scheme}\\UserChoice`, (err2, stdout2) => {
|
||||
output += stdout2.toString();
|
||||
if (err1 || err2) {
|
||||
callback(err1 || err2);
|
||||
return;
|
||||
}
|
||||
callback(output.includes('Nylas'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
resetURLScheme() {
|
||||
remote.dialog.showMessageBox(null, {
|
||||
type: 'info',
|
||||
buttons: ['Learn More'],
|
||||
message: "Visit Windows Settings to change your default mail client",
|
||||
detail: "You'll find Nylas Mail, along with other options, listed in Default Apps > Mail.",
|
||||
}, () => {
|
||||
shell.openExternal('https://support.nylas.com/hc/en-us/articles/229277648');
|
||||
});
|
||||
}
|
||||
|
||||
registerForURLScheme(scheme, callback = () => {}) {
|
||||
// Ensure that our registry entires are present
|
||||
const WindowsUpdater = remote.require('./windows-updater')
|
||||
WindowsUpdater.createRegistryEntries({
|
||||
allowEscalation: true,
|
||||
registerDefaultIfPossible: true,
|
||||
}, (err, didMakeDefault) => {
|
||||
if (err) {
|
||||
remote.dialog.showMessageBox(null, {
|
||||
type: 'error',
|
||||
buttons: ['OK'],
|
||||
message: 'Sorry, an error occurred.',
|
||||
detail: err.message,
|
||||
});
|
||||
}
|
||||
if (!didMakeDefault) {
|
||||
remote.dialog.showMessageBox(null, {
|
||||
type: 'info',
|
||||
buttons: ['Learn More'],
|
||||
defaultId: 1,
|
||||
message: "Visit Windows Settings to finish making Nylas Mail your mail client",
|
||||
detail: "Click 'Learn More' to view instructions in our knowledge base.",
|
||||
}, () => {
|
||||
shell.openExternal('https://support.nylas.com/hc/en-us/articles/229277648');
|
||||
});
|
||||
}
|
||||
callback(null, null);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class Linux {
|
||||
available() {
|
||||
return true;
|
||||
}
|
||||
|
||||
isRegisteredForURLScheme(scheme, callback) {
|
||||
if (!callback) {
|
||||
throw new Error("isRegisteredForURLScheme is async, provide a callback");
|
||||
}
|
||||
exec(`xdg-mime query default x-scheme-handler/${scheme}`, (err, stdout) =>
|
||||
(err ? callback(err) : callback(stdout.trim() === 'nylas.desktop'))
|
||||
);
|
||||
}
|
||||
|
||||
resetURLScheme(scheme, callback = () => {}) {
|
||||
exec(`xdg-mime default thunderbird.desktop x-scheme-handler/${scheme}`, (err) =>
|
||||
(err ? callback(err) : callback(null, null))
|
||||
);
|
||||
}
|
||||
registerForURLScheme(scheme, callback = () => {}) {
|
||||
exec(`xdg-mime default nylas.desktop x-scheme-handler/${scheme}`, (err) =>
|
||||
(err ? callback(err) : callback(null, null))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Mac {
|
||||
constructor() {
|
||||
this.secure = false;
|
||||
}
|
||||
|
||||
available() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getLaunchServicesPlistPath(callback) {
|
||||
const secure = `${process.env.HOME}/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist`;
|
||||
const insecure = `${process.env.HOME}/Library/Preferences/com.apple.LaunchServices.plist`;
|
||||
|
||||
fs.exists(secure, (exists) =>
|
||||
(exists ? callback(secure) : callback(insecure))
|
||||
);
|
||||
}
|
||||
|
||||
readDefaults(callback = () => {}) {
|
||||
this.getLaunchServicesPlistPath((plistPath) => {
|
||||
const tmpPath = `${plistPath}.${Math.random()}`
|
||||
exec(`plutil -convert json "${plistPath}" -o "${tmpPath}"`, (err) => {
|
||||
if (err) {
|
||||
callback(err);
|
||||
return;
|
||||
}
|
||||
fs.readFile(tmpPath, (readErr, data) => {
|
||||
if (readErr) {
|
||||
callback(readErr)
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(data)
|
||||
callback(json.LSHandlers, json)
|
||||
fs.unlink(tmpPath)
|
||||
} catch (e) {
|
||||
callback(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
writeDefaults(defaults, callback = () => {}) {
|
||||
this.getLaunchServicesPlistPath((plistPath) => {
|
||||
const tmpPath = `${plistPath}.${Math.random()}`;
|
||||
exec(`plutil -convert json "${plistPath}" -o "${tmpPath}"`, (err) => {
|
||||
if (err) {
|
||||
callback(err)
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let data = fs.readFileSync(tmpPath)
|
||||
data = JSON.parse(data)
|
||||
data.LSHandlers = defaults
|
||||
data = JSON.stringify(data)
|
||||
fs.writeFileSync(tmpPath, data)
|
||||
} catch (e) {
|
||||
callback(e)
|
||||
return;
|
||||
}
|
||||
exec(`plutil -convert binary1 "${tmpPath}" -o "${plistPath}"`, () => {
|
||||
fs.unlink(tmpPath);
|
||||
exec("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -kill -r -domain local -domain system -domain user", (registerErr) => {
|
||||
callback(registerErr);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isRegisteredForURLScheme(scheme, callback) {
|
||||
if (!callback) {
|
||||
throw new Error("isRegisteredForURLScheme is async, provide a callback");
|
||||
}
|
||||
this.readDefaults((defaults) => {
|
||||
for (const def of defaults) {
|
||||
if (def.LSHandlerURLScheme === scheme) {
|
||||
callback(def.LSHandlerRoleAll === bundleIdentifier)
|
||||
return;
|
||||
}
|
||||
}
|
||||
callback(false);
|
||||
});
|
||||
}
|
||||
|
||||
resetURLScheme(scheme, callback) {
|
||||
this.readDefaults((defaults) => {
|
||||
// Remove anything already registered for the scheme
|
||||
for (let ii = defaults.length - 1; ii >= 0; ii--) {
|
||||
if (defaults[ii].LSHandlerURLScheme === scheme) {
|
||||
defaults.splice(ii, 1);
|
||||
}
|
||||
}
|
||||
this.writeDefaults(defaults, callback);
|
||||
});
|
||||
}
|
||||
|
||||
registerForURLScheme(scheme, callback) {
|
||||
this.readDefaults((defaults) => {
|
||||
// Remove anything already registered for the scheme
|
||||
for (let ii = defaults.length - 1; ii >= 0; ii--) {
|
||||
if (defaults[ii].LSHandlerURLScheme === scheme) {
|
||||
defaults.splice(ii, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Add our scheme default
|
||||
defaults.push({
|
||||
LSHandlerURLScheme: scheme,
|
||||
LSHandlerRoleAll: bundleIdentifier,
|
||||
});
|
||||
|
||||
this.writeDefaults(defaults, callback);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
module.exports = Mac;
|
||||
} else if (process.platform === 'linux') {
|
||||
module.exports = Linux;
|
||||
} else if (process.platform === 'win32') {
|
||||
module.exports = Windows;
|
||||
} else {
|
||||
module.exports = {};
|
||||
}
|
||||
module.exports.Mac = Mac;
|
||||
module.exports.Linux = Linux;
|
||||
module.exports.Windows = Windows;
|
|
@ -1,156 +0,0 @@
|
|||
_ = require 'underscore'
|
||||
Rx = require 'rx-lite'
|
||||
NylasStore = require 'nylas-store'
|
||||
AccountStore = require('./account-store').default
|
||||
Account = require('../models/account').default
|
||||
{StandardRoles} = require('../models/category').default
|
||||
{Categories} = require 'nylas-observables'
|
||||
|
||||
asAccount = (a) ->
|
||||
throw new Error("You must pass an Account or Account Id") unless a
|
||||
if a instanceof Account then a else AccountStore.accountForId(a)
|
||||
|
||||
asAccountId = (a) ->
|
||||
throw new Error("You must pass an Account or Account Id") unless a
|
||||
if a instanceof Account then a.id else a
|
||||
|
||||
class CategoryStore extends NylasStore
|
||||
|
||||
constructor: ->
|
||||
@_categoryCache = {}
|
||||
@_standardCategories = {}
|
||||
@_userCategories = {}
|
||||
@_hiddenCategories = {}
|
||||
|
||||
NylasEnv.config.onDidChange 'core.workspace.showImportant', =>
|
||||
return unless @_categoryResult
|
||||
@_onCategoriesChanged(@_categoryResult)
|
||||
|
||||
Categories
|
||||
.forAllAccounts()
|
||||
.sort()
|
||||
.subscribe(@_onCategoriesChanged)
|
||||
|
||||
byId: (accountOrId, categoryId) ->
|
||||
categories = @_categoryCache[asAccountId(accountOrId)] ? {}
|
||||
categories[categoryId]
|
||||
|
||||
# Public: Returns an array of all categories for an account, both
|
||||
# standard and user generated. The items returned by this function will be
|
||||
# either {Folder} or {Label} objects.
|
||||
#
|
||||
categories: (accountOrId = null) ->
|
||||
if accountOrId
|
||||
cached = @_categoryCache[asAccountId(accountOrId)]
|
||||
return [] unless cached
|
||||
Object.values(cached)
|
||||
else
|
||||
all = []
|
||||
for accountId, categories of @_categoryCache
|
||||
all = all.concat(Object.values(categories))
|
||||
all
|
||||
|
||||
# Public: Returns all of the standard categories for the given account.
|
||||
#
|
||||
standardCategories: (accountOrId) ->
|
||||
@_standardCategories[asAccountId(accountOrId)] ? []
|
||||
|
||||
hiddenCategories: (accountOrId) ->
|
||||
@_hiddenCategories[asAccountId(accountOrId)] ? []
|
||||
|
||||
# Public: Returns all of the categories that are not part of the standard
|
||||
# category set.
|
||||
#
|
||||
userCategories: (accountOrId) ->
|
||||
@_userCategories[asAccountId(accountOrId)] ? []
|
||||
|
||||
# Public: Returns the Folder or Label object for a standard category name and
|
||||
# for a given account.
|
||||
# ('inbox', 'drafts', etc.) It's possible for this to return `null`.
|
||||
# For example, Gmail likely doesn't have an `archive` label.
|
||||
#
|
||||
getCategoryByRole: (accountOrId, role) =>
|
||||
return null unless accountOrId
|
||||
|
||||
unless role in StandardRoles
|
||||
throw new Error("'#{role}' is not a standard category")
|
||||
|
||||
return _.findWhere(@_standardCategories[asAccountId(accountOrId)], {role})
|
||||
|
||||
# Public: Returns the set of all standard categories that match the given
|
||||
# names for each of the provided accounts
|
||||
getCategoriesWithRoles: (accountsOrIds, names...) =>
|
||||
if Array.isArray(accountsOrIds)
|
||||
res = []
|
||||
for accOrId in accountsOrIds
|
||||
cats = names.map((name) => @getCategoryByRole(accOrId, name))
|
||||
res = res.concat(_.compact(cats))
|
||||
res
|
||||
else
|
||||
_.compact(names.map((name) => @getCategoryByRole(accountsOrIds, name)))
|
||||
|
||||
# Public: Returns the Folder or Label object that should be used for "Archive"
|
||||
# actions. On Gmail, this is the "all" label. On providers using folders, it
|
||||
# returns any available "Archive" folder, or null if no such folder exists.
|
||||
#
|
||||
getArchiveCategory: (accountOrId) =>
|
||||
return null unless accountOrId
|
||||
account = asAccount(accountOrId)
|
||||
return null unless account
|
||||
|
||||
return @getCategoryByRole(account.id, "archive") || @getCategoryByRole(account.id, "all")
|
||||
|
||||
# Public: Returns Label object for "All mail"
|
||||
#
|
||||
getAllMailCategory: (accountOrId) =>
|
||||
return null unless accountOrId
|
||||
account = asAccount(accountOrId)
|
||||
return null unless account
|
||||
|
||||
return @getCategoryByRole(account.id, "all")
|
||||
|
||||
# Public: Returns the Folder or Label object that should be used for
|
||||
# the inbox or null if it doesn't exist
|
||||
#
|
||||
getInboxCategory: (accountOrId) =>
|
||||
@getCategoryByRole(accountOrId, "inbox")
|
||||
|
||||
# Public: Returns the Folder or Label object that should be used for
|
||||
# "Move to Trash", or null if no trash folder exists.
|
||||
#
|
||||
getTrashCategory: (accountOrId) =>
|
||||
@getCategoryByRole(accountOrId, "trash")
|
||||
|
||||
# Public: Returns the Folder or Label object that should be used for
|
||||
# "Move to Spam", or null if no trash folder exists.
|
||||
#
|
||||
getSpamCategory: (accountOrId) =>
|
||||
@getCategoryByRole(accountOrId, "spam")
|
||||
|
||||
_onCategoriesChanged: (categories) =>
|
||||
@_categoryResult = categories
|
||||
@_categoryCache = {}
|
||||
for cat in categories
|
||||
@_categoryCache[cat.accountId] ?= {}
|
||||
@_categoryCache[cat.accountId][cat.id] = cat
|
||||
|
||||
filteredByAccount = (fn) ->
|
||||
result = {}
|
||||
for cat in categories
|
||||
continue unless fn(cat)
|
||||
result[cat.accountId] ?= []
|
||||
result[cat.accountId].push(cat)
|
||||
result
|
||||
|
||||
@_standardCategories = filteredByAccount (cat) -> cat.isStandardCategory()
|
||||
@_userCategories = filteredByAccount (cat) -> cat.isUserCategory()
|
||||
@_hiddenCategories = filteredByAccount (cat) -> cat.isHiddenCategory()
|
||||
|
||||
# Ensure standard categories are always sorted in the correct order
|
||||
for accountId, items of @_standardCategories
|
||||
@_standardCategories[accountId].sort (a, b) ->
|
||||
StandardRoles.indexOf(a.name) - StandardRoles.indexOf(b.name)
|
||||
|
||||
@trigger()
|
||||
|
||||
module.exports = new CategoryStore()
|
195
packages/client-app/src/flux/stores/category-store.es6
Normal file
195
packages/client-app/src/flux/stores/category-store.es6
Normal file
|
@ -0,0 +1,195 @@
|
|||
import _ from 'underscore'
|
||||
import {Categories} from 'nylas-observables'
|
||||
import NylasStore from 'nylas-store'
|
||||
import AccountStore from './account-store'
|
||||
import Account from '../models/account'
|
||||
import Category from '../models/category'
|
||||
|
||||
const asAccount = (a) => {
|
||||
if (!a) {
|
||||
throw new Error("You must pass an Account or Account Id");
|
||||
}
|
||||
return a instanceof Account ? a : AccountStore.accountForId(a);
|
||||
}
|
||||
|
||||
const asAccountId = (a) => {
|
||||
if (!a) {
|
||||
throw new Error("You must pass an Account or Account Id");
|
||||
}
|
||||
return a instanceof Account ? a.id : a;
|
||||
}
|
||||
|
||||
class CategoryStore extends NylasStore {
|
||||
constructor() {
|
||||
super();
|
||||
this._categoryCache = {};
|
||||
this._standardCategories = {};
|
||||
this._userCategories = {};
|
||||
this._hiddenCategories = {};
|
||||
|
||||
NylasEnv.config.onDidChange('core.workspace.showImportant', () => {
|
||||
if (this._categoryResult) {
|
||||
this._onCategoriesChanged(this._categoryResult);
|
||||
}
|
||||
});
|
||||
|
||||
Categories
|
||||
.forAllAccounts()
|
||||
.sort()
|
||||
.subscribe(this._onCategoriesChanged);
|
||||
}
|
||||
|
||||
byId(accountOrId, categoryId) {
|
||||
const categories = this._categoryCache[asAccountId(accountOrId)] || {};
|
||||
return categories[categoryId];
|
||||
}
|
||||
|
||||
// Public: Returns an array of all categories for an account, both
|
||||
// standard and user generated. The items returned by this function will be
|
||||
// either {Folder} or {Label} objects.
|
||||
//
|
||||
categories(accountOrId = null) {
|
||||
if (accountOrId) {
|
||||
const cached = this._categoryCache[asAccountId(accountOrId)]
|
||||
return cached ? Object.values(cached) : [];
|
||||
}
|
||||
let all = [];
|
||||
for (const accountCategories of Object.values(this._categoryCache)) {
|
||||
all = all.concat(Object.values(accountCategories));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
// Public: Returns all of the standard categories for the given account.
|
||||
//
|
||||
standardCategories(accountOrId) {
|
||||
return this._standardCategories[asAccountId(accountOrId)] || [];
|
||||
}
|
||||
|
||||
hiddenCategories(accountOrId) {
|
||||
return this._hiddenCategories[asAccountId(accountOrId)] || [];
|
||||
}
|
||||
|
||||
// Public: Returns all of the categories that are not part of the standard
|
||||
// category set.
|
||||
//
|
||||
userCategories(accountOrId) {
|
||||
return this._userCategories[asAccountId(accountOrId)] || [];
|
||||
}
|
||||
|
||||
// Public: Returns the Folder or Label object for a standard category name and
|
||||
// for a given account.
|
||||
// ('inbox', 'drafts', etc.) It's possible for this to return `null`.
|
||||
// For example, Gmail likely doesn't have an `archive` label.
|
||||
//
|
||||
getCategoryByRole(accountOrId, role) {
|
||||
if (!accountOrId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Category.StandardRoles.includes(role)) {
|
||||
throw new Error(`'${role}' is not a standard category`);
|
||||
}
|
||||
return this._standardCategories[asAccountId(accountOrId)].find(c => c.role === role);
|
||||
}
|
||||
|
||||
// Public: Returns the set of all standard categories that match the given
|
||||
// names for each of the provided accounts
|
||||
getCategoriesWithRoles(accountsOrIds, ...names) {
|
||||
if (Array.isArray(accountsOrIds)) {
|
||||
let res = []
|
||||
for (const accOrId of accountsOrIds) {
|
||||
const cats = names.map((name) => this.getCategoryByRole(accOrId, name));
|
||||
res = res.concat(_.compact(cats));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
return _.compact(names.map((name) => this.getCategoryByRole(accountsOrIds, name)));
|
||||
}
|
||||
|
||||
// Public: Returns the Folder or Label object that should be used for "Archive"
|
||||
// actions. On Gmail, this is the "all" label. On providers using folders, it
|
||||
// returns any available "Archive" folder, or null if no such folder exists.
|
||||
//
|
||||
getArchiveCategory(accountOrId) {
|
||||
if (!accountOrId) {
|
||||
return null;
|
||||
}
|
||||
const account = asAccount(accountOrId);
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getCategoryByRole(account.id, "archive") || this.getCategoryByRole(account.id, "all");
|
||||
}
|
||||
|
||||
// Public: Returns Label object for "All mail"
|
||||
//
|
||||
getAllMailCategory(accountOrId) {
|
||||
if (!accountOrId) {
|
||||
return null;
|
||||
}
|
||||
const account = asAccount(accountOrId)
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getCategoryByRole(account.id, "all");
|
||||
}
|
||||
|
||||
// Public: Returns the Folder or Label object that should be used for
|
||||
// the inbox or null if it doesn't exist
|
||||
//
|
||||
getInboxCategory(accountOrId) {
|
||||
return this.getCategoryByRole(accountOrId, "inbox")
|
||||
}
|
||||
|
||||
// Public: Returns the Folder or Label object that should be used for
|
||||
// "Move to Trash", or null if no trash folder exists.
|
||||
//
|
||||
getTrashCategory(accountOrId) {
|
||||
return this.getCategoryByRole(accountOrId, "trash")
|
||||
}
|
||||
|
||||
// Public: Returns the Folder or Label object that should be used for
|
||||
// "Move to Spam", or null if no trash folder exists.
|
||||
//
|
||||
getSpamCategory(accountOrId) {
|
||||
return this.getCategoryByRole(accountOrId, "spam")
|
||||
}
|
||||
|
||||
_onCategoriesChanged = (categories) => {
|
||||
this._categoryResult = categories;
|
||||
this._categoryCache = {};
|
||||
for (const cat of categories) {
|
||||
this._categoryCache[cat.accountId] = this._categoryCache[cat.accountId] || {};
|
||||
this._categoryCache[cat.accountId][cat.id] = cat;
|
||||
}
|
||||
|
||||
const filteredByAccount = (fn) => {
|
||||
const result = {};
|
||||
for (const cat of categories) {
|
||||
if (!fn(cat)) {
|
||||
continue;
|
||||
}
|
||||
result[cat.accountId] = result[cat.accountId] || [];
|
||||
result[cat.accountId].push(cat);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
this._standardCategories = filteredByAccount((cat) => cat.isStandardCategory());
|
||||
this._userCategories = filteredByAccount((cat) => cat.isUserCategory());
|
||||
this._hiddenCategories = filteredByAccount((cat) => cat.isHiddenCategory());
|
||||
|
||||
// Ensure standard categories are always sorted in the correct order
|
||||
for (const accountCategories of Object.values(this._standardCategories)) {
|
||||
accountCategories.sort((a, b) =>
|
||||
Category.StandardRoles.indexOf(a.name) - Category.StandardRoles.indexOf(b.name)
|
||||
);
|
||||
}
|
||||
this.trigger();
|
||||
}
|
||||
}
|
||||
|
||||
export default new CategoryStore()
|
|
@ -1,131 +0,0 @@
|
|||
fs = require 'fs'
|
||||
path = require 'path'
|
||||
Rx = require 'rx-lite'
|
||||
Actions = require('../actions').default
|
||||
Contact = require('../models/contact').default
|
||||
Utils = require '../models/utils'
|
||||
NylasStore = require 'nylas-store'
|
||||
RegExpUtils = require '../../regexp-utils'
|
||||
DatabaseStore = require('./database-store').default
|
||||
AccountStore = require('./account-store').default
|
||||
ComponentRegistry = require('../../registries/component-registry')
|
||||
|
||||
###
|
||||
Public: ContactStore provides convenience methods for searching contacts and
|
||||
formatting contacts. When Contacts become editable, this store will be expanded
|
||||
with additional actions.
|
||||
|
||||
Section: Stores
|
||||
###
|
||||
class ContactStore extends NylasStore
|
||||
|
||||
# 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`.
|
||||
#
|
||||
# - `search` {String} A search phrase, such as `ben@n` or `Ben G`
|
||||
# - `options` (optional) {Object} If you will only be displaying a few results,
|
||||
# you should pass a limit value. {::searchContacts} will return as soon
|
||||
# as `limit` matches have been found.
|
||||
#
|
||||
# Returns an {Array} of matching {Contact} models
|
||||
#
|
||||
searchContacts: (search, options={}) =>
|
||||
{limit} = options
|
||||
limit ?= 5
|
||||
limit = Math.max(limit, 0)
|
||||
|
||||
search = search.toLowerCase()
|
||||
accountCount = AccountStore.accounts().length
|
||||
extensions = ComponentRegistry.findComponentsMatching({
|
||||
role: "ContactSearchResults"
|
||||
})
|
||||
|
||||
if not search or search.length is 0
|
||||
return Promise.resolve([])
|
||||
|
||||
# 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
|
||||
# could exist in every account. Rather than make SQLite do a SELECT DISTINCT
|
||||
# (which is very slow), we just ask for more items.
|
||||
query = DatabaseStore.findAll(Contact)
|
||||
.search(search)
|
||||
.limit(limit * accountCount)
|
||||
.order(Contact.attributes.refs.descending())
|
||||
query.then (results) =>
|
||||
# remove query results that were already found in ranked contacts
|
||||
results = @_distinctByEmail(results)
|
||||
return Promise.each extensions, (ext) =>
|
||||
return ext.findAdditionalContacts(search, results).then (contacts) =>
|
||||
results = contacts
|
||||
.then =>
|
||||
if (results.length > limit) then results.length = limit
|
||||
return Promise.resolve(results)
|
||||
|
||||
isValidContact: (contact) =>
|
||||
return false unless contact instanceof Contact
|
||||
return contact.isValid()
|
||||
|
||||
parseContactsInString: (contactString, options={}) =>
|
||||
{skipNameLookup} = options
|
||||
|
||||
detected = []
|
||||
emailRegex = RegExpUtils.emailRegex()
|
||||
lastMatchEnd = 0
|
||||
|
||||
while (match = emailRegex.exec(contactString))
|
||||
email = match[0]
|
||||
name = null
|
||||
|
||||
startsWithQuote = email[0] in ['\'','"']
|
||||
hasTrailingQuote = contactString[match.index+email.length] in ['\'','"']
|
||||
if startsWithQuote and hasTrailingQuote
|
||||
email = email[1..-1]
|
||||
|
||||
hasLeadingParen = contactString[match.index-1] in ['(','<']
|
||||
hasTrailingParen = contactString[match.index+email.length] in [')','>']
|
||||
|
||||
if hasLeadingParen and hasTrailingParen
|
||||
nameStart = lastMatchEnd
|
||||
for char in [',', '\n', '\r']
|
||||
i = contactString.lastIndexOf(char, match.index)
|
||||
nameStart = i+1 if i+1 > nameStart
|
||||
name = contactString.substr(nameStart, match.index - 1 - nameStart).trim()
|
||||
|
||||
# The "nameStart" for the next match must begin after lastMatchEnd
|
||||
lastMatchEnd = match.index+email.length
|
||||
if hasTrailingParen
|
||||
lastMatchEnd += 1
|
||||
|
||||
if not name or name.length is 0
|
||||
name = email
|
||||
|
||||
# If the first and last character of the name are quotation marks, remove them
|
||||
[firstChar,...,lastChar] = name
|
||||
if firstChar in ['"', "'"] and lastChar in ['"', "'"]
|
||||
name = name[1...-1]
|
||||
|
||||
detected.push(new Contact({email, name}))
|
||||
|
||||
if skipNameLookup
|
||||
return Promise.resolve(detected)
|
||||
|
||||
Promise.all detected.map (contact) =>
|
||||
return contact if contact.name isnt contact.email
|
||||
@searchContacts(contact.email, {limit: 1}).then ([match]) =>
|
||||
return match if match and match.email is contact.email
|
||||
return contact
|
||||
|
||||
_distinctByEmail: (contacts) =>
|
||||
# remove query results that are duplicates, prefering ones that have names
|
||||
uniq = {}
|
||||
for contact in contacts
|
||||
continue unless contact.email
|
||||
key = contact.email.toLowerCase()
|
||||
existing = uniq[key]
|
||||
if not existing or (not existing.name or existing.name is existing.email)
|
||||
uniq[key] = contact
|
||||
Object.values(uniq)
|
||||
|
||||
module.exports = new ContactStore()
|
147
packages/client-app/src/flux/stores/contact-store.es6
Normal file
147
packages/client-app/src/flux/stores/contact-store.es6
Normal file
|
@ -0,0 +1,147 @@
|
|||
import NylasStore from 'nylas-store';
|
||||
import Contact from '../models/contact';
|
||||
import RegExpUtils from '../../regexp-utils';
|
||||
import DatabaseStore from './database-store';
|
||||
import AccountStore from './account-store';
|
||||
import ComponentRegistry from '../../registries/component-registry';
|
||||
|
||||
/**
|
||||
Public: ContactStore provides convenience methods for searching contacts and
|
||||
formatting contacts. When Contacts become editable, this store will be expanded
|
||||
with additional actions.
|
||||
|
||||
Section: Stores
|
||||
*/
|
||||
class ContactStore extends NylasStore {
|
||||
|
||||
// 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`.
|
||||
//
|
||||
// - `search` {String} A search phrase, such as `ben@n` or `Ben G`
|
||||
// - `options` (optional) {Object} If you will only be displaying a few results,
|
||||
// you should pass a limit value. {::searchContacts} will return as soon
|
||||
// as `limit` matches have been found.
|
||||
//
|
||||
// Returns an {Array} of matching {Contact} models
|
||||
//
|
||||
searchContacts(_search, options = {}) {
|
||||
const limit = Math.max(options.limit ? options.limit : 5, 0);
|
||||
const search = _search.toLowerCase();
|
||||
|
||||
const accountCount = AccountStore.accounts().length;
|
||||
const extensions = ComponentRegistry.findComponentsMatching({
|
||||
role: "ContactSearchResults",
|
||||
});
|
||||
|
||||
if (!search || search.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
// 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
|
||||
// could exist in every account. Rather than make SQLite do a SELECT DISTINCT
|
||||
// (which is very slow), we just ask for more items.
|
||||
const query = DatabaseStore.findAll(Contact)
|
||||
.search(search)
|
||||
.limit(limit * accountCount)
|
||||
.order(Contact.attributes.refs.descending());
|
||||
|
||||
return query.then(async (_results) => {
|
||||
// remove query results that were already found in ranked contacts
|
||||
let results = this._distinctByEmail(_results);
|
||||
for (const ext of extensions) {
|
||||
results = await ext.findAdditionalContacts(search, results);
|
||||
}
|
||||
if (results.length > limit) {
|
||||
results.length = limit;
|
||||
}
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
isValidContact(contact) {
|
||||
return (contact instanceof Contact) ? contact.isValid() : false;
|
||||
}
|
||||
|
||||
parseContactsInString(contactString, {skipNameLookup} = {}) {
|
||||
const detected = [];
|
||||
const emailRegex = RegExpUtils.emailRegex();
|
||||
let lastMatchEnd = 0;
|
||||
let match = null;
|
||||
|
||||
while (match = emailRegex.exec(contactString)) { // eslint-disable-line
|
||||
let email = match[0]
|
||||
let name = null
|
||||
|
||||
const startsWithQuote = email[0] in ['\'', '"']
|
||||
const hasTrailingQuote = contactString[match.index + email.length] in ['\'', '"']
|
||||
if (startsWithQuote && hasTrailingQuote) {
|
||||
email = email.slice(1, email.length - 1);
|
||||
}
|
||||
|
||||
const hasLeadingParen = contactString[match.index - 1] in ['(', '<']
|
||||
const hasTrailingParen = contactString[match.index + email.length] in [')', '>']
|
||||
|
||||
if (hasLeadingParen && hasTrailingParen) {
|
||||
let nameStart = lastMatchEnd;
|
||||
for (const char of [',', '\n', '\r']) {
|
||||
const i = contactString.lastIndexOf(char, match.index)
|
||||
if (i + 1 > nameStart) {
|
||||
nameStart = i + 1;
|
||||
}
|
||||
}
|
||||
name = contactString.substr(nameStart, match.index - 1 - nameStart).trim();
|
||||
}
|
||||
|
||||
// The "nameStart" for the next match must begin after lastMatchEnd
|
||||
lastMatchEnd = match.index + email.length;
|
||||
if (hasTrailingParen) {
|
||||
lastMatchEnd += 1;
|
||||
}
|
||||
|
||||
if (!name || name.length === 0) {
|
||||
name = email;
|
||||
}
|
||||
|
||||
// If the first and last character of the name are quotation marks, remove them
|
||||
if (['"', "'"].includes(name[0]) && ['"', "'"].includes(name[name.length - 1])) {
|
||||
name = name.slice(1, name.length - 1);
|
||||
}
|
||||
|
||||
detected.push(new Contact({email, name}))
|
||||
}
|
||||
|
||||
if (skipNameLookup) {
|
||||
return Promise.resolve(detected);
|
||||
}
|
||||
|
||||
return Promise.all(detected.map((contact) => {
|
||||
if (contact.name !== contact.email) {
|
||||
return contact;
|
||||
}
|
||||
return this.searchContacts(contact.email, {limit: 1}).then(([smatch]) =>
|
||||
((smatch && smatch.email === contact.email) ? smatch : contact)
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
_distinctByEmail(contacts) {
|
||||
// remove query results that are duplicates, prefering ones that have names
|
||||
const uniq = {};
|
||||
for (const contact of contacts) {
|
||||
if (!contact.email) {
|
||||
continue;
|
||||
}
|
||||
const key = contact.email.toLowerCase();
|
||||
const existing = uniq[key];
|
||||
if (!existing || (!existing.name || existing.name === existing.email)) {
|
||||
uniq[key] = contact;
|
||||
}
|
||||
}
|
||||
return Object.values(uniq);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ContactStore();
|
|
@ -1,7 +0,0 @@
|
|||
###
|
||||
Public: DraftStoreExtension is deprecated. Use {ComposerExtension} instead.
|
||||
Section: Extensions
|
||||
###
|
||||
class DraftStoreExtension
|
||||
|
||||
module.exports = DraftStoreExtension
|
|
@ -342,10 +342,10 @@ class DraftStore extends NylasStore {
|
|||
// Stop any pending tasks related ot the draft
|
||||
TaskQueue.queue().forEach((task) => {
|
||||
if (task instanceof SyncbackDraftTask && task.headerMessageId === headerMessageId) {
|
||||
Actions.dequeueTask(task.id);
|
||||
Actions.cancelTask(task);
|
||||
}
|
||||
if (task instanceof SendDraftTask && task.headerMessageId === headerMessageId) {
|
||||
Actions.dequeueTask(task.id);
|
||||
Actions.cancelTask(task);
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -1,216 +0,0 @@
|
|||
_ = require 'underscore'
|
||||
AccountStore = require('./account-store').default
|
||||
WorkspaceStore = require './workspace-store'
|
||||
DatabaseStore = require('./database-store').default
|
||||
FocusedPerspectiveStore = require('./focused-perspective-store').default
|
||||
MailboxPerspective = require '../../mailbox-perspective'
|
||||
Actions = require('../actions').default
|
||||
Thread = require('../models/thread').default
|
||||
Model = require('../models/model').default
|
||||
|
||||
{Listener, Publisher} = require '../modules/reflux-coffee'
|
||||
CoffeeHelpers = require '../coffee-helpers'
|
||||
|
||||
###
|
||||
Public: The FocusedContentStore provides access to the objects currently selected
|
||||
or otherwise focused in the window. Normally, focus would be maintained internally
|
||||
by components that show models. The FocusedContentStore makes the concept of
|
||||
selection public so that you can observe focus changes and trigger your own changes
|
||||
to focus.
|
||||
|
||||
Since {FocusedContentStore} is a Flux-compatible Store, you do not call setters
|
||||
on it directly. Instead, use {Actions::setFocus} or
|
||||
{Actions::setCursorPosition} to set focus. The FocusedContentStore observes
|
||||
these models, changes it's state, and broadcasts to it's observers.
|
||||
|
||||
Note: The {FocusedContentStore} triggers when a focused model is changed, even if
|
||||
it's ID has not. For example, if the user has a {Thread} selected and removes a tag,
|
||||
{FocusedContentStore} will trigger so you can fetch the new version of the
|
||||
{Thread}. If you observe the {FocusedContentStore} properly, you should always
|
||||
have the latest version of the the selected object.
|
||||
|
||||
**Standard Collections**:
|
||||
|
||||
- thread
|
||||
- file
|
||||
|
||||
**Example: Observing the Selected Thread**
|
||||
|
||||
```coffeescript
|
||||
@unsubscribe = FocusedContentStore.listen(@_onFocusChanged, @)
|
||||
|
||||
...
|
||||
|
||||
# Called when focus has changed, or when the focused model has been modified.
|
||||
_onFocusChanged: ->
|
||||
thread = FocusedContentStore.focused('thread')
|
||||
if thread
|
||||
console.log("#{thread.subject} is selected!")
|
||||
else
|
||||
console.log("No thread is selected!")
|
||||
```
|
||||
|
||||
Section: Stores
|
||||
###
|
||||
class FocusedContentStore
|
||||
@include: CoffeeHelpers.includeModule
|
||||
|
||||
@include Publisher
|
||||
@include Listener
|
||||
|
||||
constructor: ->
|
||||
@_resetInstanceVars()
|
||||
@listenTo AccountStore, @_onAccountsChange
|
||||
@listenTo WorkspaceStore, @_onWorkspaceChange
|
||||
@listenTo DatabaseStore, @_onDataChange
|
||||
@listenTo Actions.setFocus, @_onFocus
|
||||
@listenTo Actions.setCursorPosition, @_onFocusKeyboard
|
||||
|
||||
triggerAfterAnimationFrame: (payload) =>
|
||||
window.requestAnimationFrame =>
|
||||
@trigger(payload)
|
||||
|
||||
_resetInstanceVars: =>
|
||||
@_focused = {}
|
||||
@_focusedUsingClick = {}
|
||||
@_keyboardCursor = {}
|
||||
@_keyboardCursorEnabled = WorkspaceStore.layoutMode() is 'list'
|
||||
|
||||
# Inbound Events
|
||||
|
||||
_onAccountsChange: =>
|
||||
# Ensure internal consistency by removing any focused items that belong
|
||||
# to accounts which no longer exist.
|
||||
changed = []
|
||||
|
||||
for dict in [@_focused, @_keyboardCursor]
|
||||
for collection, item of dict
|
||||
if item and item.accountId and !AccountStore.accountForId(item.accountId)
|
||||
delete dict[collection]
|
||||
changed.push(collection)
|
||||
|
||||
if changed.length > 0
|
||||
@trigger({ impactsCollection: (c) -> changed.includes(c) })
|
||||
|
||||
_onFocusKeyboard: ({collection, item}) =>
|
||||
throw new Error("focusKeyboard() requires a Model or null") if item and not (item instanceof Model)
|
||||
throw new Error("focusKeyboard() requires a collection") unless collection
|
||||
return if @_keyboardCursor[collection]?.id is item?.id
|
||||
|
||||
@_keyboardCursor[collection] = item
|
||||
@triggerAfterAnimationFrame({ impactsCollection: (c) -> c is collection })
|
||||
|
||||
_onFocus: ({collection, item, usingClick}) =>
|
||||
throw new Error("focus() requires a Model or null") if item and not (item instanceof Model)
|
||||
throw new Error("focus() requires a collection") unless collection
|
||||
return if @_focused[collection]?.id is item?.id
|
||||
|
||||
@_focused[collection] = item
|
||||
@_focusedUsingClick[collection] = usingClick
|
||||
@_keyboardCursor[collection] = item if item
|
||||
@triggerAfterAnimationFrame({ impactsCollection: (c) -> c is collection })
|
||||
|
||||
_onWorkspaceChange: =>
|
||||
keyboardCursorEnabled = WorkspaceStore.layoutMode() is 'list'
|
||||
|
||||
if keyboardCursorEnabled isnt @_keyboardCursorEnabled
|
||||
@_keyboardCursorEnabled = keyboardCursorEnabled
|
||||
|
||||
if keyboardCursorEnabled
|
||||
for collection, item of @_focused
|
||||
@_keyboardCursor[collection] = item
|
||||
@_focused = {}
|
||||
else
|
||||
for collection, item of @_keyboardCursor
|
||||
@_onFocus({collection, item})
|
||||
|
||||
@trigger({ impactsCollection: -> true })
|
||||
|
||||
_onDataChange: (change) =>
|
||||
# If one of the objects we're storing in our focused or keyboard cursor
|
||||
# dictionaries has changed, we need to let our observers know, since they
|
||||
# may now be holding on to outdated data.
|
||||
return unless change and change.objectClass
|
||||
|
||||
touched = []
|
||||
|
||||
for data in [@_focused, @_keyboardCursor]
|
||||
for key, val of data
|
||||
continue unless val and val.constructor.name is change.objectClass
|
||||
for obj in change.objects
|
||||
if val.id is obj.id
|
||||
if change.type is 'unpersist'
|
||||
data[key] = null
|
||||
else
|
||||
data[key] = obj
|
||||
touched.push(key)
|
||||
|
||||
if touched.length > 0
|
||||
@trigger({ impactsCollection: (c) -> c in touched })
|
||||
|
||||
# Public Methods
|
||||
|
||||
###
|
||||
Public: Returns the focused {Model} in the collection specified,
|
||||
or undefined if no item is focused.
|
||||
|
||||
- `collection` The {String} name of a collection. Standard collections are
|
||||
listed above.
|
||||
###
|
||||
focused: (collection) =>
|
||||
@_focused[collection]
|
||||
|
||||
###
|
||||
Public: Returns the ID of the focused {Model} in the collection specified,
|
||||
or undefined if no item is focused.
|
||||
|
||||
- `collection` The {String} name of a collection. Standard collections are
|
||||
listed above.
|
||||
###
|
||||
focusedId: (collection) =>
|
||||
@_focused[collection]?.id
|
||||
|
||||
###
|
||||
Public: Returns true if the item for the collection was focused via a click or
|
||||
false otherwise.
|
||||
|
||||
- `collection` The {String} name of a collection. Standard collections are
|
||||
listed above.
|
||||
###
|
||||
didFocusUsingClick: (collection) =>
|
||||
@_focusedUsingClick[collection] ? false
|
||||
|
||||
###
|
||||
Public: Returns the {Model} the keyboard is currently focused on
|
||||
in the collection specified. Keyboard focus is not always separate from
|
||||
primary focus (selection). You can use {::keyboardCursorEnabled} to determine
|
||||
whether keyboard focus is enabled.
|
||||
|
||||
- `collection` The {String} name of a collection. Standard collections are
|
||||
listed above.
|
||||
###
|
||||
keyboardCursor: (collection) =>
|
||||
@_keyboardCursor[collection]
|
||||
|
||||
###
|
||||
Public: Returns the ID of the {Model} the keyboard is currently focused on
|
||||
in the collection specified. Keyboard focus is not always separate from
|
||||
primary focus (selection). You can use {::keyboardCursorEnabled} to determine
|
||||
whether keyboard focus is enabled.
|
||||
|
||||
- `collection` The {String} name of a collection. Standard collections are
|
||||
listed above.
|
||||
###
|
||||
keyboardCursorId: (collection) =>
|
||||
@_keyboardCursor[collection]?.id
|
||||
|
||||
###
|
||||
Public: Returns a {Boolean} - `true` if the keyboard cursor concept applies in
|
||||
the current {WorkspaceStore} layout mode. The keyboard cursor is currently only
|
||||
enabled in `list` mode.
|
||||
###
|
||||
keyboardCursorEnabled: =>
|
||||
@_keyboardCursorEnabled
|
||||
|
||||
|
||||
module.exports = new FocusedContentStore()
|
246
packages/client-app/src/flux/stores/focused-content-store.es6
Normal file
246
packages/client-app/src/flux/stores/focused-content-store.es6
Normal file
|
@ -0,0 +1,246 @@
|
|||
import NylasStore from 'nylas-store';
|
||||
import AccountStore from './account-store';
|
||||
import WorkspaceStore from './workspace-store';
|
||||
import DatabaseStore from './database-store';
|
||||
import Actions from '../actions';
|
||||
import Model from '../models/model';
|
||||
|
||||
/**
|
||||
Public: The FocusedContentStore provides access to the objects currently selected
|
||||
or otherwise focused in the window. Normally, focus would be maintained internally
|
||||
by components that show models. The FocusedContentStore makes the concept of
|
||||
selection public so that you can observe focus changes and trigger your own changes
|
||||
to focus.
|
||||
|
||||
Since {FocusedContentStore} is a Flux-compatible Store, you do not call setters
|
||||
on it directly. Instead, use {Actions::setFocus} or
|
||||
{Actions::setCursorPosition} to set focus. The FocusedContentStore observes
|
||||
these models, changes it's state, and broadcasts to it's observers.
|
||||
|
||||
Note: The {FocusedContentStore} triggers when a focused model is changed, even if
|
||||
it's ID has not. For example, if the user has a {Thread} selected and removes a tag,
|
||||
{FocusedContentStore} will trigger so you can fetch the new version of the
|
||||
{Thread}. If you observe the {FocusedContentStore} properly, you should always
|
||||
have the latest version of the the selected object.
|
||||
|
||||
**Standard Collections**:
|
||||
|
||||
- thread
|
||||
- file
|
||||
|
||||
**Example: Observing the Selected Thread**
|
||||
|
||||
```js
|
||||
this.unsubscribe = FocusedContentStore.listen(this._onFocusChanged, this)
|
||||
|
||||
...
|
||||
|
||||
// Called when focus has changed, or when the focused model has been modified.
|
||||
_onFocusChanged: =>
|
||||
thread = FocusedContentStore.focused('thread')
|
||||
if thread
|
||||
console.log("#{thread.subject} is selected!")
|
||||
else
|
||||
console.log("No thread is selected!")
|
||||
```
|
||||
|
||||
Section: Stores
|
||||
*/
|
||||
class FocusedContentStore extends NylasStore {
|
||||
constructor() {
|
||||
super();
|
||||
this._resetInstanceVars();
|
||||
this.listenTo(AccountStore, this._onAccountsChange);
|
||||
this.listenTo(WorkspaceStore, this._onWorkspaceChange);
|
||||
this.listenTo(DatabaseStore, this._onDataChange);
|
||||
this.listenTo(Actions.setFocus, this._onFocus);
|
||||
this.listenTo(Actions.setCursorPosition, this._onFocusKeyboard);
|
||||
}
|
||||
|
||||
triggerAfterAnimationFrame(payload) {
|
||||
window.requestAnimationFrame(() => this.trigger(payload));
|
||||
}
|
||||
|
||||
_resetInstanceVars() {
|
||||
this._focused = {};
|
||||
this._focusedUsingClick = {};
|
||||
this._keyboardCursor = {};
|
||||
this._keyboardCursorEnabled = WorkspaceStore.layoutMode() === 'list';
|
||||
}
|
||||
|
||||
// Inbound Events
|
||||
|
||||
_onAccountsChange = () => {
|
||||
// Ensure internal consistency by removing any focused items that belong
|
||||
// to accounts which no longer exist.
|
||||
const changed = [];
|
||||
|
||||
for (const dict of [this._focused, this._keyboardCursor]) {
|
||||
for (const [collection, item] of Object.entries(dict)) {
|
||||
if (item && item.accountId && !AccountStore.accountForId(item.accountId)) {
|
||||
delete dict[collection];
|
||||
changed.push(collection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed.length > 0) {
|
||||
this.trigger({ impactsCollection: (c) => changed.includes(c) });
|
||||
}
|
||||
}
|
||||
|
||||
_onFocusKeyboard = ({collection, item}) => {
|
||||
if (item && !(item instanceof Model)) {
|
||||
throw new Error("focusKeyboard() requires a Model or null");
|
||||
}
|
||||
if (!collection) {
|
||||
throw new Error("focusKeyboard() requires a collection");
|
||||
}
|
||||
if (this._keyboardCursor[collection] && item && this._keyboardCursor[collection].id === item.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._keyboardCursor[collection] = item;
|
||||
this.triggerAfterAnimationFrame({ impactsCollection: (c) => c === collection });
|
||||
}
|
||||
|
||||
_onFocus = ({collection, item, usingClick}) => {
|
||||
if (item && !(item instanceof Model)) {
|
||||
throw new Error("focus() requires a Model or null")
|
||||
}
|
||||
if (!collection) {
|
||||
throw new Error("focus() requires a collection");
|
||||
}
|
||||
if (item && this._focused[collection] && this._focused[collection].id === item.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._focused[collection] = item;
|
||||
this._focusedUsingClick[collection] = usingClick;
|
||||
if (item) {
|
||||
this._keyboardCursor[collection] = item;
|
||||
}
|
||||
this.triggerAfterAnimationFrame({ impactsCollection: (c) => c === collection });
|
||||
}
|
||||
|
||||
_onWorkspaceChange = () => {
|
||||
const keyboardCursorEnabled = WorkspaceStore.layoutMode() === 'list'
|
||||
|
||||
if (keyboardCursorEnabled !== this._keyboardCursorEnabled) {
|
||||
this._keyboardCursorEnabled = keyboardCursorEnabled;
|
||||
|
||||
if (keyboardCursorEnabled) {
|
||||
for (const [collection, item] of Object.entries(this._focused)) {
|
||||
this._keyboardCursor[collection] = item;
|
||||
}
|
||||
this._focused = {}
|
||||
} else {
|
||||
for (const [collection, item] of Object.entries(this._keyboardCursor)) {
|
||||
this._onFocus({collection, item});
|
||||
}
|
||||
}
|
||||
}
|
||||
this.trigger({ impactsCollection: () => true });
|
||||
}
|
||||
|
||||
_onDataChange = (change) => {
|
||||
// If one of the objects we're storing in our focused or keyboard cursor
|
||||
// dictionaries has changed, we need to let our observers know, since they
|
||||
// may now be holding on to outdated data.
|
||||
if (!change || !change.objectClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touched = [];
|
||||
|
||||
for (const data of [this._focused, this._keyboardCursor]) {
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (!val || val.constructor.name !== change.objectClass) {
|
||||
continue;
|
||||
}
|
||||
for (const obj of change.objects) {
|
||||
if (val.id === obj.id) {
|
||||
data[key] = (change.type === 'unpersist') ? null : obj;
|
||||
touched.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (touched.length > 0) {
|
||||
this.trigger({ impactsCollection: (c) => c in touched });
|
||||
}
|
||||
}
|
||||
|
||||
// Public Methods
|
||||
|
||||
/**
|
||||
Public: Returns the focused {Model} in the collection specified,
|
||||
or undefined if no item is focused.
|
||||
|
||||
- `collection` The {String} name of a collection. Standard collections are
|
||||
listed above.
|
||||
*/
|
||||
focused(collection) {
|
||||
return this._focused[collection];
|
||||
}
|
||||
|
||||
/**
|
||||
Public: Returns the ID of the focused {Model} in the collection specified,
|
||||
or undefined if no item is focused.
|
||||
|
||||
- `collection` The {String} name of a collection. Standard collections are
|
||||
listed above.
|
||||
*/
|
||||
focusedId(collection) {
|
||||
return this._focused[collection] && this._focused[collection].id;
|
||||
}
|
||||
|
||||
/**
|
||||
Public: Returns true if the item for the collection was focused via a click or
|
||||
false otherwise.
|
||||
|
||||
- `collection` The {String} name of a collection. Standard collections are
|
||||
listed above.
|
||||
*/
|
||||
didFocusUsingClick(collection) {
|
||||
return this._focusedUsingClick[collection] || false;
|
||||
}
|
||||
|
||||
/**
|
||||
Public: Returns the {Model} the keyboard is currently focused on
|
||||
in the collection specified. Keyboard focus is not always separate from
|
||||
primary focus (selection). You can use {::keyboardCursorEnabled} to determine
|
||||
whether keyboard focus is enabled.
|
||||
|
||||
- `collection` The {String} name of a collection. Standard collections are
|
||||
listed above.
|
||||
*/
|
||||
keyboardCursor(collection) {
|
||||
return this._keyboardCursor[collection];
|
||||
}
|
||||
|
||||
/**
|
||||
Public: Returns the ID of the {Model} the keyboard is currently focused on
|
||||
in the collection specified. Keyboard focus is not always separate from
|
||||
primary focus (selection). You can use {::keyboardCursorEnabled} to determine
|
||||
whether keyboard focus is enabled.
|
||||
|
||||
- `collection` The {String} name of a collection. Standard collections are
|
||||
listed above.
|
||||
*/
|
||||
keyboardCursorId(collection) {
|
||||
return this._keyboardCursor[collection] && this._keyboardCursor[collection].id;
|
||||
}
|
||||
|
||||
/**
|
||||
Public: Returns a {Boolean} - `true` if the keyboard cursor concept applies in
|
||||
the current {WorkspaceStore} layout mode. The keyboard cursor is currently only
|
||||
enabled in `list` mode.
|
||||
*/
|
||||
keyboardCursorEnabled() {
|
||||
return this._keyboardCursorEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
export default new FocusedContentStore()
|
|
@ -99,7 +99,7 @@ class MailRulesStore extends NylasStore {
|
|||
|
||||
// Cancel all bulk processing jobs
|
||||
for (const task of TaskQueue.findTasks(ReprocessMailRulesTask, {})) {
|
||||
Actions.dequeueTask(task.id);
|
||||
Actions.cancelTask(task);
|
||||
}
|
||||
|
||||
this.trigger();
|
||||
|
|
|
@ -6,7 +6,7 @@ Utils = require '../models/utils'
|
|||
DatabaseStore = require("./database-store").default
|
||||
TaskFactory = require("../tasks/task-factory").default
|
||||
FocusedPerspectiveStore = require('./focused-perspective-store').default
|
||||
FocusedContentStore = require "./focused-content-store"
|
||||
FocusedContentStore = require("./focused-content-store").default
|
||||
NylasAPIHelpers = require '../nylas-api-helpers'
|
||||
ExtensionRegistry = require('../../registries/extension-registry')
|
||||
async = require 'async'
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
_ = require 'underscore'
|
||||
NylasStore = require 'nylas-store'
|
||||
DatabaseStore = require('./database-store').default
|
||||
Thread = require('../models/thread').default
|
||||
|
||||
class ThreadCountsStore extends NylasStore
|
||||
constructor: ->
|
||||
@_counts = {}
|
||||
|
||||
if NylasEnv.isMainWindow()
|
||||
# For now, unread counts are only retrieved in the main window.
|
||||
@_onCountsChangedDebounced = _.throttle(@_onCountsChanged, 1000)
|
||||
DatabaseStore.listen (change) =>
|
||||
if change.objectClass is Thread.name
|
||||
@_onCountsChangedDebounced()
|
||||
@_onCountsChangedDebounced()
|
||||
|
||||
_onCountsChanged: =>
|
||||
DatabaseStore._query("SELECT * FROM `ThreadCounts`").then (results) =>
|
||||
nextCounts = {}
|
||||
for {categoryId, unread, total} in results
|
||||
nextCounts[categoryId] = {unread, total}
|
||||
if _.isEqual(nextCounts, @_counts)
|
||||
return
|
||||
@_counts = nextCounts
|
||||
@trigger()
|
||||
|
||||
unreadCountForCategoryId: (catId) =>
|
||||
return null if @_counts[catId] is undefined
|
||||
@_counts[catId]['unread']
|
||||
|
||||
totalCountForCategoryId: (catId) =>
|
||||
return null if @_counts[catId] is undefined
|
||||
@_counts[catId]['total']
|
||||
|
||||
module.exports = new ThreadCountsStore
|
52
packages/client-app/src/flux/stores/thread-counts-store.es6
Normal file
52
packages/client-app/src/flux/stores/thread-counts-store.es6
Normal file
|
@ -0,0 +1,52 @@
|
|||
import _ from 'underscore';
|
||||
import NylasStore from 'nylas-store'
|
||||
import DatabaseStore from './database-store';
|
||||
import Thread from '../models/thread';
|
||||
|
||||
class ThreadCountsStore extends NylasStore {
|
||||
constructor() {
|
||||
super();
|
||||
this._counts = {};
|
||||
|
||||
if (NylasEnv.isMainWindow()) {
|
||||
// For now, unread counts are only retrieved in the main window.
|
||||
this._onCountsChangedDebounced = _.throttle(this._onCountsChanged, 1000);
|
||||
DatabaseStore.listen((change) => {
|
||||
if (change.objectClass === Thread.name) {
|
||||
this._onCountsChangedDebounced();
|
||||
}
|
||||
});
|
||||
this._onCountsChangedDebounced();
|
||||
}
|
||||
}
|
||||
|
||||
_onCountsChanged = () => {
|
||||
DatabaseStore._query("SELECT * FROM `ThreadCounts`").then((results) => {
|
||||
const nextCounts = {};
|
||||
for (const {categoryId, unread, total} of results) {
|
||||
nextCounts[categoryId] = {unread, total};
|
||||
}
|
||||
if (_.isEqual(nextCounts, this._counts)) {
|
||||
return;
|
||||
}
|
||||
this._counts = nextCounts;
|
||||
this.trigger();
|
||||
});
|
||||
}
|
||||
|
||||
unreadCountForCategoryId(catId) {
|
||||
if (this._counts[catId] === undefined) {
|
||||
return null;
|
||||
}
|
||||
return this._counts[catId]['unread'];
|
||||
}
|
||||
|
||||
totalCountForCategoryId(catId) {
|
||||
if (this._counts[catId] === undefined) {
|
||||
return null;
|
||||
}
|
||||
return this._counts[catId]['total']
|
||||
}
|
||||
}
|
||||
|
||||
export default new ThreadCountsStore();
|
|
@ -1,8 +1,8 @@
|
|||
_ = require 'underscore'
|
||||
Actions = require('../actions').default
|
||||
AccountStore = require('./account-store').default
|
||||
CategoryStore = require './category-store'
|
||||
MailboxPerspective = require '../../mailbox-perspective'
|
||||
CategoryStore = require('./category-store').default
|
||||
MailboxPerspective = require('../../mailbox-perspective').default
|
||||
NylasStore = require 'nylas-store'
|
||||
|
||||
Sheet = {}
|
||||
|
|
|
@ -1,120 +0,0 @@
|
|||
# Publically exposed Nylas UI Components
|
||||
class NylasComponentKit
|
||||
|
||||
@default = (requireValue) -> requireValue.default ? requireValue
|
||||
|
||||
@load = (prop, path) ->
|
||||
Object.defineProperty @prototype, prop,
|
||||
get: ->
|
||||
NylasComponentKit.default(require "../components/#{path}")
|
||||
|
||||
# We use require to load the component immediately (instead of lazy loading)
|
||||
# to improve visible latency,
|
||||
# Sometimes a component won't be loaded until the user performs an action
|
||||
# (e.g. opening a popover), so we don't want to wait until that happens to load the
|
||||
# component. In our example, the popover would take a long time to open the first time
|
||||
# if it was lazy loaded
|
||||
@require = (prop, path) ->
|
||||
exported = NylasComponentKit.default(require "../components/#{path}")
|
||||
Object.defineProperty @prototype, prop,
|
||||
get: -> exported
|
||||
|
||||
@requireFrom = (prop, path) ->
|
||||
exported = require "../components/#{path}"
|
||||
Object.defineProperty @prototype, prop,
|
||||
get: -> exported[prop]
|
||||
|
||||
@loadFrom = (prop, path) ->
|
||||
Object.defineProperty @prototype, prop,
|
||||
get: ->
|
||||
exported = require "../components/#{path}"
|
||||
return exported[prop]
|
||||
|
||||
|
||||
@load "Menu", 'menu'
|
||||
@load "DropZone", 'drop-zone'
|
||||
@load "Spinner", 'spinner'
|
||||
@load "Switch", 'switch'
|
||||
@load "FixedPopover", 'fixed-popover'
|
||||
@require "DatePickerPopover", 'date-picker-popover'
|
||||
@load "Modal", 'modal'
|
||||
@load "Webview", 'webview'
|
||||
@load "FeatureUsedUpModal", 'feature-used-up-modal'
|
||||
@load "BillingModal", 'billing-modal'
|
||||
@load "OpenIdentityPageButton", 'open-identity-page-button'
|
||||
@load "Flexbox", 'flexbox'
|
||||
@load "RetinaImg", 'retina-img'
|
||||
@load "SwipeContainer", 'swipe-container'
|
||||
@load "FluxContainer", 'flux-container'
|
||||
@load "FocusContainer", 'focus-container'
|
||||
@load "SyncingListState", 'syncing-list-state'
|
||||
@load "EmptyListState", 'empty-list-state'
|
||||
@load "ListTabular", 'list-tabular'
|
||||
@load "Notification", 'notification'
|
||||
@load "NylasCalendar", 'nylas-calendar/nylas-calendar'
|
||||
@load "MiniMonthView", 'nylas-calendar/mini-month-view'
|
||||
@load "CalendarEventPopover", 'nylas-calendar/calendar-event-popover'
|
||||
@load "EventedIFrame", 'evented-iframe'
|
||||
@load "ButtonDropdown", 'button-dropdown'
|
||||
@load "Contenteditable", 'contenteditable/contenteditable'
|
||||
@load "MultiselectList", 'multiselect-list'
|
||||
@load "BoldedSearchResult", 'bolded-search-result'
|
||||
@load "MultiselectDropdown", "multiselect-dropdown"
|
||||
@load "KeyCommandsRegion", 'key-commands-region'
|
||||
@load "TabGroupRegion", 'tab-group-region'
|
||||
@load "InjectedComponent", 'injected-component'
|
||||
@load "TokenizingTextField", 'tokenizing-text-field'
|
||||
@load "ParticipantsTextField", 'participants-text-field'
|
||||
@load "MultiselectToolbar", 'multiselect-toolbar'
|
||||
@load "InjectedComponentSet", 'injected-component-set'
|
||||
@load "MetadataComposerToggleButton", 'metadata-composer-toggle-button'
|
||||
@load "ConfigPropContainer", "config-prop-container"
|
||||
@load "DisclosureTriangle", "disclosure-triangle"
|
||||
@load "EditableList", "editable-list"
|
||||
@load "OutlineViewItem", "outline-view-item"
|
||||
@load "OutlineView", "outline-view"
|
||||
@load "DateInput", "date-input"
|
||||
@load "DatePicker", "date-picker"
|
||||
@load "TimePicker", "time-picker"
|
||||
@load "Table", "table/table"
|
||||
@loadFrom "TableRow", "table/table"
|
||||
@loadFrom "TableCell", "table/table"
|
||||
@load "SelectableTable", "selectable-table"
|
||||
@loadFrom "SelectableTableRow", "selectable-table"
|
||||
@loadFrom "SelectableTableCell", "selectable-table"
|
||||
@load "EditableTable", "editable-table"
|
||||
@loadFrom "EditableTableCell", "editable-table"
|
||||
@load "Toast", "toast"
|
||||
@load "UndoToast", "undo-toast"
|
||||
@load "LazyRenderedList", "lazy-rendered-list"
|
||||
@load "OverlaidComponents", "overlaid-components/overlaid-components"
|
||||
@load "OverlaidComposerExtension", "overlaid-components/overlaid-composer-extension"
|
||||
@load "OAuthSignInPage", "oauth-signin-page"
|
||||
@requireFrom "AttachmentItem", "attachment-items"
|
||||
@requireFrom "ImageAttachmentItem", "attachment-items"
|
||||
@load "CodeSnippet", "code-snippet"
|
||||
|
||||
@load "ScrollRegion", 'scroll-region'
|
||||
@load "ResizableRegion", 'resizable-region'
|
||||
|
||||
@loadFrom "MailLabel", "mail-label"
|
||||
@loadFrom "LabelColorizer", "mail-label"
|
||||
@load "MailLabelSet", "mail-label-set"
|
||||
@load "MailImportantIcon", 'mail-important-icon'
|
||||
|
||||
@loadFrom "FormItem", "generated-form"
|
||||
@loadFrom "GeneratedForm", "generated-form"
|
||||
@loadFrom "GeneratedFieldset", "generated-form"
|
||||
|
||||
@load "ScenarioEditor", 'scenario-editor'
|
||||
@load "NewsletterSignup", 'newsletter-signup'
|
||||
|
||||
@load "SearchBar", 'search-bar'
|
||||
|
||||
# Higher order components
|
||||
@load "ListensToObservable", 'decorators/listens-to-observable'
|
||||
@load "ListensToFluxStore", 'decorators/listens-to-flux-store'
|
||||
@load "ListensToMovementKeys", 'decorators/listens-to-movement-keys'
|
||||
@load "HasTutorialTip", 'decorators/has-tutorial-tip'
|
||||
|
||||
module.exports = new NylasComponentKit()
|
121
packages/client-app/src/global/nylas-component-kit.es6
Normal file
121
packages/client-app/src/global/nylas-component-kit.es6
Normal file
|
@ -0,0 +1,121 @@
|
|||
/* eslint global-require: 0 */
|
||||
/* eslint import/no-dynamic-require: 0 */
|
||||
|
||||
// This module exports an empty object, with a ton of defined properties that
|
||||
// `require` files the first time they're called.
|
||||
module.exports = exports = {};
|
||||
|
||||
const resolveExport = (requireValue) => {
|
||||
return requireValue.default || requireValue;
|
||||
}
|
||||
|
||||
const lazyLoadWithGetter = (prop, getter) => {
|
||||
const key = `${prop}`;
|
||||
|
||||
if (exports[key]) {
|
||||
throw new Error(`Fatal error: Duplicate entry in nylas-exports: ${key}`)
|
||||
}
|
||||
Object.defineProperty(exports, prop, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
const value = getter();
|
||||
Object.defineProperty(exports, prop, { enumerable: true, value });
|
||||
return value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const lazyLoad = (prop, path) => {
|
||||
lazyLoadWithGetter(prop, () => resolveExport(require(`../components/${path}`)));
|
||||
};
|
||||
|
||||
const lazyLoadFrom = (prop, path) => {
|
||||
lazyLoadWithGetter(prop, () => resolveExport(require(`../components/${path}`))[prop]);
|
||||
};
|
||||
|
||||
lazyLoad("Menu", 'menu');
|
||||
lazyLoad("DropZone", 'drop-zone');
|
||||
lazyLoad("Spinner", 'spinner');
|
||||
lazyLoad("Switch", 'switch');
|
||||
lazyLoad("FixedPopover", 'fixed-popover');
|
||||
lazyLoad("DatePickerPopover", 'date-picker-popover');
|
||||
lazyLoad("Modal", 'modal');
|
||||
lazyLoad("Webview", 'webview');
|
||||
lazyLoad("FeatureUsedUpModal", 'feature-used-up-modal');
|
||||
lazyLoad("BillingModal", 'billing-modal');
|
||||
lazyLoad("OpenIdentityPageButton", 'open-identity-page-button');
|
||||
lazyLoad("Flexbox", 'flexbox');
|
||||
lazyLoad("RetinaImg", 'retina-img');
|
||||
lazyLoad("SwipeContainer", 'swipe-container');
|
||||
lazyLoad("FluxContainer", 'flux-container');
|
||||
lazyLoad("FocusContainer", 'focus-container');
|
||||
lazyLoad("SyncingListState", 'syncing-list-state');
|
||||
lazyLoad("EmptyListState", 'empty-list-state');
|
||||
lazyLoad("ListTabular", 'list-tabular');
|
||||
lazyLoad("Notification", 'notification');
|
||||
lazyLoad("NylasCalendar", 'nylas-calendar/nylas-calendar');
|
||||
lazyLoad("MiniMonthView", 'nylas-calendar/mini-month-view');
|
||||
lazyLoad("CalendarEventPopover", 'nylas-calendar/calendar-event-popover');
|
||||
lazyLoad("EventedIFrame", 'evented-iframe');
|
||||
lazyLoad("ButtonDropdown", 'button-dropdown');
|
||||
lazyLoad("Contenteditable", 'contenteditable/contenteditable');
|
||||
lazyLoad("MultiselectList", 'multiselect-list');
|
||||
lazyLoad("BoldedSearchResult", 'bolded-search-result');
|
||||
lazyLoad("MultiselectDropdown", "multiselect-dropdown");
|
||||
lazyLoad("KeyCommandsRegion", 'key-commands-region');
|
||||
lazyLoad("TabGroupRegion", 'tab-group-region');
|
||||
lazyLoad("InjectedComponent", 'injected-component');
|
||||
lazyLoad("TokenizingTextField", 'tokenizing-text-field');
|
||||
lazyLoad("ParticipantsTextField", 'participants-text-field');
|
||||
lazyLoad("MultiselectToolbar", 'multiselect-toolbar');
|
||||
lazyLoad("InjectedComponentSet", 'injected-component-set');
|
||||
lazyLoad("MetadataComposerToggleButton", 'metadata-composer-toggle-button');
|
||||
lazyLoad("ConfigPropContainer", "config-prop-container");
|
||||
lazyLoad("DisclosureTriangle", "disclosure-triangle");
|
||||
lazyLoad("EditableList", "editable-list");
|
||||
lazyLoad("OutlineViewItem", "outline-view-item");
|
||||
lazyLoad("OutlineView", "outline-view");
|
||||
lazyLoad("DateInput", "date-input");
|
||||
lazyLoad("DatePicker", "date-picker");
|
||||
lazyLoad("TimePicker", "time-picker");
|
||||
lazyLoad("Table", "table/table");
|
||||
lazyLoadFrom("TableRow", "table/table");
|
||||
lazyLoadFrom("TableCell", "table/table");
|
||||
lazyLoad("SelectableTable", "selectable-table");
|
||||
lazyLoadFrom("SelectableTableRow", "selectable-table");
|
||||
lazyLoadFrom("SelectableTableCell", "selectable-table");
|
||||
lazyLoad("EditableTable", "editable-table");
|
||||
lazyLoadFrom("EditableTableCell", "editable-table");
|
||||
lazyLoad("Toast", "toast");
|
||||
lazyLoad("UndoToast", "undo-toast");
|
||||
lazyLoad("LazyRenderedList", "lazy-rendered-list");
|
||||
lazyLoad("OverlaidComponents", "overlaid-components/overlaid-components");
|
||||
lazyLoad("OverlaidComposerExtension", "overlaid-components/overlaid-composer-extension");
|
||||
lazyLoad("OAuthSignInPage", "oauth-signin-page");
|
||||
lazyLoadFrom("AttachmentItem", "attachment-items");
|
||||
lazyLoadFrom("ImageAttachmentItem", "attachment-items");
|
||||
lazyLoad("CodeSnippet", "code-snippet");
|
||||
|
||||
lazyLoad("ScrollRegion", 'scroll-region');
|
||||
lazyLoad("ResizableRegion", 'resizable-region');
|
||||
|
||||
lazyLoadFrom("MailLabel", "mail-label");
|
||||
lazyLoadFrom("LabelColorizer", "mail-label");
|
||||
lazyLoad("MailLabelSet", "mail-label-set");
|
||||
lazyLoad("MailImportantIcon", 'mail-important-icon');
|
||||
|
||||
lazyLoadFrom("FormItem", "generated-form");
|
||||
lazyLoadFrom("GeneratedForm", "generated-form");
|
||||
lazyLoadFrom("GeneratedFieldset", "generated-form");
|
||||
|
||||
lazyLoad("ScenarioEditor", 'scenario-editor');
|
||||
lazyLoad("NewsletterSignup", 'newsletter-signup');
|
||||
|
||||
lazyLoad("SearchBar", 'search-bar');
|
||||
|
||||
// Higher order components
|
||||
lazyLoad("ListensToObservable", 'decorators/listens-to-observable');
|
||||
lazyLoad("ListensToFluxStore", 'decorators/listens-to-flux-store');
|
||||
lazyLoad("ListensToMovementKeys", 'decorators/listens-to-movement-keys');
|
||||
lazyLoad("HasTutorialTip", 'decorators/has-tutorial-tip');
|
|
@ -3,20 +3,14 @@
|
|||
import StoreRegistry from '../registries/store-registry'
|
||||
import DatabaseObjectRegistry from '../registries/database-object-registry'
|
||||
|
||||
const resolveExport = (requireValue) => {
|
||||
return requireValue.default || requireValue;
|
||||
}
|
||||
|
||||
// This module exports an empty object, with a ton of defined properties that
|
||||
// `require` files the first time they're called.
|
||||
module.exports = exports = window.$n = {};
|
||||
|
||||
// Calling require() repeatedly isn't free! Even though it has it's own cache,
|
||||
// it still needs to resolve the path to a file based on the current __dirname,
|
||||
// match it against it's cache, etc. We can shortcut all this work.
|
||||
const RequireCache = {};
|
||||
const resolveExport = (requireValue) => {
|
||||
return requireValue.default || requireValue;
|
||||
}
|
||||
|
||||
// Will lazy load when requested
|
||||
const lazyLoadWithGetter = (prop, getter) => {
|
||||
const key = `${prop}`;
|
||||
|
||||
|
@ -24,11 +18,13 @@ const lazyLoadWithGetter = (prop, getter) => {
|
|||
throw new Error(`Fatal error: Duplicate entry in nylas-exports: ${key}`)
|
||||
}
|
||||
Object.defineProperty(exports, prop, {
|
||||
get: () => {
|
||||
RequireCache[key] = RequireCache[key] || getter();
|
||||
return RequireCache[key];
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
const value = getter();
|
||||
Object.defineProperty(exports, prop, { enumerable: true, value });
|
||||
return value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -196,7 +192,6 @@ lazyLoad(`SanitizeTransformer`, 'services/sanitize-transformer');
|
|||
lazyLoad(`QuotedHTMLTransformer`, 'services/quoted-html-transformer');
|
||||
lazyLoad(`InlineStyleTransformer`, 'services/inline-style-transformer');
|
||||
lazyLoad(`SearchableComponentMaker`, 'searchable-components/searchable-component-maker');
|
||||
lazyLoad(`QuotedPlainTextTransformer`, 'services/quoted-plain-text-transformer');
|
||||
lazyLoad(`BatteryStatusManager`, 'services/battery-status-manager');
|
||||
|
||||
// Errors
|
||||
|
@ -204,7 +199,6 @@ lazyLoadWithGetter(`APIError`, () => require('../flux/errors').APIError);
|
|||
|
||||
// Process Internals
|
||||
lazyLoad(`DefaultClientHelper`, 'default-client-helper');
|
||||
lazyLoad(`BufferedProcess`, 'buffered-process');
|
||||
lazyLoad(`SystemStartService`, 'system-start-service');
|
||||
|
||||
// Testing
|
||||
|
|
|
@ -1,126 +0,0 @@
|
|||
Rx = require 'rx-lite'
|
||||
_ = require 'underscore'
|
||||
Folder = require('../flux/models/folder').default
|
||||
Label = require('../flux/models/label').default
|
||||
QuerySubscriptionPool = require('../flux/models/query-subscription-pool').default
|
||||
DatabaseStore = require('../flux/stores/database-store').default
|
||||
|
||||
CategoryOperators =
|
||||
sort: ->
|
||||
obs = @.map (categories) ->
|
||||
return categories.sort (catA, catB) ->
|
||||
nameA = catA.displayName
|
||||
nameB = catB.displayName
|
||||
|
||||
# Categories that begin with [, like [Mailbox]/For Later
|
||||
# should appear at the bottom, because they're likely autogenerated.
|
||||
nameA = "ZZZ"+nameA if nameA[0] is '['
|
||||
nameB = "ZZZ"+nameB if nameB[0] is '['
|
||||
|
||||
nameA.localeCompare(nameB)
|
||||
Object.assign(obs, CategoryOperators)
|
||||
|
||||
categoryFilter: (filter) ->
|
||||
obs = @.map (categories) ->
|
||||
return categories.filter filter
|
||||
Object.assign(obs, CategoryOperators)
|
||||
|
||||
CategoryObservables =
|
||||
|
||||
forAllAccounts: =>
|
||||
folders = Rx.Observable.fromQuery(DatabaseStore.findAll(Folder))
|
||||
labels = Rx.Observable.fromQuery(DatabaseStore.findAll(Label))
|
||||
joined = Rx.Observable.combineLatest(folders, labels, (f, l) => [].concat(f, l))
|
||||
Object.assign(joined, CategoryOperators)
|
||||
joined
|
||||
|
||||
forAccount: (account) =>
|
||||
if account
|
||||
folders = Rx.Observable.fromQuery(DatabaseStore.findAll(Folder).where(accountId: account.id))
|
||||
labels = Rx.Observable.fromQuery(DatabaseStore.findAll(Label).where(accountId: account.id))
|
||||
joined = Rx.Observable.combineLatest(folders, labels, (f, l) => [].concat(f, l))
|
||||
else
|
||||
folders = Rx.Observable.fromQuery(DatabaseStore.findAll(Folder))
|
||||
labels = Rx.Observable.fromQuery(DatabaseStore.findAll(Label))
|
||||
joined = Rx.Observable.combineLatest(folders, labels, (f, l) => [].concat(f, l))
|
||||
Object.assign(joined, CategoryOperators)
|
||||
joined
|
||||
|
||||
standard: (account) =>
|
||||
observable = Rx.Observable.fromConfig('core.workspace.showImportant')
|
||||
.flatMapLatest (showImportant) =>
|
||||
return CategoryObservables.forAccount(account).sort()
|
||||
.categoryFilter (cat) -> cat.isStandardCategory(showImportant)
|
||||
Object.assign(observable, CategoryOperators)
|
||||
observable
|
||||
|
||||
user: (account) =>
|
||||
CategoryObservables.forAccount(account).sort()
|
||||
.categoryFilter (cat) -> cat.isUserCategory()
|
||||
|
||||
hidden: (account) =>
|
||||
CategoryObservables.forAccount(account).sort()
|
||||
.categoryFilter (cat) -> cat.isHiddenCategory()
|
||||
|
||||
module.exports =
|
||||
Categories: CategoryObservables
|
||||
|
||||
# Attach a few global helpers
|
||||
|
||||
Rx.Observable.fromStore = (store) =>
|
||||
return Rx.Observable.create (observer) =>
|
||||
unsubscribe = store.listen =>
|
||||
observer.onNext(store)
|
||||
observer.onNext(store)
|
||||
return Rx.Disposable.create(unsubscribe)
|
||||
|
||||
# Takes a store that provides an {ObservableListDataSource} via `dataSource()`
|
||||
# Returns an observable that provides array of selected items on subscription
|
||||
Rx.Observable.fromListSelection = (originStore) =>
|
||||
return Rx.Observable.create((observer) =>
|
||||
dataSourceDisposable = null
|
||||
storeObservable = Rx.Observable.fromStore(originStore)
|
||||
|
||||
disposable = storeObservable.subscribe( =>
|
||||
dataSource = originStore.dataSource()
|
||||
dataSourceObservable = Rx.Observable.fromStore(dataSource)
|
||||
|
||||
if dataSourceDisposable
|
||||
dataSourceDisposable.dispose()
|
||||
|
||||
dataSourceDisposable = dataSourceObservable.subscribe( =>
|
||||
observer.onNext(dataSource.selection.items())
|
||||
)
|
||||
return
|
||||
)
|
||||
dispose = =>
|
||||
if dataSourceDisposable
|
||||
dataSourceDisposable.dispose()
|
||||
disposable.dispose()
|
||||
return Rx.Disposable.create(dispose)
|
||||
)
|
||||
|
||||
Rx.Observable.fromConfig = (configKey) =>
|
||||
return Rx.Observable.create (observer) =>
|
||||
disposable = NylasEnv.config.onDidChange configKey, =>
|
||||
observer.onNext(NylasEnv.config.get(configKey))
|
||||
observer.onNext(NylasEnv.config.get(configKey))
|
||||
return Rx.Disposable.create(disposable.dispose)
|
||||
|
||||
Rx.Observable.fromAction = (action) =>
|
||||
return Rx.Observable.create (observer) =>
|
||||
unsubscribe = action.listen (args...) =>
|
||||
observer.onNext(args...)
|
||||
return Rx.Disposable.create(unsubscribe)
|
||||
|
||||
Rx.Observable.fromQuery = (query) =>
|
||||
return Rx.Observable.create (observer) =>
|
||||
unsubscribe = QuerySubscriptionPool.add query, (result) =>
|
||||
observer.onNext(result)
|
||||
return Rx.Disposable.create(unsubscribe)
|
||||
|
||||
Rx.Observable.fromNamedQuerySubscription = (name, subscription) =>
|
||||
return Rx.Observable.create (observer) =>
|
||||
unsubscribe = QuerySubscriptionPool.addPrivateSubscription name, subscription, (result) =>
|
||||
observer.onNext(result)
|
||||
return Rx.Disposable.create(unsubscribe)
|
160
packages/client-app/src/global/nylas-observables.es6
Normal file
160
packages/client-app/src/global/nylas-observables.es6
Normal file
|
@ -0,0 +1,160 @@
|
|||
import Rx from 'rx-lite'
|
||||
import Folder from '../flux/models/folder'
|
||||
import Label from '../flux/models/label'
|
||||
import QuerySubscriptionPool from '../flux/models/query-subscription-pool'
|
||||
import DatabaseStore from '../flux/stores/database-store'
|
||||
|
||||
const CategoryOperators = {
|
||||
sort() {
|
||||
const obs = this.map((categories) => {
|
||||
return categories.sort((catA, catB) => {
|
||||
let nameA = catA.displayName
|
||||
let nameB = catB.displayName
|
||||
|
||||
// Categories that begin with [, like [Mailbox]/For Later
|
||||
// should appear at the bottom, because they're likely autogenerated.
|
||||
if (nameA[0] === '[') {
|
||||
nameA = `ZZZ${nameA}`;
|
||||
}
|
||||
if (nameB[0] === '[') {
|
||||
nameB = `ZZZ${nameB}`;
|
||||
}
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
});
|
||||
return Object.assign(obs, CategoryOperators);
|
||||
},
|
||||
|
||||
categoryFilter(filter) {
|
||||
const obs = this.map((categories) =>
|
||||
categories.filter(filter)
|
||||
);
|
||||
return Object.assign(obs, CategoryOperators);
|
||||
},
|
||||
}
|
||||
|
||||
const CategoryObservables = {
|
||||
forAllAccounts() {
|
||||
const folders = Rx.Observable.fromQuery(DatabaseStore.findAll(Folder));
|
||||
const labels = Rx.Observable.fromQuery(DatabaseStore.findAll(Label));
|
||||
const joined = Rx.Observable.combineLatest(folders, labels, (f, l) => [].concat(f, l));
|
||||
Object.assign(joined, CategoryOperators);
|
||||
return joined
|
||||
},
|
||||
|
||||
forAccount(account) {
|
||||
const scoped = account ? (q) => q.where({accountId: account.id}) : (q) => q
|
||||
|
||||
const folders = Rx.Observable.fromQuery(scoped(DatabaseStore.findAll(Folder)))
|
||||
const labels = Rx.Observable.fromQuery(scoped(DatabaseStore.findAll(Label)))
|
||||
const joined = Rx.Observable.combineLatest(folders, labels, (f, l) => [].concat(f, l))
|
||||
Object.assign(joined, CategoryOperators)
|
||||
return joined;
|
||||
},
|
||||
|
||||
standard(account) {
|
||||
const observable = Rx.Observable
|
||||
.fromConfig('core.workspace.showImportant')
|
||||
.flatMapLatest((showImportant) => {
|
||||
return CategoryObservables.forAccount(account).sort()
|
||||
.categoryFilter((cat) => cat.isStandardCategory(showImportant))
|
||||
});
|
||||
Object.assign(observable, CategoryOperators)
|
||||
return observable;
|
||||
},
|
||||
|
||||
user(account) {
|
||||
return CategoryObservables.forAccount(account)
|
||||
.sort()
|
||||
.categoryFilter((cat) => cat.isUserCategory())
|
||||
},
|
||||
|
||||
hidden(account) {
|
||||
return CategoryObservables.forAccount(account)
|
||||
.sort()
|
||||
.categoryFilter((cat) => cat.isHiddenCategory())
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Categories: CategoryObservables,
|
||||
}
|
||||
|
||||
// Attach a few global helpers
|
||||
|
||||
Rx.Observable.fromStore = (store) => {
|
||||
return Rx.Observable.create((observer) => {
|
||||
const unsubscribe = store.listen(() => {
|
||||
observer.onNext(store);
|
||||
});
|
||||
observer.onNext(store);
|
||||
return Rx.Disposable.create(unsubscribe);
|
||||
});
|
||||
}
|
||||
|
||||
// Takes a store that provides an {ObservableListDataSource} via `dataSource()`
|
||||
// Returns an observable that provides array of selected items on subscription
|
||||
Rx.Observable.fromListSelection = (originStore) => {
|
||||
return Rx.Observable.create((observer) => {
|
||||
let dataSourceDisposable = null;
|
||||
const storeObservable = Rx.Observable.fromStore(originStore)
|
||||
|
||||
const disposable = storeObservable.subscribe(() => {
|
||||
const dataSource = originStore.dataSource()
|
||||
const dataSourceObservable = Rx.Observable.fromStore(dataSource)
|
||||
|
||||
if (dataSourceDisposable) {
|
||||
dataSourceDisposable.dispose();
|
||||
}
|
||||
|
||||
dataSourceDisposable = dataSourceObservable.subscribe(() =>
|
||||
observer.onNext(dataSource.selection.items())
|
||||
);
|
||||
return;
|
||||
});
|
||||
const dispose = () => {
|
||||
if (dataSourceDisposable) {
|
||||
dataSourceDisposable.dispose();
|
||||
}
|
||||
disposable.dispose();
|
||||
}
|
||||
return Rx.Disposable.create(dispose);
|
||||
})
|
||||
}
|
||||
|
||||
Rx.Observable.fromConfig = (configKey) => {
|
||||
return Rx.Observable.create((observer) => {
|
||||
const disposable = NylasEnv.config.onDidChange(configKey, () =>
|
||||
observer.onNext(NylasEnv.config.get(configKey))
|
||||
);
|
||||
observer.onNext(NylasEnv.config.get(configKey))
|
||||
return Rx.Disposable.create(disposable.dispose)
|
||||
});
|
||||
}
|
||||
|
||||
Rx.Observable.fromAction = (action) => {
|
||||
return Rx.Observable.create((observer) => {
|
||||
const unsubscribe = action.listen((...args) =>
|
||||
observer.onNext(...args)
|
||||
);
|
||||
return Rx.Disposable.create(unsubscribe);
|
||||
});
|
||||
}
|
||||
|
||||
Rx.Observable.fromQuery = (query) => {
|
||||
return Rx.Observable.create((observer) => {
|
||||
const unsubscribe = QuerySubscriptionPool.add(query, (result) =>
|
||||
observer.onNext(result)
|
||||
);
|
||||
return Rx.Disposable.create(unsubscribe);
|
||||
});
|
||||
}
|
||||
|
||||
Rx.Observable.fromNamedQuerySubscription = (name, subscription) => {
|
||||
return Rx.Observable.create((observer) => {
|
||||
const unsubscribe = QuerySubscriptionPool.addPrivateSubscription(name, subscription, (result) =>
|
||||
observer.onNext(result)
|
||||
);
|
||||
return Rx.Disposable.create(unsubscribe);
|
||||
});
|
||||
}
|
|
@ -1,437 +0,0 @@
|
|||
_ = require 'underscore'
|
||||
|
||||
Utils = require './flux/models/utils'
|
||||
TaskFactory = require('./flux/tasks/task-factory').default
|
||||
AccountStore = require('./flux/stores/account-store').default
|
||||
CategoryStore = require './flux/stores/category-store'
|
||||
DatabaseStore = require('./flux/stores/database-store').default
|
||||
OutboxStore = require('./flux/stores/outbox-store').default
|
||||
ThreadCountsStore = require './flux/stores/thread-counts-store'
|
||||
RecentlyReadStore = require('./flux/stores/recently-read-store').default
|
||||
FolderSyncProgressStore = require('./flux/stores/folder-sync-progress-store').default
|
||||
MutableQuerySubscription = require('./flux/models/mutable-query-subscription').default
|
||||
UnreadQuerySubscription = require('./flux/models/unread-query-subscription').default
|
||||
Matcher = require('./flux/attributes/matcher').default
|
||||
Thread = require('./flux/models/thread').default
|
||||
Category = require('./flux/models/category').default
|
||||
Label = require('./flux/models/label').default
|
||||
Folder = require('./flux/models/folder').default
|
||||
Actions = require('./flux/actions').default
|
||||
|
||||
ChangeLabelsTask = require('./flux/tasks/change-labels-task').default
|
||||
ChangeFolderTask = require('./flux/tasks/change-folder-task').default
|
||||
ChangeUnreadTask = require('./flux/tasks/change-unread-task').default
|
||||
|
||||
# This is a class cluster. Subclasses are not for external use!
|
||||
# https://developer.apple.com/library/ios/documentation/General/Conceptual/CocoaEncyclopedia/ClassClusters/ClassClusters.html
|
||||
|
||||
|
||||
class MailboxPerspective
|
||||
|
||||
# Factory Methods
|
||||
@forNothing: ->
|
||||
new EmptyMailboxPerspective()
|
||||
|
||||
@forDrafts: (accountsOrIds) ->
|
||||
new DraftsMailboxPerspective(accountsOrIds)
|
||||
|
||||
@forCategory: (category) ->
|
||||
return @forNothing() unless category
|
||||
new CategoryMailboxPerspective([category])
|
||||
|
||||
@forCategories: (categories) ->
|
||||
return @forNothing() if categories.length is 0
|
||||
new CategoryMailboxPerspective(categories)
|
||||
|
||||
@forStandardCategories: (accountsOrIds, names...) ->
|
||||
# TODO this method is broken
|
||||
categories = CategoryStore.getCategoriesWithRoles(accountsOrIds, names...)
|
||||
@forCategories(categories)
|
||||
|
||||
@forStarred: (accountsOrIds) ->
|
||||
new StarredMailboxPerspective(accountsOrIds)
|
||||
|
||||
@forUnread: (categories) ->
|
||||
return @forNothing() if categories.length is 0
|
||||
new UnreadMailboxPerspective(categories)
|
||||
|
||||
@forInbox: (accountsOrIds) =>
|
||||
@forStandardCategories(accountsOrIds, 'inbox')
|
||||
|
||||
@fromJSON: (json) =>
|
||||
try
|
||||
if json.type is CategoryMailboxPerspective.name
|
||||
categories = JSON.parse(json.serializedCategories).map(Utils.convertToModel)
|
||||
return @forCategories(categories)
|
||||
else if json.type is UnreadMailboxPerspective.name
|
||||
categories = JSON.parse(json.serializedCategories).map(Utils.convertToModel)
|
||||
return @forUnread(categories)
|
||||
else if json.type is StarredMailboxPerspective.name
|
||||
return @forStarred(json.accountIds)
|
||||
else if json.type is DraftsMailboxPerspective.name
|
||||
return @forDrafts(json.accountIds)
|
||||
else
|
||||
return @forInbox(json.accountIds)
|
||||
catch error
|
||||
NylasEnv.reportError(new Error("Could not restore mailbox perspective: #{error}"))
|
||||
return null
|
||||
|
||||
# Instance Methods
|
||||
|
||||
constructor: (@accountIds) ->
|
||||
unless @accountIds instanceof Array and _.every(@accountIds, (aid) =>
|
||||
(typeof aid is 'string') or (typeof aid is 'number')
|
||||
)
|
||||
throw new Error("#{@constructor.name}: You must provide an array of string `accountIds`")
|
||||
@
|
||||
|
||||
toJSON: =>
|
||||
return {accountIds: @accountIds, type: @constructor.name}
|
||||
|
||||
isEqual: (other) =>
|
||||
return false unless other and @constructor is other.constructor
|
||||
return false unless other.name is @name
|
||||
return false unless _.isEqual(@accountIds, other.accountIds)
|
||||
true
|
||||
|
||||
isInbox: =>
|
||||
@categoriesSharedRole() is 'inbox'
|
||||
|
||||
isSent: =>
|
||||
@categoriesSharedRole() is 'sent'
|
||||
|
||||
isTrash: =>
|
||||
@categoriesSharedRole() is 'trash'
|
||||
|
||||
isArchive: =>
|
||||
false
|
||||
|
||||
emptyMessage: =>
|
||||
"No Messages"
|
||||
|
||||
categories: =>
|
||||
[]
|
||||
|
||||
# overwritten in CategoryMailboxPerspective
|
||||
hasSyncingCategories: =>
|
||||
false
|
||||
|
||||
categoriesSharedRole: =>
|
||||
@_categoriesSharedRole ?= Category.categoriesSharedRole(@categories())
|
||||
@_categoriesSharedRole
|
||||
|
||||
category: =>
|
||||
return null unless @categories().length is 1
|
||||
return @categories()[0]
|
||||
|
||||
threads: =>
|
||||
throw new Error("threads: Not implemented in base class.")
|
||||
|
||||
unreadCount: =>
|
||||
0
|
||||
|
||||
# Public:
|
||||
# - accountIds {Array} Array of unique account ids associated with the threads
|
||||
# that want to be included in this perspective
|
||||
#
|
||||
# Returns true if the accountIds are part of the current ids, or false
|
||||
# otherwise. This means that it checks if I am attempting to move threads
|
||||
# between the same set of accounts:
|
||||
#
|
||||
# E.g.:
|
||||
# perpective = Starred for accountIds: a1, a2
|
||||
# thread1 has accountId a3
|
||||
# thread2 has accountId a2
|
||||
#
|
||||
# perspective.canReceiveThreadsFromAccountIds([a2, a3]) -> false -> I cant move those threads to Starred
|
||||
# perspective.canReceiveThreadsFromAccountIds([a2]) -> true -> I can move that thread to # Starred
|
||||
canReceiveThreadsFromAccountIds: (accountIds) =>
|
||||
return false unless accountIds and accountIds.length > 0
|
||||
areIncomingIdsInCurrent = _.difference(accountIds, @accountIds).length is 0
|
||||
return areIncomingIdsInCurrent
|
||||
|
||||
receiveThreads: (threadsOrIds) =>
|
||||
throw new Error("receiveThreads: Not implemented in base class.")
|
||||
|
||||
canArchiveThreads: (threads) =>
|
||||
return false if @isArchive()
|
||||
accounts = AccountStore.accountsForItems(threads)
|
||||
return accounts.every (acc) -> acc.canArchiveThreads()
|
||||
|
||||
canTrashThreads: (threads) =>
|
||||
@canMoveThreadsTo(threads, 'trash')
|
||||
|
||||
canMoveThreadsTo: (threads, standardCategoryName) =>
|
||||
return false if @categoriesSharedRole() is standardCategoryName
|
||||
return AccountStore.accountsForItems(threads).every (acc) ->
|
||||
CategoryStore.getCategoryByRole(acc, standardCategoryName)?
|
||||
|
||||
tasksForRemovingItems: (threads) =>
|
||||
if not threads instanceof Array
|
||||
throw new Error("tasksForRemovingItems: you must pass an array of threads or thread ids")
|
||||
[]
|
||||
|
||||
|
||||
class DraftsMailboxPerspective extends MailboxPerspective
|
||||
constructor: (@accountIds) ->
|
||||
super(@accountIds)
|
||||
@name = "Drafts"
|
||||
@iconName = "drafts.png"
|
||||
@drafts = true # The DraftListStore looks for this
|
||||
@
|
||||
|
||||
threads: =>
|
||||
null
|
||||
|
||||
unreadCount: =>
|
||||
count = 0
|
||||
count += OutboxStore.itemsForAccount(aid).length for aid in @accountIds
|
||||
count
|
||||
|
||||
canReceiveThreadsFromAccountIds: =>
|
||||
false
|
||||
|
||||
|
||||
class StarredMailboxPerspective extends MailboxPerspective
|
||||
constructor: (@accountIds) ->
|
||||
super(@accountIds)
|
||||
@name = "Starred"
|
||||
@iconName = "starred.png"
|
||||
@
|
||||
|
||||
threads: =>
|
||||
query = DatabaseStore.findAll(Thread).where([
|
||||
Thread.attributes.starred.equal(true),
|
||||
Thread.attributes.inAllMail.equal(true),
|
||||
]).limit(0)
|
||||
|
||||
# Adding a "account_id IN (a,b,c)" clause to our query can result in a full
|
||||
# table scan. Don't add the where clause if we know we want results from all.
|
||||
if @accountIds.length < AccountStore.accounts().length
|
||||
query.where(Thread.attributes.accountId.in(@accountIds))
|
||||
|
||||
return new MutableQuerySubscription(query, {emitResultSet: true})
|
||||
|
||||
canReceiveThreadsFromAccountIds: =>
|
||||
super
|
||||
|
||||
receiveThreads: (threadsOrIds) =>
|
||||
ChangeStarredTask = require('./flux/tasks/change-starred-task').default
|
||||
task = new ChangeStarredTask({threads:threadsOrIds, starred: true, source: "Dragged Into List"})
|
||||
Actions.queueTask(task)
|
||||
|
||||
tasksForRemovingItems: (threads) =>
|
||||
task = TaskFactory.taskForInvertingStarred({
|
||||
threads: threads
|
||||
source: "Removed From List"
|
||||
})
|
||||
return [task]
|
||||
|
||||
|
||||
class EmptyMailboxPerspective extends MailboxPerspective
|
||||
constructor: ->
|
||||
@accountIds = []
|
||||
|
||||
threads: =>
|
||||
# We need a Thread query that will not return any results and take no time.
|
||||
# We use lastMessageReceivedTimestamp because it is the first column on an
|
||||
# index so this returns zero items nearly instantly. In the future, we might
|
||||
# want to make a Query.forNothing() to go along with MailboxPerspective.forNothing()
|
||||
query = DatabaseStore.findAll(Thread).where(lastMessageReceivedTimestamp: -1).limit(0)
|
||||
return new MutableQuerySubscription(query, {emitResultSet: true})
|
||||
|
||||
canReceiveThreadsFromAccountIds: =>
|
||||
false
|
||||
|
||||
|
||||
class CategoryMailboxPerspective extends MailboxPerspective
|
||||
constructor: (@_categories) ->
|
||||
super(_.uniq(_.pluck(@_categories, 'accountId')))
|
||||
|
||||
if @_categories.length is 0
|
||||
throw new Error("CategoryMailboxPerspective: You must provide at least one category.")
|
||||
|
||||
# Note: We pick the display name and icon assuming that you won't create a
|
||||
# perspective with Inbox and Sent or anything crazy like that... todo?
|
||||
@name = @_categories[0].displayName
|
||||
if @_categories[0].role
|
||||
@iconName = "#{@_categories[0].role}.png"
|
||||
else
|
||||
@iconName = if @_categories[0] instanceof Label then "label.png" else "folder.png"
|
||||
@
|
||||
|
||||
toJSON: =>
|
||||
json = super
|
||||
json.serializedCategories = JSON.stringify(@_categories)
|
||||
json
|
||||
|
||||
isEqual: (other) =>
|
||||
super(other) and _.isEqual(_.pluck(@categories(), 'id'), _.pluck(other.categories(), 'id'))
|
||||
|
||||
threads: =>
|
||||
query = DatabaseStore.findAll(Thread)
|
||||
.where([Thread.attributes.categories.containsAny(_.pluck(@categories(), 'id'))])
|
||||
.limit(0)
|
||||
|
||||
if @isSent()
|
||||
query.order(Thread.attributes.lastMessageSentTimestamp.descending())
|
||||
|
||||
unless @categoriesSharedRole() in ['spam', 'trash']
|
||||
query.where(inAllMail: true)
|
||||
|
||||
if @_categories.length > 1 and @accountIds.length < @_categories.length
|
||||
# The user has multiple categories in the same account selected, which
|
||||
# means our result set could contain multiple copies of the same threads
|
||||
# (since we do an inner join) and we need SELECT DISTINCT. Note that this
|
||||
# can be /much/ slower and we shouldn't do it if we know we don't need it.
|
||||
query.distinct()
|
||||
|
||||
return new MutableQuerySubscription(query, {emitResultSet: true})
|
||||
|
||||
unreadCount: =>
|
||||
sum = 0
|
||||
for cat in @_categories
|
||||
sum += ThreadCountsStore.unreadCountForCategoryId(cat.id)
|
||||
sum
|
||||
|
||||
categories: =>
|
||||
@_categories
|
||||
|
||||
hasSyncingCategories: =>
|
||||
not @_categories.every (cat) =>
|
||||
representedFolder = cat instanceof Folder ? cat : CategoryStore.getAllMailCategory(cat.accountId)
|
||||
return FolderSyncProgressStore.isSyncCompleteForAccount(cat.accountId, representedFolder.path)
|
||||
|
||||
isArchive: =>
|
||||
@_categories.every((cat) -> cat.isArchive())
|
||||
|
||||
canReceiveThreadsFromAccountIds: =>
|
||||
super and not @_categories.some((c) -> c.isLockedCategory())
|
||||
|
||||
receiveThreads: (threadsOrIds) =>
|
||||
FocusedPerspectiveStore = require('./flux/stores/focused-perspective-store').default
|
||||
current = FocusedPerspectiveStore.current()
|
||||
|
||||
# This assumes that the we don't have more than one category per accountId
|
||||
# attached to this perspective
|
||||
DatabaseStore.modelify(Thread, threadsOrIds).then (threads) =>
|
||||
tasks = TaskFactory.tasksForThreadsByAccountId(threads, (accountThreads, accountId) =>
|
||||
if current.categoriesSharedRole() in Category.LockedCategoryNames
|
||||
return null
|
||||
|
||||
myCat = @categories().find((c) -> c.accountId == accountId)
|
||||
currentCat = current.categories().find((c) -> c.accountId == accountId)
|
||||
|
||||
if myCat instanceof Folder
|
||||
# folder/label to folder
|
||||
return new ChangeFolderTask({
|
||||
threads: accountThreads,
|
||||
source: "Dragged into list",
|
||||
folder: myCat,
|
||||
})
|
||||
else if myCat instanceof Label and currentCat instanceof Folder
|
||||
# folder to label
|
||||
# dragging from trash or spam into a label? We need to both apply the label and move.
|
||||
return [
|
||||
new ChangeFolderTask({
|
||||
threads: accountThreads,
|
||||
source: "Dragged into list",
|
||||
folder: CategoryStore.getCategoryByRole(accountId, 'all'),
|
||||
}),
|
||||
new ChangeLabelsTask({
|
||||
threads: accountThreads,
|
||||
source: "Dragged into list",
|
||||
labelsToAdd: [myCat],
|
||||
labelsToRemove: [],
|
||||
})
|
||||
]
|
||||
else
|
||||
# label to label
|
||||
return [
|
||||
new ChangeLabelsTask({
|
||||
threads: accountThreads,
|
||||
source: "Dragged into list",
|
||||
labelsToAdd: [myCat],
|
||||
labelsToRemove: [currentCat],
|
||||
})
|
||||
]
|
||||
)
|
||||
Actions.queueTasks(tasks)
|
||||
|
||||
# Public:
|
||||
# Returns the tasks for removing threads from this perspective and moving them
|
||||
# to the default destination based on the current view:
|
||||
#
|
||||
# if you're looking at a folder:
|
||||
# - spam: null
|
||||
# - trash: null
|
||||
# - archive: trash
|
||||
# - all others: "finished category (archive or trash)"
|
||||
|
||||
# if you're looking at a label
|
||||
# - if finished category === "archive" remove the label
|
||||
# - if finished category === "trash" move to trash folder, keep labels intact
|
||||
#
|
||||
tasksForRemovingItems: (threads, source = "Removed from list") =>
|
||||
# TODO this is an awful hack
|
||||
if @isArchive()
|
||||
role = 'archive'
|
||||
else
|
||||
role = @categoriesSharedRole()
|
||||
'archive'
|
||||
|
||||
if role == 'spam' or role == 'trash'
|
||||
return []
|
||||
|
||||
if role == 'archive'
|
||||
return TaskFactory.tasksForMovingToTrash({threads, source})
|
||||
|
||||
return TaskFactory.tasksForThreadsByAccountId(threads, (accountThreads, accountId) =>
|
||||
acct = AccountStore.accountForId(accountId)
|
||||
preferred = acct.preferredRemovalDestination()
|
||||
cat = @categories().find((c) -> c.accountId == accountId)
|
||||
if cat instanceof Label and preferred.role != 'trash'
|
||||
inboxCat = CategoryStore.getInboxCategory(accountId)
|
||||
return new ChangeLabelsTask({
|
||||
threads: accountThreads,
|
||||
labelsToAdd: [],
|
||||
labelsToRemove: [cat, inboxCat],
|
||||
source: source,
|
||||
})
|
||||
else
|
||||
return new ChangeFolderTask({
|
||||
threads: accountThreads,
|
||||
folder: preferred,
|
||||
source: source,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
class UnreadMailboxPerspective extends CategoryMailboxPerspective
|
||||
constructor: (categories) ->
|
||||
super(categories)
|
||||
@name = "Unread"
|
||||
@iconName = "unread.png"
|
||||
@
|
||||
|
||||
threads: =>
|
||||
return new UnreadQuerySubscription(_.pluck(@categories(), 'id'))
|
||||
|
||||
unreadCount: =>
|
||||
0
|
||||
|
||||
receiveThreads: (threadsOrIds) =>
|
||||
super(threadsOrIds)
|
||||
|
||||
ChangeUnreadTask ?= require('./flux/tasks/change-unread-task').default
|
||||
task = new ChangeUnreadTask({threads:threadsOrIds, unread: true, source: "Dragged Into List"})
|
||||
Actions.queueTask(task)
|
||||
|
||||
tasksForRemovingItems: (threads, ruleset, source) =>
|
||||
ChangeUnreadTask ?= require('./flux/tasks/change-unread-task').default
|
||||
tasks = super(threads, ruleset)
|
||||
tasks.push new ChangeUnreadTask({threads, unread: false, source: source || "Removed From List"})
|
||||
return tasks
|
||||
|
||||
|
||||
module.exports = MailboxPerspective
|
524
packages/client-app/src/mailbox-perspective.es6
Normal file
524
packages/client-app/src/mailbox-perspective.es6
Normal file
|
@ -0,0 +1,524 @@
|
|||
/* eslint global-require: 0 */
|
||||
/* eslint no-use-before-define: 0 */
|
||||
import _ from 'underscore';
|
||||
|
||||
import Utils from './flux/models/utils'
|
||||
import TaskFactory from './flux/tasks/task-factory'
|
||||
import AccountStore from './flux/stores/account-store'
|
||||
import CategoryStore from './flux/stores/category-store'
|
||||
import DatabaseStore from './flux/stores/database-store'
|
||||
import OutboxStore from './flux/stores/outbox-store'
|
||||
import ThreadCountsStore from './flux/stores/thread-counts-store'
|
||||
import FolderSyncProgressStore from './flux/stores/folder-sync-progress-store'
|
||||
import MutableQuerySubscription from './flux/models/mutable-query-subscription'
|
||||
import UnreadQuerySubscription from './flux/models/unread-query-subscription'
|
||||
import Thread from './flux/models/thread'
|
||||
import Category from './flux/models/category'
|
||||
import Label from './flux/models/label'
|
||||
import Folder from './flux/models/folder'
|
||||
import Actions from './flux/actions'
|
||||
|
||||
let ChangeStarredTask = null;
|
||||
let ChangeLabelsTask = null;
|
||||
let ChangeFolderTask = null;
|
||||
let ChangeUnreadTask = null;
|
||||
let FocusedPerspectiveStore = null;
|
||||
|
||||
// This is a class cluster. Subclasses are not for external use!
|
||||
// https://developer.apple.com/library/ios/documentation/General/Conceptual/CocoaEncyclopedia/ClassClusters/ClassClusters.html
|
||||
|
||||
|
||||
export default class MailboxPerspective {
|
||||
|
||||
// Factory Methods
|
||||
static forNothing() {
|
||||
return new EmptyMailboxPerspective()
|
||||
}
|
||||
|
||||
static forDrafts(accountsOrIds) {
|
||||
return new DraftsMailboxPerspective(accountsOrIds)
|
||||
}
|
||||
|
||||
static forCategory(category) {
|
||||
return category ? new CategoryMailboxPerspective([category]) : this.forNothing();
|
||||
}
|
||||
|
||||
static forCategories(categories) {
|
||||
return (categories.length > 0) ? new CategoryMailboxPerspective(categories) : this.forNothing();
|
||||
}
|
||||
|
||||
static forStandardCategories(accountsOrIds, ...names) {
|
||||
// TODO this method is broken
|
||||
const categories = CategoryStore.getCategoriesWithRoles(accountsOrIds, ...names);
|
||||
return this.forCategories(categories);
|
||||
}
|
||||
|
||||
static forStarred(accountsOrIds) {
|
||||
return new StarredMailboxPerspective(accountsOrIds);
|
||||
}
|
||||
|
||||
static forUnread(categories) {
|
||||
return (categories.length > 0) ? new UnreadMailboxPerspective(categories) : this.forNothing();
|
||||
}
|
||||
|
||||
static forInbox(accountsOrIds) {
|
||||
return this.forStandardCategories(accountsOrIds, 'inbox');
|
||||
}
|
||||
|
||||
static fromJSON(json) {
|
||||
try {
|
||||
if (json.type === CategoryMailboxPerspective.name) {
|
||||
const categories = JSON.parse(json.serializedCategories).map(Utils.convertToModel)
|
||||
return this.forCategories(categories)
|
||||
}
|
||||
if (json.type === UnreadMailboxPerspective.name) {
|
||||
const categories = JSON.parse(json.serializedCategories).map(Utils.convertToModel)
|
||||
return this.forUnread(categories)
|
||||
}
|
||||
if (json.type === StarredMailboxPerspective.name) {
|
||||
return this.forStarred(json.accountIds)
|
||||
}
|
||||
if (json.type === DraftsMailboxPerspective.name) {
|
||||
return this.forDrafts(json.accountIds)
|
||||
}
|
||||
return this.forInbox(json.accountIds)
|
||||
} catch (error) {
|
||||
NylasEnv.reportError(new Error(`Could not restore mailbox perspective: ${error}`));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Instance Methods
|
||||
|
||||
constructor(accountIds) {
|
||||
this.accountIds = accountIds;
|
||||
if (!(accountIds instanceof Array) || !accountIds.every((aid) =>
|
||||
(typeof aid === 'string') || (typeof aid === 'number'))) {
|
||||
throw new Error(`${this.constructor.name}: You must provide an array of string "accountIds"`)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {accountIds: this.accountIds, type: this.constructor.name};
|
||||
}
|
||||
|
||||
isEqual(other) {
|
||||
if (!other || this.constructor !== other.constructor) {
|
||||
return false;
|
||||
}
|
||||
if (other.name !== this.name) {
|
||||
return false;
|
||||
}
|
||||
if (!_.isEqual(this.accountIds, other.accountIds)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
isInbox() {
|
||||
return this.categoriesSharedRole() === 'inbox';
|
||||
}
|
||||
|
||||
isSent() {
|
||||
return this.categoriesSharedRole() === 'sent';
|
||||
}
|
||||
|
||||
isTrash() {
|
||||
return this.categoriesSharedRole() === 'trash';
|
||||
}
|
||||
|
||||
isArchive() {
|
||||
return false;
|
||||
}
|
||||
|
||||
emptyMessage() {
|
||||
return "No Messages";
|
||||
}
|
||||
|
||||
categories() {
|
||||
return [];
|
||||
}
|
||||
|
||||
// overwritten in CategoryMailboxPerspective
|
||||
hasSyncingCategories() {
|
||||
return false;
|
||||
}
|
||||
|
||||
categoriesSharedRole() {
|
||||
this._categoriesSharedRole = this._categoriesSharedRole || Category.categoriesSharedRole(this.categories())
|
||||
return this._categoriesSharedRole
|
||||
}
|
||||
|
||||
category() {
|
||||
return this.categories().length === 1 ? this.categories()[0] : null;
|
||||
}
|
||||
|
||||
threads() {
|
||||
throw new Error("threads: Not implemented in base class.")
|
||||
}
|
||||
|
||||
unreadCount() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Public:
|
||||
// - accountIds {Array} Array of unique account ids associated with the threads
|
||||
// that want to be included in this perspective
|
||||
//
|
||||
// Returns true if the accountIds are part of the current ids, or false
|
||||
// otherwise. This means that it checks if I am attempting to move threads
|
||||
// between the same set of accounts:
|
||||
//
|
||||
// E.g.:
|
||||
// perpective = Starred for accountIds: a1, a2
|
||||
// thread1 has accountId a3
|
||||
// thread2 has accountId a2
|
||||
//
|
||||
// perspective.canReceiveThreadsFromAccountIds([a2, a3]) -> false -> I cant move those threads to Starred
|
||||
// perspective.canReceiveThreadsFromAccountIds([a2]) -> true -> I can move that thread to Starred
|
||||
canReceiveThreadsFromAccountIds(accountIds) {
|
||||
if (!accountIds || accountIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const areIncomingIdsInCurrent = _.difference(accountIds, this.accountIds).length === 0
|
||||
return areIncomingIdsInCurrent;
|
||||
}
|
||||
|
||||
receiveThreads(threadsOrIds) { // eslint-disable-line
|
||||
throw new Error("receiveThreads: Not implemented in base class.");
|
||||
}
|
||||
|
||||
canArchiveThreads(threads) {
|
||||
if (this.isArchive()) {
|
||||
return false;
|
||||
}
|
||||
const accounts = AccountStore.accountsForItems(threads)
|
||||
return accounts.every((acc) => acc.canArchiveThreads());
|
||||
}
|
||||
|
||||
canTrashThreads(threads) {
|
||||
return this.canMoveThreadsTo(threads, 'trash');
|
||||
}
|
||||
|
||||
canMoveThreadsTo(threads, standardCategoryName) {
|
||||
if (this.categoriesSharedRole() === standardCategoryName) {
|
||||
return false;
|
||||
}
|
||||
return AccountStore.accountsForItems(threads).every((acc) =>
|
||||
CategoryStore.getCategoryByRole(acc, standardCategoryName) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
tasksForRemovingItems(threads) {
|
||||
if (!(threads instanceof Array)) {
|
||||
throw new Error("tasksForRemovingItems: you must pass an array of threads or thread ids");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class DraftsMailboxPerspective extends MailboxPerspective {
|
||||
constructor(accountIds) {
|
||||
super(accountIds);
|
||||
this.name = "Drafts";
|
||||
this.iconName = "drafts.png";
|
||||
this.drafts = true; // The DraftListStore looks for this
|
||||
}
|
||||
|
||||
threads() {
|
||||
return null;
|
||||
}
|
||||
|
||||
unreadCount() {
|
||||
let count = 0
|
||||
for (const aid of this.accountIds) {
|
||||
count += OutboxStore.itemsForAccount(aid).length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
canReceiveThreadsFromAccountIds() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class StarredMailboxPerspective extends MailboxPerspective {
|
||||
constructor(accountIds) {
|
||||
super(accountIds);
|
||||
this.name = "Starred";
|
||||
this.iconName = "starred.png";
|
||||
}
|
||||
|
||||
threads() {
|
||||
const query = DatabaseStore.findAll(Thread).where([
|
||||
Thread.attributes.starred.equal(true),
|
||||
Thread.attributes.inAllMail.equal(true),
|
||||
]).limit(0);
|
||||
|
||||
// Adding a "account_id IN (a,b,c)" clause to our query can result in a full
|
||||
// table scan. Don't add the where clause if we know we want results from all.
|
||||
if (this.accountIds.length < AccountStore.accounts().length) {
|
||||
query.where(Thread.attributes.accountId.in(this.accountIds));
|
||||
}
|
||||
|
||||
return new MutableQuerySubscription(query, {emitResultSet: true});
|
||||
}
|
||||
|
||||
canReceiveThreadsFromAccountIds() {
|
||||
return super.canReceiveThreadsFromAccountIds();
|
||||
}
|
||||
|
||||
receiveThreads(threadsOrIds) {
|
||||
ChangeStarredTask = ChangeStarredTask || require('./flux/tasks/change-starred-task').default
|
||||
const task = new ChangeStarredTask({threads: threadsOrIds, starred: true, source: "Dragged Into List"})
|
||||
Actions.queueTask(task);
|
||||
}
|
||||
|
||||
tasksForRemovingItems(threads) {
|
||||
const task = TaskFactory.taskForInvertingStarred({
|
||||
threads: threads,
|
||||
source: "Removed From List",
|
||||
});
|
||||
return [task];
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyMailboxPerspective extends MailboxPerspective {
|
||||
constructor() {
|
||||
super([]);
|
||||
}
|
||||
|
||||
threads() {
|
||||
// We need a Thread query that will not return any results and take no time.
|
||||
// We use lastMessageReceivedTimestamp because it is the first column on an
|
||||
// index so this returns zero items nearly instantly. In the future, we might
|
||||
// want to make a Query.forNothing() to go along with MailboxPerspective.forNothing()
|
||||
const query = DatabaseStore.findAll(Thread).where({lastMessageReceivedTimestamp: -1}).limit(0)
|
||||
return new MutableQuerySubscription(query, {emitResultSet: true});
|
||||
}
|
||||
|
||||
canReceiveThreadsFromAccountIds() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CategoryMailboxPerspective extends MailboxPerspective {
|
||||
constructor(_categories) {
|
||||
super(_.uniq(_categories.map(c => c.accountId)));
|
||||
this._categories = _categories;
|
||||
|
||||
if (this._categories.length === 0) {
|
||||
throw new Error("CategoryMailboxPerspective: You must provide at least one category.");
|
||||
}
|
||||
|
||||
// Note: We pick the display name and icon assuming that you won't create a
|
||||
// perspective with Inbox and Sent or anything crazy like that... todo?
|
||||
this.name = this._categories[0].displayName;
|
||||
if (this._categories[0].role) {
|
||||
this.iconName = `${this._categories[0].role}.png`
|
||||
} else {
|
||||
this.iconName = (this._categories[0] instanceof Label) ? "label.png" : "folder.png";
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const json = super.toJSON()
|
||||
json.serializedCategories = JSON.stringify(this._categories);
|
||||
return json;
|
||||
}
|
||||
|
||||
isEqual(other) {
|
||||
return super.isEqual(other) && _.isEqual(this.categories().map(c => c.id), other.categories().map(c => c.id))
|
||||
}
|
||||
|
||||
threads() {
|
||||
const query = DatabaseStore.findAll(Thread)
|
||||
.where([Thread.attributes.categories.containsAny(this.categories().map(c => c.id))])
|
||||
.limit(0)
|
||||
|
||||
if (this.isSent()) {
|
||||
query.order(Thread.attributes.lastMessageSentTimestamp.descending());
|
||||
}
|
||||
|
||||
if (!['spam', 'trash'].includes(this.categoriesSharedRole())) {
|
||||
query.where({inAllMail: true});
|
||||
}
|
||||
|
||||
if (this._categories.length > 1 && this.accountIds.length < this._categories.length) {
|
||||
// The user has multiple categories in the same account selected, which
|
||||
// means our result set could contain multiple copies of the same threads
|
||||
// (since we do an inner join) and we need SELECT DISTINCT. Note that this
|
||||
// can be /much/ slower and we shouldn't do it if we know we don't need it.
|
||||
query.distinct();
|
||||
}
|
||||
|
||||
return new MutableQuerySubscription(query, {emitResultSet: true});
|
||||
}
|
||||
|
||||
unreadCount() {
|
||||
let sum = 0
|
||||
for (const cat of this._categories) {
|
||||
sum += ThreadCountsStore.unreadCountForCategoryId(cat.id);
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
categories() {
|
||||
return this._categories;
|
||||
}
|
||||
|
||||
hasSyncingCategories() {
|
||||
return (!this._categories.every((cat) => {
|
||||
const representedFolder = cat instanceof Folder ? cat : CategoryStore.getAllMailCategory(cat.accountId)
|
||||
return FolderSyncProgressStore.isSyncCompleteForAccount(cat.accountId, representedFolder.path)
|
||||
}));
|
||||
}
|
||||
|
||||
isArchive() {
|
||||
return this._categories.every((cat) => cat.isArchive());
|
||||
}
|
||||
|
||||
canReceiveThreadsFromAccountIds() {
|
||||
return super.canReceiveThreadsFromAccountIds() && !this._categories.some((c) => c.isLockedCategory());
|
||||
}
|
||||
|
||||
receiveThreads(threadsOrIds) {
|
||||
FocusedPerspectiveStore = FocusedPerspectiveStore || require('./flux/stores/focused-perspective-store').default
|
||||
ChangeLabelsTask = ChangeLabelsTask || require('./flux/tasks/change-labels-task').default;
|
||||
ChangeFolderTask = ChangeFolderTask || require('./flux/tasks/change-folder-task').default;
|
||||
|
||||
const current = FocusedPerspectiveStore.current()
|
||||
|
||||
// This assumes that the we don't have more than one category per accountId
|
||||
// attached to this perspective
|
||||
return DatabaseStore.modelify(Thread, threadsOrIds).then((threads) => {
|
||||
const tasks = TaskFactory.tasksForThreadsByAccountId(threads, (accountThreads, accountId) => {
|
||||
if (Category.LockedCategoryNames.includes(current.categoriesSharedRole())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const myCat = this.categories().find((c) => c.accountId === accountId)
|
||||
const currentCat = current.categories().find((c) => c.accountId === accountId)
|
||||
|
||||
if (myCat instanceof Folder) {
|
||||
// folder/label to folder
|
||||
return new ChangeFolderTask({
|
||||
threads: accountThreads,
|
||||
source: "Dragged into list",
|
||||
folder: myCat,
|
||||
})
|
||||
}
|
||||
if ((myCat instanceof Label) && (currentCat instanceof Folder)) {
|
||||
// folder to label
|
||||
// dragging from trash or spam into a label? We need to both apply the label and move.
|
||||
return [
|
||||
new ChangeFolderTask({
|
||||
threads: accountThreads,
|
||||
source: "Dragged into list",
|
||||
folder: CategoryStore.getCategoryByRole(accountId, 'all'),
|
||||
}),
|
||||
new ChangeLabelsTask({
|
||||
threads: accountThreads,
|
||||
source: "Dragged into list",
|
||||
labelsToAdd: [myCat],
|
||||
labelsToRemove: [],
|
||||
}),
|
||||
];
|
||||
}
|
||||
// label to label
|
||||
return [
|
||||
new ChangeLabelsTask({
|
||||
threads: accountThreads,
|
||||
source: "Dragged into list",
|
||||
labelsToAdd: [myCat],
|
||||
labelsToRemove: [currentCat],
|
||||
}),
|
||||
];
|
||||
});
|
||||
Actions.queueTasks(tasks);
|
||||
});
|
||||
}
|
||||
|
||||
// Public:
|
||||
// Returns the tasks for removing threads from this perspective and moving them
|
||||
// to the default destination based on the current view:
|
||||
//
|
||||
// if you're looking at a folder:
|
||||
// - spam: null
|
||||
// - trash: null
|
||||
// - archive: trash
|
||||
// - all others: "finished category (archive or trash)"
|
||||
|
||||
// if you're looking at a label
|
||||
// - if finished category === "archive" remove the label
|
||||
// - if finished category === "trash" move to trash folder, keep labels intact
|
||||
//
|
||||
tasksForRemovingItems(threads, source = "Removed from list") {
|
||||
ChangeLabelsTask = ChangeLabelsTask || require('./flux/tasks/change-labels-task').default;
|
||||
ChangeFolderTask = ChangeFolderTask || require('./flux/tasks/change-folder-task').default;
|
||||
|
||||
// TODO this is an awful hack
|
||||
const role = this.isArchive() ? 'archive' : this.categoriesSharedRole();
|
||||
|
||||
if (role === 'spam' || role === 'trash') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (role === 'archive') {
|
||||
return TaskFactory.tasksForMovingToTrash({threads, source});
|
||||
}
|
||||
|
||||
return TaskFactory.tasksForThreadsByAccountId(threads, (accountThreads, accountId) => {
|
||||
const acct = AccountStore.accountForId(accountId);
|
||||
const preferred = acct.preferredRemovalDestination();
|
||||
const cat = this.categories().find((c) => c.accountId === accountId);
|
||||
if (cat instanceof Label && preferred.role !== 'trash') {
|
||||
const inboxCat = CategoryStore.getInboxCategory(accountId);
|
||||
return new ChangeLabelsTask({
|
||||
threads: accountThreads,
|
||||
labelsToAdd: [],
|
||||
labelsToRemove: [cat, inboxCat],
|
||||
source: source,
|
||||
});
|
||||
}
|
||||
return new ChangeFolderTask({
|
||||
threads: accountThreads,
|
||||
folder: preferred,
|
||||
source: source,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class UnreadMailboxPerspective extends CategoryMailboxPerspective {
|
||||
constructor(categories) {
|
||||
super(categories);
|
||||
this.name = "Unread";
|
||||
this.iconName = "unread.png";
|
||||
}
|
||||
|
||||
threads() {
|
||||
return new UnreadQuerySubscription(this.categories().map(c => c.id));
|
||||
}
|
||||
|
||||
unreadCount() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
receiveThreads(threadsOrIds) {
|
||||
ChangeUnreadTask = ChangeUnreadTask || require('./flux/tasks/change-unread-task').default
|
||||
|
||||
super.receiveThreads(threadsOrIds)
|
||||
const task = new ChangeUnreadTask({threads: threadsOrIds, unread: true, source: "Dragged Into List"})
|
||||
Actions.queueTask(task);
|
||||
}
|
||||
|
||||
tasksForRemovingItems(threads, ruleset, source) {
|
||||
ChangeUnreadTask = ChangeUnreadTask || require('./flux/tasks/change-unread-task').default;
|
||||
|
||||
const tasks = super.tasksForRemovingItems(threads, ruleset);
|
||||
tasks.push(new ChangeUnreadTask({threads, unread: false, source: source || "Removed From List"}));
|
||||
return tasks;
|
||||
}
|
||||
}
|
|
@ -1,207 +0,0 @@
|
|||
_ = require 'underscore'
|
||||
|
||||
{Listener, Publisher} = require '../flux/modules/reflux-coffee'
|
||||
CoffeeHelpers = require '../flux/coffee-helpers'
|
||||
|
||||
DeprecatedRoles = {
|
||||
'thread:BulkAction': 'ThreadActionsToolbarButton',
|
||||
'draft:BulkAction': 'DraftActionsToolbarButton',
|
||||
'message:Toolbar': 'ThreadActionsToolbarButton',
|
||||
'thread:Toolbar': 'ThreadActionsToolbarButton',
|
||||
}
|
||||
|
||||
###
|
||||
Public: The ComponentRegistry maintains an index of React components registered
|
||||
by Nylas packages. Components can use {InjectedComponent} and {InjectedComponentSet}
|
||||
to dynamically render components registered with the ComponentRegistry.
|
||||
|
||||
Section: Stores
|
||||
###
|
||||
class ComponentRegistry
|
||||
@include: CoffeeHelpers.includeModule
|
||||
|
||||
@include Publisher
|
||||
@include Listener
|
||||
|
||||
constructor: ->
|
||||
@_registry = {}
|
||||
@_cache = {}
|
||||
@_showComponentRegions = false
|
||||
|
||||
|
||||
# Public: Register a new component with the Component Registry.
|
||||
# Typically, packages call this method from their main `activate` method
|
||||
# to extend the Nylas user interface, and call the corresponding `unregister`
|
||||
# method in `deactivate`.
|
||||
#
|
||||
# * `component` {Object} A React Component with a `displayName`
|
||||
# * `options` {Object}:
|
||||
#
|
||||
# * `role`: (optional) {String} If you want to display your component in a location
|
||||
# desigated by a role, pass the role identifier.
|
||||
#
|
||||
# * `modes`: (optional) {Array} If your component should only be displayed
|
||||
# in particular Workspace Modes, pass an array of supported modes.
|
||||
# ('list', 'split', etc.)
|
||||
#
|
||||
# * `location`: (optional) {Object} If your component should be displayed in a
|
||||
# column or toolbar, pass the fully qualified location object, such as:
|
||||
# `WorkspaceStore.Location.ThreadList`
|
||||
#
|
||||
# Note that for advanced use cases, you can also pass (`modes`, `roles`, `locations`)
|
||||
# with arrays instead of single values.
|
||||
#
|
||||
# This method is chainable.
|
||||
#
|
||||
register: (component, options) =>
|
||||
if component.view?
|
||||
return console.warn("Ignoring component trying to register with old CommandRegistry.register syntax")
|
||||
|
||||
throw new Error("ComponentRegistry.register() requires `options` that describe the component") unless options
|
||||
throw new Error("ComponentRegistry.register() requires `component`, a React component") unless component
|
||||
throw new Error("ComponentRegistry.register() requires that your React Component defines a `displayName`") unless component.displayName
|
||||
|
||||
{locations, modes, roles} = @_pluralizeDescriptor(options)
|
||||
|
||||
throw new Error("ComponentRegistry.register() requires `role` or `location`") if not roles and not locations
|
||||
|
||||
if @_registry[component.displayName] and @_registry[component.displayName].component isnt component
|
||||
throw new Error("ComponentRegistry.register(): A different component was already registered with the name #{component.displayName}")
|
||||
|
||||
roles = @_removeDeprecatedRoles(component.displayName, roles) if roles
|
||||
|
||||
@_cache = {}
|
||||
@_registry[component.displayName] = {component, locations, modes, roles}
|
||||
|
||||
# Trigger listeners. It's very important the component registry is debounced.
|
||||
# During app launch packages register tons of components and if we re-rendered
|
||||
# the entire UI after each registration it takes forever to load the UI.
|
||||
@triggerDebounced()
|
||||
|
||||
# Return `this` for chaining
|
||||
@
|
||||
|
||||
unregister: (component) =>
|
||||
if _.isString(component)
|
||||
throw new Error("ComponentRegistry.unregister() must be called with a component.")
|
||||
@_cache = {}
|
||||
delete @_registry[component.displayName]
|
||||
@triggerDebounced()
|
||||
|
||||
# Public: Retrieve the registry entry for a given name.
|
||||
#
|
||||
# - `name`: The {String} name of the registered component to retrieve.
|
||||
#
|
||||
# Returns a {React.Component}
|
||||
#
|
||||
findComponentByName: (name) =>
|
||||
@_registry[name]?.component
|
||||
|
||||
###
|
||||
Public: Retrieve all of the registry entries matching a given descriptor.
|
||||
|
||||
```coffee
|
||||
ComponentRegistry.findComponentsMatching({
|
||||
role: 'Composer:ActionButton'
|
||||
})
|
||||
|
||||
ComponentRegistry.findComponentsMatching({
|
||||
location: WorkspaceStore.Location.RootSidebar.Toolbar
|
||||
})
|
||||
```
|
||||
|
||||
- `descriptor`: An {Object} that specifies set of components using the
|
||||
available keys below.
|
||||
|
||||
* `mode`: (optional) {String} Components that specifically list modes
|
||||
will only be returned if they include this mode.
|
||||
|
||||
* `role`: (optional) {String} Only return components that have registered
|
||||
for this role.
|
||||
|
||||
* `location`: (optional) {Object} Only return components that have registered
|
||||
for this location.
|
||||
|
||||
Note that for advanced use cases, you can also pass (`modes`, `roles`, `locations`)
|
||||
with arrays instead of single values.
|
||||
|
||||
Returns an {Array} of {React.Component} objects
|
||||
###
|
||||
findComponentsMatching: (descriptor) =>
|
||||
if not descriptor?
|
||||
throw new Error("ComponentRegistry.findComponentsMatching called without descriptor")
|
||||
|
||||
{locations, modes, roles} = @_pluralizeDescriptor(descriptor)
|
||||
|
||||
if not locations and not modes and not roles
|
||||
throw new Error("ComponentRegistry.findComponentsMatching called with an empty descriptor")
|
||||
|
||||
cacheKey = JSON.stringify({locations, modes, roles})
|
||||
return [].concat(@_cache[cacheKey]) if @_cache[cacheKey]
|
||||
|
||||
# Made into a convenience function because default
|
||||
# values (`[]`) are necessary and it was getting messy.
|
||||
overlaps = (entry = [], search = []) ->
|
||||
_.intersection(entry, search).length > 0
|
||||
|
||||
entries = Object.values(@_registry)
|
||||
entries = entries.filter (entry) ->
|
||||
if modes and entry.modes and not overlaps(modes, entry.modes)
|
||||
return false
|
||||
if locations and not overlaps(locations, entry.locations)
|
||||
return false
|
||||
if roles and not overlaps(roles, entry.roles)
|
||||
return false
|
||||
return true
|
||||
|
||||
results = entries.map (entry) -> entry.component
|
||||
@_cache[cacheKey] = results
|
||||
|
||||
return [].concat(results)
|
||||
|
||||
# We debounce because a single plugin may activate many components in
|
||||
# their `activate` methods. Furthermore, when the window loads several
|
||||
# plugins may load in sequence. Plugin loading takes a while (dozens of
|
||||
# ms) since javascript is being read and `require` trees are being
|
||||
# traversed.
|
||||
#
|
||||
# Triggering the ComponentRegistry is fairly expensive since many very
|
||||
# high-level components (like the <Sheet />) listen and re-render when
|
||||
# this triggers.
|
||||
#
|
||||
# We set the debouce interval to 1 "frame" (16ms) to balance
|
||||
# responsiveness and efficient batching.
|
||||
triggerDebounced: _.debounce(( -> @trigger(@)), 16)
|
||||
|
||||
_removeDeprecatedRoles: (displayName, roles) ->
|
||||
newRoles = _.clone(roles)
|
||||
roles.forEach (role, idx) ->
|
||||
if role of DeprecatedRoles
|
||||
instead = DeprecatedRoles[role]
|
||||
console.warn("Deprecation warning! The role `#{role}` has been deprecated.
|
||||
Register `#{displayName}` for the role `#{instead}` instead.")
|
||||
newRoles.splice(idx, 1, instead)
|
||||
return newRoles
|
||||
|
||||
_pluralizeDescriptor: (descriptor) ->
|
||||
{locations, modes, roles} = descriptor
|
||||
modes = [descriptor.mode] if descriptor.mode
|
||||
roles = [descriptor.role] if descriptor.role
|
||||
locations = [descriptor.location] if descriptor.location
|
||||
{locations, modes, roles}
|
||||
|
||||
_clear: =>
|
||||
@_cache = {}
|
||||
@_registry = {}
|
||||
|
||||
# Showing Component Regions
|
||||
|
||||
toggleComponentRegions: ->
|
||||
@_showComponentRegions = !@_showComponentRegions
|
||||
@trigger(@)
|
||||
|
||||
showComponentRegions: =>
|
||||
@_showComponentRegions
|
||||
|
||||
|
||||
module.exports = new ComponentRegistry()
|
208
packages/client-app/src/registries/component-registry.es6
Normal file
208
packages/client-app/src/registries/component-registry.es6
Normal file
|
@ -0,0 +1,208 @@
|
|||
import _ from 'underscore'
|
||||
import NylasStore from 'nylas-store'
|
||||
|
||||
/**
|
||||
Public: The ComponentRegistry maintains an index of React components registered
|
||||
by Nylas packages. Components can use {InjectedComponent} and {InjectedComponentSet}
|
||||
to dynamically render components registered with the ComponentRegistry.
|
||||
|
||||
Section: Stores
|
||||
*/
|
||||
class ComponentRegistry extends NylasStore {
|
||||
constructor() {
|
||||
super();
|
||||
this._registry = {}
|
||||
this._cache = {}
|
||||
this._showComponentRegions = false
|
||||
}
|
||||
|
||||
// Public: Register a new component with the Component Registry.
|
||||
// Typically, packages call this method from their main `activate` method
|
||||
// to extend the Nylas user interface, and call the corresponding `unregister`
|
||||
// method in `deactivate`.
|
||||
//
|
||||
// * `component` {Object} A React Component with a `displayName`
|
||||
// * `options` {Object}:
|
||||
//
|
||||
// * `role`: (optional) {String} If you want to display your component in a location
|
||||
// desigated by a role, pass the role identifier.
|
||||
//
|
||||
// * `modes`: (optional) {Array} If your component should only be displayed
|
||||
// in particular Workspace Modes, pass an array of supported modes.
|
||||
// ('list', 'split', etc.)
|
||||
//
|
||||
// * `location`: (optional) {Object} If your component should be displayed in a
|
||||
// column or toolbar, pass the fully qualified location object, such as:
|
||||
// `WorkspaceStore.Location.ThreadList`
|
||||
//
|
||||
// Note that for advanced use cases, you can also pass (`modes`, `roles`, `locations`)
|
||||
// with arrays instead of single values.
|
||||
//
|
||||
// This method is chainable.
|
||||
//
|
||||
register(component, options) {
|
||||
if (component.view) {
|
||||
return console.warn("Ignoring component trying to register with old CommandRegistry.register syntax");
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
throw new Error("ComponentRegistry.register() requires `options` that describe the component");
|
||||
}
|
||||
if (!component) {
|
||||
throw new Error("ComponentRegistry.register() requires `component`, a React component");
|
||||
}
|
||||
if (!component.displayName) {
|
||||
throw new Error("ComponentRegistry.register() requires that your React Component defines a `displayName`");
|
||||
}
|
||||
|
||||
const {locations, modes, roles} = this._pluralizeDescriptor(options);
|
||||
if (!roles && !locations) {
|
||||
throw new Error("ComponentRegistry.register() requires `role` or `location`");
|
||||
}
|
||||
|
||||
if (this._registry[component.displayName] && this._registry[component.displayName].component !== component) {
|
||||
throw new Error("ComponentRegistry.register(): A different component was already registered with the name #{component.displayName}");
|
||||
}
|
||||
|
||||
this._cache = {};
|
||||
this._registry[component.displayName] = {component, locations, modes, roles};
|
||||
|
||||
// Trigger listeners. It's very important the component registry is debounced.
|
||||
// During app launch packages register tons of components and if we re-rendered
|
||||
// the entire UI after each registration it takes forever to load the UI.
|
||||
this.triggerDebounced();
|
||||
|
||||
// Return `this` for chaining
|
||||
return this;
|
||||
}
|
||||
|
||||
unregister(component) {
|
||||
if (typeof component === 'string') {
|
||||
throw new Error("ComponentRegistry.unregister() must be called with a component.");
|
||||
}
|
||||
this._cache = {};
|
||||
delete this._registry[component.displayName];
|
||||
this.triggerDebounced();
|
||||
}
|
||||
|
||||
// Public: Retrieve the registry entry for a given name.
|
||||
//
|
||||
// - `name`: The {String} name of the registered component to retrieve.
|
||||
//
|
||||
// Returns a {React.Component}
|
||||
//
|
||||
findComponentByName(name) {
|
||||
return this._registry[name] && this._registry[name].component;
|
||||
}
|
||||
|
||||
/**
|
||||
Public: Retrieve all of the registry entries matching a given descriptor.
|
||||
|
||||
```coffee
|
||||
ComponentRegistry.findComponentsMatching({
|
||||
role: 'Composer:ActionButton'
|
||||
})
|
||||
|
||||
ComponentRegistry.findComponentsMatching({
|
||||
location: WorkspaceStore.Location.RootSidebar.Toolbar
|
||||
})
|
||||
```
|
||||
|
||||
- `descriptor`: An {Object} that specifies set of components using the
|
||||
available keys below.
|
||||
|
||||
* `mode`: (optional) {String} Components that specifically list modes
|
||||
will only be returned if they include this mode.
|
||||
|
||||
* `role`: (optional) {String} Only return components that have registered
|
||||
for this role.
|
||||
|
||||
* `location`: (optional) {Object} Only return components that have registered
|
||||
for this location.
|
||||
|
||||
Note that for advanced use cases, you can also pass (`modes`, `roles`, `locations`)
|
||||
with arrays instead of single values.
|
||||
|
||||
Returns an {Array} of {React.Component} objects
|
||||
*/
|
||||
findComponentsMatching(descriptor) {
|
||||
if (!descriptor) {
|
||||
throw new Error("ComponentRegistry.findComponentsMatching called without descriptor");
|
||||
}
|
||||
|
||||
const {locations, modes, roles} = this._pluralizeDescriptor(descriptor);
|
||||
|
||||
if (!locations && !modes && !roles) {
|
||||
throw new Error("ComponentRegistry.findComponentsMatching called with an empty descriptor");
|
||||
}
|
||||
|
||||
const cacheKey = JSON.stringify({locations, modes, roles})
|
||||
if (this._cache[cacheKey]) {
|
||||
return [].concat(this._cache[cacheKey]);
|
||||
}
|
||||
|
||||
// Made into a convenience function because default
|
||||
// values (`[]`) are necessary and it was getting messy.
|
||||
const overlaps = (entry = [], search = []) =>
|
||||
_.intersection(entry, search).length > 0
|
||||
|
||||
const entries = Object.values(this._registry).filter((entry) => {
|
||||
if (modes && entry.modes && !overlaps(modes, entry.modes)) {
|
||||
return false;
|
||||
}
|
||||
if (locations && !overlaps(locations, entry.locations)) {
|
||||
return false;
|
||||
}
|
||||
if (roles && !overlaps(roles, entry.roles)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const results = entries.map((entry) => entry.component);
|
||||
this._cache[cacheKey] = results;
|
||||
|
||||
return [].concat(results);
|
||||
}
|
||||
|
||||
// We debounce because a single plugin may activate many components in
|
||||
// their `activate` methods. Furthermore, when the window loads several
|
||||
// plugins may load in sequence. Plugin loading takes a while (dozens of
|
||||
// ms) since javascript is being read and `require` trees are being
|
||||
// traversed.
|
||||
//
|
||||
// Triggering the ComponentRegistry is fairly expensive since many very
|
||||
// high-level components (like the <Sheet />) listen and re-render when
|
||||
// this triggers.
|
||||
//
|
||||
// We set the debouce interval to 1 "frame" (16ms) to balance
|
||||
// responsiveness and efficient batching.
|
||||
//
|
||||
triggerDebounced = _.debounce(() => this.trigger(this), 16)
|
||||
|
||||
_pluralizeDescriptor(descriptor) {
|
||||
let {locations, modes, roles} = descriptor;
|
||||
if (descriptor.mode) { modes = [descriptor.mode] }
|
||||
if (descriptor.role) { roles = [descriptor.role] }
|
||||
if (descriptor.location) { locations = [descriptor.location] }
|
||||
return {locations, modes, roles};
|
||||
}
|
||||
|
||||
_clear() {
|
||||
this._cache = {};
|
||||
this._registry = {};
|
||||
}
|
||||
|
||||
// Showing Component Regions
|
||||
|
||||
toggleComponentRegions() {
|
||||
this._showComponentRegions = !this._showComponentRegions;
|
||||
this.trigger(this);
|
||||
}
|
||||
|
||||
showComponentRegions() {
|
||||
return this._showComponentRegions;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ComponentRegistry()
|
|
@ -1,40 +0,0 @@
|
|||
_ = require 'underscore'
|
||||
path = require 'path'
|
||||
|
||||
class SoundRegistry
|
||||
constructor: ->
|
||||
@_sounds = {}
|
||||
|
||||
playSound: (name) ->
|
||||
return if NylasEnv.inSpecMode()
|
||||
src = @_sounds[name]
|
||||
return unless src
|
||||
|
||||
a = new Audio()
|
||||
|
||||
if _.isString src
|
||||
if src.indexOf("nylas://") is 0
|
||||
a.src = src
|
||||
else
|
||||
a.src = path.join(resourcePath, 'static', 'sounds', src)
|
||||
else if _.isArray src
|
||||
{resourcePath} = NylasEnv.getLoadSettings()
|
||||
args = [resourcePath].concat(src)
|
||||
a.src = path.join.apply(@, args)
|
||||
|
||||
a.autoplay = true
|
||||
a.play()
|
||||
|
||||
register: (name, path) ->
|
||||
if _.isObject(name)
|
||||
@_sounds[key] = path for key, path of name
|
||||
else if _.isString(name)
|
||||
@_sounds[name] = path
|
||||
|
||||
unregister: (name) ->
|
||||
if _.isArray(name)
|
||||
delete @_sounds[key] for key in name
|
||||
else if _.isString(name)
|
||||
delete @_sounds[name]
|
||||
|
||||
module.exports = new SoundRegistry()
|
55
packages/client-app/src/registries/sound-registry.es6
Normal file
55
packages/client-app/src/registries/sound-registry.es6
Normal file
|
@ -0,0 +1,55 @@
|
|||
import path from 'path';
|
||||
|
||||
class SoundRegistry {
|
||||
constructor() {
|
||||
this._sounds = {};
|
||||
}
|
||||
|
||||
playSound(name) {
|
||||
if (NylasEnv.inSpecMode()) {
|
||||
return;
|
||||
}
|
||||
const src = this._sounds[name];
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
|
||||
const a = new Audio()
|
||||
const {resourcePath} = NylasEnv.getLoadSettings();
|
||||
|
||||
if (typeof src === 'string') {
|
||||
if (src.indexOf("nylas://") === 0) {
|
||||
a.src = src;
|
||||
} else {
|
||||
a.src = path.join(resourcePath, 'static', 'sounds', src);
|
||||
}
|
||||
} else if (src instanceof Array) {
|
||||
const args = [resourcePath].concat(src);
|
||||
a.src = path.join.apply(this, args);
|
||||
}
|
||||
a.autoplay = true;
|
||||
a.play();
|
||||
}
|
||||
|
||||
register(name, rpath) {
|
||||
if (typeof name === 'object') {
|
||||
for (const [key, kpath] of Object.entries(name)) {
|
||||
this._sounds[key] = kpath;
|
||||
}
|
||||
} else if (typeof name === 'string') {
|
||||
this._sounds[name] = rpath;
|
||||
}
|
||||
}
|
||||
|
||||
unregister(name) {
|
||||
if (name instanceof Array) {
|
||||
for (const key of name) {
|
||||
delete this._sounds[key];
|
||||
}
|
||||
} else if (typeof name === 'string') {
|
||||
delete this._sounds[name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new SoundRegistry();
|
|
@ -50,7 +50,7 @@ class InlineStyleTransformer {
|
|||
if (i === -1) { return body; }
|
||||
|
||||
if (typeof userAgentDefault === 'undefined' || userAgentDefault === null) {
|
||||
userAgentDefault = require('../chrome-user-agent-stylesheet-string');
|
||||
userAgentDefault = require('../chrome-user-agent-stylesheet-string').default;
|
||||
}
|
||||
return `${body.slice(0, i)}<style>${userAgentDefault}</style>${body.slice(i)}`;
|
||||
}
|
||||
|
|
|
@ -1,190 +0,0 @@
|
|||
_ = require 'underscore'
|
||||
_str = require 'underscore.string'
|
||||
|
||||
# Parses plain text emails to find quoted text and signatures.
|
||||
#
|
||||
# For plain text emails we look for lines that look like they're quoted
|
||||
# text based on common conventions:
|
||||
#
|
||||
# For HTML emails use QuotedHTMLTransformer
|
||||
#
|
||||
# This is modied from https://github.com/mko/emailreplyparser, which is a
|
||||
# JS port of GitHub's Ruby https://github.com/github/email_reply_parser
|
||||
QuotedPlainTextParser =
|
||||
parse: (text) ->
|
||||
parsedEmail = new ParsedEmail
|
||||
parsedEmail.parse text
|
||||
|
||||
visibleText: (text, {showQuoted, showSignature}={}) ->
|
||||
showQuoted ?= false
|
||||
showSignature ?= false
|
||||
@parse(text).visibleText({showQuoted, showSignature})
|
||||
|
||||
hiddenText: (text, {showQuoted, showSignature}={}) ->
|
||||
showQuoted ?= false
|
||||
showSignature ?= false
|
||||
@parse(text).hiddenText({showQuoted, showSignature})
|
||||
|
||||
hasQuotedHTML: (text) ->
|
||||
return @parse(text).hasQuotedHTML()
|
||||
|
||||
chomp = ->
|
||||
@replace /(\n|\r)+$/, ''
|
||||
|
||||
# An ParsedEmail instance contains various `Fragment`s that indicate if we
|
||||
# think a section of text is quoted or is a signature
|
||||
class ParsedEmail
|
||||
constructor: ->
|
||||
@fragments = []
|
||||
@currentFragment = null
|
||||
return
|
||||
|
||||
fragments: []
|
||||
|
||||
hasQuotedHTML: ->
|
||||
for fragment in @fragments
|
||||
return true if fragment.quoted
|
||||
return false
|
||||
|
||||
visibleText: ({showSignature, showQuoted}={}) ->
|
||||
@_setHiddenState({showSignature, showQuoted})
|
||||
return _.reject(@fragments, (f) -> f.hidden).map((f) -> f.to_s()).join('\n')
|
||||
|
||||
hiddenText: ({showSignature, showQuoted}={}) ->
|
||||
@_setHiddenState({showSignature, showQuoted})
|
||||
return _.filter(@fragments, (f) -> f.hidden).map((f) -> f.to_s()).join('\n')
|
||||
|
||||
# We set a hidden state just so we can test the expected output in our
|
||||
# specs. The hidden state is determined by the requested view parameters
|
||||
# and the `quoted` flag on each `fragment`
|
||||
_setHiddenState: ({showSignature, showQuoted}={}) ->
|
||||
fragments = _.reject @fragments, (f) ->
|
||||
if f.to_s().trim() is ""
|
||||
f.hidden = true
|
||||
return true
|
||||
else return false
|
||||
|
||||
for fragment, i in fragments
|
||||
fragment.hidden = true
|
||||
if fragment.quoted
|
||||
if showQuoted or (fragments[i+1]? and not (fragments[i+1].quoted or fragments[i+1].signature))
|
||||
fragment.hidden = false
|
||||
continue
|
||||
else continue
|
||||
|
||||
if fragment.signature
|
||||
if showSignature
|
||||
fragment.hidden = false
|
||||
continue
|
||||
else continue
|
||||
|
||||
fragment.hidden = false
|
||||
|
||||
parse: (text) ->
|
||||
|
||||
# This instance variable points to the current Fragment. If the matched
|
||||
# line fits, it should be added to this Fragment. Otherwise, finish it
|
||||
# and start a new Fragment.
|
||||
@currentFragment = null
|
||||
@_parsePlain(text)
|
||||
|
||||
_parsePlain: (text) ->
|
||||
# Check for multi-line reply headers. Some clients break up
|
||||
# the "On DATE, NAME <EMAIL> wrote:" line into multiple lines.
|
||||
patt = /^(On\s(\n|.)*wrote:)$/m
|
||||
doubleOnPatt = /^(On\s(\n|.)*(^(> )?On\s)((\n|.)*)wrote:)$/m
|
||||
if patt.test(text) and !doubleOnPatt.test(text)
|
||||
replyHeader = patt.exec(text)[0]
|
||||
# Remove all new lines from the reply header.
|
||||
text = text.replace(replyHeader, replyHeader.replace(/\n/g, ' '))
|
||||
|
||||
# The text is reversed initially due to the way we check for hidden
|
||||
# fragments.
|
||||
text = _str.reverse(text)
|
||||
|
||||
# Use the StringScanner to pull out each line of the email content.
|
||||
lines = text.split('\n')
|
||||
|
||||
for i of lines
|
||||
@_scanPlainLine lines[i]
|
||||
|
||||
# Finish up the final fragment. Finishing a fragment will detect any
|
||||
# attributes (hidden, signature, reply), and join each line into a
|
||||
# string.
|
||||
@_finishFragment()
|
||||
|
||||
# Now that parsing is done, reverse the order.
|
||||
@fragments.reverse()
|
||||
|
||||
return @
|
||||
|
||||
_signatureRE:
|
||||
/(--|__|^-\w)|(^sent from my (\s*\w+){1,3}$)/i
|
||||
|
||||
# NOTE: Plain lines are scanned bottom to top. We reverse the text in
|
||||
# `_parsePlain`
|
||||
_scanPlainLine: (line) ->
|
||||
line = chomp.apply(line)
|
||||
|
||||
if !new RegExp(@_signatureRE).test(_str.reverse(line))
|
||||
line = _str.ltrim(line)
|
||||
|
||||
# Mark the current Fragment as a signature if the current line is ''
|
||||
# and the Fragment starts with a common signature indicator.
|
||||
if @currentFragment != null and line == ''
|
||||
if new RegExp(@_signatureRE).test(_str.reverse(@currentFragment.lines[@currentFragment.lines.length - 1]))
|
||||
@currentFragment.signature = true
|
||||
@_finishFragment()
|
||||
|
||||
# We're looking for leading `>`'s to see if this line is part of a
|
||||
# quoted Fragment.
|
||||
isQuoted = new RegExp('(>+)$').test(line)
|
||||
|
||||
# If the line matches the current fragment, add it. Note that a common
|
||||
# reply header also counts as part of the quoted Fragment, even though
|
||||
# it doesn't start with `>`.
|
||||
if @currentFragment != null and (@currentFragment.quoted == isQuoted or @currentFragment.quoted and (@_quoteHeader(line) or line == ''))
|
||||
@currentFragment.lines.push line
|
||||
else
|
||||
@_finishFragment()
|
||||
@currentFragment = new Fragment(isQuoted, line, "plain")
|
||||
return
|
||||
|
||||
_quoteHeader: (line) ->
|
||||
new RegExp('^:etorw.*nO$').test line
|
||||
|
||||
_finishFragment: ->
|
||||
if @currentFragment != null
|
||||
@currentFragment.finish()
|
||||
@fragments.push @currentFragment
|
||||
@currentFragment = null
|
||||
return
|
||||
|
||||
# Represents a group of paragraphs in the email sharing common attributes.
|
||||
# Paragraphs should get their own fragment if they are a quoted area or a
|
||||
# signature.
|
||||
class Fragment
|
||||
constructor: (@quoted, firstLine) ->
|
||||
@signature = false
|
||||
@hidden = false
|
||||
@lines = [ firstLine ]
|
||||
@content = null
|
||||
@lines = @lines.filter(->
|
||||
true
|
||||
)
|
||||
return
|
||||
|
||||
content: null
|
||||
|
||||
finish: ->
|
||||
@content = @lines.join("\n")
|
||||
@lines = []
|
||||
|
||||
@content = _str.reverse(@content)
|
||||
|
||||
return
|
||||
|
||||
to_s: ->
|
||||
@content.toString().trim()
|
||||
|
||||
module.exports = QuotedPlainTextParser
|
|
@ -1,235 +0,0 @@
|
|||
path = require 'path'
|
||||
_ = require 'underscore'
|
||||
{Disposable} = require 'event-kit'
|
||||
{shell, ipcRenderer, remote} = require 'electron'
|
||||
fs = require 'fs-plus'
|
||||
url = require 'url'
|
||||
|
||||
# Handles low-level events related to the window.
|
||||
module.exports =
|
||||
class WindowEventHandler
|
||||
constructor: ->
|
||||
@unloadCallbacks = []
|
||||
|
||||
_.defer =>
|
||||
@showDevModeMessages()
|
||||
|
||||
ipcRenderer.on 'update-available', (event, detail) ->
|
||||
NylasEnv.updateAvailable(detail)
|
||||
|
||||
ipcRenderer.on 'browser-window-focus', ->
|
||||
document.body.classList.remove('is-blurred')
|
||||
window.dispatchEvent(new Event('browser-window-focus'))
|
||||
|
||||
ipcRenderer.on 'browser-window-blur', ->
|
||||
document.body.classList.add('is-blurred')
|
||||
window.dispatchEvent(new Event('browser-window-blur'))
|
||||
|
||||
ipcRenderer.on 'command', (event, command, args...) ->
|
||||
NylasEnv.commands.dispatch(command, args[0])
|
||||
|
||||
ipcRenderer.on 'scroll-touch-begin', ->
|
||||
window.dispatchEvent(new Event('scroll-touch-begin'))
|
||||
|
||||
ipcRenderer.on 'scroll-touch-end', ->
|
||||
window.dispatchEvent(new Event('scroll-touch-end'))
|
||||
|
||||
window.onbeforeunload = =>
|
||||
if NylasEnv.inSpecMode() then return undefined
|
||||
# Don't hide the window here if we don't want the renderer process to be
|
||||
# throttled in case more work needs to be done before closing
|
||||
|
||||
# In Electron, returning any value other than undefined cancels the close.
|
||||
if @runUnloadCallbacks()
|
||||
# Good to go! Window will be closing...
|
||||
NylasEnv.storeWindowDimensions()
|
||||
NylasEnv.saveStateAndUnloadWindow()
|
||||
return undefined
|
||||
return false
|
||||
|
||||
NylasEnv.commands.add document.body, 'window:toggle-full-screen', ->
|
||||
NylasEnv.toggleFullScreen()
|
||||
|
||||
NylasEnv.commands.add document.body, 'window:close', ->
|
||||
NylasEnv.close()
|
||||
|
||||
NylasEnv.commands.add document.body, 'window:reload', =>
|
||||
NylasEnv.reload()
|
||||
|
||||
NylasEnv.commands.add document.body, 'window:toggle-dev-tools', ->
|
||||
NylasEnv.toggleDevTools()
|
||||
|
||||
NylasEnv.commands.add document.body, 'window:open-errorlogger-logs', ->
|
||||
NylasEnv.errorLogger.openLogs()
|
||||
|
||||
NylasEnv.commands.add document.body, 'window:toggle-component-regions', ->
|
||||
ComponentRegistry = require './registries/component-registry'
|
||||
ComponentRegistry.toggleComponentRegions()
|
||||
|
||||
webContents = NylasEnv.getCurrentWindow().webContents
|
||||
NylasEnv.commands.add(document.body, 'core:copy', => webContents.copy())
|
||||
NylasEnv.commands.add(document.body, 'core:cut', => webContents.cut())
|
||||
NylasEnv.commands.add(document.body, 'core:paste', => webContents.paste())
|
||||
NylasEnv.commands.add(document.body, 'core:paste-and-match-style', => webContents.pasteAndMatchStyle())
|
||||
NylasEnv.commands.add(document.body, 'core:undo', => webContents.undo())
|
||||
NylasEnv.commands.add(document.body, 'core:redo', => webContents.redo())
|
||||
NylasEnv.commands.add(document.body, 'core:select-all', => webContents.selectAll())
|
||||
|
||||
# "Pinch to zoom" on the Mac gets translated by the system into a
|
||||
# "scroll with ctrl key down". To prevent the page from zooming in,
|
||||
# prevent default when the ctrlKey is detected.
|
||||
document.addEventListener 'mousewheel', ->
|
||||
if event.ctrlKey
|
||||
event.preventDefault()
|
||||
|
||||
document.addEventListener 'drop', @onDrop
|
||||
|
||||
document.addEventListener 'dragover', @onDragOver
|
||||
|
||||
document.addEventListener 'click', (event) =>
|
||||
if event.target.closest('[href]')
|
||||
@openLink(event)
|
||||
|
||||
document.addEventListener 'contextmenu', (event) =>
|
||||
if event.target.nodeName is 'INPUT'
|
||||
@openContextualMenuForInput(event)
|
||||
|
||||
# Prevent form submits from changing the current window's URL
|
||||
document.addEventListener 'submit', (event) =>
|
||||
if event.target.nodeName is 'FORM'
|
||||
event.preventDefault()
|
||||
@openContextualMenuForInput(event)
|
||||
|
||||
addUnloadCallback: (callback) ->
|
||||
@unloadCallbacks.push(callback)
|
||||
|
||||
removeUnloadCallback: (callback) ->
|
||||
@unloadCallbacks = @unloadCallbacks.filter (cb) -> cb isnt callback
|
||||
|
||||
runUnloadCallbacks: ->
|
||||
hasReturned = false
|
||||
|
||||
unloadCallbacksRunning = 0
|
||||
unloadCallbackComplete = =>
|
||||
unloadCallbacksRunning -= 1
|
||||
if unloadCallbacksRunning is 0 and hasReturned
|
||||
@runUnloadFinished()
|
||||
|
||||
for callback in @unloadCallbacks
|
||||
returnValue = callback(unloadCallbackComplete)
|
||||
if returnValue is false
|
||||
unloadCallbacksRunning += 1
|
||||
else if returnValue isnt true
|
||||
console.warn "You registered an `onBeforeUnload` callback that does not return either exactly `true` or `false`. It returned #{returnValue}", callback
|
||||
|
||||
# In Electron, returning false cancels the close.
|
||||
hasReturned = true
|
||||
return (unloadCallbacksRunning is 0)
|
||||
|
||||
runUnloadFinished: ->
|
||||
{remote} = require('electron')
|
||||
_.defer ->
|
||||
if remote.getGlobal('application').isQuitting()
|
||||
remote.app.quit()
|
||||
else if NylasEnv.isReloading
|
||||
NylasEnv.isReloading = false
|
||||
NylasEnv.reload()
|
||||
else
|
||||
NylasEnv.close()
|
||||
|
||||
# Important: even though we don't do anything here, we need to catch the
|
||||
# drop event to prevent the browser from navigating the to the "url" of the
|
||||
# file and completely leaving the app.
|
||||
onDrop: (event) ->
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
onDragOver: (event) ->
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
resolveHref: (el) ->
|
||||
return null unless el
|
||||
closestHrefEl = el.closest('[href]')
|
||||
return closestHrefEl.getAttribute('href') if closestHrefEl
|
||||
return null
|
||||
|
||||
openLink: ({href, target, currentTarget, metaKey}) ->
|
||||
if not href
|
||||
href = @resolveHref(target || currentTarget)
|
||||
return unless href
|
||||
|
||||
return if target?.closest('.no-open-link-events')
|
||||
|
||||
{protocol} = url.parse(href)
|
||||
return unless protocol
|
||||
|
||||
if protocol in ['mailto:', 'nylas:']
|
||||
# We sometimes get mailto URIs that are not escaped properly, or have been only partially escaped.
|
||||
# (T1927) Be sure to escape them once, and completely, before we try to open them. This logic
|
||||
# *might* apply to http/https as well but it's unclear.
|
||||
href = encodeURI(decodeURI(href))
|
||||
remote.getGlobal('application').openUrl(href)
|
||||
else if protocol in ['http:', 'https:', 'tel:']
|
||||
shell.openExternal(href, activate: !metaKey)
|
||||
|
||||
return
|
||||
|
||||
openContextualMenuForInput: (event) ->
|
||||
event.preventDefault()
|
||||
|
||||
return unless event.target.type in ['text', 'password', 'email', 'number', 'range', 'search', 'tel', 'url']
|
||||
hasSelectedText = event.target.selectionStart isnt event.target.selectionEnd
|
||||
|
||||
if hasSelectedText
|
||||
wordStart = event.target.selectionStart
|
||||
wordEnd = event.target.selectionEnd
|
||||
else
|
||||
wordStart = event.target.value.lastIndexOf(" ", event.target.selectionStart)
|
||||
wordStart = 0 if wordStart is -1
|
||||
wordEnd = event.target.value.indexOf(" ", event.target.selectionStart)
|
||||
wordEnd = event.target.value.length if wordEnd is -1
|
||||
word = event.target.value.substr(wordStart, wordEnd - wordStart)
|
||||
|
||||
{remote} = require('electron')
|
||||
{Menu, MenuItem} = remote
|
||||
menu = new Menu()
|
||||
|
||||
Spellchecker = require('./spellchecker').default
|
||||
Spellchecker.appendSpellingItemsToMenu
|
||||
menu: menu,
|
||||
word: word,
|
||||
onCorrect: (correction) =>
|
||||
insertionPoint = wordStart + correction.length
|
||||
event.target.value = event.target.value.replace(word, correction)
|
||||
event.target.setSelectionRange(insertionPoint, insertionPoint)
|
||||
|
||||
menu.append(new MenuItem({
|
||||
label: 'Cut'
|
||||
enabled: hasSelectedText
|
||||
click: => document.execCommand('cut')
|
||||
}))
|
||||
menu.append(new MenuItem({
|
||||
label: 'Copy'
|
||||
enabled: hasSelectedText
|
||||
click: => document.execCommand('copy')
|
||||
}))
|
||||
menu.append(new MenuItem({
|
||||
label: 'Paste',
|
||||
click: => document.execCommand('paste')
|
||||
}))
|
||||
menu.popup(remote.getCurrentWindow())
|
||||
|
||||
showDevModeMessages: ->
|
||||
return unless NylasEnv.isMainWindow()
|
||||
|
||||
if !NylasEnv.inDevMode()
|
||||
console.log("%c Welcome to Nylas Mail! If you're exploring the source or building a
|
||||
plugin, you should enable debug flags. It's slower, but
|
||||
gives you better exceptions, the debug version of React,
|
||||
and more. Choose %c Developer > Run with Debug Flags %c
|
||||
from the menu. Also, check out https://nylas.github.io/N1/docs/
|
||||
for documentation and sample code!",
|
||||
"background-color: antiquewhite;",
|
||||
"background-color: antiquewhite; font-weight:bold;",
|
||||
"background-color: antiquewhite; font-weight:normal;")
|
289
packages/client-app/src/window-event-handler.es6
Normal file
289
packages/client-app/src/window-event-handler.es6
Normal file
|
@ -0,0 +1,289 @@
|
|||
/* eslint global-require: 0 */
|
||||
import {shell, ipcRenderer, remote} from 'electron';
|
||||
import url from 'url';
|
||||
|
||||
let ComponentRegistry = null;
|
||||
let Spellchecker = null;
|
||||
|
||||
// Handles low-level events related to the window.
|
||||
export default class WindowEventHandler {
|
||||
constructor() {
|
||||
this.unloadCallbacks = []
|
||||
|
||||
setTimeout(() => this.showDevModeMessages(), 1);
|
||||
|
||||
ipcRenderer.on('update-available', (event, detail) =>
|
||||
NylasEnv.updateAvailable(detail)
|
||||
)
|
||||
|
||||
ipcRenderer.on('browser-window-focus', () => {
|
||||
document.body.classList.remove('is-blurred');
|
||||
window.dispatchEvent(new Event('browser-window-focus'));
|
||||
});
|
||||
|
||||
ipcRenderer.on('browser-window-blur', () => {
|
||||
document.body.classList.add('is-blurred');
|
||||
window.dispatchEvent(new Event('browser-window-blur'));
|
||||
});
|
||||
|
||||
ipcRenderer.on('command', (event, command, ...args) => {
|
||||
NylasEnv.commands.dispatch(command, args[0]);
|
||||
})
|
||||
|
||||
ipcRenderer.on('scroll-touch-begin', () => {
|
||||
window.dispatchEvent(new Event('scroll-touch-begin'));
|
||||
});
|
||||
|
||||
ipcRenderer.on('scroll-touch-end', () => {
|
||||
window.dispatchEvent(new Event('scroll-touch-end'))
|
||||
});
|
||||
|
||||
window.onbeforeunload = () => {
|
||||
if (NylasEnv.inSpecMode()) {
|
||||
return undefined;
|
||||
}
|
||||
// Don't hide the window here if we don't want the renderer process to be
|
||||
// throttled in case more work needs to be done before closing
|
||||
|
||||
// In Electron, returning any value other than undefined cancels the close.
|
||||
if (this.runUnloadCallbacks()) {
|
||||
// Good to go! Window will be closing...
|
||||
NylasEnv.storeWindowDimensions();
|
||||
NylasEnv.saveStateAndUnloadWindow();
|
||||
return undefined;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
NylasEnv.commands.add(document.body, 'window:toggle-full-screen', () => {
|
||||
NylasEnv.toggleFullScreen()
|
||||
});
|
||||
|
||||
NylasEnv.commands.add(document.body, 'window:close', () => {
|
||||
NylasEnv.close()
|
||||
});
|
||||
|
||||
NylasEnv.commands.add(document.body, 'window:reload', () => {
|
||||
NylasEnv.reload()
|
||||
});
|
||||
|
||||
NylasEnv.commands.add(document.body, 'window:toggle-dev-tools', () => {
|
||||
NylasEnv.toggleDevTools()
|
||||
});
|
||||
|
||||
NylasEnv.commands.add(document.body, 'window:open-errorlogger-logs', () => {
|
||||
NylasEnv.errorLogger.openLogs()
|
||||
});
|
||||
|
||||
NylasEnv.commands.add(document.body, 'window:toggle-component-regions', () => {
|
||||
ComponentRegistry = ComponentRegistry || require('./registries/component-registry').default
|
||||
ComponentRegistry.toggleComponentRegions()
|
||||
});
|
||||
|
||||
const webContents = NylasEnv.getCurrentWindow().webContents;
|
||||
NylasEnv.commands.add(document.body, 'core:copy', () => webContents.copy())
|
||||
NylasEnv.commands.add(document.body, 'core:cut', () => webContents.cut())
|
||||
NylasEnv.commands.add(document.body, 'core:paste', () => webContents.paste())
|
||||
NylasEnv.commands.add(document.body, 'core:paste-and-match-style', () => webContents.pasteAndMatchStyle())
|
||||
NylasEnv.commands.add(document.body, 'core:undo', () => webContents.undo())
|
||||
NylasEnv.commands.add(document.body, 'core:redo', () => webContents.redo())
|
||||
NylasEnv.commands.add(document.body, 'core:select-all', () => webContents.selectAll())
|
||||
|
||||
// "Pinch to zoom" on the Mac gets translated by the system into a
|
||||
// "scroll with ctrl key down". To prevent the page from zooming in,
|
||||
// prevent default when the ctrlKey is detected.
|
||||
document.addEventListener('mousewheel', () => {
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('drop', this.onDrop);
|
||||
|
||||
document.addEventListener('dragover', this.onDragOver);
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (event.target.closest('[href]')) {
|
||||
this.openLink(event);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('contextmenu', (event) => {
|
||||
if (event.target.nodeName === 'INPUT') {
|
||||
this.openContextualMenuForInput(event);
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent form submits from changing the current window's URL
|
||||
document.addEventListener('submit', (event) => {
|
||||
if (event.target.nodeName === 'FORM') {
|
||||
event.preventDefault()
|
||||
this.openContextualMenuForInput(event)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addUnloadCallback(callback) {
|
||||
this.unloadCallbacks.push(callback);
|
||||
}
|
||||
|
||||
removeUnloadCallback(callback) {
|
||||
this.unloadCallbacks = this.unloadCallbacks.filter((cb) => cb !== callback);
|
||||
}
|
||||
|
||||
runUnloadCallbacks() {
|
||||
let hasReturned = false
|
||||
|
||||
let unloadCallbacksRunning = 0;
|
||||
const unloadCallbackComplete = () => {
|
||||
unloadCallbacksRunning -= 1;
|
||||
if (unloadCallbacksRunning === 0 && hasReturned) {
|
||||
this.runUnloadFinished();
|
||||
}
|
||||
};
|
||||
|
||||
for (const callback of this.unloadCallbacks) {
|
||||
const returnValue = callback(unloadCallbackComplete);
|
||||
if (returnValue === false) {
|
||||
unloadCallbacksRunning += 1;
|
||||
} else if (returnValue !== true) {
|
||||
console.warn(`You registered an "onBeforeUnload" callback that does not return either exactly true or false. It returned ${returnValue}`, callback);
|
||||
}
|
||||
}
|
||||
|
||||
// In Electron, returning false cancels the close.
|
||||
hasReturned = true;
|
||||
return (unloadCallbacksRunning === 0);
|
||||
}
|
||||
|
||||
runUnloadFinished() {
|
||||
setTimeout(() => {
|
||||
if (remote.getGlobal('application').isQuitting()) {
|
||||
remote.app.quit();
|
||||
} else if (NylasEnv.isReloading) {
|
||||
NylasEnv.isReloading = false;
|
||||
NylasEnv.reload();
|
||||
} else {
|
||||
NylasEnv.close();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Important: even though we don't do anything here, we need to catch the
|
||||
// drop event to prevent the browser from navigating the to the "url" of the
|
||||
// file and completely leaving the app.
|
||||
onDrop = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
onDragOver = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
resolveHref(el) {
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
const closestHrefEl = el.closest('[href]');
|
||||
return closestHrefEl ? closestHrefEl.getAttribute('href') : null;
|
||||
}
|
||||
|
||||
openLink({href, target, currentTarget, metaKey}) {
|
||||
const resolved = href || this.resolveHref(target || currentTarget);
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
if (target && target.closest('.no-open-link-events')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {protocol} = url.parse(resolved);
|
||||
if (!protocol) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (['mailto:', 'nylas:'].includes(protocol)) {
|
||||
// We sometimes get mailto URIs that are not escaped properly, or have been only partially escaped.
|
||||
// (T1927) Be sure to escape them once, and completely, before we try to open them. This logic
|
||||
// *might* apply to http/https as well but it's unclear.
|
||||
const sanitized = encodeURI(decodeURI(resolved));
|
||||
remote.getGlobal('application').openUrl(sanitized);
|
||||
} else if (['http:', 'https:', 'tel:'].includes(protocol)) {
|
||||
shell.openExternal(resolved, {activate: !metaKey});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
openContextualMenuForInput(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!['text', 'password', 'email', 'number', 'range', 'search', 'tel', 'url'].includes(event.target.type)) {
|
||||
return;
|
||||
}
|
||||
const hasSelectedText = event.target.selectionStart !== event.target.selectionEnd;
|
||||
|
||||
let wordStart = null;
|
||||
let wordEnd = null;
|
||||
|
||||
if (hasSelectedText) {
|
||||
wordStart = event.target.selectionStart
|
||||
wordEnd = event.target.selectionEnd
|
||||
} else {
|
||||
wordStart = event.target.value.lastIndexOf(" ", event.target.selectionStart)
|
||||
if (wordStart === -1) { wordStart = 0; }
|
||||
wordEnd = event.target.value.indexOf(" ", event.target.selectionStart)
|
||||
if (wordEnd === -1) { wordEnd = event.target.value.length; }
|
||||
}
|
||||
const word = event.target.value.substr(wordStart, wordEnd - wordStart);
|
||||
|
||||
const {Menu, MenuItem} = remote;
|
||||
const menu = new Menu();
|
||||
|
||||
Spellchecker = Spellchecker || require('./spellchecker').default
|
||||
Spellchecker.appendSpellingItemsToMenu({
|
||||
menu: menu,
|
||||
word: word,
|
||||
onCorrect: (correction) => {
|
||||
const insertionPoint = wordStart + correction.length
|
||||
event.target.value = event.target.value.replace(word, correction)
|
||||
event.target.setSelectionRange(insertionPoint, insertionPoint)
|
||||
},
|
||||
});
|
||||
|
||||
menu.append(new MenuItem({
|
||||
label: 'Cut',
|
||||
enabled: hasSelectedText,
|
||||
click: () => document.execCommand('cut'),
|
||||
}));
|
||||
menu.append(new MenuItem({
|
||||
label: 'Copy',
|
||||
enabled: hasSelectedText,
|
||||
click: () => document.execCommand('copy'),
|
||||
}))
|
||||
menu.append(new MenuItem({
|
||||
label: 'Paste',
|
||||
click: () => document.execCommand('paste'),
|
||||
}));
|
||||
menu.popup(remote.getCurrentWindow());
|
||||
}
|
||||
|
||||
showDevModeMessages() {
|
||||
if (!NylasEnv.isMainWindow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!NylasEnv.inDevMode()) {
|
||||
console.log(`%c Welcome to Nylas Mail! If you're exploring the source or building a
|
||||
plugin, you should enable debug flags. It's slower, but
|
||||
gives you better exceptions, the debug version of React,
|
||||
and more. Choose %c Developer > Run with Debug Flags %c
|
||||
from the menu. Also, check out https://nylas.github.io/N1/docs/
|
||||
for documentation and sample code!`,
|
||||
"background-color: antiquewhite;",
|
||||
"background-color: antiquewhite; font-weight:bold;",
|
||||
"background-color: antiquewhite; font-weight:normal;");
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue