mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-01 05:06:53 +08:00
Merge branch 'master' into unified-inbox
# Conflicts: # spec/stores/file-download-store-spec.coffee
This commit is contained in:
commit
cb5f2985ef
37 changed files with 624 additions and 418 deletions
22
CHANGELOG.md
22
CHANGELOG.md
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}'"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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, '')
|
||||
|
|
|
@ -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", ->
|
||||
|
|
|
@ -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>
|
|
@ -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 <code> 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 <code> tags the `var` class to mark them as template regions. Add
|
||||
the `empty` class to make them dark yellow. When you send your message, <code>
|
||||
tags are always stripped so the recipient never sees any highlighting.
|
||||
<p>
|
||||
- Nylas Team
|
||||
</p>
|
|
@ -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 <code> 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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -32,7 +32,7 @@ class TemplateStatusBar extends React.Component {
|
|||
|
||||
static containerStyles = {
|
||||
textAlign: 'center',
|
||||
width: 530,
|
||||
width: 580,
|
||||
margin: 'auto',
|
||||
}
|
||||
|
||||
|
|
|
@ -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});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: =>
|
||||
|
|
|
@ -15,7 +15,6 @@
|
|||
"engines": {
|
||||
"nylas": ">=0.3.0 <0.5.0"
|
||||
},
|
||||
"description": "Enter a description of your package!",
|
||||
"dependencies": [],
|
||||
"license": "MIT"
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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 {} \;
|
||||
|
||||
|
|
|
@ -73,4 +73,3 @@ describe "AccountStore", ->
|
|||
|
||||
it "triggers", ->
|
||||
expect(@instance.trigger).toHaveBeenCalled()
|
||||
expect(@instance.trigger.calls.length).toBe 1
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'}>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) ->
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
BIN
static/images/preferences/plugin-icon-default@2x.png
Normal file
BIN
static/images/preferences/plugin-icon-default@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 63 KiB |
Loading…
Reference in a new issue