mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
[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:
parent
8bf9e4c481
commit
d62122b884
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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/>);
|
||||
|
||||
|
@ -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}/>}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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));
|
||||
};
|
||||
|
||||
|
@ -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 => {
|
||||
|
@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
@ -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));
|
@ -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');
|
@ -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);
|
@ -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;
|
||||
|
@ -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.
|
||||
*
|
||||
|
293
webapp/channels/src/utils/desktop_api.ts
Normal file
293
webapp/channels/src/utils/desktop_api.ts
Normal 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;
|
14
webapp/package-lock.json
generated
14
webapp/package-lock.json
generated
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user