about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch')
-rw-r--r--app/javascript/flavours/glitch/actions/lists.js4
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js2
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js21
-rw-r--r--app/javascript/flavours/glitch/components/error_boundary.js95
-rw-r--r--app/javascript/flavours/glitch/containers/mastodon.js13
-rw-r--r--app/javascript/flavours/glitch/features/list_timeline/index.js32
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js108
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/bundle.js5
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js70
-rw-r--r--app/javascript/flavours/glitch/reducers/contexts.js95
-rw-r--r--app/javascript/flavours/glitch/styles/components/error_boundary.scss32
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss23
-rw-r--r--app/javascript/flavours/glitch/styles/components/media.scss57
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss2
14 files changed, 455 insertions, 104 deletions
diff --git a/app/javascript/flavours/glitch/actions/lists.js b/app/javascript/flavours/glitch/actions/lists.js
index 3021fa5b5..7d94ee950 100644
--- a/app/javascript/flavours/glitch/actions/lists.js
+++ b/app/javascript/flavours/glitch/actions/lists.js
@@ -148,10 +148,10 @@ export const createListFail = error => ({
   error,
 });
 
-export const updateList = (id, title, shouldReset) => (dispatch, getState) => {
+export const updateList = (id, title, shouldReset, replies_policy) => (dispatch, getState) => {
   dispatch(updateListRequest(id));
 
-  api(getState).put(`/api/v1/lists/${id}`, { title }).then(({ data }) => {
+  api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy }).then(({ data }) => {
     dispatch(updateListSuccess(data));
 
     if (shouldReset) {
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index ddf14f78f..0184d9c80 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -102,7 +102,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
     };
 
     if (!maxId && notifications.get('items').size > 0) {
-      params.since_id = notifications.getIn(['items', 0]);
+      params.since_id = notifications.getIn(['items', 0, 'id']);
     }
 
     dispatch(expandNotificationsRequest(isLoadingMore));
diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js
index 27ca66e51..ffd259d5f 100644
--- a/app/javascript/flavours/glitch/actions/timelines.js
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -12,34 +12,13 @@ export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 
 export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
 
-export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
-
 export function updateTimeline(timeline, status) {
   return (dispatch, getState) => {
-    const parents = [];
-
-    if (status.in_reply_to_id) {
-      let parent = getState().getIn(['statuses', status.in_reply_to_id]);
-
-      while (parent && parent.get('in_reply_to_id')) {
-        parents.push(parent.get('id'));
-        parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]);
-      }
-    }
-
     dispatch({
       type: TIMELINE_UPDATE,
       timeline,
       status,
     });
-
-    if (parents.length > 0) {
-      dispatch({
-        type: TIMELINE_CONTEXT_UPDATE,
-        status,
-        references: parents,
-      });
-    }
   };
 };
 
diff --git a/app/javascript/flavours/glitch/components/error_boundary.js b/app/javascript/flavours/glitch/components/error_boundary.js
new file mode 100644
index 000000000..142a0c21a
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/error_boundary.js
@@ -0,0 +1,95 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import { preferencesLink } from 'flavours/glitch/util/backend_links';
+
+export default class ErrorBoundary extends React.PureComponent {
+
+  static propTypes = {
+    children: PropTypes.node,
+  };
+
+  state = {
+    hasError: false,
+    stackTrace: undefined,
+    componentStack: undefined,
+  }
+
+  componentDidCatch(error, info) {
+    this.setState({
+      hasError: true,
+      stackTrace: error.stack,
+      componentStack: info && info.componentStack,
+    });
+  }
+
+  handleReload(e) {
+    e.preventDefault();
+    window.location.reload();
+  }
+
+  render() {
+    const { hasError, stackTrace, componentStack } = this.state;
+
+    if (!hasError) return this.props.children;
+
+    let debugInfo = '';
+    if (stackTrace) {
+      debugInfo += 'Stack trace\n-----------\n\n```\n' + stackTrace.toString() + '\n```';
+    }
+    if (componentStack) {
+      if (debugInfo) {
+        debugInfo += '\n\n\n';
+      }
+      debugInfo += 'React component stack\n---------------------\n\n```\n' + componentStack.toString() + '\n```';
+    }
+
+    return (
+      <div tabIndex='-1'>
+        <div className='error-boundary'>
+          <h1><FormattedMessage id='web_app_crash.title' defaultMessage="We're sorry, but something went wrong with the Mastodon app." /></h1>
+          <p>
+            <FormattedMessage id='web_app_crash.content' defaultMessage='You could try any of the following:' />
+            <ul>
+              <li>
+                <FormattedMessage
+                  id='web_app_crash.report_issue'
+                  defaultMessage='Report a bug in the {issuetracker}'
+                  values={{ issuetracker: <a href='https://github.com/glitch-soc/mastodon/issues' rel='noopener' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
+                />
+                { debugInfo !== '' && (
+                  <details>
+                    <summary><FormattedMessage id='web_app_crash.debug_info' defaultMessage='Debug information' /></summary>
+                    <textarea
+                      className='web_app_crash-stacktrace'
+                      value={debugInfo}
+                      rows='10'
+                      readOnly
+                    />
+                  </details>
+                )}
+              </li>
+              <li>
+                <FormattedMessage
+                  id='web_app_crash.reload_page'
+                  defaultMessage='{reload} the current page'
+                  values={{ reload: <a href='#' onClick={this.handleReload}><FormattedMessage id='web_app_crash.reload' defaultMessage='Reload' /></a> }}
+                />
+              </li>
+              { preferencesLink !== undefined && (
+                <li>
+                  <FormattedMessage
+                    id='web_app_crash.change_your_settings'
+                    defaultMessage='Change your {settings}'
+                    values={{ settings: <a href={preferencesLink}><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></a> }}
+                  />
+                </li>
+              )}
+            </ul>
+          </p>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js
index 4bd9cb75e..4fb6be476 100644
--- a/app/javascript/flavours/glitch/containers/mastodon.js
+++ b/app/javascript/flavours/glitch/containers/mastodon.js
@@ -12,6 +12,7 @@ import { connectUserStream } from 'flavours/glitch/actions/streaming';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import { getLocale } from 'locales';
 import initialState from 'flavours/glitch/util/initial_state';
+import ErrorBoundary from 'flavours/glitch/components/error_boundary';
 
 const { localeData, messages } = getLocale();
 addLocaleData(localeData);
@@ -61,11 +62,13 @@ export default class Mastodon extends React.PureComponent {
     return (
       <IntlProvider locale={locale} messages={messages}>
         <Provider store={store}>
-          <BrowserRouter basename='/web'>
-            <ScrollContext>
-              <Route path='/' component={UI} />
-            </ScrollContext>
-          </BrowserRouter>
+          <ErrorBoundary>
+            <BrowserRouter basename='/web'>
+              <ScrollContext>
+                <Route path='/' component={UI} />
+              </ScrollContext>
+            </BrowserRouter>
+          </ErrorBoundary>
         </Provider>
       </IntlProvider>
     );
diff --git a/app/javascript/flavours/glitch/features/list_timeline/index.js b/app/javascript/flavours/glitch/features/list_timeline/index.js
index 2e77ba235..ef829b937 100644
--- a/app/javascript/flavours/glitch/features/list_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/list_timeline/index.js
@@ -9,7 +9,7 @@ import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/col
 import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import { connectListStream } from 'flavours/glitch/actions/streaming';
 import { expandListTimeline } from 'flavours/glitch/actions/timelines';
-import { fetchList, deleteList } from 'flavours/glitch/actions/lists';
+import { fetchList, deleteList, updateList } from 'flavours/glitch/actions/lists';
 import { openModal } from 'flavours/glitch/actions/modal';
 import MissingIndicator from 'flavours/glitch/components/missing_indicator';
 import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
@@ -17,6 +17,9 @@ import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
 const messages = defineMessages({
   deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' },
   deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' },
+  all_replies:   { id: 'lists.replies_policy.all_replies', defaultMessage: 'any followed user' },
+  no_replies:    { id: 'lists.replies_policy.no_replies', defaultMessage: 'no one' },
+  list_replies:  { id: 'lists.replies_policy.list_replies', defaultMessage: 'members of the list' },
 });
 
 const mapStateToProps = (state, props) => ({
@@ -111,11 +114,18 @@ export default class ListTimeline extends React.PureComponent {
     }));
   }
 
+  handleRepliesPolicyChange = ({ target }) => {
+    const { dispatch, list } = this.props;
+    const { id } = this.props.params;
+    this.props.dispatch(updateList(id, undefined, false, target.value));
+  }
+
   render () {
-    const { hasUnread, columnId, multiColumn, list } = this.props;
+    const { hasUnread, columnId, multiColumn, list, intl } = this.props;
     const { id } = this.props.params;
     const pinned = !!columnId;
     const title  = list ? list.get('title') : id;
+    const replies_policy = list ? list.get('replies_policy') : undefined;
 
     if (typeof list === 'undefined') {
       return (
@@ -157,6 +167,24 @@ export default class ListTimeline extends React.PureComponent {
             </button>
           </div>
 
+          { replies_policy !== undefined && (
+            <div>
+              <div className='column-settings__row'>
+                <fieldset>
+                  <legend><FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /></legend>
+                  { ['no_replies', 'list_replies', 'all_replies'].map(policy => (
+                    <div className='setting-radio'>
+                      <input className='setting-radio__input' id={['setting', 'radio', id, policy].join('-')} type='radio' value={policy} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
+                      <label className='setting-radio__label' htmlFor={['setting', 'radio', id, policy].join('-')}>
+                        <FormattedMessage {...messages[policy]} />
+                      </label>
+                    </div>
+                  ))}
+                </fieldset>
+              </div>
+            </div>
+          )}
+
           <hr />
         </ColumnHeader>
 
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index cfa1450f6..d2d5a05c8 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -1,3 +1,4 @@
+import Immutable from 'immutable';
 import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
@@ -57,13 +58,49 @@ const messages = defineMessages({
 const makeMapStateToProps = () => {
   const getStatus = makeGetStatus();
 
-  const mapStateToProps = (state, props) => ({
-    status: getStatus(state, { id: props.params.statusId }),
-    settings: state.get('local_settings'),
-    ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
-    descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
-    askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
-  });
+  const mapStateToProps = (state, props) => {
+    const status = getStatus(state, { id: props.params.statusId });
+    let ancestorsIds = Immutable.List();
+    let descendantsIds = Immutable.List();
+
+    if (status) {
+      ancestorsIds = ancestorsIds.withMutations(mutable => {
+        let id = status.get('in_reply_to_id');
+
+        while (id) {
+          mutable.unshift(id);
+          id = state.getIn(['contexts', 'inReplyTos', id]);
+        }
+      });
+
+      descendantsIds = descendantsIds.withMutations(mutable => {
+        const ids = [status.get('id')];
+
+        while (ids.length > 0) {
+          let id        = ids.shift();
+          const replies = state.getIn(['contexts', 'replies', id]);
+
+          if (status.get('id') !== id) {
+            mutable.push(id);
+          }
+
+          if (replies) {
+            replies.reverse().forEach(reply => {
+              ids.unshift(reply);
+            });
+          }
+        }
+      });
+    }
+
+    return {
+      status,
+      ancestorsIds,
+      descendantsIds,
+      settings: state.get('local_settings'),
+      askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
+    };
+  };
 
   return mapStateToProps;
 };
@@ -91,26 +128,36 @@ export default class Status extends ImmutablePureComponent {
     fullscreen: false,
     isExpanded: undefined,
     threadExpanded: undefined,
+    statusId: undefined,
   };
 
-  componentWillMount () {
-    this.props.dispatch(fetchStatus(this.props.params.statusId));
-  }
-
   componentDidMount () {
     attachFullscreenListener(this.onFullScreenChange);
-  }
+    this.props.dispatch(fetchStatus(this.props.params.statusId));
 
-  componentWillReceiveProps (nextProps) {
-    if (this.state.isExpanded === undefined) {
-      const isExpanded = autoUnfoldCW(nextProps.settings, nextProps.status);
-      if (isExpanded !== undefined) this.setState({ isExpanded: isExpanded });
+    const { status, ancestorsIds } = this.props;
+
+    if (status && ancestorsIds && ancestorsIds.size > 0) {
+      const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
+
+      window.requestAnimationFrame(() => {
+        element.scrollIntoView(true);
+      });
     }
-    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
-      this._scrolledIntoView = false;
-      this.props.dispatch(fetchStatus(nextProps.params.statusId));
-      this.setState({ isExpanded: autoUnfoldCW(nextProps.settings, nextProps.status), threadExpanded: undefined });
+  }
+
+  static getDerivedStateFromProps(props, state) {
+    if (state.statusId === props.params.statusId || !props.params.statusId) {
+      return null;
     }
+
+    props.dispatch(fetchStatus(props.params.statusId));
+
+    return {
+      threadExpanded: undefined,
+      isExpanded: autoUnfoldCW(props.settings, props.status),
+      statusId: props.params.statusId,
+    };
   }
 
   handleExpandedToggle = () => {
@@ -338,20 +385,17 @@ export default class Status extends ImmutablePureComponent {
     this.node = c;
   }
 
-  componentDidUpdate () {
-    if (this._scrolledIntoView) {
-      return;
-    }
-
-    const { status, ancestorsIds } = this.props;
+  componentDidUpdate (prevProps) {
+    if (this.props.params.statusId && (this.props.params.statusId !== prevProps.params.statusId || prevProps.ancestorsIds.size < this.props.ancestorsIds.size)) {
+      const { status, ancestorsIds } = this.props;
 
-    if (status && ancestorsIds && ancestorsIds.size > 0) {
-      const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
+      if (status && ancestorsIds && ancestorsIds.size > 0) {
+        const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
 
-      window.requestAnimationFrame(() => {
-        element.scrollIntoView(true);
-      });
-      this._scrolledIntoView = true;
+        window.requestAnimationFrame(() => {
+          element.scrollIntoView(true);
+        });
+      }
     }
   }
 
diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle.js b/app/javascript/flavours/glitch/features/ui/components/bundle.js
index fc88e0c70..8f0d7b8b1 100644
--- a/app/javascript/flavours/glitch/features/ui/components/bundle.js
+++ b/app/javascript/flavours/glitch/features/ui/components/bundle.js
@@ -52,6 +52,11 @@ class Bundle extends React.Component {
   load = (props) => {
     const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props;
 
+    if (fetchComponent === undefined) {
+      this.setState({ mod: null });
+      return Promise.resolve();
+    }
+
     onFetch();
 
     if (Bundle.cache[fetchComponent.name]) {
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
index 4c2e5e62b..30592707c 100644
--- a/app/javascript/flavours/glitch/features/video/index.js
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -108,6 +108,7 @@ export default class Video extends React.PureComponent {
   state = {
     currentTime: 0,
     duration: 0,
+    volume: 0.5,
     paused: true,
     dragging: false,
     containerWidth: false,
@@ -117,6 +118,15 @@ export default class Video extends React.PureComponent {
     revealed: this.props.revealed === undefined ? (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all') : this.props.revealed,
   };
 
+  // hard coded in components.scss
+  // any way to get ::before values programatically?
+  volWidth = 50;
+  volOffset = 70;
+  volHandleOffset = v => {
+    const offset = v * this.volWidth + this.volOffset;
+    return (offset > 110) ? 110 : offset;
+  }
+
   setPlayerRef = c => {
     this.player = c;
 
@@ -135,6 +145,10 @@ export default class Video extends React.PureComponent {
     this.seek = c;
   }
 
+  setVolumeRef = c => {
+    this.volume = c;
+  }
+
   handleMouseDownRoot = e => {
     e.preventDefault();
     e.stopPropagation();
@@ -155,6 +169,43 @@ export default class Video extends React.PureComponent {
     });
   }
 
+  handleVolumeMouseDown = e => {
+
+    document.addEventListener('mousemove', this.handleMouseVolSlide, true);
+    document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
+    document.addEventListener('touchmove', this.handleMouseVolSlide, true);
+    document.addEventListener('touchend', this.handleVolumeMouseUp, true);
+
+    this.handleMouseVolSlide(e);
+
+    e.preventDefault();
+    e.stopPropagation();
+  }
+
+  handleVolumeMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
+    document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
+    document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
+    document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
+  }
+
+  handleMouseVolSlide = throttle(e => {
+
+    const rect = this.volume.getBoundingClientRect();
+    const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
+
+    if(!isNaN(x)) {
+      var slideamt = x;
+      if(x > 1) {
+        slideamt = 1;
+      } else if(x < 0) {
+        slideamt = 0;
+      }
+      this.video.volume = slideamt;
+      this.setState({ volume: slideamt });
+    }
+  }, 60);
+
   handleMouseDown = e => {
     document.addEventListener('mousemove', this.handleMouseMove, true);
     document.addEventListener('mouseup', this.handleMouseUp, true);
@@ -290,10 +341,13 @@ export default class Video extends React.PureComponent {
 
   render () {
     const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive } = this.props;
-    const { containerWidth, currentTime, duration, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+    const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
     const progress = (currentTime / duration) * 100;
     const playerStyle = {};
 
+    const volumeWidth = (muted) ? 0 : volume * this.volWidth;
+    const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume);
+
     const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth });
 
     let { width, height } = this.props;
@@ -346,6 +400,7 @@ export default class Video extends React.PureComponent {
           title={alt}
           width={width}
           height={height}
+          volume={volume}
           onClick={this.togglePlay}
           onPlay={this.handlePlay}
           onPause={this.handlePause}
@@ -374,9 +429,15 @@ export default class Video extends React.PureComponent {
           <div className='video-player__buttons-bar'>
             <div className='video-player__buttons left'>
               <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><i className={classNames('fa fa-fw', { 'fa-play': paused, 'fa-pause': !paused })} /></button>
-              <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><i className={classNames('fa fa-fw', { 'fa-volume-off': muted, 'fa-volume-up': !muted })} /></button>
-
-              {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye' /></button>}
+              <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onMouseEnter={this.volumeSlider} onMouseLeave={this.volumeSlider} onClick={this.toggleMute}><i className={classNames('fa fa-fw', { 'fa-volume-off': muted, 'fa-volume-up': !muted })} /></button>
+              <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
+                <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
+                <span
+                  className={classNames('video-player__volume__handle')}
+                  tabIndex='0'
+                  style={{ left: `${volumeHandleLoc}px` }}
+                />
+              </div>
 
               {(detailed || fullscreen) &&
                 <span>
@@ -388,6 +449,7 @@ export default class Video extends React.PureComponent {
             </div>
 
             <div className='video-player__buttons right'>
+              {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye' /></button>}
               {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>}
               {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-compress' /></button>}
               <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button>
diff --git a/app/javascript/flavours/glitch/reducers/contexts.js b/app/javascript/flavours/glitch/reducers/contexts.js
index effd70756..73b25fe3f 100644
--- a/app/javascript/flavours/glitch/reducers/contexts.js
+++ b/app/javascript/flavours/glitch/reducers/contexts.js
@@ -3,38 +3,63 @@ import {
   ACCOUNT_MUTE_SUCCESS,
 } from 'flavours/glitch/actions/accounts';
 import { CONTEXT_FETCH_SUCCESS } from 'flavours/glitch/actions/statuses';
-import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from 'flavours/glitch/actions/timelines';
+import { TIMELINE_DELETE, TIMELINE_UPDATE } from 'flavours/glitch/actions/timelines';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import compareId from 'flavours/glitch/util/compare_id';
 
 const initialState = ImmutableMap({
-  ancestors: ImmutableMap(),
-  descendants: ImmutableMap(),
+  inReplyTos: ImmutableMap(),
+  replies: ImmutableMap(),
 });
 
-const normalizeContext = (state, id, ancestors, descendants) => {
-  const ancestorsIds   = ImmutableList(ancestors.map(ancestor => ancestor.id));
-  const descendantsIds = ImmutableList(descendants.map(descendant => descendant.id));
+const normalizeContext = (immutableState, id, ancestors, descendants) => immutableState.withMutations(state => {
+  state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
+    state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
+      function addReply({ id, in_reply_to_id }) {
+        if (in_reply_to_id && !inReplyTos.has(id)) {
 
-  return state.withMutations(map => {
-    map.setIn(['ancestors', id], ancestorsIds);
-    map.setIn(['descendants', id], descendantsIds);
-  });
-};
+          replies.update(in_reply_to_id, ImmutableList(), siblings => {
+            const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0);
+            return siblings.insert(index + 1, id);
+          });
+
+          inReplyTos.set(id, in_reply_to_id);
+        }
+      }
+
+      // We know in_reply_to_id of statuses but `id` itself.
+      // So we assume that the status of the id replies to last ancestors.
+
+      ancestors.forEach(addReply);
+
+      if (ancestors[0]) {
+        addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id });
+      }
+
+      descendants.forEach(addReply);
+    }));
+  }));
+});
 
 const deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => {
-  state.update('ancestors', immutableAncestors => immutableAncestors.withMutations(ancestors => {
-    state.update('descendants', immutableDescendants => immutableDescendants.withMutations(descendants => {
+  state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
+    state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
       ids.forEach(id => {
-        descendants.get(id, ImmutableList()).forEach(descendantId => {
-          ancestors.update(descendantId, ImmutableList(), list => list.filterNot(itemId => itemId === id));
-        });
+        const inReplyToIdOfId = inReplyTos.get(id);
+        const repliesOfId = replies.get(id);
+        const siblings = replies.get(inReplyToIdOfId);
+
+        if (siblings) {
+          replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id));
+        }
 
-        ancestors.get(id, ImmutableList()).forEach(ancestorId => {
-          descendants.update(ancestorId, ImmutableList(), list => list.filterNot(itemId => itemId === id));
-        });
 
-        descendants.delete(id);
-        ancestors.delete(id);
+        if (repliesOfId) {
+          repliesOfId.forEach(reply => inReplyTos.delete(reply));
+        }
+
+        inReplyTos.delete(id);
+        replies.delete(id);
       });
     }));
   }));
@@ -47,23 +72,23 @@ const filterContexts = (state, relationship, statuses) => {
   return deleteFromContexts(state, ownedStatusIds);
 };
 
-const updateContext = (state, status, references) => {
-  return state.update('descendants', map => {
-    references.forEach(parentId => {
-      map = map.update(parentId, ImmutableList(), list => {
-        if (list.includes(status.id)) {
-          return list;
-        }
+const updateContext = (state, status) => {
+  if (status.in_reply_to_id) {
+    return state.withMutations(mutable => {
+      const replies = mutable.getIn(['replies', status.in_reply_to_id], ImmutableList());
 
-        return list.push(status.id);
-      });
+      mutable.setIn(['inReplyTos', status.id], status.in_reply_to_id);
+
+      if (!replies.includes(status.id)) {
+        mutable.setIn(['replies', status.in_reply_to_id], replies.push(status.id));
+      }
     });
+  }
 
-    return map;
-  });
+  return state;
 };
 
-export default function contexts(state = initialState, action) {
+export default function replies(state = initialState, action) {
   switch(action.type) {
   case ACCOUNT_BLOCK_SUCCESS:
   case ACCOUNT_MUTE_SUCCESS:
@@ -72,8 +97,8 @@ export default function contexts(state = initialState, action) {
     return normalizeContext(state, action.id, action.ancestors, action.descendants);
   case TIMELINE_DELETE:
     return deleteFromContexts(state, [action.id]);
-  case TIMELINE_CONTEXT_UPDATE:
-    return updateContext(state, action.status, action.references);
+  case TIMELINE_UPDATE:
+    return updateContext(state, action.status);
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/styles/components/error_boundary.scss b/app/javascript/flavours/glitch/styles/components/error_boundary.scss
new file mode 100644
index 000000000..f9bf425f8
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/error_boundary.scss
@@ -0,0 +1,32 @@
+.error-boundary {
+  h1 {
+    font-size: 26px;
+    line-height: 36px;
+    font-weight: 400;
+    margin-bottom: 8px;
+  }
+
+  p {
+    color: $primary-text-color;
+    font-size: 15px;
+    line-height: 20px;
+
+    a {
+      color: $primary-text-color;
+      text-decoration: underline;
+    }
+
+    ul {
+      list-style: disc;
+      margin-left: 0;
+      padding-left: 1em;
+    }
+
+    textarea.web_app_crash-stacktrace {
+      width: 100%;
+      resize: none;
+      white-space: pre;
+      font-family: $font-monospace, monospace;
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index b16b13d87..873dfa98d 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -1062,6 +1062,7 @@
 }
 
 .setting-toggle__label,
+.setting-radio__label,
 .setting-meta__label {
   color: $darker-text-color;
   display: inline-block;
@@ -1070,6 +1071,27 @@
   vertical-align: middle;
 }
 
+.setting-radio {
+  display: block;
+  line-height: 18px;
+}
+
+.setting-radio__label {
+  margin-bottom: 0;
+}
+
+.column-settings__row legend {
+  color: $darker-text-color;
+  cursor: default;
+  display: block;
+  font-weight: 500;
+  margin-top: 10px;
+}
+
+.setting-radio__input {
+  vertical-align: middle;
+}
+
 .setting-meta__label {
   float: right;
 }
@@ -1241,3 +1263,4 @@ noscript {
 @import 'lists';
 @import 'emoji_picker';
 @import 'local_settings';
+@import 'error_boundary';
diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss
index 40a144de4..e8011bde9 100644
--- a/app/javascript/flavours/glitch/styles/components/media.scss
+++ b/app/javascript/flavours/glitch/styles/components/media.scss
@@ -277,6 +277,19 @@
   z-index: 100;
 }
 
+.detailed,
+.fullscreen {
+  .video-player__volume__current,
+  .video-player__volume::before {
+    bottom: 27px;
+  }
+
+  .video-player__volume__handle {
+    bottom: 23px;
+  }
+
+}
+
 .video-player {
   overflow: hidden;
   position: relative;
@@ -432,7 +445,7 @@
 
   &__time-current {
     color: $white;
-    margin-left: 10px;
+    margin-left: 60px;
   }
 
   &__time-sep {
@@ -445,6 +458,48 @@
     color: $white;
   }
 
+  &__volume {
+    cursor: pointer;
+    height: 24px;
+    display: inline;
+
+    &::before {
+      content: "";
+      width: 50px;
+      background: rgba($white, 0.35);
+      border-radius: 4px;
+      display: block;
+      position: absolute;
+      height: 4px;
+      left: 70px;
+      bottom: 20px;
+    }
+
+    &__current {
+      display: block;
+      position: absolute;
+      height: 4px;
+      border-radius: 4px;
+      left: 70px;
+      bottom: 20px;
+      background: lighten($ui-highlight-color, 8%);
+    }
+
+    &__handle {
+      position: absolute;
+      z-index: 3;
+      border-radius: 50%;
+      width: 12px;
+      height: 12px;
+      bottom: 16px;
+      left: 70px;
+      transition: opacity .1s ease;
+      background: lighten($ui-highlight-color, 8%);
+      box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);
+      pointer-events: none;
+    }
+  }
+
   &__seek {
     cursor: pointer;
     height: 24px;
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index 46ef85774..4f96204f2 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -266,7 +266,7 @@ code {
         font-family: inherit;
         font-size: 14px;
         color: $primary-text-color;
-        display: block;
+        display: inline-block;
         width: auto;
         position: relative;
         padding-top: 5px;