Compare commits

...

195 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
Anders Kaseorg
07e2ebe340 release: New release v5.7.0.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-30 21:33:50 -07:00
Anders Kaseorg
884d5e0e16 Upgrade dependencies, including Electron 12.0.6.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-30 21:30:36 -07:00
Anders Kaseorg
a3a79534ab Build for arm64 on macOS.
Fixes #1076.

For now we only build a DMG and zip for arm64, because the arm64 pkg
would overwrite the x64 pkg
(https://github.com/electron-userland/electron-builder/issues/5847).

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-30 21:19:34 -07:00
Anders Kaseorg
6acf1d3411 package-lock.json: Restore mysterious optional node-addon-api dependency.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-30 18:28:49 -07:00
Anders Kaseorg
e32480abfb package-lock.json: Upgrade various SHA-1 hashes to SHA-512.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-28 20:29:40 -07:00
Anders Kaseorg
07e7251d7d Upgrade dependencies, including Electron 12.0.5.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-28 20:29:40 -07:00
Anders Kaseorg
753b244630 tests: Convert if chain to switch statement.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-28 20:29:40 -07:00
Anders Kaseorg
892f7c8e47 Strongly type config options.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-28 20:29:40 -07:00
Anders Kaseorg
6a3f50d606 Use Partial types for DNDSettings, SettingsOptions.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-28 18:00:15 -07:00
Anders Kaseorg
a49cb77840 Skip startup code if another instance is running.
Commit 171d88755c removed a top-level
‘return’ statement, thereby allowing the app to proceed with running
some of its startup code even if it failed to grab the single-instance
lock.  Under some circumstances, this seems to result in an orphaned
background process constantly consuming CPU cycles.

Fixes #1102.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-26 19:02:51 -07:00
Anders Kaseorg
79f9362736 Strongly type IPC messages.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-25 20:44:05 -07:00
Anders Kaseorg
164038ec3c Specialize the renderer-callback event to permission-callback.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-25 20:39:49 -07:00
Anders Kaseorg
eacd52fb6c preference: Rename one of the two different toggletray messages.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-25 20:06:30 -07:00
Anders Kaseorg
20295ddc50 webview: Use send method.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-25 19:57:38 -07:00
Anders Kaseorg
994c412bd2 renderer: Use ipcRenderer.sendTo to communicate with other WebContents.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-25 19:57:38 -07:00
Anders Kaseorg
3b3fa88c89 electron-bridge: Check types of all event parameters.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-25 19:57:38 -07:00
Anders Kaseorg
afec96025b general-section: Remove some casts.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-25 19:57:38 -07:00
Anders Kaseorg
57b6144e7f Update dmg background.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-20 20:03:00 -07:00
Anders Kaseorg
9bff18ece3 Add dmg icon.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-20 20:02:46 -07:00
tarun8718
e89f44c87f css: Remove irregular shortcut highlighting.
Fixes #1104.

Signed-off-by: tarun8718 <tarunkumar8718@gmail.com>
2021-04-15 20:09:05 -07:00
Anders Kaseorg
99fe94fbab Upgrade dependencies, including Electron 12.0.4.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-15 20:00:30 -07:00
Anders Kaseorg
e0c2f43b2b xo: Enable import/no-cycle.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-15 19:49:32 -07:00
Tim Abbott
0c81eb93b9 docs: Create FUNDING.yml. 2021-04-08 06:32:09 -07:00
Anders Kaseorg
391c515779 injected: Use WeakMap instead of Symbol for attributeListener.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-03 15:38:52 -07:00
Anders Kaseorg
077f3e6e78 eslint: Enable sort-imports for member sorting.
This sorts the members imported within each individual declaration; we
use import/order for sorting multiple declarations.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-03 15:07:40 -07:00
Anders Kaseorg
3b2256bcec Fix Prettier formatting.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-03 00:47:53 -07:00
ashishbinu
87f5fa049f Fix vertical alignment of input placeholder 2021-04-03 12:37:52 +05:30
ashishbinu
5774b8a67b Fix input placeholder overflow in general settings 2021-04-03 12:37:52 +05:30
Anders Kaseorg
80fe51702b typescript: Use DefinitelyTyped declarations for @yaireo/tagify.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 18:20:21 -07:00
Anders Kaseorg
303ec73fa8 Move stylelint config packages to devDependencies.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 18:12:17 -07:00
Anders Kaseorg
815d9d4e28 typescript: Remove skipLibCheck.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 16:39:41 -07:00
Anders Kaseorg
6044b6328d tsconfig: Remove unused options.
noImplicitAny, noImplicitThis, and alwaysStrict are implied by strict.
noUnusedLocals, noUnusedParameters, and noImplicitReturns are handled
by xo.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 16:23:11 -07:00
Anders Kaseorg
9a81ade1c8 typescript: Enable strictNullChecks.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 15:19:42 -07:00
Anders Kaseorg
33c21d0153 ReconnectUtil: Inline clearState method.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 15:17:31 -07:00
Anders Kaseorg
1735ce6a8a tray: Remove unused return value from destroytray handler.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 15:15:36 -07:00
Anders Kaseorg
385ec00640 PreferenceNav: inline init method.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 15:08:40 -07:00
Anders Kaseorg
4f58a2a357 electron_bridge: Make the options argument of new_notification required.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 15:06:38 -07:00
Anders Kaseorg
1e10a3c406 domain-util: Make alias and icon required members of ServerConf.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 15:01:38 -07:00
Anders Kaseorg
ff3ea429d2 context-menu: Fix strictNullChecks violations.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 14:29:14 -07:00
Anders Kaseorg
bdf7d1b813 index: Fix strictNullChecks violations.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 14:28:55 -07:00
Anders Kaseorg
ae4b3a4778 Explode PreferenceView class to a function.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 14:28:52 -07:00
Anders Kaseorg
66bc43674b Explode ShortcutsSection class to a function.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 14:28:11 -07:00
Anders Kaseorg
8f0f82f98e Explode NetworkSection class to a function.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 14:28:09 -07:00
Anders Kaseorg
43f0b4c902 Explode ConnectedOrgSection class to a function.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 14:27:47 -07:00
Anders Kaseorg
867bb61e0d Explode GeneralSection class to a function.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 14:26:53 -07:00
Anders Kaseorg
10912fe270 Explode ServersSection class to a function.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 14:26:47 -07:00
Anders Kaseorg
76d9c36426 Explode NewServerForm class to a function.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 14:26:20 -07:00
Anders Kaseorg
6ca4d77b8f Explode ServerInfoForm class to a function.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 14:25:59 -07:00
Anders Kaseorg
7b3d40ca1e Explode FindAccounts class to a function.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 14:25:41 -07:00
Anders Kaseorg
ea2d84e810 Explode BaseSection class to functions.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 14:14:48 -07:00
Anders Kaseorg
e477aed8ff generateNodeFromHTML: Check for null.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 14:14:07 -07:00
Anders Kaseorg
07ffb09391 Explode BaseComponent class to a function.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-02 13:10:15 -07:00
Anders Kaseorg
54f02c9616 PreferenceView: Lift init() calls into switch.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-01 18:45:55 -07:00
Anders Kaseorg
eeade47eb6 nav: Add NavItem literal type.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-01 18:45:55 -07:00
Anders Kaseorg
8ec9a98c86 ServerTab: Inline init method.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-01 18:13:30 -07:00
Anders Kaseorg
277d7ef824 FunctionalTab: Inline init method.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-01 18:11:55 -07:00
Anders Kaseorg
cc844e6905 Logger: Remove unused options.
All of our loggers have timestamp: true.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-01 14:06:43 -07:00
Anders Kaseorg
9b550d6e4f Logger: Remove dynamic method assignment.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-01 14:06:43 -07:00
Anders Kaseorg
9c25807b99 Avoid use of null when accessing electron.remote.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-01 14:06:43 -07:00
Anders Kaseorg
b07995c3ed Simplify querySelectorAll(…)[0] to querySelector(…).
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-04-01 14:06:43 -07:00
Anders Kaseorg
67228d295d Reformat all code with Prettier.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-31 20:04:00 -07:00
Anders Kaseorg
4521041619 Enable stylelint-config-standard.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-31 20:03:06 -07:00
Anders Kaseorg
ce9a680333 Add a tagged template function for HTML supporting HTML interpolation.
This allows better Prettier integration: Prettier recognizes and
reformats tagged template literals with a tag named ‘html’.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-31 20:00:49 -07:00
Anders Kaseorg
2c40843306 Remove zulip-electron-launcher.sh.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-31 17:59:32 -07:00
Anders Kaseorg
8d3dad234e Remove .node-version.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-31 17:59:32 -07:00
Anders Kaseorg
6da7cf6b8e Remove Gulp build system.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-31 17:59:32 -07:00
tarun8718
25d0aefe37 server-tab: Fix upper limit of generated shortcuts
Signed-off-by: tarun8718 <tarunkumar8718@gmail.com>
2021-03-29 20:23:06 -07:00
Anders Kaseorg
afb25d5b3d Upgrade dependencies, including Electron 12.0.2.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-29 19:58:26 -07:00
Anders Kaseorg
78ae1b34ab Revert GDK_BACKEND to work around Electron shell.openExternal bug.
https://github.com/electron/electron/issues/28436

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-29 19:52:18 -07:00
Anders Kaseorg
d3401cc87e xo: Fix @typescript-eslint/type-annotation-spacing.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-29 18:01:52 -07:00
Anders Kaseorg
9cbe4fdb4a xo: Enable import/no-restricted-paths.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-29 18:01:52 -07:00
Anders Kaseorg
851bb7904f Move modules shared between main and renderer processes to app/common.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-29 18:01:52 -07:00
Anders Kaseorg
d7598d3091 Replace LinkUtil.openBrowser in main process with shell.openExternal.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-29 18:01:15 -07:00
Anders Kaseorg
76d321fa79 xo: Enable @typescript-eslint/consistent-type-imports.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-29 17:48:54 -07:00
Anders Kaseorg
c42aafe0b9 request: Use a stable URL for the isOnline check.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-29 13:31:08 -07:00
Anders Kaseorg
28db945b91 Remove obsolete troubleshooting guide
None of these issues are current.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-29 12:20:19 -07:00
Saksham Agarwal
a805e260c4 docs: Fix keyboard shortcuts for Electron developer console. 2021-03-24 15:12:53 -07:00
Anders Kaseorg
6e95e5439b Upgrade dependencies, including Electron 11.3.0.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-08 23:14:00 -08:00
Anders Kaseorg
a9f479d60d Enable worldSafeExecuteJavaScript.
This has no effect on our use of executeJavaScript, and will become
the default in Electron 12, but for now it silences a warning in
development mode.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-08 23:14:00 -08:00
Anders Kaseorg
7b095a683c Replace electron-is-dev with app.isPackaged.
This unfortunately uses remote, but that’s what electron-is-dev was
doing anyway.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-08 23:14:00 -08:00
Anders Kaseorg
78931bbb22 xo: Fix unicorn/no-array-push-push.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-08 23:13:50 -08:00
Anders Kaseorg
12ae84b757 xo: Fix unicorn/prefer-spread.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-08 23:13:43 -08:00
Anders Kaseorg
75da0a16c1 xo: Fix unicorn/no-array-for-each.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-03-08 23:13:36 -08:00
120 changed files with 22035 additions and 16582 deletions

View File

@@ -1,16 +1,11 @@
root = true root = true
[*] [*]
indent_style = tab
indent_size = 4
end_of_line = lf end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
[{package.json,*.yml}] [{*.css,*.html,*.js,*.json,*.ts}]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
[*.md]
trim_trailing_whitespace = false

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
github: zulip
patreon: zulip
open_collective: zulip

View File

@@ -4,22 +4,28 @@ about: Create a report to help us improve
--- ---
**Describe the bug** **Describe the bug**
<!-- A clear and concise description of what the bug is. --> <!-- A clear and concise description of what the bug is. -->
**To Reproduce** **To Reproduce**
<!-- Clear steps to reproduce the issue. --> <!-- Clear steps to reproduce the issue. -->
**Expected behavior** **Expected behavior**
<!-- A clear and concise description of what you expected to happen. --> <!-- A clear and concise description of what you expected to happen. -->
**Screenshots** **Screenshots**
<!-- If applicable, add screenshots to help explain your problem. --> <!-- If applicable, add screenshots to help explain your problem. -->
**Desktop (please complete the following information):** **Desktop (please complete the following information):**
- Operating System:
<!-- (Platform and Version) e.g. macOS 10.13.6 / Windows 10 (1803) / Ubuntu 18.04 x64 --> - Operating System:
- Zulip Desktop Version: <!-- (Platform and Version) e.g. macOS 10.13.6 / Windows 10 (1803) / Ubuntu 18.04 x64 -->
<!-- e.g. 5.2.0 --> - Zulip Desktop Version:
<!-- e.g. 5.2.0 -->
**Additional context** **Additional context**
<!-- Add any other context about the problem here. --> <!-- Add any other context about the problem here. -->

View File

@@ -2,5 +2,3 @@
name: Custom issue template name: Custom issue template
about: Describe this issue template's purpose here. about: Describe this issue template's purpose here.
--- ---

View File

@@ -4,13 +4,17 @@ about: Suggest an idea for this project
--- ---
**Problem Description** **Problem Description**
<!-- Please add a clear and concise description of what the problem is. --> <!-- Please add a clear and concise description of what the problem is. -->
**Proposed Solution** **Proposed Solution**
<!-- Describe the solution you'd like in a clear and concise manner --> <!-- Describe the solution you'd like in a clear and concise manner -->
**Describe alternatives you've considered** **Describe alternatives you've considered**
<!-- A clear and concise description of any alternative solutions or features you've considered. --> <!-- A clear and concise description of any alternative solutions or features you've considered. -->
**Additional context** **Additional context**
<!-- Add any other context or screenshots about the feature request here. --> <!-- Add any other context or screenshots about the feature request here. -->

View File

@@ -1,4 +1,5 @@
--- ---
<!-- <!--
Remove the fields that are not appropriate Remove the fields that are not appropriate
Please include: Please include:
@@ -11,6 +12,7 @@ Please include:
**Screenshots?** **Screenshots?**
**You have tested this PR on:** **You have tested this PR on:**
- [ ] Windows
- [ ] Linux/Ubuntu - [ ] Windows
- [ ] macOS - [ ] Linux/Ubuntu
- [ ] macOS

View File

@@ -1,12 +1,7 @@
{ {
"tagname-lowercase": true,
"attr-lowercase": true,
"attr-value-double-quotes": true,
"attr-value-not-empty": false, "attr-value-not-empty": false,
"attr-no-duplication": true, "attr-no-duplication": true,
"doctype-first": true, "doctype-first": true,
"tag-pair": true,
"empty-tag-not-self-closed": true,
"spec-char-escape": true, "spec-char-escape": true,
"id-unique": true, "id-unique": true,
"src-not-empty": true, "src-not-empty": true,
@@ -17,9 +12,8 @@
"style-disabled": false, "style-disabled": false,
"inline-style-disabled": false, "inline-style-disabled": false,
"inline-script-disabled": false, "inline-script-disabled": false,
"space-tab-mixed-disabled": "space4",
"id-class-ad-disabled": false, "id-class-ad-disabled": false,
"href-abs-or-rel": false, "href-abs-or-rel": false,
"attr-unsafe-chars": true, "attr-unsafe-chars": true,
"head-script-disabled": true "head-script-disabled": true
} }

View File

@@ -1 +0,0 @@
6.9.4

3
.prettierignore Normal file
View File

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

View File

@@ -1,67 +1,12 @@
{ {
"rules": { "extends": ["stylelint-config-standard", "stylelint-config-prettier"],
# Stylistic rules for CSS. "rules": {
"function-comma-space-after": "always", "color-named": "never",
"function-comma-space-before": "never", "color-no-hex": true,
"function-max-empty-lines": 0, "font-family-no-missing-generic-family-keyword": [
"function-whitespace-after": "always", true,
{"ignoreFontFamilies": ["Material Icons"]}
"value-keyword-case": "lower", ],
"value-list-comma-newline-after": "always-multi-line", "selector-type-no-unknown": [true, {"ignoreTypes": ["webview"]}]
"value-list-comma-space-after": "always-single-line", }
"value-list-comma-space-before": "never", }
"value-list-max-empty-lines": 0,
"unit-case": "lower",
"property-case": "lower",
"color-hex-case": "lower",
"declaration-bang-space-before": "always",
"declaration-colon-newline-after": "always-multi-line",
"declaration-colon-space-after": "always-single-line",
"declaration-colon-space-before": "never",
"declaration-block-semicolon-newline-after": "always",
"declaration-block-semicolon-space-before": "never",
"declaration-block-trailing-semicolon": "always",
"block-closing-brace-empty-line-before": "never",
"block-closing-brace-newline-after": "always",
"block-closing-brace-newline-before": "always",
"block-opening-brace-newline-after": "always",
"block-opening-brace-space-before": "always",
"selector-attribute-brackets-space-inside": "never",
"selector-attribute-operator-space-after": "never",
"selector-attribute-operator-space-before": "never",
"selector-combinator-space-after": "always",
"selector-combinator-space-before": "always",
"selector-descendant-combinator-no-non-space": true,
"selector-pseudo-class-parentheses-space-inside": "never",
"selector-pseudo-element-case": "lower",
"selector-pseudo-element-colon-notation": "double",
"selector-type-case": "lower",
"selector-list-comma-newline-after": "always",
"selector-list-comma-space-before": "never",
"media-feature-colon-space-after": "always",
"media-feature-colon-space-before": "never",
"media-feature-name-case": "lower",
"media-feature-parentheses-space-inside": "never",
"media-feature-range-operator-space-after": "always",
"media-feature-range-operator-space-before": "always",
"media-query-list-comma-newline-after": "always",
"media-query-list-comma-space-before": "never",
"at-rule-name-case": "lower",
"at-rule-name-space-after": "always",
"at-rule-semicolon-newline-after": "always",
"at-rule-semicolon-space-before": "never",
"comment-whitespace-inside": "always",
"indentation": 4,
# Limit language features
"color-no-hex": true,
"color-named": "never",
}
}

View File

