about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorStarfall <admin@plural.cafe>2020-05-31 12:56:29 -0500
committerStarfall <admin@plural.cafe>2020-05-31 12:56:29 -0500
commit76f79dd29909d39f1d36ef48e6892223d59e29d7 (patch)
tree8a5ace5fea5cf8a4c6f1c55e01db99a289c4b1e3 /app
parent0167552bed46508efeed266853a1ee4437e12998 (diff)
parentddcd9fe9f0e85b1db12ae584749e680146611f9a (diff)
Merge branch 'glitch'
Diffstat (limited to 'app')
-rw-r--r--app/chewy/statuses_index.rb2
-rw-r--r--app/javascript/flavours/glitch/actions/markers.js91
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js4
-rw-r--r--app/javascript/flavours/glitch/actions/streaming.js2
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js12
-rw-r--r--app/javascript/flavours/glitch/components/media_gallery.js4
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.js17
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js2
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/index.js16
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/markers.js25
-rw-r--r--app/javascript/mastodon/actions/markers.js94
-rw-r--r--app/javascript/mastodon/actions/notifications.js4
-rw-r--r--app/javascript/mastodon/actions/streaming.js2
-rw-r--r--app/javascript/mastodon/actions/timelines.js11
-rw-r--r--app/javascript/mastodon/components/media_gallery.js5
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/index.js16
-rw-r--r--app/javascript/mastodon/features/ui/index.js4
-rw-r--r--app/javascript/mastodon/locales/bg.json2
-rw-r--r--app/javascript/mastodon/locales/br.json2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json4
-rw-r--r--app/javascript/mastodon/locales/en.json2
-rw-r--r--app/javascript/mastodon/locales/ga.json2
-rw-r--r--app/javascript/mastodon/locales/hi.json2
-rw-r--r--app/javascript/mastodon/locales/kn.json2
-rw-r--r--app/javascript/mastodon/locales/lt.json2
-rw-r--r--app/javascript/mastodon/locales/lv.json2
-rw-r--r--app/javascript/mastodon/locales/mk.json2
-rw-r--r--app/javascript/mastodon/locales/ml.json2
-rw-r--r--app/javascript/mastodon/locales/mr.json2
-rw-r--r--app/javascript/mastodon/locales/ms.json2
-rw-r--r--app/javascript/mastodon/locales/ur.json2
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/reducers/markers.js25
-rw-r--r--app/lib/sanitize_config.rb2
-rw-r--r--app/lib/tag_manager.rb1
-rw-r--r--app/models/form/admin_settings.rb1
-rw-r--r--app/models/import.rb2
-rw-r--r--app/models/status.rb2
-rw-r--r--app/serializers/activitypub/note_serializer.rb6
-rw-r--r--app/views/admin/settings/edit.html.haml3
42 files changed, 308 insertions, 83 deletions
diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb
index bec9ed88b..d4b05fca9 100644
--- a/app/chewy/statuses_index.rb
+++ b/app/chewy/statuses_index.rb
@@ -33,7 +33,7 @@ class StatusesIndex < Chewy::Index
 
   define_type ::Status.unscoped.kept.without_reblogs.includes(:media_attachments), delete_if: ->(status) { status.searchable_by.empty? } do
     crutch :mentions do |collection|
-      data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
+      data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local, silent: false).pluck(:status_id, :account_id)
       data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
     end
 
diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js
index 7ffab404d..96e29accf 100644
--- a/app/javascript/flavours/glitch/actions/markers.js
+++ b/app/javascript/flavours/glitch/actions/markers.js
@@ -1,38 +1,107 @@
 import api from 'flavours/glitch/util/api';
+import { debounce } from 'lodash';
+import compareId from 'flavours/glitch/util/compare_id';
+import { showAlertForError } from './alerts';
 
 export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST';
 export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS';
 export const MARKERS_FETCH_FAIL    = 'MARKERS_FETCH_FAIL';
+export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS';
 
