From c96769a1e1f61b713069c3c5bf3c79f0cc0fc315 Mon Sep 17 00:00:00 2001 From: Vladimir Y <1560781+vladimiry@users.noreply.github.com> Date: Sun, 4 Apr 2021 21:05:23 +0300 Subject: [PATCH] enable support for dark/light mode, closes #242 * Defaults to OS's dark mode preference. --- README.md | 1 + package.json | 2 +- src/e2e/workflow.ts | 2 +- src/electron-main/api/constants.ts | 6 +- .../api/endpoints-builders/database/index.ts | 12 +- .../database/indexing/api.ts | 16 +- .../database/indexing/service.ts | 14 +- .../endpoints-builders/database/search/api.ts | 12 +- .../api/endpoints-builders/general.ts | 5 +- src/electron-main/api/index.ts | 5 + src/electron-main/bootstrap/app-ready.ts | 8 +- src/electron-main/context.ts | 28 +--- src/electron-main/native-theme.ts | 19 +++ src/electron-main/util.ts | 9 +- src/electron-main/window/about.ts | 126 +++++++-------- src/electron-main/window/find-in-page.ts | 22 +-- src/electron-preload/about/index.ts | 3 + .../database-indexer/index.ts | 20 +-- .../lib/hovered-href-highlighter/index.scss | 11 +- src/shared/api/main.ts | 15 +- src/shared/constants.ts | 12 +- src/shared/model/electron.ts | 2 +- src/shared/model/options.ts | 2 + src/shared/util.ts | 25 +++ src/shared/webpack-conts.ts | 5 + src/web/about/index.ts | 3 + src/web/about/tsconfig.json | 1 + src/web/browser-window/app-theming-dark.scss | 16 ++ src/web/browser-window/app-theming-light.scss | 2 + src/web/browser-window/app-theming.scss | 128 +++++++++++++++ .../_accounts/account-title.component.scss | 6 - .../app/_accounts/account.component.scss | 2 +- .../app/_accounts/accounts.component.scss | 11 +- .../app/_db-view/db-view-entry.component.scss | 1 - .../_db-view/db-view-mail-body.component.html | 4 +- .../_db-view/db-view-mail-body.component.scss | 10 -- .../_db-view/db-view-mail-body.component.ts | 39 ++++- .../_db-view/db-view-mail-tab.component.html | 2 +- .../_db-view/db-view-mail-tab.component.scss | 5 - .../app/_db-view/db-view-mail.component.scss | 17 +- .../db-view-mails-export.component.html | 8 +- .../db-view-mails-search.component.html | 4 +- .../app/_db-view/db-view-mails.component.html | 8 +- .../app/_db-view/db-view-mails.component.scss | 8 - .../db-view-monaco-editor.component.html | 2 +- .../db-view-monaco-editor.component.scss | 4 - .../db-view-monaco-editor.component.ts | 13 +- src/web/browser-window/app/_db-view/lib.scss | 8 +- .../app/_options/account-edit.component.html | 24 +-- .../app/_options/account-edit.component.scss | 3 +- ...nent.html => accounts-list.component.html} | 2 +- ...nent.scss => accounts-list.component.scss} | 0 ...omponent.ts => accounts-list.component.ts} | 8 +- .../app/_options/base-settings.component.html | 97 +++++++----- .../app/_options/base-settings.component.ts | 11 +- .../db-metadata-reset-request.component.html | 4 +- .../encryption-presets.component.html | 4 +- .../app/_options/login.component.html | 11 +- .../app/_options/login.component.scss | 1 - .../app/_options/options.effects.ts | 3 + .../app/_options/options.module.ts | 4 +- .../app/_options/options.routing.module.ts | 4 +- .../_options/settings-setup.component.html | 11 +- .../app/_options/storage.component.html | 8 +- src/web/browser-window/app/bootstrap-app.ts | 3 + .../app/store/actions/options.ts | 1 + .../app/store/reducers/options.ts | 2 + .../app/store/selectors/options.ts | 1 + src/web/browser-window/index.ejs | 20 +++ src/web/browser-window/index.scss | 149 +----------------- src/web/browser-window/index.ts | 2 + src/web/browser-window/lib.scss | 14 +- src/web/browser-window/vendor/app.scss | 10 -- src/web/browser-window/vendor/index.ts | 6 +- .../vendor/shared-vendor-dark.scss | 7 + .../vendor/shared-vendor-light.scss | 2 + .../browser-window/vendor/shared-vendor.scss | 10 +- .../browser-window/vendor/vendor-dark.scss | 18 +++ .../browser-window/vendor/vendor-light.scss | 2 + src/web/browser-window/vendor/vendor.scss | 143 +++++++++++++++++ src/web/lib/native-theme.ts | 76 +++++++++ src/web/search-in-page-browser-view/index.ejs | 6 +- src/web/search-in-page-browser-view/index.ts | 3 + src/web/theming-variables-dark.scss | 54 +++++++ src/web/theming-variables-light.scss | 15 ++ src/web/variables.scss | 20 +-- src/web/vendor-variables.scss | 21 ++- webpack-configs/preload/lib.ts | 6 + webpack-configs/web/browser-window.ts | 42 +++-- webpack-configs/web/lib.ts | 10 +- 90 files changed, 967 insertions(+), 555 deletions(-) create mode 100644 src/electron-main/native-theme.ts create mode 100644 src/shared/webpack-conts.ts create mode 100644 src/web/browser-window/app-theming-dark.scss create mode 100644 src/web/browser-window/app-theming-light.scss create mode 100644 src/web/browser-window/app-theming.scss rename src/web/browser-window/app/_options/{accounts.component.html => accounts-list.component.html} (98%) rename src/web/browser-window/app/_options/{accounts.component.scss => accounts-list.component.scss} (100%) rename src/web/browser-window/app/_options/{accounts.component.ts => accounts-list.component.ts} (94%) delete mode 100644 src/web/browser-window/vendor/app.scss create mode 100644 src/web/browser-window/vendor/shared-vendor-dark.scss create mode 100644 src/web/browser-window/vendor/shared-vendor-light.scss create mode 100644 src/web/browser-window/vendor/vendor-dark.scss create mode 100644 src/web/browser-window/vendor/vendor-light.scss create mode 100644 src/web/browser-window/vendor/vendor.scss create mode 100644 src/web/lib/native-theme.ts create mode 100644 src/web/theming-variables-dark.scss create mode 100644 src/web/theming-variables-light.scss diff --git a/README.md b/README.md index 9b807ae81..e7970eb40 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Some Linux package types are available for installing from the repositories (`Pa - :package: **Batch emails export** to EML files (attachments can optionally be exported in `online / live` mode, not available in `offline` mode since not stored locally). Feature released with [v2.0.0-beta.4](https://github.com/vladimiry/ElectronMail/releases/tag/v2.0.0-beta.4) version, requires [local store](https://github.com/vladimiry/ElectronMail/wiki/FAQ) feature to be enabled. - :closed_lock_with_key: **Built-in/prepackaged web clients**. The prepackaged with the app proton web clients assembled from source code, see the respective [official repositories](https://github.com/ProtonMail/). See [79](https://github.com/vladimiry/ElectronMail/issues/79) and [80](https://github.com/vladimiry/ElectronMail/issues/80) issues for details. - :gear: **Configuring proxy per account** support. Enabled since [v3.0.0](https://github.com/vladimiry/ElectronMail/releases/tag/v3.0.0) release. See [113](https://github.com/vladimiry/ElectronMail/issues/113) and [120](https://github.com/vladimiry/ElectronMail/issues/120) issues for details. +- :moon: **Dark mode** support. See details in [#242](https://github.com/vladimiry/ElectronMail/issues/242). - :bell: **System tray icon** with a total number of unread messages shown on top of it. Enabling [local store](https://github.com/vladimiry/ElectronMail/wiki/FAQ) improves this feature, see [#30](https://github.com/vladimiry/ElectronMail/issues/30). - :gear: **Starting minimized to tray** and **closing to tray** opt-out features. - :bell: **Native notifications** for individual accounts clicking on which focuses the app window and selects respective account in the accounts list. diff --git a/package.json b/package.json index e62457e9c..dc76e8c9a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "electron-mail", "description": "Unofficial ProtonMail Desktop App", - "version": "4.11.1", + "version": "4.12.0", "author": "Vladimir Yakovlev ", "license": "MIT", "homepage": "https://github.com/vladimiry/ElectronMail", diff --git a/src/e2e/workflow.ts b/src/e2e/workflow.ts index c622c45d4..dd7db1cc8 100644 --- a/src/e2e/workflow.ts +++ b/src/e2e/workflow.ts @@ -376,7 +376,7 @@ function buildWorkflow(t: ExecutionContext) { if (index === 0) { await t.context.app.client - .$(`.modal-body electron-mail-accounts`) + .$(`.modal-body electron-mail-accounts-list`) .then(async (el) => el.waitForDisplayed()); } }, diff --git a/src/electron-main/api/constants.ts b/src/electron-main/api/constants.ts index 3eb9208b3..c2889714f 100644 --- a/src/electron-main/api/constants.ts +++ b/src/electron-main/api/constants.ts @@ -1,10 +1,10 @@ import {Subject} from "rxjs"; import {UnionOf} from "@vladimiry/unionize"; -import {IPC_MAIN_API_DB_INDEXER_ON_ACTIONS, IpcMainServiceScan} from "src/shared/api/main"; +import {IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS, IpcMainServiceScan} from "src/shared/api/main"; export const IPC_MAIN_API_NOTIFICATION$ = new Subject(); -export const IPC_MAIN_API_DB_INDEXER_NOTIFICATION$ = new Subject(); +export const IPC_MAIN_API_DB_INDEXER_REQUEST$ = new Subject(); -export const IPC_MAIN_API_DB_INDEXER_ON_NOTIFICATION$ = new Subject>(); +export const IPC_MAIN_API_DB_INDEXER_RESPONSE$ = new Subject>(); diff --git a/src/electron-main/api/endpoints-builders/database/index.ts b/src/electron-main/api/endpoints-builders/database/index.ts index 1f44a08f2..3dcbb89fb 100644 --- a/src/electron-main/api/endpoints-builders/database/index.ts +++ b/src/electron-main/api/endpoints-builders/database/index.ts @@ -7,8 +7,8 @@ import {omit} from "remeda"; import {Context} from "src/electron-main/model"; import {DB_DATA_CONTAINER_FIELDS, IndexableMail} from "src/shared/model/database"; import {Database} from "src/electron-main/database"; -import {IPC_MAIN_API_DB_INDEXER_NOTIFICATION$, IPC_MAIN_API_NOTIFICATION$} from "src/electron-main/api/constants"; -import {IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS, IPC_MAIN_API_NOTIFICATION_ACTIONS, IpcMainApiEndpoints} from "src/shared/api/main"; +import {IPC_MAIN_API_DB_INDEXER_REQUEST$, IPC_MAIN_API_NOTIFICATION$} from "src/electron-main/api/constants"; +import {IPC_MAIN_API_DB_INDEXER_REQUEST_ACTIONS, IPC_MAIN_API_NOTIFICATION_ACTIONS, IpcMainApiEndpoints} from "src/shared/api/main"; import {buildDbExportEndpoints} from "./export/api"; import {buildDbIndexingEndpoints} from "./indexing/api"; import {buildDbSearchEndpoints} from "./search/api"; @@ -80,8 +80,8 @@ export async function buildEndpoints(ctx: Context): Promise { // send mails to indexing process // TODO performance optimization: send mails to indexing process if indexing feature activated - IPC_MAIN_API_DB_INDEXER_NOTIFICATION$.next( - IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS.Index( + IPC_MAIN_API_DB_INDEXER_REQUEST$.next( + IPC_MAIN_API_DB_INDEXER_REQUEST_ACTIONS.Index( { uid: new UUID(4).format(), ...narrowIndexActionPayload({ @@ -124,8 +124,8 @@ export async function buildEndpoints(ctx: Context): Promise { // removing stale mails form the full text search index // TODO performance optimization: send mails to indexing process if indexing feature activated - IPC_MAIN_API_DB_INDEXER_NOTIFICATION$.next( - IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS.Index( + IPC_MAIN_API_DB_INDEXER_REQUEST$.next( + IPC_MAIN_API_DB_INDEXER_REQUEST_ACTIONS.Index( { uid: new UUID(4).format(), ...narrowIndexActionPayload({ diff --git a/src/electron-main/api/endpoints-builders/database/indexing/api.ts b/src/electron-main/api/endpoints-builders/database/indexing/api.ts index aca86e3c3..d8fe1ac86 100644 --- a/src/electron-main/api/endpoints-builders/database/indexing/api.ts +++ b/src/electron-main/api/endpoints-builders/database/indexing/api.ts @@ -4,13 +4,13 @@ import {filter, first, startWith, takeUntil} from "rxjs/operators"; import {Context} from "src/electron-main/model"; import { - IPC_MAIN_API_DB_INDEXER_NOTIFICATION$, - IPC_MAIN_API_DB_INDEXER_ON_NOTIFICATION$, + IPC_MAIN_API_DB_INDEXER_REQUEST$, + IPC_MAIN_API_DB_INDEXER_RESPONSE$, IPC_MAIN_API_NOTIFICATION$, } from "src/electron-main/api/constants"; import { - IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS, - IPC_MAIN_API_DB_INDEXER_ON_ACTIONS, + IPC_MAIN_API_DB_INDEXER_REQUEST_ACTIONS, + IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS, IPC_MAIN_API_NOTIFICATION_ACTIONS, IpcMainApiEndpoints, } from "src/shared/api/main"; @@ -29,10 +29,10 @@ export async function buildDbIndexingEndpoints( // propagating action to custom stream setTimeout(() => { - IPC_MAIN_API_DB_INDEXER_ON_NOTIFICATION$.next(action); + IPC_MAIN_API_DB_INDEXER_RESPONSE$.next(action); }); - IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.match(action, { + IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS.match(action, { Bootstrapped() { const indexAccounts$ = defer( async () => { @@ -87,8 +87,8 @@ export async function buildDbIndexingEndpoints( // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types dbIndexerNotification() { - return IPC_MAIN_API_DB_INDEXER_NOTIFICATION$.asObservable().pipe( - startWith(IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS.Bootstrap({})), + return IPC_MAIN_API_DB_INDEXER_REQUEST$.asObservable().pipe( + startWith(IPC_MAIN_API_DB_INDEXER_REQUEST_ACTIONS.Bootstrap({})), ); }, }; diff --git a/src/electron-main/api/endpoints-builders/database/indexing/service.ts b/src/electron-main/api/endpoints-builders/database/indexing/service.ts index 694874e87..18067484b 100644 --- a/src/electron-main/api/endpoints-builders/database/indexing/service.ts +++ b/src/electron-main/api/endpoints-builders/database/indexing/service.ts @@ -7,15 +7,15 @@ import {race, throwError, timer} from "rxjs"; import {Config} from "src/shared/model/options"; import {DbAccountPk, FsDbAccount, INDEXABLE_MAIL_FIELDS, Mail} from "src/shared/model/database"; -import {IPC_MAIN_API_DB_INDEXER_NOTIFICATION$, IPC_MAIN_API_DB_INDEXER_ON_NOTIFICATION$,} from "src/electron-main/api/constants"; -import {IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS, IPC_MAIN_API_DB_INDEXER_ON_ACTIONS,} from "src/shared/api/main"; +import {IPC_MAIN_API_DB_INDEXER_REQUEST$, IPC_MAIN_API_DB_INDEXER_RESPONSE$,} from "src/electron-main/api/constants"; +import {IPC_MAIN_API_DB_INDEXER_REQUEST_ACTIONS, IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS,} from "src/shared/api/main"; import {curryFunctionMembers} from "src/shared/util"; import {hrtimeDuration} from "src/electron-main/util"; const logger = curryFunctionMembers(electronLog, __filename); export const narrowIndexActionPayload: ( - payload: StrictOmit, { type: "Index" }>["payload"], "uid">, + payload: StrictOmit, { type: "Index" }>["payload"], "uid">, ) => typeof payload = ((): typeof narrowIndexActionPayload => { type Fn = typeof narrowIndexActionPayload; type Mails = ReturnType["add"]; @@ -46,8 +46,8 @@ async function indexMails( const duration = hrtimeDuration(); const uid = new UUID(4).format(); const result$ = race( - IPC_MAIN_API_DB_INDEXER_ON_NOTIFICATION$.pipe( - filter(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.is.IndexingResult), + IPC_MAIN_API_DB_INDEXER_RESPONSE$.pipe( + filter(IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS.is.IndexingResult), filter(({payload}) => payload.uid === uid), first(), ), @@ -56,8 +56,8 @@ async function indexMails( ), ); - IPC_MAIN_API_DB_INDEXER_NOTIFICATION$.next( - IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS.Index({ + IPC_MAIN_API_DB_INDEXER_REQUEST$.next( + IPC_MAIN_API_DB_INDEXER_REQUEST_ACTIONS.Index({ uid, ...narrowIndexActionPayload({ key, diff --git a/src/electron-main/api/endpoints-builders/database/search/api.ts b/src/electron-main/api/endpoints-builders/database/search/api.ts index eb7d3cbc7..b0032708d 100644 --- a/src/electron-main/api/endpoints-builders/database/search/api.ts +++ b/src/electron-main/api/endpoints-builders/database/search/api.ts @@ -4,8 +4,8 @@ import {Observable, from, of, race, throwError, timer} from "rxjs"; import {concatMap, filter, first, mergeMap, switchMap} from "rxjs/operators"; import {Context} from "src/electron-main/model"; -import {IPC_MAIN_API_DB_INDEXER_NOTIFICATION$, IPC_MAIN_API_DB_INDEXER_ON_NOTIFICATION$} from "src/electron-main/api/constants"; -import {IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS, IPC_MAIN_API_DB_INDEXER_ON_ACTIONS, IpcMainApiEndpoints} from "src/shared/api/main"; +import {IPC_MAIN_API_DB_INDEXER_REQUEST$, IPC_MAIN_API_DB_INDEXER_RESPONSE$} from "src/electron-main/api/constants"; +import {IPC_MAIN_API_DB_INDEXER_REQUEST_ACTIONS, IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS, IpcMainApiEndpoints} from "src/shared/api/main"; import {IndexableMailId} from "src/shared/model/database"; import {curryFunctionMembers} from "src/shared/util"; import {searchRootConversationNodes, secondSearchStep} from "src/electron-main/api/endpoints-builders/database/search/service"; @@ -44,8 +44,8 @@ export async function buildDbSearchEndpoints( : null; const fullTextSearch$: Observable | null> = query ? race( - IPC_MAIN_API_DB_INDEXER_ON_NOTIFICATION$.pipe( - filter(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.is.SearchResult), + IPC_MAIN_API_DB_INDEXER_RESPONSE$.pipe( + filter(IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS.is.SearchResult), filter(({payload}) => payload.uid === fullTextSearchUid), first(), mergeMap(({payload: {data: {items}}}) => [new Map( @@ -74,8 +74,8 @@ export async function buildDbSearchEndpoints( ); if (fullTextSearchUid) { - IPC_MAIN_API_DB_INDEXER_NOTIFICATION$.next( - IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS.Search({query, uid: fullTextSearchUid}), + IPC_MAIN_API_DB_INDEXER_REQUEST$.next( + IPC_MAIN_API_DB_INDEXER_REQUEST_ACTIONS.Search({query, uid: fullTextSearchUid}), ); } diff --git a/src/electron-main/api/endpoints-builders/general.ts b/src/electron-main/api/endpoints-builders/general.ts index bf241f85f..b16ee2ef8 100644 --- a/src/electron-main/api/endpoints-builders/general.ts +++ b/src/electron-main/api/endpoints-builders/general.ts @@ -2,7 +2,7 @@ import ProxyAgent from "proxy-agent"; import compareVersions from "compare-versions"; import electronLog from "electron-log"; import fetch from "node-fetch"; -import {app, dialog, shell} from "electron"; +import {app, dialog, nativeTheme, shell} from "electron"; import {first, map, startWith} from "rxjs/operators"; import {from, merge, of, throwError} from "rxjs"; import {inspect} from "util"; @@ -374,8 +374,7 @@ export async function buildEndpoints( // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types notification() { return IPC_MAIN_API_NOTIFICATION$.asObservable().pipe( - // TODO replace "startWith" with "defaultIfEmpty" (simply some response needed to avoid timeout error) - startWith(IPC_MAIN_API_NOTIFICATION_ACTIONS.Bootstrap({})), + startWith(IPC_MAIN_API_NOTIFICATION_ACTIONS.NativeTheme({shouldUseDarkColors: nativeTheme.shouldUseDarkColors})), ); }, diff --git a/src/electron-main/api/index.ts b/src/electron-main/api/index.ts index 8aa316bf7..0e82cdd95 100644 --- a/src/electron-main/api/index.ts +++ b/src/electron-main/api/index.ts @@ -10,6 +10,7 @@ import {Database} from "src/electron-main/database"; import {IPC_MAIN_API, IPC_MAIN_API_NOTIFICATION_ACTIONS, IpcMainApiEndpoints, IpcMainServiceScan} from "src/shared/api/main"; import {IPC_MAIN_API_NOTIFICATION$} from "src/electron-main/api/constants"; import {PACKAGE_NAME, PRODUCT_NAME, PROTON_MONACO_EDITOR_DTS_ASSETS_LOCATION} from "src/shared/constants"; +import {applyThemeSource} from "src/electron-main/native-theme"; import {applyZoomFactor} from "src/electron-main/window/util"; import {attachFullTextIndexWindow, detachFullTextIndexWindow} from "src/electron-main/window/full-text-search"; import {buildSettingsAdapter} from "src/electron-main/util"; @@ -186,6 +187,10 @@ export const initApi = async (ctx: Context): Promise => { } } + if (updatedConfig.themeSource !== previousConfig.themeSource) { + applyThemeSource(updatedConfig.themeSource); + } + return updatedConfig; }, diff --git a/src/electron-main/bootstrap/app-ready.ts b/src/electron-main/bootstrap/app-ready.ts index 6ac2dd1ff..c0f7775f6 100644 --- a/src/electron-main/bootstrap/app-ready.ts +++ b/src/electron-main/bootstrap/app-ready.ts @@ -6,6 +6,7 @@ import {getDefaultSession, initSession} from "src/electron-main/session"; import {initApi} from "src/electron-main/api"; import {initApplicationMenu} from "src/electron-main/menu"; import {initMainBrowserWindow} from "src/electron-main/window/main"; +import {initNativeThemeNotification} from "src/electron-main/native-theme"; import {initSpellCheckController} from "src/electron-main/spell-check/controller"; import {initTray} from "src/electron-main/tray"; import {initWebContentsCreatingHandlers} from "src/electron-main/web-contents"; @@ -19,13 +20,14 @@ export async function appReadyHandler(ctx: Context): Promise { const endpoints = await initApi(ctx); - // "endpoints.readConfig()" call initializes the config.json file - // so consequent "ctx.configStore.readExisting()" calls don't fail - const {spellCheckLocale, customTrayIconColor, logLevel} = await endpoints.readConfig(); + // so consequent "ctx.configStore.readExisting()" calls don't fail since "endpoints.readConfig()" call initializes the config + const {spellCheckLocale, customTrayIconColor, logLevel, themeSource} = await endpoints.readConfig(); // TODO test "logger.transports.file.level" update electronLog.transports.file.level = logLevel; + initNativeThemeNotification(themeSource); + await (async (): Promise => { const spellCheckController = await initSpellCheckController(spellCheckLocale); ctx.getSpellCheckController = (): typeof spellCheckController => spellCheckController; diff --git a/src/electron-main/context.ts b/src/electron-main/context.ts index 07ea094ed..18a1d93ca 100644 --- a/src/electron-main/context.ts +++ b/src/electron-main/context.ts @@ -10,19 +10,14 @@ import {Fs as StoreFs, Model as StoreModel, Store} from "fs-json-store"; import {app} from "electron"; import {distinctUntilChanged, take} from "rxjs/operators"; -import { - BINARY_NAME, - LOCAL_WEBCLIENT_PROTOCOL_PREFIX, - RUNTIME_ENV_USER_DATA_DIR, - WEB_CHUNK_NAMES, - WEB_PROTOCOL_SCHEME -} from "src/shared/constants"; +import {BINARY_NAME, LOCAL_WEBCLIENT_PROTOCOL_PREFIX, RUNTIME_ENV_USER_DATA_DIR, WEB_PROTOCOL_SCHEME} from "src/shared/constants"; import {Config, Settings} from "src/shared/model/options"; import {Context, ContextInitOptions, ContextInitOptionsPaths, ProperLockfileError} from "./model"; import {Database} from "./database"; import {ElectronContextLocations} from "src/shared/model/electron"; import {INITIAL_STORES, configEncryptionPresetValidator, settingsAccountLoginUniquenessValidator} from "./constants"; import {SessionStorage} from "src/electron-main/session-storage"; +import {WEBPACK_WEB_CHUNK_NAMES} from "src/shared/webpack-conts"; import {formatFileUrl} from "./util"; function exists(file: string, storeFs: StoreModel.StoreFs): boolean { @@ -122,10 +117,10 @@ function initLocations( trayIcon: icon, trayIconFont: appRelativePath("./assets/fonts/tray-icon/roboto-derivative.ttf"), browserWindowPage: formatFileUrl( - appRelativePath("./web/", WEB_CHUNK_NAMES["browser-window"], "index.html"), + appRelativePath("./web/", WEBPACK_WEB_CHUNK_NAMES["browser-window"], "index.html"), ), - aboutBrowserWindowPage: appRelativePath("./web/", WEB_CHUNK_NAMES.about, "index.html"), - searchInPageBrowserViewPage: appRelativePath("./web/", WEB_CHUNK_NAMES["search-in-page-browser-view"], "index.html"), + aboutBrowserWindowPage: appRelativePath("./web/", WEBPACK_WEB_CHUNK_NAMES.about, "index.html"), + searchInPageBrowserViewPage: appRelativePath("./web/", WEBPACK_WEB_CHUNK_NAMES["search-in-page-browser-view"], "index.html"), preload: { aboutBrowserWindow: appRelativePath("./electron-preload/about.js"), browserWindow: appRelativePath("./electron-preload/browser-window.js"), @@ -135,16 +130,9 @@ function initLocations( primary: formatFileUrl(appRelativePath("./electron-preload/webview/primary.js")), calendar: formatFileUrl(appRelativePath("./electron-preload/webview/calendar.js")), }, - vendorsAppCssLinkHref: ((): string => { - // TODO electron: get rid of "baseURLForDataURL" workaround, see https://github.com/electron/electron/issues/20700 - const webRelativeCssFilePath = "browser-window/shared-vendor.css"; - const file = appRelativePath("web", webRelativeCssFilePath); - const stat = storeFs._impl.statSync(file); - if (!stat.isFile()) { - throw new Error(`Location "${file}" exists but it's not a file`); - } - return `${WEB_PROTOCOL_SCHEME}://${webRelativeCssFilePath}`; - })(), + // TODO electron: get rid of "baseURLForDataURL" workaround, see https://github.com/electron/electron/issues/20700 + vendorsAppCssLinkHrefs: ["shared-vendor-dark", "shared-vendor-light"] + .map((value) => `${WEB_PROTOCOL_SCHEME}://browser-window/${value}.css`), ...((): NoExtraProps> => { const {protocolBundles, webClients}: { diff --git a/src/electron-main/native-theme.ts b/src/electron-main/native-theme.ts new file mode 100644 index 000000000..592b440d2 --- /dev/null +++ b/src/electron-main/native-theme.ts @@ -0,0 +1,19 @@ +import {nativeTheme} from "electron"; + +import {Config} from "src/shared/model/options"; +import {IPC_MAIN_API_NOTIFICATION$} from "src/electron-main/api/constants"; +import {IPC_MAIN_API_NOTIFICATION_ACTIONS} from "src/shared/api/main"; + +export const applyThemeSource = (themeSource: Config["themeSource"]): void => { + nativeTheme.themeSource = themeSource; +}; + +export const initNativeThemeNotification = (themeSource: Config["themeSource"]): void => { + nativeTheme.on("updated", () => { + IPC_MAIN_API_NOTIFICATION$.next( + IPC_MAIN_API_NOTIFICATION_ACTIONS.NativeTheme({shouldUseDarkColors: nativeTheme.shouldUseDarkColors}), + ); + }); + + applyThemeSource(themeSource); +}; diff --git a/src/electron-main/util.ts b/src/electron-main/util.ts index 99313e94a..6de11fb23 100644 --- a/src/electron-main/util.ts +++ b/src/electron-main/util.ts @@ -4,10 +4,11 @@ import path from "path"; import {EncryptionAdapter} from "fs-json-store-encryption-adapter"; import {Model as StoreModel} from "fs-json-store"; import {format as formatURL} from "url"; +import {nativeTheme} from "electron"; import {Config} from "src/shared/model/options"; import {Context} from "./model"; -import {curryFunctionMembers} from "src/shared/util"; +import {buildInitialVendorsAppCssLinks, curryFunctionMembers} from "src/shared/util"; const logger = curryFunctionMembers(_logger, __filename); @@ -17,15 +18,15 @@ export function formatFileUrl(pathname: string): string { export async function injectVendorsAppCssIntoHtmlFile( pageLocation: string, - {vendorsAppCssLinkHref}: Context["locations"], + {vendorsAppCssLinkHrefs}: Context["locations"], ): Promise<{ html: string; baseURLForDataURL: string }> { const pageContent = fs.readFileSync(pageLocation).toString(); const baseURLForDataURL = formatFileUrl(`${path.dirname(pageLocation)}${path.sep}`); - const htmlInjection = ``; + const htmlInjection = buildInitialVendorsAppCssLinks(vendorsAppCssLinkHrefs, nativeTheme.shouldUseDarkColors); const html = pageContent.replace(/(.*)()(.*)/i, `$1$2${htmlInjection}$3`); if (!html.includes(htmlInjection)) { - logger.error(JSON.stringify({html})); + logger.error(nameof(injectVendorsAppCssIntoHtmlFile), JSON.stringify({html})); throw new Error(`Failed to inject "${htmlInjection}" into the "${pageLocation}" page`); } diff --git a/src/electron-main/window/about.ts b/src/electron-main/window/about.ts index 5a26523a4..4759761e5 100644 --- a/src/electron-main/window/about.ts +++ b/src/electron-main/window/about.ts @@ -11,79 +11,72 @@ import { PACKAGE_LICENSE, PACKAGE_VERSION, PRODUCT_NAME, - WEB_CHUNK_NAMES, WEB_PROTOCOL_SCHEME, ZOOM_FACTOR_DEFAULT, } from "src/shared/constants"; +import {WEBPACK_WEB_CHUNK_NAMES} from "src/shared/webpack-conts"; import {applyZoomFactor} from "src/electron-main/window/util"; import {curryFunctionMembers} from "src/shared/util"; import {injectVendorsAppCssIntoHtmlFile} from "src/electron-main/util"; const logger = curryFunctionMembers(_logger, __filename); -const resolveContent: (ctx: Context) => Promise>> = ( - (): typeof resolveContent => { - let result: typeof resolveContent = async (ctx: Context) => { - const {commit, shortCommit} = await import("./about.json"); - const htmlInjection: string = [ - sanitizeHtml( - ` -

