diff --git a/webapp/channels/package.json b/webapp/channels/package.json index dfffbad49c58..ea4e7bdc5203 100644 --- a/webapp/channels/package.json +++ b/webapp/channels/package.json @@ -14,6 +14,7 @@ "@mattermost/client": "*", "@mattermost/compass-components": "^0.2.12", "@mattermost/compass-icons": "0.1.39", + "@mattermost/desktop-api": "5.7.0-1", "@mattermost/types": "*", "@mui/base": "5.0.0-alpha.127", "@mui/material": "5.11.16", diff --git a/webapp/channels/src/actions/notification_actions.jsx b/webapp/channels/src/actions/notification_actions.jsx index 2728d7f9e284..0b3d184e729b 100644 --- a/webapp/channels/src/actions/notification_actions.jsx +++ b/webapp/channels/src/actions/notification_actions.jsx @@ -20,6 +20,7 @@ import {isThreadOpen} from 'selectors/views/threads'; import {getHistory} from 'utils/browser_history'; import Constants, {NotificationLevels, UserStatuses, IgnoreChannelMentions} from 'utils/constants'; +import DesktopApp from 'utils/desktop_api'; import {t} from 'utils/i18n'; import {stripMarkdown, formatWithRenderer} from 'utils/markdown'; import MentionableRenderer from 'utils/markdown/mentionable_renderer'; @@ -312,24 +313,7 @@ export function sendDesktopNotification(post, msgProps) { export const notifyMe = (title, body, channel, teamId, silent, soundName, url) => (dispatch) => { // handle notifications in desktop app if (isDesktopApp()) { - const msg = { - title, - body, - channel, - teamId, - silent, - }; - msg.data = {soundName}; - msg.url = url; - - // get the desktop app to trigger the notification - window.postMessage( - { - type: 'dispatch-notification', - message: msg, - }, - window.location.origin, - ); + DesktopApp.dispatchNotification(title, body, channel.id, teamId, silent, soundName, url); } else { showNotification({ title, diff --git a/webapp/channels/src/components/channel_layout/channel_controller.test.tsx b/webapp/channels/src/components/channel_layout/channel_controller.test.tsx index baf16b2099f6..c5dd75e5d5db 100644 --- a/webapp/channels/src/components/channel_layout/channel_controller.test.tsx +++ b/webapp/channels/src/components/channel_layout/channel_controller.test.tsx @@ -9,7 +9,7 @@ jest.mock('components/reset_status_modal', () => () =>
); jest.mock('components/sidebar', () => () =>
); jest.mock('components/channel_layout/center_channel', () => () =>
); jest.mock('components/loading_screen', () => () =>
); -jest.mock('components/favicon_title_handler', () => () =>
); +jest.mock('components/unreads_status_handler', () => () =>
); jest.mock('components/product_notices_modal', () => () =>
); jest.mock('plugins/pluggable', () => () =>
); diff --git a/webapp/channels/src/components/channel_layout/channel_controller.tsx b/webapp/channels/src/components/channel_layout/channel_controller.tsx index 9b8535c30759..1e3c807006dd 100644 --- a/webapp/channels/src/components/channel_layout/channel_controller.tsx +++ b/webapp/channels/src/components/channel_layout/channel_controller.tsx @@ -10,11 +10,11 @@ import type {DispatchFunc} from 'mattermost-redux/types/actions'; import {loadStatusesForChannelAndSidebar} from 'actions/status_actions'; import CenterChannel from 'components/channel_layout/center_channel'; -import FaviconTitleHandler from 'components/favicon_title_handler'; import LoadingScreen from 'components/loading_screen'; import ProductNoticesModal from 'components/product_notices_modal'; import ResetStatusModal from 'components/reset_status_modal'; import Sidebar from 'components/sidebar'; +import UnreadsStatusHandler from 'components/unreads_status_handler'; import Pluggable from 'plugins/pluggable'; import {Constants} from 'utils/constants'; @@ -57,7 +57,7 @@ export default function ChannelController(props: Props) { className='channel-view' data-testid='channel_view' > - +
{props.shouldRenderCenterChannel ? : } diff --git a/webapp/channels/src/components/desktop_auth_token.tsx b/webapp/channels/src/components/desktop_auth_token.tsx index 5f9bd9136538..1093b11a82b9 100644 --- a/webapp/channels/src/components/desktop_auth_token.tsx +++ b/webapp/channels/src/components/desktop_auth_token.tsx @@ -14,19 +14,13 @@ import type {DispatchFunc} from 'mattermost-redux/types/actions'; import {loginWithDesktopToken} from 'actions/views/login'; +import DesktopApp from 'utils/desktop_api'; + import './desktop_auth_token.scss'; const BOTTOM_MESSAGE_TIMEOUT = 10000; const DESKTOP_AUTH_PREFIX = 'desktop_auth_client_token'; -declare global { - interface Window { - desktopAPI?: { - isDev?: () => Promise; - }; - } -} - enum DesktopAuthStatus { None, WaitingForBrowser, @@ -71,8 +65,7 @@ const DesktopAuthToken: React.FC = ({href, onLogin}: Props) => { }; const openExternalLoginURL = async () => { - const isDev = await window.desktopAPI?.isDev?.(); - const desktopToken = `${isDev ? 'dev-' : ''}${crypto.randomBytes(32).toString('hex')}`.slice(0, 64); + const desktopToken = `${DesktopApp.isDev() ? 'dev-' : ''}${crypto.randomBytes(32).toString('hex')}`.slice(0, 64); sessionStorage.setItem(DESKTOP_AUTH_PREFIX, desktopToken); const parsedURL = new URL(href); diff --git a/webapp/channels/src/components/global_header/left_controls/history_buttons/history_buttons.tsx b/webapp/channels/src/components/global_header/left_controls/history_buttons/history_buttons.tsx index 8e80862accdc..4caaf600cf2b 100644 --- a/webapp/channels/src/components/global_header/left_controls/history_buttons/history_buttons.tsx +++ b/webapp/channels/src/components/global_header/left_controls/history_buttons/history_buttons.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useState, useCallback} from 'react'; +import React, {useEffect, useState} from 'react'; import {useHistory} from 'react-router-dom'; import styled from 'styled-components'; @@ -18,6 +18,7 @@ import OverlayTrigger from 'components/overlay_trigger'; import Tooltip from 'components/tooltip'; import Constants from 'utils/constants'; +import DesktopApp from 'utils/desktop_api'; import * as Utils from 'utils/utils'; const HistoryButtonsContainer = styled.nav` @@ -49,45 +50,29 @@ const HistoryButtons = (): JSX.Element => { const goBack = () => { trackEvent('ui', 'ui_history_back'); history.goBack(); - window.postMessage( - { - type: 'history-button', - }, - window.location.origin, - ); + requestButtons(); }; const goForward = () => { trackEvent('ui', 'ui_history_forward'); history.goForward(); - window.postMessage( - { - type: 'history-button', - }, - window.location.origin, - ); + requestButtons(); }; - const handleButtonMessage = useCallback((message: {origin: string; data: {type: string; message: {enableBack: boolean; enableForward: boolean}}}) => { - if (message.origin !== window.location.origin) { - return; - } - - switch (message.data.type) { - case 'history-button-return': { - setCanGoBack(message.data.message.enableBack); - setCanGoForward(message.data.message.enableForward); - break; - } - } - }, []); + const requestButtons = async () => { + const {canGoBack, canGoForward} = await DesktopApp.getBrowserHistoryStatus(); + updateButtons(canGoBack, canGoForward); + }; + + const updateButtons = (enableBack: boolean, enableForward: boolean) => { + setCanGoBack(enableBack); + setCanGoForward(enableForward); + }; useEffect(() => { - window.addEventListener('message', handleButtonMessage); - return () => { - window.removeEventListener('message', handleButtonMessage); - }; - }, [handleButtonMessage]); + const off = DesktopApp.onBrowserHistoryStatusUpdated(updateButtons); + return off; + }, []); return ( diff --git a/webapp/channels/src/components/logged_in/index.ts b/webapp/channels/src/components/logged_in/index.ts index 6c7e9103c4f1..2776b14f9c3a 100644 --- a/webapp/channels/src/components/logged_in/index.ts +++ b/webapp/channels/src/components/logged_in/index.ts @@ -5,11 +5,9 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import type {Dispatch} from 'redux'; -import type {Channel} from '@mattermost/types/channels'; - import {markChannelAsViewedOnServer, updateApproximateViewTime} from 'mattermost-redux/actions/channels'; import {autoUpdateTimezone} from 'mattermost-redux/actions/timezone'; -import {getCurrentChannelId, isManuallyUnread} from 'mattermost-redux/selectors/entities/channels'; +import {getChannel, getCurrentChannelId, isManuallyUnread} from 'mattermost-redux/selectors/entities/channels'; import {getLicense, getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentUser, shouldShowTermsOfService} from 'mattermost-redux/selectors/entities/users'; import type {DispatchFunc, GenericAction} from 'mattermost-redux/types/actions'; @@ -46,13 +44,14 @@ function mapStateToProps(state: GlobalState, ownProps: Props) { } // NOTE: suggestions where to keep this welcomed -const getChannelURLAction = (channel: Channel, teamId: string, url: string) => (dispatch: DispatchFunc, getState: () => GlobalState) => { +const getChannelURLAction = (channelId: string, teamId: string, url: string) => (dispatch: DispatchFunc, getState: () => GlobalState) => { const state = getState(); if (url && isPermalinkURL(url)) { return getHistory().push(url); } + const channel = getChannel(state, channelId); return getHistory().push(getChannelURL(state, channel, teamId)); }; diff --git a/webapp/channels/src/components/logged_in/logged_in.tsx b/webapp/channels/src/components/logged_in/logged_in.tsx index 98a6e18fef11..eb746344f8ef 100644 --- a/webapp/channels/src/components/logged_in/logged_in.tsx +++ b/webapp/channels/src/components/logged_in/logged_in.tsx @@ -3,9 +3,7 @@ import React from 'react'; import {Redirect} from 'react-router-dom'; -import semver from 'semver'; -import type {Channel} from '@mattermost/types/channels'; import type {UserProfile} from '@mattermost/types/users'; import * as GlobalActions from 'actions/global_actions'; @@ -16,6 +14,7 @@ import LoadingScreen from 'components/loading_screen'; import WebSocketClient from 'client/web_websocket_client'; import Constants from 'utils/constants'; +import DesktopApp from 'utils/desktop_api'; import {isKeyPressed} from 'utils/keyboard'; import {getBrowserTimezone} from 'utils/timezone'; import * as UserAgent from 'utils/user_agent'; @@ -36,7 +35,7 @@ export type Props = { mfaRequired: boolean; actions: { autoUpdateTimezone: (deviceTimezone: string) => void; - getChannelURLAction: (channel: Channel, teamId: string, url: string) => void; + getChannelURLAction: (channelId: string, teamId: string, url: string) => void; markChannelAsViewedOnServer: (channelId: string) => void; updateApproximateViewTime: (channelId: string) => void; }; @@ -47,22 +46,9 @@ export type Props = { }; } -type DesktopMessage = { - origin: string; - data: { - type: string; - message: { - version: string; - userIsActive: boolean; - manual: boolean; - channel: Channel; - teamId: string; - url: string; - }; - }; -} - export default class LoggedIn extends React.PureComponent { + private cleanupDesktopListeners?: () => void; + constructor(props: Props) { super(props); @@ -92,16 +78,13 @@ export default class LoggedIn extends React.PureComponent { GlobalActions.emitBrowserFocus(false); } - // Listen for messages from the desktop app - window.addEventListener('message', this.onDesktopMessageListener); - - // Tell the desktop app the webapp is ready - window.postMessage( - { - type: 'webapp-ready', - }, - window.location.origin, - ); + // Listen for user activity and notifications from the Desktop App (if applicable) + const offUserActivity = DesktopApp.onUserActivityUpdate(this.updateActiveStatus); + const offNotificationClicked = DesktopApp.onNotificationClicked(this.clickNotification); + this.cleanupDesktopListeners = () => { + offUserActivity(); + offNotificationClicked(); + }; // Device tracking setup if (UserAgent.isIos()) { @@ -133,7 +116,8 @@ export default class LoggedIn extends React.PureComponent { window.removeEventListener('focus', this.onFocusListener); window.removeEventListener('blur', this.onBlurListener); - window.removeEventListener('message', this.onDesktopMessageListener); + + this.cleanupDesktopListeners?.(); } public render(): React.ReactNode { @@ -164,44 +148,22 @@ export default class LoggedIn extends React.PureComponent { GlobalActions.emitBrowserFocus(false); } - // listen for messages from the desktop app - // TODO: This needs to be deprecated in favour of a more solid Desktop App API. - private onDesktopMessageListener = (desktopMessage: DesktopMessage) => { + private updateActiveStatus = (userIsActive: boolean, idleTime: number, manual: boolean) => { if (!this.props.currentUser) { return; } - if (desktopMessage.origin !== window.location.origin) { - return; - } - switch (desktopMessage.data.type) { - case 'register-desktop': { - // Currently used by calls - const {version} = desktopMessage.data.message; - if (!window.desktop) { - window.desktop = {}; - } - window.desktop.version = semver.valid(semver.coerce(version)); - break; + // update the server with the users current away status + if (userIsActive === true || userIsActive === false) { + WebSocketClient.userUpdateActiveStatus(userIsActive, manual); } - case 'user-activity-update': { - const {userIsActive, manual} = desktopMessage.data.message; + }; - // update the server with the users current away status - if (userIsActive === true || userIsActive === false) { - WebSocketClient.userUpdateActiveStatus(userIsActive, manual); - } - break; - } - case 'notification-clicked': { - const {channel, teamId, url} = desktopMessage.data.message; - window.focus(); + private clickNotification = (channelId: string, teamId: string, url: string) => { + window.focus(); - // navigate to the appropriate channel - this.props.actions.getChannelURLAction(channel, teamId, url); - break; - } - } + // navigate to the appropriate channel + this.props.actions.getChannelURLAction(channelId, teamId, url); }; private handleBackSpace = (e: KeyboardEvent): void => { diff --git a/webapp/channels/src/components/login/login.tsx b/webapp/channels/src/components/login/login.tsx index 339e7db500ee..5034f05a3ec5 100644 --- a/webapp/channels/src/components/login/login.tsx +++ b/webapp/channels/src/components/login/login.tsx @@ -53,6 +53,7 @@ import Input, {SIZE} from 'components/widgets/inputs/input/input'; import PasswordInput from 'components/widgets/inputs/password_input/password_input'; import Constants from 'utils/constants'; +import DesktopApp from 'utils/desktop_api'; import {t} from 'utils/i18n'; import {showNotification} from 'utils/notifications'; import {isDesktopApp} from 'utils/user_agent'; @@ -239,6 +240,7 @@ const Login = ({onCustomizeHeader}: LoginProps) => { const onDismissSessionExpired = useCallback(() => { LocalStorageStore.setWasLoggedIn(false); setSessionExpired(false); + DesktopApp.setSessionExpired(false); dismissAlert(); }, []); @@ -430,9 +432,12 @@ const Login = ({onCustomizeHeader}: LoginProps) => { // our session after we use it to complete the sign in change. LocalStorageStore.setWasLoggedIn(false); } else { + setSessionExpired(true); + DesktopApp.setSessionExpired(true); + // Although the authority remains the local sessionExpired bit on the state, set this // extra field in the querystring to signal the desktop app. - setSessionExpired(true); + // This is legacy support for older Desktop Apps and can be removed eventually const newSearchParam = new URLSearchParams(search); newSearchParam.set('extra', Constants.SESSION_EXPIRED); history.replace(`${pathname}?${newSearchParam}`); @@ -455,6 +460,8 @@ const Login = ({onCustomizeHeader}: LoginProps) => { window.removeEventListener('resize', onWindowResize); window.removeEventListener('focus', onWindowFocus); + + DesktopApp.setSessionExpired(false); }; }, []); diff --git a/webapp/channels/src/components/favicon_title_handler/index.ts b/webapp/channels/src/components/unreads_status_handler/index.ts similarity index 91% rename from webapp/channels/src/components/favicon_title_handler/index.ts rename to webapp/channels/src/components/unreads_status_handler/index.ts index 922f217e0621..8edb063e4022 100644 --- a/webapp/channels/src/components/favicon_title_handler/index.ts +++ b/webapp/channels/src/components/unreads_status_handler/index.ts @@ -15,11 +15,11 @@ import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; import type {GenericAction} from 'mattermost-redux/types/actions'; -import FaviconTitleHandler from './favicon_title_handler'; +import UnreadsStatusHandler from './unreads_status_handler'; type Props = RouteChildrenProps; -function mapStateToProps(state: GlobalState, {location: {pathname}}: Props): ComponentProps { +function mapStateToProps(state: GlobalState, {location: {pathname}}: Props): ComponentProps { const config = getConfig(state); const currentChannel = getCurrentChannel(state); const currentTeammate = (currentChannel && currentChannel.teammate_id) ? currentChannel : null; @@ -43,4 +43,4 @@ function mapDispatchToProps(dispatch: Dispatch) { }; } -export default withRouter(connect(mapStateToProps, mapDispatchToProps)(FaviconTitleHandler)); +export default withRouter(connect(mapStateToProps, mapDispatchToProps)(UnreadsStatusHandler)); diff --git a/webapp/channels/src/components/favicon_title_handler/favicon_title_handler.test.tsx b/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.test.tsx similarity index 85% rename from webapp/channels/src/components/favicon_title_handler/favicon_title_handler.test.tsx rename to webapp/channels/src/components/unreads_status_handler/unreads_status_handler.test.tsx index f37d14f61139..25f5109a4a6f 100644 --- a/webapp/channels/src/components/favicon_title_handler/favicon_title_handler.test.tsx +++ b/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.test.tsx @@ -8,15 +8,15 @@ import type {ComponentProps} from 'react'; import type {ChannelType} from '@mattermost/types/channels'; import type {TeamType} from '@mattermost/types/teams'; -import FaviconTitleHandler from 'components/favicon_title_handler/favicon_title_handler'; -import type {FaviconTitleHandlerClass} from 'components/favicon_title_handler/favicon_title_handler'; +import UnreadsStatusHandler from 'components/unreads_status_handler/unreads_status_handler'; +import type {UnreadsStatusHandlerClass} from 'components/unreads_status_handler/unreads_status_handler'; import {shallowWithIntl} from 'tests/helpers/intl-test-helper'; import {Constants} from 'utils/constants'; import {TestHelper} from 'utils/test_helper'; import {isChrome, isFirefox} from 'utils/user_agent'; -type Props = ComponentProps; +type Props = ComponentProps; jest.mock('utils/user_agent', () => { const original = jest.requireActual('utils/user_agent'); @@ -27,7 +27,7 @@ jest.mock('utils/user_agent', () => { }; }); -describe('components/FaviconTitleHandler', () => { +describe('components/UnreadsStatusHandler', () => { const defaultProps = { unreadStatus: false, siteName: 'Test site', @@ -51,8 +51,8 @@ describe('components/FaviconTitleHandler', () => { test('set correctly the title when needed', () => { const wrapper = shallowWithIntl( - , - ) as unknown as ShallowWrapper; + , + ) as unknown as ShallowWrapper; const instance = wrapper.instance(); instance.updateTitle(); instance.componentDidUpdate = jest.fn(); @@ -91,8 +91,8 @@ describe('components/FaviconTitleHandler', () => { (isFirefox as jest.Mock).mockImplementation(() => false); (isChrome as jest.Mock).mockImplementation(() => false); const wrapper = shallowWithIntl( - , - ) as unknown as ShallowWrapper; + , + ) as unknown as ShallowWrapper; const instance = wrapper.instance(); wrapper.setProps({ @@ -115,8 +115,8 @@ describe('components/FaviconTitleHandler', () => { document.head.appendChild(link); const wrapper = shallowWithIntl( - , - ) as unknown as ShallowWrapper; + , + ) as unknown as ShallowWrapper; const instance = wrapper.instance(); instance.updateFavicon = jest.fn(); @@ -138,13 +138,13 @@ describe('components/FaviconTitleHandler', () => { test('should display correct title when in drafts', () => { const wrapper = shallowWithIntl( - , - ) as unknown as ShallowWrapper; + ) as unknown as ShallowWrapper; wrapper.instance().updateTitle(); expect(document.title).toBe('Drafts - Test team display name'); diff --git a/webapp/channels/src/components/favicon_title_handler/favicon_title_handler.tsx b/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.tsx similarity index 93% rename from webapp/channels/src/components/favicon_title_handler/favicon_title_handler.tsx rename to webapp/channels/src/components/unreads_status_handler/unreads_status_handler.tsx index 2f49dbff948c..540a2c93886b 100644 --- a/webapp/channels/src/components/favicon_title_handler/favicon_title_handler.tsx +++ b/webapp/channels/src/components/unreads_status_handler/unreads_status_handler.tsx @@ -27,6 +27,7 @@ import faviconUnread32x32 from 'images/favicon/favicon-unread-32x32.png'; import faviconUnread64x64 from 'images/favicon/favicon-unread-64x64.png'; import faviconUnread96x96 from 'images/favicon/favicon-unread-96x96.png'; import {Constants} from 'utils/constants'; +import DesktopApp from 'utils/desktop_api'; import * as UserAgent from 'utils/user_agent'; enum BadgeStatus { @@ -46,7 +47,7 @@ type Props = { inDrafts: boolean; }; -export class FaviconTitleHandlerClass extends React.PureComponent { +export class UnreadsStatusHandlerClass extends React.PureComponent { componentDidUpdate(prevProps: Props) { this.updateTitle(); const oldBadgeStatus = this.getBadgeStatus(prevProps.unreadStatus); @@ -55,6 +56,8 @@ export class FaviconTitleHandlerClass extends React.PureComponent { if (oldBadgeStatus !== newBadgeStatus) { this.updateFavicon(newBadgeStatus); } + + this.updateDesktopApp(); } get isDynamicFaviconSupported() { @@ -70,6 +73,13 @@ export class FaviconTitleHandlerClass extends React.PureComponent { return BadgeStatus.None; } + updateDesktopApp = () => { + const {unreadStatus} = this.props; + const {isUnread, unreadMentionCount} = basicUnreadMeta(unreadStatus); + + DesktopApp.updateUnreadsAndMentions(isUnread, unreadMentionCount); + }; + updateTitle = () => { const { siteName, @@ -170,4 +180,4 @@ export class FaviconTitleHandlerClass extends React.PureComponent { } } -export default injectIntl(FaviconTitleHandlerClass); +export default injectIntl(UnreadsStatusHandlerClass); diff --git a/webapp/channels/src/plugins/export.js b/webapp/channels/src/plugins/export.js index a6e64f6d00cd..fb83d0444653 100644 --- a/webapp/channels/src/plugins/export.js +++ b/webapp/channels/src/plugins/export.js @@ -19,6 +19,7 @@ import Avatar from 'components/widgets/users/avatar'; import {getHistory} from 'utils/browser_history'; import {ModalIdentifiers} from 'utils/constants'; +import DesktopApp from 'utils/desktop_api'; import messageHtmlToComponent from 'utils/message_html_to_component'; import * as NotificationSounds from 'utils/notification_sounds'; import {formatText} from 'utils/text_formatting'; @@ -103,3 +104,6 @@ window.ProductApi = { getRhsSelectedPostId: getSelectedPostId, getIsRhsOpen, }; + +// Desktop App module containing the app info and a series of helpers to work with legacy code +window.DesktopApp = DesktopApp; diff --git a/webapp/channels/src/utils/browser_history.tsx b/webapp/channels/src/utils/browser_history.tsx index 1ea8949634c1..61e26cb109b6 100644 --- a/webapp/channels/src/utils/browser_history.tsx +++ b/webapp/channels/src/utils/browser_history.tsx @@ -5,57 +5,27 @@ import {createBrowserHistory} from 'history'; import type {History} from 'history'; import {getModule} from 'module_registry'; +import DesktopApp from 'utils/desktop_api'; import {isServerVersionGreaterThanOrEqualTo} from 'utils/server_version'; import {isDesktopApp, getDesktopVersion} from 'utils/user_agent'; const b = createBrowserHistory({basename: window.basename}); const isDesktop = isDesktopApp() && isServerVersionGreaterThanOrEqualTo(getDesktopVersion(), '5.0.0'); - -type Data = { - type?: string; - message?: Record; -} - -type Params = { - origin?: string; - data?: Data; -} - -window.addEventListener('message', ({origin, data: {type, message = {}} = {}}: Params = {}) => { - if (origin !== window.location.origin) { - return; - } - - switch (type) { - case 'browser-history-push-return': { - if (message.pathName) { - const {pathName} = message; - b.push(pathName); - } - break; - } - } -}); - const browserHistory = { ...b, push: (path: string | { pathname: string }, ...args: string[]) => { if (isDesktop) { - window.postMessage( - { - type: 'browser-history-push', - message: { - path: typeof path === 'object' ? path.pathname : path, - }, - }, - window.location.origin, - ); + DesktopApp.doBrowserHistoryPush(typeof path === 'object' ? path.pathname : path); } else { b.push(path, ...args); } }, }; +if (isDesktop) { + DesktopApp.onBrowserHistoryPush((pathName) => b.push(pathName)); +} + /** * Returns the current history object. * diff --git a/webapp/channels/src/utils/desktop_api.ts b/webapp/channels/src/utils/desktop_api.ts new file mode 100644 index 000000000000..a05f05c6ea9c --- /dev/null +++ b/webapp/channels/src/utils/desktop_api.ts @@ -0,0 +1,293 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import semver from 'semver'; + +import type {DesktopAPI} from '@mattermost/desktop-api'; + +import {isDesktopApp} from 'utils/user_agent'; + +declare global { + interface Window { + desktopAPI?: Partial; + } +} + +class DesktopAppAPI { + private name?: string; + private version?: string | null; + private dev?: boolean; + + /** + * @deprecated + */ + private postMessageListeners?: Map void>>; + + constructor() { + // Check the user agent string first + if (!isDesktopApp()) { + return; + } + + this.getDesktopAppInfo().then(({name, version}) => { + this.name = name; + this.version = semver.valid(semver.coerce(version)); + + // Legacy Desktop App version, used by some plugins + if (!window.desktop) { + window.desktop = {}; + } + window.desktop.version = semver.valid(semver.coerce(version)); + }); + window.desktopAPI?.isDev?.().then((isDev) => { + this.dev = isDev; + }); + + // Legacy code - to be removed + this.postMessageListeners = new Map(); + window.addEventListener('message', this.postMessageListener); + window.addEventListener('beforeunload', () => { + window.removeEventListener('message', this.postMessageListener); + }); + } + + /******************************************************* + * Getters/setters for Desktop App specific information + *******************************************************/ + + getAppName = () => { + return this.name; + }; + + getAppVersion = () => { + return this.version; + }; + + isDev = () => { + return this.dev; + }; + + private getDesktopAppInfo = () => { + if (window.desktopAPI?.getAppInfo) { + return window.desktopAPI.getAppInfo(); + } + + return this.invokeWithMessaging( + 'webapp-ready', + undefined, + 'register-desktop', + ); + }; + + /********************** + * Exposed API methods + **********************/ + + /** + * Invokes + */ + + getBrowserHistoryStatus = async () => { + if (window.desktopAPI?.requestBrowserHistoryStatus) { + return window.desktopAPI.requestBrowserHistoryStatus(); + } + + const {enableBack, enableForward} = await this.invokeWithMessaging( + 'history-button', + undefined, + 'history-button-return', + ); + + return { + canGoBack: enableBack, + canGoForward: enableForward, + }; + }; + + /** + * Listeners + */ + + onUserActivityUpdate = (listener: (userIsActive: boolean, idleTime: number, isSystemEvent: boolean) => void) => { + if (window.desktopAPI?.onUserActivityUpdate) { + return window.desktopAPI.onUserActivityUpdate(listener); + } + + const legacyListener = ({userIsActive, manual}: {userIsActive: boolean; manual: boolean}) => listener(userIsActive, 0, manual); + this.addPostMessageListener('user-activity-update', legacyListener); + + return () => this.removePostMessageListener('user-activity-update', legacyListener); + }; + + onNotificationClicked = (listener: (channelId: string, teamId: string, url: string) => void) => { + if (window.desktopAPI?.onNotificationClicked) { + return window.desktopAPI.onNotificationClicked(listener); + } + + const legacyListener = ({channel, teamId, url}: {channel: {id: string}; teamId: string; url: string}) => listener(channel.id, teamId, url); + this.addPostMessageListener('notification-clicked', legacyListener); + + return () => this.removePostMessageListener('notification-clicked', legacyListener); + }; + + onBrowserHistoryPush = (listener: (pathName: string) => void) => { + if (window.desktopAPI?.onBrowserHistoryPush) { + return window.desktopAPI.onBrowserHistoryPush(listener); + } + + const legacyListener = ({pathName}: {pathName: string}) => listener(pathName); + this.addPostMessageListener('browser-history-push-return', legacyListener); + + return () => this.removePostMessageListener('browser-history-push-return', legacyListener); + }; + + onBrowserHistoryStatusUpdated = (listener: (enableBack: boolean, enableForward: boolean) => void) => { + if (window.desktopAPI?.onBrowserHistoryStatusUpdated) { + return window.desktopAPI.onBrowserHistoryStatusUpdated(listener); + } + + const legacyListener = ({enableBack, enableForward}: {enableBack: boolean; enableForward: boolean}) => listener(enableBack, enableForward); + this.addPostMessageListener('history-button-return', legacyListener); + + return () => this.removePostMessageListener('history-button-return', legacyListener); + }; + + /** + * One-ways + */ + + dispatchNotification = ( + title: string, + body: string, + channelId: string, + teamId: string, + silent: boolean, + soundName: string, + url: string, + ) => { + if (window.desktopAPI?.sendNotification) { + window.desktopAPI.sendNotification(title, body, channelId, teamId, url, silent, soundName); + return; + } + + // get the desktop app to trigger the notification + window.postMessage( + { + type: 'dispatch-notification', + message: { + title, + body, + channel: {id: channelId}, + teamId, + silent, + data: {soundName}, + url, + }, + }, + window.location.origin, + ); + }; + + doBrowserHistoryPush = (path: string) => { + if (window.desktopAPI?.sendBrowserHistoryPush) { + window.desktopAPI.sendBrowserHistoryPush(path); + return; + } + + window.postMessage( + { + type: 'browser-history-push', + message: {path}, + }, + window.location.origin, + ); + }; + + updateUnreadsAndMentions = (isUnread: boolean, mentionCount: number) => + window.desktopAPI?.setUnreadsAndMentions && window.desktopAPI.setUnreadsAndMentions(isUnread, mentionCount); + setSessionExpired = (expired: boolean) => window.desktopAPI?.setSessionExpired && window.desktopAPI.setSessionExpired(expired); + + /********************************************************************* + * Helper functions for legacy code + * Remove all of this once we have no use for message passing anymore + *********************************************************************/ + + /** + * @deprecated + */ + private postMessageListener = ({origin, data: {type, message}}: {origin: string; data: {type: string; message: unknown}}) => { + if (origin !== window.location.origin) { + return; + } + + const listeners = this.postMessageListeners?.get(type); + if (!listeners) { + return; + } + + listeners.forEach((listener) => { + listener(message); + }); + }; + + /** + * @deprecated + */ + private addPostMessageListener = (channel: string, listener: (message: any) => void) => { + if (this.postMessageListeners?.has(channel)) { + this.postMessageListeners.set(channel, this.postMessageListeners.get(channel)!.add(listener)); + } else { + this.postMessageListeners?.set(channel, new Set([listener])); + } + }; + + /** + * @deprecated + */ + private removePostMessageListener = (channel: string, listener: (message: any) => void) => { + const set = this.postMessageListeners?.get(channel); + set?.delete(listener); + if (set?.size) { + this.postMessageListeners?.set(channel, set); + } else { + this.postMessageListeners?.delete(channel); + } + }; + + /** + * @deprecated + */ + private invokeWithMessaging = ( + sendChannel: string, + sendData?: T, + receiveChannel?: string, + ) => { + return new Promise((resolve) => { + /* create and register a temporary listener if necessary */ + const response = ({origin, data: {type, message}}: {origin: string; data: {type: string; message: T2}}) => { + /* ignore messages from other frames */ + if (origin !== window.location.origin) { + return; + } + + /* ignore messages from other channels */ + if (type !== (receiveChannel ?? sendChannel)) { + return; + } + + /* clean up listener and resolve */ + window.removeEventListener('message', response); + resolve(message); + }; + + window.addEventListener('message', response); + window.postMessage( + {type: sendChannel, message: sendData}, + window.location.origin, + ); + }); + }; +} + +const DesktopApp = new DesktopAppAPI(); +export default DesktopApp; diff --git a/webapp/package-lock.json b/webapp/package-lock.json index d9c79bae6851..6b15cf024590 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -60,6 +60,7 @@ "@mattermost/client": "*", "@mattermost/compass-components": "^0.2.12", "@mattermost/compass-icons": "0.1.39", + "@mattermost/desktop-api": "5.7.0-1", "@mattermost/types": "*", "@mui/base": "5.0.0-alpha.127", "@mui/material": "5.11.16", @@ -4084,6 +4085,19 @@ "resolved": "platform/components", "link": true }, + "node_modules/@mattermost/desktop-api": { + "version": "5.7.0-1", + "resolved": "https://registry.npmjs.org/@mattermost/desktop-api/-/desktop-api-5.7.0-1.tgz", + "integrity": "sha512-3VdmrdiGwqXpLGomRWiDt8L2LMlOyeAP+S9uKm82JpRt8HoVFeSe9fu39VV9pbnA8O6u6wfnwGUXFeasmmIIkQ==", + "peerDependencies": { + "typescript": "^4.3" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@mattermost/eslint-plugin": { "resolved": "platform/eslint-plugin", "link": true