about summary refs log tree commit diff
path: root/app/javascript/flavours
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2020-10-30 09:55:47 -0500
committerStarfall <us@starfall.systems>2020-10-30 09:55:47 -0500
commit259470ec37dfc5c3d34ed5456adcd3ab1a622a18 (patch)
tree0ed8e47864234419df1133ed7cbac481ea8e12dc /app/javascript/flavours
parentab2148ef84c2880465599bd8c80e15378cf0a517 (diff)
parent5a41704f8926d9594c66028ca30dc1fc0f98da3d (diff)
Merge branch 'glitch' into main
Diffstat (limited to 'app/javascript/flavours')
-rw-r--r--app/javascript/flavours/glitch/actions/accounts.js4
-rw-r--r--app/javascript/flavours/glitch/actions/markers.js8
-rw-r--r--app/javascript/flavours/glitch/actions/mutes.js10
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js44
-rw-r--r--app/javascript/flavours/glitch/actions/picture_in_picture.js38
-rw-r--r--app/javascript/flavours/glitch/components/account.js7
-rw-r--r--app/javascript/flavours/glitch/components/animated_number.js17
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_emoji.js2
-rw-r--r--app/javascript/flavours/glitch/components/column_header.js18
-rw-r--r--app/javascript/flavours/glitch/components/icon_button.js12
-rw-r--r--app/javascript/flavours/glitch/components/icon_with_badge.js4
-rw-r--r--app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js69
-rw-r--r--app/javascript/flavours/glitch/components/status.js16
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js22
-rw-r--r--app/javascript/flavours/glitch/containers/mastodon.js7
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js10
-rw-r--r--app/javascript/flavours/glitch/features/audio/index.js60
-rw-r--r--app/javascript/flavours/glitch/features/emoji_picker/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/components/announcements.js3
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/index.js33
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/column_settings.js36
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js30
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js5
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js35
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js13
-rw-r--r--app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js162
-rw-r--r--app/javascript/flavours/glitch/features/picture_in_picture/components/header.js40
-rw-r--r--app/javascript/flavours/glitch/features/picture_in_picture/index.js88
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js7
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js5
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js3
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/media_modal.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/mute_modal.js44
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/video_modal.js4
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js6
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js66
-rw-r--r--app/javascript/flavours/glitch/locales/es.js114
-rw-r--r--app/javascript/flavours/glitch/names.yml8
-rw-r--r--app/javascript/flavours/glitch/packs/about.js1
-rw-r--r--app/javascript/flavours/glitch/packs/common.js1
-rw-r--r--app/javascript/flavours/glitch/packs/error.js1
-rw-r--r--app/javascript/flavours/glitch/packs/home.js1
-rw-r--r--app/javascript/flavours/glitch/packs/public.js1
-rw-r--r--app/javascript/flavours/glitch/packs/settings.js1
-rw-r--r--app/javascript/flavours/glitch/packs/share.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js3
-rw-r--r--app/javascript/flavours/glitch/reducers/mutes.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/notifications.js14
-rw-r--r--app/javascript/flavours/glitch/reducers/picture_in_picture.js22
-rw-r--r--app/javascript/flavours/glitch/reducers/relationships.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js13
-rw-r--r--app/javascript/flavours/glitch/reducers/user_lists.js20
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss121
-rw-r--r--app/javascript/flavours/glitch/styles/components/columns.scss156
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss26
-rw-r--r--app/javascript/flavours/glitch/styles/components/modal.scss16
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss124
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light/diff.scss5
-rw-r--r--app/javascript/flavours/glitch/util/config.js10
-rw-r--r--app/javascript/flavours/glitch/util/emoji/index.js3
-rw-r--r--app/javascript/flavours/glitch/util/main.js2
-rw-r--r--app/javascript/flavours/glitch/util/notifications.js29
-rw-r--r--app/javascript/flavours/vanilla/names.yml8
64 files changed, 1402 insertions, 239 deletions
diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js
index 428b62f68..912a3d179 100644
--- a/app/javascript/flavours/glitch/actions/accounts.js
+++ b/app/javascript/flavours/glitch/actions/accounts.js
@@ -274,11 +274,11 @@ export function unblockAccountFail(error) {
 };
 
 
