From c09ecbc53eb3d3a8a1e2a1e61e20c9e5dbd4f560 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 13 Aug 2019 12:22:16 +0200 Subject: [PATCH] Add indicator of unread content to window title when web UI is out of focus (#11560) Fix #1288 --- app/javascript/mastodon/actions/app.js | 10 +++++ .../features/ui/components/document_title.js | 41 +++++++++++++++++++ app/javascript/mastodon/features/ui/index.js | 18 +++++++- app/javascript/mastodon/initial_state.js | 1 + app/javascript/mastodon/reducers/index.js | 2 + .../mastodon/reducers/missed_updates.js | 23 +++++++++++ app/serializers/initial_state_serializer.rb | 1 + 7 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 app/javascript/mastodon/actions/app.js create mode 100644 app/javascript/mastodon/features/ui/components/document_title.js create mode 100644 app/javascript/mastodon/reducers/missed_updates.js diff --git a/app/javascript/mastodon/actions/app.js b/app/javascript/mastodon/actions/app.js new file mode 100644 index 000000000..414968f7d --- /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 000000000..cd081b20c --- /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 d1a3dc949..f0c3eff83 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 38e7b0595..56fb58546 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 3b60878eb..0f4b209d4 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 000000000..eeb8b40f6 --- /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 c92c5e606..2cebef2c0 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,