mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-11-14 13:44:41 +08:00
44a7fa7117
Summary: This commit adds a couple of missing assets, including the icon for the plugin list and other misc icons. It also switches to the new UI where we use the thread timestamps to display the reminder date in the Reminders perspective instead of using mail labels. It also adds a header to the threads inside the reminders perspective to indicate that a reminder will be triggered if no one replies to their email. It also adds a header to indicate when a message has been brought back to the inbox due to a reminder based on sdw's designs. Finally it restores some code that magically disappeared when landing reminders + other misc cleanup Test Plan: Manual Reviewers: bengotow, halla Reviewed By: bengotow, halla Differential Revision: https://phab.nylas.com/D3388
423 lines
13 KiB
CoffeeScript
423 lines
13 KiB
CoffeeScript
_ = require 'underscore'
|
|
React = require 'react'
|
|
ReactDOM = require 'react-dom'
|
|
classNames = require 'classnames'
|
|
FindInThread = require('./find-in-thread').default
|
|
MessageItemContainer = require './message-item-container'
|
|
|
|
{Utils,
|
|
Actions,
|
|
Message,
|
|
DraftStore,
|
|
MessageStore,
|
|
AccountStore,
|
|
DatabaseStore,
|
|
WorkspaceStore,
|
|
ChangeLabelsTask,
|
|
ComponentRegistry,
|
|
ChangeStarredTask,
|
|
SearchableComponentStore
|
|
SearchableComponentMaker} = require("nylas-exports")
|
|
|
|
{Spinner,
|
|
RetinaImg,
|
|
MailLabelSet,
|
|
ScrollRegion,
|
|
MailImportantIcon,
|
|
InjectedComponent,
|
|
KeyCommandsRegion,
|
|
InjectedComponentSet} = require('nylas-component-kit')
|
|
|
|
class MessageListScrollTooltip extends React.Component
|
|
@displayName: 'MessageListScrollTooltip'
|
|
@propTypes:
|
|
viewportCenter: React.PropTypes.number.isRequired
|
|
totalHeight: React.PropTypes.number.isRequired
|
|
|
|
componentWillMount: =>
|
|
@setupForProps(@props)
|
|
|
|
componentWillReceiveProps: (newProps) =>
|
|
@setupForProps(newProps)
|
|
|
|
shouldComponentUpdate: (newProps, newState) =>
|
|
not _.isEqual(@state,newState)
|
|
|
|
setupForProps: (props) ->
|
|
# Technically, we could have MessageList provide the currently visible
|
|
# item index, but the DOM approach is simple and self-contained.
|
|
#
|
|
els = document.querySelectorAll('.message-item-wrap')
|
|
idx = _.findIndex els, (el) -> el.offsetTop > props.viewportCenter
|
|
if idx is -1
|
|
idx = els.length
|
|
|
|
@setState
|
|
idx: idx
|
|
count: els.length
|
|
|
|
render: ->
|
|
<div className="scroll-tooltip">
|
|
{@state.idx} of {@state.count}
|
|
</div>
|
|
|
|
class MessageList extends React.Component
|
|
@displayName: 'MessageList'
|
|
@containerRequired: false
|
|
@containerStyles:
|
|
minWidth: 500
|
|
maxWidth: 999999
|
|
|
|
constructor: (@props) ->
|
|
@state = @_getStateFromStores()
|
|
@state.minified = true
|
|
@_draftScrollInProgress = false
|
|
@MINIFY_THRESHOLD = 3
|
|
|
|
componentDidMount: =>
|
|
@_unsubscribers = []
|
|
@_unsubscribers.push MessageStore.listen @_onChange
|
|
@_unsubscribers.push Actions.focusDraft.listen ({draftClientId}) =>
|
|
Utils.waitFor( => @_getMessageContainer(draftClientId)?).then =>
|
|
@_focusDraft(@_getMessageContainer(draftClientId))
|
|
.catch =>
|
|
|
|
componentWillUnmount: =>
|
|
unsubscribe() for unsubscribe in @_unsubscribers
|
|
|
|
shouldComponentUpdate: (nextProps, nextState) =>
|
|
not Utils.isEqualReact(nextProps, @props) or
|
|
not Utils.isEqualReact(nextState, @state)
|
|
|
|
componentDidUpdate: (prevProps, prevState) =>
|
|
|
|
_globalMenuItems: ->
|
|
toggleExpandedLabel = if @state.hasCollapsedItems then "Expand" else "Collapse"
|
|
[
|
|
{
|
|
"label": "Thread",
|
|
"submenu": [{
|
|
"label": "#{toggleExpandedLabel} conversation",
|
|
"command": "message-list:toggle-expanded",
|
|
"position": "endof=view-actions",
|
|
}]
|
|
}
|
|
]
|
|
|
|
_globalKeymapHandlers: ->
|
|
handlers =
|
|
'core:reply': =>
|
|
Actions.composeReply({
|
|
thread: @state.currentThread,
|
|
message: @_lastMessage(),
|
|
type: 'reply',
|
|
behavior: 'prefer-existing',
|
|
})
|
|
'core:reply-all': =>
|
|
Actions.composeReply({
|
|
thread: @state.currentThread,
|
|
message: @_lastMessage(),
|
|
type: 'reply-all',
|
|
behavior: 'prefer-existing',
|
|
})
|
|
'core:forward': => @_onForward()
|
|
'core:print-thread': => @_onPrintThread()
|
|
'core:messages-page-up': => @_onScrollByPage(-1)
|
|
'core:messages-page-down': => @_onScrollByPage(1)
|
|
|
|
if @state.canCollapse
|
|
handlers['message-list:toggle-expanded'] = => @_onToggleAllMessagesExpanded()
|
|
|
|
handlers
|
|
|
|
_getMessageContainer: (clientId) =>
|
|
@refs["message-container-#{clientId}"]
|
|
|
|
_focusDraft: (draftElement) =>
|
|
# Note: We don't want the contenteditable view competing for scroll offset,
|
|
# so we block incoming childScrollRequests while we scroll to the new draft.
|
|
@_draftScrollInProgress = true
|
|
draftElement.focus()
|
|
@refs.messageWrap.scrollTo(draftElement, {
|
|
position: ScrollRegion.ScrollPosition.Top,
|
|
settle: true,
|
|
done: =>
|
|
@_draftScrollInProgress = false
|
|
})
|
|
|
|
_onForward: =>
|
|
return unless @state.currentThread
|
|
Actions.composeForward(thread: @state.currentThread)
|
|
|
|
render: =>
|
|
if not @state.currentThread
|
|
return <span />
|
|
|
|
wrapClass = classNames
|
|
"messages-wrap": true
|
|
"ready": not @state.loading
|
|
|
|
messageListClass = classNames
|
|
"message-list": true
|
|
"height-fix": SearchableComponentStore.searchTerm isnt null
|
|
|
|
<KeyCommandsRegion
|
|
globalHandlers={@_globalKeymapHandlers()}
|
|
globalMenuItems={@_globalMenuItems()}>
|
|
<FindInThread ref="findInThread" />
|
|
<div className={messageListClass} id="message-list">
|
|
<ScrollRegion tabIndex="-1"
|
|
className={wrapClass}
|
|
scrollbarTickProvider={SearchableComponentStore}
|
|
scrollTooltipComponent={MessageListScrollTooltip}
|
|
ref="messageWrap">
|
|
{@_renderSubject()}
|
|
<div className="headers" style={position:'relative'}>
|
|
<InjectedComponentSet
|
|
className="message-list-headers"
|
|
matching={{role: "MessageListHeaders"}}
|
|
exposedProps={{thread: @state.currentThread, messages: @state.messages}}
|
|
direction="column"
|
|
/>
|
|
</div>
|
|
{@_messageElements()}
|
|
</ScrollRegion>
|
|
<Spinner visible={@state.loading} />
|
|
</div>
|
|
</KeyCommandsRegion>
|
|
|
|
_renderSubject: ->
|
|
subject = @state.currentThread.subject
|
|
subject = "(No Subject)" if not subject or subject.length is 0
|
|
|
|
<div className="message-subject-wrap">
|
|
<MailImportantIcon thread={@state.currentThread}/>
|
|
<div style={flex: 1}>
|
|
<span className="message-subject">{subject}</span>
|
|
<MailLabelSet
|
|
removable={true}
|
|
messages={@state.messages}
|
|
thread={@state.currentThread}
|
|
includeCurrentCategories={true}
|
|
/>
|
|
</div>
|
|
{@_renderIcons()}
|
|
</div>
|
|
|
|
_renderIcons: =>
|
|
<div className="message-icons-wrap">
|
|
{@_renderExpandToggle()}
|
|
<div onClick={@_onPrintThread}>
|
|
<RetinaImg name="print.png" title="Print Thread" mode={RetinaImg.Mode.ContentIsMask}/>
|
|
</div>
|
|
{@_renderPopoutToggle()}
|
|
</div>
|
|
|
|
_renderExpandToggle: =>
|
|
return <span/> unless @state.canCollapse
|
|
|
|
if @state.hasCollapsedItems
|
|
<div onClick={@_onToggleAllMessagesExpanded}>
|
|
<RetinaImg name={"expand.png"} title={"Expand All"} mode={RetinaImg.Mode.ContentIsMask}/>
|
|
</div>
|
|
else
|
|
<div onClick={@_onToggleAllMessagesExpanded}>
|
|
<RetinaImg name={"collapse.png"} title={"Collapse All"} mode={RetinaImg.Mode.ContentIsMask}/>
|
|
</div>
|
|
|
|
_renderPopoutToggle: =>
|
|
if NylasEnv.isThreadWindow()
|
|
<div onClick={@_onPopThreadIn}>
|
|
<RetinaImg name="thread-popin.png" title="Pop thread in" mode={RetinaImg.Mode.ContentIsMask}/>
|
|
</div>
|
|
else
|
|
<div onClick={@_onPopoutThread}>
|
|
<RetinaImg name="thread-popout.png" title="Popout thread" mode={RetinaImg.Mode.ContentIsMask}/>
|
|
</div>
|
|
|
|
|
|
_renderReplyArea: =>
|
|
<div className="footer-reply-area-wrap" onClick={@_onClickReplyArea} key='reply-area'>
|
|
<div className="footer-reply-area">
|
|
<RetinaImg name="#{@_replyType()}-footer.png" mode={RetinaImg.Mode.ContentIsMask}/>
|
|
<span className="reply-text">Write a reply…</span>
|
|
</div>
|
|
</div>
|
|
|
|
_lastMessage: =>
|
|
_.last(_.filter((@state.messages ? []), (m) -> not m.draft))
|
|
|
|
# Returns either "reply" or "reply-all"
|
|
_replyType: =>
|
|
defaultReplyType = NylasEnv.config.get('core.sending.defaultReplyType')
|
|
lastMessage = @_lastMessage()
|
|
return 'reply' unless lastMessage
|
|
|
|
if lastMessage.canReplyAll()
|
|
if defaultReplyType is 'reply-all'
|
|
return 'reply-all'
|
|
else
|
|
return 'reply'
|
|
else
|
|
return 'reply'
|
|
|
|
_onToggleAllMessagesExpanded: ->
|
|
Actions.toggleAllMessagesExpanded()
|
|
|
|
_onPrintThread: =>
|
|
node = ReactDOM.findDOMNode(@)
|
|
Actions.printThread(@state.currentThread, node.innerHTML)
|
|
|
|
_onPopThreadIn: =>
|
|
return unless @state.currentThread
|
|
Actions.focusThreadMainWindow(@state.currentThread)
|
|
NylasEnv.close()
|
|
|
|
_onPopoutThread: =>
|
|
return unless @state.currentThread
|
|
Actions.popoutThread(@state.currentThread)
|
|
# This returns the single-pane view to the inbox, and does nothing for
|
|
# double-pane view because we're at the root sheet.
|
|
Actions.popSheet()
|
|
|
|
_onClickReplyArea: =>
|
|
return unless @state.currentThread
|
|
Actions.composeReply({
|
|
thread: @state.currentThread,
|
|
message: @_lastMessage(),
|
|
type: @_replyType(),
|
|
behavior: 'prefer-existing-if-pristine',
|
|
})
|
|
|
|
_messageElements: =>
|
|
elements = []
|
|
|
|
hasReplyArea = not _.last(@state.messages)?.draft
|
|
messages = @_messagesWithMinification(@state.messages)
|
|
messages.forEach (message, idx) =>
|
|
|
|
if message.type is "minifiedBundle"
|
|
elements.push(@_renderMinifiedBundle(message))
|
|
return
|
|
|
|
collapsed = !@state.messagesExpandedState[message.id]
|
|
isLastMsg = (messages.length - 1 is idx)
|
|
isBeforeReplyArea = isLastMsg and hasReplyArea
|
|
|
|
elements.push(
|
|
<MessageItemContainer
|
|
key={message.clientId}
|
|
ref={"message-container-#{message.clientId}"}
|
|
thread={@state.currentThread}
|
|
message={message}
|
|
messages={@state.messages}
|
|
collapsed={collapsed}
|
|
isLastMsg={isLastMsg}
|
|
isBeforeReplyArea={isBeforeReplyArea}
|
|
scrollTo={@_scrollTo}
|
|
/>
|
|
)
|
|
|
|
if hasReplyArea
|
|
elements.push(@_renderReplyArea())
|
|
|
|
return elements
|
|
|
|
_renderMinifiedBundle: (bundle) ->
|
|
BUNDLE_HEIGHT = 36
|
|
lines = bundle.messages[0...10]
|
|
h = Math.round(BUNDLE_HEIGHT / lines.length)
|
|
|
|
<div className="minified-bundle"
|
|
onClick={ => @setState minified: false }
|
|
key={Utils.generateTempId()}>
|
|
<div className="num-messages">{bundle.messages.length} older messages</div>
|
|
<div className="msg-lines" style={height: h*lines.length}>
|
|
{lines.map (msg, i) ->
|
|
<div key={msg.id} style={height: h*2, top: -h*i} className="msg-line"></div>}
|
|
</div>
|
|
</div>
|
|
|
|
_messagesWithMinification: (messages=[]) =>
|
|
return messages unless @state.minified
|
|
|
|
messages = _.clone(messages)
|
|
minifyRanges = []
|
|
consecutiveCollapsed = 0
|
|
|
|
messages.forEach (message, idx) =>
|
|
return if idx is 0 # Never minify the 1st message
|
|
|
|
expandState = @state.messagesExpandedState[message.id]
|
|
|
|
if not expandState
|
|
consecutiveCollapsed += 1
|
|
else
|
|
# We add a +1 because we don't minify the last collapsed message,
|
|
# but the MINIFY_THRESHOLD refers to the smallest N that can be in
|
|
# the "N older messages" minified block.
|
|
if expandState is "default"
|
|
minifyOffset = 1
|
|
else # if expandState is "explicit"
|
|
minifyOffset = 0
|
|
|
|
if consecutiveCollapsed >= @MINIFY_THRESHOLD + minifyOffset
|
|
minifyRanges.push
|
|
start: idx - consecutiveCollapsed
|
|
length: (consecutiveCollapsed - minifyOffset)
|
|
consecutiveCollapsed = 0
|
|
|
|
indexOffset = 0
|
|
for range in minifyRanges
|
|
start = range.start - indexOffset
|
|
minified =
|
|
type: "minifiedBundle"
|
|
messages: messages[start...(start+range.length)]
|
|
messages.splice(start, range.length, minified)
|
|
|
|
# While we removed `range.length` items, we also added 1 back in.
|
|
indexOffset += (range.length - 1)
|
|
|
|
return messages
|
|
|
|
# Some child components (like the composer) might request that we scroll
|
|
# to a given location. If `selectionTop` is defined that means we should
|
|
# scroll to that absolute position.
|
|
#
|
|
# If messageId and location are defined, that means we want to scroll
|
|
# smoothly to the top of a particular message.
|
|
_scrollTo: ({clientId, rect, position}={}) =>
|
|
return if @_draftScrollInProgress
|
|
if clientId
|
|
messageElement = @_getMessageContainer(clientId)
|
|
return unless messageElement
|
|
pos = position ? ScrollRegion.ScrollPosition.Visible
|
|
@refs.messageWrap.scrollTo(messageElement, {
|
|
position: pos
|
|
})
|
|
else if rect
|
|
@refs.messageWrap.scrollToRect(rect, {
|
|
position: ScrollRegion.ScrollPosition.CenterIfInvisible
|
|
})
|
|
else
|
|
throw new Error("onChildScrollRequest: expected clientId or rect")
|
|
|
|
_onScrollByPage: (direction) =>
|
|
height = ReactDOM.findDOMNode(@refs.messageWrap).clientHeight
|
|
@refs.messageWrap.scrollTop += height * direction
|
|
|
|
_onChange: =>
|
|
newState = @_getStateFromStores()
|
|
if @state.currentThread?.id isnt newState.currentThread?.id
|
|
newState.minified = true
|
|
@setState(newState)
|
|
|
|
_getStateFromStores: =>
|
|
messages: (MessageStore.items() ? [])
|
|
messagesExpandedState: MessageStore.itemsExpandedState()
|
|
canCollapse: MessageStore.items().length > 1
|
|
hasCollapsedItems: MessageStore.hasCollapsedItems()
|
|
currentThread: MessageStore.thread()
|
|
loading: MessageStore.itemsLoading()
|
|
|
|
module.exports = SearchableComponentMaker.extend(MessageList)
|