-export function muteAccount(id, notifications) {
+export function muteAccount(id, notifications, duration=0) {
   return (dispatch, getState) => {
     dispatch(muteAccountRequest(id));
 
-    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
+    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => {
       // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
       dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
     }).catch(error => {
diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js
index 80bcada6e..c0e7a93af 100644
--- a/app/javascript/flavours/glitch/actions/markers.js
+++ b/app/javascript/flavours/glitch/actions/markers.js
@@ -100,8 +100,12 @@ export function submitMarkersSuccess({ home, notifications }) {
   };
 };
 
-export function submitMarkers() {
-  return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
+export function submitMarkers(params = {}) {
+  const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
+  if (params.immediate === true) {
+    debouncedSubmitMarkers.flush();
+  }
+  return result;
 };
 
 export const fetchMarkers = () => (dispatch, getState) => {
diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js
index 927fc7415..2bacfadb7 100644
--- a/app/javascript/flavours/glitch/actions/mutes.js
+++ b/app/javascript/flavours/glitch/actions/mutes.js
@@ -13,6 +13,7 @@ export const MUTES_EXPAND_FAIL    = 'MUTES_EXPAND_FAIL';
 
 export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
 export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
+export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION';
 
 export function fetchMutes() {
   return (dispatch, getState) => {
@@ -104,3 +105,12 @@ export function toggleHideNotifications() {
     dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
   };
 }
+
+export function changeMuteDuration(duration) {
+  return dispatch => {
+    dispatch({
+      type: MUTES_CHANGE_DURATION,
+      duration,
+    });
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index 7f311153b..eb7087027 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -16,6 +16,7 @@ import { getFiltersRegex } from 'flavours/glitch/selectors';
 import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';
 import compareId from 'flavours/glitch/util/compare_id';
 import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer';
+import { requestNotificationPermission } from 'flavours/glitch/util/notifications';
 
 export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
 export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@@ -46,8 +47,12 @@ export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
 
 export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY';
 
+
 export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
 
+export const NOTIFICATIONS_SET_BROWSER_SUPPORT    = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
+
 defineMessages({
   mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
 });
@@ -327,3 +332,42 @@ export function markNotificationsAsRead() {
     type: NOTIFICATIONS_MARK_AS_READ,
   };
 };
+
+// Browser support
+export function setupBrowserNotifications() {
+  return dispatch => {
+    dispatch(setBrowserSupport('Notification' in window));
+    if ('Notification' in window) {
+      dispatch(setBrowserPermission(Notification.permission));
+    }
+
+    if ('Notification' in window && 'permissions' in navigator) {
+      navigator.permissions.query({ name: 'notifications' }).then((status) => {
+        status.onchange = () => dispatch(setBrowserPermission(Notification.permission));
+      });
+    }
+  };
+}
+
+export function requestBrowserPermission(callback = noOp) {
+  return dispatch => {
+    requestNotificationPermission((permission) => {
+      dispatch(setBrowserPermission(permission));
+      callback(permission);
+    });
+  };
+};
+
+export function setBrowserSupport (value) {
+  return {
+    type: NOTIFICATIONS_SET_BROWSER_SUPPORT,
+    value,
+  };
+}
+
+export function setBrowserPermission (value) {
+  return {
+    type: NOTIFICATIONS_SET_BROWSER_PERMISSION,
+    value,
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/picture_in_picture.js b/app/javascript/flavours/glitch/actions/picture_in_picture.js
new file mode 100644
index 000000000..4085cb59e
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/picture_in_picture.js
@@ -0,0 +1,38 @@
+// @ts-check
+
+export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY';
+export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE';
+
+/**
+ * @typedef MediaProps
+ * @property {string} src
+ * @property {boolean} muted
+ * @property {number} volume
+ * @property {number} currentTime
+ * @property {string} poster
+ * @property {string} backgroundColor
+ * @property {string} foregroundColor
+ * @property {string} accentColor
+ */
+
+/**
+ * @param {string} statusId
+ * @param {string} accountId
+ * @param {string} playerType
+ * @param {MediaProps} props
+ * @return {object}
+ */
+export const deployPictureInPicture = (statusId, accountId, playerType, props) => ({
+  type: PICTURE_IN_PICTURE_DEPLOY,
+  statusId,
+  accountId,
+  playerType,
+  props,
+});
+
+/*
+ * @return {object}
+ */
+export const removePictureInPicture = () => ({
+  type: PICTURE_IN_PICTURE_REMOVE,
+});
diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js
index f3e58dfe3..23399c630 100644
--- a/app/javascript/flavours/glitch/components/account.js
+++ b/app/javascript/flavours/glitch/components/account.js
@@ -8,6 +8,7 @@ import IconButton from './icon_button';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { me } from 'flavours/glitch/util/initial_state';
+import RelativeTimestamp from './relative_timestamp';
 
 const messages = defineMessages({
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
@@ -116,6 +117,11 @@ class Account extends ImmutablePureComponent {
       }
     }
 
+    let mute_expires_at;
+    if (account.get('mute_expires_at')) {
+      mute_expires_at =  <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>;
+    }
+
     return small ? (
       <Permalink
         className='account small'
@@ -138,6 +144,7 @@ class Account extends ImmutablePureComponent {
         <div className='account__wrapper'>
           <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+            {mute_expires_at}
             <DisplayName account={account} />
           </Permalink>
           {buttons ?
diff --git a/app/javascript/flavours/glitch/components/animated_number.js b/app/javascript/flavours/glitch/components/animated_number.js
index e3235e368..3cc5173dd 100644
--- a/app/javascript/flavours/glitch/components/animated_number.js
+++ b/app/javascript/flavours/glitch/components/animated_number.js
@@ -5,10 +5,21 @@ import TransitionMotion from 'react-motion/lib/TransitionMotion';
 import spring from 'react-motion/lib/spring';
 import { reduceMotion } from 'flavours/glitch/util/initial_state';
 
+const obfuscatedCount = count => {
+  if (count < 0) {
+    return 0;
+  } else if (count <= 1) {
+    return count;
+  } else {
+    return '1+';
+  }
+};
+
 export default class AnimatedNumber extends React.PureComponent {
 
   static propTypes = {
     value: PropTypes.number.isRequired,
+    obfuscate: PropTypes.bool,
   };
 
   state = {
@@ -36,11 +47,11 @@ export default class AnimatedNumber extends React.PureComponent {
   }
 
   render () {
-    const { value } = this.props;
+    const { value, obfuscate } = this.props;
     const { direction } = this.state;
 
     if (reduceMotion) {
-      return <FormattedNumber value={value} />;
+      return obfuscate ? obfuscatedCount(value) : <FormattedNumber value={value} />;
     }
 
     const styles = [{
@@ -54,7 +65,7 @@ export default class AnimatedNumber extends React.PureComponent {
         {items => (
           <span className='animated-number'>
             {items.map(({ key, data, style }) => (
-              <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}><FormattedNumber value={data} /></span>
+              <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span>
             ))}
           </span>
         )}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js
index c8609e48f..d04c1eb68 100644
--- a/app/javascript/flavours/glitch/components/autosuggest_emoji.js
+++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.js
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
 
-const assetHost = process.env.CDN_HOST || '';
+import { assetHost } from 'flavours/glitch/util/config';
 
 export default class AutosuggestEmoji extends React.PureComponent {
 
diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js
index 01bd4a246..ccd0714f1 100644
--- a/app/javascript/flavours/glitch/components/column_header.js
+++ b/app/javascript/flavours/glitch/components/column_header.js
@@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent {
     onMove: PropTypes.func,
     onClick: PropTypes.func,
     appendContent: PropTypes.node,
+    collapseIssues: PropTypes.bool,
   };
 
   state = {
@@ -88,7 +89,7 @@ class ColumnHeader extends React.PureComponent {
   }
 
   render () {
-    const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props;
+    const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
     const { collapsed, animating } = this.state;
 
     const wrapperClassName = classNames('column-header__wrapper', {
@@ -150,7 +151,20 @@ class ColumnHeader extends React.PureComponent {
     }
 
     if (children || (multiColumn && this.props.onPin)) {
-      collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>;
+      collapseButton = (
+        <button
+          className={collapsibleButtonClassName}
+          title={formatMessage(collapsed ? messages.show : messages.hide)}
+          aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
+          aria-pressed={collapsed ? 'false' : 'true'}
+          onClick={this.handleToggleClick}
+        >
+          <i className='icon-with-badge'>
+            <Icon id='sliders' />
+            {collapseIssues && <i className='icon-with-badge__issue-badge' />}
+          </i>
+        </button>
+      );
     }
 
     const hasTitle = icon && title;
diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js
index e134d0a39..58d3568dd 100644
--- a/app/javascript/flavours/glitch/components/icon_button.js
+++ b/app/javascript/flavours/glitch/components/icon_button.js
@@ -4,6 +4,7 @@ import spring from 'react-motion/lib/spring';
 import PropTypes from 'prop-types';
 import classNames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
+import AnimatedNumber from 'flavours/glitch/components/animated_number';
 
 export default class IconButton extends React.PureComponent {
 
@@ -27,6 +28,8 @@ export default class IconButton extends React.PureComponent {
     overlay: PropTypes.bool,
     tabIndex: PropTypes.string,
     label: PropTypes.string,
+    counter: PropTypes.number,
+    obfuscateCount: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -104,6 +107,8 @@ export default class IconButton extends React.PureComponent {
       pressed,
       tabIndex,
       title,
+      counter,
+      obfuscateCount,
     } = this.props;
 
     const {
@@ -118,8 +123,13 @@ export default class IconButton extends React.PureComponent {
       activate,
       deactivate,
       overlayed: overlay,
+      'icon-button--with-counter': typeof counter !== 'undefined',
     });
 
+    if (typeof counter !== 'undefined') {
+      style.width = 'auto';
+    }
+
     return (
       <button
         aria-label={title}
@@ -135,7 +145,7 @@ export default class IconButton extends React.PureComponent {
         tabIndex={tabIndex}
         disabled={disabled}
       >
-        <Icon id={icon} fixedWidth aria-hidden='true' />
+        <Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>}
         {this.props.label}
       </button>
     );
diff --git a/app/javascript/flavours/glitch/components/icon_with_badge.js b/app/javascript/flavours/glitch/components/icon_with_badge.js
index 219efc28c..a42ba4589 100644
--- a/app/javascript/flavours/glitch/components/icon_with_badge.js
+++ b/app/javascript/flavours/glitch/components/icon_with_badge.js
@@ -4,16 +4,18 @@ import Icon from 'flavours/glitch/components/icon';
 
 const formatNumber = num => num > 40 ? '40+' : num;
 
-const IconWithBadge = ({ id, count, className }) => (
+const IconWithBadge = ({ id, count, issueBadge, className }) => (
   <i className='icon-with-badge'>
     <Icon id={id} fixedWidth className={className} />
     {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
+    {issueBadge && <i className='icon-with-badge__issue-badge' />}
   </i>
 );
 
 IconWithBadge.propTypes = {
   id: PropTypes.string.isRequired,
   count: PropTypes.number.isRequired,
+  issueBadge: PropTypes.bool,
   className: PropTypes.string,
 };
 
diff --git a/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js
new file mode 100644
index 000000000..01dce0a38
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js
@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Icon from 'flavours/glitch/components/icon';
+import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
+import { connect } from 'react-redux';
+import { debounce } from 'lodash';
+import { FormattedMessage } from 'react-intl';
+
+export default @connect()
+class PictureInPicturePlaceholder extends React.PureComponent {
+
+  static propTypes = {
+    width: PropTypes.number,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  state = {
+    width: this.props.width,
+    height: this.props.width && (this.props.width / (16/9)),
+  };
+
+  handleClick = () => {
+    const { dispatch } = this.props;
+    dispatch(removePictureInPicture());
+  }
+
+  setRef = c => {
+    this.node = c;
+
+    if (this.node) {
+      this._setDimensions();
+    }
+  }
+
+  _setDimensions () {
+    const width  = this.node.offsetWidth;
+    const height = width / (16/9);
+
+    this.setState({ width, height });
+  }
+
+  componentDidMount () {
+    window.addEventListener('resize', this.handleResize, { passive: true });
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize);
+  }
+
+  handleResize = debounce(() => {
+    if (this.node) {
+      this._setDimensions();
+    }
+  }, 250, {
+    trailing: true,
+  });
+
+  render () {
+    const { height } = this.state;
+
+    return (
+      <div ref={this.setRef} className='picture-in-picture-placeholder' style={{ height }} role='button' tabIndex='0' onClick={this.handleClick}>
+        <Icon id='window-restore' />
+        <FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index fc7940e5a..1b7dce4c4 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -17,6 +17,7 @@ import classNames from 'classnames';
 import { autoUnfoldCW } from 'flavours/glitch/util/content_warning';
 import PollContainer from 'flavours/glitch/containers/poll_container';
 import { displayMedia } from 'flavours/glitch/util/initial_state';
+import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
 
 // We use the component (and not the container) since we do not want
 // to use the progress bar to show download progress
@@ -97,6 +98,8 @@ class Status extends ImmutablePureComponent {
     cachedMediaWidth: PropTypes.number,
     onClick: PropTypes.func,
     scrollKey: PropTypes.string,
+    deployPictureInPicture: PropTypes.func,
+    usingPiP: PropTypes.bool,
   };
 
   state = {
@@ -123,6 +126,7 @@ class Status extends ImmutablePureComponent {
     'hidden',
     'expanded',
     'unread',
+    'usingPiP',
   ]
 
   updateOnStates = [
@@ -394,6 +398,12 @@ class Status extends ImmutablePureComponent {
     }
   }
 
+  handleDeployPictureInPicture = (type, mediaProps) => {
+    const { deployPictureInPicture, status } = this.props;
+
+    deployPictureInPicture(status, type, mediaProps);
+  }
+
   handleHotkeyReply = e => {
     e.preventDefault();
     this.props.onReply(this.props.status, this.context.router.history);
@@ -496,6 +506,7 @@ class Status extends ImmutablePureComponent {
       hidden,
       unread,
       featured,
+      usingPiP,
       ...other
     } = this.props;
     const { isExpanded, isCollapsed, forceFilter } = this.state;
@@ -576,6 +587,9 @@ class Status extends ImmutablePureComponent {
     if (status.get('poll')) {
       media = <PollContainer pollId={status.get('poll')} />;
       mediaIcon = 'tasks';
+    } else if (usingPiP) {
+      media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
+      mediaIcon = 'video-camera';
     } else if (attachments.size > 0) {
       if (muted || attachments.some(item => item.get('type') === 'unknown')) {
         media = (
@@ -601,6 +615,7 @@ class Status extends ImmutablePureComponent {
                 width={this.props.cachedMediaWidth}
                 height={110}
                 cacheWidth={this.props.cacheMediaWidth}
+                deployPictureInPicture={this.handleDeployPictureInPicture}
               />
             )}
           </Bundle>
@@ -624,6 +639,7 @@ class Status extends ImmutablePureComponent {
               onOpenVideo={this.handleOpenVideo}
               width={this.props.cachedMediaWidth}
               cacheWidth={this.props.cacheMediaWidth}
+              deployPictureInPicture={this.handleDeployPictureInPicture}
               visible={this.state.showMedia}
               onToggleVisibility={this.handleToggleMediaVisibility}
             />)}
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index cfb03c21b..2ccb02c62 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -40,16 +40,6 @@ const messages = defineMessages({
   hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
 });
 
-const obfuscatedCount = count => {
-  if (count < 0) {
-    return 0;
-  } else if (count <= 1) {
-    return count;
-  } else {
-    return '1+';
-  }
-};
-
 export default @injectIntl
 class StatusActionBar extends ImmutablePureComponent {
 
@@ -284,10 +274,14 @@ class StatusActionBar extends ImmutablePureComponent {
     );
     if (showReplyCount) {
       replyButton = (
-        <div className='status__action-bar__counter'>
-          {replyButton}
-          <span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span>
-        </div>
+        <IconButton
+          className='status__action-bar-button'
+          title={replyTitle}
+          icon={replyIcon}
+          onClick={this.handleReplyClick}
+          counter={status.get('replies_count')}
+          obfuscateCount
+        />
       );
     }
 
diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js
index 8101be87e..762280bec 100644
--- a/app/javascript/flavours/glitch/containers/mastodon.js
+++ b/app/javascript/flavours/glitch/containers/mastodon.js
@@ -32,13 +32,6 @@ export default class Mastodon extends React.PureComponent {
 
   componentDidMount() {
     this.disconnect = store.dispatch(connectUserStream());
-
-    // Desktop notifications
-    // Ask after 1 minute
-    if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
-      window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
-    }
-
     store.dispatch(showOnboardingOnce());
   }
 
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index 2cbe3d094..ac423c58d 100644
--- a/app/javascript/flavours/glitch/containers/status_container.js
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -22,6 +22,7 @@ import { initMuteModal } from 'flavours/glitch/actions/mutes';
 import { initBlockModal } from 'flavours/glitch/actions/blocks';
 import { initReport } from 'flavours/glitch/actions/reports';
 import { openModal } from 'flavours/glitch/actions/modal';
+import { deployPictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
 import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state';
@@ -69,6 +70,7 @@ const makeMapStateToProps = () => {
       account     : account || props.account,
       settings    : state.get('local_settings'),
       prepend     : prepend || props.prepend,
+      usingPiP    : state.get('picture_in_picture').statusId === props.id,
     };
   };
 
@@ -245,6 +247,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
     }
   },
 
+  deployPictureInPicture (status, type, mediaProps) {
+    dispatch((_, getState) => {
+      if (getState().getIn(['local_settings', 'media', 'pop_in_player'])) {
+        dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
+      }
+    });
+  },
+
 });
 
 export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js
index 7a2fb7fb6..6d09ac8d2 100644
--- a/app/javascript/flavours/glitch/features/audio/index.js
+++ b/app/javascript/flavours/glitch/features/audio/index.js
@@ -37,7 +37,11 @@ class Audio extends React.PureComponent {
     backgroundColor: PropTypes.string,
     foregroundColor: PropTypes.string,
     accentColor: PropTypes.string,
+    currentTime: PropTypes.number,
     autoPlay: PropTypes.bool,
+    volume: PropTypes.number,
+    muted: PropTypes.bool,
+    deployPictureInPicture: PropTypes.func,
   };
 
   state = {
@@ -64,6 +68,19 @@ class Audio extends React.PureComponent {
     }
   }
 
+  _pack() {
+    return {
+      src: this.props.src,
+      volume: this.audio.volume,
+      muted: this.audio.muted,
+      currentTime: this.audio.currentTime,
+      poster: this.props.poster,
+      backgroundColor: this.props.backgroundColor,
+      foregroundColor: this.props.foregroundColor,
+      accentColor: this.props.accentColor,
+    };
+  }
+
   _setDimensions () {
     const width  = this.player.offsetWidth;
     const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
@@ -100,6 +117,7 @@ class Audio extends React.PureComponent {
   }
  
   componentDidMount () {
+    window.addEventListener('scroll', this.handleScroll);
     window.addEventListener('resize', this.handleResize, { passive: true });
   }
 
@@ -115,7 +133,12 @@ class Audio extends React.PureComponent {
   }
 
   componentWillUnmount () {
+    window.removeEventListener('scroll', this.handleScroll);
     window.removeEventListener('resize', this.handleResize);
+
+    if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
+      this.props.deployPictureInPicture('audio', this._pack());
+    }
   }
 
   togglePlay = () => {
@@ -243,6 +266,25 @@ class Audio extends React.PureComponent {
     }
   }, 15);
 
+  handleScroll = throttle(() => {
+    if (!this.canvas || !this.audio) {
+      return;
+    }
+
+    const { top, height } = this.canvas.getBoundingClientRect();
+    const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
+
+    if (!this.state.paused && !inView) {
+      this.audio.pause();
+
+      if (this.props.deployPictureInPicture) {
+        this.props.deployPictureInPicture('audio', this._pack());
+      }
+
+      this.setState({ paused: true });
+    }
+  }, 150, { trailing: true });
+
   handleMouseEnter = () => {
     this.setState({ hovered: true });
   }
@@ -252,10 +294,22 @@ class Audio extends React.PureComponent {
   }
 
   handleLoadedData = () => {
-    const { autoPlay } = this.props;
+    const { autoPlay, currentTime, volume, muted } = this.props;
+
+    if (currentTime) {
+      this.audio.currentTime = currentTime;
+    }
+
+    if (volume !== undefined) {
+      this.audio.volume = volume;
+    }
+
+    if (muted !== undefined) {
+      this.audio.muted = muted;
+    }
 
     if (autoPlay) {
-      this.audio.play();
+      this.togglePlay();
     }
   }
 
@@ -341,7 +395,7 @@ class Audio extends React.PureComponent {
   render () {
     const { src, intl, alt, editable, autoPlay } = this.props;
     const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
-    const progress = (currentTime / duration) * 100;
+    const progress = Math.min((currentTime / duration) * 100, 100);
 
     return (
       <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js
index d0d9714a8..89219d739 100644
--- a/app/javascript/flavours/glitch/features/emoji_picker/index.js
+++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js
@@ -13,6 +13,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import detectPassiveEvents from 'detect-passive-events';
 import { buildCustomEmojis, categoriesFromEmojis } from 'flavours/glitch/util/emoji';
 import { useSystemEmojiFont } from 'flavours/glitch/util/initial_state';
+import { assetHost } from 'flavours/glitch/util/config';
 
 const messages = defineMessages({
   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@@ -105,7 +106,6 @@ const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
   },
 });
 
-const assetHost = process.env.CDN_HOST || '';
 let EmojiPicker, Emoji; // load asynchronously
 
 const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
diff --git a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
index acaa78fe3..cd81d07de 100644
--- a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
+++ b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
@@ -15,6 +15,7 @@ import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker';
 import AnimatedNumber from 'flavours/glitch/components/animated_number';
 import TransitionMotion from 'react-motion/lib/TransitionMotion';
 import spring from 'react-motion/lib/spring';
+import { assetHost } from 'flavours/glitch/util/config';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -153,8 +154,6 @@ class Content extends ImmutablePureComponent {
 
 }
 
-const assetHost = process.env.CDN_HOST || '';
-
 class Emoji extends React.PureComponent {
 
   static propTypes = {
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js
index 0b3428027..3af6cbdf6 100644
--- a/app/javascript/flavours/glitch/features/local_settings/page/index.js
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js
@@ -28,6 +28,8 @@ const messages = defineMessages({
   rewrite_mentions_no: { id: 'settings.rewrite_mentions_no', defaultMessage: 'Do not rewrite mentions' },
   rewrite_mentions_acct: { id: 'settings.rewrite_mentions_acct', defaultMessage: 'Rewrite with username and domain (when the account is remote)' },
   rewrite_mentions_username: { id: 'settings.rewrite_mentions_username', defaultMessage:  'Rewrite with username' },
+  pop_in_left: { id: 'settings.pop_in_left', defaultMessage: 'Left' },
+  pop_in_right: { id: 'settings.pop_in_right', defaultMessage:  'Right' },
 });
 
 export default @injectIntl
@@ -111,6 +113,14 @@ class LocalSettingsPage extends React.PureComponent {
             <FormattedMessage id='settings.notifications.favicon_badge' defaultMessage='Unread notifications favicon badge' />
             <span className='hint'><FormattedMessage id='settings.notifications.favicon_badge.hint' defaultMessage="Add a badge for unread notifications to the favicon" /></span>
           </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['notifications', 'show_unread']}
+            id='mastodon-settings--notifications-show_unread'
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.notifications.show_unread' defaultMessage='Show unread marker' />
+          </LocalSettingsPageItem>
         </section>
         <section>
           <h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2>
@@ -384,7 +394,7 @@ class LocalSettingsPage extends React.PureComponent {
         </section>
       </div>
     ),
-    ({ onChange, settings }) => (
+    ({ intl, onChange, settings }) => (
       <div className='glitch local-settings__page media'>
         <h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1>
         <LocalSettingsPageItem
@@ -420,6 +430,27 @@ class LocalSettingsPage extends React.PureComponent {
         >
           <FormattedMessage id='settings.media_reveal_behind_cw' defaultMessage='Reveal sensitive media behind a CW by default' />
         </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'pop_in_player']}
+          id='mastodon-settings--pop-in-player'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.pop_in_player' defaultMessage='Enable pop-in player' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'pop_in_position']}
+          id='mastodon-settings--pop-in-position'
+          options={[
+            { value: 'left', message: intl.formatMessage(messages.pop_in_left) },
+            { value: 'right', message: intl.formatMessage(messages.pop_in_right) },
+          ]}
+          onChange={onChange}
+          dependsOn={[['media', 'pop_in_player']]}
+        >
+          <FormattedMessage id='settings.pop_in_position' defaultMessage='Pop-in player position:' />
+        </LocalSettingsPageItem>
       </div>
     ),
   ];
diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
index e4d5d0eda..9748219dd 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
@@ -12,6 +12,10 @@ export default class ColumnSettings extends React.PureComponent {
     pushSettings: ImmutablePropTypes.map.isRequired,
     onChange: PropTypes.func.isRequired,
     onClear: PropTypes.func.isRequired,
+    onRequestNotificationPermission: PropTypes.func,
+    alertsEnabled: PropTypes.bool,
+    browserSupport: PropTypes.bool,
+    browserPermission: PropTypes.bool,
   };
 
   onPushChange = (path, checked) => {
@@ -19,7 +23,7 @@ export default class ColumnSettings extends React.PureComponent {
   }
 
   render () {
-    const { settings, pushSettings, onChange, onClear } = this.props;
+    const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission } = this.props;
 
     const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
     const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
@@ -33,6 +37,12 @@ export default class ColumnSettings extends React.PureComponent {
 
     return (
       <div>
+        {alertsEnabled && browserSupport && browserPermission === 'denied' && (
+          <div className='column-settings__row column-settings__row--with-margin'>
+            <span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span>
+          </div>
+        )}
+
         <div className='column-settings__row'>
           <ClearColumnButton onClick={onClear} />
         </div>
@@ -41,6 +51,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-filter-bar' className='column-settings__section'>
             <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
           </span>
+
           <div className='column-settings__row'>
             <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
             <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
@@ -51,7 +62,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
@@ -62,7 +73,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
@@ -73,7 +84,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
@@ -84,7 +95,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} />
@@ -95,7 +106,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
@@ -106,12 +117,23 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
           </div>
         </div>
+
+        <div role='group' aria-labelledby='notifications-status'>
+          <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js
new file mode 100644
index 000000000..8e77f5a03
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import Icon from 'flavours/glitch/components/icon';
+import Button from 'flavours/glitch/components/button';
+import { requestBrowserPermission } from 'flavours/glitch/actions/notifications';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+export default @connect(() => {})
+class NotificationsPermissionBanner extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.dispatch(requestBrowserPermission());
+  }
+
+  render () {
+    return (
+      <div className='notifications-permission-banner'>
+        <h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2>
+        <p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p>
+        <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js
index 0264b6815..e472f7c4f 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js
@@ -13,6 +13,7 @@ export default class SettingToggle extends React.PureComponent {
     meta: PropTypes.node,
     onChange: PropTypes.func.isRequired,
     defaultValue: PropTypes.bool,
+    disabled: PropTypes.bool,
   }
 
   onChange = ({ target }) => {
@@ -20,12 +21,12 @@ export default class SettingToggle extends React.PureComponent {
   }
 
   render () {
-    const { prefix, settings, settingPath, label, meta, defaultValue } = this.props;
+    const { prefix, settings, settingPath, label, meta, defaultValue, disabled } = this.props;
     const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-');
 
     return (
       <div className='setting-toggle'>
-        <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
+        <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
         <label htmlFor={id} className='setting-toggle__label'>{label}</label>
         {meta && <span className='setting-meta__label'>{meta}</span>}
       </div>
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
index 4b863712a..c2564f44e 100644
--- a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
+++ b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
@@ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl';
 import ColumnSettings from '../components/column_settings';
 import { changeSetting } from 'flavours/glitch/actions/settings';
 import { setFilter } from 'flavours/glitch/actions/notifications';
-import { clearNotifications } from 'flavours/glitch/actions/notifications';
+import { clearNotifications, requestBrowserPermission } from 'flavours/glitch/actions/notifications';
 import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications';
 import { openModal } from 'flavours/glitch/actions/modal';
+import { showAlert } from 'flavours/glitch/actions/alerts';
 
 const messages = defineMessages({
   clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
   clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' },
+  permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' },
 });
 
 const mapStateToProps = state => ({
   settings: state.getIn(['settings', 'notifications']),
   pushSettings: state.get('push_notifications'),
+  alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true),
+  browserSupport: state.getIn(['notifications', 'browserSupport']),
+  browserPermission: state.getIn(['notifications', 'browserPermission']),
 });
 
 const mapDispatchToProps = (dispatch, { intl }) => ({
 
   onChange (path, checked) {
     if (path[0] === 'push') {
-      dispatch(changePushNotifications(path.slice(1), checked));
+      if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+        dispatch(requestBrowserPermission((permission) => {
+          if (permission === 'granted') {
+            dispatch(changePushNotifications(path.slice(1), checked));
+          } else {
+            dispatch(showAlert(undefined, messages.permissionDenied));
+          }
+        }));
+      } else {
+        dispatch(changePushNotifications(path.slice(1), checked));
+      }
     } else if (path[0] === 'quickFilter') {
       dispatch(changeSetting(['notifications', ...path], checked));
       dispatch(setFilter('all'));
+    } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+      if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+        dispatch(requestBrowserPermission((permission) => {
+          if (permission === 'granted') {
+            dispatch(changeSetting(['notifications', ...path], checked));
+          } else {
+            dispatch(showAlert(undefined, messages.permissionDenied));
+          }
+        }));
+      } else {
+        dispatch(changeSetting(['notifications', ...path], checked));
+      }
     } else {
       dispatch(changeSetting(['notifications', ...path], checked));
     }
@@ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }));
   },
 
+  onRequestNotificationPermission () {
+    dispatch(requestBrowserPermission());
+  },
+
 });
 
 export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 475968caa..97434b586 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -27,6 +27,7 @@ import ScrollableList from 'flavours/glitch/components/scrollable_list';
 import LoadGap from 'flavours/glitch/components/load_gap';
 import Icon from 'flavours/glitch/components/icon';
 import compareId from 'flavours/glitch/util/compare_id';
+import NotificationsPermissionBanner from './components/notifications_permission_banner';
 
 import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container';
 
@@ -60,8 +61,9 @@ const mapStateToProps = state => ({
   hasMore: state.getIn(['notifications', 'hasMore']),
   numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
   notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
-  lastReadId: state.getIn(['notifications', 'readMarkerId']),
-  canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
+  lastReadId: state.getIn(['local_settings', 'notifications', 'show_unread']) ? state.getIn(['notifications', 'readMarkerId']) : '0',
+  canMarkAsRead: state.getIn(['local_settings', 'notifications', 'show_unread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
+  needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default',
 });
 
 /* glitch */
@@ -71,7 +73,7 @@ const mapDispatchToProps = dispatch => ({
   },
   onMarkAsRead() {
     dispatch(markNotificationsAsRead());
-    dispatch(submitMarkers());
+    dispatch(submitMarkers({ immediate: true }));
   },
   onMount() {
     dispatch(mountNotifications());
@@ -105,6 +107,7 @@ class Notifications extends React.PureComponent {
     onUnmount: PropTypes.func,
     lastReadId: PropTypes.string,
     canMarkAsRead: PropTypes.bool,
+    needsNotificationPermission: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -211,7 +214,7 @@ class Notifications extends React.PureComponent {
   }
 
   render () {
-    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props;
+    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
     const { notifCleaning, notifCleaningActive } = this.props;
     const { animatingNCD } = this.state;
     const pinned = !!columnId;
@@ -257,6 +260,8 @@ class Notifications extends React.PureComponent {
         showLoading={isLoading && notifications.size === 0}
         hasMore={hasMore}
         numPending={numPending}
+        prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
+        alwaysPrepend
         emptyMessage={emptyMessage}
         onLoadMore={this.handleLoadOlder}
         onLoadPending={this.handleLoadPending}
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js
new file mode 100644
index 000000000..2ddba140e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js
@@ -0,0 +1,162 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'flavours/glitch/components/icon_button';
+import classNames from 'classnames';
+import { me, boostModal } from 'flavours/glitch/util/initial_state';
+import { defineMessages, injectIntl } from 'react-intl';
+import { replyCompose } from 'flavours/glitch/actions/compose';
+import { reblog, favourite, unreblog, unfavourite } from 'flavours/glitch/actions/interactions';
+import { makeGetStatus } from 'flavours/glitch/selectors';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+const messages = defineMessages({
+  reply: { id: 'status.reply', defaultMessage: 'Reply' },
+  replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+  reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
+  cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+  cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, { statusId }) => ({
+    status: getStatus(state, { id: statusId }),
+    askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
+    showReplyCount: state.getIn(['local_settings', 'show_reply_count']),
+  });
+
+  return mapStateToProps;
+};
+
+export default @connect(makeMapStateToProps)
+@injectIntl
+class Footer extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    statusId: PropTypes.string.isRequired,
+    status: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    askReplyConfirmation: PropTypes.bool,
+    showReplyCount: PropTypes.bool,
+  };
+
+  _performReply = () => {
+    const { dispatch, status } = this.props;
+    dispatch(replyCompose(status, this.context.router.history));
+  };
+
+  handleReplyClick = () => {
+    const { dispatch, askReplyConfirmation, intl } = this.props;
+
+    if (askReplyConfirmation) {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(messages.replyMessage),
+        confirm: intl.formatMessage(messages.replyConfirm),
+        onConfirm: this._performReply,
+      }));
+    } else {
+      this._performReply();
+    }
+  };
+
+  handleFavouriteClick = () => {
+    const { dispatch, status } = this.props;
+
+    if (status.get('favourited')) {
+      dispatch(unfavourite(status));
+    } else {
+      dispatch(favourite(status));
+    }
+  };
+
+  _performReblog = () => {
+    const { dispatch, status } = this.props;
+    dispatch(reblog(status));
+  }
+
+  handleReblogClick = e => {
+    const { dispatch, status } = this.props;
+
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else if ((e && e.shiftKey) || !boostModal) {
+      this._performReblog();
+    } else {
+      dispatch(openModal('BOOST', { status, onReblog: this._performReblog }));
+    }
+  };
+
+  render () {
+    const { status, intl, showReplyCount } = this.props;
+
+    const publicStatus  = ['public', 'unlisted'].includes(status.get('visibility'));
+    const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
+
+    let replyIcon, replyTitle;
+
+    if (status.get('in_reply_to_id', null) === null) {
+      replyIcon = 'reply';
+      replyTitle = intl.formatMessage(messages.reply);
+    } else {
+      replyIcon = 'reply-all';
+      replyTitle = intl.formatMessage(messages.replyAll);
+    }
+
+    let reblogTitle = '';
+
+    if (status.get('reblogged')) {
+      reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
+    } else if (publicStatus) {
+      reblogTitle = intl.formatMessage(messages.reblog);
+    } else if (reblogPrivate) {
+      reblogTitle = intl.formatMessage(messages.reblog_private);
+    } else {
+      reblogTitle = intl.formatMessage(messages.cannot_reblog);
+    }
+
+    let replyButton = null;
+    if (showReplyCount) {
+      replyButton = (
+        <IconButton
+          className='status__action-bar-button'
+          title={replyTitle}
+          icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon}
+          onClick={this.handleReplyClick}
+          counter={status.get('replies_count')}
+          obfuscateCount
+        />
+      );
+    } else {
+      replyButton = (
+        <IconButton
+          className='status__action-bar-button'
+          title={replyTitle}
+          icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon}
+          onClick={this.handleReplyClick}
+        />
+      );
+    }
+
+    return (
+      <div className='picture-in-picture__footer'>
+        {replyButton}
+        <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate}  active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
+        <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js
new file mode 100644
index 000000000..24adcde25
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { Link } from 'react-router-dom';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+
+const mapStateToProps = (state, { accountId }) => ({
+  account: state.getIn(['accounts', accountId]),
+});
+
+export default @connect(mapStateToProps)
+class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    accountId: PropTypes.string.isRequired,
+    statusId: PropTypes.string.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  render () {
+    const { account, statusId, onClose } = this.props;
+
+    return (
+      <div className='picture-in-picture__header'>
+        <Link to={`/statuses/${statusId}`} className='picture-in-picture__header__account'>
+          <Avatar account={account} size={36} />
+          <DisplayName account={account} />
+        </Link>
+
+        <IconButton icon='times' onClick={onClose} title='Close' />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/index.js b/app/javascript/flavours/glitch/features/picture_in_picture/index.js
new file mode 100644
index 000000000..3e6a20faa
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/index.js
@@ -0,0 +1,88 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import Video from 'flavours/glitch/features/video';
+import Audio from 'flavours/glitch/features/audio';
+import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
+import Header from './components/header';
+import Footer from './components/footer';
+import classNames from 'classnames';
+
+const mapStateToProps = state => ({
+  ...state.get('picture_in_picture'),
+  left: state.getIn(['local_settings', 'media', 'pop_in_position']) === 'left',
+});
+
+export default @connect(mapStateToProps)
+class PictureInPicture extends React.Component {
+
+  static propTypes = {
+    statusId: PropTypes.string,
+    accountId: PropTypes.string,
+    type: PropTypes.string,
+    src: PropTypes.string,
+    muted: PropTypes.bool,
+    volume: PropTypes.number,
+    currentTime: PropTypes.number,
+    poster: PropTypes.string,
+    backgroundColor: PropTypes.string,
+    foregroundColor: PropTypes.string,
+    accentColor: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+    left: PropTypes.bool,
+  };
+
+  handleClose = () => {
+    const { dispatch } = this.props;
+    dispatch(removePictureInPicture());
+  }
+
+  render () {
+    const { type, src, currentTime, accountId, statusId, left } = this.props;
+
+    if (!currentTime) {
+      return null;
+    }
+
+    let player;
+
+    if (type === 'video') {
+      player = (
+        <Video
+          src={src}
+          currentTime={this.props.currentTime}
+          volume={this.props.volume}
+          muted={this.props.muted}
+          autoPlay
+          inline
+          alwaysVisible
+        />
+      );
+    } else if (type === 'audio') {
+      player = (
+        <Audio
+          src={src}
+          currentTime={this.props.currentTime}
+          volume={this.props.volume}
+          muted={this.props.muted}
+          poster={this.props.poster}
+          backgroundColor={this.props.backgroundColor}
+          foregroundColor={this.props.foregroundColor}
+          accentColor={this.props.accentColor}
+          autoPlay
+        />
+      );
+    }
+
+    return (
+      <div className={classNames('picture-in-picture', { left })}>
+        <Header accountId={accountId} statusId={statusId} onClose={this.handleClose} />
+
+        {player}
+
+        <Footer statusId={statusId} />
+      </div>
+    );
+  }
+
+}
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 e4aecbf94..04d350bcb 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -18,6 +18,7 @@ import classNames from 'classnames';
 import PollContainer from 'flavours/glitch/containers/poll_container';
 import Icon from 'flavours/glitch/components/icon';
 import AnimatedNumber from 'flavours/glitch/components/animated_number';
+import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
 
 export default class DetailedStatus extends ImmutablePureComponent {
 
@@ -37,6 +38,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
     domain: PropTypes.string.isRequired,
     compact: PropTypes.bool,
     showMedia: PropTypes.bool,
+    usingPiP: PropTypes.bool,
     onToggleMediaVisibility: PropTypes.func,
   };
 
@@ -109,7 +111,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
 
   render () {
     const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
-    const { expanded, onToggleHidden, settings } = this.props;
+    const { expanded, onToggleHidden, settings, usingPiP } = this.props;
     const outerStyle = { boxSizing: 'border-box' };
     const { compact } = this.props;
 
@@ -131,6 +133,9 @@ export default class DetailedStatus extends ImmutablePureComponent {
     if (status.get('poll')) {
       media = <PollContainer pollId={status.get('poll')} />;
       mediaIcon = 'tasks';
+    } else if (usingPiP) {
+      media = <PictureInPicturePlaceholder />;
+      mediaIcon = 'video-camera';
     } else if (status.get('media_attachments').size > 0) {
       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
         media = <AttachmentList media={status.get('media_attachments')} />;
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 3e2e95f35..b330adf3f 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -132,6 +132,7 @@ const makeMapStateToProps = () => {
       settings: state.get('local_settings'),
       askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0,
       domain: state.getIn(['meta', 'domain']),
+      usingPiP: state.get('picture_in_picture').statusId === props.params.statusId,
     };
   };
 
@@ -157,6 +158,7 @@ class Status extends ImmutablePureComponent {
     askReplyConfirmation: PropTypes.bool,
     multiColumn: PropTypes.bool,
     domain: PropTypes.string.isRequired,
+    usingPiP: PropTypes.bool,
   };
 
   state = {
@@ -514,7 +516,7 @@ class Status extends ImmutablePureComponent {
   render () {
     let ancestors, descendants;
     const { setExpansion } = this;
-    const { status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props;
+    const { status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props;
     const { fullscreen, isExpanded } = this.state;
 
     if (status === null) {
@@ -578,6 +580,7 @@ class Status extends ImmutablePureComponent {
                   domain={domain}
                   showMedia={this.state.showMedia}
                   onToggleMediaVisibility={this.handleToggleMediaVisibility}
+                  usingPiP={usingPiP}
                 />
 
                 <ActionBar
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
index b790b29a0..5de3e26d5 100644
--- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -20,6 +20,7 @@ import GIFV from 'flavours/glitch/components/gifv';
 import { me } from 'flavours/glitch/util/initial_state';
 import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
 import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
+import { assetHost } from 'flavours/glitch/util/config';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -50,8 +51,6 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
   .replace(/\n/g, ' ')
   .replace(/\*\*\*\*\*\*/g, '\n\n');
 
-const assetHost = process.env.CDN_HOST || '';
-
 class ImageLoader extends React.PureComponent {
 
   static propTypes = {
diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
index 23e8dac7e..aa6554107 100644
--- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
@@ -140,7 +140,7 @@ class MediaModal extends ImmutablePureComponent {
             src={image.get('url')}
             width={image.get('width')}
             height={image.get('height')}
-            startTime={time || 0}
+            currentTime={time || 0}
             onCloseVideo={onClose}
             detailed
             alt={image.get('description')}
diff --git a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
index 2aab82751..7d25db316 100644
--- a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
@@ -1,25 +1,32 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
-import { injectIntl, FormattedMessage } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import Toggle from 'react-toggle';
 import Button from 'flavours/glitch/components/button';
 import { closeModal } from 'flavours/glitch/actions/modal';
 import { muteAccount } from 'flavours/glitch/actions/accounts';
-import { toggleHideNotifications } from 'flavours/glitch/actions/mutes';
+import { toggleHideNotifications, changeMuteDuration } from 'flavours/glitch/actions/mutes';
 
+const messages = defineMessages({
+  minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
+  hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
+  days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
+  indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' },
+});
 
 const mapStateToProps = state => {
   return {
     account: state.getIn(['mutes', 'new', 'account']),
     notifications: state.getIn(['mutes', 'new', 'notifications']),
+    muteDuration: state.getIn(['mutes', 'new', 'duration']),
   };
 };
 
 const mapDispatchToProps = dispatch => {
   return {
-    onConfirm(account, notifications) {
-      dispatch(muteAccount(account.get('id'), notifications));
+    onConfirm(account, notifications, muteDuration) {
+      dispatch(muteAccount(account.get('id'), notifications, muteDuration));
     },
 
     onClose() {
@@ -29,6 +36,10 @@ const mapDispatchToProps = dispatch => {
     onToggleNotifications() {
       dispatch(toggleHideNotifications());
     },
+
+    onChangeMuteDuration(e) {
+      dispatch(changeMuteDuration(e.target.value));
+    },
   };
 };
 
@@ -43,6 +54,8 @@ class MuteModal extends React.PureComponent {
     onConfirm: PropTypes.func.isRequired,
     onToggleNotifications: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
+    muteDuration: PropTypes.number.isRequired,
+    onChangeMuteDuration: PropTypes.func.isRequired,
   };
 
   componentDidMount() {
@@ -51,7 +64,7 @@ class MuteModal extends React.PureComponent {
 
   handleClick = () => {
     this.props.onClose();
-    this.props.onConfirm(this.props.account, this.props.notifications);
+    this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration);
   }
 
   handleCancel = () => {
@@ -66,8 +79,12 @@ class MuteModal extends React.PureComponent {
     this.props.onToggleNotifications();
   }
 
+  changeMuteDuration = (e) => {
+    this.props.onChangeMuteDuration(e);
+  }
+
   render () {
-    const { account, notifications } = this.props;
+    const { account, notifications, muteDuration, intl } = this.props;
 
     return (
       <div className='modal-root__modal mute-modal'>
@@ -91,6 +108,21 @@ class MuteModal extends React.PureComponent {
               <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
             </label>
           </div>
+          <div>
+            <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span>
+
+            {/* eslint-disable-next-line jsx-a11y/no-onchange */}
+            <select value={muteDuration} onChange={this.changeMuteDuration}>
+              <option value={0}>{intl.formatMessage(messages.indefinite)}</option>
+              <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
+              <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
+              <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
+              <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
+              <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
+              <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
+              <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
+            </select>
+          </div>
         </div>
 
         <div className='mute-modal__action-bar'>
diff --git a/app/javascript/flavours/glitch/features/ui/components/video_modal.js b/app/javascript/flavours/glitch/features/ui/components/video_modal.js
index afeff90a4..c8d2a81b0 100644
--- a/app/javascript/flavours/glitch/features/ui/components/video_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js
@@ -42,9 +42,9 @@ export default class VideoModal extends ImmutablePureComponent {
             preview={media.get('preview_url')}
             blurhash={media.get('blurhash')}
             src={media.get('url')}
-            startTime={options.startTime}
+            currentTime={options.startTime}
             autoPlay={options.autoPlay}
-            defaultVolume={options.defaultVolume}
+            volume={options.defaultVolume}
             onCloseVideo={onClose}
             detailed
             alt={media.get('description')}
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index a399fc2b3..61a34fd2b 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -19,6 +19,7 @@ import PermaLink from 'flavours/glitch/components/permalink';
 import ColumnsAreaContainer from './containers/columns_area_container';
 import classNames from 'classnames';
 import Favico from 'favico.js';
+import PictureInPicture from 'flavours/glitch/features/picture_in_picture';
 import {
   Compose,
   Status,
@@ -359,7 +360,7 @@ class UI extends React.Component {
     const visibility = !document[this.visibilityHiddenProp];
     this.props.dispatch(notificationsSetVisibility(visibility));
     if (visibility) {
-      this.props.dispatch(submitMarkers());
+      this.props.dispatch(submitMarkers({ immediate: true }));
     }
   }
 
@@ -385,7 +386,7 @@ class UI extends React.Component {
 
   componentDidMount () {
     this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
-      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey;
+      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
     };
 
     if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support
@@ -614,6 +615,7 @@ class UI extends React.Component {
             {children}
           </SwitchingColumnsArea>
 
+          <PictureInPicture />
           <NotificationsContainer />
           <LoadingBarContainer className='loading-bar' />
           <ModalContainer />
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
index cc60a0d2e..95bee1331 100644
--- a/app/javascript/flavours/glitch/features/video/index.js
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -103,7 +103,7 @@ class Video extends React.PureComponent {
     width: PropTypes.number,
     height: PropTypes.number,
     sensitive: PropTypes.bool,
-    startTime: PropTypes.number,
+    currentTime: PropTypes.number,
     onOpenVideo: PropTypes.func,
     onCloseVideo: PropTypes.func,
     letterbox: PropTypes.bool,
@@ -111,15 +111,18 @@ class Video extends React.PureComponent {
     detailed: PropTypes.bool,
     inline: PropTypes.bool,
     editable: PropTypes.bool,
+    alwaysVisible: PropTypes.bool,
     cacheWidth: PropTypes.func,
     intl: PropTypes.object.isRequired,
     visible: PropTypes.bool,
     onToggleVisibility: PropTypes.func,
+    deployPictureInPicture: PropTypes.func,
     preventPlayback: PropTypes.bool,
     blurhash: PropTypes.string,
     link: PropTypes.node,
     autoPlay: PropTypes.bool,
-    defaultVolume: PropTypes.number,
+    volume: PropTypes.number,
+    muted: PropTypes.bool,
   };
 
   state = {
@@ -298,16 +301,27 @@ class Video extends React.PureComponent {
     document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
     document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
 
+    window.addEventListener('scroll', this.handleScroll);
     window.addEventListener('resize', this.handleResize, { passive: true });
   }
 
   componentWillUnmount () {
+    window.removeEventListener('scroll', this.handleScroll);
     window.removeEventListener('resize', this.handleResize);
 
     document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
     document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
     document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
     document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+
+    if (!this.state.paused && this.video && this.props.deployPictureInPicture) {
+      this.props.deployPictureInPicture('video', {
+        src: this.props.src,
+        currentTime: this.video.currentTime,
+        muted: this.video.muted,
+        volume: this.video.volume,
+      });
+    }
   }
 
   componentDidUpdate (prevProps) {
@@ -330,6 +344,30 @@ class Video extends React.PureComponent {
     trailing: true,
   });
 
+  handleScroll = throttle(() => {
+    if (!this.video) {
+      return;
+    }
+
+    const { top, height } = this.video.getBoundingClientRect();
+    const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
+
+    if (!this.state.paused && !inView) {
+      this.video.pause();
+
+      if (this.props.deployPictureInPicture) {
+        this.props.deployPictureInPicture('video', {
+          src: this.props.src,
+          currentTime: this.video.currentTime,
+          muted: this.video.muted,
+          volume: this.video.volume,
+        });
+      }
+
+      this.setState({ paused: true });
+    }
+  }, 150, { trailing: true })
+
   handleFullscreenChange = () => {
     this.setState({ fullscreen: isFullscreen() });
   }
@@ -360,15 +398,21 @@ class Video extends React.PureComponent {
   }
 
   handleLoadedData = () => {
-    if (this.props.startTime) {
-      this.video.currentTime = this.props.startTime;
+    const { currentTime, volume, muted, autoPlay } = this.props;
+
+    if (currentTime) {
+      this.video.currentTime = currentTime;
+    }
+
+    if (volume !== undefined) {
+      this.video.volume = volume;
     }
 
-    if (this.props.defaultVolume !== undefined) {
-      this.video.volume = this.props.defaultVolume;
+    if (muted !== undefined) {
+      this.video.muted = muted;
     }
 
-    if (this.props.autoPlay) {
+    if (autoPlay) {
       this.video.play();
     }
   }
@@ -413,9 +457,9 @@ class Video extends React.PureComponent {
   }
 
   render () {
-    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable, blurhash } = this.props;
+    const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable, blurhash } = this.props;
     const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
-    const progress = (currentTime / duration) * 100;
+    const progress = Math.min((currentTime / duration) * 100, 100);
     const playerStyle = {};
 
     const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth });
@@ -440,7 +484,7 @@ class Video extends React.PureComponent {
 
     let preload;
 
-    if (startTime || fullscreen || dragging) {
+    if (this.props.currentTime || fullscreen || dragging) {
       preload = 'auto';
     } else if (detailed) {
       preload = 'metadata';
@@ -532,7 +576,7 @@ class Video extends React.PureComponent {
             </div>
 
             <div className='video-player__buttons right'>
-              {(!onCloseVideo && !editable && !fullscreen) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
+              {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
               {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
               {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
               <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
diff --git a/app/javascript/flavours/glitch/locales/es.js b/app/javascript/flavours/glitch/locales/es.js
index 456df3c47..086873881 100644
--- a/app/javascript/flavours/glitch/locales/es.js
+++ b/app/javascript/flavours/glitch/locales/es.js
@@ -1,7 +1,119 @@
 import inherited from 'mastodon/locales/es.json';
 
 const messages = {
-  //  No translations available.
+  'advanced_options.icon_title': 'Opciones avanzadas',
+  'advanced_options.local-only.long': 'No publicar a otras instancias',
+  'advanced_options.local-only.short': 'Local',
+  'advanced_options.local-only.tooltip': 'Este toot es local',
+  'advanced_options.threaded_mode.long': 'Al publicar abre automáticamente una respuesta',
+  'advanced_options.threaded_mode.short': 'Modo hilo',
+  'advanced_options.threaded_mode.tooltip': 'Modo hilo habilitado',
+  'compose.attach.doodle': 'Dibujar algo',
+  'compose.attach.upload': 'Subir un archivo',
+  'compose.attach': 'Adjuntar...',
+  'favourite_modal.combo': 'Puedes presionar {combo} para omitir esto la próxima vez',
+  'getting_started.onboarding': 'Paseo inicial',
+  'getting_started.open_source_notice': 'Glitchsoc es software libre y gratuito bifurcado de {Mastodon}. Puedes contribuir o reportar errores en GitHub en {github}.',
+  'home.column_settings.show_direct': 'Mostrar mensajes directos',
+  'layout.auto': 'Automático',
+  'layout.current_is': 'Tu diseño actual es:',
+  'layout.desktop': 'Escritorio',
+  'layout.hint.auto': 'Seleccionar un diseño automáticamente basado en "Habilitar interface web avanzada" y tamaño de pantalla',
+  'layout.hint.desktop': 'Utiliza el diseño multi-columna sin importar "Habilitar interface web avanzada" o tamaño de pantalla',
+  'layout.hint.single': 'Utiliza el diseño de una columna sin importar "Habilitar interface web avanzada" o tamaño de pantalla',
+  'layout.mobile': 'Móvil',
+  'media_gallery.sensitive': 'Sensible',
+  'navigation_bar.app_settings': 'Ajustes de aplicación',
+  'notification_purge.btn_all': 'Seleccionar\ntodo',
+  'notification_purge.btn_apply': 'Borrar\nselección',
+  'notification_purge.btn_invert': 'Invertir\nselección',
+  'notification_purge.btn_none': 'Seleccionar\nnada',
+  'notification.markForDeletion': 'Marcar para borrar',
+  'notifications.clear': 'Limpiar notificaciones',
+  'notifications.marked_clear_confirmation': '¿Deseas borrar permanentemente todas las notificaciones seleccionadas?',
+  'notifications.marked_clear': 'Limpiar notificaciones seleccionadas',
+  'onboarding.page_one.federation': '{domain} es una "instancia" de Mastodon. Mastodon es una red de servidores independientes que se unen para crear una red social más grande. A estos servidores los llamamos instancias.',
+  'onboarding.page_one.welcome': '¡Bienvenidx a {domain}!',
+  'onboarding.page_six.github': '{domain} usa Glitchsoc. Glitchsoc es una bifurcación {fork} amigable de {Mastodon}, y es compatible con cualquier instancia o aplicación de Mastodon. Glitchsoc es completamente gratuito y de código abierto. Puedes reportar errores, solicitar funciones o contribuir al código en {github}.',
+  'settings.always_show_spoilers_field': 'Siempre mostrar el campo de advertencia de contenido',
+  'settings.auto_collapse_all': 'Todo',
+  'settings.auto_collapse_lengthy': 'Toots largos',
+  'settings.auto_collapse_media': 'Toots con medios',
+  'settings.auto_collapse_notifications': 'Notificaciones',
+  'settings.auto_collapse_reblogs': 'Retoots',
+  'settings.auto_collapse_replies': 'Respuestas',
+  'settings.auto_collapse': 'Colapsar automáticamente',
+  'settings.close': 'Cerrar',
+  'settings.collapsed_statuses': 'Toots colapsados',
+  'settings.compose_box_opts': 'Cuadro de redacción',
+  'settings.confirm_before_clearing_draft': 'Mostrar diálogo de confirmación antes de sobreescribir un mensaje estabas escribiendo',
+  'settings.confirm_boost_missing_media_description': 'Mostrar diálogo de confirmación antes de retootear publicaciones con medios sin descripción',
+  'settings.confirm_missing_media_description': 'Mostrar diálogo de confirmación antes de publicar toots con medios sin descripción',
+  'settings.content_warnings_filter': 'No descolapsar estas advertencias de contenido:',
+  'settings.content_warnings.regexp': 'Regexp (expresión regular)',
+  'settings.content_warnings': 'Advertencias de contenido',
+  'settings.enable_collapsed': 'Habilitar toots colapsados',
+  'settings.enable_content_warnings_auto_unfold': 'Descolapsar automáticamente advertencias de contenido',
+  'settings.filtering_behavior.cw': 'Mostrar el toot y agregar las palabras filtradas a la advertencia de contenido',
+  'settings.filtering_behavior.drop': 'Ocultar toots filtrados por completo',
+  'settings.filtering_behavior.hide': 'Mostrar "Filtrado" y agregar un botón para saber por qué',
+  'settings.filtering_behavior.upstream': 'Mostrar "Filtrado"',
+  'settings.filtering_behavior': 'Configuración de filtros',
+  'settings.filters': 'Filtros',
+  'settings.general': 'General',
+  'settings.hicolor_privacy_icons': 'Íconos de privacidad más visibles',
+  'settings.image_backgrounds_media': 'Vista previa de medios de toots colapsados',
+  'settings.image_backgrounds_users': 'Darle fondo de imagen a toots colapsados',
+  'settings.image_backgrounds': 'Fondos de imágenes',
+  'settings.inline_preview_cards': 'Vista previa para enlaces externos',
+  'settings.layout_opts': 'Opciones de diseño',
+  'settings.layout': 'Diseño',
+  'settings.media_fullwidth': 'Ancho completo al mostrar medios ',
+  'settings.media_letterbox_hint': 'Escalar medios para que llenen el espacio del contenedor sin cambiar sus proporciones sin recortarlos',
+  'settings.media_letterbox': 'Mantener proporciones al mostrar medios',
+  'settings.media_reveal_behind_cw': 'Siempre mostrar medios sensibles dentro de las advertencias de contenido',
+  'settings.media': 'Medios',
+  'settings.navbar_under': 'Barra de navegación en la parte inferior (solo móvil)',
+  'settings.notifications_opts': 'Opciones de notificaciones',
+  'settings.notifications.favicon_badge.hint': 'Muestra un marcador de notificaciones sin leer en el favicon',
+  'settings.notifications.favicon_badge': 'Marcador de notificaciones en el favicon',
+  'settings.notifications.tab_badge.hint': 'Muestra un marcador de notificaciones sin leer en el ícono de notificaciones cuando dicha columna no está abierta',
+  'settings.notifications.tab_badge': 'Marcador de notificaciones no leídas',
+  'settings.preferences': 'Preferencias de usuarix',
+  'settings.prepend_cw_re': 'Anteponer "re: " a las advertencias de contenido al responder',
+  'settings.preselect_on_reply_hint': 'Al responder a conversaciones con múltiples participantes, preselecciona los nombres de usuarix subsecuentes del/la primerx',
+  'settings.preselect_on_reply': 'Preseleccionar nombres de usuarix al responder',
+  'settings.rewrite_mentions_acct': 'Reescribir con nombre de usuarix y dominio (para cuentas remotas)',
+  'settings.rewrite_mentions_no': 'No reescribir menciones',
+  'settings.rewrite_mentions_username': 'Reescribir con nombre de usuarix',
+  'settings.rewrite_mentions': 'Reescribir menciones in publicaciones mostradas',
+  'settings.show_action_bar': 'Mostrar botones de acción en toots colapsados',
+  'settings.show_content_type_choice': 'Mostrar selección de tipo de contenido al crear toots',
+  'settings.show_reply_counter': 'Mostrar un conteo estimado de respuestas',
+  'settings.side_arm_reply_mode.copy': 'Copiar opción de privacidad del toot al que estás respondiendo',
+  'settings.side_arm_reply_mode.keep': 'Conservar opción de privacidad',
+  'settings.side_arm_reply_mode.restrict': 'Restringir la opción de privacidad a la misma del toot al que estás respondiendo',
+  'settings.side_arm_reply_mode': 'Al responder a un toot:',
+  'settings.side_arm.none': 'Ninguno',
+  'settings.side_arm': 'Botón secundario:',
+  'settings.swipe_to_change_columns': 'Permitir deslizar para cambiar columnas (Sólo en móvil)',
+  'settings.tag_misleading_links.hint': 'Añadir una indicación visual indicando el destino de los enlace que no los mencionen explícitamente',
+  'settings.tag_misleading_links': 'Marcar enlaces engañosos',
+  'settings.wide_view': 'Vista amplia (solo modo de escritorio)',
+  'status.collapse': 'Colapsar',
+  'status.uncollapse': 'Descolapsar',
+  'confirmations.unfilter': 'Información del toot filtrado',
+  'confirmations.unfilter.author': 'Publicado por',
+  'confirmations.unfilter.filters': 'Coincidencia con {count, plural, one {filtro} other {filtros}}',
+  'confirmations.unfilter.edit_filter': 'Editar filtro',
+  'confirmations.unfilter.confirm': 'Mostrar',
+  'confirmations.delete.confirm': 'Borrar',
+  'confirmations.delete.message': 'Por favor confirma borrado',
+  'confirmations.redraft.confirm': 'Borrar y volver a borrador',
+  'confirmations.redraft.message': '¿Borrar y volver a borrador? Perderás todas las respuestas, retoots y favoritos asociados, y las respuestas a la publicación original quedarán huérfanos.',
+  'confirmations.reply.confirm': 'Responder',
+  'confirmations.reply.message': 'Responder no sobreescribirá el mensaje que estás escibiendo actualmente. ¿Deseas continuar?',
+  'status.show_filter_reason': '(mostrar por qué)',
 };
 
 export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/names.yml b/app/javascript/flavours/glitch/names.yml
index 0801c4565..0d56fd3cc 100644
--- a/app/javascript/flavours/glitch/names.yml
+++ b/app/javascript/flavours/glitch/names.yml
@@ -13,3 +13,11 @@ pl:
   skins:
     glitch:
       default: Domyślny
+es:
+  flavours:
+    glitch:
+      description: El diseño predeterminado para las instancias con GlitchSoc.
+      name: Glitchsoc
+  skins:
+    glitch:
+      default: Predeterminado
diff --git a/app/javascript/flavours/glitch/packs/about.js b/app/javascript/flavours/glitch/packs/about.js
index bc0a4887b..2e2cce501 100644
--- a/app/javascript/flavours/glitch/packs/about.js
+++ b/app/javascript/flavours/glitch/packs/about.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 
 function loaded() {
diff --git a/app/javascript/flavours/glitch/packs/common.js b/app/javascript/flavours/glitch/packs/common.js
index 1fedc890a..7dc34eba9 100644
--- a/app/javascript/flavours/glitch/packs/common.js
+++ b/app/javascript/flavours/glitch/packs/common.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import { start } from '@rails/ujs';
 
 start();
diff --git a/app/javascript/flavours/glitch/packs/error.js b/app/javascript/flavours/glitch/packs/error.js
index 81c86c3ab..9f692ad37 100644
--- a/app/javascript/flavours/glitch/packs/error.js
+++ b/app/javascript/flavours/glitch/packs/error.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import ready from 'flavours/glitch/util/ready';
 
 ready(() => {
diff --git a/app/javascript/flavours/glitch/packs/home.js b/app/javascript/flavours/glitch/packs/home.js
index b8f7b7d8e..d06688985 100644
--- a/app/javascript/flavours/glitch/packs/home.js
+++ b/app/javascript/flavours/glitch/packs/home.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 
 loadPolyfills().then(() => {
diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js
index b9b588dc6..c74e5c9af 100644
--- a/app/javascript/flavours/glitch/packs/public.js
+++ b/app/javascript/flavours/glitch/packs/public.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 import ready from 'flavours/glitch/util/ready';
 import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';
diff --git a/app/javascript/flavours/glitch/packs/settings.js b/app/javascript/flavours/glitch/packs/settings.js
index 8a9f23505..9c4d119c1 100644
--- a/app/javascript/flavours/glitch/packs/settings.js
+++ b/app/javascript/flavours/glitch/packs/settings.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 import ready from 'flavours/glitch/util/ready';
 import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';
diff --git a/app/javascript/flavours/glitch/packs/share.js b/app/javascript/flavours/glitch/packs/share.js
index 9f2aa2553..f4a97e201 100644
--- a/app/javascript/flavours/glitch/packs/share.js
+++ b/app/javascript/flavours/glitch/packs/share.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 
 function loaded() {
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index cadbd01a3..b1ddb769e 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -38,6 +38,7 @@ import trends from './trends';
 import announcements from './announcements';
 import markers from './markers';
 import account_notes from './account_notes';
+import picture_in_picture from './picture_in_picture';
 
 const reducers = {
   announcements,
@@ -79,6 +80,7 @@ const reducers = {
   trends,
   markers,
   account_notes,
+  picture_in_picture,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js
index 3d94d665c..ea37ae4aa 100644
--- a/app/javascript/flavours/glitch/reducers/local_settings.js
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -49,10 +49,13 @@ const initialState = ImmutableMap({
     letterbox        : true,
     fullwidth        : true,
     reveal_behind_cw : false,
+    pop_in_player    : true,
+    pop_in_position  : 'right',
   }),
   notifications : ImmutableMap({
     favicon_badge : false,
     tab_badge     : true,
+    show_unread   : true,
   }),
 });
 
diff --git a/app/javascript/flavours/glitch/reducers/mutes.js b/app/javascript/flavours/glitch/reducers/mutes.js
index 7111bb710..d346d9a78 100644
--- a/app/javascript/flavours/glitch/reducers/mutes.js
+++ b/app/javascript/flavours/glitch/reducers/mutes.js
@@ -3,12 +3,14 @@ import Immutable from 'immutable';
 import {
   MUTES_INIT_MODAL,
   MUTES_TOGGLE_HIDE_NOTIFICATIONS,
+  MUTES_CHANGE_DURATION,
 } from 'flavours/glitch/actions/mutes';
 
 const initialState = Immutable.Map({
   new: Immutable.Map({
     account: null,
     notifications: true,
+    duration: 0,
   }),
 });
 
@@ -21,6 +23,8 @@ export default function mutes(state = initialState, action) {
     });
   case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
     return state.updateIn(['new', 'notifications'], (old) => !old);
+  case MUTES_CHANGE_DURATION:
+    return state.setIn(['new', 'duration'], Number(action.duration));
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js
index e136369ae..b4c5ef71a 100644
--- a/app/javascript/flavours/glitch/reducers/notifications.js
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -17,6 +17,8 @@ import {
   NOTIFICATIONS_ENTER_CLEARING_MODE,
   NOTIFICATIONS_MARK_ALL_FOR_DELETE,
   NOTIFICATIONS_MARK_AS_READ,
+  NOTIFICATIONS_SET_BROWSER_SUPPORT,
+  NOTIFICATIONS_SET_BROWSER_PERMISSION,
 } from 'flavours/glitch/actions/notifications';
 import {
   ACCOUNT_BLOCK_SUCCESS,
@@ -44,6 +46,8 @@ const initialState = ImmutableMap({
   isLoading: false,
   cleaningMode: false,
   isTabVisible: true,
+  browserSupport: false,
+  browserPermission: 'default',
   // notification removal mark of new notifs loaded whilst cleaningMode is true.
   markNewForDelete: false,
 });
@@ -185,7 +189,7 @@ const deleteMarkedNotifs = (state) => {
 
 const updateMounted = (state) => {
   state = state.update('mounted', count => count + 1);
-  if (!shouldCountUnreadNotifications(state)) {
+  if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) {
     state = state.set('readMarkerId', state.get('lastReadId'));
     state = clearUnread(state);
   }
@@ -201,7 +205,7 @@ const updateVisibility = (state, visibility) => {
   return state;
 };
 
-const shouldCountUnreadNotifications = (state) => {
+const shouldCountUnreadNotifications = (state, ignoreScroll = false) => {
   const isTabVisible   = state.get('isTabVisible');
   const isOnTop        = state.get('top');
   const isMounted      = state.get('mounted') > 0;
@@ -209,7 +213,7 @@ const shouldCountUnreadNotifications = (state) => {
   const lastItem       = state.get('items').findLast(item => item !== null);
   const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0);
 
-  return !(isTabVisible && isOnTop && isMounted && lastItemReached);
+  return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached);
 };
 
 const recountUnread = (state, last_read_id) => {
@@ -275,6 +279,10 @@ export default function notifications(state = initialState, action) {
     return action.timeline === 'home' ?
       state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) :
       state;
+  case NOTIFICATIONS_SET_BROWSER_SUPPORT:
+    return state.set('browserSupport', action.value);
+  case NOTIFICATIONS_SET_BROWSER_PERMISSION:
+    return state.set('browserPermission', action.value);
 
   case NOTIFICATION_MARK_FOR_DELETE:
     return markForDelete(state, action.id, action.yes);
diff --git a/app/javascript/flavours/glitch/reducers/picture_in_picture.js b/app/javascript/flavours/glitch/reducers/picture_in_picture.js
new file mode 100644
index 000000000..f552a59c2
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/picture_in_picture.js
@@ -0,0 +1,22 @@
+import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'flavours/glitch/actions/picture_in_picture';
+
+const initialState = {
+  statusId: null,
+  accountId: null,
+  type: null,
+  src: null,
+  muted: false,
+  volume: 0,
+  currentTime: 0,
+};
+
+export default function pictureInPicture(state = initialState, action) {
+  switch(action.type) {
+  case PICTURE_IN_PICTURE_DEPLOY:
+    return { statusId: action.statusId, accountId: action.accountId, type: action.playerType, ...action.props };
+  case PICTURE_IN_PICTURE_REMOVE:
+    return { ...initialState };
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/relationships.js b/app/javascript/flavours/glitch/reducers/relationships.js
index 33eb5b425..49dd77ef5 100644
--- a/app/javascript/flavours/glitch/reducers/relationships.js
+++ b/app/javascript/flavours/glitch/reducers/relationships.js
@@ -45,7 +45,7 @@ const initialState = ImmutableMap();
 export default function relationships(state = initialState, action) {
   switch(action.type) {
   case ACCOUNT_FOLLOW_REQUEST:
-    return state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
+    return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
   case ACCOUNT_FOLLOW_FAIL:
     return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
   case ACCOUNT_UNFOLLOW_REQUEST:
diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js
index ef99ad552..bf0545c48 100644
--- a/app/javascript/flavours/glitch/reducers/settings.js
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -33,12 +33,13 @@ const initialState = ImmutableMap({
 
   notifications: ImmutableMap({
     alerts: ImmutableMap({
-      follow: true,
+      follow: false,
       follow_request: false,
-      favourite: true,
-      reblog: true,
-      mention: true,
-      poll: true,
+      favourite: false,
+      reblog: false,
+      mention: false,
+      poll: false,
+      status: false,
     }),
 
     quickFilter: ImmutableMap({
@@ -54,6 +55,7 @@ const initialState = ImmutableMap({
       reblog: true,
       mention: true,
       poll: true,
+      status: true,
     }),
 
     sounds: ImmutableMap({
@@ -63,6 +65,7 @@ const initialState = ImmutableMap({
       reblog: true,
       mention: true,
       poll: true,
+      status: true,
     }),
   }),
 
diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js
index 202f9198f..bfddbd246 100644
--- a/app/javascript/flavours/glitch/reducers/user_lists.js
+++ b/app/javascript/flavours/glitch/reducers/user_lists.js
@@ -53,14 +53,20 @@ import {
 } from 'flavours/glitch/actions/directory';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
+const initialListState = ImmutableMap({
+  next: null,
+  isLoading: false,
+  items: ImmutableList(),
+});
+
 const initialState = ImmutableMap({
-  followers: ImmutableMap(),
-  following: ImmutableMap(),
-  reblogged_by: ImmutableMap(),
-  favourited_by: ImmutableMap(),
-  follow_requests: ImmutableMap(),
-  blocks: ImmutableMap(),
-  mutes: ImmutableMap(),
+  followers: initialListState,
+  following: initialListState,
+  reblogged_by: initialListState,
+  favourited_by: initialListState,
+  follow_requests: initialListState,
+  blocks: initialListState,
+  mutes: initialListState,
 });
 
 const normalizeList = (state, path, accounts, next) => {
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
index 145219fa7..c0bbe5633 100644
--- a/app/javascript/flavours/glitch/styles/components/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -254,127 +254,6 @@
   text-align: center;
 }
 
-.column-settings__outer {
-  background: lighten($ui-base-color, 8%);
-  padding: 15px;
-}
-
-.column-settings__section {
-  color: $darker-text-color;
-  cursor: default;
-  display: block;
-  font-weight: 500;
-  margin-bottom: 10px;
-}
-
-.column-settings__hashtags {
-  .column-settings__row {
-    margin-bottom: 15px;
-  }
-
-  .column-select {
-    &__control {
-      @include search-input();
-
-      &::placeholder {
-        color: lighten($darker-text-color, 4%);
-      }
-
-      &::-moz-focus-inner {
-        border: 0;
-      }
-
-      &::-moz-focus-inner,
-      &:focus,
-      &:active {
-        outline: 0 !important;
-      }
-
-      &:focus {
-        background: lighten($ui-base-color, 4%);
-      }
-
-      @media screen and (max-width: 600px) {
-        font-size: 16px;
-      }
-    }
-
-    &__placeholder {
-      color: $dark-text-color;
-      padding-left: 2px;
-      font-size: 12px;
-    }
-
-    &__value-container {
-      padding-left: 6px;
-    }
-
-    &__multi-value {
-      background: lighten($ui-base-color, 8%);
-
-      &__remove {
-        cursor: pointer;
-
-        &:hover,
-        &:active,
-        &:focus {
-          background: lighten($ui-base-color, 12%);
-          color: lighten($darker-text-color, 4%);
-        }
-      }
-    }
-
-    &__multi-value__label,
-    &__input {
-      color: $darker-text-color;
-    }
-
-    &__clear-indicator,
-    &__dropdown-indicator {
-      cursor: pointer;
-      transition: none;
-      color: $dark-text-color;
-
-      &:hover,
-      &:active,
-      &:focus {
-        color: lighten($dark-text-color, 4%);
-      }
-    }
-
-    &__indicator-separator {
-      background-color: lighten($ui-base-color, 8%);
-    }
-
-    &__menu {
-      @include search-popout();
-      padding: 0;
-      background: $ui-secondary-color;
-    }
-
-    &__menu-list {
-      padding: 6px;
-    }
-
-    &__option {
-      color: $inverted-text-color;
-      border-radius: 4px;
-      font-size: 14px;
-
-      &--is-focused,
-      &--is-selected {
-        background: darken($ui-secondary-color, 10%);
-      }
-    }
-  }
-}
-
-.column-settings__row {
-  .text-btn {
-    margin-bottom: 15px;
-  }
-}
-
 .relationship-tag {
   color: $primary-text-color;
   margin-bottom: 4px;
diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss
index 6b657660a..be32ae52e 100644
--- a/app/javascript/flavours/glitch/styles/components/columns.scss
+++ b/app/javascript/flavours/glitch/styles/components/columns.scss
@@ -463,6 +463,15 @@
   flex: 1;
 }
 
+.column-header__issue-btn {
+  color: $warning-red;
+
+  &:hover {
+    color: $error-red;
+    text-decoration: underline;
+  }
+}
+
 .column-header__icon {
   display: inline-block;
   margin-right: 5px;
@@ -560,3 +569,150 @@
     margin: 0 5px;
   }
 }
+
+.column-settings__outer {
+  background: lighten($ui-base-color, 8%);
+  padding: 15px;
+}
+
+.column-settings__section {
+  color: $darker-text-color;
+  cursor: default;
+  display: block;
+  font-weight: 500;
+  margin-bottom: 10px;
+}
+
+.column-settings__row--with-margin {
+  margin-bottom: 15px;
+}
+
+.column-settings__hashtags {
+  .column-settings__row {
+    margin-bottom: 15px;
+  }
+
+  .column-select {
+    &__control {
+      @include search-input();
+
+      &::placeholder {
+        color: lighten($darker-text-color, 4%);
+      }
+
+      &::-moz-focus-inner {
+        border: 0;
+      }
+
+      &::-moz-focus-inner,
+      &:focus,
+      &:active {
+        outline: 0 !important;
+      }
+
+      &:focus {
+        background: lighten($ui-base-color, 4%);
+      }
+
+      @media screen and (max-width: 600px) {
+        font-size: 16px;
+      }
+    }
+
+    &__placeholder {
+      color: $dark-text-color;
+      padding-left: 2px;
+      font-size: 12px;
+    }
+
+    &__value-container {
+      padding-left: 6px;
+    }
+
+    &__multi-value {
+      background: lighten($ui-base-color, 8%);
+
+      &__remove {
+        cursor: pointer;
+
+        &:hover,
+        &:active,
+        &:focus {
+          background: lighten($ui-base-color, 12%);
+          color: lighten($darker-text-color, 4%);
+        }
+      }
+    }
+
+    &__multi-value__label,
+    &__input {
+      color: $darker-text-color;
+    }
+
+    &__clear-indicator,
+    &__dropdown-indicator {
+      cursor: pointer;
+      transition: none;
+      color: $dark-text-color;
+
+      &:hover,
+      &:active,
+      &:focus {
+        color: lighten($dark-text-color, 4%);
+      }
+    }
+
+    &__indicator-separator {
+      background-color: lighten($ui-base-color, 8%);
+    }
+
+    &__menu {
+      @include search-popout();
+      padding: 0;
+      background: $ui-secondary-color;
+    }
+
+    &__menu-list {
+      padding: 6px;
+    }
+
+    &__option {
+      color: $inverted-text-color;
+      border-radius: 4px;
+      font-size: 14px;
+
+      &--is-focused,
+      &--is-selected {
+        background: darken($ui-secondary-color, 10%);
+      }
+    }
+  }
+}
+
+.column-settings__row {
+  .text-btn {
+    margin-bottom: 15px;
+  }
+}
+
+.notifications-permission-banner {
+  padding: 30px;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+
+  h2 {
+    font-size: 16px;
+    font-weight: 500;
+    margin-bottom: 15px;
+    text-align: center;
+  }
+
+  p {
+    color: $darker-text-color;
+    margin-bottom: 15px;
+    text-align: center;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 04266c497..0614278e2 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -153,6 +153,7 @@
   cursor: pointer;
   transition: all 100ms ease-in;
   transition-property: background-color, color;
+  text-decoration: none;
 
   &:hover,
   &:active,
@@ -226,6 +227,20 @@
       background: rgba($base-overlay-background, 0.9);
     }
   }
+
+  &--with-counter {
+    display: inline-flex;
+    align-items: center;
+    width: auto !important;
+  }
+
+  &__counter {
+    display: inline-block;
+    width: 14px;
+    margin-left: 4px;
+    font-size: 12px;
+    font-weight: 500;
+  }
 }
 
 .text-icon-button {
@@ -708,6 +723,17 @@
     line-height: 14px;
     color: $primary-text-color;
   }
+
+  &__issue-badge {
+    position: absolute;
+    left: 11px;
+    bottom: 1px;
+    display: block;
+    background: $error-red;
+    border-radius: 50%;
+    width: 0.625rem;
+    height: 0.625rem;
+  }
 }
 
 .column-link--transparent .icon-with-badge__badge {
diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
index d0be730ac..85f216887 100644
--- a/app/javascript/flavours/glitch/styles/components/modal.scss
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -785,6 +785,22 @@
       }
     }
   }
+
+  select {
+    appearance: none;
+    box-sizing: border-box;
+    font-size: 14px;
+    color: $inverted-text-color;
+    display: inline-block;
+    width: auto;
+    outline: 0;
+    font-family: inherit;
+    background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px;
+    border: 1px solid darken($simple-background-color, 14%);
+    border-radius: 4px;
+    padding: 6px 10px;
+    padding-right: 30px;
+  }
 }
 
 .confirmation-modal__container,
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index b70ff00f1..03acd72d8 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -572,28 +572,14 @@
   align-items: center;
   display: flex;
   margin-top: 8px;
-
-  &__counter {
-    display: inline-flex;
-    margin-right: 11px;
-    align-items: center;
-
-    .status__action-bar-button {
-      margin-right: 4px;
-    }
-
-    &__label {
-      display: inline-block;
-      width: 14px;
-      font-size: 12px;
-      font-weight: 500;
-      color: $action-button-color;
-    }
-  }
 }
 
 .status__action-bar-button {
   margin-right: 18px;
+
+  &.icon-button--with-counter {
+    margin-right: 14px;
+  }
 }
 
 .status__action-bar-dropdown {
@@ -1081,3 +1067,105 @@ a.status-card.compact:hover {
     }
   }
 }
+
+.picture-in-picture {
+  position: fixed;
+  bottom: 20px;
+  right: 20px;
+  width: 300px;
+
+  &.left {
+    right: unset;
+    left: 20px;
+  }
+
+  &__footer {
+    border-radius: 0 0 4px 4px;
+    background: lighten($ui-base-color, 4%);
+    padding: 10px;
+    padding-top: 12px;
+    display: flex;
+    justify-content: space-between;
+  }
+
+  &__header {
+    border-radius: 4px 4px 0 0;
+    background: lighten($ui-base-color, 4%);
+    padding: 10px;
+    display: flex;
+    justify-content: space-between;
+
+    &__account {
+      display: flex;
+      text-decoration: none;
+    }
+
+    .account__avatar {
+      margin-right: 10px;
+    }
+
+    .display-name {
+      color: $primary-text-color;
+      text-decoration: none;
+
+      strong,
+      span {
+        display: block;
+        text-overflow: ellipsis;
+        overflow: hidden;
+      }
+
+      span {
+        color: $darker-text-color;
+      }
+    }
+  }
+
+  .video-player,
+  .audio-player {
+    border-radius: 0;
+  }
+
+  @media screen and (max-width: 415px) {
+    width: 210px;
+    bottom: 10px;
+    right: 10px;
+
+    &__footer {
+      display: none;
+    }
+
+    .video-player,
+    .audio-player {
+      border-radius: 0 0 4px 4px;
+    }
+  }
+}
+
+.picture-in-picture-placeholder {
+  box-sizing: border-box;
+  border: 2px dashed lighten($ui-base-color, 8%);
+  background: $base-shadow-color;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  margin-top: 10px;
+  font-size: 16px;
+  font-weight: 500;
+  cursor: pointer;
+  color: $darker-text-color;
+
+  i {
+    display: block;
+    font-size: 24px;
+    font-weight: 400;
+    margin-bottom: 10px;
+  }
+
+  &:hover,
+  &:focus,
+  &:active {
+    border-color: lighten($ui-base-color, 12%);
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
index e5a5cc246..163b5220c 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
@@ -385,3 +385,8 @@
 .directory__tag > div {
   box-shadow: none;
 }
+
+.mute-modal select {
+  border: 1px solid lighten($ui-base-color, 8%);
+  background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>") no-repeat right 8px center / auto 16px;
+}
diff --git a/app/javascript/flavours/glitch/util/config.js b/app/javascript/flavours/glitch/util/config.js
new file mode 100644
index 000000000..c3e2b73ae
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/config.js
@@ -0,0 +1,10 @@
+import ready from './ready';
+
+export let assetHost = '';
+
+ready(() => {
+  const cdnHost = document.querySelector('meta[name=cdn-host]');
+  if (cdnHost) {
+    assetHost = cdnHost.content || '';
+  }
+});
diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js
index 233ec25e3..43ff4661c 100644
--- a/app/javascript/flavours/glitch/util/emoji/index.js
+++ b/app/javascript/flavours/glitch/util/emoji/index.js
@@ -1,11 +1,10 @@
 import { autoPlayGif, useSystemEmojiFont } from 'flavours/glitch/util/initial_state';
 import unicodeMapping from './emoji_unicode_mapping_light';
+import { assetHost } from 'flavours/glitch/util/config';
 import Trie from 'substring-trie';
 
 const trie = new Trie(Object.keys(unicodeMapping));
 
-const assetHost = process.env.CDN_HOST || '';
-
 // Convert to file names from emojis. (For different variation selector emojis)
 const emojiFilenames = (emojis) => {
   return emojis.map(v => unicodeMapping[v].filename);
diff --git a/app/javascript/flavours/glitch/util/main.js b/app/javascript/flavours/glitch/util/main.js
index 1fdb9ff2b..6577b70c2 100644
--- a/app/javascript/flavours/glitch/util/main.js
+++ b/app/javascript/flavours/glitch/util/main.js
@@ -1,4 +1,5 @@
 import * as registerPushNotifications from 'flavours/glitch/actions/push_notifications';
+import { setupBrowserNotifications } from 'flavours/glitch/actions/notifications';
 import { default as Mastodon, store } from 'flavours/glitch/containers/mastodon';
 import React from 'react';
 import ReactDOM from 'react-dom';
@@ -22,6 +23,7 @@ function main() {
     const props = JSON.parse(mountNode.getAttribute('data-props'));
 
     ReactDOM.render(<Mastodon {...props} />, mountNode);
+    store.dispatch(setupBrowserNotifications());
     if (process.env.NODE_ENV === 'production') {
       // avoid offline in dev mode because it's harder to debug
       require('offline-plugin/runtime').install();
diff --git a/app/javascript/flavours/glitch/util/notifications.js b/app/javascript/flavours/glitch/util/notifications.js
new file mode 100644
index 000000000..ab119c2e3
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/notifications.js
@@ -0,0 +1,29 @@
+// Handles browser quirks, based on
+// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
+
+const checkNotificationPromise = () => {
+  try {
+    Notification.requestPermission().then();
+  } catch(e) {
+    return false;
+  }
+
+  return true;
+};
+
+const handlePermission = (permission, callback) => {
+  // Whatever the user answers, we make sure Chrome stores the information
+  if(!('permission' in Notification)) {
+    Notification.permission = permission;
+  }
+
+  callback(Notification.permission);
+};
+
+export const requestNotificationPermission = (callback) => {
+  if (checkNotificationPromise()) {
+    Notification.requestPermission().then((permission) => handlePermission(permission, callback));
+  } else {
+    Notification.requestPermission((permission) => handlePermission(permission, callback));
+  }
+};
diff --git a/app/javascript/flavours/vanilla/names.yml b/app/javascript/flavours/vanilla/names.yml
index 1b3dab9c9..8749f2eda 100644
--- a/app/javascript/flavours/vanilla/names.yml
+++ b/app/javascript/flavours/vanilla/names.yml
@@ -14,3 +14,11 @@ pl:
   skins:
     vanilla:
       default: Domyślny
+es:
+  flavours:
+    vanilla:
+      description: El diseño predeterminado en las instancias de Mastodon. Puede que no soporte todas las características de GlitchSoc.
+      name: Mastodon Original
+  skins:
+    vanilla:
+      default: Predeterminado