- ${PRODUCT_NAME} v${PACKAGE_VERSION} - - ${shortCommit} - -

-

${PACKAGE_DESCRIPTION}

-

Distributed under ${PACKAGE_LICENSE} license.

- `, - { - allowedTags: sanitizeHtml.defaults.allowedTags.concat(["h1"]), - allowedAttributes: { - a: ["href", "style"], - h1: ["style"], - }, - }, - ), - ((): string => { - const {versions} = process; - const props = [ - {prop: "electron", title: "Electron"}, - {prop: "chrome", title: "Chromium"}, - {prop: "node", title: "Node"}, - {prop: "v8", title: "V8"}, - ] as const; - return ` -
    - ${props.map(({prop, title}) => sanitizeHtml(`
  • ${title}: ${versions[prop]}
  • `)).join("")} -
- `; - })(), - ].join(""); - const pageLocation = ctx.locations.aboutBrowserWindowPage; - const cache = await injectVendorsAppCssIntoHtmlFile(pageLocation, ctx.locations); - - cache.html = cache.html.replace( - /(.*)#MAIN_PROCESS_INJECTION_POINTCUT#(.*)/i, - `$1${htmlInjection}$2`, - ); - if (!cache.html.includes(htmlInjection)) { - logger.error(JSON.stringify({cache})); - throw new Error(`Failed to inject "${htmlInjection}" into the "${pageLocation}" page`); - } - logger.verbose(JSON.stringify(cache)); - - // memoize the result - result = async (): Promise => cache; +const resolveContent = async (ctx: Context): Promise>> => { + const {commit, shortCommit} = await import("./about.json"); + const htmlInjection: string = [ + sanitizeHtml( + ` +

