diff --git a/internal_packages/category-picker/lib/category-picker.cjsx b/internal_packages/category-picker/lib/category-picker.cjsx
new file mode 100644
index 000000000..03086d07e
--- /dev/null
+++ b/internal_packages/category-picker/lib/category-picker.cjsx
@@ -0,0 +1,205 @@
+_ = require 'underscore'
+React = require 'react'
+
+{Actions,
+ TaskQueue,
+ CategoryStore,
+ NamespaceStore,
+ ChangeLabelsTask,
+ ChangeFolderTask,
+ FocusedContentStore} = require 'nylas-exports'
+
+{Menu,
+ Popover,
+ RetinaImg} = require 'nylas-component-kit'
+
+# This changes the category on one or more threads.
+#
+# See internal_packages/thread-list/lib/thread-buttons.cjsx
+# See internal_packages/message-list/lib/thread-tags-button.cjsx
+# See internal_packages/message-list/lib/thread-archive-button.cjsx
+# See internal_packages/message-list/lib/message-toolbar-items.cjsx
+
+class CategoryPicker extends React.Component
+ @displayName: "CategoryPicker"
+ @containerRequired: false
+
+ @propTypes:
+ selection: React.PropTypes.object.isRequired
+
+ constructor: (@props) ->
+ @state = _.extend @_recalculateState(@props), searchValue: ""
+
+ componentDidMount: =>
+ @unsubscribers = []
+ @unsubscribers.push CategoryStore.listen @_onStoreChanged
+ @unsubscribers.push NamespaceStore.listen @_onStoreChanged
+ @unsubscribers.push FocusedContentStore.listen @_onStoreChanged
+
+ @_commandUnsubscriber = atom.commands.add 'body',
+ "application:change-category": @_onChangeCategory
+
+ # If the threads we're picking categories for change, (like when they
+ # get their categories updated), we expect our parents to pass us new
+ # props. We don't listen to the DatabaseStore ourselves.
+
+ componentWillReceiveProps: (nextProps) ->
+ @setState @_recalculateState(nextProps)
+
+ componentWillUnmount: =>
+ return unless @unsubscribers
+ unsubscribe() for unsubscribe in @unsubscribers
+ @_commandUnsubscriber.dispose()
+
+ render: =>
+ button =
+
+ headerComponents = [
+
+ ]
+
+
+
+
+ _tooltipLabel: ->
+ return "" unless @_namespace
+ if @_namespace.usesLabels()
+ return "Apply Labels"
+ else if @_namespace.usesFolders()
+ return "Move to Folder"
+
+ _onChangeCategory: =>
+ return unless @_threads().length > 0
+ @refs.popover.open()
+
+ _itemContent: (categoryDatum) =>
+ {categoryDatum.display_name}
+
+ _onSelectCategory: (categoryDatum) =>
+ return unless @_threads().length > 0
+ return unless @_namespace
+ @refs.menu.setSelectedItem(null)
+
+ if @_namespace.usesLabels()
+ if categoryDatum.usage > 0
+ task = new ChangeLabelsTask
+ labelsToRemove: [categoryDatum.id]
+ threadIds: @_threadIds()
+ else
+ task = new ChangeLabelsTask
+ labelsToAdd: [categoryDatum.id]
+ threadIds: @_threadIds()
+ else if @_namespace.usesFolders()
+ task = new ChangeFolderTask
+ folderOrId: categoryDatum.id
+ threadIds: @_threadIds()
+ if @props.thread
+ Actions.moveThread(@props.thread, task)
+ else if @props.selection
+ Actions.moveThreads(@_threads(), task)
+
+ else throw new Error("Invalid organizationUnit")
+
+ TaskQueue.enqueue(task)
+
+ _onStoreChanged: =>
+ @setState @_recalculateState(@props)
+
+ _onSearchValueChange: (event) =>
+ @setState @_recalculateState(@props, searchValue: event.target.value)
+
+ _onPopoverOpened: =>
+ @setState @_recalculateState(@props, searchValue: "")
+
+ _recalculateState: (props=@props, {searchValue}={}) =>
+ searchValue = searchValue ? @state?.searchValue ? ""
+ if @_threads(props).length is 0
+ return {categoryData: [], searchValue}
+ @_namespace = NamespaceStore.current()
+ return unless @_namespace
+
+ categories = CategoryStore.getCategories()
+ usageCount = @_categoryUsageCount(props, categories)
+ categoryData = _.chain(categories)
+ .filter(@_isUserFacing)
+ .filter(_.partial(@_isInSearch, searchValue))
+ .map(_.partial(@_extendCategoryWithUsage, usageCount))
+ .value()
+
+ return {categoryData, searchValue}
+
+ _categoryUsageCount: (props, categories) =>
+ categoryUsageCount = {}
+ _.flatten(@_threads(props).map(@_threadCategories)).forEach (category) ->
+ categoryUsageCount[category.id] ?= 0
+ categoryUsageCount[category.id] += 1
+ return categoryUsageCount
+
+ _isInSearch: (searchValue, category) ->
+ searchTerm = searchValue.trim().toLowerCase()
+ return true if searchTerm.length is 0
+
+ catName = category.displayName.trim().toLowerCase()
+
+ wordIndices = []
+ # Where a non-word character is followed by a word character
+ # We don't use \b (word boundary) because we want to split on slashes
+ # and dashes and other non word things
+ re = /\W[\w\d]/g
+ while match = re.exec(catName) then wordIndices.push(match.index)
+ # To shift to the start of each word.
+ wordIndices = wordIndices.map (i) -> i += 1
+
+ # Always include the start
+ wordIndices.push(0)
+
+ return catName.indexOf(searchTerm) in wordIndices
+
+ _isUserFacing: (category) =>
+ hiddenCategories = ["inbox", "all", "archive", "drafts", "sent"]
+ return category.name not in hiddenCategories
+
+ _extendCategoryWithUsage: (usageCount, category, i, categories) ->
+ cat = category.toJSON()
+ usage = usageCount[cat.id] ? 0
+ cat.usage = usage
+ cat.totalUsage = categories.length
+ return cat
+
+ _threadCategories: (thread) =>
+ if @_namespace.usesLabels()
+ return (thread.labels ? [])
+ else if @_namespace.usesFolders()
+ return (thread.folders ? [])
+ else throw new Error("Invalid organizationUnit")
+
+ _threads: (props=@props) =>
+ if props.selection then return (props.selection.items() ? [])
+ else if props.thread then return [props.thread]
+ else return []
+
+ _threadIds: =>
+ @_threads().map (thread) -> thread.id
+
+module.exports = CategoryPicker
diff --git a/internal_packages/category-picker/lib/main.cjsx b/internal_packages/category-picker/lib/main.cjsx
new file mode 100644
index 000000000..ea4a37b9d
--- /dev/null
+++ b/internal_packages/category-picker/lib/main.cjsx
@@ -0,0 +1,12 @@
+CategoryPicker = require "./category-picker"
+
+{ComponentRegistry,
+ WorkspaceStore} = require 'nylas-exports'
+
+module.exports =
+ activate: (@state={}) ->
+ ComponentRegistry.register CategoryPicker,
+ roles: ['thread:BulkAction', 'message:Toolbar']
+
+ deactivate: ->
+ ComponentRegistry.unregister(CategoryPicker)
diff --git a/internal_packages/category-picker/package.json b/internal_packages/category-picker/package.json
new file mode 100755
index 000000000..257926a62
--- /dev/null
+++ b/internal_packages/category-picker/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "category-picker",
+ "version": "0.1.0",
+ "main": "./lib/main",
+ "description": "Label & Folder Picker",
+ "license": "Proprietary",
+ "private": true,
+ "engines": {
+ "atom": "*"
+ }
+}
diff --git a/internal_packages/category-picker/stylesheets/category-picker.less b/internal_packages/category-picker/stylesheets/category-picker.less
new file mode 100644
index 000000000..1d0653412
--- /dev/null
+++ b/internal_packages/category-picker/stylesheets/category-picker.less
@@ -0,0 +1,4 @@
+@import "ui-variables";
+
+.category-picker {
+}
diff --git a/internal_packages/composer/spec/composer-view-spec.cjsx b/internal_packages/composer/spec/composer-view-spec.cjsx
index 4a451b423..32f12ba93 100644
--- a/internal_packages/composer/spec/composer-view-spec.cjsx
+++ b/internal_packages/composer/spec/composer-view-spec.cjsx
@@ -110,11 +110,20 @@ useDraft = (draftAttributes={}) ->
@draft = new Message _.extend({draft: true, body: ""}, draftAttributes)
draft = @draft
proxy = draftStoreProxyStub(DRAFT_LOCAL_ID, @draft)
- spyOn(DraftStore, "sessionForLocalId").andCallFake -> new Promise (resolve, reject) -> resolve(proxy)
+
+
spyOn(ComposerView.prototype, "componentWillMount").andCallFake ->
@_prepareForDraft(DRAFT_LOCAL_ID)
@_setupSession(proxy)
+ # Normally when sessionForLocalId resolves, it will call `_setupSession`
+ # and pass the new session proxy. However, in our faked
+ # `componentWillMount`, we manually call sessionForLocalId to make this
+ # part of the test synchronous. We need to make the `then` block of the
+ # sessionForLocalId do nothing so `_setupSession` is not called twice!
+ spyOn(DraftStore, "sessionForLocalId").andCallFake ->
+ then: ->
+
useFullDraft = ->
useDraft.call @,
from: [u1]
diff --git a/internal_packages/message-list/lib/main.cjsx b/internal_packages/message-list/lib/main.cjsx
index 34dcff23f..53f302c05 100644
--- a/internal_packages/message-list/lib/main.cjsx
+++ b/internal_packages/message-list/lib/main.cjsx
@@ -4,6 +4,9 @@ MessageToolbarItems = require "./message-toolbar-items"
WorkspaceStore} = require 'nylas-exports'
SidebarThreadParticipants = require "./sidebar-thread-participants"
+ThreadStarButton = require './thread-star-button'
+ThreadArchiveButton = require './thread-archive-button'
+
module.exports =
item: null # The DOM item the main React component renders into
@@ -18,8 +21,17 @@ module.exports =
ComponentRegistry.register SidebarThreadParticipants,
location: WorkspaceStore.Location.MessageListSidebar
+ ComponentRegistry.register ThreadStarButton,
+ role: 'message:Toolbar'
+
+ ComponentRegistry.register ThreadArchiveButton,
+ role: 'message:Toolbar'
+
deactivate: ->
ComponentRegistry.unregister MessageList
+ ComponentRegistry.unregister ThreadStarButton
+ ComponentRegistry.unregister ThreadArchiveButton
ComponentRegistry.unregister MessageToolbarItems
+ ComponentRegistry.unregister SidebarThreadParticipants
serialize: -> @state
diff --git a/internal_packages/message-list/lib/message-toolbar-items.cjsx b/internal_packages/message-list/lib/message-toolbar-items.cjsx
index b61fcfbf4..40248c300 100644
--- a/internal_packages/message-list/lib/message-toolbar-items.cjsx
+++ b/internal_packages/message-list/lib/message-toolbar-items.cjsx
@@ -1,11 +1,15 @@
_ = require 'underscore'
React = require 'react'
classNames = require 'classnames'
-{Actions, Utils, FocusedContentStore, WorkspaceStore} = require 'nylas-exports'
-{RetinaImg, Popover, Menu} = require 'nylas-component-kit'
-ThreadArchiveButton = require './thread-archive-button'
-ThreadStarButton = require './thread-star-button'
+{Actions,
+ WorkspaceStore,
+ FocusedContentStore} = require 'nylas-exports'
+
+{Menu,
+ Popover,
+ RetinaImg,
+ InjectedComponentSet} = require 'nylas-component-kit'
class MessageToolbarItems extends React.Component
@displayName: "MessageToolbarItems"
@@ -20,8 +24,8 @@ class MessageToolbarItems extends React.Component
"hidden": !@state.thread
-
-
+
componentDidMount: =>
diff --git a/internal_packages/message-list/lib/thread-archive-button.cjsx b/internal_packages/message-list/lib/thread-archive-button.cjsx
index 9dd31ac71..387797ed2 100644
--- a/internal_packages/message-list/lib/thread-archive-button.cjsx
+++ b/internal_packages/message-list/lib/thread-archive-button.cjsx
@@ -3,8 +3,8 @@ React = require 'react'
{Actions, DOMUtils} = require 'nylas-exports'
{RetinaImg} = require 'nylas-component-kit'
-class ArchiveButton extends React.Component
- @displayName: "ArchiveButton"
+class ThreadArchiveButton extends React.Component
+ @displayName: "ThreadArchiveButton"
render: =>