Compare commits

..

216 Commits

Author SHA1 Message Date
Akash Nimare
67fa9cca8c release: New release v5.4.0. 2020-07-21 18:44:45 +05:30
Manav Mehta
a90bf1af08 macOS: Change the logo to Z without the circle. 2020-07-21 17:00:07 +05:30
Manav Mehta
cb145acc73 Update icons introducing the new logo. 2020-07-19 01:56:17 +05:30
Akash Nimare
099e10673c translation: Sync strings from Transifex. 2020-07-18 21:28:59 +05:30
Anders Kaseorg
4b3608fc1e dependencies: Upgrade dependencies.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-07-17 18:57:33 -07:00
Manav Mehta
6128c0e12a appLanguage: add 'sk' and correct some locale names.
* sk was not in supported locales and thus not in dropdown menu
* el-GR is not the correct name and thus not applicable
* zh-Hant/Hans are exact names, though case didn't hamper the translation
2020-07-14 15:51:38 +05:30
Manav Mehta
14a1f5d3e1 request: Replace deprecated request module with net.request.
Co-authored-by: Anders Kaseorg <anders@zulip.com>

Fixes: #886.
2020-07-14 15:50:52 +05:30
Anders Kaseorg
9cf26f4890 dependencies: Upgrade dependencies.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-07-01 15:01:47 -07:00
Anders Kaseorg
397a7381b8 main: Use import() syntax for electron-connect.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-07-01 15:01:38 -07:00
Anders Kaseorg
24b28f9ded proxy-util: Fix misuse of void.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-07-01 15:01:29 -07:00
Anders Kaseorg
9ceabe02d5 Use optional catch binding.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-07-01 15:01:29 -07:00
Anders Kaseorg
b207ee57de main: Avoid object type.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-07-01 15:01:25 -07:00
Anders Kaseorg
cf9d0c8aa2 main: Remove mainWindowState from globals.
(No, globals are not accessible from the other process.)

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-07-01 14:47:09 -07:00
Manav Mehta
e97ab2e6dd Replace deprecated getWebContents API 2020-06-30 20:33:41 -07:00
Samyak Gaur
6a7f26d7e8 settings: Update font size for spellchecker explanation.
Fixes: #978.
2020-07-01 03:11:53 +05:30
Anders Kaseorg
b6e11f623a darwin-notifications: Avoid no-useless-return warnings.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-06-26 18:56:07 -07:00
Anders Kaseorg
1c60c335fd logger-util: Avoid no-fallthrough warnings.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-06-26 18:56:03 -07:00
Anders Kaseorg
c9249b1724 context-menu: Avoid no-unnecessary-boolean-literal-compare warning.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-06-26 15:56:23 -07:00
Anders Kaseorg
9e957ba704 context-menu: Copy the email address, not the link text.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-06-26 15:47:38 -07:00
Anders Kaseorg
6c37e30233 Accomodate WebSocket URLs in certificate-error handler.
Our certificates are indexed by https: URLs.  Fixes #988.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-06-26 15:24:55 -07:00
Abhigyan Khaund
addfe2e414 context-menu: Remove trailing and leading separators when not required.
Fixes: #979.
2020-06-27 02:38:03 +05:30
Abhigyan Khaund
bda0dd29df context-menu: Fix bug in Copy Link and add copy Email.
Fixes: #986
2020-06-27 02:27:44 +05:30
Abhigyan Khaund
01926e1234 context-menu: Enable copy only when copy is possible. 2020-06-27 02:27:44 +05:30
Manav Mehta
9138bbfaf2 Update changelog for release v5.3.0. 2020-06-25 00:32:37 +05:30
Akash Nimare
596561b731 release: New release v5.3.0. 2020-06-24 14:47:54 +05:30
Akash Nimare
5943c21814 Update electron to v8.3.3. 2020-06-21 01:06:05 +05:30
Akash Nimare
2456bba5ae translations: Sync translations from transifex. 2020-06-20 23:06:50 +05:30
Manav Mehta
0fff6336c7 spellchecker: Use Electron 8 built-in spellchecker.
* Using electron built-in spellchecker
* Added the custom context menu

Co-authored-by: Anders Kaseorg <anders@zulipchat.com>

Fixes: #504
2020-06-18 18:14:23 +05:30
Samyak Gaur
4261874e29 docs: Update issue template.
Explanation lines commented as the titles are self explanatory.
2020-06-17 14:50:45 +05:30
Akash Nimare
f976270d33 lint: Fix linting errors. 2020-06-17 01:44:08 +05:30
Samyak Gaur
4782f1cfd3 Docs: Update issue template.
Fixes: #926.
2020-06-17 00:20:20 +05:30
Akash Nimare
2f70621255 windows: Turn off start at login by default.
Fixes: #960.
2020-06-16 20:10:23 +05:30
Manav Mehta
b6f4e2b83c zoom: Fix zoom issues.
The zoom issues seemed to be caused due to electron roles.
We also had our own definitions firing up along with the roles which might be clashing and the other instances of roles not being much effective in our use case.

* Remove roles from zoom options
* Add accelerator for zoomIn

Fixes: #911.
2020-06-13 12:59:43 +05:30
Manav Mehta
25f4c3aba8 translations: Prevent i18n to automatically add strings.
Now that we have moved to Transifex, auto updation of translation strings is not required.
We would only need to update the resource file (en.json) and push it to Transifex and pulling the rest <lang>.json
2020-06-12 13:18:04 +05:30
Anders Kaseorg
1b23468375 Update zulipchat.com emails to zulip.com.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-06-08 14:02:08 -07:00
Anders Kaseorg
5d775405d9 Update zulipchat.com links to zulip.com.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-06-08 14:01:40 -07:00
Akash Nimare
7f13d9162a mac: Support pkg installer on macOS. 2020-06-05 23:26:10 +05:30
Anders Kaseorg
e0013c22ff package-lock.json: Update.
Unclear why npm wants to change this now.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-31 02:13:55 -07:00
Samyak Gaur
5c41afdccd window: Responsive fixes for main window.
Fixes: #963.
2020-05-27 13:23:40 +05:30
Anders Kaseorg
99a3530238 xo: Add arrow-body-style rule
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-26 11:25:24 -07:00
Manav Mehta
a5ceffc856 transifex: Setup Transifex for better translation process. 2020-05-20 22:11:13 +05:30
Manav Mehta
f55570f2f5 Handle Reset options gracefully.
Fixes: #903.
2020-05-20 21:54:36 +05:30
Manav Mehta
4f890c0316 macOS: Use electron API to get dark tray icon for the light theme. 2020-05-19 21:03:13 +05:30
Priyank Patel
bf651dece0 report-issue: Remove unnessacary instructions from the placeholder.
It looks like the removed instructions were directly copied from GitHub
issue template and did not fit here. Specifically, the first point about
including the platform is automatically included in the email we prepare for
the user and we also include browser and electron version. Then the second
bullet point re-iterates the instructions present. Lastly, the third point
asks for screenshot however user cannot add screenshot in the UI but they can
in the email.

We also remove an comment disabling a eslint rule. The rule was disable for no
reason because we should have used template literal instead of the undocumented
multi-str format which is not cleaner.
2020-05-19 11:02:39 -04:00
Priyank Patel
1babd8da42 report-issue: Darken the color of report issue button.
This is done so it compliments the newly added cancel button.
This change empahsizes the report issue button rather than the
cancel button.
2020-05-19 11:02:39 -04:00
Priyank Patel
bfd146f2d9 send-feedback: Move custom css to a css file.
It just makes it a bit cleaner and easier to update css.
During this transition we update the color hex values to rgb
because of stylelint.
2020-05-19 11:02:39 -04:00
Sanskar Bajpai
3126510245 report-issue: Add a cancel button to the modal.
A user found the lack of a cancel button in the UI a bad
experience. Previously, we would close it when the overlay
was clicked but it does not seem informative in terms of it's
function.

Fixes: #966.
2020-05-19 11:02:39 -04:00
Anders Kaseorg
d661895545 Remove the insecure ignoreCerts option.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-13 04:06:50 -07:00
Akash Nimare
ca9ab6168e Update support email. 2020-05-13 12:19:45 +05:30
Anders Kaseorg
bbdf2c6017 mailmap: Add Git .mailmap for zulipchat.com → zulip.com.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-12 22:57:22 -07:00
Manav Mehta
109a9bbd10 document: Remove inessential comment inherited from #902 and #909
The comment was created to document a piece of code in #902 but #909 addressed code quality
The comment is no longer essential and hence ought to be removed
2020-05-06 17:01:47 -07:00
Anders Kaseorg
22d7ef5615 main: Turn electron-connect off by default.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-06 16:58:25 -07:00
Anders Kaseorg
e03de26137 release: New release v5.2.0.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-05 12:54:35 -07:00
Anders Kaseorg
983254c310 CVE-2020-12637: Do not ignore certificate errors without ignoreCerts.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-05 12:54:35 -07:00
Anders Kaseorg
b6059077d8 Generalize permission-response mechanism to renderer-callback.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-05 12:54:35 -07:00
Anders Kaseorg
cafff9a008 new-server-form: Add missing string conversion for error message.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-05 12:54:35 -07:00
Anders Kaseorg
190204b2e5 notarize: Fix lint errors.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-05 12:54:09 -07:00
Akash Nimare
4c25c99abc notarization: Pass team short name info in code-signing. 2020-05-06 01:13:29 +05:30
Anders Kaseorg
55be93b906 dependencies: Upgrade dependencies.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-04 01:56:35 -07:00
Anders Kaseorg
34e2b3a3d0 typescript: Eradicate most any annotations.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-04 01:08:05 -07:00
Anders Kaseorg
e5ece8db9e package.json: Fix ‘npm start’ to do something useful.
The old run-dev script raced electron and tsc against each other in
parallel, which usually meant electron would run stale JS files.  We
should overhaul the build process at some point, but whatever we do,
this script isn’t going to be part of it.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-03 20:35:21 -07:00
Anders Kaseorg
40b26dbb0e dnd-util: Refine typing.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-03 20:03:41 -07:00
Anders Kaseorg
ae4f03f4ba logger-util: Clean up typing disaster zone.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-03 20:03:41 -07:00
Anders Kaseorg
8ea32a7a96 feedback: Stub send-feedback types.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-03 19:33:52 -07:00
Anders Kaseorg
6b7cce0366 ServerManagerView: Convert loading from object to Set.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-03 19:16:05 -07:00
Anders Kaseorg
73fec72e6d registerIpcs: Convert webviewListeners to array with functions.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-03 19:09:22 -07:00
Anders Kaseorg
920adfb169 CI: Clean unused steps from CI configuration.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-03 15:02:58 -07:00
Manav Mehta
98174fdcaf help: Update Help Center URL.
Earlier the menu option used to redirect to *.zulipchat.com/help which is already reachable from the webapp.
This will enable the desktop app to redirect only to zulipchat.com/help instead of the help page of the corresponding organization.

Fixes: #948.
2020-05-02 20:17:25 +05:30
Anders Kaseorg
a0c033431e electron-bridge: Add decrypt_clipboard helper.
This one helper allows us to implement browser-based social login
entirely on the server side.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-05-01 15:00:02 -07:00
Abhigyan Khaund
82421d843a downloadFiles: Fix issue of showing two Save As dialog box.
Currently, there are two dialog boxes shown while downloading files (in Ubuntu). One by default behavior of electron and other by the dialog box for save as feature.
This PR fixes this issue by using electron's save as dialog box.

Fixes: #947.
2020-05-01 17:55:46 +05:30
Anders Kaseorg
d9afee3330 ServerManagerView: Convert functionalTabs from object to Map.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
a46f2ed618 xo: Enable import/no-mutable-exports.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
9f3b4ff408 xo: Remove unicorn/string-content exclusion.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
fb800f7862 xo: Enable @typescript-eslint/no-unused-vars.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
ba191c3699 xo: Enable object-curly-spacing.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
e49a880ed6 xo: Enable capitalized-comments.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
4bfa7c9265 xo: Enable no-else-return.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
39c6fa4ace xo: Enable @typescript-eslint/member-ordering.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
963c2e5388 xo: Enable @typescript-eslint/restrict-plus-operands.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
849df4adaf xo: Enable @typescript-eslint/restrict-template-expressions.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
fc6ff83485 xo: Enable no-alert.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
5ae2a717fa xo: Enable padding-line-between-statements.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
cfdc08a038 xo: Enable unicorn/catch-error-name.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
b76467529d xo: Upgrade xo to 0.30.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
bb88a7b7a8 xo: Handle floating promises.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
0225778050 typescript: Annotate queueDomain.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
2154b191c8 typescript: Annotate url in WebviewProps.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
4093304b4d typescript: Annotate electron-updater events.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Anders Kaseorg
2e03f779e8 typescript: Annotate setMenu props.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-30 13:48:18 -07:00
Manav Mehta
9464390070 docs: Update changelog for release 5.1.0. 2020-04-30 00:48:31 +05:30
Manav Mehta
16f0af8853 Remove storing of User-Agent on disk.
Fixes: #921.

Co-authored-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-26 13:30:39 -07:00
am2505
bb6d90671f typescript: Complete most TypeScript todos. 2020-04-25 15:49:10 -07:00
Anders Kaseorg
e536a03fab dependencies: Fix package-lock.json stability.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-25 13:07:28 -07:00
Anders Kaseorg
ee60702276 release: Update package-lock.json for new release v5.1.0.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-25 13:07:24 -07:00
Akash Nimare
e721211619 release: New release v5.1.0 2020-04-25 13:53:44 +05:30
Anders Kaseorg
ff671b53ef dependencies: Upgrade dependencies.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-24 22:15:20 -07:00
Akash Nimare
340eb8da99 server-icon: Handle server icon update gracefully.
Don't trigger user-facing network error for updating the server icon.
2020-04-24 11:48:27 +05:30
Anders Kaseorg
ae689ad6bb general-section: Import supported-locales.json as a JSON module.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-22 13:06:00 -07:00
Anders Kaseorg
7ffddded5d Configure language by value, not by index.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-22 13:05:59 -07:00
Anders Kaseorg
714cd926ae generateSelectTemplate: Add missing HTML escaping.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-22 13:05:15 -07:00
Manav Mehta
b2f4af0f49 Add feature to set application language.
User can now select application language without changing the language in the operating system.

Fixes: #855.
2020-04-22 18:08:49 +05:30
Aditya Mudgil
d7136aef25 macOS: Update dock icon.
* Added inner padding to .icns dock icon for mac
* Change inner padding of the image to 20px for each side

Fixes: #787.
2020-04-14 20:20:48 +05:30
Anders Kaseorg
73f8b21a9f scripts: Synchronize Debian scripts with electron-builder 22.4.1.
Fixes #912.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-08 14:15:43 -07:00
Akash Nimare
4ce08cb5a2 dock: Do not toggle window on clicking dock.
We are reverting back our decision after getting a lot of feedback
on this behaviour. Fromm now on, when you click on the dock icon we'll
show the app window and won't hide it after clicking again on dock.