-export const submitMarkers = () => (dispatch, getState) => {
+export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
   const accessToken = getState().getIn(['meta', 'access_token'], '');
-  const params      = {};
+  const params      = _buildParams(getState());
 
-  const lastHomeId         = getState().getIn(['timelines', 'home', 'items', 0]);
-  const lastNotificationId = getState().getIn(['notifications', 'lastReadId']);
+  if (Object.keys(params).length === 0) {
+    return;
+  }
+
+  // The Fetch API allows us to perform requests that will be carried out
+  // after the page closes. But that only works if the `keepalive` attribute
+  // is supported.
+  if (window.fetch && 'keepalive' in new Request('')) {
+    fetch('/api/v1/markers', {
+      keepalive: true,
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${accessToken}`,
+      },
+      body: JSON.stringify(params),
+    });
+    return;
+  } else if (navigator && navigator.sendBeacon) {
+    // Failing that, we can use sendBeacon, but we have to encode the data as
+    // FormData for DoorKeeper to recognize the token.
+    const formData = new FormData();
+    formData.append('bearer_token', accessToken);
+    for (const [id, value] of Object.entries(params)) {
+      formData.append(`${id}[last_read_id]`, value.last_read_id);
+    }
+    if (navigator.sendBeacon('/api/v1/markers', formData)) {
+      return;
+    }
+  }
+
+  // If neither Fetch nor sendBeacon worked, try to perform a synchronous
+  // request.
+  try {
+    const client = new XMLHttpRequest();
+
+    client.open('POST', '/api/v1/markers', false);
+    client.setRequestHeader('Content-Type', 'application/json');
+    client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
+    client.SUBMIT(JSON.stringify(params));
+  } catch (e) {
+    // Do not make the BeforeUnload handler error out
+  }
+};
+
+const _buildParams = (state) => {
+  const params = {};
+
+  const lastHomeId         = state.getIn(['timelines', 'home', 'items', 0]);
+  const lastNotificationId = state.getIn(['notifications', 'lastReadId']);
 
-  if (lastHomeId) {
+  if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
     params.home = {
       last_read_id: lastHomeId,
     };
   }
 
-  if (lastNotificationId && lastNotificationId !== '0') {
+  if (lastNotificationId && lastNotificationId !== '0' && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) {
     params.notifications = {
       last_read_id: lastNotificationId,
     };
   }
 
+  return params;
+};
+
+const debouncedSubmitMarkers = debounce((dispatch, getState) => {
+  const params = _buildParams(getState());
+
   if (Object.keys(params).length === 0) {
     return;
   }
 
-  const client = new XMLHttpRequest();
+  api().post('/api/v1/markers', params).then(() => {
+    dispatch(submitMarkersSuccess(params));
+  }).catch(error => {
+    dispatch(showAlertForError(error));
+  });
+}, 300000, { leading: true, trailing: true });
+
+export function submitMarkersSuccess({ home, notifications }) {
+  return {
+    type: MARKERS_SUBMIT_SUCCESS,
+    home: (home || {}).last_read_id,
+    notifications: (notifications || {}).last_read_id,
+  };
+};
 
-  client.open('POST', '/api/v1/markers', false);
-  client.setRequestHeader('Content-Type', 'application/json');
-  client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
-  client.send(JSON.stringify(params));
+export function submitMarkers() {
+  return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
 };
 
 export const fetchMarkers = () => (dispatch, getState) => {
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index b3de7b5bf..ceb1e6df6 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -7,6 +7,7 @@ import {
   importFetchedStatus,
   importFetchedStatuses,
 } from './importer';
+import { submitMarkers } from './markers';
 import { saveSettings } from './settings';
 import { defineMessages } from 'react-intl';
 import { List as ImmutableList } from 'immutable';
@@ -81,6 +82,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
       filtered = regex && regex.test(searchIndex);
     }
 
+    dispatch(submitMarkers());
+
     if (showInColumn) {
       dispatch(importFetchedAccount(notification.account));
 
@@ -168,6 +171,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
 
       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
       fetchRelatedRelationships(dispatch, response.data);
+      dispatch(submitMarkers());
     }).catch(error => {
       dispatch(expandNotificationsFail(error, isLoadingMore));
     }).finally(() => {
diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js
index 875013efc..e7e57f5f5 100644
--- a/app/javascript/flavours/glitch/actions/streaming.js
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -74,6 +74,6 @@ const refreshHomeTimelineAndNotification = (dispatch, done) => {
 export const connectUserStream      = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
 export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
 export const connectPublicStream    = ({ onlyMedia, onlyRemote } = {}) => connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`);
-export const connectHashtagStream   = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept);
+export const connectHashtagStream   = (id, tag, local, accept) => connectTimelineStream(`hashtag:${id}${local ? ':local' : ''}`, `hashtag${local ? ':local' : ''}&tag=${tag}`, null, accept);
 export const connectDirectStream    = () => connectTimelineStream('direct', 'direct');
 export const connectListStream      = id => connectTimelineStream(`list:${id}`, `list&list=${id}`);
diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js
index b01109134..9a7f62a08 100644
--- a/app/javascript/flavours/glitch/actions/timelines.js
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -1,4 +1,5 @@
 import { importFetchedStatus, importFetchedStatuses } from './importer';
+import { submitMarkers } from './markers';
 import api, { getLinks } from 'flavours/glitch/util/api';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 import compareId from 'flavours/glitch/util/compare_id';
@@ -49,6 +50,10 @@ export function updateTimeline(timeline, status, accept) {
       usePendingItems: preferPendingItems,
       filtered
     });
+
+    if (timeline === 'home') {
+      dispatch(submitMarkers());
+    }
   };
 };
 
