Merge branch 'master' into unified-inbox

# Conflicts:
#	spec/stores/file-download-store-spec.coffee
This commit is contained in:
Ben Gotow 2016-01-13 17:20:34 -08:00
commit cb5f2985ef
37 changed files with 624 additions and 418 deletions

View file

@ -1,19 +1,35 @@
# N1 Changelog
### 0.3.38 (1/8/16)
### 0.3.43 (1/12/16)
- Features:
+ You can now enable and disable bundled plugins from Preferences > Plugins,
and bundled plugin updates are delivered alongside N1 updates.
+ You can now adjust the interface zoom from the workspace preferences.
- Development:
+ Packages can now list a relative `icon` path in their package.json.
- Composer Improvements:
+ You can now reply inline by outdenting (pressing delete) in quoted text.
+ The Apple Mail keyboard shortcut for send is now correct.
+ Keyboard shortcuts are shown in the shortcuts preferences.
+ Clicking beneath the message body now positions your cursor correctly.
+ Tabbing to the body positions the cursor at the end of the draft reliably.
+ Tabbing to the subject highlights it correctly.
+ Copy & paste now preserves line breaks reliably
+ Inserting a template into a draft will no longer remove your signature.
- Fixes:
+ The Apple Mail keyboard shortcut for send is not correct.
+ Composer keyboard shortcuts are shown in the shortcuts preferences.
+ You can now unsubscribe from the N1 mailing list from the Account preferences.
+ The message actions dropdown is now left aligned.
+ Thread "Quick Actions" are now displayed in the correct order.
+ Account names can no longer overflow the preferences sidebar.
+ On Windows, N1 restarts after installing an update.
+ N1 now re-opens in fullscreen mode if it exited in fullscreen mode.
+ Files with illegal filesystem characters can be dragged and dropped normally.
+ Files with illegal filesystem characters now download and open correctly.
+ The Event RSVP interface only appears if you are a participant on the event.
### 0.3.36 (1/5/16)

View file