Fixes: #914.
2020-04-07 20:16:50 +05:30
Anders Kaseorg
814ce071a3 release: New release v5.0.0.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-30 19:38:33 -07:00
Anders Kaseorg
92fb176f67 Revert "auth: Move social login process to browser."
This reverts commit 49b29bfed6 (#863).

The design of this feature is still under discussion; we expect it to
return after the security release.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-30 19:33:24 -07:00
Anders Kaseorg
a03f569af9 CVE-2020-10857: Whitelist safe URL protocols for shell.openExternal.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-30 19:33:24 -07:00
Anders Kaseorg
af59bb7c99 handleExternalLink: Do not navigate the current window.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-30 19:33:24 -07:00
Anders Kaseorg
4390966a62 Always show downloaded files in file manager.
shell.openItem is unsafe.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-30 19:33:24 -07:00
Anders Kaseorg
a6d942fe6c CVE-2020-10858: Lock down session permission requests.
This fixes a vulnerability reported by Matt Austin.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-30 19:33:24 -07:00
Anders Kaseorg
9d4093b3d8 CVE-2020-10856: Enable context isolation.
This fixes a vulnerability reported by Matt Austin.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-30 19:33:24 -07:00
Anders Kaseorg
20a6c5d128 preload: Use IPC for logout, shortcut, showNotificationSettings.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-30 19:33:24 -07:00
Anders Kaseorg
c843e179fc tray: Remove tray variable from window.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-30 19:33:24 -07:00
Anders Kaseorg
438d4fffa7 notification: Convert loadBots from jQuery to fetch.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-30 19:32:23 -07:00
Tim Abbott
5c164bfa7d webview: Disable insecure content.
Zulip servers in production are designed to only serve content over
HTTPS.  And a development environment's root page will be served over
HTTP.

So there is no purpose in enabling allowInsecureContent, even
conditionally for use against Zulip development environments; we should
just remove the setting.
2020-03-30 19:32:23 -07:00
Anders Kaseorg
cbc89a72a2 tray: Work around Electron segfault on certain platforms.
Set the tray icon’s context menu immediately after creating the Tray
object.  This seems to prevent an Electron segfault at startup on
certain platforms, such as Ubuntu 16.04 i386.  See
https://github.com/electron/electron/issues/22652 and its linked
issues.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-30 19:26:29 -07:00
Anders Kaseorg
fb1e163130 typescript: Fix errors hidden by skipLibCheck.
This requires temporarily downgrading to @types/node@^12 (see
https://github.com/electron/electron/issues/21612).

Leave skipLibCheck on for now as it still saves a few seconds when
running tsc.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-26 16:35:13 -07:00
Anders Kaseorg
2ebeeedba8 dependencies: Move fs-extra from devDependencies to dependencies.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-24 21:07:25 -07:00
Anders Kaseorg
82a7f97ca6 dependencies: Upgrade dependencies.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-23 17:22:48 -07:00
Anders Kaseorg
55eb768064 xo: Upgrade xo to 0.28.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-23 16:53:14 -07:00
Anders Kaseorg
611932c66d xo: Unabbreviate variable names.
To satisfy unicorn/prevent-abbreviations in xo 0.28.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-23 16:53:12 -07:00
Anders Kaseorg
5deffa5022 changelog: Add missing changelog entry for v4.0.3.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-23 15:08:21 -07:00
Anders Kaseorg
6f01f1362a js: Declare 'use strict' on tests too.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-09 22:14:23 -07:00
Anders Kaseorg
9d2739f050 js: Declare 'use strict' on all scripts and no modules.
And enable the import/unambiguous ESLint rule as a check on our
partition between scripts and modules.  After this commit, if you add
a new file and get this error:

  ✖  1:1  This module could be parsed as a valid script.  import/unambiguous

* For a module, add an `import` or `export` declaration to make the
  file unambiguously a module (the empty `export {};` declaration
  suffices).
* For a script, add the file to the xo overrides section of
  package.json that marks it "sourceType": "script", and add a 'use
  strict' declaration.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-09 20:04:43 -07:00
Akash Nimare
01f6e77237 macOS: Fix undo redo not working on macOS.
The default API provided by Electron doesn't work
as expected. More info here -
https://github.com/electron/electron/issues/15728

Fixes: #866.
2020-03-10 00:32:05 +05:30
Manav Mehta
7ac35cc087 macOS: Replace deprecated isDarkMode() with shouldUseDarkColors.
Fixes: #891.
2020-03-08 14:55:43 +05:30
Anders Kaseorg
32e6b3054f xo: Use more xo defaults.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-04 21:46:18 -08:00
Anders Kaseorg
40bf2a1f20 xo: Lint *.js too.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-04 21:22:04 -08:00
Anders Kaseorg
dee2f05ac0 locale-helper: Move supported-locales.js to supported-locales.json.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-04 20:45:57 -08:00
Anders Kaseorg
ca5de73155 xo: Reenable several easy rules.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-04 20:15:01 -08:00
Anders Kaseorg
d0f8c040c7 package.json: Add tsc to test script.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-04 18:26:06 -08:00
Anders Kaseorg
7cf40f1e08 typescript: One more switch to ES export syntax.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-04 18:15:42 -08:00
Anders Kaseorg
598c0df60b package.json: Reformat to match npm generated output.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-04 17:52:10 -08:00
Anders Kaseorg
d3bcd7306a typescript: Switch to ES import/export syntax.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-04 17:21:03 -08:00
Anders Kaseorg
b3261bcdff js: Explode more singleton classes to modules.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-04 16:27:44 -08:00
Manav Mehta
20c6f487c4 typescript: Implement some TODOs. 2020-03-04 14:21:25 -08:00
Anders Kaseorg
340797ca10 typescript: Refine some type annotations to avoid any.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-04 12:12:31 -08:00
Anders Kaseorg
220aac2d54 js: Explode singleton classes to modules.
Singleton classes may have a purpose.  This was not that purpose.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-04 11:54:45 -08:00
Akash Nimare
d9f6cf4cc9 docs: Update recommended node version for development. 2020-03-04 16:59:21 +05:30
Anders Kaseorg
dc3e5d4930 package.json: Sort xo overrides.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-03 21:50:02 -08:00
Anders Kaseorg
fc2b80c36a main: Fix realm icon updating.
Commit c937317ecf (#605) should have
updated this, but didn’t.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-03 21:50:02 -08:00
Anders Kaseorg
ab667d8053 checkCertError: Fix showMessageBox usage.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-03 21:50:02 -08:00
Manav Mehta
8b9a10a23d Update report issue placeholder.
Fixes: #873.
2020-03-04 11:16:31 +05:30
Anders Kaseorg
15af3e732f sentry-util: Hard-code the Sentry DSN.
Commit 088ddf9c62 (#755) does not work,
because neither the .env file nor the environment variables it
provides are available to normal users at runtime.  This silently
broke Sentry data collection.  When we upgraded @sentry/electron in
commit 107e522914, the silent failure
became an error that prevented the app from starting.

The Sentry DSN is not a secret, so we should just commit it to the
repository.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-04 11:07:00 +05:30
am2505
534f4c1463 Convert Promise to async-await.
Fixes #878.
2020-03-03 20:40:10 -08:00
Anders Kaseorg
063324550e run-dev: Fix for one package.json structure.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-03 20:00:36 -08:00
Anders Kaseorg
268471df6b typings: Remove redundant ZulipWebWindow.$.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-03 18:17:13 -08:00
Anders Kaseorg
ca6b2312be typings: Get Window.Notification from upstream.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-03 18:16:01 -08:00
Anders Kaseorg
ff026e5763 typings: Get NotificationOptions.silent from upstream.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-03 18:14:13 -08:00
Anders Kaseorg
8f810481e3 typings: Get requestIdleCallback from DefinitelyTyped.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-03 18:13:26 -08:00
Anders Kaseorg
f91e95647a typings: Use type declarations from DefinitelyTyped.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-03 18:01:24 -08:00
Anders Kaseorg
e9536f247b dependencies: Use one package.json structure.
The two package.json structure is no longer needed.
https://www.electron.build/tutorials/two-package-structure

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-03 11:02:59 -08:00
Anders Kaseorg
067cbf32a1 build: Fix cld sources exclusion.
As of commit 107e522914, @paulcbetts/cld
was renamed to cld.

docs is not being included anyway since it’s outside of app.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-03 10:59:41 -08:00
Anders Kaseorg
6824978114 dependencies: Remove @types/dotenv compatibility stub.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-02 20:31:20 -08:00
Anders Kaseorg
fa86f1ca25 dependencies: Upgrade everything to latest.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-02 20:31:19 -08:00
Anders Kaseorg
a63b3873ae dependencies: Remove electron-debug.
We already implement all of its functionality.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-02 20:15:43 -08:00
Anders Kaseorg
5064ea4b47 dependencies: Upgrade node-json-db from 0.9.2 to 1.0.3.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-02 19:52:39 -08:00
Anders Kaseorg
6b0d8520c5 dependencies: Remove unused dependencies assert, cp-file, is-ci.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-02 19:19:37 -08:00
Anders Kaseorg
4b16164155 cleanup: Remove unused tests/e2e directory.
It is not used even by test-e2e.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-02 19:18:25 -08:00
Anders Kaseorg
6036a44fb2 new-server-form: Remove useless .server-save-action wrappers.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-02 19:08:45 -08:00
Anders Kaseorg
598b96b6e8 webview: Wait for dom-ready before sending messages.
Fixes tests/test-add-organization.js.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-02 18:52:37 -08:00
Anders Kaseorg
675bc2f06c appveyor.yml, .travis.yml: Test current Node.js releases.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-03-02 16:30:12 -08:00
Tim Abbott
eb2988a5e4 dependencies: Update typescript and typescript-eslint.
The changes are mostly done via `xo --fix`; the other changes are
either trivial or disabling new linter rules that we plan to address
in future commits.
2020-02-29 23:39:55 -08:00
Tim Abbott
39ea18228c dependencies: Update gulp testing packages. 2020-02-29 22:54:50 -08:00
Tim Abbott
909e0f07e3 dependencies: Upgrade linters and fix linter errors.
The changes here are mostly straightforward; the one exception is
removing a zulipdev.org hack.

We disable some lint rules we'll want to address later (E.g. we want
to switch to using async/await rather than .then()).  But those are
out of scope for this commit.
2020-02-29 22:47:42 -08:00
Tim Abbott
31af6596bf dependencies: Upgrade Electron to version 8.
This is the latest Electron release, which means we're now getting
nearly modern Chrome (hopefully with fewer rendering bugs and better
performance).
2020-02-29 21:49:19 -08:00
Tim Abbott
3c9914542f badge: Clear badge counts on Linux as well.
My Linux desktop environment doesn't display unread badges, it seems,
but this is clearly how this code should read.
2020-02-29 21:39:56 -08:00
Tim Abbott
f4b9605742 electron: Update some setter/getters to user newer properties.
This removes a few deprecation warnings on app startup.
2020-02-29 21:39:56 -08:00
Tim Abbott
e2fc9241fa dependencies: Upgrade to Electron 7.
This works without any other changes, thanks to Electron's deprecation
process being done over multiple releases.
2020-02-29 21:39:56 -08:00
Tim Abbott
3b18357c74 download: Use removeListener for removing the updated listener.
This is slightly cleaner code, and also fixes a typescript error (that
might be a bug) we'll get when we upgrade to Electron 7.
2020-02-29 21:39:56 -08:00
Tim Abbott
c4beedf740 proxy: Migration to use async/await.
This is required for the upgrade to Electron 7, which removes the old
callback-based form of these APIs.
2020-02-29 21:39:56 -08:00
Anders Kaseorg
b34bf7236f .travis.yml: Fix npm ci invocation for app directory.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-29 18:49:01 -08:00
Tim Abbott
e32968b2f3 preferences: Convert one more dialog to use async/await.
This should have been in the main version update commit.
2020-02-29 18:32:09 -08:00
Anders Kaseorg
747fbb5ab0 .travis.yml: Run npm ci, not npm install.
This enforces that package-lock.json is up to date in Git.

Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-29 18:30:05 -08:00
vsvipul
107e522914 deps: Update Electron and related packages to Electron v6.
This updates most of our direct dependencies to much newer versions
(Electron v6, with compatible versions of related packages like
Spectron).

Further, it updates all of our recursive dependencies with `npm update
--depth=999`.

Modified by tabbott to migrate to async/await for dialogs rather than
the old synchronous API.
2020-02-29 18:28:42 -08:00
Tim Abbott
c83bc08359 i18n: Add additional automatically output strings.
I'm skeptical of the setup that these are generated dynamically while
working in the development environment; that seems not robust.
2020-02-29 17:38:23 -08:00
Anders Kaseorg
ef0b056437 package.json: Fix clean-ts-files script to spare app/node_modules.
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-02-29 17:03:53 -08:00
Tim Abbott
9370f783ba i18n: Update generated translation data.
I feel like this should probably not be in Git.
2020-02-29 16:14:00 -08:00
Tim Abbott
227e59fee2 gitattributes: Disable binary handling of lock files.
This makes it possible to see what packages change after npm
operations.
2020-02-29 13:09:29 -08:00
Akash Nimare
b7147d0b29 webview: Update web security preference.
Electron docs suggests that we should not use
`disablewebsecurity` thus removing the same.
2020-02-27 16:55:47 +05:30
Akash Nimare
f93053eb20 release: New release 4.1.0-beta. 2020-02-27 10:37:55 +05:30
ViPuL
49b29bfed6 auth: Move social login process to browser.
Moves the social login to browser since there
was no way to verify the authencity of the
auth process for a custom server and to
prevent phishing attacks.

Fixes #849.

Co-authored-by: Kanishk Kakar <kanishk.kakar@gmail.com>
2020-02-25 20:05:27 +05:30
Akash Nimare
0fb610f858 macOS: Add colorless tray icon for macOS.
Fixes: #825.
2020-02-05 21:41:10 +05:30
ViPuL
1d9a923245 startup: Use inbuilt electron API for autostartup. (#859)
Switch from using the external auto-launch module to
inbuilt setLoginItemSettings for windows and macOS,
as some users reported issues on windows.

Fixes: #851.
2020-01-28 12:26:41 +05:30
Ross Brunton
9582d32de8 Added option to select download locations.
Added an option that, when enabled, will mean any file downloads that
would normally go to ~/Downloads (or wherever), in fact prompt.
2020-01-21 16:41:56 +05:30
ViPuL
a2a21631f2 Decode server name in Window menu. 2020-01-08 11:58:44 +05:30
Akash Nimare
9490265a03 dock: Toggle app on clicking the dock icon. 2019-12-29 00:49:46 +05:30
Brandon Liu
70bd619fa8 Fix size of macOS dock icon.
Fixes: #845.
2019-11-30 17:07:31 +05:30
Akash Nimare
c5797e4edb release: New release v4.0.3. 2019-11-21 12:47:29 +05:30
Kanishk Kakar
32321daef2 docs: Add release notes for v4.0.2-beta. (#841) 2019-11-17 04:27:55 +05:30
Akash Nimare
95a9568ece beta-release: New release v4.0.2-beta. 2019-11-13 14:56:34 +05:30
Kanishk Kakar
e7a885a1fb macos: Enable notarization for macOS Catalina.
This fixes the issue for Catalina users.
2019-11-13 14:55:11 +05:30
Akash Nimare
17d4d97e2e certificate: Make certificate location dynamic.
* certificate: Make certificate location dynamic.

* Update certificate location for old servers.
2019-11-01 19:56:22 +05:30
Akash Nimare
3b14684058 Add docs for translation 2019-10-30 02:10:38 +05:30
Akash Nimare
7d592a0a1c validation: Add SSL troubleshooting guide in error message. 2019-10-25 21:03:28 +05:30
Ross Brunton
eb1be7106b Added option to quit on closing the window.
This adds a configuration option to quit the app (rather than going to
the tray) when the "close" button is clicked.
2019-10-14 01:05:18 +05:30
Tim Abbott
1da6e5d51d README: Fix typo in link to server/webapp repository. 2019-10-07 11:51:57 -07:00
Tim Abbott
dae7089c7e README: Explicitly address where to report bugs. 2019-10-01 15:41:39 -07:00
Muskan Khedia
30b40e2ff2 network: Prompts Desktop App to ask for network setting in add-org page.
Fixes: #540.
2019-09-28 17:44:34 +05:30
Akash Nimare
b76f01349a docs: Update electron tutorial guide.
Fixes: #826.
2019-09-27 23:02:25 +05:30
Kanishk Kakar
8446deb673 sidebar: Improve UX for notification settings.
* sidebar: Disable notif settings if not logged in.

* sidebar: Activate relevant tab for notif settings.
2019-09-25 18:25:00 +05:30
Kanishk Kakar
d4b9663257 network: Tackle network issues independently.
Few changes -
* webview: Show connection failure per server.
* network: Try to reconnect diff servers.
* Fixes concern that some proxy networks may allow only specific servers
to be reachable.
* domains: Show network error on server invalidation.
* webview: Handle network errors in preload script.
Fixes: #591, #312.
2019-09-24 18:22:19 +05:30
Akash Nimare
77044fd9fa enterprise: Document the enterprise feature. 2019-09-18 23:26:44 +05:30
Kanishk Kakar
177b77f0b5 sidebar: Add option to open notification setting from the context menu. 2019-09-15 21:20:08 +05:30
Kanishk Kakar
99b154b8ae system-util: Set User-Agent from main process.
* Sets user-agent config item when the app's DOM is ready.
* App sends the right User-Agent to the server-settings API.
 
Fixes #817.
2019-09-11 16:39:56 +05:30
Kanishk Kakar
3fd8aedf81 network: Reactivate network.js script.
Fixes an issue introduced during TS migration that rendered network.ts
ineffective because exports were not defined.
2019-09-02 00:25:17 +05:30
Rhythm Sharma
b4d2e55c6f linux: Fix broken icon issue for snap package. 2019-08-29 02:20:12 +05:30
Kanishk Kakar
3c701ff518 sidebar: Load last active server before others.
Fixes: #551.
2019-08-28 14:12:01 +05:30
ViPuL
1f79a97b05 system-presence: Pass system active status to webapp.
We check user status every 15 seconds and update the status accordingly
to every organization connected. The webapp then uses this system presence data
we send to set the user status based on system activity.

Fixes #352.
2019-08-19 20:09:27 -04:00
Nikita
90e8e9a806 il8n: Fix translations for ru locales. 2019-08-17 10:52:36 -04:00
Kanishk Kakar
59ef505efd settings: Fix trailing brackets.
This accidentally slipped by in the translation work we recently did.
2019-08-17 10:49:24 -04:00
Akash Nimare
8d0a111c91 version: Update app to v4.0.1. 2019-08-17 01:41:15 +05:30
Akash Nimare
a10fa8f3ad badge-count: Show badge-count on Linux.
Added support for showing the badge counts in the Unity launcher.
This should work on elementary OS and Ubuntu.

More info -
https://github.com/electron/electron/issues/16001
https://github.com/signalapp/Signal-Desktop/issues/3387
2019-08-17 01:32:50 +05:30
Akash Nimare
39427091f5 linux: Add desktopName config for Linux.
This will help in creating .desktop file for Linux.
2019-08-17 01:31:31 +05:30
ViPuL
f8d93cf397 build: Add MSI installer support.
This PR adds MSI in the target option. MSI is useful for sysadmins. There is no support for auto-updates since it often manages by the admins. 
More info -
https://github.com/electron-userland/electron-builder/releases/tag/v19.41.0
https://github.com/electron-userland/electron-builder/issues/3322

Fixes: #641.
2019-08-15 00:35:00 +05:30
Priyank Patel
ab62b8b5bb Remove accidently commited translation-util.js file.
This file was accidently committed in 77a1fc0bd3
when migrating old PR to use typescript. This commit removes it.
2019-08-14 12:50:47 -04:00
185 changed files with 15042 additions and 13644 deletions

2
.gitattributes vendored
View File

@@ -1,7 +1,5 @@
* text=auto eol=lf * text=auto eol=lf
package-lock.json binary
app/package-lock.json binary
*.gif binary *.gif binary
*.jpg binary *.jpg binary
*.jpeg binary *.jpeg binary

View File

@@ -1,8 +0,0 @@
---
<!-- Please Include: -->
- **Operating System**:
- [ ] Windows
- [ ] Linux/Ubuntu
- [ ] macOS
- **Clear steps to reproduce the issue**:
- **Relevant error messages and/or screenshots**:

25
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

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

6
.github/ISSUE_TEMPLATE/custom.md vendored Normal file
View File

@@ -0,0 +1,6 @@
---
name: Custom issue template
about: Describe this issue template's purpose here.
---

View File

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

7
.gitignore vendored
View File

@@ -1,9 +1,12 @@
# Dependency directories # Dependency directory
node_modules/ /node_modules/
# npm cache directory # npm cache directory
.npm .npm
# transifexrc - if user prefers it to be in working tree
.transifexrc
# Compiled binary build directory # Compiled binary build directory
dist/ dist/

5
.mailmap Normal file
View File

@@ -0,0 +1,5 @@
Anders Kaseorg <anders@zulip.com> <anders@zulipchat.com>
Rishi Gupta <rishig@zulip.com> <rishig@zulipchat.com>
Rishi Gupta <rishig@zulip.com> <rishig@users.noreply.github.com>
Tim Abbott <tabbott@zulip.com> <tabbott@zulipchat.com>
Tim Abbott <tabbott@zulip.com> <tabbott@mit.edu>

View File

@@ -1,6 +1,3 @@
sudo: required
dist: trusty
os: os:
- osx - osx
- linux - linux
@@ -15,23 +12,12 @@ addons:
language: node_js language: node_js
node_js: node_js:
- '8' - '10'
- '12'
before_install:
- ./scripts/travis-xvfb.sh
- npm install -g gulp
- npm install
cache: cache:
directories: directories:
- node_modules - node_modules
- app/node_modules
script: script:
- npm run travis - npm run test
notifications:
webhooks:
urls:
- https://zulip.org/zulipbot/travis
on_success: always
on_failure: always

9
.tx/config Normal file
View File

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

View File

@@ -6,7 +6,7 @@ The following is a set of guidelines for contributing to Zulip's desktop Client.
## Getting Started ## Getting Started
Zulip-Desktop app is built on top of [Electron](http://electron.atom.io/). If you are new to Electron, please head over to [this](https://jlord.dev/blog/essential-electron) great article. Zulip-Desktop app is built on top of [Electron](http://electron.atom.io/). If you are new to Electron, please head over to [this](https://jlord.us/essential-electron) great article.
## Community ## Community

View File

@@ -9,7 +9,7 @@ Desktop client for Zulip. Available for Mac, Linux, and Windows.
<img src="http://i.imgur.com/ChzTq4F.png"/> <img src="http://i.imgur.com/ChzTq4F.png"/>
# Download # Download
Please see the [installation guide](https://zulipchat.com/help/desktop-app-install-guide). Please see the [installation guide](https://zulip.com/help/desktop-app-install-guide).
# Features # Features
* Sign in to multiple organizations * Sign in to multiple organizations
@@ -18,6 +18,15 @@ Please see the [installation guide](https://zulipchat.com/help/desktop-app-insta
* Multi-language spell checker * Multi-language spell checker
* Automatic updates * Automatic updates
# Reporting issues
This desktop client shares most of its code with the Zulip webapp.
Issues in an individual organization's Zulip window should be reported
in the [Zulip server and webapp
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).
# Contribute # Contribute
First, join us on the [Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html)! First, join us on the [Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html)!

View File

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

View File

@@ -1,31 +1,20 @@
'use strict'; import {sentryInit} from '../renderer/js/utils/sentry-util';
import { sentryInit } from '../renderer/js/utils/sentry-util'; import {appUpdater} from './autoupdater';
import { appUpdater } from './autoupdater'; import {setAutoLaunch} from './startup';
import { setAutoLaunch } from './startup';
import windowStateKeeper = require('electron-window-state'); import windowStateKeeper from 'electron-window-state';
import path = require('path'); import path from 'path';
import fs = require('fs'); import fs from 'fs';
import isDev = require('electron-is-dev'); import electron, {app, dialog, ipcMain, session} from 'electron';
import electron = require('electron');
const { app, ipcMain } = electron;
import AppMenu = require('./menu'); import * as AppMenu from './menu';
import BadgeSettings = require('../renderer/js/pages/preference/badge-settings'); import * as BadgeSettings from '../renderer/js/pages/preference/badge-settings';
import ConfigUtil = require('../renderer/js/utils/config-util'); import * as CertificateUtil from '../renderer/js/utils/certificate-util';
import ProxyUtil = require('../renderer/js/utils/proxy-util'); import * as ConfigUtil from '../renderer/js/utils/config-util';
import * as ProxyUtil from '../renderer/js/utils/proxy-util';
import {_getServerSettings, _saveServerIcon, _isOnline} from './request';
interface PatchedGlobal extends NodeJS.Global { let mainWindowState: windowStateKeeper.State;
mainWindowState: windowStateKeeper.State;
}
const globalPatched = global as PatchedGlobal;
// Adds debug features like hotkeys for triggering dev tools and reload
// in development mode
if (isDev) {
require('electron-debug')();
}
// Prevent window being garbage collected // Prevent window being garbage collected
let mainWindow: Electron.BrowserWindow; let mainWindow: Electron.BrowserWindow;
@@ -51,10 +40,30 @@ if (singleInstanceLock) {
app.quit(); app.quit();
} }
const rendererCallbacks = new Map();
let nextRendererCallbackId = 0;
ipcMain.on('renderer-callback', (event: Event, rendererCallbackId: number, ...args: any[]) => {
rendererCallbacks.get(rendererCallbackId)(...args);
rendererCallbacks.delete(rendererCallbackId);
});
function makeRendererCallback(callback: (...args: any[]) => void): number {
rendererCallbacks.set(nextRendererCallbackId, callback);
return nextRendererCallbackId++;
}
const APP_ICON = path.join(__dirname, '../resources', 'Icon'); const APP_ICON = path.join(__dirname, '../resources', 'Icon');
const iconPath = (): string => { const iconPath = (): string => APP_ICON + (process.platform === 'win32' ? '.ico' : '.png');
return APP_ICON + (process.platform === 'win32' ? '.ico' : '.png');
// Toggle the app window
const toggleApp = (): void => {
if (!mainWindow.isVisible() || mainWindow.isMinimized()) {
mainWindow.show();
} else {
mainWindow.hide();
}
}; };
function createMainWindow(): Electron.BrowserWindow { function createMainWindow(): Electron.BrowserWindow {
@@ -65,9 +74,6 @@ function createMainWindow(): Electron.BrowserWindow {
path: `${app.getPath('userData')}/config` path: `${app.getPath('userData')}/config`
}); });
// Let's keep the window position global so that we can access it in other process
globalPatched.mainWindowState = mainWindowState;
const win = new electron.BrowserWindow({ const win = new electron.BrowserWindow({
// This settings needs to be saved in config // This settings needs to be saved in config
title: 'Zulip', title: 'Zulip',
@@ -76,12 +82,13 @@ function createMainWindow(): Electron.BrowserWindow {
y: mainWindowState.y, y: mainWindowState.y,
width: mainWindowState.width, width: mainWindowState.width,
height: mainWindowState.height, height: mainWindowState.height,
minWidth: 300, minWidth: 500,
minHeight: 400, minHeight: 400,
webPreferences: { webPreferences: {
plugins: true, plugins: true,
nodeIntegration: true, nodeIntegration: true,
partition: 'persist:webviewsession' partition: 'persist:webviewsession',
webviewTag: true
}, },
show: false show: false
}); });
@@ -90,12 +97,16 @@ function createMainWindow(): Electron.BrowserWindow {
win.webContents.send('focus'); win.webContents.send('focus');
}); });
win.loadURL(mainURL); (async () => win.loadURL(mainURL))();
// Keep the app running in background on close event // Keep the app running in background on close event
win.on('close', e => { win.on('close', event => {
if (ConfigUtil.getConfigItem('quitOnClose')) {
app.quit();
}
if (!isQuitting) { if (!isQuitting) {
e.preventDefault(); event.preventDefault();
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
app.hide(); app.hide();
@@ -116,8 +127,8 @@ function createMainWindow(): Electron.BrowserWindow {
}); });
// To destroy tray icon when navigate to a new URL // To destroy tray icon when navigate to a new URL
win.webContents.on('will-navigate', e => { win.webContents.on('will-navigate', event => {
if (e) { if (event) {
win.webContents.send('destroytray'); win.webContents.send('destroytray');
} }
}); });
@@ -137,19 +148,23 @@ app.disableHardwareAcceleration();
// More info here - https://github.com/electron/electron/issues/10732 // More info here - https://github.com/electron/electron/issues/10732
app.commandLine.appendSwitch('force-color-profile', 'srgb'); app.commandLine.appendSwitch('force-color-profile', 'srgb');
// eslint-disable-next-line max-params // This event is only available on macOS. Triggers when you click on the dock icon.
app.on('certificate-error', (event: Event, _webContents: Electron.WebContents, _url: string, _error: string, _certificate: any, callback) => {
event.preventDefault();
callback(true);
});
app.on('activate', () => { app.on('activate', () => {
if (!mainWindow) { if (mainWindow) {
// If there is already a window show it
mainWindow.show();
} else {
mainWindow = createMainWindow(); mainWindow = createMainWindow();
} }
}); });
app.on('ready', () => { app.on('ready', () => {
const ses = session.fromPartition('persist:webviewsession');
ses.setUserAgent(`ZulipElectron/${app.getVersion()} ${ses.getUserAgent()}`);
ipcMain.on('set-spellcheck-langs', () => {
ses.setSpellCheckerLanguages(ConfigUtil.getConfigItem('spellcheckerLanguages'));
});
AppMenu.setMenu({ AppMenu.setMenu({
tabs: [] tabs: []
}); });
@@ -158,7 +173,7 @@ app.on('ready', () => {
// Auto-hide menu bar on Windows + Linux // Auto-hide menu bar on Windows + Linux
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
const shouldHideMenu = ConfigUtil.getConfigItem('autoHideMenubar') || false; const shouldHideMenu = ConfigUtil.getConfigItem('autoHideMenubar') || false;
mainWindow.setAutoHideMenuBar(shouldHideMenu); mainWindow.autoHideMenuBar = shouldHideMenu;
mainWindow.setMenuBarVisibility(!shouldHideMenu); mainWindow.setMenuBarVisibility(!shouldHideMenu);
} }
@@ -171,7 +186,7 @@ app.on('ready', () => {
const isSystemProxy = ConfigUtil.getConfigItem('useSystemProxy'); const isSystemProxy = ConfigUtil.getConfigItem('useSystemProxy');
if (isSystemProxy) { if (isSystemProxy) {
ProxyUtil.resolveSystemProxy(mainWindow); (async () => ProxyUtil.resolveSystemProxy(mainWindow))();
} }
const page = mainWindow.webContents; const page = mainWindow.webContents;
@@ -184,13 +199,76 @@ app.on('ready', () => {
} }
}); });
page.once('did-frame-finish-load', () => { ipcMain.on('fetch-user-agent', event => {
event.returnValue = session.fromPartition('persist:webviewsession').getUserAgent();
});
ipcMain.handle('get-server-settings', async (event, domain: string) => _getServerSettings(domain, ses));
ipcMain.handle('save-server-icon', async (event, url: string) => _saveServerIcon(url, ses));
ipcMain.handle('is-online', async (event, url: string) => _isOnline(url, ses));
page.once('did-frame-finish-load', async () => {
// Initiate auto-updates on MacOS and Windows // Initiate auto-updates on MacOS and Windows
if (ConfigUtil.getConfigItem('autoUpdate')) { if (ConfigUtil.getConfigItem('autoUpdate')) {
appUpdater(); await appUpdater();
} }
}); });
app.on('certificate-error', (
event: Event,
webContents: Electron.WebContents,
urlString: string,
error: string,
certificate: Electron.Certificate,
callback: (isTrusted: boolean) => void
) /* eslint-disable-line max-params */ => {
// TODO: The entire concept of selectively ignoring certificate errors
// is ill-conceived, and this handler needs to be deleted.
const url = new URL(urlString);
if (url.protocol === 'wss:') {
url.protocol = 'https:';
}
const filename = CertificateUtil.getCertificate(encodeURIComponent(url.origin));
if (filename !== undefined) {
try {
const savedCertificate = fs.readFileSync(
path.join(`${app.getPath('userData')}/certificates`, filename),
'utf8'
);
if (certificate.data.replace(/[\r\n]/g, '') ===
savedCertificate.replace(/[\r\n]/g, '')) {
event.preventDefault();
callback(true);
return;
}
} catch (error) {
console.error(`Error reading certificate file ${filename}:`, error);
}
}
dialog.showErrorBox(
'Certificate error',
`The server presented an invalid certificate for ${url.origin}:
${error}`
);
});
page.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
const {origin} = new URL(details.requestingUrl);
page.send('permission-request', {
webContentsId: webContents.id === mainWindow.webContents.id ?
null :
webContents.id,
origin,
permission
}, makeRendererCallback(callback));
});
// Temporarily remove this event // Temporarily remove this event
// electron.powerMonitor.on('resume', () => { // electron.powerMonitor.on('resume', () => {
// mainWindow.reload(); // mainWindow.reload();
@@ -238,44 +316,40 @@ app.on('ready', () => {
}); });
ipcMain.on('clear-app-settings', () => { ipcMain.on('clear-app-settings', () => {
globalPatched.mainWindowState.unmanage(); mainWindowState.unmanage();
app.relaunch(); app.relaunch();
app.exit(); app.exit();
}); });
ipcMain.on('toggle-app', () => { ipcMain.on('toggle-app', () => {
if (!mainWindow.isVisible() || mainWindow.isMinimized()) { toggleApp();
mainWindow.show();
} else {
mainWindow.hide();
}
}); });
ipcMain.on('toggle-badge-option', () => { ipcMain.on('toggle-badge-option', () => {
BadgeSettings.updateBadge(badgeCount, mainWindow); BadgeSettings.updateBadge(badgeCount, mainWindow);
}); });
ipcMain.on('toggle-menubar', (_event: Electron.IpcMessageEvent, showMenubar: boolean) => { ipcMain.on('toggle-menubar', (_event: Electron.IpcMainEvent, showMenubar: boolean) => {
mainWindow.setAutoHideMenuBar(showMenubar); mainWindow.autoHideMenuBar = showMenubar;
mainWindow.setMenuBarVisibility(!showMenubar); mainWindow.setMenuBarVisibility(!showMenubar);
page.send('toggle-autohide-menubar', showMenubar, true); page.send('toggle-autohide-menubar', showMenubar, true);
}); });
ipcMain.on('update-badge', (_event: Electron.IpcMessageEvent, messageCount: number) => { ipcMain.on('update-badge', (_event: Electron.IpcMainEvent, messageCount: number) => {
badgeCount = messageCount; badgeCount = messageCount;
BadgeSettings.updateBadge(badgeCount, mainWindow); BadgeSettings.updateBadge(badgeCount, mainWindow);
page.send('tray', messageCount); page.send('tray', messageCount);
}); });
ipcMain.on('update-taskbar-icon', (_event: Electron.IpcMessageEvent, data: any, text: string) => { ipcMain.on('update-taskbar-icon', (_event: Electron.IpcMainEvent, data: string, text: string) => {
BadgeSettings.updateTaskbarIcon(data, text, mainWindow); BadgeSettings.updateTaskbarIcon(data, text, mainWindow);
}); });
ipcMain.on('forward-message', (_event: Electron.IpcMessageEvent, listener: any, ...params: any[]) => { ipcMain.on('forward-message', (_event: Electron.IpcMainEvent, listener: string, ...parameters: unknown[]) => {
page.send(listener, ...params); page.send(listener, ...parameters);
}); });
ipcMain.on('update-menu', (_event: Electron.IpcMessageEvent, props: any) => { ipcMain.on('update-menu', (_event: Electron.IpcMainEvent, props: AppMenu.MenuProps) => {
AppMenu.setMenu(props); AppMenu.setMenu(props);
const activeTab = props.tabs[props.activeTabIndex]; const activeTab = props.tabs[props.activeTabIndex];
if (activeTab) { if (activeTab) {
@@ -283,16 +357,20 @@ app.on('ready', () => {
} }
}); });
ipcMain.on('toggleAutoLauncher', (_event: Electron.IpcMessageEvent, AutoLaunchValue: boolean) => { ipcMain.on('toggleAutoLauncher', async (_event: Electron.IpcMainEvent, AutoLaunchValue: boolean) => {
setAutoLaunch(AutoLaunchValue); await setAutoLaunch(AutoLaunchValue);
}); });
ipcMain.on('downloadFile', (_event: Electron.IpcMessageEvent, url: string, downloadPath: string) => { ipcMain.on('downloadFile', (_event: Electron.IpcMainEvent, url: string, downloadPath: string) => {
page.downloadURL(url); page.downloadURL(url);
page.session.once('will-download', (_event: Event, item) => { page.session.once('will-download', async (_event: Event, item) => {
const filePath = path.join(downloadPath, item.getFilename()); if (ConfigUtil.getConfigItem('promptDownload', false)) {
const showDialogOptions: electron.SaveDialogOptions = {
const getTimeStamp = (): any => { defaultPath: path.join(downloadPath, item.getFilename())
};
item.setSaveDialogOptions(showDialogOptions);
} else {
const getTimeStamp = (): number => {
const date = new Date(); const date = new Date();
return date.getTime(); return date.getTime();
}; };
@@ -303,15 +381,15 @@ app.on('ready', () => {
return `${baseName}-${getTimeStamp()}${fileExtension}`; return `${baseName}-${getTimeStamp()}${fileExtension}`;
}; };
const filePath = path.join(downloadPath, item.getFilename());
// Update the name and path of the file if it already exists // Update the name and path of the file if it already exists
const updatedFilePath = path.join(downloadPath, formatFile(filePath)); const updatedFilePath = path.join(downloadPath, formatFile(filePath));
const setFilePath: string = fs.existsSync(filePath) ? updatedFilePath : filePath;
const setFilePath = fs.existsSync(filePath) ? updatedFilePath : filePath;
item.setSavePath(setFilePath); item.setSavePath(setFilePath);
}
item.on('updated', (_event: Event, state) => { const updatedListener = (_event: Event, state: string): void => {
switch (state) { switch (state) {
case 'interrupted': { case 'interrupted': {
// Can interrupted to due to network error, cancel download then // Can interrupted to due to network error, cancel download then
@@ -319,49 +397,67 @@ app.on('ready', () => {
item.cancel(); item.cancel();
break; break;
} }
case 'progressing': { case 'progressing': {
if (item.isPaused()) { if (item.isPaused()) {
item.cancel(); item.cancel();
} }
// This event can also be used to show progress in percentage in future. // This event can also be used to show progress in percentage in future.
break; break;
} }
default: { default: {
console.info('Unknown updated state of download item'); console.info('Unknown updated state of download item');
} }
} }
}); };
item.on('updated', updatedListener);
item.once('done', (_event: Event, state) => { item.once('done', (_event: Event, state) => {
const getFileName = fs.existsSync(filePath) ? formatFile(filePath) : item.getFilename();
if (state === 'completed') { if (state === 'completed') {
page.send('downloadFileCompleted', item.getSavePath(), getFileName); page.send('downloadFileCompleted', item.getSavePath(), path.basename(item.getSavePath()));
} else { } else {
console.log('Download failed state: ', state); console.log('Download failed state:', state);
page.send('downloadFileFailed'); page.send('downloadFileFailed', state);
} }
// To stop item for listening to updated events of this file // To stop item for listening to updated events of this file
item.removeAllListeners('updated'); item.removeListener('updated', updatedListener);
}); });
}); });
}); });
ipcMain.on('realm-name-changed', (_event: Electron.IpcMessageEvent, serverURL: string, realmName: string) => { ipcMain.on('realm-name-changed', (_event: Electron.IpcMainEvent, serverURL: string, realmName: string) => {
page.send('update-realm-name', serverURL, realmName); page.send('update-realm-name', serverURL, realmName);
}); });
ipcMain.on('realm-icon-changed', (_event: Electron.IpcMessageEvent, serverURL: string, iconURL: string) => { ipcMain.on('realm-icon-changed', (_event: Electron.IpcMainEvent, serverURL: string, iconURL: string) => {
page.send('update-realm-icon', serverURL, iconURL); page.send('update-realm-icon', serverURL, iconURL);
}); });
// Using event.sender.send instead of page.send here to // Using event.sender.send instead of page.send here to
// make sure the value of errorReporting is sent only once on load. // make sure the value of errorReporting is sent only once on load.
ipcMain.on('error-reporting', (event: Electron.IpcMessageEvent) => { ipcMain.on('error-reporting', (event: Electron.IpcMainEvent) => {
event.sender.send('error-reporting-val', errorReporting); event.sender.send('error-reporting-val', errorReporting);
}); });
ipcMain.on('save-last-tab', (_event: Electron.IpcMessageEvent, index: number) => { ipcMain.on('save-last-tab', (_event: Electron.IpcMainEvent, index: number) => {
ConfigUtil.setConfigItem('lastActiveTab', index); ConfigUtil.setConfigItem('lastActiveTab', index);
}); });
// Update user idle status for each realm after every 15s
const idleCheckInterval = 15 * 1000; // 15 seconds
setInterval(() => {
// Set user idle if no activity in 1 second (idleThresholdSeconds)
const idleThresholdSeconds = 1; // 1 second
const idleState = electron.powerMonitor.getSystemIdleState(idleThresholdSeconds);
if (idleState === 'active') {
page.send('set-active');
} else {
page.send('set-idle');
}
}, idleCheckInterval);
}); });
app.on('before-quit', () => { app.on('before-quit', () => {

View File

@@ -1,48 +1,43 @@
import { app, Notification } from 'electron'; import {app, Notification, net} from 'electron';
import request = require('request'); import getStream from 'get-stream';
import semver = require('semver'); import semver from 'semver';
import ConfigUtil = require('../renderer/js/utils/config-util'); import * as ConfigUtil from '../renderer/js/utils/config-util';
import ProxyUtil = require('../renderer/js/utils/proxy-util'); import * as LinuxUpdateUtil from '../renderer/js/utils/linux-update-util';
import LinuxUpdateUtil = require('../renderer/js/utils/linux-update-util'); import Logger from '../renderer/js/utils/logger-util';
import Logger = require('../renderer/js/utils/logger-util'); import {fetchResponse} from './request';
const logger = new Logger({ const logger = new Logger({
file: 'linux-update-util.log', file: 'linux-update-util.log',
timestamp: true timestamp: true
}); });
export function linuxUpdateNotification(): void { export async function linuxUpdateNotification(session: Electron.session): Promise<void> {
let url = 'https://api.github.com/repos/zulip/zulip-desktop/releases'; let url = 'https://api.github.com/repos/zulip/zulip-desktop/releases';
url = ConfigUtil.getConfigItem('betaUpdate') ? url : url + '/latest'; url = ConfigUtil.getConfigItem('betaUpdate') ? url : url + '/latest';
const proxyEnabled = ConfigUtil.getConfigItem('useManualProxy') || ConfigUtil.getConfigItem('useSystemProxy');
const options = { try {
url, const response = await fetchResponse(net.request({url, session}));
headers: {'User-Agent': 'request'}, if (response.statusCode !== 200) {
proxy: proxyEnabled ? ProxyUtil.getProxy(url) : '', logger.log('Linux update response status: ', response.statusCode);
ecdhCurve: 'auto'
};
request(options, (error: any, response: any, body: any) => {
if (error) {
logger.error('Linux update error.');
logger.error(error);
return; return;
} }
if (response.statusCode < 400) {
const data = JSON.parse(body); const data = JSON.parse(await getStream(response));
const latestVersion = ConfigUtil.getConfigItem('betaUpdate') ? data[0].tag_name : data.tag_name; const latestVersion = ConfigUtil.getConfigItem('betaUpdate') ? data[0].tag_name : data.tag_name;
if (typeof latestVersion !== 'string') {
throw new TypeError('Expected string for tag_name');
}
if (semver.gt(latestVersion, app.getVersion())) { if (semver.gt(latestVersion, app.getVersion())) {
const notified = LinuxUpdateUtil.getUpdateItem(latestVersion); const notified = LinuxUpdateUtil.getUpdateItem(latestVersion);
if (notified === null) { if (notified === null) {
new Notification({title: 'Zulip Update', body: 'A new version ' + latestVersion + ' is available. Please update using your package manager.'}).show(); new Notification({title: 'Zulip Update', body: `A new version ${latestVersion} is available. Please update using your package manager.`}).show();
LinuxUpdateUtil.setUpdateItem(latestVersion, true); LinuxUpdateUtil.setUpdateItem(latestVersion, true);
} }
} }
} else { } catch (error) {
logger.log('Linux update response status: ', response.statusCode); logger.error('Linux update error.');
logger.error(error);
} }
});
} }

View File

@@ -1,68 +1,59 @@
'use strict'; import {app, shell, BrowserWindow, Menu} from 'electron';
import { app, shell, BrowserWindow, Menu, dialog } from 'electron'; import {appUpdater} from './autoupdater';
import { appUpdater } from './autoupdater';
import AdmZip = require('adm-zip'); import AdmZip from 'adm-zip';
import fs = require('fs-extra'); import * as DNDUtil from '../renderer/js/utils/dnd-util';
import path = require('path'); import * as ConfigUtil from '../renderer/js/utils/config-util';
import DNDUtil = require('../renderer/js/utils/dnd-util'); import * as LinkUtil from '../renderer/js/utils/link-util';
import Logger = require('../renderer/js/utils/logger-util'); import * as t from '../renderer/js/utils/translation-util';
import ConfigUtil = require('../renderer/js/utils/config-util'); import type {ServerOrFunctionalTab} from '../renderer/js/main';
import t = require('../renderer/js/utils/translation-util');
const appName = app.getName(); export interface MenuProps {
tabs: ServerOrFunctionalTab[];
activeTabIndex?: number;
enableMenu?: boolean;
}
const logger = new Logger({ const appName = app.name;
file: 'errors.log',
timestamp: true
});
class AppMenu { function getHistorySubmenu(enableMenu: boolean): Electron.MenuItemConstructorOptions[] {
getHistorySubmenu(enableMenu: boolean): Electron.MenuItemConstructorOptions[] {
return [{ return [{
label: t.__('Back'), label: t.__('Back'),
accelerator: process.platform === 'darwin' ? 'Command+Left' : 'Alt+Left', accelerator: process.platform === 'darwin' ? 'Command+Left' : 'Alt+Left',
enabled: enableMenu, enabled: enableMenu,
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('back'); sendAction('back');
} }
} }
}, { }, {
label: t.__('Forward'), label: t.__('Forward'),
accelerator: process.platform === 'darwin' ? 'Command+Right' : 'Alt+Right', accelerator: process.platform === 'darwin' ? 'Command+Right' : 'Alt+Right',
enabled: enableMenu, enabled: enableMenu,
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('forward'); sendAction('forward');
} }
} }
}]; }];
} }
getToolsSubmenu(): Electron.MenuItemConstructorOptions[] { function getToolsSubmenu(): Electron.MenuItemConstructorOptions[] {
return [{ return [{
label: t.__(`Check for Updates`), label: t.__('Check for Updates'),
click() { async click() {
AppMenu.checkForUpdate(); await checkForUpdate();
} }
}, },
{ {
label: t.__(`Release Notes`), label: t.__('Release Notes'),
click() { async click() {
shell.openExternal(`https://github.com/zulip/zulip-desktop/releases/tag/v${app.getVersion()}`); await LinkUtil.openBrowser(new URL(`https://github.com/zulip/zulip-desktop/releases/tag/v${app.getVersion()}`));
} }
}, },
{ {
type: 'separator' type: 'separator'
}, },
{
label: t.__('Factory Reset'),
accelerator: process.platform === 'darwin' ? 'Command+Shift+D' : 'Ctrl+Shift+D',
click() {
AppMenu.resetAppSettings();
}
},
{ {
label: t.__('Download App Logs'), label: t.__('Download App Logs'),
click() { click() {
@@ -88,7 +79,7 @@ class AppMenu {
{ {
label: t.__('Toggle DevTools for Zulip App'), label: t.__('Toggle DevTools for Zulip App'),
accelerator: process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I', accelerator: process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I',
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
focusedWindow.webContents.openDevTools({mode: 'undocked'}); focusedWindow.webContents.openDevTools({mode: 'undocked'});
} }
@@ -97,29 +88,29 @@ class AppMenu {
{ {
label: t.__('Toggle DevTools for Active Tab'), label: t.__('Toggle DevTools for Active Tab'),
accelerator: process.platform === 'darwin' ? 'Alt+Command+U' : 'Ctrl+Shift+U', accelerator: process.platform === 'darwin' ? 'Alt+Command+U' : 'Ctrl+Shift+U',
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('tab-devtools'); sendAction('tab-devtools');
} }
} }
}]; }];
} }
getViewSubmenu(): Electron.MenuItemConstructorOptions[] { function getViewSubmenu(): Electron.MenuItemConstructorOptions[] {
return [{ return [{
label: t.__('Reload'), label: t.__('Reload'),
accelerator: 'CommandOrControl+R', accelerator: 'CommandOrControl+R',
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('reload-current-viewer'); sendAction('reload-current-viewer');
} }
} }
}, { }, {
label: t.__('Hard Reload'), label: t.__('Hard Reload'),
accelerator: 'CommandOrControl+Shift+R', accelerator: 'CommandOrControl+Shift+R',
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('hard-reload'); sendAction('hard-reload');
} }
} }
}, { }, {
@@ -129,33 +120,33 @@ class AppMenu {
role: 'togglefullscreen' role: 'togglefullscreen'
}, { }, {
label: t.__('Zoom In'), label: t.__('Zoom In'),
role: 'zoomin', accelerator: 'CommandOrControl+=',
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('zoomIn'); sendAction('zoomIn');
} }
} }
}, { }, {
label: t.__('Zoom Out'), label: t.__('Zoom Out'),
accelerator: 'CommandOrControl+-', accelerator: 'CommandOrControl+-',
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('zoomOut'); sendAction('zoomOut');
} }
} }
}, { }, {
label: t.__('Actual Size'), label: t.__('Actual Size'),
accelerator: 'CommandOrControl+0', accelerator: 'CommandOrControl+0',
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('zoomActualSize'); sendAction('zoomActualSize');
} }
} }
}, { }, {
type: 'separator' type: 'separator'
}, { }, {
label: t.__('Toggle Tray Icon'), label: t.__('Toggle Tray Icon'),
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
focusedWindow.webContents.send('toggletray'); focusedWindow.webContents.send('toggletray');
} }
@@ -163,7 +154,7 @@ class AppMenu {
}, { }, {
label: t.__('Toggle Sidebar'), label: t.__('Toggle Sidebar'),
accelerator: 'CommandOrControl+Shift+S', accelerator: 'CommandOrControl+Shift+S',
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
const newValue = !ConfigUtil.getConfigItem('showSidebar'); const newValue = !ConfigUtil.getConfigItem('showSidebar');
focusedWindow.webContents.send('toggle-sidebar', newValue); focusedWindow.webContents.send('toggle-sidebar', newValue);
@@ -174,10 +165,10 @@ class AppMenu {
label: t.__('Auto hide Menu bar'), label: t.__('Auto hide Menu bar'),
checked: ConfigUtil.getConfigItem('autoHideMenubar', false), checked: ConfigUtil.getConfigItem('autoHideMenubar', false),
visible: process.platform !== 'darwin', visible: process.platform !== 'darwin',
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
const newValue = !ConfigUtil.getConfigItem('autoHideMenubar'); const newValue = !ConfigUtil.getConfigItem('autoHideMenubar');
focusedWindow.setAutoHideMenuBar(newValue); focusedWindow.autoHideMenuBar = newValue;
focusedWindow.setMenuBarVisibility(!newValue); focusedWindow.setMenuBarVisibility(!newValue);
focusedWindow.webContents.send('toggle-autohide-menubar', newValue); focusedWindow.webContents.send('toggle-autohide-menubar', newValue);
ConfigUtil.setConfigItem('autoHideMenubar', newValue); ConfigUtil.setConfigItem('autoHideMenubar', newValue);
@@ -185,9 +176,9 @@ class AppMenu {
}, },
type: 'checkbox' type: 'checkbox'
}]; }];
} }
getHelpSubmenu(): Electron.MenuItemConstructorOptions[] { function getHelpSubmenu(): Electron.MenuItemConstructorOptions[] {
return [ return [
{ {
label: `${appName + ' Desktop'} v${app.getVersion()}`, label: `${appName + ' Desktop'} v${app.getVersion()}`,
@@ -195,24 +186,24 @@ class AppMenu {
}, },
{ {
label: t.__('About Zulip'), label: t.__('About Zulip'),
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('open-about'); sendAction('open-about');
} }
} }
}, },
{ {
label: t.__(`Help Center`), label: t.__('Help Center'),
click(focusedWindow) { click(focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('open-help'); sendAction('open-help');
} }
} }
}, },
{ {
label: t.__('Report an Issue'), label: t.__('Report an Issue'),
click() { click() {
// the goal is to notify the main.html BrowserWindow // The goal is to notify the main.html BrowserWindow
// which may not be the focused window. // which may not be the focused window.
BrowserWindow.getAllWindows().forEach(window => { BrowserWindow.getAllWindows().forEach(window => {
window.webContents.send('open-feedback-modal'); window.webContents.send('open-feedback-modal');
@@ -220,10 +211,10 @@ class AppMenu {
} }
} }
]; ];
} }
getWindowSubmenu(tabs: any[], activeTabIndex: number, enableMenu: boolean): Electron.MenuItemConstructorOptions[] { function getWindowSubmenu(tabs: ServerOrFunctionalTab[], activeTabIndex: number): Electron.MenuItemConstructorOptions[] {
const initialSubmenu: any[] = [{ const initialSubmenu: Electron.MenuItemConstructorOptions[] = [{
label: t.__('Minimize'), label: t.__('Minimize'),
role: 'minimize' role: 'minimize'
}, { }, {
@@ -246,9 +237,9 @@ class AppMenu {
label: tab.props.name, label: tab.props.name,
accelerator: tab.props.role === 'function' ? '' : `${ShortcutKey} + ${tab.props.index + 1}`, accelerator: tab.props.role === 'function' ? '' : `${ShortcutKey} + ${tab.props.index + 1}`,
checked: tab.props.index === activeTabIndex, checked: tab.props.index === activeTabIndex,
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('switch-server-tab', tab.props.index); sendAction('switch-server-tab', tab.props.index);
} }
}, },
type: 'checkbox' type: 'checkbox'
@@ -259,39 +250,39 @@ class AppMenu {
}); });
initialSubmenu.push({ initialSubmenu.push({
label: t.__('Switch to Next Organization'), label: t.__('Switch to Next Organization'),
accelerator: `Ctrl+Tab`, accelerator: 'Ctrl+Tab',
enabled: tabs.length > 1, enabled: tabs.length > 1,
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('switch-server-tab', AppMenu.getNextServer(tabs, activeTabIndex)); sendAction('switch-server-tab', getNextServer(tabs, activeTabIndex));
} }
} }
}, { }, {
label: t.__('Switch to Previous Organization'), label: t.__('Switch to Previous Organization'),
accelerator: `Ctrl+Shift+Tab`, accelerator: 'Ctrl+Shift+Tab',
enabled: tabs.length > 1, enabled: tabs.length > 1,
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('switch-server-tab', AppMenu.getPreviousServer(tabs, activeTabIndex)); sendAction('switch-server-tab', getPreviousServer(tabs, activeTabIndex));
} }
} }
}); });
} }
return initialSubmenu; return initialSubmenu;
} }
getDarwinTpl(props: any): Electron.MenuItemConstructorOptions[] { function getDarwinTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] {
const { tabs, activeTabIndex, enableMenu } = props; const {tabs, activeTabIndex, enableMenu} = props;
return [{ return [{
label: `${app.getName()}`, label: app.name,
submenu: [{ submenu: [{
label: t.__('Add Organization'), label: t.__('Add Organization'),
accelerator: 'Cmd+Shift+N', accelerator: 'Cmd+Shift+N',
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('new-server'); sendAction('new-server');
} }
} }
}, { }, {
@@ -299,23 +290,23 @@ class AppMenu {
accelerator: 'Cmd+Shift+M', accelerator: 'Cmd+Shift+M',
click() { click() {
const dndUtil = DNDUtil.toggle(); const dndUtil = DNDUtil.toggle();
AppMenu.sendAction('toggle-dnd', dndUtil.dnd, dndUtil.newSettings); sendAction('toggle-dnd', dndUtil.dnd, dndUtil.newSettings);
} }
}, { }, {
label: t.__('Desktop Settings'), label: t.__('Desktop Settings'),
accelerator: 'Cmd+,', accelerator: 'Cmd+,',
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('open-settings'); sendAction('open-settings');
} }
} }
}, { }, {
label: t.__('Keyboard Shortcuts'), label: t.__('Keyboard Shortcuts'),
accelerator: 'Cmd+Shift+K', accelerator: 'Cmd+Shift+K',
enabled: enableMenu, enabled: enableMenu,
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('shortcut'); sendAction('shortcut');
} }
} }
}, { }, {
@@ -324,18 +315,18 @@ class AppMenu {
label: t.__('Copy Zulip URL'), label: t.__('Copy Zulip URL'),
accelerator: 'Cmd+Shift+C', accelerator: 'Cmd+Shift+C',
enabled: enableMenu, enabled: enableMenu,
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('copy-zulip-url'); sendAction('copy-zulip-url');
} }
} }
}, { }, {
label: t.__('Log Out of Organization'), label: t.__('Log Out of Organization'),
accelerator: 'Cmd+L', accelerator: 'Cmd+L',
enabled: enableMenu, enabled: enableMenu,
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('log-out'); sendAction('log-out');
} }
} }
}, { }, {
@@ -351,7 +342,7 @@ class AppMenu {
role: 'hide' role: 'hide'
}, { }, {
label: t.__('Hide Others'), label: t.__('Hide Others'),
role: 'hideothers' role: 'hideOthers'
}, { }, {
label: t.__('Unhide'), label: t.__('Unhide'),
role: 'unhide' role: 'unhide'
@@ -371,10 +362,20 @@ class AppMenu {
label: t.__('Edit'), label: t.__('Edit'),
submenu: [{ submenu: [{
label: t.__('Undo'), label: t.__('Undo'),
role: 'undo' accelerator: 'Cmd+Z',
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction('undo');
}
}
}, { }, {
label: t.__('Redo'), label: t.__('Redo'),
role: 'redo' accelerator: 'Cmd+Shift+Z',
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction('redo');
}
}
}, { }, {
type: 'separator' type: 'separator'
}, { }, {
@@ -388,40 +389,40 @@ class AppMenu {
role: 'paste' role: 'paste'
}, { }, {
label: t.__('Paste and Match Style'), label: t.__('Paste and Match Style'),
role: 'pasteandmatchstyle' role: 'pasteAndMatchStyle'
}, { }, {
label: t.__('Select All'), label: t.__('Select All'),
role: 'selectall' role: 'selectAll'
}] }]
}, { }, {
label: t.__('View'), label: t.__('View'),
submenu: this.getViewSubmenu() submenu: getViewSubmenu()
}, { }, {
label: t.__('History'), label: t.__('History'),
submenu: this.getHistorySubmenu(enableMenu) submenu: getHistorySubmenu(enableMenu)
}, { }, {
label: t.__('Window'), label: t.__('Window'),
submenu: this.getWindowSubmenu(tabs, activeTabIndex, enableMenu) submenu: getWindowSubmenu(tabs, activeTabIndex)
}, { }, {
label: t.__('Tools'), label: t.__('Tools'),
submenu: this.getToolsSubmenu() submenu: getToolsSubmenu()
}, { }, {
label: t.__('Help'), label: t.__('Help'),
role: 'help', role: 'help',
submenu: this.getHelpSubmenu() submenu: getHelpSubmenu()
}]; }];
} }
getOtherTpl(props: any): Electron.MenuItemConstructorOptions[] { function getOtherTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] {
const { tabs, activeTabIndex, enableMenu } = props; const {tabs, activeTabIndex, enableMenu} = props;
return [{ return [{
label: t.__('File'), label: t.__('File'),
submenu: [{ submenu: [{
label: t.__('Add Organization'), label: t.__('Add Organization'),
accelerator: 'Ctrl+Shift+N', accelerator: 'Ctrl+Shift+N',
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('new-server'); sendAction('new-server');
} }
} }
}, { }, {
@@ -431,23 +432,23 @@ class AppMenu {
accelerator: 'Ctrl+Shift+M', accelerator: 'Ctrl+Shift+M',
click() { click() {
const dndUtil = DNDUtil.toggle(); const dndUtil = DNDUtil.toggle();
AppMenu.sendAction('toggle-dnd', dndUtil.dnd, dndUtil.newSettings); sendAction('toggle-dnd', dndUtil.dnd, dndUtil.newSettings);
} }
}, { }, {
label: t.__('Desktop Settings'), label: t.__('Desktop Settings'),
accelerator: 'Ctrl+,', accelerator: 'Ctrl+,',
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('open-settings'); sendAction('open-settings');
} }
} }
}, { }, {
label: t.__('Keyboard Shortcuts'), label: t.__('Keyboard Shortcuts'),
accelerator: 'Ctrl+Shift+K', accelerator: 'Ctrl+Shift+K',
enabled: enableMenu, enabled: enableMenu,
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('shortcut'); sendAction('shortcut');
} }
} }
}, { }, {
@@ -456,18 +457,18 @@ class AppMenu {
label: t.__('Copy Zulip URL'), label: t.__('Copy Zulip URL'),
accelerator: 'Ctrl+Shift+C', accelerator: 'Ctrl+Shift+C',
enabled: enableMenu, enabled: enableMenu,
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('copy-zulip-url'); sendAction('copy-zulip-url');
} }
} }
}, { }, {
label: t.__('Log Out of Organization'), label: t.__('Log Out of Organization'),
accelerator: 'Ctrl+L', accelerator: 'Ctrl+L',
enabled: enableMenu, enabled: enableMenu,
click(_item: any, focusedWindow: any) { click(_item, focusedWindow) {
if (focusedWindow) { if (focusedWindow) {
AppMenu.sendAction('log-out'); sendAction('log-out');
} }
} }
}, { }, {
@@ -504,98 +505,66 @@ class AppMenu {
role: 'paste' role: 'paste'
}, { }, {
label: t.__('Paste and Match Style'), label: t.__('Paste and Match Style'),
role: 'pasteandmatchstyle' role: 'pasteAndMatchStyle'
}, { }, {
type: 'separator' type: 'separator'
}, { }, {
label: t.__('Select All'), label: t.__('Select All'),
role: 'selectall' role: 'selectAll'
}] }]
}, { }, {
label: t.__('View'), label: t.__('View'),
submenu: this.getViewSubmenu() submenu: getViewSubmenu()
}, { }, {
label: t.__('History'), label: t.__('History'),
submenu: this.getHistorySubmenu(enableMenu) submenu: getHistorySubmenu(enableMenu)
}, { }, {
label: t.__('Window'), label: t.__('Window'),
submenu: this.getWindowSubmenu(tabs, activeTabIndex, enableMenu) submenu: getWindowSubmenu(tabs, activeTabIndex)
}, { }, {
label: t.__('Tools'), label: t.__('Tools'),
submenu: this.getToolsSubmenu() submenu: getToolsSubmenu()
}, { }, {
label: t.__('Help'), label: t.__('Help'),
role: 'help', role: 'help',
submenu: this.getHelpSubmenu() submenu: getHelpSubmenu()
}]; }];
} }
static sendAction(action: any, ...params: any[]): void { function sendAction(action: string, ...parameters: unknown[]): void {
const win = BrowserWindow.getAllWindows()[0]; const win = BrowserWindow.getAllWindows()[0];
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
win.restore(); win.restore();
} }
win.webContents.send(action, ...params); win.webContents.send(action, ...parameters);
} }
static checkForUpdate(): void { async function checkForUpdate(): Promise<void> {
appUpdater(true); await appUpdater(true);
} }
static getNextServer(tabs: any[], activeTabIndex: number): number { function getNextServer(tabs: ServerOrFunctionalTab[], activeTabIndex: number): number {
do { do {
activeTabIndex = (activeTabIndex + 1) % tabs.length; activeTabIndex = (activeTabIndex + 1) % tabs.length;
} }
while (tabs[activeTabIndex].props.role !== 'server'); while (tabs[activeTabIndex].props.role !== 'server');
return activeTabIndex;
}
static getPreviousServer(tabs: any[], activeTabIndex: number): number { return activeTabIndex;
}
function getPreviousServer(tabs: ServerOrFunctionalTab[], activeTabIndex: number): number {
do { do {
activeTabIndex = (activeTabIndex - 1 + tabs.length) % tabs.length; activeTabIndex = (activeTabIndex - 1 + tabs.length) % tabs.length;
} }
while (tabs[activeTabIndex].props.role !== 'server'); while (tabs[activeTabIndex].props.role !== 'server');
return activeTabIndex; return activeTabIndex;
}
static resetAppSettings(): void {
const resetAppSettingsMessage = 'By proceeding you will be removing all connected organizations and preferences from Zulip.';
// We save App's settings/configurations in following files
const settingFiles = ['config/window-state.json', 'config/domain.json', 'config/settings.json', 'config/certificates.json'];
dialog.showMessageBox({
type: 'warning',
buttons: ['YES', 'NO'],
defaultId: 0,
message: 'Are you sure?',
detail: resetAppSettingsMessage
}, response => {
if (response === 0) {
settingFiles.forEach(settingFileName => {
const getSettingFilesPath = path.join(app.getPath('appData'), appName, settingFileName);
fs.access(getSettingFilesPath, (error: any) => {
if (error) {
logger.error('Error while resetting app settings.');
logger.error(error);
} else {
fs.unlink(getSettingFilesPath, () => {
AppMenu.sendAction('clear-app-data');
});
}
});
});
}
});
}
setMenu(props: any): void {
const tpl = process.platform === 'darwin' ? this.getDarwinTpl(props) : this.getOtherTpl(props);
const menu = Menu.buildFromTemplate(tpl);
Menu.setApplicationMenu(menu);
}
} }
export = new AppMenu(); export function setMenu(props: MenuProps): void {
const tpl = process.platform === 'darwin' ? getDarwinTpl(props) : getOtherTpl(props);
const menu = Menu.buildFromTemplate(tpl);
Menu.setApplicationMenu(menu);
}