@@ -112,6 +117,10 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
 
       dispatch(importFetchedStatuses(response.data));
       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
+
+      if (timelineId === 'home') {
+        dispatch(submitMarkers());
+      }
     }).catch(error => {
       dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
     }).finally(() => {
@@ -129,11 +138,12 @@ export const expandAccountFeaturedTimeline = accountId => expandTimeline(`accoun
 export const expandAccountMediaTimeline    = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
 export const expandListTimeline            = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
 export const expandHashtagTimeline         = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
-  return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
+  return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, {
     max_id: maxId,
     any: parseTags(tags, 'any'),
     all: parseTags(tags, 'all'),
     none: parseTags(tags, 'none'),
+    local: local,
   }, done);
 };
 
diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js
index 9472e34bf..973406ad2 100644
--- a/app/javascript/flavours/glitch/components/media_gallery.js
+++ b/app/javascript/flavours/glitch/components/media_gallery.js
@@ -23,7 +23,7 @@ const messages = defineMessages({
     id: 'status.sensitive_toggle',
   },
   toggle_visible: {
-    defaultMessage: 'Hide media',
+    defaultMessage: 'Hide {number, plural, one {image} other {images}}',
     id: 'media_gallery.toggle_visible',
   },
   warning: {
@@ -368,7 +368,7 @@ class MediaGallery extends React.PureComponent {
         </button>
       );
     } else if (visible) {
-      spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
+      spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' overlay onClick={this.handleOpen} />;
     } else {
       spoilerButton = (
         <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
index 3d736e83f..a7cb95222 100644
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
@@ -158,19 +158,12 @@ class ComposeForm extends ImmutablePureComponent {
     this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
   }
 
-  //  When the escape key is released, we focus the UI.
-  handleKeyUp = ({ key, ctrlKey, keyCode, metaKey, altKey }) => {
-    if (key === 'Escape') {
-      document.querySelector('.ui').parentElement.focus();
-    }
-
-    //  We submit the status on control/meta + enter.
-    if (keyCode === 13 && (ctrlKey || metaKey)) {
+  handleKeyDown = (e) => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
       this.handleSubmit();
     }
 
-    // Submit the status with secondary visibility on alt + enter.
-    if (keyCode === 13 && altKey) {
+    if (e.keyCode == 13 && e.altKey) {
       this.handleSecondarySubmit();
     }
   }
@@ -305,7 +298,7 @@ class ComposeForm extends ImmutablePureComponent {
             placeholder={intl.formatMessage(messages.spoiler_placeholder)}
             value={spoilerText}
             onChange={this.handleChangeSpoiler}
-            onKeyUp={this.handleKeyUp}
+            onKeyDown={this.handleKeyDown}
             disabled={!spoiler}
             ref={this.handleRefSpoilerText}
             suggestions={this.props.suggestions}
@@ -325,7 +318,7 @@ class ComposeForm extends ImmutablePureComponent {
           disabled={isSubmitting}
           value={this.props.text}
           onChange={this.handleChange}
-          onKeyUp={this.handleKeyUp}
+          onKeyDown={this.handleKeyDown}
           suggestions={this.props.suggestions}
           onFocus={this.handleFocus}
           onSuggestionsFetchRequested={onFetchSuggestions}
diff --git a/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js
index 7a5268780..68568d169 100644
--- a/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js
+++ b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js
@@ -1,5 +1,5 @@
 import { connect } from 'react-redux';
-import { fetchTrends } from 'mastodon/actions/trends';
+import { fetchTrends } from 'flavours/glitch/actions/trends';
 import Trends from '../components/trends';
 
 const mapStateToProps = state => ({
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
index e94f36501..48e52e4cd 100644
--- a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
@@ -12,7 +12,7 @@ import { connectHashtagStream } from 'flavours/glitch/actions/streaming';
 import { isEqual } from 'lodash';
 
 const mapStateToProps = (state, props) => ({
-  hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
+  hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0,
 });
 
 export default @connect(mapStateToProps)
@@ -75,13 +75,13 @@ class HashtagTimeline extends React.PureComponent {
     this.column.scrollTop();
   }
 
-  _subscribe (dispatch, id, tags = {}) {
+  _subscribe (dispatch, id, tags = {}, local) {
     let any  = (tags.any || []).map(tag => tag.value);
     let all  = (tags.all || []).map(tag => tag.value);
     let none = (tags.none || []).map(tag => tag.value);
 
     [id, ...any].map(tag => {
-      this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => {
+      this.disconnects.push(dispatch(connectHashtagStream(id, tag, local, status => {
         let tags = status.tags.map(tag => tag.name);
 
         return all.filter(tag => tags.includes(tag)).length === all.length &&
@@ -99,7 +99,7 @@ class HashtagTimeline extends React.PureComponent {
     const { dispatch } = this.props;
     const { id, tags, local } = this.props.params;
 
-    this._subscribe(dispatch, id, tags);
+    this._subscribe(dispatch, id, tags, local);
     dispatch(expandHashtagTimeline(id, { tags, local }));
   }
 
@@ -109,8 +109,8 @@ class HashtagTimeline extends React.PureComponent {
 
     if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
       this._unsubscribe();
-      this._subscribe(dispatch, id, tags);
-      dispatch(clearTimeline(`hashtag:${id}`));
+      this._subscribe(dispatch, id, tags, local);
+      dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
       dispatch(expandHashtagTimeline(id, { tags, local }));
     }
   }
@@ -130,7 +130,7 @@ class HashtagTimeline extends React.PureComponent {
 
   render () {
     const { hasUnread, columnId, multiColumn } = this.props;
-    const { id } = this.props.params;
+    const { id,  local } = this.props.params;
     const pinned = !!columnId;
 
     return (
@@ -153,7 +153,7 @@ class HashtagTimeline extends React.PureComponent {
         <StatusListContainer
           trackScroll={!pinned}
           scrollKey={`hashtag_timeline-${columnId}`}
-          timelineId={`hashtag:${id}`}
+          timelineId={`hashtag:${id}${local ? ':local' : ''}`}
           onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
           bindToDocument={!multiColumn}
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 9f9ef561a..f8f6cff88 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -12,7 +12,7 @@ import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
 import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications';
 import { fetchFilters } from 'flavours/glitch/actions/filters';
 import { clearHeight } from 'flavours/glitch/actions/height_cache';
-import { submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
+import { synchronouslySubmitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
 import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers';
 import UploadArea from './components/upload_area';
 import PermaLink from 'flavours/glitch/components/permalink';
@@ -267,7 +267,7 @@ class UI extends React.Component {
   handleBeforeUnload = (e) => {
     const { intl, dispatch, hasComposingText, hasMediaAttachments } = this.props;
 
-    dispatch(submitMarkers());
+    dispatch(synchronouslySubmitMarkers());
 
     if (hasComposingText || hasMediaAttachments) {
       // Setting returnValue to any string causes confirmation dialog.
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index 586b84749..852abe9dd 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -36,6 +36,7 @@ import polls from './polls';
 import identity_proofs from './identity_proofs';
 import trends from './trends';
 import announcements from './announcements';
+import markers from './markers';
 
 const reducers = {
   announcements,
@@ -75,6 +76,7 @@ const reducers = {
   pinnedAccountsEditor,
   polls,
   trends,
+  markers,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/flavours/glitch/reducers/markers.js b/app/javascript/flavours/glitch/reducers/markers.js
new file mode 100644
index 000000000..2e67be82e
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/markers.js
@@ -0,0 +1,25 @@
+import {
+  MARKERS_SUBMIT_SUCCESS,
+} from '../actions/notifications';
+
+const initialState = ImmutableMap({
+  home: '0',
+  notifications: '0',
+});
+
+import { Map as ImmutableMap } from 'immutable';
+
+export default function markers(state = initialState, action) {
+  switch(action.type) {
+  case MARKERS_SUBMIT_SUCCESS:
+    if (action.home) {
+      state = state.set('home', action.home);
+    }
+    if (action.notifications) {
+      state = state.set('notifications', action.notifications);
+    }
+    return state;
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/actions/markers.js b/app/javascript/mastodon/actions/markers.js
index c3a5fe86f..37d1ddccf 100644
--- a/app/javascript/mastodon/actions/markers.js
+++ b/app/javascript/mastodon/actions/markers.js
@@ -1,30 +1,102 @@
-export const submitMarkers = () => (dispatch, getState) => {
+import api from '../api';
+import { debounce } from 'lodash';
+import compareId from '../compare_id';
+import { showAlertForError } from './alerts';
+
+export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS';
+
+export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
   const accessToken = getState().getIn(['meta', 'access_token'], '');
-  const params      = {};
+  const params      = _buildParams(getState());
+
+  if (Object.keys(params).length === 0) {
+    return;
+  }
 
-  const lastHomeId         = getState().getIn(['timelines', 'home', 'items', 0]);
-  const lastNotificationId = getState().getIn(['notifications', 'items', 0, 'id']);
+  // The Fetch API allows us to perform requests that will be carried out
+  // after the page closes. But that only works if the `keepalive` attribute
+  // is supported.
+  if (window.fetch && 'keepalive' in new Request('')) {
+    fetch('/api/v1/markers', {
+      keepalive: true,
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${accessToken}`,
+      },
+      body: JSON.stringify(params),
+    });
+    return;
+  } else if (navigator && navigator.sendBeacon) {
+    // Failing that, we can use sendBeacon, but we have to encode the data as
+    // FormData for DoorKeeper to recognize the token.
+    const formData = new FormData();
+    formData.append('bearer_token', accessToken);
+    for (const [id, value] of Object.entries(params)) {
+      formData.append(`${id}[last_read_id]`, value.last_read_id);
+    }
+    if (navigator.sendBeacon('/api/v1/markers', formData)) {
+      return;
+    }
+  }
 