@ -56,4 +56,4 @@ By default the N1 source points to our hosted version of the Nylas Sync Engine;
Have an idea for a package, or a feature you'd love to see in N1? Check out our
[public Trello board](https://trello.com/b/hxsqB6vx/n1-open-source-roadmap)
to contribute your thoughts and vote on existing ideas or see the [existing plugins and themes](http://github.com/nylas/n1-plugins).
to contribute your thoughts and vote on existing ideas.

View file

@ -2,6 +2,8 @@ nylas: arch-dependent-file-in-usr-share
nylas: changelog-file-missing-in-native-package
nylas: copyright-file-contains-full-apache-2-license
nylas: copyright-should-refer-to-common-license-file-for-apache-2
nylas: copyright-should-refer-to-common-license-file-for-lgpl
nylas: embedded-library
nylas: package-installs-python-bytecode
nylas: unstripped-binary-or-object
nylas: extra-license-file

View file

@ -7,5 +7,5 @@ Icon=<%= iconName %>
Type=Application
StartupNotify=true
StartupWMClass=Nylas N1
Categories=GNOME;GTK;Utility;EmailClient;Development;
Categories=GNOME;GTK;Email;Utility;Development;
MimeType=text/plain;x-scheme-handler/mailto;x-scheme-handler/nylas;

View file

@ -62,21 +62,24 @@ module.exports = (grunt) ->
return reject(err) if err
resolve()
put = (localSource, destName) ->
put = (localSource, destName, options = {}) ->
grunt.log.writeln ">> Uploading #{localSource} to S3…"
write = grunt.log.writeln
ext = path.extname(destName)
lastPc = 0
params =
Key: destName
ACL: "public-read"
Bucket: "edgehill"
_.extend(params, options)
new Promise (resolve, reject) ->
uploader = s3Client.uploadFile
localFile: localSource
s3Params:
Key: destName
ACL: "public-read"
Bucket: "edgehill"
s3Params: params
uploader.on "error", (err) ->
reject(err)
uploader.on "progress", ->
@ -158,9 +161,11 @@ module.exports = (grunt) ->
files = fs.readdirSync(buildDir)
for file in files
if path.extname(file) is '.deb'
uploadPromises.push uploadToS3(file, "#{fullVersion}/#{process.platform}-deb/#{process.arch}/N1.deb")
uploadPromises.push uploadToS3(file, "#{fullVersion}/#{process.platform}-deb/#{process.arch}/N1.deb",
{"ContentType": "application/x-deb"})
if path.extname(file) is '.rpm'
uploadPromises.push uploadToS3(file, "#{fullVersion}/#{process.platform}-rpm/#{process.arch}/N1.rpm")
uploadPromises.push uploadToS3(file, "#{fullVersion}/#{process.platform}-rpm/#{process.arch}/N1.rpm",
{"ContentType": "application/x-rpm"})
else
grunt.fail.fatal "Unsupported platform: '#{process.platform}'"

View file

@ -65,9 +65,11 @@ class AttachmentComponent extends React.Component
<RetinaImg name="icon-attachment-download.png" mode={RetinaImg.Mode.ContentPreserve} />
_onDragStart: (event) =>
path = FileDownloadStore.pathForFile(@props.file)
if fs.existsSync(path)
DownloadURL = "#{@props.file.contentType}:#{@props.file.displayName()}:file://#{path}"
filePath = FileDownloadStore.pathForFile(@props.file)
if fs.existsSync(filePath)
# Note: From trial and error, it appears that the second param /MUST/ be the
# same as the last component of the filePath URL, or the download fails.
DownloadURL = "#{@props.file.contentType}:#{path.basename(filePath)}:file://#{filePath}"
event.dataTransfer.setData("DownloadURL", DownloadURL)
event.dataTransfer.setData("text/nylas-file-url", DownloadURL)
else

View file

@ -13,7 +13,7 @@ class SpellcheckComposerExtension extends ComposerExtension
SpellcheckCache[word]
@onContentChanged: ({editor}) =>
@walkTree(editor)
@update(editor)
@onShowContextMenu: ({editor, event, menu}) =>
selection = editor.currentSelection()
@ -39,87 +39,116 @@ class SpellcheckComposerExtension extends ComposerExtension
@applyCorrection: (editor, range, selection, correction) =>
DOMUtils.Mutating.applyTextInRange(range, selection, correction)
@walkTree(editor)
@update(editor)
@learnSpelling: (editor, word) =>
spellchecker.add(word)
delete SpellcheckCache[word]
@walkTree(editor)
@update(editor)
@walkTree: (editor) =>
# Remove all existing spellcheck nodes
spellingNodes = editor.rootNode.querySelectorAll('spelling')
for node in spellingNodes
editor.whilePreservingSelection =>
DOMUtils.unwrapNode(node)
# Normalize to make sure words aren't split across text nodes
editor.rootNode.normalize()
@update: (editor) =>
@_unwrapWords(editor)
@_wrapMisspelledWords(editor)
# Creates a shallow copy of a selection object where anchorNode / focusNode
# can be changed, and provides it to the callback provided. After the callback
# runs, it applies the new selection if `snapshot.modified` has been set.
#
# Note: This is different from ExposedSelection because the nodes are not cloned.
# In the callback functions, we need to check whether the anchor/focus nodes
# are INSIDE the nodes we're adjusting.
#
@_whileApplyingSelectionChanges: (cb) =>
selection = document.getSelection()
selectionSnapshot =
anchorNode: selection.anchorNode
anchorOffset: selection.anchorOffset
focusNode: selection.focusNode
focusOffset: selection.focusOffset
selectionImpacted = false
modified: false
treeWalker = document.createTreeWalker(editor.rootNode, NodeFilter.SHOW_TEXT)
nodeList = []
nodeMisspellingsFound = 0
cb(selectionSnapshot)
while (treeWalker.nextNode())
nodeList.push(treeWalker.currentNode)
# Note: As a performance optimization, we stop spellchecking after encountering
# 10 misspelled words. This keeps the runtime of this method bounded!
while (node = nodeList.shift())
break if nodeMisspellingsFound > 10
str = node.textContent
# https://regex101.com/r/bG5yC4/1
wordRegexp = /(\w[\w'-]*\w|\w)/g
while ((match = wordRegexp.exec(str)) isnt null)
break if nodeMisspellingsFound > 10
misspelled = @isMisspelled(match[0])
if misspelled
# The insertion point is currently at the end of this misspelled word.
# Do not mark it until the user types a space or leaves.
if selectionSnapshot.focusNode is node and selectionSnapshot.focusOffset is match.index + match[0].length
continue
if match.index is 0
matchNode = node
else
matchNode = node.splitText(match.index)
afterMatchNode = matchNode.splitText(match[0].length)
spellingSpan = document.createElement('spelling')
spellingSpan.classList.add('misspelled')
spellingSpan.innerText = match[0]
matchNode.parentNode.replaceChild(spellingSpan, matchNode)
for prop in ['anchor', 'focus']
if selectionSnapshot["#{prop}Node"] is node
if selectionSnapshot["#{prop}Offset"] > match.index + match[0].length
selectionImpacted = true
selectionSnapshot["#{prop}Node"] = afterMatchNode
selectionSnapshot["#{prop}Offset"] -= match.index + match[0].length
else if selectionSnapshot["#{prop}Offset"] > match.index
selectionImpacted = true
selectionSnapshot["#{prop}Node"] = spellingSpan.childNodes[0]
selectionSnapshot["#{prop}Offset"] -= match.index
nodeMisspellingsFound += 1
nodeList.unshift(afterMatchNode)
break
if selectionImpacted
if selectionSnapshot.modified
selection.setBaseAndExtent(selectionSnapshot.anchorNode, selectionSnapshot.anchorOffset, selectionSnapshot.focusNode, selectionSnapshot.focusOffset)
# Removes all of the <spelling> nodes found in the provided `editor`.
# It normalizes the DOM after removing spelling nodes to ensure that words
# are not split between text nodes. (ie: doesn, 't => doesn't)
@_unwrapWords: (editor) =>
@_whileApplyingSelectionChanges (selectionSnapshot) =>
spellingNodes = editor.rootNode.querySelectorAll('spelling')
for node in spellingNodes
if selectionSnapshot.anchorNode is node
selectionSnapshot.anchorNode = node.firstChild
if selectionSnapshot.focusNode is node
selectionSnapshot.focusNode = node.firstChild
selectionSnapshot.modified = true
node.parentNode.insertBefore(node.firstChild, node) while (node.firstChild)
node.parentNode.removeChild(node)
editor.rootNode.normalize()
# Traverses all of the text nodes within the provided `editor`. If it finds a
# text node with a misspelled word, it splits it, wraps the misspelled word
# with a <spelling> node and updates the selection to account for the change.
@_wrapMisspelledWords: (editor) =>
@_whileApplyingSelectionChanges (selectionSnapshot) =>
treeWalker = document.createTreeWalker(editor.rootNode, NodeFilter.SHOW_TEXT)
nodeList = []
nodeMisspellingsFound = 0
while (treeWalker.nextNode())
nodeList.push(treeWalker.currentNode)
# Note: As a performance optimization, we stop spellchecking after encountering
# 30 misspelled words. This keeps the runtime of this method bounded!
while (node = nodeList.shift())
break if nodeMisspellingsFound > 30
str = node.textContent
# https://regex101.com/r/bG5yC4/1
wordRegexp = /(\w[\w'-]*\w|\w)/g
while ((match = wordRegexp.exec(str)) isnt null)
break if nodeMisspellingsFound > 30
misspelled = @isMisspelled(match[0])
if misspelled
# The insertion point is currently at the end of this misspelled word.
# Do not mark it until the user types a space or leaves.
if selectionSnapshot.focusNode is node and selectionSnapshot.focusOffset is match.index + match[0].length
continue
if match.index is 0
matchNode = node
else
matchNode = node.splitText(match.index)
afterMatchNode = matchNode.splitText(match[0].length)
spellingSpan = document.createElement('spelling')
spellingSpan.classList.add('misspelled')
spellingSpan.innerText = match[0]
matchNode.parentNode.replaceChild(spellingSpan, matchNode)
for prop in ['anchor', 'focus']
if selectionSnapshot["#{prop}Node"] is node
if selectionSnapshot["#{prop}Offset"] > match.index + match[0].length
selectionSnapshot.modified = true
selectionSnapshot["#{prop}Node"] = afterMatchNode
selectionSnapshot["#{prop}Offset"] -= match.index + match[0].length
else if selectionSnapshot["#{prop}Offset"] > match.index
selectionSnapshot.modified = true
selectionSnapshot["#{prop}Node"] = spellingSpan.childNodes[0]
selectionSnapshot["#{prop}Offset"] -= match.index
nodeMisspellingsFound += 1
nodeList.unshift(afterMatchNode)
break
@finalizeSessionBeforeSending: ({session}) ->
body = session.draft().body
clean = body.replace(/<\/?spelling[^>]*>/g, '')

View file

@ -12,7 +12,7 @@ describe "SpellcheckComposerExtension", ->
spyOn(SpellcheckComposerExtension, 'isMisspelled').andCallFake (word) ->
spellings[word]
describe "walkTree", ->
describe "update", ->
it "correctly walks a DOM tree and surrounds mispelled words", ->
dom = document.createElement('div')
dom.innerHTML = initialHTML
@ -21,7 +21,7 @@ describe "SpellcheckComposerExtension", ->
rootNode: dom
whilePreservingSelection: (cb) -> cb()
SpellcheckComposerExtension.walkTree(editor)
SpellcheckComposerExtension.update(editor)
expect(dom.innerHTML).toEqual(expectedHTML)
describe "finalizeSessionBeforeSending", ->

View file

@ -0,0 +1,24 @@
<p>
Hi there <code class="var empty">{{First Name}}</code>,
</p>
<p>
Welcome to the Quick Replies plugin! Here you can create email templates with
<code class="var empty">{{variable regions}}</code> that you can quickly fill in
before you send your email.
</p>
<blockquote>
<p style="background: #F8F8F8; border: 1px solid #AAA; border-radius: 5px; padding: 5px;">
Just wrap text in <code class="var empty">{{double brackets}}</code> to create a variable! <b>You
can add <code class="var empty">{{variables}}</code> inside areas of formatted
text too.</b>
</p>
</blockquote>
<p>
When you send your message, the highlighting is always removed so the recipient
never sees it.
<p>
Enjoy!
</p>
<p>
- Nylas Team
</p>

View file

@ -1,20 +0,0 @@
<p>
Hi there <code class="var empty">First Name</code>,
</p>
<p>
Welcome to the templates package! Templates live in the <strong>~/.nylas/templates</strong>
directory on your computer. Each template is an HTML file - the name of the
file is the name of the template, and it's contents are the default message body.
</p>
<p>
If you include HTML &lt;code&gt; tags in your template, you can create
regions that you can jump between and fill easily. Check out the source of
the template for a <code class="var">super awesome</code> example!
</p>
<p>
Give &lt;code&gt; tags the `var` class to mark them as template regions. Add
the `empty` class to make them dark yellow. When you send your message, &lt;code&gt;
tags are always stripped so the recipient never sees any highlighting.
<p>
- Nylas Team
</p>

View file

@ -13,7 +13,7 @@ class PreferencesTemplates extends React.Component
{templates, selectedTemplate, selectedTemplateName} = @_getStateFromStores()
@state =
editAsHTML: false
editState: null
editState: if templates.length==0 then "new" else null
templates: templates
selectedTemplate: selectedTemplate
selectedTemplateName: selectedTemplateName
@ -108,14 +108,14 @@ class PreferencesTemplates extends React.Component
_renderEditableTemplate: ->
<Contenteditable
ref="templateInput"
value={@state.contents}
value={@state.contents || ""}
onChange={@_onEditTemplate}
extensions={[TemplateEditor]}
spellcheck={false} />
_renderHTMLTemplate: ->
<textarea ref="templateHTMLInput"
value={@state.contents}
value={@state.contents || ""}
onChange={@_onEditTemplate}/>
_renderModeToggle: ->
@ -164,8 +164,15 @@ class PreferencesTemplates extends React.Component
# DELETE AND NEW
_deleteTemplate: =>
numTemplates = @state.templates.length
if @state.selectedTemplate?
TemplateStore.deleteTemplate(@state.selectedTemplate.name)
if numTemplates==1
@setState
editState: "new"
selectedTemplate: null
selectedTemplateName: ""
contents: ""
_startNewTemplate: =>
@setState
@ -175,7 +182,7 @@ class PreferencesTemplates extends React.Component
contents: ""
_saveNewTemplate: =>
TemplateStore.saveNewTemplate(@state.selectedTemplateName, @state.contents, (template) =>
TemplateStore.saveNewTemplate(@state.selectedTemplateName, @state.contents || "", (template) =>
@setState
selectedTemplate: template
editState: null
@ -190,10 +197,11 @@ class PreferencesTemplates extends React.Component
@_loadTemplateContents(template)
_renderCreateNew: ->
cancel = <button className="btn template-name-btn" onClick={@_cancelNewTemplate}>Cancel</button>
<div className="section-title">
Template Name: <input type="text" className="template-name-input" value={@state.selectedTemplateName} onChange={@_onEditName}/>
<button className="btn btn-emphasis template-name-btn" onClick={@_saveNewTemplate}>Save</button>
<button className="btn template-name-btn" onClick={@_cancelNewTemplate}>Cancel</button>
{if @state.templates.length then cancel}
</div>
@ -218,6 +226,11 @@ class PreferencesTemplates extends React.Component
</div>
</div>
noTemplatesMessage =
<div className="template-status-bar no-templates-message">
You don't have any templates! Enter a template name and press Save to create one.
</div>
<div>
<section className="container-templates" style={if @state.editState is "new" then {marginBottom:50}}>
<h2>Quick Replies</h2>
@ -228,22 +241,23 @@ class PreferencesTemplates extends React.Component
else @_renderName()
}
{if @state.editState isnt "new" then editor}
{if @state.editState is "new" then noTemplatesMessage}
</section>
<section className="templates-instructions">
<p>
The Quick Replies plugin allows you to create templated email replies. Replies can contain variables, which
you can quickly jump between and fill out when using the template. To create a variable, type a set of double curly
The Quick Replies plugin allows you to create templated email replies, with variables that
you can quickly fill in before sending your email message. To create a variable, type a set of double curly
brackets wrapping the variable's name, like this: <strong>{"{{"}variable_name{"}}"}</strong>
</p>
<p>
Reply templates are saved in the <strong>~/.nylas/templates</strong> directory on your computer. Each template
is an HTML file - the name of the file is the name of the template, and its contents are the default message body.
</p>
<p>
In raw HTML, variables are defined as HTML &lt;code&gt; tags with class "var empty". Typing curly brackets creates a tag
automatically. The code tags are colored yellow to show the variable regions, but will be stripped out before the message is sent.
</p>
<p>
Reply templates live in the <strong>~/.nylas/templates</strong> directory on your computer. Each template
is an HTML file - the name of the file is the name of the template, and its contents are the default message body.
</p>
</section>
</div>

View file

@ -18,11 +18,21 @@ class TemplateEditor extends ContenteditableExtension
editor.whilePreservingSelection ->
DOMUtils.unwrapNode(codeNode)
# Attempt to sanitize spans that are needlessly created by contenteditable
for span in editor.rootNode.querySelectorAll("span")
if not span.className
# Attempt to sanitize extra nodes that may have been created by contenteditable on certain text editing
# operations (insertion/deletion of line breaks, etc.). These are generally <span>, but can also be
# <font>, <b>, and possibly others. The extra nodes often grab CSS styles from neighboring elements
# as inline style, including the yellow text from <code> nodes that we insert. This is contenteditable
# trying to be "smart" and preserve styles, which is very undesirable for the <code> node styles. The
# below code is a hack to prevent yellow text from appearing.
for node in editor.rootNode.querySelectorAll("*")
if not node.className and node.style.color == "#c79b11"
editor.whilePreservingSelection ->
DOMUtils.unwrapNode(span)
DOMUtils.unwrapNode(node)
for node in editor.rootNode.querySelectorAll("font")
if node.color == "#c79b11"
editor.whilePreservingSelection ->
DOMUtils.unwrapNode(node)
# Find all {{}} and wrap them in code nodes if they aren't already
# Regex finds any {{ <contents> }} that doesn't contain {, }, or \n
@ -39,16 +49,4 @@ class TemplateEditor extends ContenteditableExtension
editor.restoreSelectionByTextIndex(codeNode, selIndex.startIndex, selIndex.endIndex)
@onKeyDown: ({editor}) ->
# Look for all existing code tags that we may have added before,
# and remove any that now have invalid content (don't start with {{ and
# end with }} as well as any that wrap the current selection
codeNodes = editor.rootNode.querySelectorAll("code.var.empty")
for codeNode in codeNodes
text = codeNode.textContent
if not text.startsWith("{{") or not text.endsWith("}}") or DOMUtils.selectionStartsOrEndsIn(codeNode)
editor.whilePreservingSelection ->
DOMUtils.unwrapNode(codeNode)
module.exports = TemplateEditor

View file

@ -84,7 +84,7 @@ class TemplatePicker extends React.Component {
const footerComponents = [
<div className="item" key="new" onMouseDown={this._onNewTemplate}>Save Draft as Template...</div>,
<div className="item" key="manage" onMouseDown={this._onManageTemplates}>Open Templates Folder...</div>,
<div className="item" key="manage" onMouseDown={this._onManageTemplates}>Manage Templates...</div>,
];
return (

View file

@ -32,7 +32,7 @@ class TemplateStatusBar extends React.Component {
static containerStyles = {
textAlign: 'center',
width: 530,
width: 580,
margin: 'auto',
}

View file

@ -32,7 +32,7 @@ class TemplateStore extends NylasStore {
this._registerListeners();
this._templatesDir = templatesDir;
this._welcomeName = 'Welcome to Templates.html';
this._welcomeName = 'Welcome to Quick Replies.html';
this._welcomePath = path.join(__dirname, '..', 'assets', this._welcomeName);
this._watcher = null;
@ -106,7 +106,10 @@ class TemplateStore extends NylasStore {
DraftStore.sessionForClientId(draftClientId).then((session) => {
const draft = session.draft();
const draftName = name ? name : draft.subject.replace(TemplateStore.INVALID_TEMPLATE_NAME_REGEX, '');
const draftContents = contents ? contents : QuotedHTMLTransformer.removeQuotedHTML(draft.body);
let draftContents = contents ? contents : QuotedHTMLTransformer.removeQuotedHTML(draft.body);
const sigIndex = draftContents.indexOf('<div class="nylas-n1-signature">');
draftContents = sigIndex > -1 ? draftContents.slice(0, sigIndex) : draftContents;
if (!draftName || draftName.length === 0) {
this._displayError('Give your draft a subject to name your template.');
}
@ -147,6 +150,11 @@ class TemplateStore extends NylasStore {
}
saveNewTemplate(name, contents, callback) {
if (!name || name.length === 0) {
this._displayError('You must provide a template name.');
return;
}
if (name.match(TemplateStore.INVALID_TEMPLATE_NAME_REGEX)) {
this._displayError('Invalid template name! Names can only contain letters, numbers, spaces, dashes, and underscores.');
return;
@ -219,6 +227,10 @@ class TemplateStore extends NylasStore {
this._displayError('Invalid template name! Names can only contain letters, numbers, spaces, dashes, and underscores.');
return;
}
if (newName.length === 0) {
this._displayError('You must provide a template name.');
return;
}
const newFilename = `${newName}.html`;
const oldPath = path.join(this._templatesDir, `${oldName}.html`);
@ -233,7 +245,7 @@ class TemplateStore extends NylasStore {
}
_onInsertTemplateId({templateId, draftClientId} = {}) {
this.getTemplateContents(templateId, (body) => {
this.getTemplateContents(templateId, (templateBody) => {
DraftStore.sessionForClientId(draftClientId).then((session)=> {
let proceed = true;
if (!session.draft().pristine) {
@ -246,7 +258,11 @@ class TemplateStore extends NylasStore {
}
if (proceed) {
const draftHtml = QuotedHTMLTransformer.appendQuotedHTML(body, session.draft().body);
const draftContents = QuotedHTMLTransformer.removeQuotedHTML(session.draft().body);
const sigIndex = draftContents.indexOf('<div class="nylas-n1-signature">');
const signature = sigIndex > -1 ? draftContents.slice(sigIndex) : '';
const draftHtml = QuotedHTMLTransformer.appendQuotedHTML(templateBody + signature, session.draft().body);
session.changes.add({body: draftHtml});
}
});

View file

@ -22,6 +22,7 @@
border-radius: @border-radius-small;
padding-top: @padding-small-vertical @padding-small-horizontal @padding-small-vertical @padding-small-horizontal;
font-size: @font-size-small;
margin-bottom: 10px;
}
.compose-body,.container-templates .contenteditable {
@ -43,6 +44,11 @@
max-width: 640px;
.no-templates-message {
text-align: center;
margin-top: 50px;
}
.template-wrap {
position: relative;
border: 1px solid @input-border-color;
@ -112,7 +118,7 @@
.template-name-btn {
float: right;
margin: 6px;
margin: 0 6px;
}
.template-name-input {
display: inline-block;

View file

@ -128,7 +128,7 @@ class ComposerEditor extends Component {
// quoted text that is visible. (as in forwarded messages.)
//
this.refs.contenteditable.atomicEdit( ({editor})=> {
const walker = document.createTreeWalker(editor.rootNode, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
const walker = document.createTreeWalker(editor.rootNode, NodeFilter.SHOW_TEXT);
const nodesBelowUserBody = editor.rootNode.querySelectorAll('.nylas-n1-signature, .gmail_quote, blockquote');
let lastNode = null;
@ -162,6 +162,12 @@ class ComposerEditor extends Component {
});
}
nativeFocus() {
this.refs.contenteditable.atomicEdit( ({editor})=> {
editor.rootNode.focus();
});
}
/**
* @private
* This method was included so that the tests don't break

View file

@ -332,6 +332,7 @@ class ComposerView extends React.Component
onComponentDidRender={@_onEditorBodyDidRender}
requiredMethods={[
'focus'
'nativeFocus'
'getCurrentSelection'
'getPreviousSelection'
'_onDOMMutated'
@ -493,7 +494,10 @@ class ComposerView extends React.Component
_onMouseUpComposerBody: (event) =>
if event.target is @_mouseDownTarget
@setState(focusedField: Fields.Body)
# We don't set state directly here because we want the native
# contenteditable focus behavior. When the contenteditable gets focused
# the focused field state will be properly set via editor.onFocus
@refs[Fields.Body].nativeFocus()
@_mouseDownTarget = null
# When a user focuses the composer, it's possible that no input is
@ -747,6 +751,8 @@ class ComposerView extends React.Component
_mentionsAttachment: (body) =>
body = QuotedHTMLTransformer.removeQuotedHTML(body.toLowerCase().trim())
signatureIndex = body.indexOf('<div class="nylas-n1-signature">')
body = body[...signatureIndex] if signatureIndex isnt -1
return body.indexOf("attach") >= 0
_destroyDraft: =>

View file

@ -15,7 +15,6 @@
"engines": {
"nylas": ">=0.3.0 <0.5.0"
},
"description": "Enter a description of your package!",
"dependencies": [],
"license": "MIT"
}

View file

@ -1,6 +1,6 @@
React = require 'react'
_ = require 'underscore'
{Flexbox} = require 'nylas-component-kit'
{Flexbox, RetinaImg} = require 'nylas-component-kit'
PluginsActions = require './plugins-actions'
class Package extends React.Component
@ -15,6 +15,12 @@ class Package extends React.Component
actions = []
extras = []
if @props.package.icon
icon = <img src="nylas://#{@props.package.name}/#{@props.package.icon}" style={width:50} />
else
icon = <RetinaImg name="plugin-icon-default.png"/>
if @props.package.installed
if @props.package.category in ['user' ,'dev', 'example']
if @props.package.enabled
@ -42,9 +48,7 @@ class Package extends React.Component
)
<Flexbox className="package" direction="row">
<div className="icon" style={flexShink: 0}>
<img src="nylas://#{@props.package.name}/#{@props.package.icon}" style={width:50} />
</div>
<div className="icon" style={flexShink: 0}>{icon}</div>
<div className="info">
<div className="title">{title ? name}</div>
<div className="description">{description}</div>

View file

@ -1,7 +1,7 @@
{
"name": "nylas",
"productName": "Nylas N1",
"version": "0.3.40",
"version": "0.3.43",
"description": "An extensible, open-source mail client built on the modern web.",
"main": "./src/browser/main.js",
"repository": {

View file

@ -50,6 +50,9 @@ cp "$TARGET/usr/share/nylas/resources/LICENSE.md" "$TARGET/usr/share/doc/nylas/c
mkdir -m $FILE_MODE -p "$TARGET/usr/share/lintian/overrides"
cp "$ROOT/build/resources/linux/debian/lintian-overrides" "$TARGET/usr/share/lintian/overrides/nylas"
# Remove group write from all files
chmod -R g-w "$TARGET";
# Remove executable bit from .node files
find "$TARGET" -type f -name "*.node" -exec chmod a-x {} \;

View file

@ -73,4 +73,3 @@ describe "AccountStore", ->
it "triggers", ->
expect(@instance.trigger).toHaveBeenCalled()
expect(@instance.trigger.calls.length).toBe 1

View file

@ -8,278 +8,283 @@ AccountStore = require '../../src/flux/stores/account-store'
Download = FileDownloadStore.Download
describe "FileDownloadStore", ->
describe "FileDownloadStore.Download", ->
beforeEach ->
spyOn(fs, 'createWriteStream')
spyOn(NylasAPI, 'makeRequest')
describe "FileDownloadStore.Download", ->
beforeEach ->
spyOn(fs, 'createWriteStream')
spyOn(NylasAPI, 'makeRequest')
describe "constructor", ->
it "should require a non-empty filename", ->
expect(-> new Download(fileId: '123', targetPath: 'test.png')).toThrow()
expect(-> new Download(filename: null, fileId: '123', targetPath: 'test.png')).toThrow()
expect(-> new Download(filename: '', fileId: '123', targetPath: 'test.png')).toThrow()
describe "constructor", ->
it "should require a non-empty filename", ->
expect(-> new Download(fileId: '123', targetPath: 'test.png')).toThrow()
expect(-> new Download(filename: null, fileId: '123', targetPath: 'test.png')).toThrow()
expect(-> new Download(filename: '', fileId: '123', targetPath: 'test.png')).toThrow()
it "should require a non-empty fileId", ->
expect(-> new Download(filename: 'test.png', fileId: null, targetPath: 'test.png')).toThrow()
expect(-> new Download(filename: 'test.png', fileId: '', targetPath: 'test.png')).toThrow()
it "should require a non-empty fileId", ->
expect(-> new Download(filename: 'test.png', fileId: null, targetPath: 'test.png')).toThrow()
expect(-> new Download(filename: 'test.png', fileId: '', targetPath: 'test.png')).toThrow()
it "should require a download path", ->
expect(-> new Download(filename: 'test.png', fileId: '123')).toThrow()
expect(-> new Download(filename: 'test.png', fileId: '123', targetPath: '')).toThrow()
it "should require a download path", ->
expect(-> new Download(filename: 'test.png', fileId: '123')).toThrow()
expect(-> new Download(filename: 'test.png', fileId: '123', targetPath: '')).toThrow()
describe "run", ->
beforeEach ->
account = AccountStore.accounts()[0]
@download = new Download(fileId: '123', targetPath: 'test.png', filename: 'test.png', accountId: account.id)
@download.run()
expect(NylasAPI.makeRequest).toHaveBeenCalled()
it "should create a request with a null encoding to prevent the request library from attempting to parse the (potentially very large) response", ->
expect(NylasAPI.makeRequest.mostRecentCall.args[0].json).toBe(false)
expect(NylasAPI.makeRequest.mostRecentCall.args[0].encoding).toBe(null)
it "should create a request for /files/123/download", ->
expect(NylasAPI.makeRequest.mostRecentCall.args[0].path).toBe("/files/123/download")
describe "FileDownloadStore", ->
describe "run", ->
beforeEach ->
account = AccountStore.accounts()[0]
@download = new Download(fileId: '123', targetPath: 'test.png', filename: 'test.png', accountId: account.id)
@download.run()
expect(NylasAPI.makeRequest).toHaveBeenCalled()
spyOn(shell, 'showItemInFolder')
spyOn(shell, 'openItem')
@testfile = new File({
accountId: account.id,
filename: '123.png',
contentType: 'image/png',
id: "id",
size: 100
})
@testdownload = new Download({
accountId: account.id,
state : 'unknown',
fileId : 'id',
percent : 0,
filename : '123.png',
filesize : 100,
targetPath : '/Users/testuser/.nylas/downloads/id/123.png'
})
it "should create a request with a null encoding to prevent the request library from attempting to parse the (potentially very large) response", ->
expect(NylasAPI.makeRequest.mostRecentCall.args[0].json).toBe(false)
expect(NylasAPI.makeRequest.mostRecentCall.args[0].encoding).toBe(null)
FileDownloadStore._downloads = {}
FileDownloadStore._downloadDirectory = "/Users/testuser/.nylas/downloads"
it "should create a request for /files/123/download", ->
expect(NylasAPI.makeRequest.mostRecentCall.args[0].path).toBe("/files/123/download")
describe "pathForFile", ->
it "should return path within the download directory with the file id and displayName", ->
f = new File(filename: '123.png', contentType: 'image/png', id: 'id')
spyOn(f, 'displayName').andCallThrough()
expect(FileDownloadStore.pathForFile(f)).toBe("/Users/testuser/.nylas/downloads/id/123.png")
expect(f.displayName).toHaveBeenCalled()
describe "FileDownloadStore", ->
beforeEach ->
account = AccountStore.accounts()[0]
it "should return unique paths for identical filenames with different IDs", ->
f1 = new File(filename: '123.png', contentType: 'image/png', id: 'id1')
f2 = new File(filename: '123.png', contentType: 'image/png', id: 'id2')
expect(FileDownloadStore.pathForFile(f1)).toBe("/Users/testuser/.nylas/downloads/id1/123.png")
expect(FileDownloadStore.pathForFile(f2)).toBe("/Users/testuser/.nylas/downloads/id2/123.png")
spyOn(shell, 'showItemInFolder')
spyOn(shell, 'openItem')
@testfile = new File({
accountId: account.id,
filename: '123.png',
contentType: 'image/png',
id: "id",
size: 100
})
@testdownload = new Download({
accountId: account.id,
state : 'unknown',
fileId : 'id',
percent : 0,
filename : '123.png',
filesize : 100,
targetPath : '/Users/testuser/.nylas/downloads/id/123.png'
})
describe "_checkForDownloadedFile", ->
it "should return true if the file exists at the path and is the right size", ->
f = new File(filename: '123.png', contentType: 'image/png', id: "id", size: 100)
spyOn(fs, 'statAsync').andCallFake (path) ->
Promise.resolve({size: 100})
waitsForPromise ->
FileDownloadStore._checkForDownloadedFile(f).then (downloaded) ->
expect(downloaded).toBe(true)
FileDownloadStore._downloads = {}
FileDownloadStore._downloadDirectory = "/Users/testuser/.nylas/downloads"
it "should return false if the file does not exist", ->
f = new File(filename: '123.png', contentType: 'image/png', id: "id", size: 100)
spyOn(fs, 'statAsync').andCallFake (path) ->
Promise.reject(new Error("File does not exist"))
waitsForPromise ->
FileDownloadStore._checkForDownloadedFile(f).then (downloaded) ->
expect(downloaded).toBe(false)
describe "pathForFile", ->
it "should return path within the download directory with the file id and displayName", ->
f = new File(filename: '123.png', contentType: 'image/png', id: 'id')
spyOn(f, 'displayName').andCallThrough()
expect(FileDownloadStore.pathForFile(f)).toBe("/Users/testuser/.nylas/downloads/id/123.png")
expect(f.displayName).toHaveBeenCalled()
it "should return false if the file is too small", ->
f = new File(filename: '123.png', contentType: 'image/png', id: "id", size: 100)
spyOn(fs, 'statAsync').andCallFake (path) ->
Promise.resolve({size: 50})
waitsForPromise ->
FileDownloadStore._checkForDownloadedFile(f).then (downloaded) ->
expect(downloaded).toBe(false)
it "should return unique paths for identical filenames with different IDs", ->
f1 = new File(filename: '123.png', contentType: 'image/png', id: 'id1')
f2 = new File(filename: '123.png', contentType: 'image/png', id: 'id2')
expect(FileDownloadStore.pathForFile(f1)).toBe("/Users/testuser/.nylas/downloads/id1/123.png")
expect(FileDownloadStore.pathForFile(f2)).toBe("/Users/testuser/.nylas/downloads/id2/123.png")
describe "_runDownload", ->
it "should escape the displayName if it contains path separator characters", ->
f1 = new File(filename: "static#{path.sep}b#{path.sep}a.jpg", contentType: 'image/png', id: 'id1')
expect(FileDownloadStore.pathForFile(f1)).toBe("/Users/testuser/.nylas/downloads/id1/static-b-a.jpg")
f1 = new File(filename: "my:file ? Windows /hates/ me :->.jpg", contentType: 'image/png', id: 'id1')
expect(FileDownloadStore.pathForFile(f1)).toBe("/Users/testuser/.nylas/downloads/id1/my-file - Windows -hates- me ---.jpg")
describe "_checkForDownloadedFile", ->
it "should return true if the file exists at the path and is the right size", ->
f = new File(filename: '123.png', contentType: 'image/png', id: "id", size: 100)
spyOn(fs, 'statAsync').andCallFake (path) ->
Promise.resolve({size: 100})
waitsForPromise ->
FileDownloadStore._checkForDownloadedFile(f).then (downloaded) ->
expect(downloaded).toBe(true)
it "should return false if the file does not exist", ->
f = new File(filename: '123.png', contentType: 'image/png', id: "id", size: 100)
spyOn(fs, 'statAsync').andCallFake (path) ->
Promise.reject(new Error("File does not exist"))
waitsForPromise ->
FileDownloadStore._checkForDownloadedFile(f).then (downloaded) ->
expect(downloaded).toBe(false)
it "should return false if the file is too small", ->
f = new File(filename: '123.png', contentType: 'image/png', id: "id", size: 100)
spyOn(fs, 'statAsync').andCallFake (path) ->
Promise.resolve({size: 50})
waitsForPromise ->
FileDownloadStore._checkForDownloadedFile(f).then (downloaded) ->
expect(downloaded).toBe(false)
describe "_runDownload", ->
beforeEach ->
spyOn(FileDownloadStore.Download.prototype, 'run').andCallFake -> Promise.resolve(@)
spyOn(FileDownloadStore, '_prepareFolder').andCallFake -> Promise.resolve(true)
spyOn(FileDownloadStore, '_cleanupDownload')
it "should make sure that the download file path exists", ->
FileDownloadStore._runDownload(@testfile)
expect(FileDownloadStore._prepareFolder).toHaveBeenCalled()
it "should return the promise returned by download.run if the download already exists", ->
existing =
fileId: @testfile.id
run: jasmine.createSpy('existing.run').andCallFake ->
Promise.resolve(existing)
FileDownloadStore._downloads[@testfile.id] = existing
promise = FileDownloadStore._runDownload(@testfile)
expect(promise instanceof Promise).toBe(true)
waitsForPromise ->
promise.then ->
expect(existing.run).toHaveBeenCalled()
describe "when the downloaded file exists", ->
beforeEach ->
spyOn(FileDownloadStore.Download.prototype, 'run').andCallFake -> Promise.resolve(@)
spyOn(FileDownloadStore, '_prepareFolder').andCallFake -> Promise.resolve(true)
spyOn(FileDownloadStore, '_cleanupDownload')
spyOn(FileDownloadStore, '_checkForDownloadedFile').andCallFake ->
Promise.resolve(true)
it "should make sure that the download file path exists", ->
it "should resolve with a Download without calling download.run", ->
waitsForPromise =>
FileDownloadStore._runDownload(@testfile).then (download) ->
expect(FileDownloadStore.Download.prototype.run).not.toHaveBeenCalled()
expect(download instanceof FileDownloadStore.Download).toBe(true)
expect(download.data()).toEqual({
state : 'finished',
fileId : 'id',
percent : 0,
filename : '123.png',
filesize : 100,
targetPath : '/Users/testuser/.nylas/downloads/id/123.png'
})
describe "when the downloaded file does not exist", ->
beforeEach ->
spyOn(FileDownloadStore, '_checkForDownloadedFile').andCallFake ->
Promise.resolve(false)
it "should register the download with the right attributes", ->
FileDownloadStore._runDownload(@testfile)
expect(FileDownloadStore._prepareFolder).toHaveBeenCalled()
advanceClock(0)
expect(FileDownloadStore.downloadDataForFile(@testfile.id)).toEqual({
state : 'unstarted',fileId : 'id',
percent : 0,
filename : '123.png',
filesize : 100,
targetPath : '/Users/testuser/.nylas/downloads/id/123.png'
})
it "should return the promise returned by download.run if the download already exists", ->
existing =
fileId: @testfile.id
run: jasmine.createSpy('existing.run').andCallFake ->
Promise.resolve(existing)
FileDownloadStore._downloads[@testfile.id] = existing
promise = FileDownloadStore._runDownload(@testfile)
expect(promise instanceof Promise).toBe(true)
waitsForPromise ->
promise.then ->
expect(existing.run).toHaveBeenCalled()
describe "when the downloaded file exists", ->
beforeEach ->
spyOn(FileDownloadStore, '_checkForDownloadedFile').andCallFake ->
Promise.resolve(true)
it "should resolve with a Download without calling download.run", ->
waitsForPromise =>
FileDownloadStore._runDownload(@testfile).then (download) ->
expect(FileDownloadStore.Download.prototype.run).not.toHaveBeenCalled()
expect(download instanceof FileDownloadStore.Download).toBe(true)
expect(download.data()).toEqual({
state : 'finished',
fileId : 'id',
percent : 0,
filename : '123.png',
filesize : 100,
targetPath : '/Users/testuser/.nylas/downloads/id/123.png'
})
describe "when the downloaded file does not exist", ->
beforeEach ->
spyOn(FileDownloadStore, '_checkForDownloadedFile').andCallFake ->
Promise.resolve(false)
it "should register the download with the right attributes", ->
it "should call download.run", ->
waitsForPromise =>
FileDownloadStore._runDownload(@testfile)
advanceClock(0)
expect(FileDownloadStore.downloadDataForFile(@testfile.id)).toEqual({
state : 'unstarted',fileId : 'id',
percent : 0,
filename : '123.png',
filesize : 100,
targetPath : '/Users/testuser/.nylas/downloads/id/123.png'
})
runs ->
expect(FileDownloadStore.Download.prototype.run).toHaveBeenCalled()
it "should call download.run", ->
waitsForPromise =>
FileDownloadStore._runDownload(@testfile)
runs ->
expect(FileDownloadStore.Download.prototype.run).toHaveBeenCalled()
it "should resolve with a Download", ->
waitsForPromise =>
FileDownloadStore._runDownload(@testfile).then (download) ->
expect(download instanceof FileDownloadStore.Download).toBe(true)
expect(download.data()).toEqual({
state : 'unstarted',
fileId : 'id',
percent : 0,
filename : '123.png',
filesize : 100,
targetPath : '/Users/testuser/.nylas/downloads/id/123.png'
})
it "should resolve with a Download", ->
waitsForPromise =>
FileDownloadStore._runDownload(@testfile).then (download) ->
expect(download instanceof FileDownloadStore.Download).toBe(true)
expect(download.data()).toEqual({
state : 'unstarted',
fileId : 'id',
percent : 0,
filename : '123.png',
filesize : 100,
targetPath : '/Users/testuser/.nylas/downloads/id/123.png'
})
describe "_fetch", ->
it "should call through to startDownload", ->
spyOn(FileDownloadStore, '_runDownload').andCallFake ->
Promise.resolve(@testdownload)
FileDownloadStore._fetch(@testfile)
expect(FileDownloadStore._runDownload).toHaveBeenCalled()
describe "_fetch", ->
it "should call through to startDownload", ->
spyOn(FileDownloadStore, '_runDownload').andCallFake ->
Promise.resolve(@testdownload)
FileDownloadStore._fetch(@testfile)
expect(FileDownloadStore._runDownload).toHaveBeenCalled()
it "should fail silently since it's called passively", ->
spyOn(FileDownloadStore, '_presentError')
spyOn(FileDownloadStore, '_runDownload').andCallFake =>
Promise.reject(@testdownload)
FileDownloadStore._fetch(@testfile)
expect(FileDownloadStore._presentError).not.toHaveBeenCalled()
it "should fail silently since it's called passively", ->
spyOn(FileDownloadStore, '_presentError')
spyOn(FileDownloadStore, '_runDownload').andCallFake =>
Promise.reject(@testdownload)
FileDownloadStore._fetch(@testfile)
expect(FileDownloadStore._presentError).not.toHaveBeenCalled()
describe "_fetchAndOpen", ->
it "should open the file once it's been downloaded", ->
@savePath = "/Users/imaginary/.nylas/Downloads/a.png"
download = {targetPath: @savePath}
downloadResolve = null
describe "_fetchAndOpen", ->
it "should open the file once it's been downloaded", ->
@savePath = "/Users/imaginary/.nylas/Downloads/a.png"
download = {targetPath: @savePath}
downloadResolve = null
spyOn(FileDownloadStore, '_runDownload').andCallFake =>
new Promise (resolve, reject) ->
downloadResolve = resolve
spyOn(FileDownloadStore, '_runDownload').andCallFake =>
new Promise (resolve, reject) ->
downloadResolve = resolve
FileDownloadStore._fetchAndOpen(@testfile)
expect(shell.openItem).not.toHaveBeenCalled()
downloadResolve(download)
advanceClock(100)
expect(shell.openItem).toHaveBeenCalledWith(@savePath)
FileDownloadStore._fetchAndOpen(@testfile)
expect(shell.openItem).not.toHaveBeenCalled()
downloadResolve(download)
advanceClock(100)
expect(shell.openItem).toHaveBeenCalledWith(@savePath)
it "should open an error if the download fails", ->
spyOn(FileDownloadStore, '_presentError')
spyOn(FileDownloadStore, '_runDownload').andCallFake =>
Promise.reject(@testdownload)
FileDownloadStore._fetchAndOpen(@testfile)
advanceClock(1)
expect(FileDownloadStore._presentError).toHaveBeenCalled()
it "should open an error if the download fails", ->
spyOn(FileDownloadStore, '_presentError')
spyOn(FileDownloadStore, '_runDownload').andCallFake =>
Promise.reject(@testdownload)
FileDownloadStore._fetchAndOpen(@testfile)
advanceClock(1)
expect(FileDownloadStore._presentError).toHaveBeenCalled()
describe "_fetchAndSave", ->
beforeEach ->
@savePath = "/Users/imaginary/.nylas/Downloads/b.png"
spyOn(NylasEnv, 'showSaveDialog').andCallFake (options, callback) => callback(@savePath)
describe "_fetchAndSave", ->
beforeEach ->
@savePath = "/Users/imaginary/.nylas/Downloads/b.png"
spyOn(NylasEnv, 'showSaveDialog').andCallFake (options, callback) => callback(@savePath)
it "should open a save dialog and prompt the user to choose a download path", ->
spyOn(FileDownloadStore, '_runDownload').andCallFake =>
new Promise (resolve, reject) -> # never resolve
FileDownloadStore._fetchAndSave(@testfile)
expect(NylasEnv.showSaveDialog).toHaveBeenCalled()
expect(FileDownloadStore._runDownload).toHaveBeenCalledWith(@testfile)
it "should open a save dialog and prompt the user to choose a download path", ->
spyOn(FileDownloadStore, '_runDownload').andCallFake =>
new Promise (resolve, reject) -> # never resolve
FileDownloadStore._fetchAndSave(@testfile)
expect(NylasEnv.showSaveDialog).toHaveBeenCalled()
expect(FileDownloadStore._runDownload).toHaveBeenCalledWith(@testfile)
it "should copy the file to the download path after it's been downloaded and open it after the stream has ended", ->
download = {targetPath: @savePath}
onEndEventCallback = null
streamStub =
pipe: ->
on: (eventName, eventCallback) =>
onEndEventCallback = eventCallback
it "should copy the file to the download path after it's been downloaded and open it after the stream has ended", ->
download = {targetPath: @savePath}
onEndEventCallback = null
streamStub =
pipe: ->
on: (eventName, eventCallback) =>
onEndEventCallback = eventCallback
spyOn(FileDownloadStore, '_runDownload').andCallFake =>
Promise.resolve(download)
spyOn(fs, 'createReadStream').andReturn(streamStub)
spyOn(fs, 'createWriteStream')
spyOn(FileDownloadStore, '_runDownload').andCallFake =>
Promise.resolve(download)
spyOn(fs, 'createReadStream').andReturn(streamStub)
spyOn(fs, 'createWriteStream')
FileDownloadStore._fetchAndSave(@testfile)
advanceClock(1)
expect(fs.createReadStream).toHaveBeenCalledWith(download.targetPath)
expect(shell.showItemInFolder).not.toHaveBeenCalled()
onEndEventCallback()
advanceClock(1)
expect(shell.showItemInFolder).toHaveBeenCalledWith(download.targetPath)
FileDownloadStore._fetchAndSave(@testfile)
advanceClock(1)
expect(fs.createReadStream).toHaveBeenCalledWith(download.targetPath)
expect(shell.showItemInFolder).not.toHaveBeenCalled()
onEndEventCallback()
advanceClock(1)
expect(shell.showItemInFolder).toHaveBeenCalledWith(download.targetPath)
it "should open an error if the download fails", ->
spyOn(FileDownloadStore, '_presentError')
spyOn(FileDownloadStore, '_runDownload').andCallFake =>
Promise.reject(@testdownload)
FileDownloadStore._fetchAndSave(@testfile)
advanceClock(1)
expect(FileDownloadStore._presentError).toHaveBeenCalled()
it "should open an error if the download fails", ->
spyOn(FileDownloadStore, '_presentError')
spyOn(FileDownloadStore, '_runDownload').andCallFake =>
Promise.reject(@testdownload)
FileDownloadStore._fetchAndSave(@testfile)
advanceClock(1)
expect(FileDownloadStore._presentError).toHaveBeenCalled()
describe "_abortFetchFile", ->
beforeEach ->
@download =
ensureClosed: jasmine.createSpy('abort')
fileId: @testfile.id
FileDownloadStore._downloads[@testfile.id] = @download
describe "_abortFetchFile", ->
beforeEach ->
@download =
ensureClosed: jasmine.createSpy('abort')
fileId: @testfile.id
FileDownloadStore._downloads[@testfile.id] = @download
it "should cancel the download for the provided file", ->
spyOn(fs, 'exists').andCallFake (path, callback) -> callback(true)
spyOn(fs, 'unlink')
FileDownloadStore._abortFetchFile(@testfile)
expect(fs.unlink).toHaveBeenCalled()
expect(@download.ensureClosed).toHaveBeenCalled()
it "should cancel the download for the provided file", ->
spyOn(fs, 'exists').andCallFake (path, callback) -> callback(true)
spyOn(fs, 'unlink')
FileDownloadStore._abortFetchFile(@testfile)
expect(fs.unlink).toHaveBeenCalled()
expect(@download.ensureClosed).toHaveBeenCalled()
it "should not try to delete the file if doesn't exist", ->
spyOn(fs, 'exists').andCallFake (path, callback) -> callback(false)
spyOn(fs, 'unlink')
FileDownloadStore._abortFetchFile(@testfile)
expect(fs.unlink).not.toHaveBeenCalled()
expect(@download.ensureClosed).toHaveBeenCalled()
it "should not try to delete the file if doesn't exist", ->
spyOn(fs, 'exists').andCallFake (path, callback) -> callback(false)
spyOn(fs, 'unlink')
FileDownloadStore._abortFetchFile(@testfile)
expect(fs.unlink).not.toHaveBeenCalled()
expect(@download.ensureClosed).toHaveBeenCalled()

View file

@ -54,11 +54,23 @@ spawnSetx = (args, callback) ->
spawnUpdate = (args, callback) ->
spawn(updateDotExe, args, callback)
isAscii = (text) ->
index = 0
while index < text.length
return false if text.charCodeAt(index) > 127
index++
true
# Get the user's PATH environment variable registry value.
getPath = (callback) ->
spawnReg ['query', environmentKeyPath, '/v', 'Path'], (error, stdout) ->
if error?
if error.code is 1
# FIXME Don't overwrite path when reading value is disabled
# https://github.com/atom/atom/issues/5092
if stdout.indexOf('ERROR: Registry editing has been disabled by your administrator.') isnt -1
return callback(error)
# The query failed so the Path does not exist yet in the registry
return callback(null, '')
else
@ -74,10 +86,65 @@ getPath = (callback) ->
segments = lines[lines.length - 1]?.split(' ')
if segments[1] is 'Path' and segments.length >= 3
pathEnv = segments?[3..].join(' ')
callback(null, pathEnv)
if isAscii(pathEnv)
callback(null, pathEnv)
else
# FIXME Don't corrupt non-ASCII PATH values
# https://github.com/atom/atom/issues/5063
callback(new Error('PATH contains non-ASCII values'))
else
callback(new Error('Registry query for PATH failed'))
# Add N1 to the PATH
#
# This is done by adding .cmd shims to the root bin folder in the N1
# install directory that point to the newly installed versions inside
# the versioned app directories.
addCommandsToPath = (callback) ->
installCommands = (callback) ->
nylasCommandPath = path.join(binFolder, 'N1.cmd')
relativeN1Path = path.relative(binFolder, path.join(appFolder, 'resources', 'cli', 'N1.cmd'))
nylasCommand = "@echo off\r\n\"%~dp0\\#{relativeN1Path}\" %*"
nylasShCommandPath = path.join(binFolder, 'N1')
relativeN1ShPath = path.relative(binFolder, path.join(appFolder, 'resources', 'cli', 'N1.sh'))
nylasShCommand = "#!/bin/sh\r\n\"$(dirname \"$0\")/#{relativeN1ShPath.replace(/\\/g, '/')}\" \"$@\""
fs.writeFile nylasCommandPath, nylasCommand, ->
fs.writeFile nylasShCommandPath, nylasShCommand, ->
callback()
addBinToPath = (pathSegments, callback) ->
pathSegments.push(binFolder)
newPathEnv = pathSegments.join(';')
spawnSetx(['Path', newPathEnv], callback)
installCommands (error) ->
return callback(error) if error?
getPath (error, pathEnv) ->
return callback(error) if error?
pathSegments = pathEnv.split(/;+/).filter (pathSegment) -> pathSegment
if pathSegments.indexOf(binFolder) is -1
addBinToPath(pathSegments, callback)
else
callback()
# Remove N1 from the PATH
removeCommandsFromPath = (callback) ->
getPath (error, pathEnv) ->
return callback(error) if error?
pathSegments = pathEnv.split(/;+/).filter (pathSegment) ->
pathSegment and pathSegment isnt binFolder
newPathEnv = pathSegments.join(';')
if pathEnv isnt newPathEnv
spawnSetx(['Path', newPathEnv], callback)
else
callback()
# Create a desktop and start menu shortcut by using the command line API
# provided by Squirrel's Update.exe
createShortcuts = (callback) ->
@ -123,15 +190,18 @@ exports.handleStartupEvent = (app, squirrelCommand) ->
switch squirrelCommand
when '--squirrel-install'
createShortcuts ->
app.quit()
addCommandsToPath ->
app.quit()
true
when '--squirrel-updated'
updateShortcuts ->
app.quit()
addCommandsToPath ->
app.quit()
true
when '--squirrel-uninstall'
removeShortcuts ->
app.quit()
removeCommandsFromPath ->
app.quit()
true
when '--squirrel-obsolete'
app.quit()

View file

@ -426,7 +426,7 @@ class Contenteditable extends React.Component
# We also need to keep references to the previous selection state in
# order for undo/redo to work properly.
_saveExportedSelection: (exportedSelection) =>
return if (@innerState.exportedSelection?.isEqual(exportedSelection))
return if exportedSelection and exportedSelection.isEqual(@innerState.exportedSelection)
@setInnerState
exportedSelection: exportedSelection

View file

@ -36,6 +36,10 @@ class EditorAPI
currentSelection: -> @_extendedSelection
whilePreservingSelection: (fn) ->
# We only preserve selection if the active element is actually within the
# contenteditable. Otherwise, we can unintentionally "steal" focus back if
# `whilePreservingSelection` is called by a plugin when we are not focused.
return fn() unless document.activeElement is @rootNode or @rootNode.contains(document.activeElement)
sel = @currentSelection().exportSelection()
fn()
@select(sel)

View file

@ -9,12 +9,15 @@
#
class ExportedSelection
constructor: (@rawSelection, @scopeNode) ->
@anchorNode = @rawSelection.anchorNode.cloneNode(true)
@anchorOffset = @rawSelection.anchorOffset
@anchorNodeIndex = DOMUtils.getNodeIndex(@scopeNode, @rawSelection.anchorNode)
@focusNode = @rawSelection.focusNode.cloneNode(true)
@focusOffset = @rawSelection.focusOffset
@focusNodeIndex = DOMUtils.getNodeIndex(@scopeNode, @rawSelection.focusNode)
@type = @rawSelection.type
unless @type is 'None'
@anchorNode = @rawSelection.anchorNode.cloneNode(true)
@anchorOffset = @rawSelection.anchorOffset
@anchorNodeIndex = DOMUtils.getNodeIndex(@scopeNode, @rawSelection.anchorNode)
@focusNode = @rawSelection.focusNode.cloneNode(true)
@focusOffset = @rawSelection.focusOffset
@focusNodeIndex = DOMUtils.getNodeIndex(@scopeNode, @rawSelection.focusNode)
@isCollapsed = @rawSelection.isCollapsed
### Public: Tests for equality amongst exported selections
@ -32,7 +35,10 @@ class ExportedSelection
and `endNodeIndex` fields via the `DOMUtils.getNodeIndex` method.
###
isEqual: (otherSelection) ->
return true if not otherSelection?
return false unless otherSelection
return false if @type isnt otherSelection.type
return true if @type is 'None' and otherSelection.type is 'None'
return false if not otherSelection.anchorNode? or not otherSelection.focusNode?
anchorIndex = DOMUtils.getNodeIndex(@scopeNode, otherSelection.anchorNode)

View file

@ -13,14 +13,17 @@ class NewsletterSignup extends React.Component
constructor: (@props) ->
@state = {status: 'Pending'}
componentWillReceiveProps: (nextProps) =>
@_onGetStatus(nextProps)
componentDidMount: =>
@_onGetStatus()
_onGetStatus: =>
_onGetStatus: (props = @props) =>
@setState({status: 'Pending'})
EdgehillAPI.request
method: 'GET'
path: @_path()
path: @_path(props)
success: (status) =>
if status is 'Never Subscribed'
@_onSubscribe()
@ -49,8 +52,8 @@ class NewsletterSignup extends React.Component
error: =>
@setState({status: "Error"})
_path: =>
"/newsletter-subscription/#{encodeURIComponent(@props.emailAddress)}?name=#{encodeURIComponent(@props.name)}"
_path: (props = @props) =>
"/newsletter-subscription/#{encodeURIComponent(props.emailAddress)}?name=#{encodeURIComponent(props.name)}"
render: =>
<Flexbox direction='row' style={textAlign: 'left', height: 'auto'}>

View file

@ -538,7 +538,7 @@ DOMUtils =
# current selection.
selectionStartsOrEndsIn: (rangeOrNode) ->
selection = document.getSelection()
return false unless selection
return false unless (selection and selection.rangeCount>0)
if rangeOrNode instanceof Range
return @rangeStartsOrEndsInRange(selection.getRangeAt(0), rangeOrNode)
else if rangeOrNode instanceof Node
@ -552,7 +552,7 @@ DOMUtils =
# contained within it.
selectionIsWithin: (rangeOrNode) ->
selection = document.getSelection()
return false unless selection
return false unless (selection and selection.rangeCount>0)
if rangeOrNode instanceof Range
return @rangeInRange(selection.getRangeAt(0), rangeOrNode)
else if rangeOrNode instanceof Node

View file

@ -80,6 +80,7 @@ class AccountStore
console.log JSON.stringify(json)
throw new Error("Returned account data is invalid")
return if @_tokens[json.id]
@_load()
@_tokens[json.id] = json.auth_token
account = (new Account).fromJSON(json)

View file

@ -9,6 +9,7 @@ _ = require 'underscore'
Actions = require '../actions'
progress = require 'request-progress'
NylasAPI = require '../nylas-api'
RegExpUtils = require '../../regexp-utils'
Promise.promisifyAll(fs)
@ -127,7 +128,7 @@ FileDownloadStore = Reflux.createStore
pathForFile: (file) ->
return undefined unless file
filesafeName = file.displayName().replace(new RegExp(path.sep, 'g'), '-')
filesafeName = file.displayName().replace(RegExpUtils.illegalPathCharactersRegexp(), '-')
path.join(@_downloadDirectory, file.id, filesafeName)
downloadDataForFile: (fileId) ->

View file

@ -163,20 +163,21 @@ class MessageStore extends NylasStore
# Override canBeUndone to return false so that we don't see undo
# prompts (since this is a passive action vs. a user-triggered
# action.)
return unless @_thread and @_thread.unread
return if not @_thread
return if @_lastLoadedThreadId is @_thread.id
@_lastLoadedThreadId = @_thread.id
markAsReadDelay = NylasEnv.config.get('core.reading.markAsReadDelay')
markAsReadId = @_thread.id
return if markAsReadDelay < 0
if @_thread.unread
markAsReadDelay = NylasEnv.config.get('core.reading.markAsReadDelay')
markAsReadId = @_thread.id
return if markAsReadDelay < 0
setTimeout =>
return unless markAsReadId is @_thread?.id and @_thread.unread
t = new ChangeUnreadTask(thread: @_thread, unread: false)
t.canBeUndone = => false
Actions.queueTask(t)
, markAsReadDelay
setTimeout =>
return unless markAsReadId is @_thread?.id and @_thread.unread
t = new ChangeUnreadTask(thread: @_thread, unread: false)
t.canBeUndone = => false
Actions.queueTask(t)
, markAsReadDelay
_onToggleAllMessagesExpanded: =>
if @hasCollapsedItems()

View file

@ -575,7 +575,8 @@ class NylasEnvConstructor extends Model
browserWindow = @getCurrentWindow()
{x, y, width, height} = browserWindow.getBounds()
maximized = browserWindow.isMaximized()
{x, y, width, height, maximized}
fullScreen = browserWindow.isFullScreen()
{x, y, width, height, maximized, fullScreen}
# Set the dimensions of the window.
#
@ -627,6 +628,7 @@ class NylasEnvConstructor extends Model
dimensions = @getDefaultWindowDimensions()
@setWindowDimensions(dimensions)
@maximize() if dimensions.maximized and process.platform isnt 'darwin'
@setFullScreen(true) if dimensions.fullScreen
storeWindowDimensions: ->
dimensions = @getWindowDimensions()

View file

@ -33,4 +33,8 @@ RegExpUtils =
# https://regex101.com/r/pZ6zF0/1
functionArgs: -> /\(\s*([^)]+?)\s*\)/
illegalPathCharactersRegexp: ->
#https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx
/[\\\/:|?*><"]/g
module.exports = RegExpUtils

View file

@ -18,7 +18,7 @@ Preset =
blockquote: "p"
Permissive:
allowedTags: ['p', 'b', 'i', 'em', 'strong', 'a', 'br', 'img', 'ul', 'ol', 'li', 'strike', 'table', 'tr', 'td', 'th', 'col', 'colgroup']
allowedTags: ['p', 'b', 'i', 'em', 'strong', 'a', 'br', 'img', 'ul', 'ol', 'li', 'strike', 'table', 'tr', 'td', 'th', 'col', 'colgroup', 'div']
allowedAttributes: [ 'abbr', 'accept', 'acceptcharset', 'accesskey', 'action', 'align', 'alt', 'async', 'autocomplete', 'axis', 'border', 'bgcolor', 'cellpadding', 'cellspacing', 'char', 'charoff', 'charset', 'checked', 'classid', 'classname', 'colspan', 'cols', 'content', 'contextmenu', 'controls', 'coords', 'data', 'datetime', 'defer', 'dir', 'disabled', 'download', 'draggable', 'enctype', 'form', 'formaction', 'formenctype', 'formmethod', 'formnovalidate', 'formtarget', 'frame', 'frameborder', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'htmlfor', 'httpequiv', 'icon', 'id', 'label', 'lang', 'list', 'loop', 'low', 'manifest', 'marginheight', 'marginwidth', 'max', 'maxlength', 'media', 'mediagroup', 'method', 'min', 'multiple', 'muted', 'name', 'novalidate', 'nowrap', 'open', 'optimum', 'pattern', 'placeholder', 'poster', 'preload', 'radiogroup', 'readonly', 'rel', 'required', 'role', 'rowspan', 'rows', 'rules', 'sandbox', 'scope', 'scoped', 'scrolling', 'seamless', 'selected', 'shape', 'size', 'sizes', 'sortable', 'sorted', 'span', 'spellcheck', 'src', 'srcdoc', 'srcset', 'start', 'step', 'style', 'summary', 'tabindex', 'target', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'wmode' ]
UnsafeOnly:

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB