[MM-55153] Consolidate Desktop App API, use new contextBridge endpoints when available (#25438)

* Create Desktop API module, migrate message passing code

* Changes to use new API

* Use Desktop API to notify when mentions/unreads/expired changes

* Expose Desktop App module to plugins

* Fix lint

* PR feedback

* Fixed an issue where I forgot to check if the method exists first

* Slight API changes

* Fix package

* Convert all to class, add comments, small reworks for PR feedback
This commit is contained in:
Devin Binnie 2023-12-04 14:09:46 -05:00 committed by GitHub
parent 8bf9e4c481
commit d62122b884
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 401 additions and 179 deletions

View File

@ -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",

View File

@ -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,

View File

@ -9,7 +9,7 @@ jest.mock('components/reset_status_modal', () => () => <div/>);
jest.mock('components/sidebar', () => () => <div/>);
jest.mock('components/channel_layout/center_channel', () => () => <div/>);
jest.mock('components/loading_screen', () => () => <div/>);
jest.mock('components/favicon_title_handler', () => () => <div/>);
jest.mock('components/unreads_status_handler', () => () => <div/>);
jest.mock('components/product_notices_modal', () => () => <div/>);
jest.mock('plugins/pluggable', () => () => <div/>);

View File

@ -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'
>
<FaviconTitleHandler/>
<UnreadsStatusHandler/>
<ProductNoticesModal/>
<div className={classNames('container-fluid channel-view-inner')}>
{props.shouldRenderCenterChannel ? <CenterChannel/> : <LoadingScreen centered={true}/>}

View File

@ -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<boolean>;
};
}
}
enum DesktopAuthStatus {
None,
WaitingForBrowser,
@ -71,8 +65,7 @@ const DesktopAuthToken: React.FC<Props> = ({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);

View File

@ -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;
}
const requestButtons = async () => {
const {canGoBack, canGoForward} = await DesktopApp.getBrowserHistoryStatus();
updateButtons(canGoBack, canGoForward);
};
switch (message.data.type) {
case 'history-button-return': {
setCanGoBack(message.data.message.enableBack);
setCanGoForward(message.data.message.enableForward);
break;
}
}
}, []);
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 (
<HistoryButtonsContainer>

View File

@ -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));
};

View File

@ -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<Props> {
private cleanupDesktopListeners?: () => void;
constructor(props: Props) {
super(props);
@ -92,16 +78,13 @@ export default class LoggedIn extends React.PureComponent<Props> {
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<Props> {
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<Props> {
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 => {

View File

@ -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);
};
}, []);

View File

@ -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<typeof FaviconTitleHandler> {
function mapStateToProps(state: GlobalState, {location: {pathname}}: Props): ComponentProps<typeof UnreadsStatusHandler> {
const config = getConfig(state);
const currentChannel = getCurrentChannel(state);
const currentTeammate = (currentChannel && currentChannel.teammate_id) ? currentChannel : null;
@ -43,4 +43,4 @@ function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
};
}
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(FaviconTitleHandler));
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(UnreadsStatusHandler));

View File

@ -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<typeof FaviconTitleHandlerClass>;
type Props = ComponentProps<typeof UnreadsStatusHandlerClass>;
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(
<FaviconTitleHandler {...defaultProps}/>,
) as unknown as ShallowWrapper<Props, any, FaviconTitleHandlerClass>;
<UnreadsStatusHandler {...defaultProps}/>,
) as unknown as ShallowWrapper<Props, any, UnreadsStatusHandlerClass>;
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(
<FaviconTitleHandler {...defaultProps}/>,
) as unknown as ShallowWrapper<Props, any, FaviconTitleHandlerClass>;
<UnreadsStatusHandler {...defaultProps}/>,
) as unknown as ShallowWrapper<Props, any, UnreadsStatusHandlerClass>;
const instance = wrapper.instance();
wrapper.setProps({
@ -115,8 +115,8 @@ describe('components/FaviconTitleHandler', () => {
document.head.appendChild(link);
const wrapper = shallowWithIntl(
<FaviconTitleHandler {...defaultProps}/>,
) as unknown as ShallowWrapper<Props, any, FaviconTitleHandlerClass>;
<UnreadsStatusHandler {...defaultProps}/>,
) as unknown as ShallowWrapper<Props, any, UnreadsStatusHandlerClass>;
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(
<FaviconTitleHandler
<UnreadsStatusHandler
{...defaultProps}
inDrafts={true}
currentChannel={undefined}
siteName={undefined}
/>,
) as unknown as ShallowWrapper<Props, any, FaviconTitleHandlerClass>;
) as unknown as ShallowWrapper<Props, any, UnreadsStatusHandlerClass>;
wrapper.instance().updateTitle();
expect(document.title).toBe('Drafts - Test team display name');

View File

@ -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<Props> {
export class UnreadsStatusHandlerClass extends React.PureComponent<Props> {
componentDidUpdate(prevProps: Props) {
this.updateTitle();
const oldBadgeStatus = this.getBadgeStatus(prevProps.unreadStatus);
@ -55,6 +56,8 @@ export class FaviconTitleHandlerClass extends React.PureComponent<Props> {
if (oldBadgeStatus !== newBadgeStatus) {
this.updateFavicon(newBadgeStatus);
}
this.updateDesktopApp();
}
get isDynamicFaviconSupported() {
@ -70,6 +73,13 @@ export class FaviconTitleHandlerClass extends React.PureComponent<Props> {
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<Props> {
}
}
export default injectIntl(FaviconTitleHandlerClass);
export default injectIntl(UnreadsStatusHandlerClass);

View File

@ -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;

View File

@ -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<string, string>;
}
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.
*

View File

@ -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<DesktopAPI>;
}
}
class DesktopAppAPI {
private name?: string;
private version?: string | null;
private dev?: boolean;
/**
* @deprecated
*/
private postMessageListeners?: Map<string, Set<(message: unknown) => 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<void, {name: string; version: string}>(
'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<void, {enableBack: boolean; enableForward: boolean}>(
'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 = <T, T2>(
sendChannel: string,
sendData?: T,
receiveChannel?: string,
) => {
return new Promise<T2>((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;

View File

@ -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