@@ -10,11 +10,12 @@ Zulip-Desktop app is built on top of [Electron](http://electron.atom.io/). If yo
## Community ## Community
* The whole Zulip documentation, such as setting up a development environment, setting up with the Zulip webapp project, and testing, can be read [here](https://zulip.readthedocs.io). - The whole Zulip documentation, such as setting up a development environment, setting up with the Zulip webapp project, and testing, can be read [here](https://zulip.readthedocs.io).
* If you have any questions regarding zulip-desktop, open an [issue](https://github.com/zulip/zulip-desktop/issues/new/) or ask it on [chat.zulip.org](https://chat.zulip.org/#narrow/stream/16-desktop). - If you have any questions regarding zulip-desktop, open an [issue](https://github.com/zulip/zulip-desktop/issues/new/) or ask it on [chat.zulip.org](https://chat.zulip.org/#narrow/stream/16-desktop).
## Issue ## Issue
Ensure the bug was not already reported by searching on GitHub under [issues](https://github.com/zulip/zulip-desktop/issues). If you're unable to find an open issue addressing the bug, open a [new issue](https://github.com/zulip/zulip-desktop/issues/new). Ensure the bug was not already reported by searching on GitHub under [issues](https://github.com/zulip/zulip-desktop/issues). If you're unable to find an open issue addressing the bug, open a [new issue](https://github.com/zulip/zulip-desktop/issues/new).
The [zulipbot](https://github.com/zulip/zulipbot) helps to claim an issue by commenting the following in the comment section: "**@zulipbot** claim". **@zulipbot** will assign you to the issue and label the issue as **in progress**. For more details, check out [**@zulipbot**](https://github.com/zulip/zulipbot). The [zulipbot](https://github.com/zulip/zulipbot) helps to claim an issue by commenting the following in the comment section: "**@zulipbot** claim". **@zulipbot** will assign you to the issue and label the issue as **in progress**. For more details, check out [**@zulipbot**](https://github.com/zulip/zulipbot).
@@ -22,26 +23,29 @@ The [zulipbot](https://github.com/zulip/zulipbot) helps to claim an issue by com
Please pay attention to the following points while opening an issue. Please pay attention to the following points while opening an issue.
### Does it happen on web browsers? (especially Chrome) ### Does it happen on web browsers? (especially Chrome)
Zulip's desktop client is based on Electron, which integrates the Chrome engine within a standalone application. Zulip's desktop client is based on Electron, which integrates the Chrome engine within a standalone application.
If the problem you encounter can be reproduced on web browsers, it may be an issue with [Zulip web app](https://github.com/zulip/zulip). If the problem you encounter can be reproduced on web browsers, it may be an issue with [Zulip web app](https://github.com/zulip/zulip).
### Write detailed information ### Write detailed information
Detailed information is very helpful to understand an issue. Detailed information is very helpful to understand an issue.
For example: For example:
* How to reproduce the issue, step-by-step.
* The expected behavior (or what is wrong).
* Screenshots for GUI issues.
* The application version.
* The operating system.
* The Zulip-Desktop version.
- How to reproduce the issue, step-by-step.
- The expected behavior (or what is wrong).
- Screenshots for GUI issues.
- The application version.
- The operating system.
- The Zulip-Desktop version.
## Pull Requests ## Pull Requests
Pull Requests are always welcome. Pull Requests are always welcome.
1. When you edit the code, please run `npm run test` to check the formatting of your code before you `git commit`. 1. When you edit the code, please run `npm run test` to check the formatting of your code before you `git commit`.
2. Ensure the PR description clearly describes the problem and solution. It should include: 2. Ensure the PR description clearly describes the problem and solution. It should include:
* The operating system on which you tested. - The operating system on which you tested.
* The Zulip-Desktop version on which you tested. - The Zulip-Desktop version on which you tested.
* The relevant issue number, if applicable. - The relevant issue number, if applicable.

View File

@@ -1,4 +1,5 @@
# Zulip Desktop Client # Zulip Desktop Client
[![Build Status](https://travis-ci.com/zulip/zulip-desktop.svg?branch=main)](https://travis-ci.com/github/zulip/zulip-desktop) [![Build Status](https://travis-ci.com/zulip/zulip-desktop.svg?branch=main)](https://travis-ci.com/github/zulip/zulip-desktop)
[![Windows Build Status](https://ci.appveyor.com/api/projects/status/github/zulip/zulip-desktop?branch=main&svg=true)](https://ci.appveyor.com/project/zulip/zulip-desktop/branch/main) [![Windows Build Status](https://ci.appveyor.com/api/projects/status/github/zulip/zulip-desktop?branch=main&svg=true)](https://ci.appveyor.com/project/zulip/zulip-desktop/branch/main)
[![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo) [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo)
@@ -10,21 +11,23 @@ Desktop client for Zulip. Available for Mac, Linux, and Windows.
![screenshot](https://i.imgur.com/vekKnW4.png) ![screenshot](https://i.imgur.com/vekKnW4.png)
# Download # Download
Please see the [installation guide](https://zulip.com/help/desktop-app-install-guide). Please see the [installation guide](https://zulip.com/help/desktop-app-install-guide).
# Features # Features
* Sign in to multiple organizations
* Desktop notifications with inline reply - Sign in to multiple organizations
* Tray/dock integration - Desktop notifications with inline reply
* Multi-language spell checker - Tray/dock integration
* Automatic updates - Multi-language spell checker
- Automatic updates
# Reporting issues # Reporting issues
This desktop client shares most of its code with the Zulip webapp. This desktop client shares most of its code with the Zulip webapp.
Issues in an individual organization's Zulip window should be reported Issues in an individual organization's Zulip window should be reported
in the [Zulip server and webapp in the [Zulip server and webapp
project](https://github.com/zulip/zulip/issues/new). Other project](https://github.com/zulip/zulip/issues/new). Other
issues in the desktop app and its settings should be reported [in this issues in the desktop app and its settings should be reported [in this
project](https://github.com/zulip/zulip-desktop/issues/new). project](https://github.com/zulip/zulip-desktop/issues/new).
@@ -34,4 +37,5 @@ First, join us on the [Zulip community server](https://zulip.readthedocs.io/en/l
Also see our [contribution guidelines](./CONTRIBUTING.md) and our [development guide](./development.md). Also see our [contribution guidelines](./CONTRIBUTING.md) and our [development guide](./development.md).
# License # License
Released under the [Apache-2.0](./LICENSE) license. Released under the [Apache-2.0](./LICENSE) license.

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(),
};

100
app/common/config-util.ts Normal file
View File

@@ -0,0 +1,100 @@
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 {configSchemata} from "./config-schemata";
import * as EnterpriseUtil from "./enterprise-util";
import Logger from "./logger-util";
import {app, dialog} from "./remote";
export type Config = {
[Key in keyof typeof configSchemata]: z.output<typeof configSchemata[Key]>;
};
const logger = new Logger({
file: "config-util.log",
});
let db: JsonDB;
reloadDb();
export function getConfigItem<Key extends keyof Config>(
key: Key,
defaultValue: Config[Key],
): z.output<typeof configSchemata[Key]> {
try {
db.reload();
} catch (error: unknown) {
logger.error("Error while reloading settings.json: ");
logger.error(error);
}
try {
return configSchemata[key].parse(db.getObject<unknown>(`/${key}`));
} catch (error: unknown) {
if (!(error instanceof DataError)) throw error;
setConfigItem(key, defaultValue);
return defaultValue;
}
}
// This function returns whether a key exists in the configuration file (settings.json)
export function isConfigItemExists(key: string): boolean {
try {
db.reload();
} catch (error: unknown) {
logger.error("Error while reloading settings.json: ");
logger.error(error);
}
return db.exists(`/${key}`);
}
export function setConfigItem<Key extends keyof Config>(
key: Key,
value: Config[Key],
override?: boolean,
): void {
if (EnterpriseUtil.configItemExists(key) && !override) {
// If item is in global config and we're not trying to override
return;
}
configSchemata[key].parse(value);
db.push(`/${key}`, value, true);
db.save();
}
export function removeConfigItem(key: string): void {
db.delete(`/${key}`);
db.save();
}
function reloadDb(): void {
const settingsJsonPath = path.join(
app.getPath("userData"),
"/config/settings.json",
);
try {
const file = fs.readFileSync(settingsJsonPath, "utf8");
JSON.parse(file);
} catch (error: unknown) {
if (fs.existsSync(settingsJsonPath)) {
fs.unlinkSync(settingsJsonPath);
dialog.showErrorBox(
"Error saving settings",
"We encountered an error while saving the settings.",
);
logger.error("Error while JSON parsing settings.json: ");
logger.error(error);
Sentry.captureException(error);
}
}
db = new JsonDB(settingsJsonPath, true, true);
}

View File

@@ -0,0 +1,61 @@
import fs from "node:fs";
import {app} from "./remote";
let setupCompleted = false;
const zulipDir = app.getPath("userData");
const logDir = `${zulipDir}/Logs/`;
const configDir = `${zulipDir}/config/`;
export const initSetUp = (): void => {
// If it is the first time the app is running
// create zulip dir in userData folder to
// avoid errors
if (!setupCompleted) {
if (!fs.existsSync(zulipDir)) {
fs.mkdirSync(zulipDir);
}
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir);
}
// Migrate config files from app data folder to config folder inside app
// data folder. This will be done once when a user updates to the new version.
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir);
const domainJson = `${zulipDir}/domain.json`;
const settingsJson = `${zulipDir}/settings.json`;
const updatesJson = `${zulipDir}/updates.json`;
const windowStateJson = `${zulipDir}/window-state.json`;
const configData = [
{
path: domainJson,
fileName: "domain.json",
},
{
path: settingsJson,
fileName: "settings.json",
},
{
path: updatesJson,
fileName: "updates.json",
},
];
for (const data of configData) {
if (fs.existsSync(data.path)) {
fs.copyFileSync(data.path, configDir + data.fileName);
fs.unlinkSync(data.path);
}
}
// `window-state.json` is only deleted not moved, as the electron-window-state
// package will recreate the file in the config folder.
if (fs.existsSync(windowStateJson)) {
fs.unlinkSync(windowStateJson);
}
}
setupCompleted = true;
}
};

60
app/common/dnd-util.ts Normal file
View File

@@ -0,0 +1,60 @@
import process from "node:process";
import type * as z from "zod";
import type {dndSettingsSchemata} from "./config-schemata";
import * as ConfigUtil from "./config-util";
export type DndSettings = {
[Key in keyof typeof dndSettingsSchemata]: z.output<
typeof dndSettingsSchemata[Key]
>;
};
type SettingName = keyof DndSettings;
interface Toggle {
dnd: boolean;
newSettings: Partial<DndSettings>;
}
export function toggle(): Toggle {
const dnd = !ConfigUtil.getConfigItem("dnd", false);
const dndSettingList: SettingName[] = ["showNotification", "silent"];
if (process.platform === "win32") {
dndSettingList.push("flashTaskbarOnMessage");
}
let newSettings: Partial<DndSettings>;
if (dnd) {
const oldSettings: Partial<DndSettings> = {};
newSettings = {};
// Iterate through the dndSettingList.
for (const settingName of dndSettingList) {
// Store the current value of setting.
oldSettings[settingName] = ConfigUtil.getConfigItem(
settingName,
settingName !== "silent",
);
// New value of setting.
newSettings[settingName] = settingName === "silent";
}
// Store old value in oldSettings.
ConfigUtil.setConfigItem("dndPreviousSettings", oldSettings);
} else {
newSettings = ConfigUtil.getConfigItem("dndPreviousSettings", {
showNotification: true,
silent: false,
...(process.platform === "win32" && {flashTaskbarOnMessage: true}),
});
}
for (const settingName of dndSettingList) {
ConfigUtil.setConfigItem(settingName, newSettings[settingName]!);
}
ConfigUtil.setConfigItem("dnd", dnd);
return {dnd, newSettings};
}

View File

@@ -0,0 +1,94 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import * as z from "zod";
import {enterpriseConfigSchemata} from "./config-schemata";
import Logger from "./logger-util";
type EnterpriseConfig = {
[Key in keyof typeof enterpriseConfigSchemata]: z.output<
typeof enterpriseConfigSchemata[Key]
>;
};
const logger = new Logger({
file: "enterprise-util.log",
});
let enterpriseSettings: Partial<EnterpriseConfig>;
let configFile: boolean;
reloadDb();
function reloadDb(): void {
let enterpriseFile = "/etc/zulip-desktop-config/global_config.json";
if (process.platform === "win32") {
enterpriseFile =
"C:\\Program Files\\Zulip-Desktop-Config\\global_config.json";
}
enterpriseFile = path.resolve(enterpriseFile);
if (fs.existsSync(enterpriseFile)) {
configFile = true;
try {
const file = fs.readFileSync(enterpriseFile, "utf8");
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);
}
} else {
configFile = false;
}
}
export function hasConfigFile(): boolean {
return configFile;
}
export function getConfigItem<Key extends keyof EnterpriseConfig>(
key: Key,
defaultValue: EnterpriseConfig[Key],
): EnterpriseConfig[Key] {
reloadDb();
if (!configFile) {
return defaultValue;
}
const value = enterpriseSettings[key];
return value === undefined ? defaultValue : (value as EnterpriseConfig[Key]);
}
export function configItemExists(key: keyof EnterpriseConfig): boolean {
reloadDb();
if (!configFile) {
return false;
}
return enterpriseSettings[key] !== undefined;
}
export function isPresetOrg(url: string): boolean {
if (!configFile || !configItemExists("presetOrganizations")) {
return false;
}
const presetOrgs = enterpriseSettings.presetOrganizations;
if (!Array.isArray(presetOrgs)) {
throw new TypeError("Expected array for presetOrgs");
}
for (const org of presetOrgs) {
if (url.includes(org)) {
return true;
}
}
return false;
}

26
app/common/html.ts Normal file
View File

@@ -0,0 +1,26 @@
import {htmlEscape} from "escape-goat";
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)});
}
}
export function html(
template: TemplateStringsArray,
...values: unknown[]
): Html {
let html = template[0];
for (const [index, value] of values.entries()) {
html += value instanceof Html ? value.html : htmlEscape(String(value));
html += template[index + 1];
}
return new Html({html});
}

43
app/common/link-util.ts Normal file
View File

@@ -0,0 +1,43 @@
import {shell} from "electron/common";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {html} from "./html";
export async function openBrowser(url: URL): Promise<void> {
if (["http:", "https:", "mailto:"].includes(url.protocol)) {
await shell.openExternal(url.href);
} else {
// For security, indirect links to non-whitelisted protocols
// through a real web browser via a local HTML file.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "zulip-redirect-"));
const file = path.join(dir, "redirect.html");
fs.writeFileSync(
file,
html`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="Refresh" content="0; url=${url.href}" />
<title>Redirecting</title>
<style>
html {
font-family: menu, "Helvetica Neue", sans-serif;
}
</style>
</head>
<body>
<p>Opening <a href="${url.href}">${url.href}</a>…</p>
</body>
</html>
`.html,
);
await shell.openPath(file);
setTimeout(() => {
fs.unlinkSync(file);
fs.rmdirSync(dir);
}, 15_000);
}
}

89
app/common/logger-util.ts Normal file
View File

@@ -0,0 +1,89 @@
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 {app} from "./remote";
interface LoggerOptions {
file?: string;
}
initSetUp();
const logDir = `${app.getPath("userData")}/Logs`;
type Level = "log" | "debug" | "info" | "warn" | "error";
export default class Logger {
nodeConsole: Console;
constructor(options: LoggerOptions = {}) {
let {file = "console.log"} = options;
file = `${logDir}/${file}`;
// Trim log according to type of process
if (process.type === "renderer") {
requestIdleCallback(async () => this.trimLog(file));
} else {
process.nextTick(async () => this.trimLog(file));
}
const fileStream = fs.createWriteStream(file, {flags: "a"});
const nodeConsole = new Console(fileStream);
this.nodeConsole = nodeConsole;
}
_log(type: Level, ...args: unknown[]): void {
args.unshift(this.getTimestamp() + " |\t");
args.unshift(type.toUpperCase() + " |");
this.nodeConsole[type](...args);
console[type](...args);
}
log(...args: unknown[]): void {
this._log("log", ...args);
}
debug(...args: unknown[]): void {
this._log("debug", ...args);
}
info(...args: unknown[]): void {
this._log("info", ...args);
}
warn(...args: unknown[]): void {
this._log("warn", ...args);
}
error(...args: unknown[]): void {
this._log("error", ...args);
}
getTimestamp(): string {
const date = new Date();
const timestamp =
`${date.getMonth()}/${date.getDate()} ` +
`${date.getMinutes()}:${date.getSeconds()}`;
return timestamp;
}
async trimLog(file: string): Promise<void> {
const data = await fs.promises.readFile(file, "utf8");
const maxLogFileLines = 500;
const logs = data.split(os.EOL);
const logLength = logs.length - 1;
// 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);
}
}
}

38
app/common/messages.ts Normal file
View File

@@ -0,0 +1,38 @@
interface DialogBoxError {
title: string;
content: string;
}
export function invalidZulipServerError(domain: string): string {
return `${domain} does not appear to be a valid Zulip server. Make sure that
• You can connect to that URL in a web browser.
• If you need a proxy to connect to the Internet, that you've configured your proxy in the Network settings.
• It's a Zulip server. (The oldest supported version is 1.6).
• The server has a valid certificate.
• The SSL is correctly configured for the certificate. Check out the SSL troubleshooting guide -
https://zulip.readthedocs.io/en/stable/production/ssl-certificates.html`;
}
export function enterpriseOrgError(
length: number,
domains: string[],
): DialogBoxError {
let domainList = "";
for (const domain of domains) {
domainList += `${domain}\n`;
}
return {
title: `Could not add the following ${
length === 1 ? "organization" : "organizations"
}`,
content: `${domainList}\nPlease contact your system administrator.`,
};
}
export function orgRemovalError(url: string): DialogBoxError {
return {
title: `Removing ${url} is a restricted operation.`,
content: "Please contact your system administrator.",
};
}

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

@@ -0,0 +1,18 @@
import path from "node:path";
import i18n from "i18n";
import * as ConfigUtil from "./config-util";
i18n.configure({
directory: path.join(__dirname, "../translations/"),
updateFiles: false,
});
/* Fetches the current appLocale from settings.json */
const appLocale = ConfigUtil.getConfigItem("appLanguage", "en");
/* If no locale present in the json, en is set default */
export function __(phrase: string): string {
return i18n.__({phrase, locale: appLocale ? appLocale : "en"});
}

82
app/common/typed-ipc.ts Normal file
View File

@@ -0,0 +1,82 @@
import type {DndSettings} from "./dnd-util";
import type {MenuProps, ServerConf} from "./types";
export interface MainMessage {
"clear-app-settings": () => 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;
"switch-server-tab": (index: number) => void;
"toggle-app": () => void;
"toggle-badge-option": (newValue: boolean) => void;
"toggle-menubar": (showMenubar: boolean) => void;
toggleAutoLauncher: (AutoLaunchValue: boolean) => void;
"unread-count": (unreadCount: number) => void;
"update-badge": (messageCount: number) => void;
"update-menu": (props: MenuProps) => void;
"update-taskbar-icon": (data: string, text: string) => void;
}
export interface MainCall {
"get-server-settings": (domain: string) => ServerConf;
"is-online": (url: string) => boolean;
"save-server-icon": (iconURL: string) => string;
}
export interface RendererMessage {
back: () => void;
"copy-zulip-url": () => void;
destroytray: () => void;
"enter-fullscreen": () => void;
focus: () => void;
"focus-webview-with-id": (webviewId: number) => void;
forward: () => void;
"hard-reload": () => void;
"leave-fullscreen": () => void;
"log-out": () => void;
logout: () => void;
"new-server": () => void;
"open-about": () => void;
"open-help": () => void;
"open-network-settings": () => void;
"open-org-tab": () => void;
"open-settings": () => void;
"permission-request": (
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;
"render-taskbar-icon": (messageCount: number) => void;
"set-active": () => void;
"set-idle": () => void;
"show-keyboard-shortcuts": () => void;
"show-notification-settings": () => void;
"switch-server-tab": (index: number) => void;
"tab-devtools": () => void;
"toggle-autohide-menubar": (
autoHideMenubar: boolean,
updateMenu: boolean,
) => void;
"toggle-dnd": (state: boolean, newSettings: Partial<DndSettings>) => void;
"toggle-sidebar": (show: boolean) => void;
"toggle-silent": (state: boolean) => void;
"toggle-tray": (state: boolean) => void;
toggletray: () => void;
tray: (arg: number) => void;
"update-realm-icon": (serverURL: string, iconURL: string) => void;
"update-realm-name": (serveRURL: string, realmName: string) => void;
"webview-reload": () => void;
zoomActualSize: () => void;
zoomIn: () => void;
zoomOut: () => void;
}

26
app/common/types.ts Normal file
View File

@@ -0,0 +1,26 @@
export interface MenuProps {
tabs: TabData[];
activeTabIndex?: number;
enableMenu?: boolean;
}
export type NavItem =
| "General"
| "Network"
| "AddServer"
| "Organizations"
| "Shortcuts";
export interface ServerConf {
url: string;
alias: string;
icon: string;
}
export type TabRole = "server" | "function";
export interface TabData {
role: TabRole;
name: string;
index: number;
}

View File

@@ -1,111 +1,115 @@
import {app, dialog, session} from 'electron'; import {shell} from "electron/common";
import util from 'util'; import {app, dialog, session} from "electron/main";
import process from "node:process";
import util from "node:util";
import isDev from 'electron-is-dev'; import log from "electron-log";
import log from 'electron-log'; import type {UpdateDownloadedEvent, UpdateInfo} from "electron-updater";
import {UpdateDownloadedEvent, UpdateInfo, autoUpdater} from 'electron-updater'; import {autoUpdater} from "electron-updater";
import * as ConfigUtil from '../renderer/js/utils/config-util'; import * as ConfigUtil from "../common/config-util";
import * as LinkUtil from '../renderer/js/utils/link-util';
import {linuxUpdateNotification} from './linuxupdater'; // Required only in case of linux import {linuxUpdateNotification} from "./linuxupdater"; // Required only in case of linux
const sleep = util.promisify(setTimeout); const sleep = util.promisify(setTimeout);
export async function appUpdater(updateFromMenu = false): Promise<void> { export async function appUpdater(updateFromMenu = false): Promise<void> {
// Don't initiate auto-updates in development // Don't initiate auto-updates in development
if (isDev) { if (!app.isPackaged) {
return; return;
} }
if (process.platform === 'linux' && !process.env.APPIMAGE) { if (process.platform === "linux" && !process.env.APPIMAGE) {
const ses = session.fromPartition('persist:webviewsession'); const ses = session.fromPartition("persist:webviewsession");
await linuxUpdateNotification(ses); await linuxUpdateNotification(ses);
return; return;
} }
let updateAvailable = false; let updateAvailable = false;
// Create Logs directory // Log whats happening
const LogsDir = `${app.getPath('userData')}/Logs`; log.transports.file.fileName = "updates.log";
log.transports.file.level = "info";
autoUpdater.logger = log;
// Log whats happening // Handle auto updates for beta/pre releases
log.transports.file.file = `${LogsDir}/updates.log`; const isBetaUpdate = ConfigUtil.getConfigItem("betaUpdate", false);
log.transports.file.level = 'info';
autoUpdater.logger = log;
// Handle auto updates for beta/pre releases autoUpdater.allowPrerelease = isBetaUpdate;
const isBetaUpdate = ConfigUtil.getConfigItem('betaUpdate');
autoUpdater.allowPrerelease = isBetaUpdate || false; const eventsListenerRemove = ["update-available", "update-not-available"];
autoUpdater.on("update-available", async (info: UpdateInfo) => {
if (updateFromMenu) {
updateAvailable = true;
const eventsListenerRemove = ['update-available', 'update-not-available']; // This is to prevent removal of 'update-downloaded' and 'error' event listener.
autoUpdater.on('update-available', async (info: UpdateInfo) => { for (const event of eventsListenerRemove) {
if (updateFromMenu) { autoUpdater.removeAllListeners(event);
updateAvailable = true; }
// This is to prevent removal of 'update-downloaded' and 'error' event listener. await dialog.showMessageBox({
eventsListenerRemove.forEach(event => { message: `A new version ${info.version}, of Zulip Desktop is available`,
autoUpdater.removeAllListeners(event); detail:
}); "The update will be downloaded in the background. You will be notified when it is ready to be installed.",
});
}
});
await dialog.showMessageBox({ autoUpdater.on("update-not-available", async () => {
message: `A new version ${info.version}, of Zulip Desktop is available`, if (updateFromMenu) {
detail: 'The update will be downloaded in the background. You will be notified when it is ready to be installed.' // Remove all autoUpdator listeners so that next time autoUpdator is manually called these
}); // listeners don't trigger multiple times.
} autoUpdater.removeAllListeners();
});
autoUpdater.on('update-not-available', async () => { await dialog.showMessageBox({
if (updateFromMenu) { message: "No updates available",
// Remove all autoUpdator listeners so that next time autoUpdator is manually called these detail: `You are running the latest version of Zulip Desktop.\nVersion: ${app.getVersion()}`,
// listeners don't trigger multiple times. });
autoUpdater.removeAllListeners(); }
});
await dialog.showMessageBox({ autoUpdater.on("error", async (error: Error) => {
message: 'No updates available', if (updateFromMenu) {
detail: `You are running the latest version of Zulip Desktop.\nVersion: ${app.getVersion()}` // Remove all autoUpdator listeners so that next time autoUpdator is manually called these
}); // listeners don't trigger multiple times.
} autoUpdater.removeAllListeners();
});
autoUpdater.on('error', async (error: Error) => { const messageText = updateAvailable
if (updateFromMenu) { ? "Unable to download the updates"
// Remove all autoUpdator listeners so that next time autoUpdator is manually called these : "Unable to check for updates";
// listeners don't trigger multiple times. const {response} = await dialog.showMessageBox({
autoUpdater.removeAllListeners(); type: "error",
buttons: ["Manual Download", "Cancel"],
message: messageText,
detail: `Error: ${error.message}
const messageText = (updateAvailable) ? ('Unable to download the updates') : ('Unable to check for updates'); The latest version of Zulip Desktop is available at -
const {response} = await dialog.showMessageBox({ https://zulip.com/apps/.
type: 'error', Current Version: ${app.getVersion()}`,
buttons: ['Manual Download', 'Cancel'], });
message: messageText, if (response === 0) {
detail: `Error: ${error.message}\n\nThe latest version of Zulip Desktop is available at -\nhttps://zulip.com/apps/.\n await shell.openExternal("https://zulip.com/apps/");
Current Version: ${app.getVersion()}` }
}); }
if (response === 0) { });
await LinkUtil.openBrowser(new URL('https://zulip.com/apps/'));
}
}
});
// Ask the user if update is available // Ask the user if update is available
autoUpdater.on('update-downloaded', async (event: UpdateDownloadedEvent) => { autoUpdater.on("update-downloaded", async (event: UpdateDownloadedEvent) => {
// Ask user to update the app // Ask user to update the app
const {response} = await dialog.showMessageBox({ const {response} = await dialog.showMessageBox({
type: 'question', type: "question",
buttons: ['Install and Relaunch', 'Install Later'], buttons: ["Install and Relaunch", "Install Later"],
defaultId: 0, defaultId: 0,
message: `A new update ${event.version} has been downloaded`, message: `A new update ${event.version} has been downloaded`,
detail: 'It will be installed the next time you restart the application' detail: "It will be installed the next time you restart the application",
}); });
if (response === 0) { if (response === 0) {
await sleep(1000); await sleep(1000);
autoUpdater.quitAndInstall(); autoUpdater.quitAndInstall();
// Force app to quit. This is just a workaround, ideally autoUpdater.quitAndInstall() should relaunch the app. // Force app to quit. This is just a workaround, ideally autoUpdater.quitAndInstall() should relaunch the app.
app.quit(); app.quit();
} }
}); });
// Init for updates // Init for updates
await autoUpdater.checkForUpdates(); await autoUpdater.checkForUpdates();
} }

View File

@@ -0,0 +1,61 @@
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: BrowserWindow): void {
if (process.platform === "win32") {
updateOverlayIcon(messageCount, mainWindow);
} else {
app.badgeCount = messageCount;
}
}
function hideBadgeCount(mainWindow: BrowserWindow): void {
if (process.platform === "win32") {
mainWindow.setOverlayIcon(null, "");
} else {
app.badgeCount = 0;
}
}
export function updateBadge(
badgeCount: number,
mainWindow: BrowserWindow,
): void {
if (ConfigUtil.getConfigItem("badgeOption", true)) {
showBadgeCount(badgeCount, mainWindow);
} else {
hideBadgeCount(mainWindow);
}
}
function updateOverlayIcon(
messageCount: number,
mainWindow: BrowserWindow,
): void {
if (!mainWindow.isFocused()) {
mainWindow.flashFrame(
ConfigUtil.getConfigItem("flashTaskbarOnMessage", true),
);
}
if (messageCount === 0) {
mainWindow.setOverlayIcon(null, "");
} else {
send(mainWindow.webContents, "render-taskbar-icon", messageCount);
}
}
export function updateTaskbarIcon(
data: string,
text: string,
mainWindow: BrowserWindow,
): void {
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,417 +1,411 @@
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 electron, {app, dialog, ipcMain, session} from 'electron'; import * as remoteMain from "@electron/remote/main";
import fs from 'fs'; import windowStateKeeper from "electron-window-state";
import path from 'path';
import windowStateKeeper from 'electron-window-state'; import * as ConfigUtil from "../common/config-util";
import type {RendererMessage} from "../common/typed-ipc";
import type {MenuProps} from "../common/types";
import * as BadgeSettings from '../renderer/js/pages/preference/badge-settings'; import {appUpdater} from "./autoupdater";
import * as ConfigUtil from '../renderer/js/utils/config-util'; import * as BadgeSettings from "./badge-settings";
import * as ProxyUtil from '../renderer/js/utils/proxy-util'; import handleExternalLink from "./handle-external-link";
import {sentryInit} from '../renderer/js/utils/sentry-util'; import * as AppMenu from "./menu";
import {_getServerSettings, _isOnline, _saveServerIcon} from "./request";
import {sentryInit} from "./sentry";
import {setAutoLaunch} from "./startup";
import {ipcMain, send} from "./typed-ipc-main";
import {appUpdater} from './autoupdater'; // eslint-disable-next-line @typescript-eslint/naming-convention
import * as AppMenu from './menu'; const {GDK_BACKEND} = process.env;
import {_getServerSettings, _saveServerIcon, _isOnline} from './request';
import {setAutoLaunch} from './startup'; // Initialize sentry for main process
sentryInit();
let mainWindowState: windowStateKeeper.State; let mainWindowState: windowStateKeeper.State;
// Prevent window being garbage collected // Prevent window being garbage collected
let mainWindow: Electron.BrowserWindow; let mainWindow: BrowserWindow;
let badgeCount: number; let badgeCount: number;
let isQuitting = false; let isQuitting = false;
// Load this url in main window // 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 singleInstanceLock = app.requestSingleInstanceLock(); const permissionCallbacks = new Map<number, (grant: boolean) => void>();
if (singleInstanceLock) { let nextPermissionCallbackId = 0;
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show(); const appIcon = path.join(__dirname, "../resources", "Icon");
}
});
} else {
app.quit();
}
const rendererCallbacks = new Map(); const iconPath = (): string =>
let nextRendererCallbackId = 0; appIcon + (process.platform === "win32" ? ".ico" : ".png");
ipcMain.on('renderer-callback', (event: Event, rendererCallbackId: number, ...args: any[]) => {
rendererCallbacks.get(rendererCallbackId)(...args);
rendererCallbacks.delete(rendererCallbackId);
});
function makeRendererCallback(callback: (...args: any[]) => void): number {
rendererCallbacks.set(nextRendererCallbackId, callback);
return nextRendererCallbackId++;
}
const APP_ICON = path.join(__dirname, '../resources', 'Icon');
const iconPath = (): string => APP_ICON + (process.platform === 'win32' ? '.ico' : '.png');
// Toggle the app window // Toggle the app window
const toggleApp = (): void => { const toggleApp = (): void => {
if (!mainWindow.isVisible() || mainWindow.isMinimized()) { if (!mainWindow.isVisible() || mainWindow.isMinimized()) {
mainWindow.show(); mainWindow.show();
} else { } else {
mainWindow.hide(); mainWindow.hide();
} }
}; };
function createMainWindow(): Electron.BrowserWindow { function createMainWindow(): BrowserWindow {
// Load the previous state with fallback to defaults // Load the previous state with fallback to defaults
mainWindowState = windowStateKeeper({ mainWindowState = windowStateKeeper({
defaultWidth: 1100, defaultWidth: 1100,
defaultHeight: 720, defaultHeight: 720,
path: `${app.getPath('userData')}/config` path: `${app.getPath("userData")}/config`,
}); });
const win = new electron.BrowserWindow({ const win = new BrowserWindow({
// This settings needs to be saved in config // This settings needs to be saved in config
title: 'Zulip', title: "Zulip",
icon: iconPath(), icon: iconPath(),
x: mainWindowState.x, x: mainWindowState.x,
y: mainWindowState.y, y: mainWindowState.y,
width: mainWindowState.width, width: mainWindowState.width,
height: mainWindowState.height, height: mainWindowState.height,
minWidth: 500, minWidth: 500,
minHeight: 400, minHeight: 400,
webPreferences: { webPreferences: {
contextIsolation: false, preload: require.resolve("../renderer/js/main"),
enableRemoteModule: true, webviewTag: true,
nodeIntegration: true, },
partition: 'persist:webviewsession', show: false,
webviewTag: true });
}, remoteMain.enable(win.webContents);
show: false
});
win.on('focus', () => { win.on("focus", () => {
win.webContents.send('focus'); send(win.webContents, "focus");
}); });
(async () => win.loadURL(mainURL))(); (async () => win.loadURL(mainUrl))();
// Keep the app running in background on close event // Keep the app running in background on close event
win.on('close', event => { win.on("close", (event) => {
if (ConfigUtil.getConfigItem('quitOnClose')) { if (ConfigUtil.getConfigItem("quitOnClose", false)) {
app.quit(); app.quit();
} }
if (!isQuitting) { if (!isQuitting) {
event.preventDefault(); event.preventDefault();
if (process.platform === 'darwin') { if (process.platform === "darwin") {
app.hide(); app.hide();
} else { } else {
win.hide(); win.hide();
} }
} }
}); });
win.setTitle('Zulip'); win.setTitle("Zulip");
win.on('enter-full-screen', () => { win.on("enter-full-screen", () => {
win.webContents.send('enter-fullscreen'); send(win.webContents, "enter-fullscreen");
}); });
win.on('leave-full-screen', () => { win.on("leave-full-screen", () => {
win.webContents.send('leave-fullscreen'); send(win.webContents, "leave-fullscreen");
}); });
// To destroy tray icon when navigate to a new URL // To destroy tray icon when navigate to a new URL
win.webContents.on('will-navigate', event => { win.webContents.on("will-navigate", (event) => {
if (event) { if (event) {
win.webContents.send('destroytray'); send(win.webContents, "destroytray");
} }
}); });
// Let us register listeners on the window, so we can update the state // Let us register listeners on the window, so we can update the state
// automatically (the listeners will be removed when the window is closed) // automatically (the listeners will be removed when the window is closed)
// and restore the maximized or full screen state // and restore the maximized or full screen state
mainWindowState.manage(win); mainWindowState.manage(win);
return win; return win;
} }
// Temporary fix for Electron render colors differently (async () => {
// More info here - https://github.com/electron/electron/issues/10732 if (!app.requestSingleInstanceLock()) {
app.commandLine.appendSwitch('force-color-profile', 'srgb'); app.quit();
return;
}
// This event is only available on macOS. Triggers when you click on the dock icon. await app.whenReady();
app.on('activate', () => {
if (mainWindow) {
// If there is already a window show it
mainWindow.show();
} else {
mainWindow = createMainWindow();
}
});
app.on('ready', () => { if (process.env.GDK_BACKEND !== GDK_BACKEND) {
const ses = session.fromPartition('persist:webviewsession'); console.warn(
ses.setUserAgent(`ZulipElectron/${app.getVersion()} ${ses.getUserAgent()}`); "Reverting GDK_BACKEND to work around https://github.com/electron/electron/issues/28436",
);
if (GDK_BACKEND === undefined) {
delete process.env.GDK_BACKEND;
} else {
process.env.GDK_BACKEND = GDK_BACKEND;
}
}
ipcMain.on('set-spellcheck-langs', () => { // Used for notifications on Windows
ses.setSpellCheckerLanguages(ConfigUtil.getConfigItem('spellcheckerLanguages')); app.setAppUserModelId("org.zulip.zulip-electron");
});
AppMenu.setMenu({
tabs: []
});
mainWindow = createMainWindow();
// Auto-hide menu bar on Windows + Linux remoteMain.initialize();
if (process.platform !== 'darwin') {
const shouldHideMenu = ConfigUtil.getConfigItem('autoHideMenubar') || false;
mainWindow.autoHideMenuBar = shouldHideMenu;
mainWindow.setMenuBarVisibility(!shouldHideMenu);
}
// Initialize sentry for main process app.on("second-instance", () => {
const errorReporting = ConfigUtil.getConfigItem('errorReporting'); if (mainWindow) {
if (errorReporting) { if (mainWindow.isMinimized()) {
sentryInit(); mainWindow.restore();
} }
const isSystemProxy = ConfigUtil.getConfigItem('useSystemProxy'); mainWindow.show();
}
});
if (isSystemProxy) { ipcMain.on(
(async () => ProxyUtil.resolveSystemProxy(mainWindow))(); "permission-callback",
} (event: Event, permissionCallbackId: number, grant: boolean) => {
permissionCallbacks.get(permissionCallbackId)?.(grant);
permissionCallbacks.delete(permissionCallbackId);
},
);
const page = mainWindow.webContents; // This event is only available on macOS. Triggers when you click on the dock icon.
app.on("activate", () => {
mainWindow.show();
});
page.on('dom-ready', () => { app.on("web-contents-created", (_event: Event, contents: WebContents) => {
if (ConfigUtil.getConfigItem('startMinimized')) { contents.setWindowOpenHandler((details) => {
mainWindow.hide(); handleExternalLink(contents, details, page);
} else { return {action: "deny"};
mainWindow.show(); });
} });
});
ipcMain.on('fetch-user-agent', event => { const ses = session.fromPartition("persist:webviewsession");
event.returnValue = session.fromPartition('persist:webviewsession').getUserAgent(); ses.setUserAgent(`ZulipElectron/${app.getVersion()} ${ses.getUserAgent()}`);
});
ipcMain.handle('get-server-settings', async (event, domain: string) => _getServerSettings(domain, ses)); function configureSpellChecker() {
const enable = ConfigUtil.getConfigItem("enableSpellchecker", true);
if (enable && process.platform !== "darwin") {
ses.setSpellCheckerLanguages(
ConfigUtil.getConfigItem("spellcheckerLanguages", null) ?? [],
);
}
ipcMain.handle('save-server-icon', async (event, url: string) => _saveServerIcon(url, ses)); ses.setSpellCheckerEnabled(enable);
}
ipcMain.handle('is-online', async (event, url: string) => _isOnline(url, ses)); configureSpellChecker();
ipcMain.on("configure-spell-checker", configureSpellChecker);
page.once('did-frame-finish-load', async () => { AppMenu.setMenu({
// Initiate auto-updates on MacOS and Windows tabs: [],
if (ConfigUtil.getConfigItem('autoUpdate')) { });
await appUpdater(); mainWindow = createMainWindow();
}
});
app.on('certificate-error', ( // Auto-hide menu bar on Windows + Linux
event: Event, if (process.platform !== "darwin") {
webContents: Electron.WebContents, const shouldHideMenu = ConfigUtil.getConfigItem("autoHideMenubar", false);
urlString: string, mainWindow.autoHideMenuBar = shouldHideMenu;
error: string mainWindow.setMenuBarVisibility(!shouldHideMenu);
) => { }
const url = new URL(urlString);
dialog.showErrorBox(
'Certificate error',
`The server presented an invalid certificate for ${url.origin}:
${error}` const page = mainWindow.webContents;
);
});
page.session.setPermissionRequestHandler((webContents, permission, callback, details) => { page.on("dom-ready", () => {
const {origin} = new URL(details.requestingUrl); if (ConfigUtil.getConfigItem("startMinimized", false)) {
page.send('permission-request', { mainWindow.hide();
webContentsId: webContents.id === mainWindow.webContents.id ? } else {
null : mainWindow.show();
webContents.id, }
origin, });
permission
}, makeRendererCallback(callback));
});
// Temporarily remove this event ipcMain.on("fetch-user-agent", (event) => {
// electron.powerMonitor.on('resume', () => { event.returnValue = session
// mainWindow.reload(); .fromPartition("persist:webviewsession")
// page.send('destroytray'); .getUserAgent();
// }); });
ipcMain.on('focus-app', () => { ipcMain.handle("get-server-settings", async (event, domain: string) =>
mainWindow.show(); _getServerSettings(domain, ses),
}); );
ipcMain.on('quit-app', () => { ipcMain.handle("save-server-icon", async (event, url: string) =>
app.quit(); _saveServerIcon(url, ses),
}); );
// Reload full app not just webview, useful in debugging ipcMain.handle("is-online", async (event, url: string) =>
ipcMain.on('reload-full-app', () => { _isOnline(url, ses),
mainWindow.reload(); );
page.send('destroytray');
});
ipcMain.on('clear-app-settings', () => { page.once("did-frame-finish-load", async () => {
mainWindowState.unmanage(); // Initiate auto-updates on MacOS and Windows
app.relaunch(); if (ConfigUtil.getConfigItem("autoUpdate", true)) {
app.exit(); await appUpdater();
}); }
});
ipcMain.on('toggle-app', () => { app.on(
toggleApp(); "certificate-error",
}); (
event: Event,
webContents: WebContents,
urlString: string,
error: string,
) => {
const url = new URL(urlString);
dialog.showErrorBox(
"Certificate error",
`The server presented an invalid certificate for ${url.origin}:
ipcMain.on('toggle-badge-option', () => { ${error}`,
BadgeSettings.updateBadge(badgeCount, mainWindow); );
}); },
);
ipcMain.on('toggle-menubar', (_event: Electron.IpcMainEvent, showMenubar: boolean) => { ses.setPermissionRequestHandler(
mainWindow.autoHideMenuBar = showMenubar; (webContents, permission, callback, details) => {
mainWindow.setMenuBarVisibility(!showMenubar); const {origin} = new URL(details.requestingUrl);
page.send('toggle-autohide-menubar', showMenubar, true); const permissionCallbackId = nextPermissionCallbackId++;
}); permissionCallbacks.set(permissionCallbackId, callback);
send(
page,
"permission-request",
{
webContentsId:
webContents.id === mainWindow.webContents.id
? null
: webContents.id,
origin,
permission,
},
permissionCallbackId,
);
},
);
ipcMain.on('update-badge', (_event: Electron.IpcMainEvent, messageCount: number) => { // Temporarily remove this event
badgeCount = messageCount; // powerMonitor.on('resume', () => {
BadgeSettings.updateBadge(badgeCount, mainWindow); // mainWindow.reload();
page.send('tray', messageCount); // send(page, 'destroytray');
}); // });
ipcMain.on('update-taskbar-icon', (_event: Electron.IpcMainEvent, data: string, text: string) => { ipcMain.on("focus-app", () => {
BadgeSettings.updateTaskbarIcon(data, text, mainWindow); mainWindow.show();
}); });
ipcMain.on('forward-message', (_event: Electron.IpcMainEvent, listener: string, ...parameters: unknown[]) => { ipcMain.on("quit-app", () => {
page.send(listener, ...parameters); app.quit();
}); });
ipcMain.on('update-menu', (_event: Electron.IpcMainEvent, props: AppMenu.MenuProps) => { // Reload full app not just webview, useful in debugging
AppMenu.setMenu(props); ipcMain.on("reload-full-app", () => {
const activeTab = props.tabs[props.activeTabIndex]; mainWindow.reload();
if (activeTab) { send(page, "destroytray");
mainWindow.setTitle(`Zulip - ${activeTab.webviewName}`); });
}
});
ipcMain.on('toggleAutoLauncher', async (_event: Electron.IpcMainEvent, AutoLaunchValue: boolean) => { ipcMain.on("clear-app-settings", () => {
await setAutoLaunch(AutoLaunchValue); mainWindowState.unmanage();
}); app.relaunch();
app.exit();
});
ipcMain.on('downloadFile', (_event: Electron.IpcMainEvent, url: string, downloadPath: string) => { ipcMain.on("toggle-app", () => {
page.downloadURL(url); toggleApp();
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 => { ipcMain.on("toggle-badge-option", () => {
const fileExtension = path.extname(filePath); BadgeSettings.updateBadge(badgeCount, mainWindow);
const baseName = path.basename(filePath, fileExtension); });
return `${baseName}-${getTimeStamp()}${fileExtension}`;
};
const filePath = path.join(downloadPath, item.getFilename()); ipcMain.on("toggle-menubar", (_event: IpcMainEvent, showMenubar: boolean) => {
mainWindow.autoHideMenuBar = showMenubar;
mainWindow.setMenuBarVisibility(!showMenubar);
send(page, "toggle-autohide-menubar", showMenubar, true);
});
// Update the name and path of the file if it already exists ipcMain.on("update-badge", (_event: IpcMainEvent, messageCount: number) => {
const updatedFilePath = path.join(downloadPath, formatFile(filePath)); badgeCount = messageCount;
const setFilePath: string = fs.existsSync(filePath) ? updatedFilePath : filePath; BadgeSettings.updateBadge(badgeCount, mainWindow);
item.setSavePath(setFilePath); send(page, "tray", messageCount);
} });
const updatedListener = (_event: Event, state: string): void => { ipcMain.on(
switch (state) { "update-taskbar-icon",
case 'interrupted': { (_event: IpcMainEvent, data: string, text: string) => {
// Can interrupted to due to network error, cancel download then BadgeSettings.updateTaskbarIcon(data, text, mainWindow);
console.log('Download interrupted, cancelling and fallback to dialog download.'); },
item.cancel(); );
break;
}
case 'progressing': { ipcMain.on(
if (item.isPaused()) { "forward-message",
item.cancel(); <Channel extends keyof RendererMessage>(
} _event: IpcMainEvent,
listener: Channel,
...parameters: Parameters<RendererMessage[Channel]>
) => {
send(page, listener, ...parameters);
},
);
// This event can also be used to show progress in percentage in future. ipcMain.on("update-menu", (_event: IpcMainEvent, props: MenuProps) => {
break; AppMenu.setMenu(props);
} if (props.activeTabIndex !== undefined) {
const activeTab = props.tabs[props.activeTabIndex];
mainWindow.setTitle(`Zulip - ${activeTab.name}`);
}
});
default: { ipcMain.on(
console.info('Unknown updated state of download item'); "toggleAutoLauncher",
} async (_event: IpcMainEvent, AutoLaunchValue: boolean) => {
} await setAutoLaunch(AutoLaunchValue);
}; },
);
item.on('updated', updatedListener); ipcMain.on(
item.once('done', (_event: Event, state) => { "realm-name-changed",
if (state === 'completed') { (_event: IpcMainEvent, serverURL: string, realmName: string) => {
page.send('downloadFileCompleted', item.getSavePath(), path.basename(item.getSavePath())); send(page, "update-realm-name", serverURL, realmName);
} else { },
console.log('Download failed state:', state); );
page.send('downloadFileFailed', state);
}
// To stop item for listening to updated events of this file ipcMain.on(
item.removeListener('updated', updatedListener); "realm-icon-changed",
}); (_event: IpcMainEvent, serverURL: string, iconURL: string) => {
}); send(page, "update-realm-icon", serverURL, iconURL);
}); },
);
ipcMain.on('realm-name-changed', (_event: Electron.IpcMainEvent, serverURL: string, realmName: string) => { ipcMain.on("save-last-tab", (_event: IpcMainEvent, index: number) => {
page.send('update-realm-name', serverURL, realmName); ConfigUtil.setConfigItem("lastActiveTab", index);
}); });
ipcMain.on('realm-icon-changed', (_event: Electron.IpcMainEvent, serverURL: string, iconURL: string) => { ipcMain.on("focus-this-webview", (event: IpcMainEvent) => {
page.send('update-realm-icon', serverURL, iconURL); send(page, "focus-webview-with-id", event.sender.id);
}); mainWindow.show();
});
// Using event.sender.send instead of page.send here to // Update user idle status for each realm after every 15s
// make sure the value of errorReporting is sent only once on load. const idleCheckInterval = 15 * 1000; // 15 seconds
ipcMain.on('error-reporting', (event: Electron.IpcMainEvent) => { setInterval(() => {
event.sender.send('error-reporting-val', errorReporting); // Set user idle if no activity in 1 second (idleThresholdSeconds)
}); const idleThresholdSeconds = 1; // 1 second
const idleState = powerMonitor.getSystemIdleState(idleThresholdSeconds);
if (idleState === "active") {
send(page, "set-active");
} else {
send(page, "set-idle");
}
}, idleCheckInterval);
})();
ipcMain.on('save-last-tab', (_event: Electron.IpcMainEvent, index: number) => { app.on("before-quit", () => {
ConfigUtil.setConfigItem('lastActiveTab', index); isQuitting = true;
});
// 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);
if (idleState === 'active') {
page.send('set-active');
} else {
page.send('set-idle');
}
}, idleCheckInterval);
});
app.on('before-quit', () => {
isQuitting = true;
}); });
// Send crash reports // Send crash reports
process.on('uncaughtException', error => { process.on("uncaughtException", (error) => {
console.error(error); console.error(error);
console.error(error.stack); console.error(error.stack);
}); });

