mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-12-16 14:49:23 +08:00
refactor(*): Thread list fixes, flexible workspace store, multiple root sheets
Summary: Remember to remove all the event listeners added to email frame New files tab, queryable filename, not attribute Rename ThreadSelectionBar to RootSelectionBar to go with RootCenterComponent, make it appear for draft selection and file selection as well Initial file list and file list store, File Location Remove unnecessary shouldComponentUpdate Always track whether new requests have happened since ours to prevent out of order triggers Always scroll to the current [focused/keyboard-cursor] in lists So goodbye to the trash tag Only scroll to current item if focus or keyboard has moved Show message snippet in notification if no subject line Make the RootSelectionBar pull items from Component Registry New Archive button (prettier than the other one) Refactor event additions to iframe so iframe can be used for file display also Thread List is no longer the uber root package - drafts and files moved to separate packages WorkspaceStore now allows packages to register sheets, "view" concept replaced with "root sheet" concept, "mode" may not be observed by all sheets, and is now called "preferred mode" Don't animate transitions between two root sheets Mode switch is only visible on root sheets that support multiple modes Account sidebar now shows "Views" that have registered themselves: drafts and files for now Model Selection Bar is now a component, just like ModelList. Meant to be in the toolbar above a Model List Misc supporting changes New files package which registers it's views and components Rename files package to `file-list` Move checkmark column down into model list Don't throw exception if shift-down arrow and nothing selected Takes a long time on login to fetch first page of threads, make pages smaller Displaynames, spec fixes Test Plan: Run tests Reviewers: evan Reviewed By: evan Differential Revision: https://review.inboxapp.com/D1412
This commit is contained in:
parent
f7fe9a93c8
commit
d9ee12cf81
54 changed files with 911 additions and 437 deletions
|
|
@ -8,6 +8,8 @@ module.exports =
|
|||
Flexbox: require '../src/components/flexbox'
|
||||
RetinaImg: require '../src/components/retina-img'
|
||||
ListTabular: require '../src/components/list-tabular'
|
||||
ModelList: require '../src/components/model-list'
|
||||
MultiselectList: require '../src/components/multiselect-list'
|
||||
MultiselectActionBar: require '../src/components/multiselect-action-bar'
|
||||
ResizableRegion: require '../src/components/resizable-region'
|
||||
TokenizingTextField: require '../src/components/tokenizing-text-field'
|
||||
EventedIFrame: require '../src/components/evented-iframe'
|
||||
|
|
@ -4,5 +4,7 @@ React = require 'react'
|
|||
|
||||
module.exports =
|
||||
AccountSidebarDividerItem = React.createClass
|
||||
displayName: 'AccountSidebarDividerItem'
|
||||
|
||||
render: ->
|
||||
<div className="item item-divider">{@props.label}</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
React = require 'react'
|
||||
{Actions, Utils, WorkspaceStore} = require 'inbox-exports'
|
||||
{RetinaImg} = require 'ui-components'
|
||||
|
||||
module.exports =
|
||||
AccountSidebarSheetItem = React.createClass
|
||||
displayName: 'AccountSidebarSheetItem'
|
||||
|
||||
render: ->
|
||||
classSet = React.addons.classSet
|
||||
'item': true
|
||||
'selected': @props.select
|
||||
|
||||
<div className={classSet} onClick={@_onClick}>
|
||||
<RetinaImg name={"folder.png"} colorfill={@props.select} />
|
||||
<span className="name"> {@props.item.name}</span>
|
||||
</div>
|
||||
|
||||
_onClick: (event) ->
|
||||
event.preventDefault()
|
||||
Actions.selectRootSheet(@props.item)
|
||||
|
|
@ -2,6 +2,7 @@ Reflux = require 'reflux'
|
|||
_ = require 'underscore-plus'
|
||||
{DatabaseStore,
|
||||
NamespaceStore,
|
||||
WorkspaceStore,
|
||||
Actions,
|
||||
Tag,
|
||||
Message,
|
||||
|
|
@ -14,17 +15,17 @@ AccountSidebarStore = Reflux.createStore
|
|||
@_registerListeners()
|
||||
@_populate()
|
||||
|
||||
# Keep a cache of unread counts since requesting the number from the
|
||||
# server is a fairly expensive operation.
|
||||
@_unreadCountCache = {}
|
||||
@localDraftsTag = new Tag({id: "drafts", name: "Local Drafts"})
|
||||
|
||||
|
||||
########### PUBLIC #####################################################
|
||||
|
||||
sections: ->
|
||||
@_sections
|
||||
|
||||
selected: ->
|
||||
if WorkspaceStore.rootSheet() is WorkspaceStore.Sheet.Threads
|
||||
FocusedTagStore.tag()
|
||||
else
|
||||
WorkspaceStore.rootSheet()
|
||||
|
||||
########### PRIVATE ####################################################
|
||||
|
||||
_setStoreDefaults: ->
|
||||
|
|
@ -33,6 +34,8 @@ AccountSidebarStore = Reflux.createStore
|
|||
_registerListeners: ->
|
||||
@listenTo DatabaseStore, @_onDataChanged
|
||||
@listenTo NamespaceStore, @_onNamespaceChanged
|
||||
@listenTo WorkspaceStore, @_onWorkspaceChanged
|
||||
@listenTo FocusedTagStore, @_onFocusChange
|
||||
|
||||
_populate: ->
|
||||
namespace = NamespaceStore.current()
|
||||
|
|
@ -44,7 +47,9 @@ AccountSidebarStore = Reflux.createStore
|
|||
|
||||
# We ignore the server drafts so we can use our own localDrafts
|
||||
tags = _.reject tags, (tag) -> tag.id is "drafts"
|
||||
tags.push(@localDraftsTag)
|
||||
|
||||
# We ignore the trash tag because you can't trash anything
|
||||
tags = _.reject tags, (tag) -> tag.id is "trash"
|
||||
|
||||
mainTagIDs = ['inbox', 'drafts', 'sent', 'archive']
|
||||
mainTags = _.filter tags, (tag) -> _.contains(mainTagIDs, tag.id)
|
||||
|
|
@ -57,10 +62,14 @@ AccountSidebarStore = Reflux.createStore
|
|||
# Sort user tags by name
|
||||
userTags = _.sortBy(userTags, 'name')
|
||||
|
||||
# Find root views, add the Views section
|
||||
rootSheets = _.filter WorkspaceStore.Sheet, (sheet) -> sheet.root and sheet.name
|
||||
|
||||
lastSections = @_sections
|
||||
@_sections = [
|
||||
{ label: 'Mailboxes', tags: mainTags },
|
||||
{ label: 'Tags', tags: userTags },
|
||||
{ label: 'Mailboxes', items: mainTags, type: 'tag' },
|
||||
{ label: 'Views', items: rootSheets, type: 'sheet' },
|
||||
{ label: 'Tags', items: userTags, type: 'tag' },
|
||||
]
|
||||
|
||||
@trigger(@)
|
||||
|
|
@ -83,7 +92,6 @@ AccountSidebarStore = Reflux.createStore
|
|||
@localDraftsTag.unreadCount = count
|
||||
@trigger(@)
|
||||
|
||||
|
||||
_refetchFromAPI: ->
|
||||
namespace = NamespaceStore.current()
|
||||
return unless namespace
|
||||
|
|
@ -96,6 +104,12 @@ AccountSidebarStore = Reflux.createStore
|
|||
@_populateInboxCount()
|
||||
@_populate()
|
||||
|
||||
_onWorkspaceChanged: ->
|
||||
@_populate()
|
||||
|
||||
_onFocusChange: ->
|
||||
@trigger(@)
|
||||
|
||||
_onDataChanged: (change) ->
|
||||
@populateInboxCountDebounced ?= _.debounce ->
|
||||
@_populateInboxCount()
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
React = require 'react'
|
||||
{Actions, Utils} = require 'inbox-exports'
|
||||
{Actions, Utils, WorkspaceStore} = require 'inbox-exports'
|
||||
{RetinaImg} = require 'ui-components'
|
||||
|
||||
module.exports =
|
||||
AccountSidebarTagItem = React.createClass
|
||||
displayName: 'AccountSidebarTagItem'
|
||||
|
||||
shouldComponentUpdate: (nextProps) ->
|
||||
@props?.item.id isnt nextProps.item.id or
|
||||
@props?.item.unreadCount isnt nextProps.item.unreadCount or
|
||||
@props?.select isnt nextProps.select
|
||||
|
||||
render: ->
|
||||
unread = []
|
||||
if @props.tag.unreadCount > 0
|
||||
unread = <div className="unread item-count-box">{@props.tag.unreadCount}</div>
|
||||
|
||||
name = if @props.tag.name is "drafts" then "Local Drafts" else @props.tag.name
|
||||
if @props.item.unreadCount > 0
|
||||
unread = <div className="unread item-count-box">{@props.item.unreadCount}</div>
|
||||
|
||||
classSet = React.addons.classSet
|
||||
'item': true
|
||||
'item-tag': true
|
||||
'selected': @props.select
|
||||
|
||||
<div className={classSet} onClick={@_onClick} id={@props.tag.id}>
|
||||
<RetinaImg name={"#{@props.tag.id}.png"} fallback={'folder.png'} colorfill={@props.select} />
|
||||
<span className="name"> {name}</span>
|
||||
<div className={classSet} onClick={@_onClick} id={@props.item.id}>
|
||||
<RetinaImg name={"#{@props.item.id}.png"} fallback={'folder.png'} colorfill={@props.select} />
|
||||
<span className="name"> {@props.item.name}</span>
|
||||
{unread}
|
||||
</div>
|
||||
|
||||
_onClick: (event) ->
|
||||
event.preventDefault()
|
||||
|
||||
if @props.tag.id is 'drafts'
|
||||
Actions.selectView('drafts')
|
||||
else
|
||||
Actions.selectView('threads')
|
||||
Actions.focusTag(@props.tag)
|
||||
Actions.selectRootSheet(WorkspaceStore.Sheet.Threads)
|
||||
Actions.focusTag(@props.item)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
React = require 'react'
|
||||
{Actions, FocusedTagStore} = require("inbox-exports")
|
||||
{Actions} = require("inbox-exports")
|
||||
SidebarDividerItem = require("./account-sidebar-divider-item")
|
||||
SidebarTagItem = require("./account-sidebar-tag-item")
|
||||
SidebarSheetItem = require("./account-sidebar-sheet-item")
|
||||
SidebarStore = require ("./account-sidebar-store")
|
||||
|
||||
module.exports =
|
||||
|
|
@ -13,14 +14,12 @@ AccountSidebar = React.createClass
|
|||
|
||||
componentDidMount: ->
|
||||
@unsubscribe = SidebarStore.listen @_onStoreChange
|
||||
@unsubscribe_focus = FocusedTagStore.listen @_onStoreChange
|
||||
|
||||
# It's important that every React class explicitly stops listening to
|
||||
# atom events before it unmounts. Thank you event-kit
|
||||
# This can be fixed via a Reflux mixin
|
||||
componentWillUnmount: ->
|
||||
@unsubscribe() if @unsubscribe
|
||||
@unsubscribe_focus() if @unsubscribe_focus
|
||||
|
||||
render: ->
|
||||
<div id="account-sidebar" className="account-sidebar">
|
||||
|
|
@ -37,18 +36,25 @@ AccountSidebar = React.createClass
|
|||
</section>
|
||||
|
||||
_itemComponents: (section) ->
|
||||
return section.tags?.map (tag) =>
|
||||
<SidebarTagItem
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
select={tag?.id == @state?.selected}/>
|
||||
if section.type is 'tag'
|
||||
itemClass = SidebarTagItem
|
||||
else if section.type is 'sheet'
|
||||
itemClass = SidebarSheetItem
|
||||
else
|
||||
throw new Error("Unsure how to render item type #{section.type}")
|
||||
|
||||
section.items?.map (item) =>
|
||||
<itemClass
|
||||
key={item.id ? item.type}
|
||||
item={item}
|
||||
select={item.id is @state.selected.id }/>
|
||||
|
||||
_onStoreChange: ->
|
||||
@setState @_getStateFromStores()
|
||||
|
||||
_getStateFromStores: ->
|
||||
sections: SidebarStore.sections()
|
||||
selected: FocusedTagStore.tagId()
|
||||
selected: SidebarStore.selected()
|
||||
|
||||
|
||||
AccountSidebar.minWidth = 165
|
||||
|
|
|
|||
|
|
@ -26,15 +26,7 @@
|
|||
font-weight: 400;
|
||||
padding: 0 @spacing-standard;
|
||||
line-height: @line-height-large * 1.1;
|
||||
}
|
||||
|
||||
.item-divider {
|
||||
color:#586870;
|
||||
padding-top: 1em;
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.item-tag {
|
||||
.unread {
|
||||
font-weight: @font-weight-medium;
|
||||
color: @source-list-active-bg;
|
||||
|
|
@ -56,10 +48,16 @@
|
|||
background: @source-list-active-color;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
&:hover {
|
||||
background: darken(@source-list-bg, 5%);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.item-divider {
|
||||
color:#586870;
|
||||
padding-top: 1em;
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
internal_packages/file-list/.gitignore
vendored
Executable file
1
internal_packages/file-list/.gitignore
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
node_modules
|
||||
55
internal_packages/file-list/lib/file-frame-store.coffee
Normal file
55
internal_packages/file-list/lib/file-frame-store.coffee
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
Reflux = require 'reflux'
|
||||
_ = require 'underscore-plus'
|
||||
fs = require 'fs'
|
||||
|
||||
{WorkspaceStore,
|
||||
FocusedContentStore,
|
||||
FileDownloadStore,
|
||||
Actions} = require 'inbox-exports'
|
||||
|
||||
module.exports =
|
||||
FileFrameStore = Reflux.createStore
|
||||
init: ->
|
||||
@_resetInstanceVars()
|
||||
@_afterViewUpdate = []
|
||||
|
||||
@listenTo FocusedContentStore, @_onFocusedContentChange
|
||||
@listenTo FileDownloadStore, @_onFileDownloadChange
|
||||
|
||||
file: ->
|
||||
@_file
|
||||
|
||||
ready: ->
|
||||
@_ready
|
||||
|
||||
download: ->
|
||||
@_download
|
||||
|
||||
_resetInstanceVars: ->
|
||||
@_file = null
|
||||
@_download = null
|
||||
@_ready = false
|
||||
|
||||
_update: ->
|
||||
|
||||
_onFileDownloadChange: ->
|
||||
@_download = FileDownloadStore.downloadForFileId(@_file.id) if @_file
|
||||
if @_file and @_ready is false and not @_download
|
||||
@_ready = true
|
||||
@trigger()
|
||||
|
||||
_onFocusedContentChange: (change) ->
|
||||
return unless change.impactsCollection('file')
|
||||
|
||||
@_file = FocusedContentStore.focused('file')
|
||||
if @_file
|
||||
filepath = FileDownloadStore.pathForFile(@_file)
|
||||
fs.exists filepath, (exists) =>
|
||||
Actions.fetchFile(@_file) if not exists
|
||||
@_download = FileDownloadStore.downloadForFileId(@_file.id)
|
||||
@_ready = not @_download
|
||||
@trigger()
|
||||
else
|
||||
@_ready = false
|
||||
@_download = null
|
||||
@trigger()
|
||||
37
internal_packages/file-list/lib/file-frame.cjsx
Normal file
37
internal_packages/file-list/lib/file-frame.cjsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
React = require 'react'
|
||||
_ = require "underscore-plus"
|
||||
{Utils, FileDownloadStore, Actions} = require 'inbox-exports'
|
||||
{Spinner, EventedIFrame} = require 'ui-components'
|
||||
FileFrameStore = require './file-frame-store'
|
||||
|
||||
module.exports =
|
||||
FileFrame = React.createClass
|
||||
displayName: 'FileFrame'
|
||||
|
||||
render: ->
|
||||
src = if @state.ready then @state.filepath else ''
|
||||
if @state.file
|
||||
<div className="file-frame-container">
|
||||
<EventedIFrame src={src} />
|
||||
<Spinner visible={!@state.ready} />
|
||||
</div>
|
||||
else
|
||||
<div></div>
|
||||
|
||||
getInitialState: ->
|
||||
@getStateFromStores()
|
||||
|
||||
componentDidMount: ->
|
||||
@_unsubscribers = []
|
||||
@_unsubscribers.push FileFrameStore.listen @_onChange
|
||||
|
||||
componentWillUnmount: ->
|
||||
unsubscribe() for unsubscribe in @_unsubscribers
|
||||
|
||||
getStateFromStores: ->
|
||||
file: FileFrameStore.file()
|
||||
filepath: FileDownloadStore.pathForFile(FileFrameStore.file())
|
||||
ready: FileFrameStore.ready()
|
||||
|
||||
_onChange: ->
|
||||
@setState(@getStateFromStores())
|
||||
20
internal_packages/file-list/lib/file-list-store.coffee
Normal file
20
internal_packages/file-list/lib/file-list-store.coffee
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
Reflux = require 'reflux'
|
||||
_ = require 'underscore-plus'
|
||||
{File,
|
||||
DatabaseStore,
|
||||
DatabaseView} = require 'inbox-exports'
|
||||
|
||||
module.exports =
|
||||
FileListStore = Reflux.createStore
|
||||
init: ->
|
||||
@listenTo DatabaseStore, @_onDataChanged
|
||||
|
||||
@_view = new DatabaseView(File, matchers: [File.attributes.filename.not('')])
|
||||
@listenTo @_view, => @trigger({})
|
||||
|
||||
view: ->
|
||||
@_view
|
||||
|
||||
_onDataChanged: (change) ->
|
||||
return unless change.objectClass is File.name
|
||||
@_view.invalidate({shallow: true, changed: change.objects})
|
||||
48
internal_packages/file-list/lib/file-list.cjsx
Normal file
48
internal_packages/file-list/lib/file-list.cjsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
_ = require 'underscore-plus'
|
||||
React = require 'react'
|
||||
{ListTabular, MultiselectList} = require 'ui-components'
|
||||
{Actions,
|
||||
DatabaseStore,
|
||||
ComponentRegistry} = require 'inbox-exports'
|
||||
FileListStore = require './file-list-store'
|
||||
|
||||
module.exports =
|
||||
FileList = React.createClass
|
||||
displayName: 'FileList'
|
||||
|
||||
componentWillMount: ->
|
||||
prettySize = (size) ->
|
||||
units = ['GB', 'MB', 'KB', 'bytes']
|
||||
while size > 1024
|
||||
size /= 1024
|
||||
units.pop()
|
||||
size = "#{(Math.ceil(size * 10) / 10)}"
|
||||
pretty = units.pop()
|
||||
"#{size} #{pretty}"
|
||||
|
||||
c1 = new ListTabular.Column
|
||||
name: "Name"
|
||||
flex: 1
|
||||
resolver: (file) ->
|
||||
<div>{file.filename}</div>
|
||||
|
||||
c2 = new ListTabular.Column
|
||||
name: "Size"
|
||||
width: '100px'
|
||||
resolver: (file) ->
|
||||
<div>{prettySize(file.size)}</div>
|
||||
|
||||
@columns = [c1, c2]
|
||||
|
||||
render: ->
|
||||
<MultiselectList
|
||||
dataStore={FileListStore}
|
||||
columns={@columns}
|
||||
commands={{}}
|
||||
onDoubleClick={@_onDoubleClick}
|
||||
itemClassProvider={ -> }
|
||||
className="file-list"
|
||||
collection="file" />
|
||||
|
||||
_onDoubleClick: (item) ->
|
||||
|
||||
12
internal_packages/file-list/lib/file-selection-bar.cjsx
Normal file
12
internal_packages/file-list/lib/file-selection-bar.cjsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
React = require "react/addons"
|
||||
FileListStore = require './file-list-store'
|
||||
{MultiselectActionBar} = require 'ui-components'
|
||||
|
||||
module.exports =
|
||||
FileSelectionBar = React.createClass
|
||||
displayName: 'FileSelectionBar'
|
||||
|
||||
render: ->
|
||||
<MultiselectActionBar
|
||||
dataStore={FileListStore}
|
||||
collection="file" />
|
||||
34
internal_packages/file-list/lib/main.cjsx
Normal file
34
internal_packages/file-list/lib/main.cjsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
FileFrame = require "./file-frame"
|
||||
FileList = require './file-list'
|
||||
FileSelectionBar = require './file-selection-bar'
|
||||
{ComponentRegistry,
|
||||
WorkspaceStore} = require 'inbox-exports'
|
||||
|
||||
module.exports =
|
||||
|
||||
activate: (@state={}) ->
|
||||
WorkspaceStore.defineSheet 'Files', {root: true, supportedModes: ['list'], name: 'Files'},
|
||||
list: ['RootSidebar', 'FileList']
|
||||
|
||||
WorkspaceStore.defineSheet 'File', {supportedModes: ['list']},
|
||||
list: ['File']
|
||||
|
||||
ComponentRegistry.register
|
||||
view: FileList
|
||||
name: 'FileList'
|
||||
location: WorkspaceStore.Location.FileList
|
||||
|
||||
ComponentRegistry.register
|
||||
view: FileSelectionBar
|
||||
name: 'FileSelectionBar'
|
||||
location: WorkspaceStore.Location.FileList.Toolbar
|
||||
|
||||
ComponentRegistry.register
|
||||
name: 'FileFrame'
|
||||
view: FileFrame
|
||||
location: WorkspaceStore.Location.File
|
||||
|
||||
deactivate: ->
|
||||
ComponentRegistry.unregister 'FileSelectionBar'
|
||||
ComponentRegistry.unregister 'FileList'
|
||||
ComponentRegistry.unregister 'FileFrame'
|
||||
13
internal_packages/file-list/package.json
Executable file
13
internal_packages/file-list/package.json
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "file-list",
|
||||
"version": "0.1.0",
|
||||
"main": "./lib/main",
|
||||
"description": "View files",
|
||||
"license": "Proprietary",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"atom": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
}
|
||||
}
|
||||
15
internal_packages/file-list/stylesheets/file-list.less
Normal file
15
internal_packages/file-list/stylesheets/file-list.less
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
@import "ui-variables";
|
||||
@import "ui-mixins";
|
||||
|
||||
@message-max-width: 800px;
|
||||
|
||||
.file-frame-container {
|
||||
width:100%;
|
||||
height:100%;
|
||||
|
||||
iframe {
|
||||
width:100%;
|
||||
height:100%;
|
||||
border:0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
Copyright (c) 2014 GitHub Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
# Tree View package [](https://travis-ci.org/atom/tree-view)
|
||||
|
||||
Explore and open files in the current project.
|
||||
|
||||
Press `cmd-\` to open/close the Tree view and `ctrl-0` to focus it.
|
||||
|
||||
When the Tree view has focus you can press `a`, `m`, or `delete` to add, move
|
||||
or delete files and folders.
|
||||
|
||||

|
||||
|
|
@ -1,5 +1,6 @@
|
|||
React = require 'react'
|
||||
_ = require "underscore-plus"
|
||||
{EventedIFrame} = require 'ui-components'
|
||||
{Utils} = require 'inbox-exports'
|
||||
|
||||
EmailFixingStyles = """
|
||||
|
|
@ -109,7 +110,7 @@ EmailFrame = React.createClass
|
|||
displayName: 'EmailFrame'
|
||||
|
||||
render: ->
|
||||
<iframe seamless="seamless" />
|
||||
<EventedIFrame seamless="seamless" />
|
||||
|
||||
componentDidMount: ->
|
||||
@_writeContent()
|
||||
|
|
@ -119,11 +120,6 @@ EmailFrame = React.createClass
|
|||
@_writeContent()
|
||||
@_setFrameHeight()
|
||||
|
||||
componentWillUnmount: ->
|
||||
doc = @getDOMNode().contentDocument
|
||||
doc?.removeEventListener?("click")
|
||||
doc?.removeEventListener?("keydown")
|
||||
|
||||
shouldComponentUpdate: (newProps, newState) ->
|
||||
# Turns out, React is not able to tell if props.children has changed,
|
||||
# so whenever the message list updates each email-frame is repopulated,
|
||||
|
|
@ -138,11 +134,6 @@ EmailFrame = React.createClass
|
|||
doc.write(EmailFixingStyles)
|
||||
doc.write("<div id='inbox-html-wrapper' class='#{wrapperClass}'>#{@_emailContent()}</div>")
|
||||
doc.close()
|
||||
doc.addEventListener "click", @_onIFrameClick
|
||||
doc.addEventListener "keydown", @_onIFrameKeydown
|
||||
doc.addEventListener "mousedown", @_onIFrameMouseEvent
|
||||
doc.addEventListener "mousemove", @_onIFrameMouseEvent
|
||||
doc.addEventListener "mouseup", @_onIFrameMouseEvent
|
||||
|
||||
_setFrameHeight: ->
|
||||
_.defer =>
|
||||
|
|
@ -172,33 +163,3 @@ EmailFrame = React.createClass
|
|||
email
|
||||
else
|
||||
Utils.stripQuotedText(email)
|
||||
|
||||
# The iFrame captures events that take place over it, which causes some
|
||||
# interesting behaviors. For example, when you drag and release over the
|
||||
# iFrame, the mouseup never fires in the parent window.
|
||||
|
||||
_onIFrameClick: (e) ->
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
target = e.target
|
||||
|
||||
# This lets us detect when we click an element inside of an <a> tag
|
||||
while target? and (target isnt document) and (target isnt window)
|
||||
if target.getAttribute('href')?
|
||||
atom.windowEventHandler.openLink target: target
|
||||
target = null
|
||||
else
|
||||
target = target.parentElement
|
||||
|
||||
_onIFrameMouseEvent: (event) ->
|
||||
nodeRect = @getDOMNode().getBoundingClientRect()
|
||||
@getDOMNode().dispatchEvent(new MouseEvent(event.type, _.extend({}, event, {
|
||||
clientX: event.clientX + nodeRect.left
|
||||
clientY: event.clientY + nodeRect.top
|
||||
pageX: event.pageX + nodeRect.left
|
||||
pageY: event.pageY + nodeRect.top
|
||||
})))
|
||||
|
||||
_onIFrameKeydown: (event) ->
|
||||
return if event.metaKey or event.altKey or event.ctrlKey
|
||||
@getDOMNode().dispatchEvent(new KeyboardEvent(event.type, event))
|
||||
|
|
|
|||
|
|
@ -55,10 +55,7 @@ ArchiveButton = React.createClass
|
|||
|
||||
_onArchive: (e) ->
|
||||
return unless Utils.nodeIsVisible(e.currentTarget)
|
||||
if WorkspaceStore.selectedLayoutMode() is "list"
|
||||
Actions.archiveCurrentThread()
|
||||
else if WorkspaceStore.selectedLayoutMode() is "split"
|
||||
Actions.archiveAndNext()
|
||||
Actions.archive()
|
||||
e.stopPropagation()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,20 +8,18 @@ describe "MessageToolbarItems", ->
|
|||
@toolbarItems = ReactTestUtils.renderIntoDocument(<MessageToolbarItems />)
|
||||
@archiveButton = @toolbarItems.refs["archiveButton"]
|
||||
spyOn(Actions, "archiveAndNext")
|
||||
spyOn(Actions, "archiveCurrentThread")
|
||||
spyOn(Actions, "archive")
|
||||
|
||||
it "renders the archive button", ->
|
||||
btns = ReactTestUtils.scryRenderedDOMComponentsWithClass(@toolbarItems, "btn-archive")
|
||||
expect(btns.length).toBe 1
|
||||
|
||||
it "archives and next in split mode", ->
|
||||
spyOn(WorkspaceStore, "selectedLayoutMode").andReturn "split"
|
||||
it "archives in split mode", ->
|
||||
spyOn(WorkspaceStore, "layoutMode").andReturn "split"
|
||||
ReactTestUtils.Simulate.click(@archiveButton.getDOMNode())
|
||||
expect(Actions.archiveCurrentThread).not.toHaveBeenCalled()
|
||||
expect(Actions.archiveAndNext).toHaveBeenCalled()
|
||||
expect(Actions.archive).toHaveBeenCalled()
|
||||
|
||||
it "archives in list mode", ->
|
||||
spyOn(WorkspaceStore, "selectedLayoutMode").andReturn "list"
|
||||
spyOn(WorkspaceStore, "layoutMode").andReturn "list"
|
||||
ReactTestUtils.Simulate.click(@archiveButton.getDOMNode())
|
||||
expect(Actions.archiveCurrentThread).toHaveBeenCalled()
|
||||
expect(Actions.archiveAndNext).not.toHaveBeenCalled()
|
||||
expect(Actions.archive).toHaveBeenCalled()
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ module.exports =
|
|||
ComponentRegistry.register
|
||||
name: 'ModeToggle'
|
||||
view: ModeToggle
|
||||
location: WorkspaceStore.Sheet.Root.Toolbar.Right
|
||||
location: WorkspaceStore.Sheet.Global.Toolbar.Right
|
||||
|
|
|
|||
|
|
@ -5,27 +5,34 @@
|
|||
React = require "react"
|
||||
_ = require "underscore-plus"
|
||||
|
||||
|
||||
##
|
||||
## THIS FILE IS NOT IN USE! DEPRECATED IN FAVOR OF ModeToggle
|
||||
##
|
||||
|
||||
module.exports =
|
||||
ModeSwitch = React.createClass
|
||||
displayName: 'ModeSwitch'
|
||||
|
||||
getInitialState: ->
|
||||
mode: WorkspaceStore.selectedLayoutMode()
|
||||
@_getStateFromStores()
|
||||
|
||||
componentDidMount: ->
|
||||
@unsubscribe = WorkspaceStore.listen(@_onStateChanged, @)
|
||||
@unsubscribe = WorkspaceStore.listen @_onStateChanged
|
||||
|
||||
componentWillUnmount: ->
|
||||
@unsubscribe?()
|
||||
|
||||
render: ->
|
||||
return <div></div> unless @state.visible
|
||||
|
||||
knobX = if @state.mode is 'list' then 25 else 41
|
||||
|
||||
# Currently ModeSwitch is an opaque control that is not intended
|
||||
# to be styled, hence the fixed margins and positions. If we
|
||||
# turn this into a standard component one day, change!
|
||||
<div className="mode-switch"
|
||||
style={order:51, marginTop:14, marginRight:20}
|
||||
style={order:1001, marginTop:14, marginRight:20}
|
||||
onClick={@_onToggleMode}>
|
||||
<RetinaImg
|
||||
data-mode={'list'}
|
||||
|
|
@ -48,8 +55,14 @@ ModeSwitch = React.createClass
|
|||
</div>
|
||||
|
||||
_onStateChanged: ->
|
||||
@setState
|
||||
mode: WorkspaceStore.selectedLayoutMode()
|
||||
@setState(@_getStateFromStores())
|
||||
|
||||
_getStateFromStores: ->
|
||||
rootModes = WorkspaceStore.rootSheet().supportedModes
|
||||
rootVisible = WorkspaceStore.rootSheet() is WorkspaceStore.topSheet()
|
||||
|
||||
mode: WorkspaceStore.layoutMode()
|
||||
visible: rootVisible and rootModes and rootModes.length > 1
|
||||
|
||||
_onToggleMode: ->
|
||||
if @state.mode is 'list'
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ ModeToggle = React.createClass
|
|||
displayName: 'ModeToggle'
|
||||
|
||||
getInitialState: ->
|
||||
mode: WorkspaceStore.selectedLayoutMode()
|
||||
@_getStateFromStores()
|
||||
|
||||
componentDidMount: ->
|
||||
@unsubscribe = WorkspaceStore.listen(@_onStateChanged, @)
|
||||
|
|
@ -19,6 +19,8 @@ ModeToggle = React.createClass
|
|||
@unsubscribe?()
|
||||
|
||||
render: ->
|
||||
return <div></div> unless @state.visible
|
||||
|
||||
<div className="mode-switch"
|
||||
style={order:51, marginTop:10, marginRight:14}
|
||||
onClick={@_onToggleMode}>
|
||||
|
|
@ -28,8 +30,14 @@ ModeToggle = React.createClass
|
|||
</div>
|
||||
|
||||
_onStateChanged: ->
|
||||
@setState
|
||||
mode: WorkspaceStore.selectedLayoutMode()
|
||||
@setState(@_getStateFromStores())
|
||||
|
||||
_getStateFromStores: ->
|
||||
rootModes = WorkspaceStore.rootSheet().supportedModes
|
||||
rootVisible = WorkspaceStore.rootSheet() is WorkspaceStore.topSheet()
|
||||
|
||||
mode: WorkspaceStore.layoutMode()
|
||||
visible: rootVisible and rootModes and rootModes.length > 1
|
||||
|
||||
_onToggleMode: ->
|
||||
if @state.mode is 'list'
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ module.exports =
|
|||
ComponentRegistry.register
|
||||
view: NotificationsStickyBar
|
||||
name: 'NotificationsStickyBar'
|
||||
location: WorkspaceStore.Sheet.Root.Header
|
||||
location: WorkspaceStore.Sheet.Global.Header
|
||||
|
||||
deactivate: ->
|
||||
ComponentRegistry.unregister('NotificationsStickyBar')
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ module.exports =
|
|||
ComponentRegistry.register
|
||||
view: SearchBar
|
||||
name: 'SearchBar'
|
||||
location: WorkspaceStore.Location.RootCenter.Toolbar
|
||||
location: WorkspaceStore.Location.ThreadList.Toolbar
|
||||
|
||||
deactivate: ->
|
||||
ComponentRegistry.unregister 'SearchBar'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
_ = require 'underscore-plus'
|
||||
React = require 'react'
|
||||
{ListTabular, ModelList} = require 'ui-components'
|
||||
{ListTabular, MultiselectList} = require 'ui-components'
|
||||
{timestamp, subject} = require './formatting-utils'
|
||||
{Actions,
|
||||
DatabaseStore,
|
||||
|
|
@ -53,7 +53,7 @@ DraftList = React.createClass
|
|||
'core:remove-item': @_onDelete
|
||||
|
||||
render: ->
|
||||
<ModelList
|
||||
<MultiselectList
|
||||
dataStore={DraftListStore}
|
||||
columns={@columns}
|
||||
commands={@commands}
|
||||
|
|
|
|||
13
internal_packages/thread-list/lib/draft-selection-bar.cjsx
Normal file
13
internal_packages/thread-list/lib/draft-selection-bar.cjsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
React = require "react/addons"
|
||||
DraftListStore = require './draft-list-store'
|
||||
{MultiselectActionBar} = require 'ui-components'
|
||||
|
||||
module.exports =
|
||||
DraftSelectionBar = React.createClass
|
||||
displayName: 'DraftSelectionBar'
|
||||
|
||||
render: ->
|
||||
<MultiselectActionBar
|
||||
dataStore={DraftListStore}
|
||||
className="draft-list"
|
||||
collection="draft" />
|
||||
|
|
@ -1,46 +1,35 @@
|
|||
_ = require 'underscore-plus'
|
||||
React = require "react"
|
||||
{ModelList} = require 'ui-components'
|
||||
{ComponentRegistry, WorkspaceStore, Actions, DraftStore} = require "inbox-exports"
|
||||
{ComponentRegistry, WorkspaceStore} = require "inbox-exports"
|
||||
|
||||
{DownButton, UpButton} = require "./thread-nav-buttons"
|
||||
{DownButton, UpButton, ThreadBulkArchiveButton} = require "./thread-buttons"
|
||||
ThreadSelectionBar = require './thread-selection-bar'
|
||||
ThreadList = require './thread-list'
|
||||
|
||||
DraftSelectionBar = require './draft-selection-bar'
|
||||
DraftList = require './draft-list'
|
||||
|
||||
RootCenterComponent = React.createClass
|
||||
displayName: 'RootCenterComponent'
|
||||
|
||||
getInitialState: ->
|
||||
view: WorkspaceStore.selectedView()
|
||||
|
||||
componentDidMount: ->
|
||||
@unsubscribe = WorkspaceStore.listen @_onStoreChange
|
||||
|
||||
componentWillUnmount: ->
|
||||
@unsubscribe() if @unsubscribe
|
||||
|
||||
render: ->
|
||||
if @state.view is 'threads'
|
||||
<ThreadList />
|
||||
else
|
||||
<DraftList />
|
||||
|
||||
_onStoreChange: ->
|
||||
@setState
|
||||
view: WorkspaceStore.selectedView()
|
||||
|
||||
module.exports =
|
||||
activate: (@state={}) ->
|
||||
ComponentRegistry.register
|
||||
view: RootCenterComponent
|
||||
name: 'RootCenterComponent'
|
||||
location: WorkspaceStore.Location.RootCenter
|
||||
view: ThreadList
|
||||
name: 'ThreadList'
|
||||
location: WorkspaceStore.Location.ThreadList
|
||||
|
||||
ComponentRegistry.register
|
||||
name: 'ThreadSelectionBar'
|
||||
view: ThreadSelectionBar
|
||||
location: WorkspaceStore.Location.RootCenter.Toolbar
|
||||
location: WorkspaceStore.Location.ThreadList.Toolbar
|
||||
|
||||
ComponentRegistry.register
|
||||
view: DraftList
|
||||
name: 'DraftList'
|
||||
location: WorkspaceStore.Location.DraftList
|
||||
|
||||
ComponentRegistry.register
|
||||
name: 'DraftSelectionBar'
|
||||
view: DraftSelectionBar
|
||||
location: WorkspaceStore.Location.DraftList.Toolbar
|
||||
|
||||
ComponentRegistry.register
|
||||
name: 'DownButton'
|
||||
|
|
@ -54,7 +43,16 @@ module.exports =
|
|||
view: UpButton
|
||||
location: WorkspaceStore.Sheet.Thread.Toolbar.Right
|
||||
|
||||
ComponentRegistry.register
|
||||
view: ThreadBulkArchiveButton
|
||||
name: 'ThreadBulkArchiveButton'
|
||||
role: 'thread:BulkAction'
|
||||
|
||||
deactivate: ->
|
||||
ComponentRegistry.unregister 'RootCenterComponent'
|
||||
ComponentRegistry.unregister 'DraftList'
|
||||
ComponentRegistry.unregister 'DraftSelectionBar'
|
||||
ComponentRegistry.unregister 'ThreadList'
|
||||
ComponentRegistry.unregister 'ThreadSelectionBar'
|
||||
ComponentRegistry.unregister 'ThreadBulkArchiveButton'
|
||||
ComponentRegistry.unregister 'DownButton'
|
||||
ComponentRegistry.unregister 'UpButton'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,25 @@
|
|||
React = require "react/addons"
|
||||
ThreadListStore = require './thread-list-store'
|
||||
{FocusedContentStore} = require 'inbox-exports'
|
||||
{RetinaImg} = require 'ui-components'
|
||||
{Actions, AddRemoveTagsTask, FocusedContentStore} = require "inbox-exports"
|
||||
|
||||
ThreadBulkArchiveButton = React.createClass
|
||||
displayName: 'ThreadBulkArchiveButton'
|
||||
|
||||
propTypes:
|
||||
selection: React.PropTypes.object.isRequired
|
||||
|
||||
render: ->
|
||||
<button style={order:-100}
|
||||
className="btn btn-toolbar"
|
||||
data-tooltip="Archive"
|
||||
onClick={@_onArchive}>
|
||||
<RetinaImg name="toolbar-archive.png" />
|
||||
</button>
|
||||
|
||||
_onArchive: ->
|
||||
Actions.archiveSelection()
|
||||
|
||||
|
||||
ThreadNavButtonMixin =
|
||||
getInitialState: ->
|
||||
|
|
@ -71,4 +89,4 @@ UpButton = React.createClass
|
|||
_getStateFromStores: ->
|
||||
disabled: @isFirstThread()
|
||||
|
||||
module.exports = {DownButton, UpButton}
|
||||
module.exports = {DownButton, UpButton, ThreadBulkArchiveButton}
|
||||
|
|
@ -55,9 +55,6 @@ ThreadListParticipants = React.createClass
|
|||
{spans}
|
||||
</div>
|
||||
|
||||
shouldComponentUpdate: (newProps, newState) ->
|
||||
!_.isEqual(newProps.thread, @props.thread)
|
||||
|
||||
getParticipants: ->
|
||||
if @props.thread.metadata
|
||||
list = []
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ ThreadListStore = Reflux.createStore
|
|||
|
||||
@listenTo Actions.archiveAndPrevious, @_onArchiveAndPrev
|
||||
@listenTo Actions.archiveAndNext, @_onArchiveAndNext
|
||||
@listenTo Actions.archiveCurrentThread, @_onArchive
|
||||
@listenTo Actions.archiveSelection, @_onArchiveSelection
|
||||
@listenTo Actions.archive, @_onArchive
|
||||
@listenTo Actions.selectThreads, @_onSetSelection
|
||||
|
||||
@listenTo DatabaseStore, @_onDataChanged
|
||||
|
|
@ -91,6 +92,21 @@ ThreadListStore = Reflux.createStore
|
|||
_onArchive: ->
|
||||
@_archiveAndShiftBy('auto')
|
||||
|
||||
_onArchiveSelection: ->
|
||||
selected = @_view.selection.items()
|
||||
focusedId = FocusedContentStore.focusedId('thread')
|
||||
keyboardId = FocusedContentStore.keyboardCursorId('thread')
|
||||
|
||||
for thread in selected
|
||||
task = new AddRemoveTagsTask(thread, ['archive'], ['inbox'])
|
||||
Actions.queueTask(task)
|
||||
if thread.id is focusedId
|
||||
Actions.focusInCollection(collection: 'thread', item: null)
|
||||
if thread.id is keyboardId
|
||||
Actions.focusKeyboardInCollection(collection: 'thread', item: null)
|
||||
|
||||
@_view.selection.clear()
|
||||
|
||||
_onArchiveAndPrev: ->
|
||||
@_archiveAndShiftBy(-1)
|
||||
|
||||
|
|
@ -98,43 +114,45 @@ ThreadListStore = Reflux.createStore
|
|||
@_archiveAndShiftBy(1)
|
||||
|
||||
_archiveAndShiftBy: (offset) ->
|
||||
layoutMode = WorkspaceStore.selectedLayoutMode()
|
||||
selected = FocusedContentStore.focused('thread')
|
||||
return unless selected
|
||||
layoutMode = WorkspaceStore.layoutMode()
|
||||
focused = FocusedContentStore.focused('thread')
|
||||
explicitOffset = if offset is "auto" then false else true
|
||||
|
||||
# Determine the index of the current thread
|
||||
index = @_view.indexOfId(selected.id)
|
||||
return unless focused
|
||||
|
||||
# Determine the current index
|
||||
index = @_view.indexOfId(focused.id)
|
||||
return if index is -1
|
||||
|
||||
# Determine the next index we want to move to
|
||||
if offset is 'auto'
|
||||
if layoutMode is 'list'
|
||||
# If the user is in list mode, return to the thread lit
|
||||
Actions.focusInCollection(collection: 'thread', item: null)
|
||||
return
|
||||
else if layoutMode is 'split'
|
||||
# If the user is in split mode, automatically select another
|
||||
# thead when they archive the current one. We move up if the one above
|
||||
# the current thread is unread. Otherwise move down.
|
||||
thread = @_view.get(index - 1)
|
||||
if thread?.isUnread()
|
||||
offset = -1
|
||||
else
|
||||
offset = 1
|
||||
if @_view.get(index - 1)?.isUnread()
|
||||
offset = -1
|
||||
else
|
||||
offset = 1
|
||||
|
||||
index = Math.min(Math.max(index + offset, 0), @_view.count() - 1)
|
||||
next = @_view.get(index)
|
||||
nextKeyboard = nextFocus = @_view.get(index)
|
||||
|
||||
# Archive the current thread
|
||||
task = new AddRemoveTagsTask(selected, ['archive'], ['inbox'])
|
||||
|
||||
task = new AddRemoveTagsTask(focused, ['archive'], ['inbox'])
|
||||
Actions.queueTask(task)
|
||||
Actions.postNotification({message: "Archived thread", type: 'success'})
|
||||
|
||||
# Remove the current thread from selection
|
||||
@_view.selection.remove(focused)
|
||||
|
||||
# If the user is in list mode and archived without specifically saying
|
||||
# "archive and next" or "archive and prev", return to the thread list
|
||||
# instead of focusing on the next message.
|
||||
if layoutMode is 'list' and not explicitOffset
|
||||
nextFocus = null
|
||||
|
||||
@_afterViewUpdate.push ->
|
||||
Actions.focusInCollection(collection: 'thread', item: next)
|
||||
Actions.focusInCollection(collection: 'thread', item: nextFocus)
|
||||
Actions.focusKeyboardInCollection(collection: 'thread', item: nextKeyboard)
|
||||
|
||||
_autofocusForLayoutMode: ->
|
||||
focusedId = FocusedContentStore.focusedId('thread')
|
||||
if WorkspaceStore.selectedLayoutMode() is "split" and not focusedId
|
||||
if WorkspaceStore.layoutMode() is "split" and not focusedId
|
||||
_.defer => Actions.focusInCollection(collection: 'thread', item: @_view.get(0))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
_ = require 'underscore-plus'
|
||||
React = require 'react'
|
||||
{ListTabular, ModelList} = require 'ui-components'
|
||||
{ListTabular, MultiselectList} = require 'ui-components'
|
||||
{timestamp, subject} = require './formatting-utils'
|
||||
{Actions,
|
||||
Utils,
|
||||
|
|
@ -35,14 +35,6 @@ ThreadList = React.createClass
|
|||
else
|
||||
return 'replied'
|
||||
|
||||
c0 = new ListTabular.Column
|
||||
name: ""
|
||||
resolver: (thread) ->
|
||||
toggle = (event) ->
|
||||
ThreadListStore.view().selection.toggle(thread)
|
||||
event.stopPropagation()
|
||||
<div className="checkmark" onClick={toggle}><div className="inner"></div></div>
|
||||
|
||||
c1 = new ListTabular.Column
|
||||
name: "★"
|
||||
resolver: (thread) ->
|
||||
|
|
@ -72,9 +64,9 @@ ThreadList = React.createClass
|
|||
resolver: (thread) ->
|
||||
<span className="timestamp">{timestamp(thread.lastMessageTimestamp)}</span>
|
||||
|
||||
@columns = [c0, c1, c2, c3, c4]
|
||||
@columns = [c1, c2, c3, c4]
|
||||
@commands =
|
||||
'core:remove-item': -> Actions.archiveCurrentThread()
|
||||
'core:remove-item': @_onArchive
|
||||
'core:remove-and-previous': -> Actions.archiveAndPrevious()
|
||||
'core:remove-and-next': -> Actions.archiveAndNext()
|
||||
'application:reply': @_onReply
|
||||
|
|
@ -85,7 +77,7 @@ ThreadList = React.createClass
|
|||
'unread': item.isUnread()
|
||||
|
||||
render: ->
|
||||
<ModelList
|
||||
<MultiselectList
|
||||
dataStore={ThreadListStore}
|
||||
columns={@columns}
|
||||
commands={@commands}
|
||||
|
|
@ -95,6 +87,12 @@ ThreadList = React.createClass
|
|||
|
||||
# Additional Commands
|
||||
|
||||
_onArchive: ->
|
||||
if @_viewingFocusedThread() or ThreadListStore.view().selection.count() is 0
|
||||
Actions.archive()
|
||||
else
|
||||
Actions.archiveSelection()
|
||||
|
||||
_onReply: ({focusedId}) ->
|
||||
return unless focusedId? and @_viewingFocusedThread()
|
||||
Actions.composeReply(threadId: focusedId)
|
||||
|
|
@ -110,7 +108,7 @@ ThreadList = React.createClass
|
|||
# Helpers
|
||||
|
||||
_viewingFocusedThread: ->
|
||||
if WorkspaceStore.selectedLayoutMode() is "list"
|
||||
WorkspaceStore.sheet().type is "Thread"
|
||||
if WorkspaceStore.layoutMode() is "list"
|
||||
WorkspaceStore.topSheet() is WorkspaceStore.Sheet.Thread
|
||||
else
|
||||
true
|
||||
|
|
|
|||
|
|
@ -1,63 +1,13 @@
|
|||
React = require "react/addons"
|
||||
ThreadListStore = require './thread-list-store'
|
||||
{RetinaImg} = require 'ui-components'
|
||||
{Actions, AddRemoveTagsTask} = require "inbox-exports"
|
||||
{MultiselectActionBar} = require 'ui-components'
|
||||
|
||||
module.exports =
|
||||
ThreadSelectionBar = React.createClass
|
||||
displayName: 'ThreadSelectionBar'
|
||||
|
||||
getInitialState: ->
|
||||
@_getStateFromStores()
|
||||
|
||||
componentDidMount: ->
|
||||
@unsubscribers = []
|
||||
@unsubscribers.push ThreadListStore.listen @_onChange
|
||||
|
||||
componentWillUnmount: ->
|
||||
unsubscribe() for unsubscribe in @unsubscribers
|
||||
|
||||
render: ->
|
||||
<div className={@_classSet()}><div className="absolute"><div className="inner">
|
||||
<button style={order:-100}
|
||||
className="btn btn-toolbar"
|
||||
data-tooltip="Archive"
|
||||
onClick={@_onArchive}>
|
||||
<RetinaImg name="toolbar-archive.png" />
|
||||
</button>
|
||||
|
||||
<div className="centered">
|
||||
{@_label()}
|
||||
</div>
|
||||
|
||||
<button style={order:100}
|
||||
className="btn btn-toolbar"
|
||||
onClick={@_onClearSelection}>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div></div></div>
|
||||
|
||||
_label: ->
|
||||
if @state.selected.length > 0
|
||||
"#{@state.selected.length} Threads Selected"
|
||||
else
|
||||
""
|
||||
|
||||
_classSet: ->
|
||||
React.addons.classSet
|
||||
"thread-selection-bar": true
|
||||
"enabled": @state.selected.length > 0
|
||||
|
||||
_getStateFromStores: ->
|
||||
selected: ThreadListStore.view()?.selection.items() ? []
|
||||
|
||||
_onChange: ->
|
||||
@setState(@_getStateFromStores())
|
||||
|
||||
_onArchive: ->
|
||||
for thread in @state.selected
|
||||
task = new AddRemoveTagsTask(thread, ['archive'], ['inbox'])
|
||||
Actions.queueTask(task)
|
||||
|
||||
_onClearSelection: ->
|
||||
Actions.selectThreads([])
|
||||
<MultiselectActionBar
|
||||
dataStore={ThreadListStore}
|
||||
className="thread-list"
|
||||
collection="thread" />
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ describe "ThreadList", ->
|
|||
spyOn(ThreadStore, "_onNamespaceChanged")
|
||||
spyOn(DatabaseStore, "findAll").andCallFake ->
|
||||
new Promise (resolve, reject) -> resolve(test_threads())
|
||||
spyOn(Actions, "archiveCurrentThread")
|
||||
spyOn(Actions, "archive")
|
||||
spyOn(Actions, "archiveAndNext")
|
||||
spyOn(Actions, "archiveAndPrevious")
|
||||
ReactTestUtils.spyOnClass(ThreadList, "_prepareColumns").andCallFake ->
|
||||
|
|
@ -252,7 +252,7 @@ describe "ThreadList", ->
|
|||
|
||||
describe "when the workspace is in list mode", ->
|
||||
beforeEach ->
|
||||
spyOn(WorkspaceStore, "selectedLayoutMode").andReturn "list"
|
||||
spyOn(WorkspaceStore, "layoutMode").andReturn "list"
|
||||
@thread_list.setState focusedId: "t111"
|
||||
|
||||
it "allows reply only when the sheet type is 'Thread'", ->
|
||||
|
|
@ -271,7 +271,7 @@ describe "ThreadList", ->
|
|||
|
||||
describe "when the workspace is in split mode", ->
|
||||
beforeEach ->
|
||||
spyOn(WorkspaceStore, "selectedLayoutMode").andReturn "split"
|
||||
spyOn(WorkspaceStore, "layoutMode").andReturn "split"
|
||||
@thread_list.setState focusedId: "t111"
|
||||
|
||||
it "allows reply and reply-all regardless of sheet type", ->
|
||||
|
|
|
|||
|
|
@ -5,72 +5,6 @@
|
|||
outline:none;
|
||||
}
|
||||
|
||||
.sheet-toolbar .thread-selection-bar {
|
||||
// This item sits in the toolbar and takes up all the remaining
|
||||
// space from the toolbar-spacer divs, but flex-shrink means that
|
||||
// it shrinks before any other element when not enough space is available.
|
||||
|
||||
// This is important because the spacers will prevent items from being clickable,
|
||||
// (webkit-app-region:drag) even if we're covering them up. We need to make them
|
||||
// 0px wide!
|
||||
|
||||
width: 100%;
|
||||
flex-shrink:100;
|
||||
height:49px;
|
||||
z-index: 10000;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
.absolute {
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
right:-1px;
|
||||
top: 0;
|
||||
height:49px;
|
||||
border-left:1px solid @border-color-divider;
|
||||
border-right:1px solid @border-color-divider;
|
||||
background-color: @gray-lighter;
|
||||
opacity:0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
pointer-events: none;
|
||||
|
||||
.inner {
|
||||
top: -100%;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
display:flex;
|
||||
transition: top 0.2s ease-in-out;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.centered {
|
||||
flex: 1;
|
||||
cursor:default;
|
||||
text-align: center;
|
||||
color:@text-color-subtle;
|
||||
padding-top: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// .absolute is positioned full-width over all the other items in the toolbar.
|
||||
// when .enabled, it transitions to opacity:1, hiding other items beneath it.
|
||||
|
||||
// .inner contains the actual buttons and animates *down* while the opacity animation
|
||||
// is running. This means the items beneath fade out as the new ones slide in.
|
||||
|
||||
&.enabled {
|
||||
.absolute {
|
||||
opacity: 1;
|
||||
pointer-events: initial;
|
||||
.inner {
|
||||
top:0;
|
||||
.centered {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.thread-list {
|
||||
order: 3;
|
||||
flex: 1;
|
||||
|
|
|
|||
|
|
@ -43,8 +43,11 @@ module.exports =
|
|||
|
||||
if newUnreadInInbox.length is 1
|
||||
msg = newUnreadInInbox.pop()
|
||||
body = msg.subject
|
||||
if not body or body.length is 0
|
||||
body = msg.snippet
|
||||
notif = new Notification(msg.from[0].displayName(), {
|
||||
body: msg.subject
|
||||
body: body
|
||||
tag: 'unread-update'
|
||||
})
|
||||
notif.onclick = ->
|
||||
|
|
|
|||
|
|
@ -37,15 +37,16 @@ describe "InboxSyncWorker", ->
|
|||
|
||||
it "should start querying for model collections that haven't been fully cached", ->
|
||||
@worker.start()
|
||||
expect(@apiRequests.length).toBe(2)
|
||||
expect(@apiRequests.length).toBe(3)
|
||||
modelsRequested = _.map @apiRequests, (r) -> r.model
|
||||
expect(modelsRequested).toEqual(['threads', 'contacts'])
|
||||
expect(modelsRequested).toEqual(['threads', 'contacts', 'files'])
|
||||
|
||||
it "should mark incomplete collections as `busy`", ->
|
||||
@worker.start()
|
||||
expect(@state).toEqual({
|
||||
"contacts": {busy: true}
|
||||
"threads": {busy: true}
|
||||
"files": {busy: true}
|
||||
"calendars": {complete: true}
|
||||
})
|
||||
|
||||
|
|
@ -58,10 +59,10 @@ describe "InboxSyncWorker", ->
|
|||
describe "successfully, with models", ->
|
||||
it "should request the next page", ->
|
||||
models = []
|
||||
models.push(new Thread) for i in [0..499]
|
||||
models.push(new Thread) for i in [0..249]
|
||||
@request.requestOptions.success(models)
|
||||
expect(@apiRequests.length).toBe(1)
|
||||
expect(@apiRequests[0].params).toEqual({limit:500; offset: 500})
|
||||
expect(@apiRequests[0].params).toEqual({limit:250; offset: 250})
|
||||
|
||||
describe "successfully, with fewer models than requested", ->
|
||||
beforeEach ->
|
||||
|
|
@ -77,6 +78,7 @@ describe "InboxSyncWorker", ->
|
|||
@request.requestOptions.success([])
|
||||
expect(@state).toEqual({
|
||||
"contacts": {busy: true}
|
||||
"files": {busy: true}
|
||||
"threads": {complete : true}
|
||||
"calendars": {complete: true}
|
||||
})
|
||||
|
|
@ -90,6 +92,7 @@ describe "InboxSyncWorker", ->
|
|||
@request.requestOptions.success([])
|
||||
expect(@state).toEqual({
|
||||
"contacts": {busy: true}
|
||||
"files": {busy: true}
|
||||
"threads": {complete : true}
|
||||
"calendars": {complete: true}
|
||||
})
|
||||
|
|
@ -100,6 +103,7 @@ describe "InboxSyncWorker", ->
|
|||
@request.requestOptions.error(err)
|
||||
expect(@state).toEqual({
|
||||
"contacts": {busy: true}
|
||||
"files": {busy: true}
|
||||
"threads": {busy: false, error: err.toString()}
|
||||
"calendars": {complete: true}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -118,6 +118,17 @@ describe "ModelViewSelection", ->
|
|||
@selection.walk({current, next})
|
||||
expect(@selection.ids()).toEqual(['2', '4', '5'])
|
||||
|
||||
it "should select only one item if either current or next is null or undefined", ->
|
||||
current = null
|
||||
next = @items[5]
|
||||
@selection.walk({current, next})
|
||||
expect(@selection.ids()).toEqual(['2', '5'])
|
||||
|
||||
next = null
|
||||
current = @items[7]
|
||||
@selection.walk({current, next})
|
||||
expect(@selection.ids()).toEqual(['2', '5', '7'])
|
||||
|
||||
describe "when the `next` item is a step backwards in the selection history", ->
|
||||
it "should deselect the current item", ->
|
||||
@selection.set([@items[2], @items[3], @items[4], @items[5]])
|
||||
|
|
|
|||
56
src/components/evented-iframe.cjsx
Normal file
56
src/components/evented-iframe.cjsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
React = require 'react'
|
||||
_ = require "underscore-plus"
|
||||
|
||||
module.exports =
|
||||
EventedIFrame = React.createClass
|
||||
displayName: 'EventedIFrame'
|
||||
|
||||
render: ->
|
||||
<iframe seamless="seamless" {...@props} />
|
||||
|
||||
componentDidMount: ->
|
||||
@_subscribeToIFrameEvents()
|
||||
|
||||
componentWillUnmount: ->
|
||||
doc = @getDOMNode().contentDocument
|
||||
for e in ['click', 'keydown', 'mousedown', 'mousemove', 'mouseup']
|
||||
doc?.removeEventListener?(e)
|
||||
|
||||
_subscribeToIFrameEvents: ->
|
||||
doc = @getDOMNode().contentDocument
|
||||
_.defer =>
|
||||
doc.addEventListener "click", @_onIFrameClick
|
||||
doc.addEventListener "keydown", @_onIFrameKeydown
|
||||
doc.addEventListener "mousedown", @_onIFrameMouseEvent
|
||||
doc.addEventListener "mousemove", @_onIFrameMouseEvent
|
||||
doc.addEventListener "mouseup", @_onIFrameMouseEvent
|
||||
|
||||
# The iFrame captures events that take place over it, which causes some
|
||||
# interesting behaviors. For example, when you drag and release over the
|
||||
# iFrame, the mouseup never fires in the parent window.
|
||||
|
||||
_onIFrameClick: (e) ->
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
target = e.target
|
||||
|
||||
# This lets us detect when we click an element inside of an <a> tag
|
||||
while target? and (target isnt document) and (target isnt window)
|
||||
if target.getAttribute('href')?
|
||||
atom.windowEventHandler.openLink target: target
|
||||
target = null
|
||||
else
|
||||
target = target.parentElement
|
||||
|
||||
_onIFrameMouseEvent: (event) ->
|
||||
nodeRect = @getDOMNode().getBoundingClientRect()
|
||||
@getDOMNode().dispatchEvent(new MouseEvent(event.type, _.extend({}, event, {
|
||||
clientX: event.clientX + nodeRect.left
|
||||
clientY: event.clientY + nodeRect.top
|
||||
pageX: event.pageX + nodeRect.left
|
||||
pageY: event.pageY + nodeRect.top
|
||||
})))
|
||||
|
||||
_onIFrameKeydown: (event) ->
|
||||
return if event.metaKey or event.altKey or event.ctrlKey
|
||||
@getDOMNode().dispatchEvent(new KeyboardEvent(event.type, event))
|
||||
93
src/components/multiselect-action-bar.cjsx
Normal file
93
src/components/multiselect-action-bar.cjsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
React = require "react/addons"
|
||||
{RetinaImg} = require 'ui-components'
|
||||
{Actions,
|
||||
AddRemoveTagsTask,
|
||||
WorkspaceStore,
|
||||
ComponentRegistry} = require "inbox-exports"
|
||||
_ = require 'underscore-plus'
|
||||
|
||||
module.exports =
|
||||
MultiselectActionBar = React.createClass
|
||||
displayName: 'MultiselectActionBar'
|
||||
propTypes:
|
||||
collection: React.PropTypes.string.isRequired
|
||||
dataStore: React.PropTypes.object.isRequired
|
||||
|
||||
getInitialState: ->
|
||||
@_getStateFromStores()
|
||||
|
||||
componentDidMount: ->
|
||||
@setupForProps(@props)
|
||||
|
||||
componentWillReceiveProps: (newProps) ->
|
||||
return if _.isEqual(@props, newProps)
|
||||
@teardownForProps()
|
||||
@setupForProps(newProps)
|
||||
@setState(@_getStateFromStores(newProps))
|
||||
|
||||
componentWillUnmount: ->
|
||||
@teardownForProps()
|
||||
|
||||
teardownForProps: ->
|
||||
return unless @unsubscribers
|
||||
unsubscribe() for unsubscribe in @unsubscribers
|
||||
|
||||
setupForProps: (props) ->
|
||||
@unsubscribers = []
|
||||
@unsubscribers.push props.dataStore.listen @_onChange
|
||||
@unsubscribers.push WorkspaceStore.listen @_onChange
|
||||
@unsubscribers.push ComponentRegistry.listen @_onChange
|
||||
|
||||
shouldComponentUpdate: (nextProps, nextState) ->
|
||||
@props.collection isnt nextProps.collection or
|
||||
@state.count isnt nextState.count or
|
||||
@state.view isnt nextState.view or
|
||||
@state.type isnt nextState.type
|
||||
|
||||
render: ->
|
||||
<div className={@_classSet()}><div className="absolute"><div className="inner">
|
||||
{@_renderButtonsForItemType()}
|
||||
|
||||
<div className="centered">
|
||||
{@_label()}
|
||||
</div>
|
||||
|
||||
<button style={order:100}
|
||||
className="btn btn-toolbar"
|
||||
onClick={@_onClearSelection}>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div></div></div>
|
||||
|
||||
_renderButtonsForItemType: ->
|
||||
return [] unless @state.view
|
||||
(@state.ActionComponents ? []).map ({view, name}) =>
|
||||
<view key={name} selection={@state.view.selection} />
|
||||
|
||||
_label: ->
|
||||
if @state.count > 1
|
||||
"#{@state.count} #{@props.collection}s selected"
|
||||
else if @state.count is 1
|
||||
"#{@state.count} #{@props.collection} selected"
|
||||
else
|
||||
""
|
||||
|
||||
_classSet: ->
|
||||
React.addons.classSet
|
||||
"selection-bar": true
|
||||
"enabled": @state.count > 0
|
||||
|
||||
_getStateFromStores: (props) ->
|
||||
props ?= @props
|
||||
view = props.dataStore.view()
|
||||
|
||||
view: view
|
||||
count: view?.selection.items().length
|
||||
ActionComponents: ComponentRegistry.findAllByRole("#{props.collection}:BulkAction")
|
||||
|
||||
_onChange: ->
|
||||
@setState(@_getStateFromStores())
|
||||
|
||||
_onClearSelection: ->
|
||||
@state.view.selection.clear()
|
||||
|
||||
|
|
@ -10,8 +10,8 @@ Spinner = require './spinner'
|
|||
EventEmitter = require('events').EventEmitter
|
||||
|
||||
module.exports =
|
||||
ModelList = React.createClass
|
||||
displayName: 'ModelList'
|
||||
MultiselectList = React.createClass
|
||||
displayName: 'MultiselectList'
|
||||
|
||||
propTypes:
|
||||
className: React.PropTypes.string.isRequired
|
||||
|
|
@ -33,6 +33,11 @@ ModelList = React.createClass
|
|||
@setupForProps(newProps)
|
||||
@setState(@_getStateFromStores(newProps))
|
||||
|
||||
componentDidUpdate: (prevProps, prevState) ->
|
||||
if prevState.focusedId isnt @state.focusedId or
|
||||
prevState.keyboardCursorId isnt @state.keyboardCursorId
|
||||
@scrollToCurrentItem()
|
||||
|
||||
componentWillUnmount: ->
|
||||
@teardownForProps()
|
||||
|
||||
|
|
@ -55,10 +60,37 @@ ModelList = React.createClass
|
|||
context = {focusedId: @state.focusedId}
|
||||
props.commands[key](context)
|
||||
|
||||
checkmarkColumn = new ListTabular.Column
|
||||
name: ""
|
||||
resolver: (thread) ->
|
||||
toggle = (event) ->
|
||||
props.dataStore.view().selection.toggle(thread)
|
||||
event.stopPropagation()
|
||||
<div className="checkmark" onClick={toggle}><div className="inner"></div></div>
|
||||
|
||||
props.columns.splice(0, 0, checkmarkColumn)
|
||||
|
||||
@unsubscribers = []
|
||||
@unsubscribers.push props.dataStore.listen @_onChange
|
||||
@unsubscribers.push FocusedContentStore.listen @_onChange
|
||||
@command_unsubscriber = atom.commands.add('body', commands)
|
||||
|
||||
scrollToCurrentItem: ->
|
||||
item = @getDOMNode().querySelector(".focused")
|
||||
item ?= @getDOMNode().querySelector(".keyboard-cursor")
|
||||
|
||||
if item
|
||||
list = @refs.list.getDOMNode()
|
||||
itemRect = item.getBoundingClientRect()
|
||||
listRect = list.getBoundingClientRect()
|
||||
|
||||
distanceBelowBottom = (itemRect.top + itemRect.height) - (listRect.top + listRect.height)
|
||||
if distanceBelowBottom > 0
|
||||
list.scrollTop += distanceBelowBottom
|
||||
|
||||
distanceAboveTop = listRect.top - itemRect.top
|
||||
if distanceAboveTop > 0
|
||||
list.scrollTop -= distanceAboveTop
|
||||
|
||||
render: ->
|
||||
# IMPORTANT: DO NOT pass inline functions as props. _.isEqual thinks these
|
||||
|
|
@ -80,6 +112,7 @@ ModelList = React.createClass
|
|||
if @state.dataView
|
||||
<div className={className}>
|
||||
<ListTabular
|
||||
ref="list"
|
||||
columns={@props.columns}
|
||||
dataView={@state.dataView}
|
||||
itemClassProvider={@itemClassProvider}
|
||||
|
|
@ -138,8 +171,8 @@ ModelList = React.createClass
|
|||
@state.dataView.selection.walk({current, next})
|
||||
|
||||
_visible: ->
|
||||
if WorkspaceStore.selectedLayoutMode() is "list"
|
||||
WorkspaceStore.sheet().type is "Root"
|
||||
if WorkspaceStore.layoutMode() is "list"
|
||||
WorkspaceStore.topSheet().root
|
||||
else
|
||||
true
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ windowActions = [
|
|||
|
||||
# Actions for Selection State
|
||||
"selectNamespaceId",
|
||||
"selectView",
|
||||
"selectRootSheet",
|
||||
"selectLayoutMode",
|
||||
|
||||
"focusKeyboardInCollection",
|
||||
|
|
@ -77,9 +77,10 @@ windowActions = [
|
|||
"sendDraft",
|
||||
"destroyDraft",
|
||||
|
||||
"archiveAndPrevious",
|
||||
"archiveCurrentThread",
|
||||
"archive",
|
||||
"archiveSelection",
|
||||
"archiveAndNext",
|
||||
"archiveAndPrevious",
|
||||
|
||||
# Actions for Search
|
||||
"searchQueryChanged",
|
||||
|
|
|
|||
|
|
@ -77,6 +77,9 @@ class Attribute
|
|||
equal: (val) ->
|
||||
throw (new Error "this field cannot be queried against.") unless @queryable
|
||||
new Matcher(@, '=', val)
|
||||
not: (val) ->
|
||||
throw (new Error "this field cannot be queried against.") unless @queryable
|
||||
new Matcher(@, '!=', val)
|
||||
greaterThan: (val) ->
|
||||
throw (new Error "this field cannot be queried against.") unless @queryable
|
||||
new Matcher(@, '>', val)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
_ = require 'underscore-plus'
|
||||
InboxLongConnection = require './inbox-long-connection'
|
||||
|
||||
PAGE_SIZE = 500
|
||||
PAGE_SIZE = 250
|
||||
|
||||
module.exports =
|
||||
class InboxSyncWorker
|
||||
|
|
@ -26,6 +26,7 @@ class InboxSyncWorker
|
|||
@fetchCollection('threads')
|
||||
@fetchCollection('calendars')
|
||||
@fetchCollection('contacts')
|
||||
@fetchCollection('files')
|
||||
|
||||
cleanup: ->
|
||||
@_connection.end()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ class File extends Model
|
|||
'filename': Attributes.String
|
||||
modelKey: 'filename'
|
||||
jsonKey: 'filename'
|
||||
queryable: true
|
||||
|
||||
'size': Attributes.Number
|
||||
modelKey: 'size'
|
||||
|
|
|
|||
|
|
@ -232,6 +232,7 @@ class DatabaseView extends ModelView
|
|||
query.then (items) =>
|
||||
# If we've started reloading since we made our query, don't do any more work
|
||||
if page.loadingStart isnt start
|
||||
@log("Retrieval cancelled — out of date.")
|
||||
return
|
||||
|
||||
# Now, fetch the messages for each thread. We could do this with a
|
||||
|
|
@ -241,7 +242,9 @@ class DatabaseView extends ModelView
|
|||
@retrievePageMetadata(idx, items)
|
||||
|
||||
retrievePageMetadata: (idx, items) ->
|
||||
start = Date.now()
|
||||
page = @_pages[idx]
|
||||
page.loadingStart = start
|
||||
|
||||
# This method can only be used once the page is loaded. If no page is present,
|
||||
# go ahead and retrieve it in full.
|
||||
|
|
@ -259,6 +262,11 @@ class DatabaseView extends ModelView
|
|||
metadataPromises[item.id] ?= @_itemMetadataProvider(item)
|
||||
|
||||
Promise.props(metadataPromises).then (results) =>
|
||||
# If we've started reloading since we made our query, don't do any more work
|
||||
if page.loadingStart isnt start
|
||||
@log("Metadata retrieval cancelled — out of date.")
|
||||
return
|
||||
|
||||
for item in items
|
||||
item.metadata = results[item.id]
|
||||
page.metadata[item.id] = results[item.id]
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ FocusedContentStore = Reflux.createStore
|
|||
_resetInstanceVars: ->
|
||||
@_focused = {}
|
||||
@_keyboardCursor = {}
|
||||
@_keyboardCursorEnabled = WorkspaceStore.selectedLayoutMode() is 'list'
|
||||
@_keyboardCursorEnabled = WorkspaceStore.layoutMode() is 'list'
|
||||
|
||||
# Inbound Events
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ FocusedContentStore = Reflux.createStore
|
|||
@trigger({ impactsCollection: (c) -> c is collection })
|
||||
|
||||
_onWorkspaceChange: ->
|
||||
keyboardCursorEnabled = WorkspaceStore.selectedLayoutMode() is 'list'
|
||||
keyboardCursorEnabled = WorkspaceStore.layoutMode() is 'list'
|
||||
|
||||
if keyboardCursorEnabled isnt @_keyboardCursorEnabled
|
||||
@_keyboardCursorEnabled = keyboardCursorEnabled
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ class ModelViewSelection
|
|||
constructor: (@_view, @trigger) ->
|
||||
throw new Error("new ModelViewSelection(): You must provide a view.") unless @_view
|
||||
@_items = []
|
||||
|
||||
|
||||
count: ->
|
||||
@_items.length
|
||||
|
||||
ids: ->
|
||||
_.pluck(@_items, 'id')
|
||||
|
||||
|
|
@ -39,6 +42,12 @@ class ModelViewSelection
|
|||
@_items.push(item)
|
||||
@trigger(@)
|
||||
|
||||
remove: (item) ->
|
||||
without = _.reject @_items, (t) -> t.id is item.id
|
||||
if without.length < @_items.length
|
||||
@_items = without
|
||||
@trigger(@)
|
||||
|
||||
expandTo: (item) ->
|
||||
if @_items.length is 0
|
||||
@_items.push(item)
|
||||
|
|
@ -68,11 +77,11 @@ class ModelViewSelection
|
|||
|
||||
ids = @ids()
|
||||
noSelection = @_items.length is 0
|
||||
neitherSelected = ids.indexOf(current.id) is -1 and ids.indexOf(next.id) is -1
|
||||
neitherSelected = (not current or ids.indexOf(current.id) is -1) and (not next or ids.indexOf(next.id) is -1)
|
||||
|
||||
if noSelection or neitherSelected
|
||||
@_items.push(current)
|
||||
@_items.push(next)
|
||||
@_items.push(current) if current
|
||||
@_items.push(next) if next
|
||||
else
|
||||
selectionPostPopHeadId = null
|
||||
if @_items.length > 1
|
||||
|
|
|
|||
|
|
@ -3,34 +3,25 @@ NamespaceStore = require './namespace-store'
|
|||
Actions = require '../actions'
|
||||
|
||||
Location = {}
|
||||
for key in ['RootSidebar', 'RootCenter', 'MessageList', 'MessageListSidebar']
|
||||
Location[key] = {id: "#{key}", Toolbar: {id: "#{key}:Toolbar"}}
|
||||
|
||||
defineSheet = (type, columns) ->
|
||||
Toolbar:
|
||||
Left: {id: "Sheet:#{type}:Toolbar:Left"}
|
||||
Right: {id: "Sheet:#{type}:Toolbar:Right"}
|
||||
Header: {id: "Sheet:#{type}:Header"}
|
||||
Footer: {id: "Sheet:#{type}:Footer"}
|
||||
type: type
|
||||
columns: columns
|
||||
|
||||
Sheet =
|
||||
Global: defineSheet 'Global'
|
||||
|
||||
Root: defineSheet 'Root',
|
||||
list: [Location.RootSidebar, Location.RootCenter]
|
||||
split: [Location.RootSidebar, Location.RootCenter, Location.MessageList, Location.MessageListSidebar]
|
||||
|
||||
Thread: defineSheet 'Thread',
|
||||
list: [Location.MessageList, Location.MessageListSidebar]
|
||||
|
||||
Sheet = {}
|
||||
|
||||
WorkspaceStore = Reflux.createStore
|
||||
init: ->
|
||||
@defineSheet 'Global'
|
||||
|
||||
@defineSheet 'Threads', {root: true},
|
||||
list: ['RootSidebar', 'ThreadList']
|
||||
split: ['RootSidebar', 'ThreadList', 'MessageList', 'MessageListSidebar']
|
||||
|
||||
@defineSheet 'Drafts', {root: true, name: 'Local Drafts'},
|
||||
list: ['RootSidebar', 'DraftList']
|
||||
|
||||
@defineSheet 'Thread', {},
|
||||
list: ['MessageList', 'MessageListSidebar']
|
||||
|
||||
@_resetInstanceVars()
|
||||
|
||||
@listenTo Actions.selectView, @_onSelectView
|
||||
@listenTo Actions.selectRootSheet, @_onSelectRootSheet
|
||||
@listenTo Actions.selectLayoutMode, @_onSelectLayoutMode
|
||||
@listenTo Actions.focusInCollection, @_onFocusInCollection
|
||||
|
||||
|
|
@ -42,63 +33,100 @@ WorkspaceStore = Reflux.createStore
|
|||
'application:pop-sheet': => @popSheet()
|
||||
|
||||
_resetInstanceVars: ->
|
||||
@_sheetStack = [Sheet.Root]
|
||||
@_view = 'threads'
|
||||
@_layoutMode = 'list'
|
||||
@_preferredLayoutMode = 'list'
|
||||
@_sheetStack = []
|
||||
|
||||
@_onSelectRootSheet(Sheet.Threads)
|
||||
|
||||
# Inbound Events
|
||||
|
||||
_onSelectView: (view) ->
|
||||
@_view = view
|
||||
_onSelectRootSheet: (sheet) ->
|
||||
if not sheet
|
||||
throw new Error("Actions.selectRootSheet - #{sheet} is not a valid sheet.")
|
||||
if not sheet.root
|
||||
throw new Error("Actions.selectRootSheet - #{sheet} is not registered as a root sheet.")
|
||||
|
||||
@_sheetStack = []
|
||||
@_sheetStack.push(sheet)
|
||||
@trigger(@)
|
||||
|
||||
_onSelectLayoutMode: (mode) ->
|
||||
@_layoutMode = mode
|
||||
@_preferredLayoutMode = mode
|
||||
@trigger(@)
|
||||
|
||||
_onFocusInCollection: ({collection, item}) ->
|
||||
if collection is 'thread'
|
||||
if @selectedLayoutMode() is 'list'
|
||||
if item and @sheet().type isnt Sheet.Thread.type
|
||||
if @layoutMode() is 'list'
|
||||
if item and @topSheet() isnt Sheet.Thread
|
||||
@pushSheet(Sheet.Thread)
|
||||
if not item and @sheet().type is Sheet.Thread.type
|
||||
if not item and @topSheet() is Sheet.Thread
|
||||
@popSheet()
|
||||
|
||||
if collection is 'file'
|
||||
if @layoutMode() is 'list'
|
||||
if item and @topSheet() isnt Sheet.File
|
||||
@pushSheet(Sheet.File)
|
||||
if not item and @topSheet() is Sheet.File
|
||||
@popSheet()
|
||||
|
||||
# Accessing Data
|
||||
|
||||
selectedView: ->
|
||||
@_view
|
||||
layoutMode: ->
|
||||
if @_preferredLayoutMode in @rootSheet().supportedModes
|
||||
@_preferredLayoutMode
|
||||
else
|
||||
@rootSheet().supportedModes[0]
|
||||
|
||||
selectedLayoutMode: ->
|
||||
@_layoutMode
|
||||
|
||||
sheet: ->
|
||||
topSheet: ->
|
||||
@_sheetStack[@_sheetStack.length - 1]
|
||||
|
||||
rootSheet: ->
|
||||
@_sheetStack[0]
|
||||
|
||||
sheetStack: ->
|
||||
@_sheetStack
|
||||
|
||||
# Managing Sheets
|
||||
|
||||
pushSheet: (type) ->
|
||||
@_sheetStack.push(type)
|
||||
defineSheet: (id, options = {}, columns = {}) ->
|
||||
# Make sure all the locations have definitions so that packages
|
||||
# can register things into these locations and their toolbars.
|
||||
for layout, cols of columns
|
||||
for col, idx in cols
|
||||
Location[col] ?= {id: "#{col}", Toolbar: {id: "#{col}:Toolbar"}}
|
||||
cols[idx] = Location[col]
|
||||
|
||||
Sheet[id] =
|
||||
id: id
|
||||
columns: columns
|
||||
supportedModes: Object.keys(columns)
|
||||
|
||||
name: options.name
|
||||
root: options.root
|
||||
|
||||
Toolbar:
|
||||
Left: {id: "Sheet:#{id}:Toolbar:Left"}
|
||||
Right: {id: "Sheet:#{id}:Toolbar:Right"}
|
||||
Header: {id: "Sheet:#{id}:Header"}
|
||||
Footer: {id: "Sheet:#{id}:Footer"}
|
||||
|
||||
pushSheet: (sheet) ->
|
||||
@_sheetStack.push(sheet)
|
||||
@trigger()
|
||||
|
||||
popSheet: ->
|
||||
sheet = @sheet()
|
||||
sheet = @topSheet()
|
||||
|
||||
if @_sheetStack.length > 1
|
||||
@_sheetStack.pop()
|
||||
@trigger()
|
||||
|
||||
if sheet.type is Sheet.Thread.type
|
||||
if sheet is Sheet.Thread
|
||||
Actions.focusInCollection(collection: 'thread', item: null)
|
||||
|
||||
popToRootSheet: ->
|
||||
if @_sheetStack.length > 1
|
||||
@_sheetStack = [Sheet.Root]
|
||||
@trigger()
|
||||
|
||||
@_sheetStack.length = 1
|
||||
@trigger()
|
||||
|
||||
WorkspaceStore.Location = Location
|
||||
WorkspaceStore.Sheet = Sheet
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ Toolbar = React.createClass
|
|||
columnToolbarEls = @getDOMNode().querySelectorAll('[data-column]')
|
||||
|
||||
# Find the top sheet in the stack
|
||||
sheet = document.querySelector("[name='Sheet']:nth-child(#{@props.depth+1})")
|
||||
sheet = document.querySelectorAll("[name='Sheet']")[@props.depth]
|
||||
return unless sheet
|
||||
|
||||
# Position item containers so they have the position and width
|
||||
|
|
@ -137,7 +137,7 @@ Toolbar = React.createClass
|
|||
_getStateFromStores: (props) ->
|
||||
props ?= @props
|
||||
state =
|
||||
mode: WorkspaceStore.selectedLayoutMode()
|
||||
mode: WorkspaceStore.layoutMode()
|
||||
columns: []
|
||||
|
||||
# Add items registered to Regions in the current sheet
|
||||
|
|
@ -194,7 +194,7 @@ FlexboxForLocations = React.createClass
|
|||
|
||||
_getComponentRegistryState: ->
|
||||
items = []
|
||||
mode = WorkspaceStore.selectedLayoutMode()
|
||||
mode = WorkspaceStore.layoutMode()
|
||||
for location in @props.locations
|
||||
items = items.concat(ComponentRegistry.findAllByLocationAndMode(location, mode))
|
||||
{items}
|
||||
|
|
@ -216,49 +216,54 @@ SheetContainer = React.createClass
|
|||
@unsubscribe() if @unsubscribe
|
||||
|
||||
render: ->
|
||||
topSheet = @state.stack[@state.stack.length - 1]
|
||||
totalSheets = @state.stack.length
|
||||
topSheet = @state.stack[totalSheets - 1]
|
||||
|
||||
toolbarElements = @_toolbarElements()
|
||||
sheetElements = @_sheetElements()
|
||||
|
||||
<Flexbox direction="column">
|
||||
<TimeoutTransitionGroup name="Toolbar"
|
||||
style={order:0}
|
||||
leaveTimeout={125}
|
||||
enterTimeout={125}
|
||||
className="sheet-toolbar"
|
||||
transitionName="sheet-toolbar">
|
||||
{@_toolbarElements()}
|
||||
</TimeoutTransitionGroup>
|
||||
<div name="Toolbar" style={order:0} className="sheet-toolbar">
|
||||
{toolbarElements[0]}
|
||||
<TimeoutTransitionGroup leaveTimeout={125}
|
||||
enterTimeout={125}
|
||||
transitionName="sheet-toolbar">
|
||||
{toolbarElements[1..-1]}
|
||||
</TimeoutTransitionGroup>
|
||||
</div>
|
||||
|
||||
<div name="Header" style={order:1}>
|
||||
<FlexboxForLocations locations={[topSheet.Header, WorkspaceStore.Sheet.Global.Header]}
|
||||
type={topSheet.type}/>
|
||||
id={topSheet.id}/>
|
||||
</div>
|
||||
|
||||
<TimeoutTransitionGroup name="Center"
|
||||
style={order:2, flex: 1, position:'relative'}
|
||||
leaveTimeout={125}
|
||||
enterTimeout={125}
|
||||
transitionName="sheet-stack">
|
||||
{@_sheetElements()}
|
||||
</TimeoutTransitionGroup>
|
||||
<div name="Center" style={order:2, flex: 1, position:'relative'}>
|
||||
{sheetElements[0]}
|
||||
<TimeoutTransitionGroup leaveTimeout={125}
|
||||
enterTimeout={125}
|
||||
transitionName="sheet-stack">
|
||||
{sheetElements[1..-1]}
|
||||
</TimeoutTransitionGroup>
|
||||
</div>
|
||||
|
||||
<div name="Footer" style={order:3}>
|
||||
<FlexboxForLocations locations={[topSheet.Footer, WorkspaceStore.Sheet.Global.Footer]}
|
||||
type={topSheet.type}/>
|
||||
id={topSheet.id}/>
|
||||
</div>
|
||||
</Flexbox>
|
||||
|
||||
_toolbarElements: ->
|
||||
@state.stack.map (data, index) ->
|
||||
<Toolbar data={data}
|
||||
@state.stack.map (sheet, index) ->
|
||||
<Toolbar data={sheet}
|
||||
ref={"toolbar-#{index}"}
|
||||
depth={index}
|
||||
key={index} />
|
||||
key={"#{index}:#{sheet.id}:toolbar"}
|
||||
depth={index} />
|
||||
|
||||
_sheetElements: ->
|
||||
@state.stack.map (data, index) =>
|
||||
<Sheet data={data}
|
||||
@state.stack.map (sheet, index) =>
|
||||
<Sheet data={sheet}
|
||||
depth={index}
|
||||
key={index}
|
||||
key={"#{index}:#{sheet.id}"}
|
||||
onColumnSizeChanged={@_onColumnSizeChanged} />
|
||||
|
||||
_onColumnSizeChanged: (sheet) ->
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ Sheet = React.createClass
|
|||
<div name={"Sheet"}
|
||||
style={style}
|
||||
className={"sheet mode-#{@state.mode}"}
|
||||
data-type={@props.data.type}>
|
||||
data-id={@props.data.id}>
|
||||
<Flexbox direction="row">
|
||||
{@_columnFlexboxElements()}
|
||||
</Flexbox>
|
||||
|
|
@ -61,8 +61,8 @@ Sheet = React.createClass
|
|||
@state.columns.map ({entries, maxWidth, minWidth, handle, id}, idx) =>
|
||||
elements = entries.map ({name, view}) -> <view key={name} />
|
||||
if minWidth != maxWidth and maxWidth < FLEX
|
||||
<ResizableRegion key={"#{@props.type}:#{idx}"}
|
||||
name={"#{@props.type}:#{idx}"}
|
||||
<ResizableRegion key={"#{@props.data.id}:#{idx}"}
|
||||
name={"#{@props.data.id}:#{idx}"}
|
||||
className={"column-#{id}"}
|
||||
data-column={idx}
|
||||
onResize={ => @props.onColumnSizeChanged(@) }
|
||||
|
|
@ -75,8 +75,8 @@ Sheet = React.createClass
|
|||
</ResizableRegion>
|
||||
else
|
||||
<Flexbox direction="column"
|
||||
key={"#{@props.type}:#{idx}"}
|
||||
name={"#{@props.type}:#{idx}"}
|
||||
key={"#{@props.data.id}:#{idx}"}
|
||||
name={"#{@props.data.id}:#{idx}"}
|
||||
className={"column-#{id}"}
|
||||
data-column={idx}
|
||||
style={flex: 1}>
|
||||
|
|
@ -85,7 +85,7 @@ Sheet = React.createClass
|
|||
|
||||
_getStateFromStores: ->
|
||||
state =
|
||||
mode: WorkspaceStore.selectedLayoutMode()
|
||||
mode: WorkspaceStore.layoutMode()
|
||||
columns: []
|
||||
|
||||
widest = -1
|
||||
|
|
|
|||
|
|
@ -1,5 +1,72 @@
|
|||
@import "ui-variables";
|
||||
|
||||
.sheet-toolbar .selection-bar {
|
||||
// This item sits in the toolbar and takes up all the remaining
|
||||
// space from the toolbar-spacer divs, but flex-shrink means that
|
||||
// it shrinks before any other element when not enough space is available.
|
||||
|
||||
// This is important because the spacers will prevent items from being clickable,
|
||||
// (webkit-app-region:drag) even if we're covering them up. We need to make them
|
||||
// 0px wide!
|
||||
|
||||
width: 100%;
|
||||
flex-shrink:100;
|
||||
height:49px;
|
||||
z-index: 10000;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
.absolute {
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
right:-1px;
|
||||
top: 0;
|
||||
height:49px;
|
||||
border-left:1px solid @border-color-divider;
|
||||
border-right:1px solid @border-color-divider;
|
||||
background-color: @gray-lighter;
|
||||
opacity:0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
pointer-events: none;
|
||||
|
||||
.inner {
|
||||
top: -100%;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
display:flex;
|
||||
transition: top 0.2s ease-in-out;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.centered {
|
||||
flex: 1;
|
||||
cursor:default;
|
||||
text-align: center;
|
||||
color:@text-color-subtle;
|
||||
padding-top: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// .absolute is positioned full-width over all the other items in the toolbar.
|
||||
// when .enabled, it transitions to opacity:1, hiding other items beneath it.
|
||||
|
||||
// .inner contains the actual buttons and animates *down* while the opacity animation
|
||||
// is running. This means the items beneath fade out as the new ones slide in.
|
||||
|
||||
&.enabled {
|
||||
.absolute {
|
||||
opacity: 1;
|
||||
pointer-events: initial;
|
||||
.inner {
|
||||
top:0;
|
||||
.centered {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.list-container {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 262 KiB |
Loading…
Add table
Reference in a new issue