Compare commits

...

136 Commits

Author SHA1 Message Date
Anders Kaseorg
52a3fa6bd1 release: New release v5.12.1.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-29 13:23:54 -07:00
Anders Kaseorg
c1f2ae5ef8 context-menu: Enable macOS Writing Tools.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-29 13:23:54 -07:00
Anders Kaseorg
301fe26d80 Upgrade dependencies, including Electron 37.4.0.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-29 12:41:45 -07:00
Hosted Weblate
92a2b4eae9 translations: Update translations from Weblate. 2025-08-29 19:41:14 +00:00
Anders Kaseorg
6e307570d0 Replace Transifex documentation and configuration with Weblate.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-29 12:35:20 -07:00
Anders Kaseorg
dc39c68389 Modernize APT configuration format.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-28 16:38:58 -07:00
Anders Kaseorg
73cdfa7249 how-to-install: Note that the Debian package configures APT.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-28 16:38:58 -07:00
Anders Kaseorg
d9e4b0a40b Update electron-builder configuration.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-28 16:23:57 -07:00
Anders Kaseorg
0c7ce62ce1 Update apt signing key with SHA-256 binding signatures.
Fixes #1437.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-12 20:37:06 -07:00
Anders Kaseorg
9dd5fd2aa5 Mark more strings for translation.
Fixes #1128 among many other things.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-06 14:04:43 -07:00
Anders Kaseorg
11e2635aa0 Correct node-json-db Errors.js imports.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-06 14:02:00 -07:00
Anders Kaseorg
b35cf13a77 tests: Move tests/package.json to its own folder.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-06 13:38:35 -07:00
Anders Kaseorg
814de8ad6a tests: Convert tests to TypeScript.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-06 13:29:46 -07:00
Anders Kaseorg
d9dbbf2359 tests: Switch from medium to p-fifo.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-06 13:29:46 -07:00
Anders Kaseorg
a9c9de2dee Convert i18next-parser configuration to TypeScript.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-06 13:29:46 -07:00
Anders Kaseorg
9b626950ae workflows: Use actions/setup-node.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-05 01:10:40 -07:00
Anders Kaseorg
45672432db Focus the webview for notification settings, keyboard shortcuts.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-05 00:57:36 -07:00
Anders Kaseorg
b5665abb3e Upgrade dependencies, including Electron 37.2.5.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-05 00:48:30 -07:00
Anders Kaseorg
5b30bb2a16 stylelint: Fix property-no-deprecated.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-05 00:40:03 -07:00
Anders Kaseorg
598aa6f4b9 webview: Adjust app.dock feature test for TypeScript friendliness.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-05 00:40:03 -07:00
Anders Kaseorg
2e7ed457f0 xo: Fix @typescript-eslint/prefer-nullish-coalescing.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-05 00:40:03 -07:00
Anders Kaseorg
bb3cad818b xo: Fix unicorn/prefer-string-raw.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-05 00:40:03 -07:00
Anders Kaseorg
e3d9308c21 xo: Fix unicorn/prefer-single-call.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-05 00:40:03 -07:00
Anders Kaseorg
098d35fc5c index: Avoid deprecated Buffer#slice.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-05 00:40:03 -07:00
Anders Kaseorg
eb849a7b3d Switch to "type": "module".
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-05 00:32:25 -07:00
Anders Kaseorg
ab3698f56c Switch i18next-scanner to i18next-parser.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-08-02 11:53:21 -07:00
Anders Kaseorg
0fdeb1fd17 translations: Update translations from Transifex.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-07-31 17:48:29 -07:00
Anders Kaseorg
d270d56309 xo: Prohibit main-only and renderer-only APIs in wrong processes.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-06-20 16:35:18 -07:00
Anders Kaseorg
2c5b1ad297 xo: Use eslint-import-resolver-typescript.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-06-20 16:35:18 -07:00
Anders Kaseorg
26b226c7ae Use .ts extensions for imports.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-06-20 16:35:18 -07:00
Anders Kaseorg
7f6699e235 tsconfig: Enable allowImportingTsExtensions.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-06-20 16:35:18 -07:00
Anders Kaseorg
339f0d19c7 xo: Configure import/no-extraneous-dependencies packageDir.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-06-20 16:35:18 -07:00
Anders Kaseorg
86882c0741 xo: Move configuration to xo.config.cjs.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-06-20 16:35:18 -07:00
Anders Kaseorg
cf5a691a36 Revert "enterprise: Quit app after showing error for invalid global config."
This reverts commit 51ff949d34.

It incorrectly uses a main-only API in app/common, which is shared
between the main and renderer processes.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-06-20 16:35:18 -07:00
Shubham Padia
51ff949d34 enterprise: Quit app after showing error for invalid global config.
Otherwise, the error will keep showing multiple times ultimately
leading to a non-working app after multiple errors.
2025-06-17 18:07:11 -07:00
Shubham Padia
e5680b12f4 enterprise: Show error dialogbox on invalid JSON.
Fixes #1404.

Co-authored-by: sammamama <samridhsame@gmail.com>
2025-06-17 18:07:11 -07:00
Shubham Padia
b42f9de27d preferences: Increase contrast of setting text and nav items.
The text in the settings panel was unnecessarily fade, making it hard to
read. Increasing the contrast makes it more readable. The setting text
color is the same as the heading of the settings. This commit also
changes the color of the nav items to be the same as `|` bar preceding
it.
https://chat.zulip.org/#narrow/channel/101-design/topic/zulip-desktop.20preferences.20contrast
2025-06-11 12:33:00 -07:00
Shubham Padia
201faa9449 settings: Make Do not disturb icon brigther when it's on.
It was not so obvious to users when they were in DND mode, making the
icon brigther when in DND mode hopes to address that.
https://chat.zulip.org/#narrow/channel/101-design/topic/zulip-desktop.20DND.20icon.20contrast
2025-06-11 12:32:11 -07:00
Shubham Padia
4125de4a60 css: Use hsl for action-button icon colors.
We only change this for `.action-button i` and `.action-button:hover i`
since we need to make the dnd icon lighter in comparison to these two.
These are the two that are absolutely necessary to change, we can also
change others to hsl color space after discussion on CZO.
2025-06-11 12:32:11 -07:00
Alex Vandiver
916fab7963 translations: Update translations from Transifex. 2025-05-20 18:59:55 +00:00
Anders Kaseorg
15902e51f6 release: New release v5.12.0.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-03-13 14:06:16 -07:00
Anders Kaseorg
19705bc90b Update macOS notarization configuration.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-03-13 14:06:12 -07:00
Anders Kaseorg
a9313f4756 Update Azure Trusted Signing configuration.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-03-13 14:06:12 -07:00
Anders Kaseorg
13b4d2037a Upgrade dependencies, including Electron 35.0.1.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-03-13 13:36:26 -07:00
Anders Kaseorg
ab63ec2a4a translations: Update translations from Transifex.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-03-12 17:22:53 -07:00
Anders Kaseorg
1de4f88c6c webview: Address deprecation of WebContents.goBack et al.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-03-12 17:15:03 -07:00
Anders Kaseorg
ab4381a6bf xo: Fix unicorn/prefer-global-this.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-03-12 17:15:03 -07:00
Anders Kaseorg
d409a0bf33 menu: Check focusedWindow type.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-03-12 17:15:03 -07:00
Anders Kaseorg
c40e05646e Update macOS icon with a native appearance.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-03-12 16:59:49 -07:00
Anders Kaseorg
13f3818c77 supported-locales: Fix for removal of el_GR and zh-Hant.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-12-11 14:44:47 -05:00
Tim Abbott
4a0e590921 github: Use zulip/zulip pull request template style.
Hopefully this will help improve the quality of pull requests to this
repository.
2024-12-11 11:22:04 -08:00
Shubham Padia
eb19b20da2 preference: Rename show app unread badge setting.
The original wording was found to be confusing, see
https://chat.zulip.org/#narrow/channel/16-desktop/topic/.22app.20unread.20badge.22/near/1993426
for more details.
2024-12-11 11:18:19 -08:00
Alex Vandiver
69cb509fe5 translations: Remove control characters from zh_TW translation string. 2024-12-11 11:28:13 -05:00
Alex Vandiver
123263e5bb translations: Remove zh-Hant translation.
The `zh_TW` translation is much more complete.
2024-12-11 11:24:47 -05:00
Alex Vandiver
a26a10849d translations: Remove empty el_GR duplicate translation. 2024-12-11 11:20:20 -05:00
Anders Kaseorg
da7e026550 Mark dialog strings for translation.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-12-02 15:53:25 -08:00
Anders Kaseorg
c70f6df096 about: Remove inexplicable ‘cursor: pointer’.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-12-02 15:52:29 -08:00
Anders Kaseorg
ef0110f8e7 about: Mark strings for translation.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-12-02 15:52:29 -08:00
Anders Kaseorg
b7a7ca3e5c renderer: Mark strings for translation.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-12-02 15:52:29 -08:00
Anders Kaseorg
467e7b11c5 functional-tab: Split ‘name’ into ‘page’ and ‘label’.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-12-02 15:52:29 -08:00
Anders Kaseorg
105e7e93a1 translations: Add missing translatable strings with i18next-scanner.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-12-02 15:52:28 -08:00
Anders Kaseorg
a736f664c6 nav: Statically mark navigation items for translation.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-12-02 15:52:06 -08:00
Anders Kaseorg
38c7695a99 release: New release v5.11.1.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-08-23 16:31:21 -07:00
Anders Kaseorg
b268fe9478 Sign Windows binaries with Azure Trusted Signing.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-08-23 16:22:19 -07:00
Anders Kaseorg
981a262836 xo: Remove obsolete scripts/notarize.js options.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-08-22 16:18:05 -07:00
Anders Kaseorg
527bb5ab2f Upgrade dependencies, including Electron 32.0.1.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-08-22 16:13:32 -07:00
Anders Kaseorg
e2947a0ce6 translations: Update translations from Transifex.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-08-22 16:13:27 -07:00
Anders Kaseorg
3b2c758e09 translations: Sort supported-locales by display name.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-08-22 16:13:27 -07:00
Anders Kaseorg
4867fc672a preference: Sort spellchecker language names with localeCompare.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-08-22 16:13:27 -07:00
Anders Kaseorg
f85f05d66b preference: Show spellchecker language names from Intl.DisplayNames.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-08-22 16:13:27 -07:00
Anders Kaseorg
39fd0e9877 tsconfig: Work around @sentry/electron regression.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-08-07 14:58:13 -07:00
Anders Kaseorg
f6ff112f0e stylelint: Fix declaration-block-no-shorthand-property-overrides.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-08-07 14:53:08 -07:00
Anders Kaseorg
6fcd1ef0d5 Remove rimraf.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-08-06 22:55:40 -07:00
Misha Brukman
92260b0f97 ci: Replace Travis CI badge with GitHub Actions.
Travis CI was replaced with GitHub Actions in this project.
2024-07-02 15:41:59 -07:00
Anders Kaseorg
c45c9537d1 release: New release v5.11.0.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-03-22 16:19:52 -07:00
Anders Kaseorg
0eb4c9236e Upgrade dependencies, including Electron 29.1.5.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-03-22 16:12:34 -07:00
Anders Kaseorg
47366b7617 xo: Fix unicorn/prevent-abbreviations.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-03-22 16:07:32 -07:00
Anders Kaseorg
86e28f5b00 xo: Fix import/no-duplicates.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-03-22 15:33:00 -07:00
Anders Kaseorg
7072a41e01 Remove dialog for certificate errors on subresources.
Fixes #1119.  Closes #1277.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-03-22 14:56:23 -07:00
enesonus
79f6f13008 Allow hiding the window from full screen mode on macOS.
Fixes #1187.
2024-03-22 14:39:48 -07:00
Anders Kaseorg
70f0170f1d webview: Enable zooming with the mouse wheel.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-03-21 15:32:52 -07:00
Anders Kaseorg
bc75eba2bd webview: Use an exponential scale for zooming.
This matches the native Electron behavior.

Fixes part of #1360 by removing the separate zoomFactor state
variable.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-03-21 15:31:56 -07:00
Anders Kaseorg
af7272a439 release: New release v5.10.5.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-01-25 17:02:14 -08:00
Anders Kaseorg
9d08a13e64 Set a restrictive Content-Security-Policy for the app UI.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-01-25 15:39:05 -08:00
Anders Kaseorg
f98d6d7037 Upgrade dependencies, including Electron 28.2.0.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-01-25 14:05:37 -08:00
Anders Kaseorg
da1cad9dff autoupdater: Use a separate electron-log instance.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-01-23 16:09:20 -08:00
Anders Kaseorg
955a2eb6c7 Use process-specific electron-log modules.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-01-23 16:09:20 -08:00
Anders Kaseorg
1cf822a2b5 Use process-specific @sentry/electron modules.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-01-23 16:09:20 -08:00
Anders Kaseorg
b9baf140eb release: New release v5.10.4.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-01-08 17:12:08 -08:00
Anders Kaseorg
727c2335f6 electron-bridge: Fix unicorn/prefer-node-protocol.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-01-08 17:10:37 -08:00
Anders Kaseorg
e8173919f8 Upgrade dependencies, including Electron 28.1.1.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-01-08 17:10:37 -08:00
Anders Kaseorg
cf2f4fe9c9 Avoid deprecated ipcRenderer.sendTo.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2024-01-08 16:02:14 -08:00
Anders Kaseorg
47cdd5fa8b release: New release v5.10.3.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-09-29 23:38:26 -07:00
Anders Kaseorg
90e76fab6e Upgrade dependencies, including Electron 25.8.4.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-09-29 23:38:22 -07:00
Anders Kaseorg
193adb1901 Fix gatemaker TypeError with Electron 25.
This had been breaking our download notifications.  Fixes #1333.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-09-29 23:25:32 -07:00
Anders Kaseorg
b520e12492 release: New release v5.10.2.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-09-14 10:31:05 -07:00
Anders Kaseorg
ae642bc7ba Downgrade Electron from 26.2.1 to 25.8.1 to avoid renderer crash.
https://github.com/electron/electron/issues/39775

Fixes #1327.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-09-14 10:30:50 -07:00
Anders Kaseorg
e90f3732c5 release: New release v5.10.1.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-09-13 23:22:25 -07:00
Anders Kaseorg
6b31a8a0c4 workflows: Update actions/checkout to v4.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-09-13 23:22:25 -07:00
Anders Kaseorg
f8758fa303 Use electron fetch API.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-09-13 23:22:25 -07:00
Anders Kaseorg
d2de965106 translations: Update translations from Transifex.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-09-13 23:19:31 -07:00
Anders Kaseorg
a32119b55d Upgrade dependencies, including Electron 26.2.1.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-09-13 23:19:31 -07:00
Anders Kaseorg
58049a91c4 Upgrade xo and prettier.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-09-13 22:47:15 -07:00
Anders Kaseorg
9810d69c3b renderer: Compensate for Chrome’s removal of overflow: overlay.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-09-13 22:47:15 -07:00
Anders Kaseorg
d2f949d683 Use Electron Event type.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-09-13 19:15:08 -07:00
Anders Kaseorg
a8c283a50b renderer: Remove unused reloadView argument.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-09-13 19:15:08 -07:00
nooblag
dab29d4720 renderer: Improve GIF loading spinner with new SVG. 2023-09-13 19:15:08 -07:00
Anders Kaseorg
7fba8cfae9 release: New release v5.10.0.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 16:15:08 -07:00
Anders Kaseorg
32301656cc Upgrade dependencies, including Electron 24.2.0.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 16:15:04 -07:00
Anders Kaseorg
0e16283a37 stylelint: Fix declaration-block-no-redundant-longhand-properties.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 16:15:04 -07:00
Anders Kaseorg
d86482a804 stylelint: Fix media-feature-range-notation.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 16:15:04 -07:00
Anders Kaseorg
3af350e4dc translations: Update translations from Transifex.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 16:14:26 -07:00
Anders Kaseorg
39fc2053c5 translations: Update en.json.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 16:10:46 -07:00
Anders Kaseorg
044f1fd0f9 preference: Fix server icon display in connected organizations list.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 15:30:51 -07:00
Anders Kaseorg
10fb0a82f9 preload: Drop compatibility code for Zulip Server < 4.0.
The server was updated in bfd9999cf874e506592fda254dfe0fe06b5b2738
(4.0-rc1~2192) to expose a proper API for this functionality, so we
don’t need to trigger fake click events to access it.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 14:41:25 -07:00
Anders Kaseorg
123bd5b2c0 preload: Drop compatibility injected JS for Zulip Server < 3.0.
The server was updated in a6fee2f18ef9d2ef6ac248e9ed82d580daff1a07
(3.0-dev~1674) and e701f208619b8b9b28a85f84ee16cf8d8df82b72
(3.0-dev~1667) to avoid relying on this wrapper.  We no longer support
servers older than 3.0, so we can delete it.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 14:39:41 -07:00
Anders Kaseorg
ad771c3da8 Display a banner for unsupported Zulip Server versions.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 14:37:32 -07:00
Anders Kaseorg
4c58bc3aa3 webview: Add a wrapper pane around the real <webview>.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 14:35:14 -07:00
Anders Kaseorg
9a8680d209 webview: Use private methods.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 14:35:14 -07:00
Anders Kaseorg
1569890f4d webview: Use private members.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 14:35:14 -07:00
Anders Kaseorg
2ed400c23c webview: Add destroy method.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 14:35:14 -07:00
Anders Kaseorg
70621431dc translations: Update translations from Transifex.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 14:32:11 -07:00
Anders Kaseorg
55b7e09796 tx: Migrate configuration to current Transifex CLI.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 14:28:06 -07:00
Anders Kaseorg
de2829a968 translations: Update en.json.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 14:23:21 -07:00
Anders Kaseorg
296de41779 translation-util: Expose the full functionality of __.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 14:23:21 -07:00
Anders Kaseorg
8b9ebeee25 Fix more typos.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-05 14:23:21 -07:00
Anders Kaseorg
76e81ca337 Fix updating of server names and icons at startup.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-04 15:12:18 -07:00
Anders Kaseorg
2e7a9bb4ed server-tab: Encapsulate setName and setIcon.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-05-04 15:12:18 -07:00
Anders Kaseorg
77638f6287 Fix handling of server icon updates and errors.
Fixes #1283.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-04-21 15:37:28 -07:00
Anders Kaseorg
6e8fe36876 Fix typos.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-04-19 13:50:40 -07:00
Anders Kaseorg
2eea4a32a5 preference: Fix CSS in Vite dev mode.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-04-18 14:21:06 -07:00
Anders Kaseorg
677dfe425c xo: Remove redundant exclusion of unicorn/prefer-json-parse-buffer.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-04-18 14:03:45 -07:00
Anders Kaseorg
1da3ec545a Don’t show visual notifications when they’re turned off.
Fixes #1299.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-04-18 13:13:12 -07:00
Anders Kaseorg
3cb6ea4694 Handle exceptions when reading server icons.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-04-18 12:49:09 -07:00
Anders Kaseorg
0cb7297017 preference: Fix spellchecker languages dropdown positioning.
Apparently the Tagify defaults don’t work inside a shadow root.

Fixes #1286.  Closes #1290.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-04-04 15:24:22 -07:00
Anders Kaseorg
b8d7003446 Use Zod 3 style for importing Zod.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2023-03-04 00:23:00 -08:00
168 changed files with 16528 additions and 9075 deletions

View File

@@ -6,6 +6,6 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[{*.css,*.html,*.js,*.json,*.ts}]
[{*.cjs,*.css,*.html,*.js,*.json,*.ts}]
indent_style = space
indent_size = 2

View File

