feat(download-all): Adds download-all button + style updates, more ES6

Summary:
- Adds initial version of download all button
- Converts attachments plugin to ES6 and adds updated styling
- Updates quoted text button
- #905, #1712

Test Plan: - Unit + manual

Reviewers: evan, bengotow

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D2769
This commit is contained in:
Juan Tejada 2016-03-21 18:22:20 -07:00
parent a791ae76e0
commit 96846d4052
25 changed files with 429 additions and 208 deletions

View file

@ -1,97 +0,0 @@
_ = require 'underscore'
path = require 'path'
fs = require 'fs'
React = require 'react'
{RetinaImg, Flexbox} = require 'nylas-component-kit'
{Actions, Utils, FileDownloadStore} = require 'nylas-exports'
class AttachmentComponent extends React.Component
@displayName: 'AttachmentComponent'
@propTypes:
file: React.PropTypes.object.isRequired
download: React.PropTypes.object
removable: React.PropTypes.bool
targetPath: React.PropTypes.string
messageClientId: React.PropTypes.string
constructor: (@props) ->
@state = progressPercent: 0
render: =>
<div className="inner" onDoubleClick={@_onClickView} onDragStart={@_onDragStart} draggable="true">
<span className={"progress-bar-wrap state-#{@props.download?.state ? ""}"}>
<span className="progress-background"></span>
<span className="progress-foreground" style={@_downloadProgressStyle()}></span>
</span>
<Flexbox direction="row" style={alignItems: 'center'}>
<RetinaImg className="file-icon"
fallback="file-fallback.png"
mode={RetinaImg.Mode.ContentPreserve}
name="file-#{@props.file.displayExtension()}.png"/>
<span className="file-name">{@props.file.displayName()}</span>
{@_renderFileActions()}
</Flexbox>
</div>
_renderFileActions: =>
if @props.removable
<div className="file-action-icon" onClick={@_onClickRemove}>
{@_renderRemoveIcon()}
</div>
else if @_isDownloading() and @_canAbortDownload()
<div className="file-action-icon" onClick={@_onClickAbort}>
{@_renderRemoveIcon()}
</div>
else
<div className="file-action-icon" onClick={@_onClickDownload}>
{@_renderDownloadButton()}
</div>
_downloadProgressStyle: =>
width: "#{@props.download?.percent ? 0}%"
_canAbortDownload: -> true
_canClickToView: => not @props.removable
_isDownloading: => @props.download?.state is "downloading"
_renderRemoveIcon: ->
<RetinaImg name="remove-attachment.png" mode={RetinaImg.Mode.ContentPreserve} />
_renderDownloadButton: ->
<RetinaImg name="icon-attachment-download.png" mode={RetinaImg.Mode.ContentPreserve} />
_onDragStart: (event) =>
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
event.preventDefault()
return
_onClickView: =>
Actions.fetchAndOpenFile(@props.file) if @_canClickToView()
_onClickRemove: (event) =>
Actions.removeFile
file: @props.file
messageClientId: @props.messageClientId
event.stopPropagation() # Prevent 'onClickView'
_onClickDownload: (event) =>
Actions.fetchAndSaveFile(@props.file)
event.stopPropagation() # Prevent 'onClickView'
_onClickAbort: (event) =>
Actions.abortFetchFile(@props.file)
event.stopPropagation() # Prevent 'onClickView'
module.exports = AttachmentComponent

View file

