mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-05 04:04:38 +08:00
Summary: consolidate all the "untitled" stuff into a convenience method on the File itself. Previously it'd show "Unnamed Attachment", download as "Untitled" and open as "<file.id>". Now it's consistent everywhere and chooses names based on the contenttype (Event.ics). Rewrite CSS rules for uploads and attachments to be simpler - remove container divs and classnames from things that have no CSS - switch to using Flexbox so it's not necesary to have so many containers - remove zIndex hacks, apply overflow rules to name div only, so long filenames don't make action button unclickable - consolidate CSS classnames for uploads/attachments - Other style fixes - cursor "default" instead of text insertion on image attachments - cursor "default" on action buttons - image uplaods / attachments with long filenames truncate with ellpsis - attachments are not indented by an extra 15px in message bodies Prevent progress bar overflow (was ending above 100%, 100.12315%...) Update FileDownloadStore so it never creates Download objects when file is downloaded already - Previously, the download itself decided if it would be a no-op, but this meant the download was around for a split second and you'd see progress indicators flash for a moment when opening/saving an attachment. Upgrade FileDownloadStore use of promises Restore Image attachment drag and drop - was broken because the name gradient thing was covering the entire drag region. Allow file attachments to be drag and dropped to the finder and other applications 😍😍😍 Test Plan: Tests still pass Reviewers: evan Reviewed By: evan Differential Revision: https://phab.nylas.com/D1745
235 lines
7.4 KiB
CoffeeScript
235 lines
7.4 KiB
CoffeeScript
os = require 'os'
|
|
fs = require 'fs'
|
|
ipc = require 'ipc'
|
|
path = require 'path'
|
|
shell = require 'shell'
|
|
mkdirp = require 'mkdirp'
|
|
Utils = require '../models/utils'
|
|
Reflux = require 'reflux'
|
|
_ = require 'underscore'
|
|
Actions = require '../actions'
|
|
progress = require 'request-progress'
|
|
NamespaceStore = require '../stores/namespace-store'
|
|
NylasAPI = require '../nylas-api'
|
|
|
|
Promise.promisifyAll(fs)
|
|
|
|
mkdirpAsync = (folder) ->
|
|
new Promise (resolve, reject) ->
|
|
mkdirp folder, (err) ->
|
|
if err then reject(err) else resolve(folder)
|
|
|
|
class Download
|
|
constructor: ({@fileId, @targetPath, @filename, @filesize, @progressCallback}) ->
|
|
if not @filename or @filename.length is 0
|
|
throw new Error("Download.constructor: You must provide a non-empty filename.")
|
|
if not @fileId
|
|
throw new Error("Download.constructor: You must provide a fileID to download.")
|
|
if not @targetPath
|
|
throw new Error("Download.constructor: You must provide a target path to download.")
|
|
|
|
@percent = 0
|
|
@promise = null
|
|
@
|
|
|
|
state: ->
|
|
if not @promise
|
|
'unstarted'
|
|
else if @promise.isFulfilled()
|
|
'finished'
|
|
else if @promise.isRejected()
|
|
'failed'
|
|
else
|
|
'downloading'
|
|
|
|
# We need to pass a plain object so we can have fresh references for the
|
|
# React views while maintaining the single object with the running
|
|
# request.
|
|
data: -> Object.freeze _.clone
|
|
state: @state()
|
|
fileId: @fileId
|
|
percent: @percent
|
|
filename: @filename
|
|
filesize: @filesize
|
|
targetPath: @targetPath
|
|
|
|
run: ->
|
|
# If run has already been called, return the existing promise. Never
|
|
# initiate multiple downloads for the same file
|
|
return @promise if @promise
|
|
|
|
@promise = new Promise (resolve, reject) =>
|
|
namespace = NamespaceStore.current()?.id
|
|
stream = fs.createWriteStream(@targetPath)
|
|
finished = false
|
|
finishedAction = null
|
|
|
|
# We need to watch the request for `success` or `error`, but not fire
|
|
# a callback until the stream has ended. These helper functions ensure
|
|
# that resolve or reject is only fired once regardless of the order
|
|
# these two events (stream end and `success`) happen in.
|
|
streamEnded = ->
|
|
finished = true
|
|
if finishedAction
|
|
finishedAction(@)
|
|
|
|
onStreamEnded = (action) ->
|
|
if finished
|
|
action(@)
|
|
else
|
|
finishedAction = action
|
|
|
|
NylasAPI.makeRequest
|
|
json: false
|
|
path: "/n/#{namespace}/files/#{@fileId}/download"
|
|
started: (req) =>
|
|
@request = req
|
|
progress(@request, {throtte: 250})
|
|
.on "progress", (progress) =>
|
|
@percent = progress.percent
|
|
@progressCallback()
|
|
.on "end", =>
|
|
# Wait for the file stream to finish writing before we resolve or reject
|
|
stream.end(streamEnded)
|
|
.pipe(stream)
|
|
|
|
success: =>
|
|
# At this point, the file stream has not finished writing to disk.
|
|
# Don't resolve yet, or the browser will load only part of the image.
|
|
onStreamEnded(resolve)
|
|
|
|
error: =>
|
|
onStreamEnded(reject)
|
|
|
|
abort: ->
|
|
@request?.abort()
|
|
|
|
|
|
module.exports =
|
|
FileDownloadStore = Reflux.createStore
|
|
init: ->
|
|
@listenTo Actions.fetchFile, @_fetch
|
|
@listenTo Actions.fetchAndOpenFile, @_fetchAndOpen
|
|
@listenTo Actions.fetchAndSaveFile, @_fetchAndSave
|
|
@listenTo Actions.abortDownload, @_abortDownload
|
|
|
|
@_downloads = {}
|
|
@_downloadDirectory = "#{atom.getConfigDirPath()}/downloads"
|
|
mkdirp(@_downloadDirectory)
|
|
|
|
######### PUBLIC #######################################################
|
|
|
|
# Returns a path on disk for saving the file. Note that we must account
|
|
# for files that don't have a name and avoid returning <downloads/dir/"">
|
|
# which causes operations to happen on the directory (badness!)
|
|
#
|
|
pathForFile: (file) ->
|
|
return undefined unless file
|
|
path.join(@_downloadDirectory, file.id, file.displayName())
|
|
|
|
downloadDataForFile: (fileId) -> @_downloads[fileId]?.data()
|
|
|
|
# Returns a hash of download objects keyed by fileId
|
|
#
|
|
downloadDataForFiles: (fileIds=[]) ->
|
|
downloadData = {}
|
|
fileIds.forEach (fileId) =>
|
|
data = @downloadDataForFile(fileId)
|
|
return unless data
|
|
downloadData[fileId] = data
|
|
return downloadData
|
|
|
|
########### PRIVATE ####################################################
|
|
|
|
# Returns a promise with a Download object, allowing other actions to be
|
|
# daisy-chained to the end of the download operation.
|
|
_startDownload: (file, options = {}) ->
|
|
@_prepareFolder(file).then =>
|
|
targetPath = @pathForFile(file)
|
|
|
|
# is there an existing download for this file? If so,
|
|
# return that promise so users can chain to the end of it.
|
|
download = @_downloads[file.id]
|
|
return download.run() if download
|
|
|
|
# create a new download for this file
|
|
download = new Download
|
|
fileId: file.id
|
|
filesize: file.size
|
|
filename: file.displayName()
|
|
targetPath: targetPath
|
|
progressCallback: => @trigger()
|
|
|
|
# Do we actually need to queue and run the download? Queuing a download
|
|
# for an already-downloaded file has side-effects, like making the UI
|
|
# flicker briefly.
|
|
@_checkForDownloadedFile(file).then (downloaded) =>
|
|
if downloaded
|
|
# If we have the file, just resolve with a resolved download representing the file.
|
|
download.promise = Promise.resolve()
|
|
return Promise.resolve(download)
|
|
else
|
|
cleanup = =>
|
|
@_cleanupDownload(download)
|
|
Promise.resolve(download)
|
|
@_downloads[file.id] = download
|
|
@trigger()
|
|
return download.run().catch(cleanup).then(cleanup)
|
|
|
|
# Returns a promise that resolves with true or false. True if the file has
|
|
# been downloaded, false if it should be downloaded.
|
|
#
|
|
_checkForDownloadedFile: (file) ->
|
|
fs.statAsync(@pathForFile(file)).catch (err) =>
|
|
return Promise.resolve(false)
|
|
.then (stats) =>
|
|
return Promise.resolve(stats.size >= file.size)
|
|
|
|
# Checks that the folder for the download is ready. Returns a promise that
|
|
# resolves when the download directory for the file has been created.
|
|
#
|
|
_prepareFolder: (file) ->
|
|
targetFolder = path.join(@_downloadDirectory, file.id)
|
|
fs.statAsync(targetFolder).catch =>
|
|
mkdirpAsync(targetFolder)
|
|
|
|
_fetch: (file) ->
|
|
@_startDownload(file)
|
|
|
|
_fetchAndOpen: (file) ->
|
|
@_startDownload(file).then (download) ->
|
|
shell.openItem(download.targetPath)
|
|
|
|
_fetchAndSave: (file) ->
|
|
atom.showSaveDialog @_defaultSavePath(file), (savePath) =>
|
|
return unless savePath
|
|
@_startDownload(file).then (download) ->
|
|
stream = fs.createReadStream(download.targetPath)
|
|
stream.pipe(fs.createWriteStream(savePath))
|
|
stream.on 'end', ->
|
|
shell.showItemInFolder(savePath)
|
|
|
|
_abortDownload: (downloadData) ->
|
|
download = @_downloads[downloadData.fileId]
|
|
return unless download
|
|
@_cleanupDownload(download)
|
|
p = @pathForFile
|
|
id: downloadData.fileId
|
|
filename: downloadData.filename
|
|
fs.unlinkSync(p)
|
|
|
|
_cleanupDownload: (download) ->
|
|
download.abort()
|
|
delete @_downloads[download.fileId]
|
|
@trigger()
|
|
|
|
_defaultSavePath: (file) ->
|
|
if process.platform is 'win32'
|
|
home = process.env.USERPROFILE
|
|
else home = process.env.HOME
|
|
|
|
downloadDir = path.join(home, 'Downloads')
|
|
if not fs.existsSync(downloadDir)
|
|
downloadDir = os.tmpdir()
|
|
|
|
path.join(downloadDir, file.displayName())
|