mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-11-12 04:25:31 +08:00
f1ed46d144
Summary: I'm making these up based on gut feeling, so feel free to completely change these. Test Plan: manual Reviewers: spang, mark, evan Reviewed By: mark, evan Differential Revision: https://phab.nylas.com/D4026
436 lines
13 KiB
CoffeeScript
436 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,
|
|
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}
|
|
onLoad={@_onMessageLoaded}
|
|
/>
|
|
)
|
|
|
|
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")
|
|
|
|
_onMessageLoaded: =>
|
|
if @state.currentThread
|
|
timerKey = "select-thread-#{@state.currentThread.id}"
|
|
if NylasEnv.timer.isPending(timerKey)
|
|
actionTimeMs = NylasEnv.timer.stop(timerKey)
|
|
messageCount = (@state.messages || []).length
|
|
Actions.recordPerfMetric({
|
|
sample: 0.1,
|
|
action: 'select-thread',
|
|
actionTimeMs,
|
|
messageCount,
|
|
})
|
|
|
|
_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)
|