diff options
Diffstat (limited to 'app/assets/javascripts/components')
12 files changed, 236 insertions, 242 deletions
diff --git a/app/assets/javascripts/components/actions/search.jsx b/app/assets/javascripts/components/actions/search.jsx index e4af716ee..9d28ed11e 100644 --- a/app/assets/javascripts/components/actions/search.jsx +++ b/app/assets/javascripts/components/actions/search.jsx @@ -1,9 +1,12 @@ import api from '../api' -export const SEARCH_CHANGE = 'SEARCH_CHANGE'; -export const SEARCH_SUGGESTIONS_CLEAR = 'SEARCH_SUGGESTIONS_CLEAR'; -export const SEARCH_SUGGESTIONS_READY = 'SEARCH_SUGGESTIONS_READY'; -export const SEARCH_RESET = 'SEARCH_RESET'; +export const SEARCH_CHANGE = 'SEARCH_CHANGE'; +export const SEARCH_CLEAR = 'SEARCH_CLEAR'; +export const SEARCH_SHOW = 'SEARCH_SHOW'; + +export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; +export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; +export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; export function changeSearch(value) { return { @@ -12,42 +15,55 @@ export function changeSearch(value) { }; }; -export function clearSearchSuggestions() { - return { - type: SEARCH_SUGGESTIONS_CLEAR - }; -}; - -export function readySearchSuggestions(value, { accounts, hashtags, statuses }) { +export function clearSearch() { return { - type: SEARCH_SUGGESTIONS_READY, - value, - accounts, - hashtags, - statuses + type: SEARCH_CLEAR }; }; -export function fetchSearchSuggestions(value) { +export function submitSearch() { return (dispatch, getState) => { - if (getState().getIn(['search', 'loaded_value']) === value) { - return; - } + const value = getState().getIn(['search', 'value']); + + dispatch(fetchSearchRequest()); api(getState).get('/api/v1/search', { params: { q: value, - resolve: true, - limit: 4 + resolve: true } }).then(response => { - dispatch(readySearchSuggestions(value, response.data)); + dispatch(fetchSearchSuccess(response.data)); + }).catch(error => { + dispatch(fetchSearchFail(error)); }); }; }; -export function resetSearch() { +export function fetchSearchRequest() { + return { + type: SEARCH_FETCH_REQUEST + }; +}; + +export function fetchSearchSuccess(results) { + return { + type: SEARCH_FETCH_SUCCESS, + results, + accounts: results.accounts, + statuses: results.statuses + }; +}; + +export function fetchSearchFail(error) { + return { + type: SEARCH_FETCH_FAIL, + error + }; +}; + +export function showSearch() { return { - type: SEARCH_RESET + type: SEARCH_SHOW }; }; diff --git a/app/assets/javascripts/components/features/compose/components/drawer.jsx b/app/assets/javascripts/components/features/compose/components/drawer.jsx deleted file mode 100644 index ab67c86ea..000000000 --- a/app/assets/javascripts/components/features/compose/components/drawer.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Link } from 'react-router'; -import { injectIntl, defineMessages } from 'react-intl'; - -const messages = defineMessages({ - start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, - public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' }, - community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, - preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, - logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' } -}); - -const Drawer = ({ children, withHeader, intl }) => { - let header = ''; - - if (withHeader) { - header = ( - <div className='drawer__header'> - <Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link> - <Link title={intl.formatMessage(messages.community)} className='drawer__tab' to='/timelines/public/local'><i className='fa fa-fw fa-users' /></Link> - <Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link> - <a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a> - <a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a> - </div> - ); - } - - return ( - <div className='drawer'> - {header} - - <div className='drawer__inner'> - {children} - </div> - </div> - ); -}; - -Drawer.propTypes = { - withHeader: React.PropTypes.bool, - children: React.PropTypes.node, - intl: React.PropTypes.object -}; - -export default injectIntl(Drawer); diff --git a/app/assets/javascripts/components/features/compose/components/search.jsx b/app/assets/javascripts/components/features/compose/components/search.jsx index a0e8f82fb..8e86f053e 100644 --- a/app/assets/javascripts/components/features/compose/components/search.jsx +++ b/app/assets/javascripts/components/features/compose/components/search.jsx @@ -1,123 +1,67 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import Autosuggest from 'react-autosuggest'; -import AutosuggestAccountContainer from '../containers/autosuggest_account_container'; -import AutosuggestStatusContainer from '../containers/autosuggest_status_container'; -import { debounce } from 'react-decoration'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; const messages = defineMessages({ placeholder: { id: 'search.placeholder', defaultMessage: 'Search' } }); -const getSuggestionValue = suggestion => suggestion.value; - -const renderSuggestion = suggestion => { - if (suggestion.type === 'account') { - return <AutosuggestAccountContainer id={suggestion.id} />; - } else if (suggestion.type === 'hashtag') { - return <span>#{suggestion.id}</span>; - } else { - return <AutosuggestStatusContainer id={suggestion.id} />; - } -}; - -const renderSectionTitle = section => ( - <strong><FormattedMessage id={`search.${section.title}`} defaultMessage={section.title} /></strong> -); - -const getSectionSuggestions = section => section.items; - -const outerStyle = { - padding: '10px', - lineHeight: '20px', - position: 'relative' -}; - -const iconStyle = { - position: 'absolute', - top: '18px', - right: '20px', - fontSize: '18px', - pointerEvents: 'none' -}; - const Search = React.createClass({ - contextTypes: { - router: React.PropTypes.object - }, - propTypes: { - suggestions: React.PropTypes.array.isRequired, value: React.PropTypes.string.isRequired, onChange: React.PropTypes.func.isRequired, + onSubmit: React.PropTypes.func.isRequired, onClear: React.PropTypes.func.isRequired, - onFetch: React.PropTypes.func.isRequired, - onReset: React.PropTypes.func.isRequired, + onShow: React.PropTypes.func.isRequired, intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], - onChange (_, { newValue }) { - if (typeof newValue !== 'string') { - return; - } - - this.props.onChange(newValue); + handleChange (e) { + this.props.onChange(e.target.value); }, - onSuggestionsClearRequested () { + handleClear (e) { + e.preventDefault(); this.props.onClear(); }, - @debounce(500) - onSuggestionsFetchRequested ({ value }) { - value = value.replace('#', ''); - this.props.onFetch(value.trim()); + handleKeyDown (e) { + if (e.key === 'Enter') { + e.preventDefault(); + this.props.onSubmit(); + } }, - onSuggestionSelected (_, { suggestion }) { - if (suggestion.type === 'account') { - this.context.router.push(`/accounts/${suggestion.id}`); - } else if(suggestion.type === 'hashtag') { - this.context.router.push(`/timelines/tag/${suggestion.id}`); - } else { - this.context.router.push(`/statuses/${suggestion.id}`); - } + handleFocus () { + this.props.onShow(); }, render () { - const inputProps = { - placeholder: this.props.intl.formatMessage(messages.placeholder), - value: this.props.value, - onChange: this.onChange, - className: 'search__input' - }; + const { intl, value } = this.props; + const hasValue = value.length > 0; return ( - <div className='search' style={outerStyle}> - <Autosuggest - multiSection={true} - suggestions={this.props.suggestions} - focusFirstSuggestion={true} - focusInputOnSuggestionClick={false} - alwaysRenderSuggestions={false} - onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} - onSuggestionsClearRequested={this.onSuggestionsClearRequested} - onSuggestionSelected={this.onSuggestionSelected} - getSuggestionValue={getSuggestionValue} - renderSuggestion={renderSuggestion} - renderSectionTitle={renderSectionTitle} - getSectionSuggestions={getSectionSuggestions} - inputProps={inputProps} + <div className='search'> + <input + className='search__input' + type='text' + placeholder={intl.formatMessage(messages.placeholder)} + value={value} + onChange={this.handleChange} + onKeyUp={this.handleKeyDown} + onFocus={this.handleFocus} /> - <div style={iconStyle}><i className='fa fa-search' /></div> + <div className='search__icon'> + <i className={`fa fa-search ${hasValue ? '' : 'active'}`} /> + <i className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} onClick={this.handleClear} /> + </div> </div> ); - }, + } }); diff --git a/app/assets/javascripts/components/features/compose/components/search_results.jsx b/app/assets/javascripts/components/features/compose/components/search_results.jsx new file mode 100644 index 000000000..fd05e7f7e --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/search_results.jsx @@ -0,0 +1,68 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import AccountContainer from '../../../containers/account_container'; +import StatusContainer from '../../../containers/status_container'; +import { Link } from 'react-router'; + +const SearchResults = React.createClass({ + + propTypes: { + results: ImmutablePropTypes.map.isRequired + }, + + mixins: [PureRenderMixin], + + render () { + const { results } = this.props; + + let accounts, statuses, hashtags; + let count = 0; + + if (results.get('accounts') && results.get('accounts').size > 0) { + count += results.get('accounts').size; + accounts = ( + <div className='search-results__section'> + {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)} + </div> + ); + } + + if (results.get('statuses') && results.get('statuses').size > 0) { + count += results.get('statuses').size; + statuses = ( + <div className='search-results__section'> + {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)} + </div> + ); + } + + if (results.get('hashtags') && results.get('hashtags').size > 0) { + count += results.get('hashtags').size; + hashtags = ( + <div className='search-results__section'> + {results.get('hashtags').map(hashtag => + <Link className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}> + #{hashtag} + </Link> + )} + </div> + ); + } + + return ( + <div className='search-results'> + <div className='search-results__header'> + <FormattedMessage id='search_results.total' defaultMessage='{count} {count, plural, one {result} other {results}}' values={{ count }} /> + </div> + + {accounts} + {statuses} + {hashtags} + </div> + ); + } + +}); + +export default SearchResults; diff --git a/app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx b/app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx deleted file mode 100644 index 97cc9487e..000000000 --- a/app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import { FormattedMessage } from 'react-intl'; -import Toggle from 'react-toggle'; -import Collapsable from '../../../components/collapsable'; - -const SensitiveToggle = React.createClass({ - - propTypes: { - hasMedia: React.PropTypes.bool, - isSensitive: React.PropTypes.bool, - onChange: React.PropTypes.func.isRequired - }, - - mixins: [PureRenderMixin], - - render () { - const { hasMedia, isSensitive, onChange } = this.props; - - return ( - <Collapsable isVisible={hasMedia} fullHeight={39.5}> - <label className='compose-form__label'> - <Toggle checked={isSensitive} onChange={onChange} /> - <span className='compose-form__label__text'><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark media as sensitive' /></span> - </label> - </Collapsable> - ); - } - -}); - -export default SensitiveToggle; diff --git a/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx b/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx deleted file mode 100644 index 1c59e4393..000000000 --- a/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import { FormattedMessage } from 'react-intl'; -import Toggle from 'react-toggle'; - -const SpoilerToggle = React.createClass({ - - propTypes: { - isSpoiler: React.PropTypes.bool, - onChange: React.PropTypes.func.isRequired - }, - - mixins: [PureRenderMixin], - - render () { - const { isSpoiler, onChange } = this.props; - - return ( - <label className='compose-form__label with-border' style={{ marginTop: '10px' }}> - <Toggle checked={isSpoiler} onChange={onChange} /> - <span className='compose-form__label__text'><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span> - </label> - ); - } - -}); - -export default SpoilerToggle; diff --git a/app/assets/javascripts/components/features/compose/containers/search_container.jsx b/app/assets/javascripts/components/features/compose/containers/search_container.jsx index 17a68f2fc..96709215a 100644 --- a/app/assets/javascripts/components/features/compose/containers/search_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/search_container.jsx @@ -1,14 +1,13 @@ import { connect } from 'react-redux'; import { changeSearch, - clearSearchSuggestions, - fetchSearchSuggestions, - resetSearch + clearSearch, + submitSearch, + showSearch } from '../../../actions/search'; import Search from '../components/search'; const mapStateToProps = state => ({ - suggestions: state.getIn(['search', 'suggestions']), value: state.getIn(['search', 'value']) }); @@ -19,15 +18,15 @@ const mapDispatchToProps = dispatch => ({ }, onClear () { - dispatch(clearSearchSuggestions()); + dispatch(clearSearch()); }, - onFetch (value) { - dispatch(fetchSearchSuggestions(value)); + onSubmit () { + dispatch(submitSearch()); }, - onReset () { - dispatch(resetSearch()); + onShow () { + dispatch(showSearch()); } }); diff --git a/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx b/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx new file mode 100644 index 000000000..e5911fd38 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import SearchResults from '../components/search_results'; + +const mapStateToProps = state => ({ + results: state.getIn(['search', 'results']) +}); + +export default connect(mapStateToProps)(SearchResults); diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx index 15e2c5809..d21e7a9bc 100644 --- a/app/assets/javascripts/components/features/compose/index.jsx +++ b/app/assets/javascripts/components/features/compose/index.jsx @@ -1,17 +1,34 @@ -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 SearchContainer from './containers/search_container'; import { connect } from 'react-redux'; import { mountCompose, unmountCompose } from '../../actions/compose'; +import { Link } from 'react-router'; +import { injectIntl, defineMessages } from 'react-intl'; +import SearchContainer from './containers/search_container'; +import { Motion, spring } from 'react-motion'; +import SearchResultsContainer from './containers/search_results_container'; + +const messages = defineMessages({ + start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' }, + community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' } +}); + +const mapStateToProps = state => ({ + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) +}); const Compose = React.createClass({ propTypes: { dispatch: React.PropTypes.func.isRequired, - withHeader: React.PropTypes.bool + withHeader: React.PropTypes.bool, + showSearch: React.PropTypes.bool, + intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], @@ -25,15 +42,46 @@ const Compose = React.createClass({ }, render () { + const { withHeader, showSearch, intl } = this.props; + + let header = ''; + + if (withHeader) { + header = ( + <div className='drawer__header'> + <Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link> + <Link title={intl.formatMessage(messages.community)} className='drawer__tab' to='/timelines/public/local'><i className='fa fa-fw fa-users' /></Link> + <Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link> + <a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a> + <a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a> + </div> + ); + } + return ( - <Drawer withHeader={this.props.withHeader}> + <div className='drawer'> + {header} + <SearchContainer /> - <NavigationContainer /> - <ComposeFormContainer /> - </Drawer> + + <div className='drawer__pager'> + <div className='drawer__inner'> + <NavigationContainer /> + <ComposeFormContainer /> + </div> + + <Motion defaultStyle={{ x: -300 }} style={{ x: spring(showSearch ? 0 : -300, { stiffness: 210, damping: 20 }) }}> + {({ x }) => + <div className='drawer__inner darker' style={{ transform: `translateX(${x}px)` }}> + <SearchResultsContainer /> + </div> + } + </Motion> + </div> + </div> ); } }); -export default connect()(Compose); +export default connect(mapStateToProps)(injectIntl(Compose)); diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx index 6ce41670d..df9440093 100644 --- a/app/assets/javascripts/components/reducers/accounts.jsx +++ b/app/assets/javascripts/components/reducers/accounts.jsx @@ -33,7 +33,7 @@ import { STATUS_FETCH_SUCCESS, CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; -import { SEARCH_SUGGESTIONS_READY } from '../actions/search'; +import { SEARCH_FETCH_SUCCESS } from '../actions/search'; import { NOTIFICATIONS_UPDATE, NOTIFICATIONS_REFRESH_SUCCESS, @@ -97,7 +97,7 @@ export default function accounts(state = initialState, action) { return normalizeAccounts(state, action.accounts); case NOTIFICATIONS_REFRESH_SUCCESS: case NOTIFICATIONS_EXPAND_SUCCESS: - case SEARCH_SUGGESTIONS_READY: + case SEARCH_FETCH_SUCCESS: return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); case TIMELINE_REFRESH_SUCCESS: case TIMELINE_EXPAND_SUCCESS: diff --git a/app/assets/javascripts/components/reducers/search.jsx b/app/assets/javascripts/components/reducers/search.jsx index e95f9ed79..b3fe6c7be 100644 --- a/app/assets/javascripts/components/reducers/search.jsx +++ b/app/assets/javascripts/components/reducers/search.jsx @@ -1,14 +1,17 @@ import { SEARCH_CHANGE, - SEARCH_SUGGESTIONS_READY, - SEARCH_RESET + SEARCH_CLEAR, + SEARCH_FETCH_SUCCESS, + SEARCH_SHOW } from '../actions/search'; +import { COMPOSE_MENTION, COMPOSE_REPLY } from '../actions/compose'; import Immutable from 'immutable'; const initialState = Immutable.Map({ value: '', - loaded_value: '', - suggestions: [] + submitted: false, + hidden: false, + results: Immutable.Map() }); const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => { @@ -69,14 +72,24 @@ export default function search(state = initialState, action) { switch(action.type) { case SEARCH_CHANGE: return state.set('value', action.value); - case SEARCH_SUGGESTIONS_READY: - return normalizeSuggestions(state, action.value, action.accounts, action.hashtags, action.statuses); - case SEARCH_RESET: + case SEARCH_CLEAR: return state.withMutations(map => { - map.set('suggestions', []); map.set('value', ''); - map.set('loaded_value', ''); + map.set('results', Immutable.Map()); + map.set('submitted', false); + map.set('hidden', false); }); + case SEARCH_SHOW: + return state.set('hidden', false); + case COMPOSE_REPLY: + case COMPOSE_MENTION: + return state.set('hidden', true); + case SEARCH_FETCH_SUCCESS: + return state.set('results', Immutable.Map({ + accounts: Immutable.List(action.results.accounts.map(item => item.id)), + statuses: Immutable.List(action.results.statuses.map(item => item.id)), + hashtags: Immutable.List(action.results.hashtags) + })).set('submitted', true); default: return state; } diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx index 1669b8c65..ca8fa7a01 100644 --- a/app/assets/javascripts/components/reducers/statuses.jsx +++ b/app/assets/javascripts/components/reducers/statuses.jsx @@ -32,7 +32,7 @@ import { FAVOURITED_STATUSES_FETCH_SUCCESS, FAVOURITED_STATUSES_EXPAND_SUCCESS } from '../actions/favourites'; -import { SEARCH_SUGGESTIONS_READY } from '../actions/search'; +import { SEARCH_FETCH_SUCCESS } from '../actions/search'; import Immutable from 'immutable'; const normalizeStatus = (state, status) => { @@ -109,7 +109,7 @@ export default function statuses(state = initialState, action) { case NOTIFICATIONS_EXPAND_SUCCESS: case FAVOURITED_STATUSES_FETCH_SUCCESS: case FAVOURITED_STATUSES_EXPAND_SUCCESS: - case SEARCH_SUGGESTIONS_READY: + case SEARCH_FETCH_SUCCESS: return normalizeStatuses(state, action.statuses); case TIMELINE_DELETE: return deleteStatus(state, action.id, action.references); |