Browse Source

Redesign public hashtag page to use a masonry layout (#9822)

Eugen Rochko 8 months ago
parent
commit
bc642ac24b
No account linked to committer's email

+ 2
- 0
app/controllers/tags_controller.rb View File

@@ -3,6 +3,8 @@
3 3
 class TagsController < ApplicationController
4 4
   PAGE_SIZE = 20
5 5
 
6
+  layout 'public'
7
+
6 8
   before_action :set_body_classes
7 9
   before_action :set_instance_presenter
8 10
 

+ 10
- 2
app/javascript/mastodon/components/display_name.js View File

@@ -1,15 +1,17 @@
1 1
 import React from 'react';
2 2
 import ImmutablePropTypes from 'react-immutable-proptypes';
3
+import PropTypes from 'prop-types';
3 4
 
4 5
 export default class DisplayName extends React.PureComponent {
5 6
 
6 7
   static propTypes = {
7 8
     account: ImmutablePropTypes.map.isRequired,
8 9
     others: ImmutablePropTypes.list,
10
+    localDomain: PropTypes.string,
9 11
   };
10 12
 
11 13
   render () {
12
-    const { account, others } = this.props;
14
+    const { account, others, localDomain } = this.props;
13 15
     const displayNameHtml = { __html: account.get('display_name_html') };
14 16
 
15 17
     let suffix;
@@ -17,7 +19,13 @@ export default class DisplayName extends React.PureComponent {
17 19
     if (others && others.size > 1) {
18 20
       suffix = `+${others.size}`;
19 21
     } else {
20
-      suffix = <span className='display-name__account'>@{account.get('acct')}</span>;
22
+      let acct = account.get('acct');
23
+
24
+      if (acct.indexOf('@') === -1 && localDomain) {
25
+        acct = `${acct}@${localDomain}`;
26
+      }
27
+
28
+      suffix = <span className='display-name__account'>@{acct}</span>;
21 29
     }
22 30
 
23 31
     return (

+ 1
- 1
app/javascript/mastodon/components/status.js View File

@@ -77,7 +77,7 @@ class Status extends ImmutablePureComponent {
77 77
     'account',
78 78
     'muted',
79 79
     'hidden',
80
-  ]
80
+  ];
81 81
 
82 82
   handleClick = () => {
83 83
     if (this.props.onClick) {

+ 57
- 29
app/javascript/mastodon/features/standalone/hashtag_timeline/index.js View File

@@ -1,28 +1,32 @@
1 1
 import React from 'react';
2 2
 import { connect } from 'react-redux';
3 3
 import PropTypes from 'prop-types';
4
-import StatusListContainer from '../../ui/containers/status_list_container';
4
+import ImmutablePropTypes from 'react-immutable-proptypes';
5 5
 import { expandHashtagTimeline } from '../../../actions/timelines';
6
-import Column from '../../../components/column';
7
-import ColumnHeader from '../../../components/column_header';
8 6
 import { connectHashtagStream } from '../../../actions/streaming';
7
+import Masonry from 'react-masonry-infinite';
8
+import { List as ImmutableList } from 'immutable';
9
+import DetailedStatusContainer from '../../status/containers/detailed_status_container';
10
+import { debounce } from 'lodash';
11
+import LoadingIndicator from '../../../components/loading_indicator';
9 12
 
10
-export default @connect()
13
+const mapStateToProps = (state, { hashtag }) => ({
14
+  statusIds: state.getIn(['timelines', `hashtag:${hashtag}`, 'items'], ImmutableList()),
15
+  isLoading: state.getIn(['timelines', `hashtag:${hashtag}`, 'isLoading'], false),
16
+  hasMore: state.getIn(['timelines', `hashtag:${hashtag}`, 'hasMore'], false),
17
+});
18
+
19
+export default @connect(mapStateToProps)
11 20
 class HashtagTimeline extends React.PureComponent {
12 21
 
13 22
   static propTypes = {
14 23
     dispatch: PropTypes.func.isRequired,
24
+    statusIds: ImmutablePropTypes.list.isRequired,
25
+    isLoading: PropTypes.bool.isRequired,
26
+    hasMore: PropTypes.bool.isRequired,
15 27
     hashtag: PropTypes.string.isRequired,
16 28
   };
17 29
 
18
-  handleHeaderClick = () => {
19
-    this.column.scrollTop();
20
-  }
21
-
22
-  setRef = c => {
23
-    this.column = c;
24
-  }
25
-
26 30
   componentDidMount () {
27 31
     const { dispatch, hashtag } = this.props;
28 32
 
@@ -37,28 +41,52 @@ class HashtagTimeline extends React.PureComponent {
37 41
     }
38 42
   }
39 43
 
40
-  handleLoadMore = maxId => {
41
-    this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
44
+  handleLoadMore = () => {
45
+    const maxId = this.props.statusIds.last();
46
+
47
+    if (maxId) {
48
+      this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
49
+    }
50
+  }
51
+
52
+  setRef = c => {
53
+    this.masonry = c;
42 54
   }
43 55
 
56
+  handleHeightChange = debounce(() => {
57
+    if (!this.masonry) {
58
+      return;
59
+    }
60
+
61
+    this.masonry.forcePack();
62
+  }, 50)
63
+
44 64
   render () {
45
-    const { hashtag } = this.props;
65
+    const { statusIds, hasMore, isLoading } = this.props;
66
+
67
+    const sizes = [
68
+      { columns: 1, gutter: 0 },
69
+      { mq: '415px', columns: 1, gutter: 10 },
70
+      { mq: '640px', columns: 2, gutter: 10 },
71
+      { mq: '960px', columns: 3, gutter: 10 },
72
+      { mq: '1255px', columns: 3, gutter: 10 },
73
+    ];
74
+
75
+    const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined;
46 76
 
47 77
     return (
48
-      <Column ref={this.setRef}>
49
-        <ColumnHeader
50
-          icon='hashtag'
51
-          title={hashtag}
52
-          onClick={this.handleHeaderClick}
53
-        />
54
-
55
-        <StatusListContainer
56
-          trackScroll={false}
57
-          scrollKey='standalone_hashtag_timeline'
58
-          timelineId={`hashtag:${hashtag}`}
59
-          onLoadMore={this.handleLoadMore}
60
-        />
61
-      </Column>
78
+      <Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}>
79
+        {statusIds.map(statusId => (
80
+          <div className='statuses-grid__item' key={statusId}>
81
+            <DetailedStatusContainer
82
+              id={statusId}
83
+              showThread
84
+              measureHeight
85
+              onHeightChange={this.handleHeightChange}
86
+            />
87
+          </div>
88
+        )).toArray()}
89
+      </Masonry>
62 90
     );
63 91
   }
64 92
 

+ 92
- 15
app/javascript/mastodon/features/status/components/detailed_status.js View File

@@ -11,6 +11,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
11 11
 import Card from './card';
12 12
 import ImmutablePureComponent from 'react-immutable-pure-component';
13 13
 import Video from '../../video';
14
+import scheduleIdleTask from '../../ui/util/schedule_idle_task';
14 15
 
15 16
 export default class DetailedStatus extends ImmutablePureComponent {
16 17
 
@@ -23,10 +24,17 @@ export default class DetailedStatus extends ImmutablePureComponent {
23 24
     onOpenMedia: PropTypes.func.isRequired,
24 25
     onOpenVideo: PropTypes.func.isRequired,
25 26
     onToggleHidden: PropTypes.func.isRequired,
27
+    measureHeight: PropTypes.bool,
28
+    onHeightChange: PropTypes.func,
29
+    domain: PropTypes.string.isRequired,
30
+  };
31
+
32
+  state = {
33
+    height: null,
26 34
   };
27 35
 
28 36
   handleAccountClick = (e) => {
29
-    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
37
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
30 38
       e.preventDefault();
31 39
       this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
32 40
     }
@@ -42,13 +50,56 @@ export default class DetailedStatus extends ImmutablePureComponent {
42 50
     this.props.onToggleHidden(this.props.status);
43 51
   }
44 52
 
53
+  _measureHeight (heightJustChanged) {
54
+    if (this.props.measureHeight && this.node) {
55
+      scheduleIdleTask(() => this.node && this.setState({ height: this.node.offsetHeight }));
56
+
57
+      if (this.props.onHeightChange && heightJustChanged) {
58
+        this.props.onHeightChange();
59
+      }
60
+    }
61
+  }
62
+
63
+  setRef = c => {
64
+    this.node = c;
65
+    this._measureHeight();
66
+  }
67
+
68
+  componentDidUpdate (prevProps, prevState) {
69
+    this._measureHeight(prevState.height !== this.state.height);
70
+  }
71
+
72
+  handleModalLink = e => {
73
+    e.preventDefault();
74
+
75
+    let href;
76
+
77
+    if (e.target.nodeName !== 'A') {
78
+      href = e.target.parentNode.href;
79
+    } else {
80
+      href = e.target.href;
81
+    }
82
+
83
+    window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
84
+  }
85
+
45 86
   render () {
46 87
     const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
88
+    const outerStyle = { boxSizing: 'border-box' };
89
+
90
+    if (!status) {
91
+      return null;
92
+    }
47 93
 
48 94
     let media           = '';
49 95
     let applicationLink = '';
50 96
     let reblogLink = '';
51 97
     let reblogIcon = 'retweet';
98
+    let favouriteLink = '';
99
+
100
+    if (this.props.measureHeight) {
101
+      outerStyle.height = `${this.state.height}px`;
102
+    }
52 103
 
53 104
     if (status.get('media_attachments').size > 0) {
54 105
       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
@@ -95,20 +146,51 @@ export default class DetailedStatus extends ImmutablePureComponent {
95 146
 
96 147
     if (status.get('visibility') === 'private') {
97 148
       reblogLink = <i className={`fa fa-${reblogIcon}`} />;
149
+    } else if (this.context.router) {
150
+      reblogLink = (
151
+        <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
152
+          <i className={`fa fa-${reblogIcon}`} />
153
+          <span className='detailed-status__reblogs'>
154
+            <FormattedNumber value={status.get('reblogs_count')} />
155
+          </span>
156
+        </Link>
157
+      );
158
+    } else {
159
+      reblogLink = (
160
+        <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
161
+          <i className={`fa fa-${reblogIcon}`} />
162
+          <span className='detailed-status__reblogs'>
163
+            <FormattedNumber value={status.get('reblogs_count')} />
164
+          </span>
165
+        </a>
166
+      );
167
+    }
168
+
169
+    if (this.context.router) {
170
+      favouriteLink = (
171
+        <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
172
+          <i className='fa fa-star' />
173
+          <span className='detailed-status__favorites'>
174
+            <FormattedNumber value={status.get('favourites_count')} />
175
+          </span>
176
+        </Link>
177
+      );
98 178
     } else {
99
-      reblogLink = (<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
100
-        <i className={`fa fa-${reblogIcon}`} />
101
-        <span className='detailed-status__reblogs'>
102
-          <FormattedNumber value={status.get('reblogs_count')} />
103
-        </span>
104
-      </Link>);
179
+      favouriteLink = (
180
+        <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
181
+          <i className='fa fa-star' />
182
+          <span className='detailed-status__favorites'>
183
+            <FormattedNumber value={status.get('favourites_count')} />
184
+          </span>
185
+        </a>
186
+      );
105 187
     }
106 188
 
107 189
     return (
108
-      <div className='detailed-status'>
190
+      <div ref={this.setRef} className='detailed-status' style={outerStyle}>
109 191
         <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
110 192
           <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
111
-          <DisplayName account={status.get('account')} />
193
+          <DisplayName account={status.get('account')} localDomain={this.props.domain} />
112 194
         </a>
113 195
 
114 196
         <StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
@@ -118,12 +200,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
118 200
         <div className='detailed-status__meta'>
119 201
           <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
120 202
             <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
121
-          </a>{applicationLink} · {reblogLink} · <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
122
-            <i className='fa fa-star' />
123
-            <span className='detailed-status__favorites'>
124
-              <FormattedNumber value={status.get('favourites_count')} />
125
-            </span>
126
-          </Link>
203
+          </a>{applicationLink} · {reblogLink} · {favouriteLink}
127 204
         </div>
128 205
       </div>
129 206
     );

+ 172
- 0
app/javascript/mastodon/features/status/containers/detailed_status_container.js View File

@@ -0,0 +1,172 @@
1
+import React from 'react';
2
+import { connect } from 'react-redux';
3
+import DetailedStatus from '../components/detailed_status';
4
+import { makeGetStatus } from '../../../selectors';
5
+import {
6
+  replyCompose,
7
+  mentionCompose,
8
+  directCompose,
9
+} from '../../../actions/compose';
10
+import {
11
+  reblog,
12
+  favourite,
13
+  unreblog,
14
+  unfavourite,
15
+  pin,
16
+  unpin,
17
+} from '../../../actions/interactions';
18
+import { blockAccount } from '../../../actions/accounts';
19
+import {
20
+  muteStatus,
21
+  unmuteStatus,
22
+  deleteStatus,
23
+  hideStatus,
24
+  revealStatus,
25
+} from '../../../actions/statuses';
26
+import { initMuteModal } from '../../../actions/mutes';
27
+import { initReport } from '../../../actions/reports';
28
+import { openModal } from '../../../actions/modal';
29
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
30
+import { boostModal, deleteModal } from '../../../initial_state';
31
+import { showAlertForError } from '../../../actions/alerts';
32
+
33
+const messages = defineMessages({
34
+  deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
35
+  deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
36
+  redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
37
+  redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
38
+  blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
39
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
40
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
41
+});
42
+
43
+const makeMapStateToProps = () => {
44
+  const getStatus = makeGetStatus();
45
+
46
+  const mapStateToProps = (state, props) => ({
47
+    status: getStatus(state, props),
48
+    domain: state.getIn(['meta', 'domain']),
49
+  });
50
+
51
+  return mapStateToProps;
52
+};
53
+
54
+const mapDispatchToProps = (dispatch, { intl }) => ({
55
+
56
+  onReply (status, router) {
57
+    dispatch((_, getState) => {
58
+      let state = getState();
59
+      if (state.getIn(['compose', 'text']).trim().length !== 0) {
60
+        dispatch(openModal('CONFIRM', {
61
+          message: intl.formatMessage(messages.replyMessage),
62
+          confirm: intl.formatMessage(messages.replyConfirm),
63
+          onConfirm: () => dispatch(replyCompose(status, router)),
64
+        }));
65
+      } else {
66
+        dispatch(replyCompose(status, router));
67
+      }
68
+    });
69
+  },
70
+
71
+  onModalReblog (status) {
72
+    dispatch(reblog(status));
73
+  },
74
+
75
+  onReblog (status, e) {
76
+    if (status.get('reblogged')) {
77
+      dispatch(unreblog(status));
78
+    } else {
79
+      if (e.shiftKey || !boostModal) {
80
+        this.onModalReblog(status);
81
+      } else {
82
+        dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
83
+      }
84
+    }
85
+  },
86
+
87
+  onFavourite (status) {
88
+    if (status.get('favourited')) {
89
+      dispatch(unfavourite(status));
90
+    } else {
91
+      dispatch(favourite(status));
92
+    }
93
+  },
94
+
95
+  onPin (status) {
96
+    if (status.get('pinned')) {
97
+      dispatch(unpin(status));
98
+    } else {
99
+      dispatch(pin(status));
100
+    }
101
+  },
102
+
103
+  onEmbed (status) {
104
+    dispatch(openModal('EMBED', {
105
+      url: status.get('url'),
106
+      onError: error => dispatch(showAlertForError(error)),
107
+    }));
108
+  },
109
+
110
+  onDelete (status, history, withRedraft = false) {
111
+    if (!deleteModal) {
112
+      dispatch(deleteStatus(status.get('id'), history, withRedraft));
113
+    } else {
114
+      dispatch(openModal('CONFIRM', {
115
+        message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
116
+        confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
117
+        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
118
+      }));
119
+    }
120
+  },
121
+
122
+  onDirect (account, router) {
123
+    dispatch(directCompose(account, router));
124
+  },
125
+
126
+  onMention (account, router) {
127
+    dispatch(mentionCompose(account, router));
128
+  },
129
+
130
+  onOpenMedia (media, index) {
131
+    dispatch(openModal('MEDIA', { media, index }));
132
+  },
133
+
134
+  onOpenVideo (media, time) {
135
+    dispatch(openModal('VIDEO', { media, time }));
136
+  },
137
+
138
+  onBlock (account) {
139
+    dispatch(openModal('CONFIRM', {
140
+      message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
141
+      confirm: intl.formatMessage(messages.blockConfirm),
142
+      onConfirm: () => dispatch(blockAccount(account.get('id'))),
143
+    }));
144
+  },
145
+
146
+  onReport (status) {
147
+    dispatch(initReport(status.get('account'), status));
148
+  },
149
+
150
+  onMute (account) {
151
+    dispatch(initMuteModal(account));
152
+  },
153
+
154
+  onMuteConversation (status) {
155
+    if (status.get('muted')) {
156
+      dispatch(unmuteStatus(status.get('id')));
157
+    } else {
158
+      dispatch(muteStatus(status.get('id')));
159
+    }
160
+  },
161
+
162
+  onToggleHidden (status) {
163
+    if (status.get('hidden')) {
164
+      dispatch(revealStatus(status.get('id')));
165
+    } else {
166
+      dispatch(hideStatus(status.get('id')));
167
+    }
168
+  },
169
+
170
+});
171
+
172
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));

+ 27
- 0
app/javascript/styles/mastodon/widgets.scss View File

@@ -425,3 +425,30 @@
425 425
     border-radius: 0;
426 426
   }
427 427
 }
428
+
429
+$maximum-width: 1235px;
430
+$fluid-breakpoint: $maximum-width + 20px;
431
+
432
+.statuses-grid {
433
+  min-height: 600px;
434
+
435
+  &__item {
436
+    width: (960px - 20px) / 3;
437
+
438
+    @media screen and (max-width: $fluid-breakpoint) {
439
+      width: (940px - 20px) / 3;
440
+    }
441
+
442
+    @media screen and (max-width: $no-gap-breakpoint) {
443
+      width: 100vw;
444
+    }
445
+  }
446
+
447
+  .detailed-status {
448
+    border-radius: 4px;
449
+
450
+    @media screen and (max-width: $no-gap-breakpoint) {
451
+      border-bottom: 1px solid lighten($ui-base-color, 12%);
452
+    }
453
+  }
454
+}

+ 1
- 29
app/views/tags/show.html.haml View File

@@ -8,33 +8,5 @@
8 8
   = javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous'
9 9
   = render 'og'
10 10
 
11
-.landing-page.tag-page.alternative
12
-  .features
13
-    .container
14
-      .grid
15
-        .column-1
16
-          #mastodon-timeline{ data: { props: Oj.dump(default_props.merge(hashtag: @tag.name)) } }
17
-
18
-        .column-2
19
-          .about-mastodon
20
-            .about-hashtag.landing-page__information
21
-              .brand
22
-                = link_to root_url do
23
-                  = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
24
-
25
-              %p= t 'about.about_hashtag_html', hashtag: @tag.name
26
-
27
-              .cta
28
-                - if user_signed_in?
29
-                  = link_to t('settings.back'), root_path, class: 'button button-secondary'
30
-                - else
31
-                  = link_to t('auth.login'), new_user_session_path, class: 'button button-secondary'
32
-                = link_to t('about.learn_more'), about_path, class: 'button button-alternative'
33
-
34
-            .landing-page__features.landing-page__information
35
-              %h3= t 'about.what_is_mastodon'
36
-              %p= t 'about.about_mastodon_html'
37
-
38
-              = render 'features'
39
-
11
+#mastodon-timeline{ data: { props: Oj.dump(default_props.merge(hashtag: @tag.name)) } }
40 12
 #modal-container

+ 1
- 0
package.json View File

@@ -98,6 +98,7 @@
98 98
     "react-immutable-proptypes": "^2.1.0",
99 99
     "react-immutable-pure-component": "^1.1.1",
100 100
     "react-intl": "^2.7.2",
101
+    "react-masonry-infinite": "^1.2.2",
101 102
     "react-motion": "^0.5.2",
102 103
     "react-notification": "^6.8.4",
103 104
     "react-overlays": "^0.8.3",

+ 1
- 1
spec/controllers/tags_controller_spec.rb View File

@@ -17,7 +17,7 @@ RSpec.describe TagsController, type: :controller do
17 17
 
18 18
       it 'renders application layout' do
19 19
         get :show, params: { id: 'test', max_id: late.id }
20
-        expect(response).to render_template layout: 'application'
20
+        expect(response).to render_template layout: 'public'
21 21
       end
22 22
     end
23 23
 

+ 28
- 0
yarn.lock View File

@@ -1681,6 +1681,13 @@ braces@^2.3.0, braces@^2.3.1:
1681 1681
     split-string "^3.0.2"
1682 1682
     to-regex "^3.0.1"
1683 1683
 
1684
+bricks.js@^1.7.0:
1685
+  version "1.8.0"
1686
+  resolved "https://registry.yarnpkg.com/bricks.js/-/bricks.js-1.8.0.tgz#8fdeb3c0226af251f4d5727a7df7f9ac0092b4b2"
1687
+  integrity sha1-j96zwCJq8lH01XJ6fff5rACStLI=
1688
+  dependencies:
1689
+    knot.js "^1.1.5"
1690
+
1684 1691
 brorand@^1.0.1:
1685 1692
   version "1.1.0"
1686 1693
   resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
@@ -5528,6 +5535,11 @@ kleur@^2.0.1:
5528 5535
   resolved "https://registry.yarnpkg.com/kleur/-/kleur-2.0.2.tgz#b704f4944d95e255d038f0cb05fb8a602c55a300"
5529 5536
   integrity sha512-77XF9iTllATmG9lSlIv0qdQ2BQ/h9t0bJllHlbvsQ0zUWfU7Yi0S8L5JXzPZgkefIiajLmBJJ4BsMJmqcf7oxQ==
5530 5537
 
5538
+knot.js@^1.1.5:
5539
+  version "1.1.5"
5540
+  resolved "https://registry.yarnpkg.com/knot.js/-/knot.js-1.1.5.tgz#28e72522f703f50fe98812fde224dd72728fef5d"
5541
+  integrity sha1-KOclIvcD9Q/piBL94iTdcnKP710=
5542
+
5531 5543
 lcid@^1.0.0:
5532 5544
   version "1.0.0"
5533 5545
   resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835"
@@ -7558,6 +7570,13 @@ react-immutable-pure-component@^1.1.1:
7558 7570
   optionalDependencies:
7559 7571
     "@types/react" "16.4.6"
7560 7572
 
7573
+react-infinite-scroller@^1.0.12:
7574
+  version "1.2.4"
7575
+  resolved "https://registry.yarnpkg.com/react-infinite-scroller/-/react-infinite-scroller-1.2.4.tgz#f67eaec4940a4ce6417bebdd6e3433bfc38826e9"
7576
+  integrity sha512-/oOa0QhZjXPqaD6sictN2edFMsd3kkMiE19Vcz5JDgHpzEJVqYcmq+V3mkwO88087kvKGe1URNksHEOt839Ubw==
7577
+  dependencies:
7578
+    prop-types "^15.5.8"
7579
+
7561 7580
 react-input-autosize@^2.2.1:
7562 7581
   version "2.2.1"
7563 7582
   resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8"
@@ -7596,6 +7615,15 @@ react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4:
7596 7615
   resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
7597 7616
   integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
7598 7617
 
7618
+react-masonry-infinite@^1.2.2:
7619
+  version "1.2.2"
7620
+  resolved "https://registry.yarnpkg.com/react-masonry-infinite/-/react-masonry-infinite-1.2.2.tgz#20c1386f9ccdda9747527c8f42bc2c02dd2e7951"
7621
+  integrity sha1-IME4b5zN2pdHUnyPQrwsAt0ueVE=
7622
+  dependencies:
7623
+    bricks.js "^1.7.0"
7624
+    prop-types "^15.5.10"
7625
+    react-infinite-scroller "^1.0.12"
7626
+
7599 7627
 react-motion@^0.5.2:
7600 7628
   version "0.5.2"
7601 7629
   resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.5.2.tgz#0dd3a69e411316567927917c6626551ba0607316"