about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorkibigo! <marrus-sh@users.noreply.github.com>2017-08-01 13:07:43 -0700
committerkibigo! <marrus-sh@users.noreply.github.com>2017-08-01 13:20:29 -0700
commit8150689b48716bb016d492d28cef08600a4b315e (patch)
treea05a2539e894c79ef17698dce0da5a6af0c25bf6 /app
parentb61e3daf983d87c6d2de7e54d420c2e8f5a531e6 (diff)
parent7ef848256871454a790a9b7cc725053c67ba3da4 (diff)
Merge upstream (#111)
Diffstat (limited to 'app')
-rw-r--r--app/controllers/settings/two_factor_authentications_controller.rb7
-rw-r--r--app/helpers/instance_helper.rb2
-rw-r--r--app/javascript/mastodon/components/autosuggest_textarea.js31
-rw-r--r--app/javascript/mastodon/components/column_back_button.js4
-rw-r--r--app/javascript/mastodon/components/column_header.js25
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js57
-rw-r--r--app/javascript/mastodon/components/icon_button.js7
-rw-r--r--app/javascript/mastodon/components/media_gallery.js4
-rw-r--r--app/javascript/mastodon/components/setting_text.js15
-rw-r--r--app/javascript/mastodon/components/status.js30
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js8
-rw-r--r--app/javascript/mastodon/components/status_content.js8
-rw-r--r--app/javascript/mastodon/components/status_list.js40
-rw-r--r--app/javascript/mastodon/containers/dropdown_menu_container.js16
-rw-r--r--app/javascript/mastodon/emoji.js4
-rw-r--r--app/javascript/mastodon/features/account/components/action_bar.js14
-rw-r--r--app/javascript/mastodon/features/account/components/header.js7
-rw-r--r--app/javascript/mastodon/features/compose/components/character_counter.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js12
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js23
-rw-r--r--app/javascript/mastodon/features/compose/components/navigation_bar.js1
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js64
-rw-r--r--app/javascript/mastodon/features/compose/components/search.js21
-rw-r--r--app/javascript/mastodon/features/compose/components/upload_button.js23
-rw-r--r--app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js7
-rw-r--r--app/javascript/mastodon/features/compose/containers/sensitive_button_container.js5
-rw-r--r--app/javascript/mastodon/features/compose/index.js18
-rw-r--r--app/javascript/mastodon/features/compose/util/counter.js7
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js58
-rw-r--r--app/javascript/mastodon/features/notifications/components/setting_toggle.js8
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js17
-rw-r--r--app/javascript/mastodon/features/ui/components/actions_modal.js71
-rw-r--r--app/javascript/mastodon/features/ui/components/column.js12
-rw-r--r--app/javascript/mastodon/features/ui/components/column_header.js5
-rw-r--r--app/javascript/mastodon/features/ui/components/column_link.js9
-rw-r--r--app/javascript/mastodon/features/ui/components/column_loading.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js9
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js14
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js30
-rw-r--r--app/javascript/mastodon/features/ui/components/tabs_bar.js70
-rw-r--r--app/javascript/mastodon/features/ui/index.js16
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js4
-rw-r--r--app/javascript/mastodon/is_mobile.js9
-rw-r--r--app/javascript/mastodon/load_polyfills.js2
-rw-r--r--app/javascript/mastodon/locales/ar.json8
-rw-r--r--app/javascript/mastodon/locales/bg.json8
-rw-r--r--app/javascript/mastodon/locales/ca.json8
-rw-r--r--app/javascript/mastodon/locales/de.json8
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json36
-rw-r--r--app/javascript/mastodon/locales/en.json8
-rw-r--r--app/javascript/mastodon/locales/eo.json8
-rw-r--r--app/javascript/mastodon/locales/es.json8
-rw-r--r--app/javascript/mastodon/locales/fa.json36
-rw-r--r--app/javascript/mastodon/locales/fi.json8
-rw-r--r--app/javascript/mastodon/locales/fr.json10
-rw-r--r--app/javascript/mastodon/locales/he.json8
-rw-r--r--app/javascript/mastodon/locales/hr.json8
-rw-r--r--app/javascript/mastodon/locales/hu.json8
-rw-r--r--app/javascript/mastodon/locales/id.json8
-rw-r--r--app/javascript/mastodon/locales/io.json8
-rw-r--r--app/javascript/mastodon/locales/it.json8
-rw-r--r--app/javascript/mastodon/locales/ja.json14
-rw-r--r--app/javascript/mastodon/locales/ko.json8
-rw-r--r--app/javascript/mastodon/locales/nl.json14
-rw-r--r--app/javascript/mastodon/locales/no.json8
-rw-r--r--app/javascript/mastodon/locales/oc.json44
-rw-r--r--app/javascript/mastodon/locales/pl.json8
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json8
-rw-r--r--app/javascript/mastodon/locales/pt.json8
-rw-r--r--app/javascript/mastodon/locales/ru.json8
-rw-r--r--app/javascript/mastodon/locales/th.json8
-rw-r--r--app/javascript/mastodon/locales/tr.json8
-rw-r--r--app/javascript/mastodon/locales/uk.json8
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json8
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json8
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json8
-rw-r--r--app/javascript/mastodon/reducers/compose.js16
-rw-r--r--app/javascript/mastodon/service_worker/web_push_notifications.js79
-rw-r--r--app/javascript/styles/about.scss56
-rw-r--r--app/javascript/styles/components.scss104
-rw-r--r--app/lib/emoji.rb4
-rw-r--r--app/lib/exceptions.rb10
-rw-r--r--app/lib/language_detector.rb4
-rw-r--r--app/models/account.rb2
-rw-r--r--app/models/web/push_subscription.rb8
-rw-r--r--app/services/account_search_service.rb2
-rw-r--r--app/services/batched_remove_status_service.rb2
-rw-r--r--app/validators/status_length_validator.rb23
-rw-r--r--app/views/about/_contact.html.haml5
-rw-r--r--app/views/about/_registration.html.haml6
-rw-r--r--app/views/admin_mailer/new_report.text.erb2
-rw-r--r--app/views/auth/passwords/edit.html.haml4
-rw-r--r--app/views/auth/registrations/_sessions.html.haml4
-rw-r--r--app/views/auth/registrations/edit.html.haml6
-rw-r--r--app/views/auth/registrations/new.html.haml4
-rw-r--r--app/views/auth/sessions/new.html.haml2
-rw-r--r--app/views/auth/sessions/two_factor.html.haml2
-rw-r--r--app/views/notification_mailer/digest.text.erb2
-rw-r--r--app/views/notification_mailer/favourite.text.erb2
-rw-r--r--app/views/notification_mailer/follow.text.erb2
-rw-r--r--app/views/notification_mailer/follow_request.text.erb2
-rw-r--r--app/views/notification_mailer/mention.text.erb2
-rw-r--r--app/views/notification_mailer/reblog.text.erb2
-rw-r--r--app/views/settings/deletes/show.html.haml2
-rw-r--r--app/views/settings/two_factor_authentication/confirmations/new.html.haml2
-rw-r--r--app/views/settings/two_factor_authentications/show.html.haml2
-rw-r--r--app/views/user_mailer/confirmation_instructions.fa.html.erb2
-rw-r--r--app/views/user_mailer/confirmation_instructions.fa.text.erb2
-rw-r--r--app/workers/pubsubhubbub/delivery_worker.rb2
-rw-r--r--app/workers/pubsubhubbub/distribution_worker.rb4
-rw-r--r--app/workers/pubsubhubbub/subscribe_worker.rb10
-rw-r--r--app/workers/web_push_notification_worker.rb21
112 files changed, 1211 insertions, 353 deletions
diff --git a/app/controllers/settings/two_factor_authentications_controller.rb b/app/controllers/settings/two_factor_authentications_controller.rb
index 983483881..863cc7351 100644
--- a/app/controllers/settings/two_factor_authentications_controller.rb
+++ b/app/controllers/settings/two_factor_authentications_controller.rb
@@ -18,7 +18,7 @@ module Settings
     end
 
     def destroy
-      if current_user.validate_and_consume_otp!(confirmation_params[:code])
+      if acceptable_code?
         current_user.otp_required_for_login = false
         current_user.save!
         redirect_to settings_two_factor_authentication_path
@@ -38,5 +38,10 @@ module Settings
     def verify_otp_required
       redirect_to settings_two_factor_authentication_path if current_user.otp_required_for_login?
     end
+
+    def acceptable_code?
+      current_user.validate_and_consume_otp!(confirmation_params[:code]) ||
+        current_user.invalidate_otp_backup_code!(confirmation_params[:code])
+    end
   end
 end
diff --git a/app/helpers/instance_helper.rb b/app/helpers/instance_helper.rb
index a1c3c3521..70027cca9 100644
--- a/app/helpers/instance_helper.rb
+++ b/app/helpers/instance_helper.rb
@@ -2,7 +2,7 @@
 
 module InstanceHelper
   def site_title
-    Setting.site_title.to_s
+    Setting.site_title.presence || site_hostname
   end
 
   def site_hostname
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js
index fa41e59e1..35b37600f 100644
--- a/app/javascript/mastodon/components/autosuggest_textarea.js
+++ b/app/javascript/mastodon/components/autosuggest_textarea.js
@@ -162,20 +162,23 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
 
     return (
       <div className='autosuggest-textarea'>
-        <Textarea
-          inputRef={this.setTextarea}
-          className='autosuggest-textarea__textarea'
-          disabled={disabled}
-          placeholder={placeholder}
-          autoFocus={autoFocus}
-          value={value}
-          onChange={this.onChange}
-          onKeyDown={this.onKeyDown}
-          onKeyUp={onKeyUp}
-          onBlur={this.onBlur}
-          onPaste={this.onPaste}
-          style={style}
-        />
+        <label>
+          <span style={{ display: 'none' }}>{placeholder}</span>
+          <Textarea
+            inputRef={this.setTextarea}
+            className='autosuggest-textarea__textarea'
+            disabled={disabled}
+            placeholder={placeholder}
+            autoFocus={autoFocus}
+            value={value}
+            onChange={this.onChange}
+            onKeyDown={this.onKeyDown}
+            onKeyUp={onKeyUp}
+            onBlur={this.onBlur}
+            onPaste={this.onPaste}
+            style={style}
+          />
+        </label>
 
         <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
           {suggestions.map((suggestion, i) => (
diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js
index 589215ce8..50c3bf11f 100644
--- a/app/javascript/mastodon/components/column_back_button.js
+++ b/app/javascript/mastodon/components/column_back_button.js
@@ -19,10 +19,10 @@ export default class ColumnBackButton extends React.PureComponent {
 
   render () {
     return (
-      <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button'>
+      <button onClick={this.handleClick} className='column-back-button'>
         <i className='fa fa-fw fa-chevron-left column-back-button__icon' />
         <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
-      </div>
+      </button>
     );
   }
 
diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js
index 9945fc209..e0042b055 100644
--- a/app/javascript/mastodon/components/column_header.js
+++ b/app/javascript/mastodon/components/column_header.js
@@ -8,6 +8,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import NotificationPurgeButtonsContainer from '../../glitch/components/column/notif_cleaning_widget/container';
 
 const messages = defineMessages({
+  show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
+  hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
+  moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
+  moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
   enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' },
 });
 
@@ -19,11 +23,13 @@ export default class ColumnHeader extends React.PureComponent {
   };
 
   static propTypes = {
+    intl: PropTypes.object.isRequired,
     title: PropTypes.node.isRequired,
     icon: PropTypes.string.isRequired,
     active: PropTypes.bool,
     localSettings : ImmutablePropTypes.map,
     multiColumn: PropTypes.bool,
+    focusable: PropTypes.bool,
     showBackButton: PropTypes.bool,
     notifCleaning: PropTypes.bool, // true only for the notification column
     notifCleaningActive: PropTypes.bool,
@@ -36,6 +42,10 @@ export default class ColumnHeader extends React.PureComponent {
     intl: PropTypes.object.isRequired,
   };
 
+  static defaultProps = {
+    focusable: true,
+  }
+
   state = {
     collapsed: true,
     animating: false,
@@ -82,10 +92,9 @@ export default class ColumnHeader extends React.PureComponent {
   }
 
   render () {
-    const { intl, icon, active, children, pinned, onPin, multiColumn, showBackButton, notifCleaning, notifCleaningActive } = this.props;
+    const { intl, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage }, notifCleaning, notifCleaningActive } = this.props;
     const { collapsed, animating, animatingNCD } = this.state;
 
-
     let title = this.props.title;
 
     const wrapperClassName = classNames('column-header__wrapper', {
@@ -132,8 +141,8 @@ export default class ColumnHeader extends React.PureComponent {
 
       moveButtons = (
         <div key='move-buttons' className='column-header__setting-arrows'>
-          <button className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><i className='fa fa-chevron-left' /></button>
-          <button className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><i className='fa fa-chevron-right' /></button>
+          <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><i className='fa fa-chevron-left' /></button>
+          <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><i className='fa fa-chevron-right' /></button>
         </div>
       );
     } else if (multiColumn) {
@@ -159,12 +168,12 @@ export default class ColumnHeader extends React.PureComponent {
     }
 
     if (children || multiColumn) {
-      collapseButton = <button className={collapsibleButtonClassName} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>;
+      collapseButton = <button className={collapsibleButtonClassName} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>;
     }
 
     return (
       <div className={wrapperClassName}>
-        <div role='button heading' tabIndex='0' className={buttonClassName} onClick={this.handleTitleClick}>
+        <h1 tabIndex={focusable && '0'} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}>
           <i className={`fa fa-fw fa-${icon} column-header__icon`} />
           {title}
           <div className='column-header__buttons'>
@@ -181,7 +190,7 @@ export default class ColumnHeader extends React.PureComponent {
             ) : null}
             {collapseButton}
           </div>
-        </div>
+        </h1>
 
         { notifCleaning ? (
           <div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}>
@@ -191,7 +200,7 @@ export default class ColumnHeader extends React.PureComponent {
           </div>
         ) : null}
 
-        <div className={collapsibleClassName} onTransitionEnd={this.handleTransitionEnd}>
+        <div className={collapsibleClassName} tabIndex={collapsed && -1} onTransitionEnd={this.handleTransitionEnd}>
           <div className='column-header__collapsible-inner'>
             {(!collapsed || animating) && collapsedContent}
           </div>
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index 98323b069..28631f463 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -1,4 +1,5 @@
 import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
 import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
 import PropTypes from 'prop-types';
 
@@ -9,16 +10,23 @@ export default class DropdownMenu extends React.PureComponent {
   };
 
   static propTypes = {
+    isUserTouching: PropTypes.func,
+    isModalOpen: PropTypes.bool.isRequired,
+    onModalOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
     icon: PropTypes.string.isRequired,
     items: PropTypes.array.isRequired,
     size: PropTypes.number.isRequired,
     direction: PropTypes.string,
+    status: ImmutablePropTypes.map,
     ariaLabel: PropTypes.string,
     disabled: PropTypes.bool,
   };
 
   static defaultProps = {
     ariaLabel: 'Menu',
+    isModalOpen: false,
+    isUserTouching: () => false,
   };
 
   state = {
@@ -34,6 +42,10 @@ export default class DropdownMenu extends React.PureComponent {
     const i = Number(e.currentTarget.getAttribute('data-index'));
     const { action, to } = this.props.items[i];
 
+    if (this.props.isModalOpen) {
+      this.props.onModalClose();
+    }
+
     // Don't call e.preventDefault() when the item uses 'href' property.
     // ex. "Edit profile" on the account action bar
 
@@ -48,10 +60,32 @@ export default class DropdownMenu extends React.PureComponent {
     this.dropdown.hide();
   }
 
-  handleShow = () => this.setState({ expanded: true })
+  handleShow = () => {
+    if (this.props.isUserTouching()) {
+      this.props.onModalOpen({
+        status: this.props.status,
+        actions: this.props.items,
+        onClick: this.handleClick,
+      });
+    } else {
+      this.setState({ expanded: true });
+    }
+  }
 
   handleHide = () => this.setState({ expanded: false })
 
+  handleToggle = (e) => {
+    if (e.key === 'Enter') {
+      if (this.props.isUserTouching()) {
+        this.handleShow();
+      } else {
+        this.setState({ expanded: !this.state.expanded });
+      }
+    } else if (e.key === 'Escape') {
+      this.setState({ expanded: false });
+    }
+  }
+
   renderItem = (item, i) => {
     if (item === null) {
       return <li key={`sep-${i}`} className='dropdown__sep' />;
@@ -61,7 +95,7 @@ export default class DropdownMenu extends React.PureComponent {
 
     return (
       <li className='dropdown__content-list-item' key={`${text}-${i}`}>
-        <a href={href} target='_blank' rel='noopener' onClick={this.handleClick} data-index={i} className='dropdown__content-list-link'>
+        <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i} className='dropdown__content-list-link'>
           {text}
         </a>
       </li>
@@ -71,6 +105,7 @@ export default class DropdownMenu extends React.PureComponent {
   render () {
     const { icon, items, size, direction, ariaLabel, disabled } = this.props;
     const { expanded }   = this.state;
+    const isUserTouching = this.props.isUserTouching();
     const directionClass = (direction === 'left') ? 'dropdown__left' : 'dropdown__right';
     const iconStyle      = { fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` };
     const iconClassname  = `fa fa-fw fa-${icon} dropdown__icon`;
@@ -84,20 +119,26 @@ export default class DropdownMenu extends React.PureComponent {
     }
 
     const dropdownItems = expanded && (
-      <ul className='dropdown__content-list'>
+      <ul role='group' className='dropdown__content-list' onClick={this.handleHide}>
         {items.map(this.renderItem)}
       </ul>
     );
 
+    // No need to render the actual dropdown if we use the modal. If we
+    // don't render anything <Dropdow /> breaks, so we just put an empty div.
+    const dropdownContent = !isUserTouching ? (
+      <DropdownContent className={directionClass} >
+        {dropdownItems}
+      </DropdownContent>
+    ) : <div />;
+
     return (
-      <Dropdown ref={this.setRef} onShow={this.handleShow} onHide={this.handleHide}>
-        <DropdownTrigger className='icon-button' style={iconStyle} aria-label={ariaLabel}>
+      <Dropdown ref={this.setRef} active={isUserTouching ? false : expanded} onShow={this.handleShow} onHide={this.handleHide}>
+        <DropdownTrigger className='icon-button' style={iconStyle} role='button' aria-expanded={expanded} onKeyDown={this.handleToggle} tabIndex='0' aria-label={ariaLabel}>
           <i className={iconClassname} aria-hidden />
         </DropdownTrigger>
 
-        <DropdownContent className={directionClass}>
-          {dropdownItems}
-        </DropdownContent>
+        {dropdownContent}
       </Dropdown>
     );
   }
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
index 748283853..8c5b5e0b9 100644
--- a/app/javascript/mastodon/components/icon_button.js
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -12,6 +12,8 @@ export default class IconButton extends React.PureComponent {
     onClick: PropTypes.func,
     size: PropTypes.number,
     active: PropTypes.bool,
+    pressed: PropTypes.bool,
+    expanded: PropTypes.bool,
     style: PropTypes.object,
     activeStyle: PropTypes.object,
     disabled: PropTypes.bool,
@@ -19,6 +21,7 @@ export default class IconButton extends React.PureComponent {
     animate: PropTypes.bool,
     flip: PropTypes.bool,
     overlay: PropTypes.bool,
+    tabIndex: PropTypes.string,
   };
 
   static defaultProps = {
@@ -27,6 +30,7 @@ export default class IconButton extends React.PureComponent {
     disabled: false,
     animate: false,
     overlay: false,
+    tabIndex: '0',
   };
 
   handleClick = (e) =>  {
@@ -74,10 +78,13 @@ export default class IconButton extends React.PureComponent {
         {({ rotate }) =>
           <button
             aria-label={this.props.title}
+            aria-pressed={this.props.pressed}
+            aria-expanded={this.props.expanded}
             title={this.props.title}
             className={classes.join(' ')}
             onClick={this.handleClick}
             style={style}
+            tabIndex={this.props.tabIndex}
           >
             <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
           </button>
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index d95e7c75d..fa6ea72d5 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -215,10 +215,10 @@ export default class MediaGallery extends React.PureComponent {
       }
 
       children = (
-        <div role='button' tabIndex='0' className='media-spoiler' onClick={this.handleOpen}>
+        <button className='media-spoiler' onClick={this.handleOpen}>
           <span className='media-spoiler__warning'>{warning}</span>
           <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-        </div>
+        </button>
       );
     } else {
       const size = media.take(4).size;
diff --git a/app/javascript/mastodon/components/setting_text.js b/app/javascript/mastodon/components/setting_text.js
index dd975bc99..a6dde4c0f 100644
--- a/app/javascript/mastodon/components/setting_text.js
+++ b/app/javascript/mastodon/components/setting_text.js
@@ -19,12 +19,15 @@ export default class SettingText extends React.PureComponent {
     const { settings, settingKey, label } = this.props;
 
     return (
-      <input
-        className='setting-text'
-        value={settings.getIn(settingKey)}
-        onChange={this.handleChange}
-        placeholder={label}
-      />
+      <label>
+        <span style={{ display: 'none' }}>{label}</span>
+        <input
+          className='setting-text'
+          value={settings.getIn(settingKey)}
+          onChange={this.handleChange}
+          placeholder={label}
+        />
+      </label>
     );
   }
 
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 6605457f7..ac82e536f 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -44,6 +44,8 @@ export default class Status extends ImmutablePureComponent {
     autoPlayGif: PropTypes.bool,
     muted: PropTypes.bool,
     intersectionObserverWrapper: PropTypes.object,
+    index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+    listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
   };
 
   state = {
@@ -62,6 +64,7 @@ export default class Status extends ImmutablePureComponent {
     'boostModal',
     'autoPlayGif',
     'muted',
+    'listLength',
   ]
 
   updateOnStates = ['isExpanded']
@@ -70,8 +73,8 @@ export default class Status extends ImmutablePureComponent {
     if (!nextState.isIntersecting && nextState.isHidden) {
       // It's only if we're not intersecting (i.e. offscreen) and isHidden is true
       // that either "isIntersecting" or "isHidden" matter, and then they're
-      // the only things that matter.
-      return this.state.isIntersecting || !this.state.isHidden;
+      // the only things that matter (and updated ARIA attributes).
+      return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength;
     } else if (nextState.isIntersecting && !this.state.isIntersecting) {
       // If we're going from a non-intersecting state to an intersecting state,
       // (i.e. offscreen to onscreen), then we definitely need to re-render
@@ -110,17 +113,12 @@ export default class Status extends ImmutablePureComponent {
       this.height = getRectFromEntry(entry).height;
     }
 
-    // Edge 15 doesn't support isIntersecting, but we can infer it
-    // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12156111/
-    // https://github.com/WICG/IntersectionObserver/issues/211
-    const isIntersecting = (typeof entry.isIntersecting === 'boolean') ?
-      entry.isIntersecting : entry.intersectionRect.height > 0;
     this.setState((prevState) => {
-      if (prevState.isIntersecting && !isIntersecting) {
+      if (prevState.isIntersecting && !entry.isIntersecting) {
         scheduleIdleTask(this.hideIfNotIntersecting);
       }
       return {
-        isIntersecting: isIntersecting,
+        isIntersecting: entry.isIntersecting,
         isHidden: false,
       };
     });
@@ -177,7 +175,7 @@ export default class Status extends ImmutablePureComponent {
 
     // Exclude intersectionObserverWrapper from `other` variable
     // because intersection is managed in here.
-    const { status, account, intersectionObserverWrapper, ...other } = this.props;
+    const { status, account, intersectionObserverWrapper, index, listLength, wrapped, ...other } = this.props;
     const { isExpanded, isIntersecting, isHidden } = this.state;
 
     if (status === null) {
@@ -186,10 +184,10 @@ export default class Status extends ImmutablePureComponent {
 
     if (!isIntersecting && isHidden) {
       return (
-        <div ref={this.handleRef} data-id={status.get('id')} style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}>
+        <article ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0' style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}>
           {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
           {status.get('content')}
-        </div>
+        </article>
       );
     }
 
@@ -203,14 +201,14 @@ export default class Status extends ImmutablePureComponent {
       const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
 
       return (
-        <div className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} >
+        <article className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0'>
           <div className='status__prepend'>
             <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
             <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} />
           </div>
 
           <Status {...other} wrapped status={status.get('reblog')} account={status.get('account')} />
-        </div>
+        </article>
       );
     }
 
@@ -239,7 +237,7 @@ export default class Status extends ImmutablePureComponent {
     }
 
     return (
-      <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} ref={this.handleRef}>
+      <article aria-posinset={index} aria-setsize={listLength} className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} tabIndex={wrapped ? null : '0'}  ref={this.handleRef}>
         <div className='status__info'>
           <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
 
@@ -257,7 +255,7 @@ export default class Status extends ImmutablePureComponent {
         {media}
 
         <StatusActionBar {...this.props} />
-      </div>
+      </article>
     );
   }
 
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 3e947b4c5..81c2a4e23 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -5,7 +5,7 @@ import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import IconButton from './icon_button';
-import DropdownMenu from './dropdown_menu';
+import DropdownMenuContainer from '../containers/dropdown_menu_container';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
@@ -154,12 +154,12 @@ export default class StatusActionBar extends ImmutablePureComponent {
     return (
       <div className='status__action-bar'>
         <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
-        <IconButton className='status__action-bar-button' disabled={anonymousAccess || reblogDisabled} active={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
-        <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
+        <IconButton className='status__action-bar-button' disabled={anonymousAccess || reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
+        <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
         {shareButton}
 
         <div className='status__action-bar-dropdown'>
-          <DropdownMenu disabled={anonymousAccess} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
+          <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index ad925edef..5f02e3261 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -149,7 +149,7 @@ export default class StatusContent extends React.PureComponent {
       }
 
       return (
-        <div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
+        <div className={classNames} ref={this.setRef} tabIndex='0' aria-label={status.get('search_index')} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
           <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
             <span dangerouslySetInnerHTML={spoilerContent} />
             {' '}
@@ -158,13 +158,15 @@ export default class StatusContent extends React.PureComponent {
 
           {mentionsPlaceholder}
 
-          <div className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} />
+          <div tabIndex={!hidden && 0} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} />
         </div>
       );
     } else if (this.props.onClick) {
       return (
         <div
           ref={this.setRef}
+          tabIndex='0'
+          aria-label={status.get('search_index')}
           className={classNames}
           style={directionStyle}
           onMouseDown={this.handleMouseDown}
@@ -175,6 +177,8 @@ export default class StatusContent extends React.PureComponent {
     } else {
       return (
         <div
+          tabIndex='0'
+          aria-label={status.get('search_index')}
           ref={this.setRef}
           className='status__content'
           style={directionStyle}
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 3dd207dbc..639c8b4e7 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -6,7 +6,7 @@ import StatusContainer from '../../glitch/components/status/container';
 import LoadMore from './load_more';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
-import { debounce } from 'lodash';
+import { throttle } from 'lodash';
 
 export default class StatusList extends ImmutablePureComponent {
 
@@ -30,13 +30,13 @@ export default class StatusList extends ImmutablePureComponent {
 
   intersectionObserverWrapper = new IntersectionObserverWrapper();
 
-  handleScroll = debounce(() => {
+  handleScroll = throttle(() => {
     if (this.node) {
       const { scrollTop, scrollHeight, clientHeight } = this.node;
       const offset = scrollHeight - scrollTop - clientHeight;
       this._oldScrollPosition = scrollHeight - scrollTop;
 
-      if (250 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
+      if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
         this.props.onScrollToBottom();
       } else if (scrollTop < 100 && this.props.onScrollToTop) {
         this.props.onScrollToTop();
@@ -44,7 +44,7 @@ export default class StatusList extends ImmutablePureComponent {
         this.props.onScroll();
       }
     }
-  }, 200, {
+  }, 150, {
     trailing: true,
   });
 
@@ -104,6 +104,32 @@ export default class StatusList extends ImmutablePureComponent {
     this.props.onScrollToBottom();
   }
 
+  handleKeyDown = (e) => {
+    if (['PageDown', 'PageUp', 'End', 'Home'].includes(e.key)) {
+      const article = (() => {
+        switch (e.key) {
+        case 'PageDown':
+          return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
+        case 'PageUp':
+          return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
+        case 'End':
+          return this.node.querySelector('[role="feed"] > article:last-of-type');
+        case 'Home':
+          return this.node.querySelector('[role="feed"] > article:first-of-type');
+        default:
+          return null;
+        }
+      })();
+
+
+      if (article) {
+        e.preventDefault();
+        article.focus();
+        article.scrollIntoView();
+      }
+    }
+  }
+
   render () {
     const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
 
@@ -113,11 +139,11 @@ export default class StatusList extends ImmutablePureComponent {
     if (isLoading || statusIds.size > 0 || !emptyMessage) {
       scrollableArea = (
         <div className='scrollable' ref={this.setRef}>
-          <div className='status-list'>
+          <div role='feed' className='status-list' onKeyDown={this.handleKeyDown}>
             {prepend}
 
-            {statusIds.map((statusId) => {
-              return <StatusContainer key={statusId} id={statusId} intersectionObserverWrapper={this.intersectionObserverWrapper} />;
+            {statusIds.map((statusId, index) => {
+              return <StatusContainer key={statusId} id={statusId} index={index} listLength={statusIds.size} intersectionObserverWrapper={this.intersectionObserverWrapper} />;
             })}
 
             {loadMore}
diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js
new file mode 100644
index 000000000..151f25390
--- /dev/null
+++ b/app/javascript/mastodon/containers/dropdown_menu_container.js
@@ -0,0 +1,16 @@
+import { openModal, closeModal } from '../actions/modal';
+import { connect } from 'react-redux';
+import DropdownMenu from '../components/dropdown_menu';
+import { isUserTouching } from '../is_mobile';
+
+const mapStateToProps = state => ({
+  isModalOpen: state.get('modal').modalType === 'ACTIONS',
+});
+
+const mapDispatchToProps = dispatch => ({
+  isUserTouching,
+  onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+  onModalClose: () => dispatch(closeModal()),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js
index 9b58cacf5..5695c86dd 100644
--- a/app/javascript/mastodon/emoji.js
+++ b/app/javascript/mastodon/emoji.js
@@ -3,6 +3,8 @@ import Trie from 'substring-trie';
 
 const trie = new Trie(Object.keys(unicodeMapping));
 
+const excluded = ['™', '©', '®'];
+
 function emojify(str) {
   // This walks through the string from start to end, ignoring any tags (<p>, <br>, etc.)
   // and replacing valid unicode strings
@@ -19,7 +21,7 @@ function emojify(str) {
       insideTag = true;
     } else if (!insideTag && (match = trie.search(str.substring(i)))) {
       const unicodeStr = match;
-      if (unicodeStr in unicodeMapping) {
+      if (unicodeStr in unicodeMapping && excluded.indexOf(unicodeStr) === -1) {
         const [filename, shortCode] = unicodeMapping[unicodeStr];
         const alt      = unicodeStr;
         const replacement =  `<img draggable="false" class="emojione" alt="${alt}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`;
diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js
index b8df724c6..c12c0889e 100644
--- a/app/javascript/mastodon/features/account/components/action_bar.js
+++ b/app/javascript/mastodon/features/account/components/action_bar.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import DropdownMenu from '../../../components/dropdown_menu';
+import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
 import Link from 'react-router-dom/Link';
 import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
 
@@ -15,6 +15,7 @@ const messages = defineMessages({
   mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
   report: { id: 'account.report', defaultMessage: 'Report @{name}' },
+  share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
   media: { id: 'account.media', defaultMessage: 'Media' },
   blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
   unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
@@ -36,6 +37,12 @@ export default class ActionBar extends React.PureComponent {
     intl: PropTypes.object.isRequired,
   };
 
+  handleShare = () => {
+    navigator.share({
+      url: this.props.account.get('url'),
+    });
+  }
+
   render () {
     const { account, me, intl } = this.props;
 
@@ -43,6 +50,9 @@ export default class ActionBar extends React.PureComponent {
     let extraInfo = '';
 
     menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+    if ('share' in navigator) {
+      menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
+    }
     menu.push(null);
     menu.push({ text: intl.formatMessage(messages.media), to: `/accounts/${account.get('id')}/media` });
     menu.push(null);
@@ -96,7 +106,7 @@ export default class ActionBar extends React.PureComponent {
 
         <div className='account__action-bar'>
           <div className='account__action-bar-dropdown'>
-            <DropdownMenu items={menu} icon='bars' size={24} direction='right' />
+            <DropdownMenuContainer items={menu} icon='bars' size={24} direction='right' />
           </div>
 
           <div className='account__action-bar-links'>
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 1133e8a4e..9d7bc82c0 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -55,9 +55,10 @@ class Avatar extends ImmutablePureComponent {
     return (
       <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
         {({ radius }) =>
-          <a // eslint-disable-line jsx-a11y/anchor-has-content
+          <a
             href={account.get('url')}
             className='account__header__avatar'
+            role='presentation'
             target='_blank'
             rel='noopener'
             style={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }}
@@ -65,7 +66,9 @@ class Avatar extends ImmutablePureComponent {
             onMouseOut={this.handleMouseOut}
             onFocus={this.handleMouseOver}
             onBlur={this.handleMouseOut}
-          />
+          >
+            <span style={{ display: 'none' }}>{account.get('acct')}</span>
+          </a>
         }
       </Motion>
     );
diff --git a/app/javascript/mastodon/features/compose/components/character_counter.js b/app/javascript/mastodon/features/compose/components/character_counter.js
index 6c488b661..0ecfc9141 100644
--- a/app/javascript/mastodon/features/compose/components/character_counter.js
+++ b/app/javascript/mastodon/features/compose/components/character_counter.js
@@ -13,12 +13,12 @@ export default class CharacterCounter extends React.PureComponent {
     if (diff < 0) {
       return <span className='character-counter character-counter--over'>{diff}</span>;
     }
+
     return <span className='character-counter'>{diff}</span>;
   }
 
   render () {
     const diff = this.props.max - length(this.props.text);
-
     return this.checkRemainingText(diff);
   }
 
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 67906594f..0027783b4 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -19,6 +19,7 @@ import WarningContainer from '../containers/warning_container';
 import { isMobile } from '../../../is_mobile';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { length } from 'stringz';
+import { countableText } from '../util/counter';
 
 const messages = defineMessages({
   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@@ -150,9 +151,9 @@ export default class ComposeForm extends ImmutablePureComponent {
     const { intl, onPaste, showSearch } = this.props;
     const disabled = this.props.is_submitting;
     const maybeEye = this.props.advanced_options.get('do_not_federate') ? ' 👁️' : '';
-    const text = [this.props.spoiler_text, this.props.text, maybeEye].join('');
+    const text     = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join('');
 
-    let publishText    = '';
+    let publishText = '';
 
     if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
       publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
@@ -164,7 +165,10 @@ export default class ComposeForm extends ImmutablePureComponent {
       <div className='compose-form'>
         <Collapsable isVisible={this.props.spoiler} fullHeight={50}>
           <div className='spoiler-input'>
-            <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input'  id='cw-spoiler-input' />
+            <label>
+              <span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
+              <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input'  id='cw-spoiler-input' />
+            </label>
           </div>
         </Collapsable>
 
@@ -206,7 +210,7 @@ export default class ComposeForm extends ImmutablePureComponent {
 
           <div className='compose-form__publish'>
             <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div>
-            <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || this.props.is_uploading || length(text) > 500 || (text.length !==0 && text.trim().length === 0)} block /></div>
+            <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0)} block /></div>
           </div>
         </div>
       </div>
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
index acc584f20..9d05b7a34 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -65,6 +65,22 @@ export default class EmojiPickerDropdown extends React.PureComponent {
     this.setState({ active: false });
   }
 
+  onToggle = (e) => {
+    if (!this.state.loading && (!e.key || e.key === 'Enter')) {
+      if (this.state.active) {
+        this.onHideDropdown();
+      } else {
+        this.onShowDropdown();
+      }
+    }
+  }
+
+  onEmojiPickerKeyDown = (e) => {
+    if (e.key === 'Escape') {
+      this.onHideDropdown();
+    }
+  }
+
   render () {
     const { intl } = this.props;
 
@@ -104,10 +120,11 @@ export default class EmojiPickerDropdown extends React.PureComponent {
     };
 
     const { active, loading } = this.state;
+    const title = intl.formatMessage(messages.emoji);
 
     return (
-      <Dropdown ref={this.setRef} className='emoji-picker__dropdown' onShow={this.onShowDropdown} onHide={this.onHideDropdown}>
-        <DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)}>
+      <Dropdown ref={this.setRef} className='emoji-picker__dropdown' active={active && !loading} onShow={this.onShowDropdown} onHide={this.onHideDropdown}>
+        <DropdownTrigger className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onKeyDown={this.onToggle} tabIndex={0} >
           <img
             className={`emojione ${active && loading ? 'pulse-loading' : ''}`}
             alt='🙂'
@@ -118,7 +135,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
         <DropdownContent className='dropdown__left'>
           {
             this.state.active && !this.state.loading &&
-            (<EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} categories={categories} search />)
+            (<EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} onKeyDown={this.onEmojiPickerKeyDown} categories={categories} search />)
           }
         </DropdownContent>
       </Dropdown>
diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js
index b0bc0958e..5000ea2f1 100644
--- a/app/javascript/mastodon/features/compose/components/navigation_bar.js
+++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js
@@ -18,6 +18,7 @@ export default class NavigationBar extends ImmutablePureComponent {
     return (
       <div className='navigation-bar'>
         <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+          <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
           <Avatar src={this.props.account.get('avatar')} staticSrc={this.props.account.get('avatar_static')} size={40} />
         </Permalink>
 
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index 9524f7501..da3c0a0ab 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -24,6 +24,10 @@ const iconStyle = {
 export default class PrivacyDropdown extends React.PureComponent {
 
   static propTypes = {
+    isUserTouching: PropTypes.func,
+    isModalOpen: PropTypes.bool.isRequired,
+    onModalOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
     value: PropTypes.string.isRequired,
     onChange: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
@@ -34,22 +38,55 @@ export default class PrivacyDropdown extends React.PureComponent {
   };
 
   handleToggle = () => {
-    this.setState({ open: !this.state.open });
+    if (this.props.isUserTouching()) {
+      if (this.state.open) {
+        this.props.onModalClose();
+      } else {
+        this.props.onModalOpen({
+          actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
+          onClick: this.handleModalActionClick,
+        });
+      }
+    } else {
+      this.setState({ open: !this.state.open });
+    }
   }
 
-  handleClick = (e) => {
-    const value = e.currentTarget.getAttribute('data-index');
+  handleModalActionClick = (e) => {
     e.preventDefault();
-    this.setState({ open: false });
+    const { value } = this.options[e.currentTarget.getAttribute('data-index')];
+    this.props.onModalClose();
     this.props.onChange(value);
   }
 
+  handleClick = (e) => {
+    if (e.key === 'Escape') {
+      this.setState({ open: false });
+    } else if (!e.key || e.key === 'Enter') {
+      const value = e.currentTarget.getAttribute('data-index');
+      e.preventDefault();
+      this.setState({ open: false });
+      this.props.onChange(value);
+    }
+  }
+
   onGlobalClick = (e) => {
     if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
       this.setState({ open: false });
     }
   }
 
+  componentWillMount () {
+    const { intl: { formatMessage } } = this.props;
+
+    this.options = [
+      { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
+      { icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
+      { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
+      { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
+    ];
+  }
+
   componentDidMount () {
     window.addEventListener('click', this.onGlobalClick);
     window.addEventListener('touchstart', this.onGlobalClick);
@@ -68,25 +105,18 @@ export default class PrivacyDropdown extends React.PureComponent {
     const { value, intl } = this.props;
     const { open } = this.state;
 
-    const options = [
-      { icon: 'globe', value: 'public', shortText: intl.formatMessage(messages.public_short), longText: intl.formatMessage(messages.public_long) },
-      { icon: 'unlock-alt', value: 'unlisted', shortText: intl.formatMessage(messages.unlisted_short), longText: intl.formatMessage(messages.unlisted_long) },
-      { icon: 'lock', value: 'private', shortText: intl.formatMessage(messages.private_short), longText: intl.formatMessage(messages.private_long) },
-      { icon: 'envelope', value: 'direct', shortText: intl.formatMessage(messages.direct_short), longText: intl.formatMessage(messages.direct_long) },
-    ];
-
-    const valueOption = options.find(item => item.value === value);
+    const valueOption = this.options.find(item => item.value === value);
 
     return (
       <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}>
-        <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div>
+        <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} expanded={open} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div>
         <div className='privacy-dropdown__dropdown'>
-          {open && options.map(item =>
-            <div role='button' tabIndex='0' key={item.value} data-index={item.value} onClick={this.handleClick} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}>
+          {open && this.options.map(item =>
+            <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}>
               <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div>
               <div className='privacy-dropdown__option__content'>
-                <strong>{item.shortText}</strong>
-                {item.longText}
+                <strong>{item.text}</strong>
+                {item.meta}
               </div>
             </div>
           )}
diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js
index cdc7952c0..85ef767ab 100644
--- a/app/javascript/mastodon/features/compose/components/search.js
+++ b/app/javascript/mastodon/features/compose/components/search.js
@@ -52,15 +52,18 @@ export default class Search extends React.PureComponent {
 
     return (
       <div className='search'>
-        <input
-          className='search__input'
-          type='text'
-          placeholder={intl.formatMessage(messages.placeholder)}
-          value={value}
-          onChange={this.handleChange}
-          onKeyUp={this.handleKeyDown}
-          onFocus={this.handleFocus}
-        />
+        <label>
+          <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
+          <input
+            className='search__input'
+            type='text'
+            placeholder={intl.formatMessage(messages.placeholder)}
+            value={value}
+            onChange={this.handleChange}
+            onKeyUp={this.handleKeyDown}
+            onFocus={this.handleFocus}
+          />
+        </label>
 
         <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
           <i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
diff --git a/app/javascript/mastodon/features/compose/components/upload_button.js b/app/javascript/mastodon/features/compose/components/upload_button.js
index badd6cfc5..70b28a2ba 100644
--- a/app/javascript/mastodon/features/compose/components/upload_button.js
+++ b/app/javascript/mastodon/features/compose/components/upload_button.js
@@ -57,16 +57,19 @@ export default class UploadButton extends ImmutablePureComponent {
     return (
       <div className='compose-form__upload-button'>
         <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
-        <input
-          key={resetFileKey}
-          ref={this.setRef}
-          type='file'
-          multiple={false}
-          accept={acceptContentTypes.toArray().join(',')}
-          onChange={this.handleChange}
-          disabled={disabled}
-          style={{ display: 'none' }}
-        />
+        <label>
+          <span style={{ display: 'none' }}>{intl.formatMessage(messages.upload)}</span>
+          <input
+            key={resetFileKey}
+            ref={this.setRef}
+            type='file'
+            multiple={false}
+            accept={acceptContentTypes.toArray().join(',')}
+            onChange={this.handleChange}
+            disabled={disabled}
+            style={{ display: 'none' }}
+          />
+        </label>
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
index 9c05e054e..0ddf531d3 100644
--- a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
+++ b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
@@ -1,8 +1,11 @@
 import { connect } from 'react-redux';
 import PrivacyDropdown from '../components/privacy_dropdown';
 import { changeComposeVisibility } from '../../../actions/compose';
+import { openModal, closeModal } from '../../../actions/modal';
+import { isUserTouching } from '../../../is_mobile';
 
 const mapStateToProps = state => ({
+  isModalOpen: state.get('modal').modalType === 'ACTIONS',
   value: state.getIn(['compose', 'privacy']),
 });
 
@@ -12,6 +15,10 @@ const mapDispatchToProps = dispatch => ({
     dispatch(changeComposeVisibility(value));
   },
 
+  isUserTouching,
+  onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+  onModalClose: () => dispatch(closeModal()),
+
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
index 63c0e8ae4..8624849f3 100644
--- a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
+++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
@@ -15,6 +15,7 @@ const messages = defineMessages({
 const mapStateToProps = state => ({
   visible: state.getIn(['compose', 'media_attachments']).size > 0,
   active: state.getIn(['compose', 'sensitive']),
+  disabled: state.getIn(['compose', 'spoiler']),
 });
 
 const mapDispatchToProps = dispatch => ({
@@ -30,12 +31,13 @@ class SensitiveButton extends React.PureComponent {
   static propTypes = {
     visible: PropTypes.bool,
     active: PropTypes.bool,
+    disabled: PropTypes.bool,
     onClick: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
   };
 
   render () {
-    const { visible, active, onClick, intl } = this.props;
+    const { visible, active, disabled, onClick, intl } = this.props;
 
     return (
       <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
@@ -53,6 +55,7 @@ class SensitiveButton extends React.PureComponent {
                 onClick={onClick}
                 size={18}
                 active={active}
+                disabled={disabled}
                 style={{ lineHeight: null, height: null }}
                 inverted
               />
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index 66b0746c5..f0bce1e40 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -76,23 +76,23 @@ export default class Compose extends React.PureComponent {
     if (multiColumn) {
       const { columns } = this.props;
       header = (
-        <div className='drawer__header'>
-          <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)}><i role='img' aria-label={intl.formatMessage(messages.start)} className='fa fa-fw fa-asterisk' /></Link>
+        <nav className='drawer__header'>
+          <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><i role='img' className='fa fa-fw fa-asterisk' /></Link>
           {!columns.some(column => column.get('id') === 'HOME') && (
-            <Link to='/timelines/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)}><i role='img' className='fa fa-fw fa-home' aria-label={intl.formatMessage(messages.home_timeline)} /></Link>
+            <Link to='/timelines/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><i role='img' className='fa fa-fw fa-home' /></Link>
           )}
           {!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
-            <Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)}><i role='img' className='fa fa-fw fa-bell' aria-label={intl.formatMessage(messages.notifications)} /></Link>
+            <Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><i role='img' className='fa fa-fw fa-bell' /></Link>
           )}
           {!columns.some(column => column.get('id') === 'COMMUNITY') && (
-            <Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)}><i role='img' aria-label={intl.formatMessage(messages.community)} className='fa fa-fw fa-users' /></Link>
+            <Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><i role='img' className='fa fa-fw fa-users' /></Link>
           )}
           {!columns.some(column => column.get('id') === 'PUBLIC') && (
-            <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)}><i role='img' aria-label={intl.formatMessage(messages.public)} className='fa fa-fw fa-globe' /></Link>
+            <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><i role='img' className='fa fa-fw fa-globe' /></Link>
           )}
-          <a onClick={this.openSettings} role='button' tabIndex='0' className='drawer__tab' title={intl.formatMessage(messages.settings)}><i role='img' aria-label={intl.formatMessage(messages.settings)} className='fa fa-fw fa-cogs' /></a>
-          <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)}><i role='img' aria-label={intl.formatMessage(messages.logout)} className='fa fa-fw fa-sign-out' /></a>
-        </div>
+          <a onClick={this.openSettings} role='button' tabIndex='0' className='drawer__tab' title={intl.formatMessage(messages.settings)} aria-label={intl.formatMessage(messages.settings)}><i role='img' className='fa fa-fw fa-cogs' /></a>
+          <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><i role='img' className='fa fa-fw fa-sign-out' /></a>
+        </nav>
       );
     }
 
diff --git a/app/javascript/mastodon/features/compose/util/counter.js b/app/javascript/mastodon/features/compose/util/counter.js
new file mode 100644
index 000000000..f0fea1a0e
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/util/counter.js
@@ -0,0 +1,7 @@
+const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx';
+
+export function countableText(inputText) {
+  return inputText
+    .replace(/https?:\/\/\S+/g, urlPlaceholder)
+    .replace(/(?:^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+)/ig, '@$2');
+};
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
index 31cac5bc7..88a29d4d3 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -36,40 +36,48 @@ export default class ColumnSettings extends React.PureComponent {
           <ClearColumnButton onClick={onClear} />
         </div>
 
-        <span 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} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
-          {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
-          <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
-          <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
+        <div role='group' aria-labelledby='notifications-follow'>
+          <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} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
+          </div>
         </div>
 
-        <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
+        <div role='group' aria-labelledby='notifications-favourite'>
+          <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} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
-          {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
-          <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
-          <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
+          <div className='column-settings__row'>
+            <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
+          </div>
         </div>
 
-        <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
+        <div role='group' aria-labelledby='notifications-mention'>
+          <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} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
-          {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
-          <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
-          <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
+          <div className='column-settings__row'>
+            <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
+          </div>
         </div>
 
-        <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
+        <div role='group' aria-labelledby='notifications-reblog'>
+          <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} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
-          {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
-          <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
-          <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
+          <div className='column-settings__row'>
+            <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
+          </div>
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
index be1ff91d6..a20e7ca51 100644
--- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js
+++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
@@ -18,13 +18,19 @@ export default class SettingToggle extends React.PureComponent {
     this.props.onChange(this.props.settingKey, target.checked);
   }
 
+  onKeyDown = e => {
+    if (e.key === ' ') {
+      this.props.onChange(this.props.settingKey, !e.target.checked);
+    }
+  }
+
   render () {
     const { prefix, settings, settingKey, label, meta } = this.props;
     const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-');
 
     return (
       <div className='setting-toggle'>
-        <Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} />
+        <Toggle id={id} checked={settings.getIn(settingKey)} 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/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index 1eff04e97..a2885adda 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import IconButton from '../../../components/icon_button';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import DropdownMenu from '../../../components/dropdown_menu';
+import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
 import { defineMessages, injectIntl } from 'react-intl';
 
 const messages = defineMessages({
@@ -13,6 +13,7 @@ const messages = defineMessages({
   cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
   report: { id: 'status.report', defaultMessage: 'Report @{name}' },
+  share: { id: 'status.share', defaultMessage: 'Share' },
 });
 
 @injectIntl
@@ -58,6 +59,13 @@ export default class ActionBar extends React.PureComponent {
     this.props.onReport(this.props.status);
   }
 
+  handleShare = () => {
+    navigator.share({
+      text: this.props.status.get('search_index'),
+      url: this.props.status.get('url'),
+    });
+  }
+
   render () {
     const { status, me, intl } = this.props;
 
@@ -71,6 +79,10 @@ export default class ActionBar extends React.PureComponent {
       menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
     }
 
+    const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
+      <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div>
+    );
+
     let reblogIcon = 'retweet';
     //if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
     // else if (status.get('visibility') === 'private') reblogIcon = 'lock';
@@ -82,9 +94,10 @@ export default class ActionBar extends React.PureComponent {
         <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
         <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
         <div className='detailed-status__button'><IconButton animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
+        {shareButton}
 
         <div className='detailed-status__action-bar-dropdown'>
-          <DropdownMenu size={18} icon='ellipsis-h' items={menu} direction='left' ariaLabel='More' />
+          <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' ariaLabel='More' />
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.js b/app/javascript/mastodon/features/ui/components/actions_modal.js
new file mode 100644
index 000000000..cc0620d1c
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/actions_modal.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import StatusContent from '../../../components/status_content';
+import Avatar from '../../../components/avatar';
+import RelativeTimestamp from '../../../components/relative_timestamp';
+import DisplayName from '../../../components/display_name';
+import IconButton from '../../../components/icon_button';
+
+export default class ActionsModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    actions: PropTypes.array,
+    onClick: PropTypes.func,
+  };
+
+  renderAction = (action, i) => {
+    if (action === null) {
+      return <li key={`sep-${i}`} className='dropdown__sep' />;
+    }
+
+    const { icon = null, text, meta = null, active = false, href = '#' } = action;
+
+    return (
+      <li key={`${text}-${i}`}>
+        <a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={active && 'active'}>
+          {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' />}
+          <div>
+            <div>{text}</div>
+            <div>{meta}</div>
+          </div>
+        </a>
+      </li>
+    );
+  }
+
+  render () {
+    const status = this.props.status && (
+      <div className='status light'>
+        <div className='boost-modal__status-header'>
+          <div className='boost-modal__status-time'>
+            <a href={this.props.status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
+              <RelativeTimestamp timestamp={this.props.status.get('created_at')} />
+            </a>
+          </div>
+
+          <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name'>
+            <div className='status__avatar'>
+              <Avatar src={this.props.status.getIn(['account', 'avatar'])} staticSrc={this.props.status.getIn(['account', 'avatar_static'])} size={48} />
+            </div>
+
+            <DisplayName account={this.props.status.get('account')} />
+          </a>
+        </div>
+
+        <StatusContent status={this.props.status} />
+      </div>
+    );
+
+    return (
+      <div className='modal-root__modal actions-modal'>
+        {status}
+
+        <ul>
+          {this.props.actions.map(this.renderAction)}
+        </ul>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js
index ce1dca171..aea102aac 100644
--- a/app/javascript/mastodon/features/ui/components/column.js
+++ b/app/javascript/mastodon/features/ui/components/column.js
@@ -3,6 +3,7 @@ import ColumnHeader from './column_header';
 import PropTypes from 'prop-types';
 import { debounce } from 'lodash';
 import scrollTop from '../../../scroll';
+import { isMobile } from '../../../is_mobile';
 
 export default class Column extends React.PureComponent {
 
@@ -37,13 +38,12 @@ export default class Column extends React.PureComponent {
   render () {
     const { heading, icon, children, active, hideHeadingOnMobile } = this.props;
 
-    let columnHeaderId = null;
-    let header = '';
+    const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth)));
 
-    if (heading) {
-      columnHeaderId = heading.replace(/ /g, '-');
-      header = <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} hideOnMobile={hideHeadingOnMobile} columnHeaderId={columnHeaderId} />;
-    }
+    const columnHeaderId = showHeading && heading.replace(/ /g, '-');
+    const header = showHeading && (
+      <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} columnHeaderId={columnHeaderId} />
+    );
     return (
       <div
         ref={this.setRef}
diff --git a/app/javascript/mastodon/features/ui/components/column_header.js b/app/javascript/mastodon/features/ui/components/column_header.js
index dc601d6e1..af195ea9c 100644
--- a/app/javascript/mastodon/features/ui/components/column_header.js
+++ b/app/javascript/mastodon/features/ui/components/column_header.js
@@ -8,7 +8,6 @@ export default class ColumnHeader extends React.PureComponent {
     type: PropTypes.string,
     active: PropTypes.bool,
     onClick: PropTypes.func,
-    hideOnMobile: PropTypes.bool,
     columnHeaderId: PropTypes.string,
   };
 
@@ -17,7 +16,7 @@ export default class ColumnHeader extends React.PureComponent {
   }
 
   render () {
-    const { type, active, hideOnMobile, columnHeaderId } = this.props;
+    const { type, active, columnHeaderId } = this.props;
 
     let icon = '';
 
@@ -26,7 +25,7 @@ export default class ColumnHeader extends React.PureComponent {
     }
 
     return (
-      <div role='button heading' tabIndex='0' className={`column-header ${active ? 'active' : ''} ${hideOnMobile ? 'hidden-on-mobile' : ''}`} onClick={this.handleClick} id={columnHeaderId || null}>
+      <div role='heading' tabIndex='0' className={`column-header ${active ? 'active' : ''}`} onClick={this.handleClick} id={columnHeaderId || null}>
         {icon}
         {type}
       </div>
diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js
index cbc926581..06004e830 100644
--- a/app/javascript/mastodon/features/ui/components/column_link.js
+++ b/app/javascript/mastodon/features/ui/components/column_link.js
@@ -2,24 +2,24 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import Link from 'react-router-dom/Link';
 
-const ColumnLink = ({ icon, text, to, onClick, href, method, hideOnMobile }) => {
+const ColumnLink = ({ icon, text, to, onClick, href, method }) => {
   if (href) {
     return (
-      <a href={href} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`} data-method={method}>
+      <a href={href} className='column-link' data-method={method}>
         <i className={`fa fa-fw fa-${icon} column-link__icon`} />
         {text}
       </a>
     );
   } else if (to) {
     return (
-      <Link to={to} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`}>
+      <Link to={to} className='column-link'>
         <i className={`fa fa-fw fa-${icon} column-link__icon`} />
         {text}
       </Link>
     );
   } else {
     return (
-      <a onClick={onClick} role='button' tabIndex='0' className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`} data-method={method}>
+      <a onClick={onClick} role='button' tabIndex='0' data-method={method}>
         <i className={`fa fa-fw fa-${icon} column-link__icon`} />
         {text}
       </a>
@@ -34,7 +34,6 @@ ColumnLink.propTypes = {
   onClick: PropTypes.func,
   href: PropTypes.string,
   method: PropTypes.string,
-  hideOnMobile: PropTypes.bool,
 };
 
 export default ColumnLink;
diff --git a/app/javascript/mastodon/features/ui/components/column_loading.js b/app/javascript/mastodon/features/ui/components/column_loading.js
index 7ecfaf77a..1c4058926 100644
--- a/app/javascript/mastodon/features/ui/components/column_loading.js
+++ b/app/javascript/mastodon/features/ui/components/column_loading.js
@@ -6,7 +6,7 @@ import ColumnHeader from '../../../components/column_header';
 
 const ColumnLoading = ({ title = '', icon = ' ' }) => (
   <Column>
-    <ColumnHeader icon={icon} title={title} multiColumn={false} />
+    <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} />
     <div className='scrollable' />
   </Column>
 );
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 7de66ce3f..63bd1b021 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -56,6 +56,15 @@ export default class ColumnsArea extends ImmutablePureComponent {
 
   handleSwipe = (index) => {
     this.pendingIndex = index;
+
+    const nextLinkTranslationId = links[index].props['data-preview-title-id'];
+    const currentLinkSelector = '.tabs-bar__link.active';
+    const nextLinkSelector = `.tabs-bar__link[data-preview-title-id="${nextLinkTranslationId}"]`;
+
+    // HACK: Remove the active class from the current link and set it to the next one
+    // React-router does this for us, but too late, feeling laggy.
+    document.querySelector(currentLinkSelector).classList.remove('active');
+    document.querySelector(nextLinkSelector).classList.add('active');
   }
 
   handleAnimationEnd = () => {
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
index dcc9becd3..828419d5a 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -10,6 +10,8 @@ import ImageLoader from './image_loader';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+  next: { id: 'lightbox.next', defaultMessage: 'Next' },
 });
 
 @injectIntl
@@ -66,16 +68,10 @@ export default class MediaModal extends ImmutablePureComponent {
 
     const index = this.getIndex();
 
-    let leftNav, rightNav, content;
+    const leftNav  = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>;
+    const rightNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav  modal-container__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>;
 
-    leftNav = rightNav = content = '';
-
-    if (media.size > 1) {
-      leftNav  = <div role='button' tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
-      rightNav = <div role='button' tabIndex='0' className='modal-container__nav  modal-container__nav--right' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
-    }
-
-    content = media.map((image) => {
+    const content = media.map((image) => {
       const width  = image.getIn(['meta', 'original', 'width']) || null;
       const height = image.getIn(['meta', 'original', 'height']) || null;
 
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index 3da3f6391..d316ff433 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -5,6 +5,7 @@ import spring from 'react-motion/lib/spring';
 import BundleContainer from '../containers/bundle_container';
 import BundleModalError from './bundle_modal_error';
 import ModalLoading from './modal_loading';
+import ActionsModal from '../components/actions_modal';
 import {
   MediaModal,
   OnboardingModal,
@@ -23,6 +24,7 @@ const MODAL_COMPONENTS = {
   'CONFIRM': ConfirmationModal,
   'REPORT': ReportModal,
   'SETTINGS': SettingsModal,
+  'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
 };
 
 export default class ModalRoot extends React.PureComponent {
@@ -44,10 +46,34 @@ export default class ModalRoot extends React.PureComponent {
     window.addEventListener('keyup', this.handleKeyUp, false);
   }
 
+  componentWillReceiveProps (nextProps) {
+    if (!!nextProps.type && !this.props.type) {
+      this.activeElement = document.activeElement;
+
+      this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (!this.props.type && !!prevProps.type) {
+      this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
+      this.activeElement.focus();
+      this.activeElement = null;
+    }
+  }
+
   componentWillUnmount () {
     window.removeEventListener('keyup', this.handleKeyUp);
   }
 
+  getSiblings = () => {
+    return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
+  }
+
+  setRef = ref => {
+    this.node = ref;
+  }
+
   willEnter () {
     return { opacity: 0, scale: 0.98 };
   }
@@ -86,11 +112,11 @@ export default class ModalRoot extends React.PureComponent {
         willLeave={this.willLeave}
       >
         {interpolatedStyles =>
-          <div className='modal-root'>
+          <div className='modal-root' ref={this.setRef}>
             {interpolatedStyles.map(({ key, data: { type, props }, style }) => (
               <div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}>
                 <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} />
-                <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
+                <div role='dialog' className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
                   <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>
                     {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
                   </BundleContainer>
diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js
index baec86d0d..af9e6bf45 100644
--- a/app/javascript/mastodon/features/ui/components/tabs_bar.js
+++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js
@@ -1,16 +1,19 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 import NavLink from 'react-router-dom/NavLink';
-import { FormattedMessage } from 'react-intl';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import { debounce } from 'lodash';
+import { isUserTouching } from '../../../is_mobile';
 
 export const links = [
-  <NavLink className='tabs-bar__link primary' activeClassName='active' to='/statuses/new' data-preview-title-id='tabs_bar.compose' data-preview-icon='pencil' ><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></NavLink>,
-  <NavLink className='tabs-bar__link primary' activeClassName='active' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
-  <NavLink className='tabs-bar__link primary' activeClassName='active' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
+  <NavLink className='tabs-bar__link primary' to='/statuses/new' data-preview-title-id='tabs_bar.compose' data-preview-icon='pencil' ><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></NavLink>,
+  <NavLink className='tabs-bar__link primary' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
+  <NavLink className='tabs-bar__link primary' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
 
-  <NavLink className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
-  <NavLink className='tabs-bar__link secondary' activeClassName='active' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
+  <NavLink className='tabs-bar__link secondary' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
+  <NavLink className='tabs-bar__link secondary' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
 
-  <NavLink className='tabs-bar__link primary' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='tabs_bar.federated_timeline' data-preview-icon='asterisk' ><i className='fa fa-fw fa-asterisk' /></NavLink>,
+  <NavLink className='tabs-bar__link primary' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='asterisk' ><i className='fa fa-fw fa-asterisk' /></NavLink>,
 ];
 
 export function getIndex (path) {
@@ -21,13 +24,60 @@ export function getLink (index) {
   return links[index].props.to;
 }
 
+@injectIntl
 export default class TabsBar extends React.Component {
 
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  }
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+  }
+
+  setRef = ref => {
+    this.node = ref;
+  }
+
+  handleClick = (e) => {
+    // Only apply optimization for touch devices, which we assume are slower
+    // We thus avoid the 250ms delay for non-touch devices and the lag for touch devices
+    if (isUserTouching()) {
+      e.preventDefault();
+      e.persist();
+
+      requestAnimationFrame(() => {
+        const tabs = Array(...this.node.querySelectorAll('.tabs-bar__link'));
+        const currentTab = tabs.find(tab => tab.classList.contains('active'));
+        const nextTab = tabs.find(tab => tab.contains(e.target));
+        const { props: { to } } = links[Array(...this.node.childNodes).indexOf(nextTab)];
+
+
+        if (currentTab !== nextTab) {
+          if (currentTab) {
+            currentTab.classList.remove('active');
+          }
+
+          const listener = debounce(() => {
+            nextTab.removeEventListener('transitionend', listener);
+            this.context.router.history.push(to);
+          }, 50);
+
+          nextTab.addEventListener('transitionend', listener);
+          nextTab.classList.add('active');
+        }
+      });
+    }
+
+  }
+
   render () {
+    const { intl: { formatMessage } } = this.props;
+
     return (
-      <div className='tabs-bar'>
-        {React.Children.toArray(links)}
-      </div>
+      <nav className='tabs-bar' ref={this.setRef}>
+        {links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))}
+      </nav>
     );
   }
 
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index eb499c836..f7a6eb319 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -52,6 +52,10 @@ const mapStateToProps = state => ({
 @connect(mapStateToProps)
 export default class UI extends React.PureComponent {
 
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  }
+
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
     children: PropTypes.node,
@@ -129,6 +133,14 @@ export default class UI extends React.PureComponent {
     this.setState({ draggingOver: false });
   }
 
+  handleServiceWorkerPostMessage = ({ data }) => {
+    if (data.type === 'navigate') {
+      this.context.router.history.push(data.path);
+    } else {
+      console.warn('Unknown message type:', data.type); // eslint-disable-line no-console
+    }
+  }
+
   componentWillMount () {
     window.addEventListener('resize', this.handleResize, { passive: true });
     document.addEventListener('dragenter', this.handleDragEnter, false);
@@ -137,6 +149,10 @@ export default class UI extends React.PureComponent {
     document.addEventListener('dragleave', this.handleDragLeave, false);
     document.addEventListener('dragend', this.handleDragEnd, false);
 
+    if ('serviceWorker' in  navigator) {
+      navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
+    }
+
     this.props.dispatch(refreshHomeTimeline());
     this.props.dispatch(refreshNotifications());
   }
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index b7c521ac3..9267519dd 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -110,9 +110,9 @@ export function SettingsModal () {
 //  IF MASTODON EVER CHANGES DETAILED STATUSES TO REQUIRE THEM, WE'LL NEED TO UPDATE THE URLS OR SOMETHING LOL.  //
 
 export function MediaGallery () {
-  return import(/* webpackChunkName: "status/MediaGallery" */'../../../components/media_gallery');
+  return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
 }
 
 export function VideoPlayer () {
-  return import(/* webpackChunkName: "status/VideoPlayer" */'../../../components/video_player');
+  return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player');
 }
diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js
index 014a9a8d5..129d66682 100644
--- a/app/javascript/mastodon/is_mobile.js
+++ b/app/javascript/mastodon/is_mobile.js
@@ -12,6 +12,15 @@ export function isMobile(width, columns) {
 };
 
 const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+let userTouching = false;
+
+window.addEventListener('touchstart', () => {
+  userTouching = true;
+}, { once: true });
+
+export function isUserTouching() {
+  return userTouching;
+}
 
 export function isIOS() {
   return iOS;
diff --git a/app/javascript/mastodon/load_polyfills.js b/app/javascript/mastodon/load_polyfills.js
index df7889118..8927b7358 100644
--- a/app/javascript/mastodon/load_polyfills.js
+++ b/app/javascript/mastodon/load_polyfills.js
@@ -24,6 +24,8 @@ function loadPolyfills() {
   // This avoids shipping them all the polyfills.
   const needsExtraPolyfills = !(
     window.IntersectionObserver &&
+    window.IntersectionObserverEntry &&
+    'isIntersecting' in IntersectionObserverEntry.prototype &&
     window.requestIdleCallback &&
     'object-fit' in (new Image()).style
   );
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index 7f27d78cd..f5cf77f92 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -13,6 +13,7 @@
   "account.posts": "المشاركات",
   "account.report": "أبلغ عن @{name}",
   "account.requested": "في انتظار الموافقة",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "إلغاء الحظر عن @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "إلغاء المتابعة",
@@ -34,7 +35,11 @@
   "column.notifications": "الإشعارات",
   "column.public": "الخيط العام الموحد",
   "column_back_button.label": "العودة",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "التصفح",
   "column_subheading.settings": "الإعدادات",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "عرض الردود",
   "home.settings": "إعدادات العمود",
   "lightbox.close": "إغلاق",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "تحميل ...",
   "media_gallery.toggle_visible": "عرض / إخفاء",
   "missing_indicator.label": "تعذر العثور عليه",
@@ -168,6 +175,7 @@
   "status.report": "إبلِغ عن @{name}",
   "status.sensitive_toggle": "اضغط للعرض",
   "status.sensitive_warning": "محتوى حساس",
+  "status.share": "Share",
   "status.show_less": "إعرض أقلّ",
   "status.show_more": "أظهر المزيد",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index 68aaf56b0..e6788f9eb 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -13,6 +13,7 @@
   "account.posts": "Публикации",
   "account.report": "Report @{name}",
   "account.requested": "В очакване на одобрение",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Не блокирай",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Не следвай",
@@ -34,7 +35,11 @@
   "column.notifications": "Известия",
   "column.public": "Публичен канал",
   "column_back_button.label": "Назад",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Show replies",
   "home.settings": "Column settings",
   "lightbox.close": "Затвори",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Зареждане...",
   "media_gallery.toggle_visible": "Toggle visibility",
   "missing_indicator.label": "Not found",
@@ -168,6 +175,7 @@
   "status.report": "Report @{name}",
   "status.sensitive_toggle": "Покажи",
   "status.sensitive_warning": "Деликатно съдържание",
+  "status.share": "Share",
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 6fdcde4b4..95b3c60bf 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -13,6 +13,7 @@
   "account.posts": "Publicacions",
   "account.report": "Informe @{name}",
   "account.requested": "Esperant aprovació",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Desbloquejar @{name}",
   "account.unblock_domain": "Mostra {domain}",
   "account.unfollow": "Deixar de seguir",
@@ -34,7 +35,11 @@
   "column.notifications": "Notificacions",
   "column.public": "Línia de temps federada",
   "column_back_button.label": "Enrere",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navegació",
   "column_subheading.settings": "Configuració",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Mostrar respostes",
   "home.settings": "Ajustos de columna",
   "lightbox.close": "Tancar",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Carregant...",
   "media_gallery.toggle_visible": "Alternar visibilitat",
   "missing_indicator.label": "No trobat",
@@ -168,6 +175,7 @@
   "status.report": "Informar sobre @{name}",
   "status.sensitive_toggle": "Clic per veure",
   "status.sensitive_warning": "Contingut sensible",
+  "status.share": "Share",
   "status.show_less": "Mostra menys",
   "status.show_more": "Mostra més",
   "status.unmute_conversation": "Activar conversació",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index f911c7b75..67a99b765 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -13,6 +13,7 @@
   "account.posts": "Beiträge",
   "account.report": "@{name} melden",
   "account.requested": "Warte auf Erlaubnis",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "@{name} entblocken",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Entfolgen",
@@ -34,7 +35,11 @@
   "column.notifications": "Mitteilungen",
   "column.public": "Gesamtes bekanntes Netz",
   "column_back_button.label": "Zurück",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Antworten anzeigen",
   "home.settings": "Spalteneinstellungen",
   "lightbox.close": "Schließen",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Lade…",
   "media_gallery.toggle_visible": "Sichtbarkeit einstellen",
   "missing_indicator.label": "Nicht gefunden",
@@ -168,6 +175,7 @@
   "status.report": "@{name} melden",
   "status.sensitive_toggle": "Klicke, um sie zu sehen",
   "status.sensitive_warning": "Heikle Inhalte",
+  "status.share": "Share",
   "status.show_less": "Weniger anzeigen",
   "status.show_more": "Mehr anzeigen",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index a7b8f01d0..e5d541cd6 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -54,6 +54,22 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Show settings",
+        "id": "column_header.show_settings"
+      },
+      {
+        "defaultMessage": "Hide settings",
+        "id": "column_header.hide_settings"
+      },
+      {
+        "defaultMessage": "Move column to the left",
+        "id": "column_header.moveLeft_settings"
+      },
+      {
+        "defaultMessage": "Move column to the right",
+        "id": "column_header.moveRight_settings"
+      },
+      {
         "defaultMessage": "Unpin",
         "id": "column_header.unpin"
       },
@@ -139,6 +155,10 @@
         "id": "status.reply"
       },
       {
+        "defaultMessage": "Share",
+        "id": "status.share"
+      },
+      {
         "defaultMessage": "Reply to thread",
         "id": "status.replyAll"
       },
@@ -355,6 +375,10 @@
         "id": "account.report"
       },
       {
+        "defaultMessage": "Share @{name}'s profile",
+        "id": "account.share"
+      },
+      {
         "defaultMessage": "Media",
         "id": "account.media"
       },
@@ -1007,6 +1031,10 @@
       {
         "defaultMessage": "Report @{name}",
         "id": "status.report"
+      },
+      {
+        "defaultMessage": "Share",
+        "id": "status.share"
       }
     ],
     "path": "app/javascript/mastodon/features/status/components/action_bar.json"
@@ -1085,6 +1113,14 @@
       {
         "defaultMessage": "Close",
         "id": "lightbox.close"
+      },
+      {
+        "defaultMessage": "Previous",
+        "id": "lightbox.previous"
+      },
+      {
+        "defaultMessage": "Next",
+        "id": "lightbox.next"
       }
     ],
     "path": "app/javascript/mastodon/features/ui/components/media_modal.json"
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 3a201b9c1..2ea2062d3 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -13,6 +13,7 @@
   "account.posts": "Posts",
   "account.report": "Report @{name}",
   "account.requested": "Awaiting approval",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Unblock @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Unfollow",
@@ -34,7 +35,11 @@
   "column.notifications": "Notifications",
   "column.public": "Federated timeline",
   "column_back_button.label": "Back",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Show replies",
   "home.settings": "Column settings",
   "lightbox.close": "Close",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Loading...",
   "media_gallery.toggle_visible": "Toggle visibility",
   "missing_indicator.label": "Not found",
@@ -168,6 +175,7 @@
   "status.report": "Report @{name}",
   "status.sensitive_toggle": "Click to view",
   "status.sensitive_warning": "Sensitive content",
+  "status.share": "Share",
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 0bb5159c8..960d747ec 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -13,6 +13,7 @@
   "account.posts": "Mesaĝoj",
   "account.report": "Report @{name}",
   "account.requested": "Atendas aprobon",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Malbloki @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Malsekvi",
@@ -34,7 +35,11 @@
   "column.notifications": "Sciigoj",
   "column.public": "Fratara tempolinio",
   "column_back_button.label": "Reveni",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Show replies",
   "home.settings": "Column settings",
   "lightbox.close": "Fermi",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Ŝarĝanta...",
   "media_gallery.toggle_visible": "Toggle visibility",
   "missing_indicator.label": "Not found",
@@ -168,6 +175,7 @@
   "status.report": "Report @{name}",
   "status.sensitive_toggle": "Alklaki por vidi",
   "status.sensitive_warning": "Tikla enhavo",
+  "status.share": "Share",
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index a39b608c6..212d16639 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -13,6 +13,7 @@
   "account.posts": "Publicaciones",
   "account.report": "Report @{name}",
   "account.requested": "Esperando aprobación",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Desbloquear",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Dejar de seguir",
@@ -34,7 +35,11 @@
   "column.notifications": "Notificaciones",
   "column.public": "Historia federada",
   "column_back_button.label": "Atrás",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Show replies",
   "home.settings": "Column settings",
   "lightbox.close": "Cerrar",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Cargando...",
   "media_gallery.toggle_visible": "Toggle visibility",
   "missing_indicator.label": "Not found",
@@ -168,6 +175,7 @@
   "status.report": "Reportar",
   "status.sensitive_toggle": "Click para ver",
   "status.sensitive_warning": "Contenido sensible",
+  "status.share": "Share",
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar más",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 6842558d9..d2682ef12 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -13,18 +13,19 @@
   "account.posts": "نوشته‌ها",
   "account.report": "گزارش @{name}",
   "account.requested": "در انتظار پذیرش",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "رفع انسداد @{name}",
   "account.unblock_domain": "رفع پنهان‌سازی از {domain}",
   "account.unfollow": "پایان پیگیری",
   "account.unmute": "باصدا کردن @{name}",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "نمایش نمایهٔ کامل",
   "boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
-  "bundle_column_error.title": "Network error",
-  "bundle_modal_error.close": "Close",
-  "bundle_modal_error.message": "Something went wrong while loading this component.",
-  "bundle_modal_error.retry": "Try again",
+  "bundle_column_error.body": "هنگام بازکردن این بخش خطایی رخ داد.",
+  "bundle_column_error.retry": "تلاش دوباره",
+  "bundle_column_error.title": "خطای شبکه",
+  "bundle_modal_error.close": "بستن",
+  "bundle_modal_error.message": "هنگام بازکردن این بخش خطایی رخ داد.",
+  "bundle_modal_error.retry": "تلاش دوباره",
   "column.blocks": "کاربران مسدودشده",
   "column.community": "نوشته‌های محلی",
   "column.favourites": "پسندیده‌ها",
@@ -34,8 +35,12 @@
   "column.notifications": "اعلان‌ها",
   "column.public": "نوشته‌های همه‌جا",
   "column_back_button.label": "بازگشت",
-  "column_header.pin": "Pin",
-  "column_header.unpin": "Unpin",
+  "column_header.hide_settings": "نهفتن تنظیمات",
+  "column_header.moveLeft_settings": "انتقال ستون به چپ",
+  "column_header.moveRight_settings": "انتقال ستون به راست",
+  "column_header.pin": "ثابت‌کردن",
+  "column_header.show_settings": "نمایش تنظیمات",
+  "column_header.unpin": "رهاکردن",
   "column_subheading.navigation": "گشت و گذار",
   "column_subheading.settings": "تنظیمات",
   "compose_form.lock_disclaimer": "حساب شما {locked} نیست. هر کسی می‌تواند پیگیر شما شود و نوشته‌های ویژهٔ پیگیران شما را ببیند.",
@@ -56,8 +61,8 @@
   "confirmations.domain_block.message": "آیا جدی جدی می‌خواهید کل دامین {domain} را مسدود کنید؟ بیشتر وقت‌ها مسدودکردن یا بی‌صداکردن چند حساب کاربری خاص کافی است و توصیه می‌شود.",
   "confirmations.mute.confirm": "بی‌صدا کن",
   "confirmations.mute.message": "آیا واقعاً می‌خواهید {name} را بی‌صدا کنید؟",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "confirmations.unfollow.confirm": "لغو پیگیری",
+  "confirmations.unfollow.message": "آیا واقعاً می‌خواهید به پیگیری از {name} پایان دهید؟",
   "emoji_button.activity": "فعالیت",
   "emoji_button.flags": "پرچم‌ها",
   "emoji_button.food": "غذا و نوشیدنی",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "نمایش پاسخ‌ها",
   "home.settings": "تنظیمات ستون",
   "lightbox.close": "بستن",
+  "lightbox.next": "بعدی",
+  "lightbox.previous": "قبلی",
   "loading_indicator.label": "بارگیری...",
   "media_gallery.toggle_visible": "تغییر پیدایی",
   "missing_indicator.label": "پیدا نشد",
@@ -112,8 +119,8 @@
   "notifications.column_settings.favourite": "پسندیده‌ها:",
   "notifications.column_settings.follow": "پیگیران تازه:",
   "notifications.column_settings.mention": "نام‌بردن‌ها:",
-  "notifications.column_settings.push": "Push notifications",
-  "notifications.column_settings.push_meta": "This device",
+  "notifications.column_settings.push": "اعلان‌ها از سمت سرور",
+  "notifications.column_settings.push_meta": "این دستگاه",
   "notifications.column_settings.reblog": "بازبوق‌ها:",
   "notifications.column_settings.show": "نمایش در ستون",
   "notifications.column_settings.sound": "پخش صدا",
@@ -152,7 +159,7 @@
   "report.target": "گزارش‌دادن",
   "search.placeholder": "جستجو",
   "search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
-  "standalone.public_title": "A look inside...",
+  "standalone.public_title": "نگاهی به کاربران این سرور...",
   "status.cannot_reblog": "این نوشته را نمی‌شود بازبوقید",
   "status.delete": "پاک‌کردن",
   "status.favourite": "پسندیدن",
@@ -168,6 +175,7 @@
   "status.report": "گزارش دادن @{name}",
   "status.sensitive_toggle": "برای دیدن کلیک کنید",
   "status.sensitive_warning": "محتوای حساس",
+  "status.share": "هم‌رسانی",
   "status.show_less": "نهفتن",
   "status.show_more": "نمایش",
   "status.unmute_conversation": "باصداکردن گفتگو",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index efc9b1053..cb9e9c2a6 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -13,6 +13,7 @@
   "account.posts": "Postit",
   "account.report": "Report @{name}",
   "account.requested": "Odottaa hyväksyntää",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Salli @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Lopeta seuraaminen",
@@ -34,7 +35,11 @@
   "column.notifications": "Ilmoitukset",
   "column.public": "Yleinen aikajana",
   "column_back_button.label": "Takaisin",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Show replies",
   "home.settings": "Column settings",
   "lightbox.close": "Sulje",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Ladataan...",
   "media_gallery.toggle_visible": "Toggle visibility",
   "missing_indicator.label": "Not found",
@@ -168,6 +175,7 @@
   "status.report": "Report @{name}",
   "status.sensitive_toggle": "Klikkaa nähdäksesi",
   "status.sensitive_warning": "Arkaluontoista sisältöä",
+  "status.share": "Share",
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 3cc1f152a..ad9060d25 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -13,11 +13,12 @@
   "account.posts": "Statuts",
   "account.report": "Signaler",
   "account.requested": "Invitation envoyée",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Débloquer",
   "account.unblock_domain": "Ne plus masquer {domain}",
   "account.unfollow": "Ne plus suivre",
   "account.unmute": "Ne plus masquer",
-"account.view_full_profile": "Afficher le profil complet",
+  "account.view_full_profile": "Afficher le profil complet",
   "boost_modal.combo": "Vous pouvez appuyer sur {combo} pour pouvoir passer ceci, la prochaine fois",
   "bundle_column_error.body": "Une erreur s'est produite lors du chargement de ce composant.",
   "bundle_column_error.retry": "Réessayer",
@@ -34,7 +35,11 @@
   "column.notifications": "Notifications",
   "column.public": "Fil public global",
   "column_back_button.label": "Retour",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Épingler",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Retirer",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Paramètres",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Afficher les réponses",
   "home.settings": "Paramètres de la colonne",
   "lightbox.close": "Fermer",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Chargement…",
   "media_gallery.toggle_visible": "Modifier la visibilité",
   "missing_indicator.label": "Non trouvé",
@@ -168,6 +175,7 @@
   "status.report": "Signaler @{name}",
   "status.sensitive_toggle": "Cliquer pour afficher",
   "status.sensitive_warning": "Contenu sensible",
+  "status.share": "Share",
   "status.show_less": "Replier",
   "status.show_more": "Déplier",
   "status.unmute_conversation": "Ne plus masquer la conversation",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index 36be0842b..34266d8e1 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -13,6 +13,7 @@
   "account.posts": "הודעות",
   "account.report": "לדווח על @{name}",
   "account.requested": "בהמתנה לאישור",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "הסרת חסימה מעל @{name}",
   "account.unblock_domain": "הסר חסימה מקהילת {domain}",
   "account.unfollow": "הפסקת מעקב",
@@ -34,7 +35,11 @@
   "column.notifications": "התראות",
   "column.public": "בפרהסיה",
   "column_back_button.label": "חזרה",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "ניווט",
   "column_subheading.settings": "אפשרויות",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "הצגת תגובות",
   "home.settings": "הגדרות טור",
   "lightbox.close": "סגירה",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "טוען...",
   "media_gallery.toggle_visible": "נראה\\בלתי נראה",
   "missing_indicator.label": "לא נמצא",
@@ -168,6 +175,7 @@
   "status.report": "דיווח על @{name}",
   "status.sensitive_toggle": "לחצו כדי לראות",
   "status.sensitive_warning": "תוכן רגיש",
+  "status.share": "Share",
   "status.show_less": "הראה פחות",
   "status.show_more": "הראה יותר",
   "status.unmute_conversation": "הסרת השתקת שיחה",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index 363c4c490..f69b096d4 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -13,6 +13,7 @@
   "account.posts": "Postovi",
   "account.report": "Prijavi @{name}",
   "account.requested": "Čeka pristanak",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Deblokiraj @{name}",
   "account.unblock_domain": "Otkrij {domain}",
   "account.unfollow": "Prestani slijediti",
@@ -34,7 +35,11 @@
   "column.notifications": "Notifikacije",
   "column.public": "Federalni timeline",
   "column_back_button.label": "Natrag",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigacija",
   "column_subheading.settings": "Postavke",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Pokaži odgovore",
   "home.settings": "Postavke Stupca",
   "lightbox.close": "Zatvori",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Učitavam...",
   "media_gallery.toggle_visible": "Preklopi vidljivost",
   "missing_indicator.label": "Nije nađen",
@@ -168,6 +175,7 @@
   "status.report": "Prijavi @{name}",
   "status.sensitive_toggle": "Klikni da bi vidio",
   "status.sensitive_warning": "Osjetljiv sadržaj",
+  "status.share": "Share",
   "status.show_less": "Pokaži manje",
   "status.show_more": "Pokaži više",
   "status.unmute_conversation": "Poništi utišavanje razgovora",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index d43570f0d..4d2a50963 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -13,6 +13,7 @@
   "account.posts": "Posts",
   "account.report": "Report @{name}",
   "account.requested": "Awaiting approval",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Blokkolás levétele",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Követés abbahagyása",
@@ -34,7 +35,11 @@
   "column.notifications": "Értesítések",
   "column.public": "Nyilvános",
   "column_back_button.label": "Vissza",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Show replies",
   "home.settings": "Column settings",
   "lightbox.close": "Bezárás",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Betöltés...",
   "media_gallery.toggle_visible": "Toggle visibility",
   "missing_indicator.label": "Not found",
@@ -168,6 +175,7 @@
   "status.report": "Report @{name}",
   "status.sensitive_toggle": "Katt a megtekintéshez",
   "status.sensitive_warning": "Érzékeny tartalom",
+  "status.share": "Share",
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index 916f313bb..532739e3c 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -13,6 +13,7 @@
   "account.posts": "Postingan",
   "account.report": "Laporkan @{name}",
   "account.requested": "Menunggu persetujuan",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Hapus blokir @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Berhenti mengikuti",
@@ -34,7 +35,11 @@
   "column.notifications": "Notifikasi",
   "column.public": "Linimasa gabunggan",
   "column_back_button.label": "Kembali",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigasi",
   "column_subheading.settings": "Pengaturan",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Tampilkan balasan",
   "home.settings": "Pengaturan kolom",
   "lightbox.close": "Tutup",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Tunggu sebentar...",
   "media_gallery.toggle_visible": "Tampil/Sembunyikan",
   "missing_indicator.label": "Tidak ditemukan",
@@ -168,6 +175,7 @@
   "status.report": "Laporkan @{name}",
   "status.sensitive_toggle": "Klik untuk menampilkan",
   "status.sensitive_warning": "Konten sensitif",
+  "status.share": "Share",
   "status.show_less": "Tampilkan lebih sedikit",
   "status.show_more": "Tampilkan semua",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index a87cc9328..a5e363e40 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -13,6 +13,7 @@
   "account.posts": "Mesaji",
   "account.report": "Denuncar @{name}",
   "account.requested": "Vartante aprobo",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Desblokusar @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Ne plus sequar",
@@ -34,7 +35,11 @@
   "column.notifications": "Savigi",
   "column.public": "Federata tempolineo",
   "column_back_button.label": "Retro",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Montrar respondi",
   "home.settings": "Aranji di la kolumno",
   "lightbox.close": "Klozar",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Kargante...",
   "media_gallery.toggle_visible": "Chanjar videbleso",
   "missing_indicator.label": "Ne trovita",
@@ -168,6 +175,7 @@
   "status.report": "Denuncar @{name}",
   "status.sensitive_toggle": "Kliktar por vidar",
   "status.sensitive_warning": "Trubliva kontenajo",
+  "status.share": "Share",
   "status.show_less": "Montrar mine",
   "status.show_more": "Montrar plue",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 243ed7344..329eb82ca 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -13,6 +13,7 @@
   "account.posts": "Posts",
   "account.report": "Segnala @{name}",
   "account.requested": "In attesa di approvazione",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Sblocca @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Non seguire",
@@ -34,7 +35,11 @@
   "column.notifications": "Notifiche",
   "column.public": "Timeline federata",
   "column_back_button.label": "Indietro",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Mostra risposte",
   "home.settings": "Impostazioni colonna",
   "lightbox.close": "Chiudi",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Carico...",
   "media_gallery.toggle_visible": "Imposta visibilità",
   "missing_indicator.label": "Non trovato",
@@ -168,6 +175,7 @@
   "status.report": "Segnala @{name}",
   "status.sensitive_toggle": "Clicca per vedere",
   "status.sensitive_warning": "Materiale sensibile",
+  "status.share": "Share",
   "status.show_less": "Mostra meno",
   "status.show_more": "Mostra di più",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index ca36122f7..4c98086bb 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -1,7 +1,7 @@
 {
   "account.block": "ブロック",
   "account.block_domain": "{domain}全体を非表示",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.disclaimer_full": "以下の情報は不正確な可能性があります。",
   "account.edit_profile": "プロフィールを編集",
   "account.follow": "フォロー",
   "account.followers": "フォロワー",
@@ -13,11 +13,12 @@
   "account.posts": "投稿",
   "account.report": "通報",
   "account.requested": "承認待ち",
+  "account.share": "@{name} のプロフィールを共有する",
   "account.unblock": "ブロック解除",
   "account.unblock_domain": "{domain}を表示",
   "account.unfollow": "フォロー解除",
   "account.unmute": "ミュート解除",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "全ての情報を見る",
   "boost_modal.combo": "次からは{combo}を押せば、これをスキップできます。",
   "bundle_column_error.body": "コンポーネントの読み込み中に問題が発生しました。",
   "bundle_column_error.retry": "再試行",
@@ -34,7 +35,11 @@
   "column.notifications": "通知",
   "column.public": "連合タイムライン",
   "column_back_button.label": "戻る",
+  "column_header.hide_settings": "設定を隠す",
+  "column_header.moveLeft_settings": "カラムを左に移動する",
+  "column_header.moveRight_settings": "カラムを右に移動する",
   "column_header.pin": "ピン留めする",
+  "column_header.show_settings": "設定を表示",
   "column_header.unpin": "ピン留めを外す",
   "column_subheading.navigation": "ナビゲーション",
   "column_subheading.settings": "設定",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "返信表示",
   "home.settings": "カラム設定",
   "lightbox.close": "閉じる",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "読み込み中...",
   "media_gallery.toggle_visible": "表示切り替え",
   "missing_indicator.label": "見つかりません",
@@ -149,7 +156,7 @@
   "reply_indicator.cancel": "キャンセル",
   "report.placeholder": "コメント",
   "report.submit": "通報する",
-  "report.target": "問題のユーザー",
+  "report.target": "{target} を通報する",
   "search.placeholder": "検索",
   "search_results.total": "{count, number}件の結果",
   "standalone.public_title": "連合タイムライン",
@@ -168,6 +175,7 @@
   "status.report": "通報",
   "status.sensitive_toggle": "クリックして表示",
   "status.sensitive_warning": "閲覧注意",
+  "status.share": "共有",
   "status.show_less": "隠す",
   "status.show_more": "もっと見る",
   "status.unmute_conversation": "会話のミュートを解除",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 768efa37d..47d0d4087 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -13,6 +13,7 @@
   "account.posts": "포스트",
   "account.report": "신고",
   "account.requested": "승인 대기 중",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "차단 해제",
   "account.unblock_domain": "{domain} 숨김 해제",
   "account.unfollow": "팔로우 해제",
@@ -34,7 +35,11 @@
   "column.notifications": "알림",
   "column.public": "연합 타임라인",
   "column_back_button.label": "돌아가기",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "고정하기",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "고정 해제",
   "column_subheading.navigation": "내비게이션",
   "column_subheading.settings": "설정",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "답글 표시",
   "home.settings": "컬럼 설정",
   "lightbox.close": "닫기",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "불러오는 중...",
   "media_gallery.toggle_visible": "표시 전환",
   "missing_indicator.label": "찾을 수 없습니다",
@@ -168,6 +175,7 @@
   "status.report": "신고",
   "status.sensitive_toggle": "클릭해서 표시하기",
   "status.sensitive_warning": "민감한 미디어",
+  "status.share": "Share",
   "status.show_less": "숨기기",
   "status.show_more": "더 보기",
   "status.unmute_conversation": "이 대화의 뮤트 해제하기",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index fbfabc5d1..4d68c7992 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -13,11 +13,12 @@
   "account.posts": "Toots",
   "account.report": "Rapporteer @{name}",
   "account.requested": "Wacht op goedkeuring",
+  "account.share": "Profiel van @{name} delen",
   "account.unblock": "Deblokkeer @{name}",
   "account.unblock_domain": "{domain} niet meer negeren",
   "account.unfollow": "Ontvolgen",
   "account.unmute": "@{name} niet meer negeren",
-  "account.view_full_profile": "Volledig profiel tonen", 
+  "account.view_full_profile": "Volledig profiel tonen",
   "boost_modal.combo": "Je kunt {combo} klikken om dit de volgende keer over te slaan",
   "bundle_column_error.body": "Tijdens het laden van dit onderdeel is er iets fout gegaan.",
   "bundle_column_error.retry": "Opnieuw proberen",
@@ -34,7 +35,11 @@
   "column.notifications": "Meldingen",
   "column.public": "Globale tijdlijn",
   "column_back_button.label": "terug",
+  "column_header.hide_settings": "Instellingen verbergen",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Vastmaken",
+  "column_header.show_settings": "Instellingen tonen",
   "column_header.unpin": "Losmaken",
   "column_subheading.navigation": "Navigatie",
   "column_subheading.settings": "Instellingen",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Reacties tonen",
   "home.settings": "Kolom-instellingen",
   "lightbox.close": "Sluiten",
+  "lightbox.next": "Volgende",
+  "lightbox.previous": "Vorige",
   "loading_indicator.label": "Laden…",
   "media_gallery.toggle_visible": "Media wel/niet tonen",
   "missing_indicator.label": "Niet gevonden",
@@ -147,12 +154,12 @@
   "privacy.unlisted.long": "Niet op openbare tijdlijnen tonen",
   "privacy.unlisted.short": "Minder openbaar",
   "reply_indicator.cancel": "Annuleren",
-  "report.heading": "Rapporteren",
   "report.placeholder": "Extra opmerkingen",
   "report.submit": "Verzenden",
   "report.target": "Rapporteren van",
   "search.placeholder": "Zoeken",
   "search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}",
+  "standalone.public_title": "Een kijkje binnenin...",
   "status.cannot_reblog": "Deze toot kan niet geboost worden",
   "status.delete": "Verwijderen",
   "status.favourite": "Favoriet",
@@ -166,8 +173,9 @@
   "status.reply": "Reageren",
   "status.replyAll": "Reageer op iedereen",
   "status.report": "Rapporteer @{name}",
-  "status.sensitive_toggle": "Klik om te zien",
+  "status.sensitive_toggle": "Klik om te bekijken",
   "status.sensitive_warning": "Gevoelige inhoud",
+  "status.share": "Delen",
   "status.show_less": "Minder tonen",
   "status.show_more": "Meer tonen",
   "status.unmute_conversation": "Conversatie niet meer negeren",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index 8727f6147..9453e65ff 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -13,6 +13,7 @@
   "account.posts": "Innlegg",
   "account.report": "Rapportér @{name}",
   "account.requested": "Venter på godkjennelse",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Avblokker @{name}",
   "account.unblock_domain": "Vis {domain}",
   "account.unfollow": "Avfølg",
@@ -34,7 +35,11 @@
   "column.notifications": "Varsler",
   "column.public": "Felles tidslinje",
   "column_back_button.label": "Tilbake",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigasjon",
   "column_subheading.settings": "Innstillinger",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Vis svar",
   "home.settings": "Kolonneinnstillinger",
   "lightbox.close": "Lukk",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Laster...",
   "media_gallery.toggle_visible": "Veksle synlighet",
   "missing_indicator.label": "Ikke funnet",
@@ -168,6 +175,7 @@
   "status.report": "Rapporter @{name}",
   "status.sensitive_toggle": "Klikk for å vise",
   "status.sensitive_warning": "Følsomt innhold",
+  "status.share": "Share",
   "status.show_less": "Vis mindre",
   "status.show_more": "Vis mer",
   "status.unmute_conversation": "Ikke demp samtale",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index c39d57333..e2a5d7c59 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -1,7 +1,7 @@
 {
   "account.block": "Blocar @{name}",
   "account.block_domain": "Tot amagar del domeni {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.disclaimer_full": "Aquelas informacions de perfil pòdon èsser incompletas.",
   "account.edit_profile": "Modificar lo perfil",
   "account.follow": "Sègre",
   "account.followers": "Seguidors",
@@ -13,18 +13,19 @@
   "account.posts": "Estatuts",
   "account.report": "Senhalar @{name}",
   "account.requested": "Invitacion mandada",
+  "account.share": "Partejar lo perfil a @{name}",
   "account.unblock": "Desblocar @{name}",
   "account.unblock_domain": "Desblocar {domain}",
   "account.unfollow": "Quitar de sègre",
   "account.unmute": "Quitar de rescondre @{name}",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "Veire lo perfil complet",
   "boost_modal.combo": "Podètz botar {combo} per passar aquò lo còp que ven",
   "bundle_column_error.body": "Quicòm a fach meuca pendent lo cargament d’aqueste compausant.",
-  "bundle_column_error.retry": "Tornar ensejar",
+  "bundle_column_error.retry": "Tornar ensajar",
   "bundle_column_error.title": "Error de ret",
   "bundle_modal_error.close": "Tampar",
-  "bundle_modal_error.message": "Quicòm a fach meuca pendent lo cargament d’aqueste compausant.",
-  "bundle_modal_error.retry": "Tornar ensejar",
+  "bundle_modal_error.message": "Quicòm a fach mèuca pendent lo cargament d’aqueste compausant.",
+  "bundle_modal_error.retry": "Tornar ensajar",
   "column.blocks": "Personas blocadas",
   "column.community": "Flux public local",
   "column.favourites": "Favorits",
@@ -34,7 +35,11 @@
   "column.notifications": "Notificacions",
   "column.public": "Flux public global",
   "column_back_button.label": "Tornar",
+  "column_header.hide_settings": "Amagar los paramètres",
+  "column_header.moveLeft_settings": "Desplaçar la colomna a man drecha",
+  "column_header.moveRight_settings": "Desplaçar la colomna a man esquèrra",
   "column_header.pin": "Penjar",
+  "column_header.show_settings": "Mostrar los paramètres",
   "column_header.unpin": "Despenjar",
   "column_subheading.navigation": "Navigacion",
   "column_subheading.settings": "Paramètres",
@@ -46,35 +51,35 @@
   "compose_form.publish_loud": "{publish} !",
   "compose_form.sensitive": "Marcar lo mèdia coma sensible",
   "compose_form.spoiler": "Rescondre lo tèxte darrièr un avertiment",
-  "compose_form.spoiler_placeholder": "Avertiment",
+  "compose_form.spoiler_placeholder": "Escrivètz l’avertiment aquí",
   "confirmation_modal.cancel": "Anullar",
   "confirmations.block.confirm": "Blocar",
   "confirmations.block.message": "Sètz segur de voler blocar {name} ?",
   "confirmations.delete.confirm": "Suprimir",
   "confirmations.delete.message": "Sètz segur de voler suprimir l’estatut ?",
   "confirmations.domain_block.confirm": "Amagar tot lo domeni",
-  "confirmations.domain_block.message": "Sètz segur segur de voler blocar complètament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.",
+  "confirmations.domain_block.message": "Sètz segur segur de voler blocar completament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.",
   "confirmations.mute.confirm": "Metre en silenci",
   "confirmations.mute.message": "Sètz segur de voler metre en silenci {name} ?",
   "confirmations.unfollow.confirm": "Quitar de sègre",
   "confirmations.unfollow.message": "Volètz vertadièrament quitar de sègre {name} ?",
-  "emoji_button.activity": "Activitat",
+  "emoji_button.activity": "Activitats",
   "emoji_button.flags": "Drapèus",
   "emoji_button.food": "Beure e manjar",
   "emoji_button.label": "Inserir un emoji",
   "emoji_button.nature": "Natura",
   "emoji_button.objects": "Objèctes",
   "emoji_button.people": "Gents",
-  "emoji_button.search": "Cercar...",
+  "emoji_button.search": "Cercar…",
   "emoji_button.symbols": "Simbòls",
   "emoji_button.travel": "Viatges & lòcs",
-  "empty_column.community": "Lo flux public local es void. Escribètz quicòm per lo garnir !",
+  "empty_column.community": "Lo flux public local es void. Escrivètz quicòm per lo garnir !",
   "empty_column.hashtag": "I a pas encara de contengut ligat a aqueste hashtag",
-  "empty_column.home": "Pel moment segètz pas degun. Visitatz {public} o utilizatz la recèrca per vos connectar a d’autras personas.",
+  "empty_column.home": "Pel moment seguètz pas degun. Visitatz {public} o utilizatz la recèrca per vos connectar a d’autras personas.",
   "empty_column.home.inactivity": "Vòstra pagina d’acuèlh es voida. Se sètz estat inactiu per un moment, serà tornada generar per vos dins una estona.",
   "empty_column.home.public_timeline": "lo flux public",
   "empty_column.notifications": "Avètz pas encara de notificacions. Respondètz a qualqu’un per començar una conversacion.",
-  "empty_column.public": "I a pas res aquí ! Escribètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo flux public.",
+  "empty_column.public": "I a pas res aquí ! Escrivètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo flux public.",
   "follow_request.authorize": "Autorizar",
   "follow_request.reject": "Regetar",
   "getting_started.appsshort": "Apps",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Mostrar las responsas",
   "home.settings": "Paramètres de la colomna",
   "lightbox.close": "Tampar",
+  "lightbox.next": "Seguent",
+  "lightbox.previous": "Precedent",
   "loading_indicator.label": "Cargament…",
   "media_gallery.toggle_visible": "Modificar la visibilitat",
   "missing_indicator.label": "Pas trobat",
@@ -103,11 +110,11 @@
   "navigation_bar.preferences": "Preferéncias",
   "navigation_bar.public_timeline": "Flux public global",
   "notification.favourite": "{name} a ajustat a sos favorits :",
-  "notification.follow": "{name} vos sèc.",
+  "notification.follow": "{name} vos sèc",
   "notification.mention": "{name} vos a mencionat :",
   "notification.reblog": "{name} a partejat vòstre estatut :",
-  "notifications.clear": "Levar",
-  "notifications.clear_confirmation": "Volètz vertadièrament levar totas vòstras las notificacions ?",
+  "notifications.clear": "Escafar",
+  "notifications.clear_confirmation": "Volètz vertadièrament escafar totas vòstras las notificacions ?",
   "notifications.column_settings.alert": "Notificacions localas",
   "notifications.column_settings.favourite": "Favorits :",
   "notifications.column_settings.follow": "Nòus seguidors :",
@@ -119,15 +126,15 @@
   "notifications.column_settings.sound": "Emetre un son",
   "onboarding.done": "Fach",
   "onboarding.next": "Seguent",
-  "onboarding.page_five.public_timelines": "Lo flux local mòstra los estatuts publics del monde de vòstra intància, aquí {domain}. Lo flux federat mòstra los estatuts publics de tot lo mond sus {domain} sègon. Son los fluxes publics, un bon biais de trobar de mond.",
+  "onboarding.page_five.public_timelines": "Lo flux local mòstra los estatuts publics del monde de vòstra instància, aquí {domain}. Lo flux federat mòstra los estatuts publics de tot lo mond sus {domain} sègon. Son los fluxes publics, un bon biais de trobar de mond.",
   "onboarding.page_four.home": "Lo flux d’acuèlh mòstra los estatuts del mond que seguètz.",
-  "onboarding.page_four.notifications": "La colomna de notificacions vos fa veire quand qualqu’un enteragís amb vos",
+  "onboarding.page_four.notifications": "La colomna de notificacions vos fa veire quand qualqu’un interagís amb vos",
   "onboarding.page_one.federation": "Mastodon es un malhum de servidors independents que comunican per bastir un malhum ma larg. Òm los apèla instàncias.",
   "onboarding.page_one.handle": "Sètz sus {domain}, doncas vòstre identificant complet es {handle}",
   "onboarding.page_one.welcome": "Benvengut a Mastodon !",
   "onboarding.page_six.admin": "Vòstre administrator d’instància es {admin}.",
   "onboarding.page_six.almost_done": "Gaireben acabat…",
-  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.appetoot": "Bon Appetut!",
   "onboarding.page_six.apps_available": "I a d’aplicacions per mobil per iOS, Android e mai.",
   "onboarding.page_six.github": "Mastodon es un logicial liure e open-source.  Podètz senhalar de bugs, demandar de foncionalitats e contribuir al còdi sus {github}.",
   "onboarding.page_six.guidelines": "guida de la comunitat",
@@ -168,6 +175,7 @@
   "status.report": "Senhalar @{name}",
   "status.sensitive_toggle": "Clicar per mostrar",
   "status.sensitive_warning": "Contengut sensible",
+  "status.share": "Partejar",
   "status.show_less": "Tornar plegar",
   "status.show_more": "Desplegar",
   "status.unmute_conversation": "Conversacions amb silenci levat",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index af069b6d7..c42721f64 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -13,6 +13,7 @@
   "account.posts": "Posty",
   "account.report": "Zgłoś @{name}",
   "account.requested": "Oczekująca prośba",
+  "account.share": "Udostępnij profil @{name}",
   "account.unblock": "Odblokuj @{name}",
   "account.unblock_domain": "Odblokuj domenę {domain}",
   "account.unfollow": "Przestań śledzić",
@@ -34,7 +35,11 @@
   "column.notifications": "Powiadomienia",
   "column.public": "Globalna oś czasu",
   "column_back_button.label": "Wróć",
+  "column_header.hide_settings": "Ukryj ustawienia",
+  "column_header.moveLeft_settings": "Przesuń kolumnę w lewo",
+  "column_header.moveRight_settings": "Przesuń kolumnę w prawo",
   "column_header.pin": "Przypnij",
+  "column_header.show_settings": "Pokaż ustawienia",
   "column_header.unpin": "Cofnij przypięcie",
   "column_subheading.navigation": "Nawigacja",
   "column_subheading.settings": "Ustawienia",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Pokazuj odpowiedzi",
   "home.settings": "Ustawienia kolumny",
   "lightbox.close": "Zamknij",
+  "lightbox.next": "Następne",
+  "lightbox.previous": "Poprzednie",
   "loading_indicator.label": "Ładowanie...",
   "media_gallery.toggle_visible": "Przełącz widoczność",
   "missing_indicator.label": "Nie znaleziono",
@@ -168,6 +175,7 @@
   "status.report": "Zgłoś @{name}",
   "status.sensitive_toggle": "Naciśnij aby wyświetlić",
   "status.sensitive_warning": "Wrażliwa zawartość",
+  "status.share": "Udostępnij",
   "status.show_less": "Pokaż mniej",
   "status.show_more": "Pokaż więcej",
   "status.unmute_conversation": "Cofnij wyciezenie konwersacji",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index 86da7c4e6..55d2f05de 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -13,6 +13,7 @@
   "account.posts": "Posts",
   "account.report": "Denunciar @{name}",
   "account.requested": "A aguardar aprovação",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Não bloquear @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Deixar de seguir",
@@ -34,7 +35,11 @@
   "column.notifications": "Notificações",
   "column.public": "Global",
   "column_back_button.label": "Voltar",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Mostrar as respostas",
   "home.settings": "Parâmetros da listagem",
   "lightbox.close": "Fechar",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Carregando...",
   "media_gallery.toggle_visible": "Esconder/Mostrar",
   "missing_indicator.label": "Não encontrado",
@@ -168,6 +175,7 @@
   "status.report": "Denúnciar @{name}",
   "status.sensitive_toggle": "Clique para ver",
   "status.sensitive_warning": "Conteúdo sensível",
+  "status.share": "Share",
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar mais",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index 86da7c4e6..55d2f05de 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -13,6 +13,7 @@
   "account.posts": "Posts",
   "account.report": "Denunciar @{name}",
   "account.requested": "A aguardar aprovação",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Não bloquear @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Deixar de seguir",
@@ -34,7 +35,11 @@
   "column.notifications": "Notificações",
   "column.public": "Global",
   "column_back_button.label": "Voltar",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Mostrar as respostas",
   "home.settings": "Parâmetros da listagem",
   "lightbox.close": "Fechar",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Carregando...",
   "media_gallery.toggle_visible": "Esconder/Mostrar",
   "missing_indicator.label": "Não encontrado",
@@ -168,6 +175,7 @@
   "status.report": "Denúnciar @{name}",
   "status.sensitive_toggle": "Clique para ver",
   "status.sensitive_warning": "Conteúdo sensível",
+  "status.share": "Share",
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar mais",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index 16af3fe7e..1abfb4370 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -13,6 +13,7 @@
   "account.posts": "Посты",
   "account.report": "Пожаловаться",
   "account.requested": "Ожидает подтверждения",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Разблокировать",
   "account.unblock_domain": "Разблокировать {domain}",
   "account.unfollow": "Отписаться",
@@ -34,7 +35,11 @@
   "column.notifications": "Уведомления",
   "column.public": "Глобальная лента",
   "column_back_button.label": "Назад",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Закрепить",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Открепить",
   "column_subheading.navigation": "Навигация",
   "column_subheading.settings": "Настройки",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Показывать ответы",
   "home.settings": "Настройки колонки",
   "lightbox.close": "Закрыть",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Загрузка...",
   "media_gallery.toggle_visible": "Показать/скрыть",
   "missing_indicator.label": "Не найдено",
@@ -168,6 +175,7 @@
   "status.report": "Пожаловаться",
   "status.sensitive_toggle": "Нажмите для просмотра",
   "status.sensitive_warning": "Чувствительный контент",
+  "status.share": "Share",
   "status.show_less": "Свернуть",
   "status.show_more": "Развернуть",
   "status.unmute_conversation": "Снять глушение с треда",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index be5c0815d..aa0929f82 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -13,6 +13,7 @@
   "account.posts": "Posts",
   "account.report": "Report @{name}",
   "account.requested": "Awaiting approval",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Unblock @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Unfollow",
@@ -34,7 +35,11 @@
   "column.notifications": "Notifications",
   "column.public": "Federated timeline",
   "column_back_button.label": "Back",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Show replies",
   "home.settings": "Column settings",
   "lightbox.close": "Close",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Loading...",
   "media_gallery.toggle_visible": "Toggle visibility",
   "missing_indicator.label": "Not found",
@@ -168,6 +175,7 @@
   "status.report": "Report @{name}",
   "status.sensitive_toggle": "Click to view",
   "status.sensitive_warning": "Sensitive content",
+  "status.share": "Share",
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index 9d4d5fa17..37ce8597e 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -13,6 +13,7 @@
   "account.posts": "Gönderiler",
   "account.report": "Rapor et @{name}",
   "account.requested": "Onay bekleniyor",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Engeli kaldır @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Takipten vazgeç",
@@ -34,7 +35,11 @@
   "column.notifications": "Bildirimler",
   "column.public": "Federe zaman tüneli",
   "column_back_button.label": "Geri",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigasyon",
   "column_subheading.settings": "Ayarlar",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Cevapları göster",
   "home.settings": "Kolon ayarları",
   "lightbox.close": "Kapat",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Yükleniyor...",
   "media_gallery.toggle_visible": "Görünürlüğü değiştir",
   "missing_indicator.label": "Bulunamadı",
@@ -168,6 +175,7 @@
   "status.report": "@{name}'i raporla",
   "status.sensitive_toggle": "Görmek için tıklayınız",
   "status.sensitive_warning": "Hassas içerik",
+  "status.share": "Share",
   "status.show_less": "Daha azı",
   "status.show_more": "Daha fazlası",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index 60a551bb6..fea7bd94e 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -13,6 +13,7 @@
   "account.posts": "Пости",
   "account.report": "Поскаржитися",
   "account.requested": "Очікує підтвердження",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "Розблокувати",
   "account.unblock_domain": "Розблокувати {domain}",
   "account.unfollow": "Відписатися",
@@ -34,7 +35,11 @@
   "column.notifications": "Сповіщення",
   "column.public": "Глобальна стрічка",
   "column_back_button.label": "Назад",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Навігація",
   "column_subheading.settings": "Налаштування",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "Показувати відповіді",
   "home.settings": "Налаштування колонок",
   "lightbox.close": "Закрити",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "Завантаження...",
   "media_gallery.toggle_visible": "Показати/приховати",
   "missing_indicator.label": "Не знайдено",
@@ -168,6 +175,7 @@
   "status.report": "Поскаржитися",
   "status.sensitive_toggle": "Натисніть, щоб подивитися",
   "status.sensitive_warning": "Непристойний зміст",
+  "status.share": "Share",
   "status.show_less": "Згорнути",
   "status.show_more": "Розгорнути",
   "status.unmute_conversation": "Зняти глушення з діалогу",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index 97f1f5e27..d0c4b3d1b 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -13,6 +13,7 @@
   "account.posts": "嘟文",
   "account.report": "举报 @{name}",
   "account.requested": "等待审批",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "解除对 @{name} 的屏蔽",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "取消关注",
@@ -34,7 +35,11 @@
   "column.notifications": "通知",
   "column.public": "跨站公共时间轴",
   "column_back_button.label": "Back",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "导航",
   "column_subheading.settings": "设置",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "显示回应嘟文",
   "home.settings": "字段设置",
   "lightbox.close": "关闭",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "加载中……",
   "media_gallery.toggle_visible": "打开或关上",
   "missing_indicator.label": "找不到内容",
@@ -168,6 +175,7 @@
   "status.report": "举报 @{name}",
   "status.sensitive_toggle": "点击显示",
   "status.sensitive_warning": "敏感内容",
+  "status.share": "Share",
   "status.show_less": "减少显示",
   "status.show_more": "显示更多",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index c65c3d45c..7312aae82 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -13,6 +13,7 @@
   "account.posts": "文章",
   "account.report": "舉報 @{name}",
   "account.requested": "等候審批",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "解除對 @{name} 的封鎖",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "取消關注",
@@ -34,7 +35,11 @@
   "column.notifications": "通知",
   "column.public": "跨站時間軸",
   "column_back_button.label": "返回",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "瀏覽",
   "column_subheading.settings": "設定",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "顯示回應文章",
   "home.settings": "欄位設定",
   "lightbox.close": "關閉",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "載入中...",
   "media_gallery.toggle_visible": "打開或關上",
   "missing_indicator.label": "找不到內容",
@@ -168,6 +175,7 @@
   "status.report": "舉報 @{name}",
   "status.sensitive_toggle": "點擊顯示",
   "status.sensitive_warning": "敏感內容",
+  "status.share": "Share",
   "status.show_less": "減少顯示",
   "status.show_more": "顯示更多",
   "status.unmute_conversation": "Unmute conversation",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 12e840b16..1c2e35272 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -13,6 +13,7 @@
   "account.posts": "貼文",
   "account.report": "檢舉 @{name}",
   "account.requested": "正在等待許可",
+  "account.share": "Share @{name}'s profile",
   "account.unblock": "取消封鎖 @{name}",
   "account.unblock_domain": "不再隱藏 {domain}",
   "account.unfollow": "取消關注",
@@ -34,7 +35,11 @@
   "column.notifications": "通知",
   "column.public": "聯盟時間軸",
   "column_back_button.label": "上一頁",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
   "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "瀏覽",
   "column_subheading.settings": "設定",
@@ -89,6 +94,8 @@
   "home.column_settings.show_replies": "顯示回應",
   "home.settings": "欄位設定",
   "lightbox.close": "關閉",
+  "lightbox.next": "Next",
+  "lightbox.previous": "Previous",
   "loading_indicator.label": "讀取中...",
   "media_gallery.toggle_visible": "切換可見性",
   "missing_indicator.label": "找不到",
@@ -168,6 +175,7 @@
   "status.report": "通報 @{name}",
   "status.sensitive_toggle": "點來看",
   "status.sensitive_warning": "敏感內容",
+  "status.share": "Share",
   "status.show_less": "看少點",
   "status.show_more": "看更多",
   "status.unmute_conversation": "不消音對話",
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 7c98854a2..07207c93b 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -96,7 +96,7 @@ function appendMedia(state, media) {
     map.set('focusDate', new Date());
     map.set('idempotencyKey', uuid());
 
-    if (prevSize === 0 && state.get('default_sensitive')) {
+    if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
       map.set('sensitive', true);
     }
   });
@@ -165,14 +165,22 @@ export default function compose(state = initialState, action) {
         state.get('advanced_options').set(action.option, !state.getIn(['advanced_options', action.option])))
       .set('idempotencyKey', uuid());
   case COMPOSE_SENSITIVITY_CHANGE:
-    return state
-      .set('sensitive', !state.get('sensitive'))
-      .set('idempotencyKey', uuid());
+    return state.withMutations(map => {
+      if (!state.get('spoiler')) {
+        map.set('sensitive', !state.get('sensitive'));
+      }
+
+      map.set('idempotencyKey', uuid());
+    });
   case COMPOSE_SPOILERNESS_CHANGE:
     return state.withMutations(map => {
       map.set('spoiler_text', '');
       map.set('spoiler', !state.get('spoiler'));
       map.set('idempotencyKey', uuid());
+
+      if (!state.get('sensitive') && state.get('media_attachments').size >= 1) {
+        map.set('sensitive', true);
+      }
     });
   case COMPOSE_SPOILER_TEXT_CHANGE:
     return state
diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js
index 4a8a57767..acb85f626 100644
--- a/app/javascript/mastodon/service_worker/web_push_notifications.js
+++ b/app/javascript/mastodon/service_worker/web_push_notifications.js
@@ -1,3 +1,45 @@
+const MAX_NOTIFICATIONS = 5;
+const GROUP_TAG = 'tag';
+
+// Avoid loading intl-messageformat and dealing with locales in the ServiceWorker
+const formatGroupTitle = (message, count) => message.replace('%{count}', count);
+
+const notify = options =>
+  self.registration.getNotifications().then(notifications => {
+    if (notifications.length === MAX_NOTIFICATIONS) {
+      // Reached the maximum number of notifications, proceed with grouping
+      const group = {
+        title: formatGroupTitle(options.data.message, notifications.length + 1),
+        body: notifications
+          .sort((n1, n2) => n1.timestamp < n2.timestamp)
+          .map(notification => notification.title).join('\n'),
+        badge: '/badge.png',
+        icon: '/android-chrome-192x192.png',
+        tag: GROUP_TAG,
+        data: {
+          url: (new URL('/web/notifications', self.location)).href,
+          count: notifications.length + 1,
+          message: options.data.message,
+        },
+      };
+
+      notifications.forEach(notification => notification.close());
+
+      return self.registration.showNotification(group.title, group);
+    } else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) {
+      // Already grouped, proceed with appending the notification to the group
+      const group = cloneNotification(notifications[0]);
+
+      group.title = formatGroupTitle(group.data.message, group.data.count + 1);
+      group.body = `${options.title}\n${group.body}`;
+      group.data = { ...group.data, count: group.data.count + 1 };
+
+      return self.registration.showNotification(group.title, group);
+    }
+
+    return self.registration.showNotification(options.title, options);
+  });
+
 const handlePush = (event) => {
   const options = event.data.json();
 
@@ -17,7 +59,7 @@ const handlePush = (event) => {
     options.actions = options.data.actions;
   }
 
-  event.waitUntil(self.registration.showNotification(options.title, options));
+  event.waitUntil(notify(options));
 };
 
 const cloneNotification = (notification) => {
@@ -50,22 +92,37 @@ const makeRequest = (notification, action) =>
     credentials: 'include',
   });
 
+const findBestClient = clients => {
+  const focusedClient = clients.find(client => client.focused);
+  const visibleClient = clients.find(client => client.visibilityState === 'visible');
+
+  return focusedClient || visibleClient || clients[0];
+};
+
 const openUrl = url =>
   self.clients.matchAll({ type: 'window' }).then(clientList => {
-    if (clientList.length !== 0 && 'navigate' in clientList[0]) { // Chrome 42-48 does not support navigate
-      const webClients = clientList
-        .filter(client => /\/web\//.test(client.url))
-        .sort(client => client !== 'visible');
+    if (clientList.length !== 0) {
+      const webClients = clientList.filter(client => /\/web\//.test(client.url));
 
-      const visibleClient = clientList.find(client => client.visibilityState === 'visible');
-      const focusedClient = clientList.find(client => client.focused);
+      if (webClients.length !== 0) {
+        const client = findBestClient(webClients);
 
-      const client = webClients[0] || visibleClient || focusedClient || clientList[0];
+        const { pathname } = new URL(url);
 
-      return client.navigate(url).then(client => client.focus());
-    } else {
-      return self.clients.openWindow(url);
+        if (pathname.startsWith('/web/')) {
+          return client.focus().then(client => client.postMessage({
+            type: 'navigate',
+            path: pathname.slice('/web/'.length - 1),
+          }));
+        }
+      } else if ('navigate' in clientList[0]) { // Chrome 42-48 does not support navigate
+        const client = findBestClient(clientList);
+
+        return client.navigate(url).then(client => client.focus());
+      }
     }
+
+    return self.clients.openWindow(url);
   });
 
 const removeActionFromNotification = (notification, action) => {
diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss
index deab66ff2..66da44086 100644
--- a/app/javascript/styles/about.scss
+++ b/app/javascript/styles/about.scss
@@ -121,7 +121,7 @@
 
 .information-board {
   background: darken($ui-base-color, 4%);
-  padding: 40px 0;
+  padding: 20px 0;
 
   .panel {
     position: absolute;
@@ -147,10 +147,15 @@
       white-space: nowrap;
       overflow: hidden;
 
+      a,
       span {
         font-weight: 400;
         color: lighten($ui-base-color, 34%);
       }
+
+      a {
+        text-decoration: none;
+      }
     }
   }
 
@@ -162,13 +167,14 @@
   .information-board-sections {
     display: flex;
     justify-content: space-between;
+    flex-wrap: wrap;
   }
 
   .section {
     flex: 1 0 0;
     font: 16px/28px 'mastodon-font-sans-serif', sans-serif;
     text-align: right;
-    padding: 0 15px;
+    padding: 10px 15px;
 
     span,
     strong {
@@ -190,14 +196,6 @@
       color: $primary-text-color;
     }
   }
-
-  @media screen and (max-width: 500px) {
-    flex-direction: column;
-
-    .section {
-      text-align: left;
-    }
-  }
 }
 
 .owner {
@@ -317,6 +315,17 @@
     }
   }
 
+  p,
+  li {
+    font: inherit;
+    font-weight: inherit;
+    margin-bottom: 0;
+  }
+
+  hr {
+    border-color: rgba($ui-base-lighter-color, .6);
+  }
+
   .header {
     line-height: 30px;
     overflow: hidden;
@@ -553,6 +562,7 @@
   }
 
   #mastodon-timeline {
+    display: flex;
     -webkit-overflow-scrolling: touch;
     -ms-overflow-style: -ms-autohiding-scrollbar;
     font-family: 'mastodon-font-sans-serif', sans-serif;
@@ -567,11 +577,20 @@
     overflow: hidden;
     box-shadow: 0 0 6px rgba($black, 0.1);
 
+    .column-header {
+      color: inherit;
+      font-family: inherit;
+      font-size: 16px;
+      line-height: inherit;
+      font-weight: inherit;
+      margin: 0;
+      padding: 15px;
+    }
+
     .column {
       padding: 0;
       border-radius: 4px;
       overflow: hidden;
-      height: 100%;
     }
 
     .scrollable {
@@ -652,21 +671,17 @@
     }
   }
 
-  @media screen and (max-width: 800px) {
+  @media screen and (max-width: 840px) {
     .container {
       padding: 0 20px;
     }
 
-    .information-board {
-      padding-bottom: 20px;
-    }
-
     .information-board .container {
       padding-right: 20px;
 
       .panel {
         position: static;
-        margin-top: 30px;
+        margin-top: 20px;
         width: 100%;
         border-radius: 4px;
 
@@ -694,6 +709,10 @@
   @media screen and (max-width: 675px) {
     .header-wrapper {
       padding-top: 0;
+
+      &.compact .hero .heading {
+        padding-bottom: 20px;
+      }
     }
 
     .header .container,
@@ -707,14 +726,13 @@
     }
 
     .header {
-      padding-top: 0;
 
       .hero {
         margin-top: 30px;
         padding: 0;
 
         .heading {
-          padding-bottom: 20px;
+          padding: 0 20px 20px;
         }
       }
 
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
index fe74bae84..fa604df5c 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -149,12 +149,16 @@
       color: $ui-base-lighter-color;
     }
 
+    &.disabled {
+      color: $ui-primary-color;
+    }
+
     &.active {
       color: $ui-highlight-color;
-    }
 
-    &.disabled {
-      color: $ui-primary-color;
+      &.disabled {
+        color: lighten($ui-highlight-color, 13%);
+      }
     }
   }
 
@@ -215,16 +219,18 @@
 }
 
 .dropdown--active::after {
-  content: "";
-  display: block;
-  position: absolute;
-  width: 0;
-  height: 0;
-  border-style: solid;
-  border-width: 0 4.5px 7.8px;
-  border-color: transparent transparent $ui-secondary-color;
-  bottom: 8px;
-  right: 104px;
+  @media screen and (min-width: 1025px) {
+    content: "";
+    display: block;
+    position: absolute;
+    width: 0;
+    height: 0;
+    border-style: solid;
+    border-width: 0 4.5px 7.8px;
+    border-color: transparent transparent $ui-secondary-color;
+    bottom: 8px;
+    right: 104px;
+  }
 }
 
 .invisible {
@@ -1837,6 +1843,8 @@
   cursor: pointer;
   flex: 0 0 auto;
   font-size: 16px;
+  border: 0;
+  text-align: start;
   padding: 15px;
   z-index: 3;
 
@@ -1999,12 +2007,6 @@
   &:hover {
     background: lighten($ui-base-color, 11%);
   }
-
-  &.hidden-on-mobile {
-    @include single-column('screen and (max-width: 1024px)') {
-      display: none;
-    }
-  }
 }
 
 .column-link__icon {
@@ -2388,12 +2390,6 @@ button.icon-button.active i.fa-retweet {
     }
   }
 
-  &.hidden-on-mobile {
-    @include single-column('screen and (max-width: 1024px)') {
-      display: none;
-    }
-  }
-
   &:focus,
   &:active {
     outline: 0;
@@ -2672,6 +2668,8 @@ button.icon-button.active i.fa-retweet {
   cursor: pointer;
   display: flex;
   flex-direction: column;
+  border: 0;
+  width: 100%;
   height: 100%;
   justify-content: center;
   position: relative;
@@ -2754,6 +2752,7 @@ button.icon-button.active i.fa-retweet {
   align-items: center;
   background: rgba($base-overlay-background, 0.5);
   box-sizing: border-box;
+  border: 0;
   color: $primary-text-color;
   cursor: pointer;
   display: flex;
@@ -3848,7 +3847,8 @@ button.icon-button.active i.fa-retweet {
 
 .boost-modal,
 .confirmation-modal,
-.report-modal {
+.report-modal,
+.actions-modal {
   background: lighten($ui-secondary-color, 8%);
   color: $ui-base-color;
   border-radius: 8px;
@@ -3873,6 +3873,15 @@ button.icon-button.active i.fa-retweet {
   }
 }
 
+.actions-modal {
+  .status {
+    background: $white;
+    border-bottom-color: $ui-secondary-color;
+    padding-top: 10px;
+    padding-bottom: 10px;
+  }
+}
+
 .boost-modal__container {
   overflow-x: scroll;
   padding: 10px;
@@ -3914,7 +3923,7 @@ button.icon-button.active i.fa-retweet {
 }
 
 .confirmation-modal {
-  max-width: 280px;
+  max-width: 85vw;
 
   @media screen and (min-width: 480px) {
     max-width: 380px;
@@ -3939,6 +3948,47 @@ button.icon-button.active i.fa-retweet {
   }
 }
 
+.actions-modal {
+  .status {
+    overflow-y: auto;
+    max-height: 300px;
+  }
+
+  max-height: 80vh;
+  max-width: 80vw;
+
+  ul {
+    overflow-y: auto;
+    flex-shrink: 0;
+
+    li:empty {
+      margin: 0;
+    }
+
+    li:not(:empty) {
+      a {
+        color: $ui-base-color;
+        display: flex;
+        padding: 10px;
+        align-items: center;
+        text-decoration: none;
+
+        &.active {
+          &,
+          button {
+            background: $ui-highlight-color;
+            color: $primary-text-color;
+          }
+        }
+
+        button:first-child {
+          margin-right: 10px;
+        }
+      }
+    }
+  }
+}
+
 .confirmation-modal__action-bar {
   .confirmation-modal__cancel-button {
     background-color: transparent;
diff --git a/app/lib/emoji.rb b/app/lib/emoji.rb
index e444b6893..45b7f53de 100644
--- a/app/lib/emoji.rb
+++ b/app/lib/emoji.rb
@@ -6,7 +6,7 @@ class Emoji
   include Singleton
 
   def initialize
-    data = Oj.load(File.open(File.join(Rails.root, 'lib', 'assets', 'emoji.json')))
+    data = Oj.load(File.open(Rails.root.join('lib', 'assets', 'emoji.json')))
 
     @map = {}
 
@@ -32,7 +32,7 @@ class Emoji
 
   def codepoint_to_unicode(codepoint)
     if codepoint.include?('-')
-      codepoint.split('-').map(&:hex).pack('U')
+      codepoint.split('-').map(&:hex).pack('U*')
     else
       [codepoint.hex].pack('U')
     end
diff --git a/app/lib/exceptions.rb b/app/lib/exceptions.rb
index 34d84a34f..b2489711d 100644
--- a/app/lib/exceptions.rb
+++ b/app/lib/exceptions.rb
@@ -8,11 +8,11 @@ module Mastodon
 
   class UnexpectedResponseError < Error
     def initialize(response = nil)
-      @response = response
-    end
-
-    def to_s
-      "#{@response.uri} returned code #{@response.code}"
+      if response.respond_to? :uri
+        super("#{response.uri} returned code #{response.code}")
+      else
+        super
+      end
     end
   end
 end
diff --git a/app/lib/language_detector.rb b/app/lib/language_detector.rb
index 6d6ae2fb3..cc7509fdc 100644
--- a/app/lib/language_detector.rb
+++ b/app/lib/language_detector.rb
@@ -33,9 +33,7 @@ class LanguageDetector
 
   def simplified_text
     text.dup.tap do |new_text|
-      URI.extract(new_text).each do |url|
-        new_text.gsub!(url, '')
-      end
+      new_text.gsub!(FetchLinkCardService::URL_PATTERN, '')
       new_text.gsub!(Account::MENTION_RE, '')
       new_text.gsub!(Tag::HASHTAG_RE, '')
       new_text.gsub!(/\s+/, ' ')
diff --git a/app/models/account.rb b/app/models/account.rb
index 46cc84746..e217733f5 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -44,7 +44,7 @@
 #
 
 class Account < ApplicationRecord
-  MENTION_RE = /(?:^|[^\/[:word:]])@([a-z0-9_]+(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
+  MENTION_RE = /(?:^|[^\/[:word:]])@(([a-z0-9_]+)(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
 
   include AccountAvatar
   include AccountFinderConcern
diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb
index 86df9b591..e76f61278 100644
--- a/app/models/web/push_subscription.rb
+++ b/app/models/web/push_subscription.rb
@@ -53,6 +53,7 @@ class Web::PushSubscription < ApplicationRecord
           url: url,
           actions: actions,
           access_token: access_token,
+          message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker
         }
       ),
       endpoint: endpoint,
@@ -117,7 +118,7 @@ class Web::PushSubscription < ApplicationRecord
       when :mention then [
         {
           title: translate('push_notifications.mention.action_favourite'),
-          icon: full_asset_url('emoji/2764.png', skip_pipeline: true),
+          icon: full_asset_url('web-push-icon_favourite.png', skip_pipeline: true),
           todo: 'request',
           method: 'POST',
           action: "/api/v1/statuses/#{notification.target_status.id}/favourite",
@@ -130,11 +131,11 @@ class Web::PushSubscription < ApplicationRecord
     can_boost = notification.type.equal?(:mention) && !notification.target_status.nil? && !notification.target_status.hidden?
 
     if should_hide
-      actions.insert(0, title: translate('push_notifications.mention.action_expand'), icon: full_asset_url('emoji/1f441.png'), todo: 'expand', action: 'expand')
+      actions.insert(0, title: translate('push_notifications.mention.action_expand'), icon: full_asset_url('web-push-icon_expand.png', skip_pipeline: true), todo: 'expand', action: 'expand')
     end
 
     if can_boost
-      actions << { title: translate('push_notifications.mention.action_boost'), icon: full_asset_url('emoji/1f504.png'), todo: 'request', method: 'POST', action: "/api/v1/statuses/#{notification.target_status.id}/reblog" }
+      actions << { title: translate('push_notifications.mention.action_boost'), icon: full_asset_url('web-push-icon_reblog.png', skip_pipeline: true), todo: 'request', method: 'POST', action: "/api/v1/statuses/#{notification.target_status.id}/reblog" }
     end
 
     actions
@@ -160,6 +161,7 @@ class Web::PushSubscription < ApplicationRecord
           content: translate('push_notifications.subscribed.body'),
           actions: [],
           url: web_url('notifications'),
+          message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker
         }
       ),
       endpoint: endpoint,
diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb
index c266494f0..b0c663d02 100644
--- a/app/services/account_search_service.rb
+++ b/app/services/account_search_service.rb
@@ -18,7 +18,7 @@ class AccountSearchService < BaseService
     return [] if query_blank_or_hashtag? || limit < 1
 
     if resolving_non_matching_remote_account?
-      [ResolveRemoteAccountService.new.call("#{query_username}@#{query_domain}")]
+      [ResolveRemoteAccountService.new.call("#{query_username}@#{query_domain}")].compact
     else
       search_results_and_exact_match.compact.uniq.slice(0, limit)
     end
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index b462154ae..ab810c628 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -90,7 +90,7 @@ class BatchedRemoveStatusService < BaseService
     key = FeedManager.instance.key(:home, follower_id)
 
     originals = statuses.reject(&:reblog?)
-    reblogs   = statuses.reject { |s| !s.reblog? }
+    reblogs   = statuses.select(&:reblog?)
 
     # Quickly remove all originals
     redis.pipelined do
diff --git a/app/validators/status_length_validator.rb b/app/validators/status_length_validator.rb
index cd791e2f3..2ce5d1ee9 100644
--- a/app/validators/status_length_validator.rb
+++ b/app/validators/status_length_validator.rb
@@ -5,6 +5,27 @@ class StatusLengthValidator < ActiveModel::Validator
 
   def validate(status)
     return unless status.local? && !status.reblog?
-    status.errors.add(:text, I18n.t('statuses.over_character_limit', max: MAX_CHARS)) if [status.text, status.spoiler_text].join.mb_chars.grapheme_length > MAX_CHARS
+    status.errors.add(:text, I18n.t('statuses.over_character_limit', max: MAX_CHARS)) if too_long?(status)
+  end
+
+  private
+
+  def too_long?(status)
+    countable_length(status) > MAX_CHARS
+  end
+
+  def countable_length(status)
+    total_text(status).mb_chars.grapheme_length
+  end
+
+  def total_text(status)
+    [status.spoiler_text, countable_text(status)].join
+  end
+
+  def countable_text(status)
+    status.text.dup.tap do |new_text|
+      new_text.gsub!(FetchLinkCardService::URL_PATTERN, 'x' * 23)
+      new_text.gsub!(Account::MENTION_RE, '@\2')
+    end
   end
 end
diff --git a/app/views/about/_contact.html.haml b/app/views/about/_contact.html.haml
index 822639962..cf21ad5a3 100644
--- a/app/views/about/_contact.html.haml
+++ b/app/views/about/_contact.html.haml
@@ -2,7 +2,10 @@
   .panel-header
     = succeed ':' do
       = t 'about.contact'
-    %span{ title: contact.site_contact_email.presence }= contact.site_contact_email.presence
+    - if contact.site_contact_email.present?
+      = mail_to contact.site_contact_email, nil, title: contact.site_contact_email
+    - else
+      %span= t 'about.contact_unavailable'
   .panel-body
     - if contact.contact_account
       .owner
diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml
index eeeb0088f..f1c6e6b9d 100644
--- a/app/views/about/_registration.html.haml
+++ b/app/views/about/_registration.html.haml
@@ -14,15 +14,13 @@
     required: true,
     input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
   = f.input :password,
-    autocomplete: 'off',
     placeholder: t('simple_form.labels.defaults.password'),
     required: true,
-    input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }
+    input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }
   = f.input :password_confirmation,
-    autocomplete: 'off',
     placeholder: t('simple_form.labels.defaults.confirm_password'),
     required: true,
-    input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password') }
+    input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }
 
   .actions
     = f.button :button, t('auth.register'), type: :submit, class: 'button button-alternative'
diff --git a/app/views/admin_mailer/new_report.text.erb b/app/views/admin_mailer/new_report.text.erb
index 6fa744bc3..671ae5ca7 100644
--- a/app/views/admin_mailer/new_report.text.erb
+++ b/app/views/admin_mailer/new_report.text.erb
@@ -1,4 +1,4 @@
-<%= display_name(@me) %>,
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
 
 <%= raw t('admin_mailer.new_report.body', target: @report.target_account.acct, reporter: @report.account.acct) %>
 
diff --git a/app/views/auth/passwords/edit.html.haml b/app/views/auth/passwords/edit.html.haml
index 5e2b4fbd6..5ef3de976 100644
--- a/app/views/auth/passwords/edit.html.haml
+++ b/app/views/auth/passwords/edit.html.haml
@@ -5,8 +5,8 @@
   = render 'shared/error_messages', object: resource
   = f.input :reset_password_token, as: :hidden
 
-  = f.input :password, autofocus: true, autocomplete: 'off', placeholder: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password') }
-  = f.input :password_confirmation, autocomplete: 'off', placeholder: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password') }
+  = f.input :password, autofocus: true, placeholder: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off' }
+  = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' }
 
   .actions
     = f.button :button, t('auth.set_new_password'), type: :submit
diff --git a/app/views/auth/registrations/_sessions.html.haml b/app/views/auth/registrations/_sessions.html.haml
index 84207862a..7ac578bb1 100644
--- a/app/views/auth/registrations/_sessions.html.haml
+++ b/app/views/auth/registrations/_sessions.html.haml
@@ -19,10 +19,10 @@
         %td
           %samp= session.ip
         %td
-          - if request.session['auth_id'] == session.session_id
+          - if current_session.session_id == session.session_id
             = t 'sessions.current_session'
           - else
             %time.time-ago{ datetime: session.updated_at.iso8601, title: l(session.updated_at) }= l(session.updated_at)
         %td
-          - if request.session['auth_id'] != session.session_id
+          - if current_session.session_id != session.session_id
             = table_link_to 'times', t('sessions.revoke'), settings_session_path(session), method: :delete
diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml
index fbc8d017b..f016a4883 100644
--- a/app/views/auth/registrations/edit.html.haml
+++ b/app/views/auth/registrations/edit.html.haml
@@ -5,9 +5,9 @@
   = render 'shared/error_messages', object: resource
 
   = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
-  = f.input :password, autocomplete: 'off', placeholder: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password') }
-  = f.input :password_confirmation, autocomplete: 'off', placeholder: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password') }
-  = f.input :current_password, autocomplete: 'off', placeholder: t('simple_form.labels.defaults.current_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password') }
+  = f.input :password, placeholder: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off' }
+  = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' }
+  = f.input :current_password, placeholder: t('simple_form.labels.defaults.current_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'off' }
 
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index af7ee2b28..d0529a20c 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -11,8 +11,8 @@
         = "@#{site_hostname}"
 
   = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
-  = f.input :password, autocomplete: 'off', placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }
-  = f.input :password_confirmation, autocomplete: 'off', placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password') }
+  = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }
+  = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }
 
   .actions
     = f.button :button, t('auth.register'), type: :submit
diff --git a/app/views/auth/sessions/new.html.haml b/app/views/auth/sessions/new.html.haml
index f613100c1..e589377bf 100644
--- a/app/views/auth/sessions/new.html.haml
+++ b/app/views/auth/sessions/new.html.haml
@@ -3,7 +3,7 @@
 
 = simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f|
   = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
-  = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }
+  = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }
 
   .actions
     = f.button :button, t('auth.login'), type: :submit
diff --git a/app/views/auth/sessions/two_factor.html.haml b/app/views/auth/sessions/two_factor.html.haml
index 0321e1ec7..cb5e32f3e 100644
--- a/app/views/auth/sessions/two_factor.html.haml
+++ b/app/views/auth/sessions/two_factor.html.haml
@@ -3,7 +3,7 @@
 
 = simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
   = f.input :otp_attempt, type: :number, placeholder: t('simple_form.labels.defaults.otp_attempt'),
-      input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt') }, required: true, autofocus: true, autocomplete: 'off',
+      input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, required: true, autofocus: true,
       hint: t('simple_form.hints.sessions.otp')
 
   .actions
diff --git a/app/views/notification_mailer/digest.text.erb b/app/views/notification_mailer/digest.text.erb
index b63352978..e0d1f9b8b 100644
--- a/app/views/notification_mailer/digest.text.erb
+++ b/app/views/notification_mailer/digest.text.erb
@@ -1,4 +1,4 @@
-<%= display_name(@me) %>,
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
 
 <%= raw t('notification_mailer.digest.body', since: l(@since), instance: root_url) %>
 <% @notifications.each do |notification| %>
diff --git a/app/views/notification_mailer/favourite.text.erb b/app/views/notification_mailer/favourite.text.erb
index 795045307..2581b4909 100644
--- a/app/views/notification_mailer/favourite.text.erb
+++ b/app/views/notification_mailer/favourite.text.erb
@@ -1,4 +1,4 @@
-<%= display_name(@me) %>,
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
 
 <%= raw t('notification_mailer.favourite.body', name: @account.acct) %>
 
diff --git a/app/views/notification_mailer/follow.text.erb b/app/views/notification_mailer/follow.text.erb
index af41a3080..cbe46f552 100644
--- a/app/views/notification_mailer/follow.text.erb
+++ b/app/views/notification_mailer/follow.text.erb
@@ -1,4 +1,4 @@
-<%= display_name(@me) %>,
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
 
 <%= raw t('notification_mailer.follow.body', name: @account.acct) %>
 
diff --git a/app/views/notification_mailer/follow_request.text.erb b/app/views/notification_mailer/follow_request.text.erb
index 49087a575..a018394b8 100644
--- a/app/views/notification_mailer/follow_request.text.erb
+++ b/app/views/notification_mailer/follow_request.text.erb
@@ -1,4 +1,4 @@
-<%= display_name(@me) %>,
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
 
 <%= raw t('notification_mailer.follow_request.body', name: @account.acct) %>
 
diff --git a/app/views/notification_mailer/mention.text.erb b/app/views/notification_mailer/mention.text.erb
index b38c5a4d0..03f53813b 100644
--- a/app/views/notification_mailer/mention.text.erb
+++ b/app/views/notification_mailer/mention.text.erb
@@ -1,4 +1,4 @@
-<%= display_name(@me) %>,
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
 
 <%= raw t('notification_mailer.mention.body', name: @status.account.acct) %>
 
diff --git a/app/views/notification_mailer/reblog.text.erb b/app/views/notification_mailer/reblog.text.erb
index fd85437a7..8fc841bf6 100644
--- a/app/views/notification_mailer/reblog.text.erb
+++ b/app/views/notification_mailer/reblog.text.erb
@@ -1,4 +1,4 @@
-<%= display_name(@me) %>,
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
 
 <%= raw t('notification_mailer.reblog.body', name: @account.acct) %>
 
diff --git a/app/views/settings/deletes/show.html.haml b/app/views/settings/deletes/show.html.haml
index d49a7bd0c..b246f83a1 100644
--- a/app/views/settings/deletes/show.html.haml
+++ b/app/views/settings/deletes/show.html.haml
@@ -10,7 +10,7 @@
 
   %p.hint= t('deletes.description_html')
 
-  = f.input :password, autocomplete: 'off', placeholder: t('simple_form.labels.defaults.current_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password') }, hint: t('deletes.confirm_password')
+  = f.input :password, placeholder: t('simple_form.labels.defaults.current_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'off' }, hint: t('deletes.confirm_password')
 
   .actions
     = f.button :button, t('deletes.proceed'), type: :submit, class: 'negative'
diff --git a/app/views/settings/two_factor_authentication/confirmations/new.html.haml b/app/views/settings/two_factor_authentication/confirmations/new.html.haml
index b7eb0c23d..fd4a3e768 100644
--- a/app/views/settings/two_factor_authentication/confirmations/new.html.haml
+++ b/app/views/settings/two_factor_authentication/confirmations/new.html.haml
@@ -11,7 +11,7 @@
       %p.hint= t('two_factor_authentication.manual_instructions')
       %samp.qr-alternative__code= current_user.otp_secret.scan(/.{4}/).join(' ')
 
-  = f.input :code, hint: t('two_factor_authentication.code_hint'), placeholder: t('simple_form.labels.defaults.otp_attempt')
+  = f.input :code, hint: t('two_factor_authentication.code_hint'), placeholder: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }
 
   .actions
     = f.button :button, t('two_factor_authentication.enable'), type: :submit
diff --git a/app/views/settings/two_factor_authentications/show.html.haml b/app/views/settings/two_factor_authentications/show.html.haml
index 8ba42a101..67a64a046 100644
--- a/app/views/settings/two_factor_authentications/show.html.haml
+++ b/app/views/settings/two_factor_authentications/show.html.haml
@@ -10,7 +10,7 @@
   %hr/
 
   = simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f|
-    = f.input :code, hint: t('two_factor_authentication.code_hint'), placeholder: t('simple_form.labels.defaults.otp_attempt')
+    = f.input :code, hint: t('two_factor_authentication.code_hint'), placeholder: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }
 
     .actions
       = f.button :button, t('two_factor_authentication.disable'), type: :submit
diff --git a/app/views/user_mailer/confirmation_instructions.fa.html.erb b/app/views/user_mailer/confirmation_instructions.fa.html.erb
index cccdaa2c5..3e77e043b 100644
--- a/app/views/user_mailer/confirmation_instructions.fa.html.erb
+++ b/app/views/user_mailer/confirmation_instructions.fa.html.erb
@@ -9,4 +9,4 @@
 
 <p dir="rtl">با احترام,<p>
 
-<p dir="rtl">گردانندگان سرور <%= @instance %></p>
+<p dir="rtl">گردانندگان سرور <%= @instance %></p>
\ No newline at end of file
diff --git a/app/views/user_mailer/confirmation_instructions.fa.text.erb b/app/views/user_mailer/confirmation_instructions.fa.text.erb
index 904bd5bfe..76727b3be 100644
--- a/app/views/user_mailer/confirmation_instructions.fa.text.erb
+++ b/app/views/user_mailer/confirmation_instructions.fa.text.erb
@@ -9,4 +9,4 @@
 
 با احترام،
 
-گردانندگان سرور <%= @instance %>
+گردانندگان سرور <%= @instance %>
\ No newline at end of file
diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb
index 035a59048..88645cf33 100644
--- a/app/workers/pubsubhubbub/delivery_worker.rb
+++ b/app/workers/pubsubhubbub/delivery_worker.rb
@@ -16,6 +16,8 @@ class Pubsubhubbub::DeliveryWorker
     @subscription = Subscription.find(subscription_id)
     @payload = payload
     process_delivery unless blocked_domain?
+  rescue => e
+    raise e.class, "Delivery failed for #{subscription&.callback_url}: #{e.message}"
   end
 
   private
diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb
index ce467d18b..ea246128d 100644
--- a/app/workers/pubsubhubbub/distribution_worker.rb
+++ b/app/workers/pubsubhubbub/distribution_worker.rb
@@ -14,7 +14,7 @@ class Pubsubhubbub::DistributionWorker
     @subscriptions = active_subscriptions.to_a
 
     distribute_public!(stream_entries.reject(&:hidden?))
-    distribute_hidden!(stream_entries.reject { |s| !s.hidden? })
+    distribute_hidden!(stream_entries.select(&:hidden?))
   end
 
   private
@@ -35,7 +35,7 @@ class Pubsubhubbub::DistributionWorker
     @payload = OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, stream_entries))
     @domains = @account.followers.domains
 
-    Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions.reject { |s| !allowed_to_receive?(s.callback_url, s.domain) }) do |subscription|
+    Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions.select { |s| allowed_to_receive?(s.callback_url, s.domain) }) do |subscription|
       [subscription.id, @payload]
     end
   end
diff --git a/app/workers/pubsubhubbub/subscribe_worker.rb b/app/workers/pubsubhubbub/subscribe_worker.rb
index 6865e7136..7560c2671 100644
--- a/app/workers/pubsubhubbub/subscribe_worker.rb
+++ b/app/workers/pubsubhubbub/subscribe_worker.rb
@@ -3,7 +3,7 @@
 class Pubsubhubbub::SubscribeWorker
   include Sidekiq::Worker
 
-  sidekiq_options queue: 'push', retry: 10, unique: :until_executed
+  sidekiq_options queue: 'push', retry: 10, unique: :until_executed, dead: false
 
   sidekiq_retry_in do |count|
     case count
@@ -18,9 +18,17 @@ class Pubsubhubbub::SubscribeWorker
     end
   end
 
+  sidekiq_retries_exhausted do |msg, _e|
+    account = Account.find(msg['args'].first)
+    logger.error "PuSH subscription attempts for #{account.acct} exhausted. Unsubscribing"
+    ::UnsubscribeService.new.call(account)
+  end
+
   def perform(account_id)
     account = Account.find(account_id)
     logger.debug "PuSH re-subscribing to #{account.acct}"
     ::SubscribeService.new.call(account)
+  rescue => e
+    raise e.class, "Subscribe failed for #{account&.acct}: #{e.message}"
   end
 end
diff --git a/app/workers/web_push_notification_worker.rb b/app/workers/web_push_notification_worker.rb
index da4043ddb..eacea04c3 100644
--- a/app/workers/web_push_notification_worker.rb
+++ b/app/workers/web_push_notification_worker.rb
@@ -7,16 +7,19 @@ class WebPushNotificationWorker
 
   def perform(session_activation_id, notification_id)
     session_activation = SessionActivation.find(session_activation_id)
-    notification = Notification.find(notification_id)
+    notification       = Notification.find(notification_id)
 
-    begin
-      session_activation.web_push_subscription.push(notification)
-    rescue Webpush::InvalidSubscription, Webpush::ExpiredSubscription => e
-      # Subscription expiration is not currently implemented in any browser
-      session_activation.web_push_subscription.destroy!
-      session_activation.update!(web_push_subscription: nil)
+    return if session_activation.web_push_subscription.nil? || notification.activity.nil?
 
-      raise e
-    end
+    session_activation.web_push_subscription.push(notification)
+  rescue Webpush::InvalidSubscription, Webpush::ExpiredSubscription
+    # Subscription expiration is not currently implemented in any browser
+
+    session_activation.web_push_subscription.destroy!
+    session_activation.update!(web_push_subscription: nil)
+
+    true
+  rescue ActiveRecord::RecordNotFound
+    true
   end
 end