about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/components/actions/search.jsx51
-rw-r--r--app/assets/javascripts/components/features/compose/components/search.jsx126
-rw-r--r--app/assets/javascripts/components/features/compose/containers/search_container.jsx35
-rw-r--r--app/assets/javascripts/components/features/compose/index.jsx3
-rw-r--r--app/assets/javascripts/components/reducers/accounts.jsx2
-rw-r--r--app/assets/javascripts/components/reducers/index.jsx4
-rw-r--r--app/assets/javascripts/components/reducers/search.jsx60
-rw-r--r--app/assets/stylesheets/components.scss14
8 files changed, 291 insertions, 4 deletions
diff --git a/app/assets/javascripts/components/actions/search.jsx b/app/assets/javascripts/components/actions/search.jsx
new file mode 100644
index 000000000..ceb0e4a0c
--- /dev/null
+++ b/app/assets/javascripts/components/actions/search.jsx
@@ -0,0 +1,51 @@
+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 function changeSearch(value) {
+  return {
+    type: SEARCH_CHANGE,
+    value
+  };
+};
+
+export function clearSearchSuggestions() {
+  return {
+    type: SEARCH_SUGGESTIONS_CLEAR
+  };
+};
+
+export function readySearchSuggestions(value, accounts) {
+  return {
+    type: SEARCH_SUGGESTIONS_READY,
+    value,
+    accounts
+  };
+};
+
+export function fetchSearchSuggestions(value) {
+  return (dispatch, getState) => {
+    if (getState().getIn(['search', 'loaded_value']) === value) {
+      return;
+    }
+
+    api(getState).get('/api/v1/accounts/search', {
+      params: {
+        q: value,
+        resolve: true,
+        limit: 4
+      }
+    }).then(response => {
+      dispatch(readySearchSuggestions(value, response.data));
+    });
+  };
+};
+
+export function resetSearch() {
+  return {
+    type: SEARCH_RESET
+  };
+};
diff --git a/app/assets/javascripts/components/features/compose/components/search.jsx b/app/assets/javascripts/components/features/compose/components/search.jsx
new file mode 100644
index 000000000..e81771e6a
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/search.jsx
@@ -0,0 +1,126 @@
+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';
+
+const getSuggestionValue = suggestion => suggestion.value;
+
+const renderSuggestion = suggestion => {
+  if (suggestion.type === 'account') {
+    return <AutosuggestAccountContainer id={suggestion.id} />;
+  } else {
+    return <span>#{suggestion.id}</span>
+  }
+};
+
+const renderSectionTitle = section => (
+  <strong>{section.title}</strong>
+);
+
+const getSectionSuggestions = section => section.items;
+
+const outerStyle = {
+  padding: '10px',
+  lineHeight: '20px',
+  position: 'relative'
+};
+
+const inputStyle = {
+  boxSizing: 'border-box',
+  display: 'block',
+  width: '100%',
+  border: 'none',
+  padding: '10px',
+  paddingRight: '30px',
+  fontFamily: 'Roboto',
+  background: '#282c37',
+  color: '#9baec8',
+  fontSize: '14px',
+  margin: '0'
+};
+
+const iconStyle = {
+  position: 'absolute',
+  top: '18px',
+  right: '20px',
+  color: '#9baec8',
+  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,
+    onClear: React.PropTypes.func.isRequired,
+    onFetch: React.PropTypes.func.isRequired,
+    onReset: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  onChange (_, { newValue }) {
+    if (typeof newValue !== 'string') {
+      return;
+    }
+
+    this.props.onChange(newValue);
+  },
+
+  onSuggestionsClearRequested () {
+    this.props.onClear();
+  },
+
+  onSuggestionsFetchRequested ({ value }) {
+    value = value.replace('#', '');
+    this.props.onFetch(value.trim());
+  },
+
+  onSuggestionSelected (_, { suggestion }) {
+    if (suggestion.type === 'account') {
+      this.context.router.push(`/accounts/${suggestion.id}`);
+    } else {
+      this.context.router.push(`/statuses/tag/${suggestion.id}`);
+    }
+  },
+
+  render () {
+    const inputProps = {
+      placeholder: 'Search',
+      value: this.props.value,
+      onChange: this.onChange,
+      style: inputStyle
+    };
+
+    return (
+      <div 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 style={iconStyle}><i className='fa fa-search' /></div>
+      </div>
+    );
+  },
+
+});
+
+export default Search;
diff --git a/app/assets/javascripts/components/features/compose/containers/search_container.jsx b/app/assets/javascripts/components/features/compose/containers/search_container.jsx
new file mode 100644
index 000000000..17a68f2fc
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/search_container.jsx
@@ -0,0 +1,35 @@
+import { connect } from 'react-redux';
+import {
+  changeSearch,
+  clearSearchSuggestions,
+  fetchSearchSuggestions,
+  resetSearch
+} from '../../../actions/search';
+import Search from '../components/search';
+
+const mapStateToProps = state => ({
+  suggestions: state.getIn(['search', 'suggestions']),
+  value: state.getIn(['search', 'value'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeSearch(value));
+  },
+
+  onClear () {
+    dispatch(clearSearchSuggestions());
+  },
+
+  onFetch (value) {
+    dispatch(fetchSearchSuggestions(value));
+  },
+
+  onReset () {
+    dispatch(resetSearch());
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Search);
diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx
index d76afc437..260f67034 100644
--- a/app/assets/javascripts/components/features/compose/index.jsx
+++ b/app/assets/javascripts/components/features/compose/index.jsx
@@ -5,6 +5,7 @@ import UploadFormContainer  from '../ui/containers/upload_form_container';
 import NavigationContainer  from '../ui/containers/navigation_container';
 import PureRenderMixin      from 'react-addons-pure-render-mixin';
 import SuggestionsContainer from './containers/suggestions_container';
+import SearchContainer      from './containers/search_container';
 import { fetchSuggestions } from '../../actions/suggestions';
 import { connect }          from 'react-redux';
 
@@ -24,13 +25,13 @@ const Compose = React.createClass({
     return (
       <Drawer>
         <div style={{ flex: '1 1 auto' }}>
+          <SearchContainer />
           <NavigationContainer />
           <ComposeFormContainer />
           <UploadFormContainer />
         </div>
 
         <SuggestionsContainer />
-        <FollowFormContainer />
       </Drawer>
     );
   }
diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx
index 471e1b0aa..b20b3d0c5 100644
--- a/app/assets/javascripts/components/reducers/accounts.jsx
+++ b/app/assets/javascripts/components/reducers/accounts.jsx
@@ -26,6 +26,7 @@ import {
   STATUS_FETCH_SUCCESS,
   CONTEXT_FETCH_SUCCESS
 } from '../actions/statuses';
+import { SEARCH_SUGGESTIONS_READY } from '../actions/search';
 import Immutable from 'immutable';
 
 const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS(account));
@@ -70,6 +71,7 @@ export default function accounts(state = initialState, action) {
     case REBLOGS_FETCH_SUCCESS:
     case FAVOURITES_FETCH_SUCCESS:
     case COMPOSE_SUGGESTIONS_READY:
+    case SEARCH_SUGGESTIONS_READY:
       return normalizeAccounts(state, action.accounts);
     case TIMELINE_REFRESH_SUCCESS:
     case TIMELINE_EXPAND_SUCCESS:
diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx
index ccc9e8e8e..e2203cc1a 100644
--- a/app/assets/javascripts/components/reducers/index.jsx
+++ b/app/assets/javascripts/components/reducers/index.jsx
@@ -10,6 +10,7 @@ import user_lists            from './user_lists';
 import accounts              from './accounts';
 import statuses              from './statuses';
 import relationships         from './relationships';
+import search                from './search';
 
 export default combineReducers({
   timelines,
@@ -22,5 +23,6 @@ export default combineReducers({
   user_lists,
   accounts,
   statuses,
-  relationships
+  relationships,
+  search
 });
diff --git a/app/assets/javascripts/components/reducers/search.jsx b/app/assets/javascripts/components/reducers/search.jsx
new file mode 100644
index 000000000..f3ee17f60
--- /dev/null
+++ b/app/assets/javascripts/components/reducers/search.jsx
@@ -0,0 +1,60 @@
+import {
+  SEARCH_CHANGE,
+  SEARCH_SUGGESTIONS_READY,
+  SEARCH_RESET
+} from '../actions/search';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  value: '',
+  loaded_value: '',
+  suggestions: []
+});
+
+const normalizeSuggestions = (state, value, accounts) => {
+  let newSuggestions = [
+    {
+      title: 'Account',
+      items: accounts.map(item => ({
+        type: 'account',
+        id: item.id,
+        value: item.acct
+      }))
+    }
+  ];
+
+  if (value.indexOf('@') === -1) {
+    newSuggestions.push({
+      title: 'Hashtag',
+      items: [
+        {
+          type: 'hashtag',
+          id: value,
+          value: `#${value}`
+        }
+      ]
+    });
+  }
+
+  return state.withMutations(map => {
+    map.set('suggestions', newSuggestions);
+    map.set('loaded_value', value);
+  });
+};
+
+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);
+    case SEARCH_RESET:
+      return state.withMutations(map => {
+        map.set('suggestions', []);
+        map.set('value', '');
+        map.set('loaded_value', '');
+      });
+    default:
+      return state;
+  }
+};
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index 89397a96d..2cd58bb2b 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -325,12 +325,22 @@
   top: 100%;
   width: 100%;
   z-index: 99;
+  box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
 }
 
-.react-autosuggest__suggestions-list {
+.react-autosuggest__section-title {
   background: #9baec8;
+  padding: 4px 10px;
+  font-weight: 500;
+  cursor: default;
+  color: #282c37;
+  text-transform: uppercase;
+  font-size: 11px;
+}
+
+.react-autosuggest__suggestions-list {
+  background: #d9e1e8;
   color: #282c37;
-  box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
   font-size: 14px;
 }