about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/features/drawer
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch/features/drawer')
-rw-r--r--app/javascript/flavours/glitch/features/drawer/components/navigation_bar.js38
-rw-r--r--app/javascript/flavours/glitch/features/drawer/components/search.js129
-rw-r--r--app/javascript/flavours/glitch/features/drawer/components/search_results.js65
-rw-r--r--app/javascript/flavours/glitch/features/drawer/containers/navigation_container.js11
-rw-r--r--app/javascript/flavours/glitch/features/drawer/containers/search_container.js35
-rw-r--r--app/javascript/flavours/glitch/features/drawer/containers/search_results_container.js8
-rw-r--r--app/javascript/flavours/glitch/features/drawer/index.js198
7 files changed, 484 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/features/drawer/components/navigation_bar.js b/app/javascript/flavours/glitch/features/drawer/components/navigation_bar.js
new file mode 100644
index 000000000..1b6d74123
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/components/navigation_bar.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from 'flavours/glitch/components/avatar';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Permalink from 'flavours/glitch/components/permalink';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class NavigationBar extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  render () {
+    return (
+      <div className='navigation-bar'>
+        <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+          <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
+          <Avatar account={this.props.account} size={40} />
+        </Permalink>
+
+        <div className='navigation-bar__profile'>
+          <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+            <strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong>
+          </Permalink>
+
+          <a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
+        </div>
+
+        <IconButton title='' icon='close' onClick={this.props.onClose} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/drawer/components/search.js b/app/javascript/flavours/glitch/features/drawer/components/search.js
new file mode 100644
index 000000000..1ce66b19d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/components/search.js
@@ -0,0 +1,129 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+const messages = defineMessages({
+  placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
+});
+
+class SearchPopout extends React.PureComponent {
+
+  static propTypes = {
+    style: PropTypes.object,
+  };
+
+  render () {
+    const { style } = this.props;
+
+    return (
+      <div style={{ ...style, position: 'absolute', width: 285 }}>
+        <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+          {({ opacity, scaleX, scaleY }) => (
+            <div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+              <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
+
+              <ul>
+                <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
+                <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+                <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+                <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
+              </ul>
+
+              <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
+
+@injectIntl
+export default class Search extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string.isRequired,
+    submitted: PropTypes.bool,
+    onChange: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+    onClear: PropTypes.func.isRequired,
+    onShow: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    expanded: false,
+  };
+
+  handleChange = (e) => {
+    this.props.onChange(e.target.value);
+  }
+
+  handleClear = (e) => {
+    e.preventDefault();
+
+    if (this.props.value.length > 0 || this.props.submitted) {
+      this.props.onClear();
+    }
+  }
+
+  handleKeyDown = (e) => {
+    if (e.key === 'Enter') {
+      e.preventDefault();
+      this.props.onSubmit();
+    } else if (e.key === 'Escape') {
+      document.querySelector('.ui').parentElement.focus();
+    }
+  }
+
+  noop () {
+
+  }
+
+  handleFocus = () => {
+    this.setState({ expanded: true });
+    this.props.onShow();
+  }
+
+  handleBlur = () => {
+    this.setState({ expanded: false });
+  }
+
+  render () {
+    const { intl, value, submitted } = this.props;
+    const { expanded } = this.state;
+    const hasValue = value.length > 0 || submitted;
+
+    return (
+      <div className='search'>
+        <label>
+          <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
+          <input
+            className='search__input'
+            type='text'
+            placeholder={intl.formatMessage(messages.placeholder)}
+            value={value}
+            onChange={this.handleChange}
+            onKeyUp={this.handleKeyDown}
+            onFocus={this.handleFocus}
+            onBlur={this.handleBlur}
+          />
+        </label>
+
+        <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
+          <i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
+          <i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} />
+        </div>
+
+        <Overlay show={expanded && !hasValue} placement='bottom' target={this}>
+          <SearchPopout />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/drawer/components/search_results.js b/app/javascript/flavours/glitch/features/drawer/components/search_results.js
new file mode 100644
index 000000000..2a4818d4e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/components/search_results.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import StatusContainer from 'flavours/glitch/containers/status_container';
+import { Link } from 'react-router-dom';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class SearchResults extends ImmutablePureComponent {
+
+  static propTypes = {
+    results: ImmutablePropTypes.map.isRequired,
+  };
+
+  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 key={hashtag} 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, number} {count, plural, one {result} other {results}}' values={{ count }} />
+        </div>
+
+        {accounts}
+        {statuses}
+        {hashtags}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/drawer/containers/navigation_container.js b/app/javascript/flavours/glitch/features/drawer/containers/navigation_container.js
new file mode 100644
index 000000000..eb630ffbb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/containers/navigation_container.js
@@ -0,0 +1,11 @@
+import { connect }   from 'react-redux';
+import NavigationBar from '../components/navigation_bar';
+import { me } from 'flavours/glitch/util/initial_state';
+
+const mapStateToProps = state => {
+  return {
+    account: state.getIn(['accounts', me]),
+  };
+};
+
+export default connect(mapStateToProps)(NavigationBar);
diff --git a/app/javascript/flavours/glitch/features/drawer/containers/search_container.js b/app/javascript/flavours/glitch/features/drawer/containers/search_container.js
new file mode 100644
index 000000000..8f4bfcf08
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/containers/search_container.js
@@ -0,0 +1,35 @@
+import { connect } from 'react-redux';
+import {
+  changeSearch,
+  clearSearch,
+  submitSearch,
+  showSearch,
+} from 'flavours/glitch/actions/search';
+import Search from '../components/search';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['search', 'value']),
+  submitted: state.getIn(['search', 'submitted']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeSearch(value));
+  },
+
+  onClear () {
+    dispatch(clearSearch());
+  },
+
+  onSubmit () {
+    dispatch(submitSearch());
+  },
+
+  onShow () {
+    dispatch(showSearch());
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Search);
diff --git a/app/javascript/flavours/glitch/features/drawer/containers/search_results_container.js b/app/javascript/flavours/glitch/features/drawer/containers/search_results_container.js
new file mode 100644
index 000000000..16d95d417
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/containers/search_results_container.js
@@ -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/javascript/flavours/glitch/features/drawer/index.js b/app/javascript/flavours/glitch/features/drawer/index.js
new file mode 100644
index 000000000..8386ae47c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/index.js
@@ -0,0 +1,198 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, defineMessages } from 'react-intl';
+import spring from 'react-motion/lib/spring';
+import { connect } from 'react-redux';
+import { Link } from 'react-router-dom';
+
+//  Actions.
+import { changeComposing } from 'flavours/glitch/actions/compose';
+import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+import Compose from 'flavours/glitch/features/compose';
+import NavigationContainer from './containers/navigation_container';
+import SearchContainer from './containers/search_container';
+import SearchResultsContainer from './containers/search_results_container';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import {
+  assignHandlers,
+  conditionalRender,
+} from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  community: {
+    defaultMessage: 'Local timeline',
+    id: 'navigation_bar.community_timeline',
+  },
+  home_timeline: {
+    defaultMessage: 'Home',
+    id: 'tabs_bar.home',
+  },
+  logout: {
+    defaultMessage: 'Logout',
+    id: 'navigation_bar.logout',
+  },
+  notifications: {
+    defaultMessage: 'Notifications',
+    id: 'tabs_bar.notifications',
+  },
+  public: {
+    defaultMessage: 'Federated timeline',
+    id: 'navigation_bar.public_timeline',
+  },
+  settings: {
+    defaultMessage: 'App settings',
+    id: 'navigation_bar.app_settings',
+  },
+  start: {
+    defaultMessage: 'Getting started',
+    id: 'getting_started.heading',
+  },
+});
+
+//  State mapping.
+const mapStateToProps = state => ({
+  columns: state.getIn(['settings', 'columns']),
+  showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+});
+
+//  Dispatch mapping.
+const mapDispatchToProps = dispatch => ({
+  onBlur () {
+    dispatch(changeComposing(false));
+  },
+  onFocus () {
+    dispatch(changeComposing(true));
+  },
+  onSettingsOpen () {
+    dispatch(openModal('SETTINGS', {}));
+  },
+});
+
+//  The component.
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+export default function Drawer ({
+  columns,
+  intl,
+  multiColumn,
+  onBlur,
+  onFocus,
+  onSettingsOpen,
+  showSearch,
+}) {
+
+  //  Only renders the component if the column isn't being shown.
+  const renderForColumn = conditionalRender.bind(
+    columnId => !columns.some(column => column.get('id') === columnId)
+  );
+
+  //  The result.
+  return (
+    <div className='drawer'>
+      {multiColumn ? (
+        <nav className='drawer__header'>
+          <Link
+            aria-label={intl.formatMessage(messages.start)}
+            className='drawer__tab'
+            title={intl.formatMessage(messages.start)}
+            to='/getting-started'
+          ><Icon icon='asterisk' /></Link>
+          {renderForColumn('HOME', (
+            <Link
+              aria-label={intl.formatMessage(messages.home_timeline)}
+              className='drawer__tab'
+              title={intl.formatMessage(messages.home_timeline)}
+              to='/timelines/home'
+            ><Icon icon='home' /></Link>
+          ))}
+          {renderForColumn('NOTIFICATIONS', (
+            <Link
+              aria-label={intl.formatMessage(messages.notifications)}
+              className='drawer__tab'
+              title={intl.formatMessage(messages.notifications)}
+              to='/notifications'
+            ><Icon icon='bell' /></Link>
+          ))}
+          {renderForColumn('COMMUNITY', (
+            <Link
+              aria-label={intl.formatMessage(messages.community)}
+              className='drawer__tab'
+              title={intl.formatMessage(messages.community)}
+              to='/timelines/public/local'
+            ><Icon icon='users' /></Link>
+          ))}
+          {renderForColumn('PUBLIC', (
+            <Link
+              aria-label={intl.formatMessage(messages.public)}
+              className='drawer__tab'
+              title={intl.formatMessage(messages.public)}
+              to='/timelines/public'
+            ><Icon icon='globe' /></Link>
+          ))}
+          <a
+            aria-label={intl.formatMessage(messages.settings)}
+            className='drawer__tab'
+            onClick={settings}
+            role='button'
+            title={intl.formatMessage(messages.settings)}
+            tabIndex='0'
+          ><Icon icon='cogs' /></a>
+          <a
+            aria-label={intl.formatMessage(messages.logout)}
+            className='drawer__tab'
+            data-method='delete'
+            href='/auth/sign_out'
+            title={intl.formatMessage(messages.logout)}
+          ><Icon icon='sign-out' /></a>
+        </nav>
+      ) : null}
+      <SearchContainer />
+      <div className='drawer__pager'>
+        <div
+          className='drawer__inner scrollable optionally-scrollable'
+          onFocus={focus}
+        >
+          <NavigationContainer onClose={blur} />
+          <Compose />
+        </div>
+        <Motion
+          defaultStyle={{ x: -100 }}
+          style={{
+            x: spring(showSearch ? 0 : -100, {
+              stiffness: 210,
+              damping: 20,
+            })
+          }}
+        >
+          {({ x }) => (
+            <div
+              className='drawer__inner darker scrollable optionally-scrollable'
+              style={{
+                transform: `translateX(${x}%)`,
+                visibility: x === -100 ? 'hidden' : 'visible'
+              }}
+            ><SearchResultsContainer /></div>
+          )}
+        </Motion>
+      </div>
+    </div>
+  );
+}
+
+//  Props.
+Drawer.propTypes = {
+  dispatch: PropTypes.func.isRequired,
+  columns: ImmutablePropTypes.list.isRequired,
+  multiColumn: PropTypes.bool,
+  showSearch: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+};