about summary refs log tree commit diff
path: root/app/javascript/flavours
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours')
-rw-r--r--app/javascript/flavours/glitch/actions/custom_emojis.js37
-rw-r--r--app/javascript/flavours/glitch/components/media_gallery.js129
-rw-r--r--app/javascript/flavours/glitch/components/modal_root.js84
-rw-r--r--app/javascript/flavours/glitch/components/scrollable_list.js54
-rw-r--r--app/javascript/flavours/glitch/components/status.js29
-rw-r--r--app/javascript/flavours/glitch/containers/mastodon.js4
-rw-r--r--app/javascript/flavours/glitch/containers/media_galleries_container.js68
-rw-r--r--app/javascript/flavours/glitch/containers/media_gallery_container.js34
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notification.js10
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js5
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/modal_root.js79
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js8
-rw-r--r--app/javascript/flavours/glitch/packs/public.js16
-rw-r--r--app/javascript/flavours/glitch/reducers/contexts.js39
-rw-r--r--app/javascript/flavours/glitch/reducers/custom_emojis.js17
-rw-r--r--app/javascript/flavours/glitch/reducers/statuses.js19
-rw-r--r--app/javascript/flavours/glitch/styles/components/modal.scss5
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss10
-rw-r--r--app/javascript/flavours/glitch/styles/containers.scss4
19 files changed, 401 insertions, 250 deletions
diff --git a/app/javascript/flavours/glitch/actions/custom_emojis.js b/app/javascript/flavours/glitch/actions/custom_emojis.js
new file mode 100644
index 000000000..0595a6da7
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/custom_emojis.js
@@ -0,0 +1,37 @@
+import api from 'flavours/glitch/util/api';
+
+export const CUSTOM_EMOJIS_FETCH_REQUEST = 'CUSTOM_EMOJIS_FETCH_REQUEST';
+export const CUSTOM_EMOJIS_FETCH_SUCCESS = 'CUSTOM_EMOJIS_FETCH_SUCCESS';
+export const CUSTOM_EMOJIS_FETCH_FAIL = 'CUSTOM_EMOJIS_FETCH_FAIL';
+
+export function fetchCustomEmojis() {
+  return (dispatch, getState) => {
+    dispatch(fetchCustomEmojisRequest());
+
+    api(getState).get('/api/v1/custom_emojis').then(response => {
+      dispatch(fetchCustomEmojisSuccess(response.data));
+    }).catch(error => {
+      dispatch(fetchCustomEmojisFail(error));
+    });
+  };
+};
+
+export function fetchCustomEmojisRequest() {
+  return {
+    type: CUSTOM_EMOJIS_FETCH_REQUEST,
+  };
+};
+
+export function fetchCustomEmojisSuccess(custom_emojis) {
+  return {
+    type: CUSTOM_EMOJIS_FETCH_SUCCESS,
+    custom_emojis,
+  };
+};
+
+export function fetchCustomEmojisFail(error) {
+  return {
+    type: CUSTOM_EMOJIS_FETCH_FAIL,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js
index 309308d25..925132b07 100644
--- a/app/javascript/flavours/glitch/components/media_gallery.js
+++ b/app/javascript/flavours/glitch/components/media_gallery.js
@@ -33,10 +33,6 @@ const messages = defineMessages({
 
 class Item extends React.PureComponent {
 
-  static contextTypes = {
-    router: PropTypes.object,
-  };
-
   static propTypes = {
     attachment: ImmutablePropTypes.map.isRequired,
     standalone: PropTypes.bool,
@@ -73,7 +69,7 @@ class Item extends React.PureComponent {
   handleClick = (e) => {
     const { index, onClick } = this.props;
 
-    if (this.context.router && e.button === 0) {
+    if (e.button === 0) {
       e.preventDefault();
       onClick(index);
     }
@@ -136,16 +132,21 @@ class Item extends React.PureComponent {
     let thumbnail = '';
 
     if (attachment.get('type') === 'image') {
-      const previewUrl = attachment.get('preview_url');
+      const previewUrl   = attachment.get('preview_url');
       const previewWidth = attachment.getIn(['meta', 'small', 'width']);
 
-      const originalUrl = attachment.get('url');
-      const originalWidth = attachment.getIn(['meta', 'original', 'width']);
+      const originalUrl    = attachment.get('url');
+      const originalWidth  = attachment.getIn(['meta', 'original', 'width']);
 
       const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
 
       const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
-      const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
+      const sizes  = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
+
+      const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
+      const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
+      const x      = ((focusX /  2) + .5) * 100;
+      const y      = ((focusY / -2) + .5) * 100;
 
       thumbnail = (
         <a
@@ -154,7 +155,14 @@ class Item extends React.PureComponent {
           onClick={this.handleClick}
           target='_blank'
         >
-          <img className={letterbox ? 'letterbox' : null} src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} />
+          <img
+            className={letterbox ? 'letterbox' : null}
+            src={previewUrl}
+            srcSet={srcSet}
+            sizes={sizes}
+            alt={attachment.get('description')}
+            title={attachment.get('description')}
+            style={{ objectPosition: `${x}% ${y}%` }} />
         </a>
       );
     } else if (attachment.get('type') === 'gifv') {
@@ -225,30 +233,59 @@ export default class MediaGallery extends React.PureComponent {
     this.props.onOpenMedia(this.props.media, index);
   }
 
+  handleRef = (node) => {
+    if (node && this.isStandaloneEligible()) {
+      // offsetWidth triggers a layout, so only calculate when we need to
+      this.setState({
+        width: node.offsetWidth,
+      });
+    }
+  }
+
+  isStandaloneEligible() {
+    const { media, standalone } = this.props;
+    return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
+  }
+
   render () {
-    const {
-      handleClick,
-      handleOpen,
-    } = this;
-    const {
-      fullwidth,
-      intl,
-      letterbox,
-      media,
-      sensitive,
-      standalone,
-    } = this.props;
-    const { visible } = this.state;
+    const { media, intl, sensitive, letterbox, fullwidth } = this.props;
+    const { width, visible } = this.state;
     const size = media.take(4).size;
+
+    let children;
+
+    const style = {};
+
+    if (this.isStandaloneEligible() && width) {
+      style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
+    }
+
+    if (!visible) {
+      let warning = <FormattedMessage {...(sensitive ? messages.warning : messages.hidden)} />;
+
+      children = (
+        <button className='media-spoiler' type='button' onClick={this.handleOpen}>
+          <span className='media-spoiler__warning'>{warning}</span>
+          <span className='media-spoiler__trigger'><FormattedMessage {...messages.toggle} /></span>
+        </button>
+      );
+    } else {
+      if (this.isStandaloneEligible()) {
+        children = <Item standalone attachment={media.get(0)} onClick={this.handleClick} />;
+      } else {
+        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} />);
+      }
+    }
+
     const computedClass = classNames('media-gallery', `size-${size}`, { 'full-width': fullwidth });
 
     return (
-      <div className={computedClass}>
+      <div className={computedClass} style={style} ref={this.handleRef}>
         {visible ? (
           <div className='sensitive-info'>
             <IconButton
               icon='eye'
-              onClick={handleOpen}
+              onClick={this.handleOpen}
               overlay
               title={intl.formatMessage(messages.toggle_visible)}
             />
@@ -259,46 +296,8 @@ export default class MediaGallery extends React.PureComponent {
             ) : null}
           </div>
         ) : null}
-        {function () {
-          switch (true) {
-          case !visible:
-            return (
-              <button
-                className='media-spoiler'
-                type='button'
-                onClick={handleOpen}
-              >
-                <span className='media-spoiler__warning'>
-                  <FormattedMessage {...(sensitive ? messages.warning : messages.hidden)} />
-                </span>
-                <span className='media-spoiler__trigger'>
-                  <FormattedMessage {...messages.toggle} />
-                </span>
-              </button>
-            );
-          case standalone && media.size === 1 && !!media.getIn([0, 'meta', 'small', 'aspect']):
-            return (
-              <Item
-                attachment={media.get(0)}
-                onClick={handleClick}
-                standalone
-              />
-            );
-          default:
-            return media.take(4).map(
-              (attachment, i) => (
-                <Item
-                  attachment={attachment}
-                  index={i}
-                  key={attachment.get('id')}
-                  letterbox={letterbox}
-                  onClick={handleClick}
-                  size={size}
-                />
-              )
-            );
-          }
-        }()}
+
+        {children}
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js
new file mode 100644
index 000000000..789e117c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/modal_root.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class ModalRoot extends React.PureComponent {
+
+  static propTypes = {
+    children: PropTypes.node,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  state = {
+    revealed: !!this.props.children,
+  };
+
+  activeElement = this.state.revealed ? document.activeElement : null;
+
+  handleKeyUp = (e) => {
+    if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
+         && !!this.props.children && !this.props.props.noEsc) {
+      this.props.onClose();
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (!!nextProps.children && !this.props.children) {
+      this.activeElement = document.activeElement;
+
+      this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
+    } else if (!nextProps.children) {
+      this.setState({ revealed: false });
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (!this.props.children && !!prevProps.children) {
+      this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
+      this.activeElement.focus();
+      this.activeElement = null;
+    }
+    if (this.props.children) {
+      requestAnimationFrame(() => {
+        this.setState({ revealed: true });
+      });
+    }
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  }
+
+  getSiblings = () => {
+    return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
+  }
+
+  setRef = ref => {
+    this.node = ref;
+  }
+
+  render () {
+    const { children, onClose } = this.props;
+    const { revealed } = this.state;
+    const visible = !!children;
+
+    if (!visible) {
+      return (
+        <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} />
+      );
+    }
+
+    return (
+      <div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}>
+        <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
+          <div role='presentation' className='modal-root__overlay' onClick={onClose} />
+          <div role='dialog' className='modal-root__container'>{children}</div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js
index 8b1e3c93d..df3ace4c1 100644
--- a/app/javascript/flavours/glitch/components/scrollable_list.js
+++ b/app/javascript/flavours/glitch/components/scrollable_list.js
@@ -34,7 +34,7 @@ export default class ScrollableList extends PureComponent {
   };
 
   state = {
-    lastMouseMove: null,
+    fullscreen: null,
   };
 
   intersectionObserverWrapper = new IntersectionObserverWrapper();
@@ -43,7 +43,6 @@ export default class ScrollableList extends PureComponent {
     if (this.node) {
       const { scrollTop, scrollHeight, clientHeight } = this.node;
       const offset = scrollHeight - scrollTop - clientHeight;
-      this._oldScrollPosition = scrollHeight - scrollTop;
 
       if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
         this.props.onScrollToBottom();
@@ -57,14 +56,6 @@ export default class ScrollableList extends PureComponent {
     trailing: true,
   });
 
-  handleMouseMove = throttle(() => {
-    this._lastMouseMove = new Date();
-  }, 300);
-
-  handleMouseLeave = () => {
-    this._lastMouseMove = null;
-  }
-
   componentDidMount () {
     this.attachScrollListener();
     this.attachIntersectionObserver();
@@ -74,22 +65,37 @@ export default class ScrollableList extends PureComponent {
     this.handleScroll();
   }
 
-  componentDidUpdate (prevProps) {
+  getScrollPosition = () => {
+    if (this.node && this.node.scrollTop > 0) {
+      return {height: this.node.scrollHeight, top: this.node.scrollTop};
+    } else {
+      return null;
+    }
+  }
+
+  updateScrollBottom = (snapshot) => {
+    const newScrollTop = this.node.scrollHeight - snapshot;
+
+    if (this.node.scrollTop !== newScrollTop) {
+      this.node.scrollTop = newScrollTop;
+    }
+  }
+
+  getSnapshotBeforeUpdate (prevProps, prevState) {
     const someItemInserted = React.Children.count(prevProps.children) > 0 &&
       React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
       this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
+    if (someItemInserted && this.node.scrollTop > 0) {
+      return this.node.scrollHeight - this.node.scrollTop;
+    } else {
+      return null;
+    }
+  }
 
+  componentDidUpdate (prevProps, prevState, snapshot) {
     // Reset the scroll position when a new child comes in in order not to
     // jerk the scrollbar around if you're already scrolled down the page.
-    if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) {
-      const newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
-
-      if (this.node.scrollTop !== newScrollTop) {
-        this.node.scrollTop = newScrollTop;
-      }
-    } else {
-      this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
-    }
+    if (snapshot !== null) this.updateScrollBottom(snapshot);
   }
 
   componentWillUnmount () {
@@ -141,10 +147,6 @@ export default class ScrollableList extends PureComponent {
     this.props.onScrollToBottom();
   }
 
-  _recentlyMoved () {
-    return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
-  }
-
   render () {
     const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
     const { fullscreen } = this.state;
@@ -155,7 +157,7 @@ export default class ScrollableList extends PureComponent {
 
     if (isLoading || childrenCount > 0 || !emptyMessage) {
       scrollableArea = (
-        <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
+        <div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
           <div role='feed' className='item-list'>
             {prepend}
 
@@ -168,7 +170,7 @@ export default class ScrollableList extends PureComponent {
                 intersectionObserverWrapper={this.intersectionObserverWrapper}
                 saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
               >
-                {child}
+                {React.cloneElement(child, {getScrollPosition: this.getScrollPosition, updateScrollBottom: this.updateScrollBottom})}
               </IntersectionObserverArticleContainer>
             ))}
 
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index eb621d5d7..cf82c9ac6 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -45,10 +45,13 @@ export default class Status extends ImmutablePureComponent {
     withDismiss: PropTypes.bool,
     onMoveUp: PropTypes.func,
     onMoveDown: PropTypes.func,
+    getScrollPosition: PropTypes.func,
+    updateScrollBottom: PropTypes.func,
   };
 
   state = {
     isExpanded: null,
+    autoCollapsed: false,
   }
 
   // Avoid checking props that are functions (and whose equality will always
@@ -134,7 +137,31 @@ export default class Status extends ImmutablePureComponent {
       default:
         return false;
       }
-    }()) this.setExpansion(false);
+    }()) {
+      this.setExpansion(false);
+      // Hack to fix timeline jumps on second rendering when auto-collapsing
+      this.setState({ autoCollapsed: true });
+    }
+  }
+
+  getSnapshotBeforeUpdate (prevProps, prevState) {
+    if (this.props.getScrollPosition) {
+      return this.props.getScrollPosition();
+    } else {
+      return null;
+    }
+  }
+
+  //  Hack to fix timeline jumps on second rendering when auto-collapsing
+  componentDidUpdate (prevProps, prevState, snapshot) {
+    if (this.state.autoCollapsed) {
+      this.setState({ autoCollapsed: false });
+      if (snapshot !== null && this.props.updateScrollBottom) {
+        if (this.node.offsetTop < snapshot.top) {
+          this.props.updateScrollBottom(snapshot.height - snapshot.top);
+        }
+      }
+    }
   }
 
   //  `setExpansion()` sets the value of `isExpanded` in our state. It takes
diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js
index 1c98cd5f7..4bd9cb75e 100644
--- a/app/javascript/flavours/glitch/containers/mastodon.js
+++ b/app/javascript/flavours/glitch/containers/mastodon.js
@@ -6,6 +6,7 @@ import { showOnboardingOnce } from 'flavours/glitch/actions/onboarding';
 import { BrowserRouter, Route } from 'react-router-dom';
 import { ScrollContext } from 'react-router-scroll-4';
 import UI from 'flavours/glitch/features/ui';
+import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
 import { hydrateStore } from 'flavours/glitch/actions/store';
 import { connectUserStream } from 'flavours/glitch/actions/streaming';
 import { IntlProvider, addLocaleData } from 'react-intl';
@@ -19,6 +20,9 @@ export const store = configureStore();
 const hydrateAction = hydrateStore(initialState);
 store.dispatch(hydrateAction);
 
+// load custom emojis
+store.dispatch(fetchCustomEmojis());
+
 export default class Mastodon extends React.PureComponent {
 
   static propTypes = {
diff --git a/app/javascript/flavours/glitch/containers/media_galleries_container.js b/app/javascript/flavours/glitch/containers/media_galleries_container.js
new file mode 100644
index 000000000..a69457882
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/media_galleries_container.js
@@ -0,0 +1,68 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+import MediaGallery from 'flavours/glitch/components/media_gallery';
+import ModalRoot from 'flavours/glitch/components/modal_root';
+import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
+import { fromJS } from 'immutable';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export default class MediaGalleriesContainer extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+    galleries: PropTypes.object.isRequired,
+  };
+
+  state = {
+    media: null,
+    index: null,
+  };
+
+  handleOpenMedia = (media, index) => {
+    document.body.classList.add('media-gallery-standalone__body');
+    this.setState({ media, index });
+  }
+
+  handleCloseMedia = () => {
+    document.body.classList.remove('media-gallery-standalone__body');
+    this.setState({ media: null, index: null });
+  }
+
+  render () {
+    const { locale, galleries } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <React.Fragment>
+          {[].map.call(galleries, gallery => {
+            const { media, ...props } = JSON.parse(gallery.getAttribute('data-props'));
+
+            return ReactDOM.createPortal(
+              <MediaGallery
+                {...props}
+                media={fromJS(media)}
+                onOpenMedia={this.handleOpenMedia}
+              />,
+              gallery
+            );
+          })}
+          <ModalRoot onClose={this.handleCloseMedia}>
+            {this.state.media === null || this.state.index === null ? null : (
+              <MediaModal
+                media={this.state.media}
+                index={this.state.index}
+                onClose={this.handleCloseMedia}
+              />
+            )}
+          </ModalRoot>
+        </React.Fragment>
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/media_gallery_container.js b/app/javascript/flavours/glitch/containers/media_gallery_container.js
deleted file mode 100644
index 54bfbf453..000000000
--- a/app/javascript/flavours/glitch/containers/media_gallery_container.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { IntlProvider, addLocaleData } from 'react-intl';
-import { getLocale } from 'mastodon/locales';
-import MediaGallery from 'flavours/glitch/components/media_gallery';
-import { fromJS } from 'immutable';
-
-const { localeData, messages } = getLocale();
-addLocaleData(localeData);
-
-export default class MediaGalleryContainer extends React.PureComponent {
-
-  static propTypes = {
-    locale: PropTypes.string.isRequired,
-    media: PropTypes.array.isRequired,
-  };
-
-  handleOpenMedia = () => {}
-
-  render () {
-    const { locale, media, ...props } = this.props;
-
-    return (
-      <IntlProvider locale={locale} messages={messages}>
-        <MediaGallery
-          {...props}
-          media={fromJS(media)}
-          onOpenMedia={this.handleOpenMedia}
-        />
-      </IntlProvider>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js
index cc77426d3..6fc7173ed 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/notification.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js
@@ -16,6 +16,8 @@ export default class Notification extends ImmutablePureComponent {
     onMoveUp: PropTypes.func.isRequired,
     onMoveDown: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
+    getScrollPosition: PropTypes.func,
+    updateScrollBottom: PropTypes.func,
   };
 
   render () {
@@ -25,6 +27,8 @@ export default class Notification extends ImmutablePureComponent {
       onMoveDown,
       onMoveUp,
       onMention,
+      getScrollPosition,
+      updateScrollBottom,
     } = this.props;
 
     switch(notification.get('type')) {
@@ -50,6 +54,8 @@ export default class Notification extends ImmutablePureComponent {
           onMoveDown={onMoveDown}
           onMoveUp={onMoveUp}
           onMention={onMention}
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
           withDismiss
         />
       );
@@ -66,6 +72,8 @@ export default class Notification extends ImmutablePureComponent {
           onMoveDown={onMoveDown}
           onMoveUp={onMoveUp}
           onMention={onMention}
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
           withDismiss
         />
       );
@@ -82,6 +90,8 @@ export default class Notification extends ImmutablePureComponent {
           onMoveDown={onMoveDown}
           onMoveUp={onMoveUp}
           onMention={onMention}
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
           withDismiss
         />
       );
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index e7c26d013..066499da8 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -4,7 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import Avatar from 'flavours/glitch/components/avatar';
 import DisplayName from 'flavours/glitch/components/display_name';
 import StatusContent from 'flavours/glitch/components/status_content';
-import StatusGallery from 'flavours/glitch/components/media_gallery';
+import MediaGallery from 'flavours/glitch/components/media_gallery';
 import AttachmentList from 'flavours/glitch/components/attachment_list';
 import { Link } from 'react-router-dom';
 import { FormattedDate, FormattedNumber } from 'react-intl';
@@ -69,7 +69,8 @@ export default class DetailedStatus extends ImmutablePureComponent {
         mediaIcon = 'video-camera';
       } else {
         media = (
-          <StatusGallery
+          <MediaGallery
+            standalone
             sensitive={status.get('sensitive')}
             media={status.get('media_attachments')}
             letterbox={settings.getIn(['media', 'letterbox'])}
diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
index e12ee1761..320c039a4 100644
--- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import Base from '../../../components/modal_root';
 import BundleContainer from '../containers/bundle_container';
 import BundleModalError from './bundle_modal_error';
 import ModalLoading from './modal_loading';
@@ -43,56 +44,6 @@ export default class ModalRoot extends React.PureComponent {
     onClose: PropTypes.func.isRequired,
   };
 
-  state = {
-    revealed: false,
-  };
-
-  handleKeyUp = (e) => {
-    if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
-         && !!this.props.type && !this.props.props.noEsc) {
-      this.props.onClose();
-    }
-  }
-
-  componentDidMount () {
-    window.addEventListener('keyup', this.handleKeyUp, false);
-  }
-
-  componentWillReceiveProps (nextProps) {
-    if (!!nextProps.type && !this.props.type) {
-      this.activeElement = document.activeElement;
-
-      this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
-    } else if (!nextProps.type) {
-      this.setState({ revealed: false });
-    }
-  }
-
-  componentDidUpdate (prevProps) {
-    if (!this.props.type && !!prevProps.type) {
-      this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
-      this.activeElement.focus();
-      this.activeElement = null;
-    }
-    if (this.props.type) {
-      requestAnimationFrame(() => {
-        this.setState({ revealed: true });
-      });
-    }
-  }
-
-  componentWillUnmount () {
-    window.removeEventListener('keyup', this.handleKeyUp);
-  }
-
-  getSiblings = () => {
-    return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
-  }
-
-  setRef = ref => {
-    this.node = ref;
-  }
-
   renderLoading = modalId => () => {
     return ['MEDIA', 'VIDEO', 'BOOST', 'FAVOURITE', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
   }
@@ -105,30 +56,16 @@ export default class ModalRoot extends React.PureComponent {
 
   render () {
     const { type, props, onClose } = this.props;
-    const { revealed } = this.state;
     const visible = !!type;
 
-    if (!visible) {
-      return (
-        <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} />
-      );
-    }
-
     return (
-      <div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}>
-        <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
-          <div role='presentation' className='modal-root__overlay' onClick={onClose} />
-          <div role='dialog' className='modal-root__container'>
-            {
-              visible ?
-                (<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
-                  {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
-                </BundleContainer>) :
-                null
-            }
-          </div>
-        </div>
-      </div>
+      <Base onClose={onClose}>
+        {visible && (
+          <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
+            {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
+          </BundleContainer>
+        )}
+      </Base>
     );
   }
 
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
index 56ee9c20c..8c6d68dc4 100644
--- a/app/javascript/flavours/glitch/features/video/index.js
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -29,7 +29,7 @@ const formatTime = secondsNum => {
   return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
 };
 
-const findElementPosition = el => {
+export const findElementPosition = el => {
   let box;
 
   if (el.getBoundingClientRect && el.parentNode) {
@@ -60,7 +60,7 @@ const findElementPosition = el => {
   };
 };
 
-const getPointerPosition = (el, event) => {
+export const getPointerPosition = (el, event) => {
   const position = {};
   const box = findElementPosition(el);
   const boxW = el.offsetWidth;
@@ -76,7 +76,7 @@ const getPointerPosition = (el, event) => {
     pageY = event.changedTouches[0].pageY;
   }
 
-  position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
+  position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH));
   position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
 
   return position;
@@ -271,7 +271,7 @@ export default class Video extends React.PureComponent {
     }
 
     return (
-      <div className={classNames('video-player', { inactive: !revealed, detailed, inline: width && height && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth })} style={playerStyle} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+      <div className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth })} style={playerStyle} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
         <video
           ref={this.setVideoRef}
           src={src}
diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js
index 9ea82b53a..ed685b6b7 100644
--- a/app/javascript/flavours/glitch/packs/public.js
+++ b/app/javascript/flavours/glitch/packs/public.js
@@ -7,7 +7,6 @@ function main() {
   const { getLocale } = require('locales');
   const { localeData } = getLocale();
   const VideoContainer = require('flavours/glitch/containers/video_container').default;
-  const MediaGalleryContainer = require('flavours/glitch/containers/media_gallery_container').default;
   const CardContainer = require('flavours/glitch/containers/card_container').default;
   const React = require('react');
   const ReactDOM = require('react-dom');
@@ -58,15 +57,20 @@ function main() {
       ReactDOM.render(<VideoContainer locale={locale} {...props} />, content);
     });
 
-    [].forEach.call(document.querySelectorAll('[data-component="MediaGallery"]'), (content) => {
-      const props = JSON.parse(content.getAttribute('data-props'));
-      ReactDOM.render(<MediaGalleryContainer locale={locale} {...props} />, content);
-    });
-
     [].forEach.call(document.querySelectorAll('[data-component="Card"]'), (content) => {
       const props = JSON.parse(content.getAttribute('data-props'));
       ReactDOM.render(<CardContainer locale={locale} {...props} />, content);
     });
+
+    const mediaGalleries = document.querySelectorAll('[data-component="MediaGallery"]');
+
+    if (mediaGalleries.length > 0) {
+      const MediaGalleriesContainer = require('flavours/glitch/containers/media_galleries_container').default;
+      const content = document.createElement('div');
+
+      ReactDOM.render(<MediaGalleriesContainer locale={locale} galleries={mediaGalleries} />, content);
+      document.body.appendChild(content);
+    }
   });
 }
 
diff --git a/app/javascript/flavours/glitch/reducers/contexts.js b/app/javascript/flavours/glitch/reducers/contexts.js
index 53e93a589..effd70756 100644
--- a/app/javascript/flavours/glitch/reducers/contexts.js
+++ b/app/javascript/flavours/glitch/reducers/contexts.js
@@ -1,3 +1,7 @@
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  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 { Map as ImmutableMap, List as ImmutableList } from 'immutable';
@@ -17,18 +21,30 @@ const normalizeContext = (state, id, ancestors, descendants) => {
   });
 };
 
-const deleteFromContexts = (state, id) => {
-  state.getIn(['descendants', id], ImmutableList()).forEach(descendantId => {
-    state = state.updateIn(['ancestors', descendantId], ImmutableList(), list => list.filterNot(itemId => itemId === id));
-  });
+const deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => {
+  state.update('ancestors', immutableAncestors => immutableAncestors.withMutations(ancestors => {
+    state.update('descendants', immutableDescendants => immutableDescendants.withMutations(descendants => {
+      ids.forEach(id => {
+        descendants.get(id, ImmutableList()).forEach(descendantId => {
+          ancestors.update(descendantId, ImmutableList(), list => list.filterNot(itemId => itemId === id));
+        });
 
-  state.getIn(['ancestors', id], ImmutableList()).forEach(ancestorId => {
-    state = state.updateIn(['descendants', ancestorId], ImmutableList(), list => list.filterNot(itemId => itemId === id));
-  });
+        ancestors.get(id, ImmutableList()).forEach(ancestorId => {
+          descendants.update(ancestorId, ImmutableList(), list => list.filterNot(itemId => itemId === id));
+        });
+
+        descendants.delete(id);
+        ancestors.delete(id);
+      });
+    }));
+  }));
+});
 
-  state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', id]);
+const filterContexts = (state, relationship, statuses) => {
+  const ownedStatusIds = statuses.filter(status => status.get('account') === relationship.id)
+                                 .map(status => status.get('id'));
 
-  return state;
+  return deleteFromContexts(state, ownedStatusIds);
 };
 
 const updateContext = (state, status, references) => {
@@ -49,10 +65,13 @@ const updateContext = (state, status, references) => {
 
 export default function contexts(state = initialState, action) {
   switch(action.type) {
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return filterContexts(state, action.relationship, action.statuses);
   case CONTEXT_FETCH_SUCCESS:
     return normalizeContext(state, action.id, action.ancestors, action.descendants);
   case TIMELINE_DELETE:
-    return deleteFromContexts(state, action.id);
+    return deleteFromContexts(state, [action.id]);
   case TIMELINE_CONTEXT_UPDATE:
     return updateContext(state, action.status, action.references);
   default:
diff --git a/app/javascript/flavours/glitch/reducers/custom_emojis.js b/app/javascript/flavours/glitch/reducers/custom_emojis.js
index 592cea8dc..90e3040a4 100644
--- a/app/javascript/flavours/glitch/reducers/custom_emojis.js
+++ b/app/javascript/flavours/glitch/reducers/custom_emojis.js
@@ -1,16 +1,15 @@
-import { List as ImmutableList } from 'immutable';
-import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { List as ImmutableList, fromJS as ConvertToImmutable } from 'immutable';
+import { CUSTOM_EMOJIS_FETCH_SUCCESS } from 'flavours/glitch/actions/custom_emojis';
 import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light';
 import { buildCustomEmojis } from 'flavours/glitch/util/emoji';
 
-const initialState = ImmutableList();
+const initialState = ImmutableList([]);
 
 export default function custom_emojis(state = initialState, action) {
-  switch(action.type) {
-  case STORE_HYDRATE:
-    emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) });
-    return action.state.get('custom_emojis');
-  default:
-    return state;
+  if(action.type === CUSTOM_EMOJIS_FETCH_SUCCESS) {
+    state = ConvertToImmutable(action.custom_emojis);
+    emojiSearch('', { custom: buildCustomEmojis(state) });
   }
+
+  return state;
 };
diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js
index a3af3152d..f14fcea1e 100644
--- a/app/javascript/flavours/glitch/reducers/statuses.js
+++ b/app/javascript/flavours/glitch/reducers/statuses.js
@@ -27,10 +27,6 @@ import {
   TIMELINE_EXPAND_SUCCESS,
 } from 'flavours/glitch/actions/timelines';
 import {
-  ACCOUNT_BLOCK_SUCCESS,
-  ACCOUNT_MUTE_SUCCESS,
-} from 'flavours/glitch/actions/accounts';
-import {
   NOTIFICATIONS_UPDATE,
   NOTIFICATIONS_REFRESH_SUCCESS,
   NOTIFICATIONS_EXPAND_SUCCESS,
@@ -96,18 +92,6 @@ const deleteStatus = (state, id, references) => {
   return state.delete(id);
 };
 
-const filterStatuses = (state, relationship) => {
-  state.forEach(status => {
-    if (status.get('account') !== relationship.id) {
-      return;
-    }
-
-    state = deleteStatus(state, status.get('id'), state.filter(item => item.get('reblog') === status.get('id')));
-  });
-
-  return state;
-};
-
 const initialState = ImmutableMap();
 
 export default function statuses(state = initialState, action) {
@@ -155,9 +139,6 @@ export default function statuses(state = initialState, action) {
     return normalizeStatuses(state, action.statuses);
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.references);
-  case ACCOUNT_BLOCK_SUCCESS:
-  case ACCOUNT_MUTE_SUCCESS:
-    return filterStatuses(state, action.relationship);
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
index 4f0d6e1bc..2eb80aba8 100644
--- a/app/javascript/flavours/glitch/styles/components/modal.scss
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -3,13 +3,14 @@
 }
 
 .modal-root {
+  position: relative;
   transition: opacity 0.3s linear;
   will-change: opacity;
   z-index: 9999;
 }
 
 .modal-root__overlay {
-  position: absolute;
+  position: fixed;
   top: 0;
   left: 0;
   right: 0;
@@ -18,7 +19,7 @@
 }
 
 .modal-root__container {
-  position: absolute;
+  position: fixed;
   top: 0;
   left: 0;
   width: 100%;
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index 1d1cf0f9e..0a022802a 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -244,9 +244,17 @@
       height: 20px;
       overflow: hidden;
       text-overflow: ellipsis;
-      margin: 0;
       padding-top: 0;
 
+      &:after {
+        content: "";
+        position: absolute;
+        top: 0; bottom: 0;
+        left: 0; right: 0;
+        background: linear-gradient(rgba($ui-base-color, 0), rgba($ui-base-color, 1));
+        pointer-events: none;
+      }
+      
       a:hover {
         text-decoration: none;
       }
diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss
index 6fa1fa38f..e761f58eb 100644
--- a/app/javascript/flavours/glitch/styles/containers.scss
+++ b/app/javascript/flavours/glitch/styles/containers.scss
@@ -60,6 +60,10 @@
   }
 }
 
+.media-gallery-standalone__body {
+  overflow: hidden;
+}
+
 .account-header {
   width: 400px;
   margin: 0 auto;