diff --git a/desktop/main.cjs b/desktop/main.cjs index b9475cfd8..aad5fd564 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -1,202 +1,234 @@ -const { app, BrowserWindow, shell, Menu, ipcMain } = require('electron'); +const { + app, + BrowserWindow, + shell, + Menu, + ipcMain, + systemPreferences, +} = require('electron'); const fs = require('fs'); const path = require('path'); const isDev = process.env.ENV === 'dev'; const url = isDev ? 'http://localhost/comm/' : 'https://web.comm.app'; const isMac = process.platform === 'darwin'; const scrollbarCSS = fs.promises.readFile( path.join(__dirname, 'scrollbar.css'), 'utf8', ); const setApplicationMenu = () => { let mainMenu = []; if (isMac) { mainMenu = [ { label: app.name, submenu: [ { role: 'about' }, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' }, ], }, ]; } const viewMenu = { label: 'View', submenu: [ { role: 'reload' }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { type: 'separator' }, { role: 'togglefullscreen' }, { role: 'toggleDevTools' }, ], }; const windowMenu = { label: 'Window', submenu: [ { role: 'minimize' }, ...(isMac ? [ { type: 'separator' }, { role: 'front' }, { type: 'separator' }, { role: 'window' }, ] : [{ role: 'close' }]), ], }; const menu = Menu.buildFromTemplate([ ...mainMenu, { role: 'fileMenu' }, { role: 'editMenu' }, viewMenu, windowMenu, ]); Menu.setApplicationMenu(menu); }; const createMainWindow = () => { const win = new BrowserWindow({ show: false, width: 1300, height: 800, minWidth: 1100, minHeight: 600, titleBarStyle: 'hidden', trafficLightPosition: { x: 20, y: 24 }, backgroundColor: '#0A0A0A', webPreferences: { preload: path.join(__dirname, 'preload.cjs'), }, }); const updateNavigationState = () => { win.webContents.send('on-navigate', { canGoBack: win.webContents.canGoBack(), canGoForward: win.webContents.canGoForward(), }); }; win.webContents.on('did-navigate-in-page', updateNavigationState); ipcMain.on('clear-history', () => { win.webContents.clearHistory(); updateNavigationState(); }); + ipcMain.on('double-click-top-bar', () => { + if (isMac) { + // Possible values for AppleActionOnDoubleClick are Maximize, + // Minimize or None. We handle the last two inside this if. + // Maximize (which is the only behaviour for other platforms) + // is handled in the later block. + const action = systemPreferences.getUserDefault( + 'AppleActionOnDoubleClick', + 'string', + ); + if (action === 'None') { + return; + } else if (action === 'Minimize') { + win.minimize(); + return; + } + } + + if (win.isMaximized()) { + win.unmaximize(); + } else { + win.maximize(); + } + }); + win.webContents.setWindowOpenHandler(({ url: openURL }) => { shell.openExternal(openURL); // Returning 'deny' prevents a new electron window from being created return { action: 'deny' }; }); (async () => { const css = await scrollbarCSS; win.webContents.insertCSS(css); })(); win.loadURL(url); return win; }; const createSplashWindow = () => { const win = new BrowserWindow({ width: 300, height: 300, resizable: false, frame: false, alwaysOnTop: true, center: true, backgroundColor: '#111827', }); win.loadFile(path.join(__dirname, 'pages', 'splash.html')); return win; }; const createErrorWindow = () => { const win = new BrowserWindow({ show: false, width: 400, height: 300, resizable: false, center: true, titleBarStyle: 'hidden', trafficLightPosition: { x: 20, y: 24 }, backgroundColor: '#111827', }); win.on('close', () => { app.quit(); }); win.loadFile(path.join(__dirname, 'pages', 'error.html')); return win; }; const show = () => { const splash = createSplashWindow(); const error = createErrorWindow(); const main = createMainWindow(); let loadedSuccessfully = true; main.webContents.on('did-fail-load', () => { loadedSuccessfully = false; if (!splash.isDestroyed()) { splash.destroy(); } if (!error.isDestroyed()) { error.show(); } setTimeout(() => { loadedSuccessfully = true; main.loadURL(url); }, 1000); }); main.webContents.on('did-finish-load', () => { if (loadedSuccessfully) { if (!splash.isDestroyed()) { splash.destroy(); } if (!error.isDestroyed()) { error.destroy(); } main.show(); } }); }; app.setName('Comm'); setApplicationMenu(); (async () => { await app.whenReady(); show(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { show(); } }); })(); app.on('window-all-closed', () => { if (!isMac) { app.quit(); } }); diff --git a/desktop/preload.cjs b/desktop/preload.cjs index 804f49a5d..d5052680b 100644 --- a/desktop/preload.cjs +++ b/desktop/preload.cjs @@ -1,12 +1,13 @@ const { contextBridge, ipcRenderer } = require('electron'); const bridge = { onNavigate: callback => { const withEvent = (event, ...args) => callback(...args); ipcRenderer.on('on-navigate', withEvent); return () => ipcRenderer.removeListener('on-navigate', withEvent); }, clearHistory: () => ipcRenderer.send('clear-history'), + doubleClickTopBar: () => ipcRenderer.send('double-click-top-bar'), }; contextBridge.exposeInMainWorld('electronContextBridge', bridge); diff --git a/web/app.react.js b/web/app.react.js index 2529ffc28..aad89dd88 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,293 +1,297 @@ // @flow import 'basscss/css/basscss.min.css'; import './theme.css'; import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import _isEqual from 'lodash/fp/isEqual'; import * as React from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { useDispatch } from 'react-redux'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, } from 'lib/actions/entry-actions'; import { ModalProvider, useModalContext, } from 'lib/components/modal-provider.react'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors'; import { unreadCount } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { Dispatch } from 'lib/types/redux-types'; import { registerConfig } from 'lib/utils/config'; import AppsDirectory from './apps/apps-directory.react'; import Calendar from './calendar/calendar.react'; import Chat from './chat/chat.react'; import { TooltipProvider } from './chat/tooltip-provider'; import NavigationArrows from './components/navigation-arrows.react'; import electron from './electron'; import InputStateContainer from './input/input-state-container.react'; import LoadingIndicator from './loading-indicator.react'; import { MenuProvider } from './menu-provider.react'; import { updateNavInfoActionType } from './redux/action-types'; import DeviceIDUpdater from './redux/device-id-updater'; import DisconnectedBar from './redux/disconnected-bar'; import DisconnectedBarVisibilityHandler from './redux/disconnected-bar-visibility-handler'; import FocusHandler from './redux/focus-handler.react'; import { useSelector } from './redux/redux-utils'; import VisibilityHandler from './redux/visibility-handler.react'; import history from './router-history'; import AccountSettings from './settings/account-settings.react'; import DangerZone from './settings/danger-zone.react'; import LeftLayoutAside from './sidebar/left-layout-aside.react'; import Splash from './splash/splash.react'; import './typography.css'; import css from './style.css'; import getTitle from './title/getTitle'; import { type NavInfo } from './types/nav-types'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils'; // We want Webpack's css-loader and style-loader to handle the Fontawesome CSS, // so we disable the autoAddCss logic and import the CSS file. Otherwise every // icon flashes huge for a second before the CSS is loaded. import '@fortawesome/fontawesome-svg-core/styles.css'; faConfig.autoAddCss = false; registerConfig({ // We can't securely cache credentials on web, so we have no way to recover // from a cookie invalidation resolveInvalidatedCookie: null, // We use httponly cookies on web to protect against XSS attacks, so we have // no access to the cookies from JavaScript setCookieOnRequest: false, setSessionIDOnRequest: true, // Never reset the calendar range calendarRangeInactivityLimit: null, platformDetails: { platform: 'web' }, }); type BaseProps = { +location: { +pathname: string, ... }, }; type Props = { ...BaseProps, // Redux state +navInfo: NavInfo, +entriesLoadingStatus: LoadingStatus, +loggedIn: boolean, +activeThreadCurrentlyUnread: boolean, // Redux dispatch functions +dispatch: Dispatch, +modals: $ReadOnlyArray, }; class App extends React.PureComponent { componentDidMount() { const { navInfo, location: { pathname }, loggedIn, } = this.props; const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (pathname !== newURL) { history.replace(newURL); } } componentDidUpdate(prevProps: Props) { const { navInfo, location: { pathname }, loggedIn, } = this.props; if (!_isEqual(navInfo)(prevProps.navInfo)) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.push(newURL); } } else if (pathname !== prevProps.location.pathname) { const newNavInfo = navInfoFromURL(pathname, { navInfo }); if (!_isEqual(newNavInfo)(navInfo)) { this.props.dispatch({ type: updateNavInfoActionType, payload: newNavInfo, }); } } else if (loggedIn !== prevProps.loggedIn) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.replace(newURL); } } if (loggedIn !== prevProps.loggedIn) { electron?.clearHistory(); } } onWordmarkClicked = () => { this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat' }, }); }; render() { let content; if (this.props.loggedIn) { content = this.renderMainContent(); } else { content = ; } return ( {content} {this.props.modals} ); } + onHeaderDoubleClick = () => electron?.doubleClickTopBar(); + stopDoubleClickPropagation = electron ? e => e.stopPropagation() : null; + renderMainContent() { let mainContent; const { tab, settingsSection } = this.props.navInfo; if (tab === 'calendar') { mainContent = ; } else if (tab === 'chat') { mainContent = ; } else if (tab === 'apps') { mainContent = ; } else if (tab === 'settings') { if (settingsSection === 'account') { mainContent = ; } else if (settingsSection === 'danger-zone') { mainContent = ; } } let navigationArrows = null; if (electron) { navigationArrows = ; } return (
-
+

Comm

{navigationArrows}
{mainContent}
); } } const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const ConnectedApp: React.ComponentType = React.memo( function ConnectedApp(props) { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const navInfo = useSelector(state => state.navInfo); const fetchEntriesLoadingStatus = useSelector( fetchEntriesLoadingStatusSelector, ); const updateCalendarQueryLoadingStatus = useSelector( updateCalendarQueryLoadingStatusSelector, ); const entriesLoadingStatus = combineLoadingStatuses( fetchEntriesLoadingStatus, updateCalendarQueryLoadingStatus, ); const loggedIn = useSelector(isLoggedIn); const activeThreadCurrentlyUnread = useSelector( state => !activeChatThreadID || !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); const boundUnreadCount = useSelector(unreadCount); React.useEffect(() => { document.title = getTitle(boundUnreadCount); }, [boundUnreadCount]); const dispatch = useDispatch(); const modalContext = useModalContext(); const modals = React.useMemo( () => modalContext.modals.map(([modal, key]) => ( {modal} )), [modalContext.modals], ); return ( ); }, ); function AppWithProvider(props: BaseProps): React.Node { return ( ); } export default AppWithProvider; diff --git a/web/components/navigation-arrows.react.js b/web/components/navigation-arrows.react.js index 35bcbcee4..7f620e8d2 100644 --- a/web/components/navigation-arrows.react.js +++ b/web/components/navigation-arrows.react.js @@ -1,50 +1,60 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; import electron from '../electron.js'; import history from '../router-history.js'; import SWMansionIcon from '../SWMansionIcon.react.js'; import css from './navigation-arrows.css'; +const stopDoubleClickPropagation = e => e.stopPropagation(); + function NavigationArrows(): React.Node { const goBack = React.useCallback( () => history.getHistoryObject().goBack(), [], ); const goForward = React.useCallback( () => history.getHistoryObject().goForward(), [], ); const [disableBack, setDisableBack] = React.useState(false); const [disableFoward, setDisableForward] = React.useState(false); React.useEffect( () => electron?.onNavigate(({ canGoBack, canGoForward }) => { setDisableBack(!canGoBack); setDisableForward(!canGoForward); }), [], ); const goBackClasses = classnames(css.button, { [css.disabled]: disableBack }); const goForwardClasses = classnames(css.button, { [css.disabled]: disableFoward, }); return ( ); } export default NavigationArrows; diff --git a/web/electron.js b/web/electron.js index b8789dc26..ff45ba625 100644 --- a/web/electron.js +++ b/web/electron.js @@ -1,16 +1,17 @@ // @flow declare var electronContextBridge; type OnNavigateListener = ({ +canGoBack: boolean, +canGoForward: boolean, }) => void; const electron: null | { // Returns a callback that you can call to remove the listener +onNavigate: OnNavigateListener => () => void, +clearHistory: () => void, + +doubleClickTopBar: () => void, } = typeof electronContextBridge === 'undefined' ? null : electronContextBridge; export default electron;