Repository: manusa/electronim (js)

Issue:
Open links in ElectronIM
I use Teams web in ElectronIM and when I click on a link, it opens in my default browser instead of within ElectronIM. Ferdium allows you to open links directly in the app itself and it would be great if ElectronIM can do the same.

Pull request description:
✨ initial openUrlsInApp implementation
Fixes #364

Patch:
diff --git a/src/components/icon.mjs b/src/components/icon.mjs
index ee2b50b..dbab469 100644
--- a/src/components/icon.mjs
+++ b/src/components/icon.mjs
@@ -51,6 +51,7 @@ Icon.more = '\ue619';
 Icon.moreVert = '\ue5d4';
 Icon.notifications = '\ue7f4';
 Icon.notificationsOff = '\ue7f6';
+Icon.preview = '\uf1c5';
 Icon.save = '\ue161';
 Icon.settings = '\ue8b8';
 Icon.spellcheck = '\ue8ce';
diff --git a/src/settings/settings.browser.css b/src/settings/settings.browser.css
index 298ccdc..1b35b31 100644
--- a/src/settings/settings.browser.css
+++ b/src/settings/settings.browser.css
@@ -51,8 +51,9 @@
 
 .settings__tab .settings__tab-advanced {
     position: relative;
+    transform-origin: 0 0;
     transform: scaleY(0);
-    height: 0;
+    max-height: 0;
     margin: 0;
     transition:
         margin-top 0.3s  ease-out,
@@ -62,7 +63,8 @@
 
 .settings__tab--expanded .settings__tab-advanced {
     transform: scaleY(1);
-    height: 100%;
+    max-height: 100%;
+    height: 100%; /* TODO: use calc-size() when bump chrome version */
     margin-top: var(--material3-card-spacing) ;
     overflow-y: clip;
 }
diff --git a/src/settings/settings.reducer.browser.mjs b/src/settings/settings.reducer.browser.mjs
index 162b809..4cf722f 100644
--- a/src/settings/settings.reducer.browser.mjs
+++ b/src/settings/settings.reducer.browser.mjs
@@ -66,6 +66,7 @@ export const reducer = (state, action) => {
           id: newId(),
           disabled: false,
           sandboxed: false,
+          openUrlsInApp: false,
           disableNotifications: false,
           url: prependProtocol(state.newTabValue)
         }],
diff --git a/src/settings/settings.services.browser.mjs b/src/settings/settings.services.browser.mjs
index f3d037f..07f1b10 100644
--- a/src/settings/settings.services.browser.mjs
+++ b/src/settings/settings.services.browser.mjs
@@ -34,7 +34,7 @@ const ExpandButton = ({dispatch, id, expanded = false}) => {
 };
 
 const TabAdvancedSettings = (
-  {dispatch, id, sandboxed = false}
+  {dispatch, id, sandboxed = false, openUrlsInApp = false}
 ) => html`
   <div class='settings__tab-advanced'>
     <${SettingsOption}
@@ -45,6 +45,14 @@ const TabAdvancedSettings = (
       className='sandboxed-toggle'
       title='Use an isolated/sandboxed session for this tab'
     />
+    <${SettingsOption}
+        onClick=${toggleTabProperty(dispatch, 'openUrlsInApp', id)}
+        checked=${openUrlsInApp}
+        icon=${Icon.preview}
+        label='Open URLs in app'
+        className='open-urls-in-app-toggle'
+        title='Open URLs within ElectronIM instead of opening them in the default system browser'
+    />
   </div>
 `;
 
