about summary refs log tree commit diff
path: root/app/assets/javascripts/components/features
diff options
context:
space:
mode:
authorhalna_Tanaguru <fhalna@oceaneconsulting.com>2017-04-04 12:06:53 +0200
committerGitHub <noreply@github.com>2017-04-04 12:06:53 +0200
commit3abb0f7bc708ae8ca387aee8560b9558f8b209ba (patch)
tree6014fdec3201d9e40fa244b821bdaffc83bfa131 /app/assets/javascripts/components/features
parent3f30ae1f97717177f29711d5b99d7970c6b75b3e (diff)
parenteb023beb4975a019d6a3b3091483c91c2c837bbd (diff)
Merge branch 'master' into development
Diffstat (limited to 'app/assets/javascripts/components/features')
-rw-r--r--app/assets/javascripts/components/features/account/components/header.jsx51
-rw-r--r--app/assets/javascripts/components/features/community_timeline/index.jsx16
-rw-r--r--app/assets/javascripts/components/features/compose/components/drawer.jsx44
-rw-r--r--app/assets/javascripts/components/features/compose/components/search.jsx115
-rw-r--r--app/assets/javascripts/components/features/compose/components/search_results.jsx68
-rw-r--r--app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx31
-rw-r--r--app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx27
-rw-r--r--app/assets/javascripts/components/features/compose/containers/search_container.jsx20
-rw-r--r--app/assets/javascripts/components/features/compose/containers/search_results_container.jsx8
-rw-r--r--app/assets/javascripts/components/features/compose/index.jsx64
-rw-r--r--app/assets/javascripts/components/features/getting_started/index.jsx4
-rw-r--r--app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx4
-rw-r--r--app/assets/javascripts/components/features/public_timeline/index.jsx16
-rw-r--r--app/assets/javascripts/components/features/status/index.jsx4
-rw-r--r--app/assets/javascripts/components/features/ui/components/media_modal.jsx133
-rw-r--r--app/assets/javascripts/components/features/ui/components/modal_root.jsx80
-rw-r--r--app/assets/javascripts/components/features/ui/components/tabs_bar.jsx28
-rw-r--r--app/assets/javascripts/components/features/ui/containers/modal_container.jsx166
-rw-r--r--app/assets/javascripts/components/features/ui/index.jsx53
19 files changed, 532 insertions, 400 deletions
diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx
index e1aae3c77..a359963c4 100644
--- a/app/assets/javascripts/components/features/account/components/header.jsx
+++ b/app/assets/javascripts/components/features/account/components/header.jsx
@@ -4,6 +4,7 @@ import emojify from '../../../emoji';
 import escapeTextContentForBrowser from 'escape-html';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import IconButton from '../../../components/icon_button';
+import { Motion, spring } from 'react-motion';
 
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -11,6 +12,47 @@ const messages = defineMessages({
   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
 });
 
