Compare commits

...

112 Commits

Author SHA1 Message Date
Anders Kaseorg
1bfb2dd975 release: New release v5.9.0.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-04-01 17:25:21 -07:00
Anders Kaseorg
fb7937314b Upgrade dependencies.
electron-builder@next is needed to build a DMG on macOS 12.3.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-04-01 14:24:22 -07:00
Anders Kaseorg
e39d2a9b95 xo: Fix unicorn/prefer-node-protocol.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-31 21:52:32 -07:00
Anders Kaseorg
3b04b61662 Upgrade dependencies, including Electron 18.0.1.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-31 21:21:21 -07:00
Anders Kaseorg
829b2a0f2a package-lock.json: Upgrade to lockfileVersion 2.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-31 21:13:41 -07:00
Anders Kaseorg
5edffbdf21 Move handleExternalLink to main process.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-31 21:10:13 -07:00
Anders Kaseorg
27576c95e6 Skip unnecessary remote for clipboard, nativeImage, shell.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-14 21:48:44 -07:00
Anders Kaseorg
5acc45cba4 Use process-specific electron/{main,renderer,common} imports.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-14 21:38:18 -07:00
Anders Kaseorg
343e0ed848 xo: Simplify configuration.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-14 20:47:40 -07:00
Anders Kaseorg
0c784b12fa WebView: Enable allowpopups.
This is required for Electron ≥ 15 to continue invoking our new window
handler (handleExternalLink), following the nativeWindowOpen
migration.

https://github.com/electron/electron/issues/30886

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-11 18:23:39 -08:00
Anders Kaseorg
2b50b21752 tsconfig: Downgrade target to ES2021.
The ES2022 definition of Error#cause conflicts with @types/verror.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-09 15:54:05 -08:00
Anders Kaseorg
ad604f020d tsconfig: Remove lib setting.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-09 15:09:31 -08:00
Anders Kaseorg
4151e020f6 Revert "xo: Fix import/extensions."
This reverts commit 5623ab3866.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 21:58:40 -08:00
Anders Kaseorg
bc59714192 xo: Fix @typescript-eslint/naming-convention.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 21:24:49 -08:00
Anders Kaseorg
b43a7b6809 xo: Fix unicorn/template-indent.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 21:15:32 -08:00
Anders Kaseorg
fba8aa0ab0 xo: Fix object-shorthand.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 21:15:32 -08:00
Anders Kaseorg
5623ab3866 xo: Fix import/extensions.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 21:15:32 -08:00
Anders Kaseorg
a4fbf9bd28 stylelint: Fix shorthand-property-no-redundant-values.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 21:15:32 -08:00
Anders Kaseorg
db730da45c stylelint: Ignore selector-id-pattern for #nav-AddServer.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 21:15:32 -08:00
Anders Kaseorg
b5a938d3b0 stylelint: Ignore selector-class-pattern for .__tagify_input.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 21:15:32 -08:00
Anders Kaseorg
863d1e25ba stylelint: Fix keyframes-name-pattern.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 21:15:32 -08:00
Anders Kaseorg
a90aaeb86c stylelint: Fix function-url-quotes.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 21:15:32 -08:00
Anders Kaseorg
8b6af78f2a stylelint: Fix font-family-name-quotes. 2022-03-08 21:15:32 -08:00
Anders Kaseorg
6c2dcb450b stylelint: Fix alpha-value-notation, color-function-notation. 2022-03-08 21:15:32 -08:00
Anders Kaseorg
f57962d02f .stylelintrc: Format with Prettier.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 21:15:32 -08:00
Anders Kaseorg
2983c381ae Fix Electron.Session type.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 21:15:32 -08:00
Anders Kaseorg
1ea7fa813a Remove redundant webPreferences defaults.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 21:15:22 -08:00
Anders Kaseorg
e434c5b5d0 Untangle Sentry initialization.
Thanks to upstream for the helpful advice at
https://github.com/getsentry/sentry-electron/issues/427.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 16:55:36 -08:00
Anders Kaseorg
9c1f47badd Move server manager view to the default session.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 16:55:23 -08:00
Anders Kaseorg
4ed4328bf8 Toggle spell checker in the session rather than the webPreferences.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-08 16:05:54 -08:00
Anders Kaseorg
c6022e94bb main: Enable contextIsolation for BrowserWindow.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
06eb169c65 WebView: Restrict $el type to HTMLElement.
The extra methods on WebviewTag are not available from the
context-isolated preload script.
https://github.com/electron/electron/issues/26904

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
2f7529cd71 WebView: Get event parameters via WebContents rather than WebviewTag.
Works around https://github.com/electron/electron/issues/31924.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
3a8541f601 WebView: Call getWebContentsId in main world.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
0eb910b2e8 WebView: Use send method.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
76a879e4fd WebView: Convert WebviewTag methods to WebContents methods.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
7026e43575 WebView: Add getWebContents method.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
869361bac3 WebView: Type $el as required.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
832ea3c04e WebView: Remove async from send method.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
68232f966e WebView: Wait for did-navigate before constructing WebView.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
86b7da45ef WebView: Use a better focus() workaround.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
b853856317 WebView: Add factory function.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
6676f1c6ac WebView: Switch templateHTML to a static method.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
e0243bc460 main: Disable nodeIntegration for BrowserWindow.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
fd6cb548f8 WebView: Remove nodeIntegration parameter.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
743b2d6054 WebView: Make preload a string.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
fb5c6b365e css: Simplify webview CSS.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
f092e99f42 css: Remove the melodramatic fade-in animation on load.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
751eb6ef98 Switch electron.remote to @electron/remote.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-03-04 16:56:44 -08:00
Anders Kaseorg
980de649e3 common: Factor out electron.remote pattern to a module.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-28 16:42:04 -08:00
Anders Kaseorg
84849d2c84 Move functional tab pages out of separate webviews.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-28 16:42:04 -08:00
Anders Kaseorg
b263997bed tray: Move initialization to a function.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-28 16:07:37 -08:00
Anders Kaseorg
12c773bc71 tray: Be robust in case there’s no active webview.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-28 16:07:33 -08:00
Anders Kaseorg
d937539618 renderer: Restrict webview functions to ServerTab instances.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-28 16:07:28 -08:00
Anders Kaseorg
0a5d07f839 renderer: Inline FunctionalTabProps type.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-28 16:07:23 -08:00
Anders Kaseorg
5dcd3956ac preference: Unify duplicate toggle-sidebar-setting event.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-28 16:07:21 -08:00
Anders Kaseorg
3ffc7251f4 preference: Unify duplicate toggle-menubar-setting event.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-28 16:06:09 -08:00
Anders Kaseorg
7fb0cfd176 WebView: Remove redundant name property.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-28 16:05:25 -08:00
Anders Kaseorg
5c83952ba1 webview: Remove forceLoad method.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-28 16:04:29 -08:00
Anders Kaseorg
a7a051bb2a renderer: Remove dead show-network-error message.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-28 16:04:21 -08:00
Anders Kaseorg
2b2c5dbe5c about: Encapsulate in a custom element.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-28 16:04:20 -08:00
Anders Kaseorg
ffe87a9729 preference: Encapsulate in a custom element.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-28 15:58:36 -08:00
Anders Kaseorg
b366195415 Upgrade playwright-core.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-17 22:57:19 -08:00
Anders Kaseorg
f9f2b20e90 preference: Use querySelector relative to $root.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-17 22:45:10 -08:00
Anders Kaseorg
e16811065d css: Extract font definitions to fonts.css.
This works around
https://bugs.chromium.org/p/chromium/issues/detail?id=336876.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-10 00:14:00 -08:00
Anders Kaseorg
f66a1127de electron-bridge: Remove console.log debugging spew.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-03 23:09:50 -08:00
Anders Kaseorg
06ef60c4c2 notification: Remove BaseNotification wrapper class.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-03 23:02:37 -08:00
Anders Kaseorg
4b93298b58 notification: Set the AppUserModelId from the main process.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-03 22:55:15 -08:00
Anders Kaseorg
a41a771923 notification: Don’t use remote for focusCurrentServer.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-03 22:54:38 -08:00
Anders Kaseorg
a43f7d9bcf Fix glob usage in package scripts.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-02-03 03:02:07 -08:00
Anders Kaseorg
c9453f877b config-schemata: Remove unused systemProxyRules setting.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-11-23 17:55:32 -08:00
Anders Kaseorg
525fa94b18 Fix system proxy resolution.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-11-23 17:51:51 -08:00
Anders Kaseorg
460b9e5e55 main: Remove dead code for recreating main window.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-11-23 16:12:15 -08:00
Anders Kaseorg
8fc41a7ca8 system-util: Remove getOS wrapper.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-11-22 15:56:58 -08:00
Anders Kaseorg
4c7b9cf4e3 server-tab: Delete space in macOS shortcut text.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-11-22 15:50:32 -08:00
Anders Kaseorg
f4479dfda4 tests: Migrate E2E tests to Playwright.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-11-19 15:50:16 -08:00
Anders Kaseorg
377f08ad5d Fix unread count parsing from page title.
Fixes #1157

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-10-27 16:42:46 -07:00
Anders Kaseorg
add43bafda Fix ‘npm run prettier-non-js’ on Windows.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-10-10 23:15:09 -07:00
Anders Kaseorg
b35d45955b WebView: Move initialization from dom-ready event to did-attach event.
This fixes the bug where the context menu would disappear immediately
if the page had been loaded an even number of times.

Fixes #662, fixes #991, fixes #1010.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-10-08 18:10:43 -07:00
Anders Kaseorg
2ecb970da0 Revert "webview: fix focus after soft reload."
This reverts commit 6b98a49245 (#698).

The bug it worked around was fixed upstream in Electron 9.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-10-08 16:32:10 -07:00
Anders Kaseorg
edb2933dad Remove .prettierignore.non-js.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-10-06 16:13:47 -07:00
Anders Kaseorg
8141927974 tests: Remove dynamic package.json generation.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-10-06 16:07:29 -07:00
Anders Kaseorg
4db89ac3a7 typescript: Enable noImplicitOverride.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-09-10 21:52:32 -07:00
Anders Kaseorg
feb67e6c2d Deglobalize ElectronBridge type.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-09-01 14:04:51 -07:00
Anders Kaseorg
014e97b563 Remove feedback widget.
@electron-elements/send-feedback won’t work with Electron 14, and all
it ever did was open your mail client.  Have the “Report an Issue”
menu item direct users to our website instead.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-08-30 19:04:20 -07:00
Anders Kaseorg
a3f4e19aa2 autoupdater: Avoid deprecated log.FileTransport.file.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-08-30 14:18:31 -07:00
Anders Kaseorg
90a65ab6cc release: New release v5.8.1.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-29 19:26:30 -07:00
Anders Kaseorg
c00e1618e7 Downgrade electron-updater to 4.3.5.
Newer electron-updater versions are broken on macOS by
https://github.com/electron-userland/electron-builder/issues/5935 as
well as another issue that has not yet been diagnosed.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-29 19:26:30 -07:00
Anders Kaseorg
ceb6417979 Replace Linux zip build with tar.xz.
The filename of the Linux zip now conflicts with the macOS zip needed
by the auto-updater, and zip isn’t a usual format for Linux anyway.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 23:53:40 -07:00
Anders Kaseorg
1d40ebb65f release: New release v5.8.0.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 17:10:10 -07:00
Anders Kaseorg
6301427ef4 Fix Windows MSI filename.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 17:06:31 -07:00
Anders Kaseorg
64d1d6c88d Build arm64 pkg for macOS.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 16:33:37 -07:00
Anders Kaseorg
adcacd7d45 Tighten tab role type.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 15:46:40 -07:00
Anders Kaseorg
b6729b0d0a menu: Skip missing elements of tabs array.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 15:31:14 -07:00
Anders Kaseorg
ec7d5b4046 Upgrade dependencies, including Electron 13.1.7.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 13:45:09 -07:00
Anders Kaseorg
380ea3a891 tests: Add extension to .js imports.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 13:45:09 -07:00
Anders Kaseorg
320e152897 xo: Fix unicorn/numeric-separators-style.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 13:45:09 -07:00
Anders Kaseorg
c00d0abe0d enterprise-util: Use zod for type-safe validation.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 13:45:09 -07:00
Anders Kaseorg
aaa83da0f8 config-util: Use zod for type-safe validation.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 13:45:09 -07:00
Anders Kaseorg
494e716dfe domain-util: Use zod for type-safe validation.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 13:45:09 -07:00
Anders Kaseorg
50c266295e linux-update-util: Strongly type update items.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 13:45:06 -07:00
Anders Kaseorg
55a6122a6c general-section: Use zod for type-safe validation.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 13:37:31 -07:00
Anders Kaseorg
2a648b79c9 linuxupdater: Use zod for type-safe validation.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 13:37:31 -07:00
Anders Kaseorg
0bc49bf723 request: Use zod for type-safe validation.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 13:37:31 -07:00
Anders Kaseorg
cb7d1faa52 main: Annotate permissionCallbacks.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 13:37:31 -07:00
Anders Kaseorg
fa3c744e76 displayInitialCharLogo: Fix incorrect cast.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 13:37:31 -07:00
Anders Kaseorg
54be4dccce injected: Specify explicit type for cast.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 13:37:31 -07:00
Anders Kaseorg
6a407d0e42 preload: Fix weird event.target usage.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-21 13:37:31 -07:00
Anders Kaseorg
47171fffd5 Fix spell checker on macOS.
Although ses.setSpellCheckerLanguages is documented as a no-op on macOS,
ses.setSpellCheckerLanguages([]) actually disables spell checking as of
Electron 8.1.0 (https://github.com/electron/electron/issues/30215).
This effect is persistent in our persistent session, so we attempt to
undo it by copying the language list from the main BrowserWindow.

(Before commit 892f7c8e47 we were running
ses.setSpellCheckerLanguages(null), which just crashed with “TypeError:
Error processing argument at index 0, conversion failure from null”.)

Fixes #1132.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-20 16:47:05 -07:00
Anders Kaseorg
e48c9067a3 Upgrade Prettier to 2.3.2.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-07-05 16:55:47 -07:00
Anders Kaseorg
1d30c83f7a Revert "Added log-out shortcut"
This reverts commit 2a477abe5f.

This is not a common operation that needs a keyboard shortcut, and
it’s too easy to invoke by accident.  Fixes #1115.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-05-24 11:31:09 -07:00
Anders Kaseorg
9f76fb295e Remove color profile override.
Modern Chromium and Electron do color management correctly.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-05-02 14:16:10 -07:00
82 changed files with 15082 additions and 8198 deletions

View File

@@ -1,4 +0,0 @@
*.js
*.ts
/app/translations/*.json
/dist

View File

@@ -1,9 +1,12 @@
{
"extends": ["stylelint-config-standard", "stylelint-config-prettier"],
"rules": {
"color-named": "never",
"color-no-hex": true,
"font-family-no-missing-generic-family-keyword": [true, {"ignoreFontFamilies": ["Material Icons"]}],
"selector-type-no-unknown": [true, {"ignoreTypes": ["send-feedback", "webview"]}],
}
"extends": ["stylelint-config-standard", "stylelint-config-prettier"],
"rules": {
"color-named": "never",
"color-no-hex": true,
"font-family-no-missing-generic-family-keyword": [
true,
{"ignoreFontFamilies": ["Material Icons"]}
],
"selector-type-no-unknown": [true, {"ignoreTypes": ["webview"]}]
}
}

View File

@@ -0,0 +1,44 @@
import * as z from "zod";
export const dndSettingsSchemata = {
showNotification: z.boolean(),
silent: z.boolean(),
flashTaskbarOnMessage: z.boolean(),
};
export const configSchemata = {
...dndSettingsSchemata,
appLanguage: z.string().nullable(),
autoHideMenubar: z.boolean(),
autoUpdate: z.boolean(),
badgeOption: z.boolean(),
betaUpdate: z.boolean(),
// eslint-disable-next-line @typescript-eslint/naming-convention
customCSS: z.string().or(z.literal(false)).nullable(),
dnd: z.boolean(),
dndPreviousSettings: z.object(dndSettingsSchemata).partial(),
dockBouncing: z.boolean(),
downloadsPath: z.string(),
enableSpellchecker: z.boolean(),
errorReporting: z.boolean(),
lastActiveTab: z.number(),
promptDownload: z.boolean(),
proxyBypass: z.string(),
// eslint-disable-next-line @typescript-eslint/naming-convention
proxyPAC: z.string(),
proxyRules: z.string(),
quitOnClose: z.boolean(),
showSidebar: z.boolean(),
spellcheckerLanguages: z.string().array().nullable(),
startAtLogin: z.boolean(),
startMinimized: z.boolean(),
trayIcon: z.boolean(),
useManualProxy: z.boolean(),
useProxy: z.boolean(),
useSystemProxy: z.boolean(),
};
export const enterpriseConfigSchemata = {
...configSchemata,
presetOrganizations: z.string().array(),
};

View File

@@ -1,45 +1,19 @@
import electron from "electron";
import fs from "fs";
import path from "path";
import fs from "node:fs";
import path from "node:path";
import * as Sentry from "@sentry/electron";
import {JsonDB} from "node-json-db";
import {DataError} from "node-json-db/dist/lib/Errors";
import type * as z from "zod";
import type {DNDSettings} from "./dnd-util";
import {configSchemata} from "./config-schemata";
import * as EnterpriseUtil from "./enterprise-util";
import Logger from "./logger-util";
import {app, dialog} from "./remote";
export interface Config extends DNDSettings {
appLanguage: string | null;
autoHideMenubar: boolean;
autoUpdate: boolean;
badgeOption: boolean;
betaUpdate: boolean;
customCSS: string | false | null;
dnd: boolean;
dndPreviousSettings: Partial<DNDSettings>;
dockBouncing: boolean;
downloadsPath: string;
enableSpellchecker: boolean;
errorReporting: boolean;
lastActiveTab: number;
promptDownload: boolean;
proxyBypass: string;
proxyPAC: string;
proxyRules: string;
quitOnClose: boolean;
showSidebar: boolean;
spellcheckerLanguages: string[] | null;
startAtLogin: boolean;
startMinimized: boolean;
systemProxyRules: string;
trayIcon: boolean;
useManualProxy: boolean;
useProxy: boolean;
useSystemProxy: boolean;
}
/* To make the util runnable in both main and renderer process */
const {app, dialog} = process.type === "renderer" ? electron.remote : electron;
export type Config = {
[Key in keyof typeof configSchemata]: z.output<typeof configSchemata[Key]>;
};
const logger = new Logger({
file: "config-util.log",
@@ -47,12 +21,12 @@ const logger = new Logger({
let db: JsonDB;
reloadDB();
reloadDb();
export function getConfigItem<Key extends keyof Config>(
key: Key,
defaultValue: Config[Key],
): Config[Key] {
): z.output<typeof configSchemata[Key]> {
try {
db.reload();
} catch (error: unknown) {
@@ -60,13 +34,13 @@ export function getConfigItem<Key extends keyof Config>(
logger.error(error);
}
const value = db.getData("/")[key];
if (value === undefined) {
try {
return configSchemata[key].parse(db.getObject<unknown>(`/${key}`));
} catch (error: unknown) {
if (!(error instanceof DataError)) throw error;
setConfigItem(key, defaultValue);
return defaultValue;
}
return value;
}
// This function returns whether a key exists in the configuration file (settings.json)
@@ -78,8 +52,7 @@ export function isConfigItemExists(key: string): boolean {
logger.error(error);
}
const value = db.getData("/")[key];
return value !== undefined;
return db.exists(`/${key}`);
}
export function setConfigItem<Key extends keyof Config>(
@@ -92,6 +65,7 @@ export function setConfigItem<Key extends keyof Config>(
return;
}
configSchemata[key].parse(value);
db.push(`/${key}`, value, true);
db.save();
}
@@ -101,7 +75,7 @@ export function removeConfigItem(key: string): void {
db.save();
}
function reloadDB(): void {
function reloadDb(): void {
const settingsJsonPath = path.join(
app.getPath("userData"),
"/config/settings.json",
@@ -118,7 +92,7 @@ function reloadDB(): void {
);
logger.error("Error while JSON parsing settings.json: ");
logger.error(error);
logger.reportSentry(error);
Sentry.captureException(error);
}
}

View File

@@ -1,7 +1,6 @@
import electron from "electron";
import fs from "fs";
import fs from "node:fs";
const {app} = process.type === "renderer" ? electron.remote : electron;
import {app} from "./remote";
let setupCompleted = false;

View File

@@ -1,16 +1,21 @@
import process from "node:process";
import type * as z from "zod";
import type {dndSettingsSchemata} from "./config-schemata";
import * as ConfigUtil from "./config-util";
type SettingName = keyof DNDSettings;
export type DndSettings = {
[Key in keyof typeof dndSettingsSchemata]: z.output<
typeof dndSettingsSchemata[Key]
>;
};
export interface DNDSettings {
showNotification: boolean;
silent: boolean;
flashTaskbarOnMessage: boolean;
}
type SettingName = keyof DndSettings;
interface Toggle {
dnd: boolean;
newSettings: Partial<DNDSettings>;
newSettings: Partial<DndSettings>;
}
export function toggle(): Toggle {
@@ -20,9 +25,9 @@ export function toggle(): Toggle {
dndSettingList.push("flashTaskbarOnMessage");
}
let newSettings: Partial<DNDSettings>;
let newSettings: Partial<DndSettings>;
if (dnd) {
const oldSettings: Partial<DNDSettings> = {};
const oldSettings: Partial<DndSettings> = {};
newSettings = {};
// Iterate through the dndSettingList.

View File

@@ -1,12 +1,17 @@
import fs from "fs";
import path from "path";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import type {Config} from "./config-util";
import * as z from "zod";
import {enterpriseConfigSchemata} from "./config-schemata";
import Logger from "./logger-util";
interface EnterpriseConfig extends Config {
presetOrganizations: string[];
}
type EnterpriseConfig = {
[Key in keyof typeof enterpriseConfigSchemata]: z.output<
typeof enterpriseConfigSchemata[Key]
>;
};
const logger = new Logger({
file: "enterprise-util.log",
@@ -15,9 +20,9 @@ const logger = new Logger({
let enterpriseSettings: Partial<EnterpriseConfig>;
let configFile: boolean;
reloadDB();
reloadDb();
function reloadDB(): void {
function reloadDb(): void {
let enterpriseFile = "/etc/zulip-desktop-config/global_config.json";
if (process.platform === "win32") {
enterpriseFile =
@@ -29,7 +34,11 @@ function reloadDB(): void {
configFile = true;
try {
const file = fs.readFileSync(enterpriseFile, "utf8");
enterpriseSettings = JSON.parse(file);
const data: unknown = JSON.parse(file);
enterpriseSettings = z
.object(enterpriseConfigSchemata)
.partial()
.parse(data);
} catch (error: unknown) {
logger.log("Error while JSON parsing global_config.json: ");
logger.log(error);
@@ -47,7 +56,7 @@ export function getConfigItem<Key extends keyof EnterpriseConfig>(
key: Key,
defaultValue: EnterpriseConfig[Key],
): EnterpriseConfig[Key] {
reloadDB();
reloadDb();
if (!configFile) {
return defaultValue;
}
@@ -57,7 +66,7 @@ export function getConfigItem<Key extends keyof EnterpriseConfig>(
}
export function configItemExists(key: keyof EnterpriseConfig): boolean {
reloadDB();
reloadDb();
if (!configFile) {
return false;
}

View File

@@ -1,26 +1,26 @@
import {htmlEscape} from "escape-goat";
export class HTML {
export class Html {
html: string;
constructor({html}: {html: string}) {
this.html = html;
}
join(htmls: readonly HTML[]): HTML {
return new HTML({html: htmls.map((html) => html.html).join(this.html)});
join(htmls: readonly Html[]): Html {
return new Html({html: htmls.map((html) => html.html).join(this.html)});
}
}
export function html(
template: TemplateStringsArray,
...values: unknown[]
): HTML {
): Html {
let html = template[0];
for (const [index, value] of values.entries()) {
html += value instanceof HTML ? value.html : htmlEscape(String(value));
html += value instanceof Html ? value.html : htmlEscape(String(value));
html += template[index + 1];
}
return new HTML({html});
return new Html({html});
}

View File

@@ -1,13 +1,9 @@
import {shell} from "electron";
import fs from "fs";
import os from "os";
import path from "path";
import {shell} from "electron/common";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {html} from "../../../common/html";
export function isUploadsUrl(server: string, url: URL): boolean {
return url.origin === server && url.pathname.startsWith("/user_uploads/");
}
import {html} from "./html";
export async function openBrowser(url: URL): Promise<void> {
if (["http:", "https:", "mailto:"].includes(url.protocol)) {
@@ -19,7 +15,8 @@ export async function openBrowser(url: URL): Promise<void> {
const file = path.join(dir, "redirect.html");
fs.writeFileSync(
file,
html`<!DOCTYPE html>
html`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
@@ -34,12 +31,13 @@ export async function openBrowser(url: URL): Promise<void> {
<body>
<p>Opening <a href="${url.href}">${url.href}</a></p>
</body>
</html> `.html,
</html>
`.html,
);
await shell.openPath(file);
setTimeout(() => {
fs.unlinkSync(file);
fs.rmdirSync(dir);
}, 15000);
}, 15_000);
}
}

View File

@@ -1,12 +1,10 @@
import {Console} from "console"; // eslint-disable-line node/prefer-global/console
import electron from "electron";
import fs from "fs";
import os from "os";
import {Console} from "node:console";
import fs from "node:fs";
import os from "node:os";
import process from "node:process";
import {initSetUp} from "./default-util";
import {captureException, sentryInit} from "./sentry-util";
const {app} = process.type === "renderer" ? electron.remote : electron;
import {app} from "./remote";
interface LoggerOptions {
file?: string;
@@ -14,24 +12,6 @@ interface LoggerOptions {
initSetUp();
let reportErrors = true;
if (process.type === "renderer") {
// Report Errors to Sentry only if it is enabled in settings
// Gets the value of reportErrors from config-util for renderer process
// For main process, sentryInit() is handled in index.js
const {ipcRenderer} = electron;
ipcRenderer.send("error-reporting");
ipcRenderer.on(
"error-reporting-val",
(_event: Event, errorReporting: boolean) => {
reportErrors = errorReporting;
if (reportErrors) {
sentryInit();
}
},
);
}
const logDir = `${app.getPath("userData")}/Logs`;
type Level = "log" | "debug" | "info" | "warn" | "error";
@@ -92,22 +72,16 @@ export default class Logger {
return timestamp;
}
reportSentry(error: unknown): void {
if (reportErrors) {
captureException(error);
}
}
async trimLog(file: string): Promise<void> {
const data = await fs.promises.readFile(file, "utf8");
const MAX_LOG_FILE_LINES = 500;
const maxLogFileLines = 500;
const logs = data.split(os.EOL);
const logLength = logs.length - 1;
// Keep bottom MAX_LOG_FILE_LINES of each log instance
if (logLength > MAX_LOG_FILE_LINES) {
const trimmedLogs = logs.slice(logLength - MAX_LOG_FILE_LINES);
// Keep bottom maxLogFileLines of each log instance
if (logLength > maxLogFileLines) {
const trimmedLogs = logs.slice(logLength - maxLogFileLines);
const toWrite = trimmedLogs.join(os.EOL);
await fs.promises.writeFile(file, toWrite);
}

View File

@@ -13,11 +13,6 @@ export function invalidZulipServerError(domain: string): string {
https://zulip.readthedocs.io/en/stable/production/ssl-certificates.html`;
}
export function noOrgsError(domain: string): string {
return `${domain} does not have any organizations added.
Please contact your server administrator.`;
}
export function enterpriseOrgError(
length: number,
domains: string[],

8
app/common/remote.ts Normal file
View File

@@ -0,0 +1,8 @@
import process from "node:process";
export const {app, dialog} =
process.type === "renderer"
? // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
(require("@electron/remote") as typeof import("@electron/remote"))
: // eslint-disable-next-line @typescript-eslint/no-require-imports
require("electron/main");

View File

@@ -1,20 +0,0 @@
import electron from "electron";
import {init} from "@sentry/electron";
const {app} = process.type === "renderer" ? electron.remote : electron;
export const sentryInit = (): void => {
if (app.isPackaged) {
init({
dsn: "https://628dc2f2864243a08ead72e63f94c7b1@sentry.io/204668",
// We should ignore this error since it's harmless and we know the reason behind this
// This error mainly comes from the console logs.
// This is a temp solution until Sentry supports disabling the console logs
ignoreErrors: ["does not appear to be a valid Zulip server"],
/// sendTimeout: 30 // wait 30 seconds before considering the sending capture to have failed, default is 1 second
});
}
};
export {captureException} from "@sentry/electron";

View File

@@ -1,4 +1,4 @@
import path from "path";
import path from "node:path";
import i18n from "i18n";

View File

@@ -1,19 +1,18 @@
import type {DNDSettings} from "./dnd-util";
import type {MenuProps, NavItem, ServerConf} from "./types";
import type {DndSettings} from "./dnd-util";
import type {MenuProps, ServerConf} from "./types";
export interface MainMessage {
"clear-app-settings": () => void;
downloadFile: (url: string, downloadPath: string) => void;
"error-reporting": () => void;
"configure-spell-checker": () => void;
"fetch-user-agent": () => string;
"focus-app": () => void;
"focus-this-webview": () => void;
"permission-callback": (permissionCallbackId: number, grant: boolean) => void;
"quit-app": () => void;
"realm-icon-changed": (serverURL: string, iconURL: string) => void;
"realm-name-changed": (serverURL: string, realmName: string) => void;
"reload-full-app": () => void;
"save-last-tab": (index: number) => void;
"set-spellcheck-langs": () => void;
"switch-server-tab": (index: number) => void;
"toggle-app": () => void;
"toggle-badge-option": (newValue: boolean) => void;
@@ -35,10 +34,7 @@ export interface RendererMessage {
back: () => void;
"copy-zulip-url": () => void;
destroytray: () => void;
downloadFileCompleted: (filePath: string, fileName: string) => void;
downloadFileFailed: (state: string) => void;
"enter-fullscreen": () => void;
"error-reporting-val": (errorReporting: boolean) => void;
focus: () => void;
"focus-webview-with-id": (webviewId: number) => void;
forward: () => void;
@@ -48,7 +44,6 @@ export interface RendererMessage {
logout: () => void;
"new-server": () => void;
"open-about": () => void;
"open-feedback-modal": () => void;
"open-help": () => void;
"open-network-settings": () => void;
"open-org-tab": () => void;
@@ -57,6 +52,7 @@ export interface RendererMessage {
options: {webContentsId: number | null; origin: string; permission: string},
rendererCallbackId: number,
) => void;
"play-ding-sound": () => void;
"reload-current-viewer": () => void;
"reload-proxy": (showAlert: boolean) => void;
"reload-viewer": () => void;
@@ -64,19 +60,15 @@ export interface RendererMessage {
"set-active": () => void;
"set-idle": () => void;
"show-keyboard-shortcuts": () => void;
"show-network-error": (index: number) => void;
"show-notification-settings": () => void;
"switch-server-tab": (index: number) => void;
"switch-settings-nav": (navItem: NavItem) => void;
"tab-devtools": () => void;
"toggle-autohide-menubar": (
autoHideMenubar: boolean,
updateMenu: boolean,
) => void;
"toggle-dnd": (state: boolean, newSettings: Partial<DNDSettings>) => void;
"toggle-menubar-setting": (state: boolean) => void;
"toggle-dnd": (state: boolean, newSettings: Partial<DndSettings>) => void;
"toggle-sidebar": (show: boolean) => void;
"toggle-sidebar-setting": (state: boolean) => void;
"toggle-silent": (state: boolean) => void;
"toggle-tray": (state: boolean) => void;
toggletray: () => void;

View File

@@ -17,9 +17,10 @@ export interface ServerConf {
icon: string;
}
export type TabRole = "server" | "function";
export interface TabData {
role: string;
role: TabRole;
name: string;
index: number;
webviewName: string;
}

View File

@@ -1,5 +1,7 @@
import {app, dialog, session, shell} from "electron";
import util from "util";
import {shell} from "electron/common";
import {app, dialog, session} from "electron/main";
import process from "node:process";
import util from "node:util";
import log from "electron-log";
import type {UpdateDownloadedEvent, UpdateInfo} from "electron-updater";
@@ -25,11 +27,8 @@ export async function appUpdater(updateFromMenu = false): Promise<void> {
let updateAvailable = false;
// Create Logs directory
const LogsDir = `${app.getPath("userData")}/Logs`;
// Log whats happening
log.transports.file.file = `${LogsDir}/updates.log`;
log.transports.file.fileName = "updates.log";
log.transports.file.level = "info";
autoUpdater.logger = log;

View File

@@ -1,13 +1,13 @@
import electron, {app} from "electron";
import {nativeImage} from "electron/common";
import type {BrowserWindow} from "electron/main";
import {app} from "electron/main";
import process from "node:process";
import * as ConfigUtil from "../common/config-util";
import {send} from "./typed-ipc-main";
function showBadgeCount(
messageCount: number,
mainWindow: electron.BrowserWindow,
): void {
function showBadgeCount(messageCount: number, mainWindow: BrowserWindow): void {
if (process.platform === "win32") {
updateOverlayIcon(messageCount, mainWindow);
} else {
@@ -15,7 +15,7 @@ function showBadgeCount(
}
}
function hideBadgeCount(mainWindow: electron.BrowserWindow): void {
function hideBadgeCount(mainWindow: BrowserWindow): void {
if (process.platform === "win32") {
mainWindow.setOverlayIcon(null, "");
} else {
@@ -25,7 +25,7 @@ function hideBadgeCount(mainWindow: electron.BrowserWindow): void {
export function updateBadge(
badgeCount: number,
mainWindow: electron.BrowserWindow,
mainWindow: BrowserWindow,
): void {
if (ConfigUtil.getConfigItem("badgeOption", true)) {
showBadgeCount(badgeCount, mainWindow);
@@ -36,7 +36,7 @@ export function updateBadge(
function updateOverlayIcon(
messageCount: number,
mainWindow: electron.BrowserWindow,
mainWindow: BrowserWindow,
): void {
if (!mainWindow.isFocused()) {
mainWindow.flashFrame(
@@ -54,8 +54,8 @@ function updateOverlayIcon(
export function updateTaskbarIcon(
data: string,
text: string,
mainWindow: electron.BrowserWindow,
mainWindow: BrowserWindow,
): void {
const img = electron.nativeImage.createFromDataURL(data);
const img = nativeImage.createFromDataURL(data);
mainWindow.setOverlayIcon(img, text);
}

View File

@@ -0,0 +1,157 @@
import {shell} from "electron/common";
import type {
HandlerDetails,
SaveDialogOptions,
WebContents,
} from "electron/main";
import {Notification, app} from "electron/main";
import fs from "node:fs";
import path from "node:path";
import * as ConfigUtil from "../common/config-util";
import * as LinkUtil from "../common/link-util";
import {send} from "./typed-ipc-main";
function isUploadsUrl(server: string, url: URL): boolean {
return url.origin === server && url.pathname.startsWith("/user_uploads/");
}
function downloadFile({
contents,
url,
downloadPath,
completed,
failed,
}: {
contents: WebContents;
url: string;
downloadPath: string;
completed(filePath: string, fileName: string): Promise<void>;
failed(state: string): void;
}) {
contents.downloadURL(url);
contents.session.once("will-download", async (_event: Event, item) => {
if (ConfigUtil.getConfigItem("promptDownload", false)) {
const showDialogOptions: SaveDialogOptions = {
defaultPath: path.join(downloadPath, item.getFilename()),
};
item.setSaveDialogOptions(showDialogOptions);
} else {
const getTimeStamp = (): number => {
const date = new Date();
return date.getTime();
};
const formatFile = (filePath: string): string => {
const fileExtension = path.extname(filePath);
const baseName = path.basename(filePath, fileExtension);
return `${baseName}-${getTimeStamp()}${fileExtension}`;
};
const filePath = path.join(downloadPath, item.getFilename());
// Update the name and path of the file if it already exists
const updatedFilePath = path.join(downloadPath, formatFile(filePath));
const setFilePath: string = fs.existsSync(filePath)
? updatedFilePath
: filePath;
item.setSavePath(setFilePath);
}
const updatedListener = (_event: Event, state: string): void => {
switch (state) {
case "interrupted": {
// Can interrupted to due to network error, cancel download then
console.log(
"Download interrupted, cancelling and fallback to dialog download.",
);
item.cancel();
break;
}
case "progressing": {
if (item.isPaused()) {
item.cancel();
}
// This event can also be used to show progress in percentage in future.
break;
}
default: {
console.info("Unknown updated state of download item");
}
}
};
item.on("updated", updatedListener);
item.once("done", async (_event: Event, state) => {
if (state === "completed") {
await completed(item.getSavePath(), path.basename(item.getSavePath()));
} else {
console.log("Download failed state:", state);
failed(state);
}
// To stop item for listening to updated events of this file
item.removeListener("updated", updatedListener);
});
});
}
export default function handleExternalLink(
contents: WebContents,
details: HandlerDetails,
mainContents: WebContents,
): void {
const url = new URL(details.url);
const downloadPath = ConfigUtil.getConfigItem(
"downloadsPath",
`${app.getPath("downloads")}`,
);
if (isUploadsUrl(new URL(contents.getURL()).origin, url)) {
downloadFile({
contents,
url: url.href,
downloadPath,
async completed(filePath: string, fileName: string) {
const downloadNotification = new Notification({
title: "Download Complete",
body: `Click to show ${fileName} in folder`,
silent: true, // We'll play our own sound - ding.ogg
});
downloadNotification.on("click", () => {
// Reveal file in download folder
shell.showItemInFolder(filePath);
});
downloadNotification.show();
// Play sound to indicate download complete
if (!ConfigUtil.getConfigItem("silent", false)) {
send(mainContents, "play-ding-sound");
}
},
failed(state: string) {
// Automatic download failed, so show save dialog prompt and download
// through webview
// Only do this if it is the automatic download, otherwise show an error (so we aren't showing two save
// prompts right after each other)
// Check that the download is not cancelled by user
if (state !== "cancelled") {
if (ConfigUtil.getConfigItem("promptDownload", false)) {
new Notification({
title: "Download Complete",
body: "Download failed",
}).show();
} else {
contents.downloadURL(url.href);
}
}
},
});
} else {
(async () => LinkUtil.openBrowser(url))();
}
}

View File

@@ -1,42 +1,48 @@
import electron, {app, dialog, session} from "electron";
import fs from "fs";
import path from "path";
import type {IpcMainEvent, WebContents} from "electron/main";
import {BrowserWindow, app, dialog, powerMonitor, session} from "electron/main";
import path from "node:path";
import process from "node:process";
import * as remoteMain from "@electron/remote/main";
import windowStateKeeper from "electron-window-state";
import * as ConfigUtil from "../common/config-util";
import {sentryInit} from "../common/sentry-util";
import type {RendererMessage} from "../common/typed-ipc";
import type {MenuProps} from "../common/types";
import {appUpdater} from "./autoupdater";
import * as BadgeSettings from "./badge-settings";
import handleExternalLink from "./handle-external-link";
import * as AppMenu from "./menu";
import * as ProxyUtil from "./proxy-util";
import {_getServerSettings, _isOnline, _saveServerIcon} from "./request";
import {sentryInit} from "./sentry";
import {setAutoLaunch} from "./startup";
import {ipcMain, send} from "./typed-ipc-main";
// eslint-disable-next-line @typescript-eslint/naming-convention
const {GDK_BACKEND} = process.env;
// Initialize sentry for main process
sentryInit();
let mainWindowState: windowStateKeeper.State;
// Prevent window being garbage collected
let mainWindow: Electron.BrowserWindow;
let mainWindow: BrowserWindow;
let badgeCount: number;
let isQuitting = false;
// Load this url in main window
const mainURL = "file://" + path.join(__dirname, "../renderer", "main.html");
const mainUrl = "file://" + path.join(__dirname, "../renderer", "main.html");
const permissionCallbacks = new Map();
const permissionCallbacks = new Map<number, (grant: boolean) => void>();
let nextPermissionCallbackId = 0;
const APP_ICON = path.join(__dirname, "../resources", "Icon");
const appIcon = path.join(__dirname, "../resources", "Icon");
const iconPath = (): string =>
APP_ICON + (process.platform === "win32" ? ".ico" : ".png");
appIcon + (process.platform === "win32" ? ".ico" : ".png");
// Toggle the app window
const toggleApp = (): void => {
@@ -47,7 +53,7 @@ const toggleApp = (): void => {
}
};
function createMainWindow(): Electron.BrowserWindow {
function createMainWindow(): BrowserWindow {
// Load the previous state with fallback to defaults
mainWindowState = windowStateKeeper({
defaultWidth: 1100,
@@ -55,7 +61,7 @@ function createMainWindow(): Electron.BrowserWindow {
path: `${app.getPath("userData")}/config`,
});
const win = new electron.BrowserWindow({
const win = new BrowserWindow({
// This settings needs to be saved in config
title: "Zulip",
icon: iconPath(),
@@ -66,21 +72,18 @@ function createMainWindow(): Electron.BrowserWindow {
minWidth: 500,
minHeight: 400,
webPreferences: {
contextIsolation: false,
enableRemoteModule: true,
nodeIntegration: true,
partition: "persist:webviewsession",
preload: require.resolve("../renderer/js/main"),
webviewTag: true,
worldSafeExecuteJavaScript: true,
},
show: false,
});
remoteMain.enable(win.webContents);
win.on("focus", () => {
send(win.webContents, "focus");
});
(async () => win.loadURL(mainURL))();
(async () => win.loadURL(mainUrl))();
// Keep the app running in background on close event
win.on("close", (event) => {
@@ -124,10 +127,6 @@ function createMainWindow(): Electron.BrowserWindow {
return win;
}
// Temporary fix for Electron render colors differently
// More info here - https://github.com/electron/electron/issues/10732
app.commandLine.appendSwitch("force-color-profile", "srgb");
(async () => {
if (!app.requestSingleInstanceLock()) {
app.quit();
@@ -147,6 +146,11 @@ app.commandLine.appendSwitch("force-color-profile", "srgb");
}
}
// Used for notifications on Windows
app.setAppUserModelId("org.zulip.zulip-electron");
remoteMain.initialize();
app.on("second-instance", () => {
if (mainWindow) {
if (mainWindow.isMinimized()) {
@@ -160,29 +164,40 @@ app.commandLine.appendSwitch("force-color-profile", "srgb");
ipcMain.on(
"permission-callback",
(event: Event, permissionCallbackId: number, grant: boolean) => {
permissionCallbacks.get(permissionCallbackId)(grant);
permissionCallbacks.get(permissionCallbackId)?.(grant);
permissionCallbacks.delete(permissionCallbackId);
},
);
// This event is only available on macOS. Triggers when you click on the dock icon.
app.on("activate", () => {
if (mainWindow) {
// If there is already a window show it
mainWindow.show();
} else {
mainWindow = createMainWindow();
}
mainWindow.show();
});
app.on("web-contents-created", (_event: Event, contents: WebContents) => {
contents.setWindowOpenHandler((details) => {
handleExternalLink(contents, details, page);
return {action: "deny"};
});
});
const ses = session.fromPartition("persist:webviewsession");
ses.setUserAgent(`ZulipElectron/${app.getVersion()} ${ses.getUserAgent()}`);
ipcMain.on("set-spellcheck-langs", () => {
ses.setSpellCheckerLanguages(
ConfigUtil.getConfigItem("spellcheckerLanguages", null) ?? [],
);
});
function configureSpellChecker() {
const enable = ConfigUtil.getConfigItem("enableSpellchecker", true);
if (enable && process.platform !== "darwin") {
ses.setSpellCheckerLanguages(
ConfigUtil.getConfigItem("spellcheckerLanguages", null) ?? [],
);
}
ses.setSpellCheckerEnabled(enable);
}
configureSpellChecker();
ipcMain.on("configure-spell-checker", configureSpellChecker);
AppMenu.setMenu({
tabs: [],
});
@@ -195,18 +210,6 @@ app.commandLine.appendSwitch("force-color-profile", "srgb");
mainWindow.setMenuBarVisibility(!shouldHideMenu);
}
// Initialize sentry for main process
const errorReporting = ConfigUtil.getConfigItem("errorReporting", true);
if (errorReporting) {
sentryInit();
}
const isSystemProxy = ConfigUtil.getConfigItem("useSystemProxy", false);
if (isSystemProxy) {
(async () => ProxyUtil.resolveSystemProxy(mainWindow))();
}
const page = mainWindow.webContents;
page.on("dom-ready", () => {
@@ -246,7 +249,7 @@ app.commandLine.appendSwitch("force-color-profile", "srgb");
"certificate-error",
(
event: Event,
webContents: Electron.WebContents,
webContents: WebContents,
urlString: string,
error: string,
) => {
@@ -260,7 +263,7 @@ ${error}`,
},
);
page.session.setPermissionRequestHandler(
ses.setPermissionRequestHandler(
(webContents, permission, callback, details) => {
const {origin} = new URL(details.requestingUrl);
const permissionCallbackId = nextPermissionCallbackId++;
@@ -282,7 +285,7 @@ ${error}`,
);
// Temporarily remove this event
// electron.powerMonitor.on('resume', () => {
// powerMonitor.on('resume', () => {
// mainWindow.reload();
// send(page, 'destroytray');
// });
@@ -315,27 +318,21 @@ ${error}`,
BadgeSettings.updateBadge(badgeCount, mainWindow);
});
ipcMain.on(
"toggle-menubar",
(_event: Electron.IpcMainEvent, showMenubar: boolean) => {
mainWindow.autoHideMenuBar = showMenubar;
mainWindow.setMenuBarVisibility(!showMenubar);
send(page, "toggle-autohide-menubar", showMenubar, true);
},
);
ipcMain.on("toggle-menubar", (_event: IpcMainEvent, showMenubar: boolean) => {
mainWindow.autoHideMenuBar = showMenubar;
mainWindow.setMenuBarVisibility(!showMenubar);
send(page, "toggle-autohide-menubar", showMenubar, true);
});
ipcMain.on(
"update-badge",
(_event: Electron.IpcMainEvent, messageCount: number) => {
badgeCount = messageCount;
BadgeSettings.updateBadge(badgeCount, mainWindow);
send(page, "tray", messageCount);
},
);
ipcMain.on("update-badge", (_event: IpcMainEvent, messageCount: number) => {
badgeCount = messageCount;
BadgeSettings.updateBadge(badgeCount, mainWindow);
send(page, "tray", messageCount);
});
ipcMain.on(
"update-taskbar-icon",
(_event: Electron.IpcMainEvent, data: string, text: string) => {
(_event: IpcMainEvent, data: string, text: string) => {
BadgeSettings.updateTaskbarIcon(data, text, mainWindow);
},
);
@@ -343,7 +340,7 @@ ${error}`,
ipcMain.on(
"forward-message",
<Channel extends keyof RendererMessage>(
_event: Electron.IpcMainEvent,
_event: IpcMainEvent,
listener: Channel,
...parameters: Parameters<RendererMessage[Channel]>
) => {
@@ -351,138 +348,50 @@ ${error}`,
},
);
ipcMain.on(
"update-menu",
(_event: Electron.IpcMainEvent, props: MenuProps) => {
AppMenu.setMenu(props);
if (props.activeTabIndex !== undefined) {
const activeTab = props.tabs[props.activeTabIndex];
mainWindow.setTitle(`Zulip - ${activeTab.webviewName}`);
}
},
);
ipcMain.on("update-menu", (_event: IpcMainEvent, props: MenuProps) => {
AppMenu.setMenu(props);
if (props.activeTabIndex !== undefined) {
const activeTab = props.tabs[props.activeTabIndex];
mainWindow.setTitle(`Zulip - ${activeTab.name}`);
}
});
ipcMain.on(
"toggleAutoLauncher",
async (_event: Electron.IpcMainEvent, AutoLaunchValue: boolean) => {
async (_event: IpcMainEvent, AutoLaunchValue: boolean) => {
await setAutoLaunch(AutoLaunchValue);
},
);
ipcMain.on(
"downloadFile",
(_event: Electron.IpcMainEvent, url: string, downloadPath: string) => {
page.downloadURL(url);
page.session.once("will-download", async (_event: Event, item) => {
if (ConfigUtil.getConfigItem("promptDownload", false)) {
const showDialogOptions: electron.SaveDialogOptions = {
defaultPath: path.join(downloadPath, item.getFilename()),
};
item.setSaveDialogOptions(showDialogOptions);
} else {
const getTimeStamp = (): number => {
const date = new Date();
return date.getTime();
};
const formatFile = (filePath: string): string => {
const fileExtension = path.extname(filePath);
const baseName = path.basename(filePath, fileExtension);
return `${baseName}-${getTimeStamp()}${fileExtension}`;
};
const filePath = path.join(downloadPath, item.getFilename());
// Update the name and path of the file if it already exists
const updatedFilePath = path.join(downloadPath, formatFile(filePath));
const setFilePath: string = fs.existsSync(filePath)
? updatedFilePath
: filePath;
item.setSavePath(setFilePath);
}
const updatedListener = (_event: Event, state: string): void => {
switch (state) {
case "interrupted": {
// Can interrupted to due to network error, cancel download then
console.log(
"Download interrupted, cancelling and fallback to dialog download.",
);
item.cancel();
break;
}
case "progressing": {
if (item.isPaused()) {
item.cancel();
}
// This event can also be used to show progress in percentage in future.
break;
}
default: {
console.info("Unknown updated state of download item");
}
}
};
item.on("updated", updatedListener);
item.once("done", (_event: Event, state) => {
if (state === "completed") {
send(
page,
"downloadFileCompleted",
item.getSavePath(),
path.basename(item.getSavePath()),
);
} else {
console.log("Download failed state:", state);
send(page, "downloadFileFailed", state);
}
// To stop item for listening to updated events of this file
item.removeListener("updated", updatedListener);
});
});
},
);
ipcMain.on(
"realm-name-changed",
(_event: Electron.IpcMainEvent, serverURL: string, realmName: string) => {
(_event: IpcMainEvent, serverURL: string, realmName: string) => {
send(page, "update-realm-name", serverURL, realmName);
},
);
ipcMain.on(
"realm-icon-changed",
(_event: Electron.IpcMainEvent, serverURL: string, iconURL: string) => {
(_event: IpcMainEvent, serverURL: string, iconURL: string) => {
send(page, "update-realm-icon", serverURL, iconURL);
},
);
// Using event.sender.send instead of page.send here to
// make sure the value of errorReporting is sent only once on load.
ipcMain.on("error-reporting", (event: Electron.IpcMainEvent) => {
send(event.sender, "error-reporting-val", errorReporting);
ipcMain.on("save-last-tab", (_event: IpcMainEvent, index: number) => {
ConfigUtil.setConfigItem("lastActiveTab", index);
});
ipcMain.on(
"save-last-tab",
(_event: Electron.IpcMainEvent, index: number) => {
ConfigUtil.setConfigItem("lastActiveTab", index);
},
);
ipcMain.on("focus-this-webview", (event: IpcMainEvent) => {
send(page, "focus-webview-with-id", event.sender.id);
mainWindow.show();
});
// Update user idle status for each realm after every 15s
const idleCheckInterval = 15 * 1000; // 15 seconds
setInterval(() => {
// Set user idle if no activity in 1 second (idleThresholdSeconds)
const idleThresholdSeconds = 1; // 1 second
const idleState = electron.powerMonitor.getSystemIdleState(
idleThresholdSeconds,
);
const idleState = powerMonitor.getSystemIdleState(idleThresholdSeconds);
if (idleState === "active") {
send(page, "set-active");
} else {

View File

@@ -1,8 +1,9 @@
import {app, dialog} from "electron";
import fs from "fs";
import path from "path";
import {app, dialog} from "electron/main";
import fs from "node:fs";
import path from "node:path";
import {JsonDB} from "node-json-db";
import {DataError} from "node-json-db/dist/lib/Errors";
import Logger from "../common/logger-util";
@@ -12,12 +13,21 @@ const logger = new Logger({
let db: JsonDB;
reloadDB();
reloadDb();
export function getUpdateItem(key: string, defaultValue: unknown = null): any {
reloadDB();
const value = db.getData("/")[key];
if (value === undefined) {
export function getUpdateItem(
key: string,
defaultValue: true | null = null,
): true | null {
reloadDb();
let value: unknown;
try {
value = db.getObject<unknown>(`/${key}`);
} catch (error: unknown) {
if (!(error instanceof DataError)) throw error;
}
if (value !== true && value !== null) {
setUpdateItem(key, defaultValue);
return defaultValue;
}
@@ -25,17 +35,17 @@ export function getUpdateItem(key: string, defaultValue: unknown = null): any {
return value;
}
export function setUpdateItem(key: string, value: unknown): void {
export function setUpdateItem(key: string, value: true | null): void {
db.push(`/${key}`, value, true);
reloadDB();
reloadDb();
}
export function removeUpdateItem(key: string): void {
db.delete(`/${key}`);
reloadDB();
reloadDb();
}
function reloadDB(): void {
function reloadDb(): void {
const linuxUpdateJsonPath = path.join(
app.getPath("userData"),
"/config/updates.json",

View File

@@ -1,7 +1,9 @@
import {Notification, app, net} from "electron";
import type {Session} from "electron/main";
import {Notification, app, net} from "electron/main";
import getStream from "get-stream";
import * as semver from "semver";
import * as z from "zod";
import * as ConfigUtil from "../common/config-util";
import Logger from "../common/logger-util";
@@ -13,9 +15,7 @@ const logger = new Logger({
file: "linux-update-util.log",
});
export async function linuxUpdateNotification(
session: Electron.session,
): Promise<void> {
export async function linuxUpdateNotification(session: Session): Promise<void> {
let url = "https://api.github.com/repos/zulip/zulip-desktop/releases";
url = ConfigUtil.getConfigItem("betaUpdate", false) ? url : url + "/latest";
@@ -26,13 +26,12 @@ export async function linuxUpdateNotification(
return;
}
const data = JSON.parse(await getStream(response));
const data: unknown = JSON.parse(await getStream(response));
/* eslint-disable @typescript-eslint/naming-convention */
const latestVersion = ConfigUtil.getConfigItem("betaUpdate", false)
? data[0].tag_name
: data.tag_name;
if (typeof latestVersion !== "string") {
throw new TypeError("Expected string for tag_name");
}
? z.array(z.object({tag_name: z.string()})).parse(data)[0].tag_name
: z.object({tag_name: z.string()}).parse(data).tag_name;
/* eslint-enable @typescript-eslint/naming-convention */
if (semver.gt(latestVersion, app.getVersion())) {
const notified = LinuxUpdateUtil.getUpdateItem(latestVersion);

View File

@@ -1,4 +1,7 @@
import {BrowserWindow, Menu, app, shell} from "electron";
import {shell} from "electron/common";
import type {MenuItemConstructorOptions} from "electron/main";
import {BrowserWindow, Menu, app} from "electron/main";
import process from "node:process";
import AdmZip from "adm-zip";
@@ -13,9 +16,7 @@ import {send} from "./typed-ipc-main";
const appName = app.name;
function getHistorySubmenu(
enableMenu: boolean,
): Electron.MenuItemConstructorOptions[] {
function getHistorySubmenu(enableMenu: boolean): MenuItemConstructorOptions[] {
return [
{
label: t.__("Back"),
@@ -41,7 +42,7 @@ function getHistorySubmenu(
];
}
function getToolsSubmenu(): Electron.MenuItemConstructorOptions[] {
function getToolsSubmenu(): MenuItemConstructorOptions[] {
return [
{
label: t.__("Check for Updates"),
@@ -107,7 +108,7 @@ function getToolsSubmenu(): Electron.MenuItemConstructorOptions[] {
];
}
function getViewSubmenu(): Electron.MenuItemConstructorOptions[] {
function getViewSubmenu(): MenuItemConstructorOptions[] {
return [
{
label: t.__("Reload"),
@@ -256,7 +257,7 @@ function getViewSubmenu(): Electron.MenuItemConstructorOptions[] {
];
}
function getHelpSubmenu(): Electron.MenuItemConstructorOptions[] {
function getHelpSubmenu(): MenuItemConstructorOptions[] {
return [
{
label: `${appName + " Desktop"} v${app.getVersion()}`,
@@ -280,12 +281,8 @@ function getHelpSubmenu(): Electron.MenuItemConstructorOptions[] {
},
{
label: t.__("Report an Issue"),
click() {
// The goal is to notify the main.html BrowserWindow
// which may not be the focused window.
for (const window of BrowserWindow.getAllWindows()) {
send(window.webContents, "open-feedback-modal");
}
async click() {
await shell.openExternal("https://zulip.com/help/contact-support");
},
},
];
@@ -294,8 +291,8 @@ function getHelpSubmenu(): Electron.MenuItemConstructorOptions[] {
function getWindowSubmenu(
tabs: TabData[],
activeTabIndex?: number,
): Electron.MenuItemConstructorOptions[] {
const initialSubmenu: Electron.MenuItemConstructorOptions[] = [
): MenuItemConstructorOptions[] {
const initialSubmenu: MenuItemConstructorOptions[] = [
{
label: t.__("Minimize"),
role: "minimize",
@@ -307,11 +304,15 @@ function getWindowSubmenu(
];
if (tabs.length > 0) {
const ShortcutKey = process.platform === "darwin" ? "Cmd" : "Ctrl";
const shortcutKey = process.platform === "darwin" ? "Cmd" : "Ctrl";
initialSubmenu.push({
type: "separator",
});
for (const tab of tabs) {
// Skip missing elements left by `delete this.tabs[index]` in
// ServerManagerView.
if (tab === undefined) continue;
// Do not add functional tab settings to list of windows in menu bar
if (tab.role === "function" && tab.name === "Settings") {
continue;
@@ -320,7 +321,7 @@ function getWindowSubmenu(
initialSubmenu.push({
label: tab.name,
accelerator:
tab.role === "function" ? "" : `${ShortcutKey} + ${tab.index + 1}`,
tab.role === "function" ? "" : `${shortcutKey} + ${tab.index + 1}`,
checked: tab.index === activeTabIndex,
click(_item, focusedWindow) {
if (focusedWindow) {
@@ -367,7 +368,7 @@ function getWindowSubmenu(
return initialSubmenu;
}
function getDarwinTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] {
function getDarwinTpl(props: MenuProps): MenuItemConstructorOptions[] {
const {tabs, activeTabIndex, enableMenu = false} = props;
return [
@@ -425,7 +426,6 @@ function getDarwinTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] {
},
{
label: t.__("Log Out of Organization"),
accelerator: "Cmd+L",
enabled: enableMenu,
click(_item, focusedWindow) {
if (focusedWindow) {
@@ -533,7 +533,7 @@ function getDarwinTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] {
];
}
function getOtherTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] {
function getOtherTpl(props: MenuProps): MenuItemConstructorOptions[] {
const {tabs, activeTabIndex, enableMenu = false} = props;
return [
{
@@ -593,7 +593,6 @@ function getOtherTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] {
},
{
label: t.__("Log Out of Organization"),
accelerator: "Ctrl+L",
enabled: enableMenu,
click(_item, focusedWindow) {
if (focusedWindow) {
@@ -702,7 +701,7 @@ async function checkForUpdate(): Promise<void> {
function getNextServer(tabs: TabData[], activeTabIndex: number): number {
do {
activeTabIndex = (activeTabIndex + 1) % tabs.length;
} while (tabs[activeTabIndex].role !== "server");
} while (tabs[activeTabIndex]?.role !== "server");
return activeTabIndex;
}
@@ -710,7 +709,7 @@ function getNextServer(tabs: TabData[], activeTabIndex: number): number {
function getPreviousServer(tabs: TabData[], activeTabIndex: number): number {
do {
activeTabIndex = (activeTabIndex - 1 + tabs.length) % tabs.length;
} while (tabs[activeTabIndex].role !== "server");
} while (tabs[activeTabIndex]?.role !== "server");
return activeTabIndex;
}

View File

@@ -1,87 +0,0 @@
import * as ConfigUtil from "../common/config-util";
export interface ProxyRule {
hostname?: string;
port?: number;
}
// TODO: Refactor to async function
export async function resolveSystemProxy(
mainWindow: Electron.BrowserWindow,
): Promise<void> {
const page = mainWindow.webContents;
const ses = page.session;
const resolveProxyUrl = "www.example.com";
// Check HTTP Proxy
const httpProxy = (async () => {
const proxy = await ses.resolveProxy("http://" + resolveProxyUrl);
let httpString = "";
if (
proxy !== "DIRECT" &&
(proxy.includes("PROXY") || proxy.includes("HTTPS"))
) {
// In case of proxy HTTPS url:port, windows gives first word as HTTPS while linux gives PROXY
// for all other HTTP or direct url:port both uses PROXY
httpString = "http=" + proxy.split("PROXY")[1] + ";";
}
return httpString;
})();
// Check HTTPS Proxy
const httpsProxy = (async () => {
const proxy = await ses.resolveProxy("https://" + resolveProxyUrl);
let httpsString = "";
if (
(proxy !== "DIRECT" || proxy.includes("HTTPS")) &&
(proxy.includes("PROXY") || proxy.includes("HTTPS"))
) {
// In case of proxy HTTPS url:port, windows gives first word as HTTPS while linux gives PROXY
// for all other HTTP or direct url:port both uses PROXY
httpsString += "https=" + proxy.split("PROXY")[1] + ";";
}
return httpsString;
})();
// Check FTP Proxy
const ftpProxy = (async () => {
const proxy = await ses.resolveProxy("ftp://" + resolveProxyUrl);
let ftpString = "";
if (proxy !== "DIRECT" && proxy.includes("PROXY")) {
ftpString += "ftp=" + proxy.split("PROXY")[1] + ";";
}
return ftpString;
})();
// Check SOCKS Proxy
const socksProxy = (async () => {
const proxy = await ses.resolveProxy("socks4://" + resolveProxyUrl);
let socksString = "";
if (proxy !== "DIRECT") {
if (proxy.includes("SOCKS5")) {
socksString += "socks=" + proxy.split("SOCKS5")[1] + ";";
} else if (proxy.includes("SOCKS4")) {
socksString += "socks=" + proxy.split("SOCKS4")[1] + ";";
} else if (proxy.includes("PROXY")) {
socksString += "socks=" + proxy.split("PROXY")[1] + ";";
}
}
return socksString;
})();
const values = await Promise.all([
httpProxy,
httpsProxy,
ftpProxy,
socksProxy,
]);
const proxyString = values.join("");
ConfigUtil.setConfigItem("systemProxyRules", proxyString);
const useSystemProxy = ConfigUtil.getConfigItem("useSystemProxy", false);
if (useSystemProxy) {
ConfigUtil.setConfigItem("proxyRules", proxyString);
}
}

View File

@@ -1,11 +1,13 @@
import type {ClientRequest, IncomingMessage} from "electron";
import {app, net} from "electron";
import fs from "fs";
import path from "path";
import stream from "stream";
import util from "util";
import type {ClientRequest, IncomingMessage, Session} from "electron/main";
import {app, net} from "electron/main";
import fs from "node:fs";
import path from "node:path";
import stream from "node:stream";
import util from "node:util";
import * as Sentry from "@sentry/electron";
import getStream from "get-stream";
import * as z from "zod";
import Logger from "../common/logger-util";
import * as Messages from "../common/messages";
@@ -42,6 +44,7 @@ const generateFilePath = (url: string): string => {
let {length} = url;
while (length) {
// eslint-disable-next-line no-bitwise, unicorn/prefer-code-point
hash = (hash * 33) ^ url.charCodeAt(--length);
}
@@ -50,12 +53,13 @@ const generateFilePath = (url: string): string => {
fs.mkdirSync(dir);
}
// eslint-disable-next-line no-bitwise
return `${dir}/${hash >>> 0}${extension}`;
};
export const _getServerSettings = async (
domain: string,
session: Electron.session,
session: Session,
): Promise<ServerConf> => {
const response = await fetchResponse(
net.request({
@@ -67,16 +71,16 @@ export const _getServerSettings = async (
throw new Error(Messages.invalidZulipServerError(domain));
}
const {realm_name, realm_uri, realm_icon} = JSON.parse(
await getStream(response),
);
if (
typeof realm_name !== "string" ||
typeof realm_uri !== "string" ||
typeof realm_icon !== "string"
) {
throw new TypeError(Messages.noOrgsError(domain));
}
const data: unknown = JSON.parse(await getStream(response));
/* eslint-disable @typescript-eslint/naming-convention */
const {realm_name, realm_uri, realm_icon} = z
.object({
realm_name: z.string(),
realm_uri: z.string(),
realm_icon: z.string(),
})
.parse(data);
/* eslint-enable @typescript-eslint/naming-convention */
return {
// Some Zulip Servers use absolute URL for server icon whereas others use relative URL
@@ -89,7 +93,7 @@ export const _getServerSettings = async (
export const _saveServerIcon = async (
url: string,
session: Electron.session,
session: Session,
): Promise<string> => {
try {
const response = await fetchResponse(net.request({url, session}));
@@ -104,7 +108,7 @@ export const _saveServerIcon = async (
} catch (error: unknown) {
logger.log("Could not get server icon.");
logger.log(error);
logger.reportSentry(error);
Sentry.captureException(error);
return defaultIconUrl;
}
};
@@ -113,7 +117,7 @@ export const _saveServerIcon = async (
export const _isOnline = async (
url: string,
session: Electron.session,
session: Session,
): Promise<boolean> => {
try {
const response = await fetchResponse(

22
app/main/sentry.ts Normal file
View File

@@ -0,0 +1,22 @@
import {app} from "electron/main";
import * as Sentry from "@sentry/electron";
import {getConfigItem} from "../common/config-util";
export const sentryInit = (): void => {
Sentry.init({
dsn: "https://628dc2f2864243a08ead72e63f94c7b1@sentry.io/204668",
// Don't report errors in development or if disabled by the user.
beforeSend: (event) =>
app.isPackaged && getConfigItem("errorReporting", true) ? event : null,
// We should ignore this error since it's harmless and we know the reason behind this
// This error mainly comes from the console logs.
// This is a temp solution until Sentry supports disabling the console logs
ignoreErrors: ["does not appear to be a valid Zulip server"],
/// sendTimeout: 30 // wait 30 seconds before considering the sending capture to have failed, default is 1 second
});
};

View File

@@ -1,4 +1,5 @@
import {app} from "electron";
import {app} from "electron/main";
import process from "node:process";
import AutoLaunch from "auto-launch";
@@ -19,13 +20,13 @@ export const setAutoLaunch = async (
// `setLoginItemSettings` doesn't support linux
if (process.platform === "linux") {
const ZulipAutoLauncher = new AutoLaunch({
const zulipAutoLauncher = new AutoLaunch({
name: "Zulip",
isHidden: false,
});
await (autoLaunchOption
? ZulipAutoLauncher.enable()
: ZulipAutoLauncher.disable());
? zulipAutoLauncher.enable()
: zulipAutoLauncher.disable());
} else {
app.setLoginItemSettings({
openAtLogin: autoLaunchOption,

View File

@@ -1,15 +1,22 @@
import type {IpcMainEvent, IpcMainInvokeEvent, WebContents} from "electron";
import type {
IpcMainEvent,
IpcMainInvokeEvent,
WebContents,
} from "electron/main";
import {
ipcMain as untypedIpcMain, // eslint-disable-line no-restricted-imports
} from "electron";
} from "electron/main";
import type {MainCall, MainMessage, RendererMessage} from "../common/typed-ipc";
import type {
MainCall,
MainMessage,
RendererMessage,
} from "../common/typed-ipc.js";
type MainListener<
Channel extends keyof MainMessage
> = MainMessage[Channel] extends (...args: infer Args) => infer Return
? (event: IpcMainEvent & {returnValue: Return}, ...args: Args) => void
: never;
type MainListener<Channel extends keyof MainMessage> =
MainMessage[Channel] extends (...args: infer Args) => infer Return
? (event: IpcMainEvent & {returnValue: Return}, ...args: Args) => void
: never;
type MainHandler<Channel extends keyof MainCall> = MainCall[Channel] extends (
...args: infer Args

View File

@@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="css/about.css" />
<title>Zulip - About</title>
</head>
<body>
<div class="about">
<img class="logo" src="../resources/zulip.png" />
<p class="detail" id="version">v?.?.?</p>
<div class="maintenance-info">
<p class="detail maintainer">
Maintained by
<a href="https://zulip.com" target="_blank" rel="noopener noreferrer"
>Zulip</a
>
</p>
<p class="detail license">
Available under the
<a
href="https://github.com/zulip/zulip-desktop/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
>Apache 2.0 License</a
>
</p>
</div>
</div>
<script>
const {app} = require("electron").remote;
const version_tag = document.querySelector("#version");
version_tag.textContent = "v" + app.getVersion();
</script>
</body>
</html>

View File

@@ -1,5 +1,7 @@
body {
background: rgba(250, 250, 250, 1);
:host {
contain: strict;
display: flow-root;
background: rgb(250 250 250 / 100%);
font-family: menu, "Helvetica Neue", sans-serif;
-webkit-font-smoothing: subpixel-antialiased;
}
@@ -10,12 +12,13 @@ body {
}
#version {
color: rgba(68, 67, 67, 1);
color: rgb(68 67 67 / 100%);
font-size: 1.3em;
padding-top: 40px;
}
.about {
display: block !important;
margin: 25vh auto;
height: 25vh;
text-align: center;
@@ -23,7 +26,7 @@ body {
.about p {
font-size: 20px;
color: rgba(0, 0, 0, 0.62);
color: rgb(0 0 0 / 62%);
}
.about img {
@@ -48,7 +51,7 @@ body {
position: absolute;
width: 100%;
left: 0;
color: rgba(68, 68, 68, 1);
color: rgb(68 68 68 / 100%);
}
.maintenance-info p {
@@ -58,7 +61,7 @@ body {
}
p.detail a {
color: rgba(53, 95, 76, 1);
color: rgb(53 95 76 / 100%);
}
p.detail a:hover {

View File

@@ -1,5 +1,5 @@
:host {
--button-color: rgb(69, 166, 149);
--button-color: rgb(69 166 149);
}
button {
@@ -14,6 +14,6 @@ button:focus {
}
button:active {
background-color: rgb(241, 241, 241);
background-color: rgb(241 241 241);
color: var(--button-color);
}

View File

@@ -0,0 +1,12 @@
@font-face {
font-family: "Material Icons";
font-style: normal;
font-weight: 400;
src: local("Material Icons"), local("MaterialIcons-Regular"),
url("../fonts/MaterialIcons-Regular.ttf") format("truetype");
}
@font-face {
font-family: Montserrat;
src: url("../fonts/Montserrat-Regular.ttf") format("truetype");
}

View File

@@ -16,9 +16,9 @@ body {
}
.toggle-sidebar {
background: rgba(34, 44, 49, 1);
background: rgb(34 44 49 / 100%);
width: 54px;
padding: 27px 0 20px 0;
padding: 27px 0 20px;
justify-content: space-between;
display: flex;
flex-direction: column;
@@ -52,26 +52,18 @@ body {
}
#view-controls-container::-webkit-scrollbar-track {
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
box-shadow: inset 0 0 6px rgb(0 0 0 / 30%);
}
#view-controls-container::-webkit-scrollbar-thumb {
background-color: rgba(169, 169, 169, 1);
outline: 1px solid rgba(169, 169, 169, 1);
background-color: rgb(169 169 169 / 100%);
outline: 1px solid rgb(169 169 169 / 100%);
}
#view-controls-container:hover {
overflow-y: overlay;
}
@font-face {
font-family: "Material Icons";
font-style: normal;
font-weight: 400;
src: local("Material Icons"), local("MaterialIcons-Regular"),
url(../fonts/MaterialIcons-Regular.ttf) format("truetype");
}
/*******************
* Left Sidebar *
*******************/
@@ -101,7 +93,7 @@ body {
-webkit-font-smoothing: antialiased;
/* Support for Safari and Chrome. */
text-rendering: optimizeLegibility;
text-rendering: optimizelegibility;
}
#actions-container {
@@ -122,23 +114,23 @@ body {
}
.action-button i {
color: rgba(108, 133, 146, 1);
color: rgb(108 133 146 / 100%);
font-size: 28px;
}
.action-button:hover i {
color: rgba(152, 169, 179, 1);
color: rgb(152 169 179 / 100%);
}
.action-button.active {
/* background-color: rgba(255, 255, 255, 0.25); */
background-color: rgba(239, 239, 239, 1);
background-color: rgb(239 239 239 / 100%);
opacity: 0.9;
padding-right: 14px;
}
.action-button.active i {
color: rgba(28, 38, 43, 1);
color: rgb(28 38 43 / 100%);
}
.action-button.disable {
@@ -150,7 +142,7 @@ body {
}
.action-button.disable:hover i {
color: rgba(108, 133, 146, 1);
color: rgb(108 133 146 / 100%);
}
.tab {
@@ -180,7 +172,7 @@ body {
margin-top: 5px;
z-index: 11;
line-height: 31px;
color: rgba(238, 238, 238, 1);
color: rgb(238 238 238 / 100%);
text-align: center;
overflow: hidden;
opacity: 0.6;
@@ -191,7 +183,7 @@ body {
font-family: Verdana, sans-serif;
font-weight: 600;
font-size: 22px;
border: 2px solid rgba(34, 44, 49, 1);
border: 2px solid rgb(34 44 49 / 100%);
margin-left: 17%;
width: 35px;
border-radius: 4px;
@@ -203,7 +195,7 @@ body {
.tab.active .server-tab {
opacity: 1;
background-color: rgba(100, 132, 120, 1);
background-color: rgb(100 132 120 / 100%);
}
.tab.functional-tab {
@@ -214,7 +206,7 @@ body {
.tab.functional-tab.active .server-tab {
padding: 2px 0;
height: 40px;
background-color: rgba(255, 255, 255, 0.25);
background-color: rgb(255 255 255 / 25%);
}
.tab.functional-tab .server-tab i {
@@ -227,14 +219,14 @@ body {
min-width: 11px;
padding: 0 3px;
height: 17px;
background-color: rgba(244, 67, 54, 1);
background-color: rgb(244 67 54 / 100%);
font-size: 10px;
font-family: sans-serif;
position: absolute;
z-index: 15;
top: 6px;
float: right;
color: rgba(255, 255, 255, 1);
color: rgb(255 255 255 / 100%);
text-align: center;
line-height: 17px;
display: block;
@@ -262,7 +254,7 @@ body {
}
.tab .server-tab-shortcut {
color: rgba(100, 132, 120, 1);
color: rgb(100 132 120 / 100%);
font-size: 12px;
text-align: center;
font-family: sans-serif;
@@ -298,7 +290,7 @@ body {
content: "";
position: absolute;
z-index: 1;
background: rgba(255, 255, 255, 1) url(../img/ic_loading.gif) no-repeat;
background: rgb(255 255 255 / 100%) url("../img/ic_loading.gif") no-repeat;
background-size: 60px 60px;
background-position: center;
width: 100%;
@@ -307,35 +299,25 @@ body {
/* When the active webview is loaded */
#webviews-container.loaded::before {
opacity: 0;
z-index: -1;
visibility: hidden;
}
webview {
/* transition: opacity 0.3s ease-in; */
webview,
.functional-view {
position: absolute;
width: 100%;
height: 100%;
flex-grow: 1;
display: flex;
flex-direction: column;
visibility: hidden;
}
webview.onload {
transition: opacity 1s cubic-bezier(0.95, 0.05, 0.795, 0.035);
}
webview.active {
opacity: 1;
webview.active,
.functional-view.active {
z-index: 1;
visibility: visible;
}
webview.disabled {
opacity: 0;
}
webview.focus {
outline: 0 solid transparent;
}
@@ -348,13 +330,13 @@ webview.focus {
#reload-tooltip,
#setting-tooltip {
font-family: sans-serif;
background: rgba(34, 44, 49, 1);
background: rgb(34 44 49 / 100%);
margin-left: 48px;
padding: 6px 8px;
position: absolute;
margin-top: 0;
z-index: 1000;
color: rgba(255, 255, 255, 1);
color: rgb(255 255 255 / 100%);
border-radius: 4px;
text-align: center;
width: 55px;
@@ -369,7 +351,7 @@ webview.focus {
content: " ";
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-right: 8px solid rgba(34, 44, 49, 1);
border-right: 8px solid rgb(34 44 49 / 100%);
position: absolute;
top: 7px;
right: 68px;
@@ -377,14 +359,14 @@ webview.focus {
#add-server-tooltip,
.server-tooltip {
font-family: "arial", sans-serif;
background: rgba(34, 44, 49, 1);
font-family: arial, sans-serif;
background: rgb(34 44 49 / 100%);
left: 56px;
padding: 10px 20px;
position: fixed;
margin-top: 11px;
z-index: 5000 !important;
color: rgba(255, 255, 255, 1);
color: rgb(255 255 255 / 100%);
border-radius: 4px;
text-align: center;
width: max-content;
@@ -396,7 +378,7 @@ webview.focus {
content: " ";
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-right: 8px solid rgba(34, 44, 49, 1);
border-right: 8px solid rgb(34 44 49 / 100%);
position: absolute;
top: 10px;
left: -5px;
@@ -408,14 +390,14 @@ webview.focus {
position: absolute;
width: 24px;
height: 24px;
background: rgba(34, 44, 49, 1);
background: rgb(34 44 49 / 100%);
border-radius: 20px;
cursor: pointer;
box-shadow: rgba(153, 153, 153, 1) 1px 1px;
box-shadow: rgb(153 153 153 / 100%) 1px 1px;
}
#collapse-button i {
color: rgba(239, 239, 239, 1);
color: rgb(239 239 239 / 100%);
}
#main-container {
@@ -435,8 +417,8 @@ webview.focus {
.popup .popuptext {
visibility: hidden;
background-color: rgba(85, 85, 85, 1);
color: rgba(255, 255, 255, 1);
background-color: rgb(85 85 85 / 100%);
color: rgb(255 255 255 / 100%);
text-align: center;
border-radius: 6px;
padding: 9px 0;
@@ -451,11 +433,11 @@ webview.focus {
.popup .show {
visibility: visible;
animation: cssAnimation 0s ease-in 5s forwards;
animation: full-screen-popup 0s ease-in 1s forwards;
animation-fill-mode: forwards;
}
@keyframes cssAnimation {
@keyframes full-screen-popup {
from {
opacity: 0;
}
@@ -467,26 +449,3 @@ webview.focus {
opacity: 1;
}
}
send-feedback {
width: 60%;
height: 85%;
}
#feedback-modal {
display: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(68, 67, 67, 0.81);
align-items: center;
justify-content: center;
z-index: 2;
transition: all 1s ease-out;
}
#feedback-modal.show {
display: flex;
}

View File

@@ -3,8 +3,8 @@ body {
margin: 0;
cursor: default;
font-size: 14px;
color: rgba(51, 51, 51, 1);
background: rgba(255, 255, 255, 1);
color: rgb(51 51 51 / 100%);
background: rgb(255 255 255 / 100%);
user-select: none;
}
@@ -45,8 +45,8 @@ body {
.button {
font-size: 16px;
background: rgba(0, 150, 136, 1);
color: rgba(255, 255, 255, 1);
background: rgb(0 150 136 / 100%);
color: rgb(255 255 255 / 100%);
width: 96px;
height: 32px;
border-radius: 5px;

View File

@@ -1,31 +1,30 @@
html,
body {
height: 100%;
margin: 0;
:host {
contain: strict;
display: flow-root;
cursor: default;
user-select: none;
font-family: menu, "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
background: rgba(239, 239, 239, 1);
background: rgb(239 239 239 / 100%);
letter-spacing: -0.08px;
line-height: 18px;
color: rgba(139, 142, 143, 1);
color: rgb(139 142 143 / 100%);
}
kbd {
display: inline-block;
border: 1px solid rgba(204, 204, 204, 1);
border: 1px solid rgb(204 204 204 / 100%);
border-radius: 4px;
font-size: 15px;
font-family: Courier New, Courier, monospace;
font-family: "Courier New", Courier, monospace;
font-weight: bold;
white-space: nowrap;
background-color: rgba(247, 247, 247, 1);
color: rgba(51, 51, 51, 1);
background-color: rgb(247 247 247 / 100%);
color: rgb(51 51 51 / 100%);
margin: 0 0.1em;
padding: 0.3em 0.8em;
text-shadow: 0 1px 0 rgba(255, 255, 255, 1);
text-shadow: 0 1px 0 rgb(255 255 255 / 100%);
line-height: 1.4;
}
@@ -33,7 +32,7 @@ table,
th,
td {
border-collapse: collapse;
color: rgba(56, 52, 48, 1);
color: rgb(56 52 48 / 100%);
}
table {
@@ -51,19 +50,6 @@ td:nth-child(odd) {
width: 50%;
}
@font-face {
font-family: "Material Icons";
font-style: normal;
font-weight: 400;
src: local("Material Icons"), local("MaterialIcons-Regular"),
url(../fonts/MaterialIcons-Regular.ttf) format("truetype");
}
@font-face {
font-family: "Montserrat";
src: url(../fonts/Montserrat-Regular.ttf) format("truetype");
}
.material-icons {
font-family: "Material Icons";
font-weight: normal;
@@ -83,13 +69,13 @@ td:nth-child(odd) {
-webkit-font-smoothing: antialiased;
/* Support for Safari and Chrome. */
text-rendering: optimizeLegibility;
text-rendering: optimizelegibility;
}
#content {
display: flex;
display: flex !important;
height: 100%;
font-family: "Montserrat", sans-serif;
font-family: Montserrat, sans-serif;
}
#sidebar {
@@ -99,7 +85,7 @@ td:nth-child(odd) {
display: flex;
flex-direction: column;
font-size: 16px;
background: rgba(242, 242, 242, 1);
background: rgb(242 242 242 / 100%);
}
#nav-container {
@@ -108,18 +94,18 @@ td:nth-child(odd) {
.nav {
padding: 7px 0;
color: rgba(153, 153, 153, 1);
color: rgb(153 153 153 / 100%);
cursor: pointer;
}
.nav.active {
color: rgba(78, 191, 172, 1);
color: rgb(78 191 172 / 100%);
cursor: default;
position: relative;
}
.nav.active::before {
background: rgba(70, 78, 90, 1);
background: rgb(70 78 90 / 100%);
width: 3px;
height: 18px;
position: absolute;
@@ -129,13 +115,14 @@ td:nth-child(odd) {
/* We don't want to show this in nav item since we have the + button for adding an Organization */
/* stylelint-disable-next-line selector-id-pattern */
#nav-AddServer {
display: none;
}
#settings-header {
font-size: 22px;
color: rgba(34, 44, 49, 1);
color: rgb(34 44 49 / 100%);
font-weight: bold;
text-transform: uppercase;
}
@@ -155,19 +142,19 @@ td:nth-child(odd) {
.title {
font-weight: 500;
color: rgba(34, 44, 49, 1);
color: rgb(34 44 49 / 100%);
}
.page-title {
color: rgba(34, 44, 49, 1);
color: rgb(34 44 49 / 100%);
font-size: 15px;
font-weight: bold;
padding: 4px 0 6px 0;
padding: 4px 0 6px;
}
.add-server-info-row {
display: flex;
margin: 8px 0 0 0;
margin: 8px 0 0;
}
.add-server-info-right {
@@ -176,9 +163,9 @@ td:nth-child(odd) {
}
.sub-title {
padding: 4px 0 6px 0;
padding: 4px 0 6px;
font-weight: bold;
color: rgba(97, 97, 97, 1);
color: rgb(97 97 97 / 100%);
}
img.server-info-icon {
@@ -205,7 +192,7 @@ img.server-info-icon {
.server-info-row {
display: inline-block;
margin: 5px 0 0 0;
margin: 5px 0 0;
}
.server-info-left .server-info-row {
@@ -245,18 +232,18 @@ img.server-info-icon {
font-size: 14px;
border-radius: 4px;
padding: 13px;
border: rgba(237, 237, 237, 1) 2px solid;
border: rgb(237 237 237 / 100%) 2px solid;
outline-width: 0;
background: transparent;
max-width: 450px;
}
.setting-input-value:focus {
border: rgba(78, 191, 172, 1) 2px solid;
border: rgb(78 191 172 / 100%) 2px solid;
}
.invalid-input-value:focus {
border: rgba(239, 83, 80, 1) 2px solid;
border: rgb(239 83 80 / 100%) 2px solid;
}
.manual-proxy-block {
@@ -266,7 +253,7 @@ img.server-info-icon {
.actions-container {
display: flex;
font-size: 14px;
color: rgba(35, 93, 58, 1);
color: rgb(35 93 58 / 100%);
vertical-align: middle;
margin: 10px 0;
flex-wrap: wrap;
@@ -295,7 +282,7 @@ img.server-info-icon {
}
.action.disabled {
color: rgba(153, 153, 153, 1);
color: rgb(153 153 153 / 100%);
}
.action.disabled:hover {
@@ -306,14 +293,14 @@ img.server-info-icon {
display: flex;
flex-wrap: wrap;
padding: 12px 30px;
margin: 10px 0 20px 0;
background: rgba(255, 255, 255, 1);
margin: 10px 0 20px;
background: rgb(255 255 255 / 100%);
width: 80%;
transition: all 0.2s;
}
.settings-card:hover {
box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 0 0 rgba(0, 0, 0, 0.12);
box-shadow: 0 2px 5px 0 rgb(0 0 0 / 16%), 0 2px 0 0 rgb(0 0 0 / 12%);
}
.hidden {
@@ -322,11 +309,11 @@ img.server-info-icon {
}
.red {
color: rgb(240, 148, 148);
background: rgba(255, 255, 255, 1);
color: rgb(240 148 148);
background: rgb(255 255 255 / 100%);
border-radius: 4px;
display: inline-block;
border: 2px solid rgb(240, 148, 148);
border: 2px solid rgb(240 148 148);
padding: 10px;
width: 100px;
cursor: pointer;
@@ -338,13 +325,13 @@ img.server-info-icon {
}
.red:hover {
background-color: rgb(240, 148, 148);
color: rgba(255, 255, 255, 1);
background-color: rgb(240 148 148);
color: rgb(255 255 255 / 100%);
}
.green {
color: rgba(255, 255, 255, 1);
background: rgba(78, 191, 172, 1);
color: rgb(255 255 255 / 100%);
background: rgb(78 191 172 / 100%);
border-radius: 4px;
display: inline-block;
border: none;
@@ -359,8 +346,8 @@ img.server-info-icon {
}
.green:hover {
background-color: rgba(60, 159, 141, 1);
color: rgba(255, 255, 255, 1);
background-color: rgb(60 159 141 / 100%);
color: rgb(255 255 255 / 100%);
}
.w-150 {
@@ -372,9 +359,9 @@ img.server-info-icon {
}
.grey {
color: rgba(158, 158, 158, 1);
background: rgba(250, 250, 250, 1);
border: 1px solid rgba(158, 158, 158, 1);
color: rgb(158 158 158 / 100%);
background: rgb(250 250 250 / 100%);
border: 1px solid rgb(158 158 158 / 100%);
}
.setting-row {
@@ -390,7 +377,7 @@ img.server-info-icon {
}
.code {
font-family: Courier New, Courier, monospace;
font-family: "Courier New", Courier, monospace;
}
i.open-tab-button {
@@ -414,7 +401,7 @@ i.open-tab-button {
.selected-css-path,
.download-folder-path {
background: rgba(238, 238, 238, 1);
background: rgb(238 238 238 / 100%);
padding: 5px 10px;
margin-right: 10px;
display: flex;
@@ -431,7 +418,7 @@ i.open-tab-button {
}
#new-org-button {
margin: 30px 0 30px 0;
margin: 30px 0;
}
#create-organization-container {
@@ -463,7 +450,7 @@ i.open-tab-button {
}
.disallowed:hover {
background-color: rgba(241, 241, 241, 1);
background-color: rgb(241 241 241 / 100%);
cursor: not-allowed;
}
@@ -471,7 +458,7 @@ input.toggle-round + label {
padding: 2px;
width: 50px;
height: 25px;
background-color: rgba(221, 221, 221, 1);
background-color: rgb(221 221 221 / 100%);
border-radius: 25px;
}
@@ -486,7 +473,7 @@ input.toggle-round + label::after {
}
input.toggle-round + label::before {
background-color: rgba(241, 241, 241, 1);
background-color: rgb(241 241 241 / 100%);
border-radius: 25px;
top: 0;
right: 0;
@@ -497,12 +484,12 @@ input.toggle-round + label::before {
input.toggle-round + label::after {
width: 25px;
height: 25px;
background-color: rgba(255, 255, 255, 1);
background-color: rgb(255 255 255 / 100%);
border-radius: 100%;
}
input.toggle-round:checked + label::before {
background-color: rgba(78, 191, 172, 1);
background-color: rgb(78 191 172 / 100%);
top: 0;
right: 0;
left: 0;
@@ -527,17 +514,21 @@ input.toggle-round:checked + label::after {
height: 100%;
/* background: rgba(61, 64, 67, 15); */
background: linear-gradient(35deg, rgba(0, 59, 82, 1), rgba(69, 181, 155, 1));
background: linear-gradient(
35deg,
rgb(0 59 82 / 100%),
rgb(69 181 155 / 100%)
);
overflow: auto;
}
/* Modal Content */
.modal-container {
background-color: rgba(244, 247, 248, 1);
background-color: rgb(244 247 248 / 100%);
margin: auto;
padding: 57px;
border: rgba(218, 225, 227, 1) 1px solid;
border: rgb(218 225 227 / 100%) 1px solid;
width: 550px;
height: 370px;
border-radius: 4px;
@@ -551,7 +542,7 @@ input.toggle-round:checked + label::after {
.divider {
margin-bottom: 30px;
margin-top: 30px;
color: rgba(125, 135, 138, 1);
color: rgb(125 135 138 / 100%);
}
.divider hr {
@@ -582,8 +573,8 @@ input.toggle-round:checked + label::after {
margin: auto;
align-items: center;
text-align: center;
color: rgba(255, 255, 255, 1);
background: rgba(78, 191, 172, 1);
color: rgb(255 255 255 / 100%);
background: rgb(78 191 172 / 100%);
border-color: none;
border: none;
width: 98%;
@@ -593,11 +584,11 @@ input.toggle-round:checked + label::after {
}
.server-center button:hover {
background: rgba(50, 149, 136, 1);
background: rgb(50 149 136 / 100%);
}
.server-center button:focus {
background: rgba(50, 149, 136, 1);
background: rgb(50 149 136 / 100%);
}
.certificates-card {
@@ -646,7 +637,7 @@ input.toggle-round:checked + label::after {
padding-top: 15px;
align-items: center;
text-align: center;
color: rgb(78, 191, 172);
color: rgb(78 191 172);
width: 98%;
height: 46px;
cursor: pointer;
@@ -752,17 +743,19 @@ i.open-network-button {
.lang-menu {
font-size: 13px;
font-weight: bold;
background: rgba(78, 191, 172, 1);
background: rgb(78 191 172 / 100%);
width: 100px;
height: 38px;
color: rgba(255, 255, 255, 1);
border-color: rgba(0, 0, 0, 0);
color: rgb(255 255 255 / 100%);
border-color: rgb(0 0 0 / 0%);
}
/* stylelint-disable-next-line selector-class-pattern */
.tagify__input {
min-width: 130px !important;
}
/* stylelint-disable-next-line selector-class-pattern */
.tagify__input::before {
top: 0;
bottom: 0;

View File

@@ -1,5 +1,6 @@
import crypto from "crypto";
import {clipboard} from "electron";
import {clipboard} from "electron/common";
import {Buffer} from "node:buffer";
import crypto from "node:crypto";
// This helper is exposed via electron_bridge for use in the social
// login flow.
@@ -15,6 +16,12 @@ import {clipboard} from "electron";
// dont leak anything from the users clipboard other than the token
// intended for us.
export interface ClipboardDecrypter {
version: number;
key: Uint8Array;
pasted: Promise<string>;
}
export class ClipboardDecrypterImpl implements ClipboardDecrypter {
version: number;
key: Uint8Array;

View File

@@ -1,6 +1,6 @@
import type {HTML} from "../../../common/html";
import type {Html} from "../../../common/html";
export function generateNodeFromHTML(html: HTML): Element {
export function generateNodeFromHtml(html: Html): Element {
const wrapper = document.createElement("div");
wrapper.innerHTML = html.html;

View File

@@ -1,18 +1,23 @@
import type {ContextMenuParams} from "electron";
import {remote} from "electron";
import {clipboard} from "electron/common";
import type {WebContents} from "electron/main";
import type {
ContextMenuParams,
MenuItemConstructorOptions,
} from "electron/renderer";
import process from "node:process";
import {Menu} from "@electron/remote";
import * as t from "../../../common/translation-util";
const {clipboard, Menu} = remote;
export const contextMenu = (
webContents: Electron.WebContents,
webContents: WebContents,
event: Event,
props: ContextMenuParams,
) => {
const isText = props.selectionText !== "";
const isLink = props.linkURL !== "";
const linkURL = isLink ? new URL(props.linkURL) : undefined;
const linkUrl = isLink ? new URL(props.linkURL) : undefined;
const makeSuggestion = (suggestion: string) => ({
label: suggestion,
@@ -22,7 +27,7 @@ export const contextMenu = (
},
});
let menuTemplate: Electron.MenuItemConstructorOptions[] = [
let menuTemplate: MenuItemConstructorOptions[] = [
{
label: t.__("Add to Dictionary"),
visible: props.isEditable && isText && props.misspelledWord.length > 0,
@@ -77,7 +82,7 @@ export const contextMenu = (
},
{
label:
linkURL?.protocol === "mailto:"
linkUrl?.protocol === "mailto:"
? t.__("Copy Email Address")
: t.__("Copy Link"),
visible: isLink,
@@ -85,7 +90,7 @@ export const contextMenu = (
clipboard.write({
bookmark: props.linkText,
text:
linkURL?.protocol === "mailto:" ? linkURL.pathname : props.linkURL,
linkUrl?.protocol === "mailto:" ? linkUrl.pathname : props.linkURL,
});
},
},
@@ -119,9 +124,10 @@ export const contextMenu = (
if (props.misspelledWord) {
if (props.dictionarySuggestions.length > 0) {
const suggestions: Electron.MenuItemConstructorOptions[] = props.dictionarySuggestions.map(
(suggestion: string) => makeSuggestion(suggestion),
);
const suggestions: MenuItemConstructorOptions[] =
props.dictionarySuggestions.map((suggestion: string) =>
makeSuggestion(suggestion),
);
menuTemplate = [...suggestions, ...menuTemplate];
} else {
menuTemplate.unshift({

View File

@@ -1,18 +1,24 @@
import type {HTML} from "../../../common/html";
import type {Html} from "../../../common/html";
import {html} from "../../../common/html";
import {generateNodeFromHTML} from "./base";
import {generateNodeFromHtml} from "./base";
import type {TabProps} from "./tab";
import Tab from "./tab";
export interface FunctionalTabProps extends TabProps {
$view: Element;
}
export default class FunctionalTab extends Tab {
$view: Element;
$el: Element;
$closeButton?: Element;
constructor(props: TabProps) {
constructor({$view, ...props}: FunctionalTabProps) {
super(props);
this.$el = generateNodeFromHTML(this.templateHTML());
this.$view = $view;
this.$el = generateNodeFromHtml(this.templateHtml());
if (this.props.name !== "Settings") {
this.props.$root.append(this.$el);
this.$closeButton = this.$el.querySelector(".server-tab-badge")!;
@@ -20,7 +26,22 @@ export default class FunctionalTab extends Tab {
}
}
templateHTML(): HTML {
override async activate(): Promise<void> {
await super.activate();
this.$view.classList.add("active");
}
override async deactivate(): Promise<void> {
await super.deactivate();
this.$view.classList.remove("active");
}
override async destroy(): Promise<void> {
await super.destroy();
this.$view.remove();
}
templateHtml(): Html {
return html`
<div class="tab functional-tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tab-badge close-button">
@@ -33,7 +54,7 @@ export default class FunctionalTab extends Tab {
`;
}
registerListeners(): void {
override registerListeners(): void {
super.registerListeners();
this.$el.addEventListener("mouseover", () => {

View File

@@ -1,72 +0,0 @@
import {remote} from "electron";
import * as ConfigUtil from "../../../common/config-util";
import {ipcRenderer} from "../typed-ipc-renderer";
import * as LinkUtil from "../utils/link-util";
import type WebView from "./webview";
const {shell, app} = remote;
const dingSound = new Audio("../resources/sounds/ding.ogg");
export default function handleExternalLink(
this: WebView,
event: Electron.NewWindowEvent,
): void {
event.preventDefault();
const url = new URL(event.url);
const downloadPath = ConfigUtil.getConfigItem(
"downloadsPath",
`${app.getPath("downloads")}`,
);
if (LinkUtil.isUploadsUrl(this.props.url, url)) {
ipcRenderer.send("downloadFile", url.href, downloadPath);
ipcRenderer.once(
"downloadFileCompleted",
async (_event: Event, filePath: string, fileName: string) => {
const downloadNotification = new Notification("Download Complete", {
body: `Click to show ${fileName} in folder`,
silent: true, // We'll play our own sound - ding.ogg
});
downloadNotification.addEventListener("click", () => {
// Reveal file in download folder
shell.showItemInFolder(filePath);
});
ipcRenderer.removeAllListeners("downloadFileFailed");
// Play sound to indicate download complete
if (!ConfigUtil.getConfigItem("silent", false)) {
await dingSound.play();
}
},
);
ipcRenderer.once("downloadFileFailed", (_event: Event, state: string) => {
// Automatic download failed, so show save dialog prompt and download
// through webview
// Only do this if it is the automatic download, otherwise show an error (so we aren't showing two save
// prompts right after each other)
// Check that the download is not cancelled by user
if (state !== "cancelled") {
if (ConfigUtil.getConfigItem("promptDownload", false)) {
// We need to create a "new Notification" to display it, but just `Notification(...)` on its own
// doesn't work
// eslint-disable-next-line no-new
new Notification("Download Complete", {
body: "Download failed",
});
} else {
this.$el!.downloadURL(url.href);
}
}
ipcRenderer.removeAllListeners("downloadFileCompleted");
});
} else {
(async () => LinkUtil.openBrowser(url))();
}
}

View File

@@ -1,26 +1,49 @@
import type {HTML} from "../../../common/html";
import process from "node:process";
import type {Html} from "../../../common/html";
import {html} from "../../../common/html";
import {ipcRenderer} from "../typed-ipc-renderer";
import * as SystemUtil from "../utils/system-util";
import {generateNodeFromHTML} from "./base";
import {generateNodeFromHtml} from "./base";
import type {TabProps} from "./tab";
import Tab from "./tab";
import type WebView from "./webview";
export interface ServerTabProps extends TabProps {
webview: Promise<WebView>;
}
export default class ServerTab extends Tab {
webview: Promise<WebView>;
$el: Element;
$badge: Element;
constructor(props: TabProps) {
constructor({webview, ...props}: ServerTabProps) {
super(props);
this.$el = generateNodeFromHTML(this.templateHTML());
this.webview = webview;
this.$el = generateNodeFromHtml(this.templateHtml());
this.props.$root.append(this.$el);
this.registerListeners();
this.$badge = this.$el.querySelector(".server-tab-badge")!;
}
templateHTML(): HTML {
override async activate(): Promise<void> {
await super.activate();
(await this.webview).load();
}
override async deactivate(): Promise<void> {
await super.deactivate();
(await this.webview).hide();
}
override async destroy(): Promise<void> {
await super.destroy();
(await this.webview).$el.remove();
}
templateHtml(): Html {
return html`
<div class="tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tooltip" style="display:none">
@@ -53,14 +76,11 @@ export default class ServerTab extends Tab {
const shownIndex = this.props.index + 1;
let shortcutText = "";
shortcutText =
SystemUtil.getOS() === "Mac" ? `${shownIndex}` : `Ctrl+${shownIndex}`;
// Array index == Shown index - 1
ipcRenderer.send("switch-server-tab", shownIndex - 1);
return shortcutText;
return process.platform === "darwin"
? `${shownIndex}`
: `Ctrl+${shownIndex}`;
}
}

View File

@@ -1,7 +1,7 @@
import type WebView from "./webview";
import type {TabRole} from "../../../common/types";
export interface TabProps {
role: string;
role: TabRole;
icon?: string;
name: string;
$root: Element;
@@ -10,19 +10,16 @@ export interface TabProps {
tabIndex: number;
onHover?: () => void;
onHoverOut?: () => void;
webview: WebView;
materialIcon?: string;
onDestroy?: () => void;
}
export default abstract class Tab {
props: TabProps;
webview: WebView;
abstract $el: Element;
constructor(props: TabProps) {
this.props = props;
this.webview = this.props.webview;
}
registerListeners(): void {
@@ -37,22 +34,15 @@ export default abstract class Tab {
}
}
showNetworkError(): void {
this.webview.forceLoad();
}
activate(): void {
async activate(): Promise<void> {
this.$el.classList.add("active");
this.webview.load();
}
deactivate(): void {
async deactivate(): Promise<void> {
this.$el.classList.remove("active");
this.webview.hide();
}
destroy(): void {
async destroy(): Promise<void> {
this.$el.remove();
this.webview.$el!.remove();
}
}

View File

@@ -1,129 +1,150 @@
import {remote} from "electron";
import fs from "fs";
import path from "path";
import type {WebContents} from "electron/main";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import * as remote from "@electron/remote";
import {app, dialog} from "@electron/remote";
import * as ConfigUtil from "../../../common/config-util";
import {HTML, html} from "../../../common/html";
import type {Html} from "../../../common/html";
import {html} from "../../../common/html";
import type {RendererMessage} from "../../../common/typed-ipc";
import type {TabRole} from "../../../common/types";
import {ipcRenderer} from "../typed-ipc-renderer";
import * as SystemUtil from "../utils/system-util";
import {generateNodeFromHTML} from "./base";
import {generateNodeFromHtml} from "./base";
import {contextMenu} from "./context-menu";
import handleExternalLink from "./handle-external-link";
const {app, dialog} = remote;
const shouldSilentWebview = ConfigUtil.getConfigItem("silent", false);
interface WebViewProps {
$root: Element;
rootWebContents: WebContents;
index: number;
tabIndex: number;
url: string;
role: string;
name: string;
role: TabRole;
isActive: () => boolean;
switchLoading: (loading: boolean, url: string) => void;
onNetworkError: (index: number) => void;
nodeIntegration: boolean;
preload: boolean;
preload?: string;
onTitleChange: () => void;
hasPermission?: (origin: string, permission: string) => boolean;
}
export default class WebView {
props: WebViewProps;
zoomFactor: number;
badgeCount: number;
loading: boolean;
customCSS: string | false | null;
$webviewsContainer: DOMTokenList;
$el?: Electron.WebviewTag;
domReady?: Promise<void>;
constructor(props: WebViewProps) {
this.props = props;
this.zoomFactor = 1;
this.loading = true;
this.badgeCount = 0;
this.customCSS = ConfigUtil.getConfigItem("customCSS", null);
this.$webviewsContainer = document.querySelector(
"#webviews-container",
)!.classList;
}
templateHTML(): HTML {
static templateHtml(props: WebViewProps): Html {
return html`
<webview
class="disabled"
data-tab-id="${this.props.tabIndex}"
src="${this.props.url}"
${new HTML({html: this.props.nodeIntegration ? "nodeIntegration" : ""})}
${new HTML({html: this.props.preload ? 'preload="js/preload.js"' : ""})}
data-tab-id="${props.tabIndex}"
src="${props.url}"
${props.preload === undefined
? html``
: html`preload="${props.preload}"`}
partition="persist:webviewsession"
name="${this.props.name}"
webpreferences="
contextIsolation=${!this.props.nodeIntegration},
spellcheck=${Boolean(
ConfigUtil.getConfigItem("enableSpellchecker", true),
)},
worldSafeExecuteJavaScript=true
"
allowpopups
>
</webview>
`;
}
init(): void {
this.$el = generateNodeFromHTML(this.templateHTML()) as Electron.WebviewTag;
this.domReady = new Promise((resolve) => {
this.$el!.addEventListener(
"dom-ready",
static async create(props: WebViewProps): Promise<WebView> {
const $element = generateNodeFromHtml(
WebView.templateHtml(props),
) as HTMLElement;
props.$root.append($element);
// Wait for did-navigate rather than did-attach to work around
// https://github.com/electron/electron/issues/31918
await new Promise<void>((resolve) => {
$element.addEventListener(
"did-navigate",
() => {
resolve();
},
true,
);
});
this.props.$root.append(this.$el);
// Work around https://github.com/electron/electron/issues/26904
function getWebContentsIdFunction(
this: undefined,
selector: string,
): number {
return document
.querySelector<Electron.WebviewTag>(selector)!
.getWebContentsId();
}
const selector = `webview[data-tab-id="${CSS.escape(
`${props.tabIndex}`,
)}"]`;
const webContentsId: unknown =
await props.rootWebContents.executeJavaScript(
`(${getWebContentsIdFunction.toString()})(${JSON.stringify(selector)})`,
);
if (typeof webContentsId !== "number") {
throw new TypeError("Failed to get WebContents ID");
}
return new WebView(props, $element, webContentsId);
}
props: WebViewProps;
zoomFactor: number;
badgeCount: number;
loading: boolean;
customCss: string | false | null;
$webviewsContainer: DOMTokenList;
$el: HTMLElement;
webContentsId: number;
private constructor(
props: WebViewProps,
$element: HTMLElement,
webContentsId: number,
) {
this.props = props;
this.zoomFactor = 1;
this.loading = true;
this.badgeCount = 0;
this.customCss = ConfigUtil.getConfigItem("customCSS", null);
this.$webviewsContainer = document.querySelector(
"#webviews-container",
)!.classList;
this.$el = $element;
this.webContentsId = webContentsId;
this.registerListeners();
}
getWebContents(): WebContents {
return remote.webContents.fromId(this.webContentsId);
}
registerListeners(): void {
this.$el!.addEventListener("new-window", (event) => {
handleExternalLink.call(this, event);
});
const webContents = this.getWebContents();
if (shouldSilentWebview) {
this.$el!.addEventListener("dom-ready", () => {
this.$el!.setAudioMuted(true);
});
webContents.setAudioMuted(true);
}
this.$el!.addEventListener("page-title-updated", (event) => {
const {title} = event;
webContents.on("page-title-updated", (_event, title) => {
this.badgeCount = this.getBadgeCount(title);
this.props.onTitleChange();
});
this.$el!.addEventListener("did-navigate-in-page", (event) => {
const isSettingPage = event.url.includes("renderer/preference.html");
if (isSettingPage) {
return;
}
this.$el.addEventListener("did-navigate-in-page", () => {
this.canGoBackButton();
});
this.$el!.addEventListener("did-navigate", () => {
this.$el.addEventListener("did-navigate", () => {
this.canGoBackButton();
});
this.$el!.addEventListener("page-favicon-updated", (event) => {
const {favicons} = event;
webContents.on("page-favicon-updated", (_event, favicons) => {
// This returns a string of favicons URL. If there is a PM counts in unread messages then the URL would be like
// https://chat.zulip.org/static/images/favicon/favicon-pms.png
if (
@@ -139,33 +160,19 @@ export default class WebView {
}
});
this.$el!.addEventListener("dom-ready", () => {
const webContents = remote.webContents.fromId(
this.$el!.getWebContentsId(),
);
webContents.addListener("context-menu", (event, menuParameters) => {
contextMenu(webContents, event, menuParameters);
});
if (this.props.role === "server") {
this.$el!.classList.add("onload");
}
webContents.addListener("context-menu", (event, menuParameters) => {
contextMenu(webContents, event, menuParameters);
});
this.$el.addEventListener("dom-ready", () => {
this.loading = false;
this.props.switchLoading(false, this.props.url);
this.show();
// Refocus text boxes after reload
// Remove when upstream issue https://github.com/electron/electron/issues/14474 is fixed
this.$el!.blur();
this.$el!.focus();
});
this.$el!.addEventListener("did-fail-load", (event) => {
const {errorDescription} = event;
const hasConnectivityError = SystemUtil.connectivityERR.includes(
errorDescription,
);
webContents.on("did-fail-load", (_event, _errorCode, errorDescription) => {
const hasConnectivityError =
SystemUtil.connectivityError.includes(errorDescription);
if (hasConnectivityError) {
console.error("error", errorDescription);
if (!this.props.url.includes("network.html")) {
@@ -174,25 +181,22 @@ export default class WebView {
}
});
this.$el!.addEventListener("did-start-loading", () => {
const isSettingPage = this.props.url.includes("renderer/preference.html");
if (!isSettingPage) {
this.props.switchLoading(true, this.props.url);
}
this.$el.addEventListener("did-start-loading", () => {
this.props.switchLoading(true, this.props.url);
});
this.$el!.addEventListener("did-stop-loading", () => {
this.$el.addEventListener("did-stop-loading", () => {
this.props.switchLoading(false, this.props.url);
});
}
getBadgeCount(title: string): number {
const messageCountInTitle = /\((\d+)\)/.exec(title);
const messageCountInTitle = /^\((\d+)\)/.exec(title);
return messageCountInTitle ? Number(messageCountInTitle[1]) : 0;
}
async showNotificationSettings(): Promise<void> {
await this.send("show-notification-settings");
showNotificationSettings(): void {
this.send("show-notification-settings");
}
show(): void {
@@ -208,27 +212,21 @@ export default class WebView {
this.$webviewsContainer.add("loaded");
}
this.$el!.classList.remove("disabled");
this.$el!.classList.add("active");
setTimeout(() => {
if (this.props.role === "server") {
this.$el!.classList.remove("onload");
}
}, 1000);
this.$el.classList.add("active");
this.focus();
this.props.onTitleChange();
// Injecting preload css in webview to override some css rules
(async () =>
this.$el!.insertCSS(
this.getWebContents().insertCSS(
fs.readFileSync(path.join(__dirname, "/../../css/preload.css"), "utf8"),
))();
// Get customCSS again from config util to avoid warning user again
const customCSS = ConfigUtil.getConfigItem("customCSS", null);
this.customCSS = customCSS;
if (customCSS) {
if (!fs.existsSync(customCSS)) {
this.customCSS = null;
const customCss = ConfigUtil.getConfigItem("customCSS", null);
this.customCss = customCss;
if (customCss) {
if (!fs.existsSync(customCss)) {
this.customCss = null;
ConfigUtil.setConfigItem("customCSS", null);
const errorMessage = "The custom css previously set is deleted!";
@@ -237,70 +235,56 @@ export default class WebView {
}
(async () =>
this.$el!.insertCSS(
fs.readFileSync(path.resolve(__dirname, customCSS), "utf8"),
this.getWebContents().insertCSS(
fs.readFileSync(path.resolve(__dirname, customCss), "utf8"),
))();
}
}
focus(): void {
// Focus Webview and it's contents when Window regain focus.
const webContents = remote.webContents.fromId(this.$el!.getWebContentsId());
// HACK: webContents.isFocused() seems to be true even without the element
// being in focus. So, we check against `document.activeElement`.
if (webContents && this.$el !== document.activeElement) {
// HACK: Looks like blur needs to be called on the previously focused
// element to transfer focus correctly, in Electron v3.0.10
// See https://github.com/electron/electron/issues/15718
(document.activeElement as HTMLElement).blur();
this.$el!.focus();
webContents.focus();
}
this.$el.focus();
// Work around https://github.com/electron/electron/issues/31918
this.$el.shadowRoot?.querySelector("iframe")?.focus();
}
hide(): void {
this.$el!.classList.add("disabled");
this.$el!.classList.remove("active");
this.$el.classList.remove("active");
}
load(): void {
if (this.$el) {
this.show();
} else {
this.init();
}
this.show();
}
zoomIn(): void {
this.zoomFactor += 0.1;
this.$el!.setZoomFactor(this.zoomFactor);
this.getWebContents().setZoomFactor(this.zoomFactor);
}
zoomOut(): void {
this.zoomFactor -= 0.1;
this.$el!.setZoomFactor(this.zoomFactor);
this.getWebContents().setZoomFactor(this.zoomFactor);
}
zoomActualSize(): void {
this.zoomFactor = 1;
this.$el!.setZoomFactor(this.zoomFactor);
this.getWebContents().setZoomFactor(this.zoomFactor);
}
async logOut(): Promise<void> {
await this.send("logout");
logOut(): void {
this.send("logout");
}
async showKeyboardShortcuts(): Promise<void> {
await this.send("show-keyboard-shortcuts");
showKeyboardShortcuts(): void {
this.send("show-keyboard-shortcuts");
}
openDevTools(): void {
this.$el!.openDevTools();
this.getWebContents().openDevTools();
}
back(): void {
if (this.$el!.canGoBack()) {
this.$el!.goBack();
if (this.getWebContents().canGoBack()) {
this.getWebContents().goBack();
this.focus();
}
}
@@ -309,7 +293,7 @@ export default class WebView {
const $backButton = document.querySelector(
"#actions-container #back-action",
)!;
if (this.$el!.canGoBack()) {
if (this.getWebContents().canGoBack()) {
$backButton.classList.remove("disable");
} else {
$backButton.classList.add("disable");
@@ -317,8 +301,8 @@ export default class WebView {
}
forward(): void {
if (this.$el!.canGoForward()) {
this.$el!.goForward();
if (this.getWebContents().canGoForward()) {
this.getWebContents().goForward();
}
}
@@ -328,18 +312,13 @@ export default class WebView {
this.$webviewsContainer.remove("loaded");
this.loading = true;
this.props.switchLoading(true, this.props.url);
this.$el!.reload();
this.getWebContents().reload();
}
forceLoad(): void {
this.init();
}
async send<Channel extends keyof RendererMessage>(
send<Channel extends keyof RendererMessage>(
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
): Promise<void> {
await this.domReady;
ipcRenderer.sendTo(this.$el!.getWebContentsId(), channel, ...args);
): void {
ipcRenderer.sendTo(this.webContentsId, channel, ...args);
}
}

View File

@@ -1,6 +1,6 @@
import {remote} from "electron";
import {EventEmitter} from "events";
import {EventEmitter} from "node:events";
import type {ClipboardDecrypter} from "./clipboard-decrypter";
import {ClipboardDecrypterImpl} from "./clipboard-decrypter";
import type {NotificationData} from "./notification";
import {newNotification} from "./notification";
@@ -8,6 +8,21 @@ import {ipcRenderer} from "./typed-ipc-renderer";
type ListenerType = (...args: any[]) => void;
export interface ElectronBridge {
send_event: (eventName: string | symbol, ...args: unknown[]) => boolean;
on_event: (eventName: string, listener: ListenerType) => void;
new_notification: (
title: string,
options: NotificationOptions,
dispatch: (type: string, eventInit: EventInit) => boolean,
) => NotificationData;
get_idle_on_system: () => boolean;
get_last_active_on_system: () => number;
get_send_notification_reply_message_supported: () => boolean;
set_send_notification_reply_message_supported: (value: boolean) => void;
decrypt_clipboard: (version: number) => ClipboardDecrypter;
}
let notificationReplySupported = false;
// Indicates if the user is idle or not
let idle = false;
@@ -16,11 +31,12 @@ let lastActive = Date.now();
export const bridgeEvents = new EventEmitter();
/* eslint-disable @typescript-eslint/naming-convention */
const electron_bridge: ElectronBridge = {
send_event: (eventName: string | symbol, ...args: unknown[]): boolean =>
bridgeEvents.emit(eventName, ...args),
on_event: (eventName: string, listener: ListenerType): void => {
on_event(eventName: string, listener: ListenerType): void {
bridgeEvents.on(eventName, listener);
},
@@ -37,13 +53,14 @@ const electron_bridge: ElectronBridge = {
get_send_notification_reply_message_supported: (): boolean =>
notificationReplySupported,
set_send_notification_reply_message_supported: (value: boolean): void => {
set_send_notification_reply_message_supported(value: boolean): void {
notificationReplySupported = value;
},
decrypt_clipboard: (version: number): ClipboardDecrypterImpl =>
decrypt_clipboard: (version: number): ClipboardDecrypter =>
new ClipboardDecrypterImpl(version),
};
/* eslint-enable @typescript-eslint/naming-convention */
bridgeEvents.on("total_unread_count", (unreadCount: unknown) => {
if (typeof unreadCount !== "number") {
@@ -58,39 +75,31 @@ bridgeEvents.on("realm_name", (realmName: unknown) => {
throw new TypeError("Expected string for realmName");
}
const serverURL = location.origin;
ipcRenderer.send("realm-name-changed", serverURL, realmName);
const serverUrl = location.origin;
ipcRenderer.send("realm-name-changed", serverUrl, realmName);
});
bridgeEvents.on("realm_icon_url", (iconURL: unknown) => {
if (typeof iconURL !== "string") {
throw new TypeError("Expected string for iconURL");
bridgeEvents.on("realm_icon_url", (iconUrl: unknown) => {
if (typeof iconUrl !== "string") {
throw new TypeError("Expected string for iconUrl");
}
const serverURL = location.origin;
const serverUrl = location.origin;
ipcRenderer.send(
"realm-icon-changed",
serverURL,
iconURL.includes("http") ? iconURL : `${serverURL}${iconURL}`,
serverUrl,
iconUrl.includes("http") ? iconUrl : `${serverUrl}${iconUrl}`,
);
});
// Set user as active and update the time of last activity
ipcRenderer.on("set-active", () => {
if (!remote.app.isPackaged) {
console.log("active");
}
idle = false;
lastActive = Date.now();
});
// Set user as idle and time of last activity is left unchanged
ipcRenderer.on("set-idle", () => {
if (!remote.app.isPackaged) {
console.log("idle");
}
idle = true;
});

View File

@@ -1,55 +0,0 @@
import {remote} from "electron";
import fs from "fs";
import path from "path";
import SendFeedback from "@electron-elements/send-feedback";
const {app} = remote;
customElements.define("send-feedback", SendFeedback);
export const sendFeedback: SendFeedback = document.querySelector(
"send-feedback",
)!;
export const feedbackHolder = sendFeedback.parentElement!;
// Make the button color match zulip app's theme
sendFeedback.customStylesheet = "css/feedback.css";
// Customize the fields of custom elements
sendFeedback.title = "Report Issue";
sendFeedback.titleLabel = "Issue title:";
sendFeedback.titlePlaceholder = "Enter issue title";
sendFeedback.textareaLabel = "Describe the issue:";
sendFeedback.textareaPlaceholder =
"Succinctly describe your issue and steps to reproduce it...";
sendFeedback.buttonLabel = "Report Issue";
sendFeedback.loaderSuccessText = "";
sendFeedback.useReporter("emailReporter", {
email: "support@zulip.com",
});
feedbackHolder.addEventListener("click", (event: Event) => {
// Only remove the class if the grey out faded
// part is clicked and not the feedback element itself
if (event.target === event.currentTarget) {
feedbackHolder.classList.remove("show");
}
});
sendFeedback.addEventListener("feedback-submitted", () => {
setTimeout(() => {
feedbackHolder.classList.remove("show");
}, 1000);
});
sendFeedback.addEventListener("feedback-cancelled", () => {
feedbackHolder.classList.remove("show");
});
const dataDir = app.getPath("userData");
const logsDir = path.join(dataDir, "/Logs");
sendFeedback.logs.push(
...fs.readdirSync(logsDir).map((file) => path.join(logsDir, file)),
);

View File

@@ -1,5 +1,7 @@
"use strict";
type ElectronBridge = import("./electron-bridge").ElectronBridge;
interface CompatElectronBridge extends ElectronBridge {
readonly idle_on_system: boolean;
readonly last_active_on_system: number;
@@ -12,6 +14,7 @@ interface CompatElectronBridge extends ElectronBridge {
raw_electron_bridge: ElectronBridge;
};
/* eslint-disable @typescript-eslint/naming-convention */
const electron_bridge: CompatElectronBridge = {
...zulipWindow.raw_electron_bridge,
@@ -31,6 +34,7 @@ interface CompatElectronBridge extends ElectronBridge {
this.set_send_notification_reply_message_supported(value);
},
};
/* eslint-enable @typescript-eslint/naming-convention */
zulipWindow.electron_bridge = electron_bridge;
@@ -66,26 +70,10 @@ interface CompatElectronBridge extends ElectronBridge {
};
}
// eslint-disable-next-line @typescript-eslint/naming-convention
const NativeNotification = Notification;
class InjectedNotification extends EventTarget {
constructor(title: string, options: NotificationOptions = {}) {
super();
Object.assign(
this,
electron_bridge.new_notification(
title,
options,
(type: string, eventInit: EventInit) =>
this.dispatchEvent(new Event(type, eventInit)),
),
);
}
static get maxActions(): number {
return NativeNotification.maxActions;
}
static get permission(): NotificationPermission {
return NativeNotification.permission;
}
@@ -99,6 +87,19 @@ interface CompatElectronBridge extends ElectronBridge {
return NativeNotification.permission;
}
constructor(title: string, options: NotificationOptions = {}) {
super();
Object.assign(
this,
electron_bridge.new_notification(
title,
options,
(type: string, eventInit: EventInit) =>
this.dispatchEvent(new Event(type, eventInit)),
),
);
}
}
Object.defineProperties(InjectedNotification.prototype, {
@@ -108,5 +109,5 @@ interface CompatElectronBridge extends ElectronBridge {
onshow: attributeListener("show"),
});
window.Notification = InjectedNotification as any;
window.Notification = InjectedNotification as unknown as typeof Notification;
})();

View File

@@ -1,35 +1,32 @@
import {clipboard, remote} from "electron";
import path from "path";
import {clipboard} from "electron/common";
import path from "node:path";
import process from "node:process";
import {Menu, app, dialog, session} from "@electron/remote";
import * as remote from "@electron/remote";
import * as Sentry from "@sentry/electron";
import type {Config} from "../../common/config-util";
import * as ConfigUtil from "../../common/config-util";
import * as DNDUtil from "../../common/dnd-util";
import type {DNDSettings} from "../../common/dnd-util";
import type {DndSettings} from "../../common/dnd-util";
import * as EnterpriseUtil from "../../common/enterprise-util";
import * as LinkUtil from "../../common/link-util";
import Logger from "../../common/logger-util";
import * as Messages from "../../common/messages";
import type {RendererMessage} from "../../common/typed-ipc";
import type {NavItem, ServerConf, TabData} from "../../common/types";
import FunctionalTab from "./components/functional-tab";
import ServerTab from "./components/server-tab";
import WebView from "./components/webview";
import {feedbackHolder} from "./feedback";
import {AboutView} from "./pages/about";
import {PreferenceView} from "./pages/preference/preference";
import {initializeTray} from "./tray";
import {ipcRenderer} from "./typed-ipc-renderer";
import * as DomainUtil from "./utils/domain-util";
import * as LinkUtil from "./utils/link-util";
import ReconnectUtil from "./utils/reconnect-util";
// eslint-disable-next-line import/no-unassigned-import
import "./tray";
const {session, app, Menu, dialog} = remote;
interface FunctionalTabProps {
name: string;
materialIcon: string;
url: string;
}
Sentry.init({});
type WebviewListener =
| "webview-reload"
@@ -50,7 +47,11 @@ const logger = new Logger({
const rendererDirectory = path.resolve(__dirname, "..");
type ServerOrFunctionalTab = ServerTab | FunctionalTab;
class ServerManagerView {
const rootWebContents = remote.getCurrentWebContents();
const dingSound = new Audio("../resources/sounds/ding.ogg");
export class ServerManagerView {
$addServerButton: HTMLButtonElement;
$tabsContainer: Element;
$reloadButton: HTMLButtonElement;
@@ -75,15 +76,15 @@ class ServerManagerView {
functionalTabs: Map<string, number>;
tabIndex: number;
presetOrgs: string[];
preferenceView?: PreferenceView;
constructor() {
this.$addServerButton = document.querySelector("#add-tab")!;
this.$tabsContainer = document.querySelector("#tabs-container")!;
const $actionsContainer = document.querySelector("#actions-container")!;
this.$reloadButton = $actionsContainer.querySelector("#reload-action")!;
this.$loadingIndicator = $actionsContainer.querySelector(
"#loading-action",
)!;
this.$loadingIndicator =
$actionsContainer.querySelector("#loading-action")!;
this.$settingsButton = $actionsContainer.querySelector("#settings-action")!;
this.$webviewsContainer = document.querySelector("#webviews-container")!;
this.$backButton = $actionsContainer.querySelector("#back-action")!;
@@ -92,9 +93,8 @@ class ServerManagerView {
this.$addServerTooltip = document.querySelector("#add-server-tooltip")!;
this.$reloadTooltip = $actionsContainer.querySelector("#reload-tooltip")!;
this.$loadingTooltip = $actionsContainer.querySelector("#loading-tooltip")!;
this.$settingsTooltip = $actionsContainer.querySelector(
"#setting-tooltip",
)!;
this.$settingsTooltip =
$actionsContainer.querySelector("#setting-tooltip")!;
// TODO: This should have been querySelector but the problem is that
// querySelector doesn't return elements not present in dom whereas somehow
@@ -122,10 +122,11 @@ class ServerManagerView {
}
async init(): Promise<void> {
initializeTray(this);
await this.loadProxy();
this.initDefaultSettings();
this.initSidebar();
this.removeUAfromDisk();
this.removeUaFromDisk();
if (EnterpriseUtil.hasConfigFile()) {
await this.initPresetOrgs();
}
@@ -133,7 +134,6 @@ class ServerManagerView {
await this.initTabs();
this.initActions();
this.registerIpcs();
ipcRenderer.send("set-spellcheck-langs");
}
async loadProxy(): Promise<void> {
@@ -148,21 +148,16 @@ class ServerManagerView {
ConfigUtil.removeConfigItem("useProxy");
}
const proxyEnabled =
ConfigUtil.getConfigItem("useManualProxy", false) ||
ConfigUtil.getConfigItem("useSystemProxy", false);
await session.fromPartition("persist:webviewsession").setProxy(
proxyEnabled
ConfigUtil.getConfigItem("useSystemProxy", false)
? {mode: "system"}
: ConfigUtil.getConfigItem("useManualProxy", false)
? {
pacScript: ConfigUtil.getConfigItem("proxyPAC", ""),
proxyRules: ConfigUtil.getConfigItem("proxyRules", ""),
proxyBypassRules: ConfigUtil.getConfigItem("proxyBypass", ""),
}
: {
pacScript: "",
proxyRules: "",
proxyBypassRules: "",
},
: {mode: "direct"},
);
}
@@ -185,6 +180,7 @@ class ServerManagerView {
autoUpdate: true,
betaUpdate: false,
errorReporting: true,
// eslint-disable-next-line @typescript-eslint/naming-convention
customCSS: false,
silent: false,
lastActiveTab: 0,
@@ -239,7 +235,7 @@ class ServerManagerView {
// Remove the stale UA string from the disk if the app is not freshly
// installed. This should be removed in a further release.
removeUAfromDisk(): void {
removeUaFromDisk(): void {
ConfigUtil.removeConfigItem("userAgent");
}
@@ -337,7 +333,7 @@ class ServerManagerView {
servers[lastActiveTab].url,
lastActiveTab,
);
this.activateTab(lastActiveTab);
await this.activateTab(lastActiveTab);
await Promise.all(
servers.map(async (server, i) => {
// After the lastActiveTab is activated, we load the others in the background
@@ -347,7 +343,8 @@ class ServerManagerView {
}
await DomainUtil.updateSavedServer(server.url, i);
this.tabs[i].webview.load();
const tab = this.tabs[i];
if (tab instanceof ServerTab) (await tab.webview).load();
}),
);
// Remove focus from the settings icon at sidebar bottom
@@ -373,35 +370,34 @@ class ServerManagerView {
tabIndex,
onHover: this.onHover.bind(this, index),
onHoverOut: this.onHoverOut.bind(this, index),
webview: new WebView({
webview: WebView.create({
$root: this.$webviewsContainer,
rootWebContents,
index,
tabIndex,
url: server.url,
role: "server",
name: server.alias,
hasPermission: (origin: string, permission: string) =>
origin === server.url && permission === "notifications",
isActive: () => index === this.activeTabIndex,
switchLoading: (loading: boolean, url: string) => {
switchLoading: async (loading: boolean, url: string) => {
if (loading) {
this.loading.add(url);
} else {
this.loading.delete(url);
}
const tab = this.tabs[this.activeTabIndex];
this.showLoading(
this.loading.has(
this.tabs[this.activeTabIndex].webview.props.url,
),
tab instanceof ServerTab &&
this.loading.has((await tab.webview).props.url),
);
},
onNetworkError: (index: number) => {
this.openNetworkTroubleshooting(index);
onNetworkError: async (index: number) => {
await this.openNetworkTroubleshooting(index);
},
onTitleChange: this.updateBadge.bind(this),
nodeIntegration: false,
preload: true,
preload: "js/preload.js",
}),
}),
);
@@ -409,15 +405,14 @@ class ServerManagerView {
}
initActions(): void {
this.initDNDButton();
this.initDndButton();
this.initServerActions();
this.initLeftSidebarEvents();
}
initServerActions(): void {
const $serverImgs: NodeListOf<HTMLImageElement> = document.querySelectorAll(
".server-icons",
);
const $serverImgs: NodeListOf<HTMLImageElement> =
document.querySelectorAll(".server-icons");
for (const [index, $serverImg] of $serverImgs.entries()) {
this.addContextMenu($serverImg, index);
if ($serverImg.src.includes("img/icon.png")) {
@@ -440,8 +435,9 @@ class ServerManagerView {
dndUtil.newSettings,
);
});
this.$reloadButton.addEventListener("click", () => {
this.tabs[this.activeTabIndex].webview.reload();
this.$reloadButton.addEventListener("click", async () => {
const tab = this.tabs[this.activeTabIndex];
if (tab instanceof ServerTab) (await tab.webview).reload();
});
this.$addServerButton.addEventListener("click", async () => {
await this.openSettings("AddServer");
@@ -449,8 +445,9 @@ class ServerManagerView {
this.$settingsButton.addEventListener("click", async () => {
await this.openSettings("General");
});
this.$backButton.addEventListener("click", () => {
this.tabs[this.activeTabIndex].webview.back();
this.$backButton.addEventListener("click", async () => {
const tab = this.tabs[this.activeTabIndex];
if (tab instanceof ServerTab) (await tab.webview).back();
});
this.sidebarHoverEvent(this.$addServerButton, this.$addServerTooltip, true);
@@ -461,9 +458,9 @@ class ServerManagerView {
this.sidebarHoverEvent(this.$dndButton, this.$dndTooltip);
}
initDNDButton(): void {
initDndButton(): void {
const dnd = ConfigUtil.getConfigItem("dnd", false);
this.toggleDNDButton(dnd);
this.toggleDndButton(dnd);
}
getTabIndex(): number {
@@ -472,8 +469,9 @@ class ServerManagerView {
return currentIndex;
}
getCurrentActiveServer(): string {
return this.tabs[this.activeTabIndex].webview.props.url;
async getCurrentActiveServer(): Promise<string> {
const tab = this.tabs[this.activeTabIndex];
return tab instanceof ServerTab ? (await tab.webview).props.url : "";
}
displayInitialCharLogo($img: HTMLImageElement, index: number): void {
@@ -502,7 +500,7 @@ class ServerManagerView {
$img.remove();
$parent.append($altIcon);
this.addContextMenu($altIcon as HTMLImageElement, index);
this.addContextMenu($altIcon, index);
}
sidebarHoverEvent(
@@ -533,9 +531,8 @@ class ServerManagerView {
// To handle position of servers' tooltip due to scrolling of list of organizations
// This could not be handled using CSS, hence the top of the tooltip is made same
// as that of its parent element.
const {top} = this.$serverIconTooltip[
index
].parentElement!.getBoundingClientRect();
const {top} =
this.$serverIconTooltip[index].parentElement!.getBoundingClientRect();
this.$serverIconTooltip[index].style.top = `${top}px`;
}
@@ -543,15 +540,23 @@ class ServerManagerView {
this.$serverIconTooltip[index].style.display = "none";
}
openFunctionalTab(tabProps: FunctionalTabProps): void {
async openFunctionalTab(tabProps: {
name: string;
materialIcon: string;
makeView: () => Element;
destroyView: () => void;
}): Promise<void> {
if (this.functionalTabs.has(tabProps.name)) {
this.activateTab(this.functionalTabs.get(tabProps.name)!);
await this.activateTab(this.functionalTabs.get(tabProps.name)!);
return;
}
this.functionalTabs.set(tabProps.name, this.tabs.length);
const index = this.tabs.length;
this.functionalTabs.set(tabProps.name, index);
const tabIndex = this.getTabIndex();
const $view = tabProps.makeView();
this.$webviewsContainer.append($view);
this.tabs.push(
new FunctionalTab({
@@ -559,46 +564,14 @@ class ServerManagerView {
materialIcon: tabProps.materialIcon,
name: tabProps.name,
$root: this.$tabsContainer,
index: this.functionalTabs.get(tabProps.name)!,
index,
tabIndex,
onClick: this.activateTab.bind(
this,
this.functionalTabs.get(tabProps.name)!,
),
onDestroy: this.destroyTab.bind(
this,
tabProps.name,
this.functionalTabs.get(tabProps.name)!,
),
webview: new WebView({
$root: this.$webviewsContainer,
index: this.functionalTabs.get(tabProps.name)!,
tabIndex,
url: tabProps.url,
role: "function",
name: tabProps.name,
isActive: () =>
this.functionalTabs.get(tabProps.name) === this.activeTabIndex,
switchLoading: (loading: boolean, url: string) => {
if (loading) {
this.loading.add(url);
} else {
this.loading.delete(url);
}
this.showLoading(
this.loading.has(
this.tabs[this.activeTabIndex].webview.props.url,
),
);
},
onNetworkError: (index: number) => {
this.openNetworkTroubleshooting(index);
},
onTitleChange: this.updateBadge.bind(this),
nodeIntegration: true,
preload: false,
}),
onClick: this.activateTab.bind(this, index),
onDestroy: async () => {
await this.destroyTab(tabProps.name, index);
tabProps.destroyView();
},
$view,
}),
);
@@ -606,42 +579,57 @@ class ServerManagerView {
// closed when the functional tab DOM is ready, handled in webview.js
this.$webviewsContainer.classList.remove("loaded");
this.activateTab(this.functionalTabs.get(tabProps.name)!);
await this.activateTab(this.functionalTabs.get(tabProps.name)!);
}
async openSettings(nav: NavItem = "General"): Promise<void> {
this.openFunctionalTab({
await this.openFunctionalTab({
name: "Settings",
materialIcon: "settings",
url: `file://${rendererDirectory}/preference.html#${nav}`,
makeView: () => {
this.preferenceView = new PreferenceView();
this.preferenceView.$view.classList.add("functional-view");
return this.preferenceView.$view;
},
destroyView: () => {
this.preferenceView!.destroy();
this.preferenceView = undefined;
},
});
this.$settingsButton.classList.add("active");
await this.tabs[this.functionalTabs.get("Settings")!].webview.send(
"switch-settings-nav",
nav,
);
this.preferenceView!.handleNavigation(nav);
}
openAbout(): void {
this.openFunctionalTab({
async openAbout(): Promise<void> {
let aboutView: AboutView;
await this.openFunctionalTab({
name: "About",
materialIcon: "sentiment_very_satisfied",
url: `file://${rendererDirectory}/about.html`,
makeView() {
aboutView = new AboutView();
aboutView.$view.classList.add("functional-view");
return aboutView.$view;
},
destroyView() {
aboutView.destroy();
},
});
}
openNetworkTroubleshooting(index: number): void {
const reconnectUtil = new ReconnectUtil(this.tabs[index].webview);
async openNetworkTroubleshooting(index: number): Promise<void> {
const tab = this.tabs[index];
if (!(tab instanceof ServerTab)) return;
const webview = await tab.webview;
const reconnectUtil = new ReconnectUtil(webview);
reconnectUtil.pollInternetAndReload();
this.tabs[
index
].webview.props.url = `file://${rendererDirectory}/network.html`;
this.tabs[index].showNetworkError();
await webview
.getWebContents()
.loadURL(`file://${rendererDirectory}/network.html`);
}
activateLastTab(index: number): void {
async activateLastTab(index: number): Promise<void> {
// Open all the tabs in background, also activate the tab based on the index
this.activateTab(index);
await this.activateTab(index);
// Save last active tab via main process to avoid JSON DB errors
ipcRenderer.send("save-last-tab", index);
}
@@ -655,12 +643,12 @@ class ServerManagerView {
role: tab.props.role,
name: tab.props.name,
index: tab.props.index,
webviewName: tab.webview.props.name,
}));
}
activateTab(index: number, hideOldTab = true): void {
if (!this.tabs[index]) {
async activateTab(index: number, hideOldTab = true): Promise<void> {
const tab = this.tabs[index];
if (!tab) {
return;
}
@@ -678,19 +666,26 @@ class ServerManagerView {
this.$settingsButton.classList.remove("active");
}
this.tabs[this.activeTabIndex].deactivate();
await this.tabs[this.activeTabIndex].deactivate();
}
}
try {
this.tabs[index].webview.canGoBackButton();
} catch {}
if (tab instanceof ServerTab) {
try {
(await tab.webview).canGoBackButton();
} catch {}
} else {
document
.querySelector("#actions-container #back-action")!
.classList.add("disable");
}
this.activeTabIndex = index;
this.tabs[index].activate();
await tab.activate();
this.showLoading(
this.loading.has(this.tabs[this.activeTabIndex].webview.props.url),
tab instanceof ServerTab &&
this.loading.has((await tab.webview).props.url),
);
ipcRenderer.send("update-menu", {
@@ -699,7 +694,7 @@ class ServerManagerView {
tabs: this.tabsForIpc,
activeTabIndex: this.activeTabIndex,
// Following flag controls whether a menu item should be enabled or not
enableMenu: this.tabs[index].props.role === "server",
enableMenu: tab.props.role === "server",
});
}
@@ -713,19 +708,20 @@ class ServerManagerView {
}
}
destroyTab(name: string, index: number): void {
if (this.tabs[index].webview.loading) {
async destroyTab(name: string, index: number): Promise<void> {
const tab = this.tabs[index];
if (tab instanceof ServerTab && (await tab.webview).loading) {
return;
}
this.tabs[index].destroy();
await tab.destroy();
delete this.tabs[index];
this.functionalTabs.delete(name);
// Issue #188: If the functional tab was not focused, do not activate another tab.
if (this.activeTabIndex === index) {
this.activateTab(0, false);
await this.activateTab(0, false);
}
}
@@ -760,29 +756,21 @@ class ServerManagerView {
this.$reloadButton.click();
}
updateBadge(): void {
async updateBadge(): Promise<void> {
let messageCountAll = 0;
for (const tab of this.tabs) {
if (tab && tab instanceof ServerTab && tab.updateBadge) {
const count = tab.webview.badgeCount;
messageCountAll += count;
tab.updateBadge(count);
}
}
await Promise.all(
this.tabs.map(async (tab) => {
if (tab && tab instanceof ServerTab && tab.updateBadge) {
const count = (await tab.webview).badgeCount;
messageCountAll += count;
tab.updateBadge(count);
}
}),
);
ipcRenderer.send("update-badge", messageCountAll);
}
updateGeneralSettings<Channel extends keyof RendererMessage>(
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
): void {
if (this.getActiveWebview()) {
const webContentsId = this.getActiveWebview().getWebContentsId();
ipcRenderer.sendTo(webContentsId, channel, ...args);
}
}
toggleSidebar(show: boolean): void {
if (show) {
this.$sidebar.classList.remove("sidebar-hide");
@@ -792,7 +780,7 @@ class ServerManagerView {
}
// Toggles the dnd button icon.
toggleDNDButton(alert: boolean): void {
toggleDndButton(alert: boolean): void {
this.$dndTooltip.textContent =
(alert ? "Disable" : "Enable") + " Do Not Disturb";
this.$dndButton.querySelector("i")!.textContent = alert
@@ -800,24 +788,21 @@ class ServerManagerView {
: "notifications";
}
isLoggedIn(tabIndex: number): boolean {
const url = this.tabs[tabIndex].webview.$el!.src;
return !(url.endsWith("/login/") || this.tabs[tabIndex].webview.loading);
async isLoggedIn(tabIndex: number): Promise<boolean> {
const tab = this.tabs[tabIndex];
if (!(tab instanceof ServerTab)) return false;
const webview = await tab.webview;
const url = webview.getWebContents().getURL();
return !(url.endsWith("/login/") || webview.loading);
}
getActiveWebview(): Electron.WebviewTag {
const selector = "webview:not(.disabled)";
const webview: Electron.WebviewTag = document.querySelector(selector)!;
return webview;
}
addContextMenu($serverImg: HTMLImageElement, index: number): void {
$serverImg.addEventListener("contextmenu", (event) => {
addContextMenu($serverImg: HTMLElement, index: number): void {
$serverImg.addEventListener("contextmenu", async (event) => {
event.preventDefault();
const template = [
{
label: "Disconnect organization",
click: async () => {
async click() {
const {response} = await dialog.showMessageBox({
type: "warning",
buttons: ["YES", "NO"],
@@ -838,16 +823,18 @@ class ServerManagerView {
},
{
label: "Notification settings",
enabled: this.isLoggedIn(index),
enabled: await this.isLoggedIn(index),
click: async () => {
// Switch to tab whose icon was right-clicked
this.activateTab(index);
await this.tabs[index].webview.showNotificationSettings();
await this.activateTab(index);
const tab = this.tabs[index];
if (tab instanceof ServerTab)
(await tab.webview).showNotificationSettings();
},
},
{
label: "Copy Zulip URL",
click: () => {
click() {
clipboard.writeText(DomainUtil.getDomain(index).url);
},
},
@@ -859,7 +846,7 @@ class ServerManagerView {
registerIpcs(): void {
const webviewListeners: Array<
[WebviewListener, (webview: WebView) => void | Promise<void>]
[WebviewListener, (webview: WebView) => void]
> = [
[
"webview-reload",
@@ -905,14 +892,14 @@ class ServerManagerView {
],
[
"log-out",
async (webview) => {
await webview.logOut();
(webview) => {
webview.logOut();
},
],
[
"show-keyboard-shortcuts",
async (webview) => {
await webview.showKeyboardShortcuts();
(webview) => {
webview.showKeyboardShortcuts();
},
],
[
@@ -925,16 +912,17 @@ class ServerManagerView {
for (const [channel, listener] of webviewListeners) {
ipcRenderer.on(channel, async () => {
const activeWebview = this.tabs[this.activeTabIndex].webview;
if (activeWebview) {
await listener(activeWebview);
const tab = this.tabs[this.activeTabIndex];
if (tab instanceof ServerTab) {
const activeWebview = await tab.webview;
if (activeWebview) listener(activeWebview);
}
});
}
ipcRenderer.on(
"permission-request",
(
async (
event: Event,
{
webContentsId,
@@ -950,12 +938,18 @@ class ServerManagerView {
const grant =
webContentsId === null
? origin === "null" && permission === "notifications"
: this.tabs.some(
({webview}) =>
!webview.loading &&
webview.$el!.getWebContentsId() === webContentsId &&
webview.props.hasPermission?.(origin, permission),
);
: (
await Promise.all(
this.tabs.map(async (tab) => {
if (!(tab instanceof ServerTab)) return false;
const webview = await tab.webview;
return (
webview.webContentsId === webContentsId &&
webview.props.hasPermission?.(origin, permission)
);
}),
)
).some(Boolean);
console.log(
grant ? "Granted" : "Denied",
"permissions request for",
@@ -967,10 +961,6 @@ class ServerManagerView {
},
);
ipcRenderer.on("show-network-error", (event: Event, index: number) => {
this.openNetworkTroubleshooting(index);
});
ipcRenderer.on("open-settings", async () => {
await this.openSettings();
});
@@ -993,8 +983,8 @@ class ServerManagerView {
ipcRenderer.send("reload-full-app");
});
ipcRenderer.on("switch-server-tab", (event: Event, index: number) => {
this.activateLastTab(index);
ipcRenderer.on("switch-server-tab", async (event: Event, index: number) => {
await this.activateLastTab(index);
});
ipcRenderer.on("open-org-tab", async () => {
@@ -1012,56 +1002,45 @@ class ServerManagerView {
}
});
ipcRenderer.on("toggle-sidebar", (event: Event, show: boolean) => {
ipcRenderer.on("toggle-sidebar", async (event: Event, show: boolean) => {
// Toggle the left sidebar
this.toggleSidebar(show);
// Toggle sidebar switch in the general settings
this.updateGeneralSettings("toggle-sidebar-setting", show);
});
ipcRenderer.on("toggle-silent", (event: Event, state: boolean) => {
const webviews: NodeListOf<Electron.WebviewTag> = document.querySelectorAll(
"webview",
);
for (const webview of webviews) {
try {
webview.setAudioMuted(state);
} catch {
// Webview is not ready yet
webview.addEventListener("dom-ready", () => {
webview.setAudioMuted(state);
});
}
}
});
ipcRenderer.on("toggle-silent", async (event: Event, state: boolean) =>
Promise.all(
this.tabs.map(async (tab) => {
if (tab instanceof ServerTab)
(await tab.webview).getWebContents().setAudioMuted(state);
}),
),
);
ipcRenderer.on(
"toggle-autohide-menubar",
(event: Event, autoHideMenubar: boolean, updateMenu: boolean) => {
async (event: Event, autoHideMenubar: boolean, updateMenu: boolean) => {
if (updateMenu) {
ipcRenderer.send("update-menu", {
tabs: this.tabsForIpc,
activeTabIndex: this.activeTabIndex,
});
return;
}
this.updateGeneralSettings("toggle-menubar-setting", autoHideMenubar);
},
);
ipcRenderer.on(
"toggle-dnd",
(event: Event, state: boolean, newSettings: Partial<DNDSettings>) => {
this.toggleDNDButton(state);
async (
event: Event,
state: boolean,
newSettings: Partial<DndSettings>,
) => {
this.toggleDndButton(state);
ipcRenderer.send(
"forward-message",
"toggle-silent",
newSettings.silent ?? false,
);
const webContentsId = this.getActiveWebview().getWebContentsId();
ipcRenderer.sendTo(webContentsId, "toggle-dnd", state, newSettings);
},
);
@@ -1076,7 +1055,6 @@ class ServerManagerView {
);
serverTooltips[index].textContent = realmName;
this.tabs[index].props.name = realmName;
this.tabs[index].webview.props.name = realmName;
domain.alias = realmName;
DomainUtil.updateDomain(index, domain);
@@ -1100,9 +1078,8 @@ class ServerManagerView {
iconURL,
);
const serverImgsSelector = ".tab .server-icons";
const serverImgs: NodeListOf<HTMLImageElement> = document.querySelectorAll(
serverImgsSelector,
);
const serverImgs: NodeListOf<HTMLImageElement> =
document.querySelectorAll(serverImgsSelector);
serverImgs[index].src = localIconUrl;
domain.icon = localIconUrl;
DomainUtil.updateDomain(index, domain);
@@ -1123,21 +1100,20 @@ class ServerManagerView {
ipcRenderer.on(
"focus-webview-with-id",
(event: Event, webviewId: number) => {
const webviews: NodeListOf<Electron.WebviewTag> = document.querySelectorAll(
"webview",
);
for (const webview of webviews) {
const currentId = webview.getWebContentsId();
const tabId = webview.getAttribute("data-tab-id")!;
const concurrentTab: HTMLButtonElement = document.querySelector(
`div[data-tab-id="${CSS.escape(tabId)}"]`,
)!;
if (currentId === webviewId) {
concurrentTab.click();
}
}
},
async (event: Event, webviewId: number) =>
Promise.all(
this.tabs.map(async (tab) => {
if (
tab instanceof ServerTab &&
(await tab.webview).webContentsId === webviewId
) {
const concurrentTab: HTMLButtonElement = document.querySelector(
`div[data-tab-id="${CSS.escape(`${tab.props.tabIndex}`)}"]`,
)!;
concurrentTab.click();
}
}),
),
);
ipcRenderer.on(
@@ -1178,39 +1154,37 @@ class ServerManagerView {
},
);
ipcRenderer.on("open-feedback-modal", () => {
feedbackHolder.classList.add("show");
});
ipcRenderer.on("copy-zulip-url", () => {
clipboard.writeText(this.getCurrentActiveServer());
ipcRenderer.on("copy-zulip-url", async () => {
clipboard.writeText(await this.getCurrentActiveServer());
});
ipcRenderer.on("new-server", async () => {
await this.openSettings("AddServer");
});
ipcRenderer.on("set-active", () => {
const webviews: NodeListOf<Electron.WebviewTag> = document.querySelectorAll(
"webview",
);
for (const webview of webviews) {
ipcRenderer.sendTo(webview.getWebContentsId(), "set-active");
}
});
ipcRenderer.on("set-active", async () =>
Promise.all(
this.tabs.map(async (tab) => {
if (tab instanceof ServerTab) (await tab.webview).send("set-active");
}),
),
);
ipcRenderer.on("set-idle", () => {
const webviews: NodeListOf<Electron.WebviewTag> = document.querySelectorAll(
"webview",
);
for (const webview of webviews) {
ipcRenderer.sendTo(webview.getWebContentsId(), "set-idle");
}
});
ipcRenderer.on("set-idle", async () =>
Promise.all(
this.tabs.map(async (tab) => {
if (tab instanceof ServerTab) (await tab.webview).send("set-idle");
}),
),
);
ipcRenderer.on("open-network-settings", async () => {
await this.openSettings("Network");
});
ipcRenderer.on("play-ding-sound", async () => {
await dingSound.play();
});
}
}
@@ -1218,5 +1192,3 @@ window.addEventListener("load", async () => {
const serverManagerView = new ServerManagerView();
await serverManagerView.init();
});
export {};

View File

@@ -1,30 +0,0 @@
import * as ConfigUtil from "../../../common/config-util";
import {ipcRenderer} from "../typed-ipc-renderer";
import {focusCurrentServer} from "./helpers";
const NativeNotification = window.Notification;
export default class BaseNotification extends NativeNotification {
constructor(title: string, options: NotificationOptions) {
options.silent = true;
super(title, options);
this.addEventListener("click", () => {
// Focus to the server who sent the
// notification if not focused already
focusCurrentServer();
ipcRenderer.send("focus-app");
});
}
static async requestPermission(): Promise<NotificationPermission> {
return this.permission;
}
// Override default Notification permission
static get permission(): NotificationPermission {
return ConfigUtil.getConfigItem("showNotification", true)
? "granted"
: "denied";
}
}

View File

@@ -1,20 +0,0 @@
import {remote} from "electron";
import {ipcRenderer} from "../typed-ipc-renderer";
// Do not change this
export const appId = "org.zulip.zulip-electron";
const currentWindow = remote.getCurrentWindow();
const webContents = remote.getCurrentWebContents();
const webContentsId = webContents.id;
// This function will focus the server that sent
// the notification. Main function implemented in main.js
export function focusCurrentServer(): void {
ipcRenderer.sendTo(
currentWindow.webContents.id,
"focus-webview-with-id",
webContentsId,
);
}

View File

@@ -1,13 +1,4 @@
import {remote} from "electron";
import DefaultNotification from "./default-notification";
import {appId} from "./helpers";
const {app} = remote;
// From https://github.com/felixrieseberg/electron-windows-notifications#appusermodelid
// On windows 8 we have to explicitly set the appUserModelId otherwise notification won't work.
app.setAppUserModelId(appId);
import {ipcRenderer} from "../typed-ipc-renderer";
export interface NotificationData {
close: () => void;
@@ -16,16 +7,8 @@ export interface NotificationData {
lang: string;
body: string;
tag: string;
image: string;
icon: string;
badge: string;
vibrate: readonly number[];
timestamp: number;
renotify: boolean;
silent: boolean;
requireInteraction: boolean;
data: unknown;
actions: readonly NotificationAction[];
}
export function newNotification(
@@ -33,9 +16,10 @@ export function newNotification(
options: NotificationOptions,
dispatch: (type: string, eventInit: EventInit) => boolean,
): NotificationData {
const notification = new DefaultNotification(title, options);
const notification = new Notification(title, {...options, silent: true});
for (const type of ["click", "close", "error", "show"]) {
notification.addEventListener(type, (ev: Event) => {
if (type === "click") ipcRenderer.send("focus-this-webview");
if (!dispatch(type, ev)) {
ev.preventDefault();
}
@@ -43,7 +27,7 @@ export function newNotification(
}
return {
close: () => {
close() {
notification.close();
},
title: notification.title,
@@ -51,15 +35,7 @@ export function newNotification(
lang: notification.lang,
body: notification.body,
tag: notification.tag,
image: notification.image,
icon: notification.icon,
badge: notification.badge,
vibrate: notification.vibrate,
timestamp: notification.timestamp,
renotify: notification.renotify,
silent: notification.silent,
requireInteraction: notification.requireInteraction,
data: notification.data,
actions: notification.actions,
};
}

View File

@@ -0,0 +1,44 @@
import {app} from "@electron/remote";
import {html} from "../../../common/html";
export class AboutView {
readonly $view: HTMLElement;
constructor() {
this.$view = document.createElement("div");
const $shadow = this.$view.attachShadow({mode: "open"});
$shadow.innerHTML = html`
<link rel="stylesheet" href="css/about.css" />
<!-- Initially hidden to prevent FOUC -->
<div class="about" hidden>
<img class="logo" src="../resources/zulip.png" />
<p class="detail" id="version">v${app.getVersion()}</p>
<div class="maintenance-info">
<p class="detail maintainer">
Maintained by
<a
href="https://zulip.com"
target="_blank"
rel="noopener noreferrer"
>Zulip</a
>
</p>
<p class="detail license">
Available under the
<a
href="https://github.com/zulip/zulip-desktop/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
>Apache 2.0 License</a
>
</p>
</div>
</div>
`.html;
}
destroy() {
// Do nothing.
}
}

View File

@@ -1,6 +1,6 @@
import type {HTML} from "../../../../common/html";
import type {Html} from "../../../../common/html";
import {html} from "../../../../common/html";
import {generateNodeFromHTML} from "../../components/base";
import {generateNodeFromHtml} from "../../components/base";
import {ipcRenderer} from "../../typed-ipc-renderer";
interface BaseSectionProps {
@@ -15,8 +15,8 @@ export function generateSettingOption(props: BaseSectionProps): void {
$element.textContent = "";
const $optionControl = generateNodeFromHTML(
generateOptionHTML(value, disabled),
const $optionControl = generateNodeFromHtml(
generateOptionHtml(value, disabled),
);
$element.append($optionControl);
@@ -25,12 +25,13 @@ export function generateSettingOption(props: BaseSectionProps): void {
}
}
export function generateOptionHTML(
export function generateOptionHtml(
settingOption: boolean,
disabled?: boolean,
): HTML {
const labelHTML = disabled
? html`<label
): Html {
const labelHtml = disabled
? // eslint-disable-next-line unicorn/template-indent
html`<label
class="disallowed"
title="Setting locked by system administrator."
></label>`
@@ -40,7 +41,7 @@ export function generateOptionHTML(
<div class="action">
<div class="switch">
<input class="toggle toggle-round" type="checkbox" checked disabled />
${labelHTML}
${labelHtml}
</div>
</div>
`;
@@ -50,7 +51,7 @@ export function generateOptionHTML(
<div class="action">
<div class="switch">
<input class="toggle toggle-round" type="checkbox" />
${labelHTML}
${labelHtml}
</div>
</div>
`;
@@ -59,12 +60,12 @@ export function generateOptionHTML(
/* A method that in future can be used to create dropdown menus using <select> <option> tags.
it needs an object which has ``key: value`` pairs and will return a string that can be appended to HTML
*/
export function generateSelectHTML(
export function generateSelectHtml(
options: Record<string, string>,
className?: string,
idName?: string,
): HTML {
const optionsHTML = html``.join(
): Html {
const optionsHtml = html``.join(
Object.keys(options).map(
(key) => html`
<option name="${key}" value="${key}">${options[key]}</option>
@@ -73,7 +74,7 @@ export function generateSelectHTML(
);
return html`
<select class="${className}" id="${idName}">
${optionsHTML}
${optionsHtml}
</select>
`;
}

View File

@@ -11,11 +11,13 @@ interface ConnectedOrgSectionProps {
$root: Element;
}
export function initConnectedOrgSection(props: ConnectedOrgSectionProps): void {
props.$root.textContent = "";
export function initConnectedOrgSection({
$root,
}: ConnectedOrgSectionProps): void {
$root.textContent = "";
const servers = DomainUtil.getDomains();
props.$root.innerHTML = html`
$root.innerHTML = html`
<div class="settings-pane" id="server-settings-pane">
<div class="page-title">${t.__("Connected organizations")}</div>
<div class="title" id="existing-servers">
@@ -32,14 +34,11 @@ export function initConnectedOrgSection(props: ConnectedOrgSectionProps): void {
</div>
`.html;
const $serverInfoContainer = document.querySelector(
"#server-info-container",
)!;
const $existingServers = document.querySelector("#existing-servers")!;
const $newOrgButton: HTMLButtonElement = document.querySelector(
"#new-org-button",
)!;
const $findAccountsContainer = document.querySelector(
const $serverInfoContainer = $root.querySelector("#server-info-container")!;
const $existingServers = $root.querySelector("#existing-servers")!;
const $newOrgButton: HTMLButtonElement =
$root.querySelector("#new-org-button")!;
const $findAccountsContainer = $root.querySelector(
"#find-accounts-container",
)!;

View File

@@ -1,7 +1,7 @@
import {html} from "../../../../common/html";
import * as LinkUtil from "../../../../common/link-util";
import * as t from "../../../../common/translation-util";
import {generateNodeFromHTML} from "../../components/base";
import * as LinkUtil from "../../utils/link-util";
import {generateNodeFromHtml} from "../../components/base";
interface FindAccountsProps {
$root: Element;
@@ -20,7 +20,7 @@ async function findAccounts(url: string): Promise<void> {
}
export function initFindAccounts(props: FindAccountsProps): void {
const $findAccounts = generateNodeFromHTML(html`
const $findAccounts = generateNodeFromHtml(html`
<div class="settings-card certificate-card">
<div class="certificate-input">
<div>${t.__("Organization URL")}</div>

View File

@@ -1,10 +1,13 @@
import type {OpenDialogOptions} from "electron";
import {remote} from "electron";
import fs from "fs";
import path from "path";
import type {OpenDialogOptions} from "electron/renderer";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import * as remote from "@electron/remote";
import {app, dialog, session} from "@electron/remote";
import Tagify from "@yaireo/tagify";
import ISO6391 from "iso-639-1";
import * as z from "zod";
import * as ConfigUtil from "../../../../common/config-util";
import * as EnterpriseUtil from "../../../../common/enterprise-util";
@@ -13,17 +16,16 @@ import * as t from "../../../../common/translation-util";
import supportedLocales from "../../../../translations/supported-locales.json";
import {ipcRenderer} from "../../typed-ipc-renderer";
import {generateSelectHTML, generateSettingOption} from "./base-section";
import {generateSelectHtml, generateSettingOption} from "./base-section";
const {app, dialog, session} = remote;
const currentBrowserWindow = remote.getCurrentWindow();
interface GeneralSectionProps {
$root: Element;
}
export function initGeneralSection(props: GeneralSectionProps): void {
props.$root.innerHTML = html`
export function initGeneralSection({$root}: GeneralSectionProps): void {
$root.innerHTML = html`
<div class="settings-pane">
<div class="title">${t.__("Appearance")}</div>
<div id="appearance-option-settings" class="settings-card">
@@ -221,9 +223,9 @@ export function initGeneralSection(props: GeneralSectionProps): void {
showDesktopNotification();
enableSpellchecker();
minimizeOnStart();
addCustomCSS();
showCustomCSSPath();
removeCustomCSS();
addCustomCss();
showCustomCssPath();
removeCustomCss();
downloadFolder();
updateQuitOnCloseOption();
updatePromptDownloadOption();
@@ -250,9 +252,9 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function updateTrayOption(): void {
generateSettingOption({
$element: document.querySelector("#tray-option .setting-control")!,
$element: $root.querySelector("#tray-option .setting-control")!,
value: ConfigUtil.getConfigItem("trayIcon", true),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("trayIcon", true);
ConfigUtil.setConfigItem("trayIcon", newValue);
ipcRenderer.send("forward-message", "toggletray");
@@ -263,9 +265,9 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function updateMenubarOption(): void {
generateSettingOption({
$element: document.querySelector("#menubar-option .setting-control")!,
$element: $root.querySelector("#menubar-option .setting-control")!,
value: ConfigUtil.getConfigItem("autoHideMenubar", false),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("autoHideMenubar", false);
ConfigUtil.setConfigItem("autoHideMenubar", newValue);
ipcRenderer.send("toggle-menubar", newValue);
@@ -276,9 +278,9 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function updateBadgeOption(): void {
generateSettingOption({
$element: document.querySelector("#badge-option .setting-control")!,
$element: $root.querySelector("#badge-option .setting-control")!,
value: ConfigUtil.getConfigItem("badgeOption", true),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("badgeOption", true);
ConfigUtil.setConfigItem("badgeOption", newValue);
ipcRenderer.send("toggle-badge-option", newValue);
@@ -289,9 +291,9 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function updateDockBouncing(): void {
generateSettingOption({
$element: document.querySelector("#dock-bounce-option .setting-control")!,
$element: $root.querySelector("#dock-bounce-option .setting-control")!,
value: ConfigUtil.getConfigItem("dockBouncing", true),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("dockBouncing", true);
ConfigUtil.setConfigItem("dockBouncing", newValue);
updateDockBouncing();
@@ -301,11 +303,9 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function updateFlashTaskbar(): void {
generateSettingOption({
$element: document.querySelector(
"#flash-taskbar-option .setting-control",
)!,
$element: $root.querySelector("#flash-taskbar-option .setting-control")!,
value: ConfigUtil.getConfigItem("flashTaskbarOnMessage", true),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem(
"flashTaskbarOnMessage",
true,
@@ -318,10 +318,10 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function autoUpdateOption(): void {
generateSettingOption({
$element: document.querySelector("#autoupdate-option .setting-control")!,
$element: $root.querySelector("#autoupdate-option .setting-control")!,
disabled: EnterpriseUtil.configItemExists("autoUpdate"),
value: ConfigUtil.getConfigItem("autoUpdate", true),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("autoUpdate", true);
ConfigUtil.setConfigItem("autoUpdate", newValue);
if (!newValue) {
@@ -336,9 +336,9 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function betaUpdateOption(): void {
generateSettingOption({
$element: document.querySelector("#betaupdate-option .setting-control")!,
$element: $root.querySelector("#betaupdate-option .setting-control")!,
value: ConfigUtil.getConfigItem("betaUpdate", false),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("betaUpdate", false);
if (ConfigUtil.getConfigItem("autoUpdate", true)) {
ConfigUtil.setConfigItem("betaUpdate", newValue);
@@ -350,9 +350,9 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function updateSilentOption(): void {
generateSettingOption({
$element: document.querySelector("#silent-option .setting-control")!,
$element: $root.querySelector("#silent-option .setting-control")!,
value: ConfigUtil.getConfigItem("silent", false),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("silent", true);
ConfigUtil.setConfigItem("silent", newValue);
updateSilentOption();
@@ -367,11 +367,11 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function showDesktopNotification(): void {
generateSettingOption({
$element: document.querySelector(
$element: $root.querySelector(
"#show-notification-option .setting-control",
)!,
value: ConfigUtil.getConfigItem("showNotification", true),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("showNotification", true);
ConfigUtil.setConfigItem("showNotification", newValue);
showDesktopNotification();
@@ -381,9 +381,9 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function updateSidebarOption(): void {
generateSettingOption({
$element: document.querySelector("#sidebar-option .setting-control")!,
$element: $root.querySelector("#sidebar-option .setting-control")!,
value: ConfigUtil.getConfigItem("showSidebar", true),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("showSidebar", true);
ConfigUtil.setConfigItem("showSidebar", newValue);
ipcRenderer.send("forward-message", "toggle-sidebar", newValue);
@@ -394,11 +394,9 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function updateStartAtLoginOption(): void {
generateSettingOption({
$element: document.querySelector(
"#startAtLogin-option .setting-control",
)!,
$element: $root.querySelector("#startAtLogin-option .setting-control")!,
value: ConfigUtil.getConfigItem("startAtLogin", false),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("startAtLogin", false);
ConfigUtil.setConfigItem("startAtLogin", newValue);
ipcRenderer.send("toggleAutoLauncher", newValue);
@@ -409,9 +407,9 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function updateQuitOnCloseOption(): void {
generateSettingOption({
$element: document.querySelector("#quitOnClose-option .setting-control")!,
$element: $root.querySelector("#quitOnClose-option .setting-control")!,
value: ConfigUtil.getConfigItem("quitOnClose", false),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("quitOnClose", false);
ConfigUtil.setConfigItem("quitOnClose", newValue);
updateQuitOnCloseOption();
@@ -421,18 +419,18 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function enableSpellchecker(): void {
generateSettingOption({
$element: document.querySelector(
$element: $root.querySelector(
"#enable-spellchecker-option .setting-control",
)!,
value: ConfigUtil.getConfigItem("enableSpellchecker", true),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("enableSpellchecker", true);
ConfigUtil.setConfigItem("enableSpellchecker", newValue);
ipcRenderer.send("configure-spell-checker");
enableSpellchecker();
const spellcheckerLanguageInput: HTMLElement = document.querySelector(
"#spellcheck-langs",
)!;
const spellcheckerNote: HTMLElement = document.querySelector("#note")!;
const spellcheckerLanguageInput: HTMLElement =
$root.querySelector("#spellcheck-langs")!;
const spellcheckerNote: HTMLElement = $root.querySelector("#note")!;
spellcheckerLanguageInput.style.display =
spellcheckerLanguageInput.style.display === "none" ? "" : "none";
spellcheckerNote.style.display =
@@ -443,11 +441,11 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function enableErrorReporting(): void {
generateSettingOption({
$element: document.querySelector(
$element: $root.querySelector(
"#enable-error-reporting .setting-control",
)!,
value: ConfigUtil.getConfigItem("errorReporting", true),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("errorReporting", true);
ConfigUtil.setConfigItem("errorReporting", newValue);
enableErrorReporting();
@@ -472,11 +470,11 @@ export function initGeneralSection(props: GeneralSectionProps): void {
}
function setLocale(): void {
const langDiv: HTMLSelectElement = document.querySelector(".lang-div")!;
const langListHTML = generateSelectHTML(supportedLocales, "lang-menu");
langDiv.innerHTML += langListHTML.html;
const langDiv: HTMLSelectElement = $root.querySelector(".lang-div")!;
const langListHtml = generateSelectHtml(supportedLocales, "lang-menu");
langDiv.innerHTML += langListHtml.html;
// `langMenu` is the select-option dropdown menu formed after executing the previous command
const langMenu: HTMLSelectElement = document.querySelector(".lang-menu")!;
const langMenu: HTMLSelectElement = $root.querySelector(".lang-menu")!;
// The next three lines set the selected language visible on the dropdown button
let language = ConfigUtil.getConfigItem("appLanguage", "en");
@@ -491,11 +489,9 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function minimizeOnStart(): void {
generateSettingOption({
$element: document.querySelector(
"#start-minimize-option .setting-control",
)!,
$element: $root.querySelector("#start-minimize-option .setting-control")!,
value: ConfigUtil.getConfigItem("startMinimized", false),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("startMinimized", false);
ConfigUtil.setConfigItem("startMinimized", newValue);
minimizeOnStart();
@@ -503,27 +499,25 @@ export function initGeneralSection(props: GeneralSectionProps): void {
});
}
function addCustomCSS(): void {
const customCSSButton = document.querySelector(
function addCustomCss(): void {
const customCssButton = $root.querySelector(
"#add-custom-css .custom-css-button",
)!;
customCSSButton.addEventListener("click", async () => {
customCssButton.addEventListener("click", async () => {
await customCssDialog();
});
}
function showCustomCSSPath(): void {
function showCustomCssPath(): void {
if (!ConfigUtil.getConfigItem("customCSS", null)) {
const cssPATH: HTMLElement = document.querySelector(
"#remove-custom-css",
)!;
cssPATH.style.display = "none";
const cssPath: HTMLElement = $root.querySelector("#remove-custom-css")!;
cssPath.style.display = "none";
}
}
function removeCustomCSS(): void {
const removeCSSButton = document.querySelector("#css-delete-action")!;
removeCSSButton.addEventListener("click", () => {
function removeCustomCss(): void {
const removeCssButton = $root.querySelector("#css-delete-action")!;
removeCssButton.addEventListener("click", () => {
ConfigUtil.setConfigItem("customCSS", "");
ipcRenderer.send("forward-message", "hard-reload");
});
@@ -540,7 +534,7 @@ export function initGeneralSection(props: GeneralSectionProps): void {
);
if (!canceled) {
ConfigUtil.setConfigItem("downloadsPath", filePaths[0]);
const downloadFolderPath: HTMLElement = document.querySelector(
const downloadFolderPath: HTMLElement = $root.querySelector(
".download-folder-path",
)!;
downloadFolderPath.textContent = filePaths[0];
@@ -548,7 +542,7 @@ export function initGeneralSection(props: GeneralSectionProps): void {
}
function downloadFolder(): void {
const downloadFolder = document.querySelector(
const downloadFolder = $root.querySelector(
"#download-folder .download-folder-button",
)!;
downloadFolder.addEventListener("click", async () => {
@@ -558,9 +552,9 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function updatePromptDownloadOption(): void {
generateSettingOption({
$element: document.querySelector("#prompt-download .setting-control")!,
$element: $root.querySelector("#prompt-download .setting-control")!,
value: ConfigUtil.getConfigItem("promptDownload", false),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("promptDownload", false);
ConfigUtil.setConfigItem("promptDownload", newValue);
updatePromptDownloadOption();
@@ -589,7 +583,7 @@ export function initGeneralSection(props: GeneralSectionProps): void {
}
function factoryReset(): void {
const factoryResetButton = document.querySelector(
const factoryResetButton = $root.querySelector(
"#factory-reset-option .factory-reset-button",
)!;
factoryResetButton.addEventListener("click", async () => {
@@ -600,7 +594,7 @@ export function initGeneralSection(props: GeneralSectionProps): void {
function initSpellChecker(): void {
// The elctron API is a no-op on macOS and macOS default spellchecker is used.
if (process.platform === "darwin") {
const note: HTMLElement = document.querySelector("#note")!;
const note: HTMLElement = $root.querySelector("#note")!;
note.append(t.__("On macOS, the OS spellchecker is used."));
note.append(document.createElement("br"));
note.append(
@@ -609,20 +603,19 @@ export function initGeneralSection(props: GeneralSectionProps): void {
),
);
} else {
const note: HTMLElement = document.querySelector("#note")!;
const note: HTMLElement = $root.querySelector("#note")!;
note.append(
t.__("You can select a maximum of 3 languages for spellchecking."),
);
const spellDiv: HTMLElement = document.querySelector(
"#spellcheck-langs",
)!;
const spellDiv: HTMLElement = $root.querySelector("#spellcheck-langs")!;
spellDiv.innerHTML += html`
<div class="setting-description">${t.__("Spellchecker Languages")}</div>
<input name="spellcheck" placeholder="Enter Languages" />
`.html;
const availableLanguages = session.fromPartition("persist:webviewsession")
.availableSpellCheckerLanguages;
const availableLanguages = session.fromPartition(
"persist:webviewsession",
).availableSpellCheckerLanguages;
let languagePairs: Map<string, string> = new Map();
for (const l of availableLanguages) {
if (ISO6391.validate(l)) {
@@ -647,7 +640,7 @@ export function initGeneralSection(props: GeneralSectionProps): void {
[...languagePairs].sort((a, b) => (a[0] < b[0] ? -1 : 1)),
);
const tagField: HTMLInputElement = document.querySelector(
const tagField: HTMLInputElement = $root.querySelector(
"input[name=spellcheck]",
)!;
const tagify = new Tagify(tagField, {
@@ -673,23 +666,24 @@ export function initGeneralSection(props: GeneralSectionProps): void {
tagField.addEventListener("change", () => {
if (tagField.value.length === 0) {
ConfigUtil.setConfigItem("spellcheckerLanguages", []);
ipcRenderer.send("set-spellcheck-langs");
ipcRenderer.send("configure-spell-checker");
} else {
const spellLangs: string[] = [...JSON.parse(tagField.value)].map(
(elt: {value: string}) => languagePairs.get(elt.value)!,
);
const data: unknown = JSON.parse(tagField.value);
const spellLangs: string[] = z
.array(z.object({value: z.string()}))
.parse(data)
.map((elt) => languagePairs.get(elt.value)!);
ConfigUtil.setConfigItem("spellcheckerLanguages", spellLangs);
ipcRenderer.send("set-spellcheck-langs");
ipcRenderer.send("configure-spell-checker");
}
});
}
// Do not display the spellchecker input and note if it is disabled
if (!ConfigUtil.getConfigItem("enableSpellchecker", true)) {
const spellcheckerLanguageInput: HTMLElement = document.querySelector(
"#spellcheck-langs",
)!;
const spellcheckerNote: HTMLElement = document.querySelector("#note")!;
const spellcheckerLanguageInput: HTMLElement =
$root.querySelector("#spellcheck-langs")!;
const spellcheckerNote: HTMLElement = $root.querySelector("#note")!;
spellcheckerLanguageInput.style.display = "none";
spellcheckerNote.style.display = "none";
}

View File

@@ -1,8 +1,8 @@
import type {HTML} from "../../../../common/html";
import type {Html} from "../../../../common/html";
import {html} from "../../../../common/html";
import * as t from "../../../../common/translation-util";
import type {NavItem} from "../../../../common/types";
import {generateNodeFromHTML} from "../../components/base";
import {generateNodeFromHtml} from "../../components/base";
interface PreferenceNavProps {
$root: Element;
@@ -23,13 +23,13 @@ export default class PreferenceNav {
"Shortcuts",
];
this.$el = generateNodeFromHTML(this.templateHTML());
this.$el = generateNodeFromHtml(this.templateHtml());
this.props.$root.append(this.$el);
this.registerListeners();
}
templateHTML(): HTML {
const navItemsHTML = html``.join(
templateHtml(): Html {
const navItemsHtml = html``.join(
this.navItems.map(
(navItem) => html`
<div class="nav" id="nav-${navItem}">${t.__(navItem)}</div>
@@ -40,14 +40,14 @@ export default class PreferenceNav {
return html`
<div>
<div id="settings-header">${t.__("Settings")}</div>
<div id="nav-container">${navItemsHTML}</div>
<div id="nav-container">${navItemsHtml}</div>
</div>
`;
}
registerListeners(): void {
for (const navItem of this.navItems) {
const $item = document.querySelector(`#nav-${CSS.escape(navItem)}`)!;
const $item = this.$el.querySelector(`#nav-${CSS.escape(navItem)}`)!;
$item.addEventListener("click", () => {
this.props.onItemSelected(navItem);
});
@@ -65,12 +65,12 @@ export default class PreferenceNav {
}
activate(navItem: NavItem): void {
const $item = document.querySelector(`#nav-${CSS.escape(navItem)}`)!;
const $item = this.$el.querySelector(`#nav-${CSS.escape(navItem)}`)!;
$item.classList.add("active");
}
deactivate(navItem: NavItem): void {
const $item = document.querySelector(`#nav-${CSS.escape(navItem)}`)!;
const $item = this.$el.querySelector(`#nav-${CSS.escape(navItem)}`)!;
$item.classList.remove("active");
}
}

View File

@@ -9,8 +9,8 @@ interface NetworkSectionProps {
$root: Element;
}
export function initNetworkSection(props: NetworkSectionProps): void {
props.$root.innerHTML = html`
export function initNetworkSection({$root}: NetworkSectionProps): void {
$root.innerHTML = html`
<div class="settings-pane">
<div class="title">${t.__("Proxy")}</div>
<div id="appearance-option-settings" class="settings-card">
@@ -55,27 +55,27 @@ export function initNetworkSection(props: NetworkSectionProps): void {
</div>
`.html;
const $proxyPAC: HTMLInputElement = document.querySelector(
const $proxyPac: HTMLInputElement = $root.querySelector(
"#proxy-pac-option .setting-input-value",
)!;
const $proxyRules: HTMLInputElement = document.querySelector(
const $proxyRules: HTMLInputElement = $root.querySelector(
"#proxy-rules-option .setting-input-value",
)!;
const $proxyBypass: HTMLInputElement = document.querySelector(
const $proxyBypass: HTMLInputElement = $root.querySelector(
"#proxy-bypass-option .setting-input-value",
)!;
const $proxySaveAction = document.querySelector("#proxy-save-action")!;
const $manualProxyBlock = props.$root.querySelector(".manual-proxy-block")!;
const $proxySaveAction = $root.querySelector("#proxy-save-action")!;
const $manualProxyBlock = $root.querySelector(".manual-proxy-block")!;
toggleManualProxySettings(ConfigUtil.getConfigItem("useManualProxy", false));
updateProxyOption();
$proxyPAC.value = ConfigUtil.getConfigItem("proxyPAC", "");
$proxyPac.value = ConfigUtil.getConfigItem("proxyPAC", "");
$proxyRules.value = ConfigUtil.getConfigItem("proxyRules", "");
$proxyBypass.value = ConfigUtil.getConfigItem("proxyBypass", "");
$proxySaveAction.addEventListener("click", () => {
ConfigUtil.setConfigItem("proxyPAC", $proxyPAC.value);
ConfigUtil.setConfigItem("proxyPAC", $proxyPac.value);
ConfigUtil.setConfigItem("proxyRules", $proxyRules.value);
ConfigUtil.setConfigItem("proxyBypass", $proxyBypass.value);
@@ -92,11 +92,9 @@ export function initNetworkSection(props: NetworkSectionProps): void {
function updateProxyOption(): void {
generateSettingOption({
$element: document.querySelector(
"#use-system-settings .setting-control",
)!,
$element: $root.querySelector("#use-system-settings .setting-control")!,
value: ConfigUtil.getConfigItem("useSystemProxy", false),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("useSystemProxy", false);
const manualProxyValue = ConfigUtil.getConfigItem(
"useManualProxy",
@@ -118,11 +116,9 @@ export function initNetworkSection(props: NetworkSectionProps): void {
},
});
generateSettingOption({
$element: document.querySelector(
"#use-manual-settings .setting-control",
)!,
$element: $root.querySelector("#use-manual-settings .setting-control")!,
value: ConfigUtil.getConfigItem("useManualProxy", false),
clickHandler: () => {
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("useManualProxy", false);
const systemProxyValue = ConfigUtil.getConfigItem(
"useSystemProxy",

View File

@@ -1,21 +1,19 @@
import {remote} from "electron";
import {dialog} from "@electron/remote";
import {html} from "../../../../common/html";
import * as LinkUtil from "../../../../common/link-util";
import * as t from "../../../../common/translation-util";
import {generateNodeFromHTML} from "../../components/base";
import {generateNodeFromHtml} from "../../components/base";
import {ipcRenderer} from "../../typed-ipc-renderer";
import * as DomainUtil from "../../utils/domain-util";
import * as LinkUtil from "../../utils/link-util";
const {dialog} = remote;
interface NewServerFormProps {
$root: Element;
onChange: () => void;
}
export function initNewServerForm(props: NewServerFormProps): void {
const $newServerForm = generateNodeFromHTML(html`
export function initNewServerForm({$root, onChange}: NewServerFormProps): void {
const $newServerForm = generateNodeFromHtml(html`
<div class="server-input-container">
<div class="title">${t.__("Organization URL")}</div>
<div class="add-server-info-row">
@@ -50,11 +48,10 @@ export function initNewServerForm(props: NewServerFormProps): void {
</div>
</div>
`);
const $saveServerButton: HTMLButtonElement = $newServerForm.querySelector(
"#connect",
)!;
props.$root.textContent = "";
props.$root.append($newServerForm);
const $saveServerButton: HTMLButtonElement =
$newServerForm.querySelector("#connect")!;
$root.textContent = "";
$root.append($newServerForm);
const $newServerUrl: HTMLInputElement = $newServerForm.querySelector(
"input.setting-input-value",
)!;
@@ -78,7 +75,7 @@ export function initNewServerForm(props: NewServerFormProps): void {
}
await DomainUtil.addDomain(serverConf);
props.onChange();
onChange();
}
$saveServerButton.addEventListener("click", async () => {
@@ -92,14 +89,14 @@ export function initNewServerForm(props: NewServerFormProps): void {
// Open create new org link in default browser
const link = "https://zulip.com/new/";
const externalCreateNewOrgElement = document.querySelector(
const externalCreateNewOrgElement = $root.querySelector(
"#open-create-org-link",
)!;
externalCreateNewOrgElement.addEventListener("click", async () => {
await LinkUtil.openBrowser(new URL(link));
});
const networkSettingsId = document.querySelector(".server-network-option")!;
const networkSettingsId = $root.querySelector(".server-network-option")!;
networkSettingsId.addEventListener("click", () => {
ipcRenderer.send("forward-message", "open-network-settings");
});

View File

@@ -1,4 +1,7 @@
import type {DNDSettings} from "../../../../common/dnd-util";
import process from "node:process";
import type {DndSettings} from "../../../../common/dnd-util";
import {html} from "../../../../common/html";
import type {NavItem} from "../../../../common/types";
import {ipcRenderer} from "../../typed-ipc-renderer";
@@ -9,51 +12,84 @@ import {initNetworkSection} from "./network-section";
import {initServersSection} from "./servers-section";
import {initShortcutsSection} from "./shortcuts-section";
export function initPreferenceView(): void {
const $sidebarContainer = document.querySelector("#sidebar")!;
const $settingsContainer = document.querySelector("#settings-container")!;
export class PreferenceView {
readonly $view: HTMLElement;
private readonly $shadow: ShadowRoot;
private readonly $settingsContainer: Element;
private readonly nav: Nav;
private navItem: NavItem = "General";
const nav = new Nav({
$root: $sidebarContainer,
onItemSelected: handleNavigation,
});
constructor() {
this.$view = document.createElement("div");
this.$shadow = this.$view.attachShadow({mode: "open"});
this.$shadow.innerHTML = html`
<link
rel="stylesheet"
href="${require.resolve("../../../css/fonts.css")}"
/>
<link
rel="stylesheet"
href="${require.resolve("../../../css/preference.css")}"
/>
<link
rel="stylesheet"
href="${require.resolve("@yaireo/tagify/dist/tagify.css")}"
/>
<!-- Initially hidden to prevent FOUC -->
<div id="content" hidden>
<div id="sidebar"></div>
<div id="settings-container"></div>
</div>
`.html;
const navItem =
nav.navItems.find((navItem) => window.location.hash === `#${navItem}`) ??
"General";
const $sidebarContainer = this.$shadow.querySelector("#sidebar")!;
this.$settingsContainer = this.$shadow.querySelector(
"#settings-container",
)!;
handleNavigation(navItem);
this.nav = new Nav({
$root: $sidebarContainer,
onItemSelected: this.handleNavigation,
});
function handleNavigation(navItem: NavItem): void {
nav.select(navItem);
ipcRenderer.on("toggle-sidebar", this.handleToggleSidebar);
ipcRenderer.on("toggle-autohide-menubar", this.handleToggleMenubar);
ipcRenderer.on("toggle-dnd", this.handleToggleDnd);
this.handleNavigation(this.navItem);
}
handleNavigation = (navItem: NavItem): void => {
this.navItem = navItem;
this.nav.select(navItem);
switch (navItem) {
case "AddServer":
initServersSection({
$root: $settingsContainer,
$root: this.$settingsContainer,
});
break;
case "General":
initGeneralSection({
$root: $settingsContainer,
$root: this.$settingsContainer,
});
break;
case "Organizations":
initConnectedOrgSection({
$root: $settingsContainer,
$root: this.$settingsContainer,
});
break;
case "Network":
initNetworkSection({
$root: $settingsContainer,
$root: this.$settingsContainer,
});
break;
case "Shortcuts": {
initShortcutsSection({
$root: $settingsContainer,
$root: this.$settingsContainer,
});
break;
}
@@ -63,44 +99,48 @@ export function initPreferenceView(): void {
}
window.location.hash = `#${navItem}`;
};
handleToggleTray(state: boolean) {
this.handleToggle("tray-option", state);
}
destroy(): void {
ipcRenderer.off("toggle-sidebar", this.handleToggleSidebar);
ipcRenderer.off("toggle-autohide-menubar", this.handleToggleMenubar);
ipcRenderer.off("toggle-dnd", this.handleToggleDnd);
}
// Handle toggling and reflect changes in preference page
function handleToggle(elementName: string, state = false): void {
private handleToggle(elementName: string, state = false): void {
const inputSelector = `#${elementName} .action .switch input`;
const input: HTMLInputElement = document.querySelector(inputSelector)!;
const input: HTMLInputElement = this.$shadow.querySelector(inputSelector)!;
if (input) {
input.checked = state;
}
}
ipcRenderer.on("switch-settings-nav", (_event: Event, navItem: NavItem) => {
handleNavigation(navItem);
});
private readonly handleToggleSidebar = (_event: Event, state: boolean) => {
this.handleToggle("sidebar-option", state);
};
ipcRenderer.on("toggle-sidebar-setting", (_event: Event, state: boolean) => {
handleToggle("sidebar-option", state);
});
private readonly handleToggleMenubar = (_event: Event, state: boolean) => {
this.handleToggle("menubar-option", state);
};
ipcRenderer.on("toggle-menubar-setting", (_event: Event, state: boolean) => {
handleToggle("menubar-option", state);
});
private readonly handleToggleDnd = (
_event: Event,
_state: boolean,
newSettings: Partial<DndSettings>,
) => {
this.handleToggle("show-notification-option", newSettings.showNotification);
this.handleToggle("silent-option", newSettings.silent);
ipcRenderer.on("toggle-tray", (_event: Event, state: boolean) => {
handleToggle("tray-option", state);
});
ipcRenderer.on(
"toggle-dnd",
(_event: Event, _state: boolean, newSettings: Partial<DNDSettings>) => {
handleToggle("show-notification-option", newSettings.showNotification);
handleToggle("silent-option", newSettings.silent);
if (process.platform === "win32") {
handleToggle("flash-taskbar-option", newSettings.flashTaskbarOnMessage);
}
},
);
if (process.platform === "win32") {
this.handleToggle(
"flash-taskbar-option",
newSettings.flashTaskbarOnMessage,
);
}
};
}
window.addEventListener("load", initPreferenceView);

View File

@@ -1,15 +1,13 @@
import {remote} from "electron";
import {dialog} from "@electron/remote";
import {html} from "../../../../common/html";
import * as Messages from "../../../../common/messages";
import * as t from "../../../../common/translation-util";
import type {ServerConf} from "../../../../common/types";
import {generateNodeFromHTML} from "../../components/base";
import {generateNodeFromHtml} from "../../components/base";
import {ipcRenderer} from "../../typed-ipc-renderer";
import * as DomainUtil from "../../utils/domain-util";
const {dialog} = remote;
interface ServerInfoFormProps {
$root: Element;
server: ServerConf;
@@ -18,7 +16,7 @@ interface ServerInfoFormProps {
}
export function initServerInfoForm(props: ServerInfoFormProps): void {
const $serverInfoForm = generateNodeFromHTML(html`
const $serverInfoForm = generateNodeFromHtml(html`
<div class="settings-card">
<div class="server-info-left">
<img class="server-info-icon" src="${props.server.icon}" />

View File

@@ -8,10 +8,8 @@ interface ServersSectionProps {
$root: Element;
}
export function initServersSection(props: ServersSectionProps): void {
props.$root.textContent = "";
props.$root.innerHTML = html`
export function initServersSection({$root}: ServersSectionProps): void {
$root.innerHTML = html`
<div class="add-server-modal">
<div class="modal-container">
<div class="settings-pane" id="server-settings-pane">
@@ -21,7 +19,7 @@ export function initServersSection(props: ServersSectionProps): void {
</div>
</div>
`.html;
const $newServerContainer = document.querySelector("#new-server-container")!;
const $newServerContainer = $root.querySelector("#new-server-container")!;
initNewServerForm({
$root: $newServerContainer,

View File

@@ -1,16 +1,18 @@
import process from "node:process";
import {html} from "../../../../common/html";
import * as LinkUtil from "../../../../common/link-util";
import * as t from "../../../../common/translation-util";
import * as LinkUtil from "../../utils/link-util";
interface ShortcutsSectionProps {
$root: Element;
}
// eslint-disable-next-line complexity
export function initShortcutsSection(props: ShortcutsSectionProps): void {
export function initShortcutsSection({$root}: ShortcutsSectionProps): void {
const cmdOrCtrl = process.platform === "darwin" ? "⌘" : "Ctrl";
props.$root.innerHTML = html`
$root.innerHTML = html`
<div class="settings-pane">
<div class="settings-card tip">
<p>
@@ -223,9 +225,8 @@ export function initShortcutsSection(props: ShortcutsSectionProps): void {
`.html;
const link = "https://zulip.com/help/keyboard-shortcuts";
const externalCreateNewOrgElement = document.querySelector(
"#open-hotkeys-link",
)!;
const externalCreateNewOrgElement =
$root.querySelector("#open-hotkeys-link")!;
externalCreateNewOrgElement.addEventListener("click", async () => {
await LinkUtil.openBrowser(new URL(link));
});

View File

@@ -1,5 +1,5 @@
import {contextBridge, webFrame} from "electron";
import fs from "fs";
import {contextBridge, webFrame} from "electron/renderer";
import fs from "node:fs";
import electron_bridge, {bridgeEvents} from "./electron-bridge";
import * as NetworkError from "./pages/network";
@@ -65,8 +65,8 @@ ipcRenderer.on("show-notification-settings", () => {
}, 100);
});
window.addEventListener("load", (event: any): void => {
if (!event.target.URL.includes("app/renderer/network.html")) {
window.addEventListener("load", () => {
if (!location.href.includes("app/renderer/network.html")) {
return;
}

View File

@@ -1,33 +1,36 @@
import type {NativeImage, WebviewTag} from "electron";
import {remote} from "electron";
import path from "path";
import type {NativeImage} from "electron/common";
import {nativeImage} from "electron/common";
import type {Tray as ElectronTray} from "electron/main";
import path from "node:path";
import process from "node:process";
import {BrowserWindow, Menu, Tray} from "@electron/remote";
import * as ConfigUtil from "../../common/config-util";
import type {RendererMessage} from "../../common/typed-ipc";
import type {ServerManagerView} from "./main";
import {ipcRenderer} from "./typed-ipc-renderer";
const {Tray, Menu, nativeImage, BrowserWindow} = remote;
let tray: ElectronTray | null = null;
let tray: Electron.Tray | null = null;
const iconDir = "../../resources/tray";
const ICON_DIR = "../../resources/tray";
const traySuffix = "tray";
const TRAY_SUFFIX = "tray";
const APP_ICON = path.join(__dirname, ICON_DIR, TRAY_SUFFIX);
const appIcon = path.join(__dirname, iconDir, traySuffix);
const iconPath = (): string => {
if (process.platform === "linux") {
return APP_ICON + "linux.png";
return appIcon + "linux.png";
}
return (
APP_ICON + (process.platform === "win32" ? "win.ico" : "macOSTemplate.png")
appIcon + (process.platform === "win32" ? "win.ico" : "macOSTemplate.png")
);
};
const winUnreadTrayIconPath = (): string => APP_ICON + "unread.ico";
const winUnreadTrayIconPath = (): string => appIcon + "unread.ico";
let unread = 0;
@@ -60,42 +63,42 @@ const config = {
const renderCanvas = function (arg: number): HTMLCanvasElement {
config.unreadCount = arg;
const SIZE = config.size * config.pixelRatio;
const PADDING = SIZE * 0.05;
const CENTER = SIZE / 2;
const HAS_COUNT = config.showUnreadCount && config.unreadCount;
const size = config.size * config.pixelRatio;
const padding = size * 0.05;
const center = size / 2;
const hasCount = config.showUnreadCount && config.unreadCount;
const color = config.unreadCount ? config.unreadColor : config.readColor;
const backgroundColor = config.unreadCount
? config.unreadBackgroundColor
: config.readBackgroundColor;
const canvas = document.createElement("canvas");
canvas.width = SIZE;
canvas.height = SIZE;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d")!;
// Circle
// If (!config.thick || config.thick && HAS_COUNT) {
// If (!config.thick || config.thick && hasCount) {
ctx.beginPath();
ctx.arc(CENTER, CENTER, SIZE / 2 - PADDING, 0, 2 * Math.PI, false);
ctx.arc(center, center, size / 2 - padding, 0, 2 * Math.PI, false);
ctx.fillStyle = backgroundColor;
ctx.fill();
ctx.lineWidth = SIZE / (config.thick ? 10 : 20);
ctx.lineWidth = size / (config.thick ? 10 : 20);
ctx.strokeStyle = backgroundColor;
ctx.stroke();
// Count or Icon
if (HAS_COUNT) {
if (hasCount) {
ctx.fillStyle = color;
ctx.textAlign = "center";
if (config.unreadCount > 99) {
ctx.font = `${config.thick ? "bold " : ""}${SIZE * 0.4}px Helvetica`;
ctx.fillText("99+", CENTER, CENTER + SIZE * 0.15);
ctx.font = `${config.thick ? "bold " : ""}${size * 0.4}px Helvetica`;
ctx.fillText("99+", center, center + size * 0.15);
} else if (config.unreadCount < 10) {
ctx.font = `${config.thick ? "bold " : ""}${SIZE * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), CENTER, CENTER + SIZE * 0.2);
ctx.font = `${config.thick ? "bold " : ""}${size * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), center, center + size * 0.2);
} else {
ctx.font = `${config.thick ? "bold " : ""}${SIZE * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), CENTER, CENTER + SIZE * 0.15);
ctx.font = `${config.thick ? "bold " : ""}${size * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), center, center + size * 0.15);
}
}
@@ -168,70 +171,70 @@ const createTray = function (): void {
}
};
ipcRenderer.on("destroytray", (_event: Event) => {
if (!tray) {
return;
}
tray.destroy();
if (tray.isDestroyed()) {
tray = null;
} else {
throw new Error("Tray icon not properly destroyed.");
}
});
ipcRenderer.on("tray", (_event: Event, arg: number): void => {
if (!tray) {
return;
}
// We don't want to create tray from unread messages on macOS since it already has dock badges.
if (process.platform === "linux" || process.platform === "win32") {
if (arg === 0) {
unread = arg;
tray.setImage(iconPath());
tray.setToolTip("No unread messages");
} else {
unread = arg;
const image = renderNativeImage(arg);
tray.setImage(image);
tray.setToolTip(`${arg} unread messages`);
export function initializeTray(serverManagerView: ServerManagerView) {
ipcRenderer.on("destroytray", (_event: Event) => {
if (!tray) {
return;
}
}
});
function toggleTray(): void {
let state;
if (tray) {
state = false;
tray.destroy();
if (tray.isDestroyed()) {
tray = null;
} else {
throw new Error("Tray icon not properly destroyed.");
}
});
ipcRenderer.on("tray", (_event: Event, arg: number): void => {
if (!tray) {
return;
}
ConfigUtil.setConfigItem("trayIcon", false);
} else {
state = true;
createTray();
// We don't want to create tray from unread messages on macOS since it already has dock badges.
if (process.platform === "linux" || process.platform === "win32") {
const image = renderNativeImage(unread);
tray!.setImage(image);
tray!.setToolTip(`${unread} unread messages`);
if (arg === 0) {
unread = arg;
tray.setImage(iconPath());
tray.setToolTip("No unread messages");
} else {
unread = arg;
const image = renderNativeImage(arg);
tray.setImage(image);
tray.setToolTip(`${arg} unread messages`);
}
}
});
function toggleTray(): void {
let state;
if (tray) {
state = false;
tray.destroy();
if (tray.isDestroyed()) {
tray = null;
}
ConfigUtil.setConfigItem("trayIcon", false);
} else {
state = true;
createTray();
if (process.platform === "linux" || process.platform === "win32") {
const image = renderNativeImage(unread);
tray!.setImage(image);
tray!.setToolTip(`${unread} unread messages`);
}
ConfigUtil.setConfigItem("trayIcon", true);
}
ConfigUtil.setConfigItem("trayIcon", true);
serverManagerView.preferenceView?.handleToggleTray(state);
}
const selector = "webview:not([class*=disabled])";
const webview: WebviewTag = document.querySelector(selector)!;
ipcRenderer.sendTo(webview.getWebContentsId(), "toggle-tray", state);
}
ipcRenderer.on("toggletray", toggleTray);
ipcRenderer.on("toggletray", toggleTray);
if (ConfigUtil.getConfigItem("trayIcon", true)) {
createTray();
if (ConfigUtil.getConfigItem("trayIcon", true)) {
createTray();
}
}
export {};

View File

@@ -1,7 +1,7 @@
import type {IpcRendererEvent} from "electron";
import type {IpcRendererEvent} from "electron/renderer";
import {
ipcRenderer as untypedIpcRenderer, // eslint-disable-line no-restricted-imports
} from "electron";
} from "electron/renderer";
import type {
MainCall,
@@ -9,11 +9,10 @@ import type {
RendererMessage,
} from "../../common/typed-ipc";
type RendererListener<
Channel extends keyof RendererMessage
> = RendererMessage[Channel] extends (...args: infer Args) => void
? (event: IpcRendererEvent, ...args: Args) => void
: never;
type RendererListener<Channel extends keyof RendererMessage> =
RendererMessage[Channel] extends (...args: infer Args) => void
? (event: IpcRendererEvent, ...args: Args) => void
: never;
export const ipcRenderer: {
on<Channel extends keyof RendererMessage>(
@@ -24,6 +23,10 @@ export const ipcRenderer: {
channel: Channel,
listener: RendererListener<Channel>,
): void;
off<Channel extends keyof RendererMessage>(
channel: Channel,
listener: RendererListener<Channel>,
): void;
removeListener<Channel extends keyof RendererMessage>(
channel: Channel,
listener: RendererListener<Channel>,

View File

@@ -1,8 +1,11 @@
import {remote} from "electron";
import fs from "fs";
import path from "path";
import fs from "node:fs";
import path from "node:path";
import {app, dialog} from "@electron/remote";
import * as Sentry from "@sentry/electron";
import {JsonDB} from "node-json-db";
import {DataError} from "node-json-db/dist/lib/Errors";
import * as z from "zod";
import * as EnterpriseUtil from "../../../common/enterprise-util";
import Logger from "../../../common/logger-util";
@@ -10,44 +13,56 @@ import * as Messages from "../../../common/messages";
import type {ServerConf} from "../../../common/types";
import {ipcRenderer} from "../typed-ipc-renderer";
const {app, dialog} = remote;
const logger = new Logger({
file: "domain-util.log",
});
const defaultIconUrl = "../renderer/img/icon.png";
const serverConfSchema = z.object({
url: z.string(),
alias: z.string(),
icon: z.string(),
});
let db!: JsonDB;
reloadDB();
reloadDb();
// Migrate from old schema
if (db.getData("/").domain) {
(async () => {
await addDomain({
alias: "Zulip",
url: db.getData("/domain"),
});
db.delete("/domain");
})();
try {
const oldDomain = db.getObject<unknown>("/domain");
if (typeof oldDomain === "string") {
(async () => {
await addDomain({
alias: "Zulip",
url: oldDomain,
});
db.delete("/domain");
})();
}
} catch (error: unknown) {
if (!(error instanceof DataError)) throw error;
}
export function getDomains(): ServerConf[] {
reloadDB();
if (db.getData("/").domains === undefined) {
reloadDb();
try {
return serverConfSchema.array().parse(db.getObject<unknown>("/domains"));
} catch (error: unknown) {
if (!(error instanceof DataError)) throw error;
return [];
}
return db.getData("/domains");
}
export function getDomain(index: number): ServerConf {
reloadDB();
return db.getData(`/domains[${index}]`);
reloadDb();
return serverConfSchema.parse(db.getObject<unknown>(`/domains[${index}]`));
}
export function updateDomain(index: number, server: ServerConf): void {
reloadDB();
reloadDb();
serverConfSchema.parse(server);
db.push(`/domains[${index}]`, server, true);
}
@@ -59,18 +74,20 @@ export async function addDomain(server: {
if (server.icon) {
const localIconUrl = await saveServerIcon(server.icon);
server.icon = localIconUrl;
serverConfSchema.parse(server);
db.push("/domains[]", server, true);
reloadDB();
reloadDb();
} else {
server.icon = defaultIconUrl;
serverConfSchema.parse(server);
db.push("/domains[]", server, true);
reloadDB();
reloadDb();
}
}
export function removeDomains(): void {
db.delete("/domains");
reloadDB();
reloadDb();
}
export function removeDomain(index: number): boolean {
@@ -79,7 +96,7 @@ export function removeDomain(index: number): boolean {
}
db.delete(`/domains[${index}]`);
reloadDB();
reloadDb();
return true;
}
@@ -127,16 +144,16 @@ export async function updateSavedServer(
if (!oldIcon || localIconUrl !== "../renderer/img/icon.png") {
newServerConf.icon = localIconUrl;
updateDomain(index, newServerConf);
reloadDB();
reloadDb();
}
} catch (error: unknown) {
logger.log("Could not update server icon.");
logger.log(error);
logger.reportSentry(error);
Sentry.captureException(error);
}
}
function reloadDB(): void {
function reloadDb(): void {
const domainJsonPath = path.join(
app.getPath("userData"),
"config/domain.json",
@@ -154,7 +171,7 @@ function reloadDB(): void {
);
logger.error("Error while JSON parsing domain.json: ");
logger.error(error);
logger.reportSentry(error);
Sentry.captureException(error);
}
}

View File

@@ -21,7 +21,7 @@ export default class ReconnectUtil {
this.alreadyReloaded = false;
this.fibonacciBackoff = backoff.fibonacci({
initialDelay: 5000,
maxDelay: 300000,
maxDelay: 300_000,
});
}

View File

@@ -1,8 +1,6 @@
import os from "os";
import {ipcRenderer} from "../typed-ipc-renderer";
export const connectivityERR: string[] = [
export const connectivityError: string[] = [
"ERR_INTERNET_DISCONNECTED",
"ERR_PROXY_CONNECTION_FAILED",
"ERR_CONNECTION_RESET",
@@ -13,27 +11,6 @@ export const connectivityERR: string[] = [
const userAgent = ipcRenderer.sendSync("fetch-user-agent");
export function getOS(): string {
const platform = os.platform();
if (platform === "darwin") {
return "Mac";
}
if (platform === "linux") {
return "Linux";
}
if (platform === "win32") {
if (Number.parseFloat(os.release()) < 6.2) {
return "Windows 7";
}
return "Windows 10";
}
return "";
}
export function getUserAgent(): string {
return userAgent;
}

View File

@@ -4,6 +4,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>Zulip</title>
<link rel="stylesheet" href="css/fonts.css" />
<link rel="stylesheet" href="css/main.css" type="text/css" media="screen" />
</head>
@@ -51,15 +52,5 @@
<div id="webviews-container"></div>
</div>
</div>
<div id="feedback-modal">
<send-feedback show-cancel-button="show"></send-feedback>
</div>
</body>
<script>
// we don't use src='./js/main' in the script tag because
// it messes up require module path resolution
require("./js/main");
</script>
</html>

View File

@@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="responsive desktop">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>Zulip - Settings</title>
<link
rel="stylesheet"
href="css/preference.css"
type="text/css"
media="screen"
/>
<link id="tagify-css" rel="stylesheet" href="data:text/css," />
</head>
<body>
<div id="content">
<div id="sidebar"></div>
<div id="settings-container"></div>
</div>
</body>
<script>
document.querySelector("#tagify-css").href = require.resolve(
"@yaireo/tagify/dist/tagify.css",
);
require("./js/pages/preference/preference.js");
</script>
</html>

View File

@@ -2,6 +2,43 @@
All notable changes to the Zulip desktop app are documented in this file.
### v5.9.0 --2022-04-01
**Fixes**:
- Fixed unread count display when viewing a topic with a parenthesized number.
- Fixed parsing of system proxy settings.
**Enhancements**:
- Removed fade-in animation on page load.
**Dependencies**:
- Upgraded all dependencies, including Electron 18.0.1.
### v5.8.1 --2021-07-29
**Fixes**:
- Downgraded electron-updater to fix automatic updates on macOS.
(Note that 5.7.0 and 5.8.0 users may still trigger electron-updater bugs trying to automatically update _to_ 5.8.1; once updated, future updates _from_ 5.8.1 should work correctly.)
### v5.8.0 --2021-07-21
**Fixes**:
- Fixed the spell checker on macOS.
- Fixed `TypeError` after closing the About page.
**Enhancements**:
- Removed `Ctrl`+`L`/`⌘L` keyboard shortcut to prevent accidental logouts.
**Dependencies**:
- Upgraded all dependencies, including Electron 13.1.7.
### v5.7.0 --2021-04-30
**Fixes**:

19464
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "zulip",
"productName": "Zulip",
"version": "5.7.0",
"version": "5.9.0",
"main": "./app/main",
"description": "Zulip Desktop App",
"license": "Apache-2.0",
@@ -22,16 +22,16 @@
},
"scripts": {
"start": "tsc && electron .",
"clean-ts-files": "git clean app/*.js -e node_modules -xf",
"clean-ts-files": "git clean \"app/*.js\" -xf",
"watch-ts": "tsc -w",
"reinstall": "rimraf node_modules && npm install",
"postinstall": "electron-builder install-app-deps",
"lint-css": "stylelint app/renderer/css/*.css",
"lint-html": "./node_modules/.bin/htmlhint \"app/renderer/*.html\" ",
"lint-css": "stylelint \"app/**/*.css\"",
"lint-html": "htmlhint \"app/**/*.html\"",
"lint-js": "xo",
"prettier-non-js": "prettier --check --ignore-path=.prettierignore.non-js --loglevel=warn .",
"prettier-non-js": "prettier --check --loglevel=warn . \"!**/*.{js,ts}\"",
"test": "tsc --noEmit && npm run lint-html && npm run lint-css && npm run lint-js && npm run prettier-non-js",
"test-e2e": "tsc && tape 'tests/*.js'",
"test-e2e": "tsc && tape \"tests/**/*.js\"",
"pack": "tsc && electron-builder --dir",
"dist": "tsc && electron-builder",
"mas": "tsc && electron-builder --mac mas"
@@ -60,7 +60,13 @@
"arm64"
]
},
"pkg"
{
"target": "pkg",
"arch": [
"x64",
"arm64"
]
}
],
"darkModeSupport": true,
"artifactName": "${productName}-${version}-${arch}.${ext}",
@@ -75,7 +81,7 @@
"description": "Zulip Desktop Client for Linux",
"target": [
"deb",
"zip",
"tar.xz",
"AppImage",
"snap"
],
@@ -117,13 +123,18 @@
}
],
"icon": "build/icon.ico",
"artifactName": "${productName}-Web-Setup-${version}.${ext}",
"publisherName": "Kandra Labs, Inc."
},
"msi": {
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"nsis": {
"allowToChangeInstallationDirectory": true,
"oneClick": false,
"perMachine": false
},
"nsisWeb": {
"artifactName": "${productName}-Web-Setup-${version}.${ext}"
}
},
"keywords": [
@@ -135,45 +146,48 @@
"InstantMessaging"
],
"dependencies": {
"@electron-elements/send-feedback": "^2.0.3",
"@sentry/electron": "^2.4.1",
"@yaireo/tagify": "^4.1.1",
"@electron/remote": "github:andersk/electron-remote#2.0.6-fixes",
"@sentry/electron": "^3.0.3",
"@yaireo/tagify": "^4.5.0",
"adm-zip": "^0.5.5",
"auto-launch": "^5.0.5",
"backoff": "^2.5.0",
"electron-log": "^4.3.5",
"electron-updater": "^4.3.8",
"electron-updater": "^4.6.5",
"electron-window-state": "^5.0.3",
"escape-goat": "^3.0.0",
"get-stream": "^6.0.1",
"i18n": "^0.13.2",
"i18n": "^0.14.1",
"iso-639-1": "^2.1.9",
"node-json-db": "^1.3.0",
"semver": "^7.3.5"
"semver": "^7.3.5",
"zod": "^3.5.1"
},
"devDependencies": {
"@types/adm-zip": "^0.4.34",
"@types/auto-launch": "^5.0.1",
"@types/backoff": "^2.5.1",
"@types/i18n": "^0.13.0",
"@types/node": "^15.0.1",
"@types/requestidlecallback": "^0.3.1",
"@types/yaireo__tagify": "^4.1.0",
"dotenv": "^8.2.0",
"electron": "^12.0.6",
"electron-builder": "^22.10.5",
"@types/adm-zip": "^0.5.0",
"@types/auto-launch": "^5.0.2",
"@types/backoff": "^2.5.2",
"@types/i18n": "^0.13.1",
"@types/node": "^16.11.26",
"@types/requestidlecallback": "^0.3.4",
"@types/yaireo__tagify": "^4.3.2",
"dotenv": "^16.0.0",
"electron": "^18.0.1",
"electron-builder": "^23.0.3",
"electron-notarize": "^1.0.0",
"eslint-import-resolver-typescript": "^2.4.0",
"htmlhint": "^0.14.2",
"htmlhint": "^1.1.2",
"medium": "^1.2.0",
"playwright-core": "^1.19.1",
"pre-commit": "^1.2.2",
"prettier": "^2.3.2",
"rimraf": "^3.0.2",
"spectron": "^14.0.0",
"stylelint": "^13.13.0",
"stylelint-config-prettier": "^8.0.2",
"stylelint-config-standard": "^22.0.0",
"stylelint": "^14.5.3",
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-standard": "^25.0.0",
"tape": "^5.2.2",
"typescript": "^4.2.4",
"xo": "^0.39.1"
"typescript": "^4.3.5",
"xo": "^0.48.0"
},
"prettier": {
"bracketSpacing": false,
@@ -184,11 +198,16 @@
"prettier": true,
"rules": {
"@typescript-eslint/no-dynamic-delete": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"arrow-body-style": "error",
"import/first": "error",
"import/newline-after-import": "error",
"import/no-cycle": "error",
"import/extensions": [
"error",
"always",
{
"pattern": {
"ts": "never"
}
}
],
"import/no-restricted-paths": [
"error",
{
@@ -238,11 +257,21 @@
"paths": [
{
"name": "electron",
"message": "Use electron/main, electron/renderer, or electron/common."
},
{
"name": "electron/main",
"importNames": [
"ipcMain"
],
"message": "Use typed-ipc-main."
},
{
"name": "electron/renderer",
"importNames": [
"ipcMain",
"ipcRenderer"
],
"message": "Use typed-ipc-main and typed-ipc-renderer."
"message": "Use typed-ipc-renderer."
}
]
}
@@ -254,7 +283,9 @@
"ignoreDeclarationSort": true
}
],
"strict": "error"
"strict": "error",
"unicorn/prefer-json-parse-buffer": "off",
"unicorn/prefer-module": "off"
},
"envs": [
"node",
@@ -266,23 +297,21 @@
"**/*.ts"
],
"rules": {
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/consistent-type-imports": [
"error",
{
"disallowTypeAnnotations": false
}
],
"@typescript-eslint/no-redeclare": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
"vars": "all",
"args": "after-used",
"argsIgnorePattern": "^_",
"caughtErrors": "all"
}
],
"no-redeclare": "off"
"unicorn/no-await-expression-member": "off"
},
"settings": {
"import/resolver": "typescript"

View File

@@ -1,5 +1,6 @@
"use strict";
const path = require("path");
const process = require("process");
const dotenv = require("dotenv");
const {notarize} = require("electron-notarize");

View File

@@ -1,7 +0,0 @@
"use strict";
const TEST_APP_PRODUCT_NAME = "ZulipTest";
module.exports = {
TEST_APP_PRODUCT_NAME,
};

View File

@@ -1,18 +1,23 @@
"use strict";
const {chan, put, take} = require("medium");
const test = require("tape");
const setup = require("./setup");
const setup = require("./setup.js");
test("app runs", async (t) => {
t.timeoutAfter(10e3);
setup.resetTestDataDir();
const app = setup.createApp();
const app = await setup.createApp();
try {
await setup.waitForLoad(app, t);
await app.client.windowByIndex(1); // Focus on webview
await (await app.client.$('//*[@id="connect"]')).waitForExist(); // Id of the connect button
await setup.endTest(app, t);
} catch (error) {
await setup.endTest(app, t, error || "error");
const windows = chan();
for (const win of app.windows()) put(windows, win);
app.on("window", (win) => put(windows, win));
const mainWindow = await take(windows);
t.equal(await mainWindow.title(), "Zulip");
await mainWindow.waitForSelector("#connect");
} finally {
await setup.endTest(app);
}
});

4
tests/package.json Normal file
View File

@@ -0,0 +1,4 @@
{
"productName": "ZulipTest",
"main": "../app/main/index.js"
}

View File

@@ -1,84 +1,29 @@
"use strict";
const fs = require("fs");
const path = require("path");
const process = require("process");
const {_electron} = require("playwright-core");
const rimraf = require("rimraf");
const {Application} = require("spectron");
const config = require("./config");
const testsPkg = require("./package.json");
module.exports = {
createApp,
endTest,
waitForLoad,
wait,
resetTestDataDir,
};
// Runs Zulip Desktop.
// Returns a promise that resolves to a Spectron Application once the app has loaded.
// Takes a Tape test. Makes some basic assertions to verify that the app loaded correctly.
// Returns a promise that resolves to an Electron Application once the app has loaded.
function createApp() {
generateTestAppPackageJson();
return new Application({
path: path.join(
__dirname,
"..",
"node_modules",
".bin",
"electron" + (process.platform === "win32" ? ".cmd" : ""),
),
return _electron.launch({
args: [path.join(__dirname)], // Ensure this dir has a package.json file with a 'main' entry piont
env: {NODE_ENV: "test"},
waitTimeout: 10e3,
});
}
// Generates package.json for test app
// Reads app package.json and updates the productName to config.TEST_APP_PRODUCT_NAME
// We do this so that the app integration doesn't doesn't share the same appDataDir as the dev application
function generateTestAppPackageJson() {
const packageJson = require(path.join(__dirname, "../package.json"));
packageJson.productName = config.TEST_APP_PRODUCT_NAME;
packageJson.main = "../app/main";
const testPackageJsonPath = path.join(__dirname, "package.json");
fs.writeFileSync(
testPackageJsonPath,
JSON.stringify(packageJson, null, " "),
"utf-8",
);
}
// Starts the app, waits for it to load, returns a promise
async function waitForLoad(app, t, options) {
if (!options) {
options = {};
}
await app.start();
await app.client.waitUntilWindowLoaded();
await app.client.pause(2000);
const title = await app.webContents.getTitle();
t.equal(title, "Zulip", "html title");
}
// Returns a promise that resolves after 'ms' milliseconds. Default: 1 second
async function wait(ms) {
if (ms === undefined) {
ms = 1000;
} // Default: wait long enough for the UI to update
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
// Quit the app, end the test, either in success (!err) or failure (err)
async function endTest(app, t, error) {
await app.client.windowByIndex(0);
await app.stop();
t.end(error);
// Quit the app, end the test
async function endTest(app) {
await app.close();
}
function getAppDataDir() {
@@ -101,12 +46,11 @@ function getAppDataDir() {
}
console.log("Detected App Data Dir base:", base);
return path.join(base, config.TEST_APP_PRODUCT_NAME);
return path.join(base, testsPkg.productName);
}
// Resets the test directory, containing domain.json, window-state.json, etc
function resetTestDataDir() {
const appDataDir = getAppDataDir();
rimraf.sync(appDataDir);
rimraf.sync(path.join(__dirname, "package.json"));
}

View File

@@ -1,25 +1,27 @@
"use strict";
const {chan, put, take} = require("medium");
const test = require("tape");
const setup = require("./setup");
const setup = require("./setup.js");
test("add-organization", async (t) => {
t.timeoutAfter(50e3);
setup.resetTestDataDir();
const app = setup.createApp();
const app = await setup.createApp();
try {
await setup.waitForLoad(app, t);
await app.client.windowByIndex(1); // Focus on webview
await (await app.client.$(".setting-input-value")).setValue(
"chat.zulip.org",
);
await (await app.client.$("#connect")).click();
await setup.wait(5000);
await app.client.windowByIndex(0); // Switch focus back to main win
await app.client.windowByIndex(1); // Switch focus back to org webview
await (await app.client.$('//*[@id="id_username"]')).waitForExist();
await setup.endTest(app, t);
} catch (error) {
await setup.endTest(app, t, error || "error");
const windows = chan();
for (const win of app.windows()) put(windows, win);
app.on("window", (win) => put(windows, win));
const mainWindow = await take(windows);
t.equal(await mainWindow.title(), "Zulip");
await mainWindow.fill(".setting-input-value", "chat.zulip.org");
await mainWindow.click("#connect");
const orgWebview = await take(windows);
await orgWebview.waitForSelector("#id_username");
} finally {
await setup.endTest(app);
}
});

View File

@@ -1,21 +1,25 @@
"use strict";
const {chan, put, take} = require("medium");
const test = require("tape");
const setup = require("./setup");
const setup = require("./setup.js");
// Create new org link should open in the default browser [WIP]
test("new-org-link", async (t) => {
t.timeoutAfter(50e3);
setup.resetTestDataDir();
const app = setup.createApp();
const app = await setup.createApp();
try {
await setup.waitForLoad(app, t);
await app.client.windowByIndex(1); // Focus on webview
await (await app.client.$("#open-create-org-link")).click(); // Click on new org link button
await setup.wait(5000);
await setup.endTest(app, t);
} catch (error) {
await setup.endTest(app, t, error || "error");
const windows = chan();
for (const win of app.windows()) put(windows, win);
app.on("window", (win) => put(windows, win));
const mainWindow = await take(windows);
t.equal(await mainWindow.title(), "Zulip");
await mainWindow.click("#open-create-org-link");
} finally {
await setup.endTest(app);
}
});

View File

@@ -1,10 +1,10 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"target": "es2021",
"module": "commonjs",
"esModuleInterop": true,
"resolveJsonModule": true,
"strict": true
"strict": true,
"noImplicitOverride": true
}
}

37
typings.d.ts vendored
View File

@@ -2,40 +2,3 @@ declare namespace Electron {
// https://github.com/electron/typescript-definitions/issues/170
interface IncomingMessage extends NodeJS.ReadableStream {}
}
declare module "@electron-elements/send-feedback" {
class SendFeedback extends HTMLElement {
customStyles: string;
customStylesheet: string;
titleLabel: string;
titlePlaceholder: string;
textareaLabel: string;
textareaPlaceholder: string;
buttonLabel: string;
loaderSuccessText: string;
logs: string[];
useReporter: (reporter: string, data: Record<string, unknown>) => void;
}
export = SendFeedback;
}
interface ClipboardDecrypter {
version: number;
key: Uint8Array;
pasted: Promise<string>;
}
interface ElectronBridge {
send_event: (eventName: string | symbol, ...args: unknown[]) => boolean;
on_event: (eventName: string, listener: (...args: any[]) => void) => void;
new_notification: (
title: string,
options: NotificationOptions,
dispatch: (type: string, eventInit: EventInit) => boolean,
) => import("./app/renderer/js/notification").NotificationData;
get_idle_on_system: () => boolean;
get_last_active_on_system: () => number;
get_send_notification_reply_message_supported: () => boolean;
set_send_notification_reply_message_supported: (value: boolean) => void;
decrypt_clipboard: (version: number) => ClipboardDecrypter;
}