Browse Source

Fix timeline jumps (#10001)

* Avoid two-step rendering of statuses as much as possible

Cache width shared by Video player, MediaGallery and Cards at the
ScrollableList level, pass it down through StatusList and Notifications.

* Adjust scroll when new preview cards appear

* Adjust scroll when statuses above the current scroll position are deleted
ThibG 6 days ago
parent
commit
aee93bfc9c

+ 8
- 2
app/javascript/mastodon/components/media_gallery.js View File

@@ -194,6 +194,8 @@ class MediaGallery extends React.PureComponent {
194 194
     height: PropTypes.number.isRequired,
195 195
     onOpenMedia: PropTypes.func.isRequired,
196 196
     intl: PropTypes.object.isRequired,
197
+    defaultWidth: PropTypes.number,
198
+    cacheWidth: PropTypes.func,
197 199
   };
198 200
 
199 201
   static defaultProps = {
@@ -202,6 +204,7 @@ class MediaGallery extends React.PureComponent {
202 204
 
203 205
   state = {
204 206
     visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all',
207
+    width: this.props.defaultWidth,
205 208
   };
206 209
 
207 210
   componentWillReceiveProps (nextProps) {
@@ -221,6 +224,7 @@ class MediaGallery extends React.PureComponent {
221 224
   handleRef = (node) => {
222 225
     if (node /*&& this.isStandaloneEligible()*/) {
223 226
       // offsetWidth triggers a layout, so only calculate when we need to
227
+      if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
224 228
       this.setState({
225 229
         width: node.offsetWidth,
226 230
       });
@@ -233,8 +237,10 @@ class MediaGallery extends React.PureComponent {
233 237
   }
234 238
 
235 239
   render () {
236
-    const { media, intl, sensitive, height } = this.props;
237
-    const { width, visible } = this.state;
240
+    const { media, intl, sensitive, height, defaultWidth } = this.props;
241
+    const { visible } = this.state;
242
+
243
+    const width = this.state.width || defaultWidth;
238 244
 
239 245
     let children;
240 246
 

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

@@ -40,6 +40,7 @@ export default class ScrollableList extends PureComponent {
40 40
 
41 41
   state = {
42 42
     fullscreen: null,
43
+    cachedMediaWidth: 250, // Default media/card width using default Mastodon theme
43 44
   };
44 45
 
45 46
   intersectionObserverWrapper = new IntersectionObserverWrapper();
@@ -130,6 +131,20 @@ export default class ScrollableList extends PureComponent {
130 131
     this.handleScroll();
131 132
   }
132 133
 
134
+  getScrollPosition = () => {
135
+    if (this.node && (this.node.scrollTop > 0 || this.mouseMovedRecently)) {
136
+      return { height: this.node.scrollHeight, top: this.node.scrollTop };
137
+    } else {
138
+      return null;
139
+    }
140
+  }
141
+
142
+  updateScrollBottom = (snapshot) => {
143
+    const newScrollTop = this.node.scrollHeight - snapshot;
144
+
145
+    this.setScrollTop(newScrollTop);
146
+  }
147
+
133 148
   getSnapshotBeforeUpdate (prevProps) {
134 149
     const someItemInserted = React.Children.count(prevProps.children) > 0 &&
135 150
       React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
@@ -150,6 +165,12 @@ export default class ScrollableList extends PureComponent {
150 165
     }
151 166
   }
152 167
 
168
+  cacheMediaWidth = (width) => {
169
+    if (width && this.state.cachedMediaWidth !== width) {
170
+      this.setState({ cachedMediaWidth: width });
171
+    }
172
+  }
173
+
153 174
   componentWillUnmount () {
154 175
     this.clearMouseIdleTimer();
155 176
     this.detachScrollListener();
@@ -239,7 +260,12 @@ export default class ScrollableList extends PureComponent {
239 260
                 intersectionObserverWrapper={this.intersectionObserverWrapper}
240 261
                 saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
241 262
               >
242
-                {child}
263
+                {React.cloneElement(child, {
264
+                  getScrollPosition: this.getScrollPosition,
265
+                  updateScrollBottom: this.updateScrollBottom,
266
+                  cachedMediaWidth: this.state.cachedMediaWidth,
267
+                  cacheMediaWidth: this.cacheMediaWidth,
268
+                })}
243 269
               </IntersectionObserverArticleContainer>
244 270
             ))}
245 271
 

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

@@ -69,6 +69,10 @@ class Status extends ImmutablePureComponent {
69 69
     onMoveUp: PropTypes.func,
70 70
     onMoveDown: PropTypes.func,
71 71
     showThread: PropTypes.bool,
72
+    getScrollPosition: PropTypes.func,
73
+    updateScrollBottom: PropTypes.func,
74
+    cacheMediaWidth: PropTypes.func,
75
+    cachedMediaWidth: PropTypes.number,
72 76
   };
73 77
 
74 78
   // Avoid checking props that are functions (and whose equality will always
@@ -80,6 +84,43 @@ class Status extends ImmutablePureComponent {
80 84
     'hidden',
81 85
   ];
82 86
 
87
+  // Track height changes we know about to compensate scrolling
88
+  componentDidMount () {
89
+    this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status.get('card');
90
+  }
91
+
92
+  getSnapshotBeforeUpdate () {
93
+    if (this.props.getScrollPosition) {
94
+      return this.props.getScrollPosition();
95
+    } else {
96
+      return null;
97
+    }
98
+  }
99
+
100
+  // Compensate height changes
101
+  componentDidUpdate (prevProps, prevState, snapshot) {
102
+    const doShowCard  = !this.props.muted && !this.props.hidden && this.props.status.get('card');
103
+    if (doShowCard && !this.didShowCard) {
104
+      this.didShowCard = true;
105
+      if (snapshot !== null && this.props.updateScrollBottom) {
106
+        if (this.node && this.node.offsetTop < snapshot.top) {
107
+          this.props.updateScrollBottom(snapshot.height - snapshot.top);
108
+        }
109
+      }
110
+    }
111
+  }
112
+
113
+  componentWillUnmount() {
114
+    if (this.node && this.props.getScrollPosition) {
115
+      const position = this.props.getScrollPosition();
116
+      if (position !== null && this.node.offsetTop < position.top) {
117
+        requestAnimationFrame(() => {
118
+          this.props.updateScrollBottom(position.height - position.top);
119
+        });
120
+      }
121
+    }
122
+  }
123
+
83 124
   handleClick = () => {
84 125
     if (this.props.onClick) {
85 126
       this.props.onClick();
@@ -166,6 +207,10 @@ class Status extends ImmutablePureComponent {
166 207
     }
167 208
   }
168 209
 
210
+  handleRef = c => {
211
+    this.node = c;
212
+  }
213
+
169 214
   render () {
170 215
     let media = null;
171 216
     let statusAvatar, prepend, rebloggedByText;
@@ -180,7 +225,7 @@ class Status extends ImmutablePureComponent {
180 225
 
181 226
     if (hidden) {
182 227
       return (
183
-        <div>
228
+        <div ref={this.handleRef}>
184 229
           {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
185 230
           {status.get('content')}
186 231
         </div>
@@ -195,7 +240,7 @@ class Status extends ImmutablePureComponent {
195 240
 
196 241
       return (
197 242
         <HotKeys handlers={minHandlers}>
198
-          <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0'>
243
+          <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
199 244
             <FormattedMessage id='status.filtered' defaultMessage='Filtered' />
200 245
           </div>
201 246
         </HotKeys>
@@ -243,11 +288,12 @@ class Status extends ImmutablePureComponent {
243 288
                 preview={video.get('preview_url')}
244 289
                 src={video.get('url')}
245 290
                 alt={video.get('description')}
246
-                width={239}
291
+                width={this.props.cachedMediaWidth}
247 292
                 height={110}
248 293
                 inline
249 294
                 sensitive={status.get('sensitive')}
250 295
                 onOpenVideo={this.handleOpenVideo}
296
+                cacheWidth={this.props.cacheMediaWidth}
251 297
               />
252 298
             )}
253 299
           </Bundle>
@@ -255,7 +301,16 @@ class Status extends ImmutablePureComponent {
255 301
       } else {
256 302
         media = (
257 303
           <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
258
-            {Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />}
304
+            {Component => (
305
+              <Component
306
+                media={status.get('media_attachments')}
307
+                sensitive={status.get('sensitive')}
308
+                height={110}
309
+                onOpenMedia={this.props.onOpenMedia}
310
+                cacheWidth={this.props.cacheMediaWidth}
311
+                defaultWidth={this.props.cachedMediaWidth}
312
+              />
313
+            )}
259 314
           </Bundle>
260 315
         );
261 316
       }
@@ -265,6 +320,8 @@ class Status extends ImmutablePureComponent {
265 320
           onOpenMedia={this.props.onOpenMedia}
266 321
           card={status.get('card')}
267 322
           compact
323
+          cacheWidth={this.props.cacheMediaWidth}
324
+          defaultWidth={this.props.cachedMediaWidth}
268 325
         />
269 326
       );
270 327
     }
@@ -291,7 +348,7 @@ class Status extends ImmutablePureComponent {
291 348
 
292 349
     return (
293 350
       <HotKeys handlers={handlers}>
294
-        <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))}>
351
+        <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))} ref={this.handleRef}>
295 352
           {prepend}
296 353
 
297 354
           <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}>

+ 30
- 2
app/javascript/mastodon/features/notifications/components/notification.js View File

@@ -35,6 +35,10 @@ class Notification extends ImmutablePureComponent {
35 35
     onToggleHidden: PropTypes.func.isRequired,
36 36
     status: PropTypes.option,
37 37
     intl: PropTypes.object.isRequired,
38
+    getScrollPosition: PropTypes.func,
39
+    updateScrollBottom: PropTypes.func,
40
+    cacheMediaWidth: PropTypes.func,
41
+    cachedMediaWidth: PropTypes.number,
38 42
   };
39 43
 
40 44
   handleMoveUp = () => {
@@ -129,6 +133,10 @@ class Notification extends ImmutablePureComponent {
129 133
         onMoveDown={this.handleMoveDown}
130 134
         onMoveUp={this.handleMoveUp}
131 135
         contextType='notifications'
136
+        getScrollPosition={this.props.getScrollPosition}
137
+        updateScrollBottom={this.props.updateScrollBottom}
138
+        cachedMediaWidth={this.props.cachedMediaWidth}
139
+        cacheMediaWidth={this.props.cacheMediaWidth}
132 140
       />
133 141
     );
134 142
   }
@@ -149,7 +157,17 @@ class Notification extends ImmutablePureComponent {
149 157
             </span>
150 158
           </div>
151 159
 
152
-          <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
160
+          <StatusContainer
161
+            id={notification.get('status')}
162
+            account={notification.get('account')}
163
+            muted
164
+            withDismiss
165
+            hidden={!!this.props.hidden}
166
+            getScrollPosition={this.props.getScrollPosition}
167
+            updateScrollBottom={this.props.updateScrollBottom}
168
+            cachedMediaWidth={this.props.cachedMediaWidth}
169
+            cacheMediaWidth={this.props.cacheMediaWidth}
170
+          />
153 171
         </div>
154 172
       </HotKeys>
155 173
     );
@@ -171,7 +189,17 @@ class Notification extends ImmutablePureComponent {
171 189
             </span>
172 190
           </div>
173 191
 
174
-          <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
192
+          <StatusContainer
193
+            id={notification.get('status')}
194
+            account={notification.get('account')}
195
+            muted
196
+            withDismiss
197
+            hidden={this.props.hidden}
198
+            getScrollPosition={this.props.getScrollPosition}
199
+            updateScrollBottom={this.props.updateScrollBottom}
200
+            cachedMediaWidth={this.props.cachedMediaWidth}
201
+            cacheMediaWidth={this.props.cacheMediaWidth}
202
+          />
175 203
         </div>
176 204
       </HotKeys>
177 205
     );

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

@@ -61,6 +61,8 @@ export default class Card extends React.PureComponent {
61 61
     maxDescription: PropTypes.number,
62 62
     onOpenMedia: PropTypes.func.isRequired,
63 63
     compact: PropTypes.bool,
64
+    defaultWidth: PropTypes.number,
65
+    cacheWidth: PropTypes.func,
64 66
   };
65 67
 
66 68
   static defaultProps = {
@@ -69,7 +71,7 @@ export default class Card extends React.PureComponent {
69 71
   };
70 72
 
71 73
   state = {
72
-    width: 280,
74
+    width: this.props.defaultWidth || 280,
73 75
     embedded: false,
74 76
   };
75 77
 
@@ -112,6 +114,7 @@ export default class Card extends React.PureComponent {
112 114
 
113 115
   setRef = c => {
114 116
     if (c) {
117
+      if (this.props.cacheWidth) this.props.cacheWidth(c.offsetWidth);
115 118
       this.setState({ width: c.offsetWidth });
116 119
     }
117 120
   }

+ 3
- 1
app/javascript/mastodon/features/video/index.js View File

@@ -100,6 +100,7 @@ class Video extends React.PureComponent {
100 100
     onCloseVideo: PropTypes.func,
101 101
     detailed: PropTypes.bool,
102 102
     inline: PropTypes.bool,
103
+    cacheWidth: PropTypes.func,
103 104
     intl: PropTypes.object.isRequired,
104 105
   };
105 106
 
@@ -109,7 +110,7 @@ class Video extends React.PureComponent {
109 110
     volume: 0.5,
110 111
     paused: true,
111 112
     dragging: false,
112
-    containerWidth: false,
113
+    containerWidth: this.props.width,
113 114
     fullscreen: false,
114 115
     hovered: false,
115 116
     muted: false,
@@ -129,6 +130,7 @@ class Video extends React.PureComponent {
129 130
     this.player = c;
130 131
 
131 132
     if (c) {
133
+      if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
132 134
       this.setState({
133 135
         containerWidth: c.offsetWidth,
134 136
       });

Loading…
Cancel
Save