View File

@@ -0,0 +1,69 @@
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";
const logger = new Logger({
file: "linux-update-util.log",
});
let db: JsonDB;
reloadDb();
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;
}
return value;
}
export function setUpdateItem(key: string, value: true | null): void {
db.push(`/${key}`, value, true);
reloadDb();
}
export function removeUpdateItem(key: string): void {
db.delete(`/${key}`);
reloadDb();
}
function reloadDb(): void {
const linuxUpdateJsonPath = path.join(
app.getPath("userData"),
"/config/updates.json",
);
try {
const file = fs.readFileSync(linuxUpdateJsonPath, "utf8");
JSON.parse(file);
} catch (error: unknown) {
if (fs.existsSync(linuxUpdateJsonPath)) {
fs.unlinkSync(linuxUpdateJsonPath);
dialog.showErrorBox(
"Error saving update notifications.",
"We encountered an error while saving the update notifications.",
);
logger.error("Error while JSON parsing updates.json: ");
logger.error(error);
}
}
db = new JsonDB(linuxUpdateJsonPath, true, true);
}

View File

@@ -1,45 +1,50 @@
import {app, Notification, net} from 'electron'; import type {Session} from "electron/main";
import {Notification, app, net} from "electron/main";
import getStream from 'get-stream'; import getStream from "get-stream";
import * as semver from 'semver'; import * as semver from "semver";
import * as z from "zod";
import * as ConfigUtil from '../renderer/js/utils/config-util'; import * as ConfigUtil from "../common/config-util";
import * as LinuxUpdateUtil from '../renderer/js/utils/linux-update-util'; import Logger from "../common/logger-util";
import Logger from '../renderer/js/utils/logger-util';
import {fetchResponse} from './request'; import * as LinuxUpdateUtil from "./linux-update-util";
import {fetchResponse} from "./request";
const logger = new Logger({ const logger = new Logger({
file: 'linux-update-util.log', file: "linux-update-util.log",
timestamp: true
}); });
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'; let url = "https://api.github.com/repos/zulip/zulip-desktop/releases";
url = ConfigUtil.getConfigItem('betaUpdate') ? url : url + '/latest'; url = ConfigUtil.getConfigItem("betaUpdate", false) ? url : url + "/latest";
try { try {
const response = await fetchResponse(net.request({url, session})); const response = await fetchResponse(net.request({url, session}));
if (response.statusCode !== 200) { if (response.statusCode !== 200) {
logger.log('Linux update response status: ', response.statusCode); logger.log("Linux update response status: ", response.statusCode);
return; return;
} }
const data = JSON.parse(await getStream(response)); const data: unknown = JSON.parse(await getStream(response));
const latestVersion = ConfigUtil.getConfigItem('betaUpdate') ? data[0].tag_name : data.tag_name; /* eslint-disable @typescript-eslint/naming-convention */
if (typeof latestVersion !== 'string') { const latestVersion = ConfigUtil.getConfigItem("betaUpdate", false)
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())) { if (semver.gt(latestVersion, app.getVersion())) {
const notified = LinuxUpdateUtil.getUpdateItem(latestVersion); const notified = LinuxUpdateUtil.getUpdateItem(latestVersion);
if (notified === null) { if (notified === null) {
new Notification({title: 'Zulip Update', body: `A new version ${latestVersion} is available. Please update using your package manager.`}).show(); new Notification({
LinuxUpdateUtil.setUpdateItem(latestVersion, true); title: "Zulip Update",
} body: `A new version ${latestVersion} is available. Please update using your package manager.`,
} }).show();
} catch (error: unknown) { LinuxUpdateUtil.setUpdateItem(latestVersion, true);
logger.error('Linux update error.'); }
logger.error(error); }
} } catch (error: unknown) {
logger.error("Linux update error.");
logger.error(error);
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,114 +1,137 @@
import {ClientRequest, IncomingMessage, app, net} from 'electron'; import type {ClientRequest, IncomingMessage, Session} from "electron/main";
import fs from 'fs'; import {app, net} from "electron/main";
import path from 'path'; import fs from "node:fs";
import stream from 'stream'; import path from "node:path";
import util from 'util'; import stream from "node:stream";
import util from "node:util";
import getStream from 'get-stream'; import * as Sentry from "@sentry/electron";
import getStream from "get-stream";
import * as z from "zod";
import {ServerConf} from '../renderer/js/utils/domain-util'; import Logger from "../common/logger-util";
import Logger from '../renderer/js/utils/logger-util'; import * as Messages from "../common/messages";
import * as Messages from '../resources/messages'; import type {ServerConf} from "../common/types";
export async function fetchResponse(request: ClientRequest): Promise<IncomingMessage> { export async function fetchResponse(
return new Promise((resolve, reject) => { request: ClientRequest,
request.on('response', resolve); ): Promise<IncomingMessage> {
request.on('abort', () => { return new Promise((resolve, reject) => {
reject(new Error('Request aborted')); request.on("response", resolve);
}); request.on("abort", () => {
request.on('error', reject); reject(new Error("Request aborted"));
request.end(); });
}); request.on("error", reject);
request.end();
});
} }
const pipeline = util.promisify(stream.pipeline); const pipeline = util.promisify(stream.pipeline);
/* Request: domain-util */ /* Request: domain-util */
const defaultIconUrl = '../renderer/img/icon.png'; const defaultIconUrl = "../renderer/img/icon.png";
const logger = new Logger({ const logger = new Logger({
file: 'domain-util.log', file: "domain-util.log",
timestamp: true
}); });
const generateFilePath = (url: string): string => { const generateFilePath = (url: string): string => {
const dir = `${app.getPath('userData')}/server-icons`; const dir = `${app.getPath("userData")}/server-icons`;
const extension = path.extname(url).split('?')[0]; const extension = path.extname(url).split("?")[0];
let hash = 5381; let hash = 5381;
let {length} = url; let {length} = url;
while (length) { while (length) {
hash = (hash * 33) ^ url.charCodeAt(--length); // eslint-disable-next-line no-bitwise, unicorn/prefer-code-point
} hash = (hash * 33) ^ url.charCodeAt(--length);
}
// Create 'server-icons' directory if not existed // Create 'server-icons' directory if not existed
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
fs.mkdirSync(dir); fs.mkdirSync(dir);
} }
return `${dir}/${hash >>> 0}${extension}`; // eslint-disable-next-line no-bitwise
return `${dir}/${hash >>> 0}${extension}`;
}; };
export const _getServerSettings = async (domain: string, session: Electron.session): Promise<ServerConf> => { export const _getServerSettings = async (
const response = await fetchResponse(net.request({ domain: string,
url: domain + '/api/v1/server_settings', session: Session,
session ): Promise<ServerConf> => {
})); const response = await fetchResponse(
if (response.statusCode !== 200) { net.request({
throw new Error(Messages.invalidZulipServerError(domain)); url: domain + "/api/v1/server_settings",
} session,
}),
);
if (response.statusCode !== 200) {
throw new Error(Messages.invalidZulipServerError(domain));
}
const {realm_name, realm_uri, realm_icon} = JSON.parse(await getStream(response)); const data: unknown = JSON.parse(await getStream(response));
if ( /* eslint-disable @typescript-eslint/naming-convention */
typeof realm_name !== 'string' || const {realm_name, realm_uri, realm_icon} = z
typeof realm_uri !== 'string' || .object({
typeof realm_icon !== 'string' realm_name: z.string(),
) { realm_uri: z.string(),
throw new TypeError(Messages.noOrgsError(domain)); realm_icon: z.string(),
} })
.parse(data);
/* eslint-enable @typescript-eslint/naming-convention */
return { return {
// Some Zulip Servers use absolute URL for server icon whereas others use relative URL // Some Zulip Servers use absolute URL for server icon whereas others use relative URL
// Following check handles both the cases // Following check handles both the cases
icon: realm_icon.startsWith('/') ? realm_uri + realm_icon : realm_icon, icon: realm_icon.startsWith("/") ? realm_uri + realm_icon : realm_icon,
url: realm_uri, url: realm_uri,
alias: realm_name alias: realm_name,
}; };
}; };
export const _saveServerIcon = async (url: string, session: Electron.session): Promise<string> => { export const _saveServerIcon = async (
try { url: string,
const response = await fetchResponse(net.request({url, session})); session: Session,
if (response.statusCode !== 200) { ): Promise<string> => {
logger.log('Could not get server icon.'); try {
return defaultIconUrl; const response = await fetchResponse(net.request({url, session}));
} if (response.statusCode !== 200) {
logger.log("Could not get server icon.");
return defaultIconUrl;
}
const filePath = generateFilePath(url); const filePath = generateFilePath(url);
await pipeline(response, fs.createWriteStream(filePath)); await pipeline(response, fs.createWriteStream(filePath));
return filePath; return filePath;
} catch (error: unknown) { } catch (error: unknown) {
logger.log('Could not get server icon.'); logger.log("Could not get server icon.");
logger.log(error); logger.log(error);
logger.reportSentry(error); Sentry.captureException(error);
return defaultIconUrl; return defaultIconUrl;
} }
}; };
/* Request: reconnect-util */ /* Request: reconnect-util */
export const _isOnline = async (url: string, session: Electron.session): Promise<boolean> => { export const _isOnline = async (
try { url: string,
const response = await fetchResponse(net.request({ session: Session,
url: `${url}/static/favicon.ico`, ): Promise<boolean> => {
session try {
})); const response = await fetchResponse(
const isValidResponse = response.statusCode >= 200 && response.statusCode < 400; net.request({
return isValidResponse; method: "HEAD",
} catch (error: unknown) { url: `${url}/api/v1/server_settings`,
logger.log(error); session,
return false; }),
} );
const isValidResponse =
response.statusCode >= 200 && response.statusCode < 400;
return isValidResponse;
} catch (error: unknown) {
logger.log(error);
return false;
}
}; };

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,29 +1,36 @@
import {app} from 'electron'; import {app} from "electron/main";
import process from "node:process";
import AutoLaunch from 'auto-launch'; import AutoLaunch from "auto-launch";
import isDev from 'electron-is-dev';
import * as ConfigUtil from '../renderer/js/utils/config-util'; import * as ConfigUtil from "../common/config-util";
export const setAutoLaunch = async (AutoLaunchValue: boolean): Promise<void> => { export const setAutoLaunch = async (
// Don't run this in development AutoLaunchValue: boolean,
if (isDev) { ): Promise<void> => {
return; // Don't run this in development
} if (!app.isPackaged) {
return;
}
const autoLaunchOption = ConfigUtil.getConfigItem('startAtLogin', AutoLaunchValue); const autoLaunchOption = ConfigUtil.getConfigItem(
"startAtLogin",
AutoLaunchValue,
);
// `setLoginItemSettings` doesn't support linux // `setLoginItemSettings` doesn't support linux
if (process.platform === 'linux') { if (process.platform === "linux") {
const ZulipAutoLauncher = new AutoLaunch({ const zulipAutoLauncher = new AutoLaunch({
name: 'Zulip', name: "Zulip",
isHidden: false isHidden: false,
}); });
await (autoLaunchOption ? ZulipAutoLauncher.enable() : ZulipAutoLauncher.disable()); await (autoLaunchOption
} else { ? zulipAutoLauncher.enable()
app.setLoginItemSettings({ : zulipAutoLauncher.disable());
openAtLogin: autoLaunchOption, } else {
openAsHidden: false app.setLoginItemSettings({
}); openAtLogin: autoLaunchOption,
} openAsHidden: false,
});
}
}; };

View File

@@ -0,0 +1,75 @@
import type {
IpcMainEvent,
IpcMainInvokeEvent,
WebContents,
} from "electron/main";
import {
ipcMain as untypedIpcMain, // eslint-disable-line no-restricted-imports
} from "electron/main";
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 MainHandler<Channel extends keyof MainCall> = MainCall[Channel] extends (
...args: infer Args
) => infer Return
? (event: IpcMainInvokeEvent, ...args: Args) => Return | Promise<Return>
: never;
export const ipcMain: {
on(
channel: "forward-message",
listener: <Channel extends keyof RendererMessage>(
event: IpcMainEvent,
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
) => void,
): void;
on<Channel extends keyof MainMessage>(
channel: Channel,
listener: MainListener<Channel>,
): void;
once<Channel extends keyof MainMessage>(
channel: Channel,
listener: MainListener<Channel>,
): void;
removeListener<Channel extends keyof MainMessage>(
channel: Channel,
listener: MainListener<Channel>,
): void;
removeAllListeners(channel?: keyof MainMessage): void;
handle<Channel extends keyof MainCall>(
channel: Channel,
handler: MainHandler<Channel>,
): void;
handleOnce<Channel extends keyof MainCall>(
channel: Channel,
handler: MainHandler<Channel>,
): void;
removeHandler(channel: keyof MainCall): void;
} = untypedIpcMain;
export function send<Channel extends keyof RendererMessage>(
contents: WebContents,
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
): void {
contents.send(channel, ...args);
}
export function sendToFrame<Channel extends keyof RendererMessage>(
contents: WebContents,
frameId: number | [number, number],
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
): void {
contents.sendToFrame(frameId, channel, ...args);
}

View File

@@ -1,32 +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,66 +1,69 @@
body { :host {
background: rgba(250, 250, 250, 1.000); contain: strict;
font-family: menu, "Helvetica Neue", sans-serif; display: flow-root;
-webkit-font-smoothing: subpixel-antialiased; background: rgb(250 250 250 / 100%);
font-family: menu, "Helvetica Neue", sans-serif;
-webkit-font-smoothing: subpixel-antialiased;
} }
.logo { .logo {
display: block; display: block;
margin: -40px auto; margin: -40px auto;
} }
#version { #version {
color: rgba(68, 67, 67, 1.000); color: rgb(68 67 67 / 100%);
font-size: 1.3em; font-size: 1.3em;
padding-top: 40px; padding-top: 40px;
} }
.about { .about {
margin: 25vh auto; display: block !important;
height: 25vh; margin: 25vh auto;
text-align: center; height: 25vh;
text-align: center;
} }
.about p { .about p {
font-size: 20px; font-size: 20px;
color: rgba(0, 0, 0, 0.62); color: rgb(0 0 0 / 62%);
} }
.about img { .about img {
width: 150px; width: 150px;
} }
.detail { .detail {
text-align: center; text-align: center;
} }
.detail.maintainer { .detail.maintainer {
font-size: 1.2em; font-size: 1.2em;
font-weight: 500; font-weight: 500;
} }
.detail.license { .detail.license {
font-size: 0.8em; font-size: 0.8em;
} }
.maintenance-info { .maintenance-info {
cursor: pointer; cursor: pointer;
position: absolute; position: absolute;
width: 100%; width: 100%;
left: 0px; left: 0;
color: rgba(68, 68, 68, 1.000); color: rgb(68 68 68 / 100%);
} }
.maintenance-info p { .maintenance-info p {
margin: 0; margin: 0;
font-size: 1em; font-size: 1em;
width: 100%; width: 100%;
} }
p.detail a { p.detail a {
color: rgba(53, 95, 76, 1.000); color: rgb(53 95 76 / 100%);
} }
p.detail a:hover { p.detail a:hover {
text-decoration: underline; text-decoration: underline;
} }

View File