+const Avatar = React.createClass({
+
+  propTypes: {
+    account: ImmutablePropTypes.map.isRequired
+  },
+
+  getInitialState () {
+    return {
+      isHovered: false
+    };
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleMouseOver () {
+    if (this.state.isHovered) return;
+    this.setState({ isHovered: true });
+  },
+
+  handleMouseOut () {
+    if (!this.state.isHovered) return;
+    this.setState({ isHovered: false });
+  },
+
+  render () {
+    const { account }   = this.props;
+    const { isHovered } = this.state;
+
+    return (
+      <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
+        {({ radius }) =>
+          <a href={account.get('url')} className='account__header__avatar' target='_blank' rel='noopener' style={{ display: 'block', width: '90px', height: '90px', margin: '0 auto', marginBottom: '10px', borderRadius: `${radius}px`, overflow: 'hidden' }} onMouseOver={this.handleMouseOver} onMouseOut={this.handleMouseOut}>
+            <img src={account.get('avatar')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} />
+          </a>
+        }
+      </Motion>
+    );
+  }
+
+});
+
 const Header = React.createClass({
 
   propTypes: {
@@ -68,14 +110,9 @@ const Header = React.createClass({
     return (
       <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
         <div style={{ padding: '20px 10px' }}>
-          <a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
-            <div className='account__header__avatar' style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}>
-              <img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
-            </div>
-
-            <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
-          </a>
+          <Avatar account={account} />
 
+          <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
           <span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
           <div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
 
diff --git a/app/assets/javascripts/components/features/community_timeline/index.jsx b/app/assets/javascripts/components/features/community_timeline/index.jsx
index 2cfd7b2fe..0957338cf 100644
--- a/app/assets/javascripts/components/features/community_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/community_timeline/index.jsx
@@ -5,7 +5,9 @@ import Column from '../ui/components/column';
 import {
   refreshTimeline,
   updateTimeline,
-  deleteFromTimelines
+  deleteFromTimelines,
+  connectTimeline,
+  disconnectTimeline
 } from '../../actions/timelines';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
@@ -44,6 +46,18 @@ const CommunityTimeline = React.createClass({
 
     subscription = createStream(accessToken, 'public:local', {
 
+      connected () {
+        dispatch(connectTimeline('community'));
+      },
+
+      reconnected () {
+        dispatch(connectTimeline('community'));
+      },
+
+      disconnected () {
+        dispatch(disconnectTimeline('community'));
+      },
+
       received (data) {
         switch(data.event) {
         case 'update':
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..936e003f2 100644
--- a/app/assets/javascripts/components/features/compose/components/search.jsx
+++ b/app/assets/javascripts/components/features/compose/components/search.jsx
@@ -1,123 +1,68 @@
 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,
+    submitted: React.PropTypes.bool,
     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, submitted } = this.props;
+    const hasValue = value.length > 0 || submitted;
 
     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..906c0c28c 100644
--- a/app/assets/javascripts/components/features/compose/containers/search_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/search_container.jsx
@@ -1,15 +1,15 @@
 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'])
+  value: state.getIn(['search', 'value']),
+  submitted: state.getIn(['search', 'submitted'])
 });
 
 const mapDispatchToProps = dispatch => ({
@@ -19,15 +19,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..9421de3ff 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: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
+            {({ x }) =>
+              <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
+                <SearchResultsContainer />
+              </div>
+            }
+          </Motion>
+        </div>
+      </div>
     );
   }
 
 });
 
-export default connect()(Compose);
+export default connect(mapStateToProps)(injectIntl(Compose));
diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx
index 6f9e988ba..d7a78d9cc 100644
--- a/app/assets/javascripts/components/features/getting_started/index.jsx
+++ b/app/assets/javascripts/components/features/getting_started/index.jsx
@@ -43,9 +43,7 @@ const GettingStarted = ({ intl, me }) => {
 
       <div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}>
         <div className='static-content getting-started'>
-          <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
-          <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
-          <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p>
+          <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p>
         </div>
       </div>
     </Column>
diff --git a/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx
index 3317210bf..92e700874 100644
--- a/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx
+++ b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx
@@ -6,7 +6,7 @@ import SettingToggle from '../../notifications/components/setting_toggle';
 import SettingText from './setting_text';
 
 const messages = defineMessages({
-  filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter by regular expressions' }
+  filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }
 });
 
 const outerStyle = {
@@ -44,7 +44,7 @@ const ColumnSettings = React.createClass({
           <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
 
           <div style={rowStyle}>
-            <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reblogs' />} />
+            <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} />
           </div>
 
           <div style={rowStyle}>
diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx
index b2342abbd..6d766a83b 100644
--- a/app/assets/javascripts/components/features/public_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/public_timeline/index.jsx
@@ -5,7 +5,9 @@ import Column from '../ui/components/column';
 import {
   refreshTimeline,
   updateTimeline,
-  deleteFromTimelines
+  deleteFromTimelines,
+  connectTimeline,
+  disconnectTimeline
 } from '../../actions/timelines';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
@@ -44,6 +46,18 @@ const PublicTimeline = React.createClass({
 
     subscription = createStream(accessToken, 'public', {
 
+      connected () {
+        dispatch(connectTimeline('public'));
+      },
+
+      reconnected () {
+        dispatch(connectTimeline('public'));
+      },
+
+      disconnected () {
+        dispatch(disconnectTimeline('public'));
+      },
+
       received (data) {
         switch(data.event) {
         case 'update':
diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx
index 6a7635cc6..f98fe1b01 100644
--- a/app/assets/javascripts/components/features/status/index.jsx
+++ b/app/assets/javascripts/components/features/status/index.jsx
@@ -28,7 +28,7 @@ import {
 import { ScrollContainer } from 'react-router-scroll';
 import ColumnBackButton from '../../components/column_back_button';
 import StatusContainer from '../../containers/status_container';
-import { openMedia } from '../../actions/modal';
+import { openModal } from '../../actions/modal';
 import { isMobile } from '../../is_mobile'
 
 const makeMapStateToProps = () => {
@@ -99,7 +99,7 @@ const Status = React.createClass({
   },
 
   handleOpenMedia (media, index) {
-    this.props.dispatch(openMedia(media, index));
+    this.props.dispatch(openModal('MEDIA', { media, index }));
   },
 
   handleReport (status) {
diff --git a/app/assets/javascripts/components/features/ui/components/media_modal.jsx b/app/assets/javascripts/components/features/ui/components/media_modal.jsx
new file mode 100644
index 000000000..35eb2cb0c
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/media_modal.jsx
@@ -0,0 +1,133 @@
+import LoadingIndicator from '../../../components/loading_indicator';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ExtendedVideoPlayer from '../../../components/extended_video_player';
+import ImageLoader from 'react-imageloader';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' }
+});
+
+const leftNavStyle = {
+  position: 'absolute',
+  background: 'rgba(0, 0, 0, 0.5)',
+  padding: '30px 15px',
+  cursor: 'pointer',
+  fontSize: '24px',
+  top: '0',
+  left: '-61px',
+  boxSizing: 'border-box',
+  height: '100%',
+  display: 'flex',
+  alignItems: 'center'
+};
+
+const rightNavStyle = {
+  position: 'absolute',
+  background: 'rgba(0, 0, 0, 0.5)',
+  padding: '30px 15px',
+  cursor: 'pointer',
+  fontSize: '24px',
+  top: '0',
+  right: '-61px',
+  boxSizing: 'border-box',
+  height: '100%',
+  display: 'flex',
+  alignItems: 'center'
+};
+
+const closeStyle = {
+  position: 'absolute',
+  top: '4px',
+  right: '4px'
+};
+
+const MediaModal = React.createClass({
+
+  propTypes: {
+    media: ImmutablePropTypes.list.isRequired,
+    index: React.PropTypes.number.isRequired,
+    onClose: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
+  },
+
+  getInitialState () {
+    return {
+      index: null
+    };
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleNextClick () {
+    this.setState({ index: (this.getIndex() + 1) % this.props.media.size});
+  },
+
+  handlePrevClick () {
+    this.setState({ index: (this.getIndex() - 1) % this.props.media.size});
+  },
+
+  handleKeyUp (e) {
+    switch(e.key) {
+    case 'ArrowLeft':
+      this.handlePrevClick();
+      break;
+    case 'ArrowRight':
+      this.handleNextClick();
+      break;
+    }
+  },
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  },
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  },
+
+  getIndex () {
+    return this.state.index !== null ? this.state.index : this.props.index;
+  },
+
+  render () {
+    const { media, intl, onClose } = this.props;
+
+    const index = this.getIndex();
+    const attachment = media.get(index);
+    const url = attachment.get('url');
+
+    let leftNav, rightNav, content;
+
+    leftNav = rightNav = content = '';
+
+    if (media.size > 1) {
+      leftNav  = <div style={leftNavStyle} className='modal-container__nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
+      rightNav = <div style={rightNavStyle} className='modal-container__nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
+    }
+
+    if (attachment.get('type') === 'image') {
+      content = <ImageLoader src={url} imgProps={{ style: { display: 'block' } }} />;
+    } else if (attachment.get('type') === 'gifv') {
+      content = <ExtendedVideoPlayer src={url} />;
+    }
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        {leftNav}
+
+        <div>
+          <IconButton title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} style={closeStyle} />
+          {content}
+        </div>
+
+        {rightNav}
+      </div>
+    );
+  }
+
+});
+
+export default injectIntl(MediaModal);
diff --git a/app/assets/javascripts/components/features/ui/components/modal_root.jsx b/app/assets/javascripts/components/features/ui/components/modal_root.jsx
new file mode 100644
index 000000000..d2ae5e145
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/modal_root.jsx
@@ -0,0 +1,80 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import MediaModal from './media_modal';
+import { TransitionMotion, spring } from 'react-motion';
+
+const MODAL_COMPONENTS = {
+  'MEDIA': MediaModal
+};
+
+const ModalRoot = React.createClass({
+
+  propTypes: {
+    type: React.PropTypes.string,
+    props: React.PropTypes.object,
+    onClose: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleKeyUp (e) {
+    if (e.key === 'Escape' && !!this.props.type) {
+      this.props.onClose();
+    }
+  },
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  },
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  },
+
+  willEnter () {
+    return { opacity: 0, scale: 0.98 };
+  },
+
+  willLeave () {
+    return { opacity: spring(0), scale: spring(0.98) };
+  },
+
+  render () {
+    const { type, props, onClose } = this.props;
+    const items = [];
+
+    if (!!type) {
+      items.push({
+        key: type,
+        data: { type, props },
+        style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) }
+      });
+    }
+
+    return (
+      <TransitionMotion
+        styles={items}
+        willEnter={this.willEnter}
+        willLeave={this.willLeave}>
+        {interpolatedStyles =>
+          <div className='modal-root'>
+            {interpolatedStyles.map(({ key, data: { type, props }, style }) => {
+              const SpecificComponent = MODAL_COMPONENTS[type];
+
+              return (
+                <div key={key}>
+                  <div className='modal-root__overlay' style={{ opacity: style.opacity, transform: `translateZ(0px)` }} onClick={onClose} />
+                  <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
+                    <SpecificComponent {...props} onClose={onClose} />
+                  </div>
+                </div>
+              );
+            })}
+          </div>
+        }
+      </TransitionMotion>
+    );
+  }
+
+});
+
+export default ModalRoot;
diff --git a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
index 225a6a5fc..6cdb29dbf 100644
--- a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
+++ b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
@@ -1,15 +1,23 @@
 import { Link } from 'react-router';
 import { FormattedMessage } from 'react-intl';
 
-const TabsBar = () => {
-  return (
-    <div className='tabs-bar'>
-      <Link className='tabs-bar__link' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
-      <Link className='tabs-bar__link' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
-      <Link className='tabs-bar__link' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
-      <Link className='tabs-bar__link' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link>
-    </div>
-  );
-};
+const TabsBar = React.createClass({
+
+  render () {
+    return (
+      <div className='tabs-bar'>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
+
+        <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public/local'><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></Link>
+        <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public'><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></Link>
+
+        <Link className='tabs-bar__link primary' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link>
+      </div>
+    );
+  }
+
+});
 
 export default TabsBar;
diff --git a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
index e3c4281b9..26d77818c 100644
--- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
@@ -1,170 +1,16 @@
 import { connect } from 'react-redux';
-import {
-  closeModal,
-  decreaseIndexInModal,
-  increaseIndexInModal
-} from '../../../actions/modal';
-import Lightbox from '../../../components/lightbox';
-import ImageLoader from 'react-imageloader';
-import LoadingIndicator from '../../../components/loading_indicator';
-import PureRenderMixin from 'react-addons-pure-render-mixin';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ExtendedVideoPlayer from '../../../components/extended_video_player';
+import { closeModal } from '../../../actions/modal';
+import ModalRoot from '../components/modal_root';
 
 const mapStateToProps = state => ({
-  media: state.getIn(['modal', 'media']),
-  index: state.getIn(['modal', 'index']),
-  isVisible: state.getIn(['modal', 'open'])
+  type: state.get('modal').modalType,
+  props: state.get('modal').modalProps
 });
 
 const mapDispatchToProps = dispatch => ({
-  onCloseClicked () {
+  onClose () {
     dispatch(closeModal());
   },
-
-  onOverlayClicked () {
-    dispatch(closeModal());
-  },
-
-  onNextClicked () {
-    dispatch(increaseIndexInModal());
-  },
-
-  onPrevClicked () {
-    dispatch(decreaseIndexInModal());
-  }
-});
-
-const imageStyle = {
-  display: 'block',
-  maxWidth: '80vw',
-  maxHeight: '80vh'
-};
-
-const loadingStyle = {
-  width: '400px',
-  paddingBottom: '120px'
-};
-
-const preloader = () => (
-  <div className='modal-container--preloader' style={loadingStyle}>
-    <LoadingIndicator />
-  </div>
-);
-
-const leftNavStyle = {
-  position: 'absolute',
-  background: 'rgba(0, 0, 0, 0.5)',
-  padding: '30px 15px',
-  cursor: 'pointer',
-  fontSize: '24px',
-  top: '0',
-  left: '-61px',
-  boxSizing: 'border-box',
-  height: '100%',
-  display: 'flex',
-  alignItems: 'center'
-};
-
-const rightNavStyle = {
-  position: 'absolute',
-  background: 'rgba(0, 0, 0, 0.5)',
-  padding: '30px 15px',
-  cursor: 'pointer',
-  fontSize: '24px',
-  top: '0',
-  right: '-61px',
-  boxSizing: 'border-box',
-  height: '100%',
-  display: 'flex',
-  alignItems: 'center'
-};
-
-const Modal = React.createClass({
-
-  propTypes: {
-    media: ImmutablePropTypes.list,
-    index: React.PropTypes.number.isRequired,
-    isVisible: React.PropTypes.bool,
-    onCloseClicked: React.PropTypes.func,
-    onOverlayClicked: React.PropTypes.func,
-    onNextClicked: React.PropTypes.func,
-    onPrevClicked: React.PropTypes.func
-  },
-
-  mixins: [PureRenderMixin],
-
-  handleNextClick () {
-    this.props.onNextClicked();
-  },
-
-  handlePrevClick () {
-    this.props.onPrevClicked();
-  },
-
-  componentDidMount () {
-    this._listener = e => {
-      if (!this.props.isVisible) {
-        return;
-      }
-
-      switch(e.key) {
-      case 'ArrowLeft':
-        this.props.onPrevClicked();
-        break;
-      case 'ArrowRight':
-        this.props.onNextClicked();
-        break;
-      }
-    };
-
-    window.addEventListener('keyup', this._listener);
-  },
-
-  componentWillUnmount () {
-    window.removeEventListener('keyup', this._listener);
-  },
-
-  render () {
-    const { media, index, ...other } = this.props;
-
-    if (!media) {
-      return null;
-    }
-
-    const attachment = media.get(index);
-    const url = attachment.get('url');
-
-    let leftNav, rightNav, content;
-
-    leftNav = rightNav = content = '';
-
-    if (media.size > 1) {
-      leftNav  = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
-      rightNav = <div style={rightNavStyle} className='modal-container--nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
-    }
-
-    if (attachment.get('type') === 'image') {
-      content = (
-        <ImageLoader
-          src={url}
-          preloader={preloader}
-          imgProps={{ style: imageStyle }}
-        />
-      );
-    } else if (attachment.get('type') === 'gifv') {
-      content = <ExtendedVideoPlayer src={url} />;
-    }
-
-    return (
-      <Lightbox {...other}>
-        {leftNav}
-        {content}
-        {rightNav}
-      </Lightbox>
-    );
-  }
-
 });
 
-export default connect(mapStateToProps, mapDispatchToProps)(Modal);
+export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);
diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx
index b7e8f32a4..89fb82568 100644
--- a/app/assets/javascripts/components/features/ui/index.jsx
+++ b/app/assets/javascripts/components/features/ui/index.jsx
@@ -36,15 +36,33 @@ const UI = React.createClass({
     this.setState({ width: window.innerWidth });
   },
 
+  handleDragEnter (e) {
+    e.preventDefault();
+
+    if (!this.dragTargets) {
+      this.dragTargets = [];
+    }
+
+    if (this.dragTargets.indexOf(e.target) === -1) {
+      this.dragTargets.push(e.target);
+    }
+
+    if (e.dataTransfer && e.dataTransfer.files.length > 0) {
+      this.setState({ draggingOver: true });
+    }
+  },
+
   handleDragOver (e) {
     e.preventDefault();
     e.stopPropagation();
 
-    e.dataTransfer.dropEffect = 'copy';
+    try {
+      e.dataTransfer.dropEffect = 'copy';
+    } catch (err) {
 
-    if (e.dataTransfer.effectAllowed === 'all' || e.dataTransfer.effectAllowed === 'uninitialized') {
-      this.setState({ draggingOver: true });
     }
+
+    return false;
   },
 
   handleDrop (e) {
@@ -57,14 +75,25 @@ const UI = React.createClass({
     }
   },
 
-  handleDragLeave () {
+  handleDragLeave (e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
+
+    if (this.dragTargets.length > 0) {
+      return;
+    }
+
     this.setState({ draggingOver: false });
   },
 
   componentWillMount () {
     window.addEventListener('resize', this.handleResize, { passive: true });
-    window.addEventListener('dragover', this.handleDragOver);
-    window.addEventListener('drop', this.handleDrop);
+    document.addEventListener('dragenter', this.handleDragEnter, false);
+    document.addEventListener('dragover', this.handleDragOver, false);
+    document.addEventListener('drop', this.handleDrop, false);
+    document.addEventListener('dragleave', this.handleDragLeave, false);
 
     this.props.dispatch(refreshTimeline('home'));
     this.props.dispatch(refreshNotifications());
@@ -72,8 +101,14 @@ const UI = React.createClass({
 
   componentWillUnmount () {
     window.removeEventListener('resize', this.handleResize);
-    window.removeEventListener('dragover', this.handleDragOver);
-    window.removeEventListener('drop', this.handleDrop);
+    document.removeEventListener('dragenter', this.handleDragEnter);
+    document.removeEventListener('dragover', this.handleDragOver);
+    document.removeEventListener('drop', this.handleDrop);
+    document.removeEventListener('dragleave', this.handleDragLeave);
+  },
+
+  setRef (c) {
+    this.node = c;
   },
 
   render () {
@@ -100,7 +135,7 @@ const UI = React.createClass({
     }
 
     return (
-      <div className='ui' onDragLeave={this.handleDragLeave}>
+      <div className='ui' ref={this.setRef}>
         <TabsBar />
 
         {mountedColumns}