@@ -13,6 +13,9 @@ export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; | |||
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; | |||
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; | |||
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; | |||
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; | |||
export function changeCompose(text) { | |||
return { | |||
type: COMPOSE_CHANGE, | |||
@@ -129,3 +132,27 @@ export function undoUploadCompose(media_id) { | |||
media_id: media_id | |||
}; | |||
}; | |||
export function clearComposeSuggestions() { | |||
return { | |||
type: COMPOSE_SUGGESTIONS_CLEAR | |||
}; | |||
}; | |||
export function fetchComposeSuggestions(token) { | |||
return (dispatch, getState) => { | |||
const loadedCandidates = getState().get('accounts').filter(item => item.get('acct').toLowerCase().slice(0, token.length) === token).map(item => ({ | |||
label: item.get('acct'), | |||
completion: item.get('acct').slice(0, token.length) | |||
})).toList().toJS(); | |||
dispatch(readyComposeSuggestions(loadedCandidates)); | |||
}; | |||
}; | |||
export function readyComposeSuggestions(accounts) { | |||
return { | |||
type: COMPOSE_SUGGESTIONS_READY, | |||
accounts | |||
}; | |||
}; |
@@ -4,11 +4,62 @@ 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'; | |||
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 = suggestion => suggestion; | |||
const renderSuggestion = suggestion => ( | |||
<span>{suggestion}</span> | |||
); | |||
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, | |||
suggestions: React.PropTypes.array, | |||
is_submitting: React.PropTypes.bool, | |||
is_uploading: React.PropTypes.bool, | |||
in_reply_to: ImmutablePropTypes.map, | |||
@@ -35,7 +86,39 @@ const ComposeForm = React.createClass({ | |||
componentDidUpdate (prevProps) { | |||
if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) { | |||
this.refs.textarea.focus(); | |||
const node = ReactDOM.findDOMNode(this.refs.autosuggest); | |||
const textarea = node.querySelector('textarea'); | |||
if (textarea) { | |||
textarea.focus(); | |||
} | |||
} | |||
}, | |||
onSuggestionsClearRequested () { | |||
this.props.onClearSuggestions(); | |||
}, | |||
onSuggestionsFetchRequested ({ value }) { | |||
const node = ReactDOM.findDOMNode(this.refs.autosuggest); | |||
const textarea = node.querySelector('textarea'); | |||
if (textarea) { | |||
const token = getTokenForSuggestions(value, textarea.selectionStart); | |||
if (token !== null) { | |||
this.props.onFetchSuggestions(token); | |||
} | |||
} | |||
}, | |||
onSuggestionSelected (e, { suggestionValue, method }) { | |||
const node = ReactDOM.findDOMNode(this.refs.autosuggest); | |||
const textarea = node.querySelector('textarea'); | |||
if (textarea) { | |||
const str = this.props.text; | |||
this.props.onChange([str.slice(0, textarea.selectionStart), suggestionValue, str.slice(textarea.selectionStart)].join('')); | |||
} | |||
}, | |||
@@ -47,11 +130,29 @@ const ComposeForm = React.createClass({ | |||
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} | |||
<textarea ref='textarea' disabled={disabled} placeholder='What is on your mind?' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='compose-form__textarea' style={{ display: 'block', boxSizing: 'border-box', width: '100%', height: '100px', resize: 'none', border: 'none', color: '#282c37', padding: '10px', fontFamily: 'Roboto', fontSize: '14px', margin: '0' }} /> | |||
<Autosuggest | |||
ref='autosuggest' | |||
suggestions={this.props.suggestions} | |||
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> | |||
@@ -24,7 +24,7 @@ const UploadButton = React.createClass({ | |||
return ( | |||
<div> | |||
<Button disabled={this.props.disabled} onClick={this.handleClick} block={true}> | |||
<i className='fa fa-fw fa-photo' /> Add images | |||
<i className='fa fa-fw fa-photo' /> Add media | |||
</Button> | |||
<input ref='fileElement' type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} /> | |||
@@ -1,7 +1,13 @@ | |||
import { connect } from 'react-redux'; | |||
import ComposeForm from '../components/compose_form'; | |||
import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose'; | |||
import { makeGetStatus } from '../../../selectors'; | |||
import { connect } from 'react-redux'; | |||
import ComposeForm from '../components/compose_form'; | |||
import { | |||
changeCompose, | |||
submitCompose, | |||
cancelReplyCompose, | |||
clearComposeSuggestions, | |||
fetchComposeSuggestions | |||
} from '../../../actions/compose'; | |||
import { makeGetStatus } from '../../../selectors'; | |||
const makeMapStateToProps = () => { | |||
const getStatus = makeGetStatus(); | |||
@@ -9,6 +15,7 @@ const makeMapStateToProps = () => { | |||
const mapStateToProps = function (state, props) { | |||
return { | |||
text: state.getIn(['compose', 'text']), | |||
suggestions: state.getIn(['compose', 'suggestions']), | |||
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'])) | |||
@@ -20,16 +27,24 @@ const makeMapStateToProps = () => { | |||
const mapDispatchToProps = function (dispatch) { | |||
return { | |||
onChange: function (text) { | |||
onChange (text) { | |||
dispatch(changeCompose(text)); | |||
}, | |||
onSubmit: function () { | |||
onSubmit () { | |||
dispatch(submitCompose()); | |||
}, | |||
onCancelReply: function () { | |||
onCancelReply () { | |||
dispatch(cancelReplyCompose()); | |||
}, | |||
onClearSuggestions () { | |||
dispatch(clearComposeSuggestions()); | |||
}, | |||
onFetchSuggestions (token) { | |||
dispatch(fetchComposeSuggestions(token)); | |||
} | |||
} | |||
}; | |||
@@ -10,7 +10,9 @@ import { | |||
COMPOSE_UPLOAD_SUCCESS, | |||
COMPOSE_UPLOAD_FAIL, | |||
COMPOSE_UPLOAD_UNDO, | |||
COMPOSE_UPLOAD_PROGRESS | |||
COMPOSE_UPLOAD_PROGRESS, | |||
COMPOSE_SUGGESTIONS_CLEAR, | |||
COMPOSE_SUGGESTIONS_READY | |||
} from '../actions/compose'; | |||
import { TIMELINE_DELETE } from '../actions/timelines'; | |||
import { ACCOUNT_SET_SELF } from '../actions/accounts'; | |||
@@ -22,7 +24,8 @@ const initialState = Immutable.Map({ | |||
is_submitting: false, | |||
is_uploading: false, | |||
progress: 0, | |||
media_attachments: Immutable.List([]), | |||
media_attachments: Immutable.List(), | |||
suggestions: [], | |||
me: null | |||
}); | |||
@@ -95,6 +98,10 @@ export default function compose(state = initialState, action) { | |||
return state.set('progress', Math.round((action.loaded / action.total) * 100)); | |||
case COMPOSE_MENTION: | |||
return state.update('text', text => `${text}@${action.account.get('acct')} `); | |||
case COMPOSE_SUGGESTIONS_CLEAR: | |||
return state.set('suggestions', []); | |||
case COMPOSE_SUGGESTIONS_READY: | |||
return state.set('suggestions', action.accounts); | |||
case TIMELINE_DELETE: | |||
if (action.id === state.get('in_reply_to')) { | |||
return state.set('in_reply_to', null); | |||
@@ -266,3 +266,31 @@ | |||
flex-direction: column; | |||
} | |||
} | |||
.react-autosuggest__container { | |||
position: relative; | |||
} | |||
.react-autosuggest__suggestions-container { | |||
position: absolute; | |||
top: 100%; | |||
width: 100%; | |||
z-index: 99; | |||
} | |||
.react-autosuggest__suggestions-list { | |||
background: #9baec8; | |||
color: #282c37; | |||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); | |||
font-size: 14px; | |||
} | |||
.react-autosuggest__suggestion { | |||
padding: 10px; | |||
cursor: pointer; | |||
} | |||
.react-autosuggest__suggestion--focused { | |||
background: #2b90d9; | |||
color: #fff; | |||
} |
@@ -41,6 +41,7 @@ | |||
"sinon": "^1.17.6" | |||
}, | |||
"dependencies": { | |||
"react-autosuggest": "^7.0.1", | |||
"react-responsive": "^1.1.5", | |||
"react-router-scroll": "^0.3.2", | |||
"react-skylight": "^0.4.1" | |||
@@ -3243,6 +3243,10 @@ oauth-sign@~0.8.1: | |||
version "0.8.2" | |||
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" | |||
object-assign@^3.0.0: | |||
version "3.0.0" | |||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2" | |||
object-assign@^4.0.0, object-assign@^4.0.1, object-assign@^4.1.0: | |||
version "4.1.0" | |||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0" | |||
@@ -3807,6 +3811,22 @@ react-addons-pure-render-mixin@^15.3.1: | |||
version "15.3.2" | |||
resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.3.2.tgz#c09a44f583425a4a9c1b38444d7a6c3e6f0f41f6" | |||
react-autosuggest: | |||
version "7.0.1" | |||
resolved "https://registry.yarnpkg.com/react-autosuggest/-/react-autosuggest-7.0.1.tgz#e751d2c2e516a344f6cdc150672e85f134f5f2f1" | |||
dependencies: | |||
react-autowhatever "^7.0.0" | |||
react-redux "^4.4.5" | |||
redux "^3.6.0" | |||
shallow-equal "^1.0.0" | |||
react-autowhatever@^7.0.0: | |||
version "7.0.0" | |||
resolved "https://registry.yarnpkg.com/react-autowhatever/-/react-autowhatever-7.0.0.tgz#7ea19f8024183acf1568fc8e4b76c0d0cc250d00" | |||
dependencies: | |||
react-themeable "^1.1.0" | |||
section-iterator "^2.0.0" | |||
react-deep-force-update@^1.0.0: | |||
version "1.0.1" | |||
resolved "https://registry.yarnpkg.com/react-deep-force-update/-/react-deep-force-update-1.0.1.tgz#f911b5be1d2a6fe387507dd6e9a767aa2924b4c7" | |||
@@ -3878,6 +3898,15 @@ react-redux-loading-bar@^2.3.3: | |||
version "2.4.0" | |||
resolved "https://registry.yarnpkg.com/react-redux-loading-bar/-/react-redux-loading-bar-2.4.0.tgz#00cd884c7ea8e0146fb94aeb1435b1a0caffd888" | |||
react-redux@^4.4.5: | |||
version "4.4.5" | |||
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-4.4.5.tgz#f509a2981be2252d10c629ef7c559347a4aec457" | |||
dependencies: | |||
hoist-non-react-statics "^1.0.3" | |||
invariant "^2.0.0" | |||
lodash "^4.2.0" | |||
loose-envify "^1.1.0" | |||
react-redux@^5.0.0-beta.3: | |||
version "5.0.0-beta.3" | |||
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.0.0-beta.3.tgz#d50bfb00799cf7d2a9fd55fe34d6b3ecc24d3072" | |||
@@ -3931,6 +3960,12 @@ react-skylight: | |||
version "0.4.1" | |||
resolved "https://registry.yarnpkg.com/react-skylight/-/react-skylight-0.4.1.tgz#07d1af6dea0a50a5d8122a786a8ce8bc6bdf2241" | |||
react-themeable@^1.1.0: | |||
version "1.1.0" | |||
resolved "https://registry.yarnpkg.com/react-themeable/-/react-themeable-1.1.0.tgz#7d4466dd9b2b5fa75058727825e9f152ba379a0e" | |||
dependencies: | |||
object-assign "^3.0.0" | |||
react@^15.3.2: | |||
version "15.3.2" | |||
resolved "https://registry.yarnpkg.com/react/-/react-15.3.2.tgz#a7bccd2fee8af126b0317e222c28d1d54528d09e" | |||
@@ -4033,7 +4068,7 @@ redux-thunk@^2.1.0: | |||
version "2.1.0" | |||
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.1.0.tgz#c724bfee75dbe352da2e3ba9bc14302badd89a98" | |||
redux@^3.5.2: | |||
redux@^3.5.2, redux@^3.6.0: | |||
version "3.6.0" | |||
resolved "https://registry.yarnpkg.com/redux/-/redux-3.6.0.tgz#887c2b3d0b9bd86eca2be70571c27654c19e188d" | |||
dependencies: | |||
@@ -4170,6 +4205,10 @@ scroll-behavior@^0.8.0: | |||
dom-helpers "^2.4.0" | |||
invariant "^2.2.1" | |||
section-iterator@^2.0.0: | |||
version "2.0.0" | |||
resolved "https://registry.yarnpkg.com/section-iterator/-/section-iterator-2.0.0.tgz#bf444d7afeeb94ad43c39ad2fb26151627ccba2a" | |||
semver@~5.3.0: | |||
version "5.3.0" | |||
resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" | |||
@@ -4232,6 +4271,10 @@ sha.js@2.2.6: | |||
version "2.2.6" | |||
resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.2.6.tgz#17ddeddc5f722fb66501658895461977867315ba" | |||
shallow-equal@^1.0.0: | |||
version "1.0.0" | |||
resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.0.0.tgz#508d1838b3de590ab8757b011b25e430900945f7" | |||
shallowequal@0.2.x: | |||
version "0.2.2" | |||
resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-0.2.2.tgz#1e32fd5bcab6ad688a4812cb0cc04efc75c7014e" | |||