about summary refs log tree commit diff
path: root/app/assets/javascripts/components/features/compose
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/components/features/compose')
-rw-r--r--app/assets/javascripts/components/features/compose/components/character_counter.jsx24
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx180
-rw-r--r--app/assets/javascripts/components/features/compose/components/drawer.jsx26
-rw-r--r--app/assets/javascripts/components/features/compose/components/navigation_bar.jsx30
-rw-r--r--app/assets/javascripts/components/features/compose/components/reply_indicator.jsx57
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_button.jsx39
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_form.jsx34
-rw-r--r--app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx58
-rw-r--r--app/assets/javascripts/components/features/compose/containers/navigation_container.jsx8
-rw-r--r--app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx17
-rw-r--r--app/assets/javascripts/components/features/compose/containers/upload_form_container.jsx17
-rw-r--r--app/assets/javascripts/components/features/compose/index.jsx9
12 files changed, 494 insertions, 5 deletions
diff --git a/app/assets/javascripts/components/features/compose/components/character_counter.jsx b/app/assets/javascripts/components/features/compose/components/character_counter.jsx
new file mode 100644
index 000000000..f0c1b7c8d
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/character_counter.jsx
@@ -0,0 +1,24 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const CharacterCounter = React.createClass({
+
+  propTypes: {
+    text: React.PropTypes.string.isRequired,
+    max: React.PropTypes.number.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const diff = this.props.max - this.props.text.length;
+
+    return (
+      <span style={{ fontSize: '16px', cursor: 'default' }}>
+        {diff}
+      </span>
+    );
+  }
+
+});
+
+export default CharacterCounter;
diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
new file mode 100644
index 000000000..ead8e0008
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -0,0 +1,180 @@
+import CharacterCounter from './character_counter';
+import Button from '../../../components/button';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ReplyIndicator from './reply_indicator';
+import UploadButton from './upload_button';
+import Autosuggest from 'react-autosuggest';
+import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container';
+import { debounce } from 'react-decoration';
+import UploadButtonContainer from '../containers/upload_button_container';
+
+const getTokenForSuggestions = (str, caretPosition) => {
+  let word;
+
+  let left  = str.slice(0, caretPosition).search(/\S+$/);
+  let right = str.slice(caretPosition).search(/\s/);
+
+  if (right < 0) {
+    word = str.slice(left);
+  } else {
+    word = str.slice(left, right + caretPosition);
+  }
+
+  if (!word || word.trim().length < 2 || word[0] !== '@') {
+    return null;
+  }
+
+  word = word.trim().toLowerCase().slice(1);
+
+  if (word.length > 0) {
+    return word;
+  } else {
+    return null;
+  }
+};
+
+const getSuggestionValue = suggestionId => suggestionId;
+const renderSuggestion   = suggestionId => <AutosuggestAccountContainer id={suggestionId} />;
+
+const textareaStyle = {
+  display: 'block',
+  boxSizing: 'border-box',
+  width: '100%',
+  height: '100px',
+  resize: 'none',
+  border: 'none',
+  color: '#282c37',
+  padding: '10px',
+  fontFamily: 'Roboto',
+  fontSize: '14px',
+  margin: '0'
+};
+
+const renderInputComponent = inputProps => (
+  <textarea {...inputProps} placeholder='What is on your mind?'  className='compose-form__textarea' style={textareaStyle} />
+);
+
+const ComposeForm = React.createClass({
+
+  propTypes: {
+    text: React.PropTypes.string.isRequired,
+    suggestion_token: React.PropTypes.string,
+    suggestions: React.PropTypes.array,
+    is_submitting: React.PropTypes.bool,
+    is_uploading: React.PropTypes.bool,
+    in_reply_to: ImmutablePropTypes.map,
+    onChange: React.PropTypes.func.isRequired,
+    onSubmit: React.PropTypes.func.isRequired,
+    onCancelReply: React.PropTypes.func.isRequired,
+    onClearSuggestions: React.PropTypes.func.isRequired,
+    onFetchSuggestions: React.PropTypes.func.isRequired,
+    onSuggestionSelected: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleChange (e) {
+    if (typeof e.target.value === 'undefined' || typeof e.target.value === 'number') {
+      return;
+    }
+
+    this.props.onChange(e.target.value);
+  },
+
+  handleKeyUp (e) {
+    if (e.keyCode === 13 && e.ctrlKey) {
+      this.props.onSubmit();
+    }
+  },
+
+  handleSubmit () {
+    this.props.onSubmit();
+  },
+
+  componentDidUpdate (prevProps) {
+    if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) {
+      const textarea = this.autosuggest.input;
+
+      if (textarea) {
+        textarea.focus();
+      }
+    }
+  },
+
+  onSuggestionsClearRequested () {
+    this.props.onClearSuggestions();
+  },
+
+  @debounce(500)
+  onSuggestionsFetchRequested ({ value }) {
+    const textarea = this.autosuggest.input;
+
+    if (textarea) {
+      const token = getTokenForSuggestions(value, textarea.selectionStart);
+
+      if (token !== null) {
+        this.props.onFetchSuggestions(token);
+      } else {
+        this.props.onClearSuggestions();
+      }
+    }
+  },
+
+  onSuggestionSelected (e, { suggestionValue }) {
+    const textarea = this.autosuggest.input;
+
+    if (textarea) {
+      this.props.onSuggestionSelected(textarea.selectionStart, suggestionValue);
+    }
+  },
+
+  setRef (c) {
+    this.autosuggest = c;
+  },
+
+  render () {
+    let replyArea  = '';
+    const disabled = this.props.is_submitting || this.props.is_uploading;
+
+    if (this.props.in_reply_to) {
+      replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
+    }
+
+    const inputProps = {
+      placeholder: 'What is on your mind?',
+      value: this.props.text,
+      onKeyUp: this.handleKeyUp,
+      onChange: this.handleChange,
+      disabled: disabled
+    };
+
+    return (
+      <div style={{ padding: '10px' }}>
+        {replyArea}
+
+        <Autosuggest
+          ref={this.setRef}
+          suggestions={this.props.suggestions}
+          focusFirstSuggestion={true}
+          onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+          onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+          onSuggestionSelected={this.onSuggestionSelected}
+          getSuggestionValue={getSuggestionValue}
+          renderSuggestion={renderSuggestion}
+          renderInputComponent={renderInputComponent}
+          inputProps={inputProps}
+        />
+
+        <div style={{ marginTop: '10px', overflow: 'hidden' }}>
+          <div style={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={disabled} /></div>
+          <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></div>
+          <UploadButtonContainer style={{ paddingTop: '4px' }} />
+        </div>
+      </div>
+    );
+  }
+
+});
+
+export default ComposeForm;
diff --git a/app/assets/javascripts/components/features/compose/components/drawer.jsx b/app/assets/javascripts/components/features/compose/components/drawer.jsx
new file mode 100644
index 000000000..d31d0e453
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/drawer.jsx
@@ -0,0 +1,26 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const style = {
+  boxSizing: 'border-box',
+  background: '#454b5e',
+  padding: '0',
+  display: 'flex',
+  flexDirection: 'column',
+  overflowY: 'auto'
+};
+
+const Drawer = React.createClass({
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    return (
+      <div className='drawer' style={style}>
+        {this.props.children}
+      </div>
+    );
+  }
+
+});
+
+export default Drawer;
diff --git a/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx b/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx
new file mode 100644
index 000000000..d2dae141f
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx
@@ -0,0 +1,30 @@
+import PureRenderMixin    from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar             from '../../../components/avatar';
+import IconButton         from '../../../components/icon_button';
+import DisplayName        from '../../../components/display_name';
+import { Link }           from 'react-router';
+
+const NavigationBar = React.createClass({
+  propTypes: {
+    account: ImmutablePropTypes.map.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    return (
+      <div style={{ padding: '10px', display: 'flex', cursor: 'default' }}>
+        <Link to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Link>
+
+        <div style={{ flex: '1 1 auto', marginLeft: '8px', color: '#9baec8' }}>
+          <strong style={{ fontWeight: '500', display: 'block', color: '#fff' }}>{this.props.account.get('acct')}</strong>
+          <a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}>Settings</a> · <Link to='/timelines/public' style={{ color: 'inherit', textDecoration: 'none' }}>Public timeline</Link> · <a href='/auth/sign_out' data-method='delete' style={{ color: 'inherit', textDecoration: 'none' }}>Logout</a>
+        </div>
+      </div>
+    );
+  }
+
+});
+
+export default NavigationBar;
diff --git a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx
new file mode 100644
index 000000000..6298d3de9
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx
@@ -0,0 +1,57 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from '../../../components/avatar';
+import IconButton from '../../../components/icon_button';
+import DisplayName from '../../../components/display_name';
+import emojione from 'emojione';
+
+emojione.imageType = 'png';
+emojione.sprites = false;
+emojione.imagePathPNG = '/emoji/';
+
+const ReplyIndicator = React.createClass({
+
+  contextTypes: {
+    router: React.PropTypes.object
+  },
+
+  propTypes: {
+    status: ImmutablePropTypes.map.isRequired,
+    onCancel: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleClick () {
+    this.props.onCancel();
+  },
+
+  handleAccountClick (e) {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+  },
+
+  render () {
+    let content = { __html: emojione.unicodeToImage(this.props.status.get('content')) };
+
+    return (
+      <div style={{ background: '#9baec8', padding: '10px' }}>
+        <div style={{ overflow: 'hidden', marginBottom: '5px' }}>
+          <div style={{ float: 'right', lineHeight: '24px' }}><IconButton title='Cancel' icon='times' onClick={this.handleClick} /></div>
+
+          <a href={this.props.status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#282c37', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}>
+            <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={this.props.status.getIn(['account', 'avatar'])} /></div>
+            <DisplayName account={this.props.status.get('account')} />
+          </a>
+        </div>
+
+        <div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
+      </div>
+    );
+  }
+
+});
+
+export default ReplyIndicator;
diff --git a/app/assets/javascripts/components/features/compose/components/upload_button.jsx b/app/assets/javascripts/components/features/compose/components/upload_button.jsx
new file mode 100644
index 000000000..8289e0a09
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/upload_button.jsx
@@ -0,0 +1,39 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import IconButton from '../../../components/icon_button';
+
+const UploadButton = React.createClass({
+
+  propTypes: {
+    disabled: React.PropTypes.bool,
+    onSelectFile: React.PropTypes.func.isRequired,
+    style: React.PropTypes.object
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleChange (e) {
+    if (e.target.files.length > 0) {
+      this.props.onSelectFile(e.target.files);
+    }
+  },
+
+  handleClick () {
+    this.fileElement.click();
+  },
+
+  setRef (c) {
+    this.fileElement = c;
+  },
+
+  render () {
+    return (
+      <div style={this.props.style}>
+        <IconButton icon='photo' title='Add media' disabled={this.props.disabled} onClick={this.handleClick} size={24} />
+        <input ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />
+      </div>
+    );
+  }
+
+});
+
+export default UploadButton;
diff --git a/app/assets/javascripts/components/features/compose/components/upload_form.jsx b/app/assets/javascripts/components/features/compose/components/upload_form.jsx
new file mode 100644
index 000000000..751f76ab7
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/upload_form.jsx
@@ -0,0 +1,34 @@
+import PureRenderMixin    from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import IconButton         from '../../../components/icon_button';
+
+const UploadForm = React.createClass({
+
+  propTypes: {
+    media: ImmutablePropTypes.list.isRequired,
+    is_uploading: React.PropTypes.bool,
+    onSelectFile: React.PropTypes.func.isRequired,
+    onRemoveFile: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const uploads = this.props.media.map(attachment => (
+      <div key={attachment.get('id')} style={{ borderRadius: '4px', marginBottom: '10px' }} className='transparent-background'>
+        <div style={{ width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}>
+          <IconButton icon='times' title='Undo' size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} />
+        </div>
+      </div>
+    ));
+
+    return (
+      <div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden' }}>
+        {uploads}
+      </div>
+    );
+  }
+
+});
+
+export default UploadForm;
diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
new file mode 100644
index 000000000..87bcd6b99
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
@@ -0,0 +1,58 @@
+import { connect } from 'react-redux';
+import ComposeForm from '../components/compose_form';
+import {
+  changeCompose,
+  submitCompose,
+  cancelReplyCompose,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  selectComposeSuggestion
+} from '../../../actions/compose';
+import { makeGetStatus } from '../../../selectors';
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = function (state, props) {
+    return {
+      text: state.getIn(['compose', 'text']),
+      suggestion_token: state.getIn(['compose', 'suggestion_token']),
+      suggestions: state.getIn(['compose', 'suggestions']).toJS(),
+      is_submitting: state.getIn(['compose', 'is_submitting']),
+      is_uploading: state.getIn(['compose', 'is_uploading']),
+      in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
+    };
+  };
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = function (dispatch) {
+  return {
+    onChange (text) {
+      dispatch(changeCompose(text));
+    },
+
+    onSubmit () {
+      dispatch(submitCompose());
+    },
+
+    onCancelReply () {
+      dispatch(cancelReplyCompose());
+    },
+
+    onClearSuggestions () {
+      dispatch(clearComposeSuggestions());
+    },
+
+    onFetchSuggestions (token) {
+      dispatch(fetchComposeSuggestions(token));
+    },
+
+    onSuggestionSelected (position, accountId) {
+      dispatch(selectComposeSuggestion(position, accountId));
+    }
+  }
+};
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx b/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx
new file mode 100644
index 000000000..51e2513d8
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx
@@ -0,0 +1,8 @@
+import { connect }   from 'react-redux';
+import NavigationBar from '../components/navigation_bar';
+
+const mapStateToProps = (state, props) => ({
+  account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
+});
+
+export default connect(mapStateToProps)(NavigationBar);
diff --git a/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx
new file mode 100644
index 000000000..4154b0737
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import UploadButton from '../components/upload_button';
+import { uploadCompose } from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+  disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onSelectFile (files) {
+    dispatch(uploadCompose(files));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);
diff --git a/app/assets/javascripts/components/features/compose/containers/upload_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/upload_form_container.jsx
new file mode 100644
index 000000000..a6a202e17
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/upload_form_container.jsx
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import UploadForm from '../components/upload_form';
+import { undoUploadCompose } from '../../../actions/compose';
+
+const mapStateToProps = (state, props) => ({
+  media: state.getIn(['compose', 'media_attachments']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onRemoveFile (media_id) {
+    dispatch(undoUploadCompose(media_id));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(UploadForm);
diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx
index 260f67034..a50118bef 100644
--- a/app/assets/javascripts/components/features/compose/index.jsx
+++ b/app/assets/javascripts/components/features/compose/index.jsx
@@ -1,8 +1,7 @@
-import Drawer               from '../ui/components/drawer';
-import ComposeFormContainer from '../ui/containers/compose_form_container';
-import FollowFormContainer  from '../ui/containers/follow_form_container';
-import UploadFormContainer  from '../ui/containers/upload_form_container';
-import NavigationContainer  from '../ui/containers/navigation_container';
+import Drawer               from './components/drawer';
+import ComposeFormContainer from './containers/compose_form_container';
+import UploadFormContainer  from './containers/upload_form_container';
+import NavigationContainer  from './containers/navigation_container';
 import PureRenderMixin      from 'react-addons-pure-render-mixin';
 import SuggestionsContainer from './containers/suggestions_container';
 import SearchContainer      from './containers/search_container';