111
app/main/request.ts Normal file
View File

@@ -0,0 +1,111 @@
import {ClientRequest, IncomingMessage, app, net} from 'electron';
import fs from 'fs';
import path from 'path';
import stream from 'stream';
import util from 'util';
import * as Messages from '../resources/messages';
import Logger from '../renderer/js/utils/logger-util';
import {ServerConf} from '../renderer/js/utils/domain-util';
import escape from 'escape-html';
import getStream from 'get-stream';
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);
/* Request: domain-util */
const defaultIconUrl = '../renderer/img/icon.png';
const logger = new Logger({
file: 'domain-util.log',
timestamp: true
});
const generateFilePath = (url: string): string => {
const dir = `${app.getPath('userData')}/server-icons`;
const extension = path.extname(url).split('?')[0];
let hash = 5381;
let {length} = url;
while (length) {
hash = (hash * 33) ^ url.charCodeAt(--length);
}
// Create 'server-icons' directory if not existed
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
return `${dir}/${hash >>> 0}${extension}`;
};
export const _getServerSettings = async (domain: string, session: Electron.session): Promise<ServerConf> => {
const response = await fetchResponse(net.request({
url: domain + '/api/v1/server_settings',
session
}));
if (response.statusCode !== 200) {
throw new Error(Messages.invalidZulipServerError(domain));
}
const {realm_name, realm_uri, realm_icon} = JSON.parse(await getStream(response));
if (
typeof realm_name !== 'string' ||
typeof realm_uri !== 'string' ||
typeof realm_icon !== 'string'
) {
throw new TypeError(Messages.noOrgsError(domain));
}
return {
// Some Zulip Servers use absolute URL for server icon whereas others use relative URL
// Following check handles both the cases
icon: realm_icon.startsWith('/') ? realm_uri + realm_icon : realm_icon,
url: realm_uri,
alias: escape(realm_name)
};
};
export const _saveServerIcon = async (url: string, session: Electron.session): Promise<string> => {
try {
const response = await fetchResponse(net.request({url, session}));
if (response.statusCode !== 200) {
logger.log('Could not get server icon.');
return defaultIconUrl;
}
const filePath = generateFilePath(url);
await pipeline(response, fs.createWriteStream(filePath));
return filePath;
} catch (error) {
logger.log('Could not get server icon.');
logger.log(error);
logger.reportSentry(error);
return defaultIconUrl;
}
};
/* Request: reconnect-util */
export const _isOnline = async (url: string, session: Electron.session): Promise<boolean> => {
try {
const response = await fetchResponse(net.request({
url: `${url}/static/favicon.ico`,
session
}));
const isValidResponse = response.statusCode >= 200 && response.statusCode < 400;
return isValidResponse;
} catch (error) {
logger.log(error);
return false;
}
};

View File

@@ -1,31 +1,32 @@
'use strict'; import {app} from 'electron';
import { app } from 'electron';
import AutoLaunch = require('auto-launch'); import AutoLaunch from 'auto-launch';
import isDev = require('electron-is-dev'); import isDev from 'electron-is-dev';
import ConfigUtil = require('../renderer/js/utils/config-util'); import * as ConfigUtil from '../renderer/js/utils/config-util';
export const setAutoLaunch = (AutoLaunchValue: boolean): void => { export const setAutoLaunch = async (AutoLaunchValue: boolean): Promise<void> => {
// Don't run this in development // Don't run this in development
if (isDev) { if (isDev) {
return; return;
} }
// On Mac, work around a bug in auto-launch where it opens a Terminal window
// See https://github.com/Teamwork/node-auto-launch/issues/28#issuecomment-222194437
const appPath = process.platform === 'darwin' ? app.getPath('exe').replace(/\.app\/Content.*/, '.app') : undefined; // Use the default
const ZulipAutoLauncher = new AutoLaunch({
name: 'Zulip',
path: appPath,
isHidden: false
});
const autoLaunchOption = ConfigUtil.getConfigItem('startAtLogin', AutoLaunchValue); const autoLaunchOption = ConfigUtil.getConfigItem('startAtLogin', AutoLaunchValue);
// `setLoginItemSettings` doesn't support linux
if (process.platform === 'linux') {
const ZulipAutoLauncher = new AutoLaunch({
name: 'Zulip',
isHidden: false
});
if (autoLaunchOption) { if (autoLaunchOption) {
ZulipAutoLauncher.enable(); await ZulipAutoLauncher.enable();
} else { } else {
ZulipAutoLauncher.disable(); await ZulipAutoLauncher.disable();
}
} else {
app.setLoginItemSettings({
openAtLogin: autoLaunchOption,
openAsHidden: false
});
} }
}; };

1940
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,50 +0,0 @@
{
"name": "zulip",
"productName": "Zulip",
"version": "4.0.0",
"description": "Zulip Desktop App",
"license": "Apache-2.0",
"copyright": "Kandra Labs, Inc.",
"author": {
"name": "Kandra Labs, Inc.",
"email": "support@zulipchat.com"
},
"repository": {
"type": "git",
"url": "https://github.com/zulip/zulip-desktop.git"
},
"bugs": {
"url": "https://github.com/zulip/zulip-desktop/issues"
},
"main": "main/index.js",
"keywords": [
"Zulip",
"Group Chat app",
"electron-app",
"electron",
"Desktop app",
"InstantMessaging"
],
"dependencies": {
"@electron-elements/send-feedback": "1.0.8",
"@sentry/electron": "0.14.0",
"adm-zip": "0.4.11",
"auto-launch": "5.0.5",
"dotenv": "8.0.0",
"electron-is-dev": "0.3.0",
"electron-log": "2.2.14",
"electron-spellchecker": "1.1.2",
"electron-updater": "4.0.6",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
"i18n": "0.8.3",
"is-online": "7.0.0",
"node-json-db": "0.9.2",
"request": "2.85.0",
"semver": "5.4.1",
"wurl": "2.5.0"
},
"optionalDependencies": {
"node-mac-notifier": "1.1.0"
}
}

View File

@@ -14,33 +14,19 @@
<div class="maintenance-info"> <div class="maintenance-info">
<p class="detail maintainer"> <p class="detail maintainer">
Maintained by Maintained by
<a onclick="linkInBrowser('website')">Zulip</a> <a href="https://zulip.com" target="_blank" rel="noopener noreferrer">Zulip</a>
</p> </p>
<p class="detail license"> <p class="detail license">
Available under the Available under the
<a onclick="linkInBrowser('license')">Apache 2.0 License</a> <a href="https://github.com/zulip/zulip-desktop/blob/master/LICENSE" target="_blank" rel="noopener noreferrer">Apache 2.0 License</a>
</p> </p>
</div> </div>
</div> </div>
<script> <script>
const { app } = require('electron').remote; const { app } = require('electron').remote;
const { shell } = require('electron');
const version_tag = document.querySelector('#version'); const version_tag = document.querySelector('#version');
version_tag.innerHTML = 'v' + app.getVersion(); version_tag.innerHTML = 'v' + app.getVersion();
function linkInBrowser(type) {
let url;
switch (type) {
case 'website':
url = "https://zulipchat.com";
break;
case 'license':
url = "https://github.com/zulip/zulip-desktop/blob/master/LICENSE";
break;
}
shell.openExternal(url);
}
</script> </script>
<script>require('./js/shared/preventdrag.js')</script> <script>require('./js/shared/preventdrag.js')</script>
</body> </body>

View File

@@ -0,0 +1,19 @@
:host {
--button-color: rgb(69, 166, 149);
}
button:hover,
button:focus {
border-color: var(--button-color);
color: var(--button-color);
}
button:active {
background-color: rgb(241, 241, 241);
color: var(--button-color);
}
button {
background-color: var(--button-color);
border-color: var(--button-color);
}

View File

@@ -17,27 +17,43 @@ body {
} }
#title { #title {
text-align: left;
font-size: 24px; font-size: 24px;
font-weight: bold; font-weight: bold;
margin: 20px 0; margin: 20px 0;
} }
#subtitle {
font-size: 20px;
text-align: left;
margin: 12px 0;
}
#description { #description {
text-align: left;
font-size: 16px; font-size: 16px;
list-style-position: inside;
} }
#reconnect { #reconnect {
float: left;
}
#settings {
margin-left: 116px;
}
.button {
font-size: 16px; font-size: 16px;
background: rgba(0, 150, 136, 1.000); background: rgba(0, 150, 136, 1.000);
color: rgba(255, 255, 255, 1.000); color: rgba(255, 255, 255, 1.000);
width: 84px; width: 96px;
height: 32px; height: 32px;
border-radius: 5px; border-radius: 5px;
line-height: 32px; line-height: 32px;
margin: 20px auto 0;
cursor: pointer; cursor: pointer;
} }
#reconnect:hover { .button:hover {
opacity: 0.8; opacity: 0.8;
} }

View File

@@ -389,6 +389,10 @@ img.server-info-icon {
margin: 6px; margin: 6px;
} }
#note {
font-size: 10px;
}
.code { .code {
font-family: Courier New, Courier, monospace; font-family: Courier New, Courier, monospace;
} }
@@ -642,7 +646,47 @@ input.toggle-round:checked + label::after {
cursor: pointer; cursor: pointer;
} }
.server-network-option {
font-weight: bold;
font-size: 1.1rem;
margin-top: 10px;
padding-top: 15px;
align-items: center;
text-align: center;
color: rgb(78, 191, 172);
width: 98%;
height: 46px;
cursor: pointer;
}
i.open-network-button {
font-size: 16px;
cursor: pointer;
padding-left: 5px;
position: absolute;
}
/* responsive grid */ /* responsive grid */
@media (min-width: 500px) and (max-width: 720px) {
#new-server-container {
padding-left: 0px;
width: 60vw;
padding-right: 4vh;
}
.page-title {
width: 60vw;
}
}
@media (max-width: 500px) {
#new-server-container {
padding-left: 0px;
width: 54%;
}
.page-title {
width: 54%;
}
}
@media (max-width: 650px) { @media (max-width: 650px) {
.selected-css-path, .selected-css-path,
@@ -675,9 +719,6 @@ input.toggle-round:checked + label::after {
margin-right: 6px; margin-right: 6px;
width: 43%; width: 43%;
} }
#new-server-container {
padding-left: 0px;
}
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@@ -704,3 +745,13 @@ input.toggle-round:checked + label::after {
margin-top: 10px; margin-top: 10px;
} }
} }
.lang-menu {
font-size: 13px;
font-weight: bold;
background: rgba(78, 191, 172, 1.000);
width: 100px;
height: 38px;
color: rgba(255, 255, 255, 1.000);
border-color: rgba(0, 0, 0, 0);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,82 @@
import {clipboard} from 'electron';
import crypto from 'crypto';
// This helper is exposed via electron_bridge for use in the social
// login flow.
//
// It consists of a key and a promised token. The in-app page sends
// the key to the server, and opens the users browser to a page where
// they can log in and get a token encrypted to that key. When the
// user copies the encrypted token from their browser to the
// clipboard, we decrypt it and resolve the promise. The in-app page
// then uses the decrypted token to log the user in within the app.
//
// The encryption is authenticated (AES-GCM) to guarantee that we
// dont leak anything from the users clipboard other than the token
// intended for us.
export class ClipboardDecrypterImpl implements ClipboardDecrypter {
version: number;
key: Uint8Array;
pasted: Promise<string>;
constructor(_: number) {
// At this time, the only version is 1.
this.version = 1;
this.key = crypto.randomBytes(32);
this.pasted = new Promise(resolve => {
let interval: NodeJS.Timeout | null = null;
const startPolling = () => {
if (interval === null) {
interval = setInterval(poll, 1000);
}
poll();
};
const stopPolling = () => {
if (interval !== null) {
clearInterval(interval);
interval = null;
}
};
const poll = () => {
let plaintext;
try {
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 decipher = crypto.createDecipheriv(
'aes-256-gcm',
this.key,
iv,
{authTagLength: 16}
);
decipher.setAuthTag(authTag);
plaintext =
decipher.update(ciphertext, undefined, 'utf8') +
decipher.final('utf8');
} catch {
// If the parsing or decryption failed in any way,
// the correct token hasnt been copied yet; try
// again next time.
return;
}
window.removeEventListener('focus', startPolling);
window.removeEventListener('blur', stopPolling);
stopPolling();
resolve(plaintext);
};
window.addEventListener('focus', startPolling);
window.addEventListener('blur', stopPolling);
if (document.hasFocus()) {
startPolling();
}
});
}
}

View File

@@ -1,11 +1,7 @@
'use strict'; export default class BaseComponent {
class BaseComponent {
generateNodeFromTemplate(template: string): Element | null { generateNodeFromTemplate(template: string): Element | null {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.innerHTML = template; wrapper.innerHTML = template;
return wrapper.firstElementChild; return wrapper.firstElementChild;
} }
} }
export = BaseComponent;

View File

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

View File

@@ -1,9 +1,13 @@
'use strict'; import Tab, {TabProps} from './tab';
import Tab = require('./tab'); export default class FunctionalTab extends Tab {
class FunctionalTab extends Tab {
$closeButton: Element; $closeButton: Element;
constructor(props: TabProps) {
super(props);
this.init();
}
template(): string { template(): string {
return `<div class="tab functional-tab" data-tab-id="${this.props.tabIndex}"> return `<div class="tab functional-tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tab-badge close-button"> <div class="server-tab-badge close-button">
@@ -15,12 +19,6 @@ class FunctionalTab extends Tab {
</div>`; </div>`;
} }
// TODO: Typescript - This type for props should be TabProps
constructor(props: any) {
super(props);
this.init();
}
init(): void { init(): void {
this.$el = this.generateNodeFromTemplate(this.template()); this.$el = this.generateNodeFromTemplate(this.template());
if (this.props.name !== 'Settings') { if (this.props.name !== 'Settings') {
@@ -41,11 +39,9 @@ class FunctionalTab extends Tab {
this.$closeButton.classList.remove('active'); this.$closeButton.classList.remove('active');
}); });
this.$closeButton.addEventListener('click', (e: Event) => { this.$closeButton.addEventListener('click', (event: Event) => {
this.props.onDestroy(); this.props.onDestroy();
e.stopPropagation(); event.stopPropagation();
}); });
} }
} }
export = FunctionalTab;

View File

@@ -1,83 +1,60 @@
import { ipcRenderer, remote } from 'electron'; import {ipcRenderer, remote} from 'electron';
import LinkUtil = require('../utils/link-util'); import * as LinkUtil from '../utils/link-util';
import DomainUtil = require('../utils/domain-util'); import * as ConfigUtil from '../utils/config-util';
import ConfigUtil = require('../utils/config-util'); import type WebView from './webview';
const { shell, app } = remote; const {shell, app} = remote;
const dingSound = new Audio('../resources/sounds/ding.ogg'); const dingSound = new Audio('../resources/sounds/ding.ogg');
// TODO: TypeScript - Figure out a way to pass correct type here. export default function handleExternalLink(this: WebView, event: Electron.NewWindowEvent): void {
function handleExternalLink(this: any, event: any): void {
const { url } = event;
const domainPrefix = DomainUtil.getDomain(this.props.index).url;
const downloadPath = ConfigUtil.getConfigItem('downloadsPath', `${app.getPath('downloads')}`);
const shouldShowInFolder = ConfigUtil.getConfigItem('showDownloadFolder', false);
// Whitelist URLs which are allowed to be opened in the app
const {
isInternalUrl: isWhiteListURL,
isUploadsUrl: isUploadsURL
} = LinkUtil.isInternal(domainPrefix, url);
if (isWhiteListURL) {
event.preventDefault(); event.preventDefault();
// Code to show pdf in a new BrowserWindow (currently commented out due to bug-upstream) const url = new URL(event.url);
// Show pdf attachments in a new window const downloadPath = ConfigUtil.getConfigItem('downloadsPath', `${app.getPath('downloads')}`);
// if (LinkUtil.isPDF(url) && isUploadsURL) {
// ipcRenderer.send('pdf-view', url);
// return;
// }
// download txt, mp3, mp4 etc.. by using downloadURL in the if (LinkUtil.isUploadsUrl(this.props.url, url)) {
// main process which allows the user to save the files to their desktop ipcRenderer.send('downloadFile', url.href, downloadPath);
// and not trigger webview reload while image in webview will ipcRenderer.once('downloadFileCompleted', async (_event: Event, filePath: string, fileName: string) => {
// do nothing and will not save it
// Code to show pdf in a new BrowserWindow (currently commented out due to bug-upstream)
// if (!LinkUtil.isImage(url) && !LinkUtil.isPDF(url) && isUploadsURL) {
if (!LinkUtil.isImage(url) && isUploadsURL) {
ipcRenderer.send('downloadFile', url, downloadPath);
ipcRenderer.once('downloadFileCompleted', (_event: Event, filePath: string, fileName: string) => {
const downloadNotification = new Notification('Download Complete', { const downloadNotification = new Notification('Download Complete', {
body: shouldShowInFolder ? `Click to show ${fileName} in folder` : `Click to open ${fileName}`, body: `Click to show ${fileName} in folder`,
silent: true // We'll play our own sound - ding.ogg silent: true // We'll play our own sound - ding.ogg
}); });
// Play sound to indicate download complete
if (!ConfigUtil.getConfigItem('silent')) {
dingSound.play();
}
downloadNotification.addEventListener('click', () => { downloadNotification.addEventListener('click', () => {
if (shouldShowInFolder) {
// Reveal file in download folder // Reveal file in download folder
shell.showItemInFolder(filePath); shell.showItemInFolder(filePath);
} else {
// Open file in the default native app
shell.openItem(filePath);
}
}); });
ipcRenderer.removeAllListeners('downloadFileFailed'); ipcRenderer.removeAllListeners('downloadFileFailed');
// Play sound to indicate download complete
if (!ConfigUtil.getConfigItem('silent')) {
await dingSound.play();
}
}); });
ipcRenderer.once('downloadFileFailed', () => { ipcRenderer.once('downloadFileFailed', (_event: Event, state: string) => {
// Automatic download failed, so show save dialog prompt and download // Automatic download failed, so show save dialog prompt and download
// through webview // through webview
this.$el.downloadURL(url); // Only do this if it is the automatic download, otherwise show an error (so we aren't showing two save
// prompts right after each other)
// Check that the download is not cancelled by user
if (state !== 'cancelled') {
if (ConfigUtil.getConfigItem('promptDownload', false)) {
// We need to create a "new Notification" to display it, but just `Notification(...)` on its own
// doesn't work
new Notification('Download Complete', { // eslint-disable-line no-new
body: 'Download failed'
});
} else {
this.$el.downloadURL(url.href);
}
}
ipcRenderer.removeAllListeners('downloadFileCompleted'); ipcRenderer.removeAllListeners('downloadFileCompleted');
}); });
return;
}
// open internal urls inside the current webview.
this.$el.loadURL(url);
} else { } else {
event.preventDefault(); (async () => LinkUtil.openBrowser(url))();
shell.openExternal(url);
} }
} }
export = handleExternalLink;

View File

@@ -1,13 +1,16 @@
'use strict'; import {ipcRenderer} from 'electron';
import { ipcRenderer } from 'electron'; import Tab, {TabProps} from './tab';
import * as SystemUtil from '../utils/system-util';
import Tab = require('./tab'); export default class ServerTab extends Tab {
import SystemUtil = require('../utils/system-util');
class ServerTab extends Tab {
$badge: Element; $badge: Element;
constructor(props: TabProps) {
super(props);
this.init();
}
template(): string { template(): string {
return `<div class="tab" data-tab-id="${this.props.tabIndex}"> return `<div class="tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tooltip" style="display:none">${this.props.name}</div> <div class="server-tooltip" style="display:none">${this.props.name}</div>
@@ -19,12 +22,6 @@ class ServerTab extends Tab {
</div>`; </div>`;
} }
// TODO: Typescript - This type for props should be TabProps
constructor(props: any) {
super(props);
this.init();
}
init(): void { init(): void {
this.$el = this.generateNodeFromTemplate(this.template()); this.$el = this.generateNodeFromTemplate(this.template());
this.props.$root.append(this.$el); this.props.$root.append(this.$el);
@@ -64,5 +61,3 @@ class ServerTab extends Tab {
return shortcutText; return shortcutText;
} }
} }
export = ServerTab;

View File

@@ -1,14 +1,22 @@
'use strict'; import WebView from './webview';
import BaseComponent from './base';
import WebView = require('./webview'); export interface TabProps {
import BaseComponent = require('./base'); role: string;
icon?: string;
// TODO: TypeScript - Type annotate props name: string;
interface TabProps { $root: Element;
[key: string]: any; onClick: () => void;
index: number;
tabIndex: number;
onHover?: () => void;
onHoverOut?: () => void;
webview: WebView;
materialIcon?: string;
onDestroy?: () => void;
} }
class Tab extends BaseComponent { export default class Tab extends BaseComponent {
props: TabProps; props: TabProps;
webview: WebView; webview: WebView;
$el: Element; $el: Element;
@@ -25,6 +33,10 @@ class Tab extends BaseComponent {
this.$el.addEventListener('mouseout', this.props.onHoverOut); this.$el.addEventListener('mouseout', this.props.onHoverOut);
} }
showNetworkError(): void {
this.webview.forceLoad();
}
activate(): void { activate(): void {
this.$el.classList.add('active'); this.$el.classList.add('active');
this.webview.load(); this.webview.load();
@@ -36,9 +48,7 @@ class Tab extends BaseComponent {
} }
destroy(): void { destroy(): void {
this.$el.parentNode.removeChild(this.$el); this.$el.remove();
this.webview.$el.parentNode.removeChild(this.webview.$el); this.webview.$el.remove();
} }
} }
export = Tab;

View File

@@ -1,40 +1,48 @@
'use strict'; import {ipcRenderer, remote} from 'electron';
import { remote } from 'electron';
import path = require('path'); import path from 'path';
import fs = require('fs'); import fs from 'fs';
import ConfigUtil = require('../utils/config-util'); import * as ConfigUtil from '../utils/config-util';
import SystemUtil = require('../utils/system-util'); import * as SystemUtil from '../utils/system-util';
import BaseComponent = require('../components/base'); import BaseComponent from './base';
import handleExternalLink = require('../components/handle-external-link'); import handleExternalLink from './handle-external-link';
import {contextMenu} from './context-menu';
const { app, dialog } = remote; const {app, dialog} = remote;
const shouldSilentWebview = ConfigUtil.getConfigItem('silent'); const shouldSilentWebview = ConfigUtil.getConfigItem('silent');
// TODO: TypeScript - Type annotate WebViewProps.
interface WebViewProps { interface WebViewProps {
[key: string]: any; $root: Element;
index: number;
tabIndex: number;
url: string;
role: string;
name: string;
isActive: () => boolean;
switchLoading: (loading: boolean, url: string) => void;
onNetworkError: (index: number) => void;
nodeIntegration: boolean;
preload: boolean;
onTitleChange: () => void;
hasPermission?: (origin: string, permission: string) => boolean;
} }
class WebView extends BaseComponent { export default class WebView extends BaseComponent {
props: any; props: WebViewProps;
zoomFactor: number; zoomFactor: number;
badgeCount: number; badgeCount: number;
loading: boolean; loading: boolean;
customCSS: string; customCSS: string;
$webviewsContainer: DOMTokenList; $webviewsContainer: DOMTokenList;
$el: Electron.WebviewTag; $el: Electron.WebviewTag;
domReady?: Promise<void>;
// This is required because in main.js we access WebView.method as
// webview[method].
[key: string]: any;
constructor(props: WebViewProps) { constructor(props: WebViewProps) {
super(); super();
this.props = props; this.props = props;
this.zoomFactor = 1.0; this.zoomFactor = 1;
this.loading = true; this.loading = true;
this.badgeCount = 0; this.badgeCount = 0;
this.customCSS = ConfigUtil.getConfigItem('customCSS'); this.customCSS = ConfigUtil.getConfigItem('customCSS');
@@ -47,16 +55,22 @@ class WebView extends BaseComponent {
data-tab-id="${this.props.tabIndex}" data-tab-id="${this.props.tabIndex}"
src="${this.props.url}" src="${this.props.url}"
${this.props.nodeIntegration ? 'nodeIntegration' : ''} ${this.props.nodeIntegration ? 'nodeIntegration' : ''}
disablewebsecurity
${this.props.preload ? 'preload="js/preload.js"' : ''} ${this.props.preload ? 'preload="js/preload.js"' : ''}
partition="persist:webviewsession" partition="persist:webviewsession"
name="${this.props.name}" name="${this.props.name}"
webpreferences="allowRunningInsecureContent, javascript=yes"> webpreferences="
${this.props.nodeIntegration ? '' : 'contextIsolation,'}
${ConfigUtil.getConfigItem('enableSpellchecker') ? 'spellcheck,' : ''}
javascript
">
</webview>`; </webview>`;
} }
init(): void { init(): void {
this.$el = this.generateNodeFromTemplate(this.template()) as Electron.WebviewTag; this.$el = this.generateNodeFromTemplate(this.template()) as Electron.WebviewTag;
this.domReady = new Promise(resolve => {
this.$el.addEventListener('dom-ready', () => resolve(), true);
});
this.props.$root.append(this.$el); this.props.$root.append(this.$el);
this.registerListeners(); this.registerListeners();
@@ -74,7 +88,7 @@ class WebView extends BaseComponent {
} }
this.$el.addEventListener('page-title-updated', event => { this.$el.addEventListener('page-title-updated', event => {
const { title } = event; const {title} = event;
this.badgeCount = this.getBadgeCount(title); this.badgeCount = this.getBadgeCount(title);
this.props.onTitleChange(); this.props.onTitleChange();
}); });
@@ -84,6 +98,7 @@ class WebView extends BaseComponent {
if (isSettingPage) { if (isSettingPage) {
return; return;
} }
this.canGoBackButton(); this.canGoBackButton();
}); });
@@ -92,14 +107,14 @@ class WebView extends BaseComponent {
}); });
this.$el.addEventListener('page-favicon-updated', event => { this.$el.addEventListener('page-favicon-updated', event => {
const { favicons } = event; const {favicons} = event;
// This returns a string of favicons URL. If there is a PM counts in unread messages then the URL would be like // 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 // 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 && process.platform === 'darwin') {
// This api is only supported on macOS // This api is only supported on macOS
app.dock.setBadge('●'); app.dock.setBadge('●');
// bounce the dock // Bounce the dock
if (ConfigUtil.getConfigItem('dockBouncing')) { if (ConfigUtil.getConfigItem('dockBouncing')) {
app.dock.bounce(); app.dock.bounce();
} }
@@ -107,9 +122,15 @@ class WebView extends BaseComponent {
}); });
this.$el.addEventListener('dom-ready', () => { this.$el.addEventListener('dom-ready', () => {
const webContents = remote.webContents.fromId(this.$el.getWebContentsId());
webContents.addListener('context-menu', (event, menuParameters) => {
contextMenu(webContents, event, menuParameters);
});
if (this.props.role === 'server') { if (this.props.role === 'server') {
this.$el.classList.add('onload'); this.$el.classList.add('onload');
} }
this.loading = false; this.loading = false;
this.props.switchLoading(false, this.props.url); this.props.switchLoading(false, this.props.url);
this.show(); this.show();
@@ -121,11 +142,13 @@ class WebView extends BaseComponent {
}); });
this.$el.addEventListener('did-fail-load', event => { this.$el.addEventListener('did-fail-load', event => {
const { errorDescription } = event; const {errorDescription} = event;
const hasConnectivityErr = SystemUtil.connectivityERR.includes(errorDescription); const hasConnectivityErr = SystemUtil.connectivityERR.includes(errorDescription);
if (hasConnectivityErr) { if (hasConnectivityErr) {
console.error('error', errorDescription); console.error('error', errorDescription);
this.props.onNetworkError(); if (!this.props.url.includes('network.html')) {
this.props.onNetworkError(this.props.index);
}
} }
}); });
@@ -134,12 +157,6 @@ class WebView extends BaseComponent {
if (!isSettingPage) { if (!isSettingPage) {
this.props.switchLoading(true, this.props.url); this.props.switchLoading(true, this.props.url);
} }
let userAgent = SystemUtil.getUserAgent();
if (!userAgent) {
SystemUtil.setUserAgent(this.$el.getUserAgent());
userAgent = SystemUtil.getUserAgent();
}
this.$el.setUserAgent(userAgent);
}); });
this.$el.addEventListener('did-stop-loading', () => { this.$el.addEventListener('did-stop-loading', () => {
@@ -152,6 +169,10 @@ class WebView extends BaseComponent {
return messageCountInTitle ? Number(messageCountInTitle[1]) : 0; return messageCountInTitle ? Number(messageCountInTitle[1]) : 0;
} }
showNotificationSettings(): void {
ipcRenderer.sendTo(this.$el.getWebContentsId(), 'show-notification-settings');
}
show(): void { show(): void {
// Do not show WebView if another tab was selected and this tab should be in background. // Do not show WebView if another tab was selected and this tab should be in background.
if (!this.props.isActive()) { if (!this.props.isActive()) {
@@ -175,27 +196,27 @@ class WebView extends BaseComponent {
this.focus(); this.focus();
this.props.onTitleChange(); this.props.onTitleChange();
// Injecting preload css in webview to override some css rules // Injecting preload css in webview to override some css rules
this.$el.insertCSS(fs.readFileSync(path.join(__dirname, '/../../css/preload.css'), 'utf8')); (async () => this.$el.insertCSS(fs.readFileSync(path.join(__dirname, '/../../css/preload.css'), 'utf8')))();
// get customCSS again from config util to avoid warning user again // Get customCSS again from config util to avoid warning user again
this.customCSS = ConfigUtil.getConfigItem('customCSS'); this.customCSS = ConfigUtil.getConfigItem('customCSS');
if (this.customCSS) { if (this.customCSS) {
if (!fs.existsSync(this.customCSS)) { if (!fs.existsSync(this.customCSS)) {
this.customCSS = null; this.customCSS = null;
ConfigUtil.setConfigItem('customCSS', null); ConfigUtil.setConfigItem('customCSS', null);
const errMsg = 'The custom css previously set is deleted!'; const errorMessage = 'The custom css previously set is deleted!';
dialog.showErrorBox('custom css file deleted!', errMsg); dialog.showErrorBox('custom css file deleted!', errorMessage);
return; return;
} }
this.$el.insertCSS(fs.readFileSync(path.resolve(__dirname, this.customCSS), 'utf8')); (async () => this.$el.insertCSS(fs.readFileSync(path.resolve(__dirname, this.customCSS), 'utf8')))();
} }
} }
focus(): void { focus(): void {
// focus Webview and it's contents when Window regain focus. // Focus Webview and it's contents when Window regain focus.
const webContents = this.$el.getWebContents(); const webContents = remote.webContents.fromId(this.$el.getWebContentsId());
// HACK: webContents.isFocused() seems to be true even without the element // HACK: webContents.isFocused() seems to be true even without the element
// being in focus. So, we check against `document.activeElement`. // being in focus. So, we check against `document.activeElement`.
if (webContents && this.$el !== document.activeElement) { if (webContents && this.$el !== document.activeElement) {
@@ -232,16 +253,16 @@ class WebView extends BaseComponent {
} }
zoomActualSize(): void { zoomActualSize(): void {
this.zoomFactor = 1.0; this.zoomFactor = 1;
this.$el.setZoomFactor(this.zoomFactor); this.$el.setZoomFactor(this.zoomFactor);
} }
logOut(): void { logOut(): void {
this.$el.executeJavaScript('logout()'); ipcRenderer.sendTo(this.$el.getWebContentsId(), 'logout');
} }
showShortcut(): void { showShortcut(): void {
this.$el.executeJavaScript('shortcut()'); ipcRenderer.sendTo(this.$el.getWebContentsId(), 'shortcut');
} }
openDevTools(): void { openDevTools(): void {
@@ -279,9 +300,12 @@ class WebView extends BaseComponent {
this.$el.reload(); this.$el.reload();
} }
send(channel: string, ...param: any[]): void { forceLoad(): void {
this.$el.send(channel, ...param); this.init();
}
async send(channel: string, ...parameters: unknown[]): Promise<void> {
await this.domReady;
await this.$el.send(channel, ...parameters);
} }
} }
export = WebView;

View File

@@ -1,25 +1,58 @@
import { ipcRenderer } from 'electron'; import {ipcRenderer} from 'electron';
import events = require('events'); import {EventEmitter} from 'events';
import {ClipboardDecrypterImpl} from './clipboard-decrypter';
import {NotificationData, newNotification} from './notification';
type ListenerType = ((...args: any[]) => void); type ListenerType = ((...args: any[]) => void);
// we have and will have some non camelcase stuff class ElectronBridgeImpl extends EventEmitter implements ElectronBridge {
// while working with zulip so just turning the rule off send_notification_reply_message_supported: boolean;
// for the whole file. idle_on_system: boolean;
/* eslint-disable @typescript-eslint/camelcase */ last_active_on_system: number;
class ElectronBridge extends events {
send_notification_reply_message_supported = false; constructor() {
send_event(eventName: string | symbol, ...args: any[]): void { super();
this.emit(eventName, ...args); this.send_notification_reply_message_supported = false;
// Indicates if the user is idle or not
this.idle_on_system = false;
// Indicates the time at which user was last active
this.last_active_on_system = Date.now();
} }
on_event(eventName: string, listener: ListenerType): void { send_event = (eventName: string | symbol, ...args: unknown[]): void => {
this.emit(eventName, ...args);
};
on_event = (eventName: string, listener: ListenerType): void => {
this.on(eventName, listener); this.on(eventName, listener);
} };
new_notification = (
title: string,
options: NotificationOptions | undefined,
dispatch: (type: string, eventInit: EventInit) => boolean
): NotificationData =>
newNotification(title, options, dispatch);
get_idle_on_system = (): boolean => this.idle_on_system;
get_last_active_on_system = (): number => this.last_active_on_system;
get_send_notification_reply_message_supported = (): boolean =>
this.send_notification_reply_message_supported;
set_send_notification_reply_message_supported = (value: boolean): void => {
this.send_notification_reply_message_supported = value;
};
decrypt_clipboard = (version: number): ClipboardDecrypterImpl =>
new ClipboardDecrypterImpl(version);
} }
const electron_bridge = new ElectronBridge(); const electron_bridge = new ElectronBridgeImpl();
electron_bridge.on('total_unread_count', (...args) => { electron_bridge.on('total_unread_count', (...args) => {
ipcRenderer.send('unread-count', ...args); ipcRenderer.send('unread-count', ...args);
@@ -30,17 +63,19 @@ electron_bridge.on('realm_name', realmName => {
ipcRenderer.send('realm-name-changed', serverURL, realmName); ipcRenderer.send('realm-name-changed', serverURL, realmName);
}); });
electron_bridge.on('realm_icon_url', iconURL => { electron_bridge.on('realm_icon_url', (iconURL: unknown) => {
if (typeof iconURL !== 'string') {
throw new TypeError('Expected string for iconURL');
}
const serverURL = location.origin; const serverURL = location.origin;
iconURL = iconURL.includes('http') ? iconURL : `${serverURL}${iconURL}`; iconURL = iconURL.includes('http') ? iconURL : `${serverURL}${iconURL}`;
ipcRenderer.send('realm-icon-changed', serverURL, iconURL); ipcRenderer.send('realm-icon-changed', serverURL, iconURL);
}); });
// this follows node's idiomatic implementation of event // This follows node's idiomatic implementation of event
// emitters to make event handling more simpler instead of using // 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 ElectronBrigde.send_event
// which is alias of .emit and on this side we can handle the data by adding // which is alias of .emit and on this side we can handle the data by adding
// a listener for the event. // a listener for the event.
export = electron_bridge; export default electron_bridge;
/* eslint-enable @typescript-eslint/camelcase */

View File

@@ -1,56 +1,36 @@
import { remote } from 'electron'; import {remote} from 'electron';
import SendFeedback from '@electron-elements/send-feedback'; import SendFeedback from '@electron-elements/send-feedback';
import path = require('path'); import path from 'path';
import fs = require('fs'); import fs from 'fs';
const { app } = remote; const {app} = remote;
interface SendFeedback extends HTMLElement {
[key: string]: any;
}
type SendFeedbackType = SendFeedback;
// make the button color match zulip app's theme
SendFeedback.customStyles = `
button:hover, button:focus {
border-color: #4EBFAC;
color: #4EBFAC;
}
button:active {
background-color: #f1f1f1;
color: #4EBFAC;
}
button {
background-color: #4EBFAC;
border-color: #4EBFAC;
}
`;
customElements.define('send-feedback', SendFeedback); customElements.define('send-feedback', SendFeedback);
export const sendFeedback: SendFeedbackType = document.querySelector('send-feedback'); export const sendFeedback: SendFeedback = document.querySelector('send-feedback');
export const feedbackHolder = sendFeedback.parentElement; export const feedbackHolder = sendFeedback.parentElement;
// customize the fields of custom elements // Make the button color match zulip app's theme
sendFeedback.customStylesheet = 'css/feedback.css';
// Customize the fields of custom elements
sendFeedback.title = 'Report Issue'; sendFeedback.title = 'Report Issue';
sendFeedback.titleLabel = 'Issue title:'; sendFeedback.titleLabel = 'Issue title:';
sendFeedback.titlePlaceholder = 'Enter issue title'; sendFeedback.titlePlaceholder = 'Enter issue title';
sendFeedback.textareaLabel = 'Describe the issue:'; sendFeedback.textareaLabel = 'Describe the issue:';
sendFeedback.textareaPlaceholder = 'Succinctly describe your issue and steps to reproduce it...'; sendFeedback.textareaPlaceholder = 'Succinctly describe your issue and steps to reproduce it...';
sendFeedback.buttonLabel = 'Report Issue'; sendFeedback.buttonLabel = 'Report Issue';
sendFeedback.loaderSuccessText = ''; sendFeedback.loaderSuccessText = '';
sendFeedback.useReporter('emailReporter', { sendFeedback.useReporter('emailReporter', {
email: 'akash@zulipchat.com' email: 'support@zulip.com'
}); });
feedbackHolder.addEventListener('click', (e: Event) => { feedbackHolder.addEventListener('click', (event: Event) => {
// only remove the class if the grey out faded // Only remove the class if the grey out faded
// part is clicked and not the feedback element itself // part is clicked and not the feedback element itself
if (e.target === e.currentTarget) { if (event.target === event.currentTarget) {
feedbackHolder.classList.remove('show'); feedbackHolder.classList.remove('show');
} }
}); });
@@ -61,6 +41,10 @@ sendFeedback.addEventListener('feedback-submitted', () => {
}, 1000); }, 1000);
}); });
sendFeedback.addEventListener('feedback-cancelled', () => {
feedbackHolder.classList.remove('show');
});
const dataDir = app.getPath('userData'); const dataDir = app.getPath('userData');
const logsDir = path.join(dataDir, '/Logs'); const logsDir = path.join(dataDir, '/Logs');
sendFeedback.logs.push(...fs.readdirSync(logsDir).map(file => path.join(logsDir, file))); sendFeedback.logs.push(...fs.readdirSync(logsDir).map(file => path.join(logsDir, file)));

133
app/renderer/js/injected.ts Normal file
View File

@@ -0,0 +1,133 @@
'use strict';
interface CompatElectronBridge extends ElectronBridge {
readonly idle_on_system: boolean;
readonly last_active_on_system: number;
send_notification_reply_message_supported: boolean;
}
(() => {
const zulipWindow = window as typeof window & {
electron_bridge: CompatElectronBridge;
narrow: {
by_subject?: (target_id: number, opts: {trigger?: string}) => void;
by_topic?: (target_id: number, opts: {trigger?: string}) => void;
};
page_params?: {
default_language?: string;
};
raw_electron_bridge: ElectronBridge;
};
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);
}
};
zulipWindow.electron_bridge = electron_bridge;
(async () => {
if (document.readyState === 'loading') {
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', () => {
resolve();
});
});
}
const {page_params} = zulipWindow;
if (page_params) {
electron_bridge.send_event('zulip-loaded');
}
})();
electron_bridge.on_event('narrow-by-topic', (id: number) => {
const {narrow} = zulipWindow;
const narrowByTopic = narrow.by_topic || narrow.by_subject;
narrowByTopic(id, {trigger: 'notification'});
});
function attributeListener<T extends EventTarget>(type: string): PropertyDescriptor {
const symbol = Symbol('on' + type);
function listener(this: T, ev: Event): void {
if ((this as any)[symbol].call(this, ev) === false) {
ev.preventDefault();
}
}
return {
configurable: true,
enumerable: true,
get(this: T) {
return (this as any)[symbol];
},
set(this: T, value: unknown) {
if (typeof value === 'function') {
if (!(symbol in this)) {
this.addEventListener(type, listener);
}
(this as any)[symbol] = value;
} else if (symbol in this) {
this.removeEventListener(type, listener);
delete (this as any)[symbol];
}
}
};
}
const NativeNotification = Notification;
class InjectedNotification extends EventTarget {
constructor(title: string, options?: NotificationOptions) {
super();
Object.assign(
this,
electron_bridge.new_notification(title, options, (type: string, eventInit: EventInit) =>
this.dispatchEvent(new Event(type, eventInit))
)
);
}
static get maxActions(): number {
return NativeNotification.maxActions;
}
static get permission(): NotificationPermission {
return NativeNotification.permission;
}
static async requestPermission(callback?: NotificationPermissionCallback): Promise<NotificationPermission> {
if (callback) {
callback(await Promise.resolve(NativeNotification.permission));
}
return NativeNotification.permission;
}
}
Object.defineProperties(InjectedNotification.prototype, {
onclick: attributeListener('click'),
onclose: attributeListener('close'),
onerror: attributeListener('error'),
onshow: attributeListener('show')
});
window.Notification = InjectedNotification as any;
})();