@ -0,0 +1,154 @@
import fs from 'fs'
import path from 'path'
import React, {Component, PropTypes} from 'react'
import {RetinaImg, Flexbox} from 'nylas-component-kit'
import {Actions, FileDownloadStore} from 'nylas-exports'
class AttachmentComponent extends Component {
static displayName = 'AttachmentComponent';
static propTypes = {
file: PropTypes.object.isRequired,
download: PropTypes.object,
removable: PropTypes.bool,
targetPath: PropTypes.string,
messageClientId: PropTypes.string,
};
constructor() {
super()
this.state = {progressPercent: 0}
}
static containerRequired = false;
_isDownloading() {
const {download} = this.props
const state = download ? download.state : null
return state === 'downloading'
}
_canClickToView() {
return !this.props.removable
}
_canAbortDownload() {
return true
}
_downloadProgressStyle() {
const {download} = this.props
const percent = download ? download.percent || 0 : 0;
return {
width: `${percent}%`,
}
}
_onDragStart = (event) => {
const {file} = this.props
const filePath = FileDownloadStore.pathForFile(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.
const DownloadURL = `${file.contentType}:${path.basename(filePath)}:file://${filePath}`
event.dataTransfer.setData("DownloadURL", DownloadURL)
event.dataTransfer.setData("text/nylas-file-url", DownloadURL)
} else {
event.preventDefault()
}
};
_onClickView = () => {
if (this._canClickToView()) {
Actions.fetchAndOpenFile(this.props.file)
}
};
_onClickRemove = (event) => {
Actions.removeFile({
file: this.props.file,
messageClientId: this.props.messageClientId,
})
event.stopPropagation() // Prevent 'onClickView'
};
_onClickDownload = (event) => {
Actions.fetchAndSaveFile(this.props.file)
event.stopPropagation() // Prevent 'onClickView'
};
_onClickAbort = (event) => {
Actions.abortFetchFile(this.props.file)
event.stopPropagation() // Prevent 'onClickView'
};
_renderRemoveIcon() {
return (
<RetinaImg
name="remove-attachment.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
)
}
_renderDownloadButton() {
return (
<RetinaImg
name="icon-attachment-download.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
)
}
_renderFileActionIcon() {
if (this.props.removable) {
return (
<div className="file-action-icon" onClick={this._onClickRemove}>
{this._renderRemoveIcon()}
</div>
)
} else if (this._isDownloading() && this._canAbortDownload()) {
return (
<div className="file-action-icon" onClick={this._onClickAbort}>
{this._renderRemoveIcon()}
</div>
)
}
return (
<div className="file-action-icon" onClick={this._onClickDownload}>
{this._renderDownloadButton()}
</div>
)
}
render() {
const {file, download} = this.props;
const downloadState = download ? download.state || "" : "";
return (
<div className="inner" onDoubleClick={this._onClickView} onDragStart={this._onDragStart} draggable="true">
<span className={`progress-bar-wrap state-${downloadState}`}>
<span className="progress-background" />
<span className="progress-foreground" style={this._downloadProgressStyle()} />
</span>
<Flexbox direction="row" style={{alignItems: 'center'}}>
<div className="file-info-wrap">
<RetinaImg
className="file-icon"
fallback="file-fallback.png"
mode={RetinaImg.Mode.ContentPreserve}
name={`file-${file.displayExtension()}.png`}
/>
<span className="file-name">{file.displayName()}</span>
<span className="file-size">{file.displayFileSize()}</span>
</div>
{this._renderFileActionIcon()}
</Flexbox>
</div>
)
}
}
export default AttachmentComponent

View file

@ -1,44 +0,0 @@
path = require 'path'
React = require 'react'
AttachmentComponent = require './attachment-component'
{RetinaImg, Spinner, DraggableImg} = require 'nylas-component-kit'
class ImageAttachmentComponent extends AttachmentComponent
@displayName: 'ImageAttachmentComponent'
render: =>
<div>
<span className={"progress-bar-wrap state-#{@props.download?.state ? ""}"}>
<span className="progress-background"></span>
<span className="progress-foreground" style={@_downloadProgressStyle()}></span>
</span>
{@_renderFileActions()}
<div className="file-preview" onDoubleClick={@_onClickView}>
<div className="file-name-container">
<div className="file-name">{@props.file.displayName()}</div>
</div>
{@_imgOrLoader()}
</div>
</div>
_canAbortDownload: -> false
_renderRemoveIcon: ->
<RetinaImg name="image-cancel-button.png" mode={RetinaImg.Mode.ContentPreserve} />
_renderDownloadButton: ->
<RetinaImg name="image-download-button.png" mode={RetinaImg.Mode.ContentPreserve} />
_imgOrLoader: ->
if @props.download and @props.download.percent <= 5
<div style={width: "100%", height: "100px"}>
<Spinner visible={true} />
</div>
else if @props.download and @props.download.percent < 100
<DraggableImg src={"#{@props.targetPath}?percent=#{@props.download.percent}"} />
else
<DraggableImg src={@props.targetPath} />
module.exports = ImageAttachmentComponent

