Compare commits

..

2 Commits

Author SHA1 Message Date
Akash Nimare
be869a52d5 release: v4.0.3. 2020-02-28 10:14:22 +05:30
Akash Nimare
fcbb5da18e webview: Update web security preference.
Electron docs suggests that we should not use
`disablewebsecurity` thus removing the same.
2020-02-28 10:13:30 +05:30
230 changed files with 24160 additions and 21290 deletions

View File

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

2
.gitattributes vendored
View File

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

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 Remove the fields that are not appropriate
Please include: Please include:
@@ -12,7 +11,6 @@ Please include:
**Screenshots?** **Screenshots?**
**You have tested this PR on:** **You have tested this PR on:**
- [ ] Windows
- [ ] Windows - [ ] Linux/Ubuntu
- [ ] Linux/Ubuntu - [ ] macOS
- [ ] 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@v2
- run: npm ci
- run: npm test

7
.gitignore vendored
View File

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

View File

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

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 @@
/app/**/*.js
/app/translations/*.json
/dist

View File

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

View File

@@ -1,9 +1,67 @@
{ {
"extends": ["stylelint-config-standard", "stylelint-config-prettier"],
"rules": { "rules": {
"color-named": "never", # 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-no-hex": true,
"font-family-no-missing-generic-family-keyword": [true, {"ignoreFontFamilies": ["Material Icons"]}], "color-named": "never",
"selector-type-no-unknown": [true, {"ignoreTypes": ["send-feedback", "webview"]}],
} }
} }

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
[zulip.desktopjson]
file_filter = app/translations/<lang>.json
minimum_perc = 0
source_file = app/translations/en.json
source_lang = en
type = KEYVALUEJSON

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
import * as z from "zod";
export const dndSettingsSchemata = {
showNotification: z.boolean(),
silent: z.boolean(),
flashTaskbarOnMessage: z.boolean(),
};
export const configSchemata = {
...dndSettingsSchemata,
appLanguage: z.string().nullable(),
autoHideMenubar: z.boolean(),
autoUpdate: z.boolean(),
badgeOption: z.boolean(),
betaUpdate: z.boolean(),
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(),
proxyPAC: z.string(),
proxyRules: z.string(),
quitOnClose: z.boolean(),
showSidebar: z.boolean(),
spellcheckerLanguages: z.string().array().nullable(),
startAtLogin: z.boolean(),
startMinimized: z.boolean(),
systemProxyRules: z.string(),
trayIcon: z.boolean(),
useManualProxy: z.boolean(),
useProxy: z.boolean(),
useSystemProxy: z.boolean(),
};
export const enterpriseConfigSchemata = {
...configSchemata,
presetOrganizations: z.string().array(),
};

View File

@@ -1,102 +0,0 @@
import electron from "electron";
import fs from "fs";
import path from "path";
import {JsonDB} from "node-json-db";
import {DataError} from "node-json-db/dist/lib/Errors";
import type * as z from "zod";
import {configSchemata} from "./config-schemata";
import * as EnterpriseUtil from "./enterprise-util";
import Logger from "./logger-util";
export type Config = {
[Key in keyof typeof configSchemata]: z.output<typeof configSchemata[Key]>;
};
/* To make the util runnable in both main and renderer process */
const {app, dialog} = process.type === "renderer" ? electron.remote : electron;
const logger = new Logger({
file: "config-util.log",
});
let db: JsonDB;
reloadDB();
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);
logger.reportSentry(error);
}
}
db = new JsonDB(settingsJsonPath, true, true);
}

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -1,90 +0,0 @@
import type {DNDSettings} from "./dnd-util";
import type {MenuProps, NavItem, ServerConf} from "./types";
export interface MainMessage {
"clear-app-settings": () => void;
downloadFile: (url: string, downloadPath: string) => void;
"error-reporting": () => void;
"fetch-user-agent": () => string;
"focus-app": () => void;
"permission-callback": (permissionCallbackId: number, grant: boolean) => void;
"quit-app": () => void;
"realm-icon-changed": (serverURL: string, iconURL: string) => void;
"realm-name-changed": (serverURL: string, realmName: string) => void;
"reload-full-app": () => void;
"save-last-tab": (index: number) => void;
"set-spellcheck-langs": () => void;
"switch-server-tab": (index: number) => void;
"toggle-app": () => void;
"toggle-badge-option": (newValue: boolean) => void;
"toggle-menubar": (showMenubar: boolean) => void;
toggleAutoLauncher: (AutoLaunchValue: boolean) => void;
"unread-count": (unreadCount: number) => void;
"update-badge": (messageCount: number) => void;
"update-menu": (props: MenuProps) => void;
"update-taskbar-icon": (data: string, text: string) => void;
}
export interface MainCall {
"get-server-settings": (domain: string) => ServerConf;
"is-online": (url: string) => boolean;
"save-server-icon": (iconURL: string) => string;
}
export interface RendererMessage {
back: () => void;
"copy-zulip-url": () => void;
destroytray: () => void;
downloadFileCompleted: (filePath: string, fileName: string) => void;
downloadFileFailed: (state: string) => void;
"enter-fullscreen": () => void;
"error-reporting-val": (errorReporting: boolean) => void;
focus: () => void;
"focus-webview-with-id": (webviewId: number) => void;
forward: () => void;
"hard-reload": () => void;
"leave-fullscreen": () => void;
"log-out": () => void;
logout: () => void;
"new-server": () => void;
"open-about": () => void;
"open-feedback-modal": () => 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;
"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-network-error": (index: number) => void;
"show-notification-settings": () => void;
"switch-server-tab": (index: number) => void;
"switch-settings-nav": (navItem: NavItem) => void;
"tab-devtools": () => void;
"toggle-autohide-menubar": (
autoHideMenubar: boolean,
updateMenu: boolean,
) => void;
"toggle-dnd": (state: boolean, newSettings: Partial<DNDSettings>) => void;
"toggle-menubar-setting": (state: boolean) => void;
"toggle-sidebar": (show: boolean) => void;
"toggle-sidebar-setting": (state: 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,27 +0,0 @@
export interface MenuProps {
tabs: TabData[];
activeTabIndex?: number;
enableMenu?: boolean;
}
export type NavItem =
| "General"
| "Network"
| "AddServer"
| "Organizations"
| "Shortcuts";
export interface ServerConf {
url: string;
alias: string;
icon: string;
}
export type TabRole = "server" | "function";
export interface TabData {
role: TabRole;
name: string;
index: number;
webviewName: string;
}

View File

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

View File

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

View File

@@ -1,25 +1,31 @@
import electron, {app, dialog, session} from "electron"; 'use strict';
import fs from "fs"; import { sentryInit } from '../renderer/js/utils/sentry-util';
import path from "path"; import { appUpdater } from './autoupdater';
import { setAutoLaunch } from './startup';
import windowStateKeeper from "electron-window-state"; import windowStateKeeper = require('electron-window-state');
import path = require('path');
import fs = require('fs');
import isDev = require('electron-is-dev');
import electron = require('electron');
const { app, ipcMain, session } = electron;
import * as ConfigUtil from "../common/config-util"; import AppMenu = require('./menu');
import {sentryInit} from "../common/sentry-util"; import BadgeSettings = require('../renderer/js/pages/preference/badge-settings');
import type {RendererMessage} from "../common/typed-ipc"; import ConfigUtil = require('../renderer/js/utils/config-util');
import type {MenuProps} from "../common/types"; import ProxyUtil = require('../renderer/js/utils/proxy-util');
import {appUpdater} from "./autoupdater"; interface PatchedGlobal extends NodeJS.Global {
import * as BadgeSettings from "./badge-settings"; mainWindowState: windowStateKeeper.State;
import * as AppMenu from "./menu"; }
import * as ProxyUtil from "./proxy-util";
import {_getServerSettings, _isOnline, _saveServerIcon} from "./request";
import {setAutoLaunch} from "./startup";
import {ipcMain, send} from "./typed-ipc-main";
const {GDK_BACKEND} = process.env; const globalPatched = global as PatchedGlobal;
let mainWindowState: windowStateKeeper.State; // Adds debug features like hotkeys for triggering dev tools and reload
// in development mode
if (isDev) {
require('electron-debug')();
}
// Prevent window being garbage collected // Prevent window being garbage collected
let mainWindow: Electron.BrowserWindow; let mainWindow: Electron.BrowserWindow;
@@ -28,474 +34,369 @@ let badgeCount: number;
let isQuitting = false; let isQuitting = false;
// Load this url in main window // Load this url in main window
const mainURL = "file://" + path.join(__dirname, "../renderer", "main.html"); const mainURL = 'file://' + path.join(__dirname, '../renderer', 'main.html');
const permissionCallbacks = new Map<number, (grant: boolean) => void>(); const singleInstanceLock = app.requestSingleInstanceLock();
let nextPermissionCallbackId = 0; if (singleInstanceLock) {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
const APP_ICON = path.join(__dirname, "../resources", "Icon"); mainWindow.show();
}
});
} else {
app.quit();
}
const iconPath = (): string => const APP_ICON = path.join(__dirname, '../resources', 'Icon');
APP_ICON + (process.platform === "win32" ? ".ico" : ".png");
// Toggle the app window const iconPath = (): string => {
const toggleApp = (): void => { return APP_ICON + (process.platform === 'win32' ? '.ico' : '.png');
if (!mainWindow.isVisible() || mainWindow.isMinimized()) {
mainWindow.show();
} else {
mainWindow.hide();
}
}; };
function createMainWindow(): Electron.BrowserWindow { function createMainWindow(): Electron.BrowserWindow {
// Load the previous state with fallback to defaults // Load the previous state with fallback to defaults
mainWindowState = windowStateKeeper({ const mainWindowState: windowStateKeeper.State = windowStateKeeper({
defaultWidth: 1100, defaultWidth: 1100,
defaultHeight: 720, defaultHeight: 720,
path: `${app.getPath("userData")}/config`, path: `${app.getPath('userData')}/config`
}); });
const win = new electron.BrowserWindow({ // Let's keep the window position global so that we can access it in other process
// This settings needs to be saved in config globalPatched.mainWindowState = mainWindowState;
title: "Zulip",
icon: iconPath(),
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
height: mainWindowState.height,
minWidth: 500,
minHeight: 400,
webPreferences: {
contextIsolation: false,
enableRemoteModule: true,
nodeIntegration: true,
partition: "persist:webviewsession",
webviewTag: true,
worldSafeExecuteJavaScript: true,
},
show: false,
});
win.on("focus", () => { const win = new electron.BrowserWindow({
send(win.webContents, "focus"); // 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
});
(async () => win.loadURL(mainURL))(); win.on('focus', () => {
win.webContents.send('focus');
});
// Keep the app running in background on close event win.loadURL(mainURL);
win.on("close", (event) => {
if (ConfigUtil.getConfigItem("quitOnClose", false)) {
app.quit();
}
if (!isQuitting) { // Keep the app running in background on close event
event.preventDefault(); win.on('close', e => {
if (ConfigUtil.getConfigItem("quitOnClose")) {
app.quit();
}
if (!isQuitting) {
e.preventDefault();
if (process.platform === "darwin") { if (process.platform === 'darwin') {
app.hide(); app.hide();
} else { } else {
win.hide(); win.hide();
} }
} }
}); });
win.setTitle("Zulip"); win.setTitle('Zulip');
win.on("enter-full-screen", () => { win.on('enter-full-screen', () => {
send(win.webContents, "enter-fullscreen"); win.webContents.send('enter-fullscreen');
}); });
win.on("leave-full-screen", () => { win.on('leave-full-screen', () => {
send(win.webContents, "leave-fullscreen"); win.webContents.send('leave-fullscreen');
}); });
// To destroy tray icon when navigate to a new URL // To destroy tray icon when navigate to a new URL
win.webContents.on("will-navigate", (event) => { win.webContents.on('will-navigate', e => {
if (event) { if (e) {
send(win.webContents, "destroytray"); win.webContents.send('destroytray');
} }
}); });
// Let us register listeners on the window, so we can update the state // Let us register listeners on the window, so we can update the state
// automatically (the listeners will be removed when the window is closed) // automatically (the listeners will be removed when the window is closed)
// and restore the maximized or full screen state // and restore the maximized or full screen state
mainWindowState.manage(win); mainWindowState.manage(win);
return win; return win;
} }
(async () => { // Decrease load on GPU (experimental)
if (!app.requestSingleInstanceLock()) { app.disableHardwareAcceleration();
app.quit();
return;
}
await app.whenReady(); // Temporary fix for Electron render colors differently
// More info here - https://github.com/electron/electron/issues/10732
app.commandLine.appendSwitch('force-color-profile', 'srgb');
if (process.env.GDK_BACKEND !== GDK_BACKEND) { // eslint-disable-next-line max-params
console.warn( app.on('certificate-error', (event: Event, _webContents: Electron.WebContents, _url: string, _error: string, _certificate: any, callback) => {
"Reverting GDK_BACKEND to work around https://github.com/electron/electron/issues/28436", event.preventDefault();
); callback(true);
if (GDK_BACKEND === undefined) { });
delete process.env.GDK_BACKEND;
} else {
process.env.GDK_BACKEND = GDK_BACKEND;
}
}
app.on("second-instance", () => { app.on('activate', () => {
if (mainWindow) { if (!mainWindow) {
if (mainWindow.isMinimized()) { mainWindow = createMainWindow();
mainWindow.restore(); }
} });
mainWindow.show(); app.on('ready', () => {
} AppMenu.setMenu({
}); tabs: []
});
mainWindow = createMainWindow();
ipcMain.on( // Auto-hide menu bar on Windows + Linux
"permission-callback", if (process.platform !== 'darwin') {
(event: Event, permissionCallbackId: number, grant: boolean) => { const shouldHideMenu = ConfigUtil.getConfigItem('autoHideMenubar') || false;
permissionCallbacks.get(permissionCallbackId)?.(grant); mainWindow.setAutoHideMenuBar(shouldHideMenu);
permissionCallbacks.delete(permissionCallbackId); mainWindow.setMenuBarVisibility(!shouldHideMenu);
}, }
);
// This event is only available on macOS. Triggers when you click on the dock icon. // Initialize sentry for main process
app.on("activate", () => { const errorReporting = ConfigUtil.getConfigItem('errorReporting');
if (mainWindow) { if (errorReporting) {
// If there is already a window show it sentryInit();
mainWindow.show(); }
} else {
mainWindow = createMainWindow();
}
});
const ses = session.fromPartition("persist:webviewsession"); const isSystemProxy = ConfigUtil.getConfigItem('useSystemProxy');
ses.setUserAgent(`ZulipElectron/${app.getVersion()} ${ses.getUserAgent()}`);
ipcMain.on("set-spellcheck-langs", () => { if (isSystemProxy) {
ses.setSpellCheckerLanguages( ProxyUtil.resolveSystemProxy(mainWindow);
process.platform === "darwin" }
? // Work around https://github.com/electron/electron/issues/30215.
mainWindow.webContents.session.getSpellCheckerLanguages()
: ConfigUtil.getConfigItem("spellcheckerLanguages", null) ?? [],
);
});
AppMenu.setMenu({ const page = mainWindow.webContents;
tabs: [],
});
mainWindow = createMainWindow();
// Auto-hide menu bar on Windows + Linux page.on('dom-ready', () => {
if (process.platform !== "darwin") { if (ConfigUtil.getConfigItem('startMinimized')) {
const shouldHideMenu = ConfigUtil.getConfigItem("autoHideMenubar", false); mainWindow.hide();
mainWindow.autoHideMenuBar = shouldHideMenu; } else {
mainWindow.setMenuBarVisibility(!shouldHideMenu); mainWindow.show();
} }
if (!ConfigUtil.isConfigItemExists('userAgent')) {
const userAgent = session.fromPartition('webview:persistsession').getUserAgent();
ConfigUtil.setConfigItem('userAgent', userAgent);
}
});
// Initialize sentry for main process page.once('did-frame-finish-load', () => {
const errorReporting = ConfigUtil.getConfigItem("errorReporting", true); // Initiate auto-updates on MacOS and Windows
if (errorReporting) { if (ConfigUtil.getConfigItem('autoUpdate')) {
sentryInit(); appUpdater();
} }
});
const isSystemProxy = ConfigUtil.getConfigItem("useSystemProxy", false); // Temporarily remove this event
// electron.powerMonitor.on('resume', () => {
// mainWindow.reload();
// page.send('destroytray');
// });
if (isSystemProxy) { ipcMain.on('focus-app', () => {
(async () => ProxyUtil.resolveSystemProxy(mainWindow))(); mainWindow.show();
} });
const page = mainWindow.webContents; ipcMain.on('quit-app', () => {
app.quit();
});
page.on("dom-ready", () => { // Code to show pdf in a new BrowserWindow (currently commented out due to bug-upstream)
if (ConfigUtil.getConfigItem("startMinimized", false)) { // ipcMain.on('pdf-view', (event, url) => {
mainWindow.hide(); // // Paddings for pdfWindow so that it fits into the main browserWindow
} else { // const paddingWidth = 55;
mainWindow.show(); // const paddingHeight = 22;
}
});
ipcMain.on("fetch-user-agent", (event) => { // // Get the config of main browserWindow
event.returnValue = session // const mainWindowState = global.mainWindowState;
.fromPartition("persist:webviewsession")
.getUserAgent();
});
ipcMain.handle("get-server-settings", async (event, domain: string) => // // Window to view the pdf file
_getServerSettings(domain, ses), // 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);
ipcMain.handle("save-server-icon", async (event, url: string) => // // We don't want to have the menu bar in pdf window
_saveServerIcon(url, ses), // pdfWindow.setMenu(null);
); // });
ipcMain.handle("is-online", async (event, url: string) => // Reload full app not just webview, useful in debugging
_isOnline(url, ses), ipcMain.on('reload-full-app', () => {
); mainWindow.reload();
page.send('destroytray');
});
page.once("did-frame-finish-load", async () => { ipcMain.on('clear-app-settings', () => {
// Initiate auto-updates on MacOS and Windows globalPatched.mainWindowState.unmanage();
if (ConfigUtil.getConfigItem("autoUpdate", true)) { app.relaunch();
await appUpdater(); app.exit();
} });
});
app.on( ipcMain.on('toggle-app', () => {
"certificate-error", if (!mainWindow.isVisible() || mainWindow.isMinimized()) {
( mainWindow.show();
event: Event, } else {
webContents: Electron.WebContents, mainWindow.hide();
urlString: string, }
error: string, });
) => {
const url = new URL(urlString);
dialog.showErrorBox(
"Certificate error",
`The server presented an invalid certificate for ${url.origin}:
${error}`, ipcMain.on('toggle-badge-option', () => {
); BadgeSettings.updateBadge(badgeCount, mainWindow);
}, });
);
page.session.setPermissionRequestHandler( ipcMain.on('toggle-menubar', (_event: Electron.IpcMessageEvent, showMenubar: boolean) => {
(webContents, permission, callback, details) => { mainWindow.setAutoHideMenuBar(showMenubar);
const {origin} = new URL(details.requestingUrl); mainWindow.setMenuBarVisibility(!showMenubar);
const permissionCallbackId = nextPermissionCallbackId++; page.send('toggle-autohide-menubar', showMenubar, true);
permissionCallbacks.set(permissionCallbackId, callback); });
send(
page,
"permission-request",
{
webContentsId:
webContents.id === mainWindow.webContents.id
? null
: webContents.id,
origin,
permission,
},
permissionCallbackId,
);
},
);
// Temporarily remove this event ipcMain.on('update-badge', (_event: Electron.IpcMessageEvent, messageCount: number) => {
// electron.powerMonitor.on('resume', () => { badgeCount = messageCount;
// mainWindow.reload(); BadgeSettings.updateBadge(badgeCount, mainWindow);
// send(page, 'destroytray'); page.send('tray', messageCount);
// }); });
ipcMain.on("focus-app", () => { ipcMain.on('update-taskbar-icon', (_event: Electron.IpcMessageEvent, data: any, text: string) => {
mainWindow.show(); BadgeSettings.updateTaskbarIcon(data, text, mainWindow);
}); });
ipcMain.on("quit-app", () => { ipcMain.on('forward-message', (_event: Electron.IpcMessageEvent, listener: any, ...params: any[]) => {
app.quit(); page.send(listener, ...params);
}); });
// Reload full app not just webview, useful in debugging ipcMain.on('update-menu', (_event: Electron.IpcMessageEvent, props: any) => {
ipcMain.on("reload-full-app", () => { AppMenu.setMenu(props);
mainWindow.reload(); const activeTab = props.tabs[props.activeTabIndex];
send(page, "destroytray"); if (activeTab) {
}); mainWindow.setTitle(`Zulip - ${activeTab.webview.props.name}`);
}
});
ipcMain.on("clear-app-settings", () => { ipcMain.on('toggleAutoLauncher', (_event: Electron.IpcMessageEvent, AutoLaunchValue: boolean) => {
mainWindowState.unmanage(); setAutoLaunch(AutoLaunchValue);
app.relaunch(); });
app.exit();
});
ipcMain.on("toggle-app", () => { ipcMain.on('downloadFile', (_event: Electron.IpcMessageEvent, url: string, downloadPath: string) => {
toggleApp(); page.downloadURL(url);
}); page.session.once('will-download', (_event: Event, item) => {
const filePath = path.join(downloadPath, item.getFilename());
ipcMain.on("toggle-badge-option", () => { const getTimeStamp = (): any => {
BadgeSettings.updateBadge(badgeCount, mainWindow); const date = new Date();
}); return date.getTime();
};
ipcMain.on( const formatFile = (filePath: string): string => {
"toggle-menubar", const fileExtension = path.extname(filePath);
(_event: Electron.IpcMainEvent, showMenubar: boolean) => { const baseName = path.basename(filePath, fileExtension);
mainWindow.autoHideMenuBar = showMenubar; return `${baseName}-${getTimeStamp()}${fileExtension}`;
mainWindow.setMenuBarVisibility(!showMenubar); };
send(page, "toggle-autohide-menubar", showMenubar, true);
},
);
ipcMain.on( // Update the name and path of the file if it already exists
"update-badge",
(_event: Electron.IpcMainEvent, messageCount: number) => {
badgeCount = messageCount;
BadgeSettings.updateBadge(badgeCount, mainWindow);
send(page, "tray", messageCount);
},
);
ipcMain.on( const updatedFilePath = path.join(downloadPath, formatFile(filePath));
"update-taskbar-icon",
(_event: Electron.IpcMainEvent, data: string, text: string) => {
BadgeSettings.updateTaskbarIcon(data, text, mainWindow);
},
);
ipcMain.on( const setFilePath = fs.existsSync(filePath) ? updatedFilePath : filePath;
"forward-message",
<Channel extends keyof RendererMessage>(
_event: Electron.IpcMainEvent,
listener: Channel,
...parameters: Parameters<RendererMessage[Channel]>
) => {
send(page, listener, ...parameters);
},
);
ipcMain.on( item.setSavePath(setFilePath);
"update-menu",
(_event: Electron.IpcMainEvent, props: MenuProps) => {
AppMenu.setMenu(props);
if (props.activeTabIndex !== undefined) {
const activeTab = props.tabs[props.activeTabIndex];
mainWindow.setTitle(`Zulip - ${activeTab.webviewName}`);
}
},
);
ipcMain.on( item.on('updated', (_event: Event, state) => {
"toggleAutoLauncher", switch (state) {
async (_event: Electron.IpcMainEvent, AutoLaunchValue: boolean) => { case 'interrupted': {
await setAutoLaunch(AutoLaunchValue); // 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: 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( ipcMain.on('realm-name-changed', (_event: Electron.IpcMessageEvent, serverURL: string, realmName: string) => {
"downloadFile", page.send('update-realm-name', serverURL, realmName);
(_event: Electron.IpcMainEvent, url: string, downloadPath: string) => { });
page.downloadURL(url);
page.session.once("will-download", async (_event: Event, item) => {
if (ConfigUtil.getConfigItem("promptDownload", false)) {
const showDialogOptions: electron.SaveDialogOptions = {
defaultPath: path.join(downloadPath, item.getFilename()),
};
item.setSaveDialogOptions(showDialogOptions);
} else {
const getTimeStamp = (): number => {
const date = new Date();
return date.getTime();
};
const formatFile = (filePath: string): string => { ipcMain.on('realm-icon-changed', (_event: Electron.IpcMessageEvent, serverURL: string, iconURL: string) => {
const fileExtension = path.extname(filePath); page.send('update-realm-icon', serverURL, iconURL);
const baseName = path.basename(filePath, fileExtension); });
return `${baseName}-${getTimeStamp()}${fileExtension}`;
};
const filePath = path.join(downloadPath, item.getFilename()); // Using event.sender.send instead of page.send here to
// make sure the value of errorReporting is sent only once on load.
ipcMain.on('error-reporting', (event: Electron.IpcMessageEvent) => {
event.sender.send('error-reporting-val', errorReporting);
});
// Update the name and path of the file if it already exists ipcMain.on('save-last-tab', (_event: Electron.IpcMessageEvent, index: number) => {
const updatedFilePath = path.join(downloadPath, formatFile(filePath)); ConfigUtil.setConfigItem('lastActiveTab', index);
const setFilePath: string = fs.existsSync(filePath) });
? updatedFilePath
: filePath;
item.setSavePath(setFilePath);
}
const updatedListener = (_event: Event, state: string): void => { // Update user idle status for each realm after every 15s
switch (state) { const idleCheckInterval = 15 * 1000; // 15 seconds
case "interrupted": { setInterval(() => {
// Can interrupted to due to network error, cancel download then // Set user idle if no activity in 1 second (idleThresholdSeconds)
console.log( const idleThresholdSeconds = 1; // 1 second
"Download interrupted, cancelling and fallback to dialog download.",
);
item.cancel();
break;
}
case "progressing": { // TODO: Remove typecast to any when types get added
if (item.isPaused()) { // TODO: use powerMonitor.getSystemIdleState when upgrading electron
item.cancel(); // powerMonitor.querySystemIdleState is deprecated in current electron
} // version at the time of writing.
const powerMonitor = electron.powerMonitor as any;
powerMonitor.querySystemIdleState(idleThresholdSeconds, (idleState: string) => {
if (idleState === 'active') {
page.send('set-active');
} else {
page.send('set-idle');
}
});
}, idleCheckInterval);
});
// This event can also be used to show progress in percentage in future. app.on('before-quit', () => {
break; isQuitting = true;
}
default: {
console.info("Unknown updated state of download item");
}
}
};
item.on("updated", updatedListener);
item.once("done", (_event: Event, state) => {
if (state === "completed") {
send(
page,
"downloadFileCompleted",
item.getSavePath(),
path.basename(item.getSavePath()),
);
} else {
console.log("Download failed state:", state);
send(page, "downloadFileFailed", state);
}
// To stop item for listening to updated events of this file
item.removeListener("updated", updatedListener);
});
});
},
);
ipcMain.on(
"realm-name-changed",
(_event: Electron.IpcMainEvent, serverURL: string, realmName: string) => {
send(page, "update-realm-name", serverURL, realmName);
},
);
ipcMain.on(
"realm-icon-changed",
(_event: Electron.IpcMainEvent, serverURL: string, iconURL: string) => {
send(page, "update-realm-icon", serverURL, iconURL);
},
);
// Using event.sender.send instead of page.send here to
// make sure the value of errorReporting is sent only once on load.
ipcMain.on("error-reporting", (event: Electron.IpcMainEvent) => {
send(event.sender, "error-reporting-val", errorReporting);
});
ipcMain.on(
"save-last-tab",
(_event: Electron.IpcMainEvent, index: number) => {
ConfigUtil.setConfigItem("lastActiveTab", index);
},
);
// Update user idle status for each realm after every 15s
const idleCheckInterval = 15 * 1000; // 15 seconds
setInterval(() => {
// Set user idle if no activity in 1 second (idleThresholdSeconds)
const idleThresholdSeconds = 1; // 1 second
const idleState =
electron.powerMonitor.getSystemIdleState(idleThresholdSeconds);
if (idleState === "active") {
send(page, "set-active");
} else {
send(page, "set-idle");
}
}, idleCheckInterval);
})();
app.on("before-quit", () => {
isQuitting = true;
}); });
// Send crash reports // Send crash reports
process.on("uncaughtException", (error) => { process.on('uncaughtException', err => {
console.error(error); console.error(err);
console.error(error.stack); console.error(err.stack);
}); });

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,132 +0,0 @@
import type {ClientRequest, IncomingMessage} from "electron";
import {app, net} from "electron";
import fs from "fs";
import path from "path";
import stream from "stream";
import util from "util";
import getStream from "get-stream";
import * as z from "zod";
import Logger from "../common/logger-util";
import * as Messages from "../common/messages";
import type {ServerConf} from "../common/types";
export async function fetchResponse(
request: ClientRequest,
): Promise<IncomingMessage> {
return new Promise((resolve, reject) => {
request.on("response", resolve);
request.on("abort", () => {
reject(new Error("Request aborted"));
});
request.on("error", reject);
request.end();
});
}
const pipeline = util.promisify(stream.pipeline);
/* Request: domain-util */
const defaultIconUrl = "../renderer/img/icon.png";
const logger = new Logger({
file: "domain-util.log",
});
const generateFilePath = (url: string): string => {
const dir = `${app.getPath("userData")}/server-icons`;
const extension = path.extname(url).split("?")[0];
let hash = 5381;
let {length} = url;
while (length) {
hash = (hash * 33) ^ url.charCodeAt(--length);
}
// Create 'server-icons' directory if not existed
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
return `${dir}/${hash >>> 0}${extension}`;
};
export const _getServerSettings = async (
domain: string,
session: Electron.session,
): Promise<ServerConf> => {
const response = await fetchResponse(
net.request({
url: domain + "/api/v1/server_settings",
session,
}),
);
if (response.statusCode !== 200) {
throw new Error(Messages.invalidZulipServerError(domain));
}
const data: unknown = JSON.parse(await getStream(response));
const {realm_name, realm_uri, realm_icon} = z
.object({
realm_name: z.string(),
realm_uri: z.string(),
realm_icon: z.string(),
})
.parse(data);
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,
};
};
export const _saveServerIcon = async (
url: string,
session: Electron.session,
): Promise<string> => {
try {
const response = await fetchResponse(net.request({url, session}));
if (response.statusCode !== 200) {
logger.log("Could not get server icon.");
return defaultIconUrl;
}
const filePath = generateFilePath(url);
await pipeline(response, fs.createWriteStream(filePath));
return filePath;
} catch (error: unknown) {
logger.log("Could not get server icon.");
logger.log(error);
logger.reportSentry(error);
return defaultIconUrl;
}
};
/* Request: reconnect-util */
export const _isOnline = async (
url: string,
session: Electron.session,
): Promise<boolean> => {
try {
const response = await fetchResponse(
net.request({
method: "HEAD",
url: `${url}/api/v1/server_settings`,
session,
}),
);
const isValidResponse =
response.statusCode >= 200 && response.statusCode < 400;
return isValidResponse;
} catch (error: unknown) {
logger.log(error);
return false;
}
};

View File

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

View File

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

1940
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
app/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "zulip",
"productName": "Zulip",
"version": "4.0.3",
"desktopName": "zulip.desktop",
"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",
"backoff": "2.5.0",
"dotenv": "8.0.0",
"electron-is-dev": "0.3.0",
"electron-log": "2.2.14",
"electron-spellchecker": "1.1.2",
"electron-updater": "4.0.6",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
"i18n": "0.8.3",
"node-json-db": "0.9.2",
"request": "2.85.0",
"semver": "5.4.1",
"wurl": "2.5.0"
},
"optionalDependencies": {
"node-mac-notifier": "1.1.0"
}
}

View File

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

View File

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

View File

@@ -1,144 +0,0 @@
import type {ContextMenuParams} from "electron";
import {remote} from "electron";
import * as t from "../../../common/translation-util";
const {clipboard, Menu} = remote;
export const contextMenu = (
webContents: Electron.WebContents,
event: Event,
props: ContextMenuParams,
) => {
const isText = props.selectionText !== "";
const isLink = props.linkURL !== "";
const linkURL = isLink ? new URL(props.linkURL) : undefined;
const makeSuggestion = (suggestion: string) => ({
label: suggestion,
visible: true,
async click() {
await webContents.insertText(suggestion);
},
});
let menuTemplate: Electron.MenuItemConstructorOptions[] = [
{
label: t.__("Add to Dictionary"),
visible: props.isEditable && isText && props.misspelledWord.length > 0,
click(_item) {
webContents.session.addWordToSpellCheckerDictionary(
props.misspelledWord,
);
},
},
{
type: "separator",
visible: props.isEditable && isText && props.misspelledWord.length > 0,
},
{
label: `${t.__("Look Up")} "${props.selectionText}"`,
visible: process.platform === "darwin" && isText,
click(_item) {
webContents.showDefinitionForSelection();
},
},
{
type: "separator",
visible: process.platform === "darwin" && isText,
},
{
label: t.__("Cut"),
visible: isText,
enabled: props.isEditable,
accelerator: "CommandOrControl+X",
click(_item) {
webContents.cut();
},
},
{
label: t.__("Copy"),
accelerator: "CommandOrControl+C",
enabled: props.editFlags.canCopy,
click(_item) {
webContents.copy();
},
},
{
label: t.__("Paste"), // Bug: Paste replaces text
accelerator: "CommandOrControl+V",
enabled: props.isEditable,
click() {
webContents.paste();
},
},
{
type: "separator",
},
{
label:
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: Electron.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 menuitems. So we remove them here.
// https://github.com/electron/electron/issues/5869
// https://github.com/electron/electron/issues/6906
const filteredMenuTemplate = menuTemplate.filter(
(menuItem) => menuItem.visible ?? true,
);
const menu = Menu.buildFromTemplate(filteredMenuTemplate);
menu.popup();
};

View File

@@ -1,52 +1,51 @@
import type {HTML} from "../../../common/html"; 'use strict';
import {html} from "../../../common/html";
import {generateNodeFromHTML} from "./base"; import Tab = require('./tab');
import type {TabProps} from "./tab";
import Tab from "./tab";
export default class FunctionalTab extends Tab { class FunctionalTab extends Tab {
$el: Element; $closeButton: Element;
$closeButton?: Element; template(): string {
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>`;
}
constructor(props: TabProps) { // TODO: Typescript - This type for props should be TabProps
super(props); constructor(props: any) {
super(props);
this.init();
}
this.$el = generateNodeFromHTML(this.templateHTML()); init(): void {
if (this.props.name !== "Settings") { this.$el = this.generateNodeFromTemplate(this.template());
this.props.$root.append(this.$el); if (this.props.name !== 'Settings') {
this.$closeButton = this.$el.querySelector(".server-tab-badge")!; this.props.$root.append(this.$el);
this.registerListeners(); this.$closeButton = this.$el.querySelectorAll('.server-tab-badge')[0];
} this.registerListeners();
} }
}
templateHTML(): HTML { registerListeners(): void {
return html` super.registerListeners();
<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>
`;
}
registerListeners(): void { this.$el.addEventListener('mouseover', () => {
super.registerListeners(); this.$closeButton.classList.add('active');
});
this.$el.addEventListener("mouseover", () => { this.$el.addEventListener('mouseout', () => {
this.$closeButton?.classList.add("active"); this.$closeButton.classList.remove('active');
}); });
this.$el.addEventListener("mouseout", () => { this.$closeButton.addEventListener('click', (e: Event) => {
this.$closeButton?.classList.remove("active"); this.props.onDestroy();
}); e.stopPropagation();
});
this.$closeButton?.addEventListener("click", (event: Event) => { }
this.props.onDestroy?.();
event.stopPropagation();
});
}
} }
export = FunctionalTab;

View File

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

View File

@@ -1,66 +1,68 @@
import type {HTML} from "../../../common/html"; 'use strict';
import {html} from "../../../common/html";
import {ipcRenderer} from "../typed-ipc-renderer";
import * as SystemUtil from "../utils/system-util";
import {generateNodeFromHTML} from "./base"; import { ipcRenderer } from 'electron';
import type {TabProps} from "./tab";
import Tab from "./tab";
export default class ServerTab extends Tab { import Tab = require('./tab');
$el: Element; import SystemUtil = require('../utils/system-util');
$badge: Element;
constructor(props: TabProps) { class ServerTab extends Tab {
super(props); $badge: Element;
this.$el = generateNodeFromHTML(this.templateHTML()); template(): string {
this.props.$root.append(this.$el); return `<div class="tab" data-tab-id="${this.props.tabIndex}">
this.registerListeners(); <div class="server-tooltip" style="display:none">${this.props.name}</div>
this.$badge = this.$el.querySelector(".server-tab-badge")!; <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>`;
}
templateHTML(): HTML { // TODO: Typescript - This type for props should be TabProps
return html` constructor(props: any) {
<div class="tab" data-tab-id="${this.props.tabIndex}"> super(props);
<div class="server-tooltip" style="display:none"> this.init();
${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>
`;
}
updateBadge(count: number): void { init(): void {
if (count > 0) { this.$el = this.generateNodeFromTemplate(this.template());
const formattedCount = count > 999 ? "1K+" : count.toString(); this.props.$root.append(this.$el);
this.$badge.textContent = formattedCount; this.registerListeners();
this.$badge.classList.add("active"); this.$badge = this.$el.querySelectorAll('.server-tab-badge')[0];
} else { }
this.$badge.classList.remove("active");
}
}
generateShortcutText(): string { updateBadge(count: number): void {
// Only provide shortcuts for server [0..9] if (count > 0) {
if (this.props.index >= 9) { const formattedCount = count > 999 ? '1K+' : count.toString();
return ""; this.$badge.innerHTML = formattedCount;
} this.$badge.classList.add('active');
} else {
this.$badge.classList.remove('active');
}
}
const shownIndex = this.props.index + 1; generateShortcutText(): string {
// Only provide shortcuts for server [0..10]
if (this.props.index >= 10) {
return '';
}
let shortcutText = ""; const shownIndex = this.props.index + 1;
shortcutText = let shortcutText = '';
SystemUtil.getOS() === "Mac" ? `${shownIndex}` : `Ctrl+${shownIndex}`;
// Array index == Shown index - 1 if (SystemUtil.getOS() === 'Mac') {
ipcRenderer.send("switch-server-tab", shownIndex - 1); shortcutText = `${shownIndex}`;
} else {
shortcutText = `Ctrl+${shownIndex}`;
}
return shortcutText; // Array index == Shown index - 1
} ipcRenderer.send('switch-server-tab', shownIndex - 1);
return shortcutText;
}
} }
export = ServerTab;

View File

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

View File

@@ -1,345 +1,296 @@
import {remote} from "electron"; 'use strict';
import fs from "fs"; import { remote } from 'electron';
import path from "path";
import * as ConfigUtil from "../../../common/config-util"; import path = require('path');
import {HTML, html} from "../../../common/html"; import fs = require('fs');
import type {RendererMessage} from "../../../common/typed-ipc"; import ConfigUtil = require('../utils/config-util');
import type {TabRole} from "../../../common/types"; import SystemUtil = require('../utils/system-util');
import {ipcRenderer} from "../typed-ipc-renderer"; import BaseComponent = require('../components/base');
import * as SystemUtil from "../utils/system-util"; import handleExternalLink = require('../components/handle-external-link');
import {generateNodeFromHTML} from "./base"; const { app, dialog } = remote;
import {contextMenu} from "./context-menu";
import handleExternalLink from "./handle-external-link";
const {app, dialog} = remote; const shouldSilentWebview = ConfigUtil.getConfigItem('silent');
const shouldSilentWebview = ConfigUtil.getConfigItem("silent", false);
// TODO: TypeScript - Type annotate WebViewProps.
interface WebViewProps { interface WebViewProps {
$root: Element; [key: string]: any;
index: number;
tabIndex: number;
url: string;
role: TabRole;
name: string;
isActive: () => boolean;
switchLoading: (loading: boolean, url: string) => void;
onNetworkError: (index: number) => void;
nodeIntegration: boolean;
preload: boolean;
onTitleChange: () => void;
hasPermission?: (origin: string, permission: string) => boolean;
} }
export default class WebView { class WebView extends BaseComponent {
props: WebViewProps; props: any;
zoomFactor: number; zoomFactor: number;
badgeCount: number; badgeCount: number;
loading: boolean; loading: boolean;
customCSS: string | false | null; customCSS: string;
$webviewsContainer: DOMTokenList; $webviewsContainer: DOMTokenList;
$el?: Electron.WebviewTag; $el: Electron.WebviewTag;
domReady?: Promise<void>;
constructor(props: WebViewProps) { // This is required because in main.js we access WebView.method as
this.props = props; // webview[method].
this.zoomFactor = 1; [key: string]: any;
this.loading = true;
this.badgeCount = 0;
this.customCSS = ConfigUtil.getConfigItem("customCSS", null);
this.$webviewsContainer = document.querySelector(
"#webviews-container",
)!.classList;
}
templateHTML(): HTML { constructor(props: WebViewProps) {
return html` super();
<webview
class="disabled"
data-tab-id="${this.props.tabIndex}"
src="${this.props.url}"
${new HTML({html: this.props.nodeIntegration ? "nodeIntegration" : ""})}
${new HTML({html: this.props.preload ? 'preload="js/preload.js"' : ""})}
partition="persist:webviewsession"
name="${this.props.name}"
webpreferences="
contextIsolation=${!this.props.nodeIntegration},
spellcheck=${Boolean(
ConfigUtil.getConfigItem("enableSpellchecker", true),
)},
worldSafeExecuteJavaScript=true
"
>
</webview>
`;
}
init(): void { this.props = props;
this.$el = generateNodeFromHTML(this.templateHTML()) as Electron.WebviewTag; this.zoomFactor = 1.0;
this.domReady = new Promise((resolve) => { this.loading = true;
this.$el!.addEventListener( this.badgeCount = 0;
"dom-ready", this.customCSS = ConfigUtil.getConfigItem('customCSS');
() => { this.$webviewsContainer = document.querySelector('#webviews-container').classList;
resolve(); }
},
true,
);
});
this.props.$root.append(this.$el);
this.registerListeners(); template(): string {
} return `<webview
class="disabled"
data-tab-id="${this.props.tabIndex}"
src="${this.props.url}"
${this.props.nodeIntegration ? 'nodeIntegration' : ''}
${this.props.preload ? 'preload="js/preload.js"' : ''}
partition="persist:webviewsession"
name="${this.props.name}"
webpreferences="allowRunningInsecureContent, javascript=yes">
</webview>`;
}
registerListeners(): void { init(): void {
this.$el!.addEventListener("new-window", (event) => { this.$el = this.generateNodeFromTemplate(this.template()) as Electron.WebviewTag;
handleExternalLink.call(this, event); this.props.$root.append(this.$el);
});
if (shouldSilentWebview) { this.registerListeners();
this.$el!.addEventListener("dom-ready", () => { }
this.$el!.setAudioMuted(true);
});
}
this.$el!.addEventListener("page-title-updated", (event) => { registerListeners(): void {
const {title} = event; this.$el.addEventListener('new-window', event => {
this.badgeCount = this.getBadgeCount(title); handleExternalLink.call(this, event);
this.props.onTitleChange(); });
});
this.$el!.addEventListener("did-navigate-in-page", (event) => { if (shouldSilentWebview) {
const isSettingPage = event.url.includes("renderer/preference.html"); this.$el.addEventListener('dom-ready', () => {
if (isSettingPage) { this.$el.setAudioMuted(true);
return; });
} }
this.canGoBackButton(); this.$el.addEventListener('page-title-updated', event => {
}); const { title } = event;
this.badgeCount = this.getBadgeCount(title);
this.props.onTitleChange();
});
this.$el!.addEventListener("did-navigate", () => { this.$el.addEventListener('did-navigate-in-page', event => {
this.canGoBackButton(); const isSettingPage = event.url.includes('renderer/preference.html');
}); if (isSettingPage) {
return;
}
this.canGoBackButton();
});
this.$el!.addEventListener("page-favicon-updated", (event) => { this.$el.addEventListener('did-navigate', () => {
const {favicons} = event; this.canGoBackButton();
});
// This returns a string of favicons URL. If there is a PM counts in unread messages then the URL would be like this.$el.addEventListener('page-favicon-updated', event => {
// https://chat.zulip.org/static/images/favicon/favicon-pms.png const { favicons } = event;
if (
favicons[0].indexOf("favicon-pms") > 0 &&
process.platform === "darwin"
) {
// This api is only supported on macOS
app.dock.setBadge("●");
// Bounce the dock
if (ConfigUtil.getConfigItem("dockBouncing", true)) {
app.dock.bounce();
}
}
});
this.$el!.addEventListener("dom-ready", () => { // This returns a string of favicons URL. If there is a PM counts in unread messages then the URL would be like
const webContents = remote.webContents.fromId( // https://chat.zulip.org/static/images/favicon/favicon-pms.png
this.$el!.getWebContentsId(), if (favicons[0].indexOf('favicon-pms') > 0 && process.platform === 'darwin') {
); // This api is only supported on macOS
webContents.addListener("context-menu", (event, menuParameters) => { app.dock.setBadge('●');
contextMenu(webContents, event, menuParameters); // bounce the dock
}); if (ConfigUtil.getConfigItem('dockBouncing')) {
app.dock.bounce();
}
}
});
if (this.props.role === "server") { this.$el.addEventListener('dom-ready', () => {
this.$el!.classList.add("onload"); if (this.props.role === 'server') {
} this.$el.classList.add('onload');
}
this.loading = false;
this.props.switchLoading(false, this.props.url);
this.show();
this.loading = false; // Refocus text boxes after reload
this.props.switchLoading(false, this.props.url); // Remove when upstream issue https://github.com/electron/electron/issues/14474 is fixed
this.show(); this.$el.blur();
this.$el.focus();
});
// Refocus text boxes after reload this.$el.addEventListener('did-fail-load', event => {
// Remove when upstream issue https://github.com/electron/electron/issues/14474 is fixed const { errorDescription } = event;
this.$el!.blur(); const hasConnectivityErr = SystemUtil.connectivityERR.includes(errorDescription);
this.$el!.focus(); if (hasConnectivityErr) {
}); console.error('error', errorDescription);
if (!this.props.url.includes('network.html')) {
this.props.onNetworkError(this.props.index);
}
}
});
this.$el!.addEventListener("did-fail-load", (event) => { this.$el.addEventListener('did-start-loading', () => {
const {errorDescription} = event; const isSettingPage = this.props.url.includes('renderer/preference.html');
const hasConnectivityError = if (!isSettingPage) {
SystemUtil.connectivityERR.includes(errorDescription); this.props.switchLoading(true, this.props.url);
if (hasConnectivityError) { }
console.error("error", errorDescription); let userAgent = SystemUtil.getUserAgent();
if (!this.props.url.includes("network.html")) { if (!userAgent) {
this.props.onNetworkError(this.props.index); SystemUtil.setUserAgent(this.$el.getUserAgent());
} userAgent = SystemUtil.getUserAgent();
} }
}); this.$el.setUserAgent(userAgent);
});
this.$el!.addEventListener("did-start-loading", () => { this.$el.addEventListener('did-stop-loading', () => {
const isSettingPage = this.props.url.includes("renderer/preference.html"); this.props.switchLoading(false, this.props.url);
if (!isSettingPage) { });
this.props.switchLoading(true, this.props.url); }
}
});
this.$el!.addEventListener("did-stop-loading", () => { getBadgeCount(title: string): number {
this.props.switchLoading(false, this.props.url); const messageCountInTitle = (/\((\d+)\)/).exec(title);
}); return messageCountInTitle ? Number(messageCountInTitle[1]) : 0;
} }
getBadgeCount(title: string): number { showNotificationSettings(): void {
const messageCountInTitle = /\((\d+)\)/.exec(title); this.$el.executeJavaScript('showNotificationSettings()');
return messageCountInTitle ? Number(messageCountInTitle[1]) : 0; }
}
async showNotificationSettings(): Promise<void> { show(): void {
await this.send("show-notification-settings"); // Do not show WebView if another tab was selected and this tab should be in background.
} if (!this.props.isActive()) {
return;
}
show(): void { // To show or hide the loading indicator in the the active tab
// Do not show WebView if another tab was selected and this tab should be in background. if (this.loading) {
if (!this.props.isActive()) { this.$webviewsContainer.remove('loaded');
return; } else {
} this.$webviewsContainer.add('loaded');
}
// To show or hide the loading indicator in the the active tab this.$el.classList.remove('disabled');
if (this.loading) { this.$el.classList.add('active');
this.$webviewsContainer.remove("loaded"); setTimeout(() => {
} else { if (this.props.role === 'server') {
this.$webviewsContainer.add("loaded"); 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'));
this.$el!.classList.remove("disabled"); // get customCSS again from config util to avoid warning user again
this.$el!.classList.add("active"); this.customCSS = ConfigUtil.getConfigItem('customCSS');
setTimeout(() => { if (this.customCSS) {
if (this.props.role === "server") { if (!fs.existsSync(this.customCSS)) {
this.$el!.classList.remove("onload"); this.customCSS = null;
} ConfigUtil.setConfigItem('customCSS', null);
}, 1000);
this.focus();
this.props.onTitleChange();
// Injecting preload css in webview to override some css rules
(async () =>
this.$el!.insertCSS(
fs.readFileSync(path.join(__dirname, "/../../css/preload.css"), "utf8"),
))();
// Get customCSS again from config util to avoid warning user again const errMsg = 'The custom css previously set is deleted!';
const customCSS = ConfigUtil.getConfigItem("customCSS", null); dialog.showErrorBox('custom css file deleted!', errMsg);
this.customCSS = customCSS; return;
if (customCSS) { }
if (!fs.existsSync(customCSS)) {
this.customCSS = null;
ConfigUtil.setConfigItem("customCSS", null);
const errorMessage = "The custom css previously set is deleted!"; this.$el.insertCSS(fs.readFileSync(path.resolve(__dirname, this.customCSS), 'utf8'));
dialog.showErrorBox("custom css file deleted!", errorMessage); }
return; }
}
(async () => focus(): void {
this.$el!.insertCSS( // focus Webview and it's contents when Window regain focus.
fs.readFileSync(path.resolve(__dirname, customCSS), "utf8"), 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 as HTMLElement).blur();
this.$el.focus();
webContents.focus();
}
}
focus(): void { hide(): void {
// Focus Webview and it's contents when Window regain focus. this.$el.classList.add('disabled');
const webContents = remote.webContents.fromId(this.$el!.getWebContentsId()); this.$el.classList.remove('active');
// HACK: webContents.isFocused() seems to be true even without the element }
// being in focus. So, we check against `document.activeElement`.
if (webContents && this.$el !== document.activeElement) {
// HACK: Looks like blur needs to be called on the previously focused
// element to transfer focus correctly, in Electron v3.0.10
// See https://github.com/electron/electron/issues/15718
(document.activeElement as HTMLElement).blur();
this.$el!.focus();
webContents.focus();
}
}
hide(): void { load(): void {
this.$el!.classList.add("disabled"); if (this.$el) {
this.$el!.classList.remove("active"); this.show();
} } else {
this.init();
}
}
load(): void { zoomIn(): void {
if (this.$el) { this.zoomFactor += 0.1;
this.show(); this.$el.setZoomFactor(this.zoomFactor);
} else { }
this.init();
}
}
zoomIn(): void { zoomOut(): void {
this.zoomFactor += 0.1; this.zoomFactor -= 0.1;
this.$el!.setZoomFactor(this.zoomFactor); this.$el.setZoomFactor(this.zoomFactor);
} }
zoomOut(): void { zoomActualSize(): void {
this.zoomFactor -= 0.1; this.zoomFactor = 1.0;
this.$el!.setZoomFactor(this.zoomFactor); this.$el.setZoomFactor(this.zoomFactor);
} }
zoomActualSize(): void { logOut(): void {
this.zoomFactor = 1; this.$el.executeJavaScript('logout()');
this.$el!.setZoomFactor(this.zoomFactor); }
}
async logOut(): Promise<void> { showShortcut(): void {
await this.send("logout"); this.$el.executeJavaScript('shortcut()');
} }
async showKeyboardShortcuts(): Promise<void> { openDevTools(): void {
await this.send("show-keyboard-shortcuts"); this.$el.openDevTools();
} }
openDevTools(): void { back(): void {
this.$el!.openDevTools(); if (this.$el.canGoBack()) {
} this.$el.goBack();
this.focus();
}
}
back(): void { canGoBackButton(): void {
if (this.$el!.canGoBack()) { const $backButton = document.querySelector('#actions-container #back-action');
this.$el!.goBack(); if (this.$el.canGoBack()) {
this.focus(); $backButton.classList.remove('disable');
} } else {
} $backButton.classList.add('disable');
}
}
canGoBackButton(): void { forward(): void {
const $backButton = document.querySelector( if (this.$el.canGoForward()) {
"#actions-container #back-action", this.$el.goForward();
)!; }
if (this.$el!.canGoBack()) { }
$backButton.classList.remove("disable");
} else {
$backButton.classList.add("disable");
}
}
forward(): void { reload(): void {
if (this.$el!.canGoForward()) { this.hide();
this.$el!.goForward(); // Shows the loading indicator till the webview is reloaded
} this.$webviewsContainer.remove('loaded');
} this.loading = true;
this.props.switchLoading(true, this.props.url);
this.$el.reload();
}
reload(): void { forceLoad(): void {
this.hide(); this.init();
// Shows the loading indicator till the webview is reloaded }
this.$webviewsContainer.remove("loaded");
this.loading = true;
this.props.switchLoading(true, this.props.url);
this.$el!.reload();
}
forceLoad(): void { send(channel: string, ...param: any[]): void {
this.init(); this.$el.send(channel, ...param);
} }
async send<Channel extends keyof RendererMessage>(
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
): Promise<void> {
await this.domReady;
ipcRenderer.sendTo(this.$el!.getWebContentsId(), channel, ...args);
}
} }
export = WebView;

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
'use strict';
import { ipcRenderer } from 'electron';
import {
appId, customReply, focusCurrentServer, parseReply, setupReply
} from './helpers';
import url = require('url');
import MacNotifier = require('node-mac-notifier');
import ConfigUtil = require('../utils/config-util');
type ReplyHandler = (response: string) => void;
type ClickHandler = () => void;
let replyHandler: ReplyHandler;
let clickHandler: ClickHandler;
declare const window: ZulipWebWindow;
interface NotificationHandlerArgs {
response: string;
}
class DarwinNotification {
tag: string;
constructor(title: string, opts: NotificationOptions) {
const silent: boolean = 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(): void {
return; // eslint-disable-line no-useless-return
}
// Override default Notification permission
static get permission(): NotificationPermission {
return ConfigUtil.getConfigItem('showNotification') ? 'granted' : 'denied';
}
set onreply(handler: ReplyHandler) {
replyHandler = handler;
}
get onreply(): ReplyHandler {
return replyHandler;
}
set onclick(handler: ClickHandler) {
clickHandler = handler;
}
get onclick(): ClickHandler {
return clickHandler;
}
// not something that is common or
// used by zulip server but added to be
// future proff.
addEventListener(event: string, handler: ClickHandler | ReplyHandler): void {
if (event === 'click') {
clickHandler = handler as ClickHandler;
}
if (event === 'reply') {
replyHandler = handler as ReplyHandler;
}
}
notificationHandler({ response }: NotificationHandlerArgs): void {
response = parseReply(response);
focusCurrentServer();
if (window.electron_bridge.send_notification_reply_message_supported) {
window.electron_bridge.send_event('send_notification_reply_message', this.tag, response);
return;
}
setupReply(this.tag);
if (replyHandler) {
replyHandler(response);
return;
}
customReply(response);
}
// method specific to notification api
// used by zulip
close(): void {
return; // eslint-disable-line no-useless-return
}
}
module.exports = DarwinNotification;

View File

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

View File

@@ -1,20 +1,153 @@
import {remote} from "electron"; import { remote } from 'electron';
import {ipcRenderer} from "../typed-ipc-renderer"; import Logger = require('../utils/logger-util');
const logger = new Logger({
file: 'errors.log',
timestamp: true
});
// Do not change this // Do not change this
export const appId = "org.zulip.zulip-electron"; export const appId = 'org.zulip.zulip-electron';
export type BotListItem = [string, string];
const botsList: BotListItem[] = [];
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
export function loadBots(sync = false): void {
const { $ } = window;
botsList.length = 0;
if (sync) {
$.ajaxSetup({async: false});
}
$.getJSON('/json/users')
.done((data: any) => {
const { members } = data;
members.forEach((membersRow: any) => {
if (membersRow.is_bot) {
const bot = `@${membersRow.full_name}`;
const mention = `@**${bot.replace(/^@/, '')}**`;
botsList.push([bot, mention]);
}
});
botsListLoaded = true;
})
.fail((error: any) => {
logger.log('Load bots request failed: ', error.responseText);
logger.log('Load bots request status: ', error.statusText);
});
if (sync) {
$.ajaxSetup({async: true});
}
}
export function checkElements(...elements: any[]): boolean {
let status = true;
elements.forEach(element => {
if (element === null || element === undefined) {
status = false;
}
});
return status;
}
export function customReply(reply: string): void {
// 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: HTMLInputElement = document.querySelector('#compose-textarea');
const messagebox: HTMLButtonElement = document.querySelector(messageboxSelector);
const sendButton: HTMLButtonElement = 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 currentWindow = remote.getCurrentWindow();
const webContents = remote.getCurrentWebContents(); const webContents = remote.getCurrentWebContents();
const webContentsId = webContents.id; const webContentsId = webContents.id;
// This function will focus the server that sent // this function will focus the server that sent
// the notification. Main function implemented in main.js // the notification. Main function implemented in main.js
export function focusCurrentServer(): void { export function focusCurrentServer(): void {
ipcRenderer.sendTo( // TODO: TypeScript: currentWindow of type BrowserWindow doesn't
currentWindow.webContents.id, // have a .send() property per typescript.
"focus-webview-with-id", (currentWindow as any).send('focus-webview-with-id', webContentsId);
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
export function parseReply(reply: string): string {
const usersDiv = document.querySelectorAll('#user_presences li');
const streamHolder = document.querySelectorAll('#stream_filters li');
type UsersItem = BotListItem;
type StreamsItem = BotListItem;
const users: UsersItem[] = [];
const streams: StreamsItem[] = [];
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;
}
export function setupReply(id: string): void {
const { narrow } = window;
const narrowByTopic = narrow.by_topic || narrow.by_subject;
narrowByTopic(id, { trigger: 'notification' });
} }

View File

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

View File

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

View File

@@ -0,0 +1,100 @@
'use-strict';
import { remote, OpenDialogOptions } from 'electron';
import path = require('path');
import BaseComponent = require('../../components/base');
import CertificateUtil = require('../../utils/certificate-util');
import DomainUtil = require('../../utils/domain-util');
import t = require('../../utils/translation-util');
const { dialog } = remote;
class AddCertificate extends BaseComponent {
// TODO: TypeScript - Here props should be object type
props: any;
_certFile: string;
$addCertificate: Element | null;
addCertificateButton: Element | null;
serverUrl: HTMLInputElement | null;
constructor(props: any) {
super();
this.props = props;
this._certFile = '';
}
template(): string {
return `
<div class="settings-card certificates-card">
<div class="certificate-input">
<div>${t.__('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>${t.__('Certificate file')}</div>
<button class="green" id="add-certificate-button">${t.__('Upload')}</button>
</div>
</div>
`;
}
init(): void {
this.$addCertificate = this.generateNodeFromTemplate(this.template());
this.props.$root.append(this.$addCertificate);
this.addCertificateButton = this.$addCertificate.querySelector('#add-certificate-button');
this.serverUrl = this.$addCertificate.querySelectorAll('input.setting-input-value')[0] as HTMLInputElement;
this.initListeners();
}
validateAndAdd(): void {
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(): void {
const showDialogOptions: OpenDialogOptions = {
title: 'Select file',
properties: ['openFile'],
filters: [{ name: 'crt, pem', extensions: ['crt', 'pem'] }]
};
dialog.showOpenDialog(showDialogOptions, selectedFile => {
if (selectedFile) {
this._certFile = selectedFile[0] || '';
this.validateAndAdd();
}
});
}
initListeners(): void {
this.addCertificateButton.addEventListener('click', () => {
this.addHandler();
});
this.serverUrl.addEventListener('keypress', event => {
const EnterkeyCode = event.keyCode;
if (EnterkeyCode === 13) {
this.addHandler();
}
});
}
}
export = AddCertificate;

View File

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

View File

@@ -1,83 +1,50 @@
import type {HTML} from "../../../../common/html"; 'use strict';
import {html} from "../../../../common/html";
import {generateNodeFromHTML} from "../../components/base";
import {ipcRenderer} from "../../typed-ipc-renderer";
interface BaseSectionProps { import { ipcRenderer } from 'electron';
$element: HTMLElement;
disabled?: boolean; import BaseComponent = require('../../components/base');
value: boolean;
clickHandler: () => void; class BaseSection extends BaseComponent {
// TODO: TypeScript - Here props should be object type
generateSettingOption(props: any): void {
const {$element, disabled, value, clickHandler} = props;
$element.innerHTML = '';
const $optionControl = this.generateNodeFromTemplate(this.generateOptionTemplate(value, disabled));
$element.append($optionControl);
if (!disabled) {
$optionControl.addEventListener('click', clickHandler);
}
}
generateOptionTemplate(settingOption: boolean, disabled: boolean): string {
const label = disabled ? `<label class="disallowed" title="Setting locked by system administrator."/>` : `<label/>`;
if (settingOption) {
return `
<div class="action">
<div class="switch">
<input class="toggle toggle-round" type="checkbox" checked disabled>
${label}
</div>
</div>
`;
} else {
return `
<div class="action">
<div class="switch">
<input class="toggle toggle-round" type="checkbox">
${label}
</div>
</div>
`;
}
}
reloadApp(): void {
ipcRenderer.send('forward-message', 'reload-viewer');
}
} }
export function generateSettingOption(props: BaseSectionProps): void { export = BaseSection;
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
? 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

@@ -1,65 +1,90 @@
import {html} from "../../../../common/html"; 'use strict';
import * as t from "../../../../common/translation-util";
import {ipcRenderer} from "../../typed-ipc-renderer";
import * as DomainUtil from "../../utils/domain-util";
import {reloadApp} from "./base-section"; import { ipcRenderer } from 'electron';
import {initFindAccounts} from "./find-accounts";
import {initServerInfoForm} from "./server-info-form";
interface ConnectedOrgSectionProps { import BaseSection = require('./base-section');
$root: Element; import DomainUtil = require('../../utils/domain-util');
import ServerInfoForm = require('./server-info-form');
import AddCertificate = require('./add-certificate');
import FindAccounts = require('./find-accounts');
import t = require('../../utils/translation-util');
class ConnectedOrgSection extends BaseSection {
// TODO: TypeScript - Here props should be object type
props: any;
$serverInfoContainer: Element | null;
$existingServers: Element | null;
$newOrgButton: HTMLButtonElement | null;
$addCertificateContainer: Element | null;
$findAccountsContainer: Element | null;
constructor(props: any) {
super();
this.props = props;
}
template(): string {
return `
<div class="settings-pane" id="server-settings-pane">
<div class="page-title">${t.__('Connected organizations')}</div>
<div class="title" id="existing-servers">${t.__('All the connected orgnizations will appear here.')}</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.__('Add Custom Certificates')}</div>
<div id="add-certificate-container"></div>
<div class="page-title">${t.__('Find accounts by email')}</div>
<div id="find-accounts-container"></div>
</div>
`;
}
init(): void {
this.initServers();
}
initServers(): void {
this.props.$root.innerHTML = '';
const servers = DomainUtil.getDomains();
this.props.$root.innerHTML = this.template();
this.$serverInfoContainer = document.querySelector('#server-info-container');
this.$existingServers = document.querySelector('#existing-servers');
this.$newOrgButton = document.querySelector('#new-org-button');
this.$addCertificateContainer = document.querySelector('#add-certificate-container');
this.$findAccountsContainer = document.querySelector('#find-accounts-container');
const noServerText = t.__('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', () => {
ipcRenderer.send('forward-message', 'open-org-tab');
});
this.initAddCertificate();
this.initFindAccounts();
}
initAddCertificate(): void {
new AddCertificate({
$root: this.$addCertificateContainer
}).init();
}
initFindAccounts(): void {
new FindAccounts({
$root: this.$findAccountsContainer
}).init();
}
} }
export function initConnectedOrgSection(props: ConnectedOrgSectionProps): void { export = ConnectedOrgSection;
props.$root.textContent = "";
const servers = DomainUtil.getDomains();
props.$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 orgnizations 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 = document.querySelector(
"#server-info-container",
)!;
const $existingServers = document.querySelector("#existing-servers")!;
const $newOrgButton: HTMLButtonElement =
document.querySelector("#new-org-button")!;
const $findAccountsContainer = document.querySelector(
"#find-accounts-container",
)!;
const noServerText = t.__("All the connected orgnizations 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,67 +1,78 @@
import {html} from "../../../../common/html"; 'use-strict';
import * as t from "../../../../common/translation-util";
import {generateNodeFromHTML} from "../../components/base";
import * as LinkUtil from "../../utils/link-util";
interface FindAccountsProps { import { shell } from 'electron';
$root: Element;
import BaseComponent = require('../../components/base');
import t = require('../../utils/translation-util');
class FindAccounts extends BaseComponent {
// TODO: TypeScript - Here props should be object type
props: any;
$findAccounts: Element | null;
$findAccountsButton: Element | null;
$serverUrlField: HTMLInputElement | null;
constructor(props: any) {
super();
this.props = props;
}
template(): string {
return `
<div class="settings-card certificate-card">
<div class="certificate-input">
<div>${t.__('Organization URL')}</div>
<input class="setting-input-value" value="zulipchat.com"/>
</div>
<div class="certificate-input">
<button class="green w-150" id="find-accounts-button">${t.__('Find accounts')}</button>
</div>
</div>
`;
}
init(): void {
this.$findAccounts = this.generateNodeFromTemplate(this.template());
this.props.$root.append(this.$findAccounts);
this.$findAccountsButton = this.$findAccounts.querySelector('#find-accounts-button');
this.$serverUrlField = this.$findAccounts.querySelectorAll('input.setting-input-value')[0] as HTMLInputElement;
this.initListeners();
}
findAccounts(url: string): void {
if (!url) {
return;
}
if (!url.startsWith('http')) {
url = 'https://' + url;
}
shell.openExternal(url + '/accounts/find');
}
initListeners(): void {
this.$findAccountsButton.addEventListener('click', () => {
this.findAccounts(this.$serverUrlField.value);
});
this.$serverUrlField.addEventListener('click', () => {
if (this.$serverUrlField.value === 'zulipchat.com') {
this.$serverUrlField.setSelectionRange(0, 0);
}
});
this.$serverUrlField.addEventListener('keypress', event => {
if (event.keyCode === 13) {
this.findAccounts(this.$serverUrlField.value);
}
});
this.$serverUrlField.addEventListener('input', () => {
if (this.$serverUrlField.value) {
this.$serverUrlField.classList.remove('invalid-input-value');
} else {
this.$serverUrlField.classList.add('invalid-input-value');
}
});
}
} }
async function findAccounts(url: string): Promise<void> { export = FindAccounts;
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", () => {
if ($serverUrlField.value) {
$serverUrlField.classList.remove("invalid-input-value");
} else {
$serverUrlField.classList.add("invalid-input-value");
}
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,76 +1,68 @@
import type {HTML} from "../../../../common/html"; 'use strict';
import {html} from "../../../../common/html";
import * as t from "../../../../common/translation-util";
import type {NavItem} from "../../../../common/types";
import {generateNodeFromHTML} from "../../components/base";
interface PreferenceNavProps { import BaseComponent = require('../../components/base');
$root: Element; import t = require('../../utils/translation-util');
onItemSelected: (navItem: NavItem) => void;
class PreferenceNav extends BaseComponent {
// TODO: TypeScript - Here props should be object type
props: any;
navItems: string[];
$el: Element;
constructor(props: any) {
super();
this.props = props;
this.navItems = ['General', 'Network', 'AddServer', 'Organizations', 'Shortcuts'];
this.init();
}
template(): string {
let navItemsTemplate = '';
for (const navItem of this.navItems) {
navItemsTemplate += `<div class="nav" id="nav-${navItem}">${t.__(navItem)}</div>`;
}
return `
<div>
<div id="settings-header">${t.__('Settings')}</div>
<div id="nav-container">${navItemsTemplate}</div>
</div>
`;
}
init(): void {
this.$el = this.generateNodeFromTemplate(this.template());
this.props.$root.append(this.$el);
this.registerListeners();
}
registerListeners(): void {
for (const navItem of this.navItems) {
const $item = document.querySelector(`#nav-${navItem}`);
$item.addEventListener('click', () => {
this.props.onItemSelected(navItem);
});
}
}
select(navItemToSelect: string): void {
for (const navItem of this.navItems) {
if (navItem === navItemToSelect) {
this.activate(navItem);
} else {
this.deactivate(navItem);
}
}
}
activate(navItem: string): void {
const $item = document.querySelector(`#nav-${navItem}`);
$item.classList.add('active');
}
deactivate(navItem: string): void {
const $item = document.querySelector(`#nav-${navItem}`);
$item.classList.remove('active');
}
} }
export default class PreferenceNav { export = PreferenceNav;
props: PreferenceNavProps;
navItems: NavItem[];
$el: Element;
constructor(props: PreferenceNavProps) {
this.props = props;
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 = document.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 = document.querySelector(`#nav-${CSS.escape(navItem)}`)!;
$item.classList.add("active");
}
deactivate(navItem: NavItem): void {
const $item = document.querySelector(`#nav-${CSS.escape(navItem)}`)!;
$item.classList.remove("active");
}
}

View File

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

View File

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

View File

@@ -1,106 +1,126 @@
import type {DNDSettings} from "../../../../common/dnd-util"; 'use strict';
import type {NavItem} from "../../../../common/types";
import {ipcRenderer} from "../../typed-ipc-renderer";
import {initConnectedOrgSection} from "./connected-org-section"; import { ipcRenderer } from 'electron';
import {initGeneralSection} from "./general-section";
import Nav from "./nav";
import {initNetworkSection} from "./network-section";
import {initServersSection} from "./servers-section";
import {initShortcutsSection} from "./shortcuts-section";
export function initPreferenceView(): void { import BaseComponent = require('../../components/base');
const $sidebarContainer = document.querySelector("#sidebar")!; import Nav = require('./nav');
const $settingsContainer = document.querySelector("#settings-container")!; import ServersSection = require('./servers-section');
import GeneralSection = require('./general-section');
import NetworkSection = require('./network-section');
import ConnectedOrgSection = require('./connected-org-section');
import ShortcutsSection = require('./shortcuts-section');
const nav = new Nav({ type Section = ServersSection | GeneralSection | NetworkSection | ConnectedOrgSection | ShortcutsSection;
$root: $sidebarContainer,
onItemSelected: handleNavigation,
});
const navItem = class PreferenceView extends BaseComponent {
nav.navItems.find((navItem) => window.location.hash === `#${navItem}`) ?? $sidebarContainer: Element;
"General"; $settingsContainer: Element;
nav: Nav;
section: Section;
constructor() {
super();
this.$sidebarContainer = document.querySelector('#sidebar');
this.$settingsContainer = document.querySelector('#settings-container');
}
handleNavigation(navItem); init(): void {
this.nav = new Nav({
$root: this.$sidebarContainer,
onItemSelected: this.handleNavigation.bind(this)
});
function handleNavigation(navItem: NavItem): void { this.setDefaultView();
nav.select(navItem); this.registerIpcs();
switch (navItem) { }
case "AddServer":
initServersSection({
$root: $settingsContainer,
});
break;
case "General": setDefaultView(): void {
initGeneralSection({ let nav = 'General';
$root: $settingsContainer, const hasTag = window.location.hash;
}); if (hasTag) {
break; nav = hasTag.substring(1);
}
this.handleNavigation(nav);
}
case "Organizations": handleNavigation(navItem: string): void {
initConnectedOrgSection({ this.nav.select(navItem);
$root: $settingsContainer, switch (navItem) {
}); case 'AddServer': {
break; this.section = new ServersSection({
$root: this.$settingsContainer
});
break;
}
case 'General': {
this.section = new GeneralSection({
$root: this.$settingsContainer
});
break;
}
case 'Organizations': {
this.section = new ConnectedOrgSection({
$root: this.$settingsContainer
});
break;
}
case 'Network': {
this.section = new NetworkSection({
$root: this.$settingsContainer
});
break;
}
case 'Shortcuts': {
this.section = new ShortcutsSection({
$root: this.$settingsContainer
});
break;
}
default: break;
}
this.section.init();
window.location.hash = `#${navItem}`;
}
case "Network": // Handle toggling and reflect changes in preference page
initNetworkSection({ handleToggle(elementName: string, state: boolean): void {
$root: $settingsContainer, const inputSelector = `#${elementName} .action .switch input`;
}); const input: HTMLInputElement = document.querySelector(inputSelector);
break; if (input) {
input.checked = state;
}
}
case "Shortcuts": { registerIpcs(): void {
initShortcutsSection({ ipcRenderer.on('switch-settings-nav', (_event: Event, navItem: string) => {
$root: $settingsContainer, this.handleNavigation(navItem);
}); });
break;
}
default: ipcRenderer.on('toggle-sidebar-setting', (_event: Event, state: boolean) => {
((n: never) => n)(navItem); this.handleToggle('sidebar-option', state);
} });
window.location.hash = `#${navItem}`; ipcRenderer.on('toggle-menubar-setting', (_event: Event, state: boolean) => {
} this.handleToggle('menubar-option', state);
});
// Handle toggling and reflect changes in preference page ipcRenderer.on('toggletray', (_event: Event, state: boolean) => {
function handleToggle(elementName: string, state = false): void { this.handleToggle('tray-option', state);
const inputSelector = `#${elementName} .action .switch input`; });
const input: HTMLInputElement = document.querySelector(inputSelector)!;
if (input) {
input.checked = state;
}
}
ipcRenderer.on("switch-settings-nav", (_event: Event, navItem: NavItem) => { ipcRenderer.on('toggle-dnd', (_event: Event, _state: boolean, newSettings: any) => {
handleNavigation(navItem); this.handleToggle('show-notification-option', newSettings.showNotification);
}); this.handleToggle('silent-option', newSettings.silent);
ipcRenderer.on("toggle-sidebar-setting", (_event: Event, state: boolean) => { if (process.platform === 'win32') {
handleToggle("sidebar-option", state); this.handleToggle('flash-taskbar-option', newSettings.flashTaskbarOnMessage);
}); }
});
ipcRenderer.on("toggle-menubar-setting", (_event: Event, state: boolean) => { }
handleToggle("menubar-option", state);
});
ipcRenderer.on("toggle-tray", (_event: Event, state: boolean) => {
handleToggle("tray-option", state);
});
ipcRenderer.on(
"toggle-dnd",
(_event: Event, _state: boolean, newSettings: Partial<DNDSettings>) => {
handleToggle("show-notification-option", newSettings.showNotification);
handleToggle("silent-option", newSettings.silent);
if (process.platform === "win32") {
handleToggle("flash-taskbar-option", newSettings.flashTaskbarOnMessage);
}
},
);
} }
window.addEventListener("load", initPreferenceView); window.addEventListener('load', () => {
const preferenceView = new PreferenceView();
preferenceView.init();
});
export = PreferenceView;

View File

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

View File

@@ -1,30 +1,50 @@
import {html} from "../../../../common/html"; 'use strict';
import * as t from "../../../../common/translation-util";
import {reloadApp} from "./base-section"; import BaseSection = require('./base-section');
import {initNewServerForm} from "./new-server-form"; import NewServerForm = require('./new-server-form');
import t = require('../../utils/translation-util');
interface ServersSectionProps { class ServersSection extends BaseSection {
$root: Element; // TODO: TypeScript - Here props should be object type
props: any;
$newServerContainer: Element;
constructor(props: any) {
super();
this.props = props;
}
template(): string {
return `
<div class="add-server-modal">
<div class="modal-container">
<div class="settings-pane" id="server-settings-pane">
<div class="page-title">${t.__('Add a Zulip organization')}</div>
<div id="new-server-container"></div>
</div>
</div>
</div>
`;
}
init(): void {
this.initServers();
}
initServers(): void {
this.props.$root.innerHTML = '';
this.props.$root.innerHTML = this.template();
this.$newServerContainer = document.querySelector('#new-server-container');
this.initNewServerForm();
}
initNewServerForm(): void {
new NewServerForm({
$root: this.$newServerContainer,
onChange: this.reloadApp
}).init();
}
} }
export function initServersSection(props: ServersSectionProps): void { export = ServersSection;
props.$root.textContent = "";
props.$root.innerHTML = html`
<div class="add-server-modal">
<div class="modal-container">
<div class="settings-pane" id="server-settings-pane">
<div class="page-title">${t.__("Add a Zulip organization")}</div>
<div id="new-server-container"></div>
</div>
</div>
</div>
`.html;
const $newServerContainer = document.querySelector("#new-server-container")!;
initNewServerForm({
$root: $newServerContainer,
onChange: reloadApp,
});
}

View File

@@ -1,231 +1,345 @@
import {html} from "../../../../common/html"; 'use strict';
import * as t from "../../../../common/translation-util";
import * as LinkUtil from "../../utils/link-util";
interface ShortcutsSectionProps { import { shell } from 'electron';
$root: Element;
import BaseSection = require('./base-section');
import t = require('../../utils/translation-util');
class ShortcutsSection extends BaseSection {
// TODO: TypeScript - Here props should be object type
props: any;
constructor(props: any) {
super();
this.props = props;
}
// TODO - Deduplicate templateMac and templateWinLin functions. In theory
// they both should be the same the only thing different should be the userOSKey
// variable but there seems to be inconsistences between both function, one has more
// lines though one may just be using more new lines and other thing is the use of +.
templateMac(): string {
const userOSKey = '⌘';
return `
<div class="settings-pane">
<div class="settings-card tip"><p><b><i class="material-icons md-14">settings</i>${t.__('Tip')}: </b>${t.__('These desktop app shortcuts extend the Zulip webapp\'s')} <span id="open-hotkeys-link"> ${t.__('keyboard shortcuts')}</span>.</p></div>
<div class="title">${t.__('Application Shortcuts')}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>,</kbd></td>
<td>${t.__('Settings')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>K</kbd></td>
<td>${t.__('Keyboard Shortcuts')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>Shift</kbd> + <kbd>M</kbd></td>
<td>${t.__('Toggle Do Not Disturb')}</td>
</tr>
<tr>
<td><kbd>Shift</kbd><kbd>${userOSKey}</kbd><kbd>D</kbd></td>
<td>${t.__('Reset App Settings')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>L</kbd></td>
<td>${t.__('Log Out')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>H</kbd></td>
<td>${t.__('Hide Zulip')}</td>
</tr>
<tr>
<td><kbd>Option</kbd><kbd>${userOSKey}</kbd><kbd>H</kbd></td>
<td>${t.__('Hide Others')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>Q</kbd></td>
<td>${t.__('Quit Zulip')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__('Edit Shortcuts')}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>Z</kbd></td>
<td>${t.__('Undo')}</td>
</tr>
<tr>
<td><kbd>Shift</kbd><kbd>${userOSKey}</kbd><kbd>Z</kbd></td>
<td>${t.__('Redo')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>X</kbd></td>
<td>${t.__('Cut')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>C</kbd></td>
<td>${t.__('Copy')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>V</kbd></td>
<td>${t.__('Paste')}</td>
</tr>
<tr>
<td><kbd>Shift</kbd><kbd>${userOSKey}</kbd><kbd>V</kbd></td>
<td>${t.__('Paste and Match Style')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>A</kbd></td>
<td>${t.__('Select All')}</td>
</tr>
<tr>
<td><kbd>Control</kbd><kbd>${userOSKey}</kbd><kbd>Space</kbd></td>
<td>${t.__('Emoji & Symbols')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__('View Shortcuts')}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>R</kbd></td>
<td>${t.__('Reload')}</td>
</tr>
<tr>
<td><kbd>Shift</kbd><kbd>${userOSKey}</kbd><kbd>R</kbd></td>
<td>${t.__('Hard Reload')}</td>
</tr>
<tr>
<td><kbd>Control</kbd><kbd>${userOSKey}</kbd><kbd>F</kbd></td>
<td>${t.__('Enter Full Screen')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>+</kbd></td>
<td>${t.__('Zoom In')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>-</kbd></td>
<td>${t.__('Zoom Out')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>0</kbd></td>
<td>${t.__('Actual Size')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>Shift</kbd> + <kbd>S</kbd></td>
<td>${t.__('Toggle Sidebar')}</td>
</tr>
<tr>
<td><kbd>Option</kbd><kbd>${userOSKey}</kbd><kbd>I</kbd></td>
<td>${t.__('Toggle DevTools for Zulip App')}</td>
</tr>
<tr>
<td><kbd>Option</kbd><kbd>${userOSKey}</kbd><kbd>U</kbd></td>
<td>${t.__('Toggle DevTools for Active Tab')}</td>
</tr>
<tr>
<td><kbd>Ctrl</kbd> + <kbd>Tab</kbd></td>
<td>${t.__('Switch to Next Organization')}</td>
</tr>
<tr>
<td><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>Tab</kbd></td>
<td>${t.__('Switch to Previous Organization')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__('History Shortcuts')}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>←</kbd></td>
<td>${t.__('Back')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>→</kbd></td>
<td>${t.__('Forward')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">Window Shortcuts</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>M</kbd></td>
<td>${t.__('Minimize')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd><kbd>W</kbd></td>
<td>${t.__('Close')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
</div>
`;
}
templateWinLin(): string {
const userOSKey = 'Ctrl';
return `
<div class="settings-pane">
<div class="settings-card tip"><p><b><i class="material-icons md-14">settings</i>${t.__('Tip')}: </b>${t.__('These desktop app shortcuts extend the Zulip webapp\'s')} <span id="open-hotkeys-link"> ${t.__('keyboard shortcuts')}</span>.</p></div>
<div class="title">${t.__('Application Shortcuts')}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>,</kbd></td>
<td>${t.__('Settings')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>K</kbd></td>
<td>${t.__('Keyboard Shortcuts')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>Shift</kbd> + <kbd>M</kbd></td>
<td>${t.__('Toggle Do Not Disturb')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>L</kbd></td>
<td>${t.__('Log Out')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>Q</kbd></td>
<td>${t.__('Quit Zulip')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__('Edit Shortcuts')}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>Z</kbd></td>
<td>${t.__('Undo')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>Y</kbd></td>
<td>${t.__('Redo')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>X</kbd></td>
<td>${t.__('Cut')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>C</kbd></td>
<td>${t.__('Copy')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>V</kbd></td>
<td>${t.__('Paste')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>Shift</kbd> + <kbd>V</kbd></td>
<td>${t.__('Paste and Match Style')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>A</kbd></td>
<td>${t.__('Select All')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__('View Shortcuts')}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>R</kbd></td>
<td>${t.__('Reload')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>Shift</kbd> + <kbd>R</kbd></td>
<td>${t.__('Hard Reload')}</td>
</tr>
<tr>
<td><kbd>F11</kbd></td>
<td>${t.__('Toggle Full Screen')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>=</kbd></td>
<td>${t.__('Zoom In')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>-</kbd></td>
<td>${t.__('Zoom Out')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>0</kbd></td>
<td>${t.__('Actual Size')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>Shift</kbd> + <kbd>S</kbd></td>
<td>${t.__('Toggle Sidebar')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd></td>
<td>${t.__('Toggle DevTools for Zulip App')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>Shift</kbd> + <kbd>U</kbd></td>
<td>${t.__('Toggle DevTools for Active Tab')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>Tab</kbd></td>
<td>${t.__('Switch to Next Organization')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>Shift</kbd> + <kbd>Tab</kbd></td>
<td>${t.__('Switch to Previous Organization')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__('History Shortcuts')}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>Alt</kbd> + <kbd>←</kbd></td>
<td>${t.__('Back')}</td>
</tr>
<tr>
<td><kbd>Alt</kbd> + <kbd>→</kbd></td>
<td>${t.__('Forward')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__('Window Shortcuts')}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>M</kbd></td>
<td>${t.__('Minimize')}</td>
</tr>
<tr>
<td><kbd>${userOSKey}</kbd> + <kbd>W</kbd></td>
<td>${t.__('Close')}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
</div>
`;
}
openHotkeysExternalLink(): void {
const link = 'https://zulipchat.com/help/keyboard-shortcuts';
const externalCreateNewOrgEl = document.querySelector('#open-hotkeys-link');
externalCreateNewOrgEl.addEventListener('click', () => {
shell.openExternal(link);
});
}
init(): void {
this.props.$root.innerHTML = (process.platform === 'darwin') ?
this.templateMac() : this.templateWinLin();
this.openHotkeysExternalLink();
}
} }
// eslint-disable-next-line complexity export = ShortcutsSection;
export function initShortcutsSection(props: ShortcutsSectionProps): void {
const cmdOrCtrl = process.platform === "darwin" ? "⌘" : "Ctrl";
props.$root.innerHTML = html`
<div class="settings-pane">
<div class="settings-card tip">
<p>
<b><i class="material-icons md-14">settings</i>${t.__("Tip")}: </b
>${t.__("These desktop app shortcuts extend the Zulip webapp's")}
<span id="open-hotkeys-link"> ${t.__("keyboard shortcuts")}</span>.
</p>
</div>
<div class="title">${t.__("Application Shortcuts")}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>,</kbd></td>
<td>${t.__("Settings")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>K</kbd></td>
<td>${t.__("Keyboard Shortcuts")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>M</kbd></td>
<td>${t.__("Toggle Do Not Disturb")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>M</kbd></td>
<td>${t.__("Toggle Do Not Disturb")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>D</kbd></td>
<td>${t.__("Reset App Settings")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>L</kbd></td>
<td>${t.__("Log Out")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>H</kbd></td>
<td>${t.__("Hide Zulip")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Option</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>H</kbd></td>
<td>${t.__("Hide Others")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Q</kbd></td>
<td>${t.__("Quit Zulip")}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__("Edit Shortcuts")}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Z</kbd></td>
<td>${t.__("Undo")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>Z</kbd></td>
<td>${t.__("Redo")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Y</kbd></td>
<td>${t.__("Redo")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>X</kbd></td>
<td>${t.__("Cut")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>C</kbd></td>
<td>${t.__("Copy")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>V</kbd></td>
<td>${t.__("Paste")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>V</kbd></td>
<td>${t.__("Paste and Match Style")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>V</kbd></td>
<td>${t.__("Paste and Match Style")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>A</kbd></td>
<td>${t.__("Select All")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td>
<kbd>Control</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>Space</kbd>
</td>
<td>${t.__("Emoji & Symbols")}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__("View Shortcuts")}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>R</kbd></td>
<td>${t.__("Reload")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>R</kbd></td>
<td>${t.__("Hard Reload")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>R</kbd></td>
<td>${t.__("Hard Reload")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>F11</kbd></td>
<td>${t.__("Toggle Full Screen")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Control</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>F</kbd></td>
<td>${t.__("Enter Full Screen")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>+</kbd></td>
<td>${t.__("Zoom In")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>=</kbd></td>
<td>${t.__("Zoom In")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>-</kbd></td>
<td>${t.__("Zoom Out")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>0</kbd></td>
<td>${t.__("Actual Size")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>S</kbd></td>
<td>${t.__("Toggle Sidebar")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Shift</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>S</kbd></td>
<td>${t.__("Toggle Sidebar")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Option</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>I</kbd></td>
<td>${t.__("Toggle DevTools for Zulip App")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd></td>
<td>${t.__("Toggle DevTools for Zulip App")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>Option</kbd> + <kbd>${cmdOrCtrl}</kbd> + <kbd>U</kbd></td>
<td>${t.__("Toggle DevTools for Active Tab")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>Shift</kbd> + <kbd>U</kbd></td>
<td>${t.__("Toggle DevTools for Active Tab")}</td>
</tr>
<tr>
<td><kbd>Ctrl</kbd> + <kbd>Tab</kbd></td>
<td>${t.__("Switch to Next Organization")}</td>
</tr>
<tr>
<td><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>Tab</kbd></td>
<td>${t.__("Switch to Previous Organization")}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__("History Shortcuts")}</div>
<div class="settings-card">
<table>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>←</kbd></td>
<td>${t.__("Back")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>Alt</kbd> + <kbd>←</kbd></td>
<td>${t.__("Back")}</td>
</tr>
<tr ${process.platform === "darwin" ? "" : "hidden"}>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>→</kbd></td>
<td>${t.__("Forward")}</td>
</tr>
<tr ${process.platform === "darwin" ? "hidden" : ""}>
<td><kbd>Alt</kbd> + <kbd>→</kbd></td>
<td>${t.__("Forward")}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
<div class="title">${t.__("Window Shortcuts")}</div>
<div class="settings-card">
<table>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>M</kbd></td>
<td>${t.__("Minimize")}</td>
</tr>
<tr>
<td><kbd>${cmdOrCtrl}</kbd> + <kbd>W</kbd></td>
<td>${t.__("Close")}</td>
</tr>
</table>
<div class="setting-control"></div>
</div>
</div>
`.html;
const link = "https://zulip.com/help/keyboard-shortcuts";
const externalCreateNewOrgElement =
document.querySelector("#open-hotkeys-link")!;
externalCreateNewOrgElement.addEventListener("click", async () => {
await LinkUtil.openBrowser(new URL(link));
});
}

View File

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

View File

@@ -0,0 +1,17 @@
'use strict';
// This is a security fix. Following function prevents drag and drop event in the app
// so that attackers can't execute any remote code within the app
// It doesn't affect the compose box so that users can still
// use drag and drop event to share files etc
const preventDragAndDrop = (): void => {
const preventEvents = ['dragover', 'drop'];
preventEvents.forEach(dragEvents => {
document.addEventListener(dragEvents, event => {
event.preventDefault();
});
});
};
preventDragAndDrop();

View File

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

View File

@@ -1,237 +1,220 @@
import type {NativeImage, WebviewTag} from "electron"; 'use strict';
import {remote} from "electron"; import { ipcRenderer, remote, WebviewTag, NativeImage } from 'electron';
import path from "path";
import * as ConfigUtil from "../../common/config-util"; import path = require('path');
import type {RendererMessage} from "../../common/typed-ipc"; import ConfigUtil = require('./utils/config-util.js');
const { Tray, Menu, nativeImage, BrowserWindow } = remote;
import {ipcRenderer} from "./typed-ipc-renderer"; const APP_ICON = path.join(__dirname, '../../resources/tray', 'tray');
const {Tray, Menu, nativeImage, BrowserWindow} = remote; declare let window: ZulipWebWindow;
let tray: Electron.Tray | null = null;
const ICON_DIR = "../../resources/tray";
const TRAY_SUFFIX = "tray";
const APP_ICON = path.join(__dirname, ICON_DIR, TRAY_SUFFIX);
const iconPath = (): string => { const iconPath = (): string => {
if (process.platform === "linux") { if (process.platform === 'linux') {
return APP_ICON + "linux.png"; return APP_ICON + 'linux.png';
} }
return APP_ICON + (process.platform === 'win32' ? 'win.ico' : 'osx.png');
return (
APP_ICON + (process.platform === "win32" ? "win.ico" : "macOSTemplate.png")
);
}; };
const winUnreadTrayIconPath = (): string => APP_ICON + "unread.ico";
let unread = 0; let unread = 0;
const trayIconSize = (): number => { const trayIconSize = (): number => {
switch (process.platform) { switch (process.platform) {
case "darwin": case 'darwin':
return 20; return 20;
case "win32": case 'win32':
return 100; return 100;
case "linux": case 'linux':
return 100; return 100;
default: default: return 80;
return 80; }
}
}; };
// Default config for Icon we might make it OS specific if needed like the size // Default config for Icon we might make it OS specific if needed like the size
const config = { const config = {
pixelRatio: window.devicePixelRatio, pixelRatio: window.devicePixelRatio,
unreadCount: 0, unreadCount: 0,
showUnreadCount: true, showUnreadCount: true,
unreadColor: "#000000", unreadColor: '#000000',
readColor: "#000000", readColor: '#000000',
unreadBackgroundColor: "#B9FEEA", unreadBackgroundColor: '#B9FEEA',
readBackgroundColor: "#B9FEEA", readBackgroundColor: '#B9FEEA',
size: trayIconSize(), size: trayIconSize(),
thick: process.platform === "win32", thick: process.platform === 'win32'
}; };
const renderCanvas = function (arg: number): HTMLCanvasElement { const renderCanvas = function (arg: number): Promise<HTMLCanvasElement> {
config.unreadCount = arg; config.unreadCount = arg;
const SIZE = config.size * config.pixelRatio; return new Promise(resolve => {
const PADDING = SIZE * 0.05; const SIZE = config.size * config.pixelRatio;
const CENTER = SIZE / 2; const PADDING = SIZE * 0.05;
const HAS_COUNT = config.showUnreadCount && config.unreadCount; const CENTER = SIZE / 2;
const color = config.unreadCount ? config.unreadColor : config.readColor; const HAS_COUNT = config.showUnreadCount && config.unreadCount;
const backgroundColor = config.unreadCount const color = config.unreadCount ? config.unreadColor : config.readColor;
? config.unreadBackgroundColor const backgroundColor = config.unreadCount ? config.unreadBackgroundColor : config.readBackgroundColor;
: config.readBackgroundColor;
const canvas = document.createElement("canvas"); const canvas = document.createElement('canvas');
canvas.width = SIZE; canvas.width = SIZE;
canvas.height = SIZE; canvas.height = SIZE;
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext('2d');
// Circle // Circle
// If (!config.thick || config.thick && HAS_COUNT) { // If (!config.thick || config.thick && HAS_COUNT) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(CENTER, CENTER, SIZE / 2 - PADDING, 0, 2 * Math.PI, false); ctx.arc(CENTER, CENTER, (SIZE / 2) - PADDING, 0, 2 * Math.PI, false);
ctx.fillStyle = backgroundColor; ctx.fillStyle = backgroundColor;
ctx.fill(); ctx.fill();
ctx.lineWidth = SIZE / (config.thick ? 10 : 20); ctx.lineWidth = SIZE / (config.thick ? 10 : 20);
ctx.strokeStyle = backgroundColor; ctx.strokeStyle = backgroundColor;
ctx.stroke(); ctx.stroke();
// Count or Icon // Count or Icon
if (HAS_COUNT) { if (HAS_COUNT) {
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.textAlign = "center"; ctx.textAlign = 'center';
if (config.unreadCount > 99) { if (config.unreadCount > 99) {
ctx.font = `${config.thick ? "bold " : ""}${SIZE * 0.4}px Helvetica`; ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.4}px Helvetica`;
ctx.fillText("99+", CENTER, CENTER + SIZE * 0.15); ctx.fillText('99+', CENTER, CENTER + (SIZE * 0.15));
} else if (config.unreadCount < 10) { } else if (config.unreadCount < 10) {
ctx.font = `${config.thick ? "bold " : ""}${SIZE * 0.5}px Helvetica`; ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), CENTER, CENTER + SIZE * 0.2); ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.20));
} else { } else {
ctx.font = `${config.thick ? "bold " : ""}${SIZE * 0.5}px Helvetica`; ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), CENTER, CENTER + SIZE * 0.15); ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.15));
} }
}
return canvas; resolve(canvas);
}
});
}; };
/** /**
* Renders the tray icon as a native image * Renders the tray icon as a native image
* @param arg: Unread count * @param arg: Unread count
* @return the native image * @return the native image
*/ */
const renderNativeImage = function (arg: number): NativeImage { const renderNativeImage = function (arg: number): Promise<NativeImage> {
if (process.platform === "win32") { return Promise.resolve()
return nativeImage.createFromPath(winUnreadTrayIconPath()); .then(() => renderCanvas(arg))
} .then(canvas => {
const pngData = nativeImage.createFromDataURL(canvas.toDataURL('image/png')).toPNG();
const canvas = renderCanvas(arg); // TODO: Fix the function to correctly use Promise correctly.
const pngData = nativeImage // the Promise.resolve().then(...) above is useless we should
.createFromDataURL(canvas.toDataURL("image/png")) // start with renderCanvas(arg).then
.toPNG(); // eslint-disable-next-line promise/no-return-wrap
return nativeImage.createFromBuffer(pngData, { return Promise.resolve(nativeImage.createFromBuffer(pngData, {
scaleFactor: config.pixelRatio, scaleFactor: config.pixelRatio
}); }));
});
}; };
function sendAction<Channel extends keyof RendererMessage>( function sendAction(action: string): void {
channel: Channel, const win = BrowserWindow.getAllWindows()[0];
...args: Parameters<RendererMessage[Channel]>
): void {
const win = BrowserWindow.getAllWindows()[0];
if (process.platform === "darwin") { if (process.platform === 'darwin') {
win.restore(); win.restore();
} }
ipcRenderer.sendTo(win.webContents.id, channel, ...args); win.webContents.send(action);
} }
const createTray = function (): void { const createTray = function (): void {
const contextMenu = Menu.buildFromTemplate([ window.tray = new Tray(iconPath());
{ const contextMenu = Menu.buildFromTemplate([
label: "Zulip", {
click() { label: 'Zulip',
ipcRenderer.send("focus-app"); click() {
}, ipcRenderer.send('focus-app');
}, }
{ },
label: "Settings", {
click() { label: 'Settings',
ipcRenderer.send("focus-app"); click() {
sendAction("open-settings"); ipcRenderer.send('focus-app');
}, sendAction('open-settings');
}, }
{ },
type: "separator", {
}, type: 'separator'
{ },
label: "Quit", {
click() { label: 'Quit',
ipcRenderer.send("quit-app"); click() {
}, ipcRenderer.send('quit-app');
}, }
]); }
tray = new Tray(iconPath()); ]);
tray.setContextMenu(contextMenu); window.tray.setContextMenu(contextMenu);
if (process.platform === "linux" || process.platform === "win32") { if (process.platform === 'linux' || process.platform === 'win32') {
tray.on("click", () => { window.tray.on('click', () => {
ipcRenderer.send("toggle-app"); ipcRenderer.send('toggle-app');
}); });
} }
}; };
ipcRenderer.on("destroytray", (_event: Event) => { ipcRenderer.on('destroytray', (event: Event): Event => {
if (!tray) { if (!window.tray) {
return; return undefined;
} }
tray.destroy(); window.tray.destroy();
if (tray.isDestroyed()) { if (window.tray.isDestroyed()) {
tray = null; window.tray = null;
} else { } else {
throw new Error("Tray icon not properly destroyed."); throw new Error('Tray icon not properly destroyed.');
} }
return event;
}); });
ipcRenderer.on("tray", (_event: Event, arg: number): void => { ipcRenderer.on('tray', (_event: Event, arg: number): void => {
if (!tray) { if (!window.tray) {
return; return;
} }
// We don't want to create tray from unread messages on macOS since it already has dock badges.
// We don't want to create tray from unread messages on macOS since it already has dock badges. if (process.platform === 'linux' || process.platform === 'win32') {
if (process.platform === "linux" || process.platform === "win32") { if (arg === 0) {
if (arg === 0) { unread = arg;
unread = arg; window.tray.setImage(iconPath());
tray.setImage(iconPath()); window.tray.setToolTip('No unread messages');
tray.setToolTip("No unread messages"); } else {
} else { unread = arg;
unread = arg; renderNativeImage(arg).then(image => {
const image = renderNativeImage(arg); window.tray.setImage(image);
tray.setImage(image); window.tray.setToolTip(arg + ' unread messages');
tray.setToolTip(`${arg} unread messages`); });
} }
} }
}); });
function toggleTray(): void { function toggleTray(): void {
let state; let state;
if (tray) { if (window.tray) {
state = false; state = false;
tray.destroy(); window.tray.destroy();
if (tray.isDestroyed()) { if (window.tray.isDestroyed()) {
tray = null; window.tray = null;
} }
ConfigUtil.setConfigItem('trayIcon', false);
ConfigUtil.setConfigItem("trayIcon", false); } else {
} else { state = true;
state = true; createTray();
createTray(); if (process.platform === 'linux' || process.platform === 'win32') {
if (process.platform === "linux" || process.platform === "win32") { renderNativeImage(unread).then(image => {
const image = renderNativeImage(unread); window.tray.setImage(image);
tray!.setImage(image); window.tray.setToolTip(unread + ' unread messages');
tray!.setToolTip(`${unread} unread messages`); });
} }
ConfigUtil.setConfigItem('trayIcon', true);
ConfigUtil.setConfigItem("trayIcon", true); }
} const selector = 'webview:not([class*=disabled])';
const webview: WebviewTag = document.querySelector(selector);
const selector = "webview:not([class*=disabled])"; const webContents = webview.getWebContents();
const webview: WebviewTag = document.querySelector(selector)!; webContents.send('toggletray', state);
ipcRenderer.sendTo(webview.getWebContentsId(), "toggle-tray", state);
} }
ipcRenderer.on("toggletray", toggleTray); ipcRenderer.on('toggletray', toggleTray);
if (ConfigUtil.getConfigItem("trayIcon", true)) { if (ConfigUtil.getConfigItem('trayIcon', true)) {
createTray(); createTray();
} }
export {};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
import fs = require('fs');
let app: Electron.App = null;
let setupCompleted = false;
if (process.type === 'renderer') {
app = require('electron').remote.app;
} else {
app = require('electron').app;
}
const zulipDir = app.getPath('userData');
const logDir = `${zulipDir}/Logs/`;
const certificatesDir = `${zulipDir}/certificates/`;
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);
}
if (!fs.existsSync(certificatesDir)) {
fs.mkdirSync(certificatesDir);
}
// 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 certificatesJson = `${zulipDir}/certificates.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: certificatesJson,
fileName: `certificates.json`
},
{
path: settingsJson,
fileName: `settings.json`
},
{
path: updatesJson,
fileName: `updates.json`
}
];
configData.forEach(data => {
if (fs.existsSync(data.path)) {
fs.copyFileSync(data.path, configDir + data.fileName);
fs.unlinkSync(data.path);
}
});
// window-state.json is only deleted not moved, as the electron-window-state
// package will recreate the file in the config folder.
if (fs.existsSync(windowStateJson)) {
fs.unlinkSync(windowStateJson);
}
}
setupCompleted = true;
}
};

View File

@@ -0,0 +1,50 @@
'use strict';
import ConfigUtil = require('./config-util');
// TODO: TypeScript - add to Setting interface
// the list of settings since we have fixed amount of them
// We want to do this by creating a new module that exports
// this interface
interface Setting {
[key: string]: any;
}
interface Toggle {
dnd: boolean;
newSettings: Setting;
}
export function toggle(): Toggle {
const dnd = !ConfigUtil.getConfigItem('dnd', false);
const dndSettingList = ['showNotification', 'silent'];
if (process.platform === 'win32') {
dndSettingList.push('flashTaskbarOnMessage');
}
let newSettings: Setting;
if (dnd) {
const oldSettings: Setting = {};
newSettings = {};
// Iterate through the dndSettingList.
for (const settingName of dndSettingList) {
// Store the current value of setting.
oldSettings[settingName] = ConfigUtil.getConfigItem(settingName);
// New value of setting.
newSettings[settingName] = (settingName === 'silent');
}
// Store old value in oldSettings.
ConfigUtil.setConfigItem('dndPreviousSettings', oldSettings);
} else {
newSettings = ConfigUtil.getConfigItem('dndPreviousSettings');
}
for (const settingName of dndSettingList) {
ConfigUtil.setConfigItem(settingName, newSettings[settingName]);
}
ConfigUtil.setConfigItem('dnd', dnd);
return {dnd, newSettings};
}

View File

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

View File

@@ -0,0 +1,80 @@
import fs = require('fs');
import path = require('path');
import Logger = require('./logger-util');
const logger = new Logger({
file: 'enterprise-util.log',
timestamp: true
});
let instance: null | EnterpriseUtil = null;
class EnterpriseUtil {
// todo: replace enterpriseSettings type with an interface once settings are final
enterpriseSettings: any;
configFile: boolean;
constructor() {
if (instance) {
return instance;
}
instance = this;
this.reloadDB();
}
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)) {
this.configFile = true;
try {
const file = fs.readFileSync(enterpriseFile, 'utf8');
this.enterpriseSettings = JSON.parse(file);
} catch (err) {
logger.log('Error while JSON parsing global_config.json: ');
logger.log(err);
}
} else {
this.configFile = false;
}
}
getConfigItem(key: string, defaultValue?: any): any {
this.reloadDB();
if (!this.configFile) {
return defaultValue;
}
if (defaultValue === undefined) {
defaultValue = null;
}
return this.configItemExists(key) ? this.enterpriseSettings[key] : defaultValue;
}
configItemExists(key: string): boolean {
this.reloadDB();
if (!this.configFile) {
return false;
}
return (this.enterpriseSettings[key] !== undefined);
}
isPresetOrg(url: string): boolean {
if (!this.configFile || !this.configItemExists('presetOrganizations')) {
return false;
}
const presetOrgs = this.enterpriseSettings.presetOrganizations;
for (const org of presetOrgs) {
if (url.includes(org)) {
return true;
}
}
return false;
}
}
export = new EnterpriseUtil();

View File

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

View File

@@ -0,0 +1,76 @@
'use strict';
import JsonDB from 'node-json-db';
import fs = require('fs');
import path = require('path');
import electron = require('electron');
import Logger = require('./logger-util');
const remote =
process.type === 'renderer' ? electron.remote : electron;
const logger = new Logger({
file: 'linux-update-util.log',
timestamp: true
});
/* To make the util runnable in both main and renderer process */
const { dialog, app } = remote;
let instance: null | LinuxUpdateUtil = null;
class LinuxUpdateUtil {
db: JsonDB;
constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
this.reloadDB();
return instance;
}
getUpdateItem(key: string, defaultValue: any = null): any {
this.reloadDB();
const value = this.db.getData('/')[key];
if (value === undefined) {
this.setUpdateItem(key, defaultValue);
return defaultValue;
} else {
return value;
}
}
setUpdateItem(key: string, value: any): void {
this.db.push(`/${key}`, value, true);
this.reloadDB();
}
removeUpdateItem(key: string): void {
this.db.delete(`/${key}`);
this.reloadDB();
}
reloadDB(): void {
const linuxUpdateJsonPath = path.join(app.getPath('userData'), '/config/updates.json');
try {
const file = fs.readFileSync(linuxUpdateJsonPath, 'utf8');
JSON.parse(file);
} catch (err) {
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(err);
}
}
this.db = new JsonDB(linuxUpdateJsonPath, true, true);
}
}
export = new LinuxUpdateUtil();

View File

@@ -0,0 +1,155 @@
import { Console as NodeConsole } from 'console'; // eslint-disable-line node/prefer-global/console
import { initSetUp } from './default-util';
import { sentryInit, captureException } from './sentry-util';
import fs = require('fs');
import os = require('os');
import isDev = require('electron-is-dev');
import electron = require('electron');
// this interface adds [key: string]: any so
// we can do console[type] later on in the code
interface PatchedConsole extends Console {
[key: string]: any;
}
interface LoggerOptions {
timestamp?: any;
file?: string;
level?: boolean;
logInDevMode?: boolean;
}
initSetUp();
let app: Electron.App = null;
let reportErrors = true;
if (process.type === 'renderer') {
app = electron.remote.app;
// Report Errors to Sentry only if it is enabled in settings
// Gets the value of reportErrors from config-util for renderer process
// For main process, sentryInit() is handled in index.js
const { ipcRenderer } = electron;
ipcRenderer.send('error-reporting');
ipcRenderer.on('error-reporting-val', (_event: any, errorReporting: boolean) => {
reportErrors = errorReporting;
if (reportErrors) {
sentryInit();
}
});
} else {
app = electron.app;
}
const browserConsole: PatchedConsole = console;
const logDir = `${app.getPath('userData')}/Logs`;
class Logger {
nodeConsole: PatchedConsole;
timestamp: any; // TODO: TypeScript - Figure out how to make this work with string | Function.
level: boolean;
logInDevMode: boolean;
[key: string]: any;
constructor(opts: LoggerOptions = {}) {
let {
timestamp = true,
file = 'console.log',
level = true,
logInDevMode = false
} = opts;
file = `${logDir}/${file}`;
if (timestamp === true) {
timestamp = this.getTimestamp;
}
// Trim log according to type of process
if (process.type === 'renderer') {
requestIdleCallback(() => this.trimLog(file));
} else {
process.nextTick(() => this.trimLog(file));
}
const fileStream = fs.createWriteStream(file, { flags: 'a' });
const nodeConsole = new NodeConsole(fileStream);
this.nodeConsole = nodeConsole;
this.timestamp = timestamp;
this.level = level;
this.logInDevMode = logInDevMode;
this.setUpConsole();
}
_log(type: string, ...args: any[]): void {
const {
nodeConsole, timestamp, level, logInDevMode
} = this;
let nodeConsoleLog;
/* eslint-disable no-fallthrough */
switch (true) {
case typeof timestamp === 'function':
args.unshift(timestamp() + ' |\t');
case (level !== false):
args.unshift(type.toUpperCase() + ' |');
case isDev || logInDevMode:
nodeConsoleLog = nodeConsole[type] || nodeConsole.log;
nodeConsoleLog.apply(null, args); // eslint-disable-line prefer-spread
default: break;
}
/* eslint-enable no-fallthrough */
browserConsole[type].apply(null, args);
}
setUpConsole(): void {
for (const type in browserConsole) {
this.setupConsoleMethod(type);
}
}
setupConsoleMethod(type: string): void {
this[type] = (...args: any[]) => {
const log = this._log.bind(this, type, ...args);
log();
};
}
getTimestamp(): string {
const date = new Date();
const timestamp =
`${date.getMonth()}/${date.getDate()} ` +
`${date.getMinutes()}:${date.getSeconds()}`;
return timestamp;
}
reportSentry(err: string): void {
if (reportErrors) {
captureException(err);
}
}
trimLog(file: string): void{
fs.readFile(file, 'utf8', (err, data) => {
if (err) {
throw err;
}
const MAX_LOG_FILE_LINES = 500;
const logs = data.split(os.EOL);
const logLength = logs.length - 1;
// Keep bottom MAX_LOG_FILE_LINES of each log instance
if (logLength > MAX_LOG_FILE_LINES) {
const trimmedLogs = logs.slice(logLength - MAX_LOG_FILE_LINES);
const toWrite = trimmedLogs.join(os.EOL);
fs.writeFileSync(file, toWrite);
}
});
}
}
export = Logger;

View File

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

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