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:
Ben Gotow 2015-04-10 14:33:05 -07:00
parent f7fe9a93c8
commit d9ee12cf81
54 changed files with 911 additions and 437 deletions

View file

@ -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'

View file

@ -4,5 +4,7 @@ React = require 'react'
module.exports =
AccountSidebarDividerItem = React.createClass
displayName: 'AccountSidebarDividerItem'
render: ->
<div className="item item-divider">{@props.label}</div>

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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
View file

@ -0,0 +1 @@
node_modules

View 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()

View 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())

View 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})

View 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) ->

View 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" />

View 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'

View 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": {
}
}

View 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;
}
}

View file

@ -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.

View file

@ -1,10 +0,0 @@
# Tree View package [![Build Status](https://travis-ci.org/atom/tree-view.svg?branch=master)](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.
![](https://f.cloud.github.com/assets/671378/2241932/6d9cface-9ceb-11e3-9026-31d5011d889d.png)

View file

@ -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))

View file

@ -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()

View file

@ -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()

View file

@ -6,4 +6,4 @@ module.exports =
ComponentRegistry.register
name: 'ModeToggle'
view: ModeToggle
location: WorkspaceStore.Sheet.Root.Toolbar.Right
location: WorkspaceStore.Sheet.Global.Toolbar.Right

View file

@ -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'

View file

@ -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'

View file

@ -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')

View file

@ -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'

View file

@ -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}

View 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" />

View file

@ -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'

View file

@ -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}

View file

@ -55,9 +55,6 @@ ThreadListParticipants = React.createClass
{spans}
</div>
shouldComponentUpdate: (newProps, newState) ->
!_.isEqual(newProps.thread, @props.thread)
getParticipants: ->
if @props.thread.metadata
list = []

View file

@ -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))

View file

@ -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

View file

@ -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" />

View file

@ -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", ->

View file

@ -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;

View file

@ -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 = ->

View file

@ -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}
})

View file

@ -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]])

View 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))

View 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()

View file

@ -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

View file

@ -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",

View file

@ -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)

View file

@ -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()

View file

@ -10,6 +10,7 @@ class File extends Model
'filename': Attributes.String
modelKey: 'filename'
jsonKey: 'filename'
queryable: true
'size': Attributes.Number
modelKey: 'size'

View file

@ -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]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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) ->

View file

@ -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

View file

@ -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