@@ -1,18 +1,49 @@
---
<!-- Describe your pull request here.-->
<!--
Remove the fields that are not appropriate
Please include:
Fixes: <!-- Issue link, or clear description.-->
<!-- If the PR makes UI changes, always include one or more still screenshots to demonstrate your changes. If it seems helpful, add a screen capture of the new functionality as well.
Tooling tips: https://zulip.readthedocs.io/en/latest/tutorials/screenshot-and-gif-software.html
-->
**What's this PR do?**
**Screenshots and screen captures:**
**Any background context you want to provide?**
**Screenshots?**
**You have tested this PR on:**
**Platforms this PR was tested on:**
- [ ] Windows
- [ ] Linux/Ubuntu
- [ ] macOS
- [ ] Linux (specify distro)
<details>
<summary>Self-review checklist</summary>
<!-- Prior to submitting a PR, follow our step-by-step guide to review your own code:
https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#how-to-review-code -->
<!-- Once you create the PR, check off all the steps below that you have completed.
If any of these steps are not relevant or you have not completed, leave them unchecked.-->
- [ ] [Self-reviewed](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#how-to-review-code) the changes for clarity and maintainability
(variable names, code reuse, readability, etc.).
Communicate decisions, questions, and potential concerns.
- [ ] Explains differences from previous plans (e.g., issue description).
- [ ] Highlights technical choices and bugs encountered.
- [ ] Calls out remaining decisions and concerns.
- [ ] Automated tests verify logic where appropriate.
Individual commits are ready for review (see [commit discipline](https://zulip.readthedocs.io/en/latest/contributing/commit-discipline.html)).
- [ ] Each commit is a coherent idea.
- [ ] Commit message(s) explain reasoning and motivation for changes.
Completed manual review and testing of the following:
- [ ] Visual appearance of the changes.
- [ ] Responsiveness and internationalization.
- [ ] Strings and tooltips.
- [ ] End-to-end functionality of buttons, interactions and flows.
- [ ] Corner cases, error conditions, and easily imagined bugs.
</details>

View File

@@ -10,6 +10,9 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v4
with:
node-version: lts/*
- uses: actions/checkout@v4
- run: npm ci
- run: npm test

3
.gitignore vendored
View File

@@ -4,9 +4,6 @@
# npm cache directory
.npm
# transifexrc - if user prefers it to be in working tree
.transifexrc
# Compiled binary build directory
/dist/
/dist-electron/

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
node-options=--experimental-strip-types

View File

@@ -1,5 +1,5 @@
{
"extends": ["stylelint-config-standard", "stylelint-config-prettier"],
"extends": ["stylelint-config-standard"],
"rules": {
"color-named": "never",
"color-no-hex": true,

View File

@@ -1,9 +0,0 @@
[main]
host = https://www.transifex.com
[zulip.desktopjson]
file_filter = public/translations/<lang>.json
minimum_perc = 0
source_file = public/translations/en.json
source_lang = en
type = KEYVALUEJSON

View File

@@ -10,7 +10,7 @@ Zulip-Desktop app is built on top of [Electron](http://electron.atom.io/). If yo
## 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 web app 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).

View File

@@ -1,6 +1,6 @@
# 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://github.com/zulip/zulip-desktop/actions/workflows/node.js.yml/badge.svg)](https://github.com/zulip/zulip-desktop/actions/workflows/node.js.yml?query=branch%3Amain)
[![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)
[![project chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://chat.zulip.org)
@@ -24,9 +24,9 @@ Please see the [installation guide](https://zulip.com/help/desktop-app-install-g
# 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 web app.
Issues in an individual organization's Zulip window should be reported
in the [Zulip server and webapp
in the [Zulip server and web app
project](https://github.com/zulip/zulip/issues/new). Other
issues in the desktop app and its settings should be reported [in this
project](https://github.com/zulip/zulip-desktop/issues/new).

View File

@@ -1,4 +1,4 @@
import * as z from "zod";
import {z} from "zod";
export const dndSettingsSchemata = {
showNotification: z.boolean(),
@@ -37,6 +37,7 @@ export const configSchemata = {
useProxy: z.boolean(),
useSystemProxy: z.boolean(),
};
export type ConfigSchemata = typeof configSchemata;
export const enterpriseConfigSchemata = {
...configSchemata,

View File

@@ -1,41 +1,47 @@
import fs from "node:fs";
import path from "node:path";
import * as Sentry from "@sentry/electron";
import * as Sentry from "@sentry/core";
import {JsonDB} from "node-json-db";
import {DataError} from "node-json-db/dist/lib/Errors";
import type * as z from "zod";
import {DataError} from "node-json-db/dist/lib/Errors.js";
import type {z} from "zod";
import {app, dialog} from "zulip:remote";
import {configSchemata} from "./config-schemata.js";
import * as EnterpriseUtil from "./enterprise-util.js";
import Logger from "./logger-util.js";
import {type ConfigSchemata, configSchemata} from "./config-schemata.ts";
import * as EnterpriseUtil from "./enterprise-util.ts";
import Logger from "./logger-util.ts";
export type Config = {
[Key in keyof typeof configSchemata]: z.output<(typeof configSchemata)[Key]>;
[Key in keyof ConfigSchemata]: z.output<ConfigSchemata[Key]>;
};
const logger = new Logger({
file: "config-util.log",
});
let db: JsonDB;
let database: JsonDB;
reloadDb();
reloadDatabase();
export function getConfigItem<Key extends keyof Config>(
key: Key,
defaultValue: Config[Key],
): z.output<(typeof configSchemata)[Key]> {
): z.output<ConfigSchemata[Key]> {
try {
db.reload();
database.reload();
} catch (error: unknown) {
logger.error("Error while reloading settings.json: ");
logger.error(error);
}
try {
return configSchemata[key].parse(db.getObject<unknown>(`/${key}`));
const typedSchemata: {
[Key in keyof Config]: z.ZodType<
z.output<ConfigSchemata[Key]>,
z.input<ConfigSchemata[Key]>
>;
} = configSchemata; // https://github.com/colinhacks/zod/issues/5154
return typedSchemata[key].parse(database.getObject<unknown>(`/${key}`));
} catch (error: unknown) {
if (!(error instanceof DataError)) throw error;
setConfigItem(key, defaultValue);
@@ -46,13 +52,13 @@ export function getConfigItem<Key extends keyof Config>(
// This function returns whether a key exists in the configuration file (settings.json)
export function isConfigItemExists(key: string): boolean {
try {
db.reload();
database.reload();
} catch (error: unknown) {
logger.error("Error while reloading settings.json: ");
logger.error(error);
}
return db.exists(`/${key}`);
return database.exists(`/${key}`);
}
export function setConfigItem<Key extends keyof Config>(
@@ -66,16 +72,16 @@ export function setConfigItem<Key extends keyof Config>(
}
configSchemata[key].parse(value);
db.push(`/${key}`, value, true);
db.save();
database.push(`/${key}`, value, true);
database.save();
}
export function removeConfigItem(key: string): void {
db.delete(`/${key}`);
db.save();
database.delete(`/${key}`);
database.save();
}
function reloadDb(): void {
function reloadDatabase(): void {
const settingsJsonPath = path.join(
app.getPath("userData"),
"/config/settings.json",
@@ -96,5 +102,5 @@ function reloadDb(): void {
}
}
db = new JsonDB(settingsJsonPath, true, true);
database = new JsonDB(settingsJsonPath, true, true);
}

View File

@@ -4,30 +4,30 @@ import {app} from "zulip:remote";
let setupCompleted = false;
const zulipDir = app.getPath("userData");
const logDir = `${zulipDir}/Logs/`;
const configDir = `${zulipDir}/config/`;
const zulipDirectory = app.getPath("userData");
const logDirectory = `${zulipDirectory}/Logs/`;
const configDirectory = `${zulipDirectory}/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(zulipDirectory)) {
fs.mkdirSync(zulipDirectory);
}
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir);
if (!fs.existsSync(logDirectory)) {
fs.mkdirSync(logDirectory);
}
// 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`;
if (!fs.existsSync(configDirectory)) {
fs.mkdirSync(configDirectory);
const domainJson = `${zulipDirectory}/domain.json`;
const settingsJson = `${zulipDirectory}/settings.json`;
const updatesJson = `${zulipDirectory}/updates.json`;
const windowStateJson = `${zulipDirectory}/window-state.json`;
const configData = [
{
path: domainJson,
@@ -44,7 +44,7 @@ export const initSetUp = (): void => {
];
for (const data of configData) {
if (fs.existsSync(data.path)) {
fs.copyFileSync(data.path, configDir + data.fileName);
fs.copyFileSync(data.path, configDirectory + data.fileName);
fs.unlinkSync(data.path);
}
}

View File

@@ -1,9 +1,9 @@
import process from "node:process";
import type * as z from "zod";
import type {z} from "zod";
import type {dndSettingsSchemata} from "./config-schemata.js";
import * as ConfigUtil from "./config-util.js";
import type {dndSettingsSchemata} from "./config-schemata.ts";
import * as ConfigUtil from "./config-util.ts";
export type DndSettings = {
[Key in keyof typeof dndSettingsSchemata]: z.output<

View File

@@ -2,10 +2,11 @@ import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import * as z from "zod";
import {z} from "zod";
import {dialog} from "zulip:remote";
import {enterpriseConfigSchemata} from "./config-schemata.js";
import Logger from "./logger-util.js";
import {enterpriseConfigSchemata} from "./config-schemata.ts";
import Logger from "./logger-util.ts";
type EnterpriseConfig = {
[Key in keyof typeof enterpriseConfigSchemata]: z.output<
@@ -20,13 +21,12 @@ const logger = new Logger({
let enterpriseSettings: Partial<EnterpriseConfig>;
let configFile: boolean;
reloadDb();
reloadDatabase();
function reloadDb(): void {
function reloadDatabase(): 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 = String.raw`C:\Program Files\Zulip-Desktop-Config\global_config.json`;
}
enterpriseFile = path.resolve(enterpriseFile);
@@ -40,6 +40,10 @@ function reloadDb(): void {
.partial()
.parse(data);
} catch (error: unknown) {
dialog.showErrorBox(
"Error loading global_config",
"We encountered an error while reading global_config.json, please make sure the file contains valid JSON.",
);
logger.log("Error while JSON parsing global_config.json: ");
logger.log(error);
}
@@ -56,7 +60,7 @@ export function getConfigItem<Key extends keyof EnterpriseConfig>(
key: Key,
defaultValue: EnterpriseConfig[Key],
): EnterpriseConfig[Key] {
reloadDb();
reloadDatabase();
if (!configFile) {
return defaultValue;
}
@@ -66,7 +70,7 @@ export function getConfigItem<Key extends keyof EnterpriseConfig>(
}
export function configItemExists(key: keyof EnterpriseConfig): boolean {
reloadDb();
reloadDatabase();
if (!configFile) {
return false;
}

View File

@@ -3,7 +3,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {html} from "./html.js";
import {Html, html} from "./html.ts";
import * as t from "./translation-util.ts";
export async function openBrowser(url: URL): Promise<void> {
if (["http:", "https:", "mailto:"].includes(url.protocol)) {
@@ -11,17 +12,17 @@ export async function openBrowser(url: URL): Promise<void> {
} 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");
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "zulip-redirect-"));
const file = path.join(directory, "redirect.html");
fs.writeFileSync(
file,
html`
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="Refresh" content="0; url=${url.href}" />
<title>Redirecting</title>
<title>${t.__("Redirecting")}</title>
<style>
html {
font-family: menu, "Helvetica Neue", sans-serif;
@@ -29,7 +30,13 @@ export async function openBrowser(url: URL): Promise<void> {
</style>
</head>
<body>
<p>Opening <a href="${url.href}">${url.href}</a>…</p>
<p>
${new Html({
html: t.__("Opening {{{link}}}…", {
link: html`<a href="${url.href}">${url.href}</a>`.html,
}),
})}
</p>
</body>
</html>
`.html,
@@ -37,7 +44,7 @@ export async function openBrowser(url: URL): Promise<void> {
await shell.openPath(file);
setTimeout(() => {
fs.unlinkSync(file);
fs.rmdirSync(dir);
fs.rmdirSync(directory);
}, 15_000);
}
}

View File

@@ -5,7 +5,7 @@ import process from "node:process";
import {app} from "zulip:remote";
import {initSetUp} from "./default-util.js";
import {initSetUp} from "./default-util.ts";
type LoggerOptions = {
file?: string;
@@ -13,7 +13,7 @@ type LoggerOptions = {
initSetUp();
const logDir = `${app.getPath("userData")}/Logs`;
const logDirectory = `${app.getPath("userData")}/Logs`;
type Level = "log" | "debug" | "info" | "warn" | "error";
@@ -23,7 +23,7 @@ export default class Logger {
constructor(options: LoggerOptions = {}) {
let {file = "console.log"} = options;
file = `${logDir}/${file}`;
file = `${logDirectory}/${file}`;
// Trim log according to type of process
if (process.type === "renderer") {
@@ -38,31 +38,31 @@ export default class Logger {
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(type: Level, ...arguments_: unknown[]): void {
arguments_.unshift(this.getTimestamp() + " |\t");
arguments_.unshift(type.toUpperCase() + " |");
this.nodeConsole[type](...arguments_);
console[type](...arguments_);
}
log(...args: unknown[]): void {
this._log("log", ...args);
log(...arguments_: unknown[]): void {
this._log("log", ...arguments_);
}
debug(...args: unknown[]): void {
this._log("debug", ...args);
debug(...arguments_: unknown[]): void {
this._log("debug", ...arguments_);
}
info(...args: unknown[]): void {
this._log("info", ...args);
info(...arguments_: unknown[]): void {
this._log("info", ...arguments_);
}
warn(...args: unknown[]): void {
this._log("warn", ...args);
warn(...arguments_: unknown[]): void {
this._log("warn", ...arguments_);
}
error(...args: unknown[]): void {
this._log("error", ...args);
error(...arguments_: unknown[]): void {
this._log("error", ...arguments_);
}
getTimestamp(): string {

View File

@@ -1,3 +1,5 @@
import * as t from "./translation-util.ts";
type DialogBoxError = {
title: string;
content: string;
@@ -13,26 +15,24 @@ export function invalidZulipServerError(domain: string): string {
https://zulip.readthedocs.io/en/stable/production/ssl-certificates.html`;
}
export function enterpriseOrgError(
length: number,
domains: string[],
): DialogBoxError {
export function enterpriseOrgError(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.`,
title: t.__mf(
"{number, plural, one {Could not add # organization} other {Could not add # organizations}}",
{number: domains.length},
),
content: `${domainList}\n${t.__("Please contact your system administrator.")}`,
};
}
export function orgRemovalError(url: string): DialogBoxError {
return {
title: `Removing ${url} is a restricted operation.`,
content: "Please contact your system administrator.",
title: t.__("Removing {{{url}}} is a restricted operation.", {url}),
content: t.__("Please contact your system administrator."),
};
}

View File

@@ -2,8 +2,8 @@ import path from "node:path";
import i18n from "i18n";
import * as ConfigUtil from "./config-util.js";
import {publicPath} from "./paths.js";
import * as ConfigUtil from "./config-util.ts";
import {publicPath} from "./paths.ts";
i18n.configure({
directory: path.join(publicPath, "translations/"),
@@ -11,9 +11,6 @@ i18n.configure({
});
/* Fetches the current appLocale from settings.json */
const appLocale = ConfigUtil.getConfigItem("appLanguage", "en");
i18n.setLocale(ConfigUtil.getConfigItem("appLanguage", "en") ?? "en");
/* If no locale present in the json, en is set default */
export function __(phrase: string): string {
return i18n.__({phrase, locale: appLocale ?? "en"});
}
export {__, __mf} from "i18n";

View File

@@ -1,5 +1,5 @@
import type {DndSettings} from "./dnd-util.js";
import type {MenuProps, ServerConf} from "./types.js";
import type {DndSettings} from "./dnd-util.ts";
import type {MenuProperties, ServerConfig} from "./types.ts";
export type MainMessage = {
"clear-app-settings": () => void;
@@ -7,7 +7,6 @@ export type MainMessage = {
"fetch-user-agent": () => string;
"focus-app": () => void;
"focus-this-webview": () => void;
"get-injected-js": () => string;
"new-clipboard-key": () => {key: Uint8Array; sig: Uint8Array};
"permission-callback": (permissionCallbackId: number, grant: boolean) => void;
"quit-app": () => void;
@@ -22,15 +21,15 @@ export type MainMessage = {
toggleAutoLauncher: (AutoLaunchValue: boolean) => void;
"unread-count": (unreadCount: number) => void;
"update-badge": (messageCount: number) => void;
"update-menu": (props: MenuProps) => void;
"update-menu": (properties: MenuProperties) => void;
"update-taskbar-icon": (data: string, text: string) => void;
};
export type MainCall = {
"get-server-settings": (domain: string) => ServerConf;
"get-server-settings": (domain: string) => ServerConfig;
"is-online": (url: string) => boolean;
"poll-clipboard": (key: Uint8Array, sig: Uint8Array) => string | undefined;
"save-server-icon": (iconURL: string) => string;
"save-server-icon": (iconURL: string) => string | null;
};
export type RendererMessage = {
@@ -75,9 +74,9 @@ export type RendererMessage = {
"toggle-silent": (state: boolean) => void;
"toggle-tray": (state: boolean) => void;
toggletray: () => void;
tray: (arg: number) => void;
tray: (argument: number) => void;
"update-realm-icon": (serverURL: string, iconURL: string) => void;
"update-realm-name": (serveRURL: string, realmName: string) => void;
"update-realm-name": (serverURL: string, realmName: string) => void;
"webview-reload": () => void;
zoomActualSize: () => void;
zoomIn: () => void;

View File

@@ -1,26 +1,30 @@
export type MenuProps = {
export type MenuProperties = {
tabs: TabData[];
activeTabIndex?: number;
enableMenu?: boolean;
};
export type NavItem =
export type NavigationItem =
| "General"
| "Network"
| "AddServer"
| "Organizations"
| "Shortcuts";
export type ServerConf = {
export type ServerConfig = {
url: string;
alias: string;
icon: string;
zulipVersion: string;
zulipFeatureLevel: number;
};
export type TabRole = "server" | "function";
export type TabPage = "Settings" | "About";
export type TabData = {
role: TabRole;
name: string;
page?: TabPage;
label: string;
index: number;
};

View File

@@ -2,13 +2,17 @@ import {shell} from "electron/common";
import {app, dialog, session} from "electron/main";
import process from "node:process";
import log from "electron-log";
import type {UpdateDownloadedEvent, UpdateInfo} from "electron-updater";
import {autoUpdater} from "electron-updater";
import log from "electron-log/main";
import {
type UpdateDownloadedEvent,
type UpdateInfo,
autoUpdater,
} from "electron-updater";
import * as ConfigUtil from "../common/config-util.js";
import * as ConfigUtil from "../common/config-util.ts";
import * as t from "../common/translation-util.ts";
import {linuxUpdateNotification} from "./linuxupdater.js"; // Required only in case of linux
import {linuxUpdateNotification} from "./linuxupdater.ts"; // Required only in case of linux
let quitting = false;
@@ -30,10 +34,11 @@ export async function appUpdater(updateFromMenu = false): Promise<void> {
let updateAvailable = false;
// Log whats happening
log.transports.file.fileName = "updates.log";
log.transports.file.level = "info";
autoUpdater.logger = log;
// Log what's happening
const updateLogger = log.create({logId: "updates"});
updateLogger.transports.file.fileName = "updates.log";
updateLogger.transports.file.level = "info";
autoUpdater.logger = updateLogger;
// Handle auto updates for beta/pre releases
const isBetaUpdate = ConfigUtil.getConfigItem("betaUpdate", false);
@@ -54,9 +59,13 @@ export async function appUpdater(updateFromMenu = false): Promise<void> {
}
await dialog.showMessageBox({
message: `A new version ${info.version}, of Zulip Desktop is available`,
detail:
message: t.__(
"A new version {{{version}}} of Zulip Desktop is available.",
{version: info.version},
),
detail: t.__(
"The update will be downloaded in the background. You will be notified when it is ready to be installed.",
),
});
}
});
@@ -68,8 +77,11 @@ export async function appUpdater(updateFromMenu = false): Promise<void> {
autoUpdater.removeAllListeners();
await dialog.showMessageBox({
message: "No updates available",
detail: `You are running the latest version of Zulip Desktop.\nVersion: ${app.getVersion()}`,
message: t.__("No updates available."),
detail: t.__(
"You are running the latest version of Zulip Desktop.\nVersion: {{{version}}}",
{version: app.getVersion()},
),
});
}
});
@@ -81,20 +93,20 @@ export async function appUpdater(updateFromMenu = false): Promise<void> {
autoUpdater.removeAllListeners();
const messageText = updateAvailable
? "Unable to download the updates"
: "Unable to check for updates";
? t.__("Unable to download the update.")
: t.__("Unable to check for updates.");
const link = "https://zulip.com/apps/";
const {response} = await dialog.showMessageBox({
type: "error",
buttons: ["Manual Download", "Cancel"],
buttons: [t.__("Manual Download"), t.__("Cancel")],
message: messageText,
detail: `Error: ${error.message}
The latest version of Zulip Desktop is available at -
https://zulip.com/apps/.
Current Version: ${app.getVersion()}`,
detail: t.__(
"Error: {{{error}}}\n\nThe latest version of Zulip Desktop is available at:\n{{{link}}}\nCurrent version: {{{version}}}",
{error: error.message, link, version: app.getVersion()},
),
});
if (response === 0) {
await shell.openExternal("https://zulip.com/apps/");
await shell.openExternal(link);
}
}
});
@@ -104,10 +116,14 @@ Current Version: ${app.getVersion()}`,
// Ask user to update the app
const {response} = await dialog.showMessageBox({
type: "question",
buttons: ["Install and Relaunch", "Install Later"],
buttons: [t.__("Install and Relaunch"), t.__("Install Later")],
defaultId: 0,
message: `A new update ${event.version} has been downloaded`,
detail: "It will be installed the next time you restart the application",
message: t.__("A new update {{{version}}} has been downloaded.", {
version: event.version,
}),
detail: t.__(
"It will be installed the next time you restart the application.",
),
});
if (response === 0) {
quitting = true;

View File

@@ -1,11 +1,10 @@
import {nativeImage} from "electron/common";
import type {BrowserWindow} from "electron/main";
import {app} from "electron/main";
import {type BrowserWindow, app} from "electron/main";
import process from "node:process";
import * as ConfigUtil from "../common/config-util.js";
import * as ConfigUtil from "../common/config-util.ts";
import {send} from "./typed-ipc-main.js";
import {send} from "./typed-ipc-main.ts";
function showBadgeCount(messageCount: number, mainWindow: BrowserWindow): void {
if (process.platform === "win32") {

View File

@@ -1,17 +1,19 @@
import {shell} from "electron/common";
import type {
HandlerDetails,
SaveDialogOptions,
WebContents,
import {type Event, shell} from "electron/common";
import {
type HandlerDetails,
Notification,
type SaveDialogOptions,
type WebContents,
app,
} 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.js";
import * as LinkUtil from "../common/link-util.js";
import * as ConfigUtil from "../common/config-util.ts";
import * as LinkUtil from "../common/link-util.ts";
import * as t from "../common/translation-util.ts";
import {send} from "./typed-ipc-main.js";
import {send} from "./typed-ipc-main.ts";
function isUploadsUrl(server: string, url: URL): boolean {
return url.origin === server && url.pathname.startsWith("/user_uploads/");
@@ -31,7 +33,7 @@ function downloadFile({
failed(state: string): void;
}) {
contents.downloadURL(url);
contents.session.once("will-download", async (_event: Event, item) => {
contents.session.once("will-download", async (_event, item) => {
if (ConfigUtil.getConfigItem("promptDownload", false)) {
const showDialogOptions: SaveDialogOptions = {
defaultPath: path.join(downloadPath, item.getFilename()),
@@ -86,7 +88,7 @@ function downloadFile({
};
item.on("updated", updatedListener);
item.once("done", async (_event: Event, state) => {
item.once("done", async (_event, state) => {
if (state === "completed") {
await completed(item.getSavePath(), path.basename(item.getSavePath()));
} else {
@@ -124,8 +126,8 @@ export default function handleExternalLink(
downloadPath,
async completed(filePath: string, fileName: string) {
const downloadNotification = new Notification({
title: "Download Complete",
body: `Click to show ${fileName} in folder`,
title: t.__("Download Complete"),
body: t.__("Click to show {{{fileName}}} in folder", {fileName}),
silent: true, // We'll play our own sound - ding.ogg
});
downloadNotification.on("click", () => {
@@ -148,8 +150,8 @@ export default function handleExternalLink(
if (state !== "cancelled") {
if (ConfigUtil.getConfigItem("promptDownload", false)) {
new Notification({
title: "Download Complete",
body: "Download failed",
title: t.__("Download Complete"),
body: t.__("Download failed"),
}).show();
} else {
contents.downloadURL(url.href);

View File

@@ -1,30 +1,38 @@
import {clipboard} from "electron/common";
import type {IpcMainEvent, WebContents} from "electron/main";
import {BrowserWindow, app, dialog, powerMonitor, session} from "electron/main";
import {
BrowserWindow,
type IpcMainEvent,
type WebContents,
app,
dialog,
powerMonitor,
session,
webContents,
} from "electron/main";
import {Buffer} from "node:buffer";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import * as remoteMain from "@electron/remote/main";
import windowStateKeeper from "electron-window-state";
import * as ConfigUtil from "../common/config-util.js";
import {bundlePath, bundleUrl, publicPath} from "../common/paths.js";
import type {RendererMessage} from "../common/typed-ipc.js";
import type {MenuProps} from "../common/types.js";
import * as ConfigUtil from "../common/config-util.ts";
import {bundlePath, bundleUrl, publicPath} from "../common/paths.ts";
import * as t from "../common/translation-util.ts";
import type {RendererMessage} from "../common/typed-ipc.ts";
import type {MenuProperties} from "../common/types.ts";
import {appUpdater, shouldQuitForUpdate} from "./autoupdater.js";
import * as BadgeSettings from "./badge-settings.js";
import handleExternalLink from "./handle-external-link.js";
import * as AppMenu from "./menu.js";
import {_getServerSettings, _isOnline, _saveServerIcon} from "./request.js";
import {sentryInit} from "./sentry.js";
import {setAutoLaunch} from "./startup.js";
import {ipcMain, send} from "./typed-ipc-main.js";
import {appUpdater, shouldQuitForUpdate} from "./autoupdater.ts";
import * as BadgeSettings from "./badge-settings.ts";
import handleExternalLink from "./handle-external-link.ts";
import * as AppMenu from "./menu.ts";
import {_getServerSettings, _isOnline, _saveServerIcon} from "./request.ts";
import {sentryInit} from "./sentry.ts";
import {setAutoLaunch} from "./startup.ts";
import {ipcMain, send} from "./typed-ipc-main.ts";
import "gatemaker/electron-setup"; // eslint-disable-line import/no-unassigned-import
import "gatemaker/electron-setup.js"; // eslint-disable-line import-x/no-unassigned-import
// eslint-disable-next-line @typescript-eslint/naming-convention
const {GDK_BACKEND} = process.env;
@@ -79,7 +87,7 @@ function createMainWindow(): BrowserWindow {
minWidth: 500,
minHeight: 400,
webPreferences: {
preload: path.join(bundlePath, "renderer.js"),
preload: path.join(bundlePath, "renderer.cjs"),
sandbox: false,
webviewTag: true,
},
@@ -103,7 +111,14 @@ function createMainWindow(): BrowserWindow {
event.preventDefault();
if (process.platform === "darwin") {
app.hide();
if (win.isFullScreen()) {
win.setFullScreen(false);
win.once("leave-full-screen", () => {
app.hide();
});
} else {
app.hide();
}
} else {
win.hide();
}
@@ -171,7 +186,7 @@ function createMainWindow(): BrowserWindow {
ipcMain.on(
"permission-callback",
(event: Event, permissionCallbackId: number, grant: boolean) => {
(event, permissionCallbackId: number, grant: boolean) => {
permissionCallbacks.get(permissionCallbackId)?.(grant);
permissionCallbacks.delete(permissionCallbackId);
},
@@ -182,7 +197,7 @@ function createMainWindow(): BrowserWindow {
mainWindow.show();
});
app.on("web-contents-created", (_event: Event, contents: WebContents) => {
app.on("web-contents-created", (_event, contents: WebContents) => {
contents.setWindowOpenHandler((details) => {
handleExternalLink(contents, details, page);
return {action: "deny"};
@@ -206,13 +221,6 @@ function createMainWindow(): BrowserWindow {
configureSpellChecker();
ipcMain.on("configure-spell-checker", configureSpellChecker);
ipcMain.on("get-injected-js", (event) => {
event.returnValue = fs.readFileSync(
path.join(bundlePath, "injected.js"),
"utf8",
);
});
const clipboardSigKey = crypto.randomBytes(32);
ipcMain.on("new-clipboard-key", (event) => {
@@ -231,9 +239,9 @@ function createMainWindow(): BrowserWindow {
try {
// Check that the data on the clipboard was encrypted to the key.
const data = Buffer.from(clipboard.readText(), "hex");
const iv = data.slice(0, 12);
const ciphertext = data.slice(12, -16);
const authTag = data.slice(-16);
const iv = data.subarray(0, 12);
const ciphertext = data.subarray(12, -16);
const authTag = data.subarray(-16);
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv, {
authTagLength: 16,
});
@@ -299,18 +307,25 @@ function createMainWindow(): BrowserWindow {
app.on(
"certificate-error",
(
event: Event,
webContents: WebContents,
urlString: string,
error: string,
event,
webContents,
urlString,
error,
certificate,
callback,
isMainFrame,
// eslint-disable-next-line max-params
) => {
const url = new URL(urlString);
dialog.showErrorBox(
"Certificate error",
`The server presented an invalid certificate for ${url.origin}:
${error}`,
);
if (isMainFrame) {
const url = new URL(urlString);
dialog.showErrorBox(
t.__("Certificate error"),
t.__(
"The server presented an invalid certificate for {{{origin}}}:\n\n{{{error}}}",
{origin: url.origin, error},
),
);
}
},
);
@@ -369,24 +384,21 @@ ${error}`,
BadgeSettings.updateBadge(badgeCount, mainWindow);
});
ipcMain.on("toggle-menubar", (_event: IpcMainEvent, showMenubar: boolean) => {
ipcMain.on("toggle-menubar", (_event, showMenubar: boolean) => {
mainWindow.autoHideMenuBar = showMenubar;
mainWindow.setMenuBarVisibility(!showMenubar);
send(page, "toggle-autohide-menubar", showMenubar, true);
});
ipcMain.on("update-badge", (_event: IpcMainEvent, messageCount: number) => {
ipcMain.on("update-badge", (_event, messageCount: number) => {
badgeCount = messageCount;
BadgeSettings.updateBadge(badgeCount, mainWindow);
send(page, "tray", messageCount);
});
ipcMain.on(
"update-taskbar-icon",
(_event: IpcMainEvent, data: string, text: string) => {
BadgeSettings.updateTaskbarIcon(data, text, mainWindow);
},
);
ipcMain.on("update-taskbar-icon", (_event, data: string, text: string) => {
BadgeSettings.updateTaskbarIcon(data, text, mainWindow);
});
ipcMain.on(
"forward-message",
@@ -399,40 +411,52 @@ ${error}`,
},
);
ipcMain.on("update-menu", (_event: IpcMainEvent, props: MenuProps) => {
AppMenu.setMenu(props);
if (props.activeTabIndex !== undefined) {
const activeTab = props.tabs[props.activeTabIndex];
mainWindow.setTitle(`Zulip - ${activeTab.name}`);
}
});
ipcMain.on(
"toggleAutoLauncher",
async (_event: IpcMainEvent, AutoLaunchValue: boolean) => {
await setAutoLaunch(AutoLaunchValue);
"forward-to",
<Channel extends keyof RendererMessage>(
_event: IpcMainEvent,
webContentsId: number,
listener: Channel,
...parameters: Parameters<RendererMessage[Channel]>
) => {
const contents = webContents.fromId(webContentsId);
if (contents !== undefined) {
send(contents, listener, ...parameters);
}
},
);
ipcMain.on("update-menu", (_event, properties: MenuProperties) => {
AppMenu.setMenu(properties);
if (properties.activeTabIndex !== undefined) {
const activeTab = properties.tabs[properties.activeTabIndex];
mainWindow.setTitle(`Zulip - ${activeTab.label}`);
}
});
ipcMain.on("toggleAutoLauncher", async (_event, AutoLaunchValue: boolean) => {
await setAutoLaunch(AutoLaunchValue);
});
ipcMain.on(
"realm-name-changed",
(_event: IpcMainEvent, serverURL: string, realmName: string) => {
(_event, serverURL: string, realmName: string) => {
send(page, "update-realm-name", serverURL, realmName);
},
);
ipcMain.on(
"realm-icon-changed",
(_event: IpcMainEvent, serverURL: string, iconURL: string) => {
(_event, serverURL: string, iconURL: string) => {
send(page, "update-realm-icon", serverURL, iconURL);
},
);
ipcMain.on("save-last-tab", (_event: IpcMainEvent, index: number) => {
ipcMain.on("save-last-tab", (_event, index: number) => {
ConfigUtil.setConfigItem("lastActiveTab", index);
});
ipcMain.on("focus-this-webview", (event: IpcMainEvent) => {
ipcMain.on("focus-this-webview", (event) => {
send(page, "focus-webview-with-id", event.sender.id);
mainWindow.show();
});

View File

@@ -3,26 +3,27 @@ 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 {DataError} from "node-json-db/dist/lib/Errors.js";
import Logger from "../common/logger-util.js";
import Logger from "../common/logger-util.ts";
import * as t from "../common/translation-util.ts";
const logger = new Logger({
file: "linux-update-util.log",
});
let db: JsonDB;
let database: JsonDB;
reloadDb();
reloadDatabase();
export function getUpdateItem(
key: string,
defaultValue: true | null = null,
): true | null {
reloadDb();
reloadDatabase();
let value: unknown;
try {
value = db.getObject<unknown>(`/${key}`);
value = database.getObject<unknown>(`/${key}`);
} catch (error: unknown) {
if (!(error instanceof DataError)) throw error;
}
@@ -36,16 +37,16 @@ export function getUpdateItem(
}
export function setUpdateItem(key: string, value: true | null): void {
db.push(`/${key}`, value, true);
reloadDb();
database.push(`/${key}`, value, true);
reloadDatabase();
}
export function removeUpdateItem(key: string): void {
db.delete(`/${key}`);
reloadDb();
database.delete(`/${key}`);
reloadDatabase();
}
function reloadDb(): void {
function reloadDatabase(): void {
const linuxUpdateJsonPath = path.join(
app.getPath("userData"),
"/config/updates.json",
@@ -57,13 +58,13 @@ function reloadDb(): void {
if (fs.existsSync(linuxUpdateJsonPath)) {
fs.unlinkSync(linuxUpdateJsonPath);
dialog.showErrorBox(
"Error saving update notifications.",
"We encountered an error while saving the update notifications.",
t.__("Error saving update notifications"),
t.__("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);
database = new JsonDB(linuxUpdateJsonPath, true, true);
}

View File

@@ -1,15 +1,13 @@
import type {Session} from "electron/main";
import {Notification, app, net} from "electron/main";
import {Notification, type Session, app} from "electron/main";
import getStream from "get-stream";
import * as semver from "semver";
import * as z from "zod";
import {z} from "zod";
import * as ConfigUtil from "../common/config-util.js";
import Logger from "../common/logger-util.js";
import * as ConfigUtil from "../common/config-util.ts";
import Logger from "../common/logger-util.ts";
import * as t from "../common/translation-util.ts";
import * as LinuxUpdateUtil from "./linux-update-util.js";
import {fetchResponse} from "./request.js";
import * as LinuxUpdateUtil from "./linux-update-util.ts";
const logger = new Logger({
file: "linux-update-util.log",
@@ -20,13 +18,13 @@ export async function linuxUpdateNotification(session: Session): Promise<void> {
url = ConfigUtil.getConfigItem("betaUpdate", false) ? url : url + "/latest";
try {
const response = await fetchResponse(net.request({url, session}));
if (response.statusCode !== 200) {
logger.log("Linux update response status: ", response.statusCode);
const response = await session.fetch(url);
if (!response.ok) {
logger.log("Linux update response status: ", response.status);
return;
}
const data: unknown = JSON.parse(await getStream(response));
const data: unknown = await response.json();
/* eslint-disable @typescript-eslint/naming-convention */
const latestVersion = ConfigUtil.getConfigItem("betaUpdate", false)
? z.array(z.object({tag_name: z.string()})).parse(data)[0].tag_name
@@ -37,8 +35,11 @@ export async function linuxUpdateNotification(session: Session): Promise<void> {
const notified = LinuxUpdateUtil.getUpdateItem(latestVersion);
if (notified === null) {
new Notification({
title: "Zulip Update",
body: `A new version ${latestVersion} is available. Please update using your package manager.`,
title: t.__("Zulip Update"),
body: t.__(
"A new version {{{version}}} is available. Please update using your package manager.",
{version: latestVersion},
),
}).show();
LinuxUpdateUtil.setUpdateItem(latestVersion, true);
}

View File

@@ -1,18 +1,22 @@
import {shell} from "electron/common";
import type {MenuItemConstructorOptions} from "electron/main";
import {BrowserWindow, Menu, app} from "electron/main";
import {
BrowserWindow,
Menu,
type MenuItemConstructorOptions,
app,
} from "electron/main";
import process from "node:process";
import AdmZip from "adm-zip";
import * as ConfigUtil from "../common/config-util.js";
import * as DNDUtil from "../common/dnd-util.js";
import * as t from "../common/translation-util.js";
import type {RendererMessage} from "../common/typed-ipc.js";
import type {MenuProps, TabData} from "../common/types.js";
import * as ConfigUtil from "../common/config-util.ts";
import * as DNDUtil from "../common/dnd-util.ts";
import * as t from "../common/translation-util.ts";
import type {RendererMessage} from "../common/typed-ipc.ts";
import type {MenuProperties, TabData} from "../common/types.ts";
import {appUpdater} from "./autoupdater.js";
import {send} from "./typed-ipc-main.js";
import {appUpdater} from "./autoupdater.ts";
import {send} from "./typed-ipc-main.ts";
const appName = app.name;
@@ -66,7 +70,7 @@ function getToolsSubmenu(): MenuItemConstructorOptions[] {
click() {
const zip = new AdmZip();
const date = new Date();
const dateString = date.toLocaleDateString().replace(/\//g, "-");
const dateString = date.toLocaleDateString().replaceAll("/", "-");
// Create a zip file of all the logs and config data
zip.addLocalFolder(`${app.getPath("appData")}/${appName}/Logs`);
@@ -90,7 +94,7 @@ function getToolsSubmenu(): MenuItemConstructorOptions[] {
accelerator:
process.platform === "darwin" ? "Alt+Command+I" : "Ctrl+Shift+I",
click(_item, focusedWindow) {
if (focusedWindow) {
if (focusedWindow instanceof BrowserWindow) {
focusedWindow.webContents.openDevTools({mode: "undocked"});
}
},
@@ -218,7 +222,7 @@ function getViewSubmenu(): MenuItemConstructorOptions[] {
{
label: t.__("Toggle Tray Icon"),
click(_item, focusedWindow) {
if (focusedWindow) {
if (focusedWindow instanceof BrowserWindow) {
send(focusedWindow.webContents, "toggletray");
}
},
@@ -227,7 +231,7 @@ function getViewSubmenu(): MenuItemConstructorOptions[] {
label: t.__("Toggle Sidebar"),
accelerator: "CommandOrControl+Shift+S",
click(_item, focusedWindow) {
if (focusedWindow) {
if (focusedWindow instanceof BrowserWindow) {
const newValue = !ConfigUtil.getConfigItem("showSidebar", true);
send(focusedWindow.webContents, "toggle-sidebar", newValue);
ConfigUtil.setConfigItem("showSidebar", newValue);
@@ -239,7 +243,7 @@ function getViewSubmenu(): MenuItemConstructorOptions[] {
checked: ConfigUtil.getConfigItem("autoHideMenubar", false),
visible: process.platform !== "darwin",
click(_item, focusedWindow) {
if (focusedWindow) {
if (focusedWindow instanceof BrowserWindow) {
const newValue = !ConfigUtil.getConfigItem("autoHideMenubar", false);
focusedWindow.autoHideMenuBar = newValue;
focusedWindow.setMenuBarVisibility(!newValue);
@@ -314,12 +318,12 @@ function getWindowSubmenu(
if (tab === undefined) continue;
// Do not add functional tab settings to list of windows in menu bar
if (tab.role === "function" && tab.name === "Settings") {
if (tab.role === "function" && tab.page === "Settings") {
continue;
}
initialSubmenu.push({
label: tab.name,
label: tab.label,
accelerator:
tab.role === "function" ? "" : `${shortcutKey} + ${tab.index + 1}`,
checked: tab.index === activeTabIndex,
@@ -368,8 +372,10 @@ function getWindowSubmenu(
return initialSubmenu;
}
function getDarwinTpl(props: MenuProps): MenuItemConstructorOptions[] {
const {tabs, activeTabIndex, enableMenu = false} = props;
function getDarwinTpl(
properties: MenuProperties,
): MenuItemConstructorOptions[] {
const {tabs, activeTabIndex, enableMenu = false} = properties;
return [
{
@@ -533,8 +539,8 @@ function getDarwinTpl(props: MenuProps): MenuItemConstructorOptions[] {
];
}
function getOtherTpl(props: MenuProps): MenuItemConstructorOptions[] {
const {tabs, activeTabIndex, enableMenu = false} = props;
function getOtherTpl(properties: MenuProperties): MenuItemConstructorOptions[] {
const {tabs, activeTabIndex, enableMenu = false} = properties;
return [
{
label: t.__("File"),
@@ -683,7 +689,7 @@ function getOtherTpl(props: MenuProps): MenuItemConstructorOptions[] {
function sendAction<Channel extends keyof RendererMessage>(
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
...arguments_: Parameters<RendererMessage[Channel]>
): void {
const win = BrowserWindow.getAllWindows()[0];
@@ -691,7 +697,7 @@ function sendAction<Channel extends keyof RendererMessage>(
win.restore();
}
send(win.webContents, channel, ...args);
send(win.webContents, channel, ...arguments_);
}
async function checkForUpdate(): Promise<void> {
@@ -714,9 +720,11 @@ function getPreviousServer(tabs: TabData[], activeTabIndex: number): number {
return activeTabIndex;
}
export function setMenu(props: MenuProps): void {
export function setMenu(properties: MenuProperties): void {
const tpl =
process.platform === "darwin" ? getDarwinTpl(props) : getOtherTpl(props);
process.platform === "darwin"
? getDarwinTpl(properties)
: getOtherTpl(properties);
const menu = Menu.buildFromTemplate(tpl);
Menu.setApplicationMenu(menu);
}

View File

@@ -1,43 +1,25 @@
import type {ClientRequest, IncomingMessage, Session} from "electron/main";
import {app, net} from "electron/main";
import {type Session, app} from "electron/main";
import fs from "node:fs";
import path from "node:path";
import stream from "node:stream";
import util from "node:util";
import {Readable} from "node:stream";
import {pipeline} from "node:stream/promises";
import type {ReadableStream} from "node:stream/web";
import * as Sentry from "@sentry/electron";
import getStream from "get-stream";
import * as z from "zod";
import * as Sentry from "@sentry/electron/main";
import {z} from "zod";
import Logger from "../common/logger-util.js";
import * as Messages from "../common/messages.js";
import type {ServerConf} from "../common/types.js";
export async function fetchResponse(
request: ClientRequest,
): Promise<IncomingMessage> {
return new Promise((resolve, reject) => {
request.on("response", resolve);
request.on("abort", () => {
reject(new Error("Request aborted"));
});
request.on("error", reject);
request.end();
});
}
const pipeline = util.promisify(stream.pipeline);
import Logger from "../common/logger-util.ts";
import * as Messages from "../common/messages.ts";
import type {ServerConfig} from "../common/types.ts";
/* Request: domain-util */
const defaultIconUrl = "../renderer/img/icon.png";
const logger = new Logger({
file: "domain-util.log",
});
const generateFilePath = (url: string): string => {
const dir = `${app.getPath("userData")}/server-icons`;
const directory = `${app.getPath("userData")}/server-icons`;
const extension = path.extname(url).split("?")[0];
let hash = 5381;
@@ -49,35 +31,38 @@ const generateFilePath = (url: string): string => {
}
// Create 'server-icons' directory if not existed
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory);
}
// eslint-disable-next-line no-bitwise
return `${dir}/${hash >>> 0}${extension}`;
return `${directory}/${hash >>> 0}${extension}`;
};
export const _getServerSettings = async (
domain: string,
session: Session,
): Promise<ServerConf> => {
const response = await fetchResponse(
net.request({
url: domain + "/api/v1/server_settings",
session,
}),
);
if (response.statusCode !== 200) {
): Promise<ServerConfig> => {
const response = await session.fetch(domain + "/api/v1/server_settings");
if (!response.ok) {
throw new Error(Messages.invalidZulipServerError(domain));
}
const data: unknown = JSON.parse(await getStream(response));
const data: unknown = await response.json();
/* eslint-disable @typescript-eslint/naming-convention */
const {realm_name, realm_uri, realm_icon} = z
const {
realm_name,
realm_uri,
realm_icon,
zulip_version,
zulip_feature_level,
} = z
.object({
realm_name: z.string(),
realm_uri: z.string(),
realm_uri: z.url(),
realm_icon: z.string(),
zulip_version: z.string().default("unknown"),
zulip_feature_level: z.number().default(0),
})
.parse(data);
/* eslint-enable @typescript-eslint/naming-convention */
@@ -88,28 +73,33 @@ export const _getServerSettings = async (
icon: realm_icon.startsWith("/") ? realm_uri + realm_icon : realm_icon,
url: realm_uri,
alias: realm_name,
zulipVersion: zulip_version,
zulipFeatureLevel: zulip_feature_level,
};
};
export const _saveServerIcon = async (
url: string,
session: Session,
): Promise<string> => {
): Promise<string | null> => {
try {
const response = await fetchResponse(net.request({url, session}));
if (response.statusCode !== 200) {
const response = await session.fetch(url);
if (!response.ok) {
logger.log("Could not get server icon.");
return defaultIconUrl;
return null;
}
const filePath = generateFilePath(url);
await pipeline(response, fs.createWriteStream(filePath));
await pipeline(
Readable.fromWeb(response.body as ReadableStream<Uint8Array>),
fs.createWriteStream(filePath),
);
return filePath;
} catch (error: unknown) {
logger.log("Could not get server icon.");
logger.log(error);
Sentry.captureException(error);
return defaultIconUrl;
return null;
}
};
@@ -120,16 +110,10 @@ export const _isOnline = async (
session: Session,
): Promise<boolean> => {
try {
const response = await fetchResponse(
net.request({
method: "HEAD",
url: `${url}/api/v1/server_settings`,
session,
}),
);
const isValidResponse =
response.statusCode >= 200 && response.statusCode < 400;
return isValidResponse;
const response = await session.fetch(`${url}/api/v1/server_settings`, {
method: "HEAD",
});
return response.ok;
} catch (error: unknown) {
logger.log(error);
return false;

View File

@@ -1,8 +1,8 @@
import {app} from "electron/main";
import * as Sentry from "@sentry/electron/main"; // eslint-disable-line n/file-extension-in-import
import * as Sentry from "@sentry/electron/main";
import {getConfigItem} from "../common/config-util.js";
import {getConfigItem} from "../common/config-util.ts";
export const sentryInit = (): void => {
Sentry.init({

View File

@@ -3,7 +3,7 @@ import process from "node:process";
import AutoLaunch from "auto-launch";
import * as ConfigUtil from "../common/config-util.js";
import * as ConfigUtil from "../common/config-util.ts";
export const setAutoLaunch = async (
AutoLaunchValue: boolean,

View File

@@ -1,9 +1,7 @@
import type {
IpcMainEvent,
IpcMainInvokeEvent,
WebContents,
} from "electron/main";
import {
type IpcMainEvent,
type IpcMainInvokeEvent,
type WebContents,
ipcMain as untypedIpcMain, // eslint-disable-line no-restricted-imports
} from "electron/main";
@@ -14,14 +12,20 @@ import type {
} 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
MainMessage[Channel] extends (...arguments_: infer Arguments) => infer Return
? (
event: IpcMainEvent & {returnValue: Return},
...arguments_: Arguments
) => void
: never;
type MainHandler<Channel extends keyof MainCall> = MainCall[Channel] extends (
...args: infer Args
...arguments_: infer Arguments
) => infer Return
? (event: IpcMainInvokeEvent, ...args: Args) => Return | Promise<Return>
? (
event: IpcMainInvokeEvent,
...arguments_: Arguments
) => Return | Promise<Return>
: never;
export const ipcMain: {
@@ -30,7 +34,16 @@ export const ipcMain: {
listener: <Channel extends keyof RendererMessage>(
event: IpcMainEvent,
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
...arguments_: Parameters<RendererMessage[Channel]>
) => void,
): void;
on(
channel: "forward-to",
listener: <Channel extends keyof RendererMessage>(
event: IpcMainEvent,
webContentsId: number,
channel: Channel,
...arguments_: Parameters<RendererMessage[Channel]>
) => void,
): void;
on<Channel extends keyof MainMessage>(
@@ -60,16 +73,16 @@ export const ipcMain: {
export function send<Channel extends keyof RendererMessage>(
contents: WebContents,
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
...arguments_: Parameters<RendererMessage[Channel]>
): void {
contents.send(channel, ...args);
contents.send(channel, ...arguments_);
}
export function sendToFrame<Channel extends keyof RendererMessage>(
contents: WebContents,
frameId: number | [number, number],
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
...arguments_: Parameters<RendererMessage[Channel]>
): void {
contents.sendToFrame(frameId, channel, ...args);
contents.sendToFrame(frameId, channel, ...arguments_);
}

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<meta charset="UTF-8" />
<link rel="stylesheet" href="css/about.css" />
@@ -6,21 +6,4 @@
<div class="about" hidden>
<img class="logo" src="../resources/zulip.png" />
<p class="detail" id="version"></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>

View File

@@ -47,7 +47,6 @@
}
.maintenance-info {
cursor: pointer;
position: absolute;
width: 100%;
left: 0;

View File

@@ -2,7 +2,9 @@
font-family: "Material Icons";
font-style: normal;
font-weight: 400;
src: local("Material Icons"), local("MaterialIcons-Regular"),
src:
local("Material Icons"),
local("MaterialIcons-Regular"),
url("../fonts/MaterialIcons-Regular.ttf") format("truetype");
}

View File

@@ -44,6 +44,7 @@ body {
#view-controls-container {
height: calc(100% - 208px);
scrollbar-gutter: stable both-edges;
overflow-y: hidden;
}
@@ -52,16 +53,15 @@ body {
}
#view-controls-container::-webkit-scrollbar-track {
box-shadow: inset 0 0 6px rgb(0 0 0 / 30%);
background-color: rgb(0 0 0 / 30%);
}
#view-controls-container::-webkit-scrollbar-thumb {
background-color: rgb(169 169 169 / 100%);
outline: 1px solid rgb(169 169 169 / 100%);
}
#view-controls-container:hover {
overflow-y: overlay;
overflow-y: scroll;
}
/*******************
@@ -85,7 +85,7 @@ body {
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
overflow-wrap: normal;
white-space: nowrap;
direction: ltr;
@@ -114,12 +114,20 @@ body {
}
.action-button i {
color: rgb(108 133 146 / 100%);
color: hsl(200.53deg 14.96% 49.8%);
font-size: 28px;
}
.action-button:hover i {
color: rgb(152 169 179 / 100%);
color: hsl(202.22deg 15.08% 64.9%);
}
.action-button > .dnd-on {
color: hsl(200.53deg 14.96% 85%);
}
.action-button:hover > .dnd-on {
color: hsl(202.22deg 15.08% 95%);
}
.action-button.active {
@@ -290,7 +298,9 @@ body {
content: "";
position: absolute;
z-index: 1;
background: rgb(255 255 255 / 100%) url("../img/ic_loading.gif") no-repeat;
/* Spinner is released under loading.io free License: https://loading.io/license/#free-license */
background: rgb(255 255 255 / 100%) url("../img/ic_loading.svg") no-repeat;
background-size: 60px 60px;
background-position: center;
width: 100%;
@@ -303,7 +313,7 @@ body {
visibility: hidden;
}
webview,
.webview-pane,
.functional-view {
position: absolute;
width: 100%;
@@ -312,7 +322,16 @@ webview,
visibility: hidden;
}
webview.active,
.webview-pane {
display: flex;
flex-direction: column;
}
.webview-pane > webview {
flex: 1;
}
.webview-pane.active,
.functional-view.active {
z-index: 1;
visibility: visible;
@@ -322,6 +341,30 @@ webview.focus {
outline: 0 solid transparent;
}
.webview-unsupported {
background: rgb(254 243 199);
border: 1px solid rgb(253 230 138);
color: rgb(69 26 3);
font-family: system-ui;
font-size: 14px;
display: flex;
}
.webview-unsupported[hidden] {
display: none;
}
.webview-unsupported-message {
padding: 0.3em;
flex: 1;
text-align: center;
}
.webview-unsupported-dismiss {
padding: 0.3em;
cursor: pointer;
}
/* Tooltip styling */
#loading-tooltip,

View File

@@ -1,3 +1,5 @@
@import url("@yaireo/tagify/dist/tagify.css");
:host {
contain: strict;
display: flow-root;
@@ -9,7 +11,12 @@
background: rgb(239 239 239 / 100%);
letter-spacing: -0.08px;
line-height: 18px;
color: rgb(139 142 143 / 100%);
color: rgb(34 44 49 / 100%);
/* Copied from https://github.com/yairEO/tagify/blob/v4.17.7/src/tagify.scss#L4-L8 */
--tagify-dd-color-primary: rgb(53 149 246);
--tagify-dd-bg-color: rgb(255 255 255);
--tagify-dd-item-pad: 0.3em 0.5em;
}
kbd {
@@ -61,7 +68,7 @@ td:nth-child(odd) {
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
overflow-wrap: normal;
white-space: nowrap;
direction: ltr;
@@ -94,7 +101,7 @@ td:nth-child(odd) {
.nav {
padding: 7px 0;
color: rgb(153 153 153 / 100%);
color: rgb(70 78 90 / 100%);
cursor: pointer;
}
@@ -300,7 +307,9 @@ img.server-info-icon {
}
.settings-card:hover {
box-shadow: 0 2px 5px 0 rgb(0 0 0 / 16%), 0 2px 0 0 rgb(0 0 0 / 12%);
box-shadow:
0 2px 5px 0 rgb(0 0 0 / 16%),
0 2px 0 0 rgb(0 0 0 / 12%);
}
.hidden {
@@ -475,10 +484,7 @@ input.toggle-round + label::after {
input.toggle-round + label::before {
background-color: rgb(241 241 241 / 100%);
border-radius: 25px;
top: 0;
right: 0;
left: 0;
bottom: 0;
inset: 0;
}
input.toggle-round + label::after {
@@ -490,10 +496,7 @@ input.toggle-round + label::after {
input.toggle-round:checked + label::before {
background-color: rgb(78 191 172 / 100%);
top: 0;
right: 0;
left: 0;
bottom: 0;
inset: 0;
}
input.toggle-round:checked + label::after {
@@ -575,7 +578,6 @@ input.toggle-round:checked + label::after {
text-align: center;
color: rgb(255 255 255 / 100%);
background: rgb(78 191 172 / 100%);
border-color: none;
border: none;
width: 98%;
height: 46px;
@@ -651,7 +653,7 @@ i.open-network-button {
}
/* responsive grid */
@media (min-width: 500px) and (max-width: 720px) {
@media (width >= 500px) and (width <= 720px) {
#new-server-container {
padding-left: 0;
width: 60vw;
@@ -663,7 +665,7 @@ i.open-network-button {
}
}
@media (max-width: 500px) {
@media (width <= 500px) {
#new-server-container {
padding-left: 0;
width: 54%;
@@ -674,7 +676,7 @@ i.open-network-button {
}
}
@media (max-width: 650px) {
@media (width <= 650px) {
.selected-css-path,
.download-folder-path {
margin-right: 15px;
@@ -689,7 +691,7 @@ i.open-network-button {
}
}
@media (max-width: 720px) {
@media (width <= 720px) {
.modal-container {
width: 60vw;
padding: 40px;
@@ -712,7 +714,7 @@ i.open-network-button {
}
}
@media (max-width: 600px) {
@media (width <= 600px) {
.divider {
margin-left: 4%;
}
@@ -724,7 +726,7 @@ i.open-network-button {
}
}
@media (max-width: 900px) {
@media (width <= 900px) {
.settings-card {
flex-direction: column;
align-items: center;
@@ -760,3 +762,9 @@ i.open-network-button {
top: 0;
bottom: 0;
}
.settings-tagify-dropdown {
position: relative;
z-index: 9999;
height: 0;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin: auto; background: rgba(0, 0, 0, 0) none repeat scroll 0% 0%; display: block; shape-rendering: auto; animation-play-state: running; animation-delay: 0s;" width="150px" height="150px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<circle cx="50" cy="50" fill="none" stroke="#759ed4" stroke-width="10" r="42" stroke-dasharray="197.92033717615698 67.97344572538566" style="animation-play-state: running; animation-delay: 0s;">
<animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;360 50 50" keyTimes="0;1" style="animation-play-state: running; animation-delay: 0s;"></animateTransform>
</circle>
<!-- Created with loading.io (https://loading.io/spinner/rolling/-bar-circle-curve-round-rotate) -->
<!-- "The Rolling spinner is released under loading.io free License." (https://loading.io/license/#free-license) -->
</svg>

After

Width:  |  Height:  |  Size: 1018 B

View File

@@ -1,4 +1,4 @@
import {ipcRenderer} from "./typed-ipc-renderer.js";
import {ipcRenderer} from "./typed-ipc-renderer.ts";
// This helper is exposed via electron_bridge for use in the social
// login flow.
@@ -20,7 +20,7 @@ export type ClipboardDecrypter = {
pasted: Promise<string>;
};
export class ClipboardDecrypterImpl implements ClipboardDecrypter {
export class ClipboardDecrypterImplementation implements ClipboardDecrypter {
version: number;
key: Uint8Array;
pasted: Promise<string>;
@@ -33,10 +33,7 @@ export class ClipboardDecrypterImpl implements ClipboardDecrypter {
this.pasted = new Promise((resolve) => {
let interval: NodeJS.Timeout | null = null;
const startPolling = () => {
if (interval === null) {
interval = setInterval(poll, 1000);
}
interval ??= setInterval(poll, 1000);
void poll();
};

View File

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

View File

@@ -1,4 +1,4 @@
import {clipboard} from "electron/common";
import {type Event, clipboard} from "electron/common";
import type {WebContents} from "electron/main";
import type {
ContextMenuParams,
@@ -6,18 +6,18 @@ import type {
} from "electron/renderer";
import process from "node:process";
import {Menu} from "@electron/remote";
import {BrowserWindow, Menu} from "@electron/remote";
import * as t from "../../../common/translation-util.js";
import * as t from "../../../common/translation-util.ts";
export const contextMenu = (
webContents: WebContents,
event: Event,
props: ContextMenuParams,
properties: ContextMenuParams,
) => {
const isText = props.selectionText !== "";
const isLink = props.linkURL !== "";
const linkUrl = isLink ? new URL(props.linkURL) : undefined;
const isText = properties.selectionText !== "";
const isLink = properties.linkURL !== "";
const linkUrl = isLink ? new URL(properties.linkURL) : undefined;
const makeSuggestion = (suggestion: string) => ({
label: suggestion,
@@ -30,19 +30,21 @@ export const contextMenu = (
let menuTemplate: MenuItemConstructorOptions[] = [
{
label: t.__("Add to Dictionary"),
visible: props.isEditable && isText && props.misspelledWord.length > 0,
visible:
properties.isEditable && isText && properties.misspelledWord.length > 0,
click(_item) {
webContents.session.addWordToSpellCheckerDictionary(
props.misspelledWord,
properties.misspelledWord,
);
},
},
{
type: "separator",
visible: props.isEditable && isText && props.misspelledWord.length > 0,
visible:
properties.isEditable && isText && properties.misspelledWord.length > 0,
},
{
label: `${t.__("Look Up")} "${props.selectionText}"`,
label: `${t.__("Look Up")} "${properties.selectionText}"`,
visible: process.platform === "darwin" && isText,
click(_item) {
webContents.showDefinitionForSelection();
@@ -55,7 +57,7 @@ export const contextMenu = (
{
label: t.__("Cut"),
visible: isText,
enabled: props.isEditable,
enabled: properties.isEditable,
accelerator: "CommandOrControl+X",
click(_item) {
webContents.cut();
@@ -64,7 +66,7 @@ export const contextMenu = (
{
label: t.__("Copy"),
accelerator: "CommandOrControl+C",
enabled: props.editFlags.canCopy,
enabled: properties.editFlags.canCopy,
click(_item) {
webContents.copy();
},
@@ -72,7 +74,7 @@ export const contextMenu = (
{
label: t.__("Paste"), // Bug: Paste replaces text
accelerator: "CommandOrControl+V",
enabled: props.isEditable,
enabled: properties.isEditable,
click() {
webContents.paste();
},
@@ -88,44 +90,37 @@ export const contextMenu = (
visible: isLink,
click(_item) {
clipboard.write({
bookmark: props.linkText,
bookmark: properties.linkText,
text:
linkUrl?.protocol === "mailto:" ? linkUrl.pathname : props.linkURL,
linkUrl?.protocol === "mailto:"
? linkUrl.pathname
: properties.linkURL,
});
},
},
{
label: t.__("Copy Image"),
visible: props.mediaType === "image",
visible: properties.mediaType === "image",
click(_item) {
webContents.copyImageAt(props.x, props.y);
webContents.copyImageAt(properties.x, properties.y);
},
},
{
label: t.__("Copy Image URL"),
visible: props.mediaType === "image",
visible: properties.mediaType === "image",
click(_item) {
clipboard.write({
bookmark: props.srcURL,
text: props.srcURL,
bookmark: properties.srcURL,
text: properties.srcURL,
});
},
},
{
type: "separator",
visible: isLink || props.mediaType === "image",
},
{
label: t.__("Services"),
visible: process.platform === "darwin",
role: "services",
},
];
if (props.misspelledWord) {
if (props.dictionarySuggestions.length > 0) {
if (properties.misspelledWord) {
if (properties.dictionarySuggestions.length > 0) {
const suggestions: MenuItemConstructorOptions[] =
props.dictionarySuggestions.map((suggestion: string) =>
properties.dictionarySuggestions.map((suggestion: string) =>
makeSuggestion(suggestion),
);
menuTemplate = [...suggestions, ...menuTemplate];
@@ -137,7 +132,7 @@ export const contextMenu = (
}
}
// 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.
// Electron has a bug which ignores visible: false parameter for separator menu items. So we remove them here.
// https://github.com/electron/electron/issues/5869
// https://github.com/electron/electron/issues/6906
@@ -145,5 +140,11 @@ export const contextMenu = (
(menuItem) => menuItem.visible ?? true,
);
const menu = Menu.buildFromTemplate(filteredMenuTemplate);
menu.popup();
menu.popup({
window: BrowserWindow.fromWebContents(webContents) ?? undefined,
frame: properties.frame ?? undefined,
x: properties.x,
y: properties.y,
sourceType: properties.menuSourceType,
});
};

View File

@@ -1,26 +1,26 @@
import type {Html} from "../../../common/html.js";
import {html} from "../../../common/html.js";
import {type Html, html} from "../../../common/html.ts";
import type {TabPage} from "../../../common/types.ts";
import {generateNodeFromHtml} from "./base.js";
import type {TabProps} from "./tab.js";
import Tab from "./tab.js";
import {generateNodeFromHtml} from "./base.ts";
import Tab, {type TabProperties} from "./tab.ts";
export type FunctionalTabProps = {
export type FunctionalTabProperties = {
$view: Element;
} & TabProps;
page: TabPage;
} & TabProperties;
export default class FunctionalTab extends Tab {
$view: Element;
$el: Element;
$closeButton?: Element;
constructor({$view, ...props}: FunctionalTabProps) {
super(props);
constructor({$view, ...properties}: FunctionalTabProperties) {
super(properties);
this.$view = $view;
this.$el = generateNodeFromHtml(this.templateHtml());
if (this.props.name !== "Settings") {
this.props.$root.append(this.$el);
if (properties.page !== "Settings") {
this.properties.$root.append(this.$el);
this.$closeButton = this.$el.querySelector(".server-tab-badge")!;
this.registerListeners();
}
@@ -43,12 +43,12 @@ export default class FunctionalTab extends Tab {
templateHtml(): Html {
return html`
<div class="tab functional-tab" data-tab-id="${this.props.tabIndex}">
<div class="tab functional-tab" data-tab-id="${this.properties.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>
<i class="material-icons">${this.properties.materialIcon}</i>
</div>
</div>
`;
@@ -65,8 +65,8 @@ export default class FunctionalTab extends Tab {
this.$closeButton?.classList.remove("active");
});
this.$closeButton?.addEventListener("click", (event: Event) => {
this.props.onDestroy?.();
this.$closeButton?.addEventListener("click", (event) => {
this.properties.onDestroy?.();
event.stopPropagation();
});
}

View File

@@ -1,30 +1,32 @@
import process from "node:process";
import type {Html} from "../../../common/html.js";
import {html} from "../../../common/html.js";
import {ipcRenderer} from "../typed-ipc-renderer.js";
import {type Html, html} from "../../../common/html.ts";
import {ipcRenderer} from "../typed-ipc-renderer.ts";
import {generateNodeFromHtml} from "./base.js";
import type {TabProps} from "./tab.js";
import Tab from "./tab.js";
import type WebView from "./webview.js";
import {generateNodeFromHtml} from "./base.ts";
import Tab, {type TabProperties} from "./tab.ts";
import type WebView from "./webview.ts";
export type ServerTabProps = {
export type ServerTabProperties = {
webview: Promise<WebView>;
} & TabProps;
} & TabProperties;
export default class ServerTab extends Tab {
webview: Promise<WebView>;
$el: Element;
$name: Element;
$icon: HTMLImageElement;
$badge: Element;
constructor({webview, ...props}: ServerTabProps) {
super(props);
constructor({webview, ...properties}: ServerTabProperties) {
super(properties);
this.webview = webview;
this.$el = generateNodeFromHtml(this.templateHtml());
this.props.$root.append(this.$el);
this.properties.$root.append(this.$el);
this.registerListeners();
this.$name = this.$el.querySelector(".server-tooltip")!;
this.$icon = this.$el.querySelector(".server-icons")!;
this.$badge = this.$el.querySelector(".server-tab-badge")!;
}
@@ -40,24 +42,34 @@ export default class ServerTab extends Tab {
override async destroy(): Promise<void> {
await super.destroy();
(await this.webview).$el.remove();
(await this.webview).destroy();
}
templateHtml(): Html {
return html`
<div class="tab" data-tab-id="${this.props.tabIndex}">
<div class="tab" data-tab-id="${this.properties.tabIndex}">
<div class="server-tooltip" style="display:none">
${this.props.name}
${this.properties.label}
</div>
<div class="server-tab-badge"></div>
<div class="server-tab">
<img class="server-icons" src="${this.props.icon}" />
<img class="server-icons" src="${this.properties.icon}" />
</div>
<div class="server-tab-shortcut">${this.generateShortcutText()}</div>
</div>
`;
}
setLabel(label: string): void {
this.properties.label = label;
this.$name.textContent = label;
}
setIcon(icon: string): void {
this.properties.icon = icon;
this.$icon.src = icon;
}
updateBadge(count: number): void {
this.$badge.textContent = count > 999 ? "1K+" : count.toString();
this.$badge.classList.toggle("active", count > 0);
@@ -65,11 +77,11 @@ export default class ServerTab extends Tab {
generateShortcutText(): string {
// Only provide shortcuts for server [0..9]
if (this.props.index >= 9) {
if (this.properties.index >= 9) {
return "";
}
const shownIndex = this.props.index + 1;
const shownIndex = this.properties.index + 1;
// Array index == Shown index - 1
ipcRenderer.send("switch-server-tab", shownIndex - 1);

View File

@@ -1,9 +1,10 @@
import type {TabRole} from "../../../common/types.js";
import type {TabPage, TabRole} from "../../../common/types.ts";
export type TabProps = {
export type TabProperties = {
role: TabRole;
page?: TabPage;
icon?: string;
name: string;
label: string;
$root: Element;
onClick: () => void;
index: number;
@@ -17,17 +18,17 @@ export type TabProps = {
export default abstract class Tab {
abstract $el: Element;
constructor(readonly props: TabProps) {}
constructor(readonly properties: TabProperties) {}
registerListeners(): void {
this.$el.addEventListener("click", this.props.onClick);
this.$el.addEventListener("click", this.properties.onClick);
if (this.props.onHover !== undefined) {
this.$el.addEventListener("mouseover", this.props.onHover);
if (this.properties.onHover !== undefined) {
this.$el.addEventListener("mouseover", this.properties.onHover);
}
if (this.props.onHoverOut !== undefined) {
this.$el.addEventListener("mouseout", this.props.onHoverOut);
if (this.properties.onHoverOut !== undefined) {
this.$el.addEventListener("mouseout", this.properties.onHoverOut);
}
}

View File

@@ -1,25 +1,24 @@
import type {WebContents} from "electron/main";
import fs from "node:fs";
import process from "node:process";
import * as remote from "@electron/remote";
import {app, dialog} from "@electron/remote";
import * as ConfigUtil from "../../../common/config-util.js";
import type {Html} from "../../../common/html.js";
import {html} from "../../../common/html.js";
import type {RendererMessage} from "../../../common/typed-ipc.js";
import type {TabRole} from "../../../common/types.js";
import preloadCss from "../../css/preload.css?raw"; // eslint-disable-line n/file-extension-in-import
import {ipcRenderer} from "../typed-ipc-renderer.js";
import * as SystemUtil from "../utils/system-util.js";
import * as ConfigUtil from "../../../common/config-util.ts";
import {type Html, html} from "../../../common/html.ts";
import * as t from "../../../common/translation-util.ts";
import type {RendererMessage} from "../../../common/typed-ipc.ts";
import type {TabRole} from "../../../common/types.ts";
import preloadCss from "../../css/preload.css?raw";
import {ipcRenderer} from "../typed-ipc-renderer.ts";
import * as SystemUtil from "../utils/system-util.ts";
import {generateNodeFromHtml} from "./base.js";
import {contextMenu} from "./context-menu.js";
import {generateNodeFromHtml} from "./base.ts";
import {contextMenu} from "./context-menu.ts";
const shouldSilentWebview = ConfigUtil.getConfigItem("silent", false);
type WebViewProps = {
type WebViewProperties = {
$root: Element;
rootWebContents: WebContents;
index: number;
@@ -32,32 +31,45 @@ type WebViewProps = {
preload?: string;
onTitleChange: () => void;
hasPermission?: (origin: string, permission: string) => boolean;
unsupportedMessage?: string;
};
export default class WebView {
static templateHtml(props: WebViewProps): Html {
static templateHtml(properties: WebViewProperties): Html {
return html`
<webview
data-tab-id="${props.tabIndex}"
src="${props.url}"
${props.preload === undefined
? html``
: html`preload="${props.preload}"`}
partition="persist:webviewsession"
allowpopups
>
</webview>
<div class="webview-pane">
<div
class="webview-unsupported"
${properties.unsupportedMessage === undefined ? html`hidden` : html``}
>
<span class="webview-unsupported-message"
>${properties.unsupportedMessage ?? ""}</span
>
<span class="webview-unsupported-dismiss">×</span>
</div>
<webview
data-tab-id="${properties.tabIndex}"
src="${properties.url}"
${properties.preload === undefined
? html``
: html`preload="${properties.preload}"`}
partition="persist:webviewsession"
allowpopups
>
</webview>
</div>
`;
}
static async create(props: WebViewProps): Promise<WebView> {
const $element = generateNodeFromHtml(
WebView.templateHtml(props),
static async create(properties: WebViewProperties): Promise<WebView> {
const $pane = generateNodeFromHtml(
WebView.templateHtml(properties),
) as HTMLElement;
props.$root.append($element);
properties.$root.append($pane);
const $webview: HTMLElement = $pane.querySelector(":scope > webview")!;
await new Promise<void>((resolve) => {
$element.addEventListener(
$webview.addEventListener(
"did-attach",
() => {
resolve();
@@ -77,50 +89,147 @@ export default class WebView {
}
const selector = `webview[data-tab-id="${CSS.escape(
`${props.tabIndex}`,
`${properties.tabIndex}`,
)}"]`;
const webContentsId: unknown =
await props.rootWebContents.executeJavaScript(
await properties.rootWebContents.executeJavaScript(
`(${getWebContentsIdFunction.toString()})(${JSON.stringify(selector)})`,
);
if (typeof webContentsId !== "number") {
throw new TypeError("Failed to get WebContents ID");
}
return new WebView(props, $element, webContentsId);
return new WebView(properties, $pane, $webview, webContentsId);
}
zoomFactor: number;
badgeCount: number;
loading: boolean;
customCss: string | false | null;
$webviewsContainer: DOMTokenList;
$el: HTMLElement;
webContentsId: number;
badgeCount = 0;
loading = true;
private customCss: string | false | null;
private readonly $webviewsContainer: DOMTokenList;
private readonly $unsupported: HTMLElement;
private readonly $unsupportedMessage: HTMLElement;
private readonly $unsupportedDismiss: HTMLElement;
private unsupportedDismissed = false;
private constructor(
readonly props: WebViewProps,
$element: HTMLElement,
webContentsId: number,
readonly properties: WebViewProperties,
private readonly $pane: HTMLElement,
private readonly $webview: HTMLElement,
readonly webContentsId: number,
) {
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.$unsupported = $pane.querySelector(".webview-unsupported")!;
this.$unsupportedMessage = $pane.querySelector(
".webview-unsupported-message",
)!;
this.$unsupportedDismiss = $pane.querySelector(
".webview-unsupported-dismiss",
)!;
this.registerListeners();
}
getWebContents(): WebContents {
return remote.webContents.fromId(this.webContentsId);
destroy(): void {
this.$pane.remove();
}
registerListeners(): void {
getWebContents(): WebContents {
return remote.webContents.fromId(this.webContentsId)!;
}
showNotificationSettings(): void {
this.send("show-notification-settings");
this.focus();
}
focus(): void {
this.$webview.focus();
// Work around https://github.com/electron/electron/issues/31918
this.$webview.shadowRoot?.querySelector("iframe")?.focus();
}
hide(): void {
this.$pane.classList.remove("active");
}
load(): void {
this.show();
}
zoomIn(): void {
this.getWebContents().zoomLevel += 0.5;
}
zoomOut(): void {
this.getWebContents().zoomLevel -= 0.5;
}
zoomActualSize(): void {
this.getWebContents().zoomLevel = 0;
}
logOut(): void {
this.send("logout");
}
showKeyboardShortcuts(): void {
this.send("show-keyboard-shortcuts");
this.focus();
}
openDevTools(): void {
this.getWebContents().openDevTools();
}
back(): void {
if (this.getWebContents().navigationHistory.canGoBack()) {
this.getWebContents().navigationHistory.goBack();
this.focus();
}
}
canGoBackButton(): void {
const $backButton = document.querySelector(
"#actions-container #back-action",
)!;
$backButton.classList.toggle(
"disable",
!this.getWebContents().navigationHistory.canGoBack(),
);
}
forward(): void {
if (this.getWebContents().navigationHistory.canGoForward()) {
this.getWebContents().navigationHistory.goForward();
}
}
reload(): void {
this.hide();
// Shows the loading indicator till the webview is reloaded
this.$webviewsContainer.remove("loaded");
this.loading = true;
this.properties.switchLoading(true, this.properties.url);
this.getWebContents().reload();
}
setUnsupportedMessage(unsupportedMessage: string | undefined) {
this.$unsupported.hidden =
unsupportedMessage === undefined || this.unsupportedDismissed;
this.$unsupportedMessage.textContent = unsupportedMessage ?? "";
}
send<Channel extends keyof RendererMessage>(
channel: Channel,
...arguments_: Parameters<RendererMessage[Channel]>
): void {
ipcRenderer.send("forward-to", this.webContentsId, channel, ...arguments_);
}
private registerListeners(): void {
const webContents = this.getWebContents();
if (shouldSilentWebview) {
@@ -129,24 +238,21 @@ export default class WebView {
webContents.on("page-title-updated", (_event, title) => {
this.badgeCount = this.getBadgeCount(title);
this.props.onTitleChange();
this.properties.onTitleChange();
});
this.$el.addEventListener("did-navigate-in-page", () => {
this.$webview.addEventListener("did-navigate-in-page", () => {
this.canGoBackButton();
});
this.$el.addEventListener("did-navigate", () => {
this.$webview.addEventListener("did-navigate", () => {
this.canGoBackButton();
});
webContents.on("page-favicon-updated", (_event, favicons) => {
// This returns a string of favicons URL. If there is a PM counts in unread messages then the URL would be like
// https://chat.zulip.org/static/images/favicon/favicon-pms.png
if (
favicons[0].indexOf("favicon-pms") > 0 &&
process.platform === "darwin"
) {
if (favicons[0].indexOf("favicon-pms") > 0 && app.dock !== undefined) {
// This api is only supported on macOS
app.dock.setBadge("●");
// Bounce the dock
@@ -160,9 +266,9 @@ export default class WebView {
contextMenu(webContents, event, menuParameters);
});
this.$el.addEventListener("dom-ready", () => {
this.$webview.addEventListener("dom-ready", () => {
this.loading = false;
this.props.switchLoading(false, this.props.url);
this.properties.switchLoading(false, this.properties.url);
this.show();
});
@@ -171,42 +277,48 @@ export default class WebView {
SystemUtil.connectivityError.includes(errorDescription);
if (hasConnectivityError) {
console.error("error", errorDescription);
if (!this.props.url.includes("network.html")) {
this.props.onNetworkError(this.props.index);
if (!this.properties.url.includes("network.html")) {
this.properties.onNetworkError(this.properties.index);
}
}
});
this.$el.addEventListener("did-start-loading", () => {
this.props.switchLoading(true, this.props.url);
this.$webview.addEventListener("did-start-loading", () => {
this.properties.switchLoading(true, this.properties.url);
});
this.$el.addEventListener("did-stop-loading", () => {
this.props.switchLoading(false, this.props.url);
this.$webview.addEventListener("did-stop-loading", () => {
this.properties.switchLoading(false, this.properties.url);
});
this.$unsupportedDismiss.addEventListener("click", () => {
this.unsupportedDismissed = true;
this.$unsupported.hidden = true;
});
webContents.on("zoom-changed", (event, zoomDirection) => {
if (zoomDirection === "in") this.zoomIn();
else if (zoomDirection === "out") this.zoomOut();
});
}
getBadgeCount(title: string): number {
private getBadgeCount(title: string): number {
const messageCountInTitle = /^\((\d+)\)/.exec(title);
return messageCountInTitle ? Number(messageCountInTitle[1]) : 0;
}
showNotificationSettings(): void {
this.send("show-notification-settings");
}
show(): void {
private show(): void {
// Do not show WebView if another tab was selected and this tab should be in background.
if (!this.props.isActive()) {
if (!this.properties.isActive()) {
return;
}
// To show or hide the loading indicator in the the active tab
// To show or hide the loading indicator in the active tab
this.$webviewsContainer.toggle("loaded", !this.loading);
this.$el.classList.add("active");
this.$pane.classList.add("active");
this.focus();
this.props.onTitleChange();
this.properties.onTitleChange();
// Injecting preload css in webview to override some css rules
(async () => this.getWebContents().insertCSS(preloadCss))();
@@ -218,8 +330,8 @@ export default class WebView {
this.customCss = null;
ConfigUtil.setConfigItem("customCSS", null);
const errorMessage = "The custom css previously set is deleted!";
dialog.showErrorBox("custom css file deleted!", errorMessage);
const errorMessage = t.__("The custom CSS previously set is deleted.");
dialog.showErrorBox(t.__("Custom CSS file deleted"), errorMessage);
return;
}
@@ -227,81 +339,4 @@ export default class WebView {
this.getWebContents().insertCSS(fs.readFileSync(customCss, "utf8")))();
}
}
focus(): void {
this.$el.focus();
// Work around https://github.com/electron/electron/issues/31918
this.$el.shadowRoot?.querySelector("iframe")?.focus();
}
hide(): void {
this.$el.classList.remove("active");
}
load(): void {
this.show();
}
zoomIn(): void {
this.zoomFactor += 0.1;
this.getWebContents().setZoomFactor(this.zoomFactor);
}
zoomOut(): void {
this.zoomFactor -= 0.1;
this.getWebContents().setZoomFactor(this.zoomFactor);
}
zoomActualSize(): void {
this.zoomFactor = 1;
this.getWebContents().setZoomFactor(this.zoomFactor);
}
logOut(): void {
this.send("logout");
}
showKeyboardShortcuts(): void {
this.send("show-keyboard-shortcuts");
}
openDevTools(): void {
this.getWebContents().openDevTools();
}
back(): void {
if (this.getWebContents().canGoBack()) {
this.getWebContents().goBack();
this.focus();
}
}
canGoBackButton(): void {
const $backButton = document.querySelector(
"#actions-container #back-action",
)!;
$backButton.classList.toggle("disable", !this.getWebContents().canGoBack());
}
forward(): void {
if (this.getWebContents().canGoForward()) {
this.getWebContents().goForward();
}
}
reload(): void {
this.hide();
// Shows the loading indicator till the webview is reloaded
this.$webviewsContainer.remove("loaded");
this.loading = true;
this.props.switchLoading(true, this.props.url);
this.getWebContents().reload();
}
send<Channel extends keyof RendererMessage>(
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
): void {
ipcRenderer.sendTo(this.webContentsId, channel, ...args);
}
}

View File

@@ -1,15 +1,17 @@
import {EventEmitter} from "events"; // eslint-disable-line unicorn/prefer-node-protocol
import {EventEmitter} from "node:events";
import type {ClipboardDecrypter} from "./clipboard-decrypter.js";
import {ClipboardDecrypterImpl} from "./clipboard-decrypter.js";
import type {NotificationData} from "./notification/index.js";
import {newNotification} from "./notification/index.js";
import {ipcRenderer} from "./typed-ipc-renderer.js";
import {
type ClipboardDecrypter,
ClipboardDecrypterImplementation,
} from "./clipboard-decrypter.ts";
import {type NotificationData, newNotification} from "./notification/index.ts";
import {ipcRenderer} from "./typed-ipc-renderer.ts";
type ListenerType = (...args: any[]) => void;
type ListenerType = (...arguments_: any[]) => void;
/* eslint-disable @typescript-eslint/naming-convention */
export type ElectronBridge = {
send_event: (eventName: string | symbol, ...args: unknown[]) => boolean;
send_event: (eventName: string | symbol, ...arguments_: unknown[]) => boolean;
on_event: (eventName: string, listener: ListenerType) => void;
new_notification: (
title: string,
@@ -22,6 +24,7 @@ export type ElectronBridge = {
set_send_notification_reply_message_supported: (value: boolean) => void;
decrypt_clipboard: (version: number) => ClipboardDecrypter;
};
/* eslint-enable @typescript-eslint/naming-convention */
let notificationReplySupported = false;
// Indicates if the user is idle or not
@@ -29,12 +32,12 @@ let idle = false;
// Indicates the time at which user was last active
let lastActive = Date.now();
export const bridgeEvents = new EventEmitter();
export const bridgeEvents = new EventEmitter(); // eslint-disable-line unicorn/prefer-event-target
/* eslint-disable @typescript-eslint/naming-convention */
const electron_bridge: ElectronBridge = {
send_event: (eventName: string | symbol, ...args: unknown[]): boolean =>
bridgeEvents.emit(eventName, ...args),
send_event: (eventName: string | symbol, ...arguments_: unknown[]): boolean =>
bridgeEvents.emit(eventName, ...arguments_),
on_event(eventName: string, listener: ListenerType): void {
bridgeEvents.on(eventName, listener);
@@ -58,7 +61,7 @@ const electron_bridge: ElectronBridge = {
},
decrypt_clipboard: (version: number): ClipboardDecrypter =>
new ClipboardDecrypterImpl(version),
new ClipboardDecrypterImplementation(version),
};
/* eslint-enable @typescript-eslint/naming-convention */
@@ -105,7 +108,7 @@ ipcRenderer.on("set-idle", () => {
// This follows node's idiomatic implementation of event
// emitters to make event handling more simpler instead of using
// functions zulip side will emit event using ElectronBrigde.send_event
// functions zulip side will emit event using ElectronBridge.send_event
// which is alias of .emit and on this side we can handle the data by adding
// a listener for the event.
export default electron_bridge;

View File

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

View File

@@ -1,33 +1,42 @@
import "./zod-config.ts"; // eslint-disable-line import-x/no-unassigned-import
import {clipboard} from "electron/common";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import url from "node:url";
import {Menu, app, dialog, session} from "@electron/remote";
import * as remote from "@electron/remote";
import * as Sentry from "@sentry/electron";
import * as Sentry from "@sentry/electron/renderer";
import type {Config} from "../../common/config-util.js";
import * as ConfigUtil from "../../common/config-util.js";
import * as DNDUtil from "../../common/dnd-util.js";
import type {DndSettings} from "../../common/dnd-util.js";
import * as EnterpriseUtil from "../../common/enterprise-util.js";
import * as LinkUtil from "../../common/link-util.js";
import Logger from "../../common/logger-util.js";
import * as Messages from "../../common/messages.js";
import {bundlePath, bundleUrl} from "../../common/paths.js";
import type {NavItem, ServerConf, TabData} from "../../common/types.js";
import type {Config} from "../../common/config-util.ts";
import * as ConfigUtil from "../../common/config-util.ts";
import * as DNDUtil from "../../common/dnd-util.ts";
import type {DndSettings} from "../../common/dnd-util.ts";
import * as EnterpriseUtil from "../../common/enterprise-util.ts";
import {html} from "../../common/html.ts";
import * as LinkUtil from "../../common/link-util.ts";
import Logger from "../../common/logger-util.ts";
import * as Messages from "../../common/messages.ts";
import {bundlePath, bundleUrl} from "../../common/paths.ts";
import * as t from "../../common/translation-util.ts";
import type {
NavigationItem,
ServerConfig,
TabData,
TabPage,
} from "../../common/types.js";
import defaultIcon from "../img/icon.png";
import FunctionalTab from "./components/functional-tab.js";
import ServerTab from "./components/server-tab.js";
import WebView from "./components/webview.js";
import {AboutView} from "./pages/about.js";
import {PreferenceView} from "./pages/preference/preference.js";
import {initializeTray} from "./tray.js";
import {ipcRenderer} from "./typed-ipc-renderer.js";
import * as DomainUtil from "./utils/domain-util.js";
import ReconnectUtil from "./utils/reconnect-util.js";
import FunctionalTab from "./components/functional-tab.ts";
import ServerTab from "./components/server-tab.ts";
import WebView from "./components/webview.ts";
import {AboutView} from "./pages/about.ts";
import {PreferenceView} from "./pages/preference/preference.ts";
import {initializeTray} from "./tray.ts";
import {ipcRenderer} from "./typed-ipc-renderer.ts";
import * as DomainUtil from "./utils/domain-util.ts";
import ReconnectUtil from "./utils/reconnect-util.ts";
Sentry.init({});
@@ -73,11 +82,10 @@ export class ServerManagerView {
$dndTooltip: HTMLElement;
$sidebar: Element;
$fullscreenPopup: Element;
$fullscreenEscapeKey: string;
loading: Set<string>;
activeTabIndex: number;
tabs: ServerOrFunctionalTab[];
functionalTabs: Map<string, number>;
functionalTabs: Map<TabPage, number>;
tabIndex: number;
presetOrgs: string[];
preferenceView?: PreferenceView;
@@ -114,8 +122,10 @@ export class ServerManagerView {
this.$sidebar = document.querySelector("#sidebar")!;
this.$fullscreenPopup = document.querySelector("#fullscreen-popup")!;
this.$fullscreenEscapeKey = process.platform === "darwin" ? "^⌘F" : "F11";
this.$fullscreenPopup.textContent = `Press ${this.$fullscreenEscapeKey} to exit full screen`;
this.$fullscreenPopup.textContent = t.__(
"Press {{{exitKey}}} to exit full screen",
{exitKey: process.platform === "darwin" ? "^⌘F" : "F11"},
);
this.loading = new Set();
this.activeTabIndex = -1;
@@ -156,12 +166,12 @@ export class ServerManagerView {
ConfigUtil.getConfigItem("useSystemProxy", false)
? {mode: "system"}
: ConfigUtil.getConfigItem("useManualProxy", false)
? {
pacScript: ConfigUtil.getConfigItem("proxyPAC", ""),
proxyRules: ConfigUtil.getConfigItem("proxyRules", ""),
proxyBypassRules: ConfigUtil.getConfigItem("proxyBypass", ""),
}
: {mode: "direct"},
? {
pacScript: ConfigUtil.getConfigItem("proxyPAC", ""),
proxyRules: ConfigUtil.getConfigItem("proxyRules", ""),
proxyBypassRules: ConfigUtil.getConfigItem("proxyBypass", ""),
}
: {mode: "direct"},
);
}
@@ -248,13 +258,16 @@ export class ServerManagerView {
// promise of addition resolves in both cases, but we consider it rejected
// if the resolved value is false
try {
const serverConf = await DomainUtil.checkDomain(domain);
await DomainUtil.addDomain(serverConf);
const serverConfig = await DomainUtil.checkDomain(domain);
await DomainUtil.addDomain(serverConfig);
return true;
} catch (error: unknown) {
logger.error(error);
logger.error(
`Could not add ${domain}. Please contact your system administrator.`,
t.__(
"Could not add {{{domain}}}. Please contact your system administrator.",
{domain},
),
);
return false;
}
@@ -283,12 +296,9 @@ export class ServerManagerView {
// ask them before reloading the app
const {response} = await dialog.showMessageBox({
type: "question",
buttons: ["Yes", "Later"],
buttons: [t.__("Yes"), t.__("Later")],
defaultId: 0,
message:
"New server" +
(domainsAdded.length > 1 ? "s" : "") +
" added. Reload app now?",
message: t.__("New servers added. Reload app now?"),
});
if (response === 0) {
ipcRenderer.send("reload-full-app");
@@ -307,10 +317,7 @@ export class ServerManagerView {
failedDomains.push(org);
}
const {title, content} = Messages.enterpriseOrgError(
domainsAdded.length,
failedDomains,
);
const {title, content} = Messages.enterpriseOrgError(failedDomains);
dialog.showErrorBox(title, content);
if (DomainUtil.getDomains().length === 0) {
// No orgs present, stop showing loading gif
@@ -323,7 +330,18 @@ export class ServerManagerView {
const servers = DomainUtil.getDomains();
if (servers.length > 0) {
for (const [i, server] of servers.entries()) {
this.initServer(server, i);
const tab = this.initServer(server, i);
(async () => {
const serverConfig = await DomainUtil.updateSavedServer(
server.url,
i,
);
tab.setLabel(serverConfig.alias);
tab.setIcon(DomainUtil.iconAsUrl(serverConfig.icon));
(await tab.webview).setUnsupportedMessage(
DomainUtil.getUnsupportedMessage(serverConfig),
);
})();
}
// Open last active tab
@@ -332,11 +350,7 @@ export class ServerManagerView {
lastActiveTab = 0;
}
// `checkDomain()` and `webview.load()` for lastActiveTab before the others
await DomainUtil.updateSavedServer(
servers[lastActiveTab].url,
lastActiveTab,
);
// `webview.load()` for lastActiveTab before the others
await this.activateTab(lastActiveTab);
await Promise.all(
servers.map(async (server, i) => {
@@ -346,7 +360,6 @@ export class ServerManagerView {
return;
}
await DomainUtil.updateSavedServer(server.url, i);
const tab = this.tabs[i];
if (tab instanceof ServerTab) (await tab.webview).load();
}),
@@ -361,54 +374,54 @@ export class ServerManagerView {
}
}
initServer(server: ServerConf, index: number): void {
initServer(server: ServerConfig, index: number): ServerTab {
const tabIndex = this.getTabIndex();
this.tabs.push(
new ServerTab({
role: "server",
icon: `data:application/octet-stream;base64,${fs.readFileSync(
server.icon,
"base64",
)}`,
name: server.alias,
$root: this.$tabsContainer,
onClick: this.activateLastTab.bind(this, index),
const tab = new ServerTab({
role: "server",
icon: DomainUtil.iconAsUrl(server.icon),
label: server.alias,
$root: this.$tabsContainer,
onClick: this.activateLastTab.bind(this, index),
index,
tabIndex,
onHover: this.onHover.bind(this, index),
onHoverOut: this.onHoverOut.bind(this, index),
webview: WebView.create({
$root: this.$webviewsContainer,
rootWebContents,
index,
tabIndex,
onHover: this.onHover.bind(this, index),
onHoverOut: this.onHoverOut.bind(this, index),
webview: WebView.create({
$root: this.$webviewsContainer,
rootWebContents,
index,
tabIndex,
url: server.url,
role: "server",
hasPermission: (origin: string, permission: string) =>
origin === server.url && permission === "notifications",
isActive: () => index === this.activeTabIndex,
switchLoading: async (loading: boolean, url: string) => {
if (loading) {
this.loading.add(url);
} else {
this.loading.delete(url);
}
url: server.url,
role: "server",
hasPermission: (origin: string, permission: string) =>
origin === server.url &&
permission === "notifications" &&
ConfigUtil.getConfigItem("showNotification", true),
isActive: () => index === this.activeTabIndex,
switchLoading: async (loading: boolean, url: string) => {
if (loading) {
this.loading.add(url);
} else {
this.loading.delete(url);
}
const tab = this.tabs[this.activeTabIndex];
this.showLoading(
tab instanceof ServerTab &&
this.loading.has((await tab.webview).props.url),
);
},
onNetworkError: async (index: number) => {
await this.openNetworkTroubleshooting(index);
},
onTitleChange: this.updateBadge.bind(this),
preload: url.pathToFileURL(path.join(bundlePath, "preload.js")).href,
}),
const tab = this.tabs[this.activeTabIndex];
this.showLoading(
tab instanceof ServerTab &&
this.loading.has((await tab.webview).properties.url),
);
},
onNetworkError: async (index: number) => {
await this.openNetworkTroubleshooting(index);
},
onTitleChange: this.updateBadge.bind(this),
preload: url.pathToFileURL(path.join(bundlePath, "preload.cjs")).href,
unsupportedMessage: DomainUtil.getUnsupportedMessage(server),
}),
);
});
this.tabs.push(tab);
this.loading.add(server.url);
return tab;
}
initActions(): void {
@@ -422,7 +435,7 @@ export class ServerManagerView {
document.querySelectorAll(".server-icons");
for (const [index, $serverImg] of $serverImgs.entries()) {
this.addContextMenu($serverImg, index);
if ($serverImg.src.includes("img/icon.png")) {
if ($serverImg.src === defaultIcon) {
this.displayInitialCharLogo($serverImg, index);
}
@@ -478,7 +491,7 @@ export class ServerManagerView {
async getCurrentActiveServer(): Promise<string> {
const tab = this.tabs[this.activeTabIndex];
return tab instanceof ServerTab ? (await tab.webview).props.url : "";
return tab instanceof ServerTab ? (await tab.webview).properties.url : "";
}
displayInitialCharLogo($img: HTMLImageElement, index: number): void {
@@ -496,13 +509,12 @@ export class ServerManagerView {
const realmName = $webview.getAttribute("name");
if (realmName === null) {
$img.src = "/img/icon.png";
$img.src = defaultIcon;
return;
}
$altIcon.textContent = realmName.charAt(0) || "Z";
$altIcon.classList.add("server-icon");
$altIcon.classList.add("alt-icon");
$altIcon.classList.add("server-icon", "alt-icon");
$img.remove();
$parent.append($altIcon);
@@ -547,36 +559,38 @@ export class ServerManagerView {
this.$serverIconTooltip[index].style.display = "none";
}
async openFunctionalTab(tabProps: {
name: string;
async openFunctionalTab(tabProperties: {
label: string;
page: TabPage;
materialIcon: string;
makeView: () => Promise<Element>;
destroyView: () => void;
}): Promise<void> {
if (this.functionalTabs.has(tabProps.name)) {
await this.activateTab(this.functionalTabs.get(tabProps.name)!);
if (this.functionalTabs.has(tabProperties.page)) {
await this.activateTab(this.functionalTabs.get(tabProperties.page)!);
return;
}
const index = this.tabs.length;
this.functionalTabs.set(tabProps.name, index);
this.functionalTabs.set(tabProperties.page, index);
const tabIndex = this.getTabIndex();
const $view = await tabProps.makeView();
const $view = await tabProperties.makeView();
this.$webviewsContainer.append($view);
this.tabs.push(
new FunctionalTab({
role: "function",
materialIcon: tabProps.materialIcon,
name: tabProps.name,
materialIcon: tabProperties.materialIcon,
label: tabProperties.label,
page: tabProperties.page,
$root: this.$tabsContainer,
index,
tabIndex,
onClick: this.activateTab.bind(this, index),
onDestroy: async () => {
await this.destroyTab(tabProps.name, index);
tabProps.destroyView();
await this.destroyFunctionalTab(tabProperties.page, index);
tabProperties.destroyView();
},
$view,
}),
@@ -586,12 +600,15 @@ export class ServerManagerView {
// closed when the functional tab DOM is ready, handled in webview.js
this.$webviewsContainer.classList.remove("loaded");
await this.activateTab(this.functionalTabs.get(tabProps.name)!);
await this.activateTab(this.functionalTabs.get(tabProperties.page)!);
}
async openSettings(nav: NavItem = "General"): Promise<void> {
async openSettings(
navigationItem: NavigationItem = "General",
): Promise<void> {
await this.openFunctionalTab({
name: "Settings",
page: "Settings",
label: t.__("Settings"),
materialIcon: "settings",
makeView: async () => {
this.preferenceView = await PreferenceView.create();
@@ -604,13 +621,14 @@ export class ServerManagerView {
},
});
this.$settingsButton.classList.add("active");
this.preferenceView!.handleNavigation(nav);
this.preferenceView!.handleNavigation(navigationItem);
}
async openAbout(): Promise<void> {
let aboutView: AboutView;
await this.openFunctionalTab({
name: "About",
page: "About",
label: t.__("About"),
materialIcon: "sentiment_very_satisfied",
async makeView() {
aboutView = await AboutView.create();
@@ -643,13 +661,14 @@ export class ServerManagerView {
// Returns this.tabs in an way that does
// not crash app when this.tabs is passed into
// ipcRenderer. Something about webview, and props.webview
// ipcRenderer. Something about webview, and properties.webview
// properties in ServerTab causes the app to crash.
get tabsForIpc(): TabData[] {
return this.tabs.map((tab) => ({
role: tab.props.role,
name: tab.props.name,
index: tab.props.index,
role: tab.properties.role,
page: tab.properties.page,
label: tab.properties.label,
index: tab.properties.index,
}));
}
@@ -667,8 +686,8 @@ export class ServerManagerView {
if (hideOldTab) {
// If old tab is functional tab Settings, remove focus from the settings icon at sidebar bottom
if (
this.tabs[this.activeTabIndex].props.role === "function" &&
this.tabs[this.activeTabIndex].props.name === "Settings"
this.tabs[this.activeTabIndex].properties.role === "function" &&
this.tabs[this.activeTabIndex].properties.page === "Settings"
) {
this.$settingsButton.classList.remove("active");
}
@@ -692,7 +711,7 @@ export class ServerManagerView {
this.showLoading(
tab instanceof ServerTab &&
this.loading.has((await tab.webview).props.url),
this.loading.has((await tab.webview).properties.url),
);
ipcRenderer.send("update-menu", {
@@ -701,7 +720,7 @@ export class ServerManagerView {
tabs: this.tabsForIpc,
activeTabIndex: this.activeTabIndex,
// Following flag controls whether a menu item should be enabled or not
enableMenu: tab.props.role === "server",
enableMenu: tab.properties.role === "server",
});
}
@@ -710,7 +729,7 @@ export class ServerManagerView {
this.$loadingIndicator.classList.toggle("hidden", !loading);
}
async destroyTab(name: string, index: number): Promise<void> {
async destroyFunctionalTab(page: TabPage, index: number): Promise<void> {
const tab = this.tabs[index];
if (tab instanceof ServerTab && (await tab.webview).loading) {
return;
@@ -718,8 +737,8 @@ export class ServerManagerView {
await tab.destroy();
delete this.tabs[index];
this.functionalTabs.delete(name);
delete this.tabs[index]; // eslint-disable-line @typescript-eslint/no-array-delete
this.functionalTabs.delete(page);
// Issue #188: If the functional tab was not focused, do not activate another tab.
if (this.activeTabIndex === index) {
@@ -743,7 +762,7 @@ export class ServerManagerView {
async reloadView(): Promise<void> {
// Save and remember the index of last active tab so that we can use it later
const lastActiveTab = this.tabs[this.activeTabIndex].props.index;
const lastActiveTab = this.tabs[this.activeTabIndex].properties.index;
ConfigUtil.setConfigItem("lastActiveTab", lastActiveTab);
// Destroy the current view and re-initiate it
@@ -779,11 +798,17 @@ export class ServerManagerView {
// Toggles the dnd button icon.
toggleDndButton(alert: boolean): void {
this.$dndTooltip.textContent =
(alert ? "Disable" : "Enable") + " Do Not Disturb";
this.$dndButton.querySelector("i")!.textContent = alert
? "notifications_off"
: "notifications";
this.$dndTooltip.textContent = alert
? t.__("Disable Do Not Disturb")
: t.__("Enable Do Not Disturb");
const $dndIcon = this.$dndButton.querySelector("i")!;
$dndIcon.textContent = alert ? "notifications_off" : "notifications";
if (alert) {
$dndIcon.classList.add("dnd-on");
} else {
$dndIcon.classList.remove("dnd-on");
}
}
async isLoggedIn(tabIndex: number): Promise<boolean> {
@@ -799,13 +824,15 @@ export class ServerManagerView {
event.preventDefault();
const template = [
{
label: "Disconnect organization",
label: t.__("Disconnect organization"),
async click() {
const {response} = await dialog.showMessageBox({
type: "warning",
buttons: ["YES", "NO"],
buttons: [t.__("Yes"), t.__("No")],
defaultId: 0,
message: "Are you sure you want to disconnect this organization?",
message: t.__(
"Are you sure you want to disconnect this organization?",
),
});
if (response === 0) {
if (DomainUtil.removeDomain(index)) {
@@ -820,7 +847,7 @@ export class ServerManagerView {
},
},
{
label: "Notification settings",
label: t.__("Notification settings"),
enabled: await this.isLoggedIn(index),
click: async () => {
// Switch to tab whose icon was right-clicked
@@ -831,7 +858,7 @@ export class ServerManagerView {
},
},
{
label: "Copy Zulip URL",
label: t.__("Copy Zulip URL"),
click() {
clipboard.writeText(DomainUtil.getDomain(index).url);
},
@@ -921,7 +948,7 @@ export class ServerManagerView {
ipcRenderer.on(
"permission-request",
async (
event: Event,
event,
{
webContentsId,
origin,
@@ -943,7 +970,7 @@ export class ServerManagerView {
const webview = await tab.webview;
return (
webview.webContentsId === webContentsId &&
webview.props.hasPermission?.(origin, permission)
webview.properties.hasPermission?.(origin, permission)
);
}),
)
@@ -970,10 +997,7 @@ export class ServerManagerView {
await LinkUtil.openBrowser(new URL("https://zulip.com/help/"));
});
ipcRenderer.on(
"reload-viewer",
this.reloadView.bind(this, this.tabs[this.activeTabIndex].props.index),
);
ipcRenderer.on("reload-viewer", this.reloadView.bind(this));
ipcRenderer.on("reload-current-viewer", this.reloadCurrentView.bind(this));
@@ -981,7 +1005,7 @@ export class ServerManagerView {
ipcRenderer.send("reload-full-app");
});
ipcRenderer.on("switch-server-tab", async (event: Event, index: number) => {
ipcRenderer.on("switch-server-tab", async (event, index: number) => {
await this.activateLastTab(index);
});
@@ -989,23 +1013,23 @@ export class ServerManagerView {
await this.openSettings("AddServer");
});
ipcRenderer.on("reload-proxy", async (event: Event, showAlert: boolean) => {
ipcRenderer.on("reload-proxy", async (event, showAlert: boolean) => {
await this.loadProxy();
if (showAlert) {
await dialog.showMessageBox({
message: "Proxy settings saved!",
buttons: ["OK"],
message: t.__("Proxy settings saved."),
buttons: [t.__("OK")],
});
ipcRenderer.send("reload-full-app");
}
});
ipcRenderer.on("toggle-sidebar", async (event: Event, show: boolean) => {
ipcRenderer.on("toggle-sidebar", async (event, show: boolean) => {
// Toggle the left sidebar
this.toggleSidebar(show);
});
ipcRenderer.on("toggle-silent", async (event: Event, state: boolean) =>
ipcRenderer.on("toggle-silent", async (event, state: boolean) =>
Promise.all(
this.tabs.map(async (tab) => {
if (tab instanceof ServerTab)
@@ -1016,7 +1040,7 @@ export class ServerManagerView {
ipcRenderer.on(
"toggle-autohide-menubar",
async (event: Event, autoHideMenubar: boolean, updateMenu: boolean) => {
async (event, autoHideMenubar: boolean, updateMenu: boolean) => {
if (updateMenu) {
ipcRenderer.send("update-menu", {
tabs: this.tabsForIpc,
@@ -1028,11 +1052,7 @@ export class ServerManagerView {
ipcRenderer.on(
"toggle-dnd",
async (
event: Event,
state: boolean,
newSettings: Partial<DndSettings>,
) => {
async (event, state: boolean, newSettings: Partial<DndSettings>) => {
this.toggleDndButton(state);
ipcRenderer.send(
"forward-message",
@@ -1044,16 +1064,11 @@ export class ServerManagerView {
ipcRenderer.on(
"update-realm-name",
(event: Event, serverURL: string, realmName: string) => {
(event, serverURL: string, realmName: string) => {
for (const [index, domain] of DomainUtil.getDomains().entries()) {
if (domain.url.includes(serverURL)) {
const serverTooltipSelector = ".tab .server-tooltip";
const serverTooltips = document.querySelectorAll(
serverTooltipSelector,
);
serverTooltips[index].textContent = realmName;
this.tabs[index].props.name = realmName;
if (domain.url === serverURL) {
const tab = this.tabs[index];
if (tab instanceof ServerTab) tab.setLabel(realmName);
domain.alias = realmName;
DomainUtil.updateDomain(index, domain);
// Update the realm name also on the Window menu
@@ -1068,18 +1083,15 @@ export class ServerManagerView {
ipcRenderer.on(
"update-realm-icon",
async (event: Event, serverURL: string, iconURL: string) => {
async (event, serverURL: string, iconURL: string) => {
await Promise.all(
DomainUtil.getDomains().map(async (domain, index) => {
if (domain.url.includes(serverURL)) {
const localIconUrl: string = await DomainUtil.saveServerIcon(
iconURL,
);
const serverImgsSelector = ".tab .server-icons";
const serverImgs: NodeListOf<HTMLImageElement> =
document.querySelectorAll(serverImgsSelector);
serverImgs[index].src = localIconUrl;
domain.icon = localIconUrl;
if (domain.url === serverURL) {
const localIconPath = await DomainUtil.saveServerIcon(iconURL);
const tab = this.tabs[index];
if (tab instanceof ServerTab)
tab.setIcon(DomainUtil.iconAsUrl(localIconPath));
domain.icon = localIconPath;
DomainUtil.updateDomain(index, domain);
}
}),
@@ -1096,61 +1108,56 @@ export class ServerManagerView {
this.$fullscreenPopup.classList.remove("show");
});
ipcRenderer.on(
"focus-webview-with-id",
async (event: Event, webviewId: number) =>
Promise.all(
this.tabs.map(async (tab) => {
if (
tab instanceof ServerTab &&
(await tab.webview).webContentsId === webviewId
) {
const concurrentTab: HTMLButtonElement = document.querySelector(
`div[data-tab-id="${CSS.escape(`${tab.props.tabIndex}`)}"]`,
)!;
concurrentTab.click();
}
}),
),
ipcRenderer.on("focus-webview-with-id", async (event, webviewId: number) =>
Promise.all(
this.tabs.map(async (tab) => {
if (
tab instanceof ServerTab &&
(await tab.webview).webContentsId === webviewId
) {
const concurrentTab: HTMLButtonElement = document.querySelector(
`div[data-tab-id="${CSS.escape(`${tab.properties.tabIndex}`)}"]`,
)!;
concurrentTab.click();
}
}),
),
);
ipcRenderer.on(
"render-taskbar-icon",
(event: Event, messageCount: number) => {
// Create a canvas from unread messagecounts
function createOverlayIcon(messageCount: number): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.height = 128;
canvas.width = 128;
canvas.style.letterSpacing = "-5px";
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#f42020";
ctx.beginPath();
ctx.ellipse(64, 64, 64, 64, 0, 0, 2 * Math.PI);
ctx.fill();
ctx.textAlign = "center";
ctx.fillStyle = "white";
if (messageCount > 99) {
ctx.font = "65px Helvetica";
ctx.fillText("99+", 64, 85);
} else if (messageCount < 10) {
ctx.font = "90px Helvetica";
ctx.fillText(String(Math.min(99, messageCount)), 64, 96);
} else {
ctx.font = "85px Helvetica";
ctx.fillText(String(Math.min(99, messageCount)), 64, 90);
}
return canvas;
ipcRenderer.on("render-taskbar-icon", (event, messageCount: number) => {
// Create a canvas from unread message counts
function createOverlayIcon(messageCount: number): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.height = 128;
canvas.width = 128;
canvas.style.letterSpacing = "-5px";
const context = canvas.getContext("2d")!;
context.fillStyle = "#f42020";
context.beginPath();
context.ellipse(64, 64, 64, 64, 0, 0, 2 * Math.PI);
context.fill();
context.textAlign = "center";
context.fillStyle = "white";
if (messageCount > 99) {
context.font = "65px Helvetica";
context.fillText("99+", 64, 85);
} else if (messageCount < 10) {
context.font = "90px Helvetica";
context.fillText(String(Math.min(99, messageCount)), 64, 96);
} else {
context.font = "85px Helvetica";
context.fillText(String(Math.min(99, messageCount)), 64, 90);
}
ipcRenderer.send(
"update-taskbar-icon",
createOverlayIcon(messageCount).toDataURL(),
String(messageCount),
);
},
);
return canvas;
}
ipcRenderer.send(
"update-taskbar-icon",
createOverlayIcon(messageCount).toDataURL(),
String(messageCount),
);
});
ipcRenderer.on("copy-zulip-url", async () => {
clipboard.writeText(await this.getCurrentActiveServer());
@@ -1187,6 +1194,62 @@ export class ServerManagerView {
}
window.addEventListener("load", async () => {
document.body.innerHTML = html`
<div id="content">
<div class="popup">
<span class="popuptext hidden" id="fullscreen-popup"></span>
</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"
>${t.__("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"
>${t.__("Do Not Disturb")}</span
>
</div>
<div class="action-button hidden" id="reload-action">
<i class="material-icons md-48">refresh</i>
<span id="reload-tooltip" style="display: none"
>${t.__("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"
>${t.__("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"
>${t.__("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"
>${t.__("Settings")}</span
>
</div>
</div>
</div>
<div id="main-container">
<div id="webviews-container"></div>
</div>
</div>
`.html;
const serverManagerView = new ServerManagerView();
await serverManagerView.init();
});

View File

@@ -1,4 +1,4 @@
import {ipcRenderer} from "../typed-ipc-renderer.js";
import {ipcRenderer} from "../typed-ipc-renderer.ts";
export type NotificationData = {
close: () => void;
@@ -18,10 +18,10 @@ export function newNotification(
): NotificationData {
const notification = new Notification(title, {...options, silent: true});
for (const type of ["click", "close", "error", "show"]) {
notification.addEventListener(type, (ev: Event) => {
notification.addEventListener(type, (event) => {
if (type === "click") ipcRenderer.send("focus-this-webview");
if (!dispatch(type, ev)) {
ev.preventDefault();
if (!dispatch(type, event)) {
event.preventDefault();
}
});
}

View File

@@ -1,6 +1,9 @@
import {app} from "@electron/remote";
import {bundleUrl} from "../../../common/paths.js";
import {Html, html} from "../../../common/html.ts";
import {bundleUrl} from "../../../common/paths.ts";
import * as t from "../../../common/translation-util.ts";
import {generateNodeFromHtml} from "../components/base.ts";
export class AboutView {
static async create(): Promise<AboutView> {
@@ -16,6 +19,32 @@ export class AboutView {
const $shadow = this.$view.attachShadow({mode: "open"});
$shadow.innerHTML = templateHtml;
$shadow.querySelector("#version")!.textContent = `v${app.getVersion()}`;
const maintenanceInfoHtml = html`
<div class="maintenance-info">
<p class="detail maintainer">
${new Html({
html: t.__("Maintained by {{{link}}}Zulip{{{endLink}}}", {
link: '<a href="https://zulip.com" target="_blank" rel="noopener noreferrer">',
endLink: "</a>",
}),
})}
</p>
<p class="detail license">
${new Html({
html: t.__(
"Available under the {{{link}}}Apache 2.0 License{{{endLink}}}",
{
link: '<a href="https://github.com/zulip/zulip-desktop/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">',
endLink: "</a>",
},
),
})}
</p>
</div>
`;
$shadow
.querySelector(".about")!
.append(generateNodeFromHtml(maintenanceInfoHtml));
}
destroy() {

View File

@@ -1,4 +1,4 @@
import {ipcRenderer} from "../typed-ipc-renderer.js";
import {ipcRenderer} from "../typed-ipc-renderer.ts";
export function init(
$reconnectButton: Element,

View File

@@ -1,17 +1,17 @@
import type {Html} from "../../../../common/html.js";
import {html} from "../../../../common/html.js";
import {generateNodeFromHtml} from "../../components/base.js";
import {ipcRenderer} from "../../typed-ipc-renderer.js";
import {type Html, html} from "../../../../common/html.ts";
import * as t from "../../../../common/translation-util.ts";
import {generateNodeFromHtml} from "../../components/base.ts";
import {ipcRenderer} from "../../typed-ipc-renderer.ts";
type BaseSectionProps = {
type BaseSectionProperties = {
$element: HTMLElement;
disabled?: boolean;
value: boolean;
clickHandler: () => void;
};
export function generateSettingOption(props: BaseSectionProps): void {
const {$element, disabled, value, clickHandler} = props;
export function generateSettingOption(properties: BaseSectionProperties): void {
const {$element, disabled, value, clickHandler} = properties;
$element.textContent = "";
@@ -30,10 +30,9 @@ export function generateOptionHtml(
disabled?: boolean,
): Html {
const labelHtml = disabled
? // eslint-disable-next-line unicorn/template-indent
html`<label
? html`<label
class="disallowed"
title="Setting locked by system administrator."
title="${t.__("Setting locked by system administrator.")}"
></label>`
: html`<label></label>`;
if (settingOption) {

View File

@@ -1,19 +1,19 @@
import {html} from "../../../../common/html.js";
import * as t from "../../../../common/translation-util.js";
import {ipcRenderer} from "../../typed-ipc-renderer.js";
import * as DomainUtil from "../../utils/domain-util.js";
import {html} from "../../../../common/html.ts";
import * as t from "../../../../common/translation-util.ts";
import {ipcRenderer} from "../../typed-ipc-renderer.ts";
import * as DomainUtil from "../../utils/domain-util.ts";
import {reloadApp} from "./base-section.js";
import {initFindAccounts} from "./find-accounts.js";
import {initServerInfoForm} from "./server-info-form.js";
import {reloadApp} from "./base-section.ts";
import {initFindAccounts} from "./find-accounts.ts";
import {initServerInfoForm} from "./server-info-form.ts";
type ConnectedOrgSectionProps = {
type ConnectedOrgSectionProperties = {
$root: Element;
};
export function initConnectedOrgSection({
$root,
}: ConnectedOrgSectionProps): void {
}: ConnectedOrgSectionProperties): void {
$root.textContent = "";
const servers = DomainUtil.getDomains();
@@ -21,7 +21,7 @@ export function initConnectedOrgSection({
<div class="settings-pane" id="server-settings-pane">
<div class="page-title">${t.__("Connected organizations")}</div>
<div class="title" id="existing-servers">
${t.__("All the connected orgnizations will appear here.")}
${t.__("All the connected organizations will appear here.")}
</div>
<div id="server-info-container"></div>
<div id="new-org-button">
@@ -42,7 +42,9 @@ export function initConnectedOrgSection({
"#find-accounts-container",
)!;
const noServerText = t.__("All the connected orgnizations will appear here");
const noServerText = t.__(
"All the connected organizations will appear here.",
);
// Show noServerText if no servers are there otherwise hide it
$existingServers.textContent = servers.length === 0 ? noServerText : "";

View File

@@ -1,9 +1,9 @@
import {html} from "../../../../common/html.js";
import * as LinkUtil from "../../../../common/link-util.js";
import * as t from "../../../../common/translation-util.js";
import {generateNodeFromHtml} from "../../components/base.js";
import {html} from "../../../../common/html.ts";
import * as LinkUtil from "../../../../common/link-util.ts";
import * as t from "../../../../common/translation-util.ts";
import {generateNodeFromHtml} from "../../components/base.ts";
type FindAccountsProps = {
type FindAccountsProperties = {
$root: Element;
};
@@ -19,7 +19,7 @@ async function findAccounts(url: string): Promise<void> {
await LinkUtil.openBrowser(new URL("/accounts/find", url));
}
export function initFindAccounts(props: FindAccountsProps): void {
export function initFindAccounts(properties: FindAccountsProperties): void {
const $findAccounts = generateNodeFromHtml(html`
<div class="settings-card certificate-card">
<div class="certificate-input">
@@ -33,7 +33,7 @@ export function initFindAccounts(props: FindAccountsProps): void {
</div>
</div>
`);
props.$root.append($findAccounts);
properties.$root.append($findAccounts);
const $findAccountsButton = $findAccounts.querySelector(
"#find-accounts-button",
)!;

View File

@@ -6,25 +6,24 @@ import process from "node:process";
import * as remote from "@electron/remote";
import {app, dialog, session} from "@electron/remote";
import Tagify from "@yaireo/tagify";
import ISO6391 from "iso-639-1";
import * as z from "zod";
import {z} from "zod";
import supportedLocales from "../../../../../public/translations/supported-locales.json";
import * as ConfigUtil from "../../../../common/config-util.js";
import * as EnterpriseUtil from "../../../../common/enterprise-util.js";
import {html} from "../../../../common/html.js";
import * as t from "../../../../common/translation-util.js";
import {ipcRenderer} from "../../typed-ipc-renderer.js";
import * as ConfigUtil from "../../../../common/config-util.ts";
import * as EnterpriseUtil from "../../../../common/enterprise-util.ts";
import {html} from "../../../../common/html.ts";
import * as t from "../../../../common/translation-util.ts";
import {ipcRenderer} from "../../typed-ipc-renderer.ts";
import {generateSelectHtml, generateSettingOption} from "./base-section.js";
import {generateSelectHtml, generateSettingOption} from "./base-section.ts";
const currentBrowserWindow = remote.getCurrentWindow();
type GeneralSectionProps = {
type GeneralSectionProperties = {
$root: Element;
};
export function initGeneralSection({$root}: GeneralSectionProps): void {
export function initGeneralSection({$root}: GeneralSectionProperties): void {
$root.innerHTML = html`
<div class="settings-pane">
<div class="title">${t.__("Appearance")}</div>
@@ -57,7 +56,7 @@ export function initGeneralSection({$root}: GeneralSectionProps): void {
</div>
<div class="setting-row" id="badge-option">
<div class="setting-description">
${t.__("Show app unread badge")}
${t.__("Show unread count badge on app icon")}
</div>
<div class="setting-control"></div>
</div>
@@ -356,7 +355,8 @@ export function initGeneralSection({$root}: GeneralSectionProps): void {
const newValue = !ConfigUtil.getConfigItem("silent", true);
ConfigUtil.setConfigItem("silent", newValue);
updateSilentOption();
ipcRenderer.sendTo(
ipcRenderer.send(
"forward-to",
currentBrowserWindow.webContents.id,
"toggle-silent",
newValue,
@@ -455,14 +455,13 @@ export function initGeneralSection({$root}: GeneralSectionProps): void {
async function customCssDialog(): Promise<void> {
const showDialogOptions: OpenDialogOptions = {
title: "Select file",
title: t.__("Select file"),
properties: ["openFile"],
filters: [{name: "CSS file", extensions: ["css"]}],
filters: [{name: t.__("CSS file"), extensions: ["css"]}],
};
const {filePaths, canceled} = await dialog.showOpenDialog(
showDialogOptions,
);
const {filePaths, canceled} =
await dialog.showOpenDialog(showDialogOptions);
if (!canceled) {
ConfigUtil.setConfigItem("customCSS", filePaths[0]);
ipcRenderer.send("forward-message", "hard-reload");
@@ -525,13 +524,12 @@ export function initGeneralSection({$root}: GeneralSectionProps): void {
async function downloadFolderDialog(): Promise<void> {
const showDialogOptions: OpenDialogOptions = {
title: "Select Download Location",
title: t.__("Select Download Location"),
properties: ["openDirectory"],
};
const {filePaths, canceled} = await dialog.showOpenDialog(
showDialogOptions,
);
const {filePaths, canceled} =
await dialog.showOpenDialog(showDialogOptions);
if (!canceled) {
ConfigUtil.setConfigItem("downloadsPath", filePaths[0]);
const downloadFolderPath: HTMLElement = $root.querySelector(
@@ -563,15 +561,16 @@ export function initGeneralSection({$root}: GeneralSectionProps): void {
}
async function factoryResetSettings(): Promise<void> {
const clearAppDataMessage =
"When the application restarts, it will be as if you have just downloaded Zulip app.";
const clearAppDataMessage = t.__(
"When the application restarts, it will be as if you have just downloaded the Zulip app.",
);
const getAppPath = path.join(app.getPath("appData"), app.name);
const {response} = await dialog.showMessageBox({
type: "warning",
buttons: ["YES", "NO"],
buttons: [t.__("Yes"), t.__("No")],
defaultId: 0,
message: "Are you sure?",
message: t.__("Are you sure?"),
detail: clearAppDataMessage,
});
if (response === 0) {
@@ -592,7 +591,7 @@ export function initGeneralSection({$root}: GeneralSectionProps): void {
}
function initSpellChecker(): void {
// The elctron API is a no-op on macOS and macOS default spellchecker is used.
// The Electron API is a no-op on macOS and macOS default spellchecker is used.
if (process.platform === "darwin") {
const note: HTMLElement = $root.querySelector("#note")!;
note.append(t.__("On macOS, the OS spellchecker is used."));
@@ -610,7 +609,9 @@ export function initGeneralSection({$root}: GeneralSectionProps): void {
const spellDiv: HTMLElement = $root.querySelector("#spellcheck-langs")!;
spellDiv.innerHTML += html`
<div class="setting-description">${t.__("Spellchecker Languages")}</div>
<input name="spellcheck" placeholder="Enter Languages" />
<div id="spellcheck-langs-value">
<input name="spellcheck" placeholder="${t.__("Enter Languages")}" />
</div>
`.html;
const availableLanguages = session.fromPartition(
@@ -618,26 +619,23 @@ export function initGeneralSection({$root}: GeneralSectionProps): void {
).availableSpellCheckerLanguages;
let languagePairs = new Map<string, string>();
for (const l of availableLanguages) {
if (ISO6391.validate(l)) {
languagePairs.set(ISO6391.getName(l), l);
}
const locale = new Intl.Locale(l.replaceAll("_", "-"));
let displayName = new Intl.DisplayNames([locale], {
type: "language",
}).of(locale.language);
if (displayName === undefined) continue;
displayName = displayName.replace(/^./u, (firstChar) =>
firstChar.toLocaleUpperCase(locale),
);
if (locale.script !== undefined)
displayName += ` (${new Intl.DisplayNames([locale], {type: "script"}).of(locale.script)})`;
if (locale.region !== undefined)
displayName += ` (${new Intl.DisplayNames([locale], {type: "region"}).of(locale.region)})`;
languagePairs.set(displayName, l);
}
// Manually set names for languages not available in ISO6391
languagePairs.set("English (AU)", "en-AU");
languagePairs.set("English (CA)", "en-CA");
languagePairs.set("English (GB)", "en-GB");
languagePairs.set("English (US)", "en-US");
languagePairs.set("Spanish (Latin America)", "es-419");
languagePairs.set("Spanish (Argentina)", "es-AR");
languagePairs.set("Spanish (Mexico)", "es-MX");
languagePairs.set("Spanish (US)", "es-US");
languagePairs.set("Portuguese (Brazil)", "pt-BR");
languagePairs.set("Portuguese (Portugal)", "pt-PT");
languagePairs.set("Serbo-Croatian", "sh");
languagePairs = new Map(
[...languagePairs].sort((a, b) => (a[0] < b[0] ? -1 : 1)),
[...languagePairs].sort((a, b) => a[0].localeCompare(b[1])),
);
const tagField: HTMLInputElement = $root.querySelector(
@@ -652,8 +650,20 @@ export function initGeneralSection({$root}: GeneralSectionProps): void {
maxItems: Number.POSITIVE_INFINITY,
closeOnSelect: false,
highlightFirst: true,
position: "manual",
classname: "settings-tagify-dropdown",
},
});
tagify.DOM.input.addEventListener("focus", () => {
tagify.dropdown.show();
$root
.querySelector("#spellcheck-langs-value")!
.append(tagify.DOM.dropdown);
});
tagify.DOM.input.addEventListener("blur", () => {
tagify.dropdown.hide(true);
tagify.DOM.dropdown.remove();
});
const configuredLanguages: string[] = (
ConfigUtil.getConfigItem("spellcheckerLanguages", null) ?? []

View File

@@ -1,74 +1,74 @@
import type {Html} from "../../../../common/html.js";
import {html} from "../../../../common/html.js";
import * as t from "../../../../common/translation-util.js";
import type {NavItem} from "../../../../common/types.js";
import {generateNodeFromHtml} from "../../components/base.js";
import {type Html, html} from "../../../../common/html.ts";
import * as t from "../../../../common/translation-util.ts";
import type {NavigationItem} from "../../../../common/types.ts";
import {generateNodeFromHtml} from "../../components/base.ts";
type PreferenceNavProps = {
type PreferenceNavigationProperties = {
$root: Element;
onItemSelected: (navItem: NavItem) => void;
onItemSelected: (navigationItem: NavigationItem) => void;
};
export default class PreferenceNav {
navItems: NavItem[];
export default class PreferenceNavigation {
navigationItems: Array<{navigationItem: NavigationItem; label: string}>;
$el: Element;
constructor(private readonly props: PreferenceNavProps) {
this.navItems = [
"General",
"Network",
"AddServer",
"Organizations",
"Shortcuts",
constructor(private readonly properties: PreferenceNavigationProperties) {
this.navigationItems = [
{navigationItem: "General", label: t.__("General")},
{navigationItem: "Network", label: t.__("Network")},
{navigationItem: "AddServer", label: t.__("Add Organization")},
{navigationItem: "Organizations", label: t.__("Organizations")},
{navigationItem: "Shortcuts", label: t.__("Shortcuts")},
];
this.$el = generateNodeFromHtml(this.templateHtml());
this.props.$root.append(this.$el);
this.properties.$root.append(this.$el);
this.registerListeners();
}
templateHtml(): Html {
const navItemsHtml = html``.join(
this.navItems.map(
(navItem) => html`
<div class="nav" id="nav-${navItem}">${t.__(navItem)}</div>
`,
const navigationItemsHtml = html``.join(
this.navigationItems.map(
({navigationItem, label}) =>
html`<div class="nav" id="nav-${navigationItem}">${label}</div>`,
),
);
return html`
<div>
<div id="settings-header">${t.__("Settings")}</div>
<div id="nav-container">${navItemsHtml}</div>
<div id="nav-container">${navigationItemsHtml}</div>
</div>
`;
}
registerListeners(): void {
for (const navItem of this.navItems) {
const $item = this.$el.querySelector(`#nav-${CSS.escape(navItem)}`)!;
for (const {navigationItem} of this.navigationItems) {
const $item = this.$el.querySelector(
`#nav-${CSS.escape(navigationItem)}`,
)!;
$item.addEventListener("click", () => {
this.props.onItemSelected(navItem);
this.properties.onItemSelected(navigationItem);
});
}
}
select(navItemToSelect: NavItem): void {
for (const navItem of this.navItems) {
if (navItem === navItemToSelect) {
this.activate(navItem);
select(navigationItemToSelect: NavigationItem): void {
for (const {navigationItem} of this.navigationItems) {
if (navigationItem === navigationItemToSelect) {
this.activate(navigationItem);
} else {
this.deactivate(navItem);
this.deactivate(navigationItem);
}
}
}
activate(navItem: NavItem): void {
const $item = this.$el.querySelector(`#nav-${CSS.escape(navItem)}`)!;
activate(navigationItem: NavigationItem): void {
const $item = this.$el.querySelector(`#nav-${CSS.escape(navigationItem)}`)!;
$item.classList.add("active");
}
deactivate(navItem: NavItem): void {
const $item = this.$el.querySelector(`#nav-${CSS.escape(navItem)}`)!;
deactivate(navigationItem: NavigationItem): void {
const $item = this.$el.querySelector(`#nav-${CSS.escape(navigationItem)}`)!;
$item.classList.remove("active");
}
}

View File

@@ -1,15 +1,15 @@
import * as ConfigUtil from "../../../../common/config-util.js";
import {html} from "../../../../common/html.js";
import * as t from "../../../../common/translation-util.js";
import {ipcRenderer} from "../../typed-ipc-renderer.js";
import * as ConfigUtil from "../../../../common/config-util.ts";
import {html} from "../../../../common/html.ts";
import * as t from "../../../../common/translation-util.ts";
import {ipcRenderer} from "../../typed-ipc-renderer.ts";
import {generateSettingOption} from "./base-section.js";
import {generateSettingOption} from "./base-section.ts";
type NetworkSectionProps = {
type NetworkSectionProperties = {
$root: Element;
};
export function initNetworkSection({$root}: NetworkSectionProps): void {
export function initNetworkSection({$root}: NetworkSectionProperties): void {
$root.innerHTML = html`
<div class="settings-pane">
<div class="title">${t.__("Proxy")}</div>
@@ -28,7 +28,7 @@ export function initNetworkSection({$root}: NetworkSectionProps): void {
</div>
<div class="manual-proxy-block">
<div class="setting-row" id="proxy-pac-option">
<span class="setting-input-key">PAC ${t.__("script")}</span>
<span class="setting-input-key">${t.__("PAC script")}</span>
<input
class="setting-input-value"
placeholder="e.g. foobar.com/pacfile.js"

View File

@@ -1,18 +1,21 @@
import {dialog} from "@electron/remote";
import {html} from "../../../../common/html.js";
import * as LinkUtil from "../../../../common/link-util.js";
import * as t from "../../../../common/translation-util.js";
import {generateNodeFromHtml} from "../../components/base.js";
import {ipcRenderer} from "../../typed-ipc-renderer.js";
import * as DomainUtil from "../../utils/domain-util.js";
import {html} from "../../../../common/html.ts";
import * as LinkUtil from "../../../../common/link-util.ts";
import * as t from "../../../../common/translation-util.ts";
import {generateNodeFromHtml} from "../../components/base.ts";
import {ipcRenderer} from "../../typed-ipc-renderer.ts";
import * as DomainUtil from "../../utils/domain-util.ts";
type NewServerFormProps = {
type NewServerFormProperties = {
$root: Element;
onChange: () => void;
};
export function initNewServerForm({$root, onChange}: NewServerFormProps): void {
export function initNewServerForm({
$root,
onChange,
}: NewServerFormProperties): void {
const $newServerForm = generateNodeFromHtml(html`
<div class="server-input-container">
<div class="title">${t.__("Organization URL")}</div>
@@ -20,7 +23,9 @@ export function initNewServerForm({$root, onChange}: NewServerFormProps): void {
<input
class="setting-input-value"
autofocus
placeholder="your-organization.zulipchat.com or zulip.your-organization.com"
placeholder="${t.__(
"your-organization.zulipchat.com or zulip.your-organization.com",
)}"
/>
</div>
<div class="server-center">
@@ -57,24 +62,24 @@ export function initNewServerForm({$root, onChange}: NewServerFormProps): void {
)!;
async function submitFormHandler(): Promise<void> {
$saveServerButton.textContent = "Connecting...";
let serverConf;
$saveServerButton.textContent = t.__("Connecting…");
let serverConfig;
try {
serverConf = await DomainUtil.checkDomain($newServerUrl.value.trim());
serverConfig = await DomainUtil.checkDomain($newServerUrl.value.trim());
} catch (error: unknown) {
$saveServerButton.textContent = "Connect";
$saveServerButton.textContent = t.__("Connect");
await dialog.showMessageBox({
type: "error",
message:
error instanceof Error
? `${error.name}: ${error.message}`
: "Unknown error",
buttons: ["OK"],
: t.__("Unknown error"),
buttons: [t.__("OK")],
});
return;
}
await DomainUtil.addDomain(serverConf);
await DomainUtil.addDomain(serverConfig);
onChange();
}

View File

@@ -1,16 +1,17 @@
import type {IpcRendererEvent} from "electron/renderer";
import process from "node:process";
import type {DndSettings} from "../../../../common/dnd-util.js";
import {bundleUrl} from "../../../../common/paths.js";
import type {NavItem} from "../../../../common/types.js";
import {ipcRenderer} from "../../typed-ipc-renderer.js";
import type {DndSettings} from "../../../../common/dnd-util.ts";
import {bundleUrl} from "../../../../common/paths.ts";
import type {NavigationItem} from "../../../../common/types.ts";
import {ipcRenderer} from "../../typed-ipc-renderer.ts";
import {initConnectedOrgSection} from "./connected-org-section.js";
import {initGeneralSection} from "./general-section.js";
import Nav from "./nav.js";
import {initNetworkSection} from "./network-section.js";
import {initServersSection} from "./servers-section.js";
import {initShortcutsSection} from "./shortcuts-section.js";
import {initConnectedOrgSection} from "./connected-org-section.ts";
import {initGeneralSection} from "./general-section.ts";
import Nav from "./nav.ts";
import {initNetworkSection} from "./network-section.ts";
import {initServersSection} from "./servers-section.ts";
import {initShortcutsSection} from "./shortcuts-section.ts";
export class PreferenceView {
static async create(): Promise<PreferenceView> {
@@ -25,7 +26,7 @@ export class PreferenceView {
private readonly $shadow: ShadowRoot;
private readonly $settingsContainer: Element;
private readonly nav: Nav;
private navItem: NavItem = "General";
private navigationItem: NavigationItem = "General";
private constructor(templateHtml: string) {
this.$view = document.createElement("div");
@@ -46,13 +47,13 @@ export class PreferenceView {
ipcRenderer.on("toggle-autohide-menubar", this.handleToggleMenubar);
ipcRenderer.on("toggle-dnd", this.handleToggleDnd);
this.handleNavigation(this.navItem);
this.handleNavigation(this.navigationItem);
}
handleNavigation = (navItem: NavItem): void => {
this.navItem = navItem;
this.nav.select(navItem);
switch (navItem) {
handleNavigation = (navigationItem: NavigationItem): void => {
this.navigationItem = navigationItem;
this.nav.select(navigationItem);
switch (navigationItem) {
case "AddServer": {
initServersSection({
$root: this.$settingsContainer,
@@ -87,13 +88,9 @@ export class PreferenceView {
});
break;
}
default: {
((n: never) => n)(navItem);
}
}
window.location.hash = `#${navItem}`;
location.hash = `#${navigationItem}`;
};
handleToggleTray(state: boolean) {
@@ -115,16 +112,22 @@ export class PreferenceView {
}
}
private readonly handleToggleSidebar = (_event: Event, state: boolean) => {
private readonly handleToggleSidebar = (
_event: IpcRendererEvent,
state: boolean,
) => {
this.handleToggle("sidebar-option", state);
};
private readonly handleToggleMenubar = (_event: Event, state: boolean) => {
private readonly handleToggleMenubar = (
_event: IpcRendererEvent,
state: boolean,
) => {
this.handleToggle("menubar-option", state);
};
private readonly handleToggleDnd = (
_event: Event,
_event: IpcRendererEvent,
_state: boolean,
newSettings: Partial<DndSettings>,
) => {

View File

@@ -1,34 +1,37 @@
import {dialog} from "@electron/remote";
import {html} from "../../../../common/html.js";
import * as Messages from "../../../../common/messages.js";
import * as t from "../../../../common/translation-util.js";
import type {ServerConf} from "../../../../common/types.js";
import {generateNodeFromHtml} from "../../components/base.js";
import {ipcRenderer} from "../../typed-ipc-renderer.js";
import * as DomainUtil from "../../utils/domain-util.js";
import {html} from "../../../../common/html.ts";
import * as Messages from "../../../../common/messages.ts";
import * as t from "../../../../common/translation-util.ts";
import type {ServerConfig} from "../../../../common/types.ts";
import {generateNodeFromHtml} from "../../components/base.ts";
import {ipcRenderer} from "../../typed-ipc-renderer.ts";
import * as DomainUtil from "../../utils/domain-util.ts";
type ServerInfoFormProps = {
type ServerInfoFormProperties = {
$root: Element;
server: ServerConf;
server: ServerConfig;
index: number;
onChange: () => void;
};
export function initServerInfoForm(props: ServerInfoFormProps): void {
export function initServerInfoForm(properties: ServerInfoFormProperties): void {
const $serverInfoForm = generateNodeFromHtml(html`
<div class="settings-card">
<div class="server-info-left">
<img class="server-info-icon" src="${props.server.icon}" />
<img
class="server-info-icon"
src="${DomainUtil.iconAsUrl(properties.server.icon)}"
/>
<div class="server-info-row">
<span class="server-info-alias">${props.server.alias}</span>
<span class="server-info-alias">${properties.server.alias}</span>
<i class="material-icons open-tab-button">open_in_new</i>
</div>
</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
<span class="server-url-info" title="${properties.server.url}"
>${properties.server.url}</span
>
</div>
<div class="server-info-row">
@@ -45,21 +48,21 @@ export function initServerInfoForm(props: ServerInfoFormProps): void {
".server-delete-action",
)!;
const $openServerButton = $serverInfoForm.querySelector(".open-tab-button")!;
props.$root.append($serverInfoForm);
properties.$root.append($serverInfoForm);
$deleteServerButton.addEventListener("click", async () => {
const {response} = await dialog.showMessageBox({
type: "warning",
buttons: [t.__("YES"), t.__("NO")],
buttons: [t.__("Yes"), t.__("No")],
defaultId: 0,
message: t.__("Are you sure you want to disconnect this organization?"),
});
if (response === 0) {
if (DomainUtil.removeDomain(props.index)) {
if (DomainUtil.removeDomain(properties.index)) {
ipcRenderer.send("reload-full-app");
} else {
const {title, content} = Messages.orgRemovalError(
DomainUtil.getDomain(props.index).url,
DomainUtil.getDomain(properties.index).url,
);
dialog.showErrorBox(title, content);
}
@@ -67,14 +70,14 @@ export function initServerInfoForm(props: ServerInfoFormProps): void {
});
$openServerButton.addEventListener("click", () => {
ipcRenderer.send("forward-message", "switch-server-tab", props.index);
ipcRenderer.send("forward-message", "switch-server-tab", properties.index);
});
$serverInfoAlias.addEventListener("click", () => {
ipcRenderer.send("forward-message", "switch-server-tab", props.index);
ipcRenderer.send("forward-message", "switch-server-tab", properties.index);
});
$serverIcon.addEventListener("click", () => {
ipcRenderer.send("forward-message", "switch-server-tab", props.index);
ipcRenderer.send("forward-message", "switch-server-tab", properties.index);
});
}

View File

@@ -1,14 +1,14 @@
import {html} from "../../../../common/html.js";
import * as t from "../../../../common/translation-util.js";
import {html} from "../../../../common/html.ts";
import * as t from "../../../../common/translation-util.ts";
import {reloadApp} from "./base-section.js";
import {initNewServerForm} from "./new-server-form.js";
import {reloadApp} from "./base-section.ts";
import {initNewServerForm} from "./new-server-form.ts";
type ServersSectionProps = {
type ServersSectionProperties = {
$root: Element;
};
export function initServersSection({$root}: ServersSectionProps): void {
export function initServersSection({$root}: ServersSectionProperties): void {
$root.innerHTML = html`
<div class="add-server-modal">
<div class="modal-container">

View File

@@ -1,15 +1,17 @@
import process from "node:process";
import {html} from "../../../../common/html.js";
import * as LinkUtil from "../../../../common/link-util.js";
import * as t from "../../../../common/translation-util.js";
import {html} from "../../../../common/html.ts";
import * as LinkUtil from "../../../../common/link-util.ts";
import * as t from "../../../../common/translation-util.ts";
type ShortcutsSectionProps = {
type ShortcutsSectionProperties = {
$root: Element;
};
// eslint-disable-next-line complexity
export function initShortcutsSection({$root}: ShortcutsSectionProps): void {
export function initShortcutsSection({
$root,
}: ShortcutsSectionProperties): void {
const cmdOrCtrl = process.platform === "darwin" ? "⌘" : "Ctrl";
$root.innerHTML = html`

View File

@@ -1,67 +1,21 @@
import {contextBridge, webFrame} from "electron/renderer";
import {contextBridge} from "electron/renderer";
import electron_bridge, {bridgeEvents} from "./electron-bridge.js";
import * as NetworkError from "./pages/network.js";
import {ipcRenderer} from "./typed-ipc-renderer.js";
import electron_bridge, {bridgeEvents} from "./electron-bridge.ts";
import * as NetworkError from "./pages/network.ts";
import {ipcRenderer} from "./typed-ipc-renderer.ts";
contextBridge.exposeInMainWorld("raw_electron_bridge", electron_bridge);
contextBridge.exposeInMainWorld("electron_bridge", electron_bridge);
ipcRenderer.on("logout", () => {
if (bridgeEvents.emit("logout")) {
return;
}
// Create the menu for the below
const dropdown: HTMLElement = document.querySelector(".dropdown-toggle")!;
dropdown.click();
const nodes: NodeListOf<HTMLElement> = document.querySelectorAll(
".dropdown-menu li:last-child a",
);
nodes[nodes.length - 1].click();
bridgeEvents.emit("logout");
});
ipcRenderer.on("show-keyboard-shortcuts", () => {
if (bridgeEvents.emit("show-keyboard-shortcuts")) {
return;
}
// Create the menu for the below
const node: HTMLElement = document.querySelector(
"a[data-overlay-trigger=keyboard-shortcuts]",
)!;
// Additional check
if (node.textContent!.trim().toLowerCase() === "keyboard shortcuts (?)") {
node.click();
} else {
// Atleast click the dropdown
const dropdown: HTMLElement = document.querySelector(".dropdown-toggle")!;
dropdown.click();
}
bridgeEvents.emit("show-keyboard-shortcuts");
});
ipcRenderer.on("show-notification-settings", () => {
if (bridgeEvents.emit("show-notification-settings")) {
return;
}
// Create the menu for the below
const dropdown: HTMLElement = document.querySelector(".dropdown-toggle")!;
dropdown.click();
const nodes: NodeListOf<HTMLElement> = document.querySelectorAll(
".dropdown-menu li a",
);
nodes[2].click();
const notificationItem: NodeListOf<HTMLElement> = document.querySelectorAll(
".normal-settings-list li div",
);
// Wait until the notification dom element shows up
setTimeout(() => {
notificationItem[2].click();
}, 100);
bridgeEvents.emit("show-notification-settings");
});
window.addEventListener("load", () => {
@@ -73,6 +27,3 @@ window.addEventListener("load", () => {
const $settingsButton = document.querySelector("#settings")!;
NetworkError.init($reconnectButton, $settingsButton);
});
(async () =>
webFrame.executeJavaScript(ipcRenderer.sendSync("get-injected-js")))();

View File

@@ -1,17 +1,17 @@
import type {NativeImage} from "electron/common";
import {nativeImage} from "electron/common";
import {type NativeImage, nativeImage} from "electron/common";
import type {Tray as ElectronTray} from "electron/main";
import path from "node:path";
import process from "node:process";
import {BrowserWindow, Menu, Tray} from "@electron/remote";
import * as ConfigUtil from "../../common/config-util.js";
import {publicPath} from "../../common/paths.js";
import type {RendererMessage} from "../../common/typed-ipc.js";
import * as ConfigUtil from "../../common/config-util.ts";
import {publicPath} from "../../common/paths.ts";
import * as t from "../../common/translation-util.ts";
import type {RendererMessage} from "../../common/typed-ipc.ts";
import type {ServerManagerView} from "./main.js";
import {ipcRenderer} from "./typed-ipc-renderer.js";
import type {ServerManagerView} from "./main.ts";
import {ipcRenderer} from "./typed-ipc-renderer.ts";
let tray: ElectronTray | null = null;
@@ -64,8 +64,8 @@ const config = {
thick: process.platform === "win32",
};
const renderCanvas = function (arg: number): HTMLCanvasElement {
config.unreadCount = arg;
const renderCanvas = function (argument: number): HTMLCanvasElement {
config.unreadCount = argument;
const size = config.size * config.pixelRatio;
const padding = size * 0.05;
@@ -79,30 +79,34 @@ const renderCanvas = function (arg: number): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d")!;
const context = canvas.getContext("2d")!;
// Circle
// If (!config.thick || config.thick && hasCount) {
ctx.beginPath();
ctx.arc(center, center, size / 2 - padding, 0, 2 * Math.PI, false);
ctx.fillStyle = backgroundColor;
ctx.fill();
ctx.lineWidth = size / (config.thick ? 10 : 20);
ctx.strokeStyle = backgroundColor;
ctx.stroke();
context.beginPath();
context.arc(center, center, size / 2 - padding, 0, 2 * Math.PI, false);
context.fillStyle = backgroundColor;
context.fill();
context.lineWidth = size / (config.thick ? 10 : 20);
context.strokeStyle = backgroundColor;
context.stroke();
// Count or Icon
if (hasCount) {
ctx.fillStyle = color;
ctx.textAlign = "center";
context.fillStyle = color;
context.textAlign = "center";
if (config.unreadCount > 99) {
ctx.font = `${config.thick ? "bold " : ""}${size * 0.4}px Helvetica`;
ctx.fillText("99+", center, center + size * 0.15);
context.font = `${config.thick ? "bold " : ""}${size * 0.4}px Helvetica`;
context.fillText("99+", center, center + size * 0.15);
} else if (config.unreadCount < 10) {
ctx.font = `${config.thick ? "bold " : ""}${size * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), center, center + size * 0.2);
context.font = `${config.thick ? "bold " : ""}${size * 0.5}px Helvetica`;
context.fillText(String(config.unreadCount), center, center + size * 0.2);
} else {
ctx.font = `${config.thick ? "bold " : ""}${size * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), center, center + size * 0.15);
context.font = `${config.thick ? "bold " : ""}${size * 0.5}px Helvetica`;
context.fillText(
String(config.unreadCount),
center,
center + size * 0.15,
);
}
}
@@ -114,12 +118,12 @@ const renderCanvas = function (arg: number): HTMLCanvasElement {
* @param arg: Unread count
* @return the native image
*/
const renderNativeImage = function (arg: number): NativeImage {
const renderNativeImage = function (argument: number): NativeImage {
if (process.platform === "win32") {
return nativeImage.createFromPath(winUnreadTrayIconPath());
}
const canvas = renderCanvas(arg);
const canvas = renderCanvas(argument);
const pngData = nativeImage
.createFromDataURL(canvas.toDataURL("image/png"))
.toPNG();
@@ -130,7 +134,7 @@ const renderNativeImage = function (arg: number): NativeImage {
function sendAction<Channel extends keyof RendererMessage>(
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
...arguments_: Parameters<RendererMessage[Channel]>
): void {
const win = BrowserWindow.getAllWindows()[0];
@@ -138,19 +142,19 @@ function sendAction<Channel extends keyof RendererMessage>(
win.restore();
}
ipcRenderer.sendTo(win.webContents.id, channel, ...args);
ipcRenderer.send("forward-to", win.webContents.id, channel, ...arguments_);
}
const createTray = function (): void {
const contextMenu = Menu.buildFromTemplate([
{
label: "Zulip",
label: t.__("Zulip"),
click() {
ipcRenderer.send("focus-app");
},
},
{
label: "Settings",
label: t.__("Settings"),
click() {
ipcRenderer.send("focus-app");
sendAction("open-settings");
@@ -160,7 +164,7 @@ const createTray = function (): void {
type: "separator",
},
{
label: "Quit",
label: t.__("Quit"),
click() {
ipcRenderer.send("quit-app");
},
@@ -176,7 +180,7 @@ const createTray = function (): void {
};
export function initializeTray(serverManagerView: ServerManagerView) {
ipcRenderer.on("destroytray", (_event: Event) => {
ipcRenderer.on("destroytray", () => {
if (!tray) {
return;
}
@@ -189,22 +193,27 @@ export function initializeTray(serverManagerView: ServerManagerView) {
}
});
ipcRenderer.on("tray", (_event: Event, arg: number): void => {
ipcRenderer.on("tray", (_event, argument: number): void => {
if (!tray) {
return;
}
// We don't want to create tray from unread messages on macOS since it already has dock badges.
if (process.platform === "linux" || process.platform === "win32") {
if (arg === 0) {
unread = arg;
if (argument === 0) {
unread = argument;
tray.setImage(iconPath());
tray.setToolTip("No unread messages");
tray.setToolTip(t.__("No unread messages"));
} else {
unread = arg;
const image = renderNativeImage(arg);
unread = argument;
const image = renderNativeImage(argument);
tray.setImage(image);
tray.setToolTip(`${arg} unread messages`);
tray.setToolTip(
t.__mf(
"{number, plural, one {# unread message} other {# unread messages}}",
{number: `${argument}`},
),
);
}
}
});

View File

@@ -1,5 +1,5 @@
import type {IpcRendererEvent} from "electron/renderer";
import {
type IpcRendererEvent,
ipcRenderer as untypedIpcRenderer, // eslint-disable-line no-restricted-imports
} from "electron/renderer";
@@ -10,8 +10,8 @@ import type {
} from "../../common/typed-ipc.js";
type RendererListener<Channel extends keyof RendererMessage> =
RendererMessage[Channel] extends (...args: infer Args) => void
? (event: IpcRendererEvent, ...args: Args) => void
RendererMessage[Channel] extends (...arguments_: infer Arguments) => void
? (event: IpcRendererEvent, ...arguments_: Arguments) => void
: never;
export const ipcRenderer: {
@@ -35,19 +35,25 @@ export const ipcRenderer: {
send<Channel extends keyof RendererMessage>(
channel: "forward-message",
rendererChannel: Channel,
...args: Parameters<RendererMessage[Channel]>
...arguments_: Parameters<RendererMessage[Channel]>
): void;
send<Channel extends keyof RendererMessage>(
channel: "forward-to",
webContentsId: number,
rendererChannel: Channel,
...arguments_: Parameters<RendererMessage[Channel]>
): void;
send<Channel extends keyof MainMessage>(
channel: Channel,
...args: Parameters<MainMessage[Channel]>
...arguments_: Parameters<MainMessage[Channel]>
): void;
invoke<Channel extends keyof MainCall>(
channel: Channel,
...args: Parameters<MainCall[Channel]>
...arguments_: Parameters<MainCall[Channel]>
): Promise<ReturnType<MainCall[Channel]>>;
sendSync<Channel extends keyof MainMessage>(
channel: Channel,
...args: Parameters<MainMessage[Channel]>
...arguments_: Parameters<MainMessage[Channel]>
): ReturnType<MainMessage[Channel]>;
postMessage<Channel extends keyof MainMessage>(
channel: Channel,
@@ -56,13 +62,8 @@ export const ipcRenderer: {
: 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]>
...arguments_: Parameters<RendererMessage[Channel]>
): void;
} = untypedIpcRenderer;

View File

@@ -2,68 +2,78 @@ import fs from "node:fs";
import path from "node:path";
import {app, dialog} from "@electron/remote";
import * as Sentry from "@sentry/electron";
import * as Sentry from "@sentry/electron/renderer";
import {JsonDB} from "node-json-db";
import {DataError} from "node-json-db/dist/lib/Errors";
import * as z from "zod";
import {DataError} from "node-json-db/dist/lib/Errors.js";
import {z} from "zod";
import * as EnterpriseUtil from "../../../common/enterprise-util.js";
import Logger from "../../../common/logger-util.js";
import * as Messages from "../../../common/messages.js";
import type {ServerConf} from "../../../common/types.js";
import {ipcRenderer} from "../typed-ipc-renderer.js";
import * as EnterpriseUtil from "../../../common/enterprise-util.ts";
import Logger from "../../../common/logger-util.ts";
import * as Messages from "../../../common/messages.ts";
import * as t from "../../../common/translation-util.ts";
import type {ServerConfig} from "../../../common/types.ts";
import defaultIcon from "../../img/icon.png";
import {ipcRenderer} from "../typed-ipc-renderer.ts";
const logger = new Logger({
file: "domain-util.log",
});
const defaultIconUrl = "../renderer/img/icon.png";
// For historical reasons, we store this string in domain.json to denote a
// missing icon; it does not change with the actual icon location.
export const defaultIconSentinel = "../renderer/img/icon.png";
const serverConfSchema = z.object({
url: z.string(),
const serverConfigSchema = z.object({
url: z.url(),
alias: z.string(),
icon: z.string(),
zulipVersion: z.string().default("unknown"),
zulipFeatureLevel: z.number().default(0),
});
let db!: JsonDB;
let database!: JsonDB;
reloadDb();
reloadDatabase();
// Migrate from old schema
try {
const oldDomain = db.getObject<unknown>("/domain");
const oldDomain = database.getObject<unknown>("/domain");
if (typeof oldDomain === "string") {
(async () => {
await addDomain({
alias: "Zulip",
url: oldDomain,
});
db.delete("/domain");
database.delete("/domain");
})();
}
} catch (error: unknown) {
if (!(error instanceof DataError)) throw error;
}
export function getDomains(): ServerConf[] {
reloadDb();
export function getDomains(): ServerConfig[] {
reloadDatabase();
try {
return serverConfSchema.array().parse(db.getObject<unknown>("/domains"));
return serverConfigSchema
.array()
.parse(database.getObject<unknown>("/domains"));
} catch (error: unknown) {
if (!(error instanceof DataError)) throw error;
return [];
}
}
export function getDomain(index: number): ServerConf {
reloadDb();
return serverConfSchema.parse(db.getObject<unknown>(`/domains[${index}]`));
export function getDomain(index: number): ServerConfig {
reloadDatabase();
return serverConfigSchema.parse(
database.getObject<unknown>(`/domains[${index}]`),
);
}
export function updateDomain(index: number, server: ServerConf): void {
reloadDb();
serverConfSchema.parse(server);
db.push(`/domains[${index}]`, server, true);
export function updateDomain(index: number, server: ServerConfig): void {
reloadDatabase();
serverConfigSchema.parse(server);
database.push(`/domains[${index}]`, server, true);
}
export async function addDomain(server: {
@@ -74,20 +84,20 @@ export async function addDomain(server: {
if (server.icon) {
const localIconUrl = await saveServerIcon(server.icon);
server.icon = localIconUrl;
serverConfSchema.parse(server);
db.push("/domains[]", server, true);
reloadDb();
serverConfigSchema.parse(server);
database.push("/domains[]", server, true);
reloadDatabase();
} else {
server.icon = defaultIconUrl;
serverConfSchema.parse(server);
db.push("/domains[]", server, true);
reloadDb();
server.icon = defaultIconSentinel;
serverConfigSchema.parse(server);
database.push("/domains[]", server, true);
reloadDatabase();
}
}
export function removeDomains(): void {
db.delete("/domains");
reloadDb();
database.delete("/domains");
reloadDatabase();
}
export function removeDomain(index: number): boolean {
@@ -95,8 +105,8 @@ export function removeDomain(index: number): boolean {
return false;
}
db.delete(`/domains[${index}]`);
reloadDb();
database.delete(`/domains[${index}]`);
reloadDatabase();
return true;
}
@@ -109,7 +119,7 @@ export function duplicateDomain(domain: string): boolean {
export async function checkDomain(
domain: string,
silent = false,
): Promise<ServerConf> {
): Promise<ServerConfig> {
if (!silent && duplicateDomain(domain)) {
// Do not check duplicate in silent mode
throw new Error("This server has been added.");
@@ -124,36 +134,43 @@ export async function checkDomain(
}
}
async function getServerSettings(domain: string): Promise<ServerConf> {
async function getServerSettings(domain: string): Promise<ServerConfig> {
return ipcRenderer.invoke("get-server-settings", domain);
}
export async function saveServerIcon(iconURL: string): Promise<string> {
return ipcRenderer.invoke("save-server-icon", iconURL);
return (
(await ipcRenderer.invoke("save-server-icon", iconURL)) ??
defaultIconSentinel
);
}
export async function updateSavedServer(
url: string,
index: number,
): Promise<void> {
): Promise<ServerConfig> {
// Does not promise successful update
const oldIcon = getDomain(index).icon;
const serverConfig = getDomain(index);
const oldIcon = serverConfig.icon;
try {
const newServerConf = await checkDomain(url, true);
const localIconUrl = await saveServerIcon(newServerConf.icon);
if (!oldIcon || localIconUrl !== "../renderer/img/icon.png") {
newServerConf.icon = localIconUrl;
updateDomain(index, newServerConf);
reloadDb();
const newServerConfig = await checkDomain(url, true);
const localIconUrl = await saveServerIcon(newServerConfig.icon);
if (!oldIcon || localIconUrl !== defaultIconSentinel) {
newServerConfig.icon = localIconUrl;
updateDomain(index, newServerConfig);
reloadDatabase();
}
return newServerConfig;
} catch (error: unknown) {
logger.log("Could not update server icon.");
logger.log(error);
Sentry.captureException(error);
return serverConfig;
}
}
function reloadDb(): void {
function reloadDatabase(): void {
const domainJsonPath = path.join(
app.getPath("userData"),
"config/domain.json",
@@ -165,9 +182,10 @@ function reloadDb(): void {
if (fs.existsSync(domainJsonPath)) {
fs.unlinkSync(domainJsonPath);
dialog.showErrorBox(
"Error saving new organization",
"There seems to be error while saving new organization, " +
"you may have to re-add your previous organizations back.",
t.__("Error saving new organization"),
t.__(
"There was an error while saving the new organization. You may have to add your previous organizations again.",
),
);
logger.error("Error while JSON parsing domain.json: ");
logger.error(error);
@@ -175,7 +193,7 @@ function reloadDb(): void {
}
}
db = new JsonDB(domainJsonPath, true, true);
database = new JsonDB(domainJsonPath, true, true);
}
export function formatUrl(domain: string): string {
@@ -189,3 +207,30 @@ export function formatUrl(domain: string): string {
return `https://${domain}`;
}
export function getUnsupportedMessage(
server: ServerConfig,
): string | undefined {
if (server.zulipFeatureLevel < 65 /* Zulip Server 4.0 */) {
const realm = new URL(server.url).hostname;
return t.__(
"{{{server}}} runs an outdated Zulip Server version {{{version}}}. It may not fully work in this app.",
{server: realm, version: server.zulipVersion},
);
}
return undefined;
}
export function iconAsUrl(iconPath: string): string {
if (iconPath === defaultIconSentinel) return defaultIcon;
try {
return `data:application/octet-stream;base64,${fs.readFileSync(
iconPath,
"base64",
)}`;
} catch {
return defaultIcon;
}
}

View File

@@ -1,9 +1,10 @@
import * as backoff from "backoff";
import {html} from "../../../common/html.js";
import Logger from "../../../common/logger-util.js";
import type WebView from "../components/webview.js";
import {ipcRenderer} from "../typed-ipc-renderer.js";
import {html} from "../../../common/html.ts";
import Logger from "../../../common/logger-util.ts";
import * as t from "../../../common/translation-util.ts";
import type WebView from "../components/webview.ts";
import {ipcRenderer} from "../typed-ipc-renderer.ts";
const logger = new Logger({
file: "domain-util.log",
@@ -15,7 +16,7 @@ export default class ReconnectUtil {
fibonacciBackoff: backoff.Backoff;
constructor(webview: WebView) {
this.url = webview.props.url;
this.url = webview.properties.url;
this.alreadyReloaded = false;
this.fibonacciBackoff = backoff.fibonacci({
initialDelay: 5000,
@@ -55,8 +56,10 @@ export default class ReconnectUtil {
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>
<div>
${t.__("Your internet connection doesn't seem to work properly!")}
</div>
<div>${t.__("Verify that it works and then click Reconnect.")}</div>
`.html;
}

View File

@@ -1,4 +1,4 @@
import {ipcRenderer} from "../typed-ipc-renderer.js";
import {ipcRenderer} from "../typed-ipc-renderer.ts";
export const connectivityError: string[] = [
"ERR_INTERNET_DISCONNECTED",

View File

@@ -0,0 +1,6 @@
import * as z from "zod";
// In an Electron preload script, Content-Security-Policy only takes effect
// after the page has loaded, which breaks Zod's detection of whether eval is
// allowed.
z.config({jitless: true});

View File

@@ -1,56 +1,16 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en" class="responsive desktop">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; connect-src 'self'; font-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'"
/>
<meta name="viewport" content="width=device-width" />
<title>Zulip</title>
<link rel="stylesheet" href="css/fonts.css" />
<link rel="stylesheet" href="css/main.css" />
</head>
<body>
<div id="content">
<div class="popup">
<span class="popuptext hidden" id="fullscreen-popup"></span>
</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 hidden" 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>
</body>
<body></body>
</html>

View File

@@ -1,7 +1,11 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en" class="responsive desktop">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; connect-src 'self'; img-src 'self'; script-src 'self'; style-src 'self'"
/>
<meta name="viewport" content="width=device-width" />
<title>Zulip - Network Troubleshooting</title>
<link

View File

@@ -1,10 +1,7 @@
<!DOCTYPE html>
<!doctype html>
<meta charset="UTF-8" />
<link rel="stylesheet" href="css/fonts.css" />
<link rel="stylesheet" href="css/preference.css" />
<script type="module">
import "@yaireo/tagify/dist/tagify.css";
</script>
<!-- Initially hidden to prevent FOUC -->
<div id="content" hidden>

BIN
build/icon-macos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

1
build/icon-macos.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-100 -100 1024 1024"><defs><linearGradient id="a" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#50adff"/><stop offset="1" stop-color="#7877fc"/></linearGradient><mask id="b"><rect x="-100" y="-100" width="1024" height="1024" fill="#fff"/><path d="M566.66 527.25c0 33.03-24.23 60.05-53.84 60.05H260.29c-29.61 0-53.84-27.02-53.84-60.05 0-20.22 9.09-38.2 22.93-49.09l134.37-120c2.5-2.14 5.74 1.31 3.94 4.19l-49.29 98.69c-1.38 2.76.41 6.16 3.25 6.16h191.18c29.61 0 53.83 27.03 53.83 60.05zm0-281.39c0 20.22-9.09 38.2-22.93 49.09l-134.37 120c-2.5 2.14-5.74-1.31-3.94-4.19l49.29-98.69c1.38-2.76-.41-6.16-3.25-6.16H260.29c-29.61 0-53.84-27.02-53.84-60.05s24.23-60.05 53.84-60.05h252.54c29.61 0 53.83 27.02 53.83 60.05z" transform="scale(1.0658112582781456)" fill="#000"/></mask><path id="c" d="M824 257c0-64 0-104-14-141A173 173 0 00708 14C671 0 631 0 567 0H257C193 0 153 0 116 14A173 173 0 0014 116C0 153 0 193 0 257V567c0 64 0 104 14 141A173 173 0 00116 810c37 14 77 14 141 14H567c64 0 104 0 141-14A173 173 0 00810 708c14-37 14-77 14-141Z"/><filter id="d"><feGaussianBlur in="SourceGraphic" stdDeviation="10"/></filter><filter id="e"><feGaussianBlur in="SourceGraphic" stdDeviation="5"/></filter></defs><use href="#c" transform="translate(0 10)" fill-opacity="0.3" filter="url(#d)"/><rect x="120" y="120" width="584" height="584" fill="#fff" /><g filter="url(#e)"><rect x="120" y="120" width="584" height="584" fill="#32497f" mask="url(#b)" transform="translate(0 5)"/></g><use href="#c" mask="url(#b)" fill="url(#a)"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 581 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 797 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -2,6 +2,114 @@
All notable changes to the Zulip desktop app are documented in this file.
### v5.12.1 --2025-08-29
**Enhancements**:
- Enabled macOS Writing Tools in the context menu.
- Marked untranslated strings for translation.
- Updated translations.
**Dependencies**:
- Upgraded all dependencies, including Electron 37.4.0.
### v5.12.0 --2025-03-13
**Enhancements**:
- Updated the icon in macOS with a native appearance.
- Marked untranslated strings for translation.
- Updated translations.
**Dependencies**:
- Upgraded all dependencies, including Electron 35.0.1.
**Removed features**:
- Removed support for macOS 10.15 and earlier, which reached end-of-life in 2022 and is [no longer supported](https://www.electronjs.org/blog/electron-33-0#removed-macos-1015-support) by Electron.
### v5.11.1 --2024-08-23
**Enhancements**:
- Updated translations.
**Dependencies**:
- Upgraded all dependencies, including Electron 32.0.1.
### v5.11.0 --2024-03-22
**Fixes**:
- Removed the popup dialog for certificate errors when loading subresources such as images.
- Allowed hiding the window from full screen mode on macOS.
**Enhancements**:
- Enabled zooming with Ctrl+mouse wheel on Linux and Windows.
**Dependencies**:
- Upgraded all dependencies, including Electron 29.1.5.
### v5.10.5 --2024-01-25
**Dependencies**:
- Upgraded all dependencies, including Electron 28.2.0.
**Enhancements**:
- Improved security hardening by setting a Content-Security-Policy for the app UI.
### v5.10.4 --2024-01-09
**Dependencies**:
- Upgraded all dependencies, including Electron 28.1.1.
### v5.10.3 --2023-09-30
**Fixes**:
- Fixed an error in the third-party `gatemaker` library that broke the display of notifications for completed downloads.
**Dependencies**:
- Upgraded all dependencies, including Electron 25.8.4.
### v5.10.2 --2023-09-14
**Dependencies**:
- Downgraded Electron to 25.8.1 to avoid a renderer process crash on Linux.
### v5.10.1 --2023-09-13
**Dependencies**:
- Upgraded all dependencies, including Electron 26.2.1.
### v5.10.0 --2023-05-05
**Removed features**:
- Removed support for Windows 8.1 and earlier, which reached end-of-life earlier this year and are [no longer supported](https://www.electronjs.org/blog/windows-7-to-8-1-deprecation-notice) by Electron.
- Removed support for Zulip Server 3.x and earlier, which have been obsolete for more than 18 months, in accordance with our [release lifecycle](https://zulip.readthedocs.io/en/latest/overview/release-lifecycle.html). A notice will now be displayed when connecting to a server with an unsupported version.
**Fixes**:
- Fixed display of the dropdown for the spellchecker languages setting.
- Fixed various bugs related to displaying and updating organization icons.
- Fixed settings to disable visual display of notifications.
**Dependencies**:
- Upgraded all dependencies, including Electron 24.2.0.
### v5.9.5 --2023-02-06
**Fixes**:
@@ -193,10 +301,10 @@ All notable changes to the Zulip desktop app are documented in this file.
**New features**:
- Add a cancel button in the report-issue modal.
- macOS: Use electron API to get dark tray icon instead of the green icon for the light theme.
- macOS: Use Electron API to get dark tray icon instead of the green icon for the light theme.
- Remove 'Reset App Data' option. Factory Reset option has been moved to Settings → General.
- Support pkg installer on macOS.
- Use electron 8 built-in spellchecker. Linux and Windows users can now choose upto three spellchecker languages from Settings → General. On macOS, default spellchecker is used.
- Use Electron 8 built-in spellchecker. Linux and Windows users can now choose up to three spellchecker languages from Settings → General. On macOS, default spellchecker is used.
- Setup Transifex for better synchronization of translations. The application now supports 41 languages instead of 21.
**Dependencies**:
@@ -304,7 +412,7 @@ All notable changes to the Zulip desktop app are documented in this file.
- Document enterprise configuration features.
- Update the Electron tutorial guide.
- Explicitly address where to report bugs in `README.md`.
- Fix typo in the link to server/webapp repository in `README.md`.
- Fix typo in the link to server/web app repository in `README.md`.
- Add documentation for translation.
### v4.0.0 --2019-08-08
@@ -355,7 +463,7 @@ All notable changes to the Zulip desktop app are documented in this file.
**Development**:
- Migrate codebase to TypeScript.
- Set the indent_size in `.editconfig` to 4.
- Set the indent_size in `.editorconfig` to 4.
- Use `.env` file for reading Sentry DSN.
**Documentation**:
@@ -424,7 +532,7 @@ All notable changes to the Zulip desktop app are documented in this file.
- Fix typo in network error message.
- Fix context menu not working on adding new org.
- Fix reply from notification.
- Fix shorcut section horizontal alignment.
- Fix shortcut section horizontal alignment.
- Fix broken link in docs.
- Fix grammatical errors.
- Fix typo error in issue template.
@@ -463,7 +571,7 @@ All notable changes to the Zulip desktop app are documented in this file.
- Auto hide menubar on Windows/Linux. Add a setting option for the same.
- Improve design of setting page.
- Toggle app on clicking the tray icon (Linux).
- Update sidebar realm name when it's changed in webapp.
- Update sidebar realm name when it's changed in web app.
- left-sidebar: Add initial character of realm name instead of default icon.
**Fixes**:
@@ -500,7 +608,7 @@ All notable changes to the Zulip desktop app are documented in this file.
**Fixes**:
- Fix youtube video not playing in lightbox.
- Fix YouTube video not playing in lightbox.
- Fix realm name not escaped properly.
<hr>
@@ -510,7 +618,7 @@ All notable changes to the Zulip desktop app are documented in this file.
**New features**:
- Add a setting option to show downloaded file in file manager.
- Added electron bridge to communicate with webapp in real time.
- Added Electron bridge to communicate with web app in real time.
**Fixes**:
@@ -623,7 +731,7 @@ electron-updater - `v2.21.8`
- Add an option to download the file attachments instead of opening it in the browser
- Open image link in webapp lightbox
- Open image link in web app lightbox
- Add scrollbar for list of organizations on overflow
@@ -660,7 +768,7 @@ electron-updater - `v2.21.8`
- Some users wanted to change the look of the Zulip. Now you have the power. Feel free to add your own CSS using the all-new setting option **Add Custom CSS**
- Added i18n locale helper script. Internalization is coming in the next release
- Added i18n locale helper script. Internationalization is coming in the next release
- Added **What's new** in `help` submenu so that you can see all the latest changes in the app
@@ -1038,7 +1146,6 @@ Minor improvements
**Fixes**:
- Fixed :
- Auto-updates
- Spellchecker
- Zooming functionality
@@ -1102,7 +1209,7 @@ Minor improvements
- Using two package.json structure
- Node integration disabled in main window due to jquery error
- Node integration disabled in main window due to jQuery error
- Now using electron-builder for packaging instead of electron-packager

View File

@@ -49,7 +49,7 @@ If [NPM](https://www.npmjs.com/get-npm) and [node-gyp](https://github.com/nodejs
[node-windows]: https://nodejs.org/en/download/package-manager/#windows
- Also, install install Windows-Build-Tools to compile native node modules by using
- Also, install Windows-Build-Tools to compile native node modules by using
```sh
$ npm install --global windows-build-tools
```

View File

@@ -5,13 +5,13 @@
- [Git](http://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
- [Node.js](https://nodejs.org) >= v6.9.0
- [python](https://www.python.org/downloads/release/python-2713/) (v2.7.x recommended)
- [node-gyp](https://github.com/nodejs/node-gyp#installation) (installed via powershell)
- [node-gyp](https://github.com/nodejs/node-gyp#installation) (installed via PowerShell)
## System specific dependencies
- use only 32bit or 64bit for all of the installers, do not mix architectures
- install using default settings
- open Windows Powershell as Admin
- open Windows PowerShell as Admin
```powershell
C:\Windows\system32> npm install --global --production windows-build-tools

View File

@@ -8,55 +8,13 @@ appropriate translation for a given string ("message") used in the UI.
To manage the set of UI messages and translations for them, and
provide a nice workflow for people to contribute translations, we use
(along with the rest of the Zulip project) a service called Transifex.
## Maintainers: syncing to/from Transifex
### Setup
You'll want Transifex's CLI client, `tx`.
- Install in your homedir with `easy_install transifex-client` or `pip3 install --user transifex-client`.
Or you can use your Zulip dev server virtualenv, which has it.
- Configure a `.transifexrc` with your API token. See [upstream
instructions](https://docs.transifex.com/client/client-configuration#transifexrc).
This can go either in your homedir, or in your working tree to make
the configuration apply only locally; it's already ignored in our
`.gitignore`.
- You'll need to be added [as a "maintainer"][tx-zulip-maintainers] to
the Zulip project on Transifex. (Upstream [recommends
this][tx-docs-maintainers] as the set of permissions on a Transifex
project needed for interacting with it as a developer.)
[tx-zulip-maintainers]: https://www.transifex.com/zulip/zulip/settings/maintainers/
[tx-docs-maintainers]: https://docs.transifex.com/teams/understanding-user-roles#project-maintainers
### Uploading strings to translate
Run `tx push -s`.
This uploads from `public/translations/en.json` to the
set of strings Transifex shows for contributors to translate.
(See `.tx/config` for how that's configured.)
### Downloading translated strings
Run `tools/tx-pull`.
This writes to files `public/translations/<lang>.json`.
(See `.tx/config` for how that's configured.)
Then look at the following sections to see if further updates are
needed to take full advantage of the new or updated translations.
(along with the rest of the Zulip project) a service called Weblate.
### Updating the languages supported in the code
Sometimes when downloading translated strings we get a file for a new
language. This happens when we've opened up a new language for people
to contribute translations into in the Zulip project on Transifex,
to contribute translations into in the Zulip project on Weblate,
which we do when someone expresses interest in contributing them.
The locales for supported languages are stored in `public/translations/supported-locales.json`

View File

@@ -41,32 +41,10 @@
1. Download [Zulip-x.x.x-amd64.deb][lr]
2. Double click and install, or run `dpkg -i Zulip-x.x.x-amd64.deb` in the terminal
3. Start the app with your app launcher or by running `zulip` in a terminal
4. Done! The app will NOT update automatically, but you can still check for updates
4. Done! You can update the app [using APT](https://documentation.ubuntu.com/server/how-to/software/package-management/#upgrading-packages).
**Other distros (Fedora, CentOS, Arch Linux etc)** :
1. Download Zulip-x.x.x-x86_64.AppImage[LR]
2. Make it executable using chmod a+x Zulip-x.x.x-x86_64.AppImage
3. Start the app with your app launcher
**You can also use `apt-get` (recommended)**:
- First download our signing key to make sure the deb you download is correct:
```bash
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv 69AD12704E71A4803DCA3A682424BE5AE9BD10D9
```
- Add the repo to your apt source list :
```bash
echo "deb https://download.zulip.com/desktop/apt stable main" |
sudo tee -a /etc/apt/sources.list.d/zulip.list
```
- Now install the client :
```bash
sudo apt-get update
sudo apt-get install zulip
```

18
i18next-parser.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import type {UserConfig} from "i18next-parser";
const config: UserConfig = {
createOldCatalogs: false,
defaultValue: (locale, namespace, key, value) =>
locale === "en" ? key! : value!,
indentation: "\t" as unknown as number,
input: ["app/**/*.ts"],
keySeparator: false,
lexers: {
ts: [{lexer: "JavascriptLexer", functions: ["t.__", "t.__mf"]}],
},
locales: ["en"],
namespaceSeparator: false,
output: "public/translations/$LOCALE.json",
sort: (a, b) => (a < b ? -1 : a > b ? 1 : 0),
};
export default config;

15548
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
{
"name": "zulip",
"productName": "Zulip",
"version": "5.9.5",
"main": "./dist-electron",
"version": "5.12.1",
"main": "./dist-electron/index.cjs",
"description": "Zulip Desktop App",
"license": "Apache-2.0",
"copyright": "Kandra Labs, Inc.",
@@ -17,8 +17,9 @@
"bugs": {
"url": "https://github.com/zulip/zulip-desktop/issues"
},
"type": "module",
"engines": {
"node": ">=16.13.2"
"node": ">=18"
},
"scripts": {
"start": "vite",
@@ -28,9 +29,9 @@
"lint-css": "stylelint \"app/**/*.css\"",
"lint-html": "htmlhint \"app/**/*.html\"",
"lint-js": "xo",
"prettier-non-js": "prettier --check --loglevel=warn . \"!**/*.{js,ts}\"",
"prettier-non-js": "prettier --check --log-level=warn . \"!**/*.{cjs,js,ts}\"",
"test": "tsc && npm run lint-html && npm run lint-css && npm run lint-js && npm run prettier-non-js",
"test-e2e": "vite build && tape \"tests/**/*.js\"",
"test-e2e": "vite build && tape \"tests/**/*.ts\"",
"pack": "vite build && electron-builder --dir",
"dist": "vite build && electron-builder",
"mas": "vite build && electron-builder --mac mas"
@@ -39,7 +40,6 @@
"test"
],
"build": {
"afterSign": "./scripts/notarize.js",
"appId": "org.zulip.zulip-electron",
"asar": true,
"asarUnpack": [
@@ -51,6 +51,7 @@
"copyright": "©2020 Kandra Labs, Inc.",
"mac": {
"category": "public.app-category.productivity",
"icon": "build/icon-macos.png",
"target": [
{
"target": "default",
@@ -72,7 +73,7 @@
},
"linux": {
"category": "Chat;GNOME;GTK;Network;InstantMessaging",
"icon": "build/icon.icns",
"icon": "build/zulip.png",
"description": "Zulip Desktop Client for Linux",
"target": [
"deb",
@@ -88,8 +89,8 @@
"synopsis": "Zulip Desktop App",
"afterInstall": "./packaging/deb-after-install.sh",
"fpm": [
"./packaging/deb-apt.list=/etc/apt/sources.list.d/zulip-desktop.list",
"./packaging/deb-apt.asc=/etc/apt/trusted.gpg.d/zulip-desktop.asc",
"./packaging/deb-apt.sources=/etc/apt/sources.list.d/zulip-desktop.sources",
"./packaging/deb-apt.asc=/usr/share/keyrings/zulip-desktop.asc",
"./packaging/deb-release-upgrades.cfg=/etc/update-manager/release-upgrades.d/zulip-desktop.cfg"
]
},
@@ -118,7 +119,14 @@
}
],
"icon": "build/icon.ico",
"publisherName": "Kandra Labs, Inc."
"azureSignOptions": {
"endpoint": "https://eus.codesigning.azure.net/",
"codeSigningAccountName": "kandralabs",
"certificateProfileName": "kandralabs",
"publisherName": "Kandra Labs, Inc.",
"timestampRfc3161": "http://timestamp.acs.microsoft.com",
"timestampDigest": "SHA256"
}
},
"msi": {
"artifactName": "${productName}-${version}-${arch}.${ext}"
@@ -141,184 +149,57 @@
"InstantMessaging"
],
"dependencies": {
"gatemaker": "^1.0.0"
"gatemaker": "https://github.com/andersk/gatemaker/archive/d31890ae1cb293faabcb1e4e465c673458f6eed2.tar.gz"
},
"devDependencies": {
"@electron/notarize": "^1.2.3",
"@electron/remote": "^2.0.8",
"@sentry/electron": "^4.1.2",
"@sentry/core": "^10.1.0",
"@sentry/electron": "^6.1.0",
"@types/adm-zip": "^0.5.0",
"@types/auto-launch": "^5.0.2",
"@types/backoff": "^2.5.2",
"@types/i18n": "^0.13.1",
"@types/node": "^16.11.26",
"@types/node": "^22.13.10",
"@types/p-fifo": "^1.0.2",
"@types/requestidlecallback": "^0.3.4",
"@types/semver": "^7.5.8",
"@types/tape": "^5.8.1",
"@types/yaireo__tagify": "^4.3.2",
"@yaireo/tagify": "^4.5.0",
"adm-zip": "^0.5.5",
"auto-launch": "^5.0.5",
"backoff": "^2.5.0",
"dotenv": "^16.0.0",
"electron": "^22.0.0",
"electron-builder": "^23.0.3",
"electron-log": "^4.3.5",
"electron-updater": "^5.0.1",
"electron": "^37.2.5",
"electron-builder": "^26.0.12",
"electron-log": "^5.0.3",
"electron-updater": "^6.3.4",
"electron-window-state": "^5.0.3",
"escape-goat": "^4.0.0",
"get-stream": "^6.0.1",
"eslint-import-resolver-typescript": "^4.4.4",
"htmlhint": "^1.1.2",
"i18n": "^0.15.1",
"iso-639-1": "^2.1.9",
"medium": "^1.2.0",
"i18next-parser": "^9.3.0",
"node-json-db": "^1.3.0",
"playwright-core": "^1.30.0-alpha-jan-3-2023",
"p-fifo": "^1.0.0",
"playwright-core": "^1.41.0-alpha-jan-9-2024",
"pre-commit": "^1.2.2",
"prettier": "^2.3.2",
"rimraf": "^4.1.2",
"prettier": "^3.0.3",
"semver": "^7.3.5",
"stylelint": "^14.5.3",
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-standard": "^29.0.0",
"stylelint": "^16.1.0",
"stylelint-config-standard": "^39.0.0",
"tape": "^5.2.2",
"typescript": "^4.3.5",
"vite": "^4.1.1",
"vite-plugin-electron": "^0.11.1",
"xo": "^0.53.1",
"zod": "^3.5.1"
"typescript": "^5.0.4",
"vite": "^5.0.11",
"vite-plugin-electron": "^0.28.0",
"xo": "^1.2.1",
"zod": "^4.1.5"
},
"overrides": {
"@types/pg": "^8.15.1"
},
"prettier": {
"bracketSpacing": false,
"singleQuote": false,
"trailingComma": "all"
},
"xo": {
"prettier": true,
"rules": {
"@typescript-eslint/no-dynamic-delete": "off",
"arrow-body-style": "error",
"import/no-restricted-paths": [
"error",
{
"zones": [
{
"target": "./app/common",
"from": "./app",
"except": [
"./common"
]
},
{
"target": "./app/main",
"from": "./app",
"except": [
"./common",
"./main"
]
},
{
"target": "./app/renderer",
"from": "./app",
"except": [
"./common",
"./renderer",
"./resources"
]
}
]
}
],
"import/order": [
"error",
{
"alphabetize": {
"order": "asc"
},
"newlines-between": "always"
}
],
"import/unambiguous": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "electron",
"message": "Use electron/main, electron/renderer, or electron/common."
},
{
"name": "electron/main",
"importNames": [
"ipcMain"
],
"message": "Use typed-ipc-main."
},
{
"name": "electron/renderer",
"importNames": [
"ipcRenderer"
],
"message": "Use typed-ipc-renderer."
}
]
}
],
"no-warning-comments": "off",
"sort-imports": [
"error",
{
"ignoreDeclarationSort": true
}
],
"strict": "error",
"unicorn/prefer-json-parse-buffer": "off",
"unicorn/prefer-module": "off",
"unicorn/prefer-top-level-await": "off"
},
"envs": [
"node",
"browser"
],
"overrides": [
{
"files": [
"**/*.ts"
],
"rules": {
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/consistent-type-imports": [
"error",
{
"disallowTypeAnnotations": false
}
],
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrors": "all"
}
],
"unicorn/no-await-expression-member": "off"
}
},
{
"files": [
"app/renderer/js/injected.ts",
"scripts/notarize.js",
"tests/**/*.js"
],
"parserOptions": {
"sourceType": "script"
}
},
{
"files": [
"**/*.d.ts"
],
"rules": {
"import/unambiguous": "off"
}
}
]
}
}

View File

@@ -11,3 +11,6 @@ update-desktop-database /usr/share/applications || true
# Clean up configuration for old Bintray repository
rm -f /etc/apt/zulip.list
# Clean up legacy APT configuration
rm -f /etc/apt/sources.list.d/zulip-desktop.list /etc/apt/trusted.gpg.d/zulip-desktop.asc

View File

@@ -7,24 +7,28 @@ LoJVvA7uJHcsNaQVWQF4RP0MaI4TLyjHZAJlpthQfbmq0AbZMEjDu8Th5G9KTsqE
WRyFoAj/SWwKQK2U4xpnA6jEraMcvsYYQMrCXlG+MOV7zVknLrH5tfk7JlmWB4DV
cs+QP5Z/UrVu+YpTpaoJoZV6LlEU1kNGjtq9ABEBAAG0TVp1bGlwIEFQVCBSZXBv
c2l0b3J5IFNpZ25pbmcgS2V5IEJpbnRyYXkgKFByb2R1Y3Rpb24pIDxzdXBwb3J0
QHp1bGlwY2hhdC5jb20+iQE4BBMBAgAiBQJZnc70AhsDBgsJCAcDAgYVCAIJCgsE
FgIDAQIeAQIXgAAKCRAkJL5a6b0Q2Vg1CADJzrH0mbwKi5GiHo5+iX5/WuUkSA8S
lI7FWzkbnPD0sfxJBwBNhZnAALQUvCybHxoU8VZ5ZbU1vbU+EG7pUMzENZLgEhoC
MDl1j8uCSahjjO+bk8qHhgM1FUKpoGec2wKfPKpcz1P+/bLTRKe7aqilkPSYOjeV
u8JI713zRL0nHd9vYZDoN2HR30J5sqgjRHtK5okNhiFG+pF3HFATG7nbNOa/tv+q
ZvhbI/5S8P5VKPSK/1lmMh0UFyNIbPg6MvWiqnfy7DAvOZGJpawkiN2B0XhNZKZR
KKXvFk3qvFpNTCUrH77MlPgjn+oRbE9SYm0phj0o2jQi/s1s2r75tk/ZuQENBFmd
zvQBCACv7VNQ6x3hfaRl8YF8bbrWXN2ZWxEa353p4QryHODsa7wHtsoNR3P30TIL
yafjjcV8P6dzyDw6TpfRqqQDKLY6FtznT2HdceQSffGTXB4CRV7KURBqh81PX/Jo
dz0NwkNrd0NWqkk6BnLX6U5tGuYiqC3vLpjOHmVQezJ41xpf85ElJ2nBW0rEcmfk
fwQthJU7BbqWKd6nbt2G+xWkCVoN6q+CWLXtK0laHMKBGQnoiQpldotsKM8UnDeQ
XPqrEi28ksjVW8tBStCkLwV2hCxk49zdTvRjrhBTQ1Ff/kenuEwqbSERiKfA7I8o
mlqulSiJ6rYdDnGjNcoRgnHb50hTABEBAAGJAR8EGAECAAkFAlmdzvQCGwwACgkQ
JCS+Wum9ENnsOQgApQ2+4azOXprYQXj1ImamD30pmvvKD06Z7oDzappFpEXzRSJK
tMfNaowG7YrXujydrpqaOgv4kFzaAJizWGbmOKXTwQJnavGC1JC4Lijx0s3CLtms
OY3EC2GMNTp2rACuxZQ+26lBuPG8Nd+rNnP8DSzdQROQD2EITplqR1Rc0FLHGspu
rL0JsVTuWS3qSpR3nlmwuLjVgIs5KEaOVEa4pkH9QwyAFDsprF0uZP8xAAs8WrVr
Isg3zs7YUcAtu/i6C2jPuMsHjGfKStkYW/4+wONIynhoFjqeYrR0CiZ9lvVa3tJk
BCeqaQFskx1HhgWBT9Qqc73+i45udWUsa3issg==
=YJGK
QHp1bGlwY2hhdC5jb20+iQGSBBMBCACGBYJonATwBAsJCAcJECQkvlrpvRDZRxQA
AAAAAB4AIHNhbHRAbm90YXRpb25zLnNlcXVvaWEtcGdwLm9yZ5to4d/e2Ts692fK
y5pjZ8XOKTBvXckVdjBe8cSiLIkvAxUICgQWAgMBAheAAhsDAh4BFiEEaa0ScE5x
pIA9yjpoJCS+Wum9ENkAAP7XCACjGUAzUgOAbf1BJTbbR1Np4BNy31++93TNj+/3
gYPbNwSJBb99yZfI6J4KwT1WepIXRx2Ikx0ChxEU5oOjEcPoM8Xslg3/vTV76dcJ
CYtQdvIvLUBKN7MkDp6+H1LVu9AnzMYoAF8HiKk6NZNI2LjMMv1znYwod2Pp3EL7
q/TPwiaOuNVDlaRSCsmbWYNPWLXAna7PU/yZ7FYwaCAKeC079+5rY59RvA/3oOmG
nUAcADyuMaNkPPnkYW5adNfCHWEPUrIUJxyJ+yVf/E/mHoUKkqYhOs60WFPXpgpX
cnYYw8E/1kXM+kAfWIOi7dGlCFiWLyQF0wjwn/sehBXZy8yquQENBFmdzvQBCACv
7VNQ6x3hfaRl8YF8bbrWXN2ZWxEa353p4QryHODsa7wHtsoNR3P30TILyafjjcV8
P6dzyDw6TpfRqqQDKLY6FtznT2HdceQSffGTXB4CRV7KURBqh81PX/Jodz0NwkNr
d0NWqkk6BnLX6U5tGuYiqC3vLpjOHmVQezJ41xpf85ElJ2nBW0rEcmfkfwQthJU7
BbqWKd6nbt2G+xWkCVoN6q+CWLXtK0laHMKBGQnoiQpldotsKM8UnDeQXPqrEi28
ksjVW8tBStCkLwV2hCxk49zdTvRjrhBTQ1Ff/kenuEwqbSERiKfA7I8omlqulSiJ
6rYdDnGjNcoRgnHb50hTABEBAAGJAX4EGAEIAHIFgmicBPAJECQkvlrpvRDZRxQA
AAAAAB4AIHNhbHRAbm90YXRpb25zLnNlcXVvaWEtcGdwLm9yZ2yYQ1NoS1Il7WjP
HCfqbeXJc9dm9yLgL46FmSMjScRXAhsMFiEEaa0ScE5xpIA9yjpoJCS+Wum9ENkA
AMOECACo0hRteH+CWZDLKaufkxQvfqd0/zq+uGJ2VYOrIUkuuaA0YBe+uGaoFwgT
hxVs0UiOpMOzSyl+zC+7ShQu9t/jIm5sTmvHsgzmO11w4b1Td7Ow8dgAnAXKcbmA
O1yaMi1C40YUI1zHRt0xkrnTJB57q+8Hclum59UXiSIgU5bKVeJhsX4LVpxi67Qg
vIHgg6pL+kDzObjRuBw+8Qx/Cugf4W35IGLD6BGzLjZM98YhbaX52sFvuHj+8gAs
xFOefLGRjZNdcp3IViTcVeR41Y9mA1Pjtlvthqrq70yra+EWjR7hUFxE9/BWjb18
fQZRjlB5JKC69SdOMa5C2UTSWNbA
=5JdK
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -1 +0,0 @@
deb https://download.zulip.com/desktop/apt stable main

View File

@@ -0,0 +1,5 @@
Types: deb
URIs: https://download.zulip.com/desktop/apt/
Suites: stable
Components: main
Signed-By: /usr/share/keyrings/zulip-desktop.asc

Binary file not shown.

View File

@@ -4,17 +4,17 @@ These are _generated_ files (\*) that contain translations of the strings in
the app.
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 Weblate, which is a nice web app for collaborating on
translations; a maintainer then syncs those translations into this repo.
To help out, [join the Zulip project on
Transifex](https://www.transifex.com/zulip/zulip/) and enter translations
Weblate](https://hosted.weblate.org/projects/zulip/) and enter translations
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
at `desktop.json`. The other resources there are for the Zulip web/mobile
Within that Weblate project, if you'd like to focus on Zulip Desktop, look
at the **Desktop** component. The other components are for the Zulip web/mobile
app, where translations are also very welcome.
(\*) 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
Transifex a list of strings to be translated. It doesn't contain any
(\*) One file is an exception: `en.json` is maintained by `i18next-parser` as a
list of (English) messages in the source code, and is used by Weblate as
a list of strings to be translated. It doesn't contain any
translations.

View File

@@ -1,45 +1,51 @@
{
"About Zulip": "حول \"زوليب\"",
"Actual Size": "الحجم الفعلي",
"Add Custom Certificates": "إضافة رخصة معدلة",
"Add Organization": "إضافة منظمة",
"Add a Zulip organization": "إضافة منظمة \"زوليب\"",
"Add custom CSS": "إصافة CSS معدلة",
"Add custom CSS": "إضافة CSS معدلة",
"Add to Dictionary": "Add to Dictionary",
"Advanced": "متقدم",
"All the connected organizations will appear here": "جميع المنظمات المتصلة تعرض هنا",
"All the connected organizations will appear here.": "جميع المنظمات المتصلة ستظهر هنا",
"Always start minimized": "دائماً إبدأ بالقليل",
"App Updates": "تحديث التطبيق",
"App Updates": "تحديثات التطبيق",
"App language (requires restart)": "App language (requires restart)",
"Appearance": "المظهر",
"Application Shortcuts": "إختصارات التطبيق",
"Are you sure you want to disconnect this organization?": "هل أنت متأكد من فصل هذة المنظمة؟",
"Ask where to save files before downloading": "Ask where to save files before downloading",
"Auto hide Menu bar": "أخف القائمة تلقائياً",
"Auto hide menu bar (Press Alt key to display)": "أخف القائمة تلقائياً (إضغط Alt لعرض القائمة)",
"Back": "رجوع",
"Bounce dock on new private message": "أخرج المنصة في حال رسالة خاصة جديدة",
"Certificate file": "ملف الشهادة",
"Cancel": "إلغاء",
"Change": "تغيير",
"Change the language from System Preferences → Keyboard → Text → Spelling.": "Change the language from System Preferences → Keyboard → Text → Spelling.",
"Check for Updates": "التحقق من التحديثات",
"Close": "اغلاق",
"Close": "إغلاق",
"Connect": "اتصال",
"Connect to another organization": "التوصيل مع منظمة أخرى",
"Connected organizations": "المنظمات المتصلة",
"Copy": "نسخ",
"Copy Image": "Copy Image",
"Copy Image URL": "Copy Image URL",
"Copy Link": "Copy Link",
"Copy Zulip URL": "نسخ رابط زوليب",
"Create a new organization": "Create a new organization",
"Create a new organization": "إنشاء منظمة جديدة",
"Cut": "قص",
"Default download location": "موقع التحميل الافتراضي",
"Delete": "حذف",
"Desktop App Settings": "إعدادت تطبيق سطح المكتب",
"Desktop Notifications": "إشعارات سطح المكتب",
"Desktop Settings": "إعدادات سطح المكتب",
"Disconnect": "قطع الاتصال",
"Download App Logs": "تنزيل سجلات التطبيق",
"Edit": "تعديل",
"Edit Shortcuts": "تعديل الاختصارات",
"Emoji & Symbols": "الإيموجي و الرموز",
"Enable auto updates": "تفعيل التحديثات التلقائية",
"Enable error reporting (requires restart)": "تفعيل تقارير الأخطاء (يتطلب إعادة التشغيل)",
"Enable spellchecker (requires restart)": "Enable spellchecker (requires restart)",
"Enter Full Screen": "اعرض الشاشة كاملة",
"Factory Reset": "إعادة ضبط المصنع",
"Factory Reset Data": "Factory Reset Data",
"File": "ملف",
"Find accounts": "Find accounts",
"Find accounts by email": "Find accounts by email",
@@ -51,17 +57,24 @@
"Hard Reload": "Hard Reload",
"Help": "Help",
"Help Center": "Help Center",
"Hide": "Hide",
"Hide Others": "Hide Others",
"Hide Zulip": "أخفي زوليب",
"History": "History",
"History Shortcuts": "History Shortcuts",
"Keyboard Shortcuts": "Keyboard Shortcuts",
"Log Out": "Log Out",
"Log Out of Organization": "Log Out of Organization",
"Look Up": "Look Up",
"Manual proxy configuration": "Manual proxy configuration",
"Minimize": "Minimize",
"Mute all sounds from Zulip": "Mute all sounds from Zulip",
"NO": "NO",
"Network": "Network",
"Network and Proxy Settings": "الشبكة و إعدادات البروكسي",
"No Suggestion Found": "No Suggestion Found",
"OK": "حسنًا",
"OR": "OR",
"On macOS, the OS spellchecker is used.": "On macOS, the OS spellchecker is used.",
"Organization URL": "Organization URL",
"Organizations": "Organizations",
"Paste": "Paste",
@@ -71,25 +84,26 @@
"Proxy rules": "Proxy rules",
"Quit": "Quit",
"Quit Zulip": "Quit Zulip",
"Quit when the window is closed": "Quit when the window is closed",
"Redo": "Redo",
"Release Notes": "Release Notes",
"Reload": "Reload",
"Report an Issue": "Report an Issue",
"Reset App Settings": "أعد ضبط إعدادات التطبيق",
"Reset the application, thus deleting all the connected organizations and accounts.": "إعادة ضبط التطبيق, و بالتالي مسح جميع المنظمات المتصلة و الحسابات",
"Save": "Save",
"Select All": "Select All",
"Services": "Services",
"Settings": "Settings",
"Shortcuts": "Shortcuts",
"Show App Logs": "Show App Logs",
"Show app icon in system tray": "Show app icon in system tray",
"Show app unread badge": "Show app unread badge",
"Show desktop notifications": "Show desktop notifications",
"Show downloaded files in file manager": "Show downloaded files in file manager",
"Show sidebar": "Show sidebar",
"Spellchecker Languages": "Spellchecker Languages",
"Start app at login": "Start app at login",
"Switch to Next Organization": "Switch to Next Organization",
"Switch to Previous Organization": "Switch to Previous Organization",
"These desktop app shortcuts extend the Zulip webapp's": "These desktop app shortcuts extend the Zulip webapp's",
"This will delete all application data including all added accounts and preferences": "This will delete all application data including all added accounts and preferences",
"Tip": "Tip",
"Toggle DevTools for Active Tab": "Toggle DevTools for Active Tab",
"Toggle DevTools for Zulip App": "Toggle DevTools for Zulip App",
@@ -99,36 +113,16 @@
"Toggle Tray Icon": "Toggle Tray Icon",
"Tools": "Tools",
"Undo": "Undo",
"Unhide": "Unhide",
"Upload": "Upload",
"Use system proxy settings (requires restart)": "Use system proxy settings (requires restart)",
"View": "View",
"View Shortcuts": "View Shortcuts",
"Window": "Window",
"Window Shortcuts": "Window Shortcuts",
"YES": "YES",
"You can select a maximum of 3 languages for spellchecking.": "You can select a maximum of 3 languages for spellchecking.",
"Zoom In": "Zoom In",
"Zoom Out": "Zoom Out",
"Zulip Help": "Zulip Help",
"keyboard shortcuts": "keyboard shortcuts",
"script": "script",
"Quit when the window is closed": "Quit when the window is closed",
"Ask where to save files before downloading": "Ask where to save files before downloading",
"Services": "Services",
"Hide": "Hide",
"Hide Others": "Hide Others",
"Unhide": "Unhide",
"AddServer": "AddServer",
"App language (requires restart)": "App language (requires restart)",
"Factory Reset Data": "Factory Reset Data",
"Reset the application, thus deleting all the connected organizations, accounts, and certificates.": "Reset the application, thus deleting all the connected organizations, accounts, and certificates.",
"On macOS, the OS spellchecker is used.": "On macOS, the OS spellchecker is used.",
"Change the language from System Preferences → Keyboard → Text → Spelling.": "Change the language from System Preferences → Keyboard → Text → Spelling.",
"Copy Link": "Copy Link",
"Copy Image": "Copy Image",
"Copy Image URL": "Copy Image URL",
"No Suggestion Found": "No Suggestion Found",
"You can select a maximum of 3 languages for spellchecking.": "You can select a maximum of 3 languages for spellchecking.",
"Spellchecker Languages": "Spellchecker Languages",
"Add to Dictionary": "Add to Dictionary",
"Look Up": "Look Up"
"{{{server}}} runs an outdated Zulip Server version {{{version}}}. It may not fully work in this app.": "{{{server}}} يقوم بتشغيل نسخة قديمة من خادم زوليب {{{version}}}. قد لا يعمل بشكل كامل مع هذا التطبيق"
}

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