diff --git a/src/common/appState.ts b/src/common/appState.ts new file mode 100644 index 00000000..4b9b451b --- /dev/null +++ b/src/common/appState.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {EventEmitter} from 'events'; + +import {UPDATE_APPSTATE, UPDATE_APPSTATE_TOTALS, UPDATE_APPSTATE_FOR_VIEW_ID} from 'common/communication'; +import ServerManager from 'common/servers/serverManager'; +import {Logger} from 'common/log'; + +const log = new Logger('AppState'); + +export class AppState extends EventEmitter { + private expired: Map; + private mentions: Map; + private unreads: Map; + + constructor() { + super(); + + this.expired = new Map(); + this.mentions = new Map(); + this.unreads = new Map(); + } + + updateExpired = (viewId: string, expired: boolean) => { + ServerManager.getViewLog(viewId, 'AppState').silly('updateExpired', expired); + + this.unreads.set(viewId, expired); + this.emitStatusForView(viewId); + } + + updateMentions = (viewId: string, mentions: number) => { + ServerManager.getViewLog(viewId, 'AppState').silly('updateMentions', mentions); + + this.mentions.set(viewId, mentions); + this.emitStatusForView(viewId); + }; + + updateUnreads = (viewId: string, unreads: boolean) => { + ServerManager.getViewLog(viewId, 'AppState').silly('updateUnreads', unreads); + + this.unreads.set(viewId, unreads); + this.emitStatusForView(viewId); + }; + + clear = (viewId: string) => { + ServerManager.getViewLog(viewId, 'AppState').silly('clear'); + + this.expired.delete(viewId); + this.mentions.delete(viewId); + this.unreads.delete(viewId); + } + + emitStatus = () => { + log.silly('emitStatus'); + + this.emit(UPDATE_APPSTATE, + this.expired, + this.mentions, + this.unreads, + ); + this.emit(UPDATE_APPSTATE_TOTALS, + [...this.expired.values()].some((value) => value), + [...this.mentions.values()].reduce((total, value) => total + value, 0), + [...this.unreads.values()].some((value) => value), + ); + }; + + private emitStatusForView = (viewId: string) => { + this.emit(UPDATE_APPSTATE_FOR_VIEW_ID, + viewId, + this.expired.get(viewId) || false, + this.mentions.get(viewId) || 0, + this.unreads.get(viewId) || false, + ); + + this.emitStatus(); + }; +} + +const appState = new AppState(); +export default appState; diff --git a/src/common/communication.ts b/src/common/communication.ts index 29a38e50..aa2f9023 100644 --- a/src/common/communication.ts +++ b/src/common/communication.ts @@ -60,8 +60,6 @@ export const UPDATE_MENTIONS = 'update_mentions'; export const IS_UNREAD = 'is_unread'; export const UNREAD_RESULT = 'unread_result'; export const SESSION_EXPIRED = 'session_expired'; -export const UPDATE_TRAY = 'update_tray'; -export const UPDATE_BADGE = 'update_badge'; export const SET_VIEW_OPTIONS = 'set-view-name'; export const REACT_APP_INITIALIZED = 'react-app-initialized'; @@ -82,7 +80,6 @@ export const LOADSCREEN_END = 'loadscreen-end'; export const OPEN_TEAMS_DROPDOWN = 'open-teams-dropdown'; export const CLOSE_TEAMS_DROPDOWN = 'close-teams-dropdown'; export const UPDATE_TEAMS_DROPDOWN = 'update-teams-dropdown'; -export const UPDATE_DROPDOWN_MENTIONS = 'update-dropdown-mentions'; export const REQUEST_TEAMS_DROPDOWN_INFO = 'request-teams-dropdown-info'; export const RECEIVE_DROPDOWN_MENU_SIZE = 'receive-dropdown-menu-size'; @@ -167,3 +164,7 @@ export const UPDATE_TAB_ORDER = 'update-tab-order'; export const GET_LAST_ACTIVE = 'get-last-active'; export const GET_ORDERED_SERVERS = 'get-ordered-servers'; export const GET_ORDERED_TABS_FOR_SERVER = 'get-ordered-tabs-for-server'; + +export const UPDATE_APPSTATE = 'update-appstate'; +export const UPDATE_APPSTATE_TOTALS = 'update-appstate-totals'; +export const UPDATE_APPSTATE_FOR_VIEW_ID = 'update-appstate-for-view-id'; diff --git a/src/main/app/initialize.test.js b/src/main/app/initialize.test.js index 27e9dc0f..4a8bbfa0 100644 --- a/src/main/app/initialize.test.js +++ b/src/main/app/initialize.test.js @@ -129,7 +129,7 @@ jest.mock('main/app/utils', () => ({ initCookieManager: jest.fn(), updateServerInfos: jest.fn(), })); -jest.mock('main/appState', () => ({ +jest.mock('common/appState', () => ({ on: jest.fn(), })); jest.mock('main/AppVersionManager', () => ({})); diff --git a/src/main/appState.ts b/src/main/appState.ts deleted file mode 100644 index 89109a6f..00000000 --- a/src/main/appState.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import events from 'events'; -import {ipcMain} from 'electron'; - -import {UPDATE_MENTIONS, UPDATE_TRAY, UPDATE_BADGE, SESSION_EXPIRED, UPDATE_DROPDOWN_MENTIONS} from 'common/communication'; -import ServerManager from 'common/servers/serverManager'; - -import WindowManager from './windows/windowManager'; - -const status = { - unreads: new Map(), - mentions: new Map(), - expired: new Map(), - emitter: new events.EventEmitter(), -}; - -const emitMentions = (viewId: string) => { - const newMentions = getMentions(viewId); - const newUnreads = getUnreads(viewId); - const isExpired = getIsExpired(viewId); - - WindowManager.sendToRenderer(UPDATE_MENTIONS, viewId, newMentions, newUnreads, isExpired); - ServerManager.getViewLog(viewId, 'AppState').silly('emitMentions', {isExpired, newMentions, newUnreads}); - emitStatus(); -}; - -const emitTray = (expired?: boolean, mentions?: number, unreads?: boolean) => { - status.emitter.emit(UPDATE_TRAY, expired, Boolean(mentions), unreads); -}; - -const emitBadge = (expired?: boolean, mentions?: number, unreads?: boolean) => { - status.emitter.emit(UPDATE_BADGE, expired, mentions, unreads); -}; - -const emitDropdown = (expired?: Map, mentions?: Map, unreads?: Map) => { - status.emitter.emit(UPDATE_DROPDOWN_MENTIONS, expired, mentions, unreads); -}; - -const emitStatus = () => { - const expired = anyExpired(); - const mentions = totalMentions(); - const unreads = anyUnreads(); - emitTray(expired, mentions, unreads); - emitBadge(expired, mentions, unreads); - emitDropdown(status.expired, status.mentions, status.unreads); -}; - -export const updateMentions = (viewId: string, mentions: number, unreads?: boolean) => { - if (typeof unreads !== 'undefined') { - status.unreads.set(viewId, Boolean(unreads)); - } - status.mentions.set(viewId, mentions || 0); - emitMentions(viewId); -}; - -export const updateUnreads = (viewId: string, unreads: boolean) => { - status.unreads.set(viewId, Boolean(unreads)); - emitMentions(viewId); -}; - -export const updateBadge = () => { - const expired = anyExpired(); - const mentions = totalMentions(); - const unreads = anyUnreads(); - emitBadge(expired, mentions, unreads); -}; - -const getUnreads = (viewId: string) => { - return status.unreads.get(viewId) || false; -}; - -const getMentions = (viewId: string) => { - return status.mentions.get(viewId) || 0; // this might be undefined as a way to tell that we don't know as it might need to login still. -}; - -const getIsExpired = (viewId: string) => { - return status.expired.get(viewId) || false; -}; - -const totalMentions = () => { - let total = 0; - for (const v of status.mentions.values()) { - total += v; - } - return total; -}; - -const anyUnreads = () => { - for (const v of status.unreads.values()) { - if (v) { - return v; - } - } - return false; -}; - -const anyExpired = () => { - for (const v of status.expired.values()) { - if (v) { - return v; - } - } - return false; -}; - -// add any other event emitter methods if needed -export const on = status.emitter.on.bind(status.emitter); - -const setSessionExpired = (viewId: string, expired: boolean) => { - const isExpired = Boolean(expired); - const old = status.expired.get(viewId); - status.expired.set(viewId, isExpired); - if (typeof old !== 'undefined' && old !== isExpired) { - emitTray(); - } - emitMentions(viewId); -}; - -ipcMain.on(SESSION_EXPIRED, (event, isExpired, viewId) => { - if (isExpired) { - ServerManager.getViewLog(viewId, 'AppState').debug('SESSION_EXPIRED', isExpired); - } - setSessionExpired(viewId, isExpired); -}); diff --git a/src/main/badge.test.js b/src/main/badge.test.js index b4646063..633f6521 100644 --- a/src/main/badge.test.js +++ b/src/main/badge.test.js @@ -19,8 +19,8 @@ jest.mock('electron', () => ({ }, })); -jest.mock('./appState', () => ({ - updateBadge: jest.fn(), +jest.mock('common/appState', () => ({ + emitStatus: jest.fn(), })); jest.mock('./windows/mainWindow', () => ({ get: jest.fn(), diff --git a/src/main/badge.ts b/src/main/badge.ts index 25972440..5a838aeb 100644 --- a/src/main/badge.ts +++ b/src/main/badge.ts @@ -4,12 +4,12 @@ import {BrowserWindow, app, nativeImage} from 'electron'; -import {UPDATE_BADGE} from 'common/communication'; +import AppState from 'common/appState'; +import {UPDATE_APPSTATE_TOTALS} from 'common/communication'; import {Logger} from 'common/log'; import {localizeMessage} from 'main/i18nManager'; -import * as AppState from './appState'; import MainWindow from './windows/mainWindow'; const log = new Logger('Badge'); @@ -128,9 +128,9 @@ function showBadge(sessionExpired: boolean, mentionCount: number, showUnreadBadg export function setUnreadBadgeSetting(showUnreadBadge: boolean) { showUnreadBadgeSetting = showUnreadBadge; - AppState.updateBadge(); + AppState.emitStatus(); } export function setupBadge() { - AppState.on(UPDATE_BADGE, showBadge); + AppState.on(UPDATE_APPSTATE_TOTALS, showBadge); } diff --git a/src/main/tray/tray.ts b/src/main/tray/tray.ts index a922389b..4e1df657 100644 --- a/src/main/tray/tray.ts +++ b/src/main/tray/tray.ts @@ -5,14 +5,13 @@ import path from 'path'; import {app, nativeImage, Tray, systemPreferences, nativeTheme} from 'electron'; -import {UPDATE_TRAY} from 'common/communication'; +import AppState from 'common/appState'; +import {UPDATE_APPSTATE_TOTALS} from 'common/communication'; import {localizeMessage} from 'main/i18nManager'; import MainWindow from 'main/windows/mainWindow'; import WindowManager from 'main/windows/windowManager'; -import * as AppState from '../appState'; - const assetsDir = path.resolve(app.getAppPath(), 'assets'); let trayImages: Record; @@ -102,8 +101,8 @@ export function setupTray(iconTheme: string) { WindowManager.restoreMain(); }); - AppState.on(UPDATE_TRAY, (anyExpired, anyMentions, anyUnreads) => { - if (anyMentions) { + AppState.on(UPDATE_APPSTATE_TOTALS, (anyExpired: boolean, anyMentions: number, anyUnreads: boolean) => { + if (anyMentions > 0) { setTray('mention', localizeMessage('main.tray.tray.mention', 'You have been mentioned')); } else if (anyUnreads) { setTray('unread', localizeMessage('main.tray.tray.unread', 'You have unread channels')); diff --git a/src/main/views/MattermostView.test.js b/src/main/views/MattermostView.test.js index 0e7aa2f1..13c16536 100644 --- a/src/main/views/MattermostView.test.js +++ b/src/main/views/MattermostView.test.js @@ -3,6 +3,7 @@ 'use strict'; +import AppState from 'common/appState'; import {LOAD_FAILED, TOGGLE_BACK_BUTTON, UPDATE_TARGET_URL} from 'common/communication'; import {MattermostServer} from 'common/servers/MattermostServer'; import MessagingTabView from 'common/tabs/MessagingTabView'; @@ -10,7 +11,6 @@ import MessagingTabView from 'common/tabs/MessagingTabView'; import MainWindow from '../windows/mainWindow'; import * as WindowManager from '../windows/windowManager'; import ContextMenu from '../contextMenu'; -import * as appState from '../appState'; import Utils from '../utils'; import {MattermostView} from './MattermostView'; @@ -45,7 +45,8 @@ jest.mock('../windows/mainWindow', () => ({ jest.mock('../windows/windowManager', () => ({ sendToRenderer: jest.fn(), })); -jest.mock('../appState', () => ({ +jest.mock('common/appState', () => ({ + clear: jest.fn(), updateMentions: jest.fn(), })); jest.mock('./webContentEvents', () => ({ @@ -374,7 +375,7 @@ describe('main/views/MattermostView', () => { const mattermostView = new MattermostView(tabView, {}, {}); mattermostView.view.webContents.destroy = jest.fn(); mattermostView.destroy(); - expect(appState.updateMentions).toBeCalledWith(mattermostView.tab.id, 0, false); + expect(AppState.clear).toBeCalledWith(mattermostView.tab.id); }); it('should clear outstanding timeouts', () => { @@ -472,12 +473,12 @@ describe('main/views/MattermostView', () => { it('should parse mentions from title', () => { mattermostView.updateMentionsFromTitle('(7) Mattermost'); - expect(appState.updateMentions).toHaveBeenCalledWith(mattermostView.tab.id, 7); + expect(AppState.updateMentions).toHaveBeenCalledWith(mattermostView.tab.id, 7); }); it('should parse unreads from title', () => { mattermostView.updateMentionsFromTitle('* Mattermost'); - expect(appState.updateMentions).toHaveBeenCalledWith(mattermostView.tab.id, 0); + expect(AppState.updateMentions).toHaveBeenCalledWith(mattermostView.tab.id, 0); }); }); }); diff --git a/src/main/views/MattermostView.ts b/src/main/views/MattermostView.ts index b2957ea1..f5d4aebb 100644 --- a/src/main/views/MattermostView.ts +++ b/src/main/views/MattermostView.ts @@ -8,6 +8,7 @@ import {EventEmitter} from 'events'; import {RELOAD_INTERVAL, MAX_SERVER_RETRIES, SECOND, MAX_LOADING_SCREEN_SECONDS} from 'common/utils/constants'; import urlUtils from 'common/utils/url'; +import AppState from 'common/appState'; import { LOAD_RETRY, LOAD_SUCCESS, @@ -29,7 +30,6 @@ import WindowManager from 'main/windows/windowManager'; import ContextMenu from '../contextMenu'; import {getWindowBoundaries, getLocalPreload, composeUserAgent, shouldHaveBackBar} from '../utils'; -import * as appState from '../appState'; import WebContentsEventManager from './webContentEvents'; @@ -237,7 +237,7 @@ export class MattermostView extends EventEmitter { destroy = () => { WebContentsEventManager.removeWebContentsListeners(this.webContentsId); - appState.updateMentions(this.id, 0, false); + AppState.clear(this.id); MainWindow.get()?.removeBrowserView(this.view); // workaround to eliminate zombie processes @@ -353,7 +353,7 @@ export class MattermostView extends EventEmitter { const results = resultsIterator.next(); // we are only interested in the first set const mentions = (results && results.value && parseInt(results.value[MENTIONS_GROUP], 10)) || 0; - appState.updateMentions(this.id, mentions); + AppState.updateMentions(this.id, mentions); } // if favicon is null, it will affect appState, but won't be memoized diff --git a/src/main/views/teamDropdownView.ts b/src/main/views/teamDropdownView.ts index 79a60a99..7dbe6261 100644 --- a/src/main/views/teamDropdownView.ts +++ b/src/main/views/teamDropdownView.ts @@ -5,12 +5,13 @@ import {BrowserView, ipcMain, IpcMainEvent} from 'electron'; import {CombinedConfig, MattermostTeam} from 'types/config'; +import AppState from 'common/appState'; import { CLOSE_TEAMS_DROPDOWN, EMIT_CONFIGURATION, OPEN_TEAMS_DROPDOWN, UPDATE_TEAMS_DROPDOWN, - UPDATE_DROPDOWN_MENTIONS, + UPDATE_APPSTATE, REQUEST_TEAMS_DROPDOWN_INFO, RECEIVE_DROPDOWN_MENU_SIZE, SET_ACTIVE_VIEW, @@ -23,7 +24,6 @@ import ServerManager from 'common/servers/serverManager'; import {getLocalPreload, getLocalURLString} from 'main/utils'; -import * as AppState from '../appState'; import WindowManager from '../windows/windowManager'; import MainWindow from '../windows/mainWindow'; @@ -71,7 +71,7 @@ export default class TeamDropdownView { ipcMain.on(REQUEST_TEAMS_DROPDOWN_INFO, this.updateDropdown); ipcMain.on(RECEIVE_DROPDOWN_MENU_SIZE, this.handleReceivedMenuSize); ipcMain.on(SET_ACTIVE_VIEW, this.updateActiveTeam); - AppState.on(UPDATE_DROPDOWN_MENTIONS, this.updateMentions); + AppState.on(UPDATE_APPSTATE, this.updateMentions); ServerManager.on(SERVERS_UPDATE, this.updateServers); } diff --git a/src/main/views/viewManager.test.js b/src/main/views/viewManager.test.js index ed67ca1b..35b7db6c 100644 --- a/src/main/views/viewManager.test.js +++ b/src/main/views/viewManager.test.js @@ -107,7 +107,7 @@ jest.mock('./modalManager', () => ({ isModalDisplayed: jest.fn(), })); jest.mock('./webContentEvents', () => ({})); -jest.mock('../appState', () => ({})); +jest.mock('common/appState', () => ({})); describe('main/views/viewManager', () => { describe('loadView', () => { diff --git a/src/main/views/viewManager.ts b/src/main/views/viewManager.ts index 92e9d8de..e9d3f353 100644 --- a/src/main/views/viewManager.ts +++ b/src/main/views/viewManager.ts @@ -3,6 +3,7 @@ import {BrowserView, dialog, ipcMain, IpcMainEvent, IpcMainInvokeEvent} from 'electron'; +import AppState from 'common/appState'; import {SECOND, TAB_BAR_HEIGHT} from 'common/utils/constants'; import { UPDATE_TARGET_URL, @@ -23,6 +24,7 @@ import { UNREAD_RESULT, HISTORY, GET_VIEW_INFO_FOR_TEST, + SESSION_EXPIRED, } from 'common/communication'; import Config from 'common/config'; import {Logger} from 'common/log'; @@ -35,7 +37,6 @@ import {TabView, TAB_MESSAGING} from 'common/tabs/TabView'; import {localizeMessage} from 'main/i18nManager'; import MainWindow from 'main/windows/mainWindow'; -import * as appState from '../appState'; import {getLocalURLString, getLocalPreload} from '../utils'; import {MattermostView} from './MattermostView'; @@ -66,6 +67,7 @@ export class ViewManager { ipcMain.on(APP_LOGGED_OUT, this.handleAppLoggedOut); ipcMain.on(RELOAD_CURRENT_VIEW, this.handleReloadCurrentView); ipcMain.on(UNREAD_RESULT, this.handleFaviconIsUnread); + ipcMain.on(SESSION_EXPIRED, this.handleSessionExpired); ServerManager.on(SERVERS_UPDATE, this.handleReloadConfiguration); } @@ -521,7 +523,13 @@ export class ViewManager { private handleFaviconIsUnread = (e: Event, favicon: string, viewId: string, result: boolean) => { log.silly('handleFaviconIsUnread', {favicon, viewId, result}); - appState.updateUnreads(viewId, result); + AppState.updateUnreads(viewId, result); + } + + private handleSessionExpired = (event: IpcMainEvent, isExpired: boolean, viewId: string) => { + ServerManager.getViewLog(viewId, 'ViewManager').debug('handleSessionExpired', isExpired); + + AppState.updateExpired(viewId, isExpired); } /** diff --git a/src/main/windows/mainWindow.ts b/src/main/windows/mainWindow.ts index e550f6b7..09505659 100644 --- a/src/main/windows/mainWindow.ts +++ b/src/main/windows/mainWindow.ts @@ -11,7 +11,8 @@ import {app, BrowserWindow, BrowserWindowConstructorOptions, dialog, Event, glob import {SavedWindowState} from 'types/mainWindow'; -import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB, GET_FULL_SCREEN_STATUS, FOCUS_THREE_DOT_MENU, SERVERS_UPDATE} from 'common/communication'; +import AppState from 'common/appState'; +import {SELECT_NEXT_TAB, SELECT_PREVIOUS_TAB, GET_FULL_SCREEN_STATUS, FOCUS_THREE_DOT_MENU, SERVERS_UPDATE, UPDATE_APPSTATE_FOR_VIEW_ID, UPDATE_MENTIONS} from 'common/communication'; import Config from 'common/config'; import {Logger} from 'common/log'; import ServerManager from 'common/servers/serverManager'; @@ -42,6 +43,8 @@ export class MainWindow { ipcMain.handle(GET_FULL_SCREEN_STATUS, () => this.win?.isFullScreen()); ServerManager.on(SERVERS_UPDATE, this.handleUpdateConfig); + + AppState.on(UPDATE_APPSTATE_FOR_VIEW_ID, this.handleUpdateAppStateForViewId); } init = () => { @@ -331,6 +334,14 @@ export class MainWindow { private handleUpdateConfig = () => { this.win?.webContents.send(SERVERS_UPDATE); } + + /** + * App State update handler + */ + + private handleUpdateAppStateForViewId = (viewId: string, isExpired: boolean, newMentions: number, newUnreads: boolean) => { + this.win?.webContents.send(UPDATE_MENTIONS, viewId, newMentions, newUnreads, isExpired); + } } const mainWindow = new MainWindow();