@@ -1,19 +1,19 @@
:host { :host {
--button-color: rgb(69, 166, 149); --button-color: rgb(69 166 149);
}
button {
background-color: var(--button-color);
border-color: var(--button-color);
} }
button:hover, button:hover,
button:focus { button:focus {
border-color: var(--button-color); border-color: var(--button-color);
color: var(--button-color); color: var(--button-color);
}
button:active {
background-color: rgb(241, 241, 241);
color: var(--button-color);
} }
button { button:active {
background-color: var(--button-color); background-color: rgb(241 241 241);
border-color: var(--button-color); 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

@@ -4,280 +4,275 @@
html, html,
body { body {
height: 100%; height: 100%;
margin: 0; margin: 0;
cursor: default; cursor: default;
user-select: none; user-select: none;
} }
#content { #content {
display: flex; display: flex;
height: 100%; height: 100%;
} }
.toggle-sidebar { .toggle-sidebar {
background: rgba(34, 44, 49, 1.000); background: rgb(34 44 49 / 100%);
width: 54px; width: 54px;
padding: 27px 0 20px 0; padding: 27px 0 20px;
justify-content: space-between; justify-content: space-between;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
-webkit-app-region: drag; -webkit-app-region: drag;
overflow: hidden; overflow: hidden;
transition: all 0.5s ease; transition: all 0.5s ease;
z-index: 2; z-index: 2;
} }
.toggle-sidebar div { .toggle-sidebar div {
transition: all 0.5s ease-out; transition: all 0.5s ease-out;
} }
.sidebar-hide { .sidebar-hide {
width: 0; width: 0;
transition: all 0.8s ease; transition: all 0.8s ease;
} }
.sidebar-hide div { .sidebar-hide div {
transform: translateX(-100%); transform: translateX(-100%);
transition: all 0.6s ease-out; transition: all 0.6s ease-out;
} }
#view-controls-container { #view-controls-container {
height: calc(100% - 208px); height: calc(100% - 208px);
overflow-y: hidden; overflow-y: hidden;
}
#view-controls-container:hover {
overflow-y: overlay;
} }
#view-controls-container::-webkit-scrollbar { #view-controls-container::-webkit-scrollbar {
width: 4px; width: 4px;
} }
#view-controls-container::-webkit-scrollbar-track { #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 { #view-controls-container::-webkit-scrollbar-thumb {
background-color: rgba(169, 169, 169, 1.000); background-color: rgb(169 169 169 / 100%);
outline: 1px solid rgba(169, 169, 169, 1.000); outline: 1px solid rgb(169 169 169 / 100%);
} }
@font-face { #view-controls-container:hover {
font-family: 'Material Icons'; overflow-y: overlay;
font-style: normal;
font-weight: 400;
src: local('Material Icons'), local('MaterialIcons-Regular'), url(../fonts/MaterialIcons-Regular.ttf) format('truetype');
} }
/******************* /*******************
* Left Sidebar * * Left Sidebar *
*******************/ *******************/
#tabs-container { #tabs-container {
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
} }
.material-icons { .material-icons {
font-family: 'Material Icons'; font-family: "Material Icons";
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
/* Preferred icon size */
font-size: 24px; /* Preferred icon size */
display: inline-block; font-size: 24px;
line-height: 1; display: inline-block;
text-transform: none; line-height: 1;
letter-spacing: normal; text-transform: none;
word-wrap: normal; letter-spacing: normal;
white-space: nowrap; word-wrap: normal;
direction: ltr; white-space: nowrap;
/* Support for all WebKit browsers. */ direction: ltr;
-webkit-font-smoothing: antialiased;
/* Support for Safari and Chrome. */ /* Support for all WebKit browsers. */
text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased;
/* Support for Safari and Chrome. */
text-rendering: optimizelegibility;
} }
#actions-container { #actions-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: fixed; position: fixed;
bottom: 0; bottom: 0;
} }
.action-button { .action-button {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 12px; padding: 12px;
} }
.action-button:hover { .action-button:hover {
cursor: pointer; cursor: pointer;
} }
.action-button i { .action-button i {
color: rgba(108, 133, 146, 1.000); color: rgb(108 133 146 / 100%);
font-size: 28px; font-size: 28px;
} }
.action-button:hover i { .action-button:hover i {
color: rgba(152, 169, 179, 1.000); color: rgb(152 169 179 / 100%);
}
.action-button.disable {
opacity: 0.6;
}
.action-button.disable:hover {
cursor: not-allowed;
}
.action-button.disable:hover i {
color: rgba(108, 133, 146, 1.000);
} }
.action-button.active { .action-button.active {
/* background-color: rgba(255, 255, 255, 0.25); */ /* background-color: rgba(255, 255, 255, 0.25); */
background-color: rgba(239, 239, 239, 1.000); background-color: rgb(239 239 239 / 100%);
opacity: 0.9; opacity: 0.9;
padding-right: 14px; padding-right: 14px;
} }
.action-button.active i { .action-button.active i {
color: rgba(28, 38, 43, 1.000); color: rgb(28 38 43 / 100%);
} }
.tab:first-child { .action-button.disable {
margin-top: 9px; opacity: 0.6;
}
.action-button.disable:hover {
cursor: not-allowed;
}
.action-button.disable:hover i {
color: rgb(108 133 146 / 100%);
} }
.tab { .tab {
position: relative; position: relative;
margin: 2px 0; margin: 2px 0;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 100%; width: 100%;
}
.tab:first-child {
margin-top: 9px;
} }
.tab .server-icons { .tab .server-icons {
width: 35px; width: 35px;
vertical-align: top; vertical-align: top;
border-radius: 4px; border-radius: 4px;
} }
.tab .server-tab { .tab .server-tab {
width: 100%; width: 100%;
height: 35px; height: 35px;
position: relative; position: relative;
margin-top: 5px; margin-top: 5px;
z-index: 11; z-index: 11;
line-height: 31px; line-height: 31px;
color: rgba(238, 238, 238, 1.000); color: rgb(238 238 238 / 100%);
text-align: center; text-align: center;
overflow: hidden; overflow: hidden;
opacity: 0.6; opacity: 0.6;
padding: 6px 0; padding: 6px 0;
} }
.server-tab .alt-icon { .server-tab .alt-icon {
font-family: Verdana; font-family: Verdana, sans-serif;
font-weight: 600; font-weight: 600;
font-size: 22px; font-size: 22px;
border: 2px solid rgba(34, 44, 49, 1.000); border: 2px solid rgb(34 44 49 / 100%);
margin-left: 17%; margin-left: 17%;
width: 35px; width: 35px;
border-radius: 4px; border-radius: 4px;
} }
.tab .server-tab:hover { .tab .server-tab:hover {
opacity: 0.8; opacity: 0.8;
}
.tab.functional-tab {
height: 46px;
padding: 0;
}
.tab.functional-tab.active .server-tab {
padding: 2px 0;
height: 40px;
background-color: rgba(255, 255, 255, 0.25);
}
.tab.functional-tab .server-tab i {
font-size: 28px;
line-height: 36px;
} }
.tab.active .server-tab { .tab.active .server-tab {
opacity: 1; opacity: 1;
background-color: rgba(100, 132, 120, 1.000); background-color: rgb(100 132 120 / 100%);
}
.tab.functional-tab {
height: 46px;
padding: 0;
}
.tab.functional-tab.active .server-tab {
padding: 2px 0;
height: 40px;
background-color: rgb(255 255 255 / 25%);
}
.tab.functional-tab .server-tab i {
font-size: 28px;
line-height: 36px;
} }
.tab .server-tab-badge.active { .tab .server-tab-badge.active {
border-radius: 9px; border-radius: 9px;
min-width: 11px; min-width: 11px;
padding: 0 3px; padding: 0 3px;
height: 17px; height: 17px;
background-color: rgba(244, 67, 54, 1.000); background-color: rgb(244 67 54 / 100%);
font-size: 10px; font-size: 10px;
font-family: sans-serif; font-family: sans-serif;
position: absolute; position: absolute;
right: 5px; z-index: 15;
z-index: 15; top: 6px;
top: 6px; float: right;
float: right; color: rgb(255 255 255 / 100%);
color: rgba(255, 255, 255, 1.000); text-align: center;
text-align: center; line-height: 17px;
line-height: 17px; display: block;
display: block; right: 0;
right: 0;
} }
.tab .server-tab-badge { .tab .server-tab-badge {
display: none; display: none;
} }
.tab .server-tab-badge.close-button { .tab .server-tab-badge.close-button {
width: 16px; width: 16px;
padding: 0; padding: 0;
} }
.tab .server-tab-badge.close-button i { .tab .server-tab-badge.close-button i {
font-size: 13px; font-size: 13px;
line-height: 17px; line-height: 17px;
} }
#add-tab { #add-tab {
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
} }
.tab .server-tab-shortcut { .tab .server-tab-shortcut {
color: rgba(100, 132, 120, 1.000); color: rgb(100 132 120 / 100%);
font-size: 12px; font-size: 12px;
text-align: center; text-align: center;
font-family: sans-serif; font-family: sans-serif;
margin-bottom: 5px; margin-bottom: 5px;
} }
.refresh { .refresh {
animation: rotate-loader 1s linear infinite; animation: rotate-loader 1s linear infinite;
} }
@keyframes rotate-loader { @keyframes rotate-loader {
from { from {
transform: rotate(0); transform: rotate(0);
} }
to {
transform: rotate(-360deg); to {
} transform: rotate(-360deg);
}
} }
/******************* /*******************
@@ -285,57 +280,46 @@ body {
*******************/ *******************/
#webviews-container { #webviews-container {
display: flex; display: flex;
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
/* Pseudo element for loading indicator */ /* Pseudo element for loading indicator */
#webviews-container::before { #webviews-container::before {
content: ""; content: "";
position: absolute; position: absolute;
z-index: 1; z-index: 1;
background: rgba(255, 255, 255, 1.000) 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-size: 60px 60px;
background-position: center; background-position: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
/* When the active webview is loaded */ /* When the active webview is loaded */
#webviews-container.loaded::before { #webviews-container.loaded::before {
opacity: 0; z-index: -1;
z-index: -1; visibility: hidden;
visibility: hidden;
} }
webview { webview,
/* transition: opacity 0.3s ease-in; */ .functional-view {
flex-grow: 1; position: absolute;
position: absolute; width: 100%;
width: 100%; height: 100%;
height: 100%; flex-grow: 1;
flex-grow: 1; visibility: hidden;
display: flex;
flex-direction: column;
} }
webview.onload { webview.active,
transition: opacity 1s cubic-bezier(0.95, 0.05, 0.795, 0.035); .functional-view.active {
} z-index: 1;
visibility: visible;
webview.active {
opacity: 1;
z-index: 1;
visibility: visible;
}
webview.disabled {
opacity: 0;
} }
webview.focus { webview.focus {
outline: 0px solid transparent; outline: 0 solid transparent;
} }
/* Tooltip styling */ /* Tooltip styling */
@@ -345,18 +329,18 @@ webview.focus {
#back-tooltip, #back-tooltip,
#reload-tooltip, #reload-tooltip,
#setting-tooltip { #setting-tooltip {
font-family: sans-serif; font-family: sans-serif;
background: rgba(34, 44, 49, 1.000); background: rgb(34 44 49 / 100%);
margin-left: 48px; margin-left: 48px;
padding: 6px 8px; padding: 6px 8px;
position: absolute; position: absolute;
margin-top: 0px; margin-top: 0;
z-index: 1000; z-index: 1000;
color: rgba(255, 255, 255, 1.000); color: rgb(255 255 255 / 100%);
border-radius: 4px; border-radius: 4px;
text-align: center; text-align: center;
width: 55px; width: 55px;
font-size: 14px; font-size: 14px;
} }
#loading-tooltip::after, #loading-tooltip::after,
@@ -364,127 +348,104 @@ webview.focus {
#back-tooltip::after, #back-tooltip::after,
#reload-tooltip::after, #reload-tooltip::after,
#setting-tooltip::after { #setting-tooltip::after {
content: " "; content: " ";
border-top: 8px solid transparent; border-top: 8px solid transparent;
border-bottom: 8px solid transparent; border-bottom: 8px solid transparent;
border-right: 8px solid rgba(34, 44, 49, 1.000); border-right: 8px solid rgb(34 44 49 / 100%);
position: absolute; position: absolute;
top: 7px; top: 7px;
right: 68px; right: 68px;
} }
#add-server-tooltip, #add-server-tooltip,
.server-tooltip { .server-tooltip {
font-family: 'arial'; font-family: arial, sans-serif;
background: rgba(34, 44, 49, 1.000); background: rgb(34 44 49 / 100%);
left: 56px; left: 56px;
padding: 10px 20px; padding: 10px 20px;
position: fixed; position: fixed;
margin-top: 11px; margin-top: 11px;
z-index: 5000 !important; z-index: 5000 !important;
color: rgba(255, 255, 255, 1.000); color: rgb(255 255 255 / 100%);
border-radius: 4px; border-radius: 4px;
text-align: center; text-align: center;
width: max-content; width: max-content;
font-size: 14px; font-size: 14px;
} }
#add-server-tooltip::after, #add-server-tooltip::after,
.server-tooltip::after { .server-tooltip::after {
content: " "; content: " ";
border-top: 8px solid transparent; border-top: 8px solid transparent;
border-bottom: 8px solid transparent; border-bottom: 8px solid transparent;
border-right: 8px solid rgba(34, 44, 49, 1.000); border-right: 8px solid rgb(34 44 49 / 100%);
position: absolute; position: absolute;
top: 10px; top: 10px;
left: -5px; left: -5px;
} }
#collapse-button { #collapse-button {
bottom: 30px; bottom: 30px;
left: 20px; left: 20px;
position: absolute; position: absolute;
width: 24px; width: 24px;
height: 24px; height: 24px;
background: rgba(34, 44, 49, 1.000); background: rgb(34 44 49 / 100%);
border-radius: 20px; border-radius: 20px;
cursor: pointer; cursor: pointer;
box-shadow: rgba(153, 153, 153, 1.000) 1px 1px; box-shadow: rgb(153 153 153 / 100%) 1px 1px;
} }
#collapse-button i { #collapse-button i {
color: rgba(239, 239, 239, 1.000); color: rgb(239 239 239 / 100%);
} }
#main-container { #main-container {
display: flex; display: flex;
height: 100%; height: 100%;
width: 100%; width: 100%;
position: relative; position: relative;
flex-grow: 1; flex-grow: 1;
flex-basis: 0px; flex-basis: 0;
} }
.hidden { .hidden {
display: none !important; display: none !important;
} }
/* Full screen Popup container */ /* Full screen Popup container */
.popup .popuptext { .popup .popuptext {
visibility: hidden; visibility: hidden;
background-color: rgba(85, 85, 85, 1.000); background-color: rgb(85 85 85 / 100%);
color: rgba(255, 255, 255, 1.000); color: rgb(255 255 255 / 100%);
text-align: center; text-align: center;
border-radius: 6px; border-radius: 6px;
padding: 9px 0; padding: 9px 0;
position: absolute; position: absolute;
z-index: 1000; z-index: 1000;
font-family: arial; font-family: arial, sans-serif;
width: 240px; width: 240px;
top: 15px; top: 15px;
height: 20px; height: 20px;
left: 43%; left: 43%;
} }
.popup .show { .popup .show {
visibility: visible; visibility: visible;
animation: cssAnimation 0s ease-in 5s forwards; animation: full-screen-popup 0s ease-in 1s forwards;
animation-fill-mode: forwards; animation-fill-mode: forwards;
} }
@keyframes cssAnimation { @keyframes full-screen-popup {
from { from {
opacity: 0; opacity: 0;
} }
to {
width: 0;
height: 0;
overflow: hidden;
opacity: 1;
}
}
send-feedback { to {
width: 60%; width: 0;
height: 85%; height: 0;
} overflow: hidden;
opacity: 1;
#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

@@ -1,59 +1,59 @@
html, html,
body { body {
margin: 0; margin: 0;
cursor: default; cursor: default;
font-size: 14px; font-size: 14px;
color: rgba(51, 51, 51, 1.000); color: rgb(51 51 51 / 100%);
background: rgba(255, 255, 255, 1.000); background: rgb(255 255 255 / 100%);
user-select: none; user-select: none;
} }
#content { #content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-family: "Trebuchet MS", Helvetica, sans-serif; font-family: "Trebuchet MS", Helvetica, sans-serif;
margin: 100px 200px; margin: 100px 200px;
text-align: center; text-align: center;
} }
#title { #title {
text-align: left; text-align: left;
font-size: 24px; font-size: 24px;
font-weight: bold; font-weight: bold;
margin: 20px 0; margin: 20px 0;
} }
#subtitle { #subtitle {
font-size: 20px; font-size: 20px;
text-align: left; text-align: left;
margin: 12px 0; margin: 12px 0;
} }
#description { #description {
text-align: left; text-align: left;
font-size: 16px; font-size: 16px;
list-style-position: inside; list-style-position: inside;
} }
#reconnect { #reconnect {
float: left; float: left;
} }
#settings { #settings {
margin-left: 116px; margin-left: 116px;
} }
.button { .button {
font-size: 16px; font-size: 16px;
background: rgba(0, 150, 136, 1.000); background: rgb(0 150 136 / 100%);
color: rgba(255, 255, 255, 1.000); color: rgb(255 255 255 / 100%);
width: 96px; width: 96px;
height: 32px; height: 32px;
border-radius: 5px; border-radius: 5px;
line-height: 32px; line-height: 32px;
cursor: pointer; cursor: pointer;
} }
.button:hover { .button:hover {
opacity: 0.8; opacity: 0.8;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
/* Override css rules */ /* Override css rules */
.portico-wrap > .header { .portico-wrap > .header {
display: none; display: none;
} }
.portico-container > .footer { .portico-container > .footer {
display: none; display: none;
} }

View File

@@ -1,5 +1,6 @@
import crypto from 'crypto'; import {clipboard} from "electron/common";
import {clipboard} from 'electron'; import {Buffer} from "node:buffer";
import crypto from "node:crypto";
// This helper is exposed via electron_bridge for use in the social // This helper is exposed via electron_bridge for use in the social
// login flow. // login flow.
@@ -15,68 +16,74 @@ import {clipboard} from 'electron';
// dont leak anything from the users clipboard other than the token // dont leak anything from the users clipboard other than the token
// intended for us. // intended for us.
export class ClipboardDecrypterImpl implements ClipboardDecrypter { export interface ClipboardDecrypter {
version: number; version: number;
key: Uint8Array; key: Uint8Array;
pasted: Promise<string>; pasted: Promise<string>;
}
constructor(_: number) {
// At this time, the only version is 1. export class ClipboardDecrypterImpl implements ClipboardDecrypter {
this.version = 1; version: number;
this.key = crypto.randomBytes(32); key: Uint8Array;
this.pasted = new Promise(resolve => { pasted: Promise<string>;
let interval: NodeJS.Timeout | null = null;
const startPolling = () => { constructor(_: number) {
if (interval === null) { // At this time, the only version is 1.
interval = setInterval(poll, 1000); this.version = 1;
} this.key = crypto.randomBytes(32);
this.pasted = new Promise((resolve) => {
poll(); let interval: NodeJS.Timeout | null = null;
}; const startPolling = () => {
if (interval === null) {
const stopPolling = () => { interval = setInterval(poll, 1000);
if (interval !== null) { }
clearInterval(interval);
interval = null; poll();
} };
};
const stopPolling = () => {
const poll = () => { if (interval !== null) {
let plaintext; clearInterval(interval);
interval = null;
try { }
const data = Buffer.from(clipboard.readText(), 'hex'); };
const iv = data.slice(0, 12);
const ciphertext = data.slice(12, -16); const poll = () => {
const authTag = data.slice(-16); let plaintext;
const decipher = crypto.createDecipheriv(
'aes-256-gcm', try {
this.key, const data = Buffer.from(clipboard.readText(), "hex");
iv, const iv = data.slice(0, 12);
{authTagLength: 16} const ciphertext = data.slice(12, -16);
); const authTag = data.slice(-16);
decipher.setAuthTag(authTag); const decipher = crypto.createDecipheriv(
plaintext = "aes-256-gcm",
decipher.update(ciphertext, undefined, 'utf8') + this.key,
decipher.final('utf8'); iv,
} catch { {authTagLength: 16},
// If the parsing or decryption failed in any way, );
// the correct token hasnt been copied yet; try decipher.setAuthTag(authTag);
// again next time. plaintext =
return; decipher.update(ciphertext, undefined, "utf8") +
} decipher.final("utf8");
} catch {
window.removeEventListener('focus', startPolling); // If the parsing or decryption failed in any way,
window.removeEventListener('blur', stopPolling); // the correct token hasnt been copied yet; try
stopPolling(); // again next time.
resolve(plaintext); return;
}; }
window.addEventListener('focus', startPolling); window.removeEventListener("focus", startPolling);
window.addEventListener('blur', stopPolling); window.removeEventListener("blur", stopPolling);
if (document.hasFocus()) { stopPolling();
startPolling(); resolve(plaintext);
} };
});
} window.addEventListener("focus", startPolling);
window.addEventListener("blur", stopPolling);
if (document.hasFocus()) {
startPolling();
}
});
}
} }

View File

@@ -1,7 +1,12 @@
export default class BaseComponent { import type {Html} from "../../../common/html";
generateNodeFromHTML(html: string): Element | null {
const wrapper = document.createElement('div'); export function generateNodeFromHtml(html: Html): Element {
wrapper.innerHTML = html; const wrapper = document.createElement("div");
return wrapper.firstElementChild; wrapper.innerHTML = html.html;
}
if (wrapper.firstElementChild === null) {
throw new Error("No element found in HTML");
}
return wrapper.firstElementChild;
} }

View File

@@ -1,115 +1,149 @@
import {remote, ContextMenuParams} 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 * as t from '../utils/translation-util'; import {Menu} from "@electron/remote";
const {clipboard, Menu} = remote; import * as t from "../../../common/translation-util";
export const contextMenu = (webContents: Electron.WebContents, event: Event, props: ContextMenuParams) => { export const contextMenu = (
const isText = props.selectionText !== ''; webContents: WebContents,
const isLink = props.linkURL !== ''; event: Event,
const linkURL = isLink ? new URL(props.linkURL) : undefined; props: ContextMenuParams,
const isEmailAddress = isLink ? linkURL.protocol === 'mailto:' : undefined; ) => {
const isText = props.selectionText !== "";
const isLink = props.linkURL !== "";
const linkUrl = isLink ? new URL(props.linkURL) : undefined;
const makeSuggestion = (suggestion: string) => ({ const makeSuggestion = (suggestion: string) => ({
label: suggestion, label: suggestion,
visible: true, visible: true,
async click() { async click() {
await webContents.insertText(suggestion); await webContents.insertText(suggestion);
} },
}); });
let menuTemplate: Electron.MenuItemConstructorOptions[] = [{ let menuTemplate: MenuItemConstructorOptions[] = [
label: t.__('Add to Dictionary'), {
visible: props.isEditable && isText && props.misspelledWord.length > 0, label: t.__("Add to Dictionary"),
click(_item) { visible: props.isEditable && isText && props.misspelledWord.length > 0,
webContents.session.addWordToSpellCheckerDictionary(props.misspelledWord); click(_item) {
} webContents.session.addWordToSpellCheckerDictionary(
}, { props.misspelledWord,
type: 'separator', );
visible: props.isEditable && isText && props.misspelledWord.length > 0 },
}, { },
label: `${t.__('Look Up')} "${props.selectionText}"`, {
visible: process.platform === 'darwin' && isText, type: "separator",
click(_item) { visible: props.isEditable && isText && props.misspelledWord.length > 0,
webContents.showDefinitionForSelection(); },
} {
}, { label: `${t.__("Look Up")} "${props.selectionText}"`,
type: 'separator', visible: process.platform === "darwin" && isText,
visible: process.platform === 'darwin' && isText click(_item) {
}, { webContents.showDefinitionForSelection();
label: t.__('Cut'), },
visible: isText, },
enabled: props.isEditable, {
accelerator: 'CommandOrControl+X', type: "separator",
click(_item) { visible: process.platform === "darwin" && isText,
webContents.cut(); },
} {
}, { label: t.__("Cut"),
label: t.__('Copy'), visible: isText,
accelerator: 'CommandOrControl+C', enabled: props.isEditable,
enabled: props.editFlags.canCopy, accelerator: "CommandOrControl+X",
click(_item) { click(_item) {
webContents.copy(); webContents.cut();
} },
}, { },
label: t.__('Paste'), // Bug: Paste replaces text {
accelerator: 'CommandOrControl+V', label: t.__("Copy"),
enabled: props.isEditable, accelerator: "CommandOrControl+C",
click() { enabled: props.editFlags.canCopy,
webContents.paste(); click(_item) {
} webContents.copy();
}, { },
type: 'separator' },
}, { {
label: isEmailAddress ? t.__('Copy Email Address') : t.__('Copy Link'), label: t.__("Paste"), // Bug: Paste replaces text
visible: isLink, accelerator: "CommandOrControl+V",
click(_item) { enabled: props.isEditable,
clipboard.write({ click() {
bookmark: props.linkText, webContents.paste();
text: isEmailAddress ? linkURL.pathname : props.linkURL },
}); },
} {
}, { type: "separator",
label: t.__('Copy Image'), },
visible: props.mediaType === 'image', {
click(_item) { label:
webContents.copyImageAt(props.x, props.y); linkUrl?.protocol === "mailto:"
} ? t.__("Copy Email Address")
}, { : t.__("Copy Link"),
label: t.__('Copy Image URL'), visible: isLink,
visible: props.mediaType === 'image', click(_item) {
click(_item) { clipboard.write({
clipboard.write({ bookmark: props.linkText,
bookmark: props.srcURL, text:
text: props.srcURL linkUrl?.protocol === "mailto:" ? linkUrl.pathname : props.linkURL,
}); });
} },
}, { },
type: 'separator', {
visible: isLink || props.mediaType === 'image' label: t.__("Copy Image"),
}, { visible: props.mediaType === "image",
label: t.__('Services'), click(_item) {
visible: process.platform === 'darwin', webContents.copyImageAt(props.x, props.y);
role: 'services' },
}]; },
{
label: t.__("Copy Image URL"),
visible: props.mediaType === "image",
click(_item) {
clipboard.write({
bookmark: props.srcURL,
text: props.srcURL,
});
},
},
{
type: "separator",
visible: isLink || props.mediaType === "image",
},
{
label: t.__("Services"),
visible: process.platform === "darwin",
role: "services",
},
];
if (props.misspelledWord) { if (props.misspelledWord) {
if (props.dictionarySuggestions.length > 0) { if (props.dictionarySuggestions.length > 0) {
const suggestions: Electron.MenuItemConstructorOptions[] = props.dictionarySuggestions.map((suggestion: string) => makeSuggestion(suggestion)); const suggestions: MenuItemConstructorOptions[] =
menuTemplate = suggestions.concat(menuTemplate); props.dictionarySuggestions.map((suggestion: string) =>
} else { makeSuggestion(suggestion),
menuTemplate.unshift({ );
label: t.__('No Suggestion Found'), menuTemplate = [...suggestions, ...menuTemplate];
enabled: false } else {
}); menuTemplate.unshift({
} label: t.__("No Suggestion Found"),
} enabled: false,
// Hide the invisible separators on Linux and Windows });
// Electron has a bug which ignores visible: false parameter for separator menuitems. So we remove them here. }
// https://github.com/electron/electron/issues/5869 }
// https://github.com/electron/electron/issues/6906 // Hide the invisible separators on Linux and Windows
// Electron has a bug which ignores visible: false parameter for separator menuitems. So we remove them here.
// https://github.com/electron/electron/issues/5869
// https://github.com/electron/electron/issues/6906
const filteredMenuTemplate = menuTemplate.filter(menuItem => menuItem.visible ?? true); const filteredMenuTemplate = menuTemplate.filter(
const menu = Menu.buildFromTemplate(filteredMenuTemplate); (menuItem) => menuItem.visible ?? true,
menu.popup(); );
const menu = Menu.buildFromTemplate(filteredMenuTemplate);
menu.popup();
}; };

View File

@@ -1,51 +1,73 @@
import {htmlEscape} from 'escape-goat'; import type {Html} from "../../../common/html";
import {html} from "../../../common/html";
import Tab, {TabProps} from './tab'; 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 { export default class FunctionalTab extends Tab {
$closeButton: Element; $view: Element;
$el: Element;
$closeButton?: Element;
constructor(props: TabProps) { constructor({$view, ...props}: FunctionalTabProps) {
super(props); super(props);
this.init();
}
templateHTML(): string { this.$view = $view;
return htmlEscape` this.$el = generateNodeFromHtml(this.templateHtml());
<div class="tab functional-tab" data-tab-id="${this.props.tabIndex}"> if (this.props.name !== "Settings") {
<div class="server-tab-badge close-button"> this.props.$root.append(this.$el);
<i class="material-icons">close</i> this.$closeButton = this.$el.querySelector(".server-tab-badge")!;
</div> this.registerListeners();
<div class="server-tab"> }
<i class="material-icons">${this.props.materialIcon}</i> }
</div>
</div>
`;
}
init(): void { override async activate(): Promise<void> {
this.$el = this.generateNodeFromHTML(this.templateHTML()); await super.activate();
if (this.props.name !== 'Settings') { this.$view.classList.add("active");
this.props.$root.append(this.$el); }
this.$closeButton = this.$el.querySelectorAll('.server-tab-badge')[0];
this.registerListeners();
}
}
registerListeners(): void { override async deactivate(): Promise<void> {
super.registerListeners(); await super.deactivate();
this.$view.classList.remove("active");
}
this.$el.addEventListener('mouseover', () => { override async destroy(): Promise<void> {
this.$closeButton.classList.add('active'); await super.destroy();
}); this.$view.remove();
}
this.$el.addEventListener('mouseout', () => { templateHtml(): Html {
this.$closeButton.classList.remove('active'); return html`
}); <div class="tab functional-tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tab-badge close-button">
<i class="material-icons">close</i>
</div>
<div class="server-tab">
<i class="material-icons">${this.props.materialIcon}</i>
</div>
</div>
`;
}
this.$closeButton.addEventListener('click', (event: Event) => { override registerListeners(): void {
this.props.onDestroy(); super.registerListeners();
event.stopPropagation();
}); this.$el.addEventListener("mouseover", () => {
} this.$closeButton?.classList.add("active");
});
this.$el.addEventListener("mouseout", () => {
this.$closeButton?.classList.remove("active");
});
this.$closeButton?.addEventListener("click", (event: Event) => {
this.props.onDestroy?.();
event.stopPropagation();
});
}
} }

View File

@@ -1,61 +0,0 @@
import {ipcRenderer, remote} from 'electron';
import * as ConfigUtil from '../utils/config-util';
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')) {
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
new Notification('Download Complete', { // eslint-disable-line no-new
body: 'Download failed'
});
} else {
this.$el.downloadURL(url.href);
}
}
ipcRenderer.removeAllListeners('downloadFileCompleted');
});
} else {
(async () => LinkUtil.openBrowser(url))();
}
}

View File

@@ -1,64 +1,86 @@
import {ipcRenderer} from 'electron'; import process from "node:process";
import {htmlEscape} from 'escape-goat'; 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 type {TabProps} from "./tab";
import Tab from "./tab";
import type WebView from "./webview";
import Tab, {TabProps} from './tab'; export interface ServerTabProps extends TabProps {
webview: Promise<WebView>;
}
export default class ServerTab extends Tab { export default class ServerTab extends Tab {
$badge: Element; webview: Promise<WebView>;
$el: Element;
$badge: Element;
constructor(props: TabProps) { constructor({webview, ...props}: ServerTabProps) {
super(props); super(props);
this.init();
}
templateHTML(): string { this.webview = webview;
return htmlEscape` this.$el = generateNodeFromHtml(this.templateHtml());
<div class="tab" data-tab-id="${this.props.tabIndex}"> this.props.$root.append(this.$el);
<div class="server-tooltip" style="display:none">${this.props.name}</div> this.registerListeners();
<div class="server-tab-badge"></div> this.$badge = this.$el.querySelector(".server-tab-badge")!;
<div class="server-tab"> }
<img class="server-icons" src="${this.props.icon}"/>
</div>
<div class="server-tab-shortcut">${this.generateShortcutText()}</div>
</div>
`;
}
init(): void { override async activate(): Promise<void> {
this.$el = this.generateNodeFromHTML(this.templateHTML()); await super.activate();
this.props.$root.append(this.$el); (await this.webview).load();
this.registerListeners(); }
this.$badge = this.$el.querySelectorAll('.server-tab-badge')[0];
}
updateBadge(count: number): void { override async deactivate(): Promise<void> {
if (count > 0) { await super.deactivate();
const formattedCount = count > 999 ? '1K+' : count.toString(); (await this.webview).hide();
this.$badge.textContent = formattedCount; }
this.$badge.classList.add('active');
} else {
this.$badge.classList.remove('active');
}
}
generateShortcutText(): string { override async destroy(): Promise<void> {
// Only provide shortcuts for server [0..10] await super.destroy();
if (this.props.index >= 10) { (await this.webview).$el.remove();
return ''; }
}
const shownIndex = this.props.index + 1; templateHtml(): Html {
return html`
<div class="tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tooltip" style="display:none">
${this.props.name}
</div>
<div class="server-tab-badge"></div>
<div class="server-tab">
<img class="server-icons" src="${this.props.icon}" />
</div>
<div class="server-tab-shortcut">${this.generateShortcutText()}</div>
</div>
`;
}
let shortcutText = ''; updateBadge(count: number): void {
if (count > 0) {
const formattedCount = count > 999 ? "1K+" : count.toString();
this.$badge.textContent = formattedCount;
this.$badge.classList.add("active");
} else {
this.$badge.classList.remove("active");
}
}
shortcutText = SystemUtil.getOS() === 'Mac' ? `${shownIndex}` : `Ctrl+${shownIndex}`; generateShortcutText(): string {
// Only provide shortcuts for server [0..9]
if (this.props.index >= 9) {
return "";
}
// Array index == Shown index - 1 const shownIndex = this.props.index + 1;
ipcRenderer.send('switch-server-tab', shownIndex - 1);
return shortcutText; // Array index == Shown index - 1
} ipcRenderer.send("switch-server-tab", shownIndex - 1);
return process.platform === "darwin"
? `${shownIndex}`
: `Ctrl+${shownIndex}`;
}
} }

View File

@@ -1,54 +1,48 @@
import BaseComponent from './base'; import type {TabRole} from "../../../common/types";
import WebView from './webview';
export interface TabProps { export interface TabProps {
role: string; role: TabRole;
icon?: string; icon?: string;
name: string; name: string;
$root: Element; $root: Element;
onClick: () => void; onClick: () => void;
index: number; index: number;
tabIndex: number; tabIndex: number;
onHover?: () => void; onHover?: () => void;
onHoverOut?: () => void; onHoverOut?: () => void;
webview: WebView; materialIcon?: string;
materialIcon?: string; onDestroy?: () => void;
onDestroy?: () => void;
} }
export default class Tab extends BaseComponent { export default abstract class Tab {
props: TabProps; props: TabProps;
webview: WebView; abstract $el: Element;
$el: Element;
constructor(props: TabProps) {
super();
this.props = props; constructor(props: TabProps) {
this.webview = this.props.webview; this.props = props;
} }
registerListeners(): void { registerListeners(): void {
this.$el.addEventListener('click', this.props.onClick); this.$el.addEventListener("click", this.props.onClick);
this.$el.addEventListener('mouseover', this.props.onHover);
this.$el.addEventListener('mouseout', this.props.onHoverOut);
}
showNetworkError(): void { if (this.props.onHover !== undefined) {
this.webview.forceLoad(); this.$el.addEventListener("mouseover", this.props.onHover);
} }
activate(): void { if (this.props.onHoverOut !== undefined) {
this.$el.classList.add('active'); this.$el.addEventListener("mouseout", this.props.onHoverOut);
this.webview.load(); }
} }
deactivate(): void { async activate(): Promise<void> {
this.$el.classList.remove('active'); this.$el.classList.add("active");
this.webview.hide(); }
}
destroy(): void { async deactivate(): Promise<void> {
this.$el.remove(); this.$el.classList.remove("active");
this.webview.$el.remove(); }
}
async destroy(): Promise<void> {
this.$el.remove();
}
} }

View File

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

View File

@@ -1,12 +1,27 @@
import {ipcRenderer} from 'electron'; import {EventEmitter} from "node:events";
import {EventEmitter} from 'events';
import isDev from 'electron-is-dev'; import type {ClipboardDecrypter} from "./clipboard-decrypter";
import {ClipboardDecrypterImpl} from "./clipboard-decrypter";
import type {NotificationData} from "./notification";
import {newNotification} from "./notification";
import {ipcRenderer} from "./typed-ipc-renderer";
import {ClipboardDecrypterImpl} from './clipboard-decrypter'; type ListenerType = (...args: any[]) => void;
import {NotificationData, newNotification} from './notification';
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; let notificationReplySupported = false;
// Indicates if the user is idle or not // Indicates if the user is idle or not
@@ -16,72 +31,76 @@ let lastActive = Date.now();
export const bridgeEvents = new EventEmitter(); export const bridgeEvents = new EventEmitter();
/* eslint-disable @typescript-eslint/naming-convention */
const electron_bridge: ElectronBridge = { const electron_bridge: ElectronBridge = {
send_event: (eventName: string | symbol, ...args: unknown[]): boolean => send_event: (eventName: string | symbol, ...args: unknown[]): boolean =>
bridgeEvents.emit(eventName, ...args), bridgeEvents.emit(eventName, ...args),
on_event: (eventName: string, listener: ListenerType): void => { on_event(eventName: string, listener: ListenerType): void {
bridgeEvents.on(eventName, listener); bridgeEvents.on(eventName, listener);
}, },
new_notification: ( new_notification: (
title: string, title: string,
options: NotificationOptions | undefined, options: NotificationOptions,
dispatch: (type: string, eventInit: EventInit) => boolean dispatch: (type: string, eventInit: EventInit) => boolean,
): NotificationData => ): NotificationData => newNotification(title, options, dispatch),
newNotification(title, options, dispatch),
get_idle_on_system: (): boolean => idle, get_idle_on_system: (): boolean => idle,
get_last_active_on_system: (): number => lastActive, get_last_active_on_system: (): number => lastActive,
get_send_notification_reply_message_supported: (): boolean => get_send_notification_reply_message_supported: (): boolean =>
notificationReplySupported, notificationReplySupported,
set_send_notification_reply_message_supported: (value: boolean): void => { set_send_notification_reply_message_supported(value: boolean): void {
notificationReplySupported = value; notificationReplySupported = value;
}, },
decrypt_clipboard: (version: number): ClipboardDecrypterImpl => decrypt_clipboard: (version: number): ClipboardDecrypter =>
new ClipboardDecrypterImpl(version) new ClipboardDecrypterImpl(version),
}; };
/* eslint-enable @typescript-eslint/naming-convention */
bridgeEvents.on('total_unread_count', (...args) => { bridgeEvents.on("total_unread_count", (unreadCount: unknown) => {
ipcRenderer.send('unread-count', ...args); if (typeof unreadCount !== "number") {
throw new TypeError("Expected string for unreadCount");
}
ipcRenderer.send("unread-count", unreadCount);
}); });
bridgeEvents.on('realm_name', realmName => { bridgeEvents.on("realm_name", (realmName: unknown) => {
const serverURL = location.origin; if (typeof realmName !== "string") {
ipcRenderer.send('realm-name-changed', serverURL, realmName); throw new TypeError("Expected string for realmName");
}
const serverUrl = location.origin;
ipcRenderer.send("realm-name-changed", serverUrl, realmName);
}); });
bridgeEvents.on('realm_icon_url', (iconURL: unknown) => { bridgeEvents.on("realm_icon_url", (iconUrl: unknown) => {
if (typeof iconURL !== 'string') { if (typeof iconUrl !== "string") {
throw new TypeError('Expected string for iconURL'); throw new TypeError("Expected string for iconUrl");
} }
const serverURL = location.origin; const serverUrl = location.origin;
iconURL = iconURL.includes('http') ? iconURL : `${serverURL}${iconURL}`; ipcRenderer.send(
ipcRenderer.send('realm-icon-changed', serverURL, iconURL); "realm-icon-changed",
serverUrl,
iconUrl.includes("http") ? iconUrl : `${serverUrl}${iconUrl}`,
);
}); });
// Set user as active and update the time of last activity // Set user as active and update the time of last activity
ipcRenderer.on('set-active', () => { ipcRenderer.on("set-active", () => {
if (isDev) { idle = false;
console.log('active'); lastActive = Date.now();
}
idle = false;
lastActive = Date.now();
}); });
// Set user as idle and time of last activity is left unchanged // Set user as idle and time of last activity is left unchanged
ipcRenderer.on('set-idle', () => { ipcRenderer.on("set-idle", () => {
if (isDev) { idle = true;
console.log('idle');
}
idle = true;
}); });
// This follows node's idiomatic implementation of event // This follows node's idiomatic implementation of event

View File

@@ -1,50 +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,105 +1,113 @@
'use strict'; "use strict";
type ElectronBridge = import("./electron-bridge").ElectronBridge;
interface CompatElectronBridge extends ElectronBridge { interface CompatElectronBridge extends ElectronBridge {
readonly idle_on_system: boolean; readonly idle_on_system: boolean;
readonly last_active_on_system: number; readonly last_active_on_system: number;
send_notification_reply_message_supported: boolean; send_notification_reply_message_supported: boolean;
} }
(() => { (() => {
const zulipWindow = window as typeof window & { const zulipWindow = window as typeof window & {
electron_bridge: CompatElectronBridge; electron_bridge: CompatElectronBridge;
raw_electron_bridge: ElectronBridge; raw_electron_bridge: ElectronBridge;
}; };
const electron_bridge: CompatElectronBridge = { /* eslint-disable @typescript-eslint/naming-convention */
...zulipWindow.raw_electron_bridge, const electron_bridge: CompatElectronBridge = {
...zulipWindow.raw_electron_bridge,
get idle_on_system(): boolean { get idle_on_system(): boolean {
return this.get_idle_on_system(); return this.get_idle_on_system();
}, },
get last_active_on_system(): number { get last_active_on_system(): number {
return this.get_last_active_on_system(); return this.get_last_active_on_system();
}, },
get send_notification_reply_message_supported(): boolean { get send_notification_reply_message_supported(): boolean {
return this.get_send_notification_reply_message_supported(); return this.get_send_notification_reply_message_supported();
}, },
set send_notification_reply_message_supported(value: boolean) { set send_notification_reply_message_supported(value: boolean) {
this.set_send_notification_reply_message_supported(value); this.set_send_notification_reply_message_supported(value);
} },
}; };
/* eslint-enable @typescript-eslint/naming-convention */
zulipWindow.electron_bridge = electron_bridge; zulipWindow.electron_bridge = electron_bridge;
function attributeListener<T extends EventTarget>(type: string): PropertyDescriptor { function attributeListener<T extends EventTarget>(
const symbol = Symbol('on' + type); type: string,
): PropertyDescriptor {
const handlers = new WeakMap<T, (event: Event) => unknown>();
function listener(this: T, ev: Event): void { function listener(this: T, event: Event): void {
if ((this as any)[symbol].call(this, ev) === false) { if (handlers.get(this)!.call(this, event) === false) {
ev.preventDefault(); event.preventDefault();
} }
} }
return { return {
configurable: true, configurable: true,
enumerable: true, enumerable: true,
get(this: T) { get(this: T) {
return (this as any)[symbol]; return handlers.get(this);
}, },
set(this: T, value: unknown) { set(this: T, value: unknown) {
if (typeof value === 'function') { if (typeof value === "function") {
if (!(symbol in this)) { if (!handlers.has(this)) {
this.addEventListener(type, listener); this.addEventListener(type, listener);
} }
(this as any)[symbol] = value; handlers.set(this, value as (event: Event) => unknown);
} else if (symbol in this) { } else if (handlers.has(this)) {
this.removeEventListener(type, listener); this.removeEventListener(type, listener);
delete (this as any)[symbol]; handlers.delete(this);
} }
} },
}; };
} }
const NativeNotification = Notification; // eslint-disable-next-line @typescript-eslint/naming-convention
const NativeNotification = Notification;
class InjectedNotification extends EventTarget { class InjectedNotification extends EventTarget {
constructor(title: string, options?: NotificationOptions) { static get permission(): NotificationPermission {
super(); return NativeNotification.permission;
Object.assign( }
this,
electron_bridge.new_notification(title, options, (type: string, eventInit: EventInit) =>
this.dispatchEvent(new Event(type, eventInit))
)
);
}
static get maxActions(): number { static async requestPermission(
return NativeNotification.maxActions; callback?: NotificationPermissionCallback,
} ): Promise<NotificationPermission> {
if (callback) {
callback(await Promise.resolve(NativeNotification.permission));
}
static get permission(): NotificationPermission { return NativeNotification.permission;
return NativeNotification.permission; }
}
static async requestPermission(callback?: NotificationPermissionCallback): Promise<NotificationPermission> { constructor(title: string, options: NotificationOptions = {}) {
if (callback) { super();
callback(await Promise.resolve(NativeNotification.permission)); Object.assign(
} this,
electron_bridge.new_notification(
title,
options,
(type: string, eventInit: EventInit) =>
this.dispatchEvent(new Event(type, eventInit)),
),
);
}
}
return NativeNotification.permission; Object.defineProperties(InjectedNotification.prototype, {
} onclick: attributeListener("click"),
} onclose: attributeListener("close"),
onerror: attributeListener("error"),
onshow: attributeListener("show"),
});
Object.defineProperties(InjectedNotification.prototype, { window.Notification = InjectedNotification as unknown as typeof Notification;
onclick: attributeListener('click'),
onclose: attributeListener('close'),
onerror: attributeListener('error'),
onshow: attributeListener('show')
});
window.Notification = InjectedNotification as any;
})(); })();

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +0,0 @@
import {ipcRenderer} from 'electron';
import * as ConfigUtil from '../utils/config-util';
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') ? 'granted' : 'denied';
}
}

View File

@@ -1,14 +0,0 @@
import {remote} from 'electron';
// 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 {
currentWindow.webContents.send('focus-webview-with-id', webContentsId);
}

