about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript')
-rw-r--r--app/javascript/mastodon/actions/search.js2
-rw-r--r--app/javascript/mastodon/actions/trends.js32
-rw-r--r--app/javascript/mastodon/components/scrollable_list.js11
-rw-r--r--app/javascript/mastodon/components/status_list.js1
-rw-r--r--app/javascript/mastodon/features/community_timeline/index.js1
-rw-r--r--app/javascript/mastodon/features/compose/components/search_results.js71
-rw-r--r--app/javascript/mastodon/features/compose/containers/search_results_container.js8
-rw-r--r--app/javascript/mastodon/features/compose/index.js4
-rw-r--r--app/javascript/mastodon/features/public_timeline/index.js1
-rw-r--r--app/javascript/mastodon/features/status/index.js21
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/reducers/search.js4
-rw-r--r--app/javascript/mastodon/reducers/trends.js13
-rw-r--r--app/javascript/mastodon/service_worker/entry.js2
-rw-r--r--app/javascript/styles/mastodon-light/diff.scss38
-rw-r--r--app/javascript/styles/mastodon/components.scss132
16 files changed, 283 insertions, 60 deletions
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
index 882c1709e..b670d25c3 100644
--- a/app/javascript/mastodon/actions/search.js
+++ b/app/javascript/mastodon/actions/search.js
@@ -33,7 +33,7 @@ export function submitSearch() {
 
     dispatch(fetchSearchRequest());
 
-    api(getState).get('/api/v1/search', {
+    api(getState).get('/api/v2/search', {
       params: {
         q: value,
         resolve: true,
diff --git a/app/javascript/mastodon/actions/trends.js b/app/javascript/mastodon/actions/trends.js
new file mode 100644
index 000000000..853e4f60a
--- /dev/null
+++ b/app/javascript/mastodon/actions/trends.js
@@ -0,0 +1,32 @@
+import api from '../api';
+
+export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST';
+export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS';
+export const TRENDS_FETCH_FAIL    = 'TRENDS_FETCH_FAIL';
+
+export const fetchTrends = () => (dispatch, getState) => {
+  dispatch(fetchTrendsRequest());
+
+  api(getState)
+    .get('/api/v1/trends')
+    .then(({ data }) => dispatch(fetchTrendsSuccess(data)))
+    .catch(err => dispatch(fetchTrendsFail(err)));
+};
+
+export const fetchTrendsRequest = () => ({
+  type: TRENDS_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchTrendsSuccess = trends => ({
+  type: TRENDS_FETCH_SUCCESS,
+  trends,
+  skipLoading: true,
+});
+
+export const fetchTrendsFail = error => ({
+  type: TRENDS_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
index fd6858d05..4b433f32c 100644
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -25,6 +25,7 @@ export default class ScrollableList extends PureComponent {
     isLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
     prepend: PropTypes.node,
+    alwaysPrepend: PropTypes.bool,
     emptyMessage: PropTypes.node,
     children: PropTypes.node,
   };
@@ -140,7 +141,7 @@ export default class ScrollableList extends PureComponent {
   }
 
   render () {
-    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, onLoadMore } = this.props;
+    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
@@ -172,8 +173,12 @@ export default class ScrollableList extends PureComponent {
       );
     } else {
       scrollableArea = (
-        <div className='empty-column-indicator' ref={this.setRef}>
-          {emptyMessage}
+        <div style={{ flex: '1 1 auto', display: 'flex', flexDirection: 'column' }}>
+          {alwaysPrepend && prepend}
+
+          <div className='empty-column-indicator' ref={this.setRef}>
+            {emptyMessage}
+          </div>
         </div>
       );
     }
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 0c971ceb0..1c34d0640 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -24,6 +24,7 @@ export default class StatusList extends ImmutablePureComponent {
     hasMore: PropTypes.bool,
     prepend: PropTypes.node,
     emptyMessage: PropTypes.node,
+    alwaysPrepend: PropTypes.bool,
   };
 
   static defaultProps = {
diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js
index f9ee835bc..d375edbd5 100644
--- a/app/javascript/mastodon/features/community_timeline/index.js
+++ b/app/javascript/mastodon/features/community_timeline/index.js
@@ -127,6 +127,7 @@ export default class CommunityTimeline extends React.PureComponent {
 
         <StatusListContainer
           prepend={headline}
+          alwaysPrepend
           trackScroll={!pinned}
           scrollKey={`community_timeline-${columnId}`}
           timelineId={`community${onlyMedia ? ':media' : ''}`}
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
index 84455563c..445bf27bb 100644
--- a/app/javascript/mastodon/features/compose/components/search_results.js
+++ b/app/javascript/mastodon/features/compose/components/search_results.js
@@ -1,28 +1,82 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage } from 'react-intl';
+import { FormattedMessage, FormattedNumber } from 'react-intl';
 import AccountContainer from '../../../containers/account_container';
 import StatusContainer from '../../../containers/status_container';
 import { Link } from 'react-router-dom';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { Sparklines, SparklinesCurve } from 'react-sparklines';
+
+const shortNumberFormat = number => {
+  if (number < 1000) {
+    return <FormattedNumber value={number} />;
+  } else {
+    return <React.Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</React.Fragment>;
+  }
+};
+
+const renderHashtag = hashtag => (
+  <div className='trends__item' key={hashtag.get('name')}>
+    <div className='trends__item__name'>
+      <Link to={`/timelines/tag/${hashtag.get('name')}`}>
+        #<span>{hashtag.get('name')}</span>
+      </Link>
+
+      <FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
+    </div>
+
+    <div className='trends__item__current'>
+      {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
+    </div>
+
+    <div className='trends__item__sparkline'>
+      <Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
+        <SparklinesCurve style={{ fill: 'none' }} />
+      </Sparklines>
+    </div>
+  </div>
+);
 
 export default class SearchResults extends ImmutablePureComponent {
 
   static propTypes = {
     results: ImmutablePropTypes.map.isRequired,
+    trends: ImmutablePropTypes.list,
+    fetchTrends: PropTypes.func.isRequired,
   };
 
+  componentDidMount () {
+    const { fetchTrends } = this.props;
+    fetchTrends();
+  }
+
   render () {
-    const { results } = this.props;
+    const { results, trends } = this.props;
 
     let accounts, statuses, hashtags;
     let count = 0;
 
+    if (results.isEmpty()) {
+      return (
+        <div className='search-results'>
+          <div className='trends'>
+            <div className='trends__header'>
+              <i className='fa fa-fire fa-fw' />
+              <FormattedMessage id='trends.header' defaultMessage='Trending now' />
+            </div>
+
+            {trends && trends.map(hashtag => renderHashtag(hashtag))}
+          </div>
+        </div>
+      );
+    }
+
     if (results.get('accounts') && results.get('accounts').size > 0) {
       count   += results.get('accounts').size;
       accounts = (
         <div className='search-results__section'>
-          <h5><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
+          <h5><i className='fa fa-fw fa-users' /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
 
           {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
         </div>
@@ -33,7 +87,7 @@ export default class SearchResults extends ImmutablePureComponent {
       count   += results.get('statuses').size;
       statuses = (
         <div className='search-results__section'>
-          <h5><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
+          <h5><i className='fa fa-fw fa-quote-right' /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
 
           {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
         </div>
@@ -44,13 +98,9 @@ export default class SearchResults extends ImmutablePureComponent {
       count += results.get('hashtags').size;
       hashtags = (
         <div className='search-results__section'>
-          <h5><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
+          <h5><i className='fa fa-fw fa-hashtag' /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
 
-          {results.get('hashtags').map(hashtag => (
-            <Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
-              #{hashtag}
-            </Link>
-          ))}
+          {results.get('hashtags').map(hashtag => renderHashtag(hashtag))}
         </div>
       );
     }
@@ -58,6 +108,7 @@ export default class SearchResults extends ImmutablePureComponent {
     return (
       <div className='search-results'>
         <div className='search-results__header'>
+          <i className='fa fa-search fa-fw' />
           <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
         </div>
 
diff --git a/app/javascript/mastodon/features/compose/containers/search_results_container.js b/app/javascript/mastodon/features/compose/containers/search_results_container.js
index 16d95d417..7273460e2 100644
--- a/app/javascript/mastodon/features/compose/containers/search_results_container.js
+++ b/app/javascript/mastodon/features/compose/containers/search_results_container.js
@@ -1,8 +1,14 @@
 import { connect } from 'react-redux';
 import SearchResults from '../components/search_results';
+import { fetchTrends } from '../../../actions/trends';
 
 const mapStateToProps = state => ({
   results: state.getIn(['search', 'results']),
+  trends: state.get('trends'),
 });
 
-export default connect(mapStateToProps)(SearchResults);
+const mapDispatchToProps = dispatch => ({
+  fetchTrends: () => dispatch(fetchTrends()),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(SearchResults);
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index 19aae0332..d8e9ad9ee 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -101,7 +101,7 @@ export default class Compose extends React.PureComponent {
         {(multiColumn || isSearchPage) && <SearchContainer /> }
 
         <div className='drawer__pager'>
-          <div className='drawer__inner' onFocus={this.onFocus}>
+          {!isSearchPage && <div className='drawer__inner' onFocus={this.onFocus}>
             <NavigationContainer onClose={this.onBlur} />
             <ComposeFormContainer />
             {multiColumn && (
@@ -109,7 +109,7 @@ export default class Compose extends React.PureComponent {
                 <img alt='' draggable='false' src={elephantUIPlane} />
               </div>
             )}
-          </div>
+          </div>}
 
           <Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
             {({ x }) => (
diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js
index 15cc3e31d..341af582a 100644
--- a/app/javascript/mastodon/features/public_timeline/index.js
+++ b/app/javascript/mastodon/features/public_timeline/index.js
@@ -127,6 +127,7 @@ export default class PublicTimeline extends React.PureComponent {
 
         <StatusListContainer
           prepend={headline}
+          alwaysPrepend
           timelineId={`public${onlyMedia ? ':media' : ''}`}
           onLoadMore={this.handleLoadMore}
           trackScroll={!pinned}
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 2e53dfa7e..505a88a3f 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -62,31 +62,28 @@ const makeMapStateToProps = () => {
 
     if (status) {
       ancestorsIds = ancestorsIds.withMutations(mutable => {
-        function addAncestor(id) {
-          if (id) {
-            const inReplyTo = state.getIn(['contexts', 'inReplyTos', id]);
+        let id = status.get('in_reply_to_id');
 
-            mutable.unshift(id);
-            addAncestor(inReplyTo);
-          }
+        while (id) {
+          mutable.unshift(id);
+          id = state.getIn(['contexts', 'inReplyTos', id]);
         }
-
-        addAncestor(status.get('in_reply_to_id'));
       });
 
       descendantsIds = descendantsIds.withMutations(mutable => {
-        function addDescendantOf(id) {
+        const ids = [status.get('id')];
+
+        while (ids.length > 0) {
+          let id        = ids.shift();
           const replies = state.getIn(['contexts', 'replies', id]);
 
           if (replies) {
             replies.forEach(reply => {
               mutable.push(reply);
-              addDescendantOf(reply);
+              ids.unshift(reply);
             });
           }
         }
-
-        addDescendantOf(status.get('id'));
       });
     }
 
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 3d9a6a132..019c1f466 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -26,6 +26,7 @@ import height_cache from './height_cache';
 import custom_emojis from './custom_emojis';
 import lists from './lists';
 import listEditor from './list_editor';
+import trends from './trends';
 
 const reducers = {
   dropdown_menu,
@@ -55,6 +56,7 @@ const reducers = {
   custom_emojis,
   lists,
   listEditor,
+  trends,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
index 56fd7226b..4758defb1 100644
--- a/app/javascript/mastodon/reducers/search.js
+++ b/app/javascript/mastodon/reducers/search.js
@@ -9,7 +9,7 @@ import {
   COMPOSE_REPLY,
   COMPOSE_DIRECT,
 } from '../actions/compose';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
 
 const initialState = ImmutableMap({
   value: '',
@@ -39,7 +39,7 @@ export default function search(state = initialState, action) {
     return state.set('results', ImmutableMap({
       accounts: ImmutableList(action.results.accounts.map(item => item.id)),
       statuses: ImmutableList(action.results.statuses.map(item => item.id)),
-      hashtags: ImmutableList(action.results.hashtags),
+      hashtags: fromJS(action.results.hashtags),
     })).set('submitted', true);
   default:
     return state;
diff --git a/app/javascript/mastodon/reducers/trends.js b/app/javascript/mastodon/reducers/trends.js
new file mode 100644
index 000000000..95cf8f284
--- /dev/null
+++ b/app/javascript/mastodon/reducers/trends.js
@@ -0,0 +1,13 @@
+import { TRENDS_FETCH_SUCCESS } from '../actions/trends';
+import { fromJS } from 'immutable';
+
+const initialState = null;
+
+export default function trendsReducer(state = initialState, action) {
+  switch(action.type) {
+  case TRENDS_FETCH_SUCCESS:
+    return fromJS(action.trends);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js
index ce42271a9..c1854c1cd 100644
--- a/app/javascript/mastodon/service_worker/entry.js
+++ b/app/javascript/mastodon/service_worker/entry.js
@@ -49,7 +49,7 @@ self.addEventListener('fetch', function(event) {
 
       return response;
     }));
-  } else if (storageFreeable && process.env.CDN_HOST ? url.host === process.env.CDN_HOST : url.pathname.startsWith('/system/')) {
+  } else if (storageFreeable && (ATTACHMENT_HOST ? url.host === ATTACHMENT_HOST : url.pathname.startsWith('/system/'))) {
     event.respondWith(openSystemCache().then(cache => {
       return cache.match(event.request.url).then(cached => {
         if (cached === undefined) {
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index 2b88b830c..fe304317d 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -132,20 +132,54 @@
 .boost-modal,
 .confirmation-modal,
 .mute-modal,
-.report-modal {
+.report-modal,
+.embed-modal,
+.error-modal,
+.onboarding-modal {
   background: $ui-base-color;
 }
 
 .boost-modal__action-bar,
 .confirmation-modal__action-bar,
-.mute-modal__action-bar {
+.mute-modal__action-bar,
+.onboarding-modal__paginator,
+.error-modal__footer {
   background: darken($ui-base-color, 6%);
+
+  .onboarding-modal__nav,
+  .error-modal__nav {
+    &:hover,
+    &:focus,
+    &:active {
+      background-color: darken($ui-base-color, 12%);
+    }
+  }
+}
+
+.display-case__case {
+  background: $white;
+}
+
+.embed-modal .embed-modal__container .embed-modal__html {
+  background: $white;
+
+  &:focus {
+    background: darken($ui-base-color, 6%);
+  }
 }
 
 .react-toggle-track {
   background: $ui-secondary-color;
 }
 
+.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
+  background: darken($ui-secondary-color, 10%);
+}
+
+.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
+  background: lighten($ui-highlight-color, 10%);
+}
+
 // Change the default color used for the text in an empty column or on the error column
 .empty-column-indicator,
 .error-column {
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index abffa6daa..712b6f813 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -3284,6 +3284,15 @@ a.status-card {
 }
 
 .search__icon {
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner,
+  &:focus {
+    outline: 0 !important;
+  }
+
   .fa {
     position: absolute;
     top: 10px;
@@ -3333,40 +3342,33 @@ a.status-card {
 .search-results__header {
   color: $dark-text-color;
   background: lighten($ui-base-color, 2%);
-  border-bottom: 1px solid darken($ui-base-color, 4%);
-  padding: 15px 10px;
-  font-size: 14px;
+  padding: 15px;
   font-weight: 500;
+  font-size: 16px;
+  cursor: default;
+
+  .fa {
+    display: inline-block;
+    margin-right: 5px;
+  }
 }
 
 .search-results__section {
-  margin-bottom: 20px;
+  margin-bottom: 5px;
 
   h5 {
-    position: relative;
-
-    &::before {
-      content: "";
-      display: block;
-      position: absolute;
-      left: 0;
-      right: 0;
-      top: 50%;
-      width: 100%;
-      height: 0;
-      border-top: 1px solid lighten($ui-base-color, 8%);
-    }
+    background: darken($ui-base-color, 4%);
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+    cursor: default;
+    display: flex;
+    padding: 15px;
+    font-weight: 500;
+    font-size: 16px;
+    color: $dark-text-color;
 
-    span {
+    .fa {
       display: inline-block;
-      background: $ui-base-color;
-      color: $darker-text-color;
-      font-size: 14px;
-      font-weight: 500;
-      padding: 10px;
-      position: relative;
-      z-index: 1;
-      cursor: default;
+      margin-right: 5px;
     }
   }
 
@@ -5209,3 +5211,81 @@ noscript {
     background: $ui-base-color;
   }
 }
+
+.trends {
+  &__header {
+    color: $dark-text-color;
+    background: lighten($ui-base-color, 2%);
+    border-bottom: 1px solid darken($ui-base-color, 4%);
+    font-weight: 500;
+    padding: 15px;
+    font-size: 16px;
+    cursor: default;
+
+    .fa {
+      display: inline-block;
+      margin-right: 5px;
+    }
+  }
+
+  &__item {
+    display: flex;
+    align-items: center;
+    padding: 15px;
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    &__name {
+      flex: 1 1 auto;
+      color: $dark-text-color;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+
+      strong {
+        font-weight: 500;
+      }
+
+      a {
+        color: $darker-text-color;
+        text-decoration: none;
+        font-size: 14px;
+        font-weight: 500;
+        display: block;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+
+        &:hover,
+        &:focus,
+        &:active {
+          span {
+            text-decoration: underline;
+          }
+        }
+      }
+    }
+
+    &__current {
+      flex: 0 0 auto;
+      width: 100px;
+      font-size: 24px;
+      line-height: 36px;
+      font-weight: 500;
+      text-align: center;
+      color: $secondary-text-color;
+    }
+
+    &__sparkline {
+      flex: 0 0 auto;
+      width: 50px;
+
+      path {
+        stroke: lighten($highlight-text-color, 6%) !important;
+      }
+    }
+  }
+}