-  if (lastHomeId) {
+  // If neither Fetch nor sendBeacon worked, try to perform a synchronous
+  // request.
+  try {
+    const client = new XMLHttpRequest();
+
+    client.open('POST', '/api/v1/markers', false);
+    client.setRequestHeader('Content-Type', 'application/json');
+    client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
+    client.SUBMIT(JSON.stringify(params));
+  } catch (e) {
+    // Do not make the BeforeUnload handler error out
+  }
+};
+
+const _buildParams = (state) => {
+  const params = {};
+
+  const lastHomeId         = state.getIn(['timelines', 'home', 'items', 0]);
+  const lastNotificationId = state.getIn(['notifications', 'items', 0, 'id']);
+
+  if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
     params.home = {
       last_read_id: lastHomeId,
     };
   }
 
-  if (lastNotificationId) {
+  if (lastNotificationId && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) {
     params.notifications = {
       last_read_id: lastNotificationId,
     };
   }
 
+  return params;
+};
+
+const debouncedSubmitMarkers = debounce((dispatch, getState) => {
+  const params = _buildParams(getState());
+
   if (Object.keys(params).length === 0) {
     return;
   }
 
-  const client = new XMLHttpRequest();
+  api().post('/api/v1/markers', params).then(() => {
+    dispatch(submitMarkersSuccess(params));
+  }).catch(error => {
+    dispatch(showAlertForError(error));
+  });
+}, 300000, { leading: true, trailing: true });
+
+export function submitMarkersSuccess({ home, notifications }) {
+  return {
+    type: MARKERS_SUBMIT_SUCCESS,
+    home: (home || {}).last_read_id,
+    notifications: (notifications || {}).last_read_id,
+  };
+};
 
