diff --git a/app/javascript/mastodon/actions/app.js b/app/javascript/mastodon/actions/app.js
new file mode 100644
index 0000000000..414968f7de
--- /dev/null
+++ b/app/javascript/mastodon/actions/app.js
@@ -0,0 +1,10 @@
+export const APP_FOCUS = 'APP_FOCUS';
+export const APP_UNFOCUS = 'APP_UNFOCUS';
+
+export const focusApp = () => ({
+ type: APP_FOCUS,
+});
+
+export const unfocusApp = () => ({
+ type: APP_UNFOCUS,
+});
diff --git a/app/javascript/mastodon/features/ui/components/document_title.js b/app/javascript/mastodon/features/ui/components/document_title.js
new file mode 100644
index 0000000000..cd081b20c7
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/document_title.js
@@ -0,0 +1,41 @@
+import { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { title } from 'mastodon/initial_state';
+
+const mapStateToProps = state => ({
+ unread: state.getIn(['missed_updates', 'unread']),
+});
+
+export default @connect(mapStateToProps)
+class DocumentTitle extends PureComponent {
+
+ static propTypes = {
+ unread: PropTypes.number.isRequired,
+ };
+
+ componentDidMount () {
+ this._sideEffects();
+ }
+
+ componentDidUpdate() {
+ this._sideEffects();
+ }
+
+ _sideEffects () {
+ const { unread } = this.props;
+
+ if (unread > 99) {
+ document.title = `(*) ${title}`;
+ } else if (unread > 0) {
+ document.title = `(${unread}) ${title}`;
+ } else {
+ document.title = title;
+ }
+ }
+
+ render () {
+ return null;
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index d1a3dc9495..f0c3eff834 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -15,9 +15,11 @@ import { expandHomeTimeline } from '../../actions/timelines';
import { expandNotifications } from '../../actions/notifications';
import { fetchFilters } from '../../actions/filters';
import { clearHeight } from '../../actions/height_cache';
+import { focusApp, unfocusApp } from 'mastodon/actions/app';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import UploadArea from './components/upload_area';
import ColumnsAreaContainer from './containers/columns_area_container';
+import DocumentTitle from './components/document_title';
import {
Compose,
Status,
@@ -226,7 +228,7 @@ class UI extends React.PureComponent {
draggingOver: false,
};
- handleBeforeUnload = (e) => {
+ handleBeforeUnload = e => {
const { intl, isComposing, hasComposingText, hasMediaAttachments } = this.props;
if (isComposing && (hasComposingText || hasMediaAttachments)) {
@@ -237,6 +239,14 @@ class UI extends React.PureComponent {
}
}
+ handleWindowFocus = () => {
+ this.props.dispatch(focusApp());
+ }
+
+ handleWindowBlur = () => {
+ this.props.dispatch(unfocusApp());
+ }
+
handleLayoutChange = () => {
// The cached heights are no longer accurate, invalidate
this.props.dispatch(clearHeight());
@@ -314,6 +324,8 @@ class UI extends React.PureComponent {
}
componentWillMount () {
+ window.addEventListener('focus', this.handleWindowFocus, false);
+ window.addEventListener('blur', this.handleWindowBlur, false);
window.addEventListener('beforeunload', this.handleBeforeUnload, false);
document.addEventListener('dragenter', this.handleDragEnter, false);
@@ -343,7 +355,10 @@ class UI extends React.PureComponent {
}
componentWillUnmount () {
+ window.removeEventListener('focus', this.handleWindowFocus);
+ window.removeEventListener('blur', this.handleWindowBlur);
window.removeEventListener('beforeunload', this.handleBeforeUnload);
+
document.removeEventListener('dragenter', this.handleDragEnter);
document.removeEventListener('dragover', this.handleDragOver);
document.removeEventListener('drop', this.handleDrop);
@@ -502,6 +517,7 @@ class UI extends React.PureComponent {
+
);
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index 38e7b0595a..56fb585466 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -23,5 +23,6 @@ export const forceSingleColumn = !getMeta('advanced_layout');
export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');
export const showTrends = getMeta('trends');
+export const title = getMeta('title');
export default initialState;
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 3b60878eb7..0f4b209d45 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -32,6 +32,7 @@ import suggestions from './suggestions';
import polls from './polls';
import identity_proofs from './identity_proofs';
import trends from './trends';
+import missed_updates from './missed_updates';
const reducers = {
dropdown_menu,
@@ -67,6 +68,7 @@ const reducers = {
suggestions,
polls,
trends,
+ missed_updates,
};
export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/reducers/missed_updates.js b/app/javascript/mastodon/reducers/missed_updates.js
new file mode 100644
index 0000000000..eeb8b40f67
--- /dev/null
+++ b/app/javascript/mastodon/reducers/missed_updates.js
@@ -0,0 +1,23 @@
+import { Map as ImmutableMap } from 'immutable';
+import { NOTIFICATIONS_UPDATE } from 'mastodon/actions/notifications';
+import { TIMELINE_UPDATE } from 'mastodon/actions/timelines';
+import { APP_FOCUS, APP_UNFOCUS } from 'mastodon/actions/app';
+
+const initialState = ImmutableMap({
+ focused: true,
+ unread: 0,
+});
+
+export default function missed_updates(state = initialState, action) {
+ switch(action.type) {
+ case APP_FOCUS:
+ return state.set('focused', true).set('unread', 0);
+ case APP_UNFOCUS:
+ return state.set('focused', false);
+ case NOTIFICATIONS_UPDATE:
+ case TIMELINE_UPDATE:
+ return state.get('focused') ? state : state.update('unread', x => x + 1);
+ default:
+ return state;
+ }
+};
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index c92c5e606b..2cebef2c00 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -12,6 +12,7 @@ class InitialStateSerializer < ActiveModel::Serializer
access_token: object.token,
locale: I18n.locale,
domain: Rails.configuration.x.local_domain,
+ title: instance_presenter.site_title,
admin: object.admin&.id&.to_s,
search_enabled: Chewy.enabled?,
repository: Mastodon::Version.repository,