mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-11-14 13:44:41 +08:00
f1c0a1615d
Summary: - Removes controlled focus in the composer! - No React components ever perfom focus in lifecycle methods. Never again. - A new `Utils.schedule({action, after, timeout})` helper makes it easy to say "setState or load draft, etc. and then focus" - The DraftStore issues a focusDraft action after creating a draft, which causes the MessageList to focus and scroll to the desired composer, which itself decides which field to focus. - The MessageList never focuses anything automatically. - Refactors ComposerView apart — ComposerHeader handles all top fields, DraftSessionContainer handles draft session initialization and exposes props to ComposerView - ComposerHeader now uses a KeyCommandRegion (with focusIn and focusOut) to do the expanding and collapsing of the participants fields. May rename that container very soon. - Removes all CommandRegistry handling of tab and shift-tab. Unless you preventDefault, the browser does it's thing. - Removes all tabIndexes greater than 1. This is an anti-pattern—assigning everything a tabIndex of 0 tells the browser to move between them based on their order in the DOM, and is almost always what you want. - Adds "TabGroupRegion" which allows you to create a tab/shift-tabbing group, (so tabbing does not leave the active composer). Can't believe this isn't a browser feature. Todos: - Occasionally, clicking out of the composer contenteditable requires two clicks. This is because atomicEdit is restoring selection within the contenteditable and breaking blur. - Because the ComposerView does not render until it has a draft, we're back to it being white in popout composers for a brief moment. We will fix this another way - all the "return unless draft" statements were untenable. - Clicking a row in the thread list no longer shifts focus to the message list and focuses the last draft. This will be restored soon. Test Plan: Broken Reviewers: juan, evan Reviewed By: juan, evan Differential Revision: https://phab.nylas.com/D2814
367 lines
12 KiB
CoffeeScript
Executable file
367 lines
12 KiB
CoffeeScript
Executable file
_ = require 'underscore'
|
|
React = require 'react'
|
|
ReactDOM = require 'react-dom'
|
|
classNames = require 'classnames'
|
|
FindInThread = require './find-in-thread'
|
|
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) =>
|
|
|
|
_globalKeymapHandlers: ->
|
|
'application:reply': =>
|
|
Actions.composeReply({
|
|
thread: @state.currentThread,
|
|
message: @_lastMessage(),
|
|
type: 'reply',
|
|
behavior: 'prefer-existing',
|
|
})
|
|
'application:reply-all': =>
|
|
Actions.composeReply({
|
|
thread: @state.currentThread,
|
|
message: @_lastMessage(),
|
|
type: 'reply-all',
|
|
behavior: 'prefer-existing',
|
|
})
|
|
'application:forward': => @_onForward()
|
|
'application:print-thread': => @_onPrintThread()
|
|
'core:messages-page-up': => @_onScrollByPage(-1)
|
|
'core:messages-page-down': => @_onScrollByPage(1)
|
|
|
|
_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()}>
|
|
<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}
|
|
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} 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>
|
|
</div>
|
|
|
|
_renderExpandToggle: =>
|
|
if MessageStore.items().length < 2
|
|
<span></span>
|
|
else if MessageStore.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>
|
|
|
|
_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)
|
|
|
|
_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}
|
|
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()
|
|
currentThread: MessageStore.thread()
|
|
loading: MessageStore.itemsLoading()
|
|
|
|
module.exports = SearchableComponentMaker.extend(MessageList)
|