View File

@@ -1,32 +1,27 @@
'use strict'; import {ipcRenderer, remote, clipboard} from 'electron';
import {feedbackHolder} from './feedback';
import { ipcRenderer, remote, clipboard, shell } from 'electron'; import path from 'path';
import { feedbackHolder } from './feedback'; import escape from 'escape-html';
import isDev from 'electron-is-dev';
import path = require('path'); const {session, app, Menu, dialog} = remote;
import escape = require('escape-html');
import isDev = require('electron-is-dev');
const { session, app, Menu, dialog } = remote;
// eslint-disable-next-line import/no-unassigned-import // eslint-disable-next-line import/no-unassigned-import
require('./tray'); import './tray';
import DomainUtil = require('./utils/domain-util'); import * as DomainUtil from './utils/domain-util';
import WebView = require('./components/webview'); import WebView from './components/webview';
import ServerTab = require('./components/server-tab'); import ServerTab from './components/server-tab';
import FunctionalTab = require('./components/functional-tab'); import FunctionalTab from './components/functional-tab';
import ConfigUtil = require('./utils/config-util'); import * as ConfigUtil from './utils/config-util';
import DNDUtil = require('./utils/dnd-util'); import * as DNDUtil from './utils/dnd-util';
import ReconnectUtil = require('./utils/reconnect-util'); import ReconnectUtil from './utils/reconnect-util';
import Logger = require('./utils/logger-util'); import Logger from './utils/logger-util';
import CommonUtil = require('./utils/common-util'); import * as CommonUtil from './utils/common-util';
import EnterpriseUtil = require('./utils/enterprise-util'); import * as EnterpriseUtil from './utils/enterprise-util';
import Messages = require('./../../resources/messages'); import * as LinkUtil from './utils/link-util';
import * as Messages from '../../resources/messages';
const logger = new Logger({ import type {DNDSettings} from './utils/dnd-util';
file: 'errors.log',
timestamp: true
});
interface FunctionalTabProps { interface FunctionalTabProps {
name: string; name: string;
@@ -34,11 +29,7 @@ interface FunctionalTabProps {
url: string; url: string;
} }
interface AnyObject { interface SettingsOptions extends DNDSettings {
[key: string]: any;
}
interface SettingsOptions {
autoHideMenubar: boolean; autoHideMenubar: boolean;
trayIcon: boolean; trayIcon: boolean;
useManualProxy: boolean; useManualProxy: boolean;
@@ -48,28 +39,27 @@ interface SettingsOptions {
startAtLogin: boolean; startAtLogin: boolean;
startMinimized: boolean; startMinimized: boolean;
enableSpellchecker: boolean; enableSpellchecker: boolean;
showNotification: boolean;
autoUpdate: boolean; autoUpdate: boolean;
betaUpdate: boolean; betaUpdate: boolean;
errorReporting: boolean; errorReporting: boolean;
customCSS: boolean; customCSS: boolean;
silent: boolean;
lastActiveTab: number; lastActiveTab: number;
dnd: boolean; dnd: boolean;
dndPreviousSettings: { dndPreviousSettings: DNDSettings;
showNotification: boolean;
silent: boolean;
flashTaskbarOnMessage?: boolean;
};
downloadsPath: string; downloadsPath: string;
showDownloadFolder: boolean; quitOnClose: boolean;
flashTaskbarOnMessage?: boolean; promptDownload: boolean;
dockBouncing?: boolean; dockBouncing?: boolean;
loading?: AnyObject; spellcheckerLanguages?: string[];
} }
const logger = new Logger({
file: 'errors.log',
timestamp: true
});
const rendererDirectory = path.resolve(__dirname, '..'); const rendererDirectory = path.resolve(__dirname, '..');
type ServerOrFunctionalTab = ServerTab | FunctionalTab; export type ServerOrFunctionalTab = ServerTab | FunctionalTab;
class ServerManagerView { class ServerManagerView {
$addServerButton: HTMLButtonElement; $addServerButton: HTMLButtonElement;
@@ -90,10 +80,10 @@ class ServerManagerView {
$sidebar: Element; $sidebar: Element;
$fullscreenPopup: Element; $fullscreenPopup: Element;
$fullscreenEscapeKey: string; $fullscreenEscapeKey: string;
loading: AnyObject; loading: Set<string>;
activeTabIndex: number; activeTabIndex: number;
tabs: ServerOrFunctionalTab[]; tabs: ServerOrFunctionalTab[];
functionalTabs: AnyObject; functionalTabs: Map<string, number>;
tabIndex: number; tabIndex: number;
presetOrgs: string[]; presetOrgs: string[];
constructor() { constructor() {
@@ -128,29 +118,30 @@ class ServerManagerView {
this.$fullscreenEscapeKey = process.platform === 'darwin' ? '^⌘F' : 'F11'; this.$fullscreenEscapeKey = process.platform === 'darwin' ? '^⌘F' : 'F11';
this.$fullscreenPopup.innerHTML = `Press ${this.$fullscreenEscapeKey} to exit full screen`; this.$fullscreenPopup.innerHTML = `Press ${this.$fullscreenEscapeKey} to exit full screen`;
this.loading = {}; this.loading = new Set();
this.activeTabIndex = -1; this.activeTabIndex = -1;
this.tabs = []; this.tabs = [];
this.presetOrgs = []; this.presetOrgs = [];
this.functionalTabs = {}; this.functionalTabs = new Map();
this.tabIndex = 0; this.tabIndex = 0;
} }
init(): void { async init(): Promise<void> {
this.loadProxy().then(() => { await this.loadProxy();
this.initDefaultSettings(); this.initDefaultSettings();
this.initSidebar(); this.initSidebar();
if (EnterpriseUtil.configFile) { this.removeUAfromDisk();
this.initPresetOrgs(); if (EnterpriseUtil.hasConfigFile()) {
} await this.initPresetOrgs();
this.initTabs();
this.initActions();
this.registerIpcs();
});
} }
loadProxy(): Promise<boolean> { await this.initTabs();
return new Promise(resolve => { this.initActions();
this.registerIpcs();
ipcRenderer.send('set-spellcheck-langs');
}
async loadProxy(): Promise<void> {
// To change proxyEnable to useManualProxy in older versions // To change proxyEnable to useManualProxy in older versions
const proxyEnabledOld = ConfigUtil.isConfigItemExists('useProxy'); const proxyEnabledOld = ConfigUtil.isConfigItemExists('useProxy');
if (proxyEnabledOld) { if (proxyEnabledOld) {
@@ -158,25 +149,25 @@ class ServerManagerView {
if (proxyEnableOldState) { if (proxyEnableOldState) {
ConfigUtil.setConfigItem('useManualProxy', true); ConfigUtil.setConfigItem('useManualProxy', true);
} }
ConfigUtil.removeConfigItem('useProxy'); ConfigUtil.removeConfigItem('useProxy');
} }
const proxyEnabled = ConfigUtil.getConfigItem('useManualProxy') || ConfigUtil.getConfigItem('useSystemProxy'); const proxyEnabled = ConfigUtil.getConfigItem('useManualProxy') || ConfigUtil.getConfigItem('useSystemProxy');
if (proxyEnabled) { if (proxyEnabled) {
session.fromPartition('persist:webviewsession').setProxy({ await session.fromPartition('persist:webviewsession').setProxy({
pacScript: ConfigUtil.getConfigItem('proxyPAC', ''), pacScript: ConfigUtil.getConfigItem('proxyPAC', ''),
proxyRules: ConfigUtil.getConfigItem('proxyRules', ''), proxyRules: ConfigUtil.getConfigItem('proxyRules', ''),
proxyBypassRules: ConfigUtil.getConfigItem('proxyBypass', '') proxyBypassRules: ConfigUtil.getConfigItem('proxyBypass', '')
}, resolve); });
} else { } else {
session.fromPartition('persist:webviewsession').setProxy({ await session.fromPartition('persist:webviewsession').setProxy({
pacScript: '', pacScript: '',
proxyRules: '', proxyRules: '',
proxyBypassRules: '' proxyBypassRules: ''
}, resolve);
}
}); });
} }
}
// Settings are initialized only when user clicks on General/Server/Network section settings // Settings are initialized only when user clicks on General/Server/Network section settings
// In case, user doesn't visit these section, those values set to be null automatically // In case, user doesn't visit these section, those values set to be null automatically
@@ -190,7 +181,7 @@ class ServerManagerView {
useSystemProxy: false, useSystemProxy: false,
showSidebar: true, showSidebar: true,
badgeOption: true, badgeOption: true,
startAtLogin: true, startAtLogin: false,
startMinimized: false, startMinimized: false,
enableSpellchecker: true, enableSpellchecker: true,
showNotification: true, showNotification: true,
@@ -206,7 +197,8 @@ class ServerManagerView {
silent: false silent: false
}, },
downloadsPath: `${app.getPath('downloads')}`, downloadsPath: `${app.getPath('downloads')}`,
showDownloadFolder: false quitOnClose: false,
promptDownload: false
}; };
// Platform specific settings // Platform specific settings
@@ -224,15 +216,15 @@ class ServerManagerView {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
settingOptions.autoHideMenubar = false; settingOptions.autoHideMenubar = false;
settingOptions.spellcheckerLanguages = ['en-US'];
} }
for (const i in settingOptions) { for (const [setting, value] of Object.entries(settingOptions)) {
const setting = i as keyof SettingsOptions; // Give preference to defaults defined in global_config.json
// give preference to defaults defined in global_config.json
if (EnterpriseUtil.configItemExists(setting)) { if (EnterpriseUtil.configItemExists(setting)) {
ConfigUtil.setConfigItem(setting, EnterpriseUtil.getConfigItem(setting), true); ConfigUtil.setConfigItem(setting, EnterpriseUtil.getConfigItem(setting), true);
} else if (ConfigUtil.getConfigItem(setting) === null) { } else if (ConfigUtil.getConfigItem(setting) === null) {
ConfigUtil.setConfigItem(setting, settingOptions[setting]); ConfigUtil.setConfigItem(setting, value);
} }
} }
} }
@@ -242,97 +234,122 @@ class ServerManagerView {
this.toggleSidebar(showSidebar); this.toggleSidebar(showSidebar);
} }
async queueDomain(domain: any): Promise<boolean> { // Remove the stale UA string from the disk if the app is not freshly
// allows us to start adding multiple domains to the app simultaneously // installed. This should be removed in a further release.
removeUAfromDisk(): void {
ConfigUtil.removeConfigItem('userAgent');
}
async queueDomain(domain: string): Promise<boolean> {
// Allows us to start adding multiple domains to the app simultaneously
// promise of addition resolves in both cases, but we consider it rejected // promise of addition resolves in both cases, but we consider it rejected
// if the resolved value is false // if the resolved value is false
try { try {
const serverConf = await DomainUtil.checkDomain(domain); const serverConf = await DomainUtil.checkDomain(domain);
await DomainUtil.addDomain(serverConf); await DomainUtil.addDomain(serverConf);
return true; return true;
} catch (err) { } catch (error) {
logger.error(err); logger.error(error);
logger.error('Could not add ' + domain + '. Please contact your system administrator.'); logger.error(`Could not add ${domain}. Please contact your system administrator.`);
return false; return false;
} }
} }
async initPresetOrgs(): Promise<void> { async initPresetOrgs(): Promise<void> {
// read preset organizations from global_config.json and queues them // Read preset organizations from global_config.json and queues them
// for addition to the app's domains // for addition to the app's domains
const preAddedDomains = DomainUtil.getDomains(); const preAddedDomains = DomainUtil.getDomains();
this.presetOrgs = EnterpriseUtil.getConfigItem('presetOrganizations', []); this.presetOrgs = EnterpriseUtil.getConfigItem('presetOrganizations', []);
// set to true if at least one new domain is added // Set to true if at least one new domain is added
const domainPromises = []; const domainPromises = [];
for (const url of this.presetOrgs) { for (const url of this.presetOrgs) {
if (DomainUtil.duplicateDomain(url)) { if (DomainUtil.duplicateDomain(url)) {
continue; continue;
} }
domainPromises.push(this.queueDomain(url)); domainPromises.push(this.queueDomain(url));
} }
const domainsAdded = await Promise.all(domainPromises); const domainsAdded = await Promise.all(domainPromises);
if (domainsAdded.includes(true)) { if (domainsAdded.includes(true)) {
// at least one domain was resolved // At least one domain was resolved
if (preAddedDomains.length > 0) { if (preAddedDomains.length > 0) {
// user already has servers added // User already has servers added
// ask them before reloading the app // ask them before reloading the app
dialog.showMessageBox({ const {response} = await dialog.showMessageBox({
type: 'question', type: 'question',
buttons: ['Yes', 'Later'], buttons: ['Yes', 'Later'],
defaultId: 0, defaultId: 0,
message: 'New server' + (domainsAdded.length > 1 ? 's' : '') + ' added. Reload app now?' message: 'New server' + (domainsAdded.length > 1 ? 's' : '') + ' added. Reload app now?'
}, response => { });
if (response === 0) { if (response === 0) {
ipcRenderer.send('reload-full-app'); ipcRenderer.send('reload-full-app');
} }
});
} else { } else {
ipcRenderer.send('reload-full-app'); ipcRenderer.send('reload-full-app');
} }
} else if (domainsAdded.length > 0) { } else if (domainsAdded.length > 0) {
// find all orgs that failed // Find all orgs that failed
const failedDomains: string[] = []; const failedDomains: string[] = [];
for (const org of this.presetOrgs) { for (const org of this.presetOrgs) {
if (DomainUtil.duplicateDomain(org)) { if (DomainUtil.duplicateDomain(org)) {
continue; continue;
} }
failedDomains.push(org); failedDomains.push(org);
} }
const { title, content } = Messages.enterpriseOrgError(domainsAdded.length, failedDomains);
const {title, content} = Messages.enterpriseOrgError(domainsAdded.length, failedDomains);
dialog.showErrorBox(title, content); dialog.showErrorBox(title, content);
if (DomainUtil.getDomains().length === 0) { if (DomainUtil.getDomains().length === 0) {
// no orgs present, stop showing loading gif // No orgs present, stop showing loading gif
this.openSettings('AddServer'); await this.openSettings('AddServer');
} }
} }
} }
initTabs(): void { async initTabs(): Promise<void> {
const servers = DomainUtil.getDomains(); const servers = DomainUtil.getDomains();
if (servers.length > 0) { if (servers.length > 0) {
for (let i = 0; i < servers.length; i++) { for (const [i, server] of servers.entries()) {
this.initServer(servers[i], i); this.initServer(server, i);
DomainUtil.updateSavedServer(servers[i].url, i);
this.activateTab(i);
} }
// Open last active tab // Open last active tab
this.activateTab(ConfigUtil.getConfigItem('lastActiveTab')); let lastActiveTab = ConfigUtil.getConfigItem('lastActiveTab');
if (lastActiveTab >= servers.length) {
lastActiveTab = 0;
}
// `checkDomain()` and `webview.load()` for lastActiveTab before the others
await DomainUtil.updateSavedServer(servers[lastActiveTab].url, lastActiveTab);
this.activateTab(lastActiveTab);
await Promise.all(servers.map(async (server, i) => {
// After the lastActiveTab is activated, we load the others in the background
// without activating them, to prevent flashing of server icons
if (i === lastActiveTab) {
return;
}
await DomainUtil.updateSavedServer(server.url, i);
this.tabs[i].webview.load();
}));
// Remove focus from the settings icon at sidebar bottom // Remove focus from the settings icon at sidebar bottom
this.$settingsButton.classList.remove('active'); this.$settingsButton.classList.remove('active');
} else if (this.presetOrgs.length === 0) { } else if (this.presetOrgs.length === 0) {
// not attempting to add organisations in the background // Not attempting to add organisations in the background
this.openSettings('AddServer'); await this.openSettings('AddServer');
} else { } else {
this.showLoading(true); this.showLoading(true);
} }
} }
initServer(server: any, index: number): void { initServer(server: DomainUtil.ServerConf, index: number): void {
const tabIndex = this.getTabIndex(); const tabIndex = this.getTabIndex();
this.tabs.push(new ServerTab({ this.tabs.push(new ServerTab({
role: 'server', role: 'server',
icon: server.icon, icon: server.icon,
name: server.alias, name: CommonUtil.decodeString(server.alias),
$root: this.$tabsContainer, $root: this.$tabsContainer,
onClick: this.activateLastTab.bind(this, index), onClick: this.activateLastTab.bind(this, index),
index, index,
@@ -346,24 +363,25 @@ class ServerManagerView {
url: server.url, url: server.url,
role: 'server', role: 'server',
name: CommonUtil.decodeString(server.alias), name: CommonUtil.decodeString(server.alias),
isActive: () => { hasPermission: (origin: string, permission: string) =>
return index === this.activeTabIndex; origin === server.url && permission === 'notifications',
}, isActive: () => index === this.activeTabIndex,
switchLoading: (loading: boolean, url: string) => { switchLoading: (loading: boolean, url: string) => {
if (!loading && this.loading[url]) { if (loading) {
this.loading[url] = false; this.loading.add(url);
} else if (loading && !this.loading[url]) { } else {
this.loading[url] = true; this.loading.delete(url);
} }
this.showLoading(this.loading[this.tabs[this.activeTabIndex].webview.props.url]);
this.showLoading(this.loading.has(this.tabs[this.activeTabIndex].webview.props.url));
}, },
onNetworkError: this.openNetworkTroubleshooting.bind(this), onNetworkError: (index: number) => this.openNetworkTroubleshooting(index),
onTitleChange: this.updateBadge.bind(this), onTitleChange: this.updateBadge.bind(this),
nodeIntegration: false, nodeIntegration: false,
preload: true preload: true
}) })
})); }));
this.loading[server.url] = true; this.loading.add(server.url);
} }
initActions(): void { initActions(): void {
@@ -379,6 +397,7 @@ class ServerManagerView {
if ($serverImg.src.includes('img/icon.png')) { if ($serverImg.src.includes('img/icon.png')) {
this.displayInitialCharLogo($serverImg, index); this.displayInitialCharLogo($serverImg, index);
} }
$serverImg.addEventListener('error', () => { $serverImg.addEventListener('error', () => {
this.displayInitialCharLogo($serverImg, index); this.displayInitialCharLogo($serverImg, index);
}); });
@@ -393,11 +412,11 @@ class ServerManagerView {
this.$reloadButton.addEventListener('click', () => { this.$reloadButton.addEventListener('click', () => {
this.tabs[this.activeTabIndex].webview.reload(); this.tabs[this.activeTabIndex].webview.reload();
}); });
this.$addServerButton.addEventListener('click', () => { this.$addServerButton.addEventListener('click', async () => {
this.openSettings('AddServer'); await this.openSettings('AddServer');
}); });
this.$settingsButton.addEventListener('click', () => { this.$settingsButton.addEventListener('click', async () => {
this.openSettings('General'); await this.openSettings('General');
}); });
this.$backButton.addEventListener('click', () => { this.$backButton.addEventListener('click', () => {
this.tabs[this.activeTabIndex].webview.back(); this.tabs[this.activeTabIndex].webview.back();
@@ -427,10 +446,9 @@ class ServerManagerView {
} }
displayInitialCharLogo($img: HTMLImageElement, index: number): void { displayInitialCharLogo($img: HTMLImageElement, index: number): void {
/* // The index parameter is needed because webview[data-tab-id] can
index parameter needed because webview[data-tab-id] can increment // increment beyond the size of the sidebar org array and throw an
beyond size of sidebar org array and throw error // error
*/
const $altIcon = document.createElement('div'); const $altIcon = document.createElement('div');
const $parent = $img.parentElement; const $parent = $img.parentElement;
@@ -448,7 +466,7 @@ class ServerManagerView {
$altIcon.classList.add('server-icon'); $altIcon.classList.add('server-icon');
$altIcon.classList.add('alt-icon'); $altIcon.classList.add('alt-icon');
$parent.removeChild($img); $img.remove();
$parent.append($altIcon); $parent.append($altIcon);
this.addContextMenu($altIcon as HTMLImageElement, index); this.addContextMenu($altIcon as HTMLImageElement, index);
@@ -462,8 +480,8 @@ class ServerManagerView {
// as that of its parent element. // as that of its parent element.
// This needs to handled only for the add server tooltip and not others. // This needs to handled only for the add server tooltip and not others.
if (addServer) { if (addServer) {
const { top } = SidebarButton.getBoundingClientRect(); const {top} = SidebarButton.getBoundingClientRect();
SidebarTooltip.style.top = top + 'px'; SidebarTooltip.style.top = `${top}px`;
} }
}); });
SidebarButton.addEventListener('mouseout', () => { SidebarButton.addEventListener('mouseout', () => {
@@ -472,14 +490,14 @@ class ServerManagerView {
} }
onHover(index: number): void { onHover(index: number): void {
// this.$serverIconTooltip[index].innerHTML already has realm name, so we are just // `this.$serverIconTooltip[index].innerHTML` already has realm name, so we are just
// removing the style. // removing the style.
this.$serverIconTooltip[index].removeAttribute('style'); this.$serverIconTooltip[index].removeAttribute('style');
// To handle position of servers' tooltip due to scrolling of list of organizations // To handle position of servers' tooltip due to scrolling of list of organizations
// This could not be handled using CSS, hence the top of the tooltip is made same // This could not be handled using CSS, hence the top of the tooltip is made same
// as that of its parent element. // as that of its parent element.
const { top } = this.$serverIconTooltip[index].parentElement.getBoundingClientRect(); const {top} = this.$serverIconTooltip[index].parentElement.getBoundingClientRect();
this.$serverIconTooltip[index].style.top = top + 'px'; this.$serverIconTooltip[index].style.top = `${top}px`;
} }
onHoverOut(index: number): void { onHoverOut(index: number): void {
@@ -487,12 +505,12 @@ class ServerManagerView {
} }
openFunctionalTab(tabProps: FunctionalTabProps): void { openFunctionalTab(tabProps: FunctionalTabProps): void {
if (this.functionalTabs[tabProps.name] !== undefined) { if (this.functionalTabs.has(tabProps.name)) {
this.activateTab(this.functionalTabs[tabProps.name]); this.activateTab(this.functionalTabs.get(tabProps.name));
return; return;
} }
this.functionalTabs[tabProps.name] = this.tabs.length; this.functionalTabs.set(tabProps.name, this.tabs.length);
const tabIndex = this.getTabIndex(); const tabIndex = this.getTabIndex();
@@ -501,29 +519,28 @@ class ServerManagerView {
materialIcon: tabProps.materialIcon, materialIcon: tabProps.materialIcon,
name: tabProps.name, name: tabProps.name,
$root: this.$tabsContainer, $root: this.$tabsContainer,
index: this.functionalTabs[tabProps.name], index: this.functionalTabs.get(tabProps.name),
tabIndex, tabIndex,
onClick: this.activateTab.bind(this, this.functionalTabs[tabProps.name]), onClick: this.activateTab.bind(this, this.functionalTabs.get(tabProps.name)),
onDestroy: this.destroyTab.bind(this, tabProps.name, this.functionalTabs[tabProps.name]), onDestroy: this.destroyTab.bind(this, tabProps.name, this.functionalTabs.get(tabProps.name)),
webview: new WebView({ webview: new WebView({
$root: this.$webviewsContainer, $root: this.$webviewsContainer,
index: this.functionalTabs[tabProps.name], index: this.functionalTabs.get(tabProps.name),
tabIndex, tabIndex,
url: tabProps.url, url: tabProps.url,
role: 'function', role: 'function',
name: tabProps.name, name: tabProps.name,
isActive: () => { isActive: () => this.functionalTabs.get(tabProps.name) === this.activeTabIndex,
return this.functionalTabs[tabProps.name] === this.activeTabIndex; switchLoading: (loading: boolean, url: string) => {
}, if (loading) {
switchLoading: (loading: AnyObject, url: string) => { this.loading.add(url);
if (!loading && this.loading[url]) { } else {
this.loading[url] = false; this.loading.delete(url);
} else if (loading && !this.loading[url]) {
this.loading[url] = true;
} }
this.showLoading(this.loading[this.tabs[this.activeTabIndex].webview.props.url]);
this.showLoading(this.loading.has(this.tabs[this.activeTabIndex].webview.props.url));
}, },
onNetworkError: this.openNetworkTroubleshooting.bind(this), onNetworkError: (index: number) => this.openNetworkTroubleshooting(index),
onTitleChange: this.updateBadge.bind(this), onTitleChange: this.updateBadge.bind(this),
nodeIntegration: true, nodeIntegration: true,
preload: false preload: false
@@ -534,17 +551,17 @@ class ServerManagerView {
// closed when the functional tab DOM is ready, handled in webview.js // closed when the functional tab DOM is ready, handled in webview.js
this.$webviewsContainer.classList.remove('loaded'); this.$webviewsContainer.classList.remove('loaded');
this.activateTab(this.functionalTabs[tabProps.name]); this.activateTab(this.functionalTabs.get(tabProps.name));
} }
openSettings(nav = 'General'): void { async openSettings(nav = 'General'): Promise<void> {
this.openFunctionalTab({ this.openFunctionalTab({
name: 'Settings', name: 'Settings',
materialIcon: 'settings', materialIcon: 'settings',
url: `file://${rendererDirectory}/preference.html#${nav}` url: `file://${rendererDirectory}/preference.html#${nav}`
}); });
this.$settingsButton.classList.add('active'); this.$settingsButton.classList.add('active');
this.tabs[this.functionalTabs.Settings].webview.send('switch-settings-nav', nav); await this.tabs[this.functionalTabs.get('Settings')].webview.send('switch-settings-nav', nav);
} }
openAbout(): void { openAbout(): void {
@@ -555,12 +572,11 @@ class ServerManagerView {
}); });
} }
openNetworkTroubleshooting(): void { openNetworkTroubleshooting(index: number): void {
this.openFunctionalTab({ const reconnectUtil = new ReconnectUtil(this.tabs[index].webview);
name: 'Network Troubleshooting', reconnectUtil.pollInternetAndReload();
materialIcon: 'network_check', this.tabs[index].webview.props.url = `file://${rendererDirectory}/network.html`;
url: `file://${rendererDirectory}/network.html` this.tabs[index].showNetworkError();
});
} }
activateLastTab(index: number): void { activateLastTab(index: number): void {
@@ -570,7 +586,7 @@ class ServerManagerView {
ipcRenderer.send('save-last-tab', index); ipcRenderer.send('save-last-tab', index);
} }
// returns this.tabs in an way that does // Returns this.tabs in an way that does
// not crash app when this.tabs is passed into // not crash app when this.tabs is passed into
// ipcRenderer. Something about webview, and props.webview // ipcRenderer. Something about webview, and props.webview
// properties in ServerTab causes the app to crash. // properties in ServerTab causes the app to crash.
@@ -580,7 +596,7 @@ class ServerManagerView {
const proto = Object.create(Object.getPrototypeOf(tab)); const proto = Object.create(Object.getPrototypeOf(tab));
const tabClone = Object.assign(proto, tab); const tabClone = Object.assign(proto, tab);
tabClone.webview = { props: {} }; tabClone.webview = {props: {}};
tabClone.webview.props.name = tab.webview.props.name; tabClone.webview.props.name = tab.webview.props.name;
delete tabClone.props.webview; delete tabClone.props.webview;
tabs.push(tabClone); tabs.push(tabClone);
@@ -597,24 +613,27 @@ class ServerManagerView {
if (this.activeTabIndex !== -1) { if (this.activeTabIndex !== -1) {
if (this.activeTabIndex === index) { if (this.activeTabIndex === index) {
return; return;
} else if (hideOldTab) { }
if (hideOldTab) {
// If old tab is functional tab Settings, remove focus from the settings icon at sidebar bottom // 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') { if (this.tabs[this.activeTabIndex].props.role === 'function' && this.tabs[this.activeTabIndex].props.name === 'Settings') {
this.$settingsButton.classList.remove('active'); this.$settingsButton.classList.remove('active');
} }
this.tabs[this.activeTabIndex].deactivate(); this.tabs[this.activeTabIndex].deactivate();
} }
} }
try { try {
this.tabs[index].webview.canGoBackButton(); this.tabs[index].webview.canGoBackButton();
} catch (err) { } catch {
} }
this.activeTabIndex = index; this.activeTabIndex = index;
this.tabs[index].activate(); this.tabs[index].activate();
this.showLoading(this.loading[this.tabs[this.activeTabIndex].webview.props.url]); this.showLoading(this.loading.has(this.tabs[this.activeTabIndex].webview.props.url));
ipcRenderer.send('update-menu', { ipcRenderer.send('update-menu', {
// JSON stringify this.tabs to avoid a crash // JSON stringify this.tabs to avoid a crash
@@ -644,7 +663,7 @@ class ServerManagerView {
this.tabs[index].destroy(); this.tabs[index].destroy();
delete this.tabs[index]; delete this.tabs[index];
delete this.functionalTabs[name]; this.functionalTabs.delete(name);
// Issue #188: If the functional tab was not focused, do not activate another tab. // Issue #188: If the functional tab was not focused, do not activate another tab.
if (this.activeTabIndex === index) { if (this.activeTabIndex === index) {
@@ -659,21 +678,21 @@ class ServerManagerView {
// Clear global variables // Clear global variables
this.activeTabIndex = -1; this.activeTabIndex = -1;
this.tabs = []; this.tabs = [];
this.functionalTabs = {}; this.functionalTabs.clear();
// Clear DOM elements // Clear DOM elements
this.$tabsContainer.innerHTML = ''; this.$tabsContainer.innerHTML = '';
this.$webviewsContainer.innerHTML = ''; this.$webviewsContainer.innerHTML = '';
} }
reloadView(): void { async reloadView(): Promise<void> {
// Save and remember the index of last active tab so that we can use it later // 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].props.index;
ConfigUtil.setConfigItem('lastActiveTab', lastActiveTab); ConfigUtil.setConfigItem('lastActiveTab', lastActiveTab);
// Destroy the current view and re-initiate it // Destroy the current view and re-initiate it
this.destroyView(); this.destroyView();
this.initTabs(); await this.initTabs();
this.initServerActions(); this.initServerActions();
} }
@@ -696,11 +715,9 @@ class ServerManagerView {
ipcRenderer.send('update-badge', messageCountAll); ipcRenderer.send('update-badge', messageCountAll);
} }
updateGeneralSettings(setting: string, value: any): void { updateGeneralSettings(setting: string, value: unknown): void {
const selector = 'webview:not([class*=disabled])'; if (this.getActiveWebview()) {
const webview: Electron.WebviewTag = document.querySelector(selector); const webContents = remote.webContents.fromId(this.getActiveWebview().getWebContentsId());
if (webview) {
const webContents = webview.getWebContents();
webContents.send(setting, value); webContents.send(setting, value);
} }
} }
@@ -719,28 +736,47 @@ class ServerManagerView {
this.$dndButton.querySelector('i').textContent = alert ? 'notifications_off' : 'notifications'; this.$dndButton.querySelector('i').textContent = alert ? 'notifications_off' : 'notifications';
} }
isLoggedIn(tabIndex: number): boolean {
const url = this.tabs[tabIndex].webview.$el.src;
return !(url.endsWith('/login/') || this.tabs[tabIndex].webview.loading);
}
getActiveWebview(): Electron.WebviewTag {
const selector = 'webview:not(.disabled)';
const webview: Electron.WebviewTag = document.querySelector(selector);
return webview;
}
addContextMenu($serverImg: HTMLImageElement, index: number): void { addContextMenu($serverImg: HTMLImageElement, index: number): void {
$serverImg.addEventListener('contextmenu', e => { $serverImg.addEventListener('contextmenu', event => {
e.preventDefault(); event.preventDefault();
const template = [ const template = [
{ {
label: 'Disconnect organization', label: 'Disconnect organization',
click: () => { click: async () => {
dialog.showMessageBox({ const {response} = await dialog.showMessageBox({
type: 'warning', type: 'warning',
buttons: ['YES', 'NO'], buttons: ['YES', 'NO'],
defaultId: 0, defaultId: 0,
message: 'Are you sure you want to disconnect this organization?' message: 'Are you sure you want to disconnect this organization?'
}, response => { });
if (response === 0) { if (response === 0) {
if (DomainUtil.removeDomain(index)) { if (DomainUtil.removeDomain(index)) {
ipcRenderer.send('reload-full-app'); ipcRenderer.send('reload-full-app');
} else { } else {
const { title, content } = Messages.orgRemovalError(DomainUtil.getDomain(index).url); const {title, content} = Messages.orgRemovalError(DomainUtil.getDomain(index).url);
dialog.showErrorBox(title, content); dialog.showErrorBox(title, content);
} }
} }
}); }
},
{
label: 'Notification settings',
enabled: this.isLoggedIn(index),
click: () => {
// Switch to tab whose icon was right-clicked
this.activateTab(index);
this.tabs[index].webview.showNotificationSettings();
} }
}, },
{ {
@@ -751,43 +787,70 @@ class ServerManagerView {
} }
]; ];
const contextMenu = Menu.buildFromTemplate(template); const contextMenu = Menu.buildFromTemplate(template);
contextMenu.popup({ window: remote.getCurrentWindow() }); contextMenu.popup({window: remote.getCurrentWindow()});
}); });
} }
registerIpcs(): void { registerIpcs(): void {
const webviewListeners: AnyObject = { const webviewListeners: Array<[string, (webview: WebView) => void]> = [
'webview-reload': 'reload', ['webview-reload', webview => webview.reload()],
back: 'back', ['back', webview => webview.back()],
focus: 'focus', ['focus', webview => webview.focus()],
forward: 'forward', ['forward', webview => webview.forward()],
zoomIn: 'zoomIn', ['zoomIn', webview => webview.zoomIn()],
zoomOut: 'zoomOut', ['zoomOut', webview => webview.zoomOut()],
zoomActualSize: 'zoomActualSize', ['zoomActualSize', webview => webview.zoomActualSize()],
'log-out': 'logOut', ['log-out', webview => webview.logOut()],
shortcut: 'showShortcut', ['shortcut', webview => webview.showShortcut()],
'tab-devtools': 'openDevTools' ['tab-devtools', webview => webview.openDevTools()]
}; ];
for (const key in webviewListeners) { for (const [channel, listener] of webviewListeners) {
ipcRenderer.on(key, () => { ipcRenderer.on(channel, () => {
const activeWebview = this.tabs[this.activeTabIndex].webview; const activeWebview = this.tabs[this.activeTabIndex].webview;
if (activeWebview) { if (activeWebview) {
activeWebview[webviewListeners[key] as string](); listener(activeWebview);
} }
}); });
} }
ipcRenderer.on('open-settings', (event: Event, settingNav: string) => { ipcRenderer.on('permission-request', (
this.openSettings(settingNav); event: Event,
{webContentsId, origin, permission}: {
webContentsId: number | null;
origin: string;
permission: string;
},
rendererCallbackId: number
) => {
const grant = webContentsId === null ?
origin === 'null' && permission === 'notifications' :
this.tabs.some(
({webview}) =>
!webview.loading &&
webview.$el.getWebContentsId() === webContentsId &&
webview.props.hasPermission?.(origin, permission)
);
console.log(
grant ? 'Granted' : 'Denied', 'permissions request for',
permission, 'from', origin
);
ipcRenderer.send('renderer-callback', rendererCallbackId, grant);
});
ipcRenderer.on('show-network-error', (event: Event, index: number) => {
this.openNetworkTroubleshooting(index);
});
ipcRenderer.on('open-settings', async (event: Event, settingNav: string) => {
await this.openSettings(settingNav);
}); });
ipcRenderer.on('open-about', this.openAbout.bind(this)); ipcRenderer.on('open-about', this.openAbout.bind(this));
ipcRenderer.on('open-help', () => { ipcRenderer.on('open-help', async () => {
// Open help page of current active server // Open help page of current active server
const helpPage = this.getCurrentActiveServer() + '/help'; await LinkUtil.openBrowser(new URL('https://zulip.com/help/'));
shell.openExternal(helpPage);
}); });
ipcRenderer.on('reload-viewer', this.reloadView.bind(this, this.tabs[this.activeTabIndex].props.index)); ipcRenderer.on('reload-viewer', this.reloadView.bind(this, this.tabs[this.activeTabIndex].props.index));
@@ -798,26 +861,24 @@ class ServerManagerView {
ipcRenderer.send('reload-full-app'); ipcRenderer.send('reload-full-app');
}); });
ipcRenderer.on('clear-app-data', () => {
ipcRenderer.send('clear-app-settings');
});
ipcRenderer.on('switch-server-tab', (event: Event, index: number) => { ipcRenderer.on('switch-server-tab', (event: Event, index: number) => {
this.activateLastTab(index); this.activateLastTab(index);
}); });
ipcRenderer.on('open-org-tab', () => { ipcRenderer.on('open-org-tab', async () => {
this.openSettings('AddServer'); await this.openSettings('AddServer');
}); });
ipcRenderer.on('reload-proxy', (event: Event, showAlert: boolean) => { ipcRenderer.on('reload-proxy', async (event: Event, showAlert: boolean) => {
this.loadProxy().then(() => { await this.loadProxy();
if (showAlert) { if (showAlert) {
alert('Proxy settings saved!'); await dialog.showMessageBox({
message: 'Proxy settings saved!',
buttons: ['OK']
});
ipcRenderer.send('reload-full-app'); ipcRenderer.send('reload-full-app');
} }
}); });
});
ipcRenderer.on('toggle-sidebar', (event: Event, show: boolean) => { ipcRenderer.on('toggle-sidebar', (event: Event, show: boolean) => {
// Toggle the left sidebar // Toggle the left sidebar
@@ -832,8 +893,8 @@ class ServerManagerView {
webviews.forEach(webview => { webviews.forEach(webview => {
try { try {
webview.setAudioMuted(state); webview.setAudioMuted(state);
} catch (err) { } catch {
// webview is not ready yet // Webview is not ready yet
webview.addEventListener('dom-ready', () => { webview.addEventListener('dom-ready', () => {
webview.setAudioMuted(state); webview.setAudioMuted(state);
}); });
@@ -849,31 +910,28 @@ class ServerManagerView {
}); });
return; return;
} }
this.updateGeneralSettings('toggle-menubar-setting', autoHideMenubar); this.updateGeneralSettings('toggle-menubar-setting', autoHideMenubar);
}); });
ipcRenderer.on('toggle-dnd', (event: Event, state: boolean, newSettings: SettingsOptions) => { ipcRenderer.on('toggle-dnd', (event: Event, state: boolean, newSettings: DNDSettings) => {
this.toggleDNDButton(state); this.toggleDNDButton(state);
ipcRenderer.send('forward-message', 'toggle-silent', newSettings.silent); ipcRenderer.send('forward-message', 'toggle-silent', newSettings.silent);
const selector = 'webview:not([class*=disabled])'; const webContents = remote.webContents.fromId(this.getActiveWebview().getWebContentsId());
const webview: Electron.WebviewTag = document.querySelector(selector);
const webContents = webview.getWebContents();
webContents.send('toggle-dnd', state, newSettings); webContents.send('toggle-dnd', state, newSettings);
}); });
ipcRenderer.on('update-realm-name', (event: Event, serverURL: string, realmName: string) => { ipcRenderer.on('update-realm-name', (event: Event, serverURL: string, realmName: string) => {
// TODO: TypeScript - Type annotate getDomains() or this domain paramter. DomainUtil.getDomains().forEach((domain: DomainUtil.ServerConf, index: number) => {
DomainUtil.getDomains().forEach((domain: any, index: number) => {
if (domain.url.includes(serverURL)) { if (domain.url.includes(serverURL)) {
const serverTooltipSelector = `.tab .server-tooltip`; const serverTooltipSelector = '.tab .server-tooltip';
const serverTooltips = document.querySelectorAll(serverTooltipSelector); const serverTooltips = document.querySelectorAll(serverTooltipSelector);
serverTooltips[index].innerHTML = escape(realmName); serverTooltips[index].innerHTML = escape(realmName);
this.tabs[index].props.name = escape(realmName); this.tabs[index].props.name = escape(realmName);
this.tabs[index].webview.props.name = realmName; this.tabs[index].webview.props.name = realmName;
domain.alias = escape(realmName); domain.alias = escape(realmName);
DomainUtil.db.push(`/domains[${index}]`, domain, true); DomainUtil.updateDomain(index, domain);
DomainUtil.reloadDB();
// Update the realm name also on the Window menu // Update the realm name also on the Window menu
ipcRenderer.send('update-menu', { ipcRenderer.send('update-menu', {
tabs: this.tabsForIpc, tabs: this.tabsForIpc,
@@ -884,18 +942,17 @@ class ServerManagerView {
}); });
ipcRenderer.on('update-realm-icon', (event: Event, serverURL: string, iconURL: string) => { ipcRenderer.on('update-realm-icon', (event: Event, serverURL: string, iconURL: string) => {
// TODO: TypeScript - Type annotate getDomains() or this domain paramter. DomainUtil.getDomains().forEach(async (domain, index) => {
DomainUtil.getDomains().forEach((domain: any, index: number) => {
if (domain.url.includes(serverURL)) { if (domain.url.includes(serverURL)) {
DomainUtil.saveServerIcon(iconURL).then((localIconUrl: string) => { const localIconUrl: string = await DomainUtil.saveServerIcon({
const serverImgsSelector = `.tab .server-icons`; url: serverURL,
icon: iconURL
});
const serverImgsSelector = '.tab .server-icons';
const serverImgs: NodeListOf<HTMLImageElement> = document.querySelectorAll(serverImgsSelector); const serverImgs: NodeListOf<HTMLImageElement> = document.querySelectorAll(serverImgsSelector);
serverImgs[index].src = localIconUrl; serverImgs[index].src = localIconUrl;
domain.icon = localIconUrl; domain.icon = localIconUrl;
DomainUtil.db.push(`/domains[${index}]`, domain, true); DomainUtil.updateDomain(index, domain);
DomainUtil.reloadDB();
});
} }
}); });
}); });
@@ -912,7 +969,7 @@ class ServerManagerView {
ipcRenderer.on('focus-webview-with-id', (event: Event, webviewId: number) => { ipcRenderer.on('focus-webview-with-id', (event: Event, webviewId: number) => {
const webviews: NodeListOf<Electron.WebviewTag> = document.querySelectorAll('webview'); const webviews: NodeListOf<Electron.WebviewTag> = document.querySelectorAll('webview');
webviews.forEach(webview => { webviews.forEach(webview => {
const currentId = webview.getWebContents().id; const currentId = webview.getWebContentsId();
const tabId = webview.getAttribute('data-tab-id'); const tabId = webview.getAttribute('data-tab-id');
const concurrentTab: HTMLButtonElement = document.querySelector(`div[data-tab-id="${tabId}"]`); const concurrentTab: HTMLButtonElement = document.querySelector(`div[data-tab-id="${tabId}"]`);
if (currentId === webviewId) { if (currentId === webviewId) {
@@ -945,8 +1002,10 @@ class ServerManagerView {
ctx.font = '85px Helvetica'; ctx.font = '85px Helvetica';
ctx.fillText(String(Math.min(99, messageCount)), 64, 90); ctx.fillText(String(Math.min(99, messageCount)), 64, 90);
} }
return canvas; return canvas;
} }
ipcRenderer.send('update-taskbar-icon', createOverlayIcon(messageCount).toDataURL(), String(messageCount)); ipcRenderer.send('update-taskbar-icon', createOverlayIcon(messageCount).toDataURL(), String(messageCount));
}); });
@@ -958,33 +1017,41 @@ class ServerManagerView {
clipboard.writeText(this.getCurrentActiveServer()); clipboard.writeText(this.getCurrentActiveServer());
}); });
ipcRenderer.on('new-server', () => { ipcRenderer.on('new-server', async () => {
this.openSettings('AddServer'); await this.openSettings('AddServer');
});
// Redo and undo functionality since the default API doesn't work on macOS
ipcRenderer.on('undo', () => this.getActiveWebview().undo());
ipcRenderer.on('redo', () => this.getActiveWebview().redo());
ipcRenderer.on('set-active', async () => {
const webviews: NodeListOf<Electron.WebviewTag> = document.querySelectorAll('webview');
await Promise.all([...webviews].map(async webview => webview.send('set-active')));
});
ipcRenderer.on('set-idle', async () => {
const webviews: NodeListOf<Electron.WebviewTag> = document.querySelectorAll('webview');
await Promise.all([...webviews].map(async webview => webview.send('set-idle')));
});
ipcRenderer.on('open-network-settings', async () => {
await this.openSettings('Network');
}); });
} }
} }
window.addEventListener('load', () => { window.addEventListener('load', async () => {
const serverManagerView = new ServerManagerView(); // Only start electron-connect (auto reload on change) when its ran
const reconnectUtil = new ReconnectUtil(serverManagerView); // from `npm run dev` or `gulp dev` and not from `npm start`
serverManagerView.init(); if (isDev && remote.getGlobal('process').argv.includes('--electron-connect')) {
window.addEventListener('online', () => { // eslint-disable-next-line node/no-unsupported-features/es-syntax
reconnectUtil.pollInternetAndReload(); (await import('electron-connect')).client.create();
});
window.addEventListener('offline', () => {
reconnectUtil.clearState();
logger.log('No internet connection, you are offline.');
});
// only start electron-connect (auto reload on change) when its ran
// from `npm run dev` or `gulp dev` and not from `npm start` when
// app is started `npm start` main process's proces.argv will have
// `--no-electron-connect`
const mainProcessArgv = remote.getGlobal('process').argv;
if (isDev && !mainProcessArgv.includes('--no-electron-connect')) {
require('electron-connect').client.create();
} }
const serverManagerView = new ServerManagerView();
await serverManagerView.init();
}); });
export = new ServerManagerView(); export { };

View File

@@ -1,34 +1,31 @@
'use strict'; import {ipcRenderer} from 'electron';
import { ipcRenderer } from 'electron';
import { import {
appId, customReply, focusCurrentServer, parseReply, setupReply appId, customReply, focusCurrentServer, parseReply
} from './helpers'; } from './helpers';
import url = require('url'); import MacNotifier from 'node-mac-notifier';
import MacNotifier = require('node-mac-notifier'); import * as ConfigUtil from '../utils/config-util';
import ConfigUtil = require('../utils/config-util'); import electron_bridge from '../electron-bridge';
type ReplyHandler = (response: string) => void; type ReplyHandler = (response: string) => void;
type ClickHandler = () => void; type ClickHandler = () => void;
let replyHandler: ReplyHandler; let replyHandler: ReplyHandler;
let clickHandler: ClickHandler; let clickHandler: ClickHandler;
declare const window: ZulipWebWindow;
interface NotificationHandlerArgs { interface NotificationHandlerArgs {
response: string; response: string;
} }
class DarwinNotification { class DarwinNotification {
tag: string; tag: number;
constructor(title: string, opts: NotificationOptions) { constructor(title: string, options: NotificationOptions) {
const silent: boolean = ConfigUtil.getConfigItem('silent') || false; const silent: boolean = ConfigUtil.getConfigItem('silent') || false;
const { host, protocol } = location; const {icon} = options;
const { icon } = opts; const profilePic = new URL(icon, location.href).href;
const profilePic = url.resolve(`${protocol}//${host}`, icon);
this.tag = opts.tag; this.tag = Number.parseInt(options.tag, 10);
const notification = new MacNotifier(title, Object.assign(opts, { const notification = new MacNotifier(title, Object.assign(options, {
bundleId: appId, bundleId: appId,
canReply: true, canReply: true,
silent, silent,
@@ -36,7 +33,7 @@ class DarwinNotification {
})); }));
notification.addEventListener('click', () => { notification.addEventListener('click', () => {
// focus to the server who sent the // Focus to the server who sent the
// notification if not focused already // notification if not focused already
if (clickHandler) { if (clickHandler) {
clickHandler(); clickHandler();
@@ -50,7 +47,7 @@ class DarwinNotification {
} }
static requestPermission(): void { static requestPermission(): void {
return; // eslint-disable-line no-useless-return // Do nothing
} }
// Override default Notification permission // Override default Notification permission
@@ -58,23 +55,23 @@ class DarwinNotification {
return ConfigUtil.getConfigItem('showNotification') ? 'granted' : 'denied'; return ConfigUtil.getConfigItem('showNotification') ? 'granted' : 'denied';
} }
set onreply(handler: ReplyHandler) {
replyHandler = handler;
}
get onreply(): ReplyHandler { get onreply(): ReplyHandler {
return replyHandler; return replyHandler;
} }
set onclick(handler: ClickHandler) { set onreply(handler: ReplyHandler) {
clickHandler = handler; replyHandler = handler;
} }
get onclick(): ClickHandler { get onclick(): ClickHandler {
return clickHandler; return clickHandler;
} }
// not something that is common or set onclick(handler: ClickHandler) {
clickHandler = handler;
}
// Not something that is common or
// used by zulip server but added to be // used by zulip server but added to be
// future proff. // future proff.
addEventListener(event: string, handler: ClickHandler | ReplyHandler): void { addEventListener(event: string, handler: ClickHandler | ReplyHandler): void {
@@ -87,15 +84,15 @@ class DarwinNotification {
} }
} }
notificationHandler({ response }: NotificationHandlerArgs): void { async notificationHandler({response}: NotificationHandlerArgs): Promise<void> {
response = parseReply(response); response = await parseReply(response);
focusCurrentServer(); focusCurrentServer();
if (window.electron_bridge.send_notification_reply_message_supported) { if (electron_bridge.send_notification_reply_message_supported) {
window.electron_bridge.send_event('send_notification_reply_message', this.tag, response); electron_bridge.send_event('send_notification_reply_message', this.tag, response);
return; return;
} }
setupReply(this.tag); electron_bridge.emit('narrow-by-topic', this.tag);
if (replyHandler) { if (replyHandler) {
replyHandler(response); replyHandler(response);
return; return;
@@ -104,10 +101,10 @@ class DarwinNotification {
customReply(response); customReply(response);
} }
// method specific to notification api // Method specific to notification api
// used by zulip // used by zulip
close(): void { close(): void {
return; // eslint-disable-line no-useless-return // Do nothing
} }
} }

View File

@@ -1,26 +1,24 @@
'use strict'; import {ipcRenderer} from 'electron';
import {focusCurrentServer} from './helpers';
import { ipcRenderer } from 'electron'; import * as ConfigUtil from '../utils/config-util';
import { focusCurrentServer } from './helpers';
import ConfigUtil = require('../utils/config-util');
const NativeNotification = window.Notification; const NativeNotification = window.Notification;
class BaseNotification extends NativeNotification { export default class BaseNotification extends NativeNotification {
constructor(title: string, opts: NotificationOptions) { constructor(title: string, options: NotificationOptions) {
opts.silent = true; options.silent = true;
super(title, opts); super(title, options);
this.addEventListener('click', () => { this.addEventListener('click', () => {
// focus to the server who sent the // Focus to the server who sent the
// notification if not focused already // notification if not focused already
focusCurrentServer(); focusCurrentServer();
ipcRenderer.send('focus-app'); ipcRenderer.send('focus-app');
}); });
} }
static requestPermission(): void { static async requestPermission(): Promise<NotificationPermission> {
return; // eslint-disable-line no-useless-return return this.permission;
} }
// Override default Notification permission // Override default Notification permission
@@ -28,5 +26,3 @@ class BaseNotification extends NativeNotification {
return ConfigUtil.getConfigItem('showNotification') ? 'granted' : 'denied'; return ConfigUtil.getConfigItem('showNotification') ? 'granted' : 'denied';
} }
} }
export = BaseNotification;

View File

@@ -1,6 +1,6 @@
import { remote } from 'electron'; import {remote} from 'electron';
import Logger = require('../utils/logger-util'); import Logger from '../utils/logger-util';
const logger = new Logger({ const logger = new Logger({
file: 'errors.log', file: 'errors.log',
@@ -14,37 +14,28 @@ export type BotListItem = [string, string];
const botsList: BotListItem[] = []; const botsList: BotListItem[] = [];
let botsListLoaded = false; let botsListLoaded = false;
// this function load list of bots from the server // This function load list of bots from the server
// sync=True for a synchronous getJSON request
// in case botsList isn't already completely loaded when required in parseRely // in case botsList isn't already completely loaded when required in parseRely
export function loadBots(sync = false): void { export async function loadBots(): Promise<void> {
const { $ } = window;
botsList.length = 0; botsList.length = 0;
if (sync) { const response = await fetch('/json/users');
$.ajaxSetup({async: false}); if (response.ok) {
} const {members} = await response.json();
$.getJSON('/json/users') members.forEach(({is_bot, full_name}: {[key: string]: unknown}) => {
.done((data: any) => { if (is_bot && typeof full_name === 'string') {
const { members } = data; const bot = `@${full_name}`;
members.forEach((membersRow: any) => {
if (membersRow.is_bot) {
const bot = `@${membersRow.full_name}`;
const mention = `@**${bot.replace(/^@/, '')}**`; const mention = `@**${bot.replace(/^@/, '')}**`;
botsList.push([bot, mention]); botsList.push([bot, mention]);
} }
}); });
botsListLoaded = true; botsListLoaded = true;
}) } else {
.fail((error: any) => { logger.log('Load bots request failed: ', await response.text());
logger.log('Load bots request failed: ', error.responseText); logger.log('Load bots request status: ', response.status);
logger.log('Load bots request status: ', error.statusText);
});
if (sync) {
$.ajaxSetup({async: true});
} }
} }
export function checkElements(...elements: any[]): boolean { export function checkElements(...elements: unknown[]): boolean {
let status = true; let status = true;
elements.forEach(element => { elements.forEach(element => {
if (element === null || element === undefined) { if (element === null || element === undefined) {
@@ -55,14 +46,14 @@ export function checkElements(...elements: any[]): boolean {
} }
export function customReply(reply: string): void { export function customReply(reply: string): void {
// server does not support notification reply yet. // Server does not support notification reply yet.
const buttonSelector = '.messagebox #send_controls button[type=submit]'; const buttonSelector = '.messagebox #send_controls button[type=submit]';
const messageboxSelector = '.selected_message .messagebox .messagebox-border .messagebox-content'; const messageboxSelector = '.selected_message .messagebox .messagebox-border .messagebox-content';
const textarea: HTMLInputElement = document.querySelector('#compose-textarea'); const textarea: HTMLInputElement = document.querySelector('#compose-textarea');
const messagebox: HTMLButtonElement = document.querySelector(messageboxSelector); const messagebox: HTMLButtonElement = document.querySelector(messageboxSelector);
const sendButton: HTMLButtonElement = document.querySelector(buttonSelector); const sendButton: HTMLButtonElement = document.querySelector(buttonSelector);
// sanity check for old server versions // Sanity check for old server versions
const elementsExists = checkElements(textarea, messagebox, sendButton); const elementsExists = checkElements(textarea, messagebox, sendButton);
if (!elementsExists) { if (!elementsExists) {
return; return;
@@ -77,19 +68,18 @@ const currentWindow = remote.getCurrentWindow();
const webContents = remote.getCurrentWebContents(); const webContents = remote.getCurrentWebContents();
const webContentsId = webContents.id; const webContentsId = webContents.id;
// this function will focus the server that sent // This function will focus the server that sent
// the notification. Main function implemented in main.js // the notification. Main function implemented in main.js
export function focusCurrentServer(): void { export function focusCurrentServer(): void {
// TODO: TypeScript: currentWindow of type BrowserWindow doesn't currentWindow.webContents.send('focus-webview-with-id', webContentsId);
// have a .send() property per typescript.
(currentWindow as any).send('focus-webview-with-id', webContentsId);
} }
// this function parses the reply from to notification
// This function parses the reply from to notification
// making it easier to reply from notification eg // making it easier to reply from notification eg
// @username in reply will be converted to @**username** // @username in reply will be converted to @**username**
// #stream in reply will be converted to #**stream** // #stream in reply will be converted to #**stream**
// bot mentions are not yet supported // bot mentions are not yet supported
export function parseReply(reply: string): string { export async function parseReply(reply: string): Promise<string> {
const usersDiv = document.querySelectorAll('#user_presences li'); const usersDiv = document.querySelectorAll('#user_presences li');
const streamHolder = document.querySelectorAll('#stream_filters li'); const streamHolder = document.querySelectorAll('#stream_filters li');
@@ -128,9 +118,9 @@ export function parseReply(reply: string): string {
reply = reply.replace(regex, streamMention); reply = reply.replace(regex, streamMention);
}); });
// If botsList isn't completely loaded yet, make a synchronous getJSON request for list // If botsList isn't completely loaded yet, make a request for list
if (botsListLoaded === false) { if (!botsListLoaded) {
loadBots(true); await loadBots();
} }
// Iterate for every bot name and replace in reply // Iterate for every bot name and replace in reply
@@ -145,9 +135,3 @@ export function parseReply(reply: string): string {
reply = reply.replace(/\\n/, '\n'); reply = reply.replace(/\\n/, '\n');
return reply; return reply;
} }
export function setupReply(id: string): void {
const { narrow } = window;
const narrowByTopic = narrow.by_topic || narrow.by_subject;
narrowByTopic(id, { trigger: 'notification' });
}

View File

@@ -1,25 +1,73 @@
'use strict'; import {remote} from 'electron';
import electron_bridge from '../electron-bridge';
import {appId, loadBots} from './helpers';
import { remote } from 'electron'; import DefaultNotification from './default-notification';
import * as params from '../utils/params-util'; const {app} = remote;
import { appId, loadBots } from './helpers';
import DefaultNotification = require('./default-notification');
const { app } = remote;
// From https://github.com/felixrieseberg/electron-windows-notifications#appusermodelid // From https://github.com/felixrieseberg/electron-windows-notifications#appusermodelid
// On windows 8 we have to explicitly set the appUserModelId otherwise notification won't work. // On windows 8 we have to explicitly set the appUserModelId otherwise notification won't work.
app.setAppUserModelId(appId); app.setAppUserModelId(appId);
window.Notification = DefaultNotification; let Notification = DefaultNotification;
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
window.Notification = require('./darwin-notifications'); Notification = require('./darwin-notifications');
} }
window.addEventListener('load', () => { export interface NotificationData {
// eslint-disable-next-line no-undef, @typescript-eslint/camelcase close: () => void;
if (params.isPageParams() && page_params.realm_uri) { title: string;
loadBots(); dir: NotificationDirection;
lang: string;
body: string;
tag: string;
image: string;
icon: string;
badge: string;
vibrate: readonly number[];
timestamp: number;
renotify: boolean;
silent: boolean;
requireInteraction: boolean;
data: unknown;
actions: readonly NotificationAction[];
}
export function newNotification(
title: string,
options: NotificationOptions | undefined,
dispatch: (type: string, eventInit: EventInit) => boolean
): NotificationData {
const notification = new Notification(title, options);
for (const type of ['click', 'close', 'error', 'show']) {
notification.addEventListener(type, (ev: Event) => {
if (!dispatch(type, ev)) {
ev.preventDefault();
} }
});
}
return {
close: () => notification.close(),
title: notification.title,
dir: notification.dir,
lang: notification.lang,
body: notification.body,
tag: notification.tag,
image: notification.image,
icon: notification.icon,
badge: notification.badge,
vibrate: notification.vibrate,
timestamp: notification.timestamp,
renotify: notification.renotify,
silent: notification.silent,
requireInteraction: notification.requireInteraction,
data: notification.data,
actions: notification.actions
};
}
electron_bridge.once('zulip-loaded', async () => {
await loadBots();
}); });

View File

@@ -1,21 +1,10 @@
'use strict'; import {ipcRenderer} from 'electron';
import { ipcRenderer } from 'electron'; export function init($reconnectButton: Element, $settingsButton: Element): void {
$reconnectButton.addEventListener('click', () => {
class NetworkTroubleshootingView {
$reconnectButton: Element;
constructor() {
this.$reconnectButton = document.querySelector('#reconnect');
}
init(): void {
this.$reconnectButton.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'reload-viewer'); ipcRenderer.send('forward-message', 'reload-viewer');
}); });
} $settingsButton.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'open-settings');
});
} }
window.addEventListener('load', () => {
const networkTroubleshootingView = new NetworkTroubleshootingView();
networkTroubleshootingView.init();
});

View File

@@ -1,23 +1,26 @@
'use-strict'; 'use-strict';
import { remote, OpenDialogOptions } from 'electron'; import {remote, OpenDialogOptions} from 'electron';
import path = require('path'); import path from 'path';
import BaseComponent = require('../../components/base'); import BaseComponent from '../../components/base';
import CertificateUtil = require('../../utils/certificate-util'); import * as CertificateUtil from '../../utils/certificate-util';
import DomainUtil = require('../../utils/domain-util'); import * as DomainUtil from '../../utils/domain-util';
import t = require('../../utils/translation-util'); import * as t from '../../utils/translation-util';
const { dialog } = remote; interface AddCertificateProps {
$root: Element;
}
class AddCertificate extends BaseComponent { const {dialog} = remote;
// TODO: TypeScript - Here props should be object type
props: any; export default class AddCertificate extends BaseComponent {
props: AddCertificateProps;
_certFile: string; _certFile: string;
$addCertificate: Element | null; $addCertificate: Element | null;
addCertificateButton: Element | null; addCertificateButton: Element | null;
serverUrl: HTMLInputElement | null; serverUrl: HTMLInputElement | null;
constructor(props: any) { constructor(props: AddCertificateProps) {
super(); super();
this.props = props; this.props = props;
this._certFile = ''; this._certFile = '';
@@ -46,7 +49,7 @@ class AddCertificate extends BaseComponent {
this.initListeners(); this.initListeners();
} }
validateAndAdd(): void { async validateAndAdd(): Promise<void> {
const certificate = this._certFile; const certificate = this._certFile;
const serverUrl = this.serverUrl.value; const serverUrl = this.serverUrl.value;
if (certificate !== '' && serverUrl !== '') { if (certificate !== '' && serverUrl !== '') {
@@ -56,45 +59,41 @@ class AddCertificate extends BaseComponent {
if (!copy) { if (!copy) {
return; return;
} }
CertificateUtil.setCertificate(server, fileName); CertificateUtil.setCertificate(server, fileName);
dialog.showMessageBox({
title: 'Success',
message: `Certificate saved!`
});
this.serverUrl.value = ''; this.serverUrl.value = '';
await dialog.showMessageBox({
title: 'Success',
message: 'Certificate saved!'
});
} else { } else {
dialog.showErrorBox('Error', `Please, ${serverUrl === '' ? dialog.showErrorBox('Error', `Please, ${serverUrl === '' ?
'Enter an Organization URL' : 'Choose certificate file'}`); 'Enter an Organization URL' : 'Choose certificate file'}`);
} }
} }
addHandler(): void { async addHandler(): Promise<void> {
const showDialogOptions: OpenDialogOptions = { const showDialogOptions: OpenDialogOptions = {
title: 'Select file', title: 'Select file',
properties: ['openFile'], properties: ['openFile'],
filters: [{ name: 'crt, pem', extensions: ['crt', 'pem'] }] filters: [{name: 'crt, pem', extensions: ['crt', 'pem']}]
}; };
dialog.showOpenDialog(showDialogOptions, selectedFile => { const {filePaths, canceled} = await dialog.showOpenDialog(showDialogOptions);
if (selectedFile) { if (!canceled) {
this._certFile = selectedFile[0] || ''; this._certFile = filePaths[0] || '';
this.validateAndAdd(); await this.validateAndAdd();
} }
});
} }
initListeners(): void { initListeners(): void {
this.addCertificateButton.addEventListener('click', () => { this.addCertificateButton.addEventListener('click', async () => {
this.addHandler(); await this.addHandler();
}); });
this.serverUrl.addEventListener('keypress', event => { this.serverUrl.addEventListener('keypress', async event => {
const EnterkeyCode = event.keyCode; if (event.key === 'Enter') {
await this.addHandler();
if (EnterkeyCode === 13) {
this.addHandler();
} }
}); });
} }
} }
export = AddCertificate;

View File

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

View File

@@ -1,12 +1,17 @@
'use strict'; import {ipcRenderer} from 'electron';
import escape from 'escape-html';
import { ipcRenderer } from 'electron'; import BaseComponent from '../../components/base';
import BaseComponent = require('../../components/base'); interface BaseSectionProps {
$element: HTMLElement;
disabled?: boolean;
value: boolean;
clickHandler: () => void;
}
class BaseSection extends BaseComponent { export default class BaseSection extends BaseComponent {
// TODO: TypeScript - Here props should be object type generateSettingOption(props: BaseSectionProps): void {
generateSettingOption(props: any): void {
const {$element, disabled, value, clickHandler} = props; const {$element, disabled, value, clickHandler} = props;
$element.innerHTML = ''; $element.innerHTML = '';
@@ -19,8 +24,8 @@ class BaseSection extends BaseComponent {
} }
} }
generateOptionTemplate(settingOption: boolean, disabled: boolean): string { generateOptionTemplate(settingOption: boolean, disabled?: boolean): string {
const label = disabled ? `<label class="disallowed" title="Setting locked by system administrator."/>` : `<label/>`; const label = disabled ? '<label class="disallowed" title="Setting locked by system administrator."/>' : '<label/>';
if (settingOption) { if (settingOption) {
return ` return `
<div class="action"> <div class="action">
@@ -30,7 +35,8 @@ class BaseSection extends BaseComponent {
</div> </div>
</div> </div>
`; `;
} else { }
return ` return `
<div class="action"> <div class="action">
<div class="switch"> <div class="switch">
@@ -40,11 +46,20 @@ class BaseSection extends BaseComponent {
</div> </div>
`; `;
} }
/* A method that in future can be used to create dropdown menus using <select> <option> tags.
it needs an object which has ``key: value`` pairs and will return a string that can be appended to HTML
*/
generateSelectTemplate(options: {[key: string]: string}, className?: string, idName?: string): string {
let select = `<select class="${escape(className)}" id="${escape(idName)}">\n`;
Object.keys(options).forEach(key => {
select += `<option name="${escape(key)}" value="${escape(key)}">${escape(options[key])}</option>\n`;
});
select += '</select>';
return select;
} }
reloadApp(): void { reloadApp(): void {
ipcRenderer.send('forward-message', 'reload-viewer'); ipcRenderer.send('forward-message', 'reload-viewer');
} }
} }
export = BaseSection;

View File

@@ -1,23 +1,24 @@
'use strict'; import {ipcRenderer} from 'electron';
import { ipcRenderer } from 'electron'; import BaseSection from './base-section';
import * as DomainUtil from '../../utils/domain-util';
import ServerInfoForm from './server-info-form';
import AddCertificate from './add-certificate';
import FindAccounts from './find-accounts';
import * as t from '../../utils/translation-util';
import BaseSection = require('./base-section'); interface ConnectedOrgSectionProps {
import DomainUtil = require('../../utils/domain-util'); $root: Element;
import ServerInfoForm = require('./server-info-form'); }
import AddCertificate = require('./add-certificate');
import FindAccounts = require('./find-accounts');
import t = require('../../utils/translation-util');
class ConnectedOrgSection extends BaseSection { export default class ConnectedOrgSection extends BaseSection {
// TODO: TypeScript - Here props should be object type props: ConnectedOrgSectionProps;
props: any;
$serverInfoContainer: Element | null; $serverInfoContainer: Element | null;
$existingServers: Element | null; $existingServers: Element | null;
$newOrgButton: HTMLButtonElement | null; $newOrgButton: HTMLButtonElement | null;
$addCertificateContainer: Element | null; $addCertificateContainer: Element | null;
$findAccountsContainer: Element | null; $findAccountsContainer: Element | null;
constructor(props: any) { constructor(props: ConnectedOrgSectionProps) {
super(); super();
this.props = props; this.props = props;
} }
@@ -57,10 +58,10 @@ class ConnectedOrgSection extends BaseSection {
// Show noServerText if no servers are there otherwise hide it // Show noServerText if no servers are there otherwise hide it
this.$existingServers.innerHTML = servers.length === 0 ? noServerText : ''; this.$existingServers.innerHTML = servers.length === 0 ? noServerText : '';
for (let i = 0; i < servers.length; i++) { for (const [i, server] of servers.entries()) {
new ServerInfoForm({ new ServerInfoForm({
$root: this.$serverInfoContainer, $root: this.$serverInfoContainer,
server: servers[i], server,
index: i, index: i,
onChange: this.reloadApp onChange: this.reloadApp
}).init(); }).init();
@@ -86,5 +87,3 @@ class ConnectedOrgSection extends BaseSection {
}).init(); }).init();
} }
} }
export = ConnectedOrgSection;

View File

@@ -1,17 +1,19 @@
'use-strict'; 'use-strict';
import { shell } from 'electron'; import BaseComponent from '../../components/base';
import * as LinkUtil from '../../utils/link-util';
import * as t from '../../utils/translation-util';
import BaseComponent = require('../../components/base'); interface FindAccountsProps {
import t = require('../../utils/translation-util'); $root: Element;
}
class FindAccounts extends BaseComponent { export default class FindAccounts extends BaseComponent {
// TODO: TypeScript - Here props should be object type props: FindAccountsProps;
props: any;
$findAccounts: Element | null; $findAccounts: Element | null;
$findAccountsButton: Element | null; $findAccountsButton: Element | null;
$serverUrlField: HTMLInputElement | null; $serverUrlField: HTMLInputElement | null;
constructor(props: any) { constructor(props: FindAccountsProps) {
super(); super();
this.props = props; this.props = props;
} }
@@ -38,19 +40,21 @@ class FindAccounts extends BaseComponent {
this.initListeners(); this.initListeners();
} }
findAccounts(url: string): void { async findAccounts(url: string): Promise<void> {
if (!url) { if (!url) {
return; return;
} }
if (!url.startsWith('http')) { if (!url.startsWith('http')) {
url = 'https://' + url; url = 'https://' + url;
} }
shell.openExternal(url + '/accounts/find');
await LinkUtil.openBrowser(new URL('/accounts/find', url));
} }
initListeners(): void { initListeners(): void {
this.$findAccountsButton.addEventListener('click', () => { this.$findAccountsButton.addEventListener('click', async () => {
this.findAccounts(this.$serverUrlField.value); await this.findAccounts(this.$serverUrlField.value);
}); });
this.$serverUrlField.addEventListener('click', () => { this.$serverUrlField.addEventListener('click', () => {
@@ -59,9 +63,9 @@ class FindAccounts extends BaseComponent {
} }
}); });
this.$serverUrlField.addEventListener('keypress', event => { this.$serverUrlField.addEventListener('keypress', async event => {
if (event.keyCode === 13) { if (event.key === 'Enter') {
this.findAccounts(this.$serverUrlField.value); await this.findAccounts(this.$serverUrlField.value);
} }
}); });
@@ -74,5 +78,3 @@ class FindAccounts extends BaseComponent {
}); });
} }
} }
export = FindAccounts;

View File

@@ -1,21 +1,26 @@
'use strict'; import {ipcRenderer, remote, OpenDialogOptions} from 'electron';
import { ipcRenderer, remote, OpenDialogOptions } from 'electron';
import path = require('path'); import path from 'path';
import fs = require('fs-extra'); import fs from 'fs-extra';
const { app, dialog } = remote; const {app, dialog, session} = remote;
const currentBrowserWindow = remote.getCurrentWindow(); const currentBrowserWindow = remote.getCurrentWindow();
import BaseSection = require('./base-section'); import BaseSection from './base-section';
import ConfigUtil = require('../../utils/config-util'); import * as ConfigUtil from '../../utils/config-util';
import EnterpriseUtil = require('./../../utils/enterprise-util'); import * as EnterpriseUtil from '../../utils/enterprise-util';
import t = require('../../utils/translation-util'); import * as t from '../../utils/translation-util';
import supportedLocales from '../../../../translations/supported-locales.json';
import Tagify from '@yaireo/tagify';
import ISO6391 from 'iso-639-1';
class GeneralSection extends BaseSection { interface GeneralSectionProps {
// TODO: TypeScript - Here props should be object type $root: Element;
props: any; }
constructor(props: any) {
export default class GeneralSection extends BaseSection {
props: GeneralSectionProps;
constructor(props: GeneralSectionProps) {
super(); super();
this.props = props; this.props = props;
} }
@@ -42,7 +47,7 @@ class GeneralSection extends BaseSection {
<div class="setting-control"></div> <div class="setting-control"></div>
</div> </div>
<div class="setting-row" id="dock-bounce-option" style= "display:${process.platform === 'darwin' ? '' : 'none'}"> <div class="setting-row" id="dock-bounce-option" style= "display:${process.platform === 'darwin' ? '' : 'none'}">
<div class="setting-description">${t.__('Bounce dock on new private message')})}</div> <div class="setting-description">${t.__('Bounce dock on new private message')}</div>
<div class="setting-control"></div> <div class="setting-control"></div>
</div> </div>
<div class="setting-row" id="flash-taskbar-option" style= "display:${process.platform === 'win32' ? '' : 'none'}"> <div class="setting-row" id="flash-taskbar-option" style= "display:${process.platform === 'win32' ? '' : 'none'}">
@@ -82,21 +87,31 @@ class GeneralSection extends BaseSection {
<div class="setting-description">${t.__('Always start minimized')}</div> <div class="setting-description">${t.__('Always start minimized')}</div>
<div class="setting-control"></div> <div class="setting-control"></div>
</div> </div>
<div class="setting-row" id="quitOnClose-option">
<div class="setting-description">${t.__('Quit when the window is closed')}</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="enable-spellchecker-option"> <div class="setting-row" id="enable-spellchecker-option">
<div class="setting-description">${t.__('Enable spellchecker (requires restart)')}</div> <div class="setting-description">${t.__('Enable spellchecker (requires restart)')}</div>
<div class="setting-control"></div> <div class="setting-control"></div>
</div> </div>
<div class="setting-row" id="spellcheck-langs" style= "display:${process.platform === 'darwin' ? 'none' : ''}"></div>
<div class="setting-row" id="note"></div>
</div> </div>
<div class="title">${t.__('Advanced')}</div> <div class="title">${t.__('Advanced')}</div>
<div class="settings-card"> <div class="settings-card">
<div class="setting-row" id="enable-error-reporting"> <div class="setting-row" id="enable-error-reporting">
<div class="setting-description">${t.__('Enable error reporting (requires restart)')}</div> <div class="setting-description">${t.__('Enable error reporting (requires restart)')}</div>
<div class="setting-control"></div> <div class="setting-control"></div>
</div> </div>
<div class="setting-row" id="show-download-folder">
<div class="setting-description">${t.__('Show downloaded files in file manager')}</div> <div class="setting-row" id="app-language">
<div class="setting-control"></div> <div class="setting-description">${t.__('App language (requires restart)')}</div>
<div id="lang-div" class="lang-div"></div>
</div> </div>
<div class="setting-row" id="add-custom-css"> <div class="setting-row" id="add-custom-css">
<div class="setting-description"> <div class="setting-description">
${t.__('Add custom CSS')} ${t.__('Add custom CSS')}
@@ -105,7 +120,7 @@ class GeneralSection extends BaseSection {
</div> </div>
<div class="setting-row" id="remove-custom-css"> <div class="setting-row" id="remove-custom-css">
<div class="setting-description"> <div class="setting-description">
<div class="selected-css-path" id="custom-css-path">${ConfigUtil.getConfigItem('customCSS')}</div> <div class="selected-css-path" id="custom-css-path">${ConfigUtil.getConfigString('customCSS', '')}</div>
</div> </div>
<div class="action red" id="css-delete-action"> <div class="action red" id="css-delete-action">
<i class="material-icons">indeterminate_check_box</i> <i class="material-icons">indeterminate_check_box</i>
@@ -120,17 +135,20 @@ class GeneralSection extends BaseSection {
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-description"> <div class="setting-description">
<div class="download-folder-path">${ConfigUtil.getConfigItem('downloadsPath', `${app.getPath('downloads')}`)}</div> <div class="download-folder-path">${ConfigUtil.getConfigString('downloadsPath', app.getPath('downloads'))}</div>
</div> </div>
</div> </div>
<div class="setting-row" id="prompt-download">
<div class="setting-description">${t.__('Ask where to save files before downloading')}</div>
<div class="setting-control"></div>
</div> </div>
<div class="title">${t.__('Reset Application Data')}</div> </div>
<div class="title">${t.__('Factory Reset Data')}</div>
<div class="settings-card"> <div class="settings-card">
<div class="setting-row" id="resetdata-option"> <div class="setting-row" id="factory-reset-option">
<div class="setting-description">${t.__('This will delete all application data including all added accounts and preferences')} <div class="setting-description">${t.__('Reset the application, thus deleting all the connected organizations, accounts, and certificates.')}
</div> </div>
<button class="reset-data-button red w-150">${t.__('Reset App Data')}</button> <button class="factory-reset-button red w-150">${t.__('Factory Reset')}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -146,7 +164,7 @@ class GeneralSection extends BaseSection {
this.betaUpdateOption(); this.betaUpdateOption();
this.updateSidebarOption(); this.updateSidebarOption();
this.updateStartAtLoginOption(); this.updateStartAtLoginOption();
this.updateResetDataOption(); this.factoryReset();
this.showDesktopNotification(); this.showDesktopNotification();
this.enableSpellchecker(); this.enableSpellchecker();
this.minimizeOnStart(); this.minimizeOnStart();
@@ -154,8 +172,11 @@ class GeneralSection extends BaseSection {
this.showCustomCSSPath(); this.showCustomCSSPath();
this.removeCustomCSS(); this.removeCustomCSS();
this.downloadFolder(); this.downloadFolder();
this.showDownloadFolder(); this.updateQuitOnCloseOption();
this.updatePromptDownloadOption();
this.enableErrorReporting(); this.enableErrorReporting();
this.setLocale();
this.initSpellChecker();
// Platform specific settings // Platform specific settings
@@ -163,6 +184,7 @@ class GeneralSection extends BaseSection {
if (process.platform === 'win32') { if (process.platform === 'win32') {
this.updateFlashTaskbar(); this.updateFlashTaskbar();
} }
// Dock bounce on macOS // Dock bounce on macOS
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
this.updateDockBouncing(); this.updateDockBouncing();
@@ -249,6 +271,7 @@ class GeneralSection extends BaseSection {
ConfigUtil.setConfigItem('betaUpdate', false); ConfigUtil.setConfigItem('betaUpdate', false);
this.betaUpdateOption(); this.betaUpdateOption();
} }
this.autoUpdateOption(); this.autoUpdateOption();
} }
}); });
@@ -276,9 +299,7 @@ class GeneralSection extends BaseSection {
const newValue = !ConfigUtil.getConfigItem('silent', true); const newValue = !ConfigUtil.getConfigItem('silent', true);
ConfigUtil.setConfigItem('silent', newValue); ConfigUtil.setConfigItem('silent', newValue);
this.updateSilentOption(); this.updateSilentOption();
// TODO: TypeScript: currentWindow of type BrowserWindow doesn't currentBrowserWindow.webContents.send('toggle-silent', newValue);
// have a .send() property per typescript.
(currentBrowserWindow as any).send('toggle-silent', newValue);
} }
}); });
} }
@@ -321,6 +342,18 @@ class GeneralSection extends BaseSection {
}); });
} }
updateQuitOnCloseOption(): void {
this.generateSettingOption({
$element: document.querySelector('#quitOnClose-option .setting-control'),
value: ConfigUtil.getConfigItem('quitOnClose', false),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('quitOnClose');
ConfigUtil.setConfigItem('quitOnClose', newValue);
this.updateQuitOnCloseOption();
}
});
}
enableSpellchecker(): void { enableSpellchecker(): void {
this.generateSettingOption({ this.generateSettingOption({
$element: document.querySelector('#enable-spellchecker-option .setting-control'), $element: document.querySelector('#enable-spellchecker-option .setting-control'),
@@ -329,6 +362,10 @@ class GeneralSection extends BaseSection {
const newValue = !ConfigUtil.getConfigItem('enableSpellchecker'); const newValue = !ConfigUtil.getConfigItem('enableSpellchecker');
ConfigUtil.setConfigItem('enableSpellchecker', newValue); ConfigUtil.setConfigItem('enableSpellchecker', newValue);
this.enableSpellchecker(); this.enableSpellchecker();
const spellcheckerLanguageInput: HTMLElement = document.querySelector('#spellcheck-langs');
const spellcheckerNote: HTMLElement = document.querySelector('#note');
spellcheckerLanguageInput.style.display = spellcheckerLanguageInput.style.display === 'none' ? '' : 'none';
spellcheckerNote.style.display = spellcheckerNote.style.display === 'none' ? '' : 'none';
} }
}); });
} }
@@ -345,43 +382,34 @@ class GeneralSection extends BaseSection {
}); });
} }
clearAppDataDialog(): void { async customCssDialog(): Promise<void> {
const clearAppDataMessage = 'By clicking proceed you will be removing all added accounts and preferences from Zulip. When the application restarts, it will be as if you are starting Zulip for the first time.';
const getAppPath = path.join(app.getPath('appData'), app.getName());
dialog.showMessageBox({
type: 'warning',
buttons: ['YES', 'NO'],
defaultId: 0,
message: 'Are you sure',
detail: clearAppDataMessage
}, response => {
if (response === 0) {
fs.remove(getAppPath);
setTimeout(() => ipcRenderer.send('forward-message', 'hard-reload'), 1000);
}
});
}
customCssDialog(): void {
const showDialogOptions: OpenDialogOptions = { const showDialogOptions: OpenDialogOptions = {
title: 'Select file', title: 'Select file',
properties: ['openFile'], properties: ['openFile'],
filters: [{ name: 'CSS file', extensions: ['css'] }] filters: [{name: 'CSS file', extensions: ['css']}]
}; };
dialog.showOpenDialog(showDialogOptions, selectedFile => { const {filePaths, canceled} = await dialog.showOpenDialog(showDialogOptions);
if (selectedFile) { if (!canceled) {
ConfigUtil.setConfigItem('customCSS', selectedFile[0]); ConfigUtil.setConfigItem('customCSS', filePaths[0]);
ipcRenderer.send('forward-message', 'hard-reload'); ipcRenderer.send('forward-message', 'hard-reload');
} }
});
} }
updateResetDataOption(): void { setLocale(): void {
const resetDataButton = document.querySelector('#resetdata-option .reset-data-button'); const langDiv: HTMLSelectElement = document.querySelector('.lang-div');
resetDataButton.addEventListener('click', () => { const langList = this.generateSelectTemplate(supportedLocales, 'lang-menu');
this.clearAppDataDialog(); langDiv.innerHTML += langList;
// `langMenu` is the select-option dropdown menu formed after executing the previous command
const langMenu: HTMLSelectElement = document.querySelector('.lang-menu');
// The next three lines set the selected language visible on the dropdown button
let language = ConfigUtil.getConfigItem('appLanguage');
language = language && langMenu.options.namedItem(language) ? language : 'en';
langMenu.options.namedItem(language).selected = true;
langMenu.addEventListener('change', (event: Event) => {
ConfigUtil.setConfigItem('appLanguage', (event.target as HTMLSelectElement).value);
}); });
} }
@@ -399,8 +427,8 @@ class GeneralSection extends BaseSection {
addCustomCSS(): void { addCustomCSS(): void {
const customCSSButton = document.querySelector('#add-custom-css .custom-css-button'); const customCSSButton = document.querySelector('#add-custom-css .custom-css-button');
customCSSButton.addEventListener('click', () => { customCSSButton.addEventListener('click', async () => {
this.customCssDialog(); await this.customCssDialog();
}); });
} }
@@ -414,43 +442,140 @@ class GeneralSection extends BaseSection {
removeCustomCSS(): void { removeCustomCSS(): void {
const removeCSSButton = document.querySelector('#css-delete-action'); const removeCSSButton = document.querySelector('#css-delete-action');
removeCSSButton.addEventListener('click', () => { removeCSSButton.addEventListener('click', () => {
ConfigUtil.setConfigItem('customCSS', ""); ConfigUtil.setConfigItem('customCSS', '');
ipcRenderer.send('forward-message', 'hard-reload'); ipcRenderer.send('forward-message', 'hard-reload');
}); });
} }
downloadFolderDialog(): void { async downloadFolderDialog(): Promise<void> {
const showDialogOptions: OpenDialogOptions = { const showDialogOptions: OpenDialogOptions = {
title: 'Select Download Location', title: 'Select Download Location',
properties: ['openDirectory'] properties: ['openDirectory']
}; };
dialog.showOpenDialog(showDialogOptions, selectedFolder => { const {filePaths, canceled} = await dialog.showOpenDialog(showDialogOptions);
if (selectedFolder) { if (!canceled) {
ConfigUtil.setConfigItem('downloadsPath', selectedFolder[0]); ConfigUtil.setConfigItem('downloadsPath', filePaths[0]);
const downloadFolderPath: HTMLElement = document.querySelector('.download-folder-path'); const downloadFolderPath: HTMLElement = document.querySelector('.download-folder-path');
downloadFolderPath.innerText = selectedFolder[0]; downloadFolderPath.textContent = filePaths[0];
} }
});
} }
downloadFolder(): void { downloadFolder(): void {
const downloadFolder = document.querySelector('#download-folder .download-folder-button'); const downloadFolder = document.querySelector('#download-folder .download-folder-button');
downloadFolder.addEventListener('click', () => { downloadFolder.addEventListener('click', async () => {
this.downloadFolderDialog(); await this.downloadFolderDialog();
}); });
} }
showDownloadFolder(): void { updatePromptDownloadOption(): void {
this.generateSettingOption({ this.generateSettingOption({
$element: document.querySelector('#show-download-folder .setting-control'), $element: document.querySelector('#prompt-download .setting-control'),
value: ConfigUtil.getConfigItem('showDownloadFolder', false), value: ConfigUtil.getConfigItem('promptDownload', false),
clickHandler: () => { clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('showDownloadFolder'); const newValue = !ConfigUtil.getConfigItem('promptDownload');
ConfigUtil.setConfigItem('showDownloadFolder', newValue); ConfigUtil.setConfigItem('promptDownload', newValue);
this.showDownloadFolder(); this.updatePromptDownloadOption();
} }
}); });
} }
async factoryResetSettings(): Promise<void> {
const clearAppDataMessage = 'When the application restarts, it will be as if you have just downloaded Zulip app.';
const getAppPath = path.join(app.getPath('appData'), app.name);
const {response} = await dialog.showMessageBox({
type: 'warning',
buttons: ['YES', 'NO'],
defaultId: 0,
message: 'Are you sure?',
detail: clearAppDataMessage
});
if (response === 0) {
await fs.remove(getAppPath);
setTimeout(() => ipcRenderer.send('clear-app-settings'), 1000);
}
}
factoryReset(): void {
const factoryResetButton = document.querySelector('#factory-reset-option .factory-reset-button');
factoryResetButton.addEventListener('click', async () => {
await this.factoryResetSettings();
});
}
initSpellChecker(): void {
// The elctron API is a no-op on macOS and macOS default spellchecker is used.
if (process.platform === 'darwin') {
const note: HTMLElement = document.querySelector('#note');
note.append(t.__('On macOS, the OS spellchecker is used.'));
note.append(document.createElement('br'));
note.append(t.__('Change the language from System Preferences → Keyboard → Text → Spelling.'));
} else {
const note: HTMLElement = document.querySelector('#note');
note.append(t.__('You can select a maximum of 3 languages for spellchecking.'));
const spellDiv: HTMLElement = document.querySelector('#spellcheck-langs');
spellDiv.innerHTML += `
<div class="setting-description">${t.__('Spellchecker Languages')}</div>
<input name='spellcheck' placeholder='Enter Languages'>`;
const availableLanguages = session.fromPartition('persist:webviewsession').availableSpellCheckerLanguages;
let languagePairs: Map<string, string> = new Map();
availableLanguages.forEach((l: string) => {
if (ISO6391.validate(l)) {
languagePairs.set(ISO6391.getName(l), 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)));
const tagField: HTMLElement = document.querySelector('input[name=spellcheck]');
const tagify = new Tagify(tagField, {
whitelist: [...languagePairs.keys()],
enforceWhitelist: true,
maxTags: 3,
dropdown: {
enabled: 0,
maxItems: Infinity,
closeOnSelect: false,
highlightFirst: true
}
});
const configuredLanguages: string[] = ConfigUtil.getConfigItem('spellcheckerLanguages').map((code: string) => [...languagePairs].filter(pair => (pair[1] === code))[0][0]);
tagify.addTags(configuredLanguages);
tagField.addEventListener('change', event => {
if ((event.target as HTMLInputElement).value.length === 0) {
ConfigUtil.setConfigItem('spellcheckerLanguages', []);
ipcRenderer.send('set-spellcheck-langs');
} else {
const spellLangs: string[] = [...JSON.parse((event.target as HTMLInputElement).value).values()].map(elt => languagePairs.get(elt.value));
ConfigUtil.setConfigItem('spellcheckerLanguages', spellLangs);
ipcRenderer.send('set-spellcheck-langs');
}
});
}
// Do not display the spellchecker input and note if it is disabled
if (!ConfigUtil.getConfigItem('enableSpellchecker')) {
const spellcheckerLanguageInput: HTMLElement = document.querySelector('#spellcheck-langs');
const spellcheckerNote: HTMLElement = document.querySelector('#note');
spellcheckerLanguageInput.style.display = 'none';
spellcheckerNote.style.display = 'none';
}
}
} }
export = GeneralSection;

View File

@@ -1,14 +1,16 @@
'use strict'; import BaseComponent from '../../components/base';
import * as t from '../../utils/translation-util';
import BaseComponent = require('../../components/base'); interface PreferenceNavProps {
import t = require('../../utils/translation-util'); $root: Element;
onItemSelected: (navItem: string) => void;
}
class PreferenceNav extends BaseComponent { export default class PreferenceNav extends BaseComponent {
// TODO: TypeScript - Here props should be object type props: PreferenceNavProps;
props: any;
navItems: string[]; navItems: string[];
$el: Element; $el: Element;
constructor(props: any) { constructor(props: PreferenceNavProps) {
super(); super();
this.props = props; this.props = props;
this.navItems = ['General', 'Network', 'AddServer', 'Organizations', 'Shortcuts']; this.navItems = ['General', 'Network', 'AddServer', 'Organizations', 'Shortcuts'];
@@ -64,5 +66,3 @@ class PreferenceNav extends BaseComponent {
$item.classList.remove('active'); $item.classList.remove('active');
} }
} }
export = PreferenceNav;

View File

@@ -1,20 +1,21 @@
'use strict'; import {ipcRenderer} from 'electron';
import { ipcRenderer } from 'electron'; import BaseSection from './base-section';
import * as ConfigUtil from '../../utils/config-util';
import * as t from '../../utils/translation-util';
import BaseSection = require('./base-section'); interface NetworkSectionProps {
import ConfigUtil = require('../../utils/config-util'); $root: Element;
import t = require('../../utils/translation-util'); }
class NetworkSection extends BaseSection { export default class NetworkSection extends BaseSection {
// TODO: TypeScript - Here props should be object type props: NetworkSectionProps;
props: any;
$proxyPAC: HTMLInputElement; $proxyPAC: HTMLInputElement;
$proxyRules: HTMLInputElement; $proxyRules: HTMLInputElement;
$proxyBypass: HTMLInputElement; $proxyBypass: HTMLInputElement;
$proxySaveAction: Element; $proxySaveAction: Element;
$manualProxyBlock: Element; $manualProxyBlock: Element;
constructor(props: any) { constructor(props: NetworkSectionProps) {
super(); super();
this.props = props; this.props = props;
} }
@@ -104,11 +105,13 @@ class NetworkSection extends BaseSection {
ConfigUtil.setConfigItem('useManualProxy', !manualProxyValue); ConfigUtil.setConfigItem('useManualProxy', !manualProxyValue);
this.toggleManualProxySettings(!manualProxyValue); this.toggleManualProxySettings(!manualProxyValue);
} }
if (newValue === false) {
if (!newValue) {
// Remove proxy system proxy settings // Remove proxy system proxy settings
ConfigUtil.setConfigItem('proxyRules', ''); ConfigUtil.setConfigItem('proxyRules', '');
ipcRenderer.send('forward-message', 'reload-proxy', false); ipcRenderer.send('forward-message', 'reload-proxy', false);
} }
ConfigUtil.setConfigItem('useSystemProxy', newValue); ConfigUtil.setConfigItem('useSystemProxy', newValue);
this.updateProxyOption(); this.updateProxyOption();
} }
@@ -123,6 +126,7 @@ class NetworkSection extends BaseSection {
if (systemProxyValue && newValue) { if (systemProxyValue && newValue) {
ConfigUtil.setConfigItem('useSystemProxy', !systemProxyValue); ConfigUtil.setConfigItem('useSystemProxy', !systemProxyValue);
} }
ConfigUtil.setConfigItem('proxyRules', ''); ConfigUtil.setConfigItem('proxyRules', '');
ConfigUtil.setConfigItem('useManualProxy', newValue); ConfigUtil.setConfigItem('useManualProxy', newValue);
// Reload app only when turning manual proxy off, hence !newValue // Reload app only when turning manual proxy off, hence !newValue
@@ -132,5 +136,3 @@ class NetworkSection extends BaseSection {
}); });
} }
} }
export = NetworkSection;

View File

@@ -1,18 +1,23 @@
'use strict'; import {ipcRenderer, remote} from 'electron';
import { shell } from 'electron'; import BaseComponent from '../../components/base';
import * as DomainUtil from '../../utils/domain-util';
import * as LinkUtil from '../../utils/link-util';
import * as t from '../../utils/translation-util';
import BaseComponent = require('../../components/base'); const {dialog} = remote;
import DomainUtil = require('../../utils/domain-util');
import t = require('../../utils/translation-util');
class NewServerForm extends BaseComponent { interface NewServerFormProps {
// TODO: TypeScript - Here props should be object type $root: Element;
props: any; onChange: () => void;
}
export default class NewServerForm extends BaseComponent {
props: NewServerFormProps;
$newServerForm: Element; $newServerForm: Element;
$saveServerButton: HTMLButtonElement; $saveServerButton: HTMLButtonElement;
$newServerUrl: HTMLInputElement; $newServerUrl: HTMLInputElement;
constructor(props: any) { constructor(props: NewServerFormProps) {
super(); super();
this.props = props; this.props = props;
} }
@@ -25,19 +30,21 @@ class NewServerForm extends BaseComponent {
<input class="setting-input-value" autofocus placeholder="your-organization.zulipchat.com or zulip.your-organization.com"/> <input class="setting-input-value" autofocus placeholder="your-organization.zulipchat.com or zulip.your-organization.com"/>
</div> </div>
<div class="server-center"> <div class="server-center">
<div class="server-save-action">
<button id="connect">${t.__('Connect')}</button> <button id="connect">${t.__('Connect')}</button>
</div> </div>
</div>
<div class="server-center"> <div class="server-center">
<div class="divider"> <div class="divider">
<hr class="left"/>${t.__('OR')}<hr class="right" /> <hr class="left"/>${t.__('OR')}<hr class="right" />
</div> </div>
</div> </div>
<div class="server-center"> <div class="server-center">
<div class="server-save-action">
<button id="open-create-org-link">${t.__('Create a new organization')}</button> <button id="open-create-org-link">${t.__('Create a new organization')}</button>
</div> </div>
<div class="server-center">
<div class="server-network-option">
<span id="open-network-settings">${t.__('Network and Proxy Settings')}</span>
<i class="material-icons open-network-button">open_in_new</i>
</div>
</div> </div>
</div> </div>
`; `;
@@ -50,46 +57,55 @@ class NewServerForm extends BaseComponent {
initForm(): void { initForm(): void {
this.$newServerForm = this.generateNodeFromTemplate(this.template()); this.$newServerForm = this.generateNodeFromTemplate(this.template());
this.$saveServerButton = this.$newServerForm.querySelectorAll('.server-save-action')[0] as HTMLButtonElement; this.$saveServerButton = this.$newServerForm.querySelector('#connect');
this.props.$root.innerHTML = ''; this.props.$root.innerHTML = '';
this.props.$root.append(this.$newServerForm); this.props.$root.append(this.$newServerForm);
this.$newServerUrl = this.$newServerForm.querySelectorAll('input.setting-input-value')[0] as HTMLInputElement; this.$newServerUrl = this.$newServerForm.querySelectorAll('input.setting-input-value')[0] as HTMLInputElement;
} }
submitFormHandler(): void { async submitFormHandler(): Promise<void> {
this.$saveServerButton.children[0].innerHTML = 'Connecting...'; this.$saveServerButton.innerHTML = 'Connecting...';
DomainUtil.checkDomain(this.$newServerUrl.value).then(serverConf => { let serverConf;
DomainUtil.addDomain(serverConf).then(() => { try {
this.props.onChange(this.props.index); serverConf = await DomainUtil.checkDomain(this.$newServerUrl.value);
}); } catch (error) {
}, errorMessage => { this.$saveServerButton.innerHTML = 'Connect';
this.$saveServerButton.children[0].innerHTML = 'Connect'; await dialog.showMessageBox({
alert(errorMessage); type: 'error',
message: error.toString(),
buttons: ['OK']
}); });
return;
}
await DomainUtil.addDomain(serverConf);
this.props.onChange();
} }
openCreateNewOrgExternalLink(): void { openCreateNewOrgExternalLink(): void {
const link = 'https://zulipchat.com/new/'; const link = 'https://zulip.com/new/';
const externalCreateNewOrgEl = document.querySelector('#open-create-org-link'); const externalCreateNewOrgElement = document.querySelector('#open-create-org-link');
externalCreateNewOrgEl.addEventListener('click', () => { externalCreateNewOrgElement.addEventListener('click', async () => {
shell.openExternal(link); await LinkUtil.openBrowser(new URL(link));
}); });
} }
networkSettingsLink(): void {
const networkSettingsId = document.querySelectorAll('.server-network-option')[0];
networkSettingsId.addEventListener('click', () => ipcRenderer.send('forward-message', 'open-network-settings'));
}
initActions(): void { initActions(): void {
this.$saveServerButton.addEventListener('click', () => { this.$saveServerButton.addEventListener('click', async () => {
this.submitFormHandler(); await this.submitFormHandler();
}); });
this.$newServerUrl.addEventListener('keypress', event => { this.$newServerUrl.addEventListener('keypress', async event => {
const EnterkeyCode = event.keyCode; if (event.key === 'Enter') {
// Submit form when Enter key is pressed await this.submitFormHandler();
if (EnterkeyCode === 13) {
this.submitFormHandler();
} }
}); });
// open create new org link in default browser // Open create new org link in default browser
this.openCreateNewOrgExternalLink(); this.openCreateNewOrgExternalLink();
this.networkSettingsLink();
} }
} }
export = NewServerForm;

View File

@@ -1,18 +1,17 @@
'use strict'; import {ipcRenderer} from 'electron';
import { ipcRenderer } from 'electron'; import BaseComponent from '../../components/base';
import Nav from './nav';
import BaseComponent = require('../../components/base'); import ServersSection from './servers-section';
import Nav = require('./nav'); import GeneralSection from './general-section';
import ServersSection = require('./servers-section'); import NetworkSection from './network-section';
import GeneralSection = require('./general-section'); import ConnectedOrgSection from './connected-org-section';
import NetworkSection = require('./network-section'); import ShortcutsSection from './shortcuts-section';
import ConnectedOrgSection = require('./connected-org-section'); import type {DNDSettings} from '../../utils/dnd-util';
import ShortcutsSection = require('./shortcuts-section');
type Section = ServersSection | GeneralSection | NetworkSection | ConnectedOrgSection | ShortcutsSection; type Section = ServersSection | GeneralSection | NetworkSection | ConnectedOrgSection | ShortcutsSection;
class PreferenceView extends BaseComponent { export default class PreferenceView extends BaseComponent {
$sidebarContainer: Element; $sidebarContainer: Element;
$settingsContainer: Element; $settingsContainer: Element;
nav: Nav; nav: Nav;
@@ -37,8 +36,9 @@ class PreferenceView extends BaseComponent {
let nav = 'General'; let nav = 'General';
const hasTag = window.location.hash; const hasTag = window.location.hash;
if (hasTag) { if (hasTag) {
nav = hasTag.substring(1); nav = hasTag.slice(1);
} }
this.handleNavigation(nav); this.handleNavigation(nav);
} }
@@ -51,32 +51,38 @@ class PreferenceView extends BaseComponent {
}); });
break; break;
} }
case 'General': { case 'General': {
this.section = new GeneralSection({ this.section = new GeneralSection({
$root: this.$settingsContainer $root: this.$settingsContainer
}); });
break; break;
} }
case 'Organizations': { case 'Organizations': {
this.section = new ConnectedOrgSection({ this.section = new ConnectedOrgSection({
$root: this.$settingsContainer $root: this.$settingsContainer
}); });
break; break;
} }
case 'Network': { case 'Network': {
this.section = new NetworkSection({ this.section = new NetworkSection({
$root: this.$settingsContainer $root: this.$settingsContainer
}); });
break; break;
} }
case 'Shortcuts': { case 'Shortcuts': {
this.section = new ShortcutsSection({ this.section = new ShortcutsSection({
$root: this.$settingsContainer $root: this.$settingsContainer
}); });
break; break;
} }
default: break; default: break;
} }
this.section.init(); this.section.init();
window.location.hash = `#${navItem}`; window.location.hash = `#${navItem}`;
} }
@@ -107,7 +113,7 @@ class PreferenceView extends BaseComponent {
this.handleToggle('tray-option', state); this.handleToggle('tray-option', state);
}); });
ipcRenderer.on('toggle-dnd', (_event: Event, _state: boolean, newSettings: any) => { ipcRenderer.on('toggle-dnd', (_event: Event, _state: boolean, newSettings: DNDSettings) => {
this.handleToggle('show-notification-option', newSettings.showNotification); this.handleToggle('show-notification-option', newSettings.showNotification);
this.handleToggle('silent-option', newSettings.silent); this.handleToggle('silent-option', newSettings.silent);
@@ -122,5 +128,3 @@ window.addEventListener('load', () => {
const preferenceView = new PreferenceView(); const preferenceView = new PreferenceView();
preferenceView.init(); preferenceView.init();
}); });
export = PreferenceView;

View File

@@ -1,23 +1,27 @@
'use strict'; import {remote, ipcRenderer} from 'electron';
import { remote, ipcRenderer } from 'electron'; import BaseComponent from '../../components/base';
import * as DomainUtil from '../../utils/domain-util';
import * as Messages from '../../../../resources/messages';
import * as t from '../../utils/translation-util';
import BaseComponent = require('../../components/base'); const {dialog} = remote;
import DomainUtil = require('../../utils/domain-util');
import Messages = require('./../../../../resources/messages');
import t = require('../../utils/translation-util');
const { dialog } = remote; interface ServerInfoFormProps {
$root: Element;
server: DomainUtil.ServerConf;
index: number;
onChange: () => void;
}
class ServerInfoForm extends BaseComponent { export default class ServerInfoForm extends BaseComponent {
// TODO: TypeScript - Here props should be object type props: ServerInfoFormProps;
props: any;
$serverInfoForm: Element; $serverInfoForm: Element;
$serverInfoAlias: Element; $serverInfoAlias: Element;
$serverIcon: Element; $serverIcon: Element;
$deleteServerButton: Element; $deleteServerButton: Element;
$openServerButton: Element; $openServerButton: Element;
constructor(props: any) { constructor(props: ServerInfoFormProps) {
super(); super();
this.props = props; this.props = props;
} }
@@ -61,23 +65,22 @@ class ServerInfoForm extends BaseComponent {
} }
initActions(): void { initActions(): void {
this.$deleteServerButton.addEventListener('click', () => { this.$deleteServerButton.addEventListener('click', async () => {
dialog.showMessageBox({ const {response} = await dialog.showMessageBox({
type: 'warning', type: 'warning',
buttons: [t.__('YES'), t.__('NO')], buttons: [t.__('YES'), t.__('NO')],
defaultId: 0, defaultId: 0,
message: t.__('Are you sure you want to disconnect this organization?') message: t.__('Are you sure you want to disconnect this organization?')
}, response => { });
if (response === 0) { if (response === 0) {
if (DomainUtil.removeDomain(this.props.index)) { if (DomainUtil.removeDomain(this.props.index)) {
ipcRenderer.send('reload-full-app'); ipcRenderer.send('reload-full-app');
} else { } else {
const { title, content } = Messages.orgRemovalError(DomainUtil.getDomain(this.props.index).url); const {title, content} = Messages.orgRemovalError(DomainUtil.getDomain(this.props.index).url);
dialog.showErrorBox(title, content); dialog.showErrorBox(title, content);
} }
} }
}); });
});
this.$openServerButton.addEventListener('click', () => { this.$openServerButton.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'switch-server-tab', this.props.index); ipcRenderer.send('forward-message', 'switch-server-tab', this.props.index);
@@ -92,5 +95,3 @@ class ServerInfoForm extends BaseComponent {
}); });
} }
} }
export = ServerInfoForm;

View File

@@ -1,14 +1,15 @@
'use strict'; import BaseSection from './base-section';
import NewServerForm from './new-server-form';
import * as t from '../../utils/translation-util';
import BaseSection = require('./base-section'); interface ServersSectionProps {
import NewServerForm = require('./new-server-form'); $root: Element;
import t = require('../../utils/translation-util'); }
class ServersSection extends BaseSection { export default class ServersSection extends BaseSection {
// TODO: TypeScript - Here props should be object type props: ServersSectionProps;
props: any;
$newServerContainer: Element; $newServerContainer: Element;
constructor(props: any) { constructor(props: ServersSectionProps) {
super(); super();
this.props = props; this.props = props;
} }
@@ -46,5 +47,3 @@ class ServersSection extends BaseSection {
}).init(); }).init();
} }
} }
export = ServersSection;

View File

@@ -1,17 +1,18 @@
'use strict'; import BaseSection from './base-section';
import * as LinkUtil from '../../utils/link-util';
import * as t from '../../utils/translation-util';
import { shell } from 'electron'; interface ShortcutsSectionProps {
$root: Element;
}
import BaseSection = require('./base-section'); export default class ShortcutsSection extends BaseSection {
import t = require('../../utils/translation-util'); props: ShortcutsSectionProps;
constructor(props: ShortcutsSectionProps) {
class ShortcutsSection extends BaseSection {
// TODO: TypeScript - Here props should be object type
props: any;
constructor(props: any) {
super(); super();
this.props = props; this.props = props;
} }
// TODO - Deduplicate templateMac and templateWinLin functions. In theory // TODO - Deduplicate templateMac and templateWinLin functions. In theory
// they both should be the same the only thing different should be the userOSKey // they both should be the same the only thing different should be the userOSKey
// variable but there seems to be inconsistences between both function, one has more // variable but there seems to be inconsistences between both function, one has more
@@ -329,17 +330,16 @@ class ShortcutsSection extends BaseSection {
} }
openHotkeysExternalLink(): void { openHotkeysExternalLink(): void {
const link = 'https://zulipchat.com/help/keyboard-shortcuts'; const link = 'https://zulip.com/help/keyboard-shortcuts';
const externalCreateNewOrgEl = document.querySelector('#open-hotkeys-link'); const externalCreateNewOrgElement = document.querySelector('#open-hotkeys-link');
externalCreateNewOrgEl.addEventListener('click', () => { externalCreateNewOrgElement.addEventListener('click', async () => {
shell.openExternal(link); await LinkUtil.openBrowser(new URL(link));
}); });
} }
init(): void { init(): void {
this.props.$root.innerHTML = (process.platform === 'darwin') ? this.props.$root.innerHTML = (process.platform === 'darwin') ?
this.templateMac() : this.templateWinLin(); this.templateMac() : this.templateWinLin();
this.openHotkeysExternalLink(); this.openHotkeysExternalLink();
} }
} }
export = ShortcutsSection;

View File

@@ -1,39 +1,30 @@
'use strict'; import {contextBridge, ipcRenderer, webFrame} from 'electron';
import fs from 'fs';
import { ipcRenderer, shell } from 'electron'; import isDev from 'electron-is-dev';
import SetupSpellChecker from './spellchecker';
import LinkUtil = require('./utils/link-util'); import * as NetworkError from './pages/network';
import params = require('./utils/params-util');
interface PatchedGlobal extends NodeJS.Global {
logout: () => void;
shortcut: () => void;
}
const globalPatched = global as PatchedGlobal;
// eslint-disable-next-line import/no-unassigned-import // eslint-disable-next-line import/no-unassigned-import
require('./notification'); import './notification';
// Prevent drag and drop event in main process which prevents remote code executaion // Prevent drag and drop event in main process which prevents remote code executaion
require(__dirname + '/shared/preventdrag.js'); // eslint-disable-next-line import/no-unassigned-import
import './shared/preventdrag';
declare let window: ZulipWebWindow; import electron_bridge from './electron-bridge';
contextBridge.exposeInMainWorld('raw_electron_bridge', electron_bridge);
// eslint-disable-next-line @typescript-eslint/camelcase ipcRenderer.on('logout', () => {
window.electron_bridge = require('./electron-bridge');
const logout = (): void => {
// Create the menu for the below // Create the menu for the below
const dropdown: HTMLElement = document.querySelector('.dropdown-toggle'); const dropdown: HTMLElement = document.querySelector('.dropdown-toggle');
dropdown.click(); dropdown.click();
const nodes: NodeListOf<HTMLElement> = document.querySelectorAll('.dropdown-menu li:last-child a'); const nodes: NodeListOf<HTMLElement> = document.querySelectorAll('.dropdown-menu li:last-child a');
nodes[nodes.length - 1].click(); nodes[nodes.length - 1].click();
}; });
const shortcut = (): void => { ipcRenderer.on('shortcut', () => {
// Create the menu for the below // Create the menu for the below
const node: HTMLElement = document.querySelector('a[data-overlay-trigger=keyboard-shortcuts]'); const node: HTMLElement = document.querySelector('a[data-overlay-trigger=keyboard-shortcuts]');
// Additional check // Additional check
@@ -44,60 +35,45 @@ const shortcut = (): void => {
const dropdown: HTMLElement = document.querySelector('.dropdown-toggle'); const dropdown: HTMLElement = document.querySelector('.dropdown-toggle');
dropdown.click(); dropdown.click();
} }
};
process.once('loaded', (): void => {
globalPatched.logout = logout;
globalPatched.shortcut = shortcut;
}); });
// To prevent failing this script on linux we need to load it after the document loaded ipcRenderer.on('show-notification-settings', () => {
document.addEventListener('DOMContentLoaded', (): void => { // Create the menu for the below
if (params.isPageParams()) { const dropdown: HTMLElement = document.querySelector('.dropdown-toggle');
// Get the default language of the server dropdown.click();
const serverLanguage = page_params.default_language; // eslint-disable-line no-undef, @typescript-eslint/camelcase
if (serverLanguage) { const nodes: NodeListOf<HTMLElement> = document.querySelectorAll('.dropdown-menu li a');
// Init spellchecker nodes[2].click();
SetupSpellChecker.init(serverLanguage);
} const notificationItem: NodeListOf<HTMLElement> = document.querySelectorAll('.normal-settings-list li div');
// redirect users to network troubleshooting page
// Wait until the notification dom element shows up
setTimeout(() => {
notificationItem[2].click();
}, 100);
});
electron_bridge.once('zulip-loaded', () => {
// Redirect users to network troubleshooting page
const getRestartButton = document.querySelector('.restart_get_events_button'); const getRestartButton = document.querySelector('.restart_get_events_button');
if (getRestartButton) { if (getRestartButton) {
getRestartButton.addEventListener('click', () => { getRestartButton.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'reload-viewer'); ipcRenderer.send('forward-message', 'reload-viewer');
}); });
} }
// Open image attachment link in the lightbox instead of opening in the default browser
const { $, lightbox } = window;
$('#main_div').on('click', '.message_content p a', function (this: HTMLElement, e: Event) {
const url = $(this).attr('href');
if (LinkUtil.isImage(url)) {
const $img = $(this).parent().siblings('.message_inline_image').find('img');
// prevent the image link from opening in a new page.
e.preventDefault();
// prevent the message compose dialog from happening.
e.stopPropagation();
// Open image in the default browser if image preview is unavailable
if (!$img[0]) {
shell.openExternal(window.location.origin + url);
}
// Open image in lightbox
lightbox.open($img);
}
});
}
}); });
// Clean up spellchecker events after you navigate away from this page; window.addEventListener('load', (event: any): void => {
// otherwise, you may experience errors if (!event.target.URL.includes('app/renderer/network.html')) {
window.addEventListener('beforeunload', (): void => { return;
SetupSpellChecker.unsubscribeSpellChecker(); }
const $reconnectButton = document.querySelector('#reconnect');
const $settingsButton = document.querySelector('#settings');
NetworkError.init($reconnectButton, $settingsButton);
}); });
// electron's globalShortcut can cause unexpected results // Electron's globalShortcut can cause unexpected results
// so adding the reload shortcut in the old-school way // so adding the reload shortcut in the old-school way
// Zoom from numpad keys is not supported by electron, so adding it through listeners. // Zoom from numpad keys is not supported by electron, so adding it through listeners.
document.addEventListener('keydown', event => { document.addEventListener('keydown', event => {
@@ -112,3 +88,26 @@ document.addEventListener('keydown', event => {
ipcRenderer.send('forward-message', 'zoomActualSize'); ipcRenderer.send('forward-message', 'zoomActualSize');
} }
}); });
// Set user as active and update the time of last activity
ipcRenderer.on('set-active', () => {
if (isDev) {
console.log('active');
}
electron_bridge.idle_on_system = false;
electron_bridge.last_active_on_system = Date.now();
});
// Set user as idle and time of last activity is left unchanged
ipcRenderer.on('set-idle', () => {
if (isDev) {
console.log('idle');
}
electron_bridge.idle_on_system = true;
});
(async () => webFrame.executeJavaScript(
fs.readFileSync(require.resolve('./injected'), 'utf8')
))();

View File

@@ -1,5 +1,3 @@
'use strict';
// This is a security fix. Following function prevents drag and drop event in the app // This is a security fix. Following function prevents drag and drop event in the app
// so that attackers can't execute any remote code within the app // so that attackers can't execute any remote code within the app
// It doesn't affect the compose box so that users can still // It doesn't affect the compose box so that users can still
@@ -15,3 +13,5 @@ const preventDragAndDrop = (): void => {
}; };
preventDragAndDrop(); preventDragAndDrop();
export {};

View File

@@ -1,56 +0,0 @@
'use strict';
import { SpellCheckHandler, ContextMenuListener, ContextMenuBuilder } from 'electron-spellchecker';
import ConfigUtil = require('./utils/config-util');
import Logger = require('./utils/logger-util');
const logger = new Logger({
file: 'errors.log',
timestamp: true
});
class SetupSpellChecker {
SpellCheckHandler: typeof SpellCheckHandler;
contextMenuListener: typeof ContextMenuListener;
init(serverLanguage: string): void {
if (ConfigUtil.getConfigItem('enableSpellchecker')) {
this.enableSpellChecker();
}
this.enableContextMenu(serverLanguage);
}
enableSpellChecker(): void {
try {
this.SpellCheckHandler = new SpellCheckHandler();
} catch (err) {
logger.error(err);
}
}
enableContextMenu(serverLanguage: string): void {
if (this.SpellCheckHandler) {
this.SpellCheckHandler.attachToInput();
this.SpellCheckHandler.switchLanguage(serverLanguage);
this.SpellCheckHandler.currentSpellcheckerChanged.subscribe(() => {
this.SpellCheckHandler.switchLanguage(this.SpellCheckHandler.currentSpellcheckerLanguage);
});
}
const contextMenuBuilder = new ContextMenuBuilder(this.SpellCheckHandler);
this.contextMenuListener = new ContextMenuListener((info: object) => {
contextMenuBuilder.showPopupMenu(info);
});
}
unsubscribeSpellChecker(): void {
if (this.SpellCheckHandler) {
this.SpellCheckHandler.unsubscribe();
}
if (this.contextMenuListener) {
this.contextMenuListener.unsubscribe();
}
}
}
export = new SetupSpellChecker();

View File

@@ -1,19 +1,24 @@
'use strict'; import {ipcRenderer, remote, WebviewTag, NativeImage} from 'electron';
import { ipcRenderer, remote, WebviewTag, NativeImage } from 'electron';
import path = require('path'); import path from 'path';
import ConfigUtil = require('./utils/config-util.js'); import * as ConfigUtil from './utils/config-util';
const { Tray, Menu, nativeImage, BrowserWindow } = remote;
const APP_ICON = path.join(__dirname, '../../resources/tray', 'tray'); const {Tray, Menu, nativeImage, BrowserWindow} = remote;
declare let window: ZulipWebWindow; let tray: Electron.Tray;
const ICON_DIR = '../../resources/tray';
const TRAY_SUFFIX = 'tray';
const APP_ICON = path.join(__dirname, ICON_DIR, TRAY_SUFFIX);
const iconPath = (): string => { const iconPath = (): string => {
if (process.platform === 'linux') { if (process.platform === 'linux') {
return APP_ICON + 'linux.png'; return APP_ICON + 'linux.png';
} }
return APP_ICON + (process.platform === 'win32' ? 'win.ico' : 'osx.png');
return APP_ICON + (process.platform === 'win32' ? 'win.ico' : 'macOSTemplate.png');
}; };
let unread = 0; let unread = 0;
@@ -43,10 +48,9 @@ const config = {
thick: process.platform === 'win32' thick: process.platform === 'win32'
}; };
const renderCanvas = function (arg: number): Promise<HTMLCanvasElement> { const renderCanvas = function (arg: number): HTMLCanvasElement {
config.unreadCount = arg; config.unreadCount = arg;
return new Promise(resolve => {
const SIZE = config.size * config.pixelRatio; const SIZE = config.size * config.pixelRatio;
const PADDING = SIZE * 0.05; const PADDING = SIZE * 0.05;
const CENTER = SIZE / 2; const CENTER = SIZE / 2;
@@ -77,34 +81,26 @@ const renderCanvas = function (arg: number): Promise<HTMLCanvasElement> {
ctx.fillText('99+', CENTER, CENTER + (SIZE * 0.15)); ctx.fillText('99+', CENTER, CENTER + (SIZE * 0.15));
} else if (config.unreadCount < 10) { } else if (config.unreadCount < 10) {
ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`; ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.20)); ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.2));
} else { } else {
ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`; ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.15)); ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.15));
} }
resolve(canvas);
} }
});
return canvas;
}; };
/** /**
* Renders the tray icon as a native image * Renders the tray icon as a native image
* @param arg: Unread count * @param arg: Unread count
* @return the native image * @return the native image
*/ */
const renderNativeImage = function (arg: number): Promise<NativeImage> { const renderNativeImage = function (arg: number): NativeImage {
return Promise.resolve() const canvas = renderCanvas(arg);
.then(() => renderCanvas(arg))
.then(canvas => {
const pngData = nativeImage.createFromDataURL(canvas.toDataURL('image/png')).toPNG(); const pngData = nativeImage.createFromDataURL(canvas.toDataURL('image/png')).toPNG();
return nativeImage.createFromBuffer(pngData, {
// TODO: Fix the function to correctly use Promise correctly.
// the Promise.resolve().then(...) above is useless we should
// start with renderCanvas(arg).then
// eslint-disable-next-line promise/no-return-wrap
return Promise.resolve(nativeImage.createFromBuffer(pngData, {
scaleFactor: config.pixelRatio scaleFactor: config.pixelRatio
}));
}); });
}; };
@@ -119,7 +115,6 @@ function sendAction(action: string): void {
} }
const createTray = function (): void { const createTray = function (): void {
window.tray = new Tray(iconPath());
const contextMenu = Menu.buildFromTemplate([ const contextMenu = Menu.buildFromTemplate([
{ {
label: 'Zulip', label: 'Zulip',
@@ -144,22 +139,23 @@ const createTray = function (): void {
} }
} }
]); ]);
window.tray.setContextMenu(contextMenu); tray = new Tray(iconPath());
tray.setContextMenu(contextMenu);
if (process.platform === 'linux' || process.platform === 'win32') { if (process.platform === 'linux' || process.platform === 'win32') {
window.tray.on('click', () => { tray.on('click', () => {
ipcRenderer.send('toggle-app'); ipcRenderer.send('toggle-app');
}); });
} }
}; };
ipcRenderer.on('destroytray', (event: Event): Event => { ipcRenderer.on('destroytray', (event: Event): Event => {
if (!window.tray) { if (!tray) {
return undefined; return undefined;
} }
window.tray.destroy(); tray.destroy();
if (window.tray.isDestroyed()) { if (tray.isDestroyed()) {
window.tray = null; tray = null;
} else { } else {
throw new Error('Tray icon not properly destroyed.'); throw new Error('Tray icon not properly destroyed.');
} }
@@ -168,48 +164,50 @@ ipcRenderer.on('destroytray', (event: Event): Event => {
}); });
ipcRenderer.on('tray', (_event: Event, arg: number): void => { ipcRenderer.on('tray', (_event: Event, arg: number): void => {
if (!window.tray) { if (!tray) {
return; return;
} }
// We don't want to create tray from unread messages on macOS since it already has dock badges. // 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 (process.platform === 'linux' || process.platform === 'win32') {
if (arg === 0) { if (arg === 0) {
unread = arg; unread = arg;
window.tray.setImage(iconPath()); tray.setImage(iconPath());
window.tray.setToolTip('No unread messages'); tray.setToolTip('No unread messages');
} else { } else {
unread = arg; unread = arg;
renderNativeImage(arg).then(image => { const image = renderNativeImage(arg);
window.tray.setImage(image); tray.setImage(image);
window.tray.setToolTip(arg + ' unread messages'); tray.setToolTip(`${arg} unread messages`);
});
} }
} }
}); });
function toggleTray(): void { function toggleTray(): void {
let state; let state;
if (window.tray) { if (tray) {
state = false; state = false;
window.tray.destroy(); tray.destroy();
if (window.tray.isDestroyed()) { if (tray.isDestroyed()) {
window.tray = null; tray = null;
} }
ConfigUtil.setConfigItem('trayIcon', false); ConfigUtil.setConfigItem('trayIcon', false);
} else { } else {
state = true; state = true;
createTray(); createTray();
if (process.platform === 'linux' || process.platform === 'win32') { if (process.platform === 'linux' || process.platform === 'win32') {
renderNativeImage(unread).then(image => { const image = renderNativeImage(unread);
window.tray.setImage(image); tray.setImage(image);
window.tray.setToolTip(unread + ' unread messages'); tray.setToolTip(`${unread} unread messages`);
});
} }
ConfigUtil.setConfigItem('trayIcon', true); ConfigUtil.setConfigItem('trayIcon', true);
} }
const selector = 'webview:not([class*=disabled])'; const selector = 'webview:not([class*=disabled])';
const webview: WebviewTag = document.querySelector(selector); const webview: WebviewTag = document.querySelector(selector);
const webContents = webview.getWebContents(); const webContents = remote.webContents.fromId(webview.getWebContentsId());
webContents.send('toggletray', state); webContents.send('toggletray', state);
} }
@@ -218,3 +216,5 @@ ipcRenderer.on('toggletray', toggleTray);
if (ConfigUtil.getConfigItem('trayIcon', true)) { if (ConfigUtil.getConfigItem('trayIcon', true)) {
createTray(); createTray();
} }
export {};

View File

@@ -1,84 +1,68 @@
'use strict'; import electron from 'electron';
import {JsonDB} from 'node-json-db';
import {initSetUp} from './default-util';
import { remote } from 'electron'; import fs from 'fs';
import JsonDB from 'node-json-db'; import path from 'path';
import { initSetUp } from './default-util'; import Logger from './logger-util';
import fs = require('fs'); const {app, dialog} =
import path = require('path'); process.type === 'renderer' ? electron.remote : electron;
import Logger = require('./logger-util');
const { app, dialog } = remote;
initSetUp(); initSetUp();
const logger = new Logger({ const logger = new Logger({
file: `certificate-util.log`, file: 'certificate-util.log',
timestamp: true timestamp: true
}); });
let instance: null | CertificateUtil = null;
const certificatesDir = `${app.getPath('userData')}/certificates`; const certificatesDir = `${app.getPath('userData')}/certificates`;
class CertificateUtil { let db: JsonDB;
db: JsonDB;
constructor() { reloadDB();
if (instance) {
return instance;
} else {
instance = this;
}
this.reloadDB(); export function getCertificate(server: string): string | undefined {
return instance; reloadDB();
} return db.getData('/')[server];
}
getCertificate(server: string, defaultValue: any = null): any { // Function to copy the certificate to userData folder
this.reloadDB(); export function copyCertificate(_server: string, location: string, fileName: string): boolean {
const value = this.db.getData('/')[server];
if (value === undefined) {
return defaultValue;
} else {
return value;
}
}
// Function to copy the certificate to userData folder
copyCertificate(_server: string, location: string, fileName: string): boolean {
let copied = false; let copied = false;
const filePath = `${certificatesDir}/${fileName}`; const filePath = `${certificatesDir}/${fileName}`;
try { try {
fs.copyFileSync(location, filePath); fs.copyFileSync(location, filePath);
copied = true; copied = true;
} catch (err) { } catch (error) {
dialog.showErrorBox( dialog.showErrorBox(
'Error saving certificate', 'Error saving certificate',
'We encountered error while saving the certificate.' 'We encountered error while saving the certificate.'
); );
logger.error('Error while copying the certificate to certificates folder.'); logger.error('Error while copying the certificate to certificates folder.');
logger.error(err); logger.error(error);
} }
return copied; return copied;
} }
setCertificate(server: string, fileName: string): void { export function setCertificate(server: string, fileName: string): void {
const filePath = `${certificatesDir}/${fileName}`; const filePath = `${fileName}`;
this.db.push(`/${server}`, filePath, true); db.push(`/${server}`, filePath, true);
this.reloadDB(); reloadDB();
} }
removeCertificate(server: string): void { export function removeCertificate(server: string): void {
this.db.delete(`/${server}`); db.delete(`/${server}`);
this.reloadDB(); reloadDB();
} }
reloadDB(): void { function reloadDB(): void {
const settingsJsonPath = path.join(app.getPath('userData'), '/config/certificates.json'); const settingsJsonPath = path.join(app.getPath('userData'), '/config/certificates.json');
try { try {
const file = fs.readFileSync(settingsJsonPath, 'utf8'); const file = fs.readFileSync(settingsJsonPath, 'utf8');
JSON.parse(file); JSON.parse(file);
} catch (err) { } catch (error) {
if (fs.existsSync(settingsJsonPath)) { if (fs.existsSync(settingsJsonPath)) {
fs.unlinkSync(settingsJsonPath); fs.unlinkSync(settingsJsonPath);
dialog.showErrorBox( dialog.showErrorBox(
@@ -86,11 +70,9 @@ class CertificateUtil {
'We encountered error while saving the certificate.' 'We encountered error while saving the certificate.'
); );
logger.error('Error while JSON parsing certificates.json: '); logger.error('Error while JSON parsing certificates.json: ');
logger.error(err); logger.error(error);
} }
} }
this.db = new JsonDB(settingsJsonPath, true, true);
}
}
export = new CertificateUtil(); db = new JsonDB(settingsJsonPath, true, true);
}

View File

@@ -1,25 +1,8 @@
'use strict'; // Unescape already encoded/escaped strings
export function decodeString(stringInput: string): string {
let instance: null | CommonUtil = null;
class CommonUtil {
constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
return instance;
}
// unescape already encoded/escaped strings
decodeString(stringInput: string): string {
const parser = new DOMParser(); const parser = new DOMParser();
const dom = parser.parseFromString( const dom = parser.parseFromString(
'<!doctype html><body>' + stringInput, '<!doctype html><body>' + stringInput,
'text/html'); 'text/html');
return dom.body.textContent; return dom.body.textContent;
}
} }
export = new CommonUtil();

View File

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

View File

@@ -1,11 +1,12 @@
import fs = require('fs'); import electron from 'electron';
import fs from 'fs';
let app: Electron.App = null; let app: Electron.App = null;
let setupCompleted = false; let setupCompleted = false;
if (process.type === 'renderer') { if (process.type === 'renderer') {
app = require('electron').remote.app; app = electron.remote.app;
} else { } else {
app = require('electron').app; app = electron.app;
} }
const zulipDir = app.getPath('userData'); const zulipDir = app.getPath('userData');
@@ -13,7 +14,7 @@ const logDir = `${zulipDir}/Logs/`;
const certificatesDir = `${zulipDir}/certificates/`; const certificatesDir = `${zulipDir}/certificates/`;
const configDir = `${zulipDir}/config/`; const configDir = `${zulipDir}/config/`;
export const initSetUp = (): void => { export const initSetUp = (): void => {
// if it is the first time the app is running // If it is the first time the app is running
// create zulip dir in userData folder to // create zulip dir in userData folder to
// avoid errors // avoid errors
if (!setupCompleted) { if (!setupCompleted) {
@@ -41,19 +42,19 @@ export const initSetUp = (): void => {
const configData = [ const configData = [
{ {
path: domainJson, path: domainJson,
fileName: `domain.json` fileName: 'domain.json'
}, },
{ {
path: certificatesJson, path: certificatesJson,
fileName: `certificates.json` fileName: 'certificates.json'
}, },
{ {
path: settingsJson, path: settingsJson,
fileName: `settings.json` fileName: 'settings.json'
}, },
{ {
path: updatesJson, path: updatesJson,
fileName: `updates.json` fileName: 'updates.json'
} }
]; ];
configData.forEach(data => { configData.forEach(data => {
@@ -62,7 +63,7 @@ export const initSetUp = (): void => {
fs.unlinkSync(data.path); fs.unlinkSync(data.path);
} }
}); });
// window-state.json is only deleted not moved, as the electron-window-state // `window-state.json` is only deleted not moved, as the electron-window-state
// package will recreate the file in the config folder. // package will recreate the file in the config folder.
if (fs.existsSync(windowStateJson)) { if (fs.existsSync(windowStateJson)) {
fs.unlinkSync(windowStateJson); fs.unlinkSync(windowStateJson);

View File

@@ -1,30 +1,28 @@
'use strict'; import * as ConfigUtil from './config-util';
import ConfigUtil = require('./config-util'); type SettingName = 'showNotification' | 'silent' | 'flashTaskbarOnMessage';
// TODO: TypeScript - add to Setting interface export interface DNDSettings {
// the list of settings since we have fixed amount of them showNotification?: boolean;
// We want to do this by creating a new module that exports silent?: boolean;
// this interface flashTaskbarOnMessage?: boolean;
interface Setting {
[key: string]: any;
} }
interface Toggle { interface Toggle {
dnd: boolean; dnd: boolean;
newSettings: Setting; newSettings: DNDSettings;
} }
export function toggle(): Toggle { export function toggle(): Toggle {
const dnd = !ConfigUtil.getConfigItem('dnd', false); const dnd = !ConfigUtil.getConfigItem('dnd', false);
const dndSettingList = ['showNotification', 'silent']; const dndSettingList: SettingName[] = ['showNotification', 'silent'];
if (process.platform === 'win32') { if (process.platform === 'win32') {
dndSettingList.push('flashTaskbarOnMessage'); dndSettingList.push('flashTaskbarOnMessage');
} }
let newSettings: Setting; let newSettings: DNDSettings;
if (dnd) { if (dnd) {
const oldSettings: Setting = {}; const oldSettings: DNDSettings = {};
newSettings = {}; newSettings = {};
// Iterate through the dndSettingList. // Iterate through the dndSettingList.

View File

@@ -1,276 +1,179 @@
'use strict'; import {JsonDB} from 'node-json-db';
import JsonDB from 'node-json-db';
import escape = require('escape-html'); import fs from 'fs';
import request = require('request'); import path from 'path';
import fs = require('fs'); import Logger from './logger-util';
import path = require('path'); import {remote, ipcRenderer} from 'electron';
import Logger = require('./logger-util');
import electron = require('electron');
import RequestUtil = require('./request-util'); import * as EnterpriseUtil from './enterprise-util';
import EnterpriseUtil = require('./enterprise-util'); import * as Messages from '../../../resources/messages';
import Messages = require('../../../resources/messages');
const { app, dialog } = electron.remote; const {app, dialog} = remote;
export interface ServerConf {
url: string;
alias?: string;
icon?: string;
}
const logger = new Logger({ const logger = new Logger({
file: `domain-util.log`, file: 'domain-util.log',
timestamp: true timestamp: true
}); });
let instance: null | DomainUtil = null;
const defaultIconUrl = '../renderer/img/icon.png'; const defaultIconUrl = '../renderer/img/icon.png';
class DomainUtil { let db: JsonDB;
db: JsonDB;
constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
this.reloadDB(); reloadDB();
// Migrate from old schema // Migrate from old schema
if (this.db.getData('/').domain) { if (db.getData('/').domain) {
this.addDomain({ (async () => {
await addDomain({
alias: 'Zulip', alias: 'Zulip',
url: this.db.getData('/domain') url: db.getData('/domain')
}); });
this.db.delete('/domain'); db.delete('/domain');
} })();
}
return instance; export function getDomains(): ServerConf[] {
} reloadDB();
if (db.getData('/').domains === undefined) {
getDomains(): any {
this.reloadDB();
if (this.db.getData('/').domains === undefined) {
return []; return [];
} else {
return this.db.getData('/domains');
}
} }
getDomain(index: number): any { return db.getData('/domains');
this.reloadDB(); }
return this.db.getData(`/domains[${index}]`);
}
updateDomain(index: number, server: object): void { export function getDomain(index: number): ServerConf {
this.reloadDB(); reloadDB();
this.db.push(`/domains[${index}]`, server, true); return db.getData(`/domains[${index}]`);
} }
addDomain(server: any): Promise<void> { export function updateDomain(index: number, server: ServerConf): void {
const { ignoreCerts } = server; reloadDB();
return new Promise(resolve => { db.push(`/domains[${index}]`, server, true);
}
export async function addDomain(server: ServerConf): Promise<void> {
if (server.icon) { if (server.icon) {
this.saveServerIcon(server, ignoreCerts).then(localIconUrl => { const localIconUrl = await saveServerIcon(server);
server.icon = localIconUrl; server.icon = localIconUrl;
this.db.push('/domains[]', server, true); db.push('/domains[]', server, true);
this.reloadDB(); reloadDB();
resolve();
});
} else { } else {
server.icon = defaultIconUrl; server.icon = defaultIconUrl;
this.db.push('/domains[]', server, true); db.push('/domains[]', server, true);
this.reloadDB(); reloadDB();
resolve();
}
});
} }
}
removeDomains(): void { export function removeDomains(): void {
this.db.delete('/domains'); db.delete('/domains');
this.reloadDB(); reloadDB();
} }
removeDomain(index: number): boolean { export function removeDomain(index: number): boolean {
if (EnterpriseUtil.isPresetOrg(this.getDomain(index).url)) { if (EnterpriseUtil.isPresetOrg(getDomain(index).url)) {
return false;
}
this.db.delete(`/domains[${index}]`);
this.reloadDB();
return true;
}
// Check if domain is already added
duplicateDomain(domain: any): boolean {
domain = this.formatUrl(domain);
const servers = this.getDomains();
for (const i in servers) {
if (servers[i].url === domain) {
return true;
}
}
return false; return false;
} }
async checkCertError(domain: any, serverConf: any, error: string, silent: boolean): Promise<string | object> { db.delete(`/domains[${index}]`);
reloadDB();
return true;
}
// Check if domain is already added
export function duplicateDomain(domain: string): boolean {
domain = formatUrl(domain);
return getDomains().some(server => server.url === domain);
}
async function checkCertError(domain: string, serverConf: ServerConf, error: any, silent: boolean): Promise<ServerConf> {
if (silent) { if (silent) {
// since getting server settings has already failed // Since getting server settings has already failed
return serverConf; return serverConf;
} else { }
// Report error to sentry to get idea of possible certificate errors // Report error to sentry to get idea of possible certificate errors
// users get when adding the servers // users get when adding the servers
logger.reportSentry(new Error(error).toString()); logger.reportSentry(error);
const certErrorMessage = Messages.certErrorMessage(domain, error); const certErrorMessage = Messages.certErrorMessage(domain, error);
const certErrorDetail = Messages.certErrorDetail(); const certErrorDetail = Messages.certErrorDetail();
const response = await (dialog.showMessageBox({ await dialog.showMessageBox({
type: 'warning', type: 'error',
buttons: ['Yes', 'No'], buttons: ['OK'],
defaultId: 1,
message: certErrorMessage, message: certErrorMessage,
detail: certErrorDetail detail: certErrorDetail
}) as any); // TODO: TypeScript - Figure this out });
if (response === 0) {
// set ignoreCerts parameter to true in case user responds with yes
serverConf.ignoreCerts = true;
try {
return await this.getServerSettings(domain, serverConf.ignoreCerts);
} catch (_) {
if (error === Messages.noOrgsError(domain)) {
throw new Error(error);
}
return serverConf;
}
} else {
throw new Error('Untrusted certificate.'); throw new Error('Untrusted certificate.');
} }
}
}
// ignoreCerts parameter helps in fetching server icon and export async function checkDomain(domain: string, silent = false): Promise<ServerConf> {
// other server details when user chooses to ignore certificate warnings if (!silent && duplicateDomain(domain)) {
async checkDomain(domain: any, ignoreCerts = false, silent = false): Promise<any> {
if (!silent && this.duplicateDomain(domain)) {
// Do not check duplicate in silent mode // Do not check duplicate in silent mode
throw new Error('This server has been added.'); throw new Error('This server has been added.');
} }
domain = this.formatUrl(domain); domain = formatUrl(domain);
const serverConf = { const serverConf = {
icon: defaultIconUrl, icon: defaultIconUrl,
url: domain, url: domain,
alias: domain, alias: domain
ignoreCerts
}; };
try { try {
return await this.getServerSettings(domain, serverConf.ignoreCerts); return await getServerSettings(domain);
} catch (err) { } catch (error_) {
// If the domain contains following strings we just bypass the server // Make sure that error is an error or string not undefined
const whitelistDomains = [
'zulipdev.org'
];
// make sure that error is an error or string not undefined
// so validation does not throw error. // so validation does not throw error.
const error = err || ''; const error = error_ || '';
const certsError = error.toString().includes('certificate'); const certsError = error.toString().includes('certificate');
if (domain.indexOf(whitelistDomains) >= 0 || certsError) { if (certsError) {
return this.checkCertError(domain, serverConf, error, silent); const result = await checkCertError(domain, serverConf, error, silent);
} else { return result;
throw Messages.invalidZulipServerError(domain);
}
}
} }
getServerSettings(domain: any, ignoreCerts = false): Promise<object | string> { throw new Error(Messages.invalidZulipServerError(domain));
const serverSettingsOptions = {
url: domain + '/api/v1/server_settings',
...RequestUtil.requestOptions(domain, ignoreCerts)
};
return new Promise((resolve, reject) => {
request(serverSettingsOptions, (error: string, response: any) => {
if (!error && response.statusCode === 200) {
const data = JSON.parse(response.body);
if (data.hasOwnProperty('realm_icon') && data.realm_icon) {
resolve({
// Some Zulip Servers use absolute URL for server icon whereas others use relative URL
// Following check handles both the cases
icon: data.realm_icon.startsWith('/') ? data.realm_uri + data.realm_icon : data.realm_icon,
url: data.realm_uri,
alias: escape(data.realm_name),
ignoreCerts
});
} else {
reject(Messages.noOrgsError(domain));
}
} else {
reject(response);
}
});
});
} }
}
saveServerIcon(server: any, ignoreCerts = false): Promise<string> { async function getServerSettings(domain: string): Promise<ServerConf> {
const url = server.icon; return ipcRenderer.invoke('get-server-settings', domain);
const domain = server.url; }
const serverIconOptions = { export async function saveServerIcon(server: ServerConf): Promise<string> {
url, return ipcRenderer.invoke('save-server-icon', server.icon);
...RequestUtil.requestOptions(domain, ignoreCerts) }
};
// The save will always succeed. If url is invalid, downgrade to default icon. export async function updateSavedServer(url: string, index: number): Promise<void> {
return new Promise(resolve => {
const filePath = this.generateFilePath(url);
const file = fs.createWriteStream(filePath);
try {
request(serverIconOptions).on('response', (response: any) => {
response.on('error', (err: string) => {
logger.log('Could not get server icon.');
logger.log(err);
logger.reportSentry(err);
resolve(defaultIconUrl);
});
response.pipe(file).on('finish', () => {
resolve(filePath);
});
}).on('error', (err: string) => {
logger.log('Could not get server icon.');
logger.log(err);
logger.reportSentry(err);
resolve(defaultIconUrl);
});
} catch (err) {
logger.log('Could not get server icon.');
logger.log(err);
logger.reportSentry(err);
resolve(defaultIconUrl);
}
});
}
updateSavedServer(url: string, index: number): void {
// Does not promise successful update // Does not promise successful update
const oldIcon = this.getDomain(index).icon; const oldIcon = getDomain(index).icon;
const { ignoreCerts } = this.getDomain(index); try {
this.checkDomain(url, ignoreCerts, true).then(newServerConf => { const newServerConf = await checkDomain(url, true);
this.saveServerIcon(newServerConf, ignoreCerts).then(localIconUrl => { const localIconUrl = await saveServerIcon(newServerConf);
if (!oldIcon || localIconUrl !== '../renderer/img/icon.png') { if (!oldIcon || localIconUrl !== '../renderer/img/icon.png') {
newServerConf.icon = localIconUrl; newServerConf.icon = localIconUrl;
this.updateDomain(index, newServerConf); updateDomain(index, newServerConf);
this.reloadDB(); reloadDB();
} }
}); } catch (error) {
}); logger.log('Could not update server icon.');
logger.log(error);
logger.reportSentry(error);
} }
}
reloadDB(): void { function reloadDB(): void {
const domainJsonPath = path.join(app.getPath('userData'), 'config/domain.json'); const domainJsonPath = path.join(app.getPath('userData'), 'config/domain.json');
try { try {
const file = fs.readFileSync(domainJsonPath, 'utf8'); const file = fs.readFileSync(domainJsonPath, 'utf8');
JSON.parse(file); JSON.parse(file);
} catch (err) { } catch (error) {
if (fs.existsSync(domainJsonPath)) { if (fs.existsSync(domainJsonPath)) {
fs.unlinkSync(domainJsonPath); fs.unlinkSync(domainJsonPath);
dialog.showErrorBox( dialog.showErrorBox(
@@ -279,40 +182,22 @@ class DomainUtil {
'you may have to re-add your previous organizations back.' 'you may have to re-add your previous organizations back.'
); );
logger.error('Error while JSON parsing domain.json: '); logger.error('Error while JSON parsing domain.json: ');
logger.error(err); logger.error(error);
logger.reportSentry(err); logger.reportSentry(error);
} }
} }
this.db = new JsonDB(domainJsonPath, true, true);
}
generateFilePath(url: string): string { db = new JsonDB(domainJsonPath, true, true);
const dir = `${app.getPath('userData')}/server-icons`;
const extension = path.extname(url).split('?')[0];
let hash = 5381;
let len = url.length;
while (len) {
hash = (hash * 33) ^ url.charCodeAt(--len);
}
// Create 'server-icons' directory if not existed
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
return `${dir}/${hash >>> 0}${extension}`;
}
formatUrl(domain: any): string {
const hasPrefix = (domain.indexOf('http') === 0);
if (hasPrefix) {
return domain;
} else {
return (domain.indexOf('localhost:') >= 0) ? `http://${domain}` : `https://${domain}`;
}
}
} }
export = new DomainUtil(); export function formatUrl(domain: string): string {
if (domain.startsWith('http://') || domain.startsWith('https://')) {
return domain;
}
if (domain.startsWith('localhost:')) {
return `http://${domain}`;
}
return `https://${domain}`;
}

View File

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

View File

@@ -1,51 +1,45 @@
'use strict'; import {shell} from 'electron';
import escape from 'escape-html';
import fs from 'fs';
import os from 'os';
import path from 'path';
// TODO: TypeScript - Add @types/ export function isUploadsUrl(server: string, url: URL): boolean {
import wurl = require('wurl'); return url.origin === server && url.pathname.startsWith('/user_uploads/');
let instance: null | LinkUtil = null;
interface IsInternalResponse {
isInternalUrl: boolean;
isUploadsUrl: boolean;
} }
class LinkUtil { export async function openBrowser(url: URL): Promise<void> {
constructor() { if (['http:', 'https:', 'mailto:'].includes(url.protocol)) {
if (instance) { await shell.openExternal(url.href);
return instance;
} else { } else {
instance = this; // For security, indirect links to non-whitelisted protocols
// through a real web browser via a local HTML file.
const dir = fs.mkdtempSync(
path.join(os.tmpdir(), 'zulip-redirect-')
);
const file = path.join(dir, 'redirect.html');
fs.writeFileSync(file, `\
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="Refresh" content="0; url=${escape(url.href)}" />
<title>Redirecting</title>
<style>
html {
font-family: menu, "Helvetica Neue", sans-serif;
} }
</style>
return instance; </head>
} <body>
<p>Opening <a href="${escape(url.href)}">${escape(url.href)}</a>…</p>
isInternal(currentUrl: string, newUrl: string): IsInternalResponse { </body>
const currentDomain = wurl('hostname', currentUrl); </html>
const newDomain = wurl('hostname', newUrl); `);
shell.openItem(file);
const sameDomainUrl = (currentDomain === newDomain || newUrl === currentUrl + '/'); setTimeout(() => {
const isUploadsUrl = newUrl.includes(currentUrl + '/user_uploads/'); fs.unlinkSync(file);
const isInternalUrl = newUrl.includes('/#narrow') || isUploadsUrl; fs.rmdirSync(dir);
}, 15000);
return {
isInternalUrl: sameDomainUrl && isInternalUrl,
isUploadsUrl
};
}
isImage(url: string): boolean {
// test for images extension as well as urls like .png?s=100
const isImageUrl = /\.(bmp|gif|jpg|jpeg|png|webp)\?*.*$/i;
return isImageUrl.test(url);
}
isPDF(url: string): boolean {
// test for pdf extension
const isPDFUrl = /\.(pdf)\?*.*$/i;
return isPDFUrl.test(url);
} }
} }
export = new LinkUtil();

View File

@@ -1,10 +1,9 @@
'use strict'; import {JsonDB} from 'node-json-db';
import JsonDB from 'node-json-db';
import fs = require('fs'); import fs from 'fs';
import path = require('path'); import path from 'path';
import electron = require('electron'); import electron from 'electron';
import Logger = require('./logger-util'); import Logger from './logger-util';
const remote = const remote =
process.type === 'renderer' ? electron.remote : electron; process.type === 'renderer' ? electron.remote : electron;
@@ -15,50 +14,39 @@ const logger = new Logger({
}); });
/* To make the util runnable in both main and renderer process */ /* To make the util runnable in both main and renderer process */
const { dialog, app } = remote; const {dialog, app} = remote;
let instance: null | LinuxUpdateUtil = null;
class LinuxUpdateUtil { let db: JsonDB;
db: JsonDB;
constructor() { reloadDB();
if (instance) {
return instance;
} else {
instance = this;
}
this.reloadDB(); export function getUpdateItem(key: string, defaultValue: unknown = null): any {
return instance; reloadDB();
} const value = db.getData('/')[key];
getUpdateItem(key: string, defaultValue: any = null): any {
this.reloadDB();
const value = this.db.getData('/')[key];
if (value === undefined) { if (value === undefined) {
this.setUpdateItem(key, defaultValue); setUpdateItem(key, defaultValue);
return defaultValue; return defaultValue;
} else { }
return value; return value;
} }
}
setUpdateItem(key: string, value: any): void { export function setUpdateItem(key: string, value: unknown): void {
this.db.push(`/${key}`, value, true); db.push(`/${key}`, value, true);
this.reloadDB(); reloadDB();
} }
removeUpdateItem(key: string): void { export function removeUpdateItem(key: string): void {
this.db.delete(`/${key}`); db.delete(`/${key}`);
this.reloadDB(); reloadDB();
} }
reloadDB(): void { function reloadDB(): void {
const linuxUpdateJsonPath = path.join(app.getPath('userData'), '/config/updates.json'); const linuxUpdateJsonPath = path.join(app.getPath('userData'), '/config/updates.json');
try { try {
const file = fs.readFileSync(linuxUpdateJsonPath, 'utf8'); const file = fs.readFileSync(linuxUpdateJsonPath, 'utf8');
JSON.parse(file); JSON.parse(file);
} catch (err) { } catch (error) {
if (fs.existsSync(linuxUpdateJsonPath)) { if (fs.existsSync(linuxUpdateJsonPath)) {
fs.unlinkSync(linuxUpdateJsonPath); fs.unlinkSync(linuxUpdateJsonPath);
dialog.showErrorBox( dialog.showErrorBox(
@@ -66,11 +54,9 @@ class LinuxUpdateUtil {
'We encountered an error while saving the update notifications.' 'We encountered an error while saving the update notifications.'
); );
logger.error('Error while JSON parsing updates.json: '); logger.error('Error while JSON parsing updates.json: ');
logger.error(err); logger.error(error);
} }
} }
this.db = new JsonDB(linuxUpdateJsonPath, true, true);
}
}
export = new LinuxUpdateUtil(); db = new JsonDB(linuxUpdateJsonPath, true, true);
}

View File

@@ -1,19 +1,14 @@
import { Console as NodeConsole } from 'console'; // eslint-disable-line node/prefer-global/console import {Console} from 'console'; // eslint-disable-line node/prefer-global/console
import { initSetUp } from './default-util'; import {initSetUp} from './default-util';
import { sentryInit, captureException } from './sentry-util'; import {sentryInit, captureException} from './sentry-util';
import fs = require('fs'); import fs from 'fs';
import os = require('os'); import os from 'os';
import isDev = require('electron-is-dev'); import isDev from 'electron-is-dev';
import electron = require('electron'); import electron from 'electron';
// this interface adds [key: string]: any so
// we can do console[type] later on in the code
interface PatchedConsole extends Console {
[key: string]: any;
}
interface LoggerOptions { interface LoggerOptions {
timestamp?: any; timestamp?: true | (() => string);
file?: string; file?: string;
level?: boolean; level?: boolean;
logInDevMode?: boolean; logInDevMode?: boolean;
@@ -29,9 +24,9 @@ if (process.type === 'renderer') {
// Report Errors to Sentry only if it is enabled in settings // Report Errors to Sentry only if it is enabled in settings
// Gets the value of reportErrors from config-util for renderer process // Gets the value of reportErrors from config-util for renderer process
// For main process, sentryInit() is handled in index.js // For main process, sentryInit() is handled in index.js
const { ipcRenderer } = electron; const {ipcRenderer} = electron;
ipcRenderer.send('error-reporting'); ipcRenderer.send('error-reporting');
ipcRenderer.on('error-reporting-val', (_event: any, errorReporting: boolean) => { ipcRenderer.on('error-reporting-val', (_event: Event, errorReporting: boolean) => {
reportErrors = errorReporting; reportErrors = errorReporting;
if (reportErrors) { if (reportErrors) {
sentryInit(); sentryInit();
@@ -41,23 +36,31 @@ if (process.type === 'renderer') {
app = electron.app; app = electron.app;
} }
const browserConsole: PatchedConsole = console;
const logDir = `${app.getPath('userData')}/Logs`; const logDir = `${app.getPath('userData')}/Logs`;
class Logger { type Level = 'log' | 'debug' | 'info' | 'warn' | 'error';
nodeConsole: PatchedConsole; const levels: Level[] = ['log', 'debug', 'info', 'warn', 'error'];
timestamp: any; // TODO: TypeScript - Figure out how to make this work with string | Function. type LogMethod = (...args: unknown[]) => void;
export default class Logger {
log: LogMethod;
debug: LogMethod;
info: LogMethod;
warn: LogMethod;
error: LogMethod;
nodeConsole: Console;
timestamp?: () => string;
level: boolean; level: boolean;
logInDevMode: boolean; logInDevMode: boolean;
[key: string]: any;
constructor(opts: LoggerOptions = {}) { constructor(options: LoggerOptions = {}) {
let { let {
timestamp = true, timestamp = true,
file = 'console.log', file = 'console.log',
level = true, level = true,
logInDevMode = false logInDevMode = false
} = opts; } = options;
file = `${logDir}/${file}`; file = `${logDir}/${file}`;
if (timestamp === true) { if (timestamp === true) {
@@ -71,8 +74,8 @@ class Logger {
process.nextTick(() => this.trimLog(file)); process.nextTick(() => this.trimLog(file));
} }
const fileStream = fs.createWriteStream(file, { flags: 'a' }); const fileStream = fs.createWriteStream(file, {flags: 'a'});
const nodeConsole = new NodeConsole(fileStream); const nodeConsole = new Console(fileStream);
this.nodeConsole = nodeConsole; this.nodeConsole = nodeConsole;
this.timestamp = timestamp; this.timestamp = timestamp;
@@ -81,42 +84,38 @@ class Logger {
this.setUpConsole(); this.setUpConsole();
} }
_log(type: string, ...args: any[]): void { _log(type: Level, ...args: unknown[]): void {
const { const {
nodeConsole, timestamp, level, logInDevMode nodeConsole, timestamp, level, logInDevMode
} = this; } = this;
let nodeConsoleLog;
/* eslint-disable no-fallthrough */
switch (true) { switch (true) {
case typeof timestamp === 'function': case typeof timestamp === 'function':
args.unshift(timestamp() + ' |\t'); args.unshift(timestamp() + ' |\t');
// Fall through
case (level !== false): case (level):
args.unshift(type.toUpperCase() + ' |'); args.unshift(type.toUpperCase() + ' |');
// Fall through
case isDev || logInDevMode: case isDev || logInDevMode:
nodeConsoleLog = nodeConsole[type] || nodeConsole.log; nodeConsole[type](...args);
nodeConsoleLog.apply(null, args); // eslint-disable-line prefer-spread break;
default: break; default:
} }
/* eslint-enable no-fallthrough */
browserConsole[type].apply(null, args); console[type](...args);
} }
setUpConsole(): void { setUpConsole(): void {
for (const type in browserConsole) { for (const type of levels) {
this.setupConsoleMethod(type); this.setupConsoleMethod(type);
} }
} }
setupConsoleMethod(type: string): void { setupConsoleMethod(type: Level): void {
this[type] = (...args: any[]) => { this[type] = (...args: unknown[]) => this._log(type, ...args);
const log = this._log.bind(this, type, ...args);
log();
};
} }
getTimestamp(): string { getTimestamp(): string {
@@ -127,7 +126,7 @@ class Logger {
return timestamp; return timestamp;
} }
reportSentry(err: string): void { reportSentry(err: unknown): void {
if (reportErrors) { if (reportErrors) {
captureException(err); captureException(err);
} }
@@ -138,6 +137,7 @@ class Logger {
if (err) { if (err) {
throw err; throw err;
} }
const MAX_LOG_FILE_LINES = 500; const MAX_LOG_FILE_LINES = 500;
const logs = data.split(os.EOL); const logs = data.split(os.EOL);
const logLength = logs.length - 1; const logLength = logs.length - 1;
@@ -151,5 +151,3 @@ class Logger {
}); });
} }
} }
export = Logger;

View File

@@ -1,11 +0,0 @@
// This util function returns the page params if they're present else returns null
export function isPageParams(): null | object {
let webpageParams = null;
try {
// eslint-disable-next-line no-undef, @typescript-eslint/camelcase
webpageParams = page_params;
} catch (_) {
webpageParams = null;
}
return webpageParams;
}

View File

@@ -1,116 +1,61 @@
'use strict'; import * as ConfigUtil from './config-util';
import url = require('url'); export interface ProxyRule {
import ConfigUtil = require('./config-util');
let instance: null | ProxyUtil = null;
interface ProxyRule {
hostname?: string; hostname?: string;
port?: number; port?: number;
} }
class ProxyUtil { // TODO: Refactor to async function
constructor() { export async function resolveSystemProxy(mainWindow: Electron.BrowserWindow): Promise<void> {
if (instance) {
return instance;
} else {
instance = this;
}
return instance;
}
// Return proxy to be used for a particular uri, to be used for request
getProxy(_uri: string): ProxyRule | void {
const parsedUri = url.parse(_uri);
if (parsedUri === null) {
return;
}
const uri = parsedUri;
const proxyRules = ConfigUtil.getConfigItem('proxyRules', '').split(';');
// If SPS is on and system uses no proxy then request should not try to use proxy from
// environment. NO_PROXY = '*' makes request ignore all environment proxy variables.
if (proxyRules[0] === '') {
process.env.NO_PROXY = '*';
return;
}
const proxyRule: any = {};
if (uri.protocol === 'http:') {
proxyRules.forEach((proxy: string) => {
if (proxy.includes('http=')) {
proxyRule.hostname = proxy.split('http=')[1].trim().split(':')[0];
proxyRule.port = proxy.split('http=')[1].trim().split(':')[1];
}
});
return proxyRule;
}
if (uri.protocol === 'https:') {
proxyRules.forEach((proxy: string) => {
if (proxy.includes('https=')) {
proxyRule.hostname = proxy.split('https=')[1].trim().split(':')[0];
proxyRule.port = proxy.split('https=')[1].trim().split(':')[1];
}
});
return proxyRule;
}
}
// TODO: Refactor to async function
resolveSystemProxy(mainWindow: Electron.BrowserWindow): void {
const page = mainWindow.webContents; const page = mainWindow.webContents;
const ses = page.session; const ses = page.session;
const resolveProxyUrl = 'www.example.com'; const resolveProxyUrl = 'www.example.com';
// Check HTTP Proxy // Check HTTP Proxy
const httpProxy = new Promise(resolve => { const httpProxy = (async () => {
ses.resolveProxy('http://' + resolveProxyUrl, (proxy: string) => { const proxy = await ses.resolveProxy('http://' + resolveProxyUrl);
let httpString = ''; let httpString = '';
if (proxy !== 'DIRECT') { if (proxy !== 'DIRECT') {
// in case of proxy HTTPS url:port, windows gives first word as HTTPS while linux gives PROXY // In case of proxy HTTPS url:port, windows gives first word as HTTPS while linux gives PROXY
// for all other HTTP or direct url:port both uses PROXY // for all other HTTP or direct url:port both uses PROXY
if (proxy.includes('PROXY') || proxy.includes('HTTPS')) { if (proxy.includes('PROXY') || proxy.includes('HTTPS')) {
httpString = 'http=' + proxy.split('PROXY')[1] + ';'; httpString = 'http=' + proxy.split('PROXY')[1] + ';';
} }
} }
resolve(httpString);
}); return httpString;
}); })();
// Check HTTPS Proxy // Check HTTPS Proxy
const httpsProxy = new Promise(resolve => { const httpsProxy = (async () => {
ses.resolveProxy('https://' + resolveProxyUrl, (proxy: string) => { const proxy = await ses.resolveProxy('https://' + resolveProxyUrl);
let httpsString = ''; let httpsString = '';
if (proxy !== 'DIRECT' || proxy.includes('HTTPS')) { if (proxy !== 'DIRECT' || proxy.includes('HTTPS')) {
// in case of proxy HTTPS url:port, windows gives first word as HTTPS while linux gives PROXY // In case of proxy HTTPS url:port, windows gives first word as HTTPS while linux gives PROXY
// for all other HTTP or direct url:port both uses PROXY // for all other HTTP or direct url:port both uses PROXY
if (proxy.includes('PROXY') || proxy.includes('HTTPS')) { if (proxy.includes('PROXY') || proxy.includes('HTTPS')) {
httpsString += 'https=' + proxy.split('PROXY')[1] + ';'; httpsString += 'https=' + proxy.split('PROXY')[1] + ';';
} }
} }
resolve(httpsString);
}); return httpsString;
}); })();
// Check FTP Proxy // Check FTP Proxy
const ftpProxy = new Promise(resolve => { const ftpProxy = (async () => {
ses.resolveProxy('ftp://' + resolveProxyUrl, (proxy: string) => { const proxy = await ses.resolveProxy('ftp://' + resolveProxyUrl);
let ftpString = ''; let ftpString = '';
if (proxy !== 'DIRECT') { if (proxy !== 'DIRECT') {
if (proxy.includes('PROXY')) { if (proxy.includes('PROXY')) {
ftpString += 'ftp=' + proxy.split('PROXY')[1] + ';'; ftpString += 'ftp=' + proxy.split('PROXY')[1] + ';';
} }
} }
resolve(ftpString);
}); return ftpString;
}); })();
// Check SOCKS Proxy // Check SOCKS Proxy
const socksProxy = new Promise(resolve => { const socksProxy = (async () => {
ses.resolveProxy('socks4://' + resolveProxyUrl, (proxy: string) => { const proxy = await ses.resolveProxy('socks4://' + resolveProxyUrl);
let socksString = ''; let socksString = '';
if (proxy !== 'DIRECT') { if (proxy !== 'DIRECT') {
if (proxy.includes('SOCKS5')) { if (proxy.includes('SOCKS5')) {
@@ -121,11 +66,11 @@ class ProxyUtil {
socksString += 'socks=' + proxy.split('PROXY')[1] + ';'; socksString += 'socks=' + proxy.split('PROXY')[1] + ';';
} }
} }
resolve(socksString);
});
});
Promise.all([httpProxy, httpsProxy, ftpProxy, socksProxy]).then(values => { return socksString;
})();
const values = await Promise.all([httpProxy, httpsProxy, ftpProxy, socksProxy]);
let proxyString = ''; let proxyString = '';
values.forEach(proxy => { values.forEach(proxy => {
proxyString += proxy; proxyString += proxy;
@@ -135,8 +80,4 @@ class ProxyUtil {
if (useSystemProxy) { if (useSystemProxy) {
ConfigUtil.setConfigItem('proxyRules', proxyString); ConfigUtil.setConfigItem('proxyRules', proxyString);
} }
});
}
} }
export = new ProxyUtil();

View File

@@ -1,67 +1,68 @@
import isOnline = require('is-online'); import {ipcRenderer} from 'electron';
import Logger = require('./logger-util');
import type WebView from '../components/webview';
import backoff from 'backoff';
import Logger from './logger-util';
const logger = new Logger({ const logger = new Logger({
file: `domain-util.log`, file: 'domain-util.log',
timestamp: true timestamp: true
}); });
class ReconnectUtil { export default class ReconnectUtil {
// TODO: TypeScript - Figure out how to annotate serverManagerView webview: WebView;
// it should be ServerManagerView; maybe make it a generic so we can url: string;
// pass the class from main.js
serverManagerView: any;
alreadyReloaded: boolean; alreadyReloaded: boolean;
fibonacciBackoff: backoff.Backoff;
constructor(serverManagerView: any) { constructor(webview: WebView) {
this.serverManagerView = serverManagerView; this.webview = webview;
this.url = webview.props.url;
this.alreadyReloaded = false; this.alreadyReloaded = false;
this.clearState();
} }
clearState(): void { clearState(): void {
this.alreadyReloaded = false; this.fibonacciBackoff = backoff.fibonacci({
initialDelay: 5000,
maxDelay: 300000
});
}
async isOnline(): Promise<boolean> {
return ipcRenderer.invoke('is-online', this.url);
} }
pollInternetAndReload(): void { pollInternetAndReload(): void {
const pollInterval = setInterval(() => { this.fibonacciBackoff.backoff();
this._checkAndReload() this.fibonacciBackoff.on('ready', async () => {
.then(status => { if (await this._checkAndReload()) {
if (status) { this.fibonacciBackoff.reset();
this.alreadyReloaded = true; } else {
clearInterval(pollInterval); this.fibonacciBackoff.backoff();
} }
}); });
}, 1500);
} }
// TODO: Make this a async function async _checkAndReload(): Promise<boolean> {
_checkAndReload(): Promise<boolean> { if (this.alreadyReloaded) {
return new Promise(resolve => { return true;
if (!this.alreadyReloaded) { // eslint-disable-line no-negated-condition
isOnline()
.then((online: boolean) => {
if (online) {
if (!this.alreadyReloaded) {
this.serverManagerView.reloadCurrentView();
} }
if (await this.isOnline()) {
ipcRenderer.send('forward-message', 'reload-viewer');
logger.log('You\'re back online.'); logger.log('You\'re back online.');
return resolve(true); return true;
} }
logger.log('There is no internet connection, try checking network cables, modem and router.'); logger.log('There is no internet connection, try checking network cables, modem and router.');
const errMsgHolder = document.querySelector('#description'); const errorMessageHolder = document.querySelector('#description');
if (errMsgHolder) { if (errorMessageHolder) {
errMsgHolder.innerHTML = ` errorMessageHolder.innerHTML = `
<div>Your internet connection doesn't seem to work properly!</div> <div>Your internet connection doesn't seem to work properly!</div>
<div>Verify that it works and then click try again.</div>`; <div>Verify that it works and then click try again.</div>`;
} }
return resolve(false);
}); return false;
} else {
return resolve(true);
}
});
} }
} }
export = ReconnectUtil;

View File

@@ -1,73 +0,0 @@
import fs = require('fs');
import ConfigUtil = require('./config-util');
import Logger = require('./logger-util');
import ProxyUtil = require('./proxy-util');
import CertificateUtil = require('./certificate-util');
import SystemUtil = require('./system-util');
const logger = new Logger({
file: `request-util.log`,
timestamp: true
});
let instance: null | RequestUtil = null;
// TODO: TypeScript - Use ProxyRule for the proxy property
// we can do this now since we use export = ProxyUtil syntax
interface RequestUtilResponse {
ca: string;
proxy: string | void | object;
ecdhCurve: 'auto';
headers: { 'User-Agent': string };
rejectUnauthorized: boolean;
}
class RequestUtil {
constructor() {
if (!instance) {
instance = this;
}
return instance;
}
// ignoreCerts parameter helps in fetching server icon and
// other server details when user chooses to ignore certificate warnings
requestOptions(domain: string, ignoreCerts: boolean): RequestUtilResponse {
domain = this.formatUrl(domain);
const certificate = CertificateUtil.getCertificate(
encodeURIComponent(domain)
);
let certificateLocation = '';
if (certificate) {
// To handle case where certificate has been moved from the location in certificates.json
try {
certificateLocation = fs.readFileSync(certificate, 'utf8');
} catch (err) {
logger.warn(`Error while trying to get certificate: ${err}`);
}
}
const proxyEnabled = ConfigUtil.getConfigItem('useManualProxy') || ConfigUtil.getConfigItem('useSystemProxy');
// If certificate for the domain exists add it as a ca key in the request's parameter else consider only domain as the parameter for request
// Add proxy as a parameter if it is being used.
return {
ca: certificateLocation ? certificateLocation : '',
proxy: proxyEnabled ? ProxyUtil.getProxy(domain) : '',
ecdhCurve: 'auto',
headers: { 'User-Agent': SystemUtil.getUserAgent() },
rejectUnauthorized: !ignoreCerts
};
}
formatUrl(domain: string): string {
const hasPrefix = domain.startsWith('http', 0);
if (hasPrefix) {
return domain;
} else {
return domain.includes('localhost:') ? `http://${domain}` : `https://${domain}`;
}
}
}
const requestUtil = new RequestUtil();
export = requestUtil;

View File

@@ -1,21 +1,18 @@
import { init } from '@sentry/electron'; import {init} from '@sentry/electron';
import isDev = require('electron-is-dev'); import isDev from 'electron-is-dev';
import path = require('path');
import dotenv = require('dotenv');
dotenv.config({ path: path.resolve(__dirname, '/../../../../.env') });
export const sentryInit = (): void => { export const sentryInit = (): void => {
if (!isDev) { if (!isDev) {
init({ init({
dsn: process.env.SENTRY_DSN, dsn: 'https://628dc2f2864243a08ead72e63f94c7b1@sentry.io/204668',
// We should ignore this error since it's harmless and we know the reason behind this // We should ignore this error since it's harmless and we know the reason behind this
// This error mainly comes from the console logs. // This error mainly comes from the console logs.
// This is a temp solution until Sentry supports disabling the console logs // This is a temp solution until Sentry supports disabling the console logs
ignoreErrors: ['does not appear to be a valid Zulip server'] ignoreErrors: ['does not appear to be a valid Zulip server']
// sendTimeout: 30 // wait 30 seconds before considering the sending capture to have failed, default is 1 second /// sendTimeout: 30 // wait 30 seconds before considering the sending capture to have failed, default is 1 second
}); });
} }
}; };
export { captureException } from '@sentry/electron'; export {captureException} from '@sentry/electron';

View File

@@ -1,60 +1,39 @@
'use strict'; import {ipcRenderer} from 'electron';
import { remote } from 'electron';
import os = require('os'); import os from 'os';
const { app } = remote; export const connectivityERR: string[] = [
let instance: null | SystemUtil = null;
class SystemUtil {
connectivityERR: string[];
userAgent: string | null;
constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
this.connectivityERR = [
'ERR_INTERNET_DISCONNECTED', 'ERR_INTERNET_DISCONNECTED',
'ERR_PROXY_CONNECTION_FAILED', 'ERR_PROXY_CONNECTION_FAILED',
'ERR_CONNECTION_RESET', 'ERR_CONNECTION_RESET',
'ERR_NOT_CONNECTED', 'ERR_NOT_CONNECTED',
'ERR_NAME_NOT_RESOLVED', 'ERR_NAME_NOT_RESOLVED',
'ERR_NETWORK_CHANGED' 'ERR_NETWORK_CHANGED'
]; ];
this.userAgent = null;
return instance; const userAgent = ipcRenderer.sendSync('fetch-user-agent');
}
getOS(): string { export function getOS(): string {
const platform = os.platform(); const platform = os.platform();
if (platform === 'darwin') { if (platform === 'darwin') {
return 'Mac'; return 'Mac';
} else if (platform === 'linux') { }
if (platform === 'linux') {
return 'Linux'; return 'Linux';
} else if (platform === 'win32') { }
if (parseFloat(os.release()) < 6.2) {
if (platform === 'win32') {
if (Number.parseFloat(os.release()) < 6.2) {
return 'Windows 7'; return 'Windows 7';
} else { }
return 'Windows 10'; return 'Windows 10';
} }
} else {
return ''; return '';
}
}
setUserAgent(webViewUserAgent: string): void {
this.userAgent = `ZulipElectron/${app.getVersion()} ${webViewUserAgent}`;
}
getUserAgent(): string | null {
return this.userAgent;
}
} }
export = new SystemUtil(); export function getUserAgent(): string {
return userAgent;
}

View File

@@ -1,29 +0,0 @@
'use strict';
const path = require("path");
const electron = require("electron");
const i18n = require("i18n");
let instance = null;
let app = null;
/* To make the util runnable in both main and renderer process */
if (process.type === 'renderer') {
app = electron.remote.app;
}
else {
app = electron.app;
}
class TranslationUtil {
constructor() {
if (instance) {
return this;
}
instance = this;
i18n.configure({
directory: path.join(__dirname, '../../../translations/'),
register: this
});
}
__(phrase) {
return i18n.__({ phrase, locale: app.getLocale() });
}
}
module.exports = new TranslationUtil();

View File

@@ -1,35 +1,16 @@
'use strict'; import path from 'path';
import i18n from 'i18n';
import * as ConfigUtil from './config-util';
import path = require('path'); i18n.configure({
import electron = require('electron');
import i18n = require('i18n');
let instance: TranslationUtil = null;
let app: Electron.App = null;
/* To make the util runnable in both main and renderer process */
if (process.type === 'renderer') {
app = electron.remote.app;
} else {
app = electron.app;
}
class TranslationUtil {
constructor() {
if (instance) {
return this;
}
instance = this;
i18n.configure({
directory: path.join(__dirname, '../../../translations/'), directory: path.join(__dirname, '../../../translations/'),
register: this updateFiles: false
}); });
}
__(phrase: string): string { /* Fetches the current appLocale from settings.json */
return i18n.__({ phrase, locale: app.getLocale() ? app.getLocale() : 'en' }); const appLocale = ConfigUtil.getConfigItem('appLanguage');
}
/* If no locale present in the json, en is set default */
export function __(phrase: string): string {
return i18n.__({phrase, locale: appLocale ? appLocale : 'en'});
} }
export = new TranslationUtil();

View File

@@ -52,7 +52,7 @@
</div> </div>
<div id="feedback-modal"> <div id="feedback-modal">
<send-feedback></send-feedback> <send-feedback show-cancel-button="show"></send-feedback>
</div> </div>
</body> </body>

View File

@@ -9,14 +9,18 @@
<body> <body>
<div id="content"> <div id="content">
<div id="picture"><img src="img/zulip_network.png"></div> <div id="picture"><img src="img/zulip_network.png"></div>
<div id="title">Zulip can't connect</div> <div id="title">We can't connect to this organization</div>
<div id="description"> <div id="subtitle">This could be because</div>
<div>Your computer seems to be offline.</div> <ul id="description">
<div>We will keep trying to reconnect, or you can try now.</div> <li>You're not online or your proxy is misconfigured.</li>
<li>There is no Zulip organization hosted at this URL.</li>
<li>This Zulip organization is temporarily unavailable.</li>
<li>This Zulip organization has been moved or deleted.</li>
</ul>
<div id="buttons">
<div id="reconnect" class="button">Reconnect</div>
<div id="settings" class="button">Settings</div>
</div> </div>
<div id="reconnect">Try now</div>
</div> </div>
</body> </body>
<script src="js/pages/network.js"></script>
<script>require('./js/shared/preventdrag.js')</script>
</html> </html>

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<title>Zulip - Settings</title> <title>Zulip - Settings</title>
<link rel="stylesheet" href="css/preference.css" type="text/css" media="screen"> <link rel="stylesheet" href="css/preference.css" type="text/css" media="screen">
<link id="tagify-css" rel="stylesheet" href="data:text/css,">
</head> </head>
<body> <body>
<div id="content"> <div id="content">
@@ -13,6 +14,7 @@
</div> </div>
</body> </body>
<script> <script>
document.querySelector('#tagify-css').href = require.resolve('@yaireo/tagify/dist/tagify.css');
require('./js/pages/preference/preference.js'); require('./js/pages/preference/preference.js');
require('./js/shared/preventdrag.js') require('./js/shared/preventdrag.js')
</script> </script>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -3,48 +3,45 @@ interface DialogBoxError {
content: string; content: string;
} }
class Messages { export function invalidZulipServerError(domain: string): string {
invalidZulipServerError(domain: string): string {
return `${domain} does not appear to be a valid Zulip server. Make sure that return `${domain} does not appear to be a valid Zulip server. Make sure that
\n • You can connect to that URL in a web browser.\ \n • You can connect to that URL in a web browser.\
\n • If you need a proxy to connect to the Internet, that you've configured your proxy in the Network settings.\ \n • If you need a proxy to connect to the Internet, that you've configured your proxy in the Network settings.\
\n • It's a Zulip server. (The oldest supported version is 1.6).\ \n • It's a Zulip server. (The oldest supported version is 1.6).\
\n • The server has a valid certificate. (You can add custom certificates in Settings > Organizations).`; \n • The server has a valid certificate. (You can add custom certificates in Settings > Organizations). \
} \n • The SSL is correctly configured for the certificate. Check out the SSL troubleshooting guide -
\n https://zulip.readthedocs.io/en/stable/production/ssl-certificates.html`;
}
noOrgsError(domain: string): string { export function noOrgsError(domain: string): string {
return `${domain} does not have any organizations added.\ return `${domain} does not have any organizations added.\
\nPlease contact your server administrator.`; \nPlease contact your server administrator.`;
} }
certErrorMessage(domain: string, error: string): string { export function certErrorMessage(domain: string, error: string): string {
return `Do you trust certificate from ${domain}? \n ${error}`; return `Certificate error for ${domain}\n${error}`;
} }
certErrorDetail(): string { export function certErrorDetail(): string {
return `The organization you're connecting to is either someone impersonating the Zulip server you entered, or the server you're trying to connect to is configured in an insecure way. return `The organization you're connecting to is either someone impersonating the Zulip server you entered, or the server you're trying to connect to is configured in an insecure way.
\nIf you have a valid certificate please add it from Settings>Organizations and try to add the organization again. \nIf you have a valid certificate please add it from Settings>Organizations and try to add the organization again.`;
\nUnless you have a good reason to believe otherwise, you should not proceed. }
\nYou can click here if you'd like to proceed with the connection.`;
}
enterpriseOrgError(length: number, domains: string[]): DialogBoxError { export function enterpriseOrgError(length: number, domains: string[]): DialogBoxError {
let domainList = ''; let domainList = '';
for (const domain of domains) { for (const domain of domains) {
domainList += `${domain}\n`; domainList += `${domain}\n`;
} }
return { return {
title: `Could not add the following ${length === 1 ? `organization` : `organizations`}`, title: `Could not add the following ${length === 1 ? 'organization' : 'organizations'}`,
content: `${domainList}\nPlease contact your system administrator.` content: `${domainList}\nPlease contact your system administrator.`
}; };
}
orgRemovalError(url: string): DialogBoxError {
return {
title: `Removing ${url} is a restricted operation.`,
content: `Please contact your system administrator.`
};
}
} }
export = new Messages(); export function orgRemovalError(url: string): DialogBoxError {
return {
title: `Removing ${url} is a restricted operation.`,
content: 'Please contact your system administrator.'
};
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,20 @@
# How to help translate Zulip Desktop
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; 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
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
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
translations.

134
app/translations/ar.json Normal file
View File

@@ -0,0 +1,134 @@
{
"About Zulip": "حول \"زوليب\"",
"Actual Size": "الحجم الفعلي",
"Add Custom Certificates": "إضافة رخصة معدلة",
"Add Organization": "إضافة منظمة",
"Add a Zulip organization": "إضافة منظمة \"زوليب\"",
"Add custom CSS": "إصافة CSS معدلة",
"Advanced": "متقدم",
"All the connected organizations will appear here": "جميع المنظمات المتصلة تعرض هنا",
"Always start minimized": "دائماً إبدأ بالقليل",
"App Updates": "تحديث التطبيق",
"Appearance": "المظهر",
"Application Shortcuts": "إختصارات التطبيق",
"Are you sure you want to disconnect this organization?": "هل أنت متأكد من فصل هذة المنظمة؟",
"Auto hide Menu bar": "أخف القائمة تلقائياً",
"Auto hide menu bar (Press Alt key to display)": "أخف القائمة تلقائياً (إضغط Alt لعرض القائمة)",
"Back": "رجوع",
"Bounce dock on new private message": "أخرج المنصة في حال رسالة خاصة جديدة",
"Certificate file": "ملف الشهادة",
"Change": "تغيير",
"Check for Updates": "التحقق من التحديثات",
"Close": "اغلاق",
"Connect": "اتصال",
"Connect to another organization": "التوصيل مع منظمة أخرى",
"Connected organizations": "المنظمات المتصلة",
"Copy": "نسخ",
"Copy Zulip URL": "نسخ رابط زوليب",
"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": "تعديل الاختصارات",
"Enable auto updates": "تفعيل التحديثات التلقائية",
"Enable error reporting (requires restart)": "تفعيل تقارير الأخطاء (يتطلب إعادة التشغيل)",
"Enable spellchecker (requires restart)": "Enable spellchecker (requires restart)",
"Factory Reset": "إعادة ضبط المصنع",
"File": "ملف",
"Find accounts": "Find accounts",
"Find accounts by email": "Find accounts by email",
"Flash taskbar on new message": "Flash taskbar on new message",
"Forward": "Forward",
"Functionality": "Functionality",
"General": "General",
"Get beta updates": "Get beta updates",
"Hard Reload": "Hard Reload",
"Help": "Help",
"Help Center": "Help Center",
"History": "History",
"History Shortcuts": "History Shortcuts",
"Keyboard Shortcuts": "Keyboard Shortcuts",
"Log Out": "Log Out",
"Log Out of Organization": "Log Out of Organization",
"Manual proxy configuration": "Manual proxy configuration",
"Minimize": "Minimize",
"Mute all sounds from Zulip": "Mute all sounds from Zulip",
"NO": "NO",
"Network": "Network",
"OR": "OR",
"Organization URL": "Organization URL",
"Organizations": "Organizations",
"Paste": "Paste",
"Paste and Match Style": "Paste and Match Style",
"Proxy": "Proxy",
"Proxy bypass rules": "Proxy bypass rules",
"Proxy rules": "Proxy rules",
"Quit": "Quit",
"Quit Zulip": "Quit Zulip",
"Redo": "Redo",
"Release Notes": "Release Notes",
"Reload": "Reload",
"Report an Issue": "Report an Issue",
"Save": "Save",
"Select All": "Select All",
"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",
"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",
"Toggle Do Not Disturb": "Toggle Do Not Disturb",
"Toggle Full Screen": "Toggle Full Screen",
"Toggle Sidebar": "Toggle Sidebar",
"Toggle Tray Icon": "Toggle Tray Icon",
"Tools": "Tools",
"Undo": "Undo",
"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",
"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"
}

View File

@@ -1,5 +1,5 @@
{ {
"About Zulip": "За Zulip", "About Zulip": "Относно Zulip",
"Actual Size": "Действителен размер", "Actual Size": "Действителен размер",
"Add Custom Certificates": "Добавяне на персонализирани сертификати", "Add Custom Certificates": "Добавяне на персонализирани сертификати",
"Add Organization": "Добавяне на организация", "Add Organization": "Добавяне на организация",
@@ -75,9 +75,6 @@
"Release Notes": "Бележки към изданието", "Release Notes": "Бележки към изданието",
"Reload": "Презареди", "Reload": "Презареди",
"Report an Issue": "Подаване на сигнал за проблем", "Report an Issue": "Подаване на сигнал за проблем",
"Reset App Data": "Нулиране на данни за приложения",
"Reset App Settings": "Нулиране на настройките на приложението",
"Reset Application Data": "Нулиране на данните на приложението",
"Save": "Запази", "Save": "Запази",
"Select All": "Избери всички", "Select All": "Избери всички",
"Settings": "Настройки", "Settings": "Настройки",
@@ -113,5 +110,25 @@
"Zoom Out": "Отдалечавам", "Zoom Out": "Отдалечавам",
"Zulip Help": "Помощ за Zulip", "Zulip Help": "Помощ за Zulip",
"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"
} }

134
app/translations/ca.json Normal file
View File

@@ -0,0 +1,134 @@
{
"About Zulip": "Quant a Zulip",
"Actual Size": "Actual Size",
"Add Custom Certificates": "Add Custom Certificates",
"Add Organization": "Add Organization",
"Add a Zulip organization": "Add a Zulip organization",
"Add custom CSS": "Add custom CSS",
"Advanced": "Advanced",
"All the connected organizations will appear here": "All the connected organizations will appear here",
"Always start minimized": "Always start minimized",
"App Updates": "App Updates",
"Appearance": "Appearance",
"Application Shortcuts": "Application Shortcuts",
"Are you sure you want to disconnect this organization?": "Are you sure you want to disconnect this organization?",
"Auto hide Menu bar": "Auto hide Menu bar",
"Auto hide menu bar (Press Alt key to display)": "Auto hide menu bar (Press Alt key to display)",
"Back": "Back",
"Bounce dock on new private message": "Bounce dock on new private message",
"Certificate file": "Certificate file",
"Change": "Change",
"Check for Updates": "Check for Updates",
"Close": "Tancar",
"Connect": "Connect",
"Connect to another organization": "Connect to another organization",
"Connected organizations": "Connected organizations",
"Copy": "Copia",
"Copy Zulip URL": "Copy Zulip URL",
"Create a new organization": "Crea una nova organització",
"Cut": "Cut",
"Default download location": "Default download location",
"Delete": "Elimina",
"Desktop App Settings": "Configuració de l'aplicació d'escriptori",
"Desktop Notifications": "Desktop Notifications",
"Desktop Settings": "Configuració d'escriptori",
"Disconnect": "Disconnect",
"Download App Logs": "Download App Logs",
"Edit": "Edita",
"Edit Shortcuts": "Edit Shortcuts",
"Enable auto updates": "Enable auto updates",
"Enable error reporting (requires restart)": "Enable error reporting (requires restart)",
"Enable spellchecker (requires restart)": "Enable spellchecker (requires restart)",
"Factory Reset": "Factory Reset",
"File": "Fitxer",
"Find accounts": "Find accounts",
"Find accounts by email": "Find accounts by email",
"Flash taskbar on new message": "Flash taskbar on new message",
"Forward": "Forward",
"Functionality": "Functionality",
"General": "General",
"Get beta updates": "Get beta updates",
"Hard Reload": "Hard Reload",
"Help": "Help",
"Help Center": "Centre d'ajuda",
"History": "Historial",
"History Shortcuts": "Dreceres d'historial",
"Keyboard Shortcuts": "Keyboard Shortcuts",
"Log Out": "Log Out",
"Log Out of Organization": "Log Out of Organization",
"Manual proxy configuration": "Manual proxy configuration",
"Minimize": "Minimize",
"Mute all sounds from Zulip": "Silencia tots els sons de Zulip",
"NO": "NO",
"Network": "Network",
"OR": "OR",
"Organization URL": "URL d'organització",
"Organizations": "Organizations",
"Paste": "Paste",
"Paste and Match Style": "Paste and Match Style",
"Proxy": "Proxy",
"Proxy bypass rules": "Proxy bypass rules",
"Proxy rules": "Proxy rules",
"Quit": "Quit",
"Quit Zulip": "Quit Zulip",
"Redo": "Redo",
"Release Notes": "Release Notes",
"Reload": "Reload",
"Report an Issue": "Report an Issue",
"Save": "Guardar",
"Select All": "Select All",
"Settings": "Configuració",
"Shortcuts": "Shortcuts",
"Show App Logs": "Show App Logs",
"Show app icon in system tray": "Show app icon in system tray",
"Show app unread badge": "Mostrar una marca en la icona si hi ha missatges no llegits",
"Show desktop notifications": "Show desktop notifications",
"Show downloaded files in file manager": "Show downloaded files in file manager",
"Show sidebar": "Show sidebar",
"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",
"Toggle Do Not Disturb": "Toggle Do Not Disturb",
"Toggle Full Screen": "Toggle Full Screen",
"Toggle Sidebar": "Toggle Sidebar",
"Toggle Tray Icon": "Toggle Tray Icon",
"Tools": "Tools",
"Undo": "Undo",
"Upload": "Pujada",
"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",
"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"
}

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