diff --git a/src/tab-manager/index.js b/src/tab-manager/index.js
index d249d15..f71320d 100644
--- a/src/tab-manager/index.js
+++ b/src/tab-manager/index.js
@@ -68,7 +68,9 @@ const cleanUserAgent = view => {
 const addTabs = ipcSender => tabsMetadata => {
   const useNativeSpellChecker = getUseNativeSpellChecker();
   const enabledDictionaries = getEnabledDictionaries();
-  tabsMetadata.forEach(({id, url, sandboxed = false}) => {
+  tabsMetadata.forEach(({
+    id, url, sandboxed = false, openUrlsInApp = false
+  }) => {
     const tabPreferences = {...webPreferences};
     if (sandboxed) {
       tabPreferences.session = session.fromPartition(`persist:${id}`, {cache: true});
@@ -94,8 +96,10 @@ const addTabs = ipcSender => tabsMetadata => {
     cleanUserAgent(tab);
     tab.webContents.loadURL(url);
 
-    tab.webContents.on('will-navigate', handleRedirect(tab));
-    tab.webContents.setWindowOpenHandler(windowOpenHandler(tab));
+    if (!openUrlsInApp) {
+      tab.webContents.on('will-navigate', handleRedirect(tab));
+      tab.webContents.setWindowOpenHandler(windowOpenHandler(tab));
+    }
 
     const handlePageTitleUpdatedForCurrentTab = handlePageTitleUpdated(ipcSender, id);
     tab.webContents.on('page-title-updated', handlePageTitleUpdatedForCurrentTab);
diff --git a/src/tab-manager/redirect.js b/src/tab-manager/redirect.js
index 369798d..e1e5b7a 100644
--- a/src/tab-manager/redirect.js
+++ b/src/tab-manager/redirect.js
@@ -75,20 +75,24 @@ const openExternal = urlString => {
   shell.openExternal(urlString).then(() => {});
 };
 
-const handleRedirect = view => (e, urlString) => {
-  const url = new URL(urlString);
-  if (shouldOpenInExternalBrowser(view, url)) {
-    e.preventDefault();
-    openExternal(urlString);
-  }
+const handleRedirect = view => {
+  return (e, urlString) => {
+    const url = new URL(urlString);
+    if (shouldOpenInExternalBrowser(view, url)) {
+      e.preventDefault();
+      openExternal(urlString);
+    }
+  };
 };
 
-const windowOpenHandler = view => ({url}) => {
-  if (!shouldOpenInExternalBrowser(view, new URL(url))) {
-    return {action: 'allow'};
-  }
-  openExternal(url);
-  return {action: 'deny'};
+const windowOpenHandler = view => {
+  return ({url}) => {
+    if (!shouldOpenInExternalBrowser(view, new URL(url))) {
+      return {action: 'allow'};
+    }
+    openExternal(url);
+    return {action: 'deny'};
+  };
 };
 
 module.exports = {


Test patch:
diff --git a/src/settings/__tests__/settings.browser.mjs b/src/settings/__tests__/settings.browser.mjs
index 7e4f340..d5bd640 100644
--- a/src/settings/__tests__/settings.browser.mjs
+++ b/src/settings/__tests__/settings.browser.mjs
@@ -26,7 +26,8 @@ export const ipcRenderer = () => {
     disableNotificationsGlobally: false,
     tabs: [
       {id: '1', url: 'https://initial-tab.com', sandboxed: true},
-      {id: '2', url: 'https://initial-tab-2.com', disabled: true, disableNotifications: true}
+      {id: '2', url: 'https://initial-tab-2.com', disabled: true, disableNotifications: true},
+      {id: '3', url: 'https://initial-tab-3.com', openUrlsInApp: true}
     ],
     theme: 'dark',
     trayEnabled: true,
diff --git a/src/settings/__tests__/settings.browser.test.mjs b/src/settings/__tests__/settings.browser.test.mjs
index 210b961..6fd3cd4 100644
--- a/src/settings/__tests__/settings.browser.test.mjs
+++ b/src/settings/__tests__/settings.browser.test.mjs
@@ -16,7 +16,7 @@
 import {jest} from '@jest/globals';
 import {loadDOM} from '../../__tests__/index.mjs';
 import {ipcRenderer} from './settings.browser.mjs';
-import {fireEvent, getByTestId, getByText, waitFor} from '@testing-library/dom';
+import {fireEvent, getAllByText, getByTestId, getByText, waitFor} from '@testing-library/dom';
 
 describe('Settings in Browser test suite', () => {
   let mockIpcRenderer;
@@ -62,7 +62,8 @@ describe('Settings in Browser test suite', () => {
           {
             tabs: [
               {id: '1', url: 'https://initial-tab.com', sandboxed: true},
-              {id: '2', url: 'https://initial-tab-2.com', disabled: true, disableNotifications: true}
+              {id: '2', url: 'https://initial-tab-2.com', disabled: true, disableNotifications: true},
+              {id: '3', url: 'https://initial-tab-3.com', openUrlsInApp: true}
             ],
             enabledDictionaries: ['en'],
             disableNotificationsGlobally: false,
@@ -116,7 +117,7 @@ describe('Settings in Browser test suite', () => {
       fireEvent.input($input, {target: {value: 'A'}});
       // Then
       await waitFor(() => expect($input.value).toBe('A'));
-      expect($tabContainer.querySelectorAll('.settings__tab').length).toBe(2);
+      expect($tabContainer.querySelectorAll('.settings__tab').length).toBe(3);
     });
     describe.each([
       'Enter',
@@ -128,7 +129,7 @@ describe('Settings in Browser test suite', () => {
         // When
         fireEvent.keyDown($input, {code});
         // Then
-        expect($tabContainer.querySelectorAll('.settings__tab').length).toBe(2);
+        expect($tabContainer.querySelectorAll('.settings__tab').length).toBe(3);
         expect($addTabButton.hasAttribute('disabled')).toBe(true);
         expect($submitButton.hasAttribute('disabled')).toBe(false);
       });
@@ -138,10 +139,10 @@ describe('Settings in Browser test suite', () => {
         // When
         fireEvent.keyDown($input, {code});
         // Then
-        expect($tabContainer.querySelectorAll('.settings__tab').length).toBe(2);
+        expect($tabContainer.querySelectorAll('.settings__tab').length).toBe(3);
         await waitFor(() =>
           expect($input.parentElement.classList.contains('errored')).toBe(true));
-        expect($tabContainer.querySelectorAll('.settings__tab .settings__tab-main input').length).toBe(2);
+        expect($tabContainer.querySelectorAll('.settings__tab .settings__tab-main input').length).toBe(3);
         expect($input.value).toBe('invalid:1337:url');
         expect($addTabButton.hasAttribute('disabled')).toBe(true);
         expect($submitButton.hasAttribute('disabled')).toBe(true);
@@ -153,9 +154,9 @@ describe('Settings in Browser test suite', () => {
         fireEvent.keyDown($input, {code});
         // Then
         await waitFor(() =>
-          expect($tabContainer.querySelectorAll('.settings__tab').length).toBe(3));
+          expect($tabContainer.querySelectorAll('.settings__tab').length).toBe(4));
         expect($input.classList.contains('is-success')).toBe(false);
-        expect($tabContainer.querySelectorAll('.settings__tab .settings__tab-main input')[2].value)
+        expect($tabContainer.querySelectorAll('.settings__tab .settings__tab-main input')[3].value)
           .toBe('https://info.cern.ch');
         expect($input.value).toBe('');
         expect($addTabButton.hasAttribute('disabled')).toBe(true);
@@ -168,10 +169,10 @@ describe('Settings in Browser test suite', () => {
         fireEvent.keyDown($input, {code});
         // Then
         await waitFor(() =>
-          expect($tabContainer.querySelectorAll('.settings__tab').length).toBe(3));
+          expect($tabContainer.querySelectorAll('.settings__tab').length).toBe(4));
         await waitFor(() =>
           expect($input.classList.contains('is-success')).toBe(false));
-        expect($tabContainer.querySelectorAll('.settings__tab .settings__tab-main input')[2].value)
+        expect($tabContainer.querySelectorAll('.settings__tab .settings__tab-main input')[3].value)
           .toBe('http://info.cern.ch');
         expect($input.value).toBe('');
         expect($addTabButton.hasAttribute('disabled')).toBe(true);
@@ -206,8 +207,8 @@ describe('Settings in Browser test suite', () => {
       fireEvent.click($addTabButton);
       // Then
       await waitFor(() =>
-        expect($tabContainer.querySelectorAll('.settings__tab').length).toBe(3));
-      expect($tabContainer.querySelectorAll('.settings__tab .settings__tab-main input')[2].value)
+        expect($tabContainer.querySelectorAll('.settings__tab').length).toBe(4));
+      expect($tabContainer.querySelectorAll('.settings__tab .settings__tab-main input')[3].value)
         .toBe('https://info.cern.ch');
       expect($input.value).toBe('');
       expect($addTabButton.hasAttribute('disabled')).toBe(true);
@@ -256,7 +257,7 @@ describe('Settings in Browser test suite', () => {
     });
     test('Notification enabled icon click, should disable notification', async () => {
       // Given
-      const $notificationEnabledIcon = getByText(document.querySelector('.settings__tabs'), '\ue7f4');
+      const $notificationEnabledIcon = getAllByText(document.querySelector('.settings__tabs'), '\ue7f4')[0];
       // When
       fireEvent.click($notificationEnabledIcon);
       // Then
@@ -370,6 +371,54 @@ describe('Settings in Browser test suite', () => {
             expect($settingsTab.textContent).toContain('\ue88d');
           });
         });
+        describe('with openUrlsInApp=true', () => {
+          let $settingsTab;
+          let $toggleIcon;
+          let $openUrlsInAppEntry;
+          beforeEach(async () => {
+            $settingsTab = document.querySelector('.settings__tab[data-id="3"]');
+            $toggleIcon = $settingsTab.querySelector('.expand-button');
+            $openUrlsInAppEntry = $settingsTab.querySelector('.open-urls-in-app-toggle');
+            if ($toggleIcon.title.startsWith('Expand')) {
+              fireEvent.click($toggleIcon);
+              // eslint-disable-next-line jest/no-standalone-expect
+              await waitFor(() => expect($toggleIcon.title).toEqual('Collapse'));
+            }
+          });
+          test('click on switch, should turn off', async () => {
+            // Given
+            const $switch = $openUrlsInAppEntry.querySelector('.material3.switch');
+            // When
+            fireEvent.click($switch);
+            // Then
+            await waitFor(() => expect($switch.classList.contains('switch--checked')).toBe(false));
+            expect($switch.classList).not.toContain('switch--checked');
+          });
+        });
+        describe('with openUrlsInApp=false', () => {
+          let $settingsTab;
+          let $toggleIcon;
+          let $openUrlsInAppEntry;
+          beforeEach(async () => {
+            $settingsTab = document.querySelector('.settings__tab[data-id="1"]');
+            $toggleIcon = $settingsTab.querySelector('.expand-button');
+            $openUrlsInAppEntry = $settingsTab.querySelector('.open-urls-in-app-toggle');
+            if ($toggleIcon.title.startsWith('Expand')) {
+              fireEvent.click($toggleIcon);
+              // eslint-disable-next-line jest/no-standalone-expect
+              await waitFor(() => expect($toggleIcon.title).toEqual('Collapse'));
+            }
+          });
+          test('click on switch, should turn on', async () => {
+            // Given
+            const $switch = $openUrlsInAppEntry.querySelector('.material3.switch');
+            // When
+            fireEvent.click($switch);
+            // Then
+            await waitFor(() => expect($switch.classList.contains('switch--checked')).toBe(true));
+            expect($switch.classList).toContain('switch--checked');
+          });
+        });
       });
     });
     describe('URL edit', () => {
diff --git a/src/tab-manager/__tests__/index.test.js b/src/tab-manager/__tests__/index.test.js
index 2f6c834..b082165 100644
--- a/src/tab-manager/__tests__/index.test.js
+++ b/src/tab-manager/__tests__/index.test.js
@@ -152,6 +152,18 @@ describe('Tab Manager module test suite', () => {
       expect(require('electron').WebContentsView).toHaveBeenCalledWith({
         webPreferences: expect.objectContaining({session: expect.anything()})});
     });
+    test('openUrlsInApp=true, should not set setWindowOpenHandler', () => {
+      // When
+      tabManager.addTabs({send: jest.fn()})([{id: 1337, url: 'https://localhost', openUrlsInApp: true}]);
+      // Then
+      expect(mockView.webContents.setWindowOpenHandler).not.toHaveBeenCalled();
+    });
+    test('openUrlsInApp=true, should not set will-navigate event handler', () => {
+      // When
+      tabManager.addTabs({send: jest.fn()})([{id: 1337, url: 'https://localhost', openUrlsInApp: true}]);
+      // Then
+      expect(mockView.listeners['will-navigate']).not.toBeDefined();
+    });
     test('Tab webContents should be configured and loaded', () => {
       // Given
       const mockIpcSender = {send: jest.fn()};