[MM-22555] Auto-fill server URLs when deep linking into the Desktop App and the server isn't configured (#3200)

* Allow deep linking to non-configured servers by auto filling the modal

* Fall back to base URL if URL with path name does not work

* Allow deep linking directly into a new server with a permalink

* Support welcome screen/no server case

* Some cleanup
This commit is contained in:
Devin Binnie
2024-11-18 14:08:00 -05:00
committed by GitHub
parent 21487e2496
commit 8aa0b86c7a
14 changed files with 107 additions and 38 deletions

View File

@@ -128,8 +128,6 @@
"main.tray.tray.expired": "Session Expired: Please sign in to continue receiving notifications.", "main.tray.tray.expired": "Session Expired: Please sign in to continue receiving notifications.",
"main.tray.tray.mention": "You have been mentioned", "main.tray.tray.mention": "You have been mentioned",
"main.tray.tray.unread": "You have unread channels", "main.tray.tray.unread": "You have unread channels",
"main.views.viewManager.handleDeepLink.error.body": "There is no configured server in the app that matches the requested url: {url}",
"main.views.viewManager.handleDeepLink.error.title": "No matching server",
"main.windows.mainWindow.closeApp.dialog.checkboxLabel": "Don't ask again", "main.windows.mainWindow.closeApp.dialog.checkboxLabel": "Don't ask again",
"main.windows.mainWindow.closeApp.dialog.detail": "You will no longer receive notifications for messages. If you want to leave {appName} running in the system tray, you can enable this in Settings.", "main.windows.mainWindow.closeApp.dialog.detail": "You will no longer receive notifications for messages. If you want to leave {appName} running in the system tray, you can enable this in Settings.",
"main.windows.mainWindow.closeApp.dialog.message": "Are you sure you want to quit?", "main.windows.mainWindow.closeApp.dialog.message": "Are you sure you want to quit?",

View File

