diff options
Diffstat (limited to 'app/assets/javascripts/components/features/compose')
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'; |