-  client.open('POST', '/api/v1/markers', false);
-  client.setRequestHeader('Content-Type', 'application/json');
-  client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
-  client.send(JSON.stringify(params));
+export function submitMarkers() {
+  return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
 };
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 8a066b896..a26844f84 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -7,6 +7,7 @@ import {
   importFetchedStatus,
   importFetchedStatuses,
 } from './importer';
+import { submitMarkers } from './markers';
 import { saveSettings } from './settings';
 import { defineMessages } from 'react-intl';
 import { List as ImmutableList } from 'immutable';
@@ -70,6 +71,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
       filtered = regex && regex.test(searchIndex);
     }
 
+    dispatch(submitMarkers());
+
     if (showInColumn) {
       dispatch(importFetchedAccount(notification.account));
 
@@ -157,6 +160,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
 
       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
       fetchRelatedRelationships(dispatch, response.data);
+      dispatch(submitMarkers());
     }).catch(error => {
       dispatch(expandNotificationsFail(error, isLoadingMore));
     }).finally(() => {
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index 080d665f4..d998fcac4 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -74,6 +74,6 @@ const refreshHomeTimelineAndNotification = (dispatch, done) => {
 export const connectUserStream      = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
 export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
 export const connectPublicStream    = ({ onlyMedia, onlyRemote } = {}) => connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`);
-export const connectHashtagStream   = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept);
+export const connectHashtagStream   = (id, tag, local, accept) => connectTimelineStream(`hashtag:${id}${local ? ':local' : ''}`, `hashtag${local ? ':local' : ''}&tag=${tag}`, null, accept);
 export const connectDirectStream    = () => connectTimelineStream('direct', 'direct');
 export const connectListStream      = id => connectTimelineStream(`list:${id}`, `list&list=${id}`);
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index 01f0fb015..de1725acf 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -1,4 +1,5 @@
 import { importFetchedStatus, importFetchedStatuses } from './importer';
+import { submitMarkers } from './markers';
 import api, { getLinks } from 'mastodon/api';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 import compareId from 'mastodon/compare_id';
@@ -36,6 +37,10 @@ export function updateTimeline(timeline, status, accept) {
       status,
       usePendingItems: preferPendingItems,
     });
+
+    if (timeline === 'home') {
+      dispatch(submitMarkers());
+    }
   };
 };
 
@@ -98,6 +103,10 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
       dispatch(importFetchedStatuses(response.data));
       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
+
+      if (timelineId === 'home') {
+        dispatch(submitMarkers());
+      }
     }).catch(error => {
       dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
     }).finally(() => {
@@ -114,7 +123,7 @@ export const expandAccountFeaturedTimeline = accountId => expandTimeline(`accoun
 export const expandAccountMediaTimeline    = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
 export const expandListTimeline            = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
 export const expandHashtagTimeline         = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
-  return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
+  return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, {
     max_id: maxId,
     any:    parseTags(tags, 'any'),
     all:    parseTags(tags, 'all'),
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index 283d7e0a5..a31de206b 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -10,7 +10,8 @@ import { autoPlayGif, cropImages, displayMedia, useBlurhash } from '../initial_s
 import { decode } from 'blurhash';
 
 const messages = defineMessages({
-  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide media' },
+  toggle_visible: { id: 'media_gallery.toggle_visible',
+    defaultMessage: 'Hide {number, plural, one {image} other {images}}' },
 });
 
 class Item extends React.PureComponent {
@@ -338,7 +339,7 @@ class MediaGallery extends React.PureComponent {
         </button>
       );
     } else if (visible) {
-      spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
+      spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' overlay onClick={this.handleOpen} />;
     } else {
       spoilerButton = (
         <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js
index 3e06e3652..5ccd9f8ea 100644
--- a/app/javascript/mastodon/features/hashtag_timeline/index.js
+++ b/app/javascript/mastodon/features/hashtag_timeline/index.js
@@ -12,7 +12,7 @@ import { connectHashtagStream } from '../../actions/streaming';
 import { isEqual } from 'lodash';
 
 const mapStateToProps = (state, props) => ({
-  hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
+  hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0,
 });
 
 export default @connect(mapStateToProps)
@@ -76,13 +76,13 @@ class HashtagTimeline extends React.PureComponent {
     this.column.scrollTop();
   }
 
-  _subscribe (dispatch, id, tags = {}) {
+  _subscribe (dispatch, id, tags = {}, local) {
     let any  = (tags.any || []).map(tag => tag.value);
     let all  = (tags.all || []).map(tag => tag.value);
     let none = (tags.none || []).map(tag => tag.value);
 
     [id, ...any].map(tag => {
-      this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => {
+      this.disconnects.push(dispatch(connectHashtagStream(id, tag, local, status => {
         let tags = status.tags.map(tag => tag.name);
 
         return all.filter(tag => tags.includes(tag)).length === all.length &&
@@ -100,7 +100,7 @@ class HashtagTimeline extends React.PureComponent {
     const { dispatch } = this.props;
     const { id, tags, local } = this.props.params;
 
-    this._subscribe(dispatch, id, tags);
+    this._subscribe(dispatch, id, tags, local);
     dispatch(expandHashtagTimeline(id, { tags, local }));
   }
 
@@ -110,8 +110,8 @@ class HashtagTimeline extends React.PureComponent {
 
     if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
       this._unsubscribe();
-      this._subscribe(dispatch, id, tags);
-      dispatch(clearTimeline(`hashtag:${id}`));
+      this._subscribe(dispatch, id, tags, local);
+      dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
       dispatch(expandHashtagTimeline(id, { tags, local }));
     }
   }
@@ -131,7 +131,7 @@ class HashtagTimeline extends React.PureComponent {
 
   render () {
     const { shouldUpdateScroll, hasUnread, columnId, multiColumn } = this.props;
-    const { id } = this.props.params;
+    const { id, local } = this.props.params;
     const pinned = !!columnId;
 
     return (
@@ -153,7 +153,7 @@ class HashtagTimeline extends React.PureComponent {
         <StatusListContainer
           trackScroll={!pinned}
           scrollKey={`hashtag_timeline-${columnId}`}
-          timelineId={`hashtag:${id}`}
+          timelineId={`hashtag:${id}${local ? ':local' : ''}`}
           onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
           shouldUpdateScroll={shouldUpdateScroll}
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 957e80737..81ffad22e 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -16,7 +16,7 @@ import { expandNotifications } from '../../actions/notifications';
 import { fetchFilters } from '../../actions/filters';
 import { clearHeight } from '../../actions/height_cache';
 import { focusApp, unfocusApp } from 'mastodon/actions/app';
-import { submitMarkers } from 'mastodon/actions/markers';
+import { synchronouslySubmitMarkers } from 'mastodon/actions/markers';
 import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
 import UploadArea from './components/upload_area';
 import ColumnsAreaContainer from './containers/columns_area_container';
@@ -251,7 +251,7 @@ class UI extends React.PureComponent {
   handleBeforeUnload = e => {
     const { intl, dispatch, isComposing, hasComposingText, hasMediaAttachments } = this.props;
 
-    dispatch(submitMarkers());
+    dispatch(synchronouslySubmitMarkers());
 
     if (isComposing && (hasComposingText || hasMediaAttachments)) {
       // Setting returnValue to any string causes confirmation dialog.
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index 8050326b3..b571d8c0e 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -257,7 +257,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Зареждане...",
-  "media_gallery.toggle_visible": "Hide media",
+  "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/br.json b/app/javascript/mastodon/locales/br.json
index d96d78993..5bf8cdb1c 100644
--- a/app/javascript/mastodon/locales/br.json
+++ b/app/javascript/mastodon/locales/br.json
@@ -257,7 +257,7 @@
   "lists.subheading": "Ho listennoù",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "O kargañ...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "Digavet",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index e242f14cc..7bd4a274c 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -217,7 +217,7 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "Hide media",
+        "defaultMessage": "Hide {number, plural, one {image} other {images}}",
         "id": "media_gallery.toggle_visible"
       },
       {
@@ -2982,4 +2982,4 @@
     ],
     "path": "app/javascript/mastodon/features/video/index.json"
   }
-]
\ No newline at end of file
+]
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index fbc488a07..225126e6f 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -261,7 +261,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Hide media",
+  "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json
index 15ce45d9c..81a1fbae0 100644
--- a/app/javascript/mastodon/locales/ga.json
+++ b/app/javascript/mastodon/locales/ga.json
@@ -257,7 +257,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Hide media",
+  "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/hi.json b/app/javascript/mastodon/locales/hi.json
index 1fb1062ee..438c84610 100644
--- a/app/javascript/mastodon/locales/hi.json
+++ b/app/javascript/mastodon/locales/hi.json
@@ -257,7 +257,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "लोड हो रहा है...",
-  "media_gallery.toggle_visible": "Hide media",
+  "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "नहीं मिला",
   "missing_indicator.sublabel": "यह संसाधन नहीं मिल सका।",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/kn.json b/app/javascript/mastodon/locales/kn.json
index dd538b7ea..d0757efbf 100644
--- a/app/javascript/mastodon/locales/kn.json
+++ b/app/javascript/mastodon/locales/kn.json
@@ -257,7 +257,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Hide media",
+  "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/lt.json b/app/javascript/mastodon/locales/lt.json
index dd538b7ea..d0757efbf 100644
--- a/app/javascript/mastodon/locales/lt.json
+++ b/app/javascript/mastodon/locales/lt.json
@@ -257,7 +257,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Hide media",
+  "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json
index bfc9fc8dd..241d7c080 100644
--- a/app/javascript/mastodon/locales/lv.json
+++ b/app/javascript/mastodon/locales/lv.json
@@ -257,7 +257,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Hide media",
+  "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/mk.json b/app/javascript/mastodon/locales/mk.json
index ef6895d97..bc5f91264 100644
--- a/app/javascript/mastodon/locales/mk.json
+++ b/app/javascript/mastodon/locales/mk.json
@@ -257,7 +257,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Hide media",
+  "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/ml.json b/app/javascript/mastodon/locales/ml.json
index ca92c18a7..788200c87 100644
--- a/app/javascript/mastodon/locales/ml.json
+++ b/app/javascript/mastodon/locales/ml.json
@@ -257,7 +257,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Hide media",
+  "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/mr.json b/app/javascript/mastodon/locales/mr.json
index 1807e064d..f213c2942 100644
--- a/app/javascript/mastodon/locales/mr.json
+++ b/app/javascript/mastodon/locales/mr.json
@@ -257,7 +257,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Hide media",
+  "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/ms.json b/app/javascript/mastodon/locales/ms.json
index 29cc06704..a5b4d199a 100644
--- a/app/javascript/mastodon/locales/ms.json
+++ b/app/javascript/mastodon/locales/ms.json
@@ -257,7 +257,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Hide media",
+  "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/ur.json b/app/javascript/mastodon/locales/ur.json
index a62a1e917..b4989afb7 100644
--- a/app/javascript/mastodon/locales/ur.json
+++ b/app/javascript/mastodon/locales/ur.json
@@ -257,7 +257,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Hide media",
+  "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index b9817cd38..3823bb05e 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -35,6 +35,7 @@ import identity_proofs from './identity_proofs';
 import trends from './trends';
 import missed_updates from './missed_updates';
 import announcements from './announcements';
+import markers from './markers';
 
 const reducers = {
   announcements,
@@ -73,6 +74,7 @@ const reducers = {
   polls,
   trends,
   missed_updates,
+  markers,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/reducers/markers.js b/app/javascript/mastodon/reducers/markers.js
new file mode 100644
index 000000000..2e67be82e
--- /dev/null
+++ b/app/javascript/mastodon/reducers/markers.js
@@ -0,0 +1,25 @@
+import {
+  MARKERS_SUBMIT_SUCCESS,
+} from '../actions/notifications';
+
+const initialState = ImmutableMap({
+  home: '0',
+  notifications: '0',
+});
+
+import { Map as ImmutableMap } from 'immutable';
+
+export default function markers(state = initialState, action) {
+  switch(action.type) {
+  case MARKERS_SUBMIT_SUCCESS:
+    if (action.home) {
+      state = state.set('home', action.home);
+    }
+    if (action.notifications) {
+      state = state.set('notifications', action.notifications);
+    }
+    return state;
+  default:
+    return state;
+  }
+};
diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb
index e63f290a3..8d094dddb 100644
--- a/app/lib/sanitize_config.rb
+++ b/app/lib/sanitize_config.rb
@@ -56,7 +56,7 @@ class Sanitize
     end
 
     LINK_REL_TRANSFORMER = lambda do |env|
-      return unless env[:node_name] == 'a'
+      return unless env[:node_name] == 'a' and env[:node]['href']
 
       node = env[:node]
 
diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb
index d06bea059..29dde128c 100644
--- a/app/lib/tag_manager.rb
+++ b/app/lib/tag_manager.rb
@@ -32,6 +32,7 @@ class TagManager
 
   def local_url?(url)
     uri    = Addressable::URI.parse(url).normalize
+    return false unless uri.host
     domain = uri.host + (uri.port ? ":#{uri.port}" : '')
 
     TagManager.instance.web_domain?(domain)
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index 84a656864..fcec3e686 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -41,6 +41,7 @@ class Form::AdminSettings
     show_domain_blocks
     show_domain_blocks_rationale
     noindex
+    outgoing_spoilers
   ).freeze
 
   BOOLEAN_KEYS = %i(
diff --git a/app/models/import.rb b/app/models/import.rb
index a7a0d8065..c78a04d07 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -17,7 +17,7 @@
 #
 
 class Import < ApplicationRecord
-  FILE_TYPES = %w(text/plain text/csv).freeze
+  FILE_TYPES = %w(text/plain text/csv application/csv).freeze
   MODES = %i(merge overwrite).freeze
 
   self.inheritance_column = false
diff --git a/app/models/status.rb b/app/models/status.rb
index 341f72090..594ae98c0 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -145,7 +145,7 @@ class Status < ApplicationRecord
     ids << account_id if local?
 
     if preloaded.nil?
-      ids += mentions.where(account: Account.local).pluck(:account_id)
+      ids += mentions.where(account: Account.local, silent: false).pluck(:account_id)
       ids += favourites.where(account: Account.local).pluck(:account_id)
       ids += reblogs.where(account: Account.local).pluck(:account_id)
       ids += bookmarks.where(account: Account.local).pluck(:account_id)
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index dce506fa2..e2d2b6bec 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -35,7 +35,11 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   end
 
   def summary
-    object.spoiler_text.presence
+    object.spoiler_text.presence || (instance_options[:allow_local_only] ? nil : Setting.outgoing_spoilers.presence)
+  end
+
+  def sensitive
+    object.sensitive || (!instance_options[:allow_local_only] && Setting.outgoing_spoilers.present?)
   end
 
   def content
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index bff706389..108846ca9 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -109,6 +109,9 @@
       = f.input :show_domain_blocks_rationale, wrapper: :with_label, collection: %i(disabled users all), label: t('admin.settings.domain_blocks_rationale.title'), label_method: lambda { |value| t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
 
   .fields-group
+    = f.input :outgoing_spoilers, wrapper: :with_label, label: t('admin.settings.outgoing_spoilers.title'), hint: t('admin.settings.outgoing_spoilers.desc_html')
+
+  .fields-group
     = f.input :site_extended_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description_extended.title'), hint: t('admin.settings.site_description_extended.desc_html'), input_html: { rows: 8 } unless whitelist_mode?
     = f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, label: t('admin.settings.registrations.closed_message.title'), hint: t('admin.settings.registrations.closed_message.desc_html'), input_html: { rows: 8 }
     = f.input :site_terms, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_terms.title'), hint: t('admin.settings.site_terms.desc_html'), input_html: { rows: 8 }