Compare commits

..

96 Commits

Author SHA1 Message Date
Anders Kaseorg
588d32fd22 release: New release v5.9.3.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-04-28 20:25:15 -07:00
Anders Kaseorg
1c471fe624 Upgrade dependencies, including Electron 18.2.0.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-04-28 20:06:46 -07:00
Anders Kaseorg
52486d687d Allow the autoupdater to quit the app normally.
Forcing it to quit would prematurely terminate the update on some
platforms.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-04-28 19:51:07 -07:00
Anders Kaseorg
73441d791c release: New release v5.9.2.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-04-20 21:23:00 -07:00
Anders Kaseorg
1bb6423721 Upgrade dependencies, including Electron 18.1.0.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-04-20 19:26:47 -07:00
Anders Kaseorg
d6775d64a3 release: New release v5.9.1.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-04-08 17:20:31 -07:00
Anders Kaseorg
e1326eae91 sentry: Update DSN.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-04-08 17:18:48 -07:00
Anders Kaseorg
b93955b28f Upgrade dependencies, including Electron 18.0.3.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-04-08 17:10:59 -07:00
Anders Kaseorg
e3452bda22 Simplify if (…) classList.add(…) else classList.remove(…) anti-pattern.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-04-02 14:34:58 -07:00
Anders Kaseorg
0aab691b44 Switch to released @electron/remote.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-04-01 21:04:51 -07:00
Anders Kaseorg
1bfb2dd975 release: New release v5.9.0.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2022-04-01 17:25:21 -07:00
Anders Kaseorg
fb7937314b Upgrade dependencies.
electron-builder@next is needed to build a DMG on macOS 12.3.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-08-30 19:04:20 -07:00
Anders Kaseorg
a3f4e19aa2 autoupdater: Avoid deprecated log.FileTransport.file.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-08-30 14:18:31 -07:00
80 changed files with 14779 additions and 6264 deletions

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ export const configSchemata = {
autoUpdate: z.boolean(),
badgeOption: z.boolean(),
betaUpdate: z.boolean(),
// eslint-disable-next-line @typescript-eslint/naming-convention
customCSS: z.string().or(z.literal(false)).nullable(),
dnd: z.boolean(),
dndPreviousSettings: z.object(dndSettingsSchemata).partial(),
@@ -23,6 +24,7 @@ export const configSchemata = {
lastActiveTab: z.number(),
promptDownload: z.boolean(),
proxyBypass: z.string(),
// eslint-disable-next-line @typescript-eslint/naming-convention
proxyPAC: z.string(),
proxyRules: z.string(),
quitOnClose: z.boolean(),
@@ -30,7 +32,6 @@ export const configSchemata = {
spellcheckerLanguages: z.string().array().nullable(),
startAtLogin: z.boolean(),
startMinimized: z.boolean(),
systemProxyRules: z.string(),
trayIcon: z.boolean(),
useManualProxy: z.boolean(),
useProxy: z.boolean(),

View File

@@ -1,7 +1,7 @@
import electron from "electron";
import fs from "fs";
import path from "path";
import fs from "node:fs";
import path from "node:path";
import * as Sentry from "@sentry/electron";
import {JsonDB} from "node-json-db";
import {DataError} from "node-json-db/dist/lib/Errors";
import type * as z from "zod";
@@ -9,21 +9,19 @@ import type * as z from "zod";
import {configSchemata} from "./config-schemata";
import * as EnterpriseUtil from "./enterprise-util";
import Logger from "./logger-util";
import {app, dialog} from "./remote";
export type Config = {
[Key in keyof typeof configSchemata]: z.output<typeof configSchemata[Key]>;
};
/* To make the util runnable in both main and renderer process */
const {app, dialog} = process.type === "renderer" ? electron.remote : electron;
const logger = new Logger({
file: "config-util.log",
});
let db: JsonDB;
reloadDB();
reloadDb();
export function getConfigItem<Key extends keyof Config>(
key: Key,
@@ -77,7 +75,7 @@ export function removeConfigItem(key: string): void {
db.save();
}
function reloadDB(): void {
function reloadDb(): void {
const settingsJsonPath = path.join(
app.getPath("userData"),
"/config/settings.json",
@@ -94,7 +92,7 @@ function reloadDB(): void {
);
logger.error("Error while JSON parsing settings.json: ");
logger.error(error);
logger.reportSentry(error);
Sentry.captureException(error);
}
}

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import fs from "fs";
import path from "path";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import * as z from "zod";
@@ -19,9 +20,9 @@ const logger = new Logger({
let enterpriseSettings: Partial<EnterpriseConfig>;
let configFile: boolean;
reloadDB();
reloadDb();
function reloadDB(): void {
function reloadDb(): void {
let enterpriseFile = "/etc/zulip-desktop-config/global_config.json";
if (process.platform === "win32") {
enterpriseFile =
@@ -55,7 +56,7 @@ export function getConfigItem<Key extends keyof EnterpriseConfig>(
key: Key,
defaultValue: EnterpriseConfig[Key],
): EnterpriseConfig[Key] {
reloadDB();
reloadDb();
if (!configFile) {
return defaultValue;
}
@@ -65,7 +66,7 @@ export function getConfigItem<Key extends keyof EnterpriseConfig>(
}
export function configItemExists(key: keyof EnterpriseConfig): boolean {
reloadDB();
reloadDb();
if (!configFile) {
return false;
}

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -23,5 +23,4 @@ export interface TabData {
role: TabRole;
name: string;
index: number;
webviewName: string;
}

View File

@@ -1,5 +1,6 @@
import {app, dialog, session, shell} from "electron";
import util from "util";
import {shell} from "electron/common";
import {app, dialog, session} from "electron/main";
import process from "node:process";
import log from "electron-log";
import type {UpdateDownloadedEvent, UpdateInfo} from "electron-updater";
@@ -9,7 +10,11 @@ import * as ConfigUtil from "../common/config-util";
import {linuxUpdateNotification} from "./linuxupdater"; // Required only in case of linux
const sleep = util.promisify(setTimeout);
let quitting = false;
export function shouldQuitForUpdate(): boolean {
return quitting;
}
export async function appUpdater(updateFromMenu = false): Promise<void> {
// Don't initiate auto-updates in development
@@ -25,11 +30,8 @@ export async function appUpdater(updateFromMenu = false): Promise<void> {
let updateAvailable = false;
// Create Logs directory
const LogsDir = `${app.getPath("userData")}/Logs`;
// Log whats happening
log.transports.file.file = `${LogsDir}/updates.log`;
log.transports.file.fileName = "updates.log";
log.transports.file.level = "info";
autoUpdater.logger = log;
@@ -105,10 +107,8 @@ Current Version: ${app.getVersion()}`,
detail: "It will be installed the next time you restart the application",
});
if (response === 0) {
await sleep(1000);
quitting = true;
autoUpdater.quitAndInstall();
// Force app to quit. This is just a workaround, ideally autoUpdater.quitAndInstall() should relaunch the app.
app.quit();
}
});
// Init for updates

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import {app, dialog} from "electron";
import fs from "fs";
import path from "path";
import {app, dialog} from "electron/main";
import fs from "node:fs";
import path from "node:path";
import {JsonDB} from "node-json-db";
import {DataError} from "node-json-db/dist/lib/Errors";
@@ -13,13 +13,13 @@ const logger = new Logger({
let db: JsonDB;
reloadDB();
reloadDb();
export function getUpdateItem(
key: string,
defaultValue: true | null = null,
): true | null {
reloadDB();
reloadDb();
let value: unknown;
try {
value = db.getObject<unknown>(`/${key}`);
@@ -37,15 +37,15 @@ export function getUpdateItem(
export function setUpdateItem(key: string, value: true | null): void {
db.push(`/${key}`, value, true);
reloadDB();
reloadDb();
}
export function removeUpdateItem(key: string): void {
db.delete(`/${key}`);
reloadDB();
reloadDb();
}
function reloadDB(): void {
function reloadDb(): void {
const linuxUpdateJsonPath = path.join(
app.getPath("userData"),
"/config/updates.json",

View File

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

View File

@@ -1,4 +1,7 @@
import {BrowserWindow, Menu, app, shell} from "electron";
import {shell} from "electron/common";
import type {MenuItemConstructorOptions} from "electron/main";
import {BrowserWindow, Menu, app} from "electron/main";
import process from "node:process";
import AdmZip from "adm-zip";
@@ -13,9 +16,7 @@ import {send} from "./typed-ipc-main";
const appName = app.name;
function getHistorySubmenu(
enableMenu: boolean,
): Electron.MenuItemConstructorOptions[] {
function getHistorySubmenu(enableMenu: boolean): MenuItemConstructorOptions[] {
return [
{
label: t.__("Back"),
@@ -41,7 +42,7 @@ function getHistorySubmenu(
];
}
function getToolsSubmenu(): Electron.MenuItemConstructorOptions[] {
function getToolsSubmenu(): MenuItemConstructorOptions[] {
return [
{
label: t.__("Check for Updates"),
@@ -107,7 +108,7 @@ function getToolsSubmenu(): Electron.MenuItemConstructorOptions[] {
];
}
function getViewSubmenu(): Electron.MenuItemConstructorOptions[] {
function getViewSubmenu(): MenuItemConstructorOptions[] {
return [
{
label: t.__("Reload"),
@@ -256,7 +257,7 @@ function getViewSubmenu(): Electron.MenuItemConstructorOptions[] {
];
}
function getHelpSubmenu(): Electron.MenuItemConstructorOptions[] {
function getHelpSubmenu(): MenuItemConstructorOptions[] {
return [
{
label: `${appName + " Desktop"} v${app.getVersion()}`,
@@ -280,12 +281,8 @@ function getHelpSubmenu(): Electron.MenuItemConstructorOptions[] {
},
{
label: t.__("Report an Issue"),
click() {
// The goal is to notify the main.html BrowserWindow
// which may not be the focused window.
for (const window of BrowserWindow.getAllWindows()) {
send(window.webContents, "open-feedback-modal");
}
async click() {
await shell.openExternal("https://zulip.com/help/contact-support");
},
},
];
@@ -294,8 +291,8 @@ function getHelpSubmenu(): Electron.MenuItemConstructorOptions[] {
function getWindowSubmenu(
tabs: TabData[],
activeTabIndex?: number,
): Electron.MenuItemConstructorOptions[] {
const initialSubmenu: Electron.MenuItemConstructorOptions[] = [
): MenuItemConstructorOptions[] {
const initialSubmenu: MenuItemConstructorOptions[] = [
{
label: t.__("Minimize"),
role: "minimize",
@@ -307,7 +304,7 @@ function getWindowSubmenu(
];
if (tabs.length > 0) {
const ShortcutKey = process.platform === "darwin" ? "Cmd" : "Ctrl";
const shortcutKey = process.platform === "darwin" ? "Cmd" : "Ctrl";
initialSubmenu.push({
type: "separator",
});
@@ -324,7 +321,7 @@ function getWindowSubmenu(
initialSubmenu.push({
label: tab.name,
accelerator:
tab.role === "function" ? "" : `${ShortcutKey} + ${tab.index + 1}`,
tab.role === "function" ? "" : `${shortcutKey} + ${tab.index + 1}`,
checked: tab.index === activeTabIndex,
click(_item, focusedWindow) {
if (focusedWindow) {
@@ -371,7 +368,7 @@ function getWindowSubmenu(
return initialSubmenu;
}
function getDarwinTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] {
function getDarwinTpl(props: MenuProps): MenuItemConstructorOptions[] {
const {tabs, activeTabIndex, enableMenu = false} = props;
return [
@@ -536,7 +533,7 @@ function getDarwinTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] {
];
}
function getOtherTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] {
function getOtherTpl(props: MenuProps): MenuItemConstructorOptions[] {
const {tabs, activeTabIndex, enableMenu = false} = props;
return [
{

View File

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

View File

@@ -1,10 +1,11 @@
import type {ClientRequest, IncomingMessage} from "electron";
import {app, net} from "electron";
import fs from "fs";
import path from "path";
import stream from "stream";
import util from "util";
import type {ClientRequest, IncomingMessage, Session} from "electron/main";
import {app, net} from "electron/main";
import fs from "node:fs";
import path from "node:path";
import stream from "node:stream";
import util from "node:util";
import * as Sentry from "@sentry/electron";
import getStream from "get-stream";
import * as z from "zod";
@@ -43,6 +44,7 @@ const generateFilePath = (url: string): string => {
let {length} = url;
while (length) {
// eslint-disable-next-line no-bitwise, unicorn/prefer-code-point
hash = (hash * 33) ^ url.charCodeAt(--length);
}
@@ -51,12 +53,13 @@ const generateFilePath = (url: string): string => {
fs.mkdirSync(dir);
}
// eslint-disable-next-line no-bitwise
return `${dir}/${hash >>> 0}${extension}`;
};
export const _getServerSettings = async (
domain: string,
session: Electron.session,
session: Session,
): Promise<ServerConf> => {
const response = await fetchResponse(
net.request({
@@ -69,6 +72,7 @@ export const _getServerSettings = async (
}
const data: unknown = JSON.parse(await getStream(response));
/* eslint-disable @typescript-eslint/naming-convention */
const {realm_name, realm_uri, realm_icon} = z
.object({
realm_name: z.string(),
@@ -76,6 +80,7 @@ export const _getServerSettings = async (
realm_icon: z.string(),
})
.parse(data);
/* eslint-enable @typescript-eslint/naming-convention */
return {
// Some Zulip Servers use absolute URL for server icon whereas others use relative URL
@@ -88,7 +93,7 @@ export const _getServerSettings = async (
export const _saveServerIcon = async (
url: string,
session: Electron.session,
session: Session,
): Promise<string> => {
try {
const response = await fetchResponse(net.request({url, session}));
@@ -103,7 +108,7 @@ export const _saveServerIcon = async (
} catch (error: unknown) {
logger.log("Could not get server icon.");
logger.log(error);
logger.reportSentry(error);
Sentry.captureException(error);
return defaultIconUrl;
}
};
@@ -112,7 +117,7 @@ export const _saveServerIcon = async (
export const _isOnline = async (
url: string,
session: Electron.session,
session: Session,
): Promise<boolean> => {
try {
const response = await fetchResponse(

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,35 +1,32 @@
import {clipboard, remote} from "electron";
import path from "path";
import {clipboard} from "electron/common";
import path from "node:path";
import process from "node:process";
import {Menu, app, dialog, session} from "@electron/remote";
import * as remote from "@electron/remote";
import * as Sentry from "@sentry/electron";
import type {Config} from "../../common/config-util";
import * as ConfigUtil from "../../common/config-util";
import * as DNDUtil from "../../common/dnd-util";
import type {DNDSettings} from "../../common/dnd-util";
import type {DndSettings} from "../../common/dnd-util";
import * as EnterpriseUtil from "../../common/enterprise-util";
import * as LinkUtil from "../../common/link-util";
import Logger from "../../common/logger-util";
import * as Messages from "../../common/messages";
import type {RendererMessage} from "../../common/typed-ipc";
import type {NavItem, ServerConf, TabData} from "../../common/types";
import FunctionalTab from "./components/functional-tab";
import ServerTab from "./components/server-tab";
import WebView from "./components/webview";
import {feedbackHolder} from "./feedback";
import {AboutView} from "./pages/about";
import {PreferenceView} from "./pages/preference/preference";
import {initializeTray} from "./tray";
import {ipcRenderer} from "./typed-ipc-renderer";
import * as DomainUtil from "./utils/domain-util";
import * as LinkUtil from "./utils/link-util";
import ReconnectUtil from "./utils/reconnect-util";
// eslint-disable-next-line import/no-unassigned-import
import "./tray";
const {session, app, Menu, dialog} = remote;
interface FunctionalTabProps {
name: string;
materialIcon: string;
url: string;
}
Sentry.init({});
type WebviewListener =
| "webview-reload"
@@ -50,7 +47,11 @@ const logger = new Logger({
const rendererDirectory = path.resolve(__dirname, "..");
type ServerOrFunctionalTab = ServerTab | FunctionalTab;
class ServerManagerView {
const rootWebContents = remote.getCurrentWebContents();
const dingSound = new Audio("../resources/sounds/ding.ogg");
export class ServerManagerView {
$addServerButton: HTMLButtonElement;
$tabsContainer: Element;
$reloadButton: HTMLButtonElement;
@@ -75,6 +76,7 @@ class ServerManagerView {
functionalTabs: Map<string, number>;
tabIndex: number;
presetOrgs: string[];
preferenceView?: PreferenceView;
constructor() {
this.$addServerButton = document.querySelector("#add-tab")!;
this.$tabsContainer = document.querySelector("#tabs-container")!;
@@ -120,10 +122,11 @@ class ServerManagerView {
}
async init(): Promise<void> {
initializeTray(this);
await this.loadProxy();
this.initDefaultSettings();
this.initSidebar();
this.removeUAfromDisk();
this.removeUaFromDisk();
if (EnterpriseUtil.hasConfigFile()) {
await this.initPresetOrgs();
}
@@ -131,7 +134,6 @@ class ServerManagerView {
await this.initTabs();
this.initActions();
this.registerIpcs();
ipcRenderer.send("set-spellcheck-langs");
}
async loadProxy(): Promise<void> {
@@ -146,21 +148,16 @@ class ServerManagerView {
ConfigUtil.removeConfigItem("useProxy");
}
const proxyEnabled =
ConfigUtil.getConfigItem("useManualProxy", false) ||
ConfigUtil.getConfigItem("useSystemProxy", false);
await session.fromPartition("persist:webviewsession").setProxy(
proxyEnabled
ConfigUtil.getConfigItem("useSystemProxy", false)
? {mode: "system"}
: ConfigUtil.getConfigItem("useManualProxy", false)
? {
pacScript: ConfigUtil.getConfigItem("proxyPAC", ""),
proxyRules: ConfigUtil.getConfigItem("proxyRules", ""),
proxyBypassRules: ConfigUtil.getConfigItem("proxyBypass", ""),
}
: {
pacScript: "",
proxyRules: "",
proxyBypassRules: "",
},
: {mode: "direct"},
);
}
@@ -183,6 +180,7 @@ class ServerManagerView {
autoUpdate: true,
betaUpdate: false,
errorReporting: true,
// eslint-disable-next-line @typescript-eslint/naming-convention
customCSS: false,
silent: false,
lastActiveTab: 0,
@@ -237,7 +235,7 @@ class ServerManagerView {
// Remove the stale UA string from the disk if the app is not freshly
// installed. This should be removed in a further release.
removeUAfromDisk(): void {
removeUaFromDisk(): void {
ConfigUtil.removeConfigItem("userAgent");
}
@@ -335,7 +333,7 @@ class ServerManagerView {
servers[lastActiveTab].url,
lastActiveTab,
);
this.activateTab(lastActiveTab);
await this.activateTab(lastActiveTab);
await Promise.all(
servers.map(async (server, i) => {
// After the lastActiveTab is activated, we load the others in the background
@@ -345,7 +343,8 @@ class ServerManagerView {
}
await DomainUtil.updateSavedServer(server.url, i);
this.tabs[i].webview.load();
const tab = this.tabs[i];
if (tab instanceof ServerTab) (await tab.webview).load();
}),
);
// Remove focus from the settings icon at sidebar bottom
@@ -371,35 +370,34 @@ class ServerManagerView {
tabIndex,
onHover: this.onHover.bind(this, index),
onHoverOut: this.onHoverOut.bind(this, index),
webview: new WebView({
webview: WebView.create({
$root: this.$webviewsContainer,
rootWebContents,
index,
tabIndex,
url: server.url,
role: "server",
name: server.alias,
hasPermission: (origin: string, permission: string) =>
origin === server.url && permission === "notifications",
isActive: () => index === this.activeTabIndex,
switchLoading: (loading: boolean, url: string) => {
switchLoading: async (loading: boolean, url: string) => {
if (loading) {
this.loading.add(url);
} else {
this.loading.delete(url);
}
const tab = this.tabs[this.activeTabIndex];
this.showLoading(
this.loading.has(
this.tabs[this.activeTabIndex].webview.props.url,
),
tab instanceof ServerTab &&
this.loading.has((await tab.webview).props.url),
);
},
onNetworkError: (index: number) => {
this.openNetworkTroubleshooting(index);
onNetworkError: async (index: number) => {
await this.openNetworkTroubleshooting(index);
},
onTitleChange: this.updateBadge.bind(this),
nodeIntegration: false,
preload: true,
preload: "js/preload.js",
}),
}),
);
@@ -407,7 +405,7 @@ class ServerManagerView {
}
initActions(): void {
this.initDNDButton();
this.initDndButton();
this.initServerActions();
this.initLeftSidebarEvents();
}
@@ -437,8 +435,9 @@ class ServerManagerView {
dndUtil.newSettings,
);
});
this.$reloadButton.addEventListener("click", () => {
this.tabs[this.activeTabIndex].webview.reload();
this.$reloadButton.addEventListener("click", async () => {
const tab = this.tabs[this.activeTabIndex];
if (tab instanceof ServerTab) (await tab.webview).reload();
});
this.$addServerButton.addEventListener("click", async () => {
await this.openSettings("AddServer");
@@ -446,8 +445,9 @@ class ServerManagerView {
this.$settingsButton.addEventListener("click", async () => {
await this.openSettings("General");
});
this.$backButton.addEventListener("click", () => {
this.tabs[this.activeTabIndex].webview.back();
this.$backButton.addEventListener("click", async () => {
const tab = this.tabs[this.activeTabIndex];
if (tab instanceof ServerTab) (await tab.webview).back();
});
this.sidebarHoverEvent(this.$addServerButton, this.$addServerTooltip, true);
@@ -458,9 +458,9 @@ class ServerManagerView {
this.sidebarHoverEvent(this.$dndButton, this.$dndTooltip);
}
initDNDButton(): void {
initDndButton(): void {
const dnd = ConfigUtil.getConfigItem("dnd", false);
this.toggleDNDButton(dnd);
this.toggleDndButton(dnd);
}
getTabIndex(): number {
@@ -469,8 +469,9 @@ class ServerManagerView {
return currentIndex;
}
getCurrentActiveServer(): string {
return this.tabs[this.activeTabIndex].webview.props.url;
async getCurrentActiveServer(): Promise<string> {
const tab = this.tabs[this.activeTabIndex];
return tab instanceof ServerTab ? (await tab.webview).props.url : "";
}
displayInitialCharLogo($img: HTMLImageElement, index: number): void {
@@ -539,15 +540,23 @@ class ServerManagerView {
this.$serverIconTooltip[index].style.display = "none";
}
openFunctionalTab(tabProps: FunctionalTabProps): void {
async openFunctionalTab(tabProps: {
name: string;
materialIcon: string;
makeView: () => Element;
destroyView: () => void;
}): Promise<void> {
if (this.functionalTabs.has(tabProps.name)) {
this.activateTab(this.functionalTabs.get(tabProps.name)!);
await this.activateTab(this.functionalTabs.get(tabProps.name)!);
return;
}
this.functionalTabs.set(tabProps.name, this.tabs.length);
const index = this.tabs.length;
this.functionalTabs.set(tabProps.name, index);
const tabIndex = this.getTabIndex();
const $view = tabProps.makeView();
this.$webviewsContainer.append($view);
this.tabs.push(
new FunctionalTab({
@@ -555,46 +564,14 @@ class ServerManagerView {
materialIcon: tabProps.materialIcon,
name: tabProps.name,
$root: this.$tabsContainer,
index: this.functionalTabs.get(tabProps.name)!,
index,
tabIndex,
onClick: this.activateTab.bind(
this,
this.functionalTabs.get(tabProps.name)!,
),
onDestroy: this.destroyTab.bind(
this,
tabProps.name,
this.functionalTabs.get(tabProps.name)!,
),
webview: new WebView({
$root: this.$webviewsContainer,
index: this.functionalTabs.get(tabProps.name)!,
tabIndex,
url: tabProps.url,
role: "function",
name: tabProps.name,
isActive: () =>
this.functionalTabs.get(tabProps.name) === this.activeTabIndex,
switchLoading: (loading: boolean, url: string) => {
if (loading) {
this.loading.add(url);
} else {
this.loading.delete(url);
}
this.showLoading(
this.loading.has(
this.tabs[this.activeTabIndex].webview.props.url,
),
);
},
onNetworkError: (index: number) => {
this.openNetworkTroubleshooting(index);
},
onTitleChange: this.updateBadge.bind(this),
nodeIntegration: true,
preload: false,
}),
onClick: this.activateTab.bind(this, index),
onDestroy: async () => {
await this.destroyTab(tabProps.name, index);
tabProps.destroyView();
},
$view,
}),
);
@@ -602,42 +579,57 @@ class ServerManagerView {
// closed when the functional tab DOM is ready, handled in webview.js
this.$webviewsContainer.classList.remove("loaded");
this.activateTab(this.functionalTabs.get(tabProps.name)!);
await this.activateTab(this.functionalTabs.get(tabProps.name)!);
}
async openSettings(nav: NavItem = "General"): Promise<void> {
this.openFunctionalTab({
await this.openFunctionalTab({
name: "Settings",
materialIcon: "settings",
url: `file://${rendererDirectory}/preference.html#${nav}`,
makeView: () => {
this.preferenceView = new PreferenceView();
this.preferenceView.$view.classList.add("functional-view");
return this.preferenceView.$view;
},
destroyView: () => {
this.preferenceView!.destroy();
this.preferenceView = undefined;
},
});
this.$settingsButton.classList.add("active");
await this.tabs[this.functionalTabs.get("Settings")!].webview.send(
"switch-settings-nav",
nav,
);
this.preferenceView!.handleNavigation(nav);
}
openAbout(): void {
this.openFunctionalTab({
async openAbout(): Promise<void> {
let aboutView: AboutView;
await this.openFunctionalTab({
name: "About",
materialIcon: "sentiment_very_satisfied",
url: `file://${rendererDirectory}/about.html`,
makeView() {
aboutView = new AboutView();
aboutView.$view.classList.add("functional-view");
return aboutView.$view;
},
destroyView() {
aboutView.destroy();
},
});
}
openNetworkTroubleshooting(index: number): void {
const reconnectUtil = new ReconnectUtil(this.tabs[index].webview);
async openNetworkTroubleshooting(index: number): Promise<void> {
const tab = this.tabs[index];
if (!(tab instanceof ServerTab)) return;
const webview = await tab.webview;
const reconnectUtil = new ReconnectUtil(webview);
reconnectUtil.pollInternetAndReload();
this.tabs[
index
].webview.props.url = `file://${rendererDirectory}/network.html`;
this.tabs[index].showNetworkError();
await webview
.getWebContents()
.loadURL(`file://${rendererDirectory}/network.html`);
}
activateLastTab(index: number): void {
async activateLastTab(index: number): Promise<void> {
// Open all the tabs in background, also activate the tab based on the index
this.activateTab(index);
await this.activateTab(index);
// Save last active tab via main process to avoid JSON DB errors
ipcRenderer.send("save-last-tab", index);
}
@@ -651,12 +643,12 @@ class ServerManagerView {
role: tab.props.role,
name: tab.props.name,
index: tab.props.index,
webviewName: tab.webview.props.name,
}));
}
activateTab(index: number, hideOldTab = true): void {
if (!this.tabs[index]) {
async activateTab(index: number, hideOldTab = true): Promise<void> {
const tab = this.tabs[index];
if (!tab) {
return;
}
@@ -674,19 +666,26 @@ class ServerManagerView {
this.$settingsButton.classList.remove("active");
}
this.tabs[this.activeTabIndex].deactivate();
await this.tabs[this.activeTabIndex].deactivate();
}
}
try {
this.tabs[index].webview.canGoBackButton();
} catch {}
if (tab instanceof ServerTab) {
try {
(await tab.webview).canGoBackButton();
} catch {}
} else {
document
.querySelector("#actions-container #back-action")!
.classList.add("disable");
}
this.activeTabIndex = index;
this.tabs[index].activate();
await tab.activate();
this.showLoading(
this.loading.has(this.tabs[this.activeTabIndex].webview.props.url),
tab instanceof ServerTab &&
this.loading.has((await tab.webview).props.url),
);
ipcRenderer.send("update-menu", {
@@ -695,33 +694,29 @@ class ServerManagerView {
tabs: this.tabsForIpc,
activeTabIndex: this.activeTabIndex,
// Following flag controls whether a menu item should be enabled or not
enableMenu: this.tabs[index].props.role === "server",
enableMenu: tab.props.role === "server",
});
}
showLoading(loading: boolean): void {
if (!loading) {
this.$reloadButton.removeAttribute("style");
this.$loadingIndicator.style.display = "none";
} else if (loading) {
this.$reloadButton.style.display = "none";
this.$loadingIndicator.removeAttribute("style");
}
this.$reloadButton.classList.toggle("hidden", loading);
this.$loadingIndicator.classList.toggle("hidden", !loading);
}
destroyTab(name: string, index: number): void {
if (this.tabs[index].webview.loading) {
async destroyTab(name: string, index: number): Promise<void> {
const tab = this.tabs[index];
if (tab instanceof ServerTab && (await tab.webview).loading) {
return;
}
this.tabs[index].destroy();
await tab.destroy();
delete this.tabs[index];
this.functionalTabs.delete(name);
// Issue #188: If the functional tab was not focused, do not activate another tab.
if (this.activeTabIndex === index) {
this.activateTab(0, false);
await this.activateTab(0, false);
}
}
@@ -756,39 +751,27 @@ class ServerManagerView {
this.$reloadButton.click();
}
updateBadge(): void {
async updateBadge(): Promise<void> {
let messageCountAll = 0;
for (const tab of this.tabs) {
if (tab && tab instanceof ServerTab && tab.updateBadge) {
const count = tab.webview.badgeCount;
messageCountAll += count;
tab.updateBadge(count);
}
}
await Promise.all(
this.tabs.map(async (tab) => {
if (tab && tab instanceof ServerTab && tab.updateBadge) {
const count = (await tab.webview).badgeCount;
messageCountAll += count;
tab.updateBadge(count);
}
}),
);
ipcRenderer.send("update-badge", messageCountAll);
}
updateGeneralSettings<Channel extends keyof RendererMessage>(
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
): void {
if (this.getActiveWebview()) {
const webContentsId = this.getActiveWebview().getWebContentsId();
ipcRenderer.sendTo(webContentsId, channel, ...args);
}
}
toggleSidebar(show: boolean): void {
if (show) {
this.$sidebar.classList.remove("sidebar-hide");
} else {
this.$sidebar.classList.add("sidebar-hide");
}
this.$sidebar.classList.toggle("sidebar-hide", !show);
}
// Toggles the dnd button icon.
toggleDNDButton(alert: boolean): void {
toggleDndButton(alert: boolean): void {
this.$dndTooltip.textContent =
(alert ? "Disable" : "Enable") + " Do Not Disturb";
this.$dndButton.querySelector("i")!.textContent = alert
@@ -796,24 +779,21 @@ class ServerManagerView {
: "notifications";
}
isLoggedIn(tabIndex: number): boolean {
const url = this.tabs[tabIndex].webview.$el!.src;
return !(url.endsWith("/login/") || this.tabs[tabIndex].webview.loading);
}
getActiveWebview(): Electron.WebviewTag {
const selector = "webview:not(.disabled)";
const webview: Electron.WebviewTag = document.querySelector(selector)!;
return webview;
async isLoggedIn(tabIndex: number): Promise<boolean> {
const tab = this.tabs[tabIndex];
if (!(tab instanceof ServerTab)) return false;
const webview = await tab.webview;
const url = webview.getWebContents().getURL();
return !(url.endsWith("/login/") || webview.loading);
}
addContextMenu($serverImg: HTMLElement, index: number): void {
$serverImg.addEventListener("contextmenu", (event) => {
$serverImg.addEventListener("contextmenu", async (event) => {
event.preventDefault();
const template = [
{
label: "Disconnect organization",
click: async () => {
async click() {
const {response} = await dialog.showMessageBox({
type: "warning",
buttons: ["YES", "NO"],
@@ -834,16 +814,18 @@ class ServerManagerView {
},
{
label: "Notification settings",
enabled: this.isLoggedIn(index),
enabled: await this.isLoggedIn(index),
click: async () => {
// Switch to tab whose icon was right-clicked
this.activateTab(index);
await this.tabs[index].webview.showNotificationSettings();
await this.activateTab(index);
const tab = this.tabs[index];
if (tab instanceof ServerTab)
(await tab.webview).showNotificationSettings();
},
},
{
label: "Copy Zulip URL",
click: () => {
click() {
clipboard.writeText(DomainUtil.getDomain(index).url);
},
},
@@ -855,7 +837,7 @@ class ServerManagerView {
registerIpcs(): void {
const webviewListeners: Array<
[WebviewListener, (webview: WebView) => void | Promise<void>]
[WebviewListener, (webview: WebView) => void]
> = [
[
"webview-reload",
@@ -901,14 +883,14 @@ class ServerManagerView {
],
[
"log-out",
async (webview) => {
await webview.logOut();
(webview) => {
webview.logOut();
},
],
[
"show-keyboard-shortcuts",
async (webview) => {
await webview.showKeyboardShortcuts();
(webview) => {
webview.showKeyboardShortcuts();
},
],
[
@@ -921,16 +903,17 @@ class ServerManagerView {
for (const [channel, listener] of webviewListeners) {
ipcRenderer.on(channel, async () => {
const activeWebview = this.tabs[this.activeTabIndex].webview;
if (activeWebview) {
await listener(activeWebview);
const tab = this.tabs[this.activeTabIndex];
if (tab instanceof ServerTab) {
const activeWebview = await tab.webview;
if (activeWebview) listener(activeWebview);
}
});
}
ipcRenderer.on(
"permission-request",
(
async (
event: Event,
{
webContentsId,
@@ -946,12 +929,18 @@ class ServerManagerView {
const grant =
webContentsId === null
? origin === "null" && permission === "notifications"
: this.tabs.some(
({webview}) =>
!webview.loading &&
webview.$el!.getWebContentsId() === webContentsId &&
webview.props.hasPermission?.(origin, permission),
);
: (
await Promise.all(
this.tabs.map(async (tab) => {
if (!(tab instanceof ServerTab)) return false;
const webview = await tab.webview;
return (
webview.webContentsId === webContentsId &&
webview.props.hasPermission?.(origin, permission)
);
}),
)
).some(Boolean);
console.log(
grant ? "Granted" : "Denied",
"permissions request for",
@@ -963,10 +952,6 @@ class ServerManagerView {
},
);
ipcRenderer.on("show-network-error", (event: Event, index: number) => {
this.openNetworkTroubleshooting(index);
});
ipcRenderer.on("open-settings", async () => {
await this.openSettings();
});
@@ -989,8 +974,8 @@ class ServerManagerView {
ipcRenderer.send("reload-full-app");
});
ipcRenderer.on("switch-server-tab", (event: Event, index: number) => {
this.activateLastTab(index);
ipcRenderer.on("switch-server-tab", async (event: Event, index: number) => {
await this.activateLastTab(index);
});
ipcRenderer.on("open-org-tab", async () => {
@@ -1008,55 +993,45 @@ class ServerManagerView {
}
});
ipcRenderer.on("toggle-sidebar", (event: Event, show: boolean) => {
ipcRenderer.on("toggle-sidebar", async (event: Event, show: boolean) => {
// Toggle the left sidebar
this.toggleSidebar(show);
// Toggle sidebar switch in the general settings
this.updateGeneralSettings("toggle-sidebar-setting", show);
});
ipcRenderer.on("toggle-silent", (event: Event, state: boolean) => {
const webviews: NodeListOf<Electron.WebviewTag> =
document.querySelectorAll("webview");
for (const webview of webviews) {
try {
webview.setAudioMuted(state);
} catch {
// Webview is not ready yet
webview.addEventListener("dom-ready", () => {
webview.setAudioMuted(state);
});
}
}
});
ipcRenderer.on("toggle-silent", async (event: Event, state: boolean) =>
Promise.all(
this.tabs.map(async (tab) => {
if (tab instanceof ServerTab)
(await tab.webview).getWebContents().setAudioMuted(state);
}),
),
);
ipcRenderer.on(
"toggle-autohide-menubar",
(event: Event, autoHideMenubar: boolean, updateMenu: boolean) => {
async (event: Event, autoHideMenubar: boolean, updateMenu: boolean) => {
if (updateMenu) {
ipcRenderer.send("update-menu", {
tabs: this.tabsForIpc,
activeTabIndex: this.activeTabIndex,
});
return;
}
this.updateGeneralSettings("toggle-menubar-setting", autoHideMenubar);
},
);
ipcRenderer.on(
"toggle-dnd",
(event: Event, state: boolean, newSettings: Partial<DNDSettings>) => {
this.toggleDNDButton(state);
async (
event: Event,
state: boolean,
newSettings: Partial<DndSettings>,
) => {
this.toggleDndButton(state);
ipcRenderer.send(
"forward-message",
"toggle-silent",
newSettings.silent ?? false,
);
const webContentsId = this.getActiveWebview().getWebContentsId();
ipcRenderer.sendTo(webContentsId, "toggle-dnd", state, newSettings);
},
);
@@ -1071,7 +1046,6 @@ class ServerManagerView {
);
serverTooltips[index].textContent = realmName;
this.tabs[index].props.name = realmName;
this.tabs[index].webview.props.name = realmName;
domain.alias = realmName;
DomainUtil.updateDomain(index, domain);
@@ -1117,20 +1091,20 @@ class ServerManagerView {
ipcRenderer.on(
"focus-webview-with-id",
(event: Event, webviewId: number) => {
const webviews: NodeListOf<Electron.WebviewTag> =
document.querySelectorAll("webview");
for (const webview of webviews) {
const currentId = webview.getWebContentsId();
const tabId = webview.getAttribute("data-tab-id")!;
const concurrentTab: HTMLButtonElement = document.querySelector(
`div[data-tab-id="${CSS.escape(tabId)}"]`,
)!;
if (currentId === webviewId) {
concurrentTab.click();
}
}
},
async (event: Event, webviewId: number) =>
Promise.all(
this.tabs.map(async (tab) => {
if (
tab instanceof ServerTab &&
(await tab.webview).webContentsId === webviewId
) {
const concurrentTab: HTMLButtonElement = document.querySelector(
`div[data-tab-id="${CSS.escape(`${tab.props.tabIndex}`)}"]`,
)!;
concurrentTab.click();
}
}),
),
);
ipcRenderer.on(
@@ -1171,37 +1145,37 @@ class ServerManagerView {
},
);
ipcRenderer.on("open-feedback-modal", () => {
feedbackHolder.classList.add("show");
});
ipcRenderer.on("copy-zulip-url", () => {
clipboard.writeText(this.getCurrentActiveServer());
ipcRenderer.on("copy-zulip-url", async () => {
clipboard.writeText(await this.getCurrentActiveServer());
});
ipcRenderer.on("new-server", async () => {
await this.openSettings("AddServer");
});
ipcRenderer.on("set-active", () => {
const webviews: NodeListOf<Electron.WebviewTag> =
document.querySelectorAll("webview");
for (const webview of webviews) {
ipcRenderer.sendTo(webview.getWebContentsId(), "set-active");
}
});
ipcRenderer.on("set-active", async () =>
Promise.all(
this.tabs.map(async (tab) => {
if (tab instanceof ServerTab) (await tab.webview).send("set-active");
}),
),
);
ipcRenderer.on("set-idle", () => {
const webviews: NodeListOf<Electron.WebviewTag> =
document.querySelectorAll("webview");
for (const webview of webviews) {
ipcRenderer.sendTo(webview.getWebContentsId(), "set-idle");
}
});
ipcRenderer.on("set-idle", async () =>
Promise.all(
this.tabs.map(async (tab) => {
if (tab instanceof ServerTab) (await tab.webview).send("set-idle");
}),
),
);
ipcRenderer.on("open-network-settings", async () => {
await this.openSettings("Network");
});
ipcRenderer.on("play-ding-sound", async () => {
await dingSound.play();
});
}
}
@@ -1209,5 +1183,3 @@ window.addEventListener("load", async () => {
const serverManagerView = new ServerManagerView();
await serverManagerView.init();
});
export {};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import {contextBridge, webFrame} from "electron";
import fs from "fs";
import {contextBridge, webFrame} from "electron/renderer";
import fs from "node:fs";
import electron_bridge, {bridgeEvents} from "./electron-bridge";
import * as NetworkError from "./pages/network";

View File

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

View File

@@ -1,7 +1,7 @@
import type {IpcRendererEvent} from "electron";
import type {IpcRendererEvent} from "electron/renderer";
import {
ipcRenderer as untypedIpcRenderer, // eslint-disable-line no-restricted-imports
} from "electron";
} from "electron/renderer";
import type {
MainCall,
@@ -23,6 +23,10 @@ export const ipcRenderer: {
channel: Channel,
listener: RendererListener<Channel>,
): void;
off<Channel extends keyof RendererMessage>(
channel: Channel,
listener: RendererListener<Channel>,
): void;
removeListener<Channel extends keyof RendererMessage>(
channel: Channel,
listener: RendererListener<Channel>,

View File

@@ -1,7 +1,8 @@
import {remote} from "electron";
import fs from "fs";
import path from "path";
import fs from "node:fs";
import path from "node:path";
import {app, dialog} from "@electron/remote";
import * as Sentry from "@sentry/electron";
import {JsonDB} from "node-json-db";
import {DataError} from "node-json-db/dist/lib/Errors";
import * as z from "zod";
@@ -12,8 +13,6 @@ import * as Messages from "../../../common/messages";
import type {ServerConf} from "../../../common/types";
import {ipcRenderer} from "../typed-ipc-renderer";
const {app, dialog} = remote;
const logger = new Logger({
file: "domain-util.log",
});
@@ -28,7 +27,7 @@ const serverConfSchema = z.object({
let db!: JsonDB;
reloadDB();
reloadDb();
// Migrate from old schema
try {
@@ -47,7 +46,7 @@ try {
}
export function getDomains(): ServerConf[] {
reloadDB();
reloadDb();
try {
return serverConfSchema.array().parse(db.getObject<unknown>("/domains"));
} catch (error: unknown) {
@@ -57,12 +56,12 @@ export function getDomains(): ServerConf[] {
}
export function getDomain(index: number): ServerConf {
reloadDB();
reloadDb();
return serverConfSchema.parse(db.getObject<unknown>(`/domains[${index}]`));
}
export function updateDomain(index: number, server: ServerConf): void {
reloadDB();
reloadDb();
serverConfSchema.parse(server);
db.push(`/domains[${index}]`, server, true);
}
@@ -77,18 +76,18 @@ export async function addDomain(server: {
server.icon = localIconUrl;
serverConfSchema.parse(server);
db.push("/domains[]", server, true);
reloadDB();
reloadDb();
} else {
server.icon = defaultIconUrl;
serverConfSchema.parse(server);
db.push("/domains[]", server, true);
reloadDB();
reloadDb();
}
}
export function removeDomains(): void {
db.delete("/domains");
reloadDB();
reloadDb();
}
export function removeDomain(index: number): boolean {
@@ -97,7 +96,7 @@ export function removeDomain(index: number): boolean {
}
db.delete(`/domains[${index}]`);
reloadDB();
reloadDb();
return true;
}
@@ -145,16 +144,16 @@ export async function updateSavedServer(
if (!oldIcon || localIconUrl !== "../renderer/img/icon.png") {
newServerConf.icon = localIconUrl;
updateDomain(index, newServerConf);
reloadDB();
reloadDb();
}
} catch (error: unknown) {
logger.log("Could not update server icon.");
logger.log(error);
logger.reportSentry(error);
Sentry.captureException(error);
}
}
function reloadDB(): void {
function reloadDb(): void {
const domainJsonPath = path.join(
app.getPath("userData"),
"config/domain.json",
@@ -172,7 +171,7 @@ function reloadDB(): void {
);
logger.error("Error while JSON parsing domain.json: ");
logger.error(error);
logger.reportSentry(error);
Sentry.captureException(error);
}
}

View File

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

View File

@@ -4,6 +4,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>Zulip</title>
<link rel="stylesheet" href="css/fonts.css" />
<link rel="stylesheet" href="css/main.css" type="text/css" media="screen" />
</head>
@@ -29,7 +30,7 @@
<i class="material-icons md-48">notifications</i>
<span id="dnd-tooltip" style="display: none">Do Not Disturb</span>
</div>
<div class="action-button" id="reload-action">
<div class="action-button hidden" id="reload-action">
<i class="material-icons md-48">refresh</i>
<span id="reload-tooltip" style="display: none">Reload</span>
</div>
@@ -51,15 +52,5 @@
<div id="webviews-container"></div>
</div>
</div>
<div id="feedback-modal">
<send-feedback show-cancel-button="show"></send-feedback>
</div>
</body>
<script>
// we don't use src='./js/main' in the script tag because
// it messes up require module path resolution
require("./js/main");
</script>
</html>

View File

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

View File

@@ -2,6 +2,44 @@
All notable changes to the Zulip desktop app are documented in this file.
### v5.9.3 --2022-04-28
**Fixes**:
- Fixed a bug in the automatic updater that would sometimes close the application instead of updating it.
(As with most updater fixes, this fix will take effect when updating _from_ 5.9.3. If you're having trouble updating _to_ 5.9.3, a workaround is to click **Install Later** rather than **Install and Relaunch**, then **Quit** from the menu bar and re-open the application manually.)
**Dependencies**:
- Upgraded all dependencies, including Electron 18.2.0.
### v5.9.2 --2022-04-20
**Dependencies**:
- Upgraded all dependencies, including Electron 18.1.0. This fixes an upstream Electron bug that crashed the application when accessibility tools such as screen readers and grammar assistants are in use.
### v5.9.1 --2022-04-08
**Dependencies**:
- Upgraded all dependencies, including Electron 18.0.3.
### v5.9.0 --2022-04-01
**Fixes**:
- Fixed unread count display when viewing a topic with a parenthesized number.
- Fixed parsing of system proxy settings.
**Enhancements**:
- Removed fade-in animation on page load.
**Dependencies**:
- Upgraded all dependencies, including Electron 18.0.1.
### v5.8.1 --2021-07-29
**Fixes**:

17546
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "zulip",
"productName": "Zulip",
"version": "5.8.1",
"version": "5.9.3",
"main": "./app/main",
"description": "Zulip Desktop App",
"license": "Apache-2.0",
@@ -22,16 +22,16 @@
},
"scripts": {
"start": "tsc && electron .",
"clean-ts-files": "git clean app/*.js -e node_modules -xf",
"clean-ts-files": "git clean \"app/*.js\" -xf",
"watch-ts": "tsc -w",
"reinstall": "rimraf node_modules && npm install",
"postinstall": "electron-builder install-app-deps",
"lint-css": "stylelint app/renderer/css/*.css",
"lint-html": "./node_modules/.bin/htmlhint \"app/renderer/*.html\" ",
"lint-css": "stylelint \"app/**/*.css\"",
"lint-html": "htmlhint \"app/**/*.html\"",
"lint-js": "xo",
"prettier-non-js": "prettier --check --ignore-path=.prettierignore.non-js --loglevel=warn .",
"prettier-non-js": "prettier --check --loglevel=warn . \"!**/*.{js,ts}\"",
"test": "tsc --noEmit && npm run lint-html && npm run lint-css && npm run lint-js && npm run prettier-non-js",
"test-e2e": "tsc && tape 'tests/*.js'",
"test-e2e": "tsc && tape \"tests/**/*.js\"",
"pack": "tsc && electron-builder --dir",
"dist": "tsc && electron-builder",
"mas": "tsc && electron-builder --mac mas"
@@ -146,47 +146,48 @@
"InstantMessaging"
],
"dependencies": {
"@electron-elements/send-feedback": "^2.0.3",
"@sentry/electron": "^2.5.1",
"@electron/remote": "^2.0.8",
"@sentry/electron": "^3.0.3",
"@yaireo/tagify": "^4.5.0",
"adm-zip": "^0.5.5",
"auto-launch": "^5.0.5",
"backoff": "^2.5.0",
"electron-log": "^4.3.5",
"electron-updater": "4.3.5",
"electron-updater": "^5.0.1",
"electron-window-state": "^5.0.3",
"escape-goat": "^3.0.0",
"get-stream": "^6.0.1",
"i18n": "^0.13.3",
"i18n": "^0.14.1",
"iso-639-1": "^2.1.9",
"node-json-db": "^1.3.0",
"semver": "^7.3.5",
"zod": "^3.5.1"
},
"devDependencies": {
"@types/adm-zip": "^0.4.34",
"@types/adm-zip": "^0.5.0",
"@types/auto-launch": "^5.0.2",
"@types/backoff": "^2.5.2",
"@types/i18n": "^0.13.1",
"@types/node": "^14.17.5",
"@types/node": "^16.11.26",
"@types/requestidlecallback": "^0.3.4",
"@types/yaireo__tagify": "^4.3.2",
"dotenv": "^10.0.0",
"electron": "^13.1.7",
"electron-builder": "^22.11.7",
"dotenv": "^16.0.0",
"electron": "^18.0.1",
"electron-builder": "^23.0.3",
"electron-notarize": "^1.0.0",
"eslint-import-resolver-typescript": "^2.4.0",
"htmlhint": "^0.15.1",
"htmlhint": "^1.1.2",
"medium": "^1.2.0",
"playwright-core": "^1.19.1",
"pre-commit": "^1.2.2",
"prettier": "^2.3.2",
"rimraf": "^3.0.2",
"spectron": "^15.0.0",
"stylelint": "^13.13.1",
"stylelint-config-prettier": "^8.0.2",
"stylelint-config-standard": "^22.0.0",
"stylelint": "^14.5.3",
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-standard": "^25.0.0",
"tape": "^5.2.2",
"typescript": "^4.3.5",
"xo": "^0.42.0"
"xo": "^0.48.0"
},
"prettier": {
"bracketSpacing": false,
@@ -197,11 +198,16 @@
"prettier": true,
"rules": {
"@typescript-eslint/no-dynamic-delete": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"arrow-body-style": "error",
"import/first": "error",
"import/newline-after-import": "error",
"import/no-cycle": "error",
"import/extensions": [
"error",
"always",
{
"pattern": {
"ts": "never"
}
}
],
"import/no-restricted-paths": [
"error",
{
@@ -251,11 +257,21 @@
"paths": [
{
"name": "electron",
"message": "Use electron/main, electron/renderer, or electron/common."
},
{
"name": "electron/main",
"importNames": [
"ipcMain"
],
"message": "Use typed-ipc-main."
},
{
"name": "electron/renderer",
"importNames": [
"ipcMain",
"ipcRenderer"
],
"message": "Use typed-ipc-main and typed-ipc-renderer."
"message": "Use typed-ipc-renderer."
}
]
}
@@ -268,8 +284,8 @@
}
],
"strict": "error",
"unicorn/prefer-module": "off",
"unicorn/prefer-node-protocol": "off"
"unicorn/prefer-json-parse-buffer": "off",
"unicorn/prefer-module": "off"
},
"envs": [
"node",
@@ -281,23 +297,21 @@
"**/*.ts"
],
"rules": {
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/consistent-type-imports": [
"error",
{
"disallowTypeAnnotations": false
}
],
"@typescript-eslint/no-redeclare": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
"vars": "all",
"args": "after-used",
"argsIgnorePattern": "^_",
"caughtErrors": "all"
}
],
"no-redeclare": "off"
"unicorn/no-await-expression-member": "off"
},
"settings": {
"import/resolver": "typescript"

View File

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

View File

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

View File

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

4
tests/package.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

37
typings.d.ts vendored
View File

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