@@ -209,7 +209,7 @@ describe('app/serverViewState', () => {
serverViewState.showNewServerModal(); serverViewState.showNewServerModal();
await promise; await promise;
expect(ServerManager.addServer).toHaveBeenCalledWith(data); expect(ServerManager.addServer).toHaveBeenCalledWith(data, undefined);
expect(serversCopy).toContainEqual(expect.objectContaining({ expect(serversCopy).toContainEqual(expect.objectContaining({
id: 'server-1', id: 'server-1',
name: 'new-server', name: 'new-server',

View File

@@ -44,7 +44,7 @@ export class ServerViewState {
constructor() { constructor() {
ipcMain.on(SWITCH_SERVER, (event, serverId) => this.switchServer(serverId)); ipcMain.on(SWITCH_SERVER, (event, serverId) => this.switchServer(serverId));
ipcMain.on(SHOW_NEW_SERVER_MODAL, this.showNewServerModal); ipcMain.on(SHOW_NEW_SERVER_MODAL, this.handleShowNewServerModal);
ipcMain.on(SHOW_EDIT_SERVER_MODAL, this.showEditServerModal); ipcMain.on(SHOW_EDIT_SERVER_MODAL, this.showEditServerModal);
ipcMain.on(SHOW_REMOVE_SERVER_MODAL, this.showRemoveServerModal); ipcMain.on(SHOW_REMOVE_SERVER_MODAL, this.showRemoveServerModal);
ipcMain.handle(VALIDATE_SERVER_URL, this.handleServerURLValidation); ipcMain.handle(VALIDATE_SERVER_URL, this.handleServerURLValidation);
@@ -123,25 +123,32 @@ export class ServerViewState {
* Server Modals * Server Modals
*/ */
showNewServerModal = () => { showNewServerModal = (prefillURL?: string) => {
log.debug('showNewServerModal'); log.debug('showNewServerModal', {prefillURL});
const mainWindow = MainWindow.get(); const mainWindow = MainWindow.get();
if (!mainWindow) { if (!mainWindow) {
return; return;
} }
const modalPromise = ModalManager.addModal<null, Server>( const modalPromise = ModalManager.addModal<{prefillURL?: string}, Server>(
'newServer', 'newServer',
'mattermost-desktop://renderer/newServer.html', 'mattermost-desktop://renderer/newServer.html',
getLocalPreload('internalAPI.js'), getLocalPreload('internalAPI.js'),
null, {prefillURL},
mainWindow, mainWindow,
!ServerManager.hasServers(), !ServerManager.hasServers(),
); );
modalPromise.then((data) => { modalPromise.then((data) => {
const newServer = ServerManager.addServer(data); let initialLoadURL;
if (prefillURL) {
const parsedServerURL = parseURL(data.url);
if (parsedServerURL) {
initialLoadURL = parseURL(`${parsedServerURL.origin}${prefillURL.substring(prefillURL.indexOf('/'))}`);
}
}
const newServer = ServerManager.addServer(data, initialLoadURL);
this.switchServer(newServer.id, true); this.switchServer(newServer.id, true);
}).catch((e) => { }).catch((e) => {
// e is undefined for user cancellation // e is undefined for user cancellation
@@ -151,6 +158,8 @@ export class ServerViewState {
}); });
}; };
private handleShowNewServerModal = () => this.showNewServerModal();
private showEditServerModal = (e: IpcMainEvent, id: string) => { private showEditServerModal = (e: IpcMainEvent, id: string) => {
log.debug('showEditServerModal', id); log.debug('showEditServerModal', id);
@@ -281,6 +290,11 @@ export class ServerViewState {
// If the original URL was invalid, don't replace that as they probably have a typo somewhere // If the original URL was invalid, don't replace that as they probably have a typo somewhere
// Also strip the trailing slash if it's there so that the user can keep typing // Also strip the trailing slash if it's there so that the user can keep typing
if (!remoteInfo) { if (!remoteInfo) {
// If the URL provided has a path, try to validate the server with parts of the path removed, until we reach the root and then return a failure
if (parsedURL.pathname !== '/') {
return this.handleServerURLValidation(e, parsedURL.toString().substring(0, parsedURL.toString().lastIndexOf('/')), currentId);
}
return {status: URLValidationStatus.NotMattermost, validatedURL: parsedURL.toString().replace(/\/$/, '')}; return {status: URLValidationStatus.NotMattermost, validatedURL: parsedURL.toString().replace(/\/$/, '')};
} }

View File

@@ -12,14 +12,16 @@ export class MattermostServer {
name: string; name: string;
url!: URL; url!: URL;
isPredefined: boolean; isPredefined: boolean;
initialLoadURL?: URL;
constructor(server: Server, isPredefined: boolean) { constructor(server: Server, isPredefined: boolean, initialLoadURL?: URL) {
this.id = uuid(); this.id = uuid();
this.name = server.name; this.name = server.name;
this.updateURL(server.url); this.updateURL(server.url);
this.isPredefined = isPredefined; this.isPredefined = isPredefined;
this.initialLoadURL = initialLoadURL;
} }
updateURL = (url: string) => { updateURL = (url: string) => {

View File

@@ -156,8 +156,8 @@ export class ServerManager extends EventEmitter {
this.persistServers(); this.persistServers();
}; };
addServer = (server: Server) => { addServer = (server: Server, initialLoadURL?: URL) => {
const newServer = new MattermostServer(server, false); const newServer = new MattermostServer(server, false, initialLoadURL);
if (this.servers.has(newServer.id)) { if (this.servers.has(newServer.id)) {
throw new Error('ID Collision detected. Cannot add server.'); throw new Error('ID Collision detected. Cannot add server.');

View File

@@ -63,7 +63,7 @@ describe('main/app/intercom', () => {
ModalManager.addModal.mockReturnValue(promise); ModalManager.addModal.mockReturnValue(promise);
handleWelcomeScreenModal(); handleWelcomeScreenModal();
expect(ModalManager.addModal).toHaveBeenCalledWith('welcomeScreen', 'mattermost-desktop://renderer/welcomeScreen.html', '/some/preload.js', null, {}, true); expect(ModalManager.addModal).toHaveBeenCalledWith('welcomeScreen', 'mattermost-desktop://renderer/welcomeScreen.html', '/some/preload.js', {prefillURL: undefined}, {}, true);
}); });
}); });

View File

@@ -8,6 +8,7 @@ import ServerViewState from 'app/serverViewState';
import {Logger} from 'common/log'; import {Logger} from 'common/log';
import ServerManager from 'common/servers/serverManager'; import ServerManager from 'common/servers/serverManager';
import {ping} from 'common/utils/requests'; import {ping} from 'common/utils/requests';
import {parseURL} from 'common/utils/url';
import NotificationManager from 'main/notifications'; import NotificationManager from 'main/notifications';
import {getLocalPreload} from 'main/utils'; import {getLocalPreload} from 'main/utils';
import ModalManager from 'main/views/modalManager'; import ModalManager from 'main/views/modalManager';
@@ -85,7 +86,7 @@ export function handleMainWindowIsShown() {
} }
} }
export function handleWelcomeScreenModal() { export function handleWelcomeScreenModal(prefillURL?: string) {
log.debug('handleWelcomeScreenModal'); log.debug('handleWelcomeScreenModal');
const html = 'mattermost-desktop://renderer/welcomeScreen.html'; const html = 'mattermost-desktop://renderer/welcomeScreen.html';
@@ -96,10 +97,17 @@ export function handleWelcomeScreenModal() {
if (!mainWindow) { if (!mainWindow) {
return; return;
} }
const modalPromise = ModalManager.addModal<null, UniqueServer>('welcomeScreen', html, preload, null, mainWindow, !ServerManager.hasServers()); const modalPromise = ModalManager.addModal<{prefillURL?: string}, UniqueServer>('welcomeScreen', html, preload, {prefillURL}, mainWindow, !ServerManager.hasServers());
if (modalPromise) { if (modalPromise) {
modalPromise.then((data) => { modalPromise.then((data) => {
const newServer = ServerManager.addServer(data); let initialLoadURL;
if (prefillURL) {
const parsedServerURL = parseURL(data.url);
if (parsedServerURL) {
initialLoadURL = parseURL(`${parsedServerURL.origin}${prefillURL.substring(prefillURL.indexOf('/'))}`);
}
}
const newServer = ServerManager.addServer(data, initialLoadURL);
ServerViewState.switchServer(newServer.id, true); ServerViewState.switchServer(newServer.id, true);
}).catch((e) => { }).catch((e) => {
// e is undefined for user cancellation // e is undefined for user cancellation

View File

@@ -65,6 +65,18 @@ export class ModalManager {
return this.modalPromises.get(key) as Promise<T2>; return this.modalPromises.get(key) as Promise<T2>;
}; };
removeModal = (key: string) => {
const modalView = this.modalQueue.find((modal) => modal.key === key);
if (!modalView) {
return;
}
modalView.hide();
modalView.resolve(null);
this.modalPromises.delete(key);
this.filterActive();
};
findModalByCaller = (event: IpcMainInvokeEvent) => { findModalByCaller = (event: IpcMainInvokeEvent) => {
if (this.modalQueue.length) { if (this.modalQueue.length) {
const requestModal = this.modalQueue.find((modal) => { const requestModal = this.modalQueue.find((modal) => {

View File

@@ -1,8 +1,6 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {dialog} from 'electron';
import ServerViewState from 'app/serverViewState'; import ServerViewState from 'app/serverViewState';
import {BROWSER_HISTORY_PUSH, LOAD_SUCCESS, SET_ACTIVE_VIEW} from 'common/communication'; import {BROWSER_HISTORY_PUSH, LOAD_SUCCESS, SET_ACTIVE_VIEW} from 'common/communication';
import ServerManager from 'common/servers/serverManager'; import ServerManager from 'common/servers/serverManager';
@@ -20,9 +18,6 @@ jest.mock('electron', () => ({
getAppPath: () => '/path/to/app', getAppPath: () => '/path/to/app',
getPath: jest.fn(() => '/valid/downloads/path'), getPath: jest.fn(() => '/valid/downloads/path'),
}, },
dialog: {
showErrorBox: jest.fn(),
},
ipcMain: { ipcMain: {
emit: jest.fn(), emit: jest.fn(),
on: jest.fn(), on: jest.fn(),
@@ -33,6 +28,7 @@ jest.mock('app/serverViewState', () => ({
getCurrentServer: jest.fn(), getCurrentServer: jest.fn(),
updateCurrentView: jest.fn(), updateCurrentView: jest.fn(),
init: jest.fn(), init: jest.fn(),
showNewServerModal: jest.fn(),
})); }));
jest.mock('common/views/View', () => ({ jest.mock('common/views/View', () => ({
getViewName: jest.fn((a, b) => `${a}-${b}`), getViewName: jest.fn((a, b) => `${a}-${b}`),
@@ -62,6 +58,10 @@ jest.mock('main/app/utils', () => ({
flushCookiesStore: jest.fn(), flushCookiesStore: jest.fn(),
})); }));
jest.mock('main/app/intercom', () => ({
handleWelcomeScreenModal: jest.fn(),
}));
jest.mock('main/i18nManager', () => ({ jest.mock('main/i18nManager', () => ({
localizeMessage: jest.fn(), localizeMessage: jest.fn(),
})); }));
@@ -116,8 +116,9 @@ jest.mock('./MattermostWebContentsView', () => ({
MattermostWebContentsView: jest.fn(), MattermostWebContentsView: jest.fn(),
})); }));
jest.mock('./modalManager', () => ({ jest.mock('main/views/modalManager', () => ({
showModal: jest.fn(), showModal: jest.fn(),
removeModal: jest.fn(),
isModalDisplayed: jest.fn(), isModalDisplayed: jest.fn(),
})); }));
jest.mock('./webContentEvents', () => ({})); jest.mock('./webContentEvents', () => ({}));
@@ -321,6 +322,7 @@ describe('main/views/viewManager', () => {
isOpen: true, isOpen: true,
url: new URL('http://server1.com/view'), url: new URL('http://server1.com/view'),
}, },
undefined,
); );
makeSpy.mockRestore(); makeSpy.mockRestore();
}); });
@@ -692,11 +694,12 @@ describe('main/views/viewManager', () => {
expect(view.load).not.toHaveBeenCalled(); expect(view.load).not.toHaveBeenCalled();
}); });
it('should throw dialog when cannot find the view', () => { it('should open new server modal when using a server that does not exist', () => {
ServerManager.hasServers.mockReturnValue(true);
const view = {...baseView}; const view = {...baseView};
viewManager.handleDeepLink('mattermost://server-1.com/deep/link?thing=yes'); viewManager.handleDeepLink('mattermost://server-2.com/deep/link?thing=yes');
expect(view.load).not.toHaveBeenCalled(); expect(view.load).not.toHaveBeenCalled();
expect(dialog.showErrorBox).toHaveBeenCalled(); expect(ServerViewState.showNewServerModal).toHaveBeenCalled();
}); });
it('should reopen closed view if called upon', () => { it('should reopen closed view if called upon', () => {

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import type {IpcMainEvent, IpcMainInvokeEvent} from 'electron'; import type {IpcMainEvent, IpcMainInvokeEvent} from 'electron';
import {WebContentsView, dialog, ipcMain} from 'electron'; import {WebContentsView, ipcMain} from 'electron';
import isDev from 'electron-is-dev'; import isDev from 'electron-is-dev';
import ServerViewState from 'app/serverViewState'; import ServerViewState from 'app/serverViewState';
@@ -41,18 +41,18 @@ import {getFormattedPathName, parseURL} from 'common/utils/url';
import Utils from 'common/utils/util'; import Utils from 'common/utils/util';
import type {MattermostView} from 'common/views/View'; import type {MattermostView} from 'common/views/View';
import {TAB_MESSAGING} from 'common/views/View'; import {TAB_MESSAGING} from 'common/views/View';
import {handleWelcomeScreenModal} from 'main/app/intercom';
import {flushCookiesStore} from 'main/app/utils'; import {flushCookiesStore} from 'main/app/utils';
import DeveloperMode from 'main/developerMode'; import DeveloperMode from 'main/developerMode';
import {localizeMessage} from 'main/i18nManager';
import performanceMonitor from 'main/performanceMonitor'; import performanceMonitor from 'main/performanceMonitor';
import PermissionsManager from 'main/permissionsManager'; import PermissionsManager from 'main/permissionsManager';
import ModalManager from 'main/views/modalManager';
import MainWindow from 'main/windows/mainWindow'; import MainWindow from 'main/windows/mainWindow';
import type {DeveloperSettings} from 'types/settings'; import type {DeveloperSettings} from 'types/settings';
import LoadingScreen from './loadingScreen'; import LoadingScreen from './loadingScreen';
import {MattermostWebContentsView} from './MattermostWebContentsView'; import {MattermostWebContentsView} from './MattermostWebContentsView';
import modalManager from './modalManager';
import {getLocalPreload, getAdjustedWindowBoundaries} from '../utils'; import {getLocalPreload, getAdjustedWindowBoundaries} from '../utils';
@@ -158,14 +158,14 @@ export class ViewManager {
} else { } else {
this.getViewLogger(viewId).warn(`Couldn't find a view with name: ${viewId}`); this.getViewLogger(viewId).warn(`Couldn't find a view with name: ${viewId}`);
} }
modalManager.showModal(); ModalManager.showModal();
}; };
focusCurrentView = () => { focusCurrentView = () => {
log.debug('focusCurrentView'); log.debug('focusCurrentView');
if (modalManager.isModalDisplayed()) { if (ModalManager.isModalDisplayed()) {
modalManager.focusCurrentModal(); ModalManager.focusCurrentModal();
return; return;
} }
@@ -227,11 +227,11 @@ export class ViewManager {
webContentsView.once(LOAD_FAILED, this.deeplinkFailed); webContentsView.once(LOAD_FAILED, this.deeplinkFailed);
} }
} }
} else if (ServerManager.hasServers()) {
ServerViewState.showNewServerModal(`${parsedURL.host}${getFormattedPathName(parsedURL.pathname)}${parsedURL.search}`);
} else { } else {
dialog.showErrorBox( ModalManager.removeModal('welcomeScreen');
localizeMessage('main.views.viewManager.handleDeepLink.error.title', 'No matching server'), handleWelcomeScreenModal(`${parsedURL.host}${getFormattedPathName(parsedURL.pathname)}${parsedURL.search}`);
localizeMessage('main.views.viewManager.handleDeepLink.error.body', 'There is no configured server in the app that matches the requested url: {url}', {url: parsedURL.toString()}),
);
} }
} }
}; };
@@ -439,7 +439,7 @@ export class ViewManager {
} else if (recycle) { } else if (recycle) {
views.set(view.id, recycle); views.set(view.id, recycle);
} else { } else {
views.set(view.id, this.makeView(srv, view)); views.set(view.id, this.makeView(srv, view, srv.initialLoadURL?.toString()));
} }
} }

View File

@@ -20,6 +20,7 @@ import 'renderer/css/components/LoadingScreen.css';
type ConfigureServerProps = { type ConfigureServerProps = {
server?: UniqueServer; server?: UniqueServer;
prefillURL?: string;
mobileView?: boolean; mobileView?: boolean;
darkMode?: boolean; darkMode?: boolean;
messageTitle?: string; messageTitle?: string;
@@ -33,6 +34,7 @@ type ConfigureServerProps = {
function ConfigureServer({ function ConfigureServer({
server, server,
prefillURL,
mobileView, mobileView,
darkMode, darkMode,
messageTitle, messageTitle,
@@ -53,8 +55,8 @@ function ConfigureServer({
const mounted = useRef(false); const mounted = useRef(false);
const [transition, setTransition] = useState<'inFromRight' | 'outToLeft'>(); const [transition, setTransition] = useState<'inFromRight' | 'outToLeft'>();
const [name, setName] = useState(prevName || ''); const [name, setName] = useState(prevName ?? '');
const [url, setUrl] = useState(prevURL || ''); const [url, setUrl] = useState(prevURL ?? prefillURL ?? '');
const [nameError, setNameError] = useState(''); const [nameError, setNameError] = useState('');
const [urlError, setURLError] = useState<{type: STATUS; value: string}>(); const [urlError, setURLError] = useState<{type: STATUS; value: string}>();
const [showContent, setShowContent] = useState(false); const [showContent, setShowContent] = useState(false);
@@ -71,6 +73,11 @@ function ConfigureServer({
setTransition('inFromRight'); setTransition('inFromRight');
setShowContent(true); setShowContent(true);
mounted.current = true; mounted.current = true;
if (url) {
fetchValidationResult(url);
}
return () => { return () => {
mounted.current = false; mounted.current = false;
}; };

View File

@@ -27,6 +27,7 @@ type Props = {
currentOrder?: number; currentOrder?: number;
setInputRef?: (inputRef: HTMLInputElement) => void; setInputRef?: (inputRef: HTMLInputElement) => void;
intl: IntlShape; intl: IntlShape;
prefillURL?: string;
}; };
type State = { type State = {
@@ -77,6 +78,13 @@ class NewServerModal extends React.PureComponent<Props, State> {
this.mounted = false; this.mounted = false;
} }
componentDidUpdate(prevProps: Readonly<Props>): void {
if (this.props.prefillURL && this.props.prefillURL !== prevProps.prefillURL) {
this.setState({serverUrl: this.props.prefillURL});
this.validateServerURL(this.props.prefillURL);
}
}
initializeOnShow = async () => { initializeOnShow = async () => {
const cameraDisabled = window.process.platform === 'win32' && await window.desktop.getMediaAccessStatus('camera') !== 'granted'; const cameraDisabled = window.process.platform === 'win32' && await window.desktop.getMediaAccessStatus('camera') !== 'granted';
const microphoneDisabled = window.process.platform === 'win32' && await window.desktop.getMediaAccessStatus('microphone') !== 'granted'; const microphoneDisabled = window.process.platform === 'win32' && await window.desktop.getMediaAccessStatus('microphone') !== 'granted';

View File

@@ -25,12 +25,18 @@ const onSave = (data: UniqueServer) => {
}; };
const NewServerModalWrapper: React.FC = () => { const NewServerModalWrapper: React.FC = () => {
const [data, setData] = useState<{prefillURL?: string}>();
const [unremoveable, setUnremovable] = useState<boolean>(); const [unremoveable, setUnremovable] = useState<boolean>();
useEffect(() => { useEffect(() => {
window.desktop.modals.isModalUncloseable().then((uncloseable) => { window.desktop.modals.isModalUncloseable().then((uncloseable) => {
setUnremovable(uncloseable); setUnremovable(uncloseable);
}); });
window.desktop.modals.getModalInfo<{prefillURL?: string}>().
then((data) => {
setData(data);
});
}, []); }, []);
return ( return (
@@ -39,6 +45,7 @@ const NewServerModalWrapper: React.FC = () => {
onClose={unremoveable ? undefined : onClose} onClose={unremoveable ? undefined : onClose}
onSave={onSave} onSave={onSave}
editMode={false} editMode={false}
prefillURL={data?.prefillURL}
show={true} show={true}
/> />
</IntlProvider> </IntlProvider>

View File

@@ -20,6 +20,7 @@ const onConnect = (data: UniqueServer) => {
}; };
const WelcomeScreenModalWrapper = () => { const WelcomeScreenModalWrapper = () => {
const [data, setData] = useState<{prefillURL?: string}>();
const [darkMode, setDarkMode] = useState(false); const [darkMode, setDarkMode] = useState(false);
const [getStarted, setGetStarted] = useState(false); const [getStarted, setGetStarted] = useState(false);
const [mobileView, setMobileView] = useState(false); const [mobileView, setMobileView] = useState(false);
@@ -37,6 +38,14 @@ const WelcomeScreenModalWrapper = () => {
setDarkMode(result); setDarkMode(result);
}); });
window.desktop.modals.getModalInfo<{prefillURL?: string}>().
then((data) => {
setData(data);
if (data.prefillURL) {
setGetStarted(true);
}
});
handleWindowResize(); handleWindowResize();
window.addEventListener('resize', handleWindowResize); window.addEventListener('resize', handleWindowResize);
@@ -56,6 +65,7 @@ const WelcomeScreenModalWrapper = () => {
mobileView={mobileView} mobileView={mobileView}
darkMode={darkMode} darkMode={darkMode}
onConnect={onConnect} onConnect={onConnect}
prefillURL={data?.prefillURL}
/> />
) : ( ) : (
<WelcomeScreen <WelcomeScreen