View File

@@ -1,65 +1,41 @@
import {remote} from 'electron'; import {ipcRenderer} from "../typed-ipc-renderer";
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);
export interface NotificationData { export interface NotificationData {
close: () => void; close: () => void;
title: string; title: string;
dir: NotificationDirection; dir: NotificationDirection;
lang: string; lang: string;
body: string; body: string;
tag: string; tag: string;
image: string; icon: string;
icon: string; data: unknown;
badge: string;
vibrate: readonly number[];
timestamp: number;
renotify: boolean;
silent: boolean;
requireInteraction: boolean;
data: unknown;
actions: readonly NotificationAction[];
} }
export function newNotification( export function newNotification(
title: string, title: string,
options: NotificationOptions | undefined, options: NotificationOptions,
dispatch: (type: string, eventInit: EventInit) => boolean dispatch: (type: string, eventInit: EventInit) => boolean,
): NotificationData { ): NotificationData {
const notification = new DefaultNotification(title, options); const notification = new Notification(title, {...options, silent: true});
for (const type of ['click', 'close', 'error', 'show']) { for (const type of ["click", "close", "error", "show"]) {
notification.addEventListener(type, (ev: Event) => { notification.addEventListener(type, (ev: Event) => {
if (!dispatch(type, ev)) { if (type === "click") ipcRenderer.send("focus-this-webview");
ev.preventDefault(); if (!dispatch(type, ev)) {
} ev.preventDefault();
}); }
} });
}
return { return {
close: () => { close() {
notification.close(); notification.close();
}, },
title: notification.title, title: notification.title,
dir: notification.dir, dir: notification.dir,
lang: notification.lang, lang: notification.lang,
body: notification.body, body: notification.body,
tag: notification.tag, tag: notification.tag,
image: notification.image, icon: notification.icon,
icon: notification.icon, data: notification.data,
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,10 +1,13 @@
import {ipcRenderer} from 'electron'; import {ipcRenderer} from "../typed-ipc-renderer";
export function init($reconnectButton: Element, $settingsButton: Element): void { export function init(
$reconnectButton.addEventListener('click', () => { $reconnectButton: Element,
ipcRenderer.send('forward-message', 'reload-viewer'); $settingsButton: Element,
}); ): void {
$settingsButton.addEventListener('click', () => { $reconnectButton.addEventListener("click", () => {
ipcRenderer.send('forward-message', 'open-settings'); ipcRenderer.send("forward-message", "reload-viewer");
}); });
$settingsButton.addEventListener("click", () => {
ipcRenderer.send("forward-message", "open-settings");
});
} }

View File

@@ -1,44 +0,0 @@
import electron, {app} from 'electron';
import * as ConfigUtil from '../../utils/config-util';
function showBadgeCount(messageCount: number, mainWindow: electron.BrowserWindow): void {
if (process.platform === 'win32') {
updateOverlayIcon(messageCount, mainWindow);
} else {
app.badgeCount = messageCount;
}
}
function hideBadgeCount(mainWindow: electron.BrowserWindow): void {
if (process.platform === 'win32') {
mainWindow.setOverlayIcon(null, '');
} else {
app.badgeCount = 0;
}
}
export function updateBadge(badgeCount: number, mainWindow: electron.BrowserWindow): void {
if (ConfigUtil.getConfigItem('badgeOption', true)) {
showBadgeCount(badgeCount, mainWindow);
} else {
hideBadgeCount(mainWindow);
}
}
function updateOverlayIcon(messageCount: number, mainWindow: electron.BrowserWindow): void {
if (!mainWindow.isFocused()) {
mainWindow.flashFrame(ConfigUtil.getConfigItem('flashTaskbarOnMessage'));
}
if (messageCount === 0) {
mainWindow.setOverlayIcon(null, '');
} else {
mainWindow.webContents.send('render-taskbar-icon', messageCount);
}
}
export function updateTaskbarIcon(data: string, text: string, mainWindow: electron.BrowserWindow): void {
const img = electron.nativeImage.createFromDataURL(data);
mainWindow.setOverlayIcon(img, text);
}

View File

@@ -1,66 +1,84 @@
import {ipcRenderer} from 'electron'; import type {Html} from "../../../../common/html";
import {html} from "../../../../common/html";
import {htmlEscape} from 'escape-goat'; import {generateNodeFromHtml} from "../../components/base";
import {ipcRenderer} from "../../typed-ipc-renderer";
import BaseComponent from '../../components/base';
interface BaseSectionProps { interface BaseSectionProps {
$element: HTMLElement; $element: HTMLElement;
disabled?: boolean; disabled?: boolean;
value: boolean; value: boolean;
clickHandler: () => void; clickHandler: () => void;
} }
export default class BaseSection extends BaseComponent { export function generateSettingOption(props: BaseSectionProps): void {
generateSettingOption(props: BaseSectionProps): void { const {$element, disabled, value, clickHandler} = props;
const {$element, disabled, value, clickHandler} = props;
$element.textContent = ''; $element.textContent = "";
const $optionControl = this.generateNodeFromHTML(this.generateOptionHTML(value, disabled)); const $optionControl = generateNodeFromHtml(
$element.append($optionControl); generateOptionHtml(value, disabled),
);
$element.append($optionControl);
if (!disabled) { if (!disabled) {
$optionControl.addEventListener('click', clickHandler); $optionControl.addEventListener("click", clickHandler);
} }
} }
generateOptionHTML(settingOption: boolean, disabled?: boolean): string { export function generateOptionHtml(
const labelHTML = disabled ? '<label class="disallowed" title="Setting locked by system administrator."></label>' : '<label></label>'; settingOption: boolean,
if (settingOption) { disabled?: boolean,
return htmlEscape` ): Html {
<div class="action"> const labelHtml = disabled
<div class="switch"> ? // eslint-disable-next-line unicorn/template-indent
<input class="toggle toggle-round" type="checkbox" checked disabled> html`<label
` + labelHTML + htmlEscape` class="disallowed"
</div> title="Setting locked by system administrator."
</div> ></label>`
`; : html`<label></label>`;
} if (settingOption) {
return html`
return htmlEscape` <div class="action">
<div class="action"> <div class="switch">
<div class="switch"> <input class="toggle toggle-round" type="checkbox" checked disabled />
<input class="toggle toggle-round" type="checkbox"> ${labelHtml}
` + labelHTML + htmlEscape` </div>
</div> </div>
</div> `;
`; }
}
return html`
/* A method that in future can be used to create dropdown menus using <select> <option> tags. <div class="action">
it needs an object which has ``key: value`` pairs and will return a string that can be appended to HTML <div class="switch">
*/ <input class="toggle toggle-round" type="checkbox" />
generateSelectHTML(options: Record<string, string>, className?: string, idName?: string): string { ${labelHtml}
let html = htmlEscape`<select class="${className}" id="${idName}">\n`; </div>
Object.keys(options).forEach(key => { </div>
html += htmlEscape`<option name="${key}" value="${key}">${options[key]}</option>\n`; `;
}); }
html += '</select>';
return html; /* 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
*/
reloadApp(): void { export function generateSelectHtml(
ipcRenderer.send('forward-message', 'reload-viewer'); options: Record<string, string>,
} className?: string,
idName?: string,
): Html {
const optionsHtml = html``.join(
Object.keys(options).map(
(key) => html`
<option name="${key}" value="${key}">${options[key]}</option>
`,
),
);
return html`
<select class="${className}" id="${idName}">
${optionsHtml}
</select>
`;
}
export function reloadApp(): void {
ipcRenderer.send("forward-message", "reload-viewer");
} }

View File

@@ -1,80 +1,65 @@
import {ipcRenderer} from 'electron'; import {html} from "../../../../common/html";
import * as t from "../../../../common/translation-util";
import {ipcRenderer} from "../../typed-ipc-renderer";
import * as DomainUtil from "../../utils/domain-util";
import {htmlEscape} from 'escape-goat'; import {reloadApp} from "./base-section";
import {initFindAccounts} from "./find-accounts";
import * as DomainUtil from '../../utils/domain-util'; import {initServerInfoForm} from "./server-info-form";
import * as t from '../../utils/translation-util';
import BaseSection from './base-section';
import FindAccounts from './find-accounts';
import ServerInfoForm from './server-info-form';
interface ConnectedOrgSectionProps { interface ConnectedOrgSectionProps {
$root: Element; $root: Element;
} }
export default class ConnectedOrgSection extends BaseSection { export function initConnectedOrgSection({
props: ConnectedOrgSectionProps; $root,
$serverInfoContainer: Element | null; }: ConnectedOrgSectionProps): void {
$existingServers: Element | null; $root.textContent = "";
$newOrgButton: HTMLButtonElement | null;
$findAccountsContainer: Element | null;
constructor(props: ConnectedOrgSectionProps) {
super();
this.props = props;
}
templateHTML(): string { const servers = DomainUtil.getDomains();
return htmlEscape` $root.innerHTML = html`
<div class="settings-pane" id="server-settings-pane"> <div class="settings-pane" id="server-settings-pane">
<div class="page-title">${t.__('Connected organizations')}</div> <div class="page-title">${t.__("Connected organizations")}</div>
<div class="title" id="existing-servers">${t.__('All the connected orgnizations will appear here.')}</div> <div class="title" id="existing-servers">
<div id="server-info-container"></div> ${t.__("All the connected orgnizations will appear here.")}
<div id="new-org-button"><button class="green sea w-250">${t.__('Connect to another organization')}</button></div> </div>
<div class="page-title">${t.__('Find accounts by email')}</div> <div id="server-info-container"></div>
<div id="find-accounts-container"></div> <div id="new-org-button">
</div> <button class="green sea w-250">
`; ${t.__("Connect to another organization")}
} </button>
</div>
<div class="page-title">${t.__("Find accounts by email")}</div>
<div id="find-accounts-container"></div>
</div>
`.html;
init(): void { const $serverInfoContainer = $root.querySelector("#server-info-container")!;
this.initServers(); const $existingServers = $root.querySelector("#existing-servers")!;
} const $newOrgButton: HTMLButtonElement =
$root.querySelector("#new-org-button")!;
const $findAccountsContainer = $root.querySelector(
"#find-accounts-container",
)!;
initServers(): void { const noServerText = t.__("All the connected orgnizations will appear here");
this.props.$root.textContent = ''; // Show noServerText if no servers are there otherwise hide it
$existingServers.textContent = servers.length === 0 ? noServerText : "";
const servers = DomainUtil.getDomains(); for (const [i, server] of servers.entries()) {
this.props.$root.innerHTML = this.templateHTML(); initServerInfoForm({
$root: $serverInfoContainer,
server,
index: i,
onChange: reloadApp,
});
}
this.$serverInfoContainer = document.querySelector('#server-info-container'); $newOrgButton.addEventListener("click", () => {
this.$existingServers = document.querySelector('#existing-servers'); ipcRenderer.send("forward-message", "open-org-tab");
this.$newOrgButton = document.querySelector('#new-org-button'); });
this.$findAccountsContainer = document.querySelector('#find-accounts-container');
const noServerText = t.__('All the connected orgnizations will appear here'); initFindAccounts({
// Show noServerText if no servers are there otherwise hide it $root: $findAccountsContainer,
this.$existingServers.textContent = servers.length === 0 ? noServerText : ''; });
for (const [i, server] of servers.entries()) {
new ServerInfoForm({
$root: this.$serverInfoContainer,
server,
index: i,
onChange: this.reloadApp
}).init();
}
this.$newOrgButton.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'open-org-tab');
});
this.initFindAccounts();
}
initFindAccounts(): void {
new FindAccounts({
$root: this.$findAccountsContainer
}).init();
}
} }

View File

@@ -1,80 +1,67 @@
import {htmlEscape} from 'escape-goat'; import {html} from "../../../../common/html";
import * as LinkUtil from "../../../../common/link-util";
import BaseComponent from '../../components/base'; import * as t from "../../../../common/translation-util";
import * as LinkUtil from '../../utils/link-util'; import {generateNodeFromHtml} from "../../components/base";
import * as t from '../../utils/translation-util';
interface FindAccountsProps { interface FindAccountsProps {
$root: Element; $root: Element;
} }
export default class FindAccounts extends BaseComponent { async function findAccounts(url: string): Promise<void> {
props: FindAccountsProps; if (!url) {
$findAccounts: Element | null; return;
$findAccountsButton: Element | null; }
$serverUrlField: HTMLInputElement | null;
constructor(props: FindAccountsProps) {
super();
this.props = props;
}
templateHTML(): string { if (!url.startsWith("http")) {
return htmlEscape` url = "https://" + url;
<div class="settings-card certificate-card"> }
<div class="certificate-input">
<div>${t.__('Organization URL')}</div>
<input class="setting-input-value" value="zulipchat.com"/>
</div>
<div class="certificate-input">
<button class="green w-150" id="find-accounts-button">${t.__('Find accounts')}</button>
</div>
</div>
`;
}
init(): void { await LinkUtil.openBrowser(new URL("/accounts/find", url));
this.$findAccounts = this.generateNodeFromHTML(this.templateHTML()); }
this.props.$root.append(this.$findAccounts);
this.$findAccountsButton = this.$findAccounts.querySelector('#find-accounts-button'); export function initFindAccounts(props: FindAccountsProps): void {
this.$serverUrlField = this.$findAccounts.querySelectorAll('input.setting-input-value')[0] as HTMLInputElement; const $findAccounts = generateNodeFromHtml(html`
this.initListeners(); <div class="settings-card certificate-card">
} <div class="certificate-input">
<div>${t.__("Organization URL")}</div>
async findAccounts(url: string): Promise<void> { <input class="setting-input-value" value="zulipchat.com" />
if (!url) { </div>
return; <div class="certificate-input">
} <button class="green w-150" id="find-accounts-button">
${t.__("Find accounts")}
if (!url.startsWith('http')) { </button>
url = 'https://' + url; </div>
} </div>
`);
await LinkUtil.openBrowser(new URL('/accounts/find', url)); props.$root.append($findAccounts);
} const $findAccountsButton = $findAccounts.querySelector(
"#find-accounts-button",
initListeners(): void { )!;
this.$findAccountsButton.addEventListener('click', async () => { const $serverUrlField: HTMLInputElement = $findAccounts.querySelector(
await this.findAccounts(this.$serverUrlField.value); "input.setting-input-value",
}); )!;
this.$serverUrlField.addEventListener('click', () => { $findAccountsButton.addEventListener("click", async () => {
if (this.$serverUrlField.value === 'zulipchat.com') { await findAccounts($serverUrlField.value);
this.$serverUrlField.setSelectionRange(0, 0); });
}
}); $serverUrlField.addEventListener("click", () => {
if ($serverUrlField.value === "zulipchat.com") {
this.$serverUrlField.addEventListener('keypress', async event => { $serverUrlField.setSelectionRange(0, 0);
if (event.key === 'Enter') { }
await this.findAccounts(this.$serverUrlField.value); });
}
}); $serverUrlField.addEventListener("keypress", async (event) => {
if (event.key === "Enter") {
this.$serverUrlField.addEventListener('input', () => { await findAccounts($serverUrlField.value);
if (this.$serverUrlField.value) { }
this.$serverUrlField.classList.remove('invalid-input-value'); });
} else {
this.$serverUrlField.classList.add('invalid-input-value'); $serverUrlField.addEventListener("input", () => {
} if ($serverUrlField.value) {
}); $serverUrlField.classList.remove("invalid-input-value");
} } else {
$serverUrlField.classList.add("invalid-input-value");
}
});
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +1,76 @@
import {htmlEscape} from 'escape-goat'; import type {Html} from "../../../../common/html";
import {html} from "../../../../common/html";
import BaseComponent from '../../components/base'; import * as t from "../../../../common/translation-util";
import * as t from '../../utils/translation-util'; import type {NavItem} from "../../../../common/types";
import {generateNodeFromHtml} from "../../components/base";
interface PreferenceNavProps { interface PreferenceNavProps {
$root: Element; $root: Element;
onItemSelected: (navItem: string) => void; onItemSelected: (navItem: NavItem) => void;
} }
export default class PreferenceNav extends BaseComponent { export default class PreferenceNav {
props: PreferenceNavProps; props: PreferenceNavProps;
navItems: string[]; navItems: NavItem[];
$el: Element; $el: Element;
constructor(props: PreferenceNavProps) { constructor(props: PreferenceNavProps) {
super(); this.props = props;
this.props = props; this.navItems = [
this.navItems = ['General', 'Network', 'AddServer', 'Organizations', 'Shortcuts']; "General",
this.init(); "Network",
} "AddServer",
"Organizations",
"Shortcuts",
];
templateHTML(): string { this.$el = generateNodeFromHtml(this.templateHtml());
let navItemsHTML = ''; this.props.$root.append(this.$el);
for (const navItem of this.navItems) { this.registerListeners();
navItemsHTML += htmlEscape`<div class="nav" id="nav-${navItem}">${t.__(navItem)}</div>`; }
}
return htmlEscape` templateHtml(): Html {
<div> const navItemsHtml = html``.join(
<div id="settings-header">${t.__('Settings')}</div> this.navItems.map(
<div id="nav-container">` + navItemsHTML + htmlEscape`</div> (navItem) => html`
</div> <div class="nav" id="nav-${navItem}">${t.__(navItem)}</div>
`; `,
} ),
);
init(): void { return html`
this.$el = this.generateNodeFromHTML(this.templateHTML()); <div>
this.props.$root.append(this.$el); <div id="settings-header">${t.__("Settings")}</div>
this.registerListeners(); <div id="nav-container">${navItemsHtml}</div>
} </div>
`;
}
registerListeners(): void { registerListeners(): void {
for (const navItem of this.navItems) { 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', () => { $item.addEventListener("click", () => {
this.props.onItemSelected(navItem); this.props.onItemSelected(navItem);
}); });
} }
} }
select(navItemToSelect: string): void { select(navItemToSelect: NavItem): void {
for (const navItem of this.navItems) { for (const navItem of this.navItems) {
if (navItem === navItemToSelect) { if (navItem === navItemToSelect) {
this.activate(navItem); this.activate(navItem);
} else { } else {
this.deactivate(navItem); this.deactivate(navItem);
} }
} }
} }
activate(navItem: string): void { 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'); $item.classList.add("active");
} }
deactivate(navItem: string): void { 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'); $item.classList.remove("active");
} }
} }

View File

@@ -1,141 +1,140 @@
import {ipcRenderer} from 'electron'; import * as ConfigUtil from "../../../../common/config-util";
import {html} from "../../../../common/html";
import * as t from "../../../../common/translation-util";
import {ipcRenderer} from "../../typed-ipc-renderer";
import {htmlEscape} from 'escape-goat'; import {generateSettingOption} from "./base-section";
import * as ConfigUtil from '../../utils/config-util';
import * as t from '../../utils/translation-util';
import BaseSection from './base-section';
interface NetworkSectionProps { interface NetworkSectionProps {
$root: Element; $root: Element;
} }
export default class NetworkSection extends BaseSection { export function initNetworkSection({$root}: NetworkSectionProps): void {
props: NetworkSectionProps; $root.innerHTML = html`
$proxyPAC: HTMLInputElement; <div class="settings-pane">
$proxyRules: HTMLInputElement; <div class="title">${t.__("Proxy")}</div>
$proxyBypass: HTMLInputElement; <div id="appearance-option-settings" class="settings-card">
$proxySaveAction: Element; <div class="setting-row" id="use-system-settings">
$manualProxyBlock: Element; <div class="setting-description">
constructor(props: NetworkSectionProps) { ${t.__("Use system proxy settings (requires restart)")}
super(); </div>
this.props = props; <div class="setting-control"></div>
} </div>
<div class="setting-row" id="use-manual-settings">
templateHTML(): string { <div class="setting-description">
return htmlEscape` ${t.__("Manual proxy configuration")}
<div class="settings-pane"> </div>
<div class="title">${t.__('Proxy')}</div> <div class="setting-control"></div>
<div id="appearance-option-settings" class="settings-card"> </div>
<div class="setting-row" id="use-system-settings"> <div class="manual-proxy-block">
<div class="setting-description">${t.__('Use system proxy settings (requires restart)')}</div> <div class="setting-row" id="proxy-pac-option">
<div class="setting-control"></div> <span class="setting-input-key">PAC ${t.__("script")}</span>
</div> <input
<div class="setting-row" id="use-manual-settings"> class="setting-input-value"
<div class="setting-description">${t.__('Manual proxy configuration')}</div> placeholder="e.g. foobar.com/pacfile.js"
<div class="setting-control"></div> />
</div> </div>
<div class="manual-proxy-block"> <div class="setting-row" id="proxy-rules-option">
<div class="setting-row" id="proxy-pac-option"> <span class="setting-input-key">${t.__("Proxy rules")}</span>
<span class="setting-input-key">PAC ${t.__('script')}</span> <input
<input class="setting-input-value" placeholder="e.g. foobar.com/pacfile.js"/> class="setting-input-value"
</div> placeholder="e.g. http=foopy:80;ftp=foopy2"
<div class="setting-row" id="proxy-rules-option"> />
<span class="setting-input-key">${t.__('Proxy rules')}</span> </div>
<input class="setting-input-value" placeholder="e.g. http=foopy:80;ftp=foopy2"/> <div class="setting-row" id="proxy-bypass-option">
</div> <span class="setting-input-key">${t.__("Proxy bypass rules")}</span>
<div class="setting-row" id="proxy-bypass-option"> <input class="setting-input-value" placeholder="e.g. foobar.com" />
<span class="setting-input-key">${t.__('Proxy bypass rules')}</span> </div>
<input class="setting-input-value" placeholder="e.g. foobar.com"/> <div class="setting-row">
</div> <div class="action green" id="proxy-save-action">
<div class="setting-row"> <span>${t.__("Save")}</span>
<div class="action green" id="proxy-save-action">
<span>${t.__('Save')}</span>
</div>
</div>
</div>
</div>
</div> </div>
`; </div>
} </div>
</div>
</div>
`.html;
init(): void { const $proxyPac: HTMLInputElement = $root.querySelector(
this.props.$root.innerHTML = this.templateHTML(); "#proxy-pac-option .setting-input-value",
this.$proxyPAC = document.querySelector('#proxy-pac-option .setting-input-value'); )!;
this.$proxyRules = document.querySelector('#proxy-rules-option .setting-input-value'); const $proxyRules: HTMLInputElement = $root.querySelector(
this.$proxyBypass = document.querySelector('#proxy-bypass-option .setting-input-value'); "#proxy-rules-option .setting-input-value",
this.$proxySaveAction = document.querySelector('#proxy-save-action'); )!;
this.$manualProxyBlock = this.props.$root.querySelector('.manual-proxy-block'); const $proxyBypass: HTMLInputElement = $root.querySelector(
this.initProxyOption(); "#proxy-bypass-option .setting-input-value",
)!;
const $proxySaveAction = $root.querySelector("#proxy-save-action")!;
const $manualProxyBlock = $root.querySelector(".manual-proxy-block")!;
this.$proxyPAC.value = ConfigUtil.getConfigItem('proxyPAC', ''); toggleManualProxySettings(ConfigUtil.getConfigItem("useManualProxy", false));
this.$proxyRules.value = ConfigUtil.getConfigItem('proxyRules', ''); updateProxyOption();
this.$proxyBypass.value = ConfigUtil.getConfigItem('proxyBypass', '');
this.$proxySaveAction.addEventListener('click', () => { $proxyPac.value = ConfigUtil.getConfigItem("proxyPAC", "");
ConfigUtil.setConfigItem('proxyPAC', this.$proxyPAC.value); $proxyRules.value = ConfigUtil.getConfigItem("proxyRules", "");
ConfigUtil.setConfigItem('proxyRules', this.$proxyRules.value); $proxyBypass.value = ConfigUtil.getConfigItem("proxyBypass", "");
ConfigUtil.setConfigItem('proxyBypass', this.$proxyBypass.value);
ipcRenderer.send('forward-message', 'reload-proxy', true); $proxySaveAction.addEventListener("click", () => {
}); ConfigUtil.setConfigItem("proxyPAC", $proxyPac.value);
} ConfigUtil.setConfigItem("proxyRules", $proxyRules.value);
ConfigUtil.setConfigItem("proxyBypass", $proxyBypass.value);
initProxyOption(): void { ipcRenderer.send("forward-message", "reload-proxy", true);
const manualProxyEnabled = ConfigUtil.getConfigItem('useManualProxy', false); });
this.toggleManualProxySettings(manualProxyEnabled);
this.updateProxyOption(); function toggleManualProxySettings(option: boolean): void {
} if (option) {
$manualProxyBlock.classList.remove("hidden");
} else {
$manualProxyBlock.classList.add("hidden");
}
}
toggleManualProxySettings(option: boolean): void { function updateProxyOption(): void {
if (option) { generateSettingOption({
this.$manualProxyBlock.classList.remove('hidden'); $element: $root.querySelector("#use-system-settings .setting-control")!,
} else { value: ConfigUtil.getConfigItem("useSystemProxy", false),
this.$manualProxyBlock.classList.add('hidden'); clickHandler() {
} const newValue = !ConfigUtil.getConfigItem("useSystemProxy", false);
} const manualProxyValue = ConfigUtil.getConfigItem(
"useManualProxy",
false,
);
if (manualProxyValue && newValue) {
ConfigUtil.setConfigItem("useManualProxy", !manualProxyValue);
toggleManualProxySettings(!manualProxyValue);
}
updateProxyOption(): void { if (!newValue) {
this.generateSettingOption({ // Remove proxy system proxy settings
$element: document.querySelector('#use-system-settings .setting-control'), ConfigUtil.setConfigItem("proxyRules", "");
value: ConfigUtil.getConfigItem('useSystemProxy', false), ipcRenderer.send("forward-message", "reload-proxy", false);
clickHandler: () => { }
const newValue = !ConfigUtil.getConfigItem('useSystemProxy');
const manualProxyValue = ConfigUtil.getConfigItem('useManualProxy');
if (manualProxyValue && newValue) {
ConfigUtil.setConfigItem('useManualProxy', !manualProxyValue);
this.toggleManualProxySettings(!manualProxyValue);
}
if (!newValue) { ConfigUtil.setConfigItem("useSystemProxy", newValue);
// Remove proxy system proxy settings updateProxyOption();
ConfigUtil.setConfigItem('proxyRules', ''); },
ipcRenderer.send('forward-message', 'reload-proxy', false); });
} generateSettingOption({
$element: $root.querySelector("#use-manual-settings .setting-control")!,
value: ConfigUtil.getConfigItem("useManualProxy", false),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("useManualProxy", false);
const systemProxyValue = ConfigUtil.getConfigItem(
"useSystemProxy",
false,
);
toggleManualProxySettings(newValue);
if (systemProxyValue && newValue) {
ConfigUtil.setConfigItem("useSystemProxy", !systemProxyValue);
}
ConfigUtil.setConfigItem('useSystemProxy', newValue); ConfigUtil.setConfigItem("proxyRules", "");
this.updateProxyOption(); ConfigUtil.setConfigItem("useManualProxy", newValue);
} // Reload app only when turning manual proxy off, hence !newValue
}); ipcRenderer.send("forward-message", "reload-proxy", !newValue);
this.generateSettingOption({ updateProxyOption();
$element: document.querySelector('#use-manual-settings .setting-control'), },
value: ConfigUtil.getConfigItem('useManualProxy', false), });
clickHandler: () => { }
const newValue = !ConfigUtil.getConfigItem('useManualProxy');
const systemProxyValue = ConfigUtil.getConfigItem('useSystemProxy');
this.toggleManualProxySettings(newValue);
if (systemProxyValue && newValue) {
ConfigUtil.setConfigItem('useSystemProxy', !systemProxyValue);
}
ConfigUtil.setConfigItem('proxyRules', '');
ConfigUtil.setConfigItem('useManualProxy', newValue);
// Reload app only when turning manual proxy off, hence !newValue
ipcRenderer.send('forward-message', 'reload-proxy', !newValue);
this.updateProxyOption();
}
});
}
} }

