I’ve 👏 had 👏 it 👏 with 👏 CoffeeScript

This commit is contained in:
Ben Gotow 2017-07-30 16:57:43 -07:00
parent f2104324be
commit a8c6095d15
43 changed files with 2465 additions and 2580 deletions

View file

@ -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

View file

@ -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>
);
}
}

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -1,130 +0,0 @@
ThreadDragImage = document.createElement("img")
ThreadDragImage.src = """data:image/png;base64,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"""
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

View file

@ -0,0 +1,139 @@
const ThreadDragImage = document.createElement("img")
ThreadDragImage.src = `data:image/png;base64,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`;
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
}

View file

@ -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; }
}
"""
`

View file

@ -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

View file

@ -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

View 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;

View file

@ -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()

View 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()

View file

@ -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()

View 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();

View file

@ -1,7 +0,0 @@
###
Public: DraftStoreExtension is deprecated. Use {ComposerExtension} instead.
Section: Extensions
###
class DraftStoreExtension
module.exports = DraftStoreExtension

View file

@ -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);
}
})

View file

@ -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()

View 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()

View file

@ -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();

View file

@ -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'

View file

@ -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

View 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();

View file

@ -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 = {}

View file

@ -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()

View 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');

View file

@ -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

View file

@ -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)

View 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);
});
}

View file

@ -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

View 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;
}
}

View file

@ -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()

View 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()

View file

@ -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()

View 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();

View file

@ -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)}`;
}

View file

@ -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

View file

@ -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;")

View 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;");
}
}
}