diff --git a/desktop/main.cjs b/desktop/main.cjs index c0c329e73..b9475cfd8 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -1,187 +1,202 @@ -const { app, BrowserWindow, shell, Menu } = require('electron'); +const { app, BrowserWindow, shell, Menu, ipcMain } = 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(); }); 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 new file mode 100644 index 000000000..804f49a5d --- /dev/null +++ b/desktop/preload.cjs @@ -0,0 +1,12 @@ +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'), +}; + +contextBridge.exposeInMainWorld('electronContextBridge', bridge); diff --git a/web/app.react.js b/web/app.react.js index 44f469fa9..2529ffc28 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,290 +1,293 @@ // @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} ); } 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 = ; } } - const shouldShowNavigationArrows = false; let navigationArrows = null; - if (shouldShowNavigationArrows) { + 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.css b/web/components/navigation-arrows.css index 7b51a5384..05d3e4479 100644 --- a/web/components/navigation-arrows.css +++ b/web/components/navigation-arrows.css @@ -1,10 +1,15 @@ .container { display: flex; column-gap: 12px; } .button { color: var(--fg); display: flex; align-items: center; } + +.disabled { + pointer-events: none; + color: var(--color-disabled); +} diff --git a/web/components/navigation-arrows.react.js b/web/components/navigation-arrows.react.js index fb8e6b4a2..35bcbcee4 100644 --- a/web/components/navigation-arrows.react.js +++ b/web/components/navigation-arrows.react.js @@ -1,31 +1,50 @@ // @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'; 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 new file mode 100644 index 000000000..b8789dc26 --- /dev/null +++ b/web/electron.js @@ -0,0 +1,16 @@ +// @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, +} = typeof electronContextBridge === 'undefined' ? null : electronContextBridge; + +export default electron;