Mailspring/internal_packages/composer/lib/participants-text-field.jsx

202 lines
6.5 KiB
React
Raw Normal View History

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 React from 'react';
import _ from 'underscore';
import {remote} from 'electron';
import {Utils, Contact, ContactStore} from 'nylas-exports';
import {TokenizingTextField, Menu, InjectedComponentSet} from 'nylas-component-kit';
export default class ParticipantsTextField extends React.Component {
static displayName = 'ParticipantsTextField';
static propTypes = {
// The name of the field, used for both display purposes and also
// to modify the `participants` provided.
field: React.PropTypes.string,
// An object containing arrays of participants. Typically, this is
// {to: [], cc: [], bcc: []}. Each ParticipantsTextField needs all of
// the values, because adding an element to one field may remove it
// from another.
participants: React.PropTypes.object.isRequired,
// The function to call with an updated `participants` object when
// changes are made.
change: React.PropTypes.func.isRequired,
className: React.PropTypes.string,
onEmptied: React.PropTypes.func,
onFocus: React.PropTypes.func,
}
static defaultProps = {
visible: true,
}
shouldComponentUpdate(nextProps, nextState) {
return !Utils.isEqualReact(nextProps, this.props) || !Utils.isEqualReact(nextState, this.state);
}
// Public. Can be called by any component that has a ref to this one to
// focus the input field.
focus = () => {
this.refs.textField.focus();
}
_completionNode = (p) => {
return (
<Menu.NameEmailItem name={p.name} email={p.email} />
);
}
_tokenNode = (p) => {
let chipText = p.email;
if (p.name && (p.name.length > 0) && (p.name !== p.email)) {
chipText = p.name;
}
return (
<div className="participant">
<InjectedComponentSet
matching={{role: "Composer:RecipientChip"}}
exposedProps={{contact: p}}
direction="column"
inline
/>
<span className="participant-primary">{chipText}</span>
</div>
);
}
_tokensForString = (string, options = {}) => {
// If the input is a string, parse out email addresses and build
// an array of contact objects. For each email address wrapped in
// parentheses, look for a preceding name, if one exists.
if (string.length === 0) {
return Promise.resolve([]);
}
return ContactStore.parseContactsInString(string, options).then((contacts) => {
if (contacts.length > 0) {
return Promise.resolve(contacts);
}
// If no contacts are returned, treat the entire string as a single
// (malformed) contact object.
return [new Contact({email: string, name: null})];
});
}
_remove = (values) => {
const field = this.props.field;
const updates = {};
updates[field] = _.reject(this.props.participants[field], (p) =>
values.includes(p.email) || values.map(o => o.email).includes(p.email)
);
this.props.change(updates);
}
_edit = (token, replacementString) => {
const field = this.props.field;
const tokenIndex = this.props.participants[field].indexOf(token);
this._tokensForString(replacementString).then((replacements) => {
const updates = {};
updates[field] = [].concat(this.props.participants[field]);
updates[field].splice(tokenIndex, 1, ...replacements);
this.props.change(updates);
});
}
_add = (values, options = {}) => {
// It's important we return here (as opposed to ignoring the
// `this.props.change` callback) because this method is asynchronous.
// The `tokensPromise` may be formed with an empty draft, but resolved
// after a draft was prepared. This would cause the bad data to be
// propagated.
// If the input is a string, parse out email addresses and build
// an array of contact objects. For each email address wrapped in
// parentheses, look for a preceding name, if one exists.
let tokensPromise = null;
if (_.isString(values)) {
tokensPromise = this._tokensForString(values, options);
} else {
tokensPromise = Promise.resolve(values);
}
tokensPromise.then((tokens) => {
// Safety check: remove anything from the incoming tokens that isn't
// a Contact. We should never receive anything else in the tokens array.
const contactTokens = tokens.filter(value => value instanceof Contact);
const updates = {}
for (const field of Object.keys(this.props.participants)) {
updates[field] = [].concat(this.props.participants[field]);
}
for (const token of contactTokens) {
// first remove the participant from all the fields. This ensures
// that drag and drop isn't "drag and copy." and you can't have the
// same recipient in multiple places.
for (const field of Object.keys(this.props.participants)) {
updates[field] = _.reject(updates[field], p => p.email === token.email)
}
// add the participant to field
updates[this.props.field] = _.union(updates[this.props.field], [token]);
}
this.props.change(updates);
});
return "";
}
_onShowContextMenu = (participant) => {
// Warning: Menu is already initialized as Menu.cjsx!
const MenuClass = remote.require('menu');
const MenuItem = remote.require('menu-item');
const menu = new MenuClass();
menu.append(new MenuItem({
label: `Copy ${participant.email}`,
click: () => require('clipboard').writeText(participant.email),
}))
menu.append(new MenuItem({
type: 'separator',
}))
menu.append(new MenuItem({
label: 'Remove',
click: () => this._remove([participant]),
}))
menu.popup(remote.getCurrentWindow());
}
render() {
const classSet = {};
classSet[this.props.field] = true;
return (
<div className={this.props.className}>
<TokenizingTextField
ref="textField"
tokens={this.props.participants[this.props.field]}
tokenKey={ (p) => p.email }
tokenIsValid={ (p) => ContactStore.isValidContact(p) }
tokenNode={this._tokenNode}
onRequestCompletions={ (input) => ContactStore.searchContacts(input) }
completionNode={this._completionNode}
onAdd={this._add}
onRemove={this._remove}
onEdit={this._edit}
onEmptied={this.props.onEmptied}
onFocus={this.props.onFocus}
onTokenAction={this._onShowContextMenu}
menuClassSet={classSet}
menuPrompt={this.props.field}
/>
</div>
);
}
}