View file

@ -0,0 +1,77 @@
import React, {PropTypes} from 'react'
import {RetinaImg, Spinner, DraggableImg} from 'nylas-component-kit'
import AttachmentComponent from './attachment-component'
class ImageAttachmentComponent extends AttachmentComponent {
static displayName = 'ImageAttachmentComponent';
static propTypes = {
file: PropTypes.object.isRequired,
download: PropTypes.object,
targetPath: PropTypes.string,
};
static containerRequired = false;
_canAbortDownload() {
return false
}
_imgOrLoader() {
const {download, targetPath} = this.props
if (download && download.percent <= 5) {
return (
<div style={{width: "100%", height: "100px"}}>
<Spinner visible />
</div>
)
} else if (download && download.percent < 100) {
return (
<DraggableImg src={`${targetPath}?percent=${download.percent}`} />
)
}
return <DraggableImg src={targetPath} />
}
_renderRemoveIcon() {
return (
<RetinaImg
name="image-cancel-button.png"
mode={RetinaImg.Mode.ContentPreserve}
/>
)
}
_renderDownloadButton() {
return (
<RetinaImg
name="image-download-button.png"
mode={RetinaImg.Mode.ContentPreserve}
/>
)
}
render() {
const {download, file} = this.props
const state = download ? download.state || "" : ""
const displayName = file.displayName()
return (
<div>
<span className={`progress-bar-wrap state-${state}`}>
<span className="progress-background"></span>
<span className="progress-foreground" style={this._downloadProgressStyle()}></span>
</span>
{this._renderFileActions()}
<div className="file-preview" onDoubleClick={this._onClickView}>
<div className="file-name-container">
<div className="file-name">{displayName}</div>
</div>
{this._imgOrLoader()}
</div>
</div>
)
}
}
export default ImageAttachmentComponent

View file

@ -1,18 +0,0 @@
{ComponentRegistry} = require 'nylas-exports'
AttachmentComponent = require "./attachment-component"
ImageAttachmentComponent = require "./image-attachment-component"
module.exports =
activate: (@state={}) ->
ComponentRegistry.register AttachmentComponent,
role: 'Attachment'
ComponentRegistry.register ImageAttachmentComponent,
role: 'Attachment:Image'
deactivate: ->
ComponentRegistry.unregister(AttachmentComponent)
ComponentRegistry.unregister(ImageAttachmentComponent)
serialize: -> @state

View file

@ -0,0 +1,14 @@
import {ComponentRegistry} from 'nylas-exports'
import AttachmentComponent from "./attachment-component"
import ImageAttachmentComponent from "./image-attachment-component"
export function activate() {
ComponentRegistry.register(AttachmentComponent, {role: 'Attachment'})
ComponentRegistry.register(ImageAttachmentComponent, {role: 'Attachment:Image'})
}
export function deactivate() {
ComponentRegistry.unregister(AttachmentComponent)
ComponentRegistry.unregister(ImageAttachmentComponent)
}

View file

