mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-07 21:24:24 +08:00
feat(draft-list) Refactor thread-list, create draft-list
Summary: Adds the draft list using a refactored list-tabular class. Also fixes several draft bugs that appeared after allowing editing. Test Plan: Run tests (need to test new ListTabular component ASAP) Reviewers: evan Reviewed By: evan Differential Revision: https://review.inboxapp.com/D1272
This commit is contained in:
parent
b479e099c1
commit
343e592569
38 changed files with 719 additions and 617 deletions
|
@ -44,6 +44,7 @@ module.exports =
|
|||
NamespaceStore: require '../src/flux/stores/namespace-store'
|
||||
FileUploadStore: require '../src/flux/stores/file-upload-store'
|
||||
FileDownloadStore: require '../src/flux/stores/file-download-store'
|
||||
WorkspaceStore: require '../src/flux/stores/workspace-store'
|
||||
|
||||
## TODO move to inside of individual Salesforce package. See https://trello.com/c/tLAGLyeb/246-move-salesforce-models-into-individual-package-db-models-for-packages-various-refactors
|
||||
SalesforceAssociation: require '../src/flux/models/salesforce-association'
|
||||
|
|
|
@ -8,3 +8,4 @@ module.exports =
|
|||
ResizableRegion: require '../src/components/resizable-region'
|
||||
Flexbox: require '../src/components/flexbox'
|
||||
RetinaImg: require '../src/components/retina-img'
|
||||
ListTabular: require '../src/components/list-tabular'
|
||||
|
|
|
@ -24,7 +24,7 @@ AccountSidebarStore = Reflux.createStore
|
|||
@_selectedId = null
|
||||
|
||||
_registerListeners: ->
|
||||
@listenTo Actions.selectTagId, @_onSelectTagID
|
||||
@listenTo Actions.selectTagId, @_onSelectTagId
|
||||
@listenTo DatabaseStore, @_onDataChanged
|
||||
@listenTo NamespaceStore, @_onNamespaceChanged
|
||||
|
||||
|
@ -93,8 +93,8 @@ AccountSidebarStore = Reflux.createStore
|
|||
if change.objectClass == Thread.name
|
||||
@_populateUnreadCountsDebounced()
|
||||
|
||||
_onSelectTagID: (tagID) ->
|
||||
@_selectedId = tagID
|
||||
_onSelectTagId: (tagId) ->
|
||||
@_selectedId = tagId
|
||||
@trigger(@)
|
||||
|
||||
module.exports = AccountSidebarStore
|
||||
|
|
|
@ -5,7 +5,10 @@ React = require 'react'
|
|||
module.exports =
|
||||
AccountSidebarTagItem = React.createClass
|
||||
render: ->
|
||||
unread = if @props.tag.unreadCount > 0 then <div className="unread item-count-box">{@props.tag.unreadCount}</div> else []
|
||||
unread = []
|
||||
if @props.tag.unreadCount > 0
|
||||
unread = <div className="unread item-count-box">{@props.tag.unreadCount}</div>
|
||||
|
||||
classSet = React.addons.classSet
|
||||
'item': true
|
||||
'item-tag': true
|
||||
|
@ -19,4 +22,9 @@ AccountSidebarTagItem = React.createClass
|
|||
|
||||
_onClick: (event) ->
|
||||
event.preventDefault()
|
||||
|
||||
if @props.tag.id is 'drafts'
|
||||
Actions.selectView('drafts')
|
||||
else
|
||||
Actions.selectView('threads')
|
||||
Actions.selectTagId(@props.tag.id)
|
||||
|
|
|
@ -9,4 +9,4 @@ module.exports =
|
|||
ComponentRegistry.register
|
||||
view: AccountSidebar
|
||||
name: 'AccountSidebar'
|
||||
role: 'ThreadList:Left'
|
||||
role: 'Root:Left'
|
||||
|
|
|
@ -116,7 +116,9 @@ ComposerView = React.createClass
|
|||
|
||||
<div className="composer-action-bar-wrap">
|
||||
<div className="composer-action-bar-content">
|
||||
{@_trashBtn()}
|
||||
<button className="btn btn-toolbar pull-right btn-trash"
|
||||
onClick={@_destroyDraft}><RetinaImg name="toolbar-trash.png" /></button>
|
||||
|
||||
<button className="btn btn-toolbar pull-right btn-attach"
|
||||
onClick={@_attachFile}><RetinaImg name="toolbar-attach.png"/></button>
|
||||
|
||||
|
@ -206,11 +208,6 @@ ComposerView = React.createClass
|
|||
|
||||
focus: (field) -> @refs[field]?.focus?() if @isMounted()
|
||||
|
||||
_trashBtn: ->
|
||||
if @props.mode isnt "fullwindow"
|
||||
<button className="btn btn-toolbar pull-right btn-trash"
|
||||
onClick={@_destroyDraft}><RetinaImg name="toolbar-trash.png" /></button>
|
||||
|
||||
_footerComponents: ->
|
||||
(@state.FooterComponents ? []).map (Component) =>
|
||||
idGen += 1
|
||||
|
|
|
@ -76,4 +76,4 @@ module.exports =
|
|||
ComponentRegistry.register
|
||||
view: NewComposeButton
|
||||
name: 'NewComposeButton'
|
||||
role: 'ThreadList:Left:Toolbar'
|
||||
role: 'Root:Left:Toolbar'
|
||||
|
|
|
@ -15,7 +15,7 @@ module.exports =
|
|||
|
||||
ComponentRegistry.register
|
||||
name: 'MessageList'
|
||||
role: 'ThreadList:Right'
|
||||
role: 'Root:Right'
|
||||
view: MessageList
|
||||
|
||||
deactivate: ->
|
||||
|
|
|
@ -10,12 +10,12 @@ module.exports =
|
|||
ComponentRegistry.register
|
||||
view: Notifications
|
||||
name: 'Notifications'
|
||||
role: 'ThreadList:Center'
|
||||
role: 'Root:Center'
|
||||
|
||||
ComponentRegistry.register
|
||||
view: NotificationsStickyBar
|
||||
name: 'NotificationsStickyBar'
|
||||
role: 'ThreadList:Top'
|
||||
role: 'Root:Top'
|
||||
|
||||
deactivate: ->
|
||||
ComponentRegistry.unregister('NotificationsStickyBar')
|
||||
|
|
|
@ -13,7 +13,7 @@ module.exports =
|
|||
ComponentRegistry.register
|
||||
view: SearchBar
|
||||
name: 'SearchBar'
|
||||
role: 'ThreadList:Right:Toolbar'
|
||||
role: 'Root:Right:Toolbar'
|
||||
|
||||
deactivate: ->
|
||||
ComponentRegistry.unregister 'SearchBar'
|
||||
|
|
99
internal_packages/thread-list/lib/draft-list.cjsx
Normal file
99
internal_packages/thread-list/lib/draft-list.cjsx
Normal file
|
@ -0,0 +1,99 @@
|
|||
_ = require 'underscore-plus'
|
||||
moment = require "moment"
|
||||
React = require 'react'
|
||||
{ListTabular} = require 'ui-components'
|
||||
{timestamp, subject} = require './formatting-utils'
|
||||
{Actions,
|
||||
DraftStore,
|
||||
ComponentRegistry,
|
||||
DatabaseStore} = require 'inbox-exports'
|
||||
|
||||
module.exports =
|
||||
DraftList = React.createClass
|
||||
displayName: 'DraftList'
|
||||
|
||||
mixins: [ComponentRegistry.Mixin]
|
||||
components: ['Participants']
|
||||
|
||||
getInitialState: ->
|
||||
items: DraftStore.items()
|
||||
columns: @_computeColumns()
|
||||
selectedId: null
|
||||
|
||||
componentDidMount: ->
|
||||
@draft_store_unsubscribe = DraftStore.listen @_onChange
|
||||
@body_unsubscriber = atom.commands.add 'body',
|
||||
'application:previous-item': => @_onShiftSelectedIndex(-1)
|
||||
'application:next-item': => @_onShiftSelectedIndex(1)
|
||||
'application:remove-item': @_onDeleteSelected
|
||||
|
||||
componentWillUnmount: ->
|
||||
@draft_store_unsubscribe()
|
||||
@body_unsubscriber.dispose()
|
||||
|
||||
render: ->
|
||||
<div className="thread-list">
|
||||
<ListTabular
|
||||
columns={@state.columns}
|
||||
items={@state.items}
|
||||
selectedId={@state.selectedId}
|
||||
onDoubleClick={@_onDoubleClick}
|
||||
onSelect={@_onSelect} />
|
||||
</div>
|
||||
|
||||
_onSelect: (item) ->
|
||||
@setState
|
||||
selectedId: item.id
|
||||
|
||||
_onDoubleClick: (item) ->
|
||||
DatabaseStore.localIdForModel(item).then (localId) ->
|
||||
Actions.composePopoutDraft(localId)
|
||||
|
||||
_computeColumns: ->
|
||||
c1 = new ListTabular.Column
|
||||
name: "Name"
|
||||
flex: 2
|
||||
resolver: (draft) =>
|
||||
Participants = @state.Participants
|
||||
<div className="participants">
|
||||
<Participants participants={[].concat(draft.to,draft.cc,draft.bcc)}
|
||||
context={'list'} clickable={false} />
|
||||
</div>
|
||||
|
||||
c2 = new ListTabular.Column
|
||||
name: "Subject"
|
||||
flex: 3
|
||||
resolver: (draft) ->
|
||||
<span className="subject">{subject(draft.subject)}</span>
|
||||
|
||||
c3 = new ListTabular.Column
|
||||
name: "Snippet"
|
||||
flex: 4
|
||||
resolver: (draft) ->
|
||||
<span className="snippet">{draft.body}</span>
|
||||
|
||||
c4 = new ListTabular.Column
|
||||
name: "Date"
|
||||
flex: 1
|
||||
resolver: (draft) ->
|
||||
<span className="timestamp">{timestamp(draft.date)}</span>
|
||||
|
||||
[c1, c2, c3, c4]
|
||||
|
||||
_onShiftSelectedIndex: (delta) ->
|
||||
item = _.find @state.items, (draft) => draft.id is @state.selectedId
|
||||
index = if item then @state.items.indexOf(item) else -1
|
||||
index = Math.max(0, Math.min(index + delta, @state.items.length-1))
|
||||
@setState
|
||||
selectedId: @state.items[index].id
|
||||
|
||||
_onDeleteSelected: ->
|
||||
item = _.find @state.items, (draft) => draft.id is @state.selectedId
|
||||
|
||||
DatabaseStore.localIdForModel(item).then (localId) ->
|
||||
Actions.destroyDraft(localId)
|
||||
@_onShiftSelectedIndex(-1)
|
||||
|
||||
_onChange: ->
|
||||
@setState
|
||||
items: DraftStore.items()
|
20
internal_packages/thread-list/lib/formatting-utils.cjsx
Normal file
20
internal_packages/thread-list/lib/formatting-utils.cjsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
moment = require "moment"
|
||||
React = require 'react'
|
||||
|
||||
module.exports =
|
||||
timestamp: (time) ->
|
||||
diff = moment().diff(time, 'days', true)
|
||||
if diff <= 1
|
||||
format = "h:mm a"
|
||||
else if diff > 1 and diff <= 365
|
||||
format = "MMM D"
|
||||
else
|
||||
format = "MMM D YYYY"
|
||||
moment(time).format(format)
|
||||
|
||||
subject: (subj) ->
|
||||
if (subj ? "").trim().length is 0
|
||||
return <span className="no-subject">(No Subject)</span>
|
||||
else
|
||||
return subj
|
||||
|
|
@ -1,11 +1,36 @@
|
|||
_ = require 'underscore-plus'
|
||||
React = require "react"
|
||||
{ComponentRegistry} = require "inbox-exports"
|
||||
ThreadListTabular = require "./thread-list-tabular"
|
||||
{ComponentRegistry, WorkspaceStore} = require "inbox-exports"
|
||||
ThreadList = require "./thread-list"
|
||||
DraftList = require "./draft-list"
|
||||
|
||||
RootCenterComponent = React.createClass
|
||||
displayName: 'RootCenterComponent'
|
||||
|
||||
getInitialState: ->
|
||||
view: WorkspaceStore.selectedView()
|
||||
|
||||
componentDidMount: ->
|
||||
@unsubscribe = WorkspaceStore.listen @_onStoreChange
|
||||
|
||||
componentWillUnmount: ->
|
||||
@unsubscribe() if @unsubscribe
|
||||
|
||||
render: ->
|
||||
views =
|
||||
'threads': ThreadList
|
||||
'drafts': DraftList
|
||||
view = views[@state.view]
|
||||
<view />
|
||||
|
||||
_onStoreChange: ->
|
||||
@setState
|
||||
view: WorkspaceStore.selectedView()
|
||||
|
||||
|
||||
module.exports =
|
||||
activate: (@state={}) ->
|
||||
ComponentRegistry.register
|
||||
view: ThreadListTabular
|
||||
name: 'ThreadListTabular'
|
||||
role: 'ThreadList:Center'
|
||||
view: RootCenterComponent
|
||||
name: 'RootCenterComponent'
|
||||
role: 'Root:Center'
|
|
@ -1,3 +0,0 @@
|
|||
module.exports =
|
||||
class ThreadListColumn
|
||||
constructor: ({@name, @resolver, @flex}) ->
|
|
@ -1,42 +0,0 @@
|
|||
_ = require 'underscore-plus'
|
||||
moment = require "moment"
|
||||
{Actions} = require 'inbox-exports'
|
||||
|
||||
module.exports =
|
||||
ThreadListItemMixin =
|
||||
threadTime: ->
|
||||
moment(@props.thread.lastMessageTimestamp).format(@_timeFormat())
|
||||
|
||||
_timeFormat: ->
|
||||
diff = @_today().diff(@props.thread.lastMessageTimestamp, 'days', true)
|
||||
if diff <= 1
|
||||
return "h:mm a"
|
||||
else if diff > 1 and diff <= 365
|
||||
return "MMM D"
|
||||
else
|
||||
return "MMM D YYYY"
|
||||
|
||||
# Stubbable for testing. Returns a `moment`
|
||||
_today: -> moment()
|
||||
|
||||
_subject: ->
|
||||
str = @props.thread.subject
|
||||
str = "No Subject" unless str
|
||||
str
|
||||
|
||||
_snippet: ->
|
||||
snip = @props.thread?.snippet ? ""
|
||||
snip = snip.replace(/(\r\n|\n|\r)/gm, "")
|
||||
if snip.length > 160
|
||||
"#{snip.slice(0, Math.min(snip.length, 160))}…"
|
||||
else snip
|
||||
|
||||
_isStarred: ->
|
||||
@props.thread.isStarred()
|
||||
|
||||
_toggleStar: ->
|
||||
@props.thread.toggleStar()
|
||||
|
||||
_onClick: (event) ->
|
||||
event.preventDefault()
|
||||
Actions.selectThreadId(@props.thread.id)
|
|
@ -1,66 +0,0 @@
|
|||
_ = require 'underscore-plus'
|
||||
{Actions, ThreadStore} = require 'inbox-exports'
|
||||
|
||||
module.exports =
|
||||
ThreadListMixin =
|
||||
getInitialState: ->
|
||||
@_getStateFromStores()
|
||||
|
||||
componentDidMount: ->
|
||||
@thread_store_unsubscribe = ThreadStore.listen @_onChange
|
||||
@thread_unsubscriber = atom.commands.add '.thread-list', {
|
||||
'thread-list:star-thread': => @_onStarThread()
|
||||
}
|
||||
@body_unsubscriber = atom.commands.add 'body', {
|
||||
'application:previous-message': => @_onShiftSelectedIndex(-1)
|
||||
'application:next-message': => @_onShiftSelectedIndex(1)
|
||||
'application:archive-thread': @_onArchiveSelected
|
||||
'application:archive-and-previous': @_onArchiveAndPrevious
|
||||
'application:reply': @_onReply
|
||||
'application:reply-all': @_onReplyAll
|
||||
'application:forward': @_onForward
|
||||
}
|
||||
|
||||
componentWillUnmount: ->
|
||||
@thread_store_unsubscribe()
|
||||
@thread_unsubscriber.dispose()
|
||||
@body_unsubscriber.dispose()
|
||||
|
||||
_onShiftSelectedIndex: (delta) ->
|
||||
item = _.find @state.threads, (thread) => thread.id == @state?.selected
|
||||
index = if item then @state.threads.indexOf(item) else -1
|
||||
index = Math.max(0, Math.min(index + delta, @state.threads.length-1))
|
||||
Actions.selectThreadId(@state.threads[index].id)
|
||||
|
||||
_onArchiveSelected: ->
|
||||
thread = ThreadStore.selectedThread()
|
||||
thread.archive() if thread
|
||||
|
||||
_onStarThread: ->
|
||||
thread = ThreadStore.selectedThread()
|
||||
thread.toggleStar() if thread
|
||||
|
||||
_onReply: ->
|
||||
thread = ThreadStore.selectedThread()
|
||||
Actions.composeReply(threadId: thread.id) if thread?
|
||||
|
||||
_onReplyAll: ->
|
||||
thread = ThreadStore.selectedThread()
|
||||
Actions.composeReplyAll(threadId: thread.id) if thread?
|
||||
|
||||
_onForward: ->
|
||||
thread = ThreadStore.selectedThread()
|
||||
Actions.composeForward(threadId: thread.id) if thread?
|
||||
|
||||
_onChange: ->
|
||||
@setState(@_getStateFromStores())
|
||||
|
||||
_onArchiveAndPrevious: ->
|
||||
@_onArchiveSelected()
|
||||
@_onShiftSelectedIndex(-1)
|
||||
|
||||
_getStateFromStores: ->
|
||||
count: ThreadStore.items().length
|
||||
threads: ThreadStore.items()
|
||||
selected: ThreadStore.selectedId()
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
_ = require 'underscore-plus'
|
||||
React = require 'react/addons'
|
||||
|
||||
ThreadListItemMixin = require './thread-list-item-mixin.cjsx'
|
||||
|
||||
module.exports =
|
||||
ThreadListTabularItem = React.createClass
|
||||
displayName: 'ThreadListTabularItem'
|
||||
mixins: [ThreadListItemMixin]
|
||||
|
||||
render: ->
|
||||
<div className={@_containerClasses()}
|
||||
onClick={@_onClick}>
|
||||
{@_columns()}
|
||||
</div>
|
||||
|
||||
# DO NOT DELETE unless you know what you're doing! This method cuts
|
||||
# React.Perf.wasted-time from ~300msec to 20msec by doing a deep
|
||||
# comparison of props before triggering a re-render.
|
||||
shouldComponentUpdate: (nextProps, nextState) ->
|
||||
not _.isEqual(@props, nextProps)
|
||||
|
||||
_columns: ->
|
||||
for column in (@props.columns ? [])
|
||||
<div key={column.name}
|
||||
style={flex: "#{@props.columnFlex[column.name]}"}
|
||||
className="thread-list-column">
|
||||
{column.resolver(@props.thread, @)}
|
||||
</div>
|
||||
|
||||
_containerClasses: ->
|
||||
React.addons.classSet
|
||||
'unread': @props.unread
|
||||
'selected': @props.selected
|
||||
'thread-list-item': true
|
||||
'thread-list-tabular-item': true
|
|
@ -1,136 +0,0 @@
|
|||
_ = require 'underscore-plus'
|
||||
React = require 'react'
|
||||
|
||||
{ComponentRegistry} = require 'inbox-exports'
|
||||
|
||||
ThreadListMixin = require './thread-list-mixin.cjsx'
|
||||
ThreadListColumn = require("./thread-list-column")
|
||||
ThreadListTabularItem = require './thread-list-tabular-item.cjsx'
|
||||
|
||||
module.exports =
|
||||
ThreadListTabular = React.createClass
|
||||
mixins: [ComponentRegistry.Mixin, ThreadListMixin]
|
||||
displayName: 'ThreadListTabular'
|
||||
components: ["Participants"]
|
||||
|
||||
getInitialState: ->
|
||||
columns: @_defaultColumns()
|
||||
threadLabelComponents: ComponentRegistry.findAllByRole("thread label")
|
||||
|
||||
componentWillUpdate: ->
|
||||
@_colFlex = null
|
||||
|
||||
componentWillMount: ->
|
||||
@unlisteners = []
|
||||
@unlisteners.push ComponentRegistry.listen (event) =>
|
||||
@setState
|
||||
threadLabelComponents: ComponentRegistry.findAllByRole("thread label")
|
||||
|
||||
componentWillUnmount: ->
|
||||
unlisten() for unlisten in @unlisteners
|
||||
|
||||
render: ->
|
||||
<div className="thread-list" id="thread-list">
|
||||
<div tabIndex=1
|
||||
className="thread-list-container thread-list-tabular">
|
||||
|
||||
<div className="thread-rows">
|
||||
{@_threadRows()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
_defaultColumns: ->
|
||||
c0 = new ThreadListColumn
|
||||
name: "★"
|
||||
flex: 0.2
|
||||
resolver: (thread, parentComponent) ->
|
||||
<span className="btn-icon star-button"
|
||||
onClick={ -> thread.toggleStar.apply(thread)}>
|
||||
<i className={"fa " + (thread.isStarred() and 'fa-star' or 'fa-star-o')}/>
|
||||
</span>
|
||||
|
||||
c1 = new ThreadListColumn
|
||||
name: "Name"
|
||||
flex: 2
|
||||
resolver: (thread, parentComponent) =>
|
||||
Participants = @state.Participants
|
||||
<div className="participants">
|
||||
<Participants participants={thread.participants}
|
||||
context={'list'} clickable={false} />
|
||||
</div>
|
||||
|
||||
subject = (thread) ->
|
||||
if (thread.subject ? "").trim().length is 0
|
||||
return <span className="no-subject">(No Subject)</span>
|
||||
else return thread.subject
|
||||
|
||||
labelComponents = (thread) =>
|
||||
for label in @state.threadLabelComponents
|
||||
LabelComponent = label.view
|
||||
<LabelComponent thread={thread} />
|
||||
|
||||
numUnread = (thread) ->
|
||||
numMsg = thread.numUnread()
|
||||
if numMsg < 2
|
||||
<span></span>
|
||||
else
|
||||
<span className="message-count item-count-box">{numMsg}</span>
|
||||
|
||||
c2 = new ThreadListColumn
|
||||
name: "Subject"
|
||||
flex: 3
|
||||
resolver: (thread) ->
|
||||
<span>
|
||||
<span className="subject">{subject(thread)}</span>
|
||||
{numUnread(thread)}
|
||||
</span>
|
||||
|
||||
c3 = new ThreadListColumn
|
||||
name: "Snippet"
|
||||
flex: 4
|
||||
resolver: (thread) ->
|
||||
<span className="snippet">{thread.snippet}</span>
|
||||
|
||||
c4 = new ThreadListColumn
|
||||
name: "Date"
|
||||
flex: 1
|
||||
resolver: (thread, parentComponent) ->
|
||||
<span className="timestamp">
|
||||
{parentComponent.threadTime()}
|
||||
</span>
|
||||
|
||||
return [c1, c2, c3, c4]
|
||||
|
||||
_threadHeaders: ->
|
||||
return <div></div>
|
||||
# TODO: There's currently no styling for headers
|
||||
# for col in @state.columns
|
||||
# <div className="thread-list-header thread-list-column"
|
||||
# key={"header-#{col.name}"}
|
||||
# style={flex: "#{@_columnFlex()[col.name]}"}>
|
||||
# {col.name}
|
||||
# </div>
|
||||
|
||||
# The `numTags` attribute is only used to trigger a re-render of the
|
||||
# ThreadListTabularItem when a tag gets added or removed (like a star).
|
||||
# React's diffing engine does not detect the change the array nested
|
||||
# deep inside of the thread and does not call render on the associated
|
||||
# ThreadListTabularItem. Add the attribute fixes this.
|
||||
_threadRows: ->
|
||||
@state.threads.map (thread) =>
|
||||
<ThreadListTabularItem key={thread.id}
|
||||
thread={thread}
|
||||
numTags={thread.tags.length}
|
||||
columns={@state.columns}
|
||||
unread={thread.isUnread()}
|
||||
columnFlex={@_columnFlex()}
|
||||
selected={thread?.id == @state.selected}/>
|
||||
|
||||
_columnFlex: ->
|
||||
if @_colFlex? then return @_colFlex
|
||||
@_colFlex = {}
|
||||
for col in (@state.columns ? [])
|
||||
@_colFlex[col.name] = col.flex
|
||||
return @_colFlex
|
||||
|
138
internal_packages/thread-list/lib/thread-list.cjsx
Normal file
138
internal_packages/thread-list/lib/thread-list.cjsx
Normal file
|
@ -0,0 +1,138 @@
|
|||
_ = require 'underscore-plus'
|
||||
React = require 'react'
|
||||
{ListTabular} = require 'ui-components'
|
||||
{timestamp, subject} = require './formatting-utils'
|
||||
{Actions, ThreadStore, ComponentRegistry} = require 'inbox-exports'
|
||||
|
||||
module.exports =
|
||||
ThreadList = React.createClass
|
||||
displayName: 'ThreadList'
|
||||
|
||||
mixins: [ComponentRegistry.Mixin]
|
||||
components: ['Participants']
|
||||
|
||||
getInitialState: ->
|
||||
@_getStateFromStores()
|
||||
|
||||
componentDidMount: ->
|
||||
@thread_store_unsubscribe = ThreadStore.listen @_onChange
|
||||
@thread_unsubscriber = atom.commands.add '.thread-list', {
|
||||
'thread-list:star-thread': => @_onStarThread()
|
||||
}
|
||||
@body_unsubscriber = atom.commands.add 'body', {
|
||||
'application:previous-item': => @_onShiftSelectedIndex(-1)
|
||||
'application:next-item': => @_onShiftSelectedIndex(1)
|
||||
'application:remove-item': @_onArchiveSelected
|
||||
'application:remove-and-previous': @_onArchiveAndPrevious
|
||||
'application:reply': @_onReply
|
||||
'application:reply-all': @_onReplyAll
|
||||
'application:forward': @_onForward
|
||||
}
|
||||
|
||||
componentWillUnmount: ->
|
||||
@thread_store_unsubscribe()
|
||||
@thread_unsubscriber.dispose()
|
||||
@body_unsubscriber.dispose()
|
||||
|
||||
render: ->
|
||||
<div className="thread-list">
|
||||
<ListTabular
|
||||
columns={@state.columns}
|
||||
items={@state.items}
|
||||
itemClassProvider={ (item) -> if item.isUnread() then 'unread' else '' }
|
||||
selectedId={@state.selectedId}
|
||||
onSelect={ (item) -> Actions.selectThreadId(item.id) } />
|
||||
</div>
|
||||
|
||||
_computeColumns: ->
|
||||
labelComponents = (thread) =>
|
||||
for label in @state.threadLabelComponents
|
||||
LabelComponent = label.view
|
||||
<LabelComponent thread={thread} />
|
||||
|
||||
numUnread = (thread) ->
|
||||
numMsg = thread.numUnread()
|
||||
if numMsg < 2
|
||||
<span></span>
|
||||
else
|
||||
<span className="message-count item-count-box">{numMsg}</span>
|
||||
|
||||
c0 = new ListTabular.Column
|
||||
name: "★"
|
||||
flex: 0.2
|
||||
resolver: (thread) ->
|
||||
<span className="btn-icon star-button"
|
||||
onClick={ -> thread.toggleStar.apply(thread)}>
|
||||
<i className={"fa " + (thread.isStarred() and 'fa-star' or 'fa-star-o')}/>
|
||||
</span>
|
||||
|
||||
c1 = new ListTabular.Column
|
||||
name: "Name"
|
||||
flex: 2
|
||||
resolver: (thread) =>
|
||||
Participants = @state.Participants
|
||||
<div className="participants">
|
||||
<Participants participants={thread.participants}
|
||||
context={'list'} clickable={false} />
|
||||
</div>
|
||||
|
||||
c2 = new ListTabular.Column
|
||||
name: "Subject"
|
||||
flex: 3
|
||||
resolver: (thread) ->
|
||||
<span>
|
||||
<span className="subject">{subject(thread.subject)}</span>
|
||||
{numUnread(thread)}
|
||||
</span>
|
||||
|
||||
c3 = new ListTabular.Column
|
||||
name: "Snippet"
|
||||
flex: 4
|
||||
resolver: (thread) ->
|
||||
<span className="snippet">{thread.snippet}</span>
|
||||
|
||||
c4 = new ListTabular.Column
|
||||
name: "Date"
|
||||
flex: 1
|
||||
resolver: (thread) ->
|
||||
<span className="timestamp">{timestamp(thread.lastMessageTimestamp)}</span>
|
||||
|
||||
[c1, c2, c3, c4]
|
||||
|
||||
_onShiftSelectedIndex: (delta) ->
|
||||
item = _.find @state.items, (thread) => thread.id == @state.selectedId
|
||||
index = if item then @state.items.indexOf(item) else -1
|
||||
index = Math.max(0, Math.min(index + delta, @state.items.length-1))
|
||||
Actions.selectThreadId(@state.items[index].id)
|
||||
|
||||
_onArchiveSelected: ->
|
||||
thread = ThreadStore.selectedThread()
|
||||
thread.archive() if thread
|
||||
|
||||
_onStarThread: ->
|
||||
thread = ThreadStore.selectedThread()
|
||||
thread.toggleStar() if thread
|
||||
|
||||
_onReply: ->
|
||||
return unless @state.selectedId?
|
||||
Actions.composeReply(threadId: @state.selectedId)
|
||||
|
||||
_onReplyAll: ->
|
||||
return unless @state.selectedId?
|
||||
Actions.composeReplyAll(threadId: @state.selectedId)
|
||||
|
||||
_onForward: ->
|
||||
return unless @state.selectedId?
|
||||
Actions.composeForward(threadId: @state.selectedId)
|
||||
|
||||
_onChange: ->
|
||||
@setState(@_getStateFromStores())
|
||||
|
||||
_onArchiveAndPrevious: ->
|
||||
@_onArchiveSelected()
|
||||
@_onShiftSelectedIndex(-1)
|
||||
|
||||
_getStateFromStores: ->
|
||||
items: ThreadStore.items()
|
||||
columns: @_computeColumns()
|
||||
selectedId: ThreadStore.selectedId()
|
|
@ -3,7 +3,7 @@ _ = require 'underscore-plus'
|
|||
CSON = require 'season'
|
||||
React = require "react/addons"
|
||||
ReactTestUtils = React.addons.TestUtils
|
||||
ReactTestUtils = _.extend ReactTestUtils, require("jasmine-react-helpers")
|
||||
ReactTestUtils = _.extend ReactTestUtils, require "jasmine-react-helpers"
|
||||
|
||||
{Thread,
|
||||
Actions,
|
||||
|
@ -13,14 +13,9 @@ ReactTestUtils = _.extend ReactTestUtils, require("jasmine-react-helpers")
|
|||
InboxTestUtils,
|
||||
NamespaceStore,
|
||||
ComponentRegistry} = require "inbox-exports"
|
||||
{ListTabular} = require 'ui-components'
|
||||
|
||||
ThreadListColumn = require("../lib/thread-list-column")
|
||||
|
||||
ThreadListNarrow = require("../lib/thread-list-narrow.cjsx")
|
||||
ThreadListNarrowItem = require("../lib/thread-list-narrow-item.cjsx")
|
||||
|
||||
ThreadListTabular = require("../lib/thread-list-tabular.cjsx")
|
||||
ThreadListTabularItem = require("../lib/thread-list-tabular-item.cjsx")
|
||||
ThreadList = require "../lib/thread-list.cjsx"
|
||||
|
||||
ParticipantsItem = React.createClass
|
||||
render: -> <div></div>
|
||||
|
@ -195,18 +190,18 @@ cjsxSubjectResolver = (thread) ->
|
|||
<span className="snippet">Snippet</span>
|
||||
</div>
|
||||
|
||||
describe "ThreadListTabular", ->
|
||||
describe "ThreadList", ->
|
||||
|
||||
Foo = React.createClass({render: -> <div>{@props.children}</div>})
|
||||
c1 = new ThreadListColumn
|
||||
c1 = new ListTabular.Column
|
||||
name: "Name"
|
||||
flex: 1
|
||||
resolver: (thread) -> "#{thread.id} Test Name"
|
||||
c2 = new ThreadListColumn
|
||||
c2 = new ListTabular.Column
|
||||
name: "Subject"
|
||||
flex: 3
|
||||
resolver: cjsxSubjectResolver
|
||||
c3 = new ThreadListColumn
|
||||
c3 = new ListTabular.Column
|
||||
name: "Date"
|
||||
resolver: (thread) -> <Foo>{thread.id}</Foo>
|
||||
|
||||
|
@ -217,8 +212,7 @@ describe "ThreadListTabular", ->
|
|||
spyOn(ThreadStore, "_onNamespaceChanged")
|
||||
spyOn(DatabaseStore, "findAll").andCallFake ->
|
||||
new Promise (resolve, reject) -> resolve(test_threads())
|
||||
ReactTestUtils.spyOnClass(ThreadListTabular, "_defaultColumns")
|
||||
.andReturn(columns)
|
||||
ReactTestUtils.spyOnClass(ThreadList, "_computeColumns").andReturn(columns)
|
||||
|
||||
ThreadStore._resetInstanceVars()
|
||||
|
||||
|
@ -227,12 +221,12 @@ describe "ThreadListTabular", ->
|
|||
view: ParticipantsItem
|
||||
|
||||
@thread_list = ReactTestUtils.renderIntoDocument(
|
||||
<ThreadListTabular />
|
||||
<ThreadList />
|
||||
)
|
||||
|
||||
it "renders into the document", ->
|
||||
expect(ReactTestUtils.isCompositeComponentWithType(@thread_list,
|
||||
ThreadListTabular)).toBe true
|
||||
ThreadList)).toBe true
|
||||
|
||||
it "stars on keymap", ->
|
||||
spyOn(@thread_list, "_onStarThread")
|
||||
|
@ -243,8 +237,7 @@ describe "ThreadListTabular", ->
|
|||
expect(@thread_list.state.columns).toEqual columns
|
||||
|
||||
it "by default has zero children", ->
|
||||
items = ReactTestUtils.scryRenderedComponentsWithType(@thread_list,
|
||||
ThreadListTabularItem)
|
||||
items = ReactTestUtils.scryRenderedComponentsWithType(@thread_list, ListTabular.Item)
|
||||
expect(items.length).toBe 0
|
||||
|
||||
describe "Populated thread list", ->
|
||||
|
@ -255,184 +248,143 @@ describe "ThreadListTabular", ->
|
|||
@thread_list_node = @thread_list.getDOMNode()
|
||||
|
||||
it "renders all of the thread list items", ->
|
||||
items = ReactTestUtils.scryRenderedComponentsWithType(@thread_list,
|
||||
ThreadListTabularItem)
|
||||
items = ReactTestUtils.scryRenderedComponentsWithType(@thread_list, ListTabular.Item)
|
||||
expect(items.length).toBe 3
|
||||
|
||||
# We no longer put headers in the thread list (for now)
|
||||
# it "Expects there to be headers", ->
|
||||
# heads = ReactTestUtils.scryRenderedDOMComponentsWithClass(@thread_list, "thread-list-header")
|
||||
# expect(heads.length).toBe(3)
|
||||
#
|
||||
|
||||
describe "ThreadListTabularItem", ->
|
||||
beforeEach ->
|
||||
items = ReactTestUtils.scryRenderedComponentsWithType(@thread_list,
|
||||
ThreadListTabularItem)
|
||||
item = items.filter (tli) -> tli.props.thread.id is "111"
|
||||
@thread_list_item = item[0]
|
||||
# describe "ThreadListNarrow", ->
|
||||
|
||||
it "finds the thread list item by id", ->
|
||||
expect(@thread_list_item.props.thread.id).toBe "111"
|
||||
# beforeEach ->
|
||||
# InboxTestUtils.loadKeymap("internal_packages/thread-list/keymaps/thread-list.cson")
|
||||
# spyOn(ThreadStore, "_onNamespaceChanged")
|
||||
# spyOn(DatabaseStore, "findAll").andCallFake ->
|
||||
# new Promise (resolve, reject) -> resolve(test_threads())
|
||||
# ThreadStore._resetInstanceVars()
|
||||
|
||||
it "Expects each thread list item to get the column list", ->
|
||||
expect(@thread_list_item.props.columns).toEqual columns
|
||||
# ComponentRegistry.register
|
||||
# name: 'Participants'
|
||||
# view: ParticipantsItem
|
||||
|
||||
it "has the proper column widths", ->
|
||||
expect(@thread_list_item.props.columnFlex["Name"]).toEqual 1
|
||||
expect(@thread_list_item.props.columnFlex["Subject"]).toEqual 3
|
||||
expect(@thread_list_item.props.columnFlex["Date"]).toEqual undefined
|
||||
# @thread_list = ReactTestUtils.renderIntoDocument(
|
||||
# <ThreadListNarrow />
|
||||
# )
|
||||
|
||||
describe "columns in thread list item", ->
|
||||
beforeEach ->
|
||||
@cols = ReactTestUtils.scryRenderedDOMComponentsWithClass(@thread_list_item, "thread-list-column")
|
||||
# it "renders into the document", ->
|
||||
# expect(ReactTestUtils.isCompositeComponentWithType(@thread_list,
|
||||
# ThreadListNarrow)).toBe true
|
||||
|
||||
it "Expects there to be three columns", ->
|
||||
expect(@cols.length).toBe(3)
|
||||
# it "by default has zero children", ->
|
||||
# items = ReactTestUtils.scryRenderedComponentsWithType(@thread_list,
|
||||
# ThreadListNarrowItem)
|
||||
# expect(items.length).toBe 0
|
||||
|
||||
it "Expects the correct test data in the columns", ->
|
||||
snip1 = ReactTestUtils.scryRenderedDOMComponentsWithClass(@cols[1], "snippet")
|
||||
snip2 = ReactTestUtils.scryRenderedComponentsWithType(@cols[2], Foo)
|
||||
# describe "Populated thread list", ->
|
||||
# beforeEach ->
|
||||
# ThreadStore._items = test_threads()
|
||||
# ThreadStore._selectedId = null
|
||||
# ThreadStore.trigger()
|
||||
# @thread_list_node = @thread_list.getDOMNode()
|
||||
|
||||
expect(@cols[0].props.children).toBe "111 Test Name"
|
||||
expect(snip1.length).toBe 1
|
||||
expect(snip1[0].props.children).toEqual "Snippet"
|
||||
expect(snip2.length).toBe 1
|
||||
expect(snip2[0].props.children).toEqual "111"
|
||||
# it "renders all of the thread list items", ->
|
||||
# items = ReactTestUtils.scryRenderedComponentsWithType(@thread_list,
|
||||
# ThreadListNarrowItem)
|
||||
# expect(items.length).toBe 3
|
||||
|
||||
describe "ThreadListNarrow", ->
|
||||
# describe "Shifting selected index", ->
|
||||
|
||||
beforeEach ->
|
||||
InboxTestUtils.loadKeymap("internal_packages/thread-list/keymaps/thread-list.cson")
|
||||
spyOn(ThreadStore, "_onNamespaceChanged")
|
||||
spyOn(DatabaseStore, "findAll").andCallFake ->
|
||||
new Promise (resolve, reject) -> resolve(test_threads())
|
||||
ThreadStore._resetInstanceVars()
|
||||
# beforeEach ->
|
||||
# spyOn(@thread_list, "_onShiftSelectedIndex")
|
||||
# spyOn(Actions, "selectThreadId")
|
||||
|
||||
ComponentRegistry.register
|
||||
name: 'Participants'
|
||||
view: ParticipantsItem
|
||||
# it "can move selection up", ->
|
||||
# atom.commands.dispatch(document.body, "application:previous-item")
|
||||
# expect(@thread_list._onShiftSelectedIndex).toHaveBeenCalledWith(-1)
|
||||
|
||||
@thread_list = ReactTestUtils.renderIntoDocument(
|
||||
<ThreadListNarrow />
|
||||
)
|
||||
# it "can move selection down", ->
|
||||
# atom.commands.dispatch(document.body, "application:next-item")
|
||||
# expect(@thread_list._onShiftSelectedIndex).toHaveBeenCalledWith(1)
|
||||
|
||||
it "renders into the document", ->
|
||||
expect(ReactTestUtils.isCompositeComponentWithType(@thread_list,
|
||||
ThreadListNarrow)).toBe true
|
||||
# describe "Triggering message list commands", ->
|
||||
# beforeEach ->
|
||||
# spyOn(Actions, "composeReply")
|
||||
# spyOn(Actions, "composeReplyAll")
|
||||
# spyOn(Actions, "composeForward")
|
||||
# ThreadStore._onSelectThreadId("111")
|
||||
# @thread = ThreadStore.selectedThread()
|
||||
# spyOn(@thread, "archive")
|
||||
# spyOn(@thread_list, "_onShiftSelectedIndex")
|
||||
# spyOn(Actions, "selectThreadId")
|
||||
|
||||
it "by default has zero children", ->
|
||||
items = ReactTestUtils.scryRenderedComponentsWithType(@thread_list,
|
||||
ThreadListNarrowItem)
|
||||
expect(items.length).toBe 0
|
||||
# it "can reply to the currently selected thread", ->
|
||||
# atom.commands.dispatch(document.body, "application:reply")
|
||||
# expect(Actions.composeReply).toHaveBeenCalledWith(threadId: @thread.id)
|
||||
|
||||
describe "Populated thread list", ->
|
||||
beforeEach ->
|
||||
ThreadStore._items = test_threads()
|
||||
ThreadStore._selectedId = null
|
||||
ThreadStore.trigger()
|
||||
@thread_list_node = @thread_list.getDOMNode()
|
||||
# it "can reply all to the currently selected thread", ->
|
||||
# atom.commands.dispatch(document.body, "application:reply-all")
|
||||
# expect(Actions.composeReplyAll).toHaveBeenCalledWith(threadId: @thread.id)
|
||||
|
||||
it "renders all of the thread list items", ->
|
||||
items = ReactTestUtils.scryRenderedComponentsWithType(@thread_list,
|
||||
ThreadListNarrowItem)
|
||||
expect(items.length).toBe 3
|
||||
# it "can forward the currently selected thread", ->
|
||||
# atom.commands.dispatch(document.body, "application:forward")
|
||||
# expect(Actions.composeForward).toHaveBeenCalledWith(threadId: @thread.id)
|
||||
|
||||
describe "Shifting selected index", ->
|
||||
# it "can archive the currently selected thread", ->
|
||||
# atom.commands.dispatch(document.body, "application:remove-item")
|
||||
# expect(@thread.archive).toHaveBeenCalled()
|
||||
|
||||
beforeEach ->
|
||||
spyOn(@thread_list, "_onShiftSelectedIndex")
|
||||
spyOn(Actions, "selectThreadId")
|
||||
# it "can archive the currently selected thread and navigate up", ->
|
||||
# atom.commands.dispatch(document.body, "application:remove-and-previous")
|
||||
# expect(@thread.archive).toHaveBeenCalled()
|
||||
# expect(@thread_list._onShiftSelectedIndex).toHaveBeenCalledWith(-1)
|
||||
|
||||
it "can move selection up", ->
|
||||
atom.commands.dispatch(document.body, "application:previous-message")
|
||||
expect(@thread_list._onShiftSelectedIndex).toHaveBeenCalledWith(-1)
|
||||
# it "does nothing when no thread is selected", ->
|
||||
# ThreadStore._selectedId = null
|
||||
# atom.commands.dispatch(document.body, "application:reply")
|
||||
# expect(Actions.composeReply.calls.length).toEqual(0)
|
||||
|
||||
it "can move selection down", ->
|
||||
atom.commands.dispatch(document.body, "application:next-message")
|
||||
expect(@thread_list._onShiftSelectedIndex).toHaveBeenCalledWith(1)
|
||||
# describe "ThreadListNarrowItem", ->
|
||||
# beforeEach ->
|
||||
# items = ReactTestUtils.scryRenderedComponentsWithType(@thread_list,
|
||||
# ThreadListNarrowItem)
|
||||
# item = items.filter (tli) -> tli.props.thread.id is "111"
|
||||
# @thread_list_item = item[0]
|
||||
# @thread_date = moment(@thread_list_item.props.thread.lastMessageTimestamp)
|
||||
|
||||
describe "Triggering message list commands", ->
|
||||
beforeEach ->
|
||||
spyOn(Actions, "composeReply")
|
||||
spyOn(Actions, "composeReplyAll")
|
||||
spyOn(Actions, "composeForward")
|
||||
ThreadStore._onSelectThreadId("111")
|
||||
@thread = ThreadStore.selectedThread()
|
||||
spyOn(@thread, "archive")
|
||||
spyOn(@thread_list, "_onShiftSelectedIndex")
|
||||
spyOn(Actions, "selectThreadId")
|
||||
# it "finds the thread list item by id", ->
|
||||
# expect(@thread_list_item.props.thread.id).toBe "111"
|
||||
|
||||
it "can reply to the currently selected thread", ->
|
||||
atom.commands.dispatch(document.body, "application:reply")
|
||||
expect(Actions.composeReply).toHaveBeenCalledWith(threadId: @thread.id)
|
||||
# it "fires the appropriate Action on click", ->
|
||||
# spyOn(Actions, "selectThreadId")
|
||||
# ReactTestUtils.Simulate.click @thread_list_item.getDOMNode()
|
||||
# expect(Actions.selectThreadId).toHaveBeenCalledWith("111")
|
||||
|
||||
it "can reply all to the currently selected thread", ->
|
||||
atom.commands.dispatch(document.body, "application:reply-all")
|
||||
expect(Actions.composeReplyAll).toHaveBeenCalledWith(threadId: @thread.id)
|
||||
# it "sets the selected state on the thread item", ->
|
||||
# ThreadStore._onSelectThreadId("111")
|
||||
# items = ReactTestUtils.scryRenderedDOMComponentsWithClass(@thread_list, "selected")
|
||||
# expect(items.length).toBe 1
|
||||
# expect(items[0].props.id).toBe "111"
|
||||
|
||||
it "can forward the currently selected thread", ->
|
||||
atom.commands.dispatch(document.body, "application:forward")
|
||||
expect(Actions.composeForward).toHaveBeenCalledWith(threadId: @thread.id)
|
||||
# it "renders de-selection when invalid id is emitted", ->
|
||||
# ThreadStore._onSelectThreadId('abc')
|
||||
# items = ReactTestUtils.scryRenderedDOMComponentsWithClass(@thread_list, "selected")
|
||||
# expect(items.length).toBe 0
|
||||
|
||||
it "can archive the currently selected thread", ->
|
||||
atom.commands.dispatch(document.body, "application:archive-thread")
|
||||
expect(@thread.archive).toHaveBeenCalled()
|
||||
# # test "last_message_timestamp": 1415742036
|
||||
# it "displays the time from threads LONG ago", ->
|
||||
# spyOn(@thread_list_item, "_today").andCallFake =>
|
||||
# @thread_date.add(2, 'years')
|
||||
# expect(@thread_list_item._timeFormat()).toBe "MMM D YYYY"
|
||||
|
||||
it "can archive the currently selected thread and navigate up", ->
|
||||
atom.commands.dispatch(document.body, "application:archive-and-previous")
|
||||
expect(@thread.archive).toHaveBeenCalled()
|
||||
expect(@thread_list._onShiftSelectedIndex).toHaveBeenCalledWith(-1)
|
||||
# it "displays the time from threads a bit ago", ->
|
||||
# spyOn(@thread_list_item, "_today").andCallFake =>
|
||||
# @thread_date.add(2, 'days')
|
||||
# expect(@thread_list_item._timeFormat()).toBe "MMM D"
|
||||
|
||||
it "does nothing when no thread is selected", ->
|
||||
ThreadStore._selectedId = null
|
||||
atom.commands.dispatch(document.body, "application:reply")
|
||||
expect(Actions.composeReply.calls.length).toEqual(0)
|
||||
# it "displays the time from threads exactly a day ago", ->
|
||||
# spyOn(@thread_list_item, "_today").andCallFake =>
|
||||
# @thread_date.add(1, 'day')
|
||||
# expect(@thread_list_item._timeFormat()).toBe "h:mm a"
|
||||
|
||||
describe "ThreadListNarrowItem", ->
|
||||
beforeEach ->
|
||||
items = ReactTestUtils.scryRenderedComponentsWithType(@thread_list,
|
||||
ThreadListNarrowItem)
|
||||
item = items.filter (tli) -> tli.props.thread.id is "111"
|
||||
@thread_list_item = item[0]
|
||||
@thread_date = moment(@thread_list_item.props.thread.lastMessageTimestamp)
|
||||
|
||||
it "finds the thread list item by id", ->
|
||||
expect(@thread_list_item.props.thread.id).toBe "111"
|
||||
|
||||
it "fires the appropriate Action on click", ->
|
||||
spyOn(Actions, "selectThreadId")
|
||||
ReactTestUtils.Simulate.click @thread_list_item.getDOMNode()
|
||||
expect(Actions.selectThreadId).toHaveBeenCalledWith("111")
|
||||
|
||||
it "sets the selected state on the thread item", ->
|
||||
ThreadStore._onSelectThreadId("111")
|
||||
items = ReactTestUtils.scryRenderedDOMComponentsWithClass(@thread_list, "selected")
|
||||
expect(items.length).toBe 1
|
||||
expect(items[0].props.id).toBe "111"
|
||||
|
||||
it "renders de-selection when invalid id is emitted", ->
|
||||
ThreadStore._onSelectThreadId('abc')
|
||||
items = ReactTestUtils.scryRenderedDOMComponentsWithClass(@thread_list, "selected")
|
||||
expect(items.length).toBe 0
|
||||
|
||||
# test "last_message_timestamp": 1415742036
|
||||
it "displays the time from threads LONG ago", ->
|
||||
spyOn(@thread_list_item, "_today").andCallFake =>
|
||||
@thread_date.add(2, 'years')
|
||||
expect(@thread_list_item._timeFormat()).toBe "MMM D YYYY"
|
||||
|
||||
it "displays the time from threads a bit ago", ->
|
||||
spyOn(@thread_list_item, "_today").andCallFake =>
|
||||
@thread_date.add(2, 'days')
|
||||
expect(@thread_list_item._timeFormat()).toBe "MMM D"
|
||||
|
||||
it "displays the time from threads exactly a day ago", ->
|
||||
spyOn(@thread_list_item, "_today").andCallFake =>
|
||||
@thread_date.add(1, 'day')
|
||||
expect(@thread_list_item._timeFormat()).toBe "h:mm a"
|
||||
|
||||
it "displays the time from threads recently", ->
|
||||
spyOn(@thread_list_item, "_today").andCallFake =>
|
||||
@thread_date.add(2, 'hours')
|
||||
expect(@thread_list_item._timeFormat()).toBe "h:mm a"
|
||||
# it "displays the time from threads recently", ->
|
||||
# spyOn(@thread_list_item, "_today").andCallFake =>
|
||||
# @thread_date.add(2, 'hours')
|
||||
# expect(@thread_list_item._timeFormat()).toBe "h:mm a"
|
||||
|
|
|
@ -5,12 +5,11 @@
|
|||
outline:none;
|
||||
}
|
||||
|
||||
#thread-list {
|
||||
.thread-list {
|
||||
order: 3;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
-webkit-user-select: none;
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
.message-count {
|
||||
|
@ -23,7 +22,10 @@
|
|||
.participants {
|
||||
font-size: @font-size-large;
|
||||
font-weight: @font-weight-semi-bold;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subject {
|
||||
font-size: @font-size-small;
|
||||
font-weight: @font-weight-normal;
|
||||
|
@ -33,6 +35,23 @@
|
|||
font-weight: @font-weight-normal;
|
||||
color: @text-color-subtle;
|
||||
}
|
||||
|
||||
.unread:not(.selected) {
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 99%;
|
||||
width: 5px;
|
||||
top: 0;
|
||||
left: 1px;
|
||||
background: @unread-color;
|
||||
}
|
||||
|
||||
.subject {
|
||||
color: @unread-color;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
.participants {
|
||||
color: @text-color-inverse;
|
||||
|
@ -44,47 +63,6 @@
|
|||
color: @text-color-inverse-subtle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.thread-list-container {
|
||||
position: relative;
|
||||
|
||||
.thread-list-item {
|
||||
font-size: @font-size-base;
|
||||
line-height: @line-height-large;
|
||||
color: @text-color;
|
||||
background: @list-bg;
|
||||
|
||||
.participants {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: @list-hover-bg;
|
||||
}
|
||||
|
||||
&.unread:not(.selected) {
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 99%;
|
||||
width: 5px;
|
||||
top: 0;
|
||||
left: 1px;
|
||||
background: @unread-color;
|
||||
}
|
||||
|
||||
.subject {
|
||||
color: @unread-color;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: @list-active-bg;
|
||||
color: @list-active-color;
|
||||
}
|
||||
}
|
||||
|
||||
.star-button {
|
||||
font-size: 16px;
|
||||
|
@ -105,61 +83,3 @@
|
|||
}
|
||||
}
|
||||
|
||||
.thread-list-tabular {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.thread-list-tabular-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
&:hover {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.thread-rows {
|
||||
overflow: auto;
|
||||
// Add back when when we re-implement thread-list-headers
|
||||
// padding-top: @font-size-base * 2; /* height of thread-list-headers*/
|
||||
}
|
||||
|
||||
.thread-list-column {
|
||||
// The width is set by React.
|
||||
display: inline-block;
|
||||
padding: @padding-base-vertical @padding-base-horizontal @padding-base-vertical @padding-base-horizontal;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
border-bottom: 1px solid @list-border;
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.thread-list-headers {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
background: fade(@list-bg,90%);
|
||||
font-size: @font-size-base;
|
||||
line-height: @font-size-base * 1.6;
|
||||
height:@font-size-base * 2;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.thread-list-header {
|
||||
}
|
||||
|
||||
.participants {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -6,15 +6,15 @@
|
|||
|
||||
'body':
|
||||
'c' : 'application:new-message' # Gmail
|
||||
'k' : 'application:previous-message' # Gmail
|
||||
'up' : 'application:previous-message' # Mac mail
|
||||
'j' : 'application:next-message' # Gmail
|
||||
'down' : 'application:next-message' # Mac mail
|
||||
']' : 'application:archive-and-previous' # Gmail
|
||||
'[' : 'application:archive-thread' # Gmail
|
||||
'e' : 'application:archive-thread' # Gmail
|
||||
'delete' : 'application:archive-thread' # Mac mail
|
||||
'backspace': 'application:archive-thread' # Outlook
|
||||
'k' : 'application:previous-item' # Gmail
|
||||
'up' : 'application:previous-item' # Mac mail
|
||||
'j' : 'application:next-item' # Gmail
|
||||
'down' : 'application:next-item' # Mac mail
|
||||
']' : 'application:remove-and-previous' # Gmail
|
||||
'[' : 'application:remove-item' # Gmail
|
||||
'e' : 'application:remove-item' # Gmail
|
||||
'delete' : 'application:remove-item' # Mac mail
|
||||
'backspace': 'application:remove-item' # Outlook
|
||||
|
||||
'r' : 'application:reply' # Gmail
|
||||
'a' : 'application:reply-all' # Gmail
|
||||
|
|
|
@ -22,20 +22,6 @@ describe "Model", ->
|
|||
m = new Model()
|
||||
expect(m.attributes()).toBe(m.constructor.attributes)
|
||||
|
||||
describe "isEqual", ->
|
||||
it "should return true iff the classes and IDs match", ->
|
||||
class Submodel extends Model
|
||||
constructor: -> super
|
||||
|
||||
a = new Model({id: "A"})
|
||||
b = new Model({id: "B"})
|
||||
aSub = new Submodel({id: "A"})
|
||||
aEqualSub = new Submodel({id: "A"})
|
||||
|
||||
expect(a.isEqual(b)).toBe(false)
|
||||
expect(a.isEqual(aSub)).toBe(false)
|
||||
expect(aSub.isEqual(aEqualSub)).toBe(true)
|
||||
|
||||
describe "isSaved", ->
|
||||
it "should return false if the object has a temp ID", ->
|
||||
a = new Model()
|
||||
|
|
|
@ -624,7 +624,7 @@ class Atom extends Model
|
|||
@displayOnboardingWindow()
|
||||
|
||||
displayComposer: (draftLocalId = null) ->
|
||||
ipc.send('show-composer-window', {draftLocalId: draftLocalId})
|
||||
ipc.send('show-composer-window', {draftLocalId})
|
||||
|
||||
displayOnboardingWindow: (page = false) ->
|
||||
options =
|
||||
|
|
|
@ -442,7 +442,8 @@ class AtomApplication
|
|||
w.focus()
|
||||
|
||||
sendComposerState = ->
|
||||
w.browserWindow.webContents.send 'composer-state', JSON.stringify({draftLocalId, draftInitialJSON})
|
||||
json = JSON.stringify({draftLocalId, draftInitialJSON})
|
||||
w.browserWindow.webContents.send('composer-state', json)
|
||||
|
||||
if w.browserWindow.webContents.isLoading()
|
||||
w.browserWindow.webContents.on('did-finish-load', sendComposerState)
|
||||
|
|
99
src/components/list-tabular.cjsx
Normal file
99
src/components/list-tabular.cjsx
Normal file
|
@ -0,0 +1,99 @@
|
|||
_ = require 'underscore-plus'
|
||||
React = require 'react'
|
||||
|
||||
class ListColumn
|
||||
constructor: ({@name, @resolver, @flex}) ->
|
||||
|
||||
ListTabularItem = React.createClass
|
||||
displayName: 'ListTabularItem'
|
||||
propTypes:
|
||||
item: React.PropTypes.object
|
||||
itemClassProvider: React.PropTypes.func
|
||||
displayHeaders: React.PropTypes.bool
|
||||
onSelect: React.PropTypes.func
|
||||
onDoubleClick: React.PropTypes.func
|
||||
|
||||
# DO NOT DELETE unless you know what you're doing! This method cuts
|
||||
# React.Perf.wasted-time from ~300msec to 20msec by doing a deep
|
||||
# comparison of props before triggering a re-render.
|
||||
shouldComponentUpdate: (nextProps, nextState) ->
|
||||
not _.isEqual(@props, nextProps)
|
||||
|
||||
render: ->
|
||||
<div className={@_containerClasses()} onClick={@_onClick}>
|
||||
{@_columns()}
|
||||
</div>
|
||||
|
||||
_columns: ->
|
||||
for column in (@props.columns ? [])
|
||||
<div key={column.name}
|
||||
displayName={column.name}
|
||||
style={flex: column.flex}
|
||||
className="list-column">
|
||||
{column.resolver(@props.item, @)}
|
||||
</div>
|
||||
|
||||
_onClick: ->
|
||||
if not @props.selected
|
||||
@props.onSelect?(@props.item)
|
||||
|
||||
if @_lastClickTime? and Date.now() - @_lastClickTime < 350
|
||||
@props.onDoubleClick?(@props.item)
|
||||
|
||||
@_lastClickTime = Date.now()
|
||||
|
||||
_containerClasses: ->
|
||||
classes = @props.itemClassProvider?(@props.item)
|
||||
classes = '' unless _.isString(classes)
|
||||
classes += ' ' + React.addons.classSet
|
||||
'selected': @props.selected
|
||||
'list-item': true
|
||||
'list-tabular-item': true
|
||||
classes
|
||||
|
||||
module.exports =
|
||||
ListTabular = React.createClass
|
||||
displayName: 'ListTabular'
|
||||
propTypes:
|
||||
columns: React.PropTypes.arrayOf(React.PropTypes.object)
|
||||
items: React.PropTypes.arrayOf(React.PropTypes.object)
|
||||
itemClassProvider: React.PropTypes.func
|
||||
selectedId: React.PropTypes.string
|
||||
onSelect: React.PropTypes.func
|
||||
onDoubleClick: React.PropTypes.func
|
||||
|
||||
render: ->
|
||||
<div tabIndex=1 className="list-container list-tabular">
|
||||
{@_headers()}
|
||||
<div className="list-rows">
|
||||
{@_rows()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
_headers: ->
|
||||
return [] unless @props.displayHeaders
|
||||
|
||||
headerColumns = @props.columns.map (column) ->
|
||||
<div className="list-header list-column"
|
||||
key={"header-#{column.name}"}
|
||||
style={flex: column.flex}>
|
||||
{column.name}
|
||||
</div>
|
||||
|
||||
<div className="list-headers">
|
||||
{headerColumns}
|
||||
</div>
|
||||
|
||||
_rows: ->
|
||||
@props.items.map (item) =>
|
||||
<ListTabularItem key={item.id}
|
||||
selected={item.id is @props.selectedId}
|
||||
item={item}
|
||||
itemClassProvider={@props.itemClassProvider}
|
||||
columns={@props.columns}
|
||||
onSelect={@props.onSelect}
|
||||
onDoubleClick={@props.onDoubleClick} />
|
||||
|
||||
|
||||
ListTabular.Item = ListTabularItem
|
||||
ListTabular.Column = ListColumn
|
|
@ -45,14 +45,14 @@ class ActionBridge
|
|||
callback = => @onRebroadcast(TargetWindows.ALL, name, arguments)
|
||||
Actions[name].listen(callback, @)
|
||||
|
||||
if @role == Role.ROOT
|
||||
# Observe the database store (possibly other stores in the future), and
|
||||
# rebroadcast it's trigger() event.
|
||||
callback = (change) =>
|
||||
@onRebroadcast(TargetWindows.ALL, Message.DATABASE_STORE_TRIGGER, [change])
|
||||
DatabaseStore.listen(callback, @)
|
||||
# Observe the database store (possibly other stores in the future), and
|
||||
# rebroadcast it's trigger() event.
|
||||
databaseCallback = (change) =>
|
||||
return if DatabaseStore.triggeringFromActionBridge
|
||||
@onRebroadcast(TargetWindows.ALL, Message.DATABASE_STORE_TRIGGER, [change])
|
||||
DatabaseStore.listen(databaseCallback, @)
|
||||
|
||||
else
|
||||
if @role isnt Role.ROOT
|
||||
# Observe all mainWindow actions fired in this window and re-broadcast
|
||||
# them to other windows so the central application stores can take action
|
||||
Actions.mainWindowActions.forEach (name) =>
|
||||
|
@ -71,8 +71,10 @@ class ActionBridge
|
|||
console.error(e)
|
||||
|
||||
if name == Message.DATABASE_STORE_TRIGGER
|
||||
return unless @role == Role.SECONDARY
|
||||
DatabaseStore.triggeringFromActionBridge = true
|
||||
DatabaseStore.trigger(args...)
|
||||
DatabaseStore.triggeringFromActionBridge = false
|
||||
|
||||
else if Actions[name]
|
||||
Actions[name].firing = true
|
||||
Actions[name](args...)
|
||||
|
|
|
@ -49,6 +49,7 @@ windowActions = [
|
|||
"selectNamespaceId",
|
||||
"selectThreadId",
|
||||
"selectTagId",
|
||||
"selectView",
|
||||
|
||||
# Actions for composer
|
||||
"composeReply",
|
||||
|
|
|
@ -31,9 +31,6 @@ class Model
|
|||
attributes: ->
|
||||
@constructor.attributes
|
||||
|
||||
isEqual: (other) ->
|
||||
other?.id == @id && other?.constructor == @constructor
|
||||
|
||||
isSaved: ->
|
||||
!isTempId(@id)
|
||||
|
||||
|
|
|
@ -33,13 +33,14 @@ class DraftChangeSet
|
|||
@_timer = setTimeout(@commit, 5000)
|
||||
|
||||
commit: =>
|
||||
return unless Object.keys(@_pending).length > 0
|
||||
if Object.keys(@_pending).length is 0
|
||||
return Promise.resolve(true)
|
||||
|
||||
DatabaseStore = require './database-store'
|
||||
DatabaseStore.findByLocalId(Message, @localId).then (draft) =>
|
||||
draft = @applyToModel(draft)
|
||||
DatabaseStore.persistModel(draft)
|
||||
@_pending = {}
|
||||
DatabaseStore.persistModel(draft)
|
||||
|
||||
applyToModel: (model) =>
|
||||
model.fromJSON(@_pending) if model
|
||||
|
|
|
@ -38,11 +38,46 @@ DraftStore = Reflux.createStore
|
|||
|
||||
@listenTo Actions.removeFile, @_onRemoveFile
|
||||
@listenTo Actions.attachFileComplete, @_onAttachFileComplete
|
||||
|
||||
@_drafts = []
|
||||
@_draftSessions = {}
|
||||
|
||||
# TODO: Doesn't work if we do window.addEventListener, but this is
|
||||
# fragile. Pending an Atom fix perhaps?
|
||||
window.onbeforeunload = (event) =>
|
||||
promises = []
|
||||
|
||||
# Normally we'd just append all promises, even the ones already
|
||||
# fulfilled (nothing to save), but in this case we only want to
|
||||
# block window closing if we have to do real work. Calling
|
||||
# window.close() within on onbeforeunload could do weird things.
|
||||
for key, session of @_draftSessions
|
||||
promise = session.changes.commit()
|
||||
if not promise.isFulfilled()
|
||||
promises.push(promise)
|
||||
|
||||
if promises.length > 0
|
||||
Promise.settle(promises).then =>
|
||||
@_draftSessions = {}
|
||||
window.close()
|
||||
|
||||
# Stop and wait before closing
|
||||
return false
|
||||
else
|
||||
# Continue closing
|
||||
return true
|
||||
|
||||
DatabaseStore.findAll(Message, draft: true).then (drafts) =>
|
||||
@_drafts = drafts
|
||||
@trigger({})
|
||||
|
||||
######### PUBLIC #######################################################
|
||||
|
||||
# Returns a promise
|
||||
|
||||
items: ->
|
||||
@_drafts
|
||||
|
||||
sessionForLocalId: (localId) ->
|
||||
@_draftSessions[localId] ?= new DraftStoreProxy(localId)
|
||||
@_draftSessions[localId]
|
||||
|
@ -53,7 +88,10 @@ DraftStore = Reflux.createStore
|
|||
return unless change.objectClass is Message.name
|
||||
containsDraft = _.some(change.objects, (msg) -> msg.draft)
|
||||
return unless containsDraft
|
||||
@trigger(change)
|
||||
|
||||
DatabaseStore.findAll(Message, draft: true).then (drafts) =>
|
||||
@_drafts = drafts
|
||||
@trigger(change)
|
||||
|
||||
_onComposeReply: (context) ->
|
||||
@_newMessageWithContext context, (thread, message) ->
|
||||
|
@ -139,7 +177,7 @@ DraftStore = Reflux.createStore
|
|||
|
||||
_onSendDraft: (draftLocalId) ->
|
||||
# Immediately save any pending changes so we don't save after sending
|
||||
save = @_draftSessions[draftLocalId]?.changes.commit() ? Promise.resolve()
|
||||
save = @_draftSessions[draftLocalId]?.changes.commit()
|
||||
save.then ->
|
||||
# Queue the task to send the draft
|
||||
Actions.queueTask(new SendDraftTask(draftLocalId))
|
||||
|
|
24
src/flux/stores/workspace-store.coffee
Normal file
24
src/flux/stores/workspace-store.coffee
Normal file
|
@ -0,0 +1,24 @@
|
|||
Reflux = require 'reflux'
|
||||
NamespaceStore = require './namespace-store'
|
||||
Actions = require '../actions'
|
||||
|
||||
WorkspaceStore = Reflux.createStore
|
||||
init: ->
|
||||
@_resetInstanceVars()
|
||||
@listenTo Actions.selectView, @_onSelectView
|
||||
|
||||
_resetInstanceVars: ->
|
||||
@_view = 'threads'
|
||||
|
||||
# Inbound Events
|
||||
|
||||
_onSelectView: (view) ->
|
||||
@_view = view
|
||||
@trigger(@)
|
||||
|
||||
# Accessing Data
|
||||
|
||||
selectedView: ->
|
||||
@_view
|
||||
|
||||
module.exports = WorkspaceStore
|
|
@ -6,7 +6,7 @@ Sheet = require './sheet'
|
|||
SheetStore = Reflux.createStore
|
||||
init: ->
|
||||
@_stack = []
|
||||
@pushSheet(<Sheet type="ThreadList" depth=0 key="0" />)
|
||||
@pushSheet(<Sheet type="Root" depth=0 key="0" />)
|
||||
|
||||
@listenTo Actions.popSheet, @popSheet
|
||||
|
||||
|
|
|
@ -93,8 +93,8 @@ Sheet = React.createClass
|
|||
# Load components that are part of our sheet. For each column,
|
||||
# (eg 'Center') we look for items with a matching `role`. We
|
||||
# then pull toolbar items the following places:
|
||||
#
|
||||
# - ThreadList:Center:Toolbar
|
||||
#
|
||||
# - Root:Center:Toolbar
|
||||
# - ComposeButton:Toolbar
|
||||
#
|
||||
_getComponentRegistryState: ->
|
||||
|
|
74
static/components/list-tabular.less
Normal file
74
static/components/list-tabular.less
Normal file
|
@ -0,0 +1,74 @@
|
|||
@import "ui-variables";
|
||||
|
||||
.list-container {
|
||||
position: relative;
|
||||
|
||||
.list-item {
|
||||
font-size: @font-size-base;
|
||||
line-height: @line-height-large;
|
||||
color: @text-color;
|
||||
background: @list-bg;
|
||||
|
||||
&:hover {
|
||||
background: @list-hover-bg;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: @list-active-bg;
|
||||
color: @list-active-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-tabular {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.list-tabular-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
&:hover {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.list-rows {
|
||||
overflow: auto;
|
||||
// Add back when when we re-implement list-headers
|
||||
// padding-top: @font-size-base * 2; /* height of list-headers*/
|
||||
}
|
||||
|
||||
.list-column {
|
||||
// The width is set by React.
|
||||
display: inline-block;
|
||||
padding: @padding-base-vertical @padding-base-horizontal @padding-base-vertical @padding-base-horizontal;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
border-bottom: 1px solid @list-border;
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.list-headers {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
background: fade(@list-bg,90%);
|
||||
font-size: @font-size-base;
|
||||
line-height: @font-size-base * 1.6;
|
||||
height:@font-size-base * 2;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
}
|
||||
|
||||
}
|
|
@ -16,3 +16,4 @@
|
|||
@import "components/menu";
|
||||
@import "components/tokenizing-text-field";
|
||||
@import "components/extra";
|
||||
@import "components/list-tabular";
|
||||
|
|
Loading…
Add table
Reference in a new issue