+ ${PRODUCT_NAME} v${PACKAGE_VERSION} + + ${shortCommit} + +

+

${PACKAGE_DESCRIPTION}

+

Distributed under ${PACKAGE_LICENSE} license.

+ `, + { + allowedTags: sanitizeHtml.defaults.allowedTags.concat(["h1"]), + allowedAttributes: { + a: ["href", "style"], + h1: ["style"], + }, + }, + ), + ((): string => { + const {versions} = process; + const props = [ + {prop: "electron", title: "Electron"}, + {prop: "chrome", title: "Chromium"}, + {prop: "node", title: "Node"}, + {prop: "v8", title: "V8"}, + ] as const; + return ` +
    + ${props.map(({prop, title}) => sanitizeHtml(`
  • ${title}: ${versions[prop]}
  • `)).join("")} +
+ `; + })(), + ].join(""); + const pageLocation = ctx.locations.aboutBrowserWindowPage; + const injection = await injectVendorsAppCssIntoHtmlFile(pageLocation, ctx.locations); + + injection.html = injection.html.replace( + /(.*)#MAIN_PROCESS_INJECTION_POINTCUT#(.*)/i, + `$1${htmlInjection}$2`, + ); - return cache; - }; - return result; + if (!injection.html.includes(htmlInjection)) { + logger.error(nameof(resolveContent), injection.html); + throw new Error(`Failed to inject "${htmlInjection}" into the "${pageLocation}" page`); } -)(); + logger.verbose(nameof(resolveContent), JSON.stringify(injection)); + + return injection; +}; export async function showAboutBrowserWindow(ctx: Context): Promise { if (!ctx.uiContext) { @@ -118,10 +111,12 @@ export async function showAboutBrowserWindow(ctx: Context): Promise { + .once("ready-to-show", async () => { if (zoomFactor !== ZOOM_FACTOR_DEFAULT) { await applyZoomFactor(ctx, browserWindow.webContents); } + browserWindow.show(); + browserWindow.focus(); }) .on("closed", () => { if (!ctx.uiContext) { @@ -132,14 +127,11 @@ export async function showAboutBrowserWindow(ctx: Context): Promise Promise>> = ( - (): typeof resolveContent => { - let result: typeof resolveContent = async (ctx: Context) => { - const cache = await injectVendorsAppCssIntoHtmlFile(ctx.locations.searchInPageBrowserViewPage, ctx.locations); - logger.verbose(JSON.stringify(cache)); - // memoize the result - result = async (): Promise => cache; - return cache; - }; - return result; - } -)(); +const resolveContent = async (ctx: Context): Promise>> => { + const injection = await injectVendorsAppCssIntoHtmlFile(ctx.locations.searchInPageBrowserViewPage, ctx.locations); + logger.verbose(nameof(resolveContent), JSON.stringify(injection)); + return injection; +}; export function syncFindInPageBrowserViewSize(ctx: Context, findInPageBrowserView?: BrowserView): void { if (!ctx.uiContext) { @@ -77,7 +71,7 @@ export const initFindInPageBrowserView: (ctx: Context) => Promise = await browserView.webContents.loadURL( `data:text/html,${html}`, - {baseURLForDataURL: `${WEB_PROTOCOL_SCHEME}:/${WEB_CHUNK_NAMES["search-in-page-browser-view"]}/`}, + {baseURLForDataURL: `${WEB_PROTOCOL_SCHEME}:/${WEBPACK_WEB_CHUNK_NAMES["search-in-page-browser-view"]}/`}, ); syncFindInPageBrowserViewSize(ctx, browserView); diff --git a/src/electron-preload/about/index.ts b/src/electron-preload/about/index.ts index a1b279100..0573220fd 100644 --- a/src/electron-preload/about/index.ts +++ b/src/electron-preload/about/index.ts @@ -1,9 +1,12 @@ import {attachHoveredHrefHighlightElement} from "src/electron-preload/lib/hovered-href-highlighter"; import {buildLoggerBundle} from "src/electron-preload/lib/util"; +import {exposeElectronStuffToWindow} from "src/electron-preload/lib/electron-exposure"; import {registerDocumentClickEventListener, registerDocumentKeyDownEventListener} from "src/electron-preload/lib/events-handling"; export const LOGGER = buildLoggerBundle(`${__filename} [preload: about]`); +exposeElectronStuffToWindow(); + registerDocumentKeyDownEventListener(document, LOGGER); registerDocumentClickEventListener(document, LOGGER); diff --git a/src/electron-preload/database-indexer/index.ts b/src/electron-preload/database-indexer/index.ts index d56c925f0..9c907d6cf 100644 --- a/src/electron-preload/database-indexer/index.ts +++ b/src/electron-preload/database-indexer/index.ts @@ -1,6 +1,6 @@ import asap from "asap-es"; -import {IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS, IPC_MAIN_API_DB_INDEXER_ON_ACTIONS, IpcMainServiceScan} from "src/shared/api/main"; +import {IPC_MAIN_API_DB_INDEXER_REQUEST_ACTIONS, IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS, IpcMainServiceScan} from "src/shared/api/main"; import {ONE_SECOND_MS} from "src/shared/constants"; import {SERVICES_FACTORY, addToMailsIndex, createMailsIndex, removeMailsFromIndex} from "./service"; import {buildLoggerBundle} from "src/electron-preload/lib/util"; @@ -20,20 +20,20 @@ async function dbIndexerNotificationHandler( ): Promise { logger.verbose(`dbIndexerNotification.next, action.type:`, action.type); - await IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS.match( + await IPC_MAIN_API_DB_INDEXER_REQUEST_ACTIONS.match( action, { Bootstrap: async () => { logger.info("action.Bootstrap()"); - await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.Bootstrapped()); + await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS.Bootstrapped()); return emptyObject; }, Index: async ({uid, key, remove, add}) => { logger.info("action.Index()", `Received mails to remove/add: ${remove.length}/${add.length}`); - await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.ProgressState({key, status: {indexing: true}})); + await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS.ProgressState({key, status: {indexing: true}})); try { await indexingQueue.q(async () => { @@ -41,10 +41,10 @@ async function dbIndexerNotificationHandler( addToMailsIndex(index, add); }); } finally { - await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.ProgressState({key, status: {indexing: false}})); + await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS.ProgressState({key, status: {indexing: false}})); } - await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.IndexingResult({uid})); + await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS.IndexingResult({uid})); return emptyObject; }, @@ -55,7 +55,7 @@ async function dbIndexerNotificationHandler( return index.search(query); }); - await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.SearchResult({uid, data: {items, expandedTerms}})); + await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS.SearchResult({uid, data: {items, expandedTerms}})); return emptyObject; }, @@ -72,14 +72,14 @@ document.addEventListener("DOMContentLoaded", () => { } catch (error) { logger.error(`dbIndexerNotification.next, action.type:`, action.type, error); await dbIndexerOn( - IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.ErrorMessage({message: (error as { message: string }).message}), + IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS.ErrorMessage({message: (error as { message: string }).message}), ); } }, async (error) => { logger.error(`dbIndexerNotification.error`, error); await dbIndexerOn( - IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.ErrorMessage({message: (error as { message: string }).message}), + IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS.ErrorMessage({message: (error as { message: string }).message}), ); }, () => { @@ -95,7 +95,7 @@ window.addEventListener("error", async (event) => { try { logger.error("uncaught error", event); // eslint-disable-next-line @typescript-eslint/no-floating-promises - dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.ErrorMessage({message: event.message})); + dbIndexerOn(IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS.ErrorMessage({message: event.message})); } catch (error) { // NOOP console.error(error); // eslint-disable-line no-console diff --git a/src/electron-preload/lib/hovered-href-highlighter/index.scss b/src/electron-preload/lib/hovered-href-highlighter/index.scss index 8464e033d..967562ce1 100644 --- a/src/electron-preload/lib/hovered-href-highlighter/index.scss +++ b/src/electron-preload/lib/hovered-href-highlighter/index.scss @@ -1,7 +1,8 @@ -@import "~src/web/variables"; +@use "src/web/theming-variables-dark" as theming-variables-dark; +@import "src/web/variables"; :host { - $bg-color: theme-color("secondary"); + $background-color: theming-variables-dark.$secondary; $padding: 0.2em; $padding-l: 0.25em; @@ -14,9 +15,9 @@ bottom: 0; z-index: 10000; word-break: break-all; - color: color-yiq($bg-color); - background-color: $bg-color; - border: 1px solid darken($bg-color, 10%); + color: theming-variables-dark.$body-color; + background-color: $background-color; + border: 1px solid darken($background-color, 10%); font-size: $app-font-size-base-medium; margin: $padding-l $padding; padding: $padding $padding-l; diff --git a/src/shared/api/main.ts b/src/shared/api/main.ts index 508bffd0f..17a41f9fc 100644 --- a/src/shared/api/main.ts +++ b/src/shared/api/main.ts @@ -19,7 +19,7 @@ import {FsDbAccount} from "src/shared/model/database"; import {PACKAGE_NAME} from "src/shared/constants"; import {ProtonAttachmentHeadersProp, ProtonClientSession} from "src/shared/model/proton"; -export const IPC_MAIN_API_DB_INDEXER_ON_ACTIONS = unionize({ +export const IPC_MAIN_API_DB_INDEXER_RESPONSE_ACTIONS = unionize({ Bootstrapped: ofType>(), ProgressState: ofType<{ key: DbModel.DbAccountPk; @@ -45,7 +45,7 @@ export const IPC_MAIN_API_DB_INDEXER_ON_ACTIONS = unionize({ }, ); -export const IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS = unionize({ +export const IPC_MAIN_API_DB_INDEXER_REQUEST_ACTIONS = unionize({ Bootstrap: ofType>(), // TODO consider splitting huge data portion to chunks, see "ramda.splitEvery" Index: ofType<{ @@ -59,13 +59,12 @@ export const IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS = unionize({ { tag: "type", value: "payload", - tagPrefix: "ipc_main_api_db_indexer_notification_actions:", + tagPrefix: "IPC_MAIN_API_DB_INDEXER_REQUEST_ACTIONS:", }, ); // WARN: do not put sensitive data or any data to the main process notification stream, only status-like signals export const IPC_MAIN_API_NOTIFICATION_ACTIONS = unionize({ - Bootstrap: ofType>(), ActivateBrowserWindow: ofType>(), TargetUrl: ofType(), - DbIndexerProgressState: ofType, { type: "ProgressState" }>["payload"]>(), + DbIndexerProgressState: + ofType, { type: "ProgressState" }>["payload"]>(), DbAttachmentExportRequest: ofType(), PowerMonitor: ofType<{ message: "suspend" | "resume" | "shutdown" }>(), ProtonSessionTokenCookiesModified: ofType<{ key: DbModel.DbAccountPk }>(), + NativeTheme: ofType<{ shouldUseDarkColors: boolean }>(), }, { tag: "type", @@ -177,9 +178,9 @@ export const ENDPOINTS_DEFINITION = { mailsBundleItems: Array<{ mail: DbModel.View.Mail & { score?: number }; conversationSize: number }>; }>>(), - dbIndexerOn: ActionType.Promise>(), + dbIndexerOn: ActionType.Promise>(), - dbIndexerNotification: ActionType.Observable>(), + dbIndexerNotification: ActionType.Observable>(), staticInit: ActionType.Promise = [ ]; export const LAYOUT_MODES = [ - {value: "top", title: "top"}, - {value: "left", title: "left"}, - {value: "left-thin", title: "left (thin)"}, + {value: "top", title: "Top"}, + {value: "left", title: "Left"}, + {value: "left-thin", title: "Left (thin)"}, ] as const; export const WEB_VIEW_SESSION_STORAGE_KEY_SKIP_LOGIN_DELAYS = "ELECTRON_MAIL_SKIP_LOGIN_DELAYS"; diff --git a/src/shared/model/electron.ts b/src/shared/model/electron.ts index f3c19ce36..6af683356 100644 --- a/src/shared/model/electron.ts +++ b/src/shared/model/electron.ts @@ -20,7 +20,7 @@ export type ElectronContextLocations = Readonly<{ trayIconFont: string; trayIcon: string; userDataDir: string; - vendorsAppCssLinkHref: string; + vendorsAppCssLinkHrefs: string[]; preload: Readonly<{ aboutBrowserWindow: string; browserWindow: string; diff --git a/src/shared/model/options.ts b/src/shared/model/options.ts index 67bc889d9..f0f1fbb4f 100644 --- a/src/shared/model/options.ts +++ b/src/shared/model/options.ts @@ -66,6 +66,7 @@ export interface Config extends BaseConfig, Partial { layoutMode: (typeof import("src/shared/constants").LAYOUT_MODES)[number]["value"] logLevel: LogLevel startHidden: boolean + themeSource: "system" | "dark" | "light" unreadNotifications: boolean zoomFactor: number } @@ -87,6 +88,7 @@ export type BaseConfig = Pick; diff --git a/src/shared/util.ts b/src/shared/util.ts index 7185dd646..5a222990b 100644 --- a/src/shared/util.ts +++ b/src/shared/util.ts @@ -109,6 +109,7 @@ export function initialConfig(): Config { layoutMode: "top", logLevel: "error", startHidden: true, + themeSource: "system", unreadNotifications: true, zoomFactor: ZOOM_FACTOR_DEFAULT, }; @@ -136,6 +137,7 @@ export const pickBaseConfigProperties = ( "layoutMode", "logLevel", "startHidden", + "themeSource", "unreadNotifications", "zoomFactor", ], @@ -685,3 +687,26 @@ export const parseProtonRestModel = ( ): T extends Mail ? RestModel.Message : RestModel.Label => { return JSON.parse(dbEntity.raw); // eslint-disable-line @typescript-eslint/no-unsafe-return }; + +export const buildInitialVendorsAppCssLinks = ( + hrefs: ReadonlyArray, + shouldUseDarkColors?: boolean, +): string => { + return hrefs.reduce( + (accumulator, value) => { + const stylesheet: boolean = ( + typeof shouldUseDarkColors !== "boolean" + || + (value.endsWith("-dark.css") && shouldUseDarkColors) + || + (value.endsWith("-light.css") && !shouldUseDarkColors) + ); + return ( + accumulator + + + `` + ); + }, + "", + ); +}; diff --git a/src/shared/webpack-conts.ts b/src/shared/webpack-conts.ts new file mode 100644 index 000000000..4f115005c --- /dev/null +++ b/src/shared/webpack-conts.ts @@ -0,0 +1,5 @@ +export const WEBPACK_WEB_CHUNK_NAMES = { + "about": "about", + "browser-window": "browser-window", + "search-in-page-browser-view": "search-in-page-browser-view", +} as const; diff --git a/src/web/about/index.ts b/src/web/about/index.ts index 570426cfd..504d1e28c 100644 --- a/src/web/about/index.ts +++ b/src/web/about/index.ts @@ -1 +1,4 @@ import "src/web/about/index.scss"; +import {registerNativeThemeReaction} from "src/web/lib/native-theme"; + +registerNativeThemeReaction(__ELECTRON_EXPOSURE__); diff --git a/src/web/about/tsconfig.json b/src/web/about/tsconfig.json index f40347289..2ddb31796 100644 --- a/src/web/about/tsconfig.json +++ b/src/web/about/tsconfig.json @@ -3,6 +3,7 @@ "include": [ "../../../src/@types/**/*", "../../../src/web/@types/index.d.ts", + "../../../src/web/@types/electron-exposure.d.ts", "./**/*" ] } diff --git a/src/web/browser-window/app-theming-dark.scss b/src/web/browser-window/app-theming-dark.scss new file mode 100644 index 000000000..723619972 --- /dev/null +++ b/src/web/browser-window/app-theming-dark.scss @@ -0,0 +1,16 @@ +@import "src/web/theming-variables-dark"; +@import "./app-theming"; + +#{$app-prefix}-db-view-mail-body, +#{$app-prefix}-db-view-mails { + #{$app-prefix}-db-view-mail { + &.selected { + background-color: inherit !important; + + & { + border-color: $secondary !important; + color: lighten(map_get($app-account-title-btn-colors, "selected-text"), 10%) !important; + } + } + } +} diff --git a/src/web/browser-window/app-theming-light.scss b/src/web/browser-window/app-theming-light.scss new file mode 100644 index 000000000..5a5e9a700 --- /dev/null +++ b/src/web/browser-window/app-theming-light.scss @@ -0,0 +1,2 @@ +@import "src/web/theming-variables-light"; +@import "./app-theming"; diff --git a/src/web/browser-window/app-theming.scss b/src/web/browser-window/app-theming.scss new file mode 100644 index 000000000..08dd44161 --- /dev/null +++ b/src/web/browser-window/app-theming.scss @@ -0,0 +1,128 @@ +@use "src/web/browser-window/lib" as lib; +@import "src/web/variables"; + +.list-group-item:not(.list-group-item-warning) { + background-color: $card-cap-bg; + color: $app-theming-db-view-color-text-mail; +} + +#{$app-prefix}-db-view-mail-tab { + .list-group-item:not(.list-group-item-warning) { + background-color: $secondary; + } +} + +#{$app-prefix}-login { + background-color: $body-bg; +} + +#{$app-prefix}-accounts { + .wrapper > .accounts-block > .controls { + .btn-group { + width: 100%; + + > .btn { + &:first-child { + border-right-color: $app-theming-color-secondary-btn-split-border; + } + } + } + } + + @media (min-width: #{map-get($grid-breakpoints, lg)}) { + .layout-mode-left-thin { + > .controls, + #{$app-prefix}-account-title { + .btn-group { + &:not(.selected) { + > .btn-sm { + &:not(:last-child) { + border-bottom-color: $app-theming-color-secondary-btn-split-border; + } + } + } + } + } + } + } +} + +#{$app-prefix}-account-title { + .btn-group { + &:not(.selected) { + > .btn:not(:last-child) { + border-right-color: $app-theming-color-secondary-btn-split-border; + } + } + } +} + +#{$app-prefix}-account-edit { + .rounded { + background-color: $card-cap-bg; + color: $card-cap-color; + } +} + +#{$app-prefix}-db-view-mail-body, +#{$app-prefix}-db-view-entry { + background-color: $app-theming-db-view-color-bg-body; +} + +#{$app-prefix}-db-view-mail { + border: 1px solid $app-theming-db-view-color-border; + + .b { + border: 1px solid $app-theming-db-view-color-border; + } + + .address { + color: $app-theming-db-view-color-text-mail; + } + + &:not(.selected) { + .address, + .conversation-size, + .date, + .score, + .folders > .b { + background-color: theme-color("secondary"); + } + } +} + +#{$app-prefix}-db-view-mail-body { + border: 1px solid $app-theming-db-view-color-border; + + .addresses, + .attachments { + .badge { + border: 1px solid $app-theming-db-view-color-border; + color: $app-theming-db-view-color-text-mail; + } + } + + .addresses { + border-bottom: 1px solid $app-theming-db-view-color-border; + } + + .attachments { + border-top: 1px solid $app-theming-db-view-color-border; + } +} + +#{$app-prefix}-db-view-monaco-editor { + .monaco-editor > .overflow-guard { + border: 1px solid $app-theming-db-view-color-border; + } +} + +.#{$app-prefix}-db-view-mails-export-modal { + .btn-group { + .btn { + &.active { + @include lib.btn-warning-light-active(); + } + } + } +} diff --git a/src/web/browser-window/app/_accounts/account-title.component.scss b/src/web/browser-window/app/_accounts/account-title.component.scss index 208a3b1b2..a2d8a7c2e 100644 --- a/src/web/browser-window/app/_accounts/account-title.component.scss +++ b/src/web/browser-window/app/_accounts/account-title.component.scss @@ -40,12 +40,6 @@ } } - &:not(.selected) { - > .btn:not(:last-child) { - border-right-color: $app-color-secondary-btn-split-border; - } - } - > .btn { &:nth-child(1) { flex-grow: 1; diff --git a/src/web/browser-window/app/_accounts/account.component.scss b/src/web/browser-window/app/_accounts/account.component.scss index e2d39be49..45c234d2d 100644 --- a/src/web/browser-window/app/_accounts/account.component.scss +++ b/src/web/browser-window/app/_accounts/account.component.scss @@ -9,7 +9,7 @@ bottom: 0; overflow-x: hidden; overflow-y: auto; - background-color: $app-color-bg-dark-1; + background-color: $app-dark-bg; &::ng-deep { webview { diff --git a/src/web/browser-window/app/_accounts/accounts.component.scss b/src/web/browser-window/app/_accounts/accounts.component.scss index 9bba506da..cf080a006 100644 --- a/src/web/browser-window/app/_accounts/accounts.component.scss +++ b/src/web/browser-window/app/_accounts/accounts.component.scss @@ -10,7 +10,7 @@ right: 0; top: 0; bottom: 0; - background-color: $app-color-bg-dark-1; + background-color: $app-dark-bg; // invisible/stub button gets focused so real buttons don't flick on window activation (tray icon "toggle" click) & > .btn-stub { @@ -59,7 +59,6 @@ &:first-child { width: 100%; white-space: nowrap; - border-right-color: $app-color-secondary-btn-split-border; } &:not(:first-child) { @@ -184,14 +183,6 @@ } } - &:not(.selected) { - > .btn-sm { - &:not(:last-child) { - border-bottom-color: $app-color-secondary-btn-split-border; - } - } - } - > .btn-sm { flex-direction: column; align-items: center; diff --git a/src/web/browser-window/app/_db-view/db-view-entry.component.scss b/src/web/browser-window/app/_db-view/db-view-entry.component.scss index ae7172ffa..725434109 100644 --- a/src/web/browser-window/app/_db-view/db-view-entry.component.scss +++ b/src/web/browser-window/app/_db-view/db-view-entry.component.scss @@ -3,7 +3,6 @@ :host { display: flex; height: 100%; - background-color: $app-db-view-color-bg-body; #{$app-prefix}-db-view-mail-tab { flex-grow: 1; diff --git a/src/web/browser-window/app/_db-view/db-view-mail-body.component.html b/src/web/browser-window/app/_db-view/db-view-mail-body.component.html index 4f29a6635..c27f4e5a9 100644 --- a/src/web/browser-window/app/_db-view/db-view-mail-body.component.html +++ b/src/web/browser-window/app/_db-view/db-view-mail-body.component.html @@ -45,7 +45,7 @@ - - diff --git a/src/web/search-in-page-browser-view/index.ts b/src/web/search-in-page-browser-view/index.ts index 89f94eb9e..4c8656d05 100644 --- a/src/web/search-in-page-browser-view/index.ts +++ b/src/web/search-in-page-browser-view/index.ts @@ -1,5 +1,8 @@ import "src/web/search-in-page-browser-view/index.scss"; import {SearchInPageWidget} from "./widget"; +import {registerNativeThemeReaction} from "src/web/lib/native-theme"; + +registerNativeThemeReaction(__ELECTRON_EXPOSURE__); document.addEventListener("DOMContentLoaded", () => { const root = document.querySelector(".input-group") as HTMLElement; diff --git a/src/web/theming-variables-dark.scss b/src/web/theming-variables-dark.scss new file mode 100644 index 000000000..ce6ff7d3f --- /dev/null +++ b/src/web/theming-variables-dark.scss @@ -0,0 +1,54 @@ +@use "src/web/variables" as variables; + +$body-bg: lighten(variables.$app-dark-bg, 5%); +$body-color: darken(variables.$white, 10%); +$border-color: darken($body-bg, 5%); +$hr-border-color: $border-color; +$component-active-color: $body-color; +$pre-color: $body-color; +$text-muted: darken($body-color, 35%); +$close-color: $body-color; + +$input-bg: $body-bg; +$input-disabled-bg: lighten($body-bg, 10%); +$input-color: $body-color; +$input-border-color: lighten($body-bg, 10%); +$input-box-shadow: variables.$app-dark-bg; +$input-placeholder-color: darken($body-color, 10%); + +$card-bg: $body-bg; +$card-cap-bg: variables.$app-dark-bg; +$card-border-color: darken($border-color, 5%); + +$dropdown-bg: $body-bg; +$dropdown-color: $body-color; +$dropdown-link-color: $dropdown-color; +$dropdown-link-hover-color: darken($dropdown-link-color, 5%); +$dropdown-link-hover-bg: darken($dropdown-bg, 5%); +$dropdown-border-color: $border-color; +$dropdown-divider-bg: $border-color; + +$list-group-bg: $body-bg; +$list-group-border-color: $border-color; +$list-group-action-color: $body-color; +$list-group-action-hover-color: darken($body-color, 10%); +$list-group-hover-bg: $dropdown-link-hover-bg; +$list-group-action-active-color: $list-group-action-hover-color; +$list-group-action-active-bg: $list-group-hover-bg; + +$tooltip-color: $body-color; +$tooltip-bg: $body-bg; + +$popover-bg: $body-bg; + +$modal-content-bg: $body-bg; +$modal-content-border-color: $border-color; +$modal-header-border-color: $border-color; + +$secondary: darken(#686a71, 2%); +$progress-bar-color: variables.$gray-100; +$app-theming-db-view-color-bg-body: $body-bg; +$app-theming-db-view-color-border: $secondary; +$app-theming-datepicker-color-box-shadow: lighten($body-bg, 5%); + +@import "./theming-variables-light"; diff --git a/src/web/theming-variables-light.scss b/src/web/theming-variables-light.scss new file mode 100644 index 000000000..6e3459e38 --- /dev/null +++ b/src/web/theming-variables-light.scss @@ -0,0 +1,15 @@ +@use "src/web/variables" as variables; + +$secondary: variables.$secondary !default; +$body-bg: variables.$body-bg !default; +$body-color: variables.$body-color !default; +$progress-bar-color: variables.$gray-700 !default; + +$app-theming-color-secondary-btn-split-border: darken($secondary, 7%) !default; +$app-theming-db-view-color-bg-body: lighten(#d1d7dc, 10%) !default; +$app-theming-db-view-color-bg-mail: $secondary !default; +$app-theming-db-view-color-border: darken($secondary, 10%) !default; +$app-theming-db-view-color-text-mail: $body-color !default; +$app-theming-datepicker-color-bg: $body-bg !default; +$app-theming-datepicker-color-text: $body-color !default; +$app-theming-datepicker-color-box-shadow: lighten($app-theming-datepicker-color-text, 40%) !default; diff --git a/src/web/variables.scss b/src/web/variables.scss index a94750e7c..352a48283 100644 --- a/src/web/variables.scss +++ b/src/web/variables.scss @@ -6,16 +6,8 @@ $app-spacer-1: map-get($spacers, 1); $app-spacer-2: map-get($spacers, 2); $app-spacer-3: map-get($spacers, 3); -$app-color-bg-dark-1: #3c414e; -// TODO consider using protonmail's bg-colors more widely -// $app-color-bg-dark-2: #30353f; -// $app-color-bg-dark-3: #262a33; -// $app-color-bg-dark-4: #1b1e24; - -$app-color-primary-light: lighten(theme-color("primary"), 2%); -$app-color-purple-light: lighten(#987ebf, 2%); -$app-color-warning-light-text: theme-color-level("warning", 6); -$app-color-secondary-btn-split-border: darken($secondary, 7%); +$app-color-primary-light: lighten(theme-color("primary"), 2%) !default; +$app-color-purple-light: lighten(#987ebf, 2%) !default; // $app-font-size-base-smallest: $font-size-base * 0.75; $app-font-size-base-small: $font-size-sm; @@ -25,17 +17,15 @@ $app-account-title-btn-colors: map-merge( ( "selected-bg": theme-color("warning-light"), "selected-border": darken(theme-color("warning-light"), 30%), - "selected-text": $app-color-warning-light-text, + "selected-text": theme-color-level("warning", 6), ), (), ); -$app-db-view-color-bg-mail: lighten(#d1d7dc, 5%); -$app-db-view-color-bg-body: lighten($app-db-view-color-bg-mail, 5%); -$app-db-view-color-text-mail: color-yiq($app-db-view-color-bg-mail); -$app-db-view-color-border: $list-group-border-color; $app-db-view-mail-padding-x: $list-group-item-padding-x * 0.5; $app-db-view-mail-padding-y: $list-group-item-padding-y * 0.5; +$app-dark-bg: lighten(#262a33, 5%); + @keyframes app-keyframes-opacity { 50% { opacity: 0; diff --git a/src/web/vendor-variables.scss b/src/web/vendor-variables.scss index 605fb43c5..f8fc8793d 100644 --- a/src/web/vendor-variables.scss +++ b/src/web/vendor-variables.scss @@ -2,29 +2,28 @@ @import "bootstrap/scss/mixins"; @import "font-awesome/scss/mixins"; -// >>>>> overriding bootstrap/font-awesome/other-libs variables before importing them - -// $enable-shadows: true; -$primary: #1ba489; -$secondary: lighten(#686a71, 9%); +$primary: #1ba489 !default; +$secondary: lighten(#ced4da, 2%) !default; $modal-header-padding: 0.7rem; $list-group-item-padding-x: 1.25rem * 0.5; $list-group-item-padding-y: 0.65rem !default; $badge-padding-y: 0.15em; $modal-dialog-margin-y-sm-up: 3rem; -// >>>>> importing bootstrap/font-awesome/other-libs variables - @import "bootstrap/scss/variables"; @import "font-awesome/scss/variables"; -// >>>>> overriding bootstrap/font-awesome/other-libs maps - $theme-colors: map-merge( $theme-colors, ( - secondary: $secondary, - secondary-light: darken($gray-400, 1%), warning-light: theme-color-level("warning", -9), ), ); + +// node_modules/@ng-select/ng-select/scss/default.theme.scss +$ng-select-primary-text: $body-color; +$ng-select-bg: $input-bg; +$ng-select-border: $border-color; +$ng-select-highlight: $dropdown-link-hover-bg; +$ng-select-selected: $ng-select-highlight; +$ng-select-marked: $ng-select-highlight; diff --git a/webpack-configs/preload/lib.ts b/webpack-configs/preload/lib.ts index ba5521078..bb2d06b3b 100644 --- a/webpack-configs/preload/lib.ts +++ b/webpack-configs/preload/lib.ts @@ -15,6 +15,12 @@ export function buildRendererConfig(entry: Configuration["entry"], tsConfigFile: externals: { electron: "require('electron')", }, + resolve: { + fallback: { + "path": false, + "fs": false, + }, + }, }, { tsConfigFile, diff --git a/webpack-configs/web/browser-window.ts b/webpack-configs/web/browser-window.ts index 68c2769e3..085d401af 100644 --- a/webpack-configs/web/browser-window.ts +++ b/webpack-configs/web/browser-window.ts @@ -3,7 +3,7 @@ import {readConfiguration} from "@angular/compiler-cli"; import {BuildAngularCompilationFlags, BuildEnvironment} from "webpack-configs/model"; import {ENVIRONMENT, rootRelativePath} from "webpack-configs/lib"; -import {WEB_CHUNK_NAMES} from "src/shared/constants"; +import {WEBPACK_WEB_CHUNK_NAMES} from "src/shared/webpack-conts"; import {browserWindowAppPath, browserWindowPath, buildBaseWebConfig, cssRuleSetRules} from "./lib"; const angularCompilationFlags: BuildAngularCompilationFlags = {aot: true, ivy: true}; @@ -43,7 +43,7 @@ const config = buildBaseWebConfig( "sass-loader", ], include: [ - browserWindowAppPath(), + browserWindowAppPath("/"), ], }, { @@ -150,19 +150,39 @@ const config = buildBaseWebConfig( return `vendor_${chunk.hash}.js`; }, }, - styles: { - name: "shared-vendor", - test: /[\\/]vendor[\\/]shared-vendor\.scss$/, - chunks: "all", - enforce: true, - }, + ...Object + .entries( + { + "shared-vendor-dark": "browser-window/vendor/shared-vendor-dark.scss", + "shared-vendor-light": "browser-window/vendor/shared-vendor-light.scss", + "vendor-dark": "browser-window/vendor/vendor-dark.scss", + "vendor-light": "browser-window/vendor/vendor-light.scss", + "app-theming-dark": "browser-window/app-theming-dark.scss", + "app-theming-light": "browser-window/app-theming-light.scss", + } as const, + ) + .reduce( + (accumulator, [name, value]) => { + return { + ...accumulator, + [`styles-${name}`]: { + // eslint-disable-next-line no-useless-escape + test: new RegExp(`src/web/${value}`.replace(/\//g, "(\|\\\\|/)"), "g"), + name, + chunks: "all", + enforce: true, + }, + }; + }, + {}, + ), }, }, }, }, { tsConfigFile, - chunkName: WEB_CHUNK_NAMES["browser-window"], + chunkName: WEBPACK_WEB_CHUNK_NAMES["browser-window"], entries: { "css.worker": "monaco-editor/esm/vs/language/css/css.worker", "editor.worker": "monaco-editor/esm/vs/editor/editor.worker.js", @@ -170,6 +190,10 @@ const config = buildBaseWebConfig( "json.worker": "monaco-editor/esm/vs/language/json/json.worker", "ts.worker": "monaco-editor/esm/vs/language/typescript/ts.worker", }, + htmlWebpackPlugin: { + // TODO enable resource ordering via the HtmlWebpackPlugin/MiniCssExtractPlugin options + inject: false, + }, }, ); diff --git a/webpack-configs/web/lib.ts b/webpack-configs/web/lib.ts index e67d53d10..7cdc0c2fb 100644 --- a/webpack-configs/web/lib.ts +++ b/webpack-configs/web/lib.ts @@ -6,7 +6,7 @@ import {merge as webpackMerge} from "webpack-merge"; import {BuildEnvironment} from "webpack-configs/model"; import {ENVIRONMENT, ENVIRONMENT_STATE, buildBaseConfig, outputRelativePath, srcRelativePath, typescriptLoaderRule} from "./../lib"; import {MiniCssExtractPlugin} from "webpack-configs/require-import"; -import {WEB_CHUNK_NAMES} from "src/shared/constants"; +import {WEBPACK_WEB_CHUNK_NAMES} from "src/shared/webpack-conts"; export const browserWindowPath = (...value: string[]): string => { return srcRelativePath("./web/browser-window", ...value); @@ -41,7 +41,7 @@ export function cssRuleSetRules(): RuleSetRule[] { export function buildMinimalWebConfig( configPatch: Configuration, options: { - chunkName: keyof typeof WEB_CHUNK_NAMES; + chunkName: keyof typeof WEBPACK_WEB_CHUNK_NAMES; }, ): Configuration { const chunkPath = (...value: string[]): string => { @@ -84,7 +84,7 @@ export function buildMinimalWebConfig( "sass-loader", ], exclude: [ - browserWindowAppPath(), + browserWindowAppPath("/"), ], }, { @@ -121,9 +121,10 @@ export function buildBaseWebConfig( configPatch: Configuration, options: { tsConfigFile?: string - chunkName: keyof typeof WEB_CHUNK_NAMES + chunkName: keyof typeof WEBPACK_WEB_CHUNK_NAMES typescriptLoader?: boolean entries?: Record + htmlWebpackPlugin?: Partial, }, ): Configuration { const chunkPath = (...value: string[]): string => { @@ -164,6 +165,7 @@ export function buildBaseWebConfig( filename: "index.html", hash: ENVIRONMENT_STATE.production, minify: false, + ...options.htmlWebpackPlugin, }), ], },