@ -12,12 +12,11 @@
-webkit-user-drag: element;
.inner {
border-radius: 4px;
border-radius: 2px;
color: @text-color;
background: @background-off-primary;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.09);
padding: 0 @spacing-standard;
height:46px;
height: 37px;
}
&:hover {
@ -25,6 +24,9 @@
.inner {
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.18);
}
.file-action-icon {
border-left: 1px solid rgba(0, 0, 0, 0.18);
}
}
&.file-upload {
@ -80,20 +82,46 @@
}
}
.file-icon {
margin-right: 10px;
flex-shrink:0;
}
.file-name {
font-weight: @font-weight-medium;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.file-info-wrap {
display: flex;
align-items: center;
padding-left: @spacing-half + 1;
width: 100%;
.file-icon {
margin-right: 10px;
flex-shrink:0;
}
.file-name {
font-weight: @font-weight-medium;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
@file-size-color: #b8b8b8;
margin-left: auto;
margin-right: @spacing-three-quarters;
color: @file-size-color;
}
}
.file-action-icon {
margin-left: 10px;
flex-shrink:0;
@file-icon-color: #c7c7c7;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding-top: 1px;
height: 100%;
width: 37px;
border-left: 1px solid rgba(0, 0, 0, 0.09);
img {
background-color: @file-icon-color;
}
}
}
@ -123,6 +151,9 @@
.file-action-icon, .file-name-container, .file-name {
display: block;
}
.file-action-icon {
border-left: none;
}
}
.file-action-icon {
@ -132,6 +163,10 @@
top: -8px;
width: 26px;
border-radius: 0 0 0 3px;
border-left: none;
img {
background: none;
}
}
.file-preview {
@ -170,7 +205,7 @@
z-index: 1;
max-width: 100%;
background: url(../static/images/attachments/transparency-background.png) top left repeat;
background-size:8px;
background-size: 8px;
}
}

View file

@ -364,9 +364,8 @@ class ComposerView extends React.Component
_renderQuotedTextControl: ->
if QuotedHTMLTransformer.hasQuotedHTML(@state.body)
text = if @state.showQuotedText then "Hide" else "Show"
<a className="quoted-text-control" onClick={@_onToggleQuotedText}>
<span className="dots">&bull;&bull;&bull;</span>{text} previous
<span className="dots">&bull;&bull;&bull;</span>
</a>
else return []

View file

@ -109,9 +109,6 @@ describe "Composer Quoted Text", ->
it 'should be rendered', ->
expect(@toggle).toBeDefined()
it 'prompts to hide the quote', ->
expect(React.findDOMNode(@toggle).textContent).toEqual "•••Hide previous"
describe 'when showQuotedText is false', ->
beforeEach ->
@composer.setState
@ -151,6 +148,3 @@ describe "Composer Quoted Text", ->
it 'should be rendered', ->
expect(@toggle).toBeDefined()
it 'prompts to hide the quote', ->
expect(React.findDOMNode(@toggle).textContent).toEqual "•••Show previous"

View file

@ -68,9 +68,8 @@ class MessageItemBody extends React.Component
_renderQuotedTextControl: =>
return null unless QuotedHTMLTransformer.hasQuotedHTML(@props.message.body)
text = if @state.showQuotedText then "Hide" else "Show"
<a className="quoted-text-control" onClick={@_toggleQuotedText}>
<span className="dots">&bull;&bull;&bull;</span>{text} previous
<span className="dots">&bull;&bull;&bull;</span>
</a>
_toggleQuotedText: =>

View file

@ -157,12 +157,38 @@ class MessageItem extends React.Component
el = el.parentElement
@_toggleCollapsed()
_onDownloadAll: =>
Actions.fetchAndSaveAllFiles(@props.message.files)
_renderDownloadAllButton: =>
<div className="download-all" onClick={@_onDownloadAll}>
<div className="attachment-number">
<RetinaImg
name="ic-attachments-all-clippy.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
<span>{@props.message.files.length} attachments</span>
</div>
<div className="separator">-</div>
<div className="download-all-action">
<RetinaImg
name="ic-attachments-download-all.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
<span>Download all</span>
</div>
</div>
_renderAttachments: =>
attachments = @_attachmentComponents()
if attachments.length > 0
<div className="attachments-area">{attachments}</div>
<div>
{if attachments.length > 1 then @_renderDownloadAllButton()}
<div className="attachments-area">{attachments}</div>
</div>
else
<div></div>
<div />
_renderHeaderSideItems: ->
styles =

View file

@ -196,9 +196,6 @@ describe "MessageItem", ->
it 'should be rendered', ->
expect(@toggle).toBeDefined()
it 'prompts to hide the quote', ->
expect(React.findDOMNode(@toggle).textContent).toEqual "•••Show previous"
it "should be initialized to true if the message contains `Forwarded`...", ->
@message.body = """
Hi guys, take a look at this. Very relevant. -mg
@ -249,9 +246,6 @@ describe "MessageItem", ->
it 'should be rendered', ->
expect(@toggle).toBeDefined()
it 'prompts to hide the quote', ->
expect(React.findDOMNode(@toggle).textContent).toEqual "•••Hide previous"
it "should pass the value into the EmailFrame", ->
frame = ReactTestUtils.findRenderedComponentWithType(@component, EmailFrameStub)
expect(frame.props.showQuotedText).toBe(true)

View file

@ -532,8 +532,43 @@ body.platform-win32 {
}
}
.download-all {
@download-btn-color: fadeout(#929292, 20%);
@download-hover-color: fadeout(@component-active-color, 20%);
display: flex;
align-items: center;
color: @download-btn-color;
font-size: 0.9em;
cursor: default;
margin-top: @spacing-three-quarters;
.separator {
margin: 0 5px;
}
.attachment-number {
display: flex;
align-items: center;
}
img {
vertical-align: middle;
margin-right: @spacing-half;
background-color: @download-btn-color;
}
.download-all-action:hover {
color: @download-hover-color;
img {
background-color: @download-hover-color;
}
}
}
.attachments-area {
padding-top: @spacing-standard;
padding-top: @spacing-half + 2;
// attachments are padded on both sides so that things like the remove "X" can
// overhang them. To make the attachments line up with the body, we need to outdent

View file

@ -475,6 +475,7 @@ class Actions
@fetchAndOpenFile: ActionScopeWindow
@fetchAndSaveFile: ActionScopeWindow
@fetchAndSaveAllFiles: ActionScopeWindow
@fetchFile: ActionScopeWindow
@abortFetchFile: ActionScopeWindow

View file

@ -67,6 +67,10 @@ class File extends Model
else
return "Unnamed Attachment"
safeDisplayName: ->
RegExpUtils = require '../../regexp-utils'
return @displayName().replace(RegExpUtils.illegalPathCharactersRegexp(), '-')
# Public: Returns the file extension that should be used for this file.
# Note that asking for the displayExtension is more accurate than trying to read
# the extension directly off the filename. The returned extension may be based
@ -77,4 +81,20 @@ class File extends Model
displayExtension: ->
path.extname(@displayName().toLowerCase())[1..-1]
displayFileSize: (bytes = @size) ->
threshold = 1000000000
units = ['B', 'KB', 'MB', 'GB']
idx = units.length - 1
result = bytes / threshold
while result < 1 and idx >= 0
threshold /= 1000
result = bytes / threshold
idx--
# parseFloat will remove trailing zeros
decimalPoints = if idx >= 2 then 1 else 0
rounded = parseFloat(result.toFixed(decimalPoints))
return "#{rounded} #{units[idx]}"
module.exports = File

View file

@ -98,6 +98,7 @@ FileDownloadStore = Reflux.createStore
@listenTo Actions.fetchFile, @_fetch
@listenTo Actions.fetchAndOpenFile, @_fetchAndOpen
@listenTo Actions.fetchAndSaveFile, @_fetchAndSave
@listenTo Actions.fetchAndSaveAllFiles, @_fetchAndSaveAll
@listenTo Actions.abortFetchFile, @_abortFetchFile
@listenTo Actions.didPassivelyReceiveNewModels, @_newMailReceived
@ -113,9 +114,7 @@ FileDownloadStore = Reflux.createStore
#
pathForFile: (file) ->
return undefined unless file
filesafeName = file.displayName().replace(RegExpUtils.illegalPathCharactersRegexp(), '-')
path.join(@_downloadDirectory, file.id, filesafeName)
path.join(@_downloadDirectory, file.id, file.safeDisplayName())
downloadDataForFile: (fileId) ->
@_downloads[fileId]?.data()
@ -202,6 +201,13 @@ FileDownloadStore = Reflux.createStore
.catch =>
@_presentError(file)
_saveDownload: (download, savePath) =>
return new Promise (resolve, reject) =>
stream = fs.createReadStream(download.targetPath)
stream.pipe(fs.createWriteStream(savePath))
stream.on 'error', (err) -> reject(err)
stream.on 'end', -> resolve()
_fetchAndSave: (file) ->
defaultPath = @_defaultSavePath(file)
defaultExtension = path.extname(defaultPath)
@ -215,12 +221,36 @@ FileDownloadStore = Reflux.createStore
if didLoseExtension
savePath = savePath + defaultExtension
defaultPath = NylasEnv.savedState.lastDownloadDirectory
@_runDownload(file).then (download) ->
stream = fs.createReadStream(download.targetPath)
stream.pipe(fs.createWriteStream(savePath))
stream.on 'end', ->
shell.showItemInFolder(savePath)
@_runDownload(file)
.then (download) => @_saveDownload(download, savePath)
.then => shell.showItemInFolder(savePath)
.catch =>
@_presentError(file)
_fetchAndSaveAll: (files) ->
defaultPath = @_defaultSaveDir()
options = {
defaultPath,
properties: ['openDirectory'],
}
NylasEnv.showOpenDialog options, (selected) =>
return unless selected
dirPath = selected[0]
return unless dirPath
NylasEnv.savedState.lastDownloadDirectory = dirPath
lastSavePath = null
savePromises = files.map (file) =>
savePath = path.join(dirPath, file.safeDisplayName())
@_runDownload(file)
.then (download) => @_saveDownload(download, savePath)
.then ->
lastSavePath = savePath
Promise.all(savePromises)
.then =>
shell.showItemInFolder(lastSavePath) if lastSavePath
.catch =>
@_presentError(file)
@ -234,7 +264,7 @@ FileDownloadStore = Reflux.createStore
fs.exists downloadPath, (exists) ->
fs.unlink(downloadPath) if exists
_defaultSavePath: (file) ->
_defaultSaveDir: ->
if process.platform is 'win32'
home = process.env.USERPROFILE
else
@ -248,8 +278,11 @@ FileDownloadStore = Reflux.createStore
if fs.existsSync(NylasEnv.savedState.lastDownloadDirectory)
downloadDir = NylasEnv.savedState.lastDownloadDirectory
filesafeName = file.displayName().replace(RegExpUtils.illegalPathCharactersRegexp(), '-')
path.join(downloadDir, filesafeName)
return downloadDir
_defaultSavePath: (file) ->
downloadDir = @_defaultSaveDir()
path.join(downloadDir, file.safeDisplayName())
_presentError: (file) ->
remote.dialog.showMessageBox

View file

@ -7,7 +7,7 @@
border: 1px solid fade(@text-color-very-subtle, 15%);
border-radius: 3px;
line-height: 10px;
padding: 6px 10px;
padding: 1px 5px;
font-weight: 600;
font-size: @font-size-smaller;
margin: 5px 0 3px 0;
@ -23,7 +23,6 @@
font-size: @font-size-smaller * 0.8;
top:-1px;
position:relative;
padding-right:8px;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 463 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 B

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB