Compare commits

..

2 Commits

Author SHA1 Message Date
Akash Nimare
d9e09374c2 Update docs and code to point to new repo url. 2019-05-21 02:31:34 +05:30
Akash Nimare
04e9936e52 Update docs and code to point to new repo url. 2019-05-19 22:20:15 +05:30
303 changed files with 22756 additions and 28001 deletions

View File

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

2
.gitattributes vendored
View File

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

3
.github/FUNDING.yml vendored
View File

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

8
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +0,0 @@
name: Node.js CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test

10
.gitignore vendored
View File

@@ -1,15 +1,11 @@
# Dependency directory
/node_modules/
# Dependency directories
node_modules/
# npm cache directory
.npm
# transifexrc - if user prefers it to be in working tree
.transifexrc
# Compiled binary build directory
/dist/
/dist-electron/
dist/
#snap generated files
snap/parts

View File

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

View File

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

1
.node-version Normal file
View File

@@ -0,0 +1 @@
6.9.4

View File

@@ -1,3 +0,0 @@
/dist
/dist-electron
/public/translations/*.json

View File

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

37
.travis.yml Normal file
View File

@@ -0,0 +1,37 @@
sudo: required
dist: trusty
os:
- osx
- linux
addons:
apt:
packages:
- build-essential
- libxext-dev
- libxtst-dev
- libxkbfile-dev
language: node_js
node_js:
- '8'
before_install:
- ./scripts/travis-xvfb.sh
- npm install -g gulp
- npm install
cache:
directories:
- node_modules
- app/node_modules
script:
- npm run travis
notifications:
webhooks:
urls:
- https://zulip.org/zulipbot/travis
on_success: always
on_failure: always

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,90 +0,0 @@
import {Console} from "node:console"; // eslint-disable-line n/prefer-global/console
import fs from "node:fs";
import os from "node:os";
import process from "node:process";
import {app} from "zulip:remote";
import {initSetUp} from "./default-util.js";
type LoggerOptions = {
file?: string;
};
initSetUp();
const logDir = `${app.getPath("userData")}/Logs`;
type Level = "log" | "debug" | "info" | "warn" | "error";
export default class Logger {
nodeConsole: Console;
constructor(options: LoggerOptions = {}) {
let {file = "console.log"} = options;
file = `${logDir}/${file}`;
// Trim log according to type of process
if (process.type === "renderer") {
requestIdleCallback(async () => this.trimLog(file));
} else {
process.nextTick(async () => this.trimLog(file));
}
const fileStream = fs.createWriteStream(file, {flags: "a"});
const nodeConsole = new Console(fileStream);
this.nodeConsole = nodeConsole;
}
_log(type: Level, ...args: unknown[]): void {
args.unshift(this.getTimestamp() + " |\t");
args.unshift(type.toUpperCase() + " |");
this.nodeConsole[type](...args);
console[type](...args);
}
log(...args: unknown[]): void {
this._log("log", ...args);
}
debug(...args: unknown[]): void {
this._log("debug", ...args);
}
info(...args: unknown[]): void {
this._log("info", ...args);
}
warn(...args: unknown[]): void {
this._log("warn", ...args);
}
error(...args: unknown[]): void {
this._log("error", ...args);
}
getTimestamp(): string {
const date = new Date();
const timestamp =
`${date.getMonth()}/${date.getDate()} ` +
`${date.getMinutes()}:${date.getSeconds()}`;
return timestamp;
}
async trimLog(file: string): Promise<void> {
const data = await fs.promises.readFile(file, "utf8");
const maxLogFileLines = 500;
const logs = data.split(os.EOL);
const logLength = logs.length - 1;
// Keep bottom maxLogFileLines of each log instance
if (logLength > maxLogFileLines) {
const trimmedLogs = logs.slice(logLength - maxLogFileLines);
const toWrite = trimmedLogs.join(os.EOL);
await fs.promises.writeFile(file, toWrite);
}
}
}

View File

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

View File

@@ -1,15 +0,0 @@
import path from "node:path";
import process from "node:process";
import url from "node:url";
export const bundlePath = __dirname;
export const publicPath = import.meta.env.DEV
? path.join(bundlePath, "../public")
: bundlePath;
export const bundleUrl = import.meta.env.DEV
? process.env.VITE_DEV_SERVER_URL
: url.pathToFileURL(__dirname).href + "/";
export const publicUrl = bundleUrl;

View File

@@ -1,16 +0,0 @@
import path from "node:path";
import i18n from "i18n";
import * as ConfigUtil from "./config-util.js";
import {publicPath} from "./paths.js";
i18n.configure({
directory: path.join(publicPath, "translations/"),
updateFiles: false,
});
/* Fetches the current appLocale from settings.json */
i18n.setLocale(ConfigUtil.getConfigItem("appLanguage", "en") ?? "en");
export {__} from "i18n";

View File

@@ -1,84 +0,0 @@
import type {DndSettings} from "./dnd-util.js";
import type {MenuProps, ServerConf} from "./types.js";
export type MainMessage = {
"clear-app-settings": () => void;
"configure-spell-checker": () => void;
"fetch-user-agent": () => string;
"focus-app": () => void;
"focus-this-webview": () => void;
"new-clipboard-key": () => {key: Uint8Array; sig: Uint8Array};
"permission-callback": (permissionCallbackId: number, grant: boolean) => void;
"quit-app": () => void;
"realm-icon-changed": (serverURL: string, iconURL: string) => void;
"realm-name-changed": (serverURL: string, realmName: string) => void;
"reload-full-app": () => void;
"save-last-tab": (index: number) => void;
"switch-server-tab": (index: number) => void;
"toggle-app": () => void;
"toggle-badge-option": (newValue: boolean) => void;
"toggle-menubar": (showMenubar: boolean) => void;
toggleAutoLauncher: (AutoLaunchValue: boolean) => void;
"unread-count": (unreadCount: number) => void;
"update-badge": (messageCount: number) => void;
"update-menu": (props: MenuProps) => void;
"update-taskbar-icon": (data: string, text: string) => void;
};
export type MainCall = {
"get-server-settings": (domain: string) => ServerConf;
"is-online": (url: string) => boolean;
"poll-clipboard": (key: Uint8Array, sig: Uint8Array) => string | undefined;
"save-server-icon": (iconURL: string) => string | null;
};
export type RendererMessage = {
back: () => void;
"copy-zulip-url": () => void;
destroytray: () => void;
"enter-fullscreen": () => void;
focus: () => void;
"focus-webview-with-id": (webviewId: number) => void;
forward: () => void;
"hard-reload": () => void;
"leave-fullscreen": () => void;
"log-out": () => void;
logout: () => void;
"new-server": () => void;
"open-about": () => void;
"open-help": () => void;
"open-network-settings": () => void;
"open-org-tab": () => void;
"open-settings": () => void;
"permission-request": (
options: {webContentsId: number | null; origin: string; permission: string},
rendererCallbackId: number,
) => void;
"play-ding-sound": () => void;
"reload-current-viewer": () => void;
"reload-proxy": (showAlert: boolean) => void;
"reload-viewer": () => void;
"render-taskbar-icon": (messageCount: number) => void;
"set-active": () => void;
"set-idle": () => void;
"show-keyboard-shortcuts": () => void;
"show-notification-settings": () => void;
"switch-server-tab": (index: number) => void;
"tab-devtools": () => void;
"toggle-autohide-menubar": (
autoHideMenubar: boolean,
updateMenu: boolean,
) => void;
"toggle-dnd": (state: boolean, newSettings: Partial<DndSettings>) => void;
"toggle-sidebar": (show: boolean) => void;
"toggle-silent": (state: boolean) => void;
"toggle-tray": (state: boolean) => void;
toggletray: () => void;
tray: (arg: number) => void;
"update-realm-icon": (serverURL: string, iconURL: string) => void;
"update-realm-name": (serverURL: string, realmName: string) => void;
"webview-reload": () => void;
zoomActualSize: () => void;
zoomIn: () => void;
zoomOut: () => void;
};

View File

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

112
app/main/autoupdater.js Normal file
View File

@@ -0,0 +1,112 @@
'use strict';
const { app, dialog, shell } = require('electron');
const { autoUpdater } = require('electron-updater');
const isDev = require('electron-is-dev');
const ConfigUtil = require('./../renderer/js/utils/config-util.js');
function appUpdater(updateFromMenu = false) {
// Don't initiate auto-updates in development
if (isDev) {
return;
}
if (process.platform === 'linux' && !process.env.APPIMAGE) {
const { linuxUpdateNotification } = require('./linuxupdater');
linuxUpdateNotification();
return;
}
let updateAvailable = false;
// Create Logs directory
const LogsDir = `${app.getPath('userData')}/Logs`;
// Log whats happening
const log = require('electron-log');
log.transports.file.file = `${LogsDir}/updates.log`;
log.transports.file.level = 'info';
autoUpdater.logger = log;
// Handle auto updates for beta/pre releases
const isBetaUpdate = ConfigUtil.getConfigItem('betaUpdate');
autoUpdater.allowPrerelease = isBetaUpdate || false;
const eventsListenerRemove = ['update-available', 'update-not-available'];
autoUpdater.on('update-available', info => {
if (updateFromMenu) {
dialog.showMessageBox({
message: `A new version ${info.version}, of Zulip Desktop is available`,
detail: 'The update will be downloaded in the background. You will be notified when it is ready to be installed.'
});
updateAvailable = true;
// This is to prevent removal of 'update-downloaded' and 'error' event listener.
eventsListenerRemove.forEach(event => {
autoUpdater.removeAllListeners(event);
});
}
});
autoUpdater.on('update-not-available', () => {
if (updateFromMenu) {
dialog.showMessageBox({
message: 'No updates available',
detail: `You are running the latest version of Zulip Desktop.\nVersion: ${app.getVersion()}`
});
// Remove all autoUpdator listeners so that next time autoUpdator is manually called these
// listeners don't trigger multiple times.
autoUpdater.removeAllListeners();
}
});
autoUpdater.on('error', error => {
if (updateFromMenu) {
const messageText = (updateAvailable) ? ('Unable to download the updates') : ('Unable to check for updates');
dialog.showMessageBox({
type: 'error',
buttons: ['Manual Download', 'Cancel'],
message: messageText,
detail: (error).toString() + `\n\nThe latest version of Zulip Desktop is available at -\nhttps://zulipchat.com/apps/.\n
Current Version: ${app.getVersion()}`
}, response => {
if (response === 0) {
shell.openExternal('https://zulipchat.com/apps/');
}
});
// Remove all autoUpdator listeners so that next time autoUpdator is manually called these
// listeners don't trigger multiple times.
autoUpdater.removeAllListeners();
}
});
// Ask the user if update is available
// eslint-disable-next-line no-unused-vars
autoUpdater.on('update-downloaded', event => {
// Ask user to update the app
dialog.showMessageBox({
type: 'question',
buttons: ['Install and Relaunch', 'Install Later'],
defaultId: 0,
message: `A new update ${event.version} has been downloaded`,
detail: 'It will be installed the next time you restart the application'
}, response => {
if (response === 0) {
setTimeout(() => {
autoUpdater.quitAndInstall();
// force app to quit. This is just a workaround, ideally autoUpdater.quitAndInstall() should relaunch the app.
app.quit();
}, 1000);
}
});
});
// Init for updates
autoUpdater.checkForUpdates();
}
module.exports = {
appUpdater
};

View File

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

View File

@@ -1,61 +0,0 @@
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.js";
import {send} from "./typed-ipc-main.js";
function showBadgeCount(messageCount: number, mainWindow: BrowserWindow): void {
if (process.platform === "win32") {
updateOverlayIcon(messageCount, mainWindow);
} else {
app.badgeCount = messageCount;
}
}
function hideBadgeCount(mainWindow: BrowserWindow): void {
if (process.platform === "win32") {
mainWindow.setOverlayIcon(null, "");
} else {
app.badgeCount = 0;
}
}
export function updateBadge(
badgeCount: number,
mainWindow: BrowserWindow,
): void {
if (ConfigUtil.getConfigItem("badgeOption", true)) {
showBadgeCount(badgeCount, mainWindow);
} else {
hideBadgeCount(mainWindow);
}
}
function updateOverlayIcon(
messageCount: number,
mainWindow: BrowserWindow,
): void {
if (!mainWindow.isFocused()) {
mainWindow.flashFrame(
ConfigUtil.getConfigItem("flashTaskbarOnMessage", true),
);
}
if (messageCount === 0) {
mainWindow.setOverlayIcon(null, "");
} else {
send(mainWindow.webContents, "render-taskbar-icon", messageCount);
}
}
export function updateTaskbarIcon(
data: string,
text: string,
mainWindow: BrowserWindow,
): void {
const img = nativeImage.createFromDataURL(data);
mainWindow.setOverlayIcon(img, text);
}

View File

@@ -1,164 +0,0 @@
import type {Event} from "electron/common";
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.js";
import * as LinkUtil from "../common/link-util.js";
import {send} from "./typed-ipc-main.js";
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, 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, 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 {
let url: URL;
try {
url = new URL(details.url);
} catch {
return;
}
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))();
}
}

366
app/main/index.js Normal file
View File

@@ -0,0 +1,366 @@
'use strict';
const path = require('path');
const fs = require('fs');
const electron = require('electron');
const windowStateKeeper = require('electron-window-state');
const isDev = require('electron-is-dev');
const appMenu = require('./menu');
const { appUpdater } = require('./autoupdater');
const { setAutoLaunch } = require('./startup');
const { app, ipcMain } = electron;
const BadgeSettings = require('./../renderer/js/pages/preference/badge-settings.js');
const ConfigUtil = require('./../renderer/js/utils/config-util.js');
const ProxyUtil = require('./../renderer/js/utils/proxy-util.js');
const { sentryInit } = require('./../renderer/js/utils/sentry-util.js');
// Adds debug features like hotkeys for triggering dev tools and reload
// in development mode
if (isDev) {
require('electron-debug')();
}
// Prevent window being garbage collected
let mainWindow;
let badgeCount;
let isQuitting = false;
// Load this url in main window
const mainURL = 'file://' + path.join(__dirname, '../renderer', 'main.html');
const singleInstanceLock = app.requestSingleInstanceLock();
if (singleInstanceLock) {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
}
});
} else {
app.quit();
}
const APP_ICON = path.join(__dirname, '../resources', 'Icon');
const iconPath = () => {
return APP_ICON + (process.platform === 'win32' ? '.ico' : '.png');
};
function createMainWindow() {
// Load the previous state with fallback to defaults
const mainWindowState = windowStateKeeper({
defaultWidth: 1100,
defaultHeight: 720,
path: `${app.getPath('userData')}/config`
});
// Let's keep the window position global so that we can access it in other process
global.mainWindowState = mainWindowState;
const win = new electron.BrowserWindow({
// This settings needs to be saved in config
title: 'Zulip',
icon: iconPath(),
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
height: mainWindowState.height,
minWidth: 300,
minHeight: 400,
webPreferences: {
plugins: true,
nodeIntegration: true,
partition: 'persist:webviewsession'
},
show: false
});
win.on('focus', () => {
win.webContents.send('focus');
});
win.loadURL(mainURL);
// Keep the app running in background on close event
win.on('close', e => {
if (!isQuitting) {
e.preventDefault();
if (process.platform === 'darwin') {
app.hide();
} else {
win.hide();
}
}
});
win.setTitle('Zulip');
win.on('enter-full-screen', () => {
win.webContents.send('enter-fullscreen');
});
win.on('leave-full-screen', () => {
win.webContents.send('leave-fullscreen');
});
// To destroy tray icon when navigate to a new URL
win.webContents.on('will-navigate', e => {
if (e) {
win.webContents.send('destroytray');
}
});
// Let us register listeners on the window, so we can update the state
// automatically (the listeners will be removed when the window is closed)
// and restore the maximized or full screen state
mainWindowState.manage(win);
return win;
}
// Decrease load on GPU (experimental)
app.disableHardwareAcceleration();
// Temporary fix for Electron render colors differently
// More info here - https://github.com/electron/electron/issues/10732
app.commandLine.appendSwitch('force-color-profile', 'srgb');
// eslint-disable-next-line max-params
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
event.preventDefault();
callback(true);
});
app.on('activate', () => {
if (!mainWindow) {
mainWindow = createMainWindow();
}
});
app.on('ready', () => {
appMenu.setMenu({
tabs: []
});
mainWindow = createMainWindow();
// Auto-hide menu bar on Windows + Linux
if (process.platform !== 'darwin') {
const shouldHideMenu = ConfigUtil.getConfigItem('autoHideMenubar') || false;
mainWindow.setAutoHideMenuBar(shouldHideMenu);
mainWindow.setMenuBarVisibility(!shouldHideMenu);
}
// Initialize sentry for main process
const errorReporting = ConfigUtil.getConfigItem('errorReporting');
if (errorReporting) {
sentryInit();
}
const isSystemProxy = ConfigUtil.getConfigItem('useSystemProxy');
if (isSystemProxy) {
ProxyUtil.resolveSystemProxy(mainWindow);
}
const page = mainWindow.webContents;
page.on('dom-ready', () => {
if (ConfigUtil.getConfigItem('startMinimized')) {
mainWindow.hide();
} else {
mainWindow.show();
}
});
page.once('did-frame-finish-load', () => {
// Initiate auto-updates on MacOS and Windows
if (ConfigUtil.getConfigItem('autoUpdate')) {
appUpdater();
}
});
// Temporarily remove this event
// electron.powerMonitor.on('resume', () => {
// mainWindow.reload();
// page.send('destroytray');
// });
ipcMain.on('focus-app', () => {
mainWindow.show();
});
ipcMain.on('quit-app', () => {
app.quit();
});
// Code to show pdf in a new BrowserWindow (currently commented out due to bug-upstream)
// ipcMain.on('pdf-view', (event, url) => {
// // Paddings for pdfWindow so that it fits into the main browserWindow
// const paddingWidth = 55;
// const paddingHeight = 22;
// // Get the config of main browserWindow
// const mainWindowState = global.mainWindowState;
// // Window to view the pdf file
// const pdfWindow = new electron.BrowserWindow({
// x: mainWindowState.x + paddingWidth,
// y: mainWindowState.y + paddingHeight,
// width: mainWindowState.width - paddingWidth,
// height: mainWindowState.height - paddingHeight,
// webPreferences: {
// plugins: true,
// partition: 'persist:webviewsession'
// }
// });
// pdfWindow.loadURL(url);
// // We don't want to have the menu bar in pdf window
// pdfWindow.setMenu(null);
// });
// Reload full app not just webview, useful in debugging
ipcMain.on('reload-full-app', () => {
mainWindow.reload();
page.send('destroytray');
});
ipcMain.on('clear-app-settings', () => {
global.mainWindowState.unmanage(mainWindow);
app.relaunch();
app.exit();
});
ipcMain.on('toggle-app', () => {
if (!mainWindow.isVisible() || mainWindow.isMinimized()) {
mainWindow.show();
} else {
mainWindow.hide();
}
});
ipcMain.on('toggle-badge-option', () => {
BadgeSettings.updateBadge(badgeCount, mainWindow);
});
ipcMain.on('toggle-menubar', (event, showMenubar) => {
mainWindow.setAutoHideMenuBar(showMenubar);
mainWindow.setMenuBarVisibility(!showMenubar);
});
ipcMain.on('update-badge', (event, messageCount) => {
badgeCount = messageCount;
BadgeSettings.updateBadge(badgeCount, mainWindow);
page.send('tray', messageCount);
});
ipcMain.on('update-taskbar-icon', (event, data, text) => {
BadgeSettings.updateTaskbarIcon(data, text, mainWindow);
});
ipcMain.on('forward-message', (event, listener, ...params) => {
page.send(listener, ...params);
});
ipcMain.on('update-menu', (event, props) => {
appMenu.setMenu(props);
const activeTab = props.tabs[props.activeTabIndex];
if (activeTab) {
mainWindow.setTitle(`Zulip - ${activeTab.webview.props.name}`);
}
});
ipcMain.on('toggleAutoLauncher', (event, AutoLaunchValue) => {
setAutoLaunch(AutoLaunchValue);
});
ipcMain.on('downloadFile', (event, url, downloadPath) => {
page.downloadURL(url);
page.session.once('will-download', (event, item) => {
const filePath = path.join(downloadPath, item.getFilename());
const getTimeStamp = () => {
const date = new Date();
return date.getTime();
};
const formatFile = filePath => {
const fileExtension = path.extname(filePath);
const baseName = path.basename(filePath, fileExtension);
return `${baseName}-${getTimeStamp()}${fileExtension}`;
};
// Update the name and path of the file if it already exists
const updatedFilePath = path.join(downloadPath, formatFile(filePath));
const setFilePath = fs.existsSync(filePath) ? updatedFilePath : filePath;
item.setSavePath(setFilePath);
item.on('updated', (event, state) => {
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.once('done', (event, state) => {
const getFileName = fs.existsSync(filePath) ? formatFile(filePath) : item.getFilename();
if (state === 'completed') {
page.send('downloadFileCompleted', item.getSavePath(), getFileName);
} else {
console.log('Download failed state: ', state);
page.send('downloadFileFailed');
}
// To stop item for listening to updated events of this file
item.removeAllListeners('updated');
});
});
});
ipcMain.on('realm-name-changed', (event, serverURL, realmName) => {
page.send('update-realm-name', serverURL, realmName);
});
ipcMain.on('realm-icon-changed', (event, serverURL, iconURL) => {
page.send('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 => {
event.sender.send('error-reporting-val', errorReporting);
});
});
app.on('before-quit', () => {
isQuitting = true;
});
// Send crash reports
process.on('uncaughtException', err => {
console.error(err);
console.error(err.stack);
});

View File

@@ -1,471 +0,0 @@
import type {Event} from "electron/common";
import {clipboard} from "electron/common";
import type {IpcMainEvent, WebContents} from "electron/main";
import {
BrowserWindow,
app,
dialog,
powerMonitor,
session,
webContents,
} from "electron/main";
import {Buffer} from "node:buffer";
import crypto from "node:crypto";
import path from "node:path";
import process from "node:process";
import * as remoteMain from "@electron/remote/main";
import windowStateKeeper from "electron-window-state";
import * as ConfigUtil from "../common/config-util.js";
import {bundlePath, bundleUrl, publicPath} from "../common/paths.js";
import type {RendererMessage} from "../common/typed-ipc.js";
import type {MenuProps} from "../common/types.js";
import {appUpdater, shouldQuitForUpdate} from "./autoupdater.js";
import * as BadgeSettings from "./badge-settings.js";
import handleExternalLink from "./handle-external-link.js";
import * as AppMenu from "./menu.js";
import {_getServerSettings, _isOnline, _saveServerIcon} from "./request.js";
import {sentryInit} from "./sentry.js";
import {setAutoLaunch} from "./startup.js";
import {ipcMain, send} from "./typed-ipc-main.js";
import "gatemaker/electron-setup"; // eslint-disable-line import/no-unassigned-import
// 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: BrowserWindow;
let badgeCount: number;
let isQuitting = false;
// Load this file in main window
const mainUrl = new URL("app/renderer/main.html", bundleUrl).href;
const permissionCallbacks = new Map<number, (grant: boolean) => void>();
let nextPermissionCallbackId = 0;
const appIcon = path.join(publicPath, "resources/Icon");
const iconPath = (): string =>
appIcon + (process.platform === "win32" ? ".ico" : ".png");
// Toggle the app window
const toggleApp = (): void => {
if (!mainWindow.isVisible() || mainWindow.isMinimized()) {
mainWindow.show();
} else {
mainWindow.hide();
}
};
function createMainWindow(): BrowserWindow {
// Load the previous state with fallback to defaults
mainWindowState = windowStateKeeper({
defaultWidth: 1100,
defaultHeight: 720,
path: `${app.getPath("userData")}/config`,
});
const win = new BrowserWindow({
// This settings needs to be saved in config
title: "Zulip",
icon: iconPath(),
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
height: mainWindowState.height,
minWidth: 500,
minHeight: 400,
webPreferences: {
preload: path.join(bundlePath, "renderer.js"),
sandbox: false,
webviewTag: true,
},
show: false,
});
remoteMain.enable(win.webContents);
win.on("focus", () => {
send(win.webContents, "focus");
});
(async () => win.loadURL(mainUrl))();
// Keep the app running in background on close event
win.on("close", (event) => {
if (ConfigUtil.getConfigItem("quitOnClose", false)) {
app.quit();
}
if (!isQuitting && !shouldQuitForUpdate()) {
event.preventDefault();
if (process.platform === "darwin") {
app.hide();
} else {
win.hide();
}
}
});
win.setTitle("Zulip");
win.on("enter-full-screen", () => {
send(win.webContents, "enter-fullscreen");
});
win.on("leave-full-screen", () => {
send(win.webContents, "leave-fullscreen");
});
// To destroy tray icon when navigate to a new URL
win.webContents.on("will-navigate", (event) => {
if (event) {
send(win.webContents, "destroytray");
}
});
// Let us register listeners on the window, so we can update the state
// automatically (the listeners will be removed when the window is closed)
// and restore the maximized or full screen state
mainWindowState.manage(win);
return win;
}
(async () => {
if (!app.requestSingleInstanceLock()) {
app.quit();
return;
}
await app.whenReady();
if (process.env.GDK_BACKEND !== GDK_BACKEND) {
console.warn(
"Reverting GDK_BACKEND to work around https://github.com/electron/electron/issues/28436",
);
if (GDK_BACKEND === undefined) {
delete process.env.GDK_BACKEND;
} else {
process.env.GDK_BACKEND = GDK_BACKEND;
}
}
// Used for notifications on Windows
app.setAppUserModelId("org.zulip.zulip-electron");
remoteMain.initialize();
app.on("second-instance", () => {
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
}
});
ipcMain.on(
"permission-callback",
(event, permissionCallbackId: number, grant: boolean) => {
permissionCallbacks.get(permissionCallbackId)?.(grant);
permissionCallbacks.delete(permissionCallbackId);
},
);
// This event is only available on macOS. Triggers when you click on the dock icon.
app.on("activate", () => {
mainWindow.show();
});
app.on("web-contents-created", (_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()}`);
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);
const clipboardSigKey = crypto.randomBytes(32);
ipcMain.on("new-clipboard-key", (event) => {
const key = crypto.randomBytes(32);
const hmac = crypto.createHmac("sha256", clipboardSigKey);
hmac.update(key);
event.returnValue = {key, sig: hmac.digest()};
});
ipcMain.handle("poll-clipboard", (event, key, sig) => {
// Check that the key was generated here.
const hmac = crypto.createHmac("sha256", clipboardSigKey);
hmac.update(key);
if (!crypto.timingSafeEqual(sig, hmac.digest())) return;
try {
// Check that the data on the clipboard was encrypted to the key.
const data = Buffer.from(clipboard.readText(), "hex");
const iv = data.slice(0, 12);
const ciphertext = data.slice(12, -16);
const authTag = data.slice(-16);
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv, {
authTagLength: 16,
});
decipher.setAuthTag(authTag);
return (
decipher.update(ciphertext, undefined, "utf8") + decipher.final("utf8")
);
} catch {
// If the parsing or decryption failed in any way,
// the correct token hasnt been copied yet; try
// again next time.
return undefined;
}
});
AppMenu.setMenu({
tabs: [],
});
mainWindow = createMainWindow();
// Auto-hide menu bar on Windows + Linux
if (process.platform !== "darwin") {
const shouldHideMenu = ConfigUtil.getConfigItem("autoHideMenubar", false);
mainWindow.autoHideMenuBar = shouldHideMenu;
mainWindow.setMenuBarVisibility(!shouldHideMenu);
}
const page = mainWindow.webContents;
page.on("dom-ready", () => {
if (ConfigUtil.getConfigItem("startMinimized", false)) {
mainWindow.hide();
} else {
mainWindow.show();
}
});
ipcMain.on("fetch-user-agent", (event) => {
event.returnValue = session
.fromPartition("persist:webviewsession")
.getUserAgent();
});
ipcMain.handle("get-server-settings", async (event, domain: string) =>
_getServerSettings(domain, ses),
);
ipcMain.handle("save-server-icon", async (event, url: string) =>
_saveServerIcon(url, ses),
);
ipcMain.handle("is-online", async (event, url: string) =>
_isOnline(url, ses),
);
page.once("did-frame-finish-load", async () => {
// Initiate auto-updates on MacOS and Windows
if (ConfigUtil.getConfigItem("autoUpdate", true)) {
await appUpdater();
}
});
app.on(
"certificate-error",
(
event: Event,
webContents: WebContents,
urlString: string,
error: string,
) => {
const url = new URL(urlString);
dialog.showErrorBox(
"Certificate error",
`The server presented an invalid certificate for ${url.origin}:
${error}`,
);
},
);
ses.setPermissionRequestHandler(
(webContents, permission, callback, details) => {
const {origin} = new URL(details.requestingUrl);
const permissionCallbackId = nextPermissionCallbackId++;
permissionCallbacks.set(permissionCallbackId, callback);
send(
page,
"permission-request",
{
webContentsId:
webContents.id === mainWindow.webContents.id
? null
: webContents.id,
origin,
permission,
},
permissionCallbackId,
);
},
);
// Temporarily remove this event
// powerMonitor.on('resume', () => {
// mainWindow.reload();
// send(page, 'destroytray');
// });
ipcMain.on("focus-app", () => {
mainWindow.show();
});
ipcMain.on("quit-app", () => {
app.quit();
});
// Reload full app not just webview, useful in debugging
ipcMain.on("reload-full-app", () => {
mainWindow.reload();
send(page, "destroytray");
});
ipcMain.on("clear-app-settings", () => {
mainWindowState.unmanage();
app.relaunch();
app.exit();
});
ipcMain.on("toggle-app", () => {
toggleApp();
});
ipcMain.on("toggle-badge-option", () => {
BadgeSettings.updateBadge(badgeCount, mainWindow);
});
ipcMain.on("toggle-menubar", (_event, showMenubar: boolean) => {
mainWindow.autoHideMenuBar = showMenubar;
mainWindow.setMenuBarVisibility(!showMenubar);
send(page, "toggle-autohide-menubar", showMenubar, true);
});
ipcMain.on("update-badge", (_event, messageCount: number) => {
badgeCount = messageCount;
BadgeSettings.updateBadge(badgeCount, mainWindow);
send(page, "tray", messageCount);
});
ipcMain.on("update-taskbar-icon", (_event, data: string, text: string) => {
BadgeSettings.updateTaskbarIcon(data, text, mainWindow);
});
ipcMain.on(
"forward-message",
<Channel extends keyof RendererMessage>(
_event: IpcMainEvent,
listener: Channel,
...parameters: Parameters<RendererMessage[Channel]>
) => {
send(page, listener, ...parameters);
},
);
ipcMain.on(
"forward-to",
<Channel extends keyof RendererMessage>(
_event: IpcMainEvent,
webContentsId: number,
listener: Channel,
...parameters: Parameters<RendererMessage[Channel]>
) => {
const contents = webContents.fromId(webContentsId);
if (contents !== undefined) {
send(contents, listener, ...parameters);
}
},
);
ipcMain.on("update-menu", (_event, 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, AutoLaunchValue: boolean) => {
await setAutoLaunch(AutoLaunchValue);
});
ipcMain.on(
"realm-name-changed",
(_event, serverURL: string, realmName: string) => {
send(page, "update-realm-name", serverURL, realmName);
},
);
ipcMain.on(
"realm-icon-changed",
(_event, serverURL: string, iconURL: string) => {
send(page, "update-realm-icon", serverURL, iconURL);
},
);
ipcMain.on("save-last-tab", (_event, index: number) => {
ConfigUtil.setConfigItem("lastActiveTab", index);
});
ipcMain.on("focus-this-webview", (event) => {
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 = powerMonitor.getSystemIdleState(idleThresholdSeconds);
if (idleState === "active") {
send(page, "set-active");
} else {
send(page, "set-idle");
}
}, idleCheckInterval);
})();
app.on("before-quit", () => {
isQuitting = true;
});
// Send crash reports
process.on("uncaughtException", (error) => {
console.error(error);
console.error(error.stack);
});

View File

@@ -1,69 +0,0 @@
import {app, dialog} from "electron/main";
import fs from "node:fs";
import path from "node:path";
import {JsonDB} from "node-json-db";
import {DataError} from "node-json-db/dist/lib/Errors";
import Logger from "../common/logger-util.js";
const logger = new Logger({
file: "linux-update-util.log",
});
let db: JsonDB;
reloadDb();
export function getUpdateItem(
key: string,
defaultValue: true | null = null,
): true | null {
reloadDb();
let value: unknown;
try {
value = db.getObject<unknown>(`/${key}`);
} catch (error: unknown) {
if (!(error instanceof DataError)) throw error;
}
if (value !== true && value !== null) {
setUpdateItem(key, defaultValue);
return defaultValue;
}
return value;
}
export function setUpdateItem(key: string, value: true | null): void {
db.push(`/${key}`, value, true);
reloadDb();
}
export function removeUpdateItem(key: string): void {
db.delete(`/${key}`);
reloadDb();
}
function reloadDb(): void {
const linuxUpdateJsonPath = path.join(
app.getPath("userData"),
"/config/updates.json",
);
try {
const file = fs.readFileSync(linuxUpdateJsonPath, "utf8");
JSON.parse(file);
} catch (error: unknown) {
if (fs.existsSync(linuxUpdateJsonPath)) {
fs.unlinkSync(linuxUpdateJsonPath);
dialog.showErrorBox(
"Error saving update notifications.",
"We encountered an error while saving the update notifications.",
);
logger.error("Error while JSON parsing updates.json: ");
logger.error(error);
}
}
db = new JsonDB(linuxUpdateJsonPath, true, true);
}

53
app/main/linuxupdater.js Normal file
View File

@@ -0,0 +1,53 @@
const { app } = require('electron');
const { Notification } = require('electron');
const request = require('request');
const semver = require('semver');
const ConfigUtil = require('../renderer/js/utils/config-util');
const ProxyUtil = require('../renderer/js/utils/proxy-util');
const LinuxUpdateUtil = require('../renderer/js/utils/linux-update-util');
const Logger = require('../renderer/js/utils/logger-util');
const logger = new Logger({
file: 'linux-update-util.log',
timestamp: true
});
function linuxUpdateNotification() {
let url = 'https://api.github.com/repos/zulip/zulip-desktop/releases';
url = ConfigUtil.getConfigItem('betaUpdate') ? url : url + '/latest';
const proxyEnabled = ConfigUtil.getConfigItem('useManualProxy') || ConfigUtil.getConfigItem('useSystemProxy');
const options = {
url,
headers: {'User-Agent': 'request'},
proxy: proxyEnabled ? ProxyUtil.getProxy(url) : '',
ecdhCurve: 'auto'
};
request(options, (error, response, body) => {
if (error) {
logger.error('Linux update error.');
logger.error(error);
return;
}
if (response.statusCode < 400) {
const data = JSON.parse(body);
const latestVersion = ConfigUtil.getConfigItem('betaUpdate') ? data[0].tag_name : data.tag_name;
if (semver.gt(latestVersion, app.getVersion())) {
const notified = LinuxUpdateUtil.getUpdateItem(latestVersion);
if (notified === null) {
new Notification({title: 'Zulip Update', body: 'A new version ' + latestVersion + ' is available. Please update using your package manager.'}).show();
LinuxUpdateUtil.setUpdateItem(latestVersion, true);
}
}
} else {
logger.log('Linux update response status: ', response.statusCode);
}
});
}
module.exports = {
linuxUpdateNotification
};

View File

@@ -1,48 +0,0 @@
import type {Session} from "electron/main";
import {Notification, app} from "electron/main";
import * as semver from "semver";
import {z} from "zod";
import * as ConfigUtil from "../common/config-util.js";
import Logger from "../common/logger-util.js";
import * as LinuxUpdateUtil from "./linux-update-util.js";
const logger = new Logger({
file: "linux-update-util.log",
});
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";
try {
const response = await session.fetch(url);
if (!response.ok) {
logger.log("Linux update response status: ", response.status);
return;
}
const data: unknown = await response.json();
/* eslint-disable @typescript-eslint/naming-convention */
const latestVersion = ConfigUtil.getConfigItem("betaUpdate", false)
? z.array(z.object({tag_name: z.string()})).parse(data)[0].tag_name
: 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);
if (notified === null) {
new Notification({
title: "Zulip Update",
body: `A new version ${latestVersion} is available. Please update using your package manager.`,
}).show();
LinuxUpdateUtil.setUpdateItem(latestVersion, true);
}
}
} catch (error: unknown) {
logger.error("Linux update error.");
logger.error(error);
}
}

539
app/main/menu.js Normal file
View File

@@ -0,0 +1,539 @@
'use strict';
const path = require('path');
const { app, shell, BrowserWindow, Menu, dialog } = require('electron');
const fs = require('fs-extra');
const AdmZip = require('adm-zip');
const { appUpdater } = require('./autoupdater');
const ConfigUtil = require(__dirname + '/../renderer/js/utils/config-util.js');
const DNDUtil = require(__dirname + '/../renderer/js/utils/dnd-util.js');
const Logger = require(__dirname + '/../renderer/js/utils/logger-util.js');
const appName = app.getName();
const logger = new Logger({
file: 'errors.log',
timestamp: true
});
class AppMenu {
getHistorySubmenu() {
return [{
label: 'Back',
accelerator: process.platform === 'darwin' ? 'Command+Left' : 'Alt+Left',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('back');
}
}
}, {
label: 'Forward',
accelerator: process.platform === 'darwin' ? 'Command+Right' : 'Alt+Right',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('forward');
}
}
}];
}
getToolsSubmenu() {
return [{
label: `Check for Updates`,
click() {
AppMenu.checkForUpdate();
}
},
{
label: `Release Notes`,
click() {
shell.openExternal(`https://github.com/zulip/zulip-desktop/releases/tag/v${app.getVersion()}`);
}
}, {
type: 'separator'
}, {
label: 'Factory Reset',
accelerator: process.platform === 'darwin' ? 'Command+Shift+D' : 'Ctrl+Shift+D',
click() {
AppMenu.resetAppSettings();
}
}, {
label: 'Download App Logs',
click() {
const zip = new AdmZip();
let date = new Date();
date = date.toLocaleDateString().replace(/\//g, '-');
// Create a zip file of all the logs and config data
zip.addLocalFolder(`${app.getPath('appData')}/${appName}/Logs`);
zip.addLocalFolder(`${app.getPath('appData')}/${appName}/config`);
// Put the log file in downloads folder
const logFilePath = `${app.getPath('downloads')}/Zulip-logs-${date}.zip`;
zip.writeZip(logFilePath);
// Open and select the log file
shell.showItemInFolder(logFilePath);
}
}, {
type: 'separator'
}, {
label: 'Toggle DevTools for Zulip App',
accelerator: process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I',
click(item, focusedWindow) {
if (focusedWindow) {
focusedWindow.webContents.toggleDevTools();
}
}
}, {
label: 'Toggle DevTools for Active Tab',
accelerator: process.platform === 'darwin' ? 'Alt+Command+U' : 'Ctrl+Shift+U',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('tab-devtools');
}
}
}];
}
getViewSubmenu() {
return [{
label: 'Reload',
accelerator: 'CommandOrControl+R',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('reload-current-viewer');
}
}
}, {
label: 'Hard Reload',
accelerator: 'CommandOrControl+Shift+R',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('hard-reload');
}
}
}, {
type: 'separator'
}, {
role: 'togglefullscreen'
}, {
label: 'Zoom In',
accelerator: process.platform === 'darwin' ? 'Command+Plus' : 'Control+=',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('zoomIn');
}
}
}, {
label: 'Zoom Out',
accelerator: 'CommandOrControl+-',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('zoomOut');
}
}
}, {
label: 'Actual Size',
accelerator: 'CommandOrControl+0',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('zoomActualSize');
}
}
}, {
type: 'separator'
}, {
label: 'Toggle Tray Icon',
click(item, focusedWindow) {
if (focusedWindow) {
focusedWindow.webContents.send('toggletray');
}
}
}, {
label: 'Toggle Sidebar',
accelerator: 'CommandOrControl+Shift+S',
click(item, focusedWindow) {
if (focusedWindow) {
const newValue = !ConfigUtil.getConfigItem('showSidebar');
focusedWindow.webContents.send('toggle-sidebar', newValue);
ConfigUtil.setConfigItem('showSidebar', newValue);
}
}
}];
}
getHelpSubmenu() {
return [
{
label: `${appName + ' Desktop'} v${app.getVersion()}`,
enabled: false
},
{
label: 'About Zulip',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('open-about');
}
}
},
{
label: `Help Center`,
click() {
shell.openExternal('https://zulipchat.com/help/');
}
}, {
label: 'Report an Issue',
click() {
// the goal is to notify the main.html BrowserWindow
// which may not be the focused window.
BrowserWindow.getAllWindows().forEach(window => {
window.webContents.send('open-feedback-modal');
});
}
}];
}
getWindowSubmenu(tabs, activeTabIndex) {
const initialSubmenu = [{
role: 'minimize'
}, {
role: 'close'
}];
if (tabs.length > 0) {
const ShortcutKey = process.platform === 'darwin' ? 'Cmd' : 'Ctrl';
initialSubmenu.push({
type: 'separator'
});
for (let i = 0; i < tabs.length; i++) {
// Do not add functional tab settings to list of windows in menu bar
if (tabs[i].props.role === 'function' && tabs[i].webview.props.name === 'Settings') {
continue;
}
initialSubmenu.push({
label: tabs[i].webview.props.name,
accelerator: tabs[i].props.role === 'function' ? '' : `${ShortcutKey} + ${tabs[i].props.index + 1}`,
checked: tabs[i].props.index === activeTabIndex,
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('switch-server-tab', tabs[i].props.index);
}
},
type: 'checkbox'
});
}
initialSubmenu.push({
type: 'separator'
});
initialSubmenu.push({
label: 'Switch to Next Organization',
accelerator: `Ctrl+Tab`,
enabled: tabs[activeTabIndex].props.role === 'server',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('switch-server-tab', AppMenu.getNextServer(tabs, activeTabIndex));
}
}
}, {
label: 'Switch to Previous Organization',
accelerator: `Ctrl+Shift+Tab`,
enabled: tabs[activeTabIndex].props.role === 'server',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('switch-server-tab', AppMenu.getPreviousServer(tabs, activeTabIndex));
}
}
});
}
return initialSubmenu;
}
getDarwinTpl(props) {
const { tabs, activeTabIndex, enableMenu } = props;
return [{
label: `${app.getName()}`,
submenu: [{
label: 'Add Organization',
accelerator: 'Cmd+Shift+N',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('new-server');
}
}
}, {
label: 'Toggle Do Not Disturb',
accelerator: 'Cmd+Shift+M',
click() {
const dndUtil = DNDUtil.toggle();
AppMenu.sendAction('toggle-dnd', dndUtil.dnd, dndUtil.newSettings);
}
}, {
label: 'Desktop Settings',
accelerator: 'Cmd+,',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('open-settings');
}
}
}, {
label: 'Keyboard Shortcuts',
accelerator: 'Cmd+Shift+K',
enabled: enableMenu,
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('shortcut');
}
}
}, {
type: 'separator'
}, {
label: 'Copy Zulip URL',
accelerator: 'Cmd+Shift+C',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('copy-zulip-url');
}
}
}, {
label: 'Log Out of Organization',
accelerator: 'Cmd+L',
enabled: enableMenu,
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('log-out');
}
}
}, {
type: 'separator'
}, {
role: 'services',
submenu: []
}, {
type: 'separator'
}, {
role: 'hide'
}, {
role: 'hideothers'
}, {
role: 'unhide'
}, {
type: 'separator'
}, {
role: 'quit'
}]
}, {
label: 'Edit',
submenu: [{
role: 'undo'
}, {
role: 'redo'
}, {
type: 'separator'
}, {
role: 'cut'
}, {
role: 'copy'
}, {
role: 'paste'
}, {
role: 'pasteandmatchstyle'
}, {
role: 'selectall'
}]
}, {
label: 'View',
submenu: this.getViewSubmenu()
}, {
label: 'History',
submenu: this.getHistorySubmenu()
}, {
label: 'Window',
submenu: this.getWindowSubmenu(tabs, activeTabIndex)
}, {
label: 'Tools',
submenu: this.getToolsSubmenu()
}, {
role: 'help',
submenu: this.getHelpSubmenu()
}];
}
getOtherTpl(props) {
const { tabs, activeTabIndex, enableMenu } = props;
return [{
label: '&File',
submenu: [{
label: 'Add Organization',
accelerator: 'Ctrl+Shift+N',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('new-server');
}
}
}, {
type: 'separator'
}, {
label: 'Toggle Do Not Disturb',
accelerator: 'Ctrl+Shift+M',
click() {
const dndUtil = DNDUtil.toggle();
AppMenu.sendAction('toggle-dnd', dndUtil.dnd, dndUtil.newSettings);
}
}, {
label: 'Desktop Settings',
accelerator: 'Ctrl+,',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('open-settings');
}
}
}, {
label: 'Keyboard Shortcuts',
accelerator: 'Ctrl+Shift+K',
enabled: enableMenu,
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('shortcut');
}
}
}, {
type: 'separator'
}, {
label: 'Copy Zulip URL',
accelerator: 'Ctrl+Shift+C',
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('copy-zulip-url');
}
}
}, {
label: 'Log Out of Organization',
accelerator: 'Ctrl+L',
enabled: enableMenu,
click(item, focusedWindow) {
if (focusedWindow) {
AppMenu.sendAction('log-out');
}
}
}, {
type: 'separator'
}, {
role: 'quit',
accelerator: 'Ctrl+Q'
}]
}, {
label: '&Edit',
submenu: [{
role: 'undo'
}, {
role: 'redo'
}, {
type: 'separator'
}, {
role: 'cut'
}, {
role: 'copy'
}, {
role: 'paste'
}, {
role: 'pasteandmatchstyle'
}, {
type: 'separator'
}, {
role: 'selectall'
}]
}, {
label: '&View',
submenu: this.getViewSubmenu()
}, {
label: '&History',
submenu: this.getHistorySubmenu()
}, {
label: '&Window',
submenu: this.getWindowSubmenu(tabs, activeTabIndex)
}, {
label: '&Tools',
submenu: this.getToolsSubmenu()
}, {
label: '&Help',
role: 'help',
submenu: this.getHelpSubmenu()
}];
}
static sendAction(action, ...params) {
const win = BrowserWindow.getAllWindows()[0];
if (process.platform === 'darwin') {
win.restore();
}
win.webContents.send(action, ...params);
}
static checkForUpdate() {
appUpdater(true);
}
static getNextServer(tabs, activeTabIndex) {
do {
activeTabIndex = (activeTabIndex + 1) % tabs.length;
}
while (tabs[activeTabIndex].props.role !== 'server');
return activeTabIndex;
}
static getPreviousServer(tabs, activeTabIndex) {
do {
activeTabIndex = (activeTabIndex - 1 + tabs.length) % tabs.length;
}
while (tabs[activeTabIndex].props.role !== 'server');
return activeTabIndex;
}
static resetAppSettings() {
const resetAppSettingsMessage = 'By proceeding you will be removing all connected organizations and preferences from Zulip.';
// We save App's settings/configurations in following files
const settingFiles = ['config/window-state.json', 'config/domain.json', 'config/settings.json', 'config/certificates.json'];
dialog.showMessageBox({
type: 'warning',
buttons: ['YES', 'NO'],
defaultId: 0,
message: 'Are you sure?',
detail: resetAppSettingsMessage
}, response => {
if (response === 0) {
settingFiles.forEach(settingFileName => {
const getSettingFilesPath = path.join(app.getPath('appData'), appName, settingFileName);
fs.access(getSettingFilesPath, error => {
if (error) {
logger.error('Error while resetting app settings.');
logger.error(error);
} else {
fs.unlink(getSettingFilesPath, () => {
AppMenu.sendAction('clear-app-data');
});
}
});
});
}
});
}
setMenu(props) {
const tpl = process.platform === 'darwin' ? this.getDarwinTpl(props) : this.getOtherTpl(props);
const menu = Menu.buildFromTemplate(tpl);
Menu.setApplicationMenu(menu);
}
}
module.exports = new AppMenu();

View File

@@ -1,722 +0,0 @@
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";
import * as ConfigUtil from "../common/config-util.js";
import * as DNDUtil from "../common/dnd-util.js";
import * as t from "../common/translation-util.js";
import type {RendererMessage} from "../common/typed-ipc.js";
import type {MenuProps, TabData} from "../common/types.js";
import {appUpdater} from "./autoupdater.js";
import {send} from "./typed-ipc-main.js";
const appName = app.name;
function getHistorySubmenu(enableMenu: boolean): MenuItemConstructorOptions[] {
return [
{
label: t.__("Back"),
accelerator: process.platform === "darwin" ? "Command+Left" : "Alt+Left",
enabled: enableMenu,
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("back");
}
},
},
{
label: t.__("Forward"),
accelerator:
process.platform === "darwin" ? "Command+Right" : "Alt+Right",
enabled: enableMenu,
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("forward");
}
},
},
];
}
function getToolsSubmenu(): MenuItemConstructorOptions[] {
return [
{
label: t.__("Check for Updates"),
async click() {
await checkForUpdate();
},
},
{
label: t.__("Release Notes"),
async click() {
await shell.openExternal(
`https://github.com/zulip/zulip-desktop/releases/tag/v${app.getVersion()}`,
);
},
},
{
type: "separator",
},
{
label: t.__("Download App Logs"),
click() {
const zip = new AdmZip();
const date = new Date();
const dateString = date.toLocaleDateString().replaceAll("/", "-");
// Create a zip file of all the logs and config data
zip.addLocalFolder(`${app.getPath("appData")}/${appName}/Logs`);
zip.addLocalFolder(`${app.getPath("appData")}/${appName}/config`);
// Put the log file in downloads folder
const logFilePath = `${app.getPath(
"downloads",
)}/Zulip-logs-${dateString}.zip`;
zip.writeZip(logFilePath);
// Open and select the log file
shell.showItemInFolder(logFilePath);
},
},
{
type: "separator",
},
{
label: t.__("Toggle DevTools for Zulip App"),
accelerator:
process.platform === "darwin" ? "Alt+Command+I" : "Ctrl+Shift+I",
click(_item, focusedWindow) {
if (focusedWindow) {
focusedWindow.webContents.openDevTools({mode: "undocked"});
}
},
},
{
label: t.__("Toggle DevTools for Active Tab"),
accelerator:
process.platform === "darwin" ? "Alt+Command+U" : "Ctrl+Shift+U",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("tab-devtools");
}
},
},
];
}
function getViewSubmenu(): MenuItemConstructorOptions[] {
return [
{
label: t.__("Reload"),
accelerator: "CommandOrControl+R",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("reload-current-viewer");
}
},
},
{
label: t.__("Hard Reload"),
accelerator: "CommandOrControl+Shift+R",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("hard-reload");
}
},
},
{
label: t.__("Hard Reload"),
visible: false,
accelerator: "F5",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("hard-reload");
}
},
},
{
type: "separator",
},
{
label: t.__("Toggle Full Screen"),
role: "togglefullscreen",
},
{
label: t.__("Zoom In"),
accelerator: "CommandOrControl+=",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("zoomIn");
}
},
},
{
label: t.__("Zoom In"),
visible: false,
accelerator: "CommandOrControl+Plus",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("zoomIn");
}
},
},
{
label: t.__("Zoom In"),
visible: false,
accelerator: "CommandOrControl+numadd",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("zoomIn");
}
},
},
{
label: t.__("Zoom Out"),
accelerator: "CommandOrControl+-",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("zoomOut");
}
},
},
{
label: t.__("Zoom Out"),
visible: false,
accelerator: "CommandOrControl+numsub",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("zoomOut");
}
},
},
{
label: t.__("Actual Size"),
accelerator: "CommandOrControl+0",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("zoomActualSize");
}
},
},
{
label: t.__("Actual Size"),
visible: false,
accelerator: "CommandOrControl+num0",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("zoomActualSize");
}
},
},
{
type: "separator",
},
{
label: t.__("Toggle Tray Icon"),
click(_item, focusedWindow) {
if (focusedWindow) {
send(focusedWindow.webContents, "toggletray");
}
},
},
{
label: t.__("Toggle Sidebar"),
accelerator: "CommandOrControl+Shift+S",
click(_item, focusedWindow) {
if (focusedWindow) {
const newValue = !ConfigUtil.getConfigItem("showSidebar", true);
send(focusedWindow.webContents, "toggle-sidebar", newValue);
ConfigUtil.setConfigItem("showSidebar", newValue);
}
},
},
{
label: t.__("Auto hide Menu bar"),
checked: ConfigUtil.getConfigItem("autoHideMenubar", false),
visible: process.platform !== "darwin",
click(_item, focusedWindow) {
if (focusedWindow) {
const newValue = !ConfigUtil.getConfigItem("autoHideMenubar", false);
focusedWindow.autoHideMenuBar = newValue;
focusedWindow.setMenuBarVisibility(!newValue);
send(
focusedWindow.webContents,
"toggle-autohide-menubar",
newValue,
false,
);
ConfigUtil.setConfigItem("autoHideMenubar", newValue);
}
},
type: "checkbox",
},
];
}
function getHelpSubmenu(): MenuItemConstructorOptions[] {
return [
{
label: `${appName + " Desktop"} v${app.getVersion()}`,
enabled: false,
},
{
label: t.__("About Zulip"),
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("open-about");
}
},
},
{
label: t.__("Help Center"),
click(focusedWindow) {
if (focusedWindow) {
sendAction("open-help");
}
},
},
{
label: t.__("Report an Issue"),
async click() {
await shell.openExternal("https://zulip.com/help/contact-support");
},
},
];
}
function getWindowSubmenu(
tabs: TabData[],
activeTabIndex?: number,
): MenuItemConstructorOptions[] {
const initialSubmenu: MenuItemConstructorOptions[] = [
{
label: t.__("Minimize"),
role: "minimize",
},
{
label: t.__("Close"),
role: "close",
},
];
if (tabs.length > 0) {
const shortcutKey = process.platform === "darwin" ? "Cmd" : "Ctrl";
initialSubmenu.push({
type: "separator",
});
for (const tab of tabs) {
// Skip missing elements left by `delete this.tabs[index]` in
// ServerManagerView.
if (tab === undefined) continue;
// Do not add functional tab settings to list of windows in menu bar
if (tab.role === "function" && tab.name === "Settings") {
continue;
}
initialSubmenu.push({
label: tab.name,
accelerator:
tab.role === "function" ? "" : `${shortcutKey} + ${tab.index + 1}`,
checked: tab.index === activeTabIndex,
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("switch-server-tab", tab.index);
}
},
type: "checkbox",
});
}
initialSubmenu.push(
{
type: "separator",
},
{
label: t.__("Switch to Next Organization"),
accelerator: "Ctrl+Tab",
enabled: tabs.length > 1,
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction(
"switch-server-tab",
getNextServer(tabs, activeTabIndex!),
);
}
},
},
{
label: t.__("Switch to Previous Organization"),
accelerator: "Ctrl+Shift+Tab",
enabled: tabs.length > 1,
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction(
"switch-server-tab",
getPreviousServer(tabs, activeTabIndex!),
);
}
},
},
);
}
return initialSubmenu;
}
function getDarwinTpl(props: MenuProps): MenuItemConstructorOptions[] {
const {tabs, activeTabIndex, enableMenu = false} = props;
return [
{
label: app.name,
submenu: [
{
label: t.__("Add Organization"),
accelerator: "Cmd+Shift+N",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("new-server");
}
},
},
{
label: t.__("Toggle Do Not Disturb"),
accelerator: "Cmd+Shift+M",
click() {
const dndUtil = DNDUtil.toggle();
sendAction("toggle-dnd", dndUtil.dnd, dndUtil.newSettings);
},
},
{
label: t.__("Desktop Settings"),
accelerator: "Cmd+,",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("open-settings");
}
},
},
{
label: t.__("Keyboard Shortcuts"),
accelerator: "Cmd+Shift+K",
enabled: enableMenu,
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("show-keyboard-shortcuts");
}
},
},
{
type: "separator",
},
{
label: t.__("Copy Zulip URL"),
accelerator: "Cmd+Shift+C",
enabled: enableMenu,
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("copy-zulip-url");
}
},
},
{
label: t.__("Log Out of Organization"),
enabled: enableMenu,
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("log-out");
}
},
},
{
type: "separator",
},
{
label: t.__("Services"),
role: "services",
submenu: [],
},
{
type: "separator",
},
{
label: t.__("Hide"),
role: "hide",
},
{
label: t.__("Hide Others"),
role: "hideOthers",
},
{
label: t.__("Unhide"),
role: "unhide",
},
{
type: "separator",
},
{
label: t.__("Minimize"),
role: "minimize",
},
{
label: t.__("Close"),
role: "close",
},
{
label: t.__("Quit"),
role: "quit",
},
],
},
{
label: t.__("Edit"),
submenu: [
{
label: t.__("Undo"),
role: "undo",
},
{
label: t.__("Redo"),
role: "redo",
},
{
type: "separator",
},
{
label: t.__("Cut"),
role: "cut",
},
{
label: t.__("Copy"),
role: "copy",
},
{
label: t.__("Paste"),
role: "paste",
},
{
label: t.__("Paste and Match Style"),
role: "pasteAndMatchStyle",
},
{
label: t.__("Select All"),
role: "selectAll",
},
],
},
{
label: t.__("View"),
submenu: getViewSubmenu(),
},
{
label: t.__("History"),
submenu: getHistorySubmenu(enableMenu),
},
{
label: t.__("Window"),
submenu: getWindowSubmenu(tabs, activeTabIndex),
},
{
label: t.__("Tools"),
submenu: getToolsSubmenu(),
},
{
label: t.__("Help"),
role: "help",
submenu: getHelpSubmenu(),
},
];
}
function getOtherTpl(props: MenuProps): MenuItemConstructorOptions[] {
const {tabs, activeTabIndex, enableMenu = false} = props;
return [
{
label: t.__("File"),
submenu: [
{
label: t.__("Add Organization"),
accelerator: "Ctrl+Shift+N",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("new-server");
}
},
},
{
type: "separator",
},
{
label: t.__("Toggle Do Not Disturb"),
accelerator: "Ctrl+Shift+M",
click() {
const dndUtil = DNDUtil.toggle();
sendAction("toggle-dnd", dndUtil.dnd, dndUtil.newSettings);
},
},
{
label: t.__("Desktop Settings"),
accelerator: "Ctrl+,",
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("open-settings");
}
},
},
{
label: t.__("Keyboard Shortcuts"),
accelerator: "Ctrl+Shift+K",
enabled: enableMenu,
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("show-keyboard-shortcuts");
}
},
},
{
type: "separator",
},
{
label: t.__("Copy Zulip URL"),
accelerator: "Ctrl+Shift+C",
enabled: enableMenu,
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("copy-zulip-url");
}
},
},
{
label: t.__("Log Out of Organization"),
enabled: enableMenu,
click(_item, focusedWindow) {
if (focusedWindow) {
sendAction("log-out");
}
},
},
{
type: "separator",
},
{
label: t.__("Minimize"),
role: "minimize",
},
{
label: t.__("Close"),
role: "close",
},
{
label: t.__("Quit"),
role: "quit",
accelerator: "Ctrl+Q",
},
],
},
{
label: t.__("Edit"),
submenu: [
{
label: t.__("Undo"),
role: "undo",
},
{
label: t.__("Redo"),
role: "redo",
},
{
type: "separator",
},
{
label: t.__("Cut"),
role: "cut",
},
{
label: t.__("Copy"),
role: "copy",
},
{
label: t.__("Paste"),
role: "paste",
},
{
label: t.__("Paste and Match Style"),
role: "pasteAndMatchStyle",
},
{
type: "separator",
},
{
label: t.__("Select All"),
role: "selectAll",
},
],
},
{
label: t.__("View"),
submenu: getViewSubmenu(),
},
{
label: t.__("History"),
submenu: getHistorySubmenu(enableMenu),
},
{
label: t.__("Window"),
submenu: getWindowSubmenu(tabs, activeTabIndex),
},
{
label: t.__("Tools"),
submenu: getToolsSubmenu(),
},
{
label: t.__("Help"),
role: "help",
submenu: getHelpSubmenu(),
},
];
}
function sendAction<Channel extends keyof RendererMessage>(
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
): void {
const win = BrowserWindow.getAllWindows()[0];
if (process.platform === "darwin") {
win.restore();
}
send(win.webContents, channel, ...args);
}
async function checkForUpdate(): Promise<void> {
await appUpdater(true);
}
function getNextServer(tabs: TabData[], activeTabIndex: number): number {
do {
activeTabIndex = (activeTabIndex + 1) % tabs.length;
} while (tabs[activeTabIndex]?.role !== "server");
return activeTabIndex;
}
function getPreviousServer(tabs: TabData[], activeTabIndex: number): number {
do {
activeTabIndex = (activeTabIndex - 1 + tabs.length) % tabs.length;
} while (tabs[activeTabIndex]?.role !== "server");
return activeTabIndex;
}
export function setMenu(props: MenuProps): void {
const tpl =
process.platform === "darwin" ? getDarwinTpl(props) : getOtherTpl(props);
const menu = Menu.buildFromTemplate(tpl);
Menu.setApplicationMenu(menu);
}

View File

@@ -1,122 +0,0 @@
import type {Session} from "electron/main";
import {app} from "electron/main";
import fs from "node:fs";
import path from "node:path";
import {Readable} from "node:stream";
import {pipeline} from "node:stream/promises";
import type {ReadableStream} from "node:stream/web";
import * as Sentry from "@sentry/electron/main";
import {z} from "zod";
import Logger from "../common/logger-util.js";
import * as Messages from "../common/messages.js";
import type {ServerConf} from "../common/types.js";
/* Request: domain-util */
const logger = new Logger({
file: "domain-util.log",
});
const generateFilePath = (url: string): string => {
const dir = `${app.getPath("userData")}/server-icons`;
const extension = path.extname(url).split("?")[0];
let hash = 5381;
let {length} = url;
while (length) {
// eslint-disable-next-line no-bitwise, unicorn/prefer-code-point
hash = (hash * 33) ^ url.charCodeAt(--length);
}
// Create 'server-icons' directory if not existed
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
// eslint-disable-next-line no-bitwise
return `${dir}/${hash >>> 0}${extension}`;
};
export const _getServerSettings = async (
domain: string,
session: Session,
): Promise<ServerConf> => {
const response = await session.fetch(domain + "/api/v1/server_settings");
if (!response.ok) {
throw new Error(Messages.invalidZulipServerError(domain));
}
const data: unknown = await response.json();
/* eslint-disable @typescript-eslint/naming-convention */
const {
realm_name,
realm_uri,
realm_icon,
zulip_version,
zulip_feature_level,
} = z
.object({
realm_name: z.string(),
realm_uri: z.string().url(),
realm_icon: z.string(),
zulip_version: z.string().default("unknown"),
zulip_feature_level: z.number().default(0),
})
.parse(data);
/* eslint-enable @typescript-eslint/naming-convention */
return {
// Some Zulip Servers use absolute URL for server icon whereas others use relative URL
// Following check handles both the cases
icon: realm_icon.startsWith("/") ? realm_uri + realm_icon : realm_icon,
url: realm_uri,
alias: realm_name,
zulipVersion: zulip_version,
zulipFeatureLevel: zulip_feature_level,
};
};
export const _saveServerIcon = async (
url: string,
session: Session,
): Promise<string | null> => {
try {
const response = await session.fetch(url);
if (!response.ok) {
logger.log("Could not get server icon.");
return null;
}
const filePath = generateFilePath(url);
await pipeline(
Readable.fromWeb(response.body as ReadableStream<Uint8Array>),
fs.createWriteStream(filePath),
);
return filePath;
} catch (error: unknown) {
logger.log("Could not get server icon.");
logger.log(error);
Sentry.captureException(error);
return null;
}
};
/* Request: reconnect-util */
export const _isOnline = async (
url: string,
session: Session,
): Promise<boolean> => {
try {
const response = await session.fetch(`${url}/api/v1/server_settings`, {
method: "HEAD",
});
return response.ok;
} catch (error: unknown) {
logger.log(error);
return false;
}
};

View File

@@ -1,22 +0,0 @@
import {app} from "electron/main";
import * as Sentry from "@sentry/electron/main";
import {getConfigItem} from "../common/config-util.js";
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
});
};

34
app/main/startup.js Normal file
View File

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

View File

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

View File

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

1676
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
app/package.json Normal file
View File

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

View File

@@ -1,26 +1,47 @@
<!doctype html>
<meta charset="UTF-8" />
<link rel="stylesheet" href="css/about.css" />
<!DOCTYPE html>
<html lang="en">
<!-- Initially hidden to prevent FOUC -->
<div class="about" hidden>
<img class="logo" src="../resources/zulip.png" />
<p class="detail" id="version"></p>
<div class="maintenance-info">
<p class="detail maintainer">
Maintained by
<a href="https://zulip.com" target="_blank" rel="noopener noreferrer"
>Zulip</a
>
</p>
<p class="detail license">
Available under the
<a
href="https://github.com/zulip/zulip-desktop/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
>Apache 2.0 License</a
>
</p>
</div>
</div>
<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 onclick="linkInBrowser('website')">Zulip</a>
</p>
<p class="detail license">
Available under the
<a onclick="linkInBrowser('license')">Apache 2.0 License</a>
</p>
</div>
</div>
<script>
const { app } = require('electron').remote;
const { shell } = require('electron');
const version_tag = document.querySelector('#version');
version_tag.innerHTML = 'v' + app.getVersion();
function linkInBrowser(type) {
let url;
switch (type) {
case 'website':
url = "https://zulipchat.com";
break;
case 'license':
url = "https://github.com/zulip/zulip-desktop/blob/master/LICENSE";
break;
}
shell.openExternal(url);
}
</script>
<script>require('./js/shared/preventdrag.js')</script>
</body>
</html>

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -1,67 +0,0 @@
import {ipcRenderer} from "./typed-ipc-renderer.js";
// This helper is exposed via electron_bridge for use in the social
// login flow.
//
// It consists of a key and a promised token. The in-app page sends
// the key to the server, and opens the users browser to a page where
// they can log in and get a token encrypted to that key. When the
// user copies the encrypted token from their browser to the
// clipboard, we decrypt it and resolve the promise. The in-app page
// then uses the decrypted token to log the user in within the app.
//
// The encryption is authenticated (AES-GCM) to guarantee that we
// dont leak anything from the users clipboard other than the token
// intended for us.
export type ClipboardDecrypter = {
version: number;
key: Uint8Array;
pasted: Promise<string>;
};
export class ClipboardDecrypterImpl implements ClipboardDecrypter {
version: number;
key: Uint8Array;
pasted: Promise<string>;
constructor(_: number) {
// At this time, the only version is 1.
this.version = 1;
const {key, sig} = ipcRenderer.sendSync("new-clipboard-key");
this.key = key;
this.pasted = new Promise((resolve) => {
let interval: NodeJS.Timeout | null = null;
const startPolling = () => {
if (interval === null) {
interval = setInterval(poll, 1000);
}
void poll();
};
const stopPolling = () => {
if (interval !== null) {
clearInterval(interval);
interval = null;
}
};
const poll = async () => {
const plaintext = await ipcRenderer.invoke("poll-clipboard", key, sig);
if (plaintext === undefined) return;
window.removeEventListener("focus", startPolling);
window.removeEventListener("blur", stopPolling);
stopPolling();
resolve(plaintext);
};
window.addEventListener("focus", startPolling);
window.addEventListener("blur", stopPolling);
if (document.hasFocus()) {
startPolling();
}
});
}
}

View File

@@ -0,0 +1,11 @@
'use strict';
class BaseComponent {
generateNodeFromTemplate(template) {
const wrapper = document.createElement('div');
wrapper.innerHTML = template;
return wrapper.firstElementChild;
}
}
module.exports = BaseComponent;

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
'use strict';
const Tab = require(__dirname + '/../components/tab.js');
class FunctionalTab extends Tab {
template() {
return `<div class="tab functional-tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tab-badge close-button">
<i class="material-icons">close</i>
</div>
<div class="server-tab">
<i class="material-icons">${this.props.materialIcon}</i>
</div>
</div>`;
}
init() {
this.$el = this.generateNodeFromTemplate(this.template());
if (this.props.name !== 'Settings') {
this.props.$root.appendChild(this.$el);
this.$closeButton = this.$el.getElementsByClassName('server-tab-badge')[0];
this.registerListeners();
}
}
registerListeners() {
super.registerListeners();
this.$el.addEventListener('mouseover', () => {
this.$closeButton.classList.add('active');
});
this.$el.addEventListener('mouseout', () => {
this.$closeButton.classList.remove('active');
});
this.$closeButton.addEventListener('click', e => {
this.props.onDestroy();
e.stopPropagation();
});
}
}
module.exports = FunctionalTab;

View File

@@ -1,73 +0,0 @@
import type {Html} from "../../../common/html.js";
import {html} from "../../../common/html.js";
import {generateNodeFromHtml} from "./base.js";
import type {TabProps} from "./tab.js";
import Tab from "./tab.js";
export type FunctionalTabProps = {
$view: Element;
} & TabProps;
export default class FunctionalTab extends Tab {
$view: Element;
$el: Element;
$closeButton?: Element;
constructor({$view, ...props}: FunctionalTabProps) {
super(props);
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")!;
this.registerListeners();
}
}
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">
<i class="material-icons">close</i>
</div>
<div class="server-tab">
<i class="material-icons">${this.props.materialIcon}</i>
</div>
</div>
`;
}
override registerListeners(): void {
super.registerListeners();
this.$el.addEventListener("mouseover", () => {
this.$closeButton?.classList.add("active");
});
this.$el.addEventListener("mouseout", () => {
this.$closeButton?.classList.remove("active");
});
this.$closeButton?.addEventListener("click", (event) => {
this.props.onDestroy?.();
event.stopPropagation();
});
}
}

View File

@@ -0,0 +1,80 @@
const { ipcRenderer } = require('electron');
const { shell, app } = require('electron').remote;
const LinkUtil = require('../utils/link-util');
const DomainUtil = require('../utils/domain-util');
const ConfigUtil = require('../utils/config-util');
const dingSound = new Audio('../resources/sounds/ding.ogg');
function handleExternalLink(event) {
const { url } = event;
const domainPrefix = DomainUtil.getDomain(this.props.index).url;
const downloadPath = ConfigUtil.getConfigItem('downloadsPath', `${app.getPath('downloads')}`);
const shouldShowInFolder = ConfigUtil.getConfigItem('showDownloadFolder', false);
// Whitelist URLs which are allowed to be opened in the app
const {
isInternalUrl: isWhiteListURL,
isUploadsUrl: isUploadsURL
} = LinkUtil.isInternal(domainPrefix, url);
if (isWhiteListURL) {
event.preventDefault();
// Code to show pdf in a new BrowserWindow (currently commented out due to bug-upstream)
// Show pdf attachments in a new window
// if (LinkUtil.isPDF(url) && isUploadsURL) {
// ipcRenderer.send('pdf-view', url);
// return;
// }
// download txt, mp3, mp4 etc.. by using downloadURL in the
// main process which allows the user to save the files to their desktop
// and not trigger webview reload while image in webview will
// do nothing and will not save it
// Code to show pdf in a new BrowserWindow (currently commented out due to bug-upstream)
// if (!LinkUtil.isImage(url) && !LinkUtil.isPDF(url) && isUploadsURL) {
if (!LinkUtil.isImage(url) && isUploadsURL) {
ipcRenderer.send('downloadFile', url, downloadPath);
ipcRenderer.once('downloadFileCompleted', (event, filePath, fileName) => {
const downloadNotification = new Notification('Download Complete', {
body: shouldShowInFolder ? `Click to show ${fileName} in folder` : `Click to open ${fileName}`,
silent: true // We'll play our own sound - ding.ogg
});
// Play sound to indicate download complete
if (!ConfigUtil.getConfigItem('silent')) {
dingSound.play();
}
downloadNotification.onclick = () => {
if (shouldShowInFolder) {
// Reveal file in download folder
shell.showItemInFolder(filePath);
} else {
// Open file in the default native app
shell.openItem(filePath);
}
};
ipcRenderer.removeAllListeners('downloadFileFailed');
});
ipcRenderer.once('downloadFileFailed', () => {
// Automatic download failed, so show save dialog prompt and download
// through webview
this.$el.downloadURL(url);
ipcRenderer.removeAllListeners('downloadFileCompleted');
});
return;
}
// open internal urls inside the current webview.
this.$el.loadURL(url);
} else {
event.preventDefault();
shell.openExternal(url);
}
}
module.exports = handleExternalLink;

View File

@@ -0,0 +1,60 @@
'use strict';
const Tab = require(__dirname + '/../components/tab.js');
const SystemUtil = require(__dirname + '/../utils/system-util.js');
const {ipcRenderer} = require('electron');
class ServerTab extends Tab {
template() {
return `<div class="tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tooltip" style="display:none">${this.props.name}</div>
<div class="server-tab-badge"></div>
<div class="server-tab">
<img class="server-icons" src='${this.props.icon}'/>
</div>
<div class="server-tab-shortcut">${this.generateShortcutText()}</div>
</div>`;
}
init() {
super.init();
this.$badge = this.$el.getElementsByClassName('server-tab-badge')[0];
}
updateBadge(count) {
if (count > 0) {
const formattedCount = count > 999 ? '1K+' : count;
this.$badge.innerHTML = formattedCount;
this.$badge.classList.add('active');
} else {
this.$badge.classList.remove('active');
}
}
generateShortcutText() {
// Only provide shortcuts for server [0..10]
if (this.props.index >= 10) {
return '';
}
const shownIndex = this.props.index + 1;
let shortcutText = '';
if (SystemUtil.getOS() === 'Mac') {
shortcutText = `${shownIndex}`;
} else {
shortcutText = `Ctrl+${shownIndex}`;
}
// Array index == Shown index - 1
ipcRenderer.send('switch-server-tab', shownIndex - 1);
return shortcutText;
}
}
module.exports = ServerTab;

View File

@@ -1,95 +0,0 @@
import process from "node:process";
import type {Html} from "../../../common/html.js";
import {html} from "../../../common/html.js";
import {ipcRenderer} from "../typed-ipc-renderer.js";
import {generateNodeFromHtml} from "./base.js";
import type {TabProps} from "./tab.js";
import Tab from "./tab.js";
import type WebView from "./webview.js";
export type ServerTabProps = {
webview: Promise<WebView>;
} & TabProps;
export default class ServerTab extends Tab {
webview: Promise<WebView>;
$el: Element;
$name: Element;
$icon: HTMLImageElement;
$badge: Element;
constructor({webview, ...props}: ServerTabProps) {
super(props);
this.webview = webview;
this.$el = generateNodeFromHtml(this.templateHtml());
this.props.$root.append(this.$el);
this.registerListeners();
this.$name = this.$el.querySelector(".server-tooltip")!;
this.$icon = this.$el.querySelector(".server-icons")!;
this.$badge = this.$el.querySelector(".server-tab-badge")!;
}
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).destroy();
}
templateHtml(): Html {
return html`
<div class="tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tooltip" style="display:none">
${this.props.name}
</div>
<div class="server-tab-badge"></div>
<div class="server-tab">
<img class="server-icons" src="${this.props.icon}" />
</div>
<div class="server-tab-shortcut">${this.generateShortcutText()}</div>
</div>
`;
}
setName(name: string): void {
this.props.name = name;
this.$name.textContent = name;
}
setIcon(icon: string): void {
this.props.icon = icon;
this.$icon.src = icon;
}
updateBadge(count: number): void {
this.$badge.textContent = count > 999 ? "1K+" : count.toString();
this.$badge.classList.toggle("active", count > 0);
}
generateShortcutText(): string {
// Only provide shortcuts for server [0..9]
if (this.props.index >= 9) {
return "";
}
const shownIndex = this.props.index + 1;
// Array index == Shown index - 1
ipcRenderer.send("switch-server-tab", shownIndex - 1);
return process.platform === "darwin"
? `${shownIndex}`
: `Ctrl+${shownIndex}`;
}
}

View File

@@ -0,0 +1,48 @@
'use strict';
const BaseComponent = require(__dirname + '/../components/base.js');
class Tab extends BaseComponent {
constructor(props) {
super();
this.props = props;
this.webview = this.props.webview;
this.init();
}
init() {
this.$el = this.generateNodeFromTemplate(this.template());
this.props.$root.appendChild(this.$el);
this.registerListeners();
}
registerListeners() {
this.$el.addEventListener('click', this.props.onClick);
this.$el.addEventListener('mouseover', this.props.onHover);
this.$el.addEventListener('mouseout', this.props.onHoverOut);
}
isLoading() {
return this.webview.isLoading;
}
activate() {
this.$el.classList.add('active');
this.webview.load();
}
deactivate() {
this.$el.classList.remove('active');
this.webview.hide();
}
destroy() {
this.$el.parentNode.removeChild(this.$el);
this.webview.$el.parentNode.removeChild(this.webview.$el);
}
}
module.exports = Tab;

View File

@@ -1,45 +0,0 @@
import type {TabRole} from "../../../common/types.js";
export type TabProps = {
role: TabRole;
icon?: string;
name: string;
$root: Element;
onClick: () => void;
index: number;
tabIndex: number;
onHover?: () => void;
onHoverOut?: () => void;
materialIcon?: string;
onDestroy?: () => void;
};
export default abstract class Tab {
abstract $el: Element;
constructor(readonly props: TabProps) {}
registerListeners(): void {
this.$el.addEventListener("click", this.props.onClick);
if (this.props.onHover !== undefined) {
this.$el.addEventListener("mouseover", this.props.onHover);
}
if (this.props.onHoverOut !== undefined) {
this.$el.addEventListener("mouseout", this.props.onHoverOut);
}
}
async activate(): Promise<void> {
this.$el.classList.add("active");
}
async deactivate(): Promise<void> {
this.$el.classList.remove("active");
}
async destroy(): Promise<void> {
this.$el.remove();
}
}

View File

@@ -0,0 +1,260 @@
'use strict';
const path = require('path');
const fs = require('fs');
const ConfigUtil = require(__dirname + '/../utils/config-util.js');
const SystemUtil = require(__dirname + '/../utils/system-util.js');
const { app, dialog } = require('electron').remote;
const BaseComponent = require(__dirname + '/../components/base.js');
const handleExternalLink = require(__dirname + '/../components/handle-external-link.js');
const shouldSilentWebview = ConfigUtil.getConfigItem('silent');
class WebView extends BaseComponent {
constructor(props) {
super();
this.props = props;
this.zoomFactor = 1.0;
this.loading = true;
this.badgeCount = 0;
this.customCSS = ConfigUtil.getConfigItem('customCSS');
this.$webviewsContainer = document.querySelector('#webviews-container').classList;
}
template() {
return `<webview
class="disabled"
data-tab-id="${this.props.tabIndex}"
src="${this.props.url}"
${this.props.nodeIntegration ? 'nodeIntegration' : ''}
disablewebsecurity
${this.props.preload ? 'preload="js/preload.js"' : ''}
partition="persist:webviewsession"
name="${this.props.name}"
webpreferences="allowRunningInsecureContent, javascript=yes">
</webview>`;
}
init() {
this.$el = this.generateNodeFromTemplate(this.template());
this.props.$root.appendChild(this.$el);
this.registerListeners();
}
registerListeners() {
this.$el.addEventListener('new-window', event => {
handleExternalLink.call(this, event);
});
if (shouldSilentWebview) {
this.$el.addEventListener('dom-ready', () => {
this.$el.setAudioMuted(true);
});
}
this.$el.addEventListener('page-title-updated', event => {
const { title } = event;
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.canGoBackButton();
});
this.$el.addEventListener('did-navigate', () => {
this.canGoBackButton();
});
this.$el.addEventListener('page-favicon-updated', event => {
const { favicons } = event;
// This returns a string of favicons URL. If there is a PM counts in unread messages then the URL would be like
// https://chat.zulip.org/static/images/favicon/favicon-pms.png
if (favicons[0].indexOf('favicon-pms') > 0 && process.platform === 'darwin') {
// This api is only supported on macOS
app.dock.setBadge('●');
// bounce the dock
if (ConfigUtil.getConfigItem('dockBouncing')) {
app.dock.bounce();
}
}
});
this.$el.addEventListener('dom-ready', () => {
if (this.props.role === 'server') {
this.$el.classList.add('onload');
}
this.loading = false;
this.show();
// Refocus text boxes after reload
// Remove when upstream issue https://github.com/electron/electron/issues/14474 is fixed
this.$el.blur();
this.$el.focus();
});
this.$el.addEventListener('did-fail-load', event => {
const { errorDescription } = event;
const hasConnectivityErr = (SystemUtil.connectivityERR.indexOf(errorDescription) >= 0);
if (hasConnectivityErr) {
console.error('error', errorDescription);
this.props.onNetworkError();
}
});
this.$el.addEventListener('did-start-loading', () => {
let userAgent = SystemUtil.getUserAgent();
if (!userAgent) {
SystemUtil.setUserAgent(this.$el.getUserAgent());
userAgent = SystemUtil.getUserAgent();
}
this.$el.setUserAgent(userAgent);
});
}
getBadgeCount(title) {
const messageCountInTitle = (/\(([0-9]+)\)/).exec(title);
return messageCountInTitle ? Number(messageCountInTitle[1]) : 0;
}
show() {
// Do not show WebView if another tab was selected and this tab should be in background.
if (!this.props.isActive()) {
return;
}
// 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.$el.classList.remove('disabled');
this.$el.classList.add('active');
setTimeout(() => {
if (this.props.role === 'server') {
this.$el.classList.remove('onload');
}
}, 1000);
this.focus();
this.props.onTitleChange();
// Injecting preload css in webview to override some css rules
this.$el.insertCSS(fs.readFileSync(path.join(__dirname, '/../../css/preload.css'), 'utf8'));
// get customCSS again from config util to avoid warning user again
this.customCSS = ConfigUtil.getConfigItem('customCSS');
if (this.customCSS) {
if (!fs.existsSync(this.customCSS)) {
this.customCSS = null;
ConfigUtil.setConfigItem('customCSS', null);
const errMsg = 'The custom css previously set is deleted!';
dialog.showErrorBox('custom css file deleted!', errMsg);
return;
}
this.$el.insertCSS(fs.readFileSync(path.resolve(__dirname, this.customCSS), 'utf8'));
}
}
focus() {
// focus Webview and it's contents when Window regain focus.
const webContents = this.$el.getWebContents();
// 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.blur();
this.$el.focus();
webContents.focus();
}
}
hide() {
this.$el.classList.add('disabled');
this.$el.classList.remove('active');
}
load() {
if (this.$el) {
this.show();
} else {
this.init();
}
}
zoomIn() {
this.zoomFactor += 0.1;
this.$el.setZoomFactor(this.zoomFactor);
}
zoomOut() {
this.zoomFactor -= 0.1;
this.$el.setZoomFactor(this.zoomFactor);
}
zoomActualSize() {
this.zoomFactor = 1.0;
this.$el.setZoomFactor(this.zoomFactor);
}
logOut() {
this.$el.executeJavaScript('logout()');
}
showShortcut() {
this.$el.executeJavaScript('shortcut()');
}
openDevTools() {
this.$el.openDevTools();
}
back() {
if (this.$el.canGoBack()) {
this.$el.goBack();
this.focus();
}
}
canGoBackButton() {
const $backButton = document.querySelector('#actions-container #back-action');
if (this.$el.canGoBack()) {
$backButton.classList.remove('disable');
} else {
$backButton.classList.add('disable');
}
}
forward() {
if (this.$el.canGoForward()) {
this.$el.goForward();
}
}
reload() {
this.hide();
// Shows the loading indicator till the webview is reloaded
this.$webviewsContainer.remove('loaded');
this.loading = true;
this.$el.reload();
}
send(...param) {
this.$el.send(...param);
}
}
module.exports = WebView;

View File

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

View File

@@ -0,0 +1,40 @@
const events = require('events');
const { ipcRenderer } = require('electron');
// we have and will have some non camelcase stuff
// while working with zulip so just turning the rule off
// for the whole file.
/* eslint-disable camelcase */
class ElectronBridge extends events {
send_event(...args) {
this.emit(...args);
}
on_event(...args) {
this.on(...args);
}
}
const electron_bridge = new ElectronBridge();
electron_bridge.on('total_unread_count', (...args) => {
ipcRenderer.send('unread-count', ...args);
});
electron_bridge.on('realm_name', realmName => {
const serverURL = location.origin;
ipcRenderer.send('realm-name-changed', serverURL, realmName);
});
electron_bridge.on('realm_icon_url', iconURL => {
const serverURL = location.origin;
iconURL = iconURL.includes('http') ? iconURL : `${serverURL}${iconURL}`;
ipcRenderer.send('realm-icon-changed', serverURL, iconURL);
});
// this follows node's idiomatic implementation of event
// emitters to make event handling more simpler instead of using
// functions zulip side will emit event using ElectronBrigde.send_event
// which is alias of .emit and on this side we can handle the data by adding
// a listener for the event.
module.exports = electron_bridge;

View File

@@ -1,113 +0,0 @@
import {EventEmitter} from "node:events";
import type {ClipboardDecrypter} from "./clipboard-decrypter.js";
import {ClipboardDecrypterImpl} from "./clipboard-decrypter.js";
import type {NotificationData} from "./notification/index.js";
import {newNotification} from "./notification/index.js";
import {ipcRenderer} from "./typed-ipc-renderer.js";
type ListenerType = (...args: any[]) => void;
/* eslint-disable @typescript-eslint/naming-convention */
export type 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;
};
/* eslint-enable @typescript-eslint/naming-convention */
let notificationReplySupported = false;
// Indicates if the user is idle or not
let idle = false;
// Indicates the time at which user was last active
let lastActive = Date.now();
export const bridgeEvents = new EventEmitter(); // eslint-disable-line unicorn/prefer-event-target
/* eslint-disable @typescript-eslint/naming-convention */
const electron_bridge: ElectronBridge = {
send_event: (eventName: string | symbol, ...args: unknown[]): boolean =>
bridgeEvents.emit(eventName, ...args),
on_event(eventName: string, listener: ListenerType): void {
bridgeEvents.on(eventName, listener);
},
new_notification: (
title: string,
options: NotificationOptions,
dispatch: (type: string, eventInit: EventInit) => boolean,
): NotificationData => newNotification(title, options, dispatch),
get_idle_on_system: (): boolean => idle,
get_last_active_on_system: (): number => lastActive,
get_send_notification_reply_message_supported: (): boolean =>
notificationReplySupported,
set_send_notification_reply_message_supported(value: boolean): void {
notificationReplySupported = value;
},
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") {
throw new TypeError("Expected string for unreadCount");
}
ipcRenderer.send("unread-count", unreadCount);
});
bridgeEvents.on("realm_name", (realmName: unknown) => {
if (typeof realmName !== "string") {
throw new TypeError("Expected string for realmName");
}
const serverUrl = location.origin;
ipcRenderer.send("realm-name-changed", serverUrl, realmName);
});
bridgeEvents.on("realm_icon_url", (iconUrl: unknown) => {
if (typeof iconUrl !== "string") {
throw new TypeError("Expected string for iconUrl");
}
const serverUrl = location.origin;
ipcRenderer.send(
"realm-icon-changed",
serverUrl,
iconUrl.includes("http") ? iconUrl : `${serverUrl}${iconUrl}`,
);
});
// Set user as active and update the time of last activity
ipcRenderer.on("set-active", () => {
idle = false;
lastActive = Date.now();
});
// Set user as idle and time of last activity is left unchanged
ipcRenderer.on("set-idle", () => {
idle = true;
});
// This follows node's idiomatic implementation of event
// emitters to make event handling more simpler instead of using
// functions zulip side will emit event using ElectronBridge.send_event
// which is alias of .emit and on this side we can handle the data by adding
// a listener for the event.
export default electron_bridge;

View File

@@ -0,0 +1,62 @@
const { app } = require('electron').remote;
const path = require('path');
const fs = require('fs');
const SendFeedback = require('@electron-elements/send-feedback');
// make the button color match zulip app's theme
SendFeedback.customStyles = `
button:hover, button:focus {
border-color: #4EBFAC;
color: #4EBFAC;
}
button:active {
background-color: #f1f1f1;
color: #4EBFAC;
}
button {
background-color: #4EBFAC;
border-color: #4EBFAC;
}
`;
customElements.define('send-feedback', SendFeedback);
const sendFeedback = document.querySelector('send-feedback');
const feedbackHolder = sendFeedback.parentElement;
// 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: 'akash@zulipchat.com'
});
feedbackHolder.addEventListener('click', e => {
// only remove the class if the grey out faded
// part is clicked and not the feedback element itself
if (e.target === e.currentTarget) {
feedbackHolder.classList.remove('show');
}
});
sendFeedback.addEventListener('feedback-submitted', () => {
setTimeout(() => {
feedbackHolder.classList.remove('show');
}, 1000);
});
const dataDir = app.getPath('userData');
const logsDir = path.join(dataDir, '/Logs');
sendFeedback.logs.push(...fs.readdirSync(logsDir).map(file => path.join(logsDir, file)));
module.exports = {
feedbackHolder,
sendFeedback
};

769
app/renderer/js/main.js Normal file
View File

@@ -0,0 +1,769 @@
'use strict';
const { ipcRenderer, remote, clipboard } = require('electron');
const isDev = require('electron-is-dev');
const { session, app, Menu, dialog } = remote;
const escape = require('escape-html');
require(__dirname + '/js/tray.js');
const DomainUtil = require(__dirname + '/js/utils/domain-util.js');
const WebView = require(__dirname + '/js/components/webview.js');
const ServerTab = require(__dirname + '/js/components/server-tab.js');
const FunctionalTab = require(__dirname + '/js/components/functional-tab.js');
const ConfigUtil = require(__dirname + '/js/utils/config-util.js');
const DNDUtil = require(__dirname + '/js/utils/dnd-util.js');
const ReconnectUtil = require(__dirname + '/js/utils/reconnect-util.js');
const Logger = require(__dirname + '/js/utils/logger-util.js');
const CommonUtil = require(__dirname + '/js/utils/common-util.js');
const { feedbackHolder } = require(__dirname + '/js/feedback.js');
const logger = new Logger({
file: 'errors.log',
timestamp: true
});
class ServerManagerView {
constructor() {
this.$addServerButton = document.getElementById('add-tab');
this.$tabsContainer = document.getElementById('tabs-container');
const $actionsContainer = document.getElementById('actions-container');
this.$reloadButton = $actionsContainer.querySelector('#reload-action');
this.$settingsButton = $actionsContainer.querySelector('#settings-action');
this.$webviewsContainer = document.getElementById('webviews-container');
this.$backButton = $actionsContainer.querySelector('#back-action');
this.$dndButton = $actionsContainer.querySelector('#dnd-action');
this.$addServerTooltip = document.getElementById('add-server-tooltip');
this.$reloadTooltip = $actionsContainer.querySelector('#reload-tooltip');
this.$settingsTooltip = $actionsContainer.querySelector('#setting-tooltip');
this.$serverIconTooltip = document.getElementsByClassName('server-tooltip');
this.$backTooltip = $actionsContainer.querySelector('#back-tooltip');
this.$dndTooltip = $actionsContainer.querySelector('#dnd-tooltip');
this.$sidebar = document.getElementById('sidebar');
this.$fullscreenPopup = document.getElementById('fullscreen-popup');
this.$fullscreenEscapeKey = process.platform === 'darwin' ? '^⌘F' : 'F11';
this.$fullscreenPopup.innerHTML = `Press ${this.$fullscreenEscapeKey} to exit full screen`;
this.activeTabIndex = -1;
this.tabs = [];
this.functionalTabs = {};
this.tabIndex = 0;
}
init() {
this.loadProxy().then(() => {
this.initSidebar();
this.initTabs();
this.initActions();
this.registerIpcs();
this.initDefaultSettings();
});
}
loadProxy() {
return new Promise(resolve => {
// To change proxyEnable to useManualProxy in older versions
const proxyEnabledOld = ConfigUtil.isConfigItemExists('useProxy');
if (proxyEnabledOld) {
const proxyEnableOldState = ConfigUtil.getConfigItem('useProxy');
if (proxyEnableOldState) {
ConfigUtil.setConfigItem('useManualProxy', true);
}
ConfigUtil.removeConfigItem('useProxy');
}
const proxyEnabled = ConfigUtil.getConfigItem('useManualProxy') || ConfigUtil.getConfigItem('useSystemProxy');
if (proxyEnabled) {
session.fromPartition('persist:webviewsession').setProxy({
pacScript: ConfigUtil.getConfigItem('proxyPAC', ''),
proxyRules: ConfigUtil.getConfigItem('proxyRules', ''),
proxyBypassRules: ConfigUtil.getConfigItem('proxyBypass', '')
}, resolve);
} else {
session.fromPartition('persist:webviewsession').setProxy({
pacScript: '',
proxyRules: '',
proxyBypassRules: ''
}, resolve);
}
});
}
// Settings are initialized only when user clicks on General/Server/Network section settings
// In case, user doesn't visit these section, those values set to be null automatically
// This will make sure the default settings are correctly set to either true or false
initDefaultSettings() {
// Default settings which should be respected
const settingOptions = {
trayIcon: true,
useManualProxy: false,
useSystemProxy: false,
showSidebar: true,
badgeOption: true,
startAtLogin: true,
startMinimized: false,
enableSpellchecker: true,
showNotification: true,
autoUpdate: true,
betaUpdate: false,
errorReporting: true,
customCSS: false,
silent: false,
lastActiveTab: 0,
dnd: false,
dndPreviousSettings: {
showNotification: true,
silent: false
},
downloadsPath: `${app.getPath('downloads')}`,
showDownloadFolder: false
};
// Platform specific settings
if (process.platform === 'win32') {
// Only available on Windows
settingOptions.flashTaskbarOnMessage = true;
settingOptions.dndPreviousSettings.flashTaskbarOnMessage = true;
}
if (process.platform === 'darwin') {
// Only available on macOS
settingOptions.dockBouncing = true;
}
if (process.platform !== 'darwin') {
settingOptions.autoHideMenubar = false;
}
for (const i in settingOptions) {
if (ConfigUtil.getConfigItem(i) === null) {
ConfigUtil.setConfigItem(i, settingOptions[i]);
}
}
}
initSidebar() {
const showSidebar = ConfigUtil.getConfigItem('showSidebar', true);
this.toggleSidebar(showSidebar);
}
initTabs() {
const servers = DomainUtil.getDomains();
if (servers.length > 0) {
for (let i = 0; i < servers.length; i++) {
this.initServer(servers[i], i);
DomainUtil.updateSavedServer(servers[i].url, i);
this.activateTab(i);
}
// Open last active tab
this.activateTab(ConfigUtil.getConfigItem('lastActiveTab'));
// Remove focus from the settings icon at sidebar bottom
this.$settingsButton.classList.remove('active');
} else {
this.openSettings('AddServer');
}
}
initServer(server, index) {
const tabIndex = this.getTabIndex();
this.tabs.push(new ServerTab({
role: 'server',
icon: server.icon,
name: server.alias,
$root: this.$tabsContainer,
onClick: this.activateLastTab.bind(this, index),
index,
tabIndex,
onHover: this.onHover.bind(this, index),
onHoverOut: this.onHoverOut.bind(this, index),
webview: new WebView({
$root: this.$webviewsContainer,
index,
tabIndex,
url: server.url,
role: 'server',
name: CommonUtil.decodeString(server.alias),
isActive: () => {
return index === this.activeTabIndex;
},
onNetworkError: this.openNetworkTroubleshooting.bind(this),
onTitleChange: this.updateBadge.bind(this),
nodeIntegration: false,
preload: true
})
}));
}
initActions() {
this.initDNDButton();
this.initServerActions();
this.initLeftSidebarEvents();
}
initServerActions() {
const $serverImgs = document.querySelectorAll('.server-icons');
$serverImgs.forEach(($serverImg, index) => {
this.addContextMenu($serverImg, index);
if ($serverImg.src.includes('img/icon.png')) {
this.displayInitialCharLogo($serverImg, index);
}
$serverImg.addEventListener('error', () => {
this.displayInitialCharLogo($serverImg, index);
});
});
}
initLeftSidebarEvents() {
this.$dndButton.addEventListener('click', () => {
const dndUtil = DNDUtil.toggle();
ipcRenderer.send('forward-message', 'toggle-dnd', dndUtil.dnd, dndUtil.newSettings);
});
this.$reloadButton.addEventListener('click', () => {
this.tabs[this.activeTabIndex].webview.reload();
});
this.$addServerButton.addEventListener('click', () => {
this.openSettings('AddServer');
});
this.$settingsButton.addEventListener('click', () => {
this.openSettings('General');
});
this.$backButton.addEventListener('click', () => {
this.tabs[this.activeTabIndex].webview.back();
});
this.sidebarHoverEvent(this.$addServerButton, this.$addServerTooltip, true);
this.sidebarHoverEvent(this.$settingsButton, this.$settingsTooltip);
this.sidebarHoverEvent(this.$reloadButton, this.$reloadTooltip);
this.sidebarHoverEvent(this.$backButton, this.$backTooltip);
this.sidebarHoverEvent(this.$dndButton, this.$dndTooltip);
}
initDNDButton() {
const dnd = ConfigUtil.getConfigItem('dnd', false);
this.toggleDNDButton(dnd);
}
getTabIndex() {
const currentIndex = this.tabIndex;
this.tabIndex++;
return currentIndex;
}
displayInitialCharLogo($img, index) {
/*
index parameter needed because webview[data-tab-id] can increment
beyond size of sidebar org array and throw error
*/
const $altIcon = document.createElement('div');
const $parent = $img.parentElement;
const $container = $parent.parentElement;
const webviewId = $container.dataset.tabId;
const $webview = document.querySelector(`webview[data-tab-id="${webviewId}"]`);
const realmName = $webview.getAttribute('name');
if (realmName === null) {
$img.src = '/img/icon.png';
return;
}
$altIcon.textContent = realmName.charAt(0) || 'Z';
$altIcon.classList.add('server-icon');
$altIcon.classList.add('alt-icon');
$parent.removeChild($img);
$parent.appendChild($altIcon);
this.addContextMenu($altIcon, index);
}
sidebarHoverEvent(SidebarButton, SidebarTooltip, addServer = false) {
SidebarButton.addEventListener('mouseover', () => {
SidebarTooltip.removeAttribute('style');
// To handle position of add server tooltip due to scrolling of list of organizations
// This could not be handled using CSS, hence the top of the tooltip is made same
// as that of its parent element.
// This needs to handled only for the add server tooltip and not others.
if (addServer) {
const { top } = SidebarButton.getBoundingClientRect();
SidebarTooltip.style.top = top + 'px';
}
});
SidebarButton.addEventListener('mouseout', () => {
SidebarTooltip.style.display = 'none';
});
}
onHover(index) {
// this.$serverIconTooltip[index].innerHTML already has realm name, so we are just
// removing the style.
this.$serverIconTooltip[index].removeAttribute('style');
// To handle position of servers' tooltip due to scrolling of list of organizations
// This could not be handled using CSS, hence the top of the tooltip is made same
// as that of its parent element.
const { top } = this.$serverIconTooltip[index].parentElement.getBoundingClientRect();
this.$serverIconTooltip[index].style.top = top + 'px';
}
onHoverOut(index) {
this.$serverIconTooltip[index].style.display = 'none';
}
openFunctionalTab(tabProps) {
if (this.functionalTabs[tabProps.name] !== undefined) {
this.activateTab(this.functionalTabs[tabProps.name]);
return;
}
this.functionalTabs[tabProps.name] = this.tabs.length;
const tabIndex = this.getTabIndex();
this.tabs.push(new FunctionalTab({
role: 'function',
materialIcon: tabProps.materialIcon,
name: tabProps.name,
$root: this.$tabsContainer,
index: this.functionalTabs[tabProps.name],
tabIndex,
onClick: this.activateTab.bind(this, this.functionalTabs[tabProps.name]),
onDestroy: this.destroyTab.bind(this, tabProps.name, this.functionalTabs[tabProps.name]),
webview: new WebView({
$root: this.$webviewsContainer,
index: this.functionalTabs[tabProps.name],
tabIndex,
url: tabProps.url,
role: 'function',
name: tabProps.name,
isActive: () => {
return this.functionalTabs[tabProps.name] === this.activeTabIndex;
},
onNetworkError: this.openNetworkTroubleshooting.bind(this),
onTitleChange: this.updateBadge.bind(this),
nodeIntegration: true,
preload: false
})
}));
// To show loading indicator the first time a functional tab is opened, indicator is
// closed when the functional tab DOM is ready, handled in webview.js
this.$webviewsContainer.classList.remove('loaded');
this.activateTab(this.functionalTabs[tabProps.name]);
}
openSettings(nav = 'General') {
this.openFunctionalTab({
name: 'Settings',
materialIcon: 'settings',
url: `file://${__dirname}/preference.html#${nav}`
});
this.$settingsButton.classList.add('active');
this.tabs[this.functionalTabs.Settings].webview.send('switch-settings-nav', nav);
}
openAbout() {
this.openFunctionalTab({
name: 'About',
materialIcon: 'sentiment_very_satisfied',
url: `file://${__dirname}/about.html`
});
}
openNetworkTroubleshooting() {
this.openFunctionalTab({
name: 'Network Troubleshooting',
materialIcon: 'network_check',
url: `file://${__dirname}/network.html`
});
}
activateLastTab(index) {
// Open all the tabs in background, also activate the tab based on the index
this.activateTab(index);
// Save last active tab
ConfigUtil.setConfigItem('lastActiveTab', index);
}
// returns this.tabs in an way that does
// not crash app when this.tabs is passed into
// ipcRenderer. Something about webview, and props.webview
// properties in ServerTab causes the app to crash.
get tabsForIpc() {
const tabs = [];
this.tabs.forEach(tab => {
const proto = Object.create(Object.getPrototypeOf(tab));
const tabClone = Object.assign(proto, tab);
tabClone.webview = { props: {} };
tabClone.webview.props.name = tab.webview.props.name;
delete tabClone.props.webview;
tabs.push(tabClone);
});
return tabs;
}
activateTab(index, hideOldTab = true) {
if (!this.tabs[index]) {
return;
}
if (this.activeTabIndex !== -1) {
if (this.activeTabIndex === index) {
return;
} else if (hideOldTab) {
// If old tab is functional tab Settings, remove focus from the settings icon at sidebar bottom
if (this.tabs[this.activeTabIndex].props.role === 'function' && this.tabs[this.activeTabIndex].props.name === 'Settings') {
this.$settingsButton.classList.remove('active');
}
this.tabs[this.activeTabIndex].deactivate();
}
}
try {
this.tabs[index].webview.canGoBackButton();
} catch (err) {
}
this.activeTabIndex = index;
this.tabs[index].activate();
ipcRenderer.send('update-menu', {
// JSON stringify this.tabs to avoid a crash
// util.inspect is being used to handle circular references
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'
});
}
destroyTab(name, index) {
if (this.tabs[index].webview.loading) {
return;
}
this.tabs[index].destroy();
delete this.tabs[index];
delete this.functionalTabs[name];
// Issue #188: If the functional tab was not focused, do not activate another tab.
if (this.activeTabIndex === index) {
this.activateTab(0, false);
}
}
destroyView() {
// Show loading indicator
this.$webviewsContainer.classList.remove('loaded');
// Clear global variables
this.activeTabIndex = -1;
this.tabs = [];
this.functionalTabs = {};
// Clear DOM elements
this.$tabsContainer.innerHTML = '';
this.$webviewsContainer.innerHTML = '';
}
reloadView() {
// Save and remember the index of last active tab so that we can use it later
const lastActiveTab = this.tabs[this.activeTabIndex].props.index;
ConfigUtil.setConfigItem('lastActiveTab', lastActiveTab);
// Destroy the current view and re-initiate it
this.destroyView();
this.initTabs();
this.initServerActions();
}
// This will trigger when pressed CTRL/CMD + R [WIP]
// It won't reload the current view properly when you add/delete a server.
reloadCurrentView() {
this.$reloadButton.click();
}
updateBadge() {
let messageCountAll = 0;
for (let i = 0; i < this.tabs.length; i++) {
if (this.tabs[i] && this.tabs[i].updateBadge) {
const count = this.tabs[i].webview.badgeCount;
messageCountAll += count;
this.tabs[i].updateBadge(count);
}
}
ipcRenderer.send('update-badge', messageCountAll);
}
toggleSidebar(show) {
if (show) {
this.$sidebar.classList.remove('sidebar-hide');
} else {
this.$sidebar.classList.add('sidebar-hide');
}
}
// Toggles the dnd button icon.
toggleDNDButton(alert) {
this.$dndTooltip.textContent = (alert ? 'Disable' : 'Enable') + ' Do Not Disturb';
this.$dndButton.querySelector('i').textContent = alert ? 'notifications_off' : 'notifications';
}
addContextMenu($serverImg, index) {
$serverImg.addEventListener('contextmenu', e => {
e.preventDefault();
const template = [
{
label: 'Disconnect organization',
click: () => {
dialog.showMessageBox({
type: 'warning',
buttons: ['YES', 'NO'],
defaultId: 0,
message: 'Are you sure you want to disconnect this organization?'
}, response => {
if (response === 0) {
DomainUtil.removeDomain(index);
ipcRenderer.send('reload-full-app');
}
});
}
},
{
label: 'Copy Zulip URL',
click: () => {
clipboard.writeText(DomainUtil.getDomain(index).url);
}
}
];
const contextMenu = Menu.buildFromTemplate(template);
contextMenu.popup({ window: remote.getCurrentWindow() });
});
}
registerIpcs() {
const webviewListeners = {
'webview-reload': 'reload',
back: 'back',
focus: 'focus',
forward: 'forward',
zoomIn: 'zoomIn',
zoomOut: 'zoomOut',
zoomActualSize: 'zoomActualSize',
'log-out': 'logOut',
shortcut: 'showShortcut',
'tab-devtools': 'openDevTools'
};
for (const key in webviewListeners) {
ipcRenderer.on(key, () => {
const activeWebview = this.tabs[this.activeTabIndex].webview;
if (activeWebview) {
activeWebview[webviewListeners[key]]();
}
});
}
ipcRenderer.on('open-settings', (event, settingNav) => {
this.openSettings(settingNav);
});
ipcRenderer.on('open-about', this.openAbout.bind(this));
ipcRenderer.on('reload-viewer', this.reloadView.bind(this, this.tabs[this.activeTabIndex].props.index));
ipcRenderer.on('reload-current-viewer', this.reloadCurrentView.bind(this));
ipcRenderer.on('hard-reload', () => {
ipcRenderer.send('reload-full-app');
});
ipcRenderer.on('clear-app-data', () => {
ipcRenderer.send('clear-app-settings');
});
ipcRenderer.on('switch-server-tab', (event, index) => {
this.activateLastTab(index);
});
ipcRenderer.on('open-org-tab', () => {
this.openSettings('AddServer');
});
ipcRenderer.on('reload-proxy', (event, showAlert) => {
this.loadProxy().then(() => {
if (showAlert) {
alert('Proxy settings saved!');
ipcRenderer.send('reload-full-app');
}
});
});
ipcRenderer.on('toggle-sidebar', (event, show) => {
// Toggle the left sidebar
this.toggleSidebar(show);
// Toggle sidebar switch in the general settings
const selector = 'webview:not([class*=disabled])';
const webview = document.querySelector(selector);
const webContents = webview.getWebContents();
webContents.send('toggle-sidebar-setting', show);
});
ipcRenderer.on('toggle-silent', (event, state) => {
const webviews = document.querySelectorAll('webview');
webviews.forEach(webview => {
try {
webview.setAudioMuted(state);
} catch (err) {
// webview is not ready yet
webview.addEventListener('dom-ready', () => {
webview.setAudioMuted(state);
});
}
});
});
ipcRenderer.on('toggle-dnd', (event, state, newSettings) => {
this.toggleDNDButton(state);
ipcRenderer.send('forward-message', 'toggle-silent', newSettings.silent);
const selector = 'webview:not([class*=disabled])';
const webview = document.querySelector(selector);
const webContents = webview.getWebContents();
webContents.send('toggle-dnd', state, newSettings);
});
ipcRenderer.on('update-realm-name', (event, serverURL, realmName) => {
DomainUtil.getDomains().forEach((domain, index) => {
if (domain.url.includes(serverURL)) {
const serverTooltipSelector = `.tab .server-tooltip`;
const serverTooltips = document.querySelectorAll(serverTooltipSelector);
serverTooltips[index].innerHTML = escape(realmName);
this.tabs[index].props.name = escape(realmName);
this.tabs[index].webview.props.name = realmName;
domain.alias = escape(realmName);
DomainUtil.db.push(`/domains[${index}]`, domain, true);
DomainUtil.reloadDB();
// Update the realm name also on the Window menu
ipcRenderer.send('update-menu', {
tabs: this.tabsForIpc,
activeTabIndex: this.activeTabIndex
});
}
});
});
ipcRenderer.on('update-realm-icon', (event, serverURL, iconURL) => {
DomainUtil.getDomains().forEach((domain, index) => {
if (domain.url.includes(serverURL)) {
DomainUtil.saveServerIcon(iconURL).then(localIconUrl => {
const serverImgsSelector = `.tab .server-icons`;
const serverImgs = document.querySelectorAll(serverImgsSelector);
serverImgs[index].src = localIconUrl;
domain.icon = localIconUrl;
DomainUtil.db.push(`/domains[${index}]`, domain, true);
DomainUtil.reloadDB();
});
}
});
});
ipcRenderer.on('enter-fullscreen', () => {
this.$fullscreenPopup.classList.add('show');
this.$fullscreenPopup.classList.remove('hidden');
});
ipcRenderer.on('leave-fullscreen', () => {
this.$fullscreenPopup.classList.remove('show');
});
ipcRenderer.on('focus-webview-with-id', (event, webviewId) => {
const webviews = document.querySelectorAll('webview');
webviews.forEach(webview => {
const currentId = webview.getWebContents().id;
const tabId = webview.getAttribute('data-tab-id');
const concurrentTab = document.querySelector(`div[data-tab-id="${tabId}"]`);
if (currentId === webviewId) {
concurrentTab.click();
}
});
});
ipcRenderer.on('render-taskbar-icon', (event, messageCount) => {
// Create a canvas from unread messagecounts
function createOverlayIcon(messageCount) {
const canvas = document.createElement('canvas');
canvas.height = 128;
canvas.width = 128;
canvas.style.letterSpacing = '-5px';
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#f42020';
ctx.beginPath();
ctx.ellipse(64, 64, 64, 64, 0, 0, 2 * Math.PI);
ctx.fill();
ctx.textAlign = 'center';
ctx.fillStyle = 'white';
if (messageCount > 99) {
ctx.font = '65px Helvetica';
ctx.fillText('99+', 64, 85);
} else if (messageCount < 10) {
ctx.font = '90px Helvetica';
ctx.fillText(String(Math.min(99, messageCount)), 64, 96);
} else {
ctx.font = '85px Helvetica';
ctx.fillText(String(Math.min(99, messageCount)), 64, 90);
}
return canvas;
}
ipcRenderer.send('update-taskbar-icon', createOverlayIcon(messageCount).toDataURL(), String(messageCount));
});
ipcRenderer.on('open-feedback-modal', () => {
feedbackHolder.classList.add('show');
});
ipcRenderer.on('copy-zulip-url', () => {
clipboard.writeText(DomainUtil.getDomain(this.activeTabIndex).url);
});
ipcRenderer.on('new-server', () => {
this.openSettings('AddServer');
});
}
}
window.onload = () => {
const serverManagerView = new ServerManagerView();
const reconnectUtil = new ReconnectUtil(serverManagerView);
serverManagerView.init();
window.addEventListener('online', () => {
reconnectUtil.pollInternetAndReload();
});
window.addEventListener('offline', () => {
reconnectUtil.clearState();
logger.log('No internet connection, you are offline.');
});
// only start electron-connect (auto reload on change) when its ran
// from `npm run dev` or `gulp dev` and not from `npm start` when
// app is started `npm start` main process's proces.argv will have
// `--no-electron-connect`
const mainProcessArgv = remote.getGlobal('process').argv;
if (isDev && !mainProcessArgv.includes('--no-electron-connect')) {
const electronConnect = require('electron-connect');
electronConnect.client.create();
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
'use strict';
const { ipcRenderer } = require('electron');
const url = require('url');
const MacNotifier = require('node-mac-notifier');
const ConfigUtil = require('../utils/config-util');
const {
appId, customReply, focusCurrentServer, parseReply, setupReply
} = require('./helpers');
let replyHandler;
let clickHandler;
class DarwinNotification {
constructor(title, opts) {
const silent = ConfigUtil.getConfigItem('silent') || false;
const { host, protocol } = location;
const { icon } = opts;
const profilePic = url.resolve(`${protocol}//${host}`, icon);
this.tag = opts.tag;
const notification = new MacNotifier(title, Object.assign(opts, {
bundleId: appId,
canReply: true,
silent,
icon: profilePic
}));
notification.addEventListener('click', () => {
// focus to the server who sent the
// notification if not focused already
if (clickHandler) {
clickHandler();
}
focusCurrentServer();
ipcRenderer.send('focus-app');
});
notification.addEventListener('reply', this.notificationHandler);
}
static requestPermission() {
return; // eslint-disable-line no-useless-return
}
// Override default Notification permission
static get permission() {
return ConfigUtil.getConfigItem('showNotification') ? 'granted' : 'denied';
}
set onreply(handler) {
replyHandler = handler;
}
get onreply() {
return replyHandler;
}
set onclick(handler) {
clickHandler = handler;
}
get onclick() {
return clickHandler;
}
// not something that is common or
// used by zulip server but added to be
// future proff.
addEventListener(event, handler) {
if (event === 'click') {
clickHandler = handler;
}
if (event === 'reply') {
replyHandler = handler;
}
}
notificationHandler({ response }) {
response = parseReply(response);
focusCurrentServer();
setupReply(this.tag);
if (replyHandler) {
replyHandler(response);
return;
}
customReply(response);
}
// method specific to notification api
// used by zulip
close() {
return; // eslint-disable-line no-useless-return
}
}
module.exports = DarwinNotification;

View File

@@ -0,0 +1,31 @@
'use strict';
const { ipcRenderer } = require('electron');
const ConfigUtil = require('../utils/config-util');
const { focusCurrentServer } = require('./helpers');
const NativeNotification = window.Notification;
class BaseNotification extends NativeNotification {
constructor(title, opts) {
opts.silent = true;
super(title, opts);
this.addEventListener('click', () => {
// focus to the server who sent the
// notification if not focused already
focusCurrentServer();
ipcRenderer.send('focus-app');
});
}
static requestPermission() {
return; // eslint-disable-line no-useless-return
}
// Override default Notification permission
static get permission() {
return ConfigUtil.getConfigItem('showNotification') ? 'granted' : 'denied';
}
}
module.exports = BaseNotification;

View File

@@ -0,0 +1,156 @@
const { remote } = require('electron');
const Logger = require('../utils/logger-util.js');
const logger = new Logger({
file: 'errors.log',
timestamp: true
});
// Do not change this
const appId = 'org.zulip.zulip-electron';
const botsList = [];
let botsListLoaded = false;
// this function load list of bots from the server
// sync=True for a synchronous getJSON request
// in case botsList isn't already completely loaded when required in parseRely
function loadBots(sync = false) {
const { $ } = window;
botsList.length = 0;
if (sync) {
$.ajaxSetup({async: false});
}
$.getJSON('/json/users')
.done(data => {
const members = data.members;
members.forEach(membersRow => {
if (membersRow.is_bot) {
const bot = `@${membersRow.full_name}`;
const mention = `@**${bot.replace(/^@/, '')}**`;
botsList.push([bot, mention]);
}
});
botsListLoaded = true;
})
.fail(error => {
logger.log('Load bots request failed: ', error.responseText);
logger.log('Load bots request status: ', error.statusText);
});
if (sync) {
$.ajaxSetup({async: true});
}
}
function checkElements(...elements) {
let status = true;
elements.forEach(element => {
if (element === null || element === undefined) {
status = false;
}
});
return status;
}
function customReply(reply) {
// server does not support notification reply yet.
const buttonSelector = '.messagebox #send_controls button[type=submit]';
const messageboxSelector = '.selected_message .messagebox .messagebox-border .messagebox-content';
const textarea = document.querySelector('#compose-textarea');
const messagebox = document.querySelector(messageboxSelector);
const sendButton = document.querySelector(buttonSelector);
// sanity check for old server versions
const elementsExists = checkElements(textarea, messagebox, sendButton);
if (!elementsExists) {
return;
}
textarea.value = reply;
messagebox.click();
sendButton.click();
}
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
function focusCurrentServer() {
currentWindow.send('focus-webview-with-id', webContentsId);
}
// this function parses the reply from to notification
// making it easier to reply from notification eg
// @username in reply will be converted to @**username**
// #stream in reply will be converted to #**stream**
// bot mentions are not yet supported
function parseReply(reply) {
const usersDiv = document.querySelectorAll('#user_presences li');
const streamHolder = document.querySelectorAll('#stream_filters li');
const users = [];
const streams = [];
usersDiv.forEach(userRow => {
const anchor = userRow.querySelector('span a');
if (anchor !== null) {
const user = `@${anchor.textContent.trim()}`;
const mention = `@**${user.replace(/^@/, '')}**`;
users.push([user, mention]);
}
});
streamHolder.forEach(stream => {
const streamAnchor = stream.querySelector('div a');
if (streamAnchor !== null) {
const streamName = `#${streamAnchor.textContent.trim()}`;
const streamMention = `#**${streamName.replace(/^#/, '')}**`;
streams.push([streamName, streamMention]);
}
});
users.forEach(([user, mention]) => {
if (reply.includes(user)) {
const regex = new RegExp(user, 'g');
reply = reply.replace(regex, mention);
}
});
streams.forEach(([stream, streamMention]) => {
const regex = new RegExp(stream, 'g');
reply = reply.replace(regex, streamMention);
});
// If botsList isn't completely loaded yet, make a synchronous getJSON request for list
if (botsListLoaded === false) {
loadBots(true);
}
// Iterate for every bot name and replace in reply
// @botname with @**botname**
botsList.forEach(([bot, mention]) => {
if (reply.includes(bot)) {
const regex = new RegExp(bot, 'g');
reply = reply.replace(regex, mention);
}
});
reply = reply.replace(/\\n/, '\n');
return reply;
}
function setupReply(id) {
const { narrow } = window;
const narrowByTopic = narrow.by_topic || narrow.by_subject;
narrowByTopic(id, { trigger: 'notification' });
}
module.exports = {
appId,
checkElements,
customReply,
parseReply,
setupReply,
focusCurrentServer,
loadBots
};

View File

@@ -0,0 +1,27 @@
'use strict';
const {
remote: { app }
} = require('electron');
const params = require('../utils/params-util.js');
const DefaultNotification = require('./default-notification');
const { appId, loadBots } = require('./helpers');
// 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);
window.Notification = DefaultNotification;
if (process.platform === 'darwin') {
const DarwinNotification = require('./darwin-notifications');
window.Notification = DarwinNotification;
}
window.addEventListener('load', () => {
// eslint-disable-next-line no-undef, camelcase
if (params.isPageParams() && page_params.realm_uri) {
loadBots();
}
});

View File

@@ -1,41 +0,0 @@
import {ipcRenderer} from "../typed-ipc-renderer.js";
export type NotificationData = {
close: () => void;
title: string;
dir: NotificationDirection;
lang: string;
body: string;
tag: string;
icon: string;
data: unknown;
};
export function newNotification(
title: string,
options: NotificationOptions,
dispatch: (type: string, eventInit: EventInit) => boolean,
): NotificationData {
const notification = new Notification(title, {...options, silent: true});
for (const type of ["click", "close", "error", "show"]) {
notification.addEventListener(type, (ev) => {
if (type === "click") ipcRenderer.send("focus-this-webview");
if (!dispatch(type, ev)) {
ev.preventDefault();
}
});
}
return {
close() {
notification.close();
},
title: notification.title,
dir: notification.dir,
lang: notification.lang,
body: notification.body,
tag: notification.tag,
icon: notification.icon,
data: notification.data,
};
}

View File

@@ -1,24 +0,0 @@
import {app} from "@electron/remote";
import {bundleUrl} from "../../../common/paths.js";
export class AboutView {
static async create(): Promise<AboutView> {
return new AboutView(
await (await fetch(new URL("app/renderer/about.html", bundleUrl))).text(),
);
}
readonly $view: HTMLElement;
private constructor(templateHtml: string) {
this.$view = document.createElement("div");
const $shadow = this.$view.attachShadow({mode: "open"});
$shadow.innerHTML = templateHtml;
$shadow.querySelector("#version")!.textContent = `v${app.getVersion()}`;
}
destroy() {
// Do nothing.
}
}

View File

@@ -0,0 +1,20 @@
'use strict';
const {ipcRenderer} = require('electron');
class NetworkTroubleshootingView {
constructor() {
this.$reconnectButton = document.getElementById('reconnect');
}
init() {
this.$reconnectButton.addEventListener('click', () => {
ipcRenderer.send('forward-message', 'reload-viewer');
});
}
}
window.onload = () => {
const networkTroubleshootingView = new NetworkTroubleshootingView();
networkTroubleshootingView.init();
};

View File

@@ -1,13 +0,0 @@
import {ipcRenderer} from "../typed-ipc-renderer.js";
export function init(
$reconnectButton: Element,
$settingsButton: Element,
): void {
$reconnectButton.addEventListener("click", () => {
ipcRenderer.send("forward-message", "reload-viewer");
});
$settingsButton.addEventListener("click", () => {
ipcRenderer.send("forward-message", "open-settings");
});
}

View File

@@ -0,0 +1,92 @@
'use-strict';
const { dialog } = require('electron').remote;
const path = require('path');
const BaseComponent = require(__dirname + '/../../components/base.js');
const CertificateUtil = require(__dirname + '/../../utils/certificate-util.js');
const DomainUtil = require(__dirname + '/../../utils/domain-util.js');
class AddCertificate extends BaseComponent {
constructor(props) {
super();
this.props = props;
this._certFile = '';
}
template() {
return `
<div class="settings-card certificates-card">
<div class="certificate-input">
<div>Organization URL</div>
<input class="setting-input-value" autofocus placeholder="your-organization.zulipchat.com or zulip.your-organization.com"/>
</div>
<div class="certificate-input">
<div>Certificate file</div>
<button class="green" id="add-certificate-button">Upload</button>
</div>
</div>
`;
}
init() {
this.$addCertificate = this.generateNodeFromTemplate(this.template());
this.props.$root.appendChild(this.$addCertificate);
this.addCertificateButton = this.$addCertificate.querySelector('#add-certificate-button');
this.serverUrl = this.$addCertificate.querySelectorAll('input.setting-input-value')[0];
this.initListeners();
}
validateAndAdd() {
const certificate = this._certFile;
const serverUrl = this.serverUrl.value;
if (certificate !== '' && serverUrl !== '') {
const server = encodeURIComponent(DomainUtil.formatUrl(serverUrl));
const fileName = path.basename(certificate);
const copy = CertificateUtil.copyCertificate(server, certificate, fileName);
if (!copy) {
return;
}
CertificateUtil.setCertificate(server, fileName);
dialog.showMessageBox({
title: 'Success',
message: `Certificate saved!`
});
this.serverUrl.value = '';
} else {
dialog.showErrorBox('Error', `Please, ${serverUrl === '' ?
'Enter an Organization URL' : 'Choose certificate file'}`);
}
}
addHandler() {
const showDialogOptions = {
title: 'Select file',
defaultId: 1,
properties: ['openFile'],
filters: [{ name: 'crt, pem', extensions: ['crt', 'pem'] }]
};
dialog.showOpenDialog(showDialogOptions, selectedFile => {
if (selectedFile) {
this._certFile = selectedFile[0] || '';
this.validateAndAdd();
}
});
}
initListeners() {
this.addCertificateButton.addEventListener('click', () => {
this.addHandler();
});
this.serverUrl.addEventListener('keypress', event => {
const EnterkeyCode = event.keyCode;
if (EnterkeyCode === 13) {
this.addHandler();
}
});
}
}
module.exports = AddCertificate;

View File

@@ -0,0 +1,63 @@
'use strict';
const electron = require('electron');
const { app } = require('electron');
const ConfigUtil = require(__dirname + '/../../utils/config-util.js');
let instance = null;
class BadgeSettings {
constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
return instance;
}
showBadgeCount(messageCount, mainWindow) {
if (process.platform === 'darwin') {
app.setBadgeCount(messageCount);
}
if (process.platform === 'win32') {
this.updateOverlayIcon(messageCount, mainWindow);
}
}
hideBadgeCount(mainWindow) {
if (process.platform === 'darwin') {
app.setBadgeCount(0);
}
if (process.platform === 'win32') {
mainWindow.setOverlayIcon(null, '');
}
}
updateBadge(badgeCount, mainWindow) {
if (ConfigUtil.getConfigItem('badgeOption', true)) {
this.showBadgeCount(badgeCount, mainWindow);
} else {
this.hideBadgeCount(mainWindow);
}
}
updateOverlayIcon(messageCount, mainWindow) {
if (!mainWindow.isFocused()) {
mainWindow.flashFrame(ConfigUtil.getConfigItem('flashTaskbarOnMessage'));
}
if (messageCount === 0) {
mainWindow.setOverlayIcon(null, '');
} else {
mainWindow.webContents.send('render-taskbar-icon', messageCount);
}
}
updateTaskbarIcon(data, text, mainWindow) {
const img = electron.nativeImage.createFromDataURL(data);
mainWindow.setOverlayIcon(img, text);
}
}
module.exports = new BadgeSettings();

View File

@@ -0,0 +1,46 @@
'use strict';
const {ipcRenderer} = require('electron');
const BaseComponent = require(__dirname + '/../../components/base.js');
class BaseSection extends BaseComponent {
generateSettingOption(props) {
const {$element, value, clickHandler} = props;
$element.innerHTML = '';
const $optionControl = this.generateNodeFromTemplate(this.generateOptionTemplate(value));
$element.appendChild($optionControl);
$optionControl.addEventListener('click', clickHandler);
}
generateOptionTemplate(settingOption) {
if (settingOption) {
return `
<div class="action">
<div class="switch">
<input class="toggle toggle-round" type="checkbox" checked>
<label></label>
</div>
</div>
`;
} else {
return `
<div class="action">
<div class="switch">
<input class="toggle toggle-round" type="checkbox">
<label></label>
</div>
</div>
`;
}
}
reloadApp() {
ipcRenderer.send('forward-message', 'reload-viewer');
}
}
module.exports = BaseSection;

View File

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

View File

@@ -0,0 +1,72 @@
'use strict';
const BaseSection = require(__dirname + '/base-section.js');
const DomainUtil = require(__dirname + '/../../utils/domain-util.js');
const ServerInfoForm = require(__dirname + '/server-info-form.js');
const AddCertificate = require(__dirname + '/add-certificate.js');
class ConnectedOrgSection extends BaseSection {
constructor(props) {
super();
this.props = props;
}
template() {
return `
<div class="settings-pane" id="server-settings-pane">
<div class="page-title">Connected organizations</div>
<div class="title" id="existing-servers">All the connected orgnizations will appear here.</div>
<div id="server-info-container"></div>
<div id="new-org-button"><button class="green sea w-250">Connect to another organization</button></div>
<div class="page-title">Add Custom Certificates</div>
<div id="add-certificate-container"></div>
</div>
`;
}
init() {
this.initServers();
}
initServers() {
this.props.$root.innerHTML = '';
const servers = DomainUtil.getDomains();
this.props.$root.innerHTML = this.template();
this.$serverInfoContainer = document.getElementById('server-info-container');
this.$existingServers = document.getElementById('existing-servers');
this.$newOrgButton = document.getElementById('new-org-button');
this.$addCertificateContainer = document.getElementById('add-certificate-container');
const noServerText = 'All the connected orgnizations will appear here';
// Show noServerText if no servers are there otherwise hide it
this.$existingServers.innerHTML = servers.length === 0 ? noServerText : '';
for (let i = 0; i < servers.length; i++) {
new ServerInfoForm({
$root: this.$serverInfoContainer,
server: servers[i],
index: i,
onChange: this.reloadApp
}).init();
}
this.$newOrgButton.addEventListener('click', () => {
// We don't need to import this since it's already imported in other files
// eslint-disable-next-line no-undef
ipcRenderer.send('forward-message', 'open-org-tab');
});
this.initAddCertificate();
}
initAddCertificate() {
new AddCertificate({
$root: this.$addCertificateContainer
}).init();
}
}
module.exports = ConnectedOrgSection;

View File

@@ -1,67 +0,0 @@
import {html} from "../../../../common/html.js";
import * as t from "../../../../common/translation-util.js";
import {ipcRenderer} from "../../typed-ipc-renderer.js";
import * as DomainUtil from "../../utils/domain-util.js";
import {reloadApp} from "./base-section.js";
import {initFindAccounts} from "./find-accounts.js";
import {initServerInfoForm} from "./server-info-form.js";
type ConnectedOrgSectionProps = {
$root: Element;
};
export function initConnectedOrgSection({
$root,
}: ConnectedOrgSectionProps): void {
$root.textContent = "";
const servers = DomainUtil.getDomains();
$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">
${t.__("All the connected organizations will appear here.")}
</div>
<div id="server-info-container"></div>
<div id="new-org-button">
<button class="green sea w-250">
${t.__("Connect to another organization")}
</button>
</div>
<div class="page-title">${t.__("Find accounts by email")}</div>
<div id="find-accounts-container"></div>
</div>
`.html;
const $serverInfoContainer = $root.querySelector("#server-info-container")!;
const $existingServers = $root.querySelector("#existing-servers")!;
const $newOrgButton: HTMLButtonElement =
$root.querySelector("#new-org-button")!;
const $findAccountsContainer = $root.querySelector(
"#find-accounts-container",
)!;
const noServerText = t.__(
"All the connected organizations will appear here.",
);
// Show noServerText if no servers are there otherwise hide it
$existingServers.textContent = servers.length === 0 ? noServerText : "";
for (const [i, server] of servers.entries()) {
initServerInfoForm({
$root: $serverInfoContainer,
server,
index: i,
onChange: reloadApp,
});
}
$newOrgButton.addEventListener("click", () => {
ipcRenderer.send("forward-message", "open-org-tab");
});
initFindAccounts({
$root: $findAccountsContainer,
});
}

View File

@@ -1,66 +0,0 @@
import {html} from "../../../../common/html.js";
import * as LinkUtil from "../../../../common/link-util.js";
import * as t from "../../../../common/translation-util.js";
import {generateNodeFromHtml} from "../../components/base.js";
type FindAccountsProps = {
$root: Element;
};
async function findAccounts(url: string): Promise<void> {
if (!url) {
return;
}
if (!url.startsWith("http")) {
url = "https://" + url;
}
await LinkUtil.openBrowser(new URL("/accounts/find", url));
}
export function initFindAccounts(props: FindAccountsProps): void {
const $findAccounts = generateNodeFromHtml(html`
<div class="settings-card certificate-card">
<div class="certificate-input">
<div>${t.__("Organization URL")}</div>
<input class="setting-input-value" value="zulipchat.com" />
</div>
<div class="certificate-input">
<button class="green w-150" id="find-accounts-button">
${t.__("Find accounts")}
</button>
</div>
</div>
`);
props.$root.append($findAccounts);
const $findAccountsButton = $findAccounts.querySelector(
"#find-accounts-button",
)!;
const $serverUrlField: HTMLInputElement = $findAccounts.querySelector(
"input.setting-input-value",
)!;
$findAccountsButton.addEventListener("click", async () => {
await findAccounts($serverUrlField.value);
});
$serverUrlField.addEventListener("click", () => {
if ($serverUrlField.value === "zulipchat.com") {
$serverUrlField.setSelectionRange(0, 0);
}
});
$serverUrlField.addEventListener("keypress", async (event) => {
if (event.key === "Enter") {
await findAccounts($serverUrlField.value);
}
});
$serverUrlField.addEventListener("input", () => {
$serverUrlField.classList.toggle(
"invalid-input-value",
$serverUrlField.value === "",
);
});
}

View File

@@ -0,0 +1,450 @@
'use strict';
const path = require('path');
const { ipcRenderer, remote } = require('electron');
const fs = require('fs-extra');
const { app, dialog } = remote;
const currentBrowserWindow = remote.getCurrentWindow();
const BaseSection = require(__dirname + '/base-section.js');
const ConfigUtil = require(__dirname + '/../../utils/config-util.js');
class GeneralSection extends BaseSection {
constructor(props) {
super();
this.props = props;
}
template() {
return `
<div class="settings-pane">
<div class="title">Appearance</div>
<div id="appearance-option-settings" class="settings-card">
<div class="setting-row" id="tray-option">
<div class="setting-description">Show app icon in system tray</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="menubar-option" style= "display:${process.platform === 'darwin' ? 'none' : ''}">
<div class="setting-description">Auto hide menu bar (Press Alt key to display)</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="sidebar-option">
<div class="setting-description">Show sidebar (<span class="code">${process.platform === 'darwin' ? 'Cmd+Shift+S' : 'Ctrl+Shift+S'}</span>)</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="badge-option">
<div class="setting-description">Show app unread badge</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="dock-bounce-option" style= "display:${process.platform === 'darwin' ? '' : 'none'}">
<div class="setting-description">Bounce dock on new private message</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="flash-taskbar-option" style= "display:${process.platform === 'win32' ? '' : 'none'}">
<div class="setting-description">Flash taskbar on new message</div>
<div class="setting-control"></div>
</div>
</div>
<div class="title">Desktop Notifications</div>
<div class="settings-card">
<div class="setting-row" id="show-notification-option">
<div class="setting-description">Show desktop notifications</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="silent-option">
<div class="setting-description">Mute all sounds from Zulip</div>
<div class="setting-control"></div>
</div>
</div>
<div class="title">App Updates</div>
<div class="settings-card">
<div class="setting-row" id="autoupdate-option">
<div class="setting-description">Enable auto updates</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="betaupdate-option">
<div class="setting-description">Get beta updates</div>
<div class="setting-control"></div>
</div>
</div>
<div class="title">Functionality</div>
<div class="settings-card">
<div class="setting-row" id="startAtLogin-option">
<div class="setting-description">Start app at login</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="start-minimize-option">
<div class="setting-description">Always start minimized</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="enable-spellchecker-option">
<div class="setting-description">Enable spellchecker (requires restart)</div>
<div class="setting-control"></div>
</div>
</div>
<div class="title">Advanced</div>
<div class="settings-card">
<div class="setting-row" id="enable-error-reporting">
<div class="setting-description">Enable error reporting (requires restart)</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="show-download-folder">
<div class="setting-description">Show downloaded files in file manager</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="add-custom-css">
<div class="setting-description">
Add custom CSS
</div>
<button class="custom-css-button green">Upload</button>
</div>
<div class="setting-row" id="remove-custom-css">
<div class="setting-description">
<div class="selected-css-path" id="custom-css-path">${ConfigUtil.getConfigItem('customCSS')}</div>
</div>
<div class="action red" id="css-delete-action">
<i class="material-icons">indeterminate_check_box</i>
<span>Delete</span>
</div>
</div>
<div class="setting-row" id="download-folder">
<div class="setting-description">
Default download location
</div>
<button class="download-folder-button green">Change</button>
</div>
<div class="setting-row">
<div class="setting-description">
<div class="download-folder-path">${ConfigUtil.getConfigItem('downloadsPath', `${app.getPath('downloads')}`)}</div>
</div>
</div>
</div>
<div class="title">Reset Application Data</div>
<div class="settings-card">
<div class="setting-row" id="resetdata-option">
<div class="setting-description">This will delete all application data including all added accounts and preferences
</div>
<button class="reset-data-button red w-150">Reset App Data</button>
</div>
</div>
</div>
`;
}
init() {
this.props.$root.innerHTML = this.template();
this.updateTrayOption();
this.updateBadgeOption();
this.updateSilentOption();
this.autoUpdateOption();
this.betaUpdateOption();
this.updateSidebarOption();
this.updateStartAtLoginOption();
this.updateResetDataOption();
this.showDesktopNotification();
this.enableSpellchecker();
this.minimizeOnStart();
this.addCustomCSS();
this.showCustomCSSPath();
this.removeCustomCSS();
this.downloadFolder();
this.showDownloadFolder();
this.enableErrorReporting();
// Platform specific settings
// Flashing taskbar on Windows
if (process.platform === 'win32') {
this.updateFlashTaskbar();
}
// Dock bounce on macOS
if (process.platform === 'darwin') {
this.updateDockBouncing();
}
// Auto hide menubar on Windows and Linux
if (process.platform !== 'darwin') {
this.updateMenubarOption();
}
}
updateTrayOption() {
this.generateSettingOption({
$element: document.querySelector('#tray-option .setting-control'),
value: ConfigUtil.getConfigItem('trayIcon', true),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('trayIcon');
ConfigUtil.setConfigItem('trayIcon', newValue);
ipcRenderer.send('forward-message', 'toggletray');
this.updateTrayOption();
}
});
}
updateMenubarOption() {
this.generateSettingOption({
$element: document.querySelector('#menubar-option .setting-control'),
value: ConfigUtil.getConfigItem('autoHideMenubar', false),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('autoHideMenubar');
ConfigUtil.setConfigItem('autoHideMenubar', newValue);
ipcRenderer.send('toggle-menubar', newValue);
this.updateMenubarOption();
}
});
}
updateBadgeOption() {
this.generateSettingOption({
$element: document.querySelector('#badge-option .setting-control'),
value: ConfigUtil.getConfigItem('badgeOption', true),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('badgeOption');
ConfigUtil.setConfigItem('badgeOption', newValue);
ipcRenderer.send('toggle-badge-option', newValue);
this.updateBadgeOption();
}
});
}
updateDockBouncing() {
this.generateSettingOption({
$element: document.querySelector('#dock-bounce-option .setting-control'),
value: ConfigUtil.getConfigItem('dockBouncing', true),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('dockBouncing');
ConfigUtil.setConfigItem('dockBouncing', newValue);
this.updateDockBouncing();
}
});
}
updateFlashTaskbar() {
this.generateSettingOption({
$element: document.querySelector('#flash-taskbar-option .setting-control'),
value: ConfigUtil.getConfigItem('flashTaskbarOnMessage', true),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('flashTaskbarOnMessage');
ConfigUtil.setConfigItem('flashTaskbarOnMessage', newValue);
this.updateFlashTaskbar();
}
});
}
autoUpdateOption() {
this.generateSettingOption({
$element: document.querySelector('#autoupdate-option .setting-control'),
value: ConfigUtil.getConfigItem('autoUpdate', true),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('autoUpdate');
ConfigUtil.setConfigItem('autoUpdate', newValue);
if (!newValue) {
ConfigUtil.setConfigItem('betaUpdate', false);
this.betaUpdateOption();
}
this.autoUpdateOption();
}
});
}
betaUpdateOption() {
this.generateSettingOption({
$element: document.querySelector('#betaupdate-option .setting-control'),
value: ConfigUtil.getConfigItem('betaUpdate', false),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('betaUpdate');
if (ConfigUtil.getConfigItem('autoUpdate')) {
ConfigUtil.setConfigItem('betaUpdate', newValue);
this.betaUpdateOption();
}
}
});
}
updateSilentOption() {
this.generateSettingOption({
$element: document.querySelector('#silent-option .setting-control'),
value: ConfigUtil.getConfigItem('silent', false),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('silent', true);
ConfigUtil.setConfigItem('silent', newValue);
this.updateSilentOption();
currentBrowserWindow.send('toggle-silent', newValue);
}
});
}
showDesktopNotification() {
this.generateSettingOption({
$element: document.querySelector('#show-notification-option .setting-control'),
value: ConfigUtil.getConfigItem('showNotification', true),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('showNotification', true);
ConfigUtil.setConfigItem('showNotification', newValue);
this.showDesktopNotification();
}
});
}
updateSidebarOption() {
this.generateSettingOption({
$element: document.querySelector('#sidebar-option .setting-control'),
value: ConfigUtil.getConfigItem('showSidebar', true),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('showSidebar');
ConfigUtil.setConfigItem('showSidebar', newValue);
ipcRenderer.send('forward-message', 'toggle-sidebar', newValue);
this.updateSidebarOption();
}
});
}
updateStartAtLoginOption() {
this.generateSettingOption({
$element: document.querySelector('#startAtLogin-option .setting-control'),
value: ConfigUtil.getConfigItem('startAtLogin', false),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('startAtLogin');
ConfigUtil.setConfigItem('startAtLogin', newValue);
ipcRenderer.send('toggleAutoLauncher', newValue);
this.updateStartAtLoginOption();
}
});
}
enableSpellchecker() {
this.generateSettingOption({
$element: document.querySelector('#enable-spellchecker-option .setting-control'),
value: ConfigUtil.getConfigItem('enableSpellchecker', true),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('enableSpellchecker');
ConfigUtil.setConfigItem('enableSpellchecker', newValue);
this.enableSpellchecker();
}
});
}
enableErrorReporting() {
this.generateSettingOption({
$element: document.querySelector('#enable-error-reporting .setting-control'),
value: ConfigUtil.getConfigItem('errorReporting', true),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('errorReporting');
ConfigUtil.setConfigItem('errorReporting', newValue);
this.enableErrorReporting();
}
});
}
clearAppDataDialog() {
const clearAppDataMessage = 'By clicking proceed you will be removing all added accounts and preferences from Zulip. When the application restarts, it will be as if you are starting Zulip for the first time.';
const getAppPath = path.join(app.getPath('appData'), app.getName());
dialog.showMessageBox({
type: 'warning',
buttons: ['YES', 'NO'],
defaultId: 0,
message: 'Are you sure',
detail: clearAppDataMessage
}, response => {
if (response === 0) {
fs.remove(getAppPath);
setTimeout(() => ipcRenderer.send('forward-message', 'hard-reload'), 1000);
}
});
}
customCssDialog() {
const showDialogOptions = {
title: 'Select file',
defaultId: 1,
properties: ['openFile'],
filters: [{ name: 'CSS file', extensions: ['css'] }]
};
dialog.showOpenDialog(showDialogOptions, selectedFile => {
if (selectedFile) {
ConfigUtil.setConfigItem('customCSS', selectedFile[0]);
ipcRenderer.send('forward-message', 'hard-reload');
}
});
}
updateResetDataOption() {
const resetDataButton = document.querySelector('#resetdata-option .reset-data-button');
resetDataButton.addEventListener('click', () => {
this.clearAppDataDialog();
});
}
minimizeOnStart() {
this.generateSettingOption({
$element: document.querySelector('#start-minimize-option .setting-control'),
value: ConfigUtil.getConfigItem('startMinimized', false),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('startMinimized');
ConfigUtil.setConfigItem('startMinimized', newValue);
this.minimizeOnStart();
}
});
}
addCustomCSS() {
const customCSSButton = document.querySelector('#add-custom-css .custom-css-button');
customCSSButton.addEventListener('click', () => {
this.customCssDialog();
});
}
showCustomCSSPath() {
if (!ConfigUtil.getConfigItem('customCSS')) {
const cssPATH = document.getElementById('remove-custom-css');
cssPATH.style.display = 'none';
}
}
removeCustomCSS() {
const removeCSSButton = document.getElementById('css-delete-action');
removeCSSButton.addEventListener('click', () => {
ConfigUtil.setConfigItem('customCSS');
ipcRenderer.send('forward-message', 'hard-reload');
});
}
downloadFolderDialog() {
const showDialogOptions = {
title: 'Select Download Location',
defaultId: 1,
properties: ['openDirectory']
};
dialog.showOpenDialog(showDialogOptions, selectedFolder => {
if (selectedFolder) {
ConfigUtil.setConfigItem('downloadsPath', selectedFolder[0]);
const downloadFolderPath = document.querySelector('.download-folder-path');
downloadFolderPath.innerText = selectedFolder[0];
}
});
}
downloadFolder() {
const downloadFolder = document.querySelector('#download-folder .download-folder-button');
downloadFolder.addEventListener('click', () => {
this.downloadFolderDialog();
});
}
showDownloadFolder() {
this.generateSettingOption({
$element: document.querySelector('#show-download-folder .setting-control'),
value: ConfigUtil.getConfigItem('showDownloadFolder', false),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('showDownloadFolder');
ConfigUtil.setConfigItem('showDownloadFolder', newValue);
this.showDownloadFolder();
}
});
}
}
module.exports = GeneralSection;

View File

@@ -1,704 +0,0 @@
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 {z} from "zod";
import supportedLocales from "../../../../../public/translations/supported-locales.json";
import * as ConfigUtil from "../../../../common/config-util.js";
import * as EnterpriseUtil from "../../../../common/enterprise-util.js";
import {html} from "../../../../common/html.js";
import * as t from "../../../../common/translation-util.js";
import {ipcRenderer} from "../../typed-ipc-renderer.js";
import {generateSelectHtml, generateSettingOption} from "./base-section.js";
const currentBrowserWindow = remote.getCurrentWindow();
type GeneralSectionProps = {
$root: Element;
};
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">
<div class="setting-row" id="tray-option">
<div class="setting-description">
${t.__("Show app icon in system tray")}
</div>
<div class="setting-control"></div>
</div>
<div
class="setting-row"
id="menubar-option"
style="display:${process.platform === "darwin" ? "none" : ""}"
>
<div class="setting-description">
${t.__("Auto hide menu bar (Press Alt key to display)")}
</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="sidebar-option">
<div class="setting-description">
${t.__("Show sidebar")} (<span class="code"
>${process.platform === "darwin"
? "Cmd+Shift+S"
: "Ctrl+Shift+S"}</span
>)
</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="badge-option">
<div class="setting-description">
${t.__("Show app unread badge")}
</div>
<div class="setting-control"></div>
</div>
<div
class="setting-row"
id="dock-bounce-option"
style="display:${process.platform === "darwin" ? "" : "none"}"
>
<div class="setting-description">
${t.__("Bounce dock on new private message")}
</div>
<div class="setting-control"></div>
</div>
<div
class="setting-row"
id="flash-taskbar-option"
style="display:${process.platform === "win32" ? "" : "none"}"
>
<div class="setting-description">
${t.__("Flash taskbar on new message")}
</div>
<div class="setting-control"></div>
</div>
</div>
<div class="title">${t.__("Desktop Notifications")}</div>
<div class="settings-card">
<div class="setting-row" id="show-notification-option">
<div class="setting-description">
${t.__("Show desktop notifications")}
</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="silent-option">
<div class="setting-description">
${t.__("Mute all sounds from Zulip")}
</div>
<div class="setting-control"></div>
</div>
</div>
<div class="title">${t.__("App Updates")}</div>
<div class="settings-card">
<div class="setting-row" id="autoupdate-option">
<div class="setting-description">${t.__("Enable auto updates")}</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="betaupdate-option">
<div class="setting-description">${t.__("Get beta updates")}</div>
<div class="setting-control"></div>
</div>
</div>
<div class="title">${t.__("Functionality")}</div>
<div class="settings-card">
<div class="setting-row" id="startAtLogin-option">
<div class="setting-description">${t.__("Start app at login")}</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="start-minimize-option">
<div class="setting-description">
${t.__("Always start minimized")}
</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="quitOnClose-option">
<div class="setting-description">
${t.__("Quit when the window is closed")}
</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="enable-spellchecker-option">
<div class="setting-description">
${t.__("Enable spellchecker (requires restart)")}
</div>
<div class="setting-control"></div>
</div>
<div
class="setting-row"
id="spellcheck-langs"
style="display:${process.platform === "darwin" ? "none" : ""}"
></div>
<div class="setting-row" id="note"></div>
</div>
<div class="title">${t.__("Advanced")}</div>
<div class="settings-card">
<div class="setting-row" id="enable-error-reporting">
<div class="setting-description">
${t.__("Enable error reporting (requires restart)")}
</div>
<div class="setting-control"></div>
</div>
<div class="setting-row" id="app-language">
<div class="setting-description">
${t.__("App language (requires restart)")}
</div>
<div id="lang-div" class="lang-div"></div>
</div>
<div class="setting-row" id="add-custom-css">
<div class="setting-description">${t.__("Add custom CSS")}</div>
<button class="custom-css-button green">${t.__("Upload")}</button>
</div>
<div class="setting-row" id="remove-custom-css">
<div class="setting-description">
<div class="selected-css-path" id="custom-css-path">
${ConfigUtil.getConfigItem("customCSS", "")}
</div>
</div>
<div class="action red" id="css-delete-action">
<i class="material-icons">indeterminate_check_box</i>
<span>${t.__("Delete")}</span>
</div>
</div>
<div class="setting-row" id="download-folder">
<div class="setting-description">
${t.__("Default download location")}
</div>
<button class="download-folder-button green">
${t.__("Change")}
</button>
</div>
<div class="setting-row">
<div class="setting-description">
<div class="download-folder-path">
${ConfigUtil.getConfigItem(
"downloadsPath",
app.getPath("downloads"),
)}
</div>
</div>
</div>
<div class="setting-row" id="prompt-download">
<div class="setting-description">
${t.__("Ask where to save files before downloading")}
</div>
<div class="setting-control"></div>
</div>
</div>
<div class="title">${t.__("Factory Reset Data")}</div>
<div class="settings-card">
<div class="setting-row" id="factory-reset-option">
<div class="setting-description">
${t.__(
"Reset the application, thus deleting all the connected organizations and accounts.",
)}
</div>
<button class="factory-reset-button red w-150">
${t.__("Factory Reset")}
</button>
</div>
</div>
</div>
`.html;
updateTrayOption();
updateBadgeOption();
updateSilentOption();
autoUpdateOption();
betaUpdateOption();
updateSidebarOption();
updateStartAtLoginOption();
factoryReset();
showDesktopNotification();
enableSpellchecker();
minimizeOnStart();
addCustomCss();
showCustomCssPath();
removeCustomCss();
downloadFolder();
updateQuitOnCloseOption();
updatePromptDownloadOption();
enableErrorReporting();
setLocale();
initSpellChecker();
// Platform specific settings
// Flashing taskbar on Windows
if (process.platform === "win32") {
updateFlashTaskbar();
}
// Dock bounce on macOS
if (process.platform === "darwin") {
updateDockBouncing();
}
// Auto hide menubar on Windows and Linux
if (process.platform !== "darwin") {
updateMenubarOption();
}
function updateTrayOption(): void {
generateSettingOption({
$element: $root.querySelector("#tray-option .setting-control")!,
value: ConfigUtil.getConfigItem("trayIcon", true),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("trayIcon", true);
ConfigUtil.setConfigItem("trayIcon", newValue);
ipcRenderer.send("forward-message", "toggletray");
updateTrayOption();
},
});
}
function updateMenubarOption(): void {
generateSettingOption({
$element: $root.querySelector("#menubar-option .setting-control")!,
value: ConfigUtil.getConfigItem("autoHideMenubar", false),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("autoHideMenubar", false);
ConfigUtil.setConfigItem("autoHideMenubar", newValue);
ipcRenderer.send("toggle-menubar", newValue);
updateMenubarOption();
},
});
}
function updateBadgeOption(): void {
generateSettingOption({
$element: $root.querySelector("#badge-option .setting-control")!,
value: ConfigUtil.getConfigItem("badgeOption", true),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("badgeOption", true);
ConfigUtil.setConfigItem("badgeOption", newValue);
ipcRenderer.send("toggle-badge-option", newValue);
updateBadgeOption();
},
});
}
function updateDockBouncing(): void {
generateSettingOption({
$element: $root.querySelector("#dock-bounce-option .setting-control")!,
value: ConfigUtil.getConfigItem("dockBouncing", true),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("dockBouncing", true);
ConfigUtil.setConfigItem("dockBouncing", newValue);
updateDockBouncing();
},
});
}
function updateFlashTaskbar(): void {
generateSettingOption({
$element: $root.querySelector("#flash-taskbar-option .setting-control")!,
value: ConfigUtil.getConfigItem("flashTaskbarOnMessage", true),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem(
"flashTaskbarOnMessage",
true,
);
ConfigUtil.setConfigItem("flashTaskbarOnMessage", newValue);
updateFlashTaskbar();
},
});
}
function autoUpdateOption(): void {
generateSettingOption({
$element: $root.querySelector("#autoupdate-option .setting-control")!,
disabled: EnterpriseUtil.configItemExists("autoUpdate"),
value: ConfigUtil.getConfigItem("autoUpdate", true),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("autoUpdate", true);
ConfigUtil.setConfigItem("autoUpdate", newValue);
if (!newValue) {
ConfigUtil.setConfigItem("betaUpdate", false);
betaUpdateOption();
}
autoUpdateOption();
},
});
}
function betaUpdateOption(): void {
generateSettingOption({
$element: $root.querySelector("#betaupdate-option .setting-control")!,
value: ConfigUtil.getConfigItem("betaUpdate", false),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("betaUpdate", false);
if (ConfigUtil.getConfigItem("autoUpdate", true)) {
ConfigUtil.setConfigItem("betaUpdate", newValue);
betaUpdateOption();
}
},
});
}
function updateSilentOption(): void {
generateSettingOption({
$element: $root.querySelector("#silent-option .setting-control")!,
value: ConfigUtil.getConfigItem("silent", false),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("silent", true);
ConfigUtil.setConfigItem("silent", newValue);
updateSilentOption();
ipcRenderer.send(
"forward-to",
currentBrowserWindow.webContents.id,
"toggle-silent",
newValue,
);
},
});
}
function showDesktopNotification(): void {
generateSettingOption({
$element: $root.querySelector(
"#show-notification-option .setting-control",
)!,
value: ConfigUtil.getConfigItem("showNotification", true),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("showNotification", true);
ConfigUtil.setConfigItem("showNotification", newValue);
showDesktopNotification();
},
});
}
function updateSidebarOption(): void {
generateSettingOption({
$element: $root.querySelector("#sidebar-option .setting-control")!,
value: ConfigUtil.getConfigItem("showSidebar", true),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("showSidebar", true);
ConfigUtil.setConfigItem("showSidebar", newValue);
ipcRenderer.send("forward-message", "toggle-sidebar", newValue);
updateSidebarOption();
},
});
}
function updateStartAtLoginOption(): void {
generateSettingOption({
$element: $root.querySelector("#startAtLogin-option .setting-control")!,
value: ConfigUtil.getConfigItem("startAtLogin", false),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("startAtLogin", false);
ConfigUtil.setConfigItem("startAtLogin", newValue);
ipcRenderer.send("toggleAutoLauncher", newValue);
updateStartAtLoginOption();
},
});
}
function updateQuitOnCloseOption(): void {
generateSettingOption({
$element: $root.querySelector("#quitOnClose-option .setting-control")!,
value: ConfigUtil.getConfigItem("quitOnClose", false),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("quitOnClose", false);
ConfigUtil.setConfigItem("quitOnClose", newValue);
updateQuitOnCloseOption();
},
});
}
function enableSpellchecker(): void {
generateSettingOption({
$element: $root.querySelector(
"#enable-spellchecker-option .setting-control",
)!,
value: ConfigUtil.getConfigItem("enableSpellchecker", true),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("enableSpellchecker", true);
ConfigUtil.setConfigItem("enableSpellchecker", newValue);
ipcRenderer.send("configure-spell-checker");
enableSpellchecker();
const spellcheckerLanguageInput: HTMLElement =
$root.querySelector("#spellcheck-langs")!;
const spellcheckerNote: HTMLElement = $root.querySelector("#note")!;
spellcheckerLanguageInput.style.display =
spellcheckerLanguageInput.style.display === "none" ? "" : "none";
spellcheckerNote.style.display =
spellcheckerNote.style.display === "none" ? "" : "none";
},
});
}
function enableErrorReporting(): void {
generateSettingOption({
$element: $root.querySelector(
"#enable-error-reporting .setting-control",
)!,
value: ConfigUtil.getConfigItem("errorReporting", true),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("errorReporting", true);
ConfigUtil.setConfigItem("errorReporting", newValue);
enableErrorReporting();
},
});
}
async function customCssDialog(): Promise<void> {
const showDialogOptions: OpenDialogOptions = {
title: "Select file",
properties: ["openFile"],
filters: [{name: "CSS file", extensions: ["css"]}],
};
const {filePaths, canceled} =
await dialog.showOpenDialog(showDialogOptions);
if (!canceled) {
ConfigUtil.setConfigItem("customCSS", filePaths[0]);
ipcRenderer.send("forward-message", "hard-reload");
}
}
function setLocale(): void {
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 = $root.querySelector(".lang-menu")!;
// The next three lines set the selected language visible on the dropdown button
let language = ConfigUtil.getConfigItem("appLanguage", "en");
language =
language && langMenu.options.namedItem(language) ? language : "en";
langMenu.options.namedItem(language)!.selected = true;
langMenu.addEventListener("change", () => {
ConfigUtil.setConfigItem("appLanguage", langMenu.value);
});
}
function minimizeOnStart(): void {
generateSettingOption({
$element: $root.querySelector("#start-minimize-option .setting-control")!,
value: ConfigUtil.getConfigItem("startMinimized", false),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("startMinimized", false);
ConfigUtil.setConfigItem("startMinimized", newValue);
minimizeOnStart();
},
});
}
function addCustomCss(): void {
const customCssButton = $root.querySelector(
"#add-custom-css .custom-css-button",
)!;
customCssButton.addEventListener("click", async () => {
await customCssDialog();
});
}
function showCustomCssPath(): void {
if (!ConfigUtil.getConfigItem("customCSS", null)) {
const cssPath: HTMLElement = $root.querySelector("#remove-custom-css")!;
cssPath.style.display = "none";
}
}
function removeCustomCss(): void {
const removeCssButton = $root.querySelector("#css-delete-action")!;
removeCssButton.addEventListener("click", () => {
ConfigUtil.setConfigItem("customCSS", "");
ipcRenderer.send("forward-message", "hard-reload");
});
}
async function downloadFolderDialog(): Promise<void> {
const showDialogOptions: OpenDialogOptions = {
title: "Select Download Location",
properties: ["openDirectory"],
};
const {filePaths, canceled} =
await dialog.showOpenDialog(showDialogOptions);
if (!canceled) {
ConfigUtil.setConfigItem("downloadsPath", filePaths[0]);
const downloadFolderPath: HTMLElement = $root.querySelector(
".download-folder-path",
)!;
downloadFolderPath.textContent = filePaths[0];
}
}
function downloadFolder(): void {
const downloadFolder = $root.querySelector(
"#download-folder .download-folder-button",
)!;
downloadFolder.addEventListener("click", async () => {
await downloadFolderDialog();
});
}
function updatePromptDownloadOption(): void {
generateSettingOption({
$element: $root.querySelector("#prompt-download .setting-control")!,
value: ConfigUtil.getConfigItem("promptDownload", false),
clickHandler() {
const newValue = !ConfigUtil.getConfigItem("promptDownload", false);
ConfigUtil.setConfigItem("promptDownload", newValue);
updatePromptDownloadOption();
},
});
}
async function factoryResetSettings(): Promise<void> {
const clearAppDataMessage =
"When the application restarts, it will be as if you have just downloaded Zulip app.";
const getAppPath = path.join(app.getPath("appData"), app.name);
const {response} = await dialog.showMessageBox({
type: "warning",
buttons: ["YES", "NO"],
defaultId: 0,
message: "Are you sure?",
detail: clearAppDataMessage,
});
if (response === 0) {
await fs.promises.rmdir(getAppPath, {recursive: true});
setTimeout(() => {
ipcRenderer.send("clear-app-settings");
}, 1000);
}
}
function factoryReset(): void {
const factoryResetButton = $root.querySelector(
"#factory-reset-option .factory-reset-button",
)!;
factoryResetButton.addEventListener("click", async () => {
await factoryResetSettings();
});
}
function initSpellChecker(): void {
// The Electron API is a no-op on macOS and macOS default spellchecker is used.
if (process.platform === "darwin") {
const note: HTMLElement = $root.querySelector("#note")!;
note.append(t.__("On macOS, the OS spellchecker is used."));
note.append(document.createElement("br"));
note.append(
t.__(
"Change the language from System Preferences → Keyboard → Text → Spelling.",
),
);
} else {
const note: HTMLElement = $root.querySelector("#note")!;
note.append(
t.__("You can select a maximum of 3 languages for spellchecking."),
);
const spellDiv: HTMLElement = $root.querySelector("#spellcheck-langs")!;
spellDiv.innerHTML += html`
<div class="setting-description">${t.__("Spellchecker Languages")}</div>
<div id="spellcheck-langs-value">
<input name="spellcheck" placeholder="Enter Languages" />
</div>
`.html;
const availableLanguages = session.fromPartition(
"persist:webviewsession",
).availableSpellCheckerLanguages;
let languagePairs = new Map<string, string>();
for (const l of availableLanguages) {
if (ISO6391.validate(l)) {
languagePairs.set(ISO6391.getName(l), l);
}
}
// Manually set names for languages not available in ISO6391
languagePairs.set("English (AU)", "en-AU");
languagePairs.set("English (CA)", "en-CA");
languagePairs.set("English (GB)", "en-GB");
languagePairs.set("English (US)", "en-US");
languagePairs.set("Spanish (Latin America)", "es-419");
languagePairs.set("Spanish (Argentina)", "es-AR");
languagePairs.set("Spanish (Mexico)", "es-MX");
languagePairs.set("Spanish (US)", "es-US");
languagePairs.set("Portuguese (Brazil)", "pt-BR");
languagePairs.set("Portuguese (Portugal)", "pt-PT");
languagePairs.set("Serbo-Croatian", "sh");
languagePairs = new Map(
[...languagePairs].sort((a, b) => (a[0] < b[0] ? -1 : 1)),
);
const tagField: HTMLInputElement = $root.querySelector(
"input[name=spellcheck]",
)!;
const tagify = new Tagify(tagField, {
whitelist: [...languagePairs.keys()],
enforceWhitelist: true,
maxTags: 3,
dropdown: {
enabled: 0,
maxItems: Number.POSITIVE_INFINITY,
closeOnSelect: false,
highlightFirst: true,
position: "manual",
classname: "settings-tagify-dropdown",
},
});
tagify.DOM.input.addEventListener("focus", () => {
tagify.dropdown.show();
$root
.querySelector("#spellcheck-langs-value")!
.append(tagify.DOM.dropdown);
});
tagify.DOM.input.addEventListener("blur", () => {
tagify.dropdown.hide(true);
tagify.DOM.dropdown.remove();
});
const configuredLanguages: string[] = (
ConfigUtil.getConfigItem("spellcheckerLanguages", null) ?? []
).map(
(code: string) =>
[...languagePairs].find((pair) => pair[1] === code)![0],
);
tagify.addTags(configuredLanguages);
tagField.addEventListener("change", () => {
if (tagField.value.length === 0) {
ConfigUtil.setConfigItem("spellcheckerLanguages", []);
ipcRenderer.send("configure-spell-checker");
} else {
const data: unknown = JSON.parse(tagField.value);
const spellLangs: string[] = z
.array(z.object({value: z.string()}))
.parse(data)
.map((elt) => languagePairs.get(elt.value)!);
ConfigUtil.setConfigItem("spellcheckerLanguages", spellLangs);
ipcRenderer.send("configure-spell-checker");
}
});
}
// Do not display the spellchecker input and note if it is disabled
if (!ConfigUtil.getConfigItem("enableSpellchecker", true)) {
const spellcheckerLanguageInput: HTMLElement =
$root.querySelector("#spellcheck-langs")!;
const spellcheckerNote: HTMLElement = $root.querySelector("#note")!;
spellcheckerLanguageInput.style.display = "none";
spellcheckerNote.style.display = "none";
}
}
}

View File

@@ -0,0 +1,67 @@
'use strict';
const BaseComponent = require(__dirname + '/../../components/base.js');
class PreferenceNav extends BaseComponent {
constructor(props) {
super();
this.props = props;
this.navItems = ['General', 'Network', 'AddServer', 'Organizations', 'Shortcuts'];
this.init();
}
template() {
let navItemsTemplate = '';
for (const navItem of this.navItems) {
navItemsTemplate += `<div class="nav" id="nav-${navItem}">${navItem}</div>`;
}
return `
<div>
<div id="settings-header">Settings</div>
<div id="nav-container">${navItemsTemplate}</div>
</div>
`;
}
init() {
this.$el = this.generateNodeFromTemplate(this.template());
this.props.$root.appendChild(this.$el);
this.registerListeners();
}
registerListeners() {
for (const navItem of this.navItems) {
const $item = document.getElementById(`nav-${navItem}`);
$item.addEventListener('click', () => {
this.props.onItemSelected(navItem);
});
}
}
select(navItemToSelect) {
for (const navItem of this.navItems) {
if (navItem === navItemToSelect) {
this.activate(navItem);
} else {
this.deactivate(navItem);
}
}
}
activate(navItem) {
const $item = document.getElementById(`nav-${navItem}`);
$item.classList.add('active');
}
deactivate(navItem) {
const $item = document.getElementById(`nav-${navItem}`);
$item.classList.remove('active');
}
}
module.exports = PreferenceNav;

View File

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

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