View File

@@ -1,116 +1,103 @@
import {ipcRenderer, remote} from 'electron'; import {dialog} from "@electron/remote";
import {htmlEscape} from 'escape-goat'; import {html} from "../../../../common/html";
import * as LinkUtil from "../../../../common/link-util";
import BaseComponent from '../../components/base'; import * as t from "../../../../common/translation-util";
import * as DomainUtil from '../../utils/domain-util'; import {generateNodeFromHtml} from "../../components/base";
import * as LinkUtil from '../../utils/link-util'; import {ipcRenderer} from "../../typed-ipc-renderer";
import * as t from '../../utils/translation-util'; import * as DomainUtil from "../../utils/domain-util";
const {dialog} = remote;
interface NewServerFormProps { interface NewServerFormProps {
$root: Element; $root: Element;
onChange: () => void; onChange: () => void;
} }
export default class NewServerForm extends BaseComponent { export function initNewServerForm({$root, onChange}: NewServerFormProps): void {
props: NewServerFormProps; const $newServerForm = generateNodeFromHtml(html`
$newServerForm: Element; <div class="server-input-container">
$saveServerButton: HTMLButtonElement; <div class="title">${t.__("Organization URL")}</div>
$newServerUrl: HTMLInputElement; <div class="add-server-info-row">
constructor(props: NewServerFormProps) { <input
super(); class="setting-input-value"
this.props = props; autofocus
} placeholder="your-organization.zulipchat.com or zulip.your-organization.com"
/>
</div>
<div class="server-center">
<button id="connect">${t.__("Connect")}</button>
</div>
<div class="server-center">
<div class="divider">
<hr class="left" />
${t.__("OR")}
<hr class="right" />
</div>
</div>
<div class="server-center">
<button id="open-create-org-link">
${t.__("Create a new organization")}
</button>
</div>
<div class="server-center">
<div class="server-network-option">
<span id="open-network-settings"
>${t.__("Network and Proxy Settings")}</span
>
<i class="material-icons open-network-button">open_in_new</i>
</div>
</div>
</div>
`);
const $saveServerButton: HTMLButtonElement =
$newServerForm.querySelector("#connect")!;
$root.textContent = "";
$root.append($newServerForm);
const $newServerUrl: HTMLInputElement = $newServerForm.querySelector(
"input.setting-input-value",
)!;
templateHTML(): string { async function submitFormHandler(): Promise<void> {
return htmlEscape` $saveServerButton.textContent = "Connecting...";
<div class="server-input-container"> let serverConf;
<div class="title">${t.__('Organization URL')}</div> try {
<div class="add-server-info-row"> serverConf = await DomainUtil.checkDomain($newServerUrl.value.trim());
<input class="setting-input-value" autofocus placeholder="your-organization.zulipchat.com or zulip.your-organization.com"/> } catch (error: unknown) {
</div> $saveServerButton.textContent = "Connect";
<div class="server-center"> await dialog.showMessageBox({
<button id="connect">${t.__('Connect')}</button> type: "error",
</div> message:
<div class="server-center"> error instanceof Error
<div class="divider"> ? `${error.name}: ${error.message}`
<hr class="left"/>${t.__('OR')}<hr class="right" /> : "Unknown error",
</div> buttons: ["OK"],
</div> });
<div class="server-center"> return;
<button id="open-create-org-link">${t.__('Create a new organization')}</button> }
</div>
<div class="server-center">
<div class="server-network-option">
<span id="open-network-settings">${t.__('Network and Proxy Settings')}</span>
<i class="material-icons open-network-button">open_in_new</i>
</div>
</div>
</div>
`;
}
init(): void { await DomainUtil.addDomain(serverConf);
this.initForm(); onChange();
this.initActions(); }
}
initForm(): void { $saveServerButton.addEventListener("click", async () => {
this.$newServerForm = this.generateNodeFromHTML(this.templateHTML()); await submitFormHandler();
this.$saveServerButton = this.$newServerForm.querySelector('#connect'); });
this.props.$root.textContent = ''; $newServerUrl.addEventListener("keypress", async (event) => {
this.props.$root.append(this.$newServerForm); if (event.key === "Enter") {
this.$newServerUrl = this.$newServerForm.querySelectorAll('input.setting-input-value')[0] as HTMLInputElement; await submitFormHandler();
} }
});
async submitFormHandler(): Promise<void> { // Open create new org link in default browser
this.$saveServerButton.textContent = 'Connecting...'; const link = "https://zulip.com/new/";
let serverConf; const externalCreateNewOrgElement = $root.querySelector(
try { "#open-create-org-link",
serverConf = await DomainUtil.checkDomain(this.$newServerUrl.value.trim()); )!;
} catch (error: unknown) { externalCreateNewOrgElement.addEventListener("click", async () => {
this.$saveServerButton.textContent = 'Connect'; await LinkUtil.openBrowser(new URL(link));
await dialog.showMessageBox({ });
type: 'error',
message: error instanceof Error ?
`${error.name}: ${error.message}` : 'Unknown error',
buttons: ['OK']
});
return;
}
await DomainUtil.addDomain(serverConf); const networkSettingsId = $root.querySelector(".server-network-option")!;
this.props.onChange(); networkSettingsId.addEventListener("click", () => {
} ipcRenderer.send("forward-message", "open-network-settings");
});
openCreateNewOrgExternalLink(): void {
const link = 'https://zulip.com/new/';
const externalCreateNewOrgElement = document.querySelector('#open-create-org-link');
externalCreateNewOrgElement.addEventListener('click', async () => {
await LinkUtil.openBrowser(new URL(link));
});
}
networkSettingsLink(): void {
const networkSettingsId = document.querySelectorAll('.server-network-option')[0];
networkSettingsId.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'open-network-settings');
});
}
initActions(): void {
this.$saveServerButton.addEventListener('click', async () => {
await this.submitFormHandler();
});
this.$newServerUrl.addEventListener('keypress', async event => {
if (event.key === 'Enter') {
await this.submitFormHandler();
}
});
// Open create new org link in default browser
this.openCreateNewOrgExternalLink();
this.networkSettingsLink();
}
} }

View File

@@ -1,131 +1,146 @@
import {ipcRenderer} from 'electron'; import process from "node:process";
import BaseComponent from '../../components/base'; import type {DndSettings} from "../../../../common/dnd-util";
import type {DNDSettings} from '../../utils/dnd-util'; import {html} from "../../../../common/html";
import type {NavItem} from "../../../../common/types";
import {ipcRenderer} from "../../typed-ipc-renderer";
import ConnectedOrgSection from './connected-org-section'; import {initConnectedOrgSection} from "./connected-org-section";
import GeneralSection from './general-section'; import {initGeneralSection} from "./general-section";
import Nav from './nav'; import Nav from "./nav";
import NetworkSection from './network-section'; import {initNetworkSection} from "./network-section";
import ServersSection from './servers-section'; import {initServersSection} from "./servers-section";
import ShortcutsSection from './shortcuts-section'; import {initShortcutsSection} from "./shortcuts-section";
type Section = ServersSection | GeneralSection | NetworkSection | ConnectedOrgSection | ShortcutsSection; export class PreferenceView {
readonly $view: HTMLElement;
private readonly $shadow: ShadowRoot;
private readonly $settingsContainer: Element;
private readonly nav: Nav;
private navItem: NavItem = "General";
export default class PreferenceView extends BaseComponent { constructor() {
$sidebarContainer: Element; this.$view = document.createElement("div");
$settingsContainer: Element; this.$shadow = this.$view.attachShadow({mode: "open"});
nav: Nav; this.$shadow.innerHTML = html`
section: Section; <link
constructor() { rel="stylesheet"
super(); href="${require.resolve("../../../css/fonts.css")}"
this.$sidebarContainer = document.querySelector('#sidebar'); />
this.$settingsContainer = document.querySelector('#settings-container'); <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;
init(): void { const $sidebarContainer = this.$shadow.querySelector("#sidebar")!;
this.nav = new Nav({ this.$settingsContainer = this.$shadow.querySelector(
$root: this.$sidebarContainer, "#settings-container",
onItemSelected: this.handleNavigation.bind(this) )!;
});
this.setDefaultView(); this.nav = new Nav({
this.registerIpcs(); $root: $sidebarContainer,
} onItemSelected: this.handleNavigation,
});
setDefaultView(): void { ipcRenderer.on("toggle-sidebar", this.handleToggleSidebar);
let nav = 'General'; ipcRenderer.on("toggle-autohide-menubar", this.handleToggleMenubar);
const hasTag = window.location.hash; ipcRenderer.on("toggle-dnd", this.handleToggleDnd);
if (hasTag) {
nav = hasTag.slice(1);
}
this.handleNavigation(nav); this.handleNavigation(this.navItem);
} }
handleNavigation(navItem: string): void { handleNavigation = (navItem: NavItem): void => {
this.nav.select(navItem); this.navItem = navItem;
switch (navItem) { this.nav.select(navItem);
case 'AddServer': { switch (navItem) {
this.section = new ServersSection({ case "AddServer":
$root: this.$settingsContainer initServersSection({
}); $root: this.$settingsContainer,
break; });
} break;
case 'General': { case "General":
this.section = new GeneralSection({ initGeneralSection({
$root: this.$settingsContainer $root: this.$settingsContainer,
}); });
break; break;
}
case 'Organizations': { case "Organizations":
this.section = new ConnectedOrgSection({ initConnectedOrgSection({
$root: this.$settingsContainer $root: this.$settingsContainer,
}); });
break; break;
}
case 'Network': { case "Network":
this.section = new NetworkSection({ initNetworkSection({
$root: this.$settingsContainer $root: this.$settingsContainer,
}); });
break; break;
}
case 'Shortcuts': { case "Shortcuts": {
this.section = new ShortcutsSection({ initShortcutsSection({
$root: this.$settingsContainer $root: this.$settingsContainer,
}); });
break; break;
} }
default: break; default:
} ((n: never) => n)(navItem);
}
this.section.init(); window.location.hash = `#${navItem}`;
window.location.hash = `#${navItem}`; };
}
// Handle toggling and reflect changes in preference page handleToggleTray(state: boolean) {
handleToggle(elementName: string, state: boolean): void { this.handleToggle("tray-option", state);
const inputSelector = `#${elementName} .action .switch input`; }
const input: HTMLInputElement = document.querySelector(inputSelector);
if (input) {
input.checked = state;
}
}
registerIpcs(): void { destroy(): void {
ipcRenderer.on('switch-settings-nav', (_event: Event, navItem: string) => { ipcRenderer.off("toggle-sidebar", this.handleToggleSidebar);
this.handleNavigation(navItem); ipcRenderer.off("toggle-autohide-menubar", this.handleToggleMenubar);
}); ipcRenderer.off("toggle-dnd", this.handleToggleDnd);
}
ipcRenderer.on('toggle-sidebar-setting', (_event: Event, state: boolean) => { // Handle toggling and reflect changes in preference page
this.handleToggle('sidebar-option', state); private handleToggle(elementName: string, state = false): void {
}); const inputSelector = `#${elementName} .action .switch input`;
const input: HTMLInputElement = this.$shadow.querySelector(inputSelector)!;
if (input) {
input.checked = state;
}
}
ipcRenderer.on('toggle-menubar-setting', (_event: Event, state: boolean) => { private readonly handleToggleSidebar = (_event: Event, state: boolean) => {
this.handleToggle('menubar-option', state); this.handleToggle("sidebar-option", state);
}); };
ipcRenderer.on('toggletray', (_event: Event, state: boolean) => { private readonly handleToggleMenubar = (_event: Event, state: boolean) => {
this.handleToggle('tray-option', state); this.handleToggle("menubar-option", state);
}); };
ipcRenderer.on('toggle-dnd', (_event: Event, _state: boolean, newSettings: DNDSettings) => { private readonly handleToggleDnd = (
this.handleToggle('show-notification-option', newSettings.showNotification); _event: Event,
this.handleToggle('silent-option', newSettings.silent); _state: boolean,
newSettings: Partial<DndSettings>,
) => {
this.handleToggle("show-notification-option", newSettings.showNotification);
this.handleToggle("silent-option", newSettings.silent);
if (process.platform === 'win32') { if (process.platform === "win32") {
this.handleToggle('flash-taskbar-option', newSettings.flashTaskbarOnMessage); this.handleToggle(
} "flash-taskbar-option",
}); newSettings.flashTaskbarOnMessage,
} );
}
};
} }
window.addEventListener('load', () => {
const preferenceView = new PreferenceView();
preferenceView.init();
});

View File

@@ -1,99 +1,80 @@
import {remote, ipcRenderer} from 'electron'; import {dialog} from "@electron/remote";
import {htmlEscape} from 'escape-goat'; import {html} from "../../../../common/html";
import * as Messages from "../../../../common/messages";
import * as Messages from '../../../../resources/messages'; import * as t from "../../../../common/translation-util";
import BaseComponent from '../../components/base'; import type {ServerConf} from "../../../../common/types";
import * as DomainUtil from '../../utils/domain-util'; import {generateNodeFromHtml} from "../../components/base";
import * as t from '../../utils/translation-util'; import {ipcRenderer} from "../../typed-ipc-renderer";
import * as DomainUtil from "../../utils/domain-util";
const {dialog} = remote;
interface ServerInfoFormProps { interface ServerInfoFormProps {
$root: Element; $root: Element;
server: DomainUtil.ServerConf; server: ServerConf;
index: number; index: number;
onChange: () => void; onChange: () => void;
} }
export default class ServerInfoForm extends BaseComponent { export function initServerInfoForm(props: ServerInfoFormProps): void {
props: ServerInfoFormProps; const $serverInfoForm = generateNodeFromHtml(html`
$serverInfoForm: Element; <div class="settings-card">
$serverInfoAlias: Element; <div class="server-info-left">
$serverIcon: Element; <img class="server-info-icon" src="${props.server.icon}" />
$deleteServerButton: Element; <div class="server-info-row">
$openServerButton: Element; <span class="server-info-alias">${props.server.alias}</span>
constructor(props: ServerInfoFormProps) { <i class="material-icons open-tab-button">open_in_new</i>
super(); </div>
this.props = props; </div>
} <div class="server-info-right">
<div class="server-info-row server-url">
<span class="server-url-info" title="${props.server.url}"
>${props.server.url}</span
>
</div>
<div class="server-info-row">
<div class="action red server-delete-action">
<span>${t.__("Disconnect")}</span>
</div>
</div>
</div>
</div>
`);
const $serverInfoAlias = $serverInfoForm.querySelector(".server-info-alias")!;
const $serverIcon = $serverInfoForm.querySelector(".server-info-icon")!;
const $deleteServerButton = $serverInfoForm.querySelector(
".server-delete-action",
)!;
const $openServerButton = $serverInfoForm.querySelector(".open-tab-button")!;
props.$root.append($serverInfoForm);
templateHTML(): string { $deleteServerButton.addEventListener("click", async () => {
return htmlEscape` const {response} = await dialog.showMessageBox({
<div class="settings-card"> type: "warning",
<div class="server-info-left"> buttons: [t.__("YES"), t.__("NO")],
<img class="server-info-icon" src="${this.props.server.icon}"/> defaultId: 0,
<div class="server-info-row"> message: t.__("Are you sure you want to disconnect this organization?"),
<span class="server-info-alias">${this.props.server.alias}</span> });
<i class="material-icons open-tab-button">open_in_new</i> if (response === 0) {
</div> if (DomainUtil.removeDomain(props.index)) {
</div> ipcRenderer.send("reload-full-app");
<div class="server-info-right"> } else {
<div class="server-info-row server-url"> const {title, content} = Messages.orgRemovalError(
<span class="server-url-info" title="${this.props.server.url}">${this.props.server.url}</span> DomainUtil.getDomain(props.index).url,
</div> );
<div class="server-info-row"> dialog.showErrorBox(title, content);
<div class="action red server-delete-action"> }
<span>${t.__('Disconnect')}</span> }
</div> });
</div>
</div>
</div>
`;
}
init(): void { $openServerButton.addEventListener("click", () => {
this.initForm(); ipcRenderer.send("forward-message", "switch-server-tab", props.index);
this.initActions(); });
}
initForm(): void { $serverInfoAlias.addEventListener("click", () => {
this.$serverInfoForm = this.generateNodeFromHTML(this.templateHTML()); ipcRenderer.send("forward-message", "switch-server-tab", props.index);
this.$serverInfoAlias = this.$serverInfoForm.querySelectorAll('.server-info-alias')[0]; });
this.$serverIcon = this.$serverInfoForm.querySelectorAll('.server-info-icon')[0];
this.$deleteServerButton = this.$serverInfoForm.querySelectorAll('.server-delete-action')[0];
this.$openServerButton = this.$serverInfoForm.querySelectorAll('.open-tab-button')[0];
this.props.$root.append(this.$serverInfoForm);
}
initActions(): void { $serverIcon.addEventListener("click", () => {
this.$deleteServerButton.addEventListener('click', async () => { ipcRenderer.send("forward-message", "switch-server-tab", props.index);
const {response} = await dialog.showMessageBox({ });
type: 'warning',
buttons: [t.__('YES'), t.__('NO')],
defaultId: 0,
message: t.__('Are you sure you want to disconnect this organization?')
});
if (response === 0) {
if (DomainUtil.removeDomain(this.props.index)) {
ipcRenderer.send('reload-full-app');
} else {
const {title, content} = Messages.orgRemovalError(DomainUtil.getDomain(this.props.index).url);
dialog.showErrorBox(title, content);
}
}
});
this.$openServerButton.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'switch-server-tab', this.props.index);
});
this.$serverInfoAlias.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'switch-server-tab', this.props.index);
});
this.$serverIcon.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'switch-server-tab', this.props.index);
});
}
} }

View File

@@ -1,52 +1,28 @@
import {htmlEscape} from 'escape-goat'; import {html} from "../../../../common/html";
import * as t from "../../../../common/translation-util";
import * as t from '../../utils/translation-util'; import {reloadApp} from "./base-section";
import {initNewServerForm} from "./new-server-form";
import BaseSection from './base-section';
import NewServerForm from './new-server-form';
interface ServersSectionProps { interface ServersSectionProps {
$root: Element; $root: Element;
} }
export default class ServersSection extends BaseSection { export function initServersSection({$root}: ServersSectionProps): void {
props: ServersSectionProps; $root.innerHTML = html`
$newServerContainer: Element; <div class="add-server-modal">
constructor(props: ServersSectionProps) { <div class="modal-container">
super(); <div class="settings-pane" id="server-settings-pane">
this.props = props; <div class="page-title">${t.__("Add a Zulip organization")}</div>
} <div id="new-server-container"></div>
</div>
</div>
</div>
`.html;
const $newServerContainer = $root.querySelector("#new-server-container")!;
templateHTML(): string { initNewServerForm({
return htmlEscape` $root: $newServerContainer,
<div class="add-server-modal"> onChange: reloadApp,
<div class="modal-container"> });
<div class="settings-pane" id="server-settings-pane">
<div class="page-title">${t.__('Add a Zulip organization')}</div>
<div id="new-server-container"></div>
</div>
</div>
</div>
`;
}
init(): void {
this.initServers();
}
initServers(): void {
this.props.$root.textContent = '';
this.props.$root.innerHTML = this.templateHTML();
this.$newServerContainer = document.querySelector('#new-server-container');
this.initNewServerForm();
}
initNewServerForm(): void {
new NewServerForm({
$root: this.$newServerContainer,
onChange: this.reloadApp
}).init();
}
} }

View File

@@ -1,240 +1,233 @@
import {htmlEscape} from 'escape-goat'; import process from "node:process";
import * as LinkUtil from '../../utils/link-util'; import {html} from "../../../../common/html";
import * as t from '../../utils/translation-util'; import * as LinkUtil from "../../../../common/link-util";
import * as t from "../../../../common/translation-util";
import BaseSection from './base-section';
interface ShortcutsSectionProps { interface ShortcutsSectionProps {
$root: Element; $root: Element;
} }
export default class ShortcutsSection extends BaseSection { // eslint-disable-next-line complexity
props: ShortcutsSectionProps; export function initShortcutsSection({$root}: ShortcutsSectionProps): void {
constructor(props: ShortcutsSectionProps) { const cmdOrCtrl = process.platform === "darwin" ? "⌘" : "Ctrl";
super();
this.props = props;
}
// eslint-disable-next-line complexity $root.innerHTML = html`
templateHTML(): string { <div class="settings-pane">
const cmdOrCtrl = process.platform === 'darwin' ? '⌘' : 'Ctrl'; <div class="settings-card tip">
<p>
<b><i class="material-icons md-14">settings</i>${t.__("Tip")}: </b
>${t.__("These desktop app shortcuts extend the Zulip webapp's")}
<span id="open-hotkeys-link"> ${t.__("keyboard shortcuts")}</span>.
</p>
</div>
<div class="title">${t.__("Application Shortcuts")}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>,</kbd></td>
<td>${t.__("Settings")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>K</kbd></td>
<td>${t.__("Keyboard Shortcuts")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>M</kbd></td>
<td>${t.__("Toggle Do Not Disturb")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>M</kbd></td>
<td>${t.__("Toggle Do Not Disturb")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>D</kbd></td>
<td>${t.__("Reset App Settings")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>L</kbd></td>
<td>${t.__("Log Out")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>H</kbd></td>
<td>${t.__("Hide Zulip")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Option</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>H</kbd></td>
<td>${t.__("Hide Others")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Q</kbd></td>
<td>${t.__("Quit Zulip")}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__("Edit Shortcuts")}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Z</kbd></td>
<td>${t.__("Undo")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>Z</kbd></td>
<td>${t.__("Redo")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Y</kbd></td>
<td>${t.__("Redo")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>X</kbd></td>
<td>${t.__("Cut")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>C</kbd></td>
<td>${t.__("Copy")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>V</kbd></td>
<td>${t.__("Paste")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>V</kbd></td>
<td>${t.__("Paste and Match Style")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>V</kbd></td>
<td>${t.__("Paste and Match Style")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>A</kbd></td>
<td>${t.__("Select All")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td>
<kbd>Control</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>Space</kbd>
</td>
<td>${t.__("Emoji & Symbols")}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__("View Shortcuts")}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>R</kbd></td>
<td>${t.__("Reload")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>R</kbd></td>
<td>${t.__("Hard Reload")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>R</kbd></td>
<td>${t.__("Hard Reload")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>F11</kbd></td>
<td>${t.__("Toggle Full Screen")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Control</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>F</kbd></td>
<td>${t.__("Enter Full Screen")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>+</kbd></td>
<td>${t.__("Zoom In")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>=</kbd></td>
<td>${t.__("Zoom In")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>-</kbd></td>
<td>${t.__("Zoom Out")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>0</kbd></td>
<td>${t.__("Actual Size")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>S</kbd></td>
<td>${t.__("Toggle Sidebar")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>S</kbd></td>
<td>${t.__("Toggle Sidebar")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Option</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>I</kbd></td>
<td>${t.__("Toggle DevTools for Zulip App")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd></td>
<td>${t.__("Toggle DevTools for Zulip App")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Option</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>U</kbd></td>
<td>${t.__("Toggle DevTools for Active Tab")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>U</kbd></td>
<td>${t.__("Toggle DevTools for Active Tab")}</td>
</tr>
<tr>
<td><kbd>Ctrl</kbd> + <kbd>Tab</kbd></td>
<td>${t.__("Switch to Next Organization")}</td>
</tr>
<tr>
<td><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>Tab</kbd></td>
<td>${t.__("Switch to Previous Organization")}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__("History Shortcuts")}</div>
<div class="settings-card">
<table>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>←</kbd></td>
<td>${t.__("Back")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>Alt</kbd> + <kbd>←</kbd></td>
<td>${t.__("Back")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>→</kbd></td>
<td>${t.__("Forward")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>Alt</kbd> + <kbd>→</kbd></td>
<td>${t.__("Forward")}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__("Window Shortcuts")}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>M</kbd></td>
<td>${t.__("Minimize")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>W</kbd></td>
<td>${t.__("Close")}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
</div>
`.html;
return htmlEscape` const link = "https://zulip.com/help/keyboard-shortcuts";
<div class="settings-pane"> const externalCreateNewOrgElement =
<div class="settings-card tip"><p><b><i class="material-icons md-14">settings</i>${t.__('Tip')}: </b>${t.__('These desktop app shortcuts extend the Zulip webapp\'s')} <span id="open-hotkeys-link"> ${t.__('keyboard shortcuts')}</span>.</p></div> $root.querySelector("#open-hotkeys-link")!;
<div class="title">${t.__('Application Shortcuts')}</div> externalCreateNewOrgElement.addEventListener("click", async () => {
<div class="settings-card"> await LinkUtil.openBrowser(new URL(link));
<table> });
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>,</kbd></td>
<td>${t.__('Settings')}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>K</kbd></td>
<td>${t.__('Keyboard Shortcuts')}</td>
</tr>
<tr ${process.platform === 'darwin' ? 'hidden' : ''}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>M</kbd></td>
<td>${t.__('Toggle Do Not Disturb')}</td>
</tr>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>M</kbd></td>
<td>${t.__('Toggle Do Not Disturb')}</td>
</tr>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>D</kbd></td>
<td>${t.__('Reset App Settings')}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>L</kbd></td>
<td>${t.__('Log Out')}</td>
</tr>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>H</kbd></td>
<td>${t.__('Hide Zulip')}</td>
</tr>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>Option</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>H</kbd></td>
<td>${t.__('Hide Others')}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Q</kbd></td>
<td>${t.__('Quit Zulip')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__('Edit Shortcuts')}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Z</kbd></td>
<td>${t.__('Undo')}</td>
</tr>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>Z</kbd></td>
<td>${t.__('Redo')}</td>
</tr>
<tr ${process.platform === 'darwin' ? 'hidden' : ''}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Y</kbd></td>
<td>${t.__('Redo')}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>X</kbd></td>
<td>${t.__('Cut')}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>C</kbd></td>
<td>${t.__('Copy')}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>V</kbd></td>
<td>${t.__('Paste')}</td>
</tr>
<tr ${process.platform === 'darwin' ? 'hidden' : ''}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>V</kbd></td>
<td>${t.__('Paste and Match Style')}</td>
</tr>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>V</kbd></td>
<td>${t.__('Paste and Match Style')}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>A</kbd></td>
<td>${t.__('Select All')}</td>
</tr>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>Control</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>Space</kbd></td>
<td>${t.__('Emoji & Symbols')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__('View Shortcuts')}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>R</kbd></td>
<td>${t.__('Reload')}</td>
</tr>
<tr ${process.platform === 'darwin' ? 'hidden' : ''}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>R</kbd></td>
<td>${t.__('Hard Reload')}</td>
</tr>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>R</kbd></td>
<td>${t.__('Hard Reload')}</td>
</tr>
<tr ${process.platform === 'darwin' ? 'hidden' : ''}>
<td><kbd>F11</kbd></td>
<td>${t.__('Toggle Full Screen')}</td>
</tr>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>Control</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>F</kbd></td>
<td>${t.__('Enter Full Screen')}</td>
</tr>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>+</kbd></td>
<td>${t.__('Zoom In')}</td>
</tr>
<tr ${process.platform === 'darwin' ? 'hidden' : ''}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>=</kbd></td>
<td>${t.__('Zoom In')}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>-</kbd></td>
<td>${t.__('Zoom Out')}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>0</kbd></td>
<td>${t.__('Actual Size')}</td>
</tr>
<tr ${process.platform === 'darwin' ? 'hidden' : ''}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>S</kbd></td>
<td>${t.__('Toggle Sidebar')}</td>
</tr>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>S</kbd></td>
<td>${t.__('Toggle Sidebar')}</td>
</tr>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>Option</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>I</kbd></td>
<td>${t.__('Toggle DevTools for Zulip App')}</td>
</tr>
<tr ${process.platform === 'darwin' ? 'hidden' : ''}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd></td>
<td>${t.__('Toggle DevTools for Zulip App')}</td>
</tr>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>Option</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>U</kbd></td>
<td>${t.__('Toggle DevTools for Active Tab')}</td>
</tr>
<tr ${process.platform === 'darwin' ? 'hidden' : ''}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>U</kbd></td>
<td>${t.__('Toggle DevTools for Active Tab')}</td>
</tr>
<tr>
<td><kbd>Ctrl</kbd> + <kbd>Tab</kbd></td>
<td>${t.__('Switch to Next Organization')}</td>
</tr>
<tr>
<td><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>Tab</kbd></td>
<td>${t.__('Switch to Previous Organization')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__('History Shortcuts')}</div>
<div class="settings-card">
<table>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>←</kbd></td>
<td>${t.__('Back')}</td>
</tr>
<tr ${process.platform === 'darwin' ? 'hidden' : ''}>
<td><kbd>Alt</kbd> + <kbd>←</kbd></td>
<td>${t.__('Back')}</td>
</tr>
<tr ${process.platform === 'darwin' ? '' : 'hidden'}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>→</kbd></td>
<td>${t.__('Forward')}</td>
</tr>
<tr ${process.platform === 'darwin' ? 'hidden' : ''}>
<td><kbd>Alt</kbd> + <kbd>→</kbd></td>
<td>${t.__('Forward')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__('Window Shortcuts')}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>M</kbd></td>
<td>${t.__('Minimize')}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>W</kbd></td>
<td>${t.__('Close')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
</div>
`;
}
openHotkeysExternalLink(): void {
const link = 'https://zulip.com/help/keyboard-shortcuts';
const externalCreateNewOrgElement = document.querySelector('#open-hotkeys-link');
externalCreateNewOrgElement.addEventListener('click', async () => {
await LinkUtil.openBrowser(new URL(link));
});
}
init(): void {
this.props.$root.innerHTML = this.templateHTML();
this.openHotkeysExternalLink();
}
} }

View File

@@ -1,71 +1,81 @@
import {contextBridge, ipcRenderer, webFrame} from 'electron'; import {contextBridge, webFrame} from "electron/renderer";
import fs from 'fs'; import fs from "node:fs";
import electron_bridge, {bridgeEvents} from './electron-bridge'; import electron_bridge, {bridgeEvents} from "./electron-bridge";
import * as NetworkError from './pages/network'; import * as NetworkError from "./pages/network";
import {ipcRenderer} from "./typed-ipc-renderer";
contextBridge.exposeInMainWorld('raw_electron_bridge', electron_bridge); contextBridge.exposeInMainWorld("raw_electron_bridge", electron_bridge);
ipcRenderer.on('logout', () => { ipcRenderer.on("logout", () => {
if (bridgeEvents.emit('logout')) { if (bridgeEvents.emit("logout")) {
return; return;
} }
// Create the menu for the below // Create the menu for the below
const dropdown: HTMLElement = document.querySelector('.dropdown-toggle'); const dropdown: HTMLElement = document.querySelector(".dropdown-toggle")!;
dropdown.click(); dropdown.click();
const nodes: NodeListOf<HTMLElement> = document.querySelectorAll('.dropdown-menu li:last-child a'); const nodes: NodeListOf<HTMLElement> = document.querySelectorAll(
nodes[nodes.length - 1].click(); ".dropdown-menu li:last-child a",
);
nodes[nodes.length - 1].click();
}); });
ipcRenderer.on('show-keyboard-shortcuts', () => { ipcRenderer.on("show-keyboard-shortcuts", () => {
if (bridgeEvents.emit('show-keyboard-shortcuts')) { if (bridgeEvents.emit("show-keyboard-shortcuts")) {
return; return;
} }
// Create the menu for the below // Create the menu for the below
const node: HTMLElement = document.querySelector('a[data-overlay-trigger=keyboard-shortcuts]'); const node: HTMLElement = document.querySelector(
// Additional check "a[data-overlay-trigger=keyboard-shortcuts]",
if (node.textContent.trim().toLowerCase() === 'keyboard shortcuts (?)') { )!;
node.click(); // Additional check
} else { if (node.textContent!.trim().toLowerCase() === "keyboard shortcuts (?)") {
// Atleast click the dropdown node.click();
const dropdown: HTMLElement = document.querySelector('.dropdown-toggle'); } else {
dropdown.click(); // Atleast click the dropdown
} const dropdown: HTMLElement = document.querySelector(".dropdown-toggle")!;
dropdown.click();
}
}); });
ipcRenderer.on('show-notification-settings', () => { ipcRenderer.on("show-notification-settings", () => {
if (bridgeEvents.emit('show-notification-settings')) { if (bridgeEvents.emit("show-notification-settings")) {
return; return;
} }
// Create the menu for the below // Create the menu for the below
const dropdown: HTMLElement = document.querySelector('.dropdown-toggle'); const dropdown: HTMLElement = document.querySelector(".dropdown-toggle")!;
dropdown.click(); dropdown.click();
const nodes: NodeListOf<HTMLElement> = document.querySelectorAll('.dropdown-menu li a'); const nodes: NodeListOf<HTMLElement> = document.querySelectorAll(
nodes[2].click(); ".dropdown-menu li a",
);
nodes[2].click();
const notificationItem: NodeListOf<HTMLElement> = document.querySelectorAll('.normal-settings-list li div'); const notificationItem: NodeListOf<HTMLElement> = document.querySelectorAll(
".normal-settings-list li div",
);
// Wait until the notification dom element shows up // Wait until the notification dom element shows up
setTimeout(() => { setTimeout(() => {
notificationItem[2].click(); notificationItem[2].click();
}, 100); }, 100);
}); });
window.addEventListener('load', (event: any): void => { window.addEventListener("load", () => {
if (!event.target.URL.includes('app/renderer/network.html')) { if (!location.href.includes("app/renderer/network.html")) {
return; return;
} }
const $reconnectButton = document.querySelector('#reconnect'); const $reconnectButton = document.querySelector("#reconnect")!;
const $settingsButton = document.querySelector('#settings'); const $settingsButton = document.querySelector("#settings")!;
NetworkError.init($reconnectButton, $settingsButton); NetworkError.init($reconnectButton, $settingsButton);
}); });
(async () => webFrame.executeJavaScript( (async () =>
fs.readFileSync(require.resolve('./injected'), 'utf8') webFrame.executeJavaScript(
))(); fs.readFileSync(require.resolve("./injected"), "utf8"),
))();

View File

@@ -1,96 +1,108 @@
import {ipcRenderer, remote, WebviewTag, NativeImage} from 'electron'; import type {NativeImage} from "electron/common";
import path from 'path'; import {nativeImage} from "electron/common";
import type {Tray as ElectronTray} from "electron/main";
import path from "node:path";
import process from "node:process";
import * as ConfigUtil from './utils/config-util'; import {BrowserWindow, Menu, Tray} from "@electron/remote";
const {Tray, Menu, nativeImage, BrowserWindow} = remote; import * as ConfigUtil from "../../common/config-util";
import type {RendererMessage} from "../../common/typed-ipc";
let tray: Electron.Tray; import type {ServerManagerView} from "./main";
import {ipcRenderer} from "./typed-ipc-renderer";
const ICON_DIR = '../../resources/tray'; let tray: ElectronTray | null = null;
const TRAY_SUFFIX = 'tray'; const iconDir = "../../resources/tray";
const APP_ICON = path.join(__dirname, ICON_DIR, TRAY_SUFFIX); const traySuffix = "tray";
const appIcon = path.join(__dirname, iconDir, traySuffix);
const iconPath = (): string => { const iconPath = (): string => {
if (process.platform === 'linux') { if (process.platform === "linux") {
return APP_ICON + 'linux.png'; return appIcon + "linux.png";
} }
return APP_ICON + (process.platform === 'win32' ? 'win.ico' : 'macOSTemplate.png'); return (
appIcon + (process.platform === "win32" ? "win.ico" : "macOSTemplate.png")
);
}; };
const winUnreadTrayIconPath = (): string => APP_ICON + 'unread.ico'; const winUnreadTrayIconPath = (): string => appIcon + "unread.ico";
let unread = 0; let unread = 0;
const trayIconSize = (): number => { const trayIconSize = (): number => {
switch (process.platform) { switch (process.platform) {
case 'darwin': case "darwin":
return 20; return 20;
case 'win32': case "win32":
return 100; return 100;
case 'linux': case "linux":
return 100; return 100;
default: return 80; default:
} return 80;
}
}; };
// Default config for Icon we might make it OS specific if needed like the size // Default config for Icon we might make it OS specific if needed like the size
const config = { const config = {
pixelRatio: window.devicePixelRatio, pixelRatio: window.devicePixelRatio,
unreadCount: 0, unreadCount: 0,
showUnreadCount: true, showUnreadCount: true,
unreadColor: '#000000', unreadColor: "#000000",
readColor: '#000000', readColor: "#000000",
unreadBackgroundColor: '#B9FEEA', unreadBackgroundColor: "#B9FEEA",
readBackgroundColor: '#B9FEEA', readBackgroundColor: "#B9FEEA",
size: trayIconSize(), size: trayIconSize(),
thick: process.platform === 'win32' thick: process.platform === "win32",
}; };
const renderCanvas = function (arg: number): HTMLCanvasElement { const renderCanvas = function (arg: number): HTMLCanvasElement {
config.unreadCount = arg; config.unreadCount = arg;
const SIZE = config.size * config.pixelRatio; const size = config.size * config.pixelRatio;
const PADDING = SIZE * 0.05; const padding = size * 0.05;
const CENTER = SIZE / 2; const center = size / 2;
const HAS_COUNT = config.showUnreadCount && config.unreadCount; const hasCount = config.showUnreadCount && config.unreadCount;
const color = config.unreadCount ? config.unreadColor : config.readColor; const color = config.unreadCount ? config.unreadColor : config.readColor;
const backgroundColor = config.unreadCount ? config.unreadBackgroundColor : config.readBackgroundColor; const backgroundColor = config.unreadCount
? config.unreadBackgroundColor
: config.readBackgroundColor;
const canvas = document.createElement('canvas'); const canvas = document.createElement("canvas");
canvas.width = SIZE; canvas.width = size;
canvas.height = SIZE; canvas.height = size;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext("2d")!;
// Circle // Circle
// If (!config.thick || config.thick && HAS_COUNT) { // If (!config.thick || config.thick && hasCount) {
ctx.beginPath(); 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.fillStyle = backgroundColor;
ctx.fill(); ctx.fill();
ctx.lineWidth = SIZE / (config.thick ? 10 : 20); ctx.lineWidth = size / (config.thick ? 10 : 20);
ctx.strokeStyle = backgroundColor; ctx.strokeStyle = backgroundColor;
ctx.stroke(); ctx.stroke();
// Count or Icon // Count or Icon
if (HAS_COUNT) { if (hasCount) {
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.textAlign = 'center'; ctx.textAlign = "center";
if (config.unreadCount > 99) { if (config.unreadCount > 99) {
ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.4}px Helvetica`; ctx.font = `${config.thick ? "bold " : ""}${size * 0.4}px Helvetica`;
ctx.fillText('99+', CENTER, CENTER + (SIZE * 0.15)); ctx.fillText("99+", center, center + size * 0.15);
} else if (config.unreadCount < 10) { } else if (config.unreadCount < 10) {
ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`; ctx.font = `${config.thick ? "bold " : ""}${size * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.2)); ctx.fillText(String(config.unreadCount), center, center + size * 0.2);
} else { } else {
ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`; ctx.font = `${config.thick ? "bold " : ""}${size * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.15)); ctx.fillText(String(config.unreadCount), center, center + size * 0.15);
} }
} }
return canvas; return canvas;
}; };
/** /**
@@ -99,128 +111,130 @@ const renderCanvas = function (arg: number): HTMLCanvasElement {
* @return the native image * @return the native image
*/ */
const renderNativeImage = function (arg: number): NativeImage { const renderNativeImage = function (arg: number): NativeImage {
if (process.platform === 'win32') { if (process.platform === "win32") {
return nativeImage.createFromPath(winUnreadTrayIconPath()); return nativeImage.createFromPath(winUnreadTrayIconPath());
} }
const canvas = renderCanvas(arg); const canvas = renderCanvas(arg);
const pngData = nativeImage.createFromDataURL(canvas.toDataURL('image/png')).toPNG(); const pngData = nativeImage
return nativeImage.createFromBuffer(pngData, { .createFromDataURL(canvas.toDataURL("image/png"))
scaleFactor: config.pixelRatio .toPNG();
}); return nativeImage.createFromBuffer(pngData, {
scaleFactor: config.pixelRatio,
});
}; };
function sendAction(action: string): void { function sendAction<Channel extends keyof RendererMessage>(
const win = BrowserWindow.getAllWindows()[0]; channel: Channel,
...args: Parameters<RendererMessage[Channel]>
): void {
const win = BrowserWindow.getAllWindows()[0];
if (process.platform === 'darwin') { if (process.platform === "darwin") {
win.restore(); win.restore();
} }
win.webContents.send(action); ipcRenderer.sendTo(win.webContents.id, channel, ...args);
} }
const createTray = function (): void { const createTray = function (): void {
const contextMenu = Menu.buildFromTemplate([ const contextMenu = Menu.buildFromTemplate([
{ {
label: 'Zulip', label: "Zulip",
click() { click() {
ipcRenderer.send('focus-app'); ipcRenderer.send("focus-app");
} },
}, },
{ {
label: 'Settings', label: "Settings",
click() { click() {
ipcRenderer.send('focus-app'); ipcRenderer.send("focus-app");
sendAction('open-settings'); sendAction("open-settings");
} },
}, },
{ {
type: 'separator' type: "separator",
}, },
{ {
label: 'Quit', label: "Quit",
click() { click() {
ipcRenderer.send('quit-app'); ipcRenderer.send("quit-app");
} },
} },
]); ]);
tray = new Tray(iconPath()); tray = new Tray(iconPath());
tray.setContextMenu(contextMenu); tray.setContextMenu(contextMenu);
if (process.platform === 'linux' || process.platform === 'win32') { if (process.platform === "linux" || process.platform === "win32") {
tray.on('click', () => { tray.on("click", () => {
ipcRenderer.send('toggle-app'); ipcRenderer.send("toggle-app");
}); });
} }
}; };
ipcRenderer.on('destroytray', (event: Event): Event => { export function initializeTray(serverManagerView: ServerManagerView) {
if (!tray) { ipcRenderer.on("destroytray", (_event: Event) => {
return undefined; if (!tray) {
} return;
}
tray.destroy(); tray.destroy();
if (tray.isDestroyed()) { if (tray.isDestroyed()) {
tray = null; tray = null;
} else { } else {
throw new Error('Tray icon not properly destroyed.'); throw new Error("Tray icon not properly destroyed.");
} }
});
return event; ipcRenderer.on("tray", (_event: Event, arg: number): void => {
}); if (!tray) {
return;
}
ipcRenderer.on('tray', (_event: Event, arg: number): void => { // We don't want to create tray from unread messages on macOS since it already has dock badges.
if (!tray) { if (process.platform === "linux" || process.platform === "win32") {
return; 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`);
}
}
});
// We don't want to create tray from unread messages on macOS since it already has dock badges. function toggleTray(): void {
if (process.platform === 'linux' || process.platform === 'win32') { let state;
if (arg === 0) { if (tray) {
unread = arg; state = false;
tray.setImage(iconPath()); tray.destroy();
tray.setToolTip('No unread messages'); if (tray.isDestroyed()) {
} else { tray = null;
unread = arg; }
const image = renderNativeImage(arg);
tray.setImage(image);
tray.setToolTip(`${arg} unread messages`);
}
}
});
function toggleTray(): void { ConfigUtil.setConfigItem("trayIcon", false);
let state; } else {
if (tray) { state = true;
state = false; createTray();
tray.destroy(); if (process.platform === "linux" || process.platform === "win32") {
if (tray.isDestroyed()) { const image = renderNativeImage(unread);
tray = null; tray!.setImage(image);
} tray!.setToolTip(`${unread} unread messages`);
}
ConfigUtil.setConfigItem('trayIcon', false); ConfigUtil.setConfigItem("trayIcon", true);
} 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); serverManagerView.preferenceView?.handleToggleTray(state);
} }
const selector = 'webview:not([class*=disabled])'; ipcRenderer.on("toggletray", toggleTray);
const webview: WebviewTag = document.querySelector(selector);
const webContents = remote.webContents.fromId(webview.getWebContentsId());
webContents.send('toggletray', state);
}
ipcRenderer.on('toggletray', toggleTray); if (ConfigUtil.getConfigItem("trayIcon", true)) {
createTray();
if (ConfigUtil.getConfigItem('trayIcon', true)) { }
createTray();
} }
export {}; export {};

View File

@@ -0,0 +1,68 @@
import type {IpcRendererEvent} from "electron/renderer";
import {
ipcRenderer as untypedIpcRenderer, // eslint-disable-line no-restricted-imports
} from "electron/renderer";
import type {
MainCall,
MainMessage,
RendererMessage,
} from "../../common/typed-ipc";
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>(
channel: Channel,
listener: RendererListener<Channel>,
): void;
once<Channel extends keyof RendererMessage>(
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>,
): void;
removeAllListeners(channel: keyof RendererMessage): void;
send<Channel extends keyof RendererMessage>(
channel: "forward-message",
rendererChannel: Channel,
...args: Parameters<RendererMessage[Channel]>
): void;
send<Channel extends keyof MainMessage>(
channel: Channel,
...args: Parameters<MainMessage[Channel]>
): void;
invoke<Channel extends keyof MainCall>(
channel: Channel,
...args: Parameters<MainCall[Channel]>
): Promise<ReturnType<MainCall[Channel]>>;
sendSync<Channel extends keyof MainMessage>(
channel: Channel,
...args: Parameters<MainMessage[Channel]>
): ReturnType<MainMessage[Channel]>;
postMessage<Channel extends keyof MainMessage>(
channel: Channel,
message: Parameters<MainMessage[Channel]> extends [infer Message]
? Message
: never,
transfer?: MessagePort[],
): void;
sendTo<Channel extends keyof RendererMessage>(
webContentsId: number,
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
): void;
sendToHost<Channel extends keyof RendererMessage>(
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
): void;
} = untypedIpcRenderer;

View File

@@ -1,106 +0,0 @@
import electron from 'electron';
import fs from 'fs';
import path from 'path';
import {JsonDB} from 'node-json-db';
import * as EnterpriseUtil from './enterprise-util';
import Logger from './logger-util';
const logger = new Logger({
file: 'config-util.log',
timestamp: true
});
let dialog: Electron.Dialog = null;
let app: Electron.App = null;
/* To make the util runnable in both main and renderer process */
if (process.type === 'renderer') {
const {remote} = electron;
dialog = remote.dialog;
app = remote.app;
} else {
dialog = electron.dialog;
app = electron.app;
}
let db: JsonDB;
reloadDB();
export function getConfigItem(key: string, defaultValue: unknown = null): any {
try {
db.reload();
} catch (error: unknown) {
logger.error('Error while reloading settings.json: ');
logger.error(error);
}
const value = db.getData('/')[key];
if (value === undefined) {
setConfigItem(key, defaultValue);
return defaultValue;
}
return value;
}
export function getConfigString(key: string, defaultValue: string): string {
const value = getConfigItem(key, defaultValue);
if (typeof value === 'string') {
return value;
}
setConfigItem(key, defaultValue);
return defaultValue;
}
// This function returns whether a key exists in the configuration file (settings.json)
export function isConfigItemExists(key: string): boolean {
try {
db.reload();
} catch (error: unknown) {
logger.error('Error while reloading settings.json: ');
logger.error(error);
}
const value = db.getData('/')[key];
return (value !== undefined);
}
export function setConfigItem(key: string, value: unknown, override? : boolean): void {
if (EnterpriseUtil.configItemExists(key) && !override) {
// If item is in global config and we're not trying to override
return;
}
db.push(`/${key}`, value, true);
db.save();
}
export function removeConfigItem(key: string): void {
db.delete(`/${key}`);
db.save();
}
function reloadDB(): void {
const settingsJsonPath = path.join(app.getPath('userData'), '/config/settings.json');
try {
const file = fs.readFileSync(settingsJsonPath, 'utf8');
JSON.parse(file);
} catch (error: unknown) {
if (fs.existsSync(settingsJsonPath)) {
fs.unlinkSync(settingsJsonPath);
dialog.showErrorBox(
'Error saving settings',
'We encountered an error while saving the settings.'
);
logger.error('Error while JSON parsing settings.json: ');
logger.error(error);
logger.reportSentry(error);
}
}
db = new JsonDB(settingsJsonPath, true, true);
}

View File

@@ -1,60 +0,0 @@
import electron from 'electron';
import fs from 'fs';
const app = process.type === 'renderer' ? electron.remote.app : electron.app;
let setupCompleted = false;
const zulipDir = app.getPath('userData');
const logDir = `${zulipDir}/Logs/`;
const configDir = `${zulipDir}/config/`;
export const initSetUp = (): void => {
// If it is the first time the app is running
// create zulip dir in userData folder to
// avoid errors
if (!setupCompleted) {
if (!fs.existsSync(zulipDir)) {
fs.mkdirSync(zulipDir);
}
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir);
}
// Migrate config files from app data folder to config folder inside app
// data folder. This will be done once when a user updates to the new version.
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir);
const domainJson = `${zulipDir}/domain.json`;
const settingsJson = `${zulipDir}/settings.json`;
const updatesJson = `${zulipDir}/updates.json`;
const windowStateJson = `${zulipDir}/window-state.json`;
const configData = [
{
path: domainJson,
fileName: 'domain.json'
},
{
path: settingsJson,
fileName: 'settings.json'
},
{
path: updatesJson,
fileName: 'updates.json'
}
];
configData.forEach(data => {
if (fs.existsSync(data.path)) {
fs.copyFileSync(data.path, configDir + data.fileName);
fs.unlinkSync(data.path);
}
});
// `window-state.json` is only deleted not moved, as the electron-window-state
// package will recreate the file in the config folder.
if (fs.existsSync(windowStateJson)) {
fs.unlinkSync(windowStateJson);
}
}
setupCompleted = true;
}
};

View File

@@ -1,48 +0,0 @@
import * as ConfigUtil from './config-util';
type SettingName = 'showNotification' | 'silent' | 'flashTaskbarOnMessage';
export interface DNDSettings {
showNotification?: boolean;
silent?: boolean;
flashTaskbarOnMessage?: boolean;
}
interface Toggle {
dnd: boolean;
newSettings: DNDSettings;
}
export function toggle(): Toggle {
const dnd = !ConfigUtil.getConfigItem('dnd', false);
const dndSettingList: SettingName[] = ['showNotification', 'silent'];
if (process.platform === 'win32') {
dndSettingList.push('flashTaskbarOnMessage');
}
let newSettings: DNDSettings;
if (dnd) {
const oldSettings: DNDSettings = {};
newSettings = {};
// Iterate through the dndSettingList.
for (const settingName of dndSettingList) {
// Store the current value of setting.
oldSettings[settingName] = ConfigUtil.getConfigItem(settingName);
// New value of setting.
newSettings[settingName] = (settingName === 'silent');
}
// Store old value in oldSettings.
ConfigUtil.setConfigItem('dndPreviousSettings', oldSettings);
} else {
newSettings = ConfigUtil.getConfigItem('dndPreviousSettings');
}
for (const settingName of dndSettingList) {
ConfigUtil.setConfigItem(settingName, newSettings[settingName]);
}
ConfigUtil.setConfigItem('dnd', dnd);
return {dnd, newSettings};
}

View File

@@ -1,167 +1,191 @@
import {remote, ipcRenderer} from 'electron'; import fs from "node:fs";
import fs from 'fs'; import path from "node:path";
import path from 'path';
import {JsonDB} from 'node-json-db'; 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 Messages from '../../../resources/messages'; import * as EnterpriseUtil from "../../../common/enterprise-util";
import Logger from "../../../common/logger-util";
import * as EnterpriseUtil from './enterprise-util'; import * as Messages from "../../../common/messages";
import Logger from './logger-util'; import type {ServerConf} from "../../../common/types";
import {ipcRenderer} from "../typed-ipc-renderer";
const {app, dialog} = remote;
export interface ServerConf {
url: string;
alias?: string;
icon?: string;
}
const logger = new Logger({ const logger = new Logger({
file: 'domain-util.log', file: "domain-util.log",
timestamp: true
}); });
const defaultIconUrl = '../renderer/img/icon.png'; const defaultIconUrl = "../renderer/img/icon.png";
let db: JsonDB; const serverConfSchema = z.object({
url: z.string(),
alias: z.string(),
icon: z.string(),
});
let db!: JsonDB;
reloadDb();
reloadDB();
// Migrate from old schema // Migrate from old schema
if (db.getData('/').domain) { try {
(async () => { const oldDomain = db.getObject<unknown>("/domain");
await addDomain({ if (typeof oldDomain === "string") {
alias: 'Zulip', (async () => {
url: db.getData('/domain') await addDomain({
}); alias: "Zulip",
db.delete('/domain'); url: oldDomain,
})(); });
db.delete("/domain");
})();
}
} catch (error: unknown) {
if (!(error instanceof DataError)) throw error;
} }
export function getDomains(): ServerConf[] { export function getDomains(): ServerConf[] {
reloadDB(); reloadDb();
if (db.getData('/').domains === undefined) { try {
return []; return serverConfSchema.array().parse(db.getObject<unknown>("/domains"));
} } catch (error: unknown) {
if (!(error instanceof DataError)) throw error;
return db.getData('/domains'); return [];
}
} }
export function getDomain(index: number): ServerConf { export function getDomain(index: number): ServerConf {
reloadDB(); reloadDb();
return db.getData(`/domains[${index}]`); return serverConfSchema.parse(db.getObject<unknown>(`/domains[${index}]`));
} }
export function updateDomain(index: number, server: ServerConf): void { export function updateDomain(index: number, server: ServerConf): void {
reloadDB(); reloadDb();
db.push(`/domains[${index}]`, server, true); serverConfSchema.parse(server);
db.push(`/domains[${index}]`, server, true);
} }
export async function addDomain(server: ServerConf): Promise<void> { export async function addDomain(server: {
if (server.icon) { url: string;
const localIconUrl = await saveServerIcon(server); alias: string;
server.icon = localIconUrl; icon?: string;
db.push('/domains[]', server, true); }): Promise<void> {
reloadDB(); if (server.icon) {
} else { const localIconUrl = await saveServerIcon(server.icon);
server.icon = defaultIconUrl; server.icon = localIconUrl;
db.push('/domains[]', server, true); serverConfSchema.parse(server);
reloadDB(); db.push("/domains[]", server, true);
} reloadDb();
} else {
server.icon = defaultIconUrl;
serverConfSchema.parse(server);
db.push("/domains[]", server, true);
reloadDb();
}
} }
export function removeDomains(): void { export function removeDomains(): void {
db.delete('/domains'); db.delete("/domains");
reloadDB(); reloadDb();
} }
export function removeDomain(index: number): boolean { export function removeDomain(index: number): boolean {
if (EnterpriseUtil.isPresetOrg(getDomain(index).url)) { if (EnterpriseUtil.isPresetOrg(getDomain(index).url)) {
return false; return false;
} }
db.delete(`/domains[${index}]`); db.delete(`/domains[${index}]`);
reloadDB(); reloadDb();
return true; return true;
} }
// Check if domain is already added // Check if domain is already added
export function duplicateDomain(domain: string): boolean { export function duplicateDomain(domain: string): boolean {
domain = formatUrl(domain); domain = formatUrl(domain);
return getDomains().some(server => server.url === domain); return getDomains().some((server) => server.url === domain);
} }
export async function checkDomain(domain: string, silent = false): Promise<ServerConf> { export async function checkDomain(
if (!silent && duplicateDomain(domain)) { domain: string,
// Do not check duplicate in silent mode silent = false,
throw new Error('This server has been added.'); ): Promise<ServerConf> {
} if (!silent && duplicateDomain(domain)) {
// Do not check duplicate in silent mode
throw new Error("This server has been added.");
}
domain = formatUrl(domain); domain = formatUrl(domain);
try { try {
return await getServerSettings(domain); return await getServerSettings(domain);
} catch { } catch {
throw new Error(Messages.invalidZulipServerError(domain)); throw new Error(Messages.invalidZulipServerError(domain));
} }
} }
async function getServerSettings(domain: string): Promise<ServerConf> { async function getServerSettings(domain: string): Promise<ServerConf> {
return ipcRenderer.invoke('get-server-settings', domain); return ipcRenderer.invoke("get-server-settings", domain);
} }
export async function saveServerIcon(server: ServerConf): Promise<string> { export async function saveServerIcon(iconURL: string): Promise<string> {
return ipcRenderer.invoke('save-server-icon', server.icon); return ipcRenderer.invoke("save-server-icon", iconURL);
} }
export async function updateSavedServer(url: string, index: number): Promise<void> { export async function updateSavedServer(
// Does not promise successful update url: string,
const oldIcon = getDomain(index).icon; index: number,
try { ): Promise<void> {
const newServerConf = await checkDomain(url, true); // Does not promise successful update
const localIconUrl = await saveServerIcon(newServerConf); const oldIcon = getDomain(index).icon;
if (!oldIcon || localIconUrl !== '../renderer/img/icon.png') { try {
newServerConf.icon = localIconUrl; const newServerConf = await checkDomain(url, true);
updateDomain(index, newServerConf); const localIconUrl = await saveServerIcon(newServerConf.icon);
reloadDB(); if (!oldIcon || localIconUrl !== "../renderer/img/icon.png") {
} newServerConf.icon = localIconUrl;
} catch (error: unknown) { updateDomain(index, newServerConf);
logger.log('Could not update server icon.'); reloadDb();
logger.log(error); }
logger.reportSentry(error); } catch (error: unknown) {
} logger.log("Could not update server icon.");
logger.log(error);
Sentry.captureException(error);
}
} }
function reloadDB(): void { function reloadDb(): void {
const domainJsonPath = path.join(app.getPath('userData'), 'config/domain.json'); const domainJsonPath = path.join(
try { app.getPath("userData"),
const file = fs.readFileSync(domainJsonPath, 'utf8'); "config/domain.json",
JSON.parse(file); );
} catch (error: unknown) { try {
if (fs.existsSync(domainJsonPath)) { const file = fs.readFileSync(domainJsonPath, "utf8");
fs.unlinkSync(domainJsonPath); JSON.parse(file);
dialog.showErrorBox( } catch (error: unknown) {
'Error saving new organization', if (fs.existsSync(domainJsonPath)) {
'There seems to be error while saving new organization, ' + fs.unlinkSync(domainJsonPath);
'you may have to re-add your previous organizations back.' dialog.showErrorBox(
); "Error saving new organization",
logger.error('Error while JSON parsing domain.json: '); "There seems to be error while saving new organization, " +
logger.error(error); "you may have to re-add your previous organizations back.",
logger.reportSentry(error); );
} logger.error("Error while JSON parsing domain.json: ");
} logger.error(error);
Sentry.captureException(error);
}
}
db = new JsonDB(domainJsonPath, true, true); db = new JsonDB(domainJsonPath, true, true);
} }
export function formatUrl(domain: string): string { export function formatUrl(domain: string): string {
if (domain.startsWith('http://') || domain.startsWith('https://')) { if (domain.startsWith("http://") || domain.startsWith("https://")) {
return domain; return domain;
} }
if (domain.startsWith('localhost:')) { if (domain.startsWith("localhost:")) {
return `http://${domain}`; return `http://${domain}`;
} }
return `https://${domain}`; return `https://${domain}`;
} }

View File

@@ -1,81 +0,0 @@
import fs from 'fs';
import path from 'path';
import Logger from './logger-util';
const logger = new Logger({
file: 'enterprise-util.log',
timestamp: true
});
// TODO: replace enterpriseSettings type with an interface once settings are final
let enterpriseSettings: Record<string, unknown>;
let configFile: boolean;
reloadDB();
function reloadDB(): void {
let enterpriseFile = '/etc/zulip-desktop-config/global_config.json';
if (process.platform === 'win32') {
enterpriseFile = 'C:\\Program Files\\Zulip-Desktop-Config\\global_config.json';
}
enterpriseFile = path.resolve(enterpriseFile);
if (fs.existsSync(enterpriseFile)) {
configFile = true;
try {
const file = fs.readFileSync(enterpriseFile, 'utf8');
enterpriseSettings = JSON.parse(file);
} catch (error: unknown) {
logger.log('Error while JSON parsing global_config.json: ');
logger.log(error);
}
} else {
configFile = false;
}
}
export function hasConfigFile(): boolean {
return configFile;
}
export function getConfigItem(key: string, defaultValue?: unknown): any {
reloadDB();
if (!configFile) {
return defaultValue;
}
if (defaultValue === undefined) {
defaultValue = null;
}
return configItemExists(key) ? enterpriseSettings[key] : defaultValue;
}
export function configItemExists(key: string): boolean {
reloadDB();
if (!configFile) {
return false;
}
return (enterpriseSettings[key] !== undefined);
}
export function isPresetOrg(url: string): boolean {
if (!configFile || !configItemExists('presetOrganizations')) {
return false;
}
const presetOrgs = enterpriseSettings.presetOrganizations;
if (!Array.isArray(presetOrgs)) {
throw new TypeError('Expected array for presetOrgs');
}
for (const org of presetOrgs) {
if (url.includes(org)) {
return true;
}
}
return false;
}

View File

@@ -1,46 +0,0 @@
import {shell} from 'electron';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {htmlEscape} from 'escape-goat';
export function isUploadsUrl(server: string, url: URL): boolean {
return url.origin === server && url.pathname.startsWith('/user_uploads/');
}
export async function openBrowser(url: URL): Promise<void> {
if (['http:', 'https:', 'mailto:'].includes(url.protocol)) {
await shell.openExternal(url.href);
} else {
// For security, indirect links to non-whitelisted protocols
// through a real web browser via a local HTML file.
const dir = fs.mkdtempSync(
path.join(os.tmpdir(), 'zulip-redirect-')
);
const file = path.join(dir, 'redirect.html');
fs.writeFileSync(file, htmlEscape`\
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="Refresh" content="0; url=${url.href}" />
<title>Redirecting</title>
<style>
html {
font-family: menu, "Helvetica Neue", sans-serif;
}
</style>
</head>
<body>
<p>Opening <a href="${url.href}">${url.href}</a>…</p>
</body>
</html>
`);
await shell.openPath(file);
setTimeout(() => {
fs.unlinkSync(file);
fs.rmdirSync(dir);
}, 15000);
}
}

View File

@@ -1,63 +0,0 @@
import electron from 'electron';
import fs from 'fs';
import path from 'path';
import {JsonDB} from 'node-json-db';
import Logger from './logger-util';
const remote =
process.type === 'renderer' ? electron.remote : electron;
const logger = new Logger({
file: 'linux-update-util.log',
timestamp: true
});
/* To make the util runnable in both main and renderer process */
const {dialog, app} = remote;
let db: JsonDB;
reloadDB();
export function getUpdateItem(key: string, defaultValue: unknown = null): any {
reloadDB();
const value = db.getData('/')[key];
if (value === undefined) {
setUpdateItem(key, defaultValue);
return defaultValue;
}
return value;
}
export function setUpdateItem(key: string, value: unknown): void {
db.push(`/${key}`, value, true);
reloadDB();
}
export function removeUpdateItem(key: string): void {
db.delete(`/${key}`);
reloadDB();
}
function reloadDB(): void {
const linuxUpdateJsonPath = path.join(app.getPath('userData'), '/config/updates.json');
try {
const file = fs.readFileSync(linuxUpdateJsonPath, 'utf8');
JSON.parse(file);
} catch (error: unknown) {
if (fs.existsSync(linuxUpdateJsonPath)) {
fs.unlinkSync(linuxUpdateJsonPath);
dialog.showErrorBox(
'Error saving update notifications.',
'We encountered an error while saving the update notifications.'
);
logger.error('Error while JSON parsing updates.json: ');
logger.error(error);
}
}
db = new JsonDB(linuxUpdateJsonPath, true, true);
}

View File

@@ -1,152 +0,0 @@
import {Console} from 'console'; // eslint-disable-line node/prefer-global/console
import electron from 'electron';
import fs from 'fs';
import os from 'os';
import isDev from 'electron-is-dev';
import {initSetUp} from './default-util';
import {sentryInit, captureException} from './sentry-util';
interface LoggerOptions {
timestamp?: true | (() => string);
file?: string;
level?: boolean;
logInDevMode?: boolean;
}
initSetUp();
let app: Electron.App = null;
let reportErrors = true;
if (process.type === 'renderer') {
app = electron.remote.app;
// 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();
}
});
} else {
app = electron.app;
}
const logDir = `${app.getPath('userData')}/Logs`;
type Level = 'log' | 'debug' | 'info' | 'warn' | 'error';
const levels: Level[] = ['log', 'debug', 'info', 'warn', 'error'];
type LogMethod = (...args: unknown[]) => void;
export default class Logger {
log: LogMethod;
debug: LogMethod;
info: LogMethod;
warn: LogMethod;
error: LogMethod;
nodeConsole: Console;
timestamp?: () => string;
level: boolean;
logInDevMode: boolean;
constructor(options: LoggerOptions = {}) {
let {
timestamp = true,
file = 'console.log',
level = true,
logInDevMode = false
} = options;
file = `${logDir}/${file}`;
if (timestamp === true) {
timestamp = this.getTimestamp;
}
// Trim log according to type of process
if (process.type === 'renderer') {
requestIdleCallback(async () => this.trimLog(file));
} else {
process.nextTick(async () => this.trimLog(file));
}
const fileStream = fs.createWriteStream(file, {flags: 'a'});
const nodeConsole = new Console(fileStream);
this.nodeConsole = nodeConsole;
this.timestamp = timestamp;
this.level = level;
this.logInDevMode = logInDevMode;
this.setUpConsole();
}
_log(type: Level, ...args: unknown[]): void {
const {
nodeConsole, timestamp, level, logInDevMode
} = this;
switch (true) {
case typeof timestamp === 'function':
args.unshift(timestamp() + ' |\t');
// Fall through
case (level):
args.unshift(type.toUpperCase() + ' |');
// Fall through
case isDev || logInDevMode:
nodeConsole[type](...args);
break;
default:
}
console[type](...args);
}
setUpConsole(): void {
for (const type of levels) {
this.setupConsoleMethod(type);
}
}
setupConsoleMethod(type: Level): void {
this[type] = (...args: unknown[]) => {
this._log(type, ...args);
};
}
getTimestamp(): string {
const date = new Date();
const timestamp =
`${date.getMonth()}/${date.getDate()} ` +
`${date.getMinutes()}:${date.getSeconds()}`;
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 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);
const toWrite = trimmedLogs.join(os.EOL);
await fs.promises.writeFile(file, toWrite);
}
}
}

View File

@@ -1,77 +0,0 @@
import * as ConfigUtil from './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]);
let proxyString = '';
values.forEach(proxy => {
proxyString += proxy;
});
ConfigUtil.setConfigItem('systemProxyRules', proxyString);
const useSystemProxy = ConfigUtil.getConfigItem('useSystemProxy');
if (useSystemProxy) {
ConfigUtil.setConfigItem('proxyRules', proxyString);
}
}

View File

@@ -1,71 +1,67 @@
import {ipcRenderer} from 'electron'; import * as backoff from "backoff";
import * as backoff from 'backoff'; import {html} from "../../../common/html";
import {htmlEscape} from 'escape-goat'; import Logger from "../../../common/logger-util";
import type WebView from "../components/webview";
import type WebView from '../components/webview'; import {ipcRenderer} from "../typed-ipc-renderer";
import Logger from './logger-util';
const logger = new Logger({ const logger = new Logger({
file: 'domain-util.log', file: "domain-util.log",
timestamp: true
}); });
export default class ReconnectUtil { export default class ReconnectUtil {
webview: WebView; webview: WebView;
url: string; url: string;
alreadyReloaded: boolean; alreadyReloaded: boolean;
fibonacciBackoff: backoff.Backoff; fibonacciBackoff: backoff.Backoff;
constructor(webview: WebView) { constructor(webview: WebView) {
this.webview = webview; this.webview = webview;
this.url = webview.props.url; this.url = webview.props.url;
this.alreadyReloaded = false; this.alreadyReloaded = false;
this.clearState(); this.fibonacciBackoff = backoff.fibonacci({
} initialDelay: 5000,
maxDelay: 300_000,
});
}
clearState(): void { async isOnline(): Promise<boolean> {
this.fibonacciBackoff = backoff.fibonacci({ return ipcRenderer.invoke("is-online", this.url);
initialDelay: 5000, }
maxDelay: 300000
});
}
async isOnline(): Promise<boolean> { pollInternetAndReload(): void {
return ipcRenderer.invoke('is-online', this.url); this.fibonacciBackoff.backoff();
} this.fibonacciBackoff.on("ready", async () => {
if (await this._checkAndReload()) {
this.fibonacciBackoff.reset();
} else {
this.fibonacciBackoff.backoff();
}
});
}
pollInternetAndReload(): void { async _checkAndReload(): Promise<boolean> {
this.fibonacciBackoff.backoff(); if (this.alreadyReloaded) {
this.fibonacciBackoff.on('ready', async () => { return true;
if (await this._checkAndReload()) { }
this.fibonacciBackoff.reset();
} else {
this.fibonacciBackoff.backoff();
}
});
}
async _checkAndReload(): Promise<boolean> { if (await this.isOnline()) {
if (this.alreadyReloaded) { ipcRenderer.send("forward-message", "reload-viewer");
return true; logger.log("You're back online.");
} return true;
}
if (await this.isOnline()) { logger.log(
ipcRenderer.send('forward-message', 'reload-viewer'); "There is no internet connection, try checking network cables, modem and router.",
logger.log('You\'re back online.'); );
return true; const errorMessageHolder = document.querySelector("#description");
} if (errorMessageHolder) {
errorMessageHolder.innerHTML = html`
<div>Your internet connection doesn't seem to work properly!</div>
<div>Verify that it works and then click try again.</div>
`.html;
}
logger.log('There is no internet connection, try checking network cables, modem and router.'); return false;
const errorMessageHolder = document.querySelector('#description'); }
if (errorMessageHolder) {
errorMessageHolder.innerHTML = htmlEscape`
<div>Your internet connection doesn't seem to work properly!</div>
<div>Verify that it works and then click try again.</div>`;
}
return false;
}
} }

View File

@@ -1,17 +0,0 @@
import {init} from '@sentry/electron';
import isDev from 'electron-is-dev';
export const sentryInit = (): void => {
if (!isDev) {
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,38 +1,16 @@
import {ipcRenderer} from 'electron'; import {ipcRenderer} from "../typed-ipc-renderer";
import os from 'os';
export const connectivityERR: string[] = [ export const connectivityError: string[] = [
'ERR_INTERNET_DISCONNECTED', "ERR_INTERNET_DISCONNECTED",
'ERR_PROXY_CONNECTION_FAILED', "ERR_PROXY_CONNECTION_FAILED",
'ERR_CONNECTION_RESET', "ERR_CONNECTION_RESET",
'ERR_NOT_CONNECTED', "ERR_NOT_CONNECTED",
'ERR_NAME_NOT_RESOLVED', "ERR_NAME_NOT_RESOLVED",
'ERR_NETWORK_CHANGED' "ERR_NETWORK_CHANGED",
]; ];
const userAgent = ipcRenderer.sendSync('fetch-user-agent'); 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 { export function getUserAgent(): string {
return userAgent; return userAgent;
} }

View File

@@ -1,18 +0,0 @@
import path from 'path';
import i18n from 'i18n';
import * as ConfigUtil from './config-util';
i18n.configure({
directory: path.join(__dirname, '../../../translations/'),
updateFiles: false
});
/* Fetches the current appLocale from settings.json */
const appLocale = ConfigUtil.getConfigItem('appLanguage');
/* If no locale present in the json, en is set default */
export function __(phrase: string): string {
return i18n.__({phrase, locale: appLocale ? appLocale : 'en'});
}

View File

@@ -1,64 +1,56 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="responsive desktop"> <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</title>
<link rel="stylesheet" href="css/fonts.css" />
<link rel="stylesheet" href="css/main.css" type="text/css" media="screen" />
</head>
<head> <body>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <div id="content">
<meta name="viewport" content="width=device-width"> <div class="popup">
<title>Zulip</title> <span class="popuptext hidden" id="fullscreen-popup"></span>
<link rel="stylesheet" href="css/main.css" type="text/css" media="screen"> </div>
</head> <div id="sidebar" class="toggle-sidebar">
<div id="view-controls-container">
<body> <div id="tabs-container"></div>
<div id="content"> <div id="add-tab" class="tab functional-tab">
<div class="popup"> <div class="server-tab" id="add-action">
<span class="popuptext hidden" id="fullscreen-popup"></span> <i class="material-icons">add</i>
</div>
<div id="sidebar" class="toggle-sidebar">
<div id="view-controls-container">
<div id="tabs-container"></div>
<div id="add-tab" class="tab functional-tab">
<div class="server-tab" id="add-action">
<i class="material-icons">add</i>
</div>
<span id="add-server-tooltip" style="display:none">Add organization</span>
</div>
</div>
<div id="actions-container">
<div class="action-button" id="dnd-action">
<i class="material-icons md-48">notifications</i>
<span id="dnd-tooltip" style="display:none">Do Not Disturb</span>
</div>
<div class="action-button" id="reload-action">
<i class="material-icons md-48">refresh</i>
<span id="reload-tooltip" style="display:none">Reload</span>
</div>
<div class="action-button disable" id="loading-action">
<i class="refresh material-icons md-48">loop</i>
<span id="loading-tooltip" style="display:none">Loading</span>
</div>
<div class="action-button disable" id="back-action">
<i class="material-icons md-48">arrow_back</i>
<span id="back-tooltip" style="display:none">Go Back</span>
</div>
<div class="action-button" id="settings-action">
<i class="material-icons md-48">settings</i>
<span id="setting-tooltip" style="display:none">Settings</span>
</div>
</div>
</div>
<div id="main-container">
<div id="webviews-container"></div>
</div> </div>
<span id="add-server-tooltip" style="display: none"
>Add organization</span
>
</div>
</div> </div>
<div id="actions-container">
<div id="feedback-modal"> <div class="action-button" id="dnd-action">
<send-feedback show-cancel-button="show"></send-feedback> <i class="material-icons md-48">notifications</i>
<span id="dnd-tooltip" style="display: none">Do Not Disturb</span>
</div>
<div class="action-button" id="reload-action">
<i class="material-icons md-48">refresh</i>
<span id="reload-tooltip" style="display: none">Reload</span>
</div>
<div class="action-button disable" id="loading-action">
<i class="refresh material-icons md-48">loop</i>
<span id="loading-tooltip" style="display: none">Loading</span>
</div>
<div class="action-button disable" id="back-action">
<i class="material-icons md-48">arrow_back</i>
<span id="back-tooltip" style="display: none">Go Back</span>
</div>
<div class="action-button" id="settings-action">
<i class="material-icons md-48">settings</i>
<span id="setting-tooltip" style="display: none">Settings</span>
</div>
</div> </div>
</body> </div>
<div id="main-container">
<script> <div id="webviews-container"></div>
// we don't use src='./js/main' in the script tag because </div>
// it messes up require module path resolution </div>
require('./js/main'); </body>
</script> </html>
</html>

View File

@@ -1,26 +1,31 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="responsive desktop"> <html lang="en" class="responsive desktop">
<head> <head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width" />
<title>Zulip - Network Troubleshooting</title> <title>Zulip - Network Troubleshooting</title>
<link rel="stylesheet" href="css/network.css" type="text/css" media="screen"> <link
</head> rel="stylesheet"
<body> href="css/network.css"
<div id="content"> type="text/css"
<div id="picture"><img src="img/zulip_network.png"></div> media="screen"
<div id="title">We can't connect to this organization</div> />
<div id="subtitle">This could be because</div> </head>
<ul id="description"> <body>
<li>You're not online or your proxy is misconfigured.</li> <div id="content">
<li>There is no Zulip organization hosted at this URL.</li> <div id="picture"><img src="img/zulip_network.png" /></div>
<li>This Zulip organization is temporarily unavailable.</li> <div id="title">We can't connect to this organization</div>
<li>This Zulip organization has been moved or deleted.</li> <div id="subtitle">This could be because</div>
</ul> <ul id="description">
<div id="buttons"> <li>You're not online or your proxy is misconfigured.</li>
<div id="reconnect" class="button">Reconnect</div> <li>There is no Zulip organization hosted at this URL.</li>
<div id="settings" class="button">Settings</div> <li>This Zulip organization is temporarily unavailable.</li>
</div> <li>This Zulip organization has been moved or deleted.</li>
</div> </ul>
</body> <div id="buttons">
<div id="reconnect" class="button">Reconnect</div>
<div id="settings" class="button">Settings</div>
</div>
</div>
</body>
</html> </html>

View File

@@ -1,20 +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

@@ -1,38 +0,0 @@
interface DialogBoxError {
title: string;
content: string;
}
export function invalidZulipServerError(domain: string): string {
return `${domain} does not appear to be a valid Zulip server. Make sure that
\n • You can connect to that URL in a web browser.\
\n • If you need a proxy to connect to the Internet, that you've configured your proxy in the Network settings.\
\n • It's a Zulip server. (The oldest supported version is 1.6).\
\n • The server has a valid certificate. \
\n • The SSL is correctly configured for the certificate. Check out the SSL troubleshooting guide -
\n https://zulip.readthedocs.io/en/stable/production/ssl-certificates.html`;
}
export function noOrgsError(domain: string): string {
return `${domain} does not have any organizations added.\
\nPlease contact your server administrator.`;
}
export function enterpriseOrgError(length: number, domains: string[]): DialogBoxError {
let domainList = '';
for (const domain of domains) {
domainList += `${domain}\n`;
}
return {
title: `Could not add the following ${length === 1 ? 'organization' : 'organizations'}`,
content: `${domainList}\nPlease contact your system administrator.`
};
}
export function orgRemovalError(url: string): DialogBoxError {
return {
title: `Removing ${url} is a restricted operation.`,
content: 'Please contact your system administrator.'
};
}

View File

@@ -1,20 +1,20 @@
# How to help translate Zulip Desktop # How to help translate Zulip Desktop
These are *generated* files (*) that contain translations of the strings in These are _generated_ files (\*) that contain translations of the strings in
the app. the app.
You can help translate Zulip Desktop into your language! We do our You can help translate Zulip Desktop into your language! We do our
translations in Transifex, which is a nice web app for collaborating on translations in Transifex, which is a nice web app for collaborating on
translations; a maintainer then syncs those translations into this repo. translations; a maintainer then syncs those translations into this repo.
To help out, [join the Zulip project on To help out, [join the Zulip project on
Transifex](https://www.transifex.com/zulip/zulip/) and enter translations Transifex](https://www.transifex.com/zulip/zulip/) and enter translations
there. More details in the [Zulip contributor docs](https://zulip.readthedocs.io/en/latest/translating/translating.html#translators-workflow). there. More details in the [Zulip contributor docs](https://zulip.readthedocs.io/en/latest/translating/translating.html#translators-workflow).
Within that Transifex project, if you'd like to focus on Zulip Desktop, look Within that Transifex project, if you'd like to focus on Zulip Desktop, look
at `desktop.json`. The other resources there are for the Zulip web/mobile at `desktop.json`. The other resources there are for the Zulip web/mobile
app, where translations are also very welcome. app, where translations are also very welcome.
(*) One file is an exception: `en.json` is manually maintained as a (\*) One file is an exception: `en.json` is manually maintained as a
list of (English) messages in the source code, and is used when we upload to list of (English) messages in the source code, and is used when we upload to
Transifex a list of strings to be translated. It doesn't contain any Transifex a list of strings to be translated. It doesn't contain any
translations. translations.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

16
build/dmg-background.svg Normal file
View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="540" height="380">
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#50adff" />
<stop offset="1" stop-color="#7877fc" />
</linearGradient>
<rect x="0" y="0" width="540" height="380" fill="url(#g)" />
<path d="M473.09 122.97c0 22.69-10.19 42.85-25.72 55.08L296.61 312.69c-2.8 2.4-6.44-1.47-4.42-4.7l55.3-110.72c1.55-3.1-.46-6.91-3.64-6.91H129.36c-33.22 0-60.4-30.32-60.4-67.37 0-37.06 27.18-67.37 60.4-67.37h283.33c33.22-.02 60.4 30.3 60.4 67.35zM129.36 506.05h283.33c33.22 0 60.4-30.32 60.4-67.37 0-37.06-27.18-67.37-60.4-67.37H198.2c-3.18 0-5.19-3.81-3.64-6.91l55.3-110.72c2.02-3.23-1.62-7.1-4.42-4.7L94.68 383.6c-15.53 12.22-25.72 32.39-25.72 55.08 0 37.05 27.18 67.37 60.4 67.37zm522.5-124.15l124.78-179.6v-1.56H663.52v-48.98h190.09v34.21L731.55 363.24v1.56h124.01v48.98h-203.7V381.9zm338.98-230.14V302.6c0 45.09 17.1 68.03 47.43 68.03 31.1 0 48.2-21.77 48.2-68.03V151.76h59.09V298.7c0 80.86-40.82 119.34-109.24 119.34-66.09 0-104.96-36.54-104.96-120.12V151.76h59.48zm244.91 0h59.48v212.25h104.18v49.76h-163.66V151.76zm297 0v262.01h-59.48V151.76h59.48zm90.18 3.5c18.27-3.11 43.93-5.44 80.08-5.44 36.54 0 62.59 7 80.08 20.99 16.72 13.22 27.99 34.99 27.99 60.64 0 25.66-8.55 47.43-24.1 62.2-20.21 19.05-50.15 27.6-85.13 27.6-7.77 0-14.77-.39-20.21-1.17v93.69h-58.7V155.26zm58.7 118.96c5.05 1.17 11.27 1.55 19.83 1.55 31.49 0 50.92-15.94 50.92-42.76 0-24.1-16.72-38.49-46.26-38.49-12.05 0-20.21 1.17-24.49 2.33v77.37z"
fill="#fff"
transform="translate(270, 70) scale(.12) translate(-940.02, -280.835)" />
<rect x="50" y="140" width="160" height="160" rx="24" fill="#fff"
opacity=".5" />
<text x="270" y="244" font-size="64" font-family="System Font"
text-anchor="middle" fill="#fff">→</text>
<rect x="330" y="140" width="160" height="160" rx="24"
fill="#fff" opacity=".5" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
build/dmg-background.tiff Normal file

Binary file not shown.

BIN
build/dmg-icon.icns Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -7,16 +7,16 @@ in order to contribute to developing it.
To build and run the app from source, you'll need the following: To build and run the app from source, you'll need the following:
* [Git](http://git-scm.com/book/en/v2/Getting-Started-Installing-Git) - [Git](http://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
* Use our [Git Guide](https://zulip.readthedocs.io/en/latest/git/setup.html) to get started with Git and GitHub. - Use our [Git Guide](https://zulip.readthedocs.io/en/latest/git/setup.html) to get started with Git and GitHub.
* [Node.js](https://nodejs.org) >= v10.16.3 - [Node.js](https://nodejs.org) >= v10.16.3
* [NPM](https://www.npmjs.com/get-npm) and - [NPM](https://www.npmjs.com/get-npm) and
[node-gyp](https://github.com/nodejs/node-gyp#installation), [node-gyp](https://github.com/nodejs/node-gyp#installation),
if they don't come bundled with your Node.js installation if they don't come bundled with your Node.js installation
* [Python](https://www.python.org/downloads/release/python-2713/) - [Python](https://www.python.org/downloads/release/python-2713/)
(v2.7.x recommended) (v2.7.x recommended)
* A C++ compiler compatible with C++11 - A C++ compiler compatible with C++11
* Development headers for the libXext, libXtst, and libxkbfile libraries, which can be installed using apt on Ubuntu using - Development headers for the libXext, libXtst, and libxkbfile libraries, which can be installed using apt on Ubuntu using
```sh ```sh
$ sudo apt install libxext-dev libxtst-dev libxkbfile-dev libgconf-2-4 $ sudo apt install libxext-dev libxtst-dev libxkbfile-dev libgconf-2-4
``` ```
@@ -37,15 +37,15 @@ $ sudo apt install git nodejs python build-essential snapcraft libxext-dev libxt
### MacOS ### MacOS
On a system running MacOS, you can refer to [official nodejs docs][node-mac] to On a system running MacOS, you can refer to [official nodejs docs][node-mac] to
install nodejs. To ensure Node.js has been installed, run ```node -v``` in terminal to know your node version. install nodejs. To ensure Node.js has been installed, run `node -v` in terminal to know your node version.
[node-mac]: https://nodejs.org/en/download/package-manager/#macos [node-mac]: https://nodejs.org/en/download/package-manager/#macos
If [NPM](https://www.npmjs.com/get-npm) and [node-gyp](https://github.com/nodejs/node-gyp#installation) don't come bundled with your Node.js installation, you will need to install them manually. If [NPM](https://www.npmjs.com/get-npm) and [node-gyp](https://github.com/nodejs/node-gyp#installation) don't come bundled with your Node.js installation, you will need to install them manually.
### Windows ### Windows
- Download Node.js for Windows and install it. You can refer to the official docs [here][node-windows] to do so. To ensure Node.js has been installed, run ```node -v``` in Git Bash to know your node version. - Download Node.js for Windows and install it. You can refer to the official docs [here][node-windows] to do so. To ensure Node.js has been installed, run `node -v` in Git Bash to know your node version.
[node-windows]: https://nodejs.org/en/download/package-manager/#windows [node-windows]: https://nodejs.org/en/download/package-manager/#windows
@@ -57,27 +57,26 @@ If [NPM](https://www.npmjs.com/get-npm) and [node-gyp](https://github.com/nodejs
## Download, build, and run ## Download, build, and run
Clone the source locally: Clone the source locally:
```sh ```sh
$ git clone https://github.com/zulip/zulip-desktop $ git clone https://github.com/zulip/zulip-desktop
$ cd zulip-desktop $ cd zulip-desktop
``` ```
Install project dependencies: Install project dependencies:
```sh ```sh
$ npm install $ npm install
``` ```
Start the app: Start the app:
```sh ```sh
$ npm start $ npm start
``` ```
Start and watch changes:
```sh
$ npm run dev
```
Run tests: Run tests:
```sh ```sh
$ npm test $ npm test
``` ```
@@ -85,27 +84,24 @@ $ npm test
## How to contribute? ## How to contribute?
Feel free to fork this repository, test it locally and then report any bugs Feel free to fork this repository, test it locally and then report any bugs
you find in the [issue tracker](https://github.com/zulip/zulip-desktop/issues). you find in the [issue tracker](https://github.com/zulip/zulip-desktop/issues).
You can read more about making contributions in our [Contributing Guide](./CONTRIBUTING.md). You can read more about making contributions in our [Contributing Guide](./CONTRIBUTING.md).
## Troubleshooting
If you have any problems running the app, see the [most common
issues](./troubleshooting.md).
## Making a release ## Making a release
To package the app into an installer: To package the app into an installer:
``` ```
npm run dist npm run dist
``` ```
This command will produce distributable packages or installers for the This command will produce distributable packages or installers for the
operating system you're running on: operating system you're running on:
* on Windows, a .7z nsis file and a .exe WebSetup file
* on macOS, a `.dmg` file - on Windows, a .7z nsis file and a .exe WebSetup file
* on Linux, a plain `.zip` file as well as a `.deb` file, `.snap` file and an - on macOS, a `.dmg` file
- on Linux, a plain `.zip` file as well as a `.deb` file, `.snap` file and an
`AppImage` file. `AppImage` file.
To generate all three types of files, you will need all three operating To generate all three types of files, you will need all three operating

Some files were not shown because too many files have changed in this diff Show More