fix(focus): Remove focusedField in favor of imperative focus, break apart ComposerView
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
2016-04-05 06:22:01 +08:00
|
|
|
import _ from 'underscore'
|
|
|
|
import _str from 'underscore.string'
|
|
|
|
import {React, Actions, ExtensionRegistry} from 'nylas-exports'
|
|
|
|
import {Menu, RetinaImg, ButtonDropdown} from 'nylas-component-kit'
|
|
|
|
|
|
|
|
const CONFIG_KEY = "core.sending.defaultSendType";
|
|
|
|
|
|
|
|
export default class SendActionButton extends React.Component {
|
|
|
|
static displayName = "SendActionButton";
|
|
|
|
|
|
|
|
static propTypes = {
|
|
|
|
draft: React.PropTypes.object,
|
|
|
|
isValidDraft: React.PropTypes.func,
|
|
|
|
};
|
|
|
|
|
|
|
|
constructor(props) {
|
|
|
|
super(props)
|
|
|
|
this.state = {
|
|
|
|
actionConfigs: this._actionConfigs(this.props),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
componentDidMount() {
|
|
|
|
this.unsub = ExtensionRegistry.Composer.listen(this._onExtensionsChanged);
|
|
|
|
}
|
|
|
|
|
|
|
|
componentWillReceiveProps(newProps) {
|
|
|
|
this.setState({actionConfigs: this._actionConfigs(newProps)});
|
|
|
|
}
|
|
|
|
|
|
|
|
componentWillUnmount() {
|
|
|
|
this.unsub();
|
|
|
|
}
|
|
|
|
|
2016-04-23 09:23:00 +08:00
|
|
|
static containerRequired = false
|
|
|
|
|
|
|
|
primaryClick() {
|
fix(focus): Remove focusedField in favor of imperative focus, break apart ComposerView
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
2016-04-05 06:22:01 +08:00
|
|
|
this._onPrimaryClick();
|
|
|
|
}
|
|
|
|
|
|
|
|
_configKeyFromTitle(title) {
|
|
|
|
return _str.dasherize(title.toLowerCase());
|
|
|
|
}
|
|
|
|
|
|
|
|
_defaultActionConfig() {
|
|
|
|
return ({
|
|
|
|
title: "Send",
|
|
|
|
iconUrl: null,
|
|
|
|
onSend: ({draft}) => Actions.sendDraft(draft.clientId),
|
|
|
|
configKey: "send",
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
_actionConfigs(props) {
|
|
|
|
const actionConfigs = [this._defaultActionConfig()]
|
|
|
|
|
|
|
|
for (const extension of ExtensionRegistry.Composer.extensions()) {
|
|
|
|
if (!extension.sendActionConfig) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
const actionConfig = extension.sendActionConfig({draft: props.draft});
|
|
|
|
if (actionConfig) {
|
|
|
|
this._verifyConfig(actionConfig, extension);
|
|
|
|
actionConfig.configKey = this._configKeyFromTitle(actionConfig.title);
|
|
|
|
actionConfigs.push(actionConfig);
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
NylasEnv.reportError(err);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return actionConfigs;
|
|
|
|
}
|
|
|
|
|
|
|
|
_verifyConfig(config = {}, extension) {
|
|
|
|
const name = extension.name;
|
|
|
|
if (!_.isString(config.title)) {
|
|
|
|
throw new Error(`${name}.sendActionConfig must return a string "title"`);
|
|
|
|
}
|
|
|
|
if (!_.isFunction(config.onSend)) {
|
|
|
|
throw new Error(`${name}.sendActionConfig must return a "onSend" function that will be called when the action is selected`);
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
_onExtensionsChanged = () => {
|
|
|
|
this.setState({actionConfigs: this._actionConfigs(this.props)});
|
|
|
|
}
|
|
|
|
|
|
|
|
_onPrimaryClick = () => {
|
|
|
|
const {preferred} = this._orderedActionConfigs();
|
|
|
|
this._onSendWithAction(preferred);
|
|
|
|
}
|
|
|
|
|
|
|
|
_onSendWithAction = ({onSend}) => {
|
|
|
|
if (this.props.isValidDraft()) {
|
|
|
|
try {
|
|
|
|
onSend({draft: this.props.draft});
|
|
|
|
} catch (err) {
|
|
|
|
NylasEnv.reportError(err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_orderedActionConfigs() {
|
|
|
|
const configKeys = _.pluck(this.state.actionConfigs, "configKey");
|
|
|
|
let preferredKey = NylasEnv.config.get(CONFIG_KEY);
|
|
|
|
if (!preferredKey || !configKeys.includes(preferredKey)) {
|
|
|
|
preferredKey = this._defaultActionConfig().configKey;
|
|
|
|
}
|
|
|
|
|
|
|
|
const preferred = _.findWhere(this.state.actionConfigs, {configKey: preferredKey});
|
|
|
|
const rest = _.without(this.state.actionConfigs, preferred);
|
|
|
|
|
|
|
|
return {preferred, rest};
|
|
|
|
}
|
|
|
|
|
|
|
|
_contentForAction = ({iconUrl}) => {
|
|
|
|
let plusHTML = "";
|
|
|
|
let additionalImg = false;
|
|
|
|
|
|
|
|
if (iconUrl) {
|
|
|
|
plusHTML = (<span> + </span>);
|
|
|
|
additionalImg = (<RetinaImg url={iconUrl} mode={RetinaImg.Mode.ContentIsMask} />);
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<span>
|
|
|
|
<RetinaImg name="icon-composer-send.png" mode={RetinaImg.Mode.ContentIsMask} />
|
|
|
|
<span className="text">Send{plusHTML}</span>{additionalImg}
|
|
|
|
</span>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
_renderSingleButton() {
|
|
|
|
return (
|
|
|
|
<button
|
|
|
|
tabIndex={-1}
|
|
|
|
className={"btn btn-toolbar btn-normal btn-emphasis btn-text btn-send"}
|
|
|
|
style={{order: -100}}
|
|
|
|
onClick={this._onPrimaryClick}>
|
|
|
|
{this._contentForAction(this.state.actionConfigs[0])}
|
|
|
|
</button>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
_renderButtonDropdown() {
|
|
|
|
const {preferred, rest} = this._orderedActionConfigs()
|
|
|
|
|
|
|
|
const menu = (
|
|
|
|
<Menu
|
|
|
|
items={rest}
|
|
|
|
itemKey={ (actionConfig) => actionConfig.configKey }
|
|
|
|
itemContent={this._contentForAction}
|
|
|
|
onSelect={this._onSendWithAction}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<ButtonDropdown
|
|
|
|
className={"btn-send btn-emphasis btn-text"}
|
|
|
|
style={{order: -100}}
|
|
|
|
primaryItem={this._contentForAction(preferred)}
|
|
|
|
primaryTitle={preferred.title}
|
|
|
|
primaryClick={this._onPrimaryClick}
|
|
|
|
closeOnMenuClick
|
|
|
|
menu={menu}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
render() {
|
|
|
|
if (this.state.actionConfigs.length === 1) {
|
|
|
|
return this._renderSingleButton();
|
|
|
|
}
|
|
|
|
return this._renderButtonDropdown();
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|