Compare commits
	
		
			197 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | e90f3732c5 | ||
|  | 6b31a8a0c4 | ||
|  | f8758fa303 | ||
|  | d2de965106 | ||
|  | a32119b55d | ||
|  | 58049a91c4 | ||
|  | 9810d69c3b | ||
|  | d2f949d683 | ||
|  | a8c283a50b | ||
|  | dab29d4720 | ||
|  | 7fba8cfae9 | ||
|  | 32301656cc | ||
|  | 0e16283a37 | ||
|  | d86482a804 | ||
|  | 3af350e4dc | ||
|  | 39fc2053c5 | ||
|  | 044f1fd0f9 | ||
|  | 10fb0a82f9 | ||
|  | 123bd5b2c0 | ||
|  | ad771c3da8 | ||
|  | 4c58bc3aa3 | ||
|  | 9a8680d209 | ||
|  | 1569890f4d | ||
|  | 2ed400c23c | ||
|  | 70621431dc | ||
|  | 55b7e09796 | ||
|  | de2829a968 | ||
|  | 296de41779 | ||
|  | 8b9ebeee25 | ||
|  | 76e81ca337 | ||
|  | 2e7a9bb4ed | ||
|  | 77638f6287 | ||
|  | 6e8fe36876 | ||
|  | 2eea4a32a5 | ||
|  | 677dfe425c | ||
|  | 1da3ec545a | ||
|  | 3cb6ea4694 | ||
|  | 0cb7297017 | ||
|  | b8d7003446 | ||
|  | 6d27cf8c7d | ||
|  | 1ac2483cc4 | ||
|  | 4d3420dcd0 | ||
|  | 38450a9aed | ||
|  | 24de7ebb97 | ||
|  | 5a571d66d0 | ||
|  | 0ae998a51e | ||
|  | 447dd18b8b | ||
|  | 9a200dc40c | ||
|  | d42b752ac1 | ||
|  | 2f4103248d | ||
|  | 985d731d2b | ||
|  | 032f95150c | ||
|  | d1aa5778c3 | ||
|  | 13ce24b75e | ||
|  | c89ec2faf1 | ||
|  | 56ab0833b8 | ||
|  | c62b393c52 | ||
|  | 991de77cad | ||
|  | 94780c44c8 | ||
|  | 82542a6390 | ||
|  | 53ff8443dc | ||
|  | 3855ecab58 | ||
|  | a57cbb4aa8 | ||
|  | 56a4461c2a | ||
|  | cd023ec5ab | ||
|  | 1aa4ade3c0 | ||
|  | dcb46eef4f | ||
|  | e3e8ef6e3e | ||
|  | 6808b1971a | ||
|  | 1dd5269549 | ||
|  | d33adca1e8 | ||
|  | 8ea7f7864f | ||
|  | 493ae06e52 | ||
|  | 2b8f3536d3 | ||
|  | 544d23ec09 | ||
|  | 588d32fd22 | ||
|  | 1c471fe624 | ||
|  | 52486d687d | ||
|  | 73441d791c | ||
|  | 1bb6423721 | ||
|  | d6775d64a3 | ||
|  | e1326eae91 | ||
|  | b93955b28f | ||
|  | e3452bda22 | ||
|  | 0aab691b44 | ||
|  | 1bfb2dd975 | ||
|  | fb7937314b | ||
|  | e39d2a9b95 | ||
|  | 3b04b61662 | ||
|  | 829b2a0f2a | ||
|  | 5edffbdf21 | ||
|  | 27576c95e6 | ||
|  | 5acc45cba4 | ||
|  | 343e0ed848 | ||
|  | 0c784b12fa | ||
|  | 2b50b21752 | ||
|  | ad604f020d | ||
|  | 4151e020f6 | ||
|  | bc59714192 | ||
|  | b43a7b6809 | ||
|  | fba8aa0ab0 | ||
|  | 5623ab3866 | ||
|  | a4fbf9bd28 | ||
|  | db730da45c | ||
|  | b5a938d3b0 | ||
|  | 863d1e25ba | ||
|  | a90aaeb86c | ||
|  | 8b6af78f2a | ||
|  | 6c2dcb450b | ||
|  | f57962d02f | ||
|  | 2983c381ae | ||
|  | 1ea7fa813a | ||
|  | e434c5b5d0 | ||
|  | 9c1f47badd | ||
|  | 4ed4328bf8 | ||
|  | c6022e94bb | ||
|  | 06eb169c65 | ||
|  | 2f7529cd71 | ||
|  | 3a8541f601 | ||
|  | 0eb910b2e8 | ||
|  | 76a879e4fd | ||
|  | 7026e43575 | ||
|  | 869361bac3 | ||
|  | 832ea3c04e | ||
|  | 68232f966e | ||
|  | 86b7da45ef | ||
|  | b853856317 | ||
|  | 6676f1c6ac | ||
|  | e0243bc460 | ||
|  | fd6cb548f8 | ||
|  | 743b2d6054 | ||
|  | fb5c6b365e | ||
|  | f092e99f42 | ||
|  | 751eb6ef98 | ||
|  | 980de649e3 | ||
|  | 84849d2c84 | ||
|  | b263997bed | ||
|  | 12c773bc71 | ||
|  | d937539618 | ||
|  | 0a5d07f839 | ||
|  | 5dcd3956ac | ||
|  | 3ffc7251f4 | ||
|  | 7fb0cfd176 | ||
|  | 5c83952ba1 | ||
|  | a7a051bb2a | ||
|  | 2b2c5dbe5c | ||
|  | ffe87a9729 | ||
|  | b366195415 | ||
|  | f9f2b20e90 | ||
|  | e16811065d | ||
|  | f66a1127de | ||
|  | 06ef60c4c2 | ||
|  | 4b93298b58 | ||
|  | a41a771923 | ||
|  | a43f7d9bcf | ||
|  | c9453f877b | ||
|  | 525fa94b18 | ||
|  | 460b9e5e55 | ||
|  | 8fc41a7ca8 | ||
|  | 4c7b9cf4e3 | ||
|  | f4479dfda4 | ||
|  | 377f08ad5d | ||
|  | add43bafda | ||
|  | b35d45955b | ||
|  | 2ecb970da0 | ||
|  | edb2933dad | ||
|  | 8141927974 | ||
|  | 4db89ac3a7 | ||
|  | feb67e6c2d | ||
|  | 014e97b563 | ||
|  | a3f4e19aa2 | ||
|  | 90a65ab6cc | ||
|  | c00e1618e7 | ||
|  | ceb6417979 | ||
|  | 1d40ebb65f | ||
|  | 6301427ef4 | ||
|  | 64d1d6c88d | ||
|  | adcacd7d45 | ||
|  | b6729b0d0a | ||
|  | ec7d5b4046 | ||
|  | 380ea3a891 | ||
|  | 320e152897 | ||
|  | c00d0abe0d | ||
|  | aaa83da0f8 | ||
|  | 494e716dfe | ||
|  | 50c266295e | ||
|  | 55a6122a6c | ||
|  | 2a648b79c9 | ||
|  | 0bc49bf723 | ||
|  | cb7d1faa52 | ||
|  | fa3c744e76 | ||
|  | 54be4dccce | ||
|  | 6a407d0e42 | ||
|  | 47171fffd5 | ||
|  | e48c9067a3 | ||
|  | 1d30c83f7a | ||
|  | 9f76fb295e | 
							
								
								
									
										2
									
								
								.github/workflows/node.js.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -10,6 +10,6 @@ jobs: | ||||
|   build: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v2 | ||||
|       - uses: actions/checkout@v4 | ||||
|       - run: npm ci | ||||
|       - run: npm test | ||||
|   | ||||
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -8,7 +8,8 @@ | ||||
| .transifexrc | ||||
|  | ||||
| # Compiled binary build directory | ||||
| dist/ | ||||
| /dist/ | ||||
| /dist-electron/ | ||||
|  | ||||
| #snap generated files | ||||
| snap/parts | ||||
| @@ -39,6 +40,3 @@ config.gypi | ||||
| # tests/package.json | ||||
|  | ||||
| .python-version | ||||
|  | ||||
| # Ignore all the typescript compiled files | ||||
| app/**/*.js | ||||
|   | ||||
| @@ -1,3 +1,3 @@ | ||||
| /app/**/*.js | ||||
| /app/translations/*.json | ||||
| /dist | ||||
| /dist-electron | ||||
| /public/translations/*.json | ||||
|   | ||||
| @@ -1,4 +0,0 @@ | ||||
| *.js | ||||
| *.ts | ||||
| /app/translations/*.json | ||||
| /dist | ||||
							
								
								
									
										17
									
								
								.stylelintrc
									
									
									
									
									
								
							
							
						
						| @@ -1,9 +1,12 @@ | ||||
| { | ||||
|     "extends": ["stylelint-config-standard", "stylelint-config-prettier"], | ||||
|     "rules": { | ||||
|         "color-named": "never", | ||||
|         "color-no-hex": true, | ||||
|         "font-family-no-missing-generic-family-keyword": [true, {"ignoreFontFamilies": ["Material Icons"]}], | ||||
|         "selector-type-no-unknown": [true, {"ignoreTypes": ["send-feedback", "webview"]}], | ||||
|     } | ||||
|   "extends": ["stylelint-config-standard"], | ||||
|   "rules": { | ||||
|     "color-named": "never", | ||||
|     "color-no-hex": true, | ||||
|     "font-family-no-missing-generic-family-keyword": [ | ||||
|       true, | ||||
|       {"ignoreFontFamilies": ["Material Icons"]} | ||||
|     ], | ||||
|     "selector-type-no-unknown": [true, {"ignoreTypes": ["webview"]}] | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| [main] | ||||
| host = https://www.transifex.com | ||||
|  | ||||
| [zulip.desktopjson] | ||||
| file_filter = app/translations/<lang>.json | ||||
| [o:zulip:p:zulip:r:desktopjson] | ||||
| file_filter = public/translations/<lang>.json | ||||
| minimum_perc = 0 | ||||
| source_file = app/translations/en.json | ||||
| source_file = public/translations/en.json | ||||
| source_lang = en | ||||
| type = KEYVALUEJSON | ||||
|   | ||||
| @@ -10,7 +10,7 @@ Zulip-Desktop app is built on top of [Electron](http://electron.atom.io/). If yo | ||||
|  | ||||
| ## Community | ||||
|  | ||||
| - The whole Zulip documentation, such as setting up a development environment, setting up with the Zulip webapp project, and testing, can be read [here](https://zulip.readthedocs.io). | ||||
| - The whole Zulip documentation, such as setting up a development environment, setting up with the Zulip web app project, and testing, can be read [here](https://zulip.readthedocs.io). | ||||
|  | ||||
| - If you have any questions regarding zulip-desktop, open an [issue](https://github.com/zulip/zulip-desktop/issues/new/) or ask it on [chat.zulip.org](https://chat.zulip.org/#narrow/stream/16-desktop). | ||||
|  | ||||
|   | ||||
| @@ -24,9 +24,9 @@ Please see the [installation guide](https://zulip.com/help/desktop-app-install-g | ||||
|  | ||||
| # Reporting issues | ||||
|  | ||||
| This desktop client shares most of its code with the Zulip webapp. | ||||
| This desktop client shares most of its code with the Zulip web app. | ||||
| Issues in an individual organization's Zulip window should be reported | ||||
| in the [Zulip server and webapp | ||||
| in the [Zulip server and web app | ||||
| project](https://github.com/zulip/zulip/issues/new). Other | ||||
| issues in the desktop app and its settings should be reported [in this | ||||
| project](https://github.com/zulip/zulip-desktop/issues/new). | ||||
|   | ||||
							
								
								
									
										44
									
								
								app/common/config-schemata.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,44 @@ | ||||
| import {z} from "zod"; | ||||
|  | ||||
| export const dndSettingsSchemata = { | ||||
|   showNotification: z.boolean(), | ||||
|   silent: z.boolean(), | ||||
|   flashTaskbarOnMessage: z.boolean(), | ||||
| }; | ||||
|  | ||||
| export const configSchemata = { | ||||
|   ...dndSettingsSchemata, | ||||
|   appLanguage: z.string().nullable(), | ||||
|   autoHideMenubar: z.boolean(), | ||||
|   autoUpdate: z.boolean(), | ||||
|   badgeOption: z.boolean(), | ||||
|   betaUpdate: z.boolean(), | ||||
|   // eslint-disable-next-line @typescript-eslint/naming-convention | ||||
|   customCSS: z.string().or(z.literal(false)).nullable(), | ||||
|   dnd: z.boolean(), | ||||
|   dndPreviousSettings: z.object(dndSettingsSchemata).partial(), | ||||
|   dockBouncing: z.boolean(), | ||||
|   downloadsPath: z.string(), | ||||
|   enableSpellchecker: z.boolean(), | ||||
|   errorReporting: z.boolean(), | ||||
|   lastActiveTab: z.number(), | ||||
|   promptDownload: z.boolean(), | ||||
|   proxyBypass: z.string(), | ||||
|   // eslint-disable-next-line @typescript-eslint/naming-convention | ||||
|   proxyPAC: z.string(), | ||||
|   proxyRules: z.string(), | ||||
|   quitOnClose: z.boolean(), | ||||
|   showSidebar: z.boolean(), | ||||
|   spellcheckerLanguages: z.string().array().nullable(), | ||||
|   startAtLogin: z.boolean(), | ||||
|   startMinimized: z.boolean(), | ||||
|   trayIcon: z.boolean(), | ||||
|   useManualProxy: z.boolean(), | ||||
|   useProxy: z.boolean(), | ||||
|   useSystemProxy: z.boolean(), | ||||
| }; | ||||
|  | ||||
| export const enterpriseConfigSchemata = { | ||||
|   ...configSchemata, | ||||
|   presetOrganizations: z.string().array(), | ||||
| }; | ||||
| @@ -1,45 +1,19 @@ | ||||
| import electron from "electron"; | ||||
| import fs from "fs"; | ||||
| import path from "path"; | ||||
| import fs from "node:fs"; | ||||
| import path from "node:path"; | ||||
|  | ||||
| import * as Sentry from "@sentry/electron"; | ||||
| import {JsonDB} from "node-json-db"; | ||||
| import {DataError} from "node-json-db/dist/lib/Errors"; | ||||
| import type {z} from "zod"; | ||||
| import {app, dialog} from "zulip:remote"; | ||||
|  | ||||
| import type {DNDSettings} from "./dnd-util"; | ||||
| import * as EnterpriseUtil from "./enterprise-util"; | ||||
| import Logger from "./logger-util"; | ||||
| import {configSchemata} from "./config-schemata.js"; | ||||
| import * as EnterpriseUtil from "./enterprise-util.js"; | ||||
| import Logger from "./logger-util.js"; | ||||
|  | ||||
| export interface Config extends DNDSettings { | ||||
|   appLanguage: string | null; | ||||
|   autoHideMenubar: boolean; | ||||
|   autoUpdate: boolean; | ||||
|   badgeOption: boolean; | ||||
|   betaUpdate: boolean; | ||||
|   customCSS: string | false | null; | ||||
|   dnd: boolean; | ||||
|   dndPreviousSettings: Partial<DNDSettings>; | ||||
|   dockBouncing: boolean; | ||||
|   downloadsPath: string; | ||||
|   enableSpellchecker: boolean; | ||||
|   errorReporting: boolean; | ||||
|   lastActiveTab: number; | ||||
|   promptDownload: boolean; | ||||
|   proxyBypass: string; | ||||
|   proxyPAC: string; | ||||
|   proxyRules: string; | ||||
|   quitOnClose: boolean; | ||||
|   showSidebar: boolean; | ||||
|   spellcheckerLanguages: string[] | null; | ||||
|   startAtLogin: boolean; | ||||
|   startMinimized: boolean; | ||||
|   systemProxyRules: string; | ||||
|   trayIcon: boolean; | ||||
|   useManualProxy: boolean; | ||||
|   useProxy: boolean; | ||||
|   useSystemProxy: boolean; | ||||
| } | ||||
|  | ||||
| /* To make the util runnable in both main and renderer process */ | ||||
| const {app, dialog} = process.type === "renderer" ? electron.remote : electron; | ||||
| export type Config = { | ||||
|   [Key in keyof typeof configSchemata]: z.output<(typeof configSchemata)[Key]>; | ||||
| }; | ||||
|  | ||||
| const logger = new Logger({ | ||||
|   file: "config-util.log", | ||||
| @@ -47,12 +21,12 @@ const logger = new Logger({ | ||||
|  | ||||
| let db: JsonDB; | ||||
|  | ||||
| reloadDB(); | ||||
| reloadDb(); | ||||
|  | ||||
| export function getConfigItem<Key extends keyof Config>( | ||||
|   key: Key, | ||||
|   defaultValue: Config[Key], | ||||
| ): Config[Key] { | ||||
| ): z.output<(typeof configSchemata)[Key]> { | ||||
|   try { | ||||
|     db.reload(); | ||||
|   } catch (error: unknown) { | ||||
| @@ -60,13 +34,13 @@ export function getConfigItem<Key extends keyof Config>( | ||||
|     logger.error(error); | ||||
|   } | ||||
|  | ||||
|   const value = db.getData("/")[key]; | ||||
|   if (value === undefined) { | ||||
|   try { | ||||
|     return configSchemata[key].parse(db.getObject<unknown>(`/${key}`)); | ||||
|   } catch (error: unknown) { | ||||
|     if (!(error instanceof DataError)) throw error; | ||||
|     setConfigItem(key, defaultValue); | ||||
|     return defaultValue; | ||||
|   } | ||||
|  | ||||
|   return value; | ||||
| } | ||||
|  | ||||
| // This function returns whether a key exists in the configuration file (settings.json) | ||||
| @@ -78,8 +52,7 @@ export function isConfigItemExists(key: string): boolean { | ||||
|     logger.error(error); | ||||
|   } | ||||
|  | ||||
|   const value = db.getData("/")[key]; | ||||
|   return value !== undefined; | ||||
|   return db.exists(`/${key}`); | ||||
| } | ||||
|  | ||||
| export function setConfigItem<Key extends keyof Config>( | ||||
| @@ -92,6 +65,7 @@ export function setConfigItem<Key extends keyof Config>( | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   configSchemata[key].parse(value); | ||||
|   db.push(`/${key}`, value, true); | ||||
|   db.save(); | ||||
| } | ||||
| @@ -101,7 +75,7 @@ export function removeConfigItem(key: string): void { | ||||
|   db.save(); | ||||
| } | ||||
|  | ||||
| function reloadDB(): void { | ||||
| function reloadDb(): void { | ||||
|   const settingsJsonPath = path.join( | ||||
|     app.getPath("userData"), | ||||
|     "/config/settings.json", | ||||
| @@ -118,7 +92,7 @@ function reloadDB(): void { | ||||
|       ); | ||||
|       logger.error("Error while JSON parsing settings.json: "); | ||||
|       logger.error(error); | ||||
|       logger.reportSentry(error); | ||||
|       Sentry.captureException(error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import electron from "electron"; | ||||
| import fs from "fs"; | ||||
| import fs from "node:fs"; | ||||
|  | ||||
| const {app} = process.type === "renderer" ? electron.remote : electron; | ||||
| import {app} from "zulip:remote"; | ||||
|  | ||||
| let setupCompleted = false; | ||||
|  | ||||
|   | ||||
| @@ -1,17 +1,22 @@ | ||||
| import * as ConfigUtil from "./config-util"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| type SettingName = keyof DNDSettings; | ||||
| import type {z} from "zod"; | ||||
|  | ||||
| export interface DNDSettings { | ||||
|   showNotification: boolean; | ||||
|   silent: boolean; | ||||
|   flashTaskbarOnMessage: boolean; | ||||
| } | ||||
| import type {dndSettingsSchemata} from "./config-schemata.js"; | ||||
| import * as ConfigUtil from "./config-util.js"; | ||||
|  | ||||
| interface Toggle { | ||||
| export type DndSettings = { | ||||
|   [Key in keyof typeof dndSettingsSchemata]: z.output< | ||||
|     (typeof dndSettingsSchemata)[Key] | ||||
|   >; | ||||
| }; | ||||
|  | ||||
| type SettingName = keyof DndSettings; | ||||
|  | ||||
| type Toggle = { | ||||
|   dnd: boolean; | ||||
|   newSettings: Partial<DNDSettings>; | ||||
| } | ||||
|   newSettings: Partial<DndSettings>; | ||||
| }; | ||||
|  | ||||
| export function toggle(): Toggle { | ||||
|   const dnd = !ConfigUtil.getConfigItem("dnd", false); | ||||
| @@ -20,9 +25,9 @@ export function toggle(): Toggle { | ||||
|     dndSettingList.push("flashTaskbarOnMessage"); | ||||
|   } | ||||
|  | ||||
|   let newSettings: Partial<DNDSettings>; | ||||
|   let newSettings: Partial<DndSettings>; | ||||
|   if (dnd) { | ||||
|     const oldSettings: Partial<DNDSettings> = {}; | ||||
|     const oldSettings: Partial<DndSettings> = {}; | ||||
|     newSettings = {}; | ||||
|  | ||||
|     // Iterate through the dndSettingList. | ||||
|   | ||||
| @@ -1,12 +1,17 @@ | ||||
| import fs from "fs"; | ||||
| import path from "path"; | ||||
| import fs from "node:fs"; | ||||
| import path from "node:path"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| import type {Config} from "./config-util"; | ||||
| import Logger from "./logger-util"; | ||||
| import {z} from "zod"; | ||||
|  | ||||
| interface EnterpriseConfig extends Config { | ||||
|   presetOrganizations: string[]; | ||||
| } | ||||
| import {enterpriseConfigSchemata} from "./config-schemata.js"; | ||||
| import Logger from "./logger-util.js"; | ||||
|  | ||||
| type EnterpriseConfig = { | ||||
|   [Key in keyof typeof enterpriseConfigSchemata]: z.output< | ||||
|     (typeof enterpriseConfigSchemata)[Key] | ||||
|   >; | ||||
| }; | ||||
|  | ||||
| const logger = new Logger({ | ||||
|   file: "enterprise-util.log", | ||||
| @@ -15,9 +20,9 @@ const logger = new Logger({ | ||||
| let enterpriseSettings: Partial<EnterpriseConfig>; | ||||
| let configFile: boolean; | ||||
|  | ||||
| reloadDB(); | ||||
| reloadDb(); | ||||
|  | ||||
| function reloadDB(): void { | ||||
| function reloadDb(): void { | ||||
|   let enterpriseFile = "/etc/zulip-desktop-config/global_config.json"; | ||||
|   if (process.platform === "win32") { | ||||
|     enterpriseFile = | ||||
| @@ -29,7 +34,11 @@ function reloadDB(): void { | ||||
|     configFile = true; | ||||
|     try { | ||||
|       const file = fs.readFileSync(enterpriseFile, "utf8"); | ||||
|       enterpriseSettings = JSON.parse(file); | ||||
|       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); | ||||
| @@ -47,7 +56,7 @@ export function getConfigItem<Key extends keyof EnterpriseConfig>( | ||||
|   key: Key, | ||||
|   defaultValue: EnterpriseConfig[Key], | ||||
| ): EnterpriseConfig[Key] { | ||||
|   reloadDB(); | ||||
|   reloadDb(); | ||||
|   if (!configFile) { | ||||
|     return defaultValue; | ||||
|   } | ||||
| @@ -57,7 +66,7 @@ export function getConfigItem<Key extends keyof EnterpriseConfig>( | ||||
| } | ||||
|  | ||||
| export function configItemExists(key: keyof EnterpriseConfig): boolean { | ||||
|   reloadDB(); | ||||
|   reloadDb(); | ||||
|   if (!configFile) { | ||||
|     return false; | ||||
|   } | ||||
|   | ||||
| @@ -1,26 +1,26 @@ | ||||
| import {htmlEscape} from "escape-goat"; | ||||
|  | ||||
| export class HTML { | ||||
| export class Html { | ||||
|   html: string; | ||||
|  | ||||
|   constructor({html}: {html: string}) { | ||||
|     this.html = html; | ||||
|   } | ||||
|  | ||||
|   join(htmls: readonly HTML[]): HTML { | ||||
|     return new HTML({html: htmls.map((html) => html.html).join(this.html)}); | ||||
|   join(htmls: readonly Html[]): Html { | ||||
|     return new Html({html: htmls.map((html) => html.html).join(this.html)}); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function html( | ||||
|   template: TemplateStringsArray, | ||||
|   ...values: unknown[] | ||||
| ): HTML { | ||||
| ): Html { | ||||
|   let html = template[0]; | ||||
|   for (const [index, value] of values.entries()) { | ||||
|     html += value instanceof HTML ? value.html : htmlEscape(String(value)); | ||||
|     html += value instanceof Html ? value.html : htmlEscape(String(value)); | ||||
|     html += template[index + 1]; | ||||
|   } | ||||
|  | ||||
|   return new HTML({html}); | ||||
|   return new Html({html}); | ||||
| } | ||||
|   | ||||
| @@ -1,13 +1,9 @@ | ||||
| import {shell} from "electron"; | ||||
| import fs from "fs"; | ||||
| import os from "os"; | ||||
| import path from "path"; | ||||
| import {shell} from "electron/common"; | ||||
| import fs from "node:fs"; | ||||
| import os from "node:os"; | ||||
| import path from "node:path"; | ||||
| 
 | ||||
| import {html} from "../../../common/html"; | ||||
| 
 | ||||
| export function isUploadsUrl(server: string, url: URL): boolean { | ||||
|   return url.origin === server && url.pathname.startsWith("/user_uploads/"); | ||||
| } | ||||
| import {html} from "./html.js"; | ||||
| 
 | ||||
| export async function openBrowser(url: URL): Promise<void> { | ||||
|   if (["http:", "https:", "mailto:"].includes(url.protocol)) { | ||||
| @@ -19,7 +15,8 @@ export async function openBrowser(url: URL): Promise<void> { | ||||
|     const file = path.join(dir, "redirect.html"); | ||||
|     fs.writeFileSync( | ||||
|       file, | ||||
|       html`<!DOCTYPE html>
 | ||||
|       html` | ||||
|         <!doctype html> | ||||
|         <html> | ||||
|           <head> | ||||
|             <meta charset="UTF-8" /> | ||||
| @@ -34,12 +31,13 @@ export async function openBrowser(url: URL): Promise<void> { | ||||
|           <body> | ||||
|             <p>Opening <a href="${url.href}">${url.href}</a>…</p> | ||||
|           </body> | ||||
|         </html> `.html,
 | ||||
|         </html> | ||||
|       `.html,
 | ||||
|     ); | ||||
|     await shell.openPath(file); | ||||
|     setTimeout(() => { | ||||
|       fs.unlinkSync(file); | ||||
|       fs.rmdirSync(dir); | ||||
|     }, 15000); | ||||
|     }, 15_000); | ||||
|   } | ||||
| } | ||||
| @@ -1,37 +1,18 @@ | ||||
| import {Console} from "console"; // eslint-disable-line node/prefer-global/console | ||||
| import electron from "electron"; | ||||
| import fs from "fs"; | ||||
| import os from "os"; | ||||
| import {Console} from "node:console"; // eslint-disable-line n/prefer-global/console | ||||
| import fs from "node:fs"; | ||||
| import os from "node:os"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| import {initSetUp} from "./default-util"; | ||||
| import {captureException, sentryInit} from "./sentry-util"; | ||||
| import {app} from "zulip:remote"; | ||||
|  | ||||
| const {app} = process.type === "renderer" ? electron.remote : electron; | ||||
| import {initSetUp} from "./default-util.js"; | ||||
|  | ||||
| interface LoggerOptions { | ||||
| type 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"; | ||||
| @@ -92,22 +73,16 @@ export default class Logger { | ||||
|     return timestamp; | ||||
|   } | ||||
|  | ||||
|   reportSentry(error: unknown): void { | ||||
|     if (reportErrors) { | ||||
|       captureException(error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async trimLog(file: string): Promise<void> { | ||||
|     const data = await fs.promises.readFile(file, "utf8"); | ||||
|  | ||||
|     const MAX_LOG_FILE_LINES = 500; | ||||
|     const maxLogFileLines = 500; | ||||
|     const logs = data.split(os.EOL); | ||||
|     const logLength = logs.length - 1; | ||||
|  | ||||
|     // Keep bottom MAX_LOG_FILE_LINES of each log instance | ||||
|     if (logLength > MAX_LOG_FILE_LINES) { | ||||
|       const trimmedLogs = logs.slice(logLength - MAX_LOG_FILE_LINES); | ||||
|     // Keep bottom maxLogFileLines of each log instance | ||||
|     if (logLength > maxLogFileLines) { | ||||
|       const trimmedLogs = logs.slice(logLength - maxLogFileLines); | ||||
|       const toWrite = trimmedLogs.join(os.EOL); | ||||
|       await fs.promises.writeFile(file, toWrite); | ||||
|     } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| interface DialogBoxError { | ||||
| type DialogBoxError = { | ||||
|   title: string; | ||||
|   content: string; | ||||
| } | ||||
| }; | ||||
|  | ||||
| export function invalidZulipServerError(domain: string): string { | ||||
|   return `${domain} does not appear to be a valid Zulip server. Make sure that | ||||
| @@ -13,11 +13,6 @@ export function invalidZulipServerError(domain: string): string { | ||||
|  https://zulip.readthedocs.io/en/stable/production/ssl-certificates.html`; | ||||
| } | ||||
|  | ||||
| export function noOrgsError(domain: string): string { | ||||
|   return `${domain} does not have any organizations added. | ||||
| Please contact your server administrator.`; | ||||
| } | ||||
|  | ||||
| export function enterpriseOrgError( | ||||
|   length: number, | ||||
|   domains: string[], | ||||
|   | ||||
							
								
								
									
										15
									
								
								app/common/paths.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,15 @@ | ||||
| import path from "node:path"; | ||||
| import process from "node:process"; | ||||
| import url from "node:url"; | ||||
|  | ||||
| export const bundlePath = __dirname; | ||||
|  | ||||
| export const publicPath = import.meta.env.DEV | ||||
|   ? path.join(bundlePath, "../public") | ||||
|   : bundlePath; | ||||
|  | ||||
| export const bundleUrl = import.meta.env.DEV | ||||
|   ? process.env.VITE_DEV_SERVER_URL | ||||
|   : url.pathToFileURL(__dirname).href + "/"; | ||||
|  | ||||
| export const publicUrl = bundleUrl; | ||||
| @@ -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"; | ||||
| @@ -1,18 +1,16 @@ | ||||
| import path from "path"; | ||||
| import path from "node:path"; | ||||
|  | ||||
| import i18n from "i18n"; | ||||
|  | ||||
| import * as ConfigUtil from "./config-util"; | ||||
| import * as ConfigUtil from "./config-util.js"; | ||||
| import {publicPath} from "./paths.js"; | ||||
|  | ||||
| i18n.configure({ | ||||
|   directory: path.join(__dirname, "../translations/"), | ||||
|   directory: path.join(publicPath, "translations/"), | ||||
|   updateFiles: false, | ||||
| }); | ||||
|  | ||||
| /* Fetches the current appLocale from settings.json */ | ||||
| const appLocale = ConfigUtil.getConfigItem("appLanguage", "en"); | ||||
| i18n.setLocale(ConfigUtil.getConfigItem("appLanguage", "en") ?? "en"); | ||||
|  | ||||
| /* If no locale present in the json, en is set default */ | ||||
| export function __(phrase: string): string { | ||||
|   return i18n.__({phrase, locale: appLocale ? appLocale : "en"}); | ||||
| } | ||||
| export {__} from "i18n"; | ||||
|   | ||||
| @@ -1,19 +1,19 @@ | ||||
| import type {DNDSettings} from "./dnd-util"; | ||||
| import type {MenuProps, NavItem, ServerConf} from "./types"; | ||||
| import type {DndSettings} from "./dnd-util.js"; | ||||
| import type {MenuProps, ServerConf} from "./types.js"; | ||||
|  | ||||
| export interface MainMessage { | ||||
| export type MainMessage = { | ||||
|   "clear-app-settings": () => void; | ||||
|   downloadFile: (url: string, downloadPath: string) => void; | ||||
|   "error-reporting": () => void; | ||||
|   "configure-spell-checker": () => void; | ||||
|   "fetch-user-agent": () => string; | ||||
|   "focus-app": () => void; | ||||
|   "focus-this-webview": () => void; | ||||
|   "new-clipboard-key": () => {key: Uint8Array; sig: Uint8Array}; | ||||
|   "permission-callback": (permissionCallbackId: number, grant: boolean) => void; | ||||
|   "quit-app": () => void; | ||||
|   "realm-icon-changed": (serverURL: string, iconURL: string) => void; | ||||
|   "realm-name-changed": (serverURL: string, realmName: string) => void; | ||||
|   "reload-full-app": () => void; | ||||
|   "save-last-tab": (index: number) => void; | ||||
|   "set-spellcheck-langs": () => void; | ||||
|   "switch-server-tab": (index: number) => void; | ||||
|   "toggle-app": () => void; | ||||
|   "toggle-badge-option": (newValue: boolean) => void; | ||||
| @@ -23,22 +23,20 @@ export interface MainMessage { | ||||
|   "update-badge": (messageCount: number) => void; | ||||
|   "update-menu": (props: MenuProps) => void; | ||||
|   "update-taskbar-icon": (data: string, text: string) => void; | ||||
| } | ||||
| }; | ||||
|  | ||||
| export interface MainCall { | ||||
| export type MainCall = { | ||||
|   "get-server-settings": (domain: string) => ServerConf; | ||||
|   "is-online": (url: string) => boolean; | ||||
|   "save-server-icon": (iconURL: string) => string; | ||||
| } | ||||
|   "poll-clipboard": (key: Uint8Array, sig: Uint8Array) => string | undefined; | ||||
|   "save-server-icon": (iconURL: string) => string | null; | ||||
| }; | ||||
|  | ||||
| export interface RendererMessage { | ||||
| export type RendererMessage = { | ||||
|   back: () => void; | ||||
|   "copy-zulip-url": () => void; | ||||
|   destroytray: () => void; | ||||
|   downloadFileCompleted: (filePath: string, fileName: string) => void; | ||||
|   downloadFileFailed: (state: string) => void; | ||||
|   "enter-fullscreen": () => void; | ||||
|   "error-reporting-val": (errorReporting: boolean) => void; | ||||
|   focus: () => void; | ||||
|   "focus-webview-with-id": (webviewId: number) => void; | ||||
|   forward: () => void; | ||||
| @@ -48,7 +46,6 @@ export interface RendererMessage { | ||||
|   logout: () => void; | ||||
|   "new-server": () => void; | ||||
|   "open-about": () => void; | ||||
|   "open-feedback-modal": () => void; | ||||
|   "open-help": () => void; | ||||
|   "open-network-settings": () => void; | ||||
|   "open-org-tab": () => void; | ||||
| @@ -57,6 +54,7 @@ export interface RendererMessage { | ||||
|     options: {webContentsId: number | null; origin: string; permission: string}, | ||||
|     rendererCallbackId: number, | ||||
|   ) => void; | ||||
|   "play-ding-sound": () => void; | ||||
|   "reload-current-viewer": () => void; | ||||
|   "reload-proxy": (showAlert: boolean) => void; | ||||
|   "reload-viewer": () => void; | ||||
| @@ -64,27 +62,23 @@ export interface RendererMessage { | ||||
|   "set-active": () => void; | ||||
|   "set-idle": () => void; | ||||
|   "show-keyboard-shortcuts": () => void; | ||||
|   "show-network-error": (index: number) => void; | ||||
|   "show-notification-settings": () => void; | ||||
|   "switch-server-tab": (index: number) => void; | ||||
|   "switch-settings-nav": (navItem: NavItem) => void; | ||||
|   "tab-devtools": () => void; | ||||
|   "toggle-autohide-menubar": ( | ||||
|     autoHideMenubar: boolean, | ||||
|     updateMenu: boolean, | ||||
|   ) => void; | ||||
|   "toggle-dnd": (state: boolean, newSettings: Partial<DNDSettings>) => void; | ||||
|   "toggle-menubar-setting": (state: boolean) => void; | ||||
|   "toggle-dnd": (state: boolean, newSettings: Partial<DndSettings>) => void; | ||||
|   "toggle-sidebar": (show: boolean) => void; | ||||
|   "toggle-sidebar-setting": (state: boolean) => void; | ||||
|   "toggle-silent": (state: boolean) => void; | ||||
|   "toggle-tray": (state: boolean) => void; | ||||
|   toggletray: () => void; | ||||
|   tray: (arg: number) => void; | ||||
|   "update-realm-icon": (serverURL: string, iconURL: string) => void; | ||||
|   "update-realm-name": (serveRURL: string, realmName: string) => void; | ||||
|   "update-realm-name": (serverURL: string, realmName: string) => void; | ||||
|   "webview-reload": () => void; | ||||
|   zoomActualSize: () => void; | ||||
|   zoomIn: () => void; | ||||
|   zoomOut: () => void; | ||||
| } | ||||
| }; | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| export interface MenuProps { | ||||
| export type MenuProps = { | ||||
|   tabs: TabData[]; | ||||
|   activeTabIndex?: number; | ||||
|   enableMenu?: boolean; | ||||
| } | ||||
| }; | ||||
|  | ||||
| export type NavItem = | ||||
|   | "General" | ||||
| @@ -11,15 +11,18 @@ export type NavItem = | ||||
|   | "Organizations" | ||||
|   | "Shortcuts"; | ||||
|  | ||||
| export interface ServerConf { | ||||
| export type ServerConf = { | ||||
|   url: string; | ||||
|   alias: string; | ||||
|   icon: string; | ||||
| } | ||||
|   zulipVersion: string; | ||||
|   zulipFeatureLevel: number; | ||||
| }; | ||||
|  | ||||
| export interface TabData { | ||||
|   role: string; | ||||
| export type TabRole = "server" | "function"; | ||||
|  | ||||
| export type TabData = { | ||||
|   role: TabRole; | ||||
|   name: string; | ||||
|   index: number; | ||||
|   webviewName: string; | ||||
| } | ||||
| }; | ||||
|   | ||||
| @@ -1,15 +1,20 @@ | ||||
| import {app, dialog, session, shell} from "electron"; | ||||
| import util from "util"; | ||||
| import {shell} from "electron/common"; | ||||
| import {app, dialog, session} from "electron/main"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| import log from "electron-log"; | ||||
| import type {UpdateDownloadedEvent, UpdateInfo} from "electron-updater"; | ||||
| import {autoUpdater} from "electron-updater"; | ||||
|  | ||||
| import * as ConfigUtil from "../common/config-util"; | ||||
| import * as ConfigUtil from "../common/config-util.js"; | ||||
|  | ||||
| import {linuxUpdateNotification} from "./linuxupdater"; // Required only in case of linux | ||||
| import {linuxUpdateNotification} from "./linuxupdater.js"; // Required only in case of linux | ||||
|  | ||||
| const sleep = util.promisify(setTimeout); | ||||
| let quitting = false; | ||||
|  | ||||
| export function shouldQuitForUpdate(): boolean { | ||||
|   return quitting; | ||||
| } | ||||
|  | ||||
| export async function appUpdater(updateFromMenu = false): Promise<void> { | ||||
|   // Don't initiate auto-updates in development | ||||
| @@ -25,11 +30,8 @@ export async function appUpdater(updateFromMenu = false): Promise<void> { | ||||
|  | ||||
|   let updateAvailable = false; | ||||
|  | ||||
|   // Create Logs directory | ||||
|   const LogsDir = `${app.getPath("userData")}/Logs`; | ||||
|  | ||||
|   // Log whats happening | ||||
|   log.transports.file.file = `${LogsDir}/updates.log`; | ||||
|   // Log what's happening | ||||
|   log.transports.file.fileName = "updates.log"; | ||||
|   log.transports.file.level = "info"; | ||||
|   autoUpdater.logger = log; | ||||
|  | ||||
| @@ -38,7 +40,10 @@ export async function appUpdater(updateFromMenu = false): Promise<void> { | ||||
|  | ||||
|   autoUpdater.allowPrerelease = isBetaUpdate; | ||||
|  | ||||
|   const eventsListenerRemove = ["update-available", "update-not-available"]; | ||||
|   const eventsListenerRemove = [ | ||||
|     "update-available", | ||||
|     "update-not-available", | ||||
|   ] as const; | ||||
|   autoUpdater.on("update-available", async (info: UpdateInfo) => { | ||||
|     if (updateFromMenu) { | ||||
|       updateAvailable = true; | ||||
| @@ -105,10 +110,8 @@ Current Version: ${app.getVersion()}`, | ||||
|       detail: "It will be installed the next time you restart the application", | ||||
|     }); | ||||
|     if (response === 0) { | ||||
|       await sleep(1000); | ||||
|       quitting = true; | ||||
|       autoUpdater.quitAndInstall(); | ||||
|       // Force app to quit. This is just a workaround, ideally autoUpdater.quitAndInstall() should relaunch the app. | ||||
|       app.quit(); | ||||
|     } | ||||
|   }); | ||||
|   // Init for updates | ||||
|   | ||||
| @@ -1,13 +1,13 @@ | ||||
| import electron, {app} from "electron"; | ||||
| import {nativeImage} from "electron/common"; | ||||
| import type {BrowserWindow} from "electron/main"; | ||||
| import {app} from "electron/main"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| import * as ConfigUtil from "../common/config-util"; | ||||
| import * as ConfigUtil from "../common/config-util.js"; | ||||
|  | ||||
| import {send} from "./typed-ipc-main"; | ||||
| import {send} from "./typed-ipc-main.js"; | ||||
|  | ||||
| function showBadgeCount( | ||||
|   messageCount: number, | ||||
|   mainWindow: electron.BrowserWindow, | ||||
| ): void { | ||||
| function showBadgeCount(messageCount: number, mainWindow: BrowserWindow): void { | ||||
|   if (process.platform === "win32") { | ||||
|     updateOverlayIcon(messageCount, mainWindow); | ||||
|   } else { | ||||
| @@ -15,7 +15,7 @@ function showBadgeCount( | ||||
|   } | ||||
| } | ||||
|  | ||||
| function hideBadgeCount(mainWindow: electron.BrowserWindow): void { | ||||
| function hideBadgeCount(mainWindow: BrowserWindow): void { | ||||
|   if (process.platform === "win32") { | ||||
|     mainWindow.setOverlayIcon(null, ""); | ||||
|   } else { | ||||
| @@ -25,7 +25,7 @@ function hideBadgeCount(mainWindow: electron.BrowserWindow): void { | ||||
|  | ||||
| export function updateBadge( | ||||
|   badgeCount: number, | ||||
|   mainWindow: electron.BrowserWindow, | ||||
|   mainWindow: BrowserWindow, | ||||
| ): void { | ||||
|   if (ConfigUtil.getConfigItem("badgeOption", true)) { | ||||
|     showBadgeCount(badgeCount, mainWindow); | ||||
| @@ -36,7 +36,7 @@ export function updateBadge( | ||||
|  | ||||
| function updateOverlayIcon( | ||||
|   messageCount: number, | ||||
|   mainWindow: electron.BrowserWindow, | ||||
|   mainWindow: BrowserWindow, | ||||
| ): void { | ||||
|   if (!mainWindow.isFocused()) { | ||||
|     mainWindow.flashFrame( | ||||
| @@ -54,8 +54,8 @@ function updateOverlayIcon( | ||||
| export function updateTaskbarIcon( | ||||
|   data: string, | ||||
|   text: string, | ||||
|   mainWindow: electron.BrowserWindow, | ||||
|   mainWindow: BrowserWindow, | ||||
| ): void { | ||||
|   const img = electron.nativeImage.createFromDataURL(data); | ||||
|   const img = nativeImage.createFromDataURL(data); | ||||
|   mainWindow.setOverlayIcon(img, text); | ||||
| } | ||||
|   | ||||
							
								
								
									
										164
									
								
								app/main/handle-external-link.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,164 @@ | ||||
| import type {Event} from "electron/common"; | ||||
| import {shell} from "electron/common"; | ||||
| import type { | ||||
|   HandlerDetails, | ||||
|   SaveDialogOptions, | ||||
|   WebContents, | ||||
| } from "electron/main"; | ||||
| import {Notification, app} from "electron/main"; | ||||
| import fs from "node:fs"; | ||||
| import path from "node:path"; | ||||
|  | ||||
| import * as ConfigUtil from "../common/config-util.js"; | ||||
| import * as LinkUtil from "../common/link-util.js"; | ||||
|  | ||||
| import {send} from "./typed-ipc-main.js"; | ||||
|  | ||||
| function isUploadsUrl(server: string, url: URL): boolean { | ||||
|   return url.origin === server && url.pathname.startsWith("/user_uploads/"); | ||||
| } | ||||
|  | ||||
| function downloadFile({ | ||||
|   contents, | ||||
|   url, | ||||
|   downloadPath, | ||||
|   completed, | ||||
|   failed, | ||||
| }: { | ||||
|   contents: WebContents; | ||||
|   url: string; | ||||
|   downloadPath: string; | ||||
|   completed(filePath: string, fileName: string): Promise<void>; | ||||
|   failed(state: string): void; | ||||
| }) { | ||||
|   contents.downloadURL(url); | ||||
|   contents.session.once("will-download", async (_event, item) => { | ||||
|     if (ConfigUtil.getConfigItem("promptDownload", false)) { | ||||
|       const showDialogOptions: SaveDialogOptions = { | ||||
|         defaultPath: path.join(downloadPath, item.getFilename()), | ||||
|       }; | ||||
|       item.setSaveDialogOptions(showDialogOptions); | ||||
|     } else { | ||||
|       const getTimeStamp = (): number => { | ||||
|         const date = new Date(); | ||||
|         return date.getTime(); | ||||
|       }; | ||||
|  | ||||
|       const formatFile = (filePath: string): string => { | ||||
|         const fileExtension = path.extname(filePath); | ||||
|         const baseName = path.basename(filePath, fileExtension); | ||||
|         return `${baseName}-${getTimeStamp()}${fileExtension}`; | ||||
|       }; | ||||
|  | ||||
|       const filePath = path.join(downloadPath, item.getFilename()); | ||||
|  | ||||
|       // Update the name and path of the file if it already exists | ||||
|       const updatedFilePath = path.join(downloadPath, formatFile(filePath)); | ||||
|       const setFilePath: string = fs.existsSync(filePath) | ||||
|         ? updatedFilePath | ||||
|         : filePath; | ||||
|       item.setSavePath(setFilePath); | ||||
|     } | ||||
|  | ||||
|     const updatedListener = (_event: Event, state: string): void => { | ||||
|       switch (state) { | ||||
|         case "interrupted": { | ||||
|           // Can interrupted to due to network error, cancel download then | ||||
|           console.log( | ||||
|             "Download interrupted, cancelling and fallback to dialog download.", | ||||
|           ); | ||||
|           item.cancel(); | ||||
|           break; | ||||
|         } | ||||
|  | ||||
|         case "progressing": { | ||||
|           if (item.isPaused()) { | ||||
|             item.cancel(); | ||||
|           } | ||||
|  | ||||
|           // This event can also be used to show progress in percentage in future. | ||||
|           break; | ||||
|         } | ||||
|  | ||||
|         default: { | ||||
|           console.info("Unknown updated state of download item"); | ||||
|         } | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     item.on("updated", updatedListener); | ||||
|     item.once("done", async (_event, state) => { | ||||
|       if (state === "completed") { | ||||
|         await completed(item.getSavePath(), path.basename(item.getSavePath())); | ||||
|       } else { | ||||
|         console.log("Download failed state:", state); | ||||
|         failed(state); | ||||
|       } | ||||
|  | ||||
|       // To stop item for listening to updated events of this file | ||||
|       item.removeListener("updated", updatedListener); | ||||
|     }); | ||||
|   }); | ||||
| } | ||||
|  | ||||
| export default function handleExternalLink( | ||||
|   contents: WebContents, | ||||
|   details: HandlerDetails, | ||||
|   mainContents: WebContents, | ||||
| ): void { | ||||
|   let url: URL; | ||||
|   try { | ||||
|     url = new URL(details.url); | ||||
|   } catch { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   const downloadPath = ConfigUtil.getConfigItem( | ||||
|     "downloadsPath", | ||||
|     `${app.getPath("downloads")}`, | ||||
|   ); | ||||
|  | ||||
|   if (isUploadsUrl(new URL(contents.getURL()).origin, url)) { | ||||
|     downloadFile({ | ||||
|       contents, | ||||
|       url: url.href, | ||||
|       downloadPath, | ||||
|       async completed(filePath: string, fileName: string) { | ||||
|         const downloadNotification = new Notification({ | ||||
|           title: "Download Complete", | ||||
|           body: `Click to show ${fileName} in folder`, | ||||
|           silent: true, // We'll play our own sound - ding.ogg | ||||
|         }); | ||||
|         downloadNotification.on("click", () => { | ||||
|           // Reveal file in download folder | ||||
|           shell.showItemInFolder(filePath); | ||||
|         }); | ||||
|         downloadNotification.show(); | ||||
|  | ||||
|         // Play sound to indicate download complete | ||||
|         if (!ConfigUtil.getConfigItem("silent", false)) { | ||||
|           send(mainContents, "play-ding-sound"); | ||||
|         } | ||||
|       }, | ||||
|       failed(state: string) { | ||||
|         // Automatic download failed, so show save dialog prompt and download | ||||
|         // through webview | ||||
|         // Only do this if it is the automatic download, otherwise show an error (so we aren't showing two save | ||||
|         // prompts right after each other) | ||||
|         // Check that the download is not cancelled by user | ||||
|         if (state !== "cancelled") { | ||||
|           if (ConfigUtil.getConfigItem("promptDownload", false)) { | ||||
|             new Notification({ | ||||
|               title: "Download Complete", | ||||
|               body: "Download failed", | ||||
|             }).show(); | ||||
|           } else { | ||||
|             contents.downloadURL(url.href); | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|     }); | ||||
|   } else { | ||||
|     (async () => LinkUtil.openBrowser(url))(); | ||||
|   } | ||||
| } | ||||
| @@ -1,42 +1,55 @@ | ||||
| import electron, {app, dialog, session} from "electron"; | ||||
| import fs from "fs"; | ||||
| import path from "path"; | ||||
| import type {Event} from "electron/common"; | ||||
| import {clipboard} from "electron/common"; | ||||
| import type {IpcMainEvent, WebContents} from "electron/main"; | ||||
| import {BrowserWindow, app, dialog, powerMonitor, session} from "electron/main"; | ||||
| import {Buffer} from "node:buffer"; | ||||
| import crypto from "node:crypto"; | ||||
| import path from "node:path"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| import * as remoteMain from "@electron/remote/main"; | ||||
| import windowStateKeeper from "electron-window-state"; | ||||
|  | ||||
| import * as ConfigUtil from "../common/config-util"; | ||||
| import {sentryInit} from "../common/sentry-util"; | ||||
| import type {RendererMessage} from "../common/typed-ipc"; | ||||
| import type {MenuProps} from "../common/types"; | ||||
| import * as ConfigUtil from "../common/config-util.js"; | ||||
| import {bundlePath, bundleUrl, publicPath} from "../common/paths.js"; | ||||
| import type {RendererMessage} from "../common/typed-ipc.js"; | ||||
| import type {MenuProps} from "../common/types.js"; | ||||
|  | ||||
| import {appUpdater} from "./autoupdater"; | ||||
| import * as BadgeSettings from "./badge-settings"; | ||||
| 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"; | ||||
| import {appUpdater, shouldQuitForUpdate} from "./autoupdater.js"; | ||||
| import * as BadgeSettings from "./badge-settings.js"; | ||||
| import handleExternalLink from "./handle-external-link.js"; | ||||
| import * as AppMenu from "./menu.js"; | ||||
| import {_getServerSettings, _isOnline, _saveServerIcon} from "./request.js"; | ||||
| import {sentryInit} from "./sentry.js"; | ||||
| import {setAutoLaunch} from "./startup.js"; | ||||
| import {ipcMain, send} from "./typed-ipc-main.js"; | ||||
|  | ||||
| import "gatemaker/electron-setup"; // eslint-disable-line import/no-unassigned-import | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/naming-convention | ||||
| const {GDK_BACKEND} = process.env; | ||||
|  | ||||
| // Initialize sentry for main process | ||||
| sentryInit(); | ||||
|  | ||||
| let mainWindowState: windowStateKeeper.State; | ||||
|  | ||||
| // Prevent window being garbage collected | ||||
| let mainWindow: Electron.BrowserWindow; | ||||
| let mainWindow: BrowserWindow; | ||||
| let badgeCount: number; | ||||
|  | ||||
| let isQuitting = false; | ||||
|  | ||||
| // Load this url in main window | ||||
| const mainURL = "file://" + path.join(__dirname, "../renderer", "main.html"); | ||||
| // Load this file in main window | ||||
| const mainUrl = new URL("app/renderer/main.html", bundleUrl).href; | ||||
|  | ||||
| const permissionCallbacks = new Map(); | ||||
| const permissionCallbacks = new Map<number, (grant: boolean) => void>(); | ||||
| let nextPermissionCallbackId = 0; | ||||
|  | ||||
| const APP_ICON = path.join(__dirname, "../resources", "Icon"); | ||||
| const appIcon = path.join(publicPath, "resources/Icon"); | ||||
|  | ||||
| const iconPath = (): string => | ||||
|   APP_ICON + (process.platform === "win32" ? ".ico" : ".png"); | ||||
|   appIcon + (process.platform === "win32" ? ".ico" : ".png"); | ||||
|  | ||||
| // Toggle the app window | ||||
| const toggleApp = (): void => { | ||||
| @@ -47,7 +60,7 @@ const toggleApp = (): void => { | ||||
|   } | ||||
| }; | ||||
|  | ||||
| function createMainWindow(): Electron.BrowserWindow { | ||||
| function createMainWindow(): BrowserWindow { | ||||
|   // Load the previous state with fallback to defaults | ||||
|   mainWindowState = windowStateKeeper({ | ||||
|     defaultWidth: 1100, | ||||
| @@ -55,7 +68,7 @@ function createMainWindow(): Electron.BrowserWindow { | ||||
|     path: `${app.getPath("userData")}/config`, | ||||
|   }); | ||||
|  | ||||
|   const win = new electron.BrowserWindow({ | ||||
|   const win = new BrowserWindow({ | ||||
|     // This settings needs to be saved in config | ||||
|     title: "Zulip", | ||||
|     icon: iconPath(), | ||||
| @@ -66,21 +79,19 @@ function createMainWindow(): Electron.BrowserWindow { | ||||
|     minWidth: 500, | ||||
|     minHeight: 400, | ||||
|     webPreferences: { | ||||
|       contextIsolation: false, | ||||
|       enableRemoteModule: true, | ||||
|       nodeIntegration: true, | ||||
|       partition: "persist:webviewsession", | ||||
|       preload: path.join(bundlePath, "renderer.js"), | ||||
|       sandbox: false, | ||||
|       webviewTag: true, | ||||
|       worldSafeExecuteJavaScript: true, | ||||
|     }, | ||||
|     show: false, | ||||
|   }); | ||||
|   remoteMain.enable(win.webContents); | ||||
|  | ||||
|   win.on("focus", () => { | ||||
|     send(win.webContents, "focus"); | ||||
|   }); | ||||
|  | ||||
|   (async () => win.loadURL(mainURL))(); | ||||
|   (async () => win.loadURL(mainUrl))(); | ||||
|  | ||||
|   // Keep the app running in background on close event | ||||
|   win.on("close", (event) => { | ||||
| @@ -88,7 +99,7 @@ function createMainWindow(): Electron.BrowserWindow { | ||||
|       app.quit(); | ||||
|     } | ||||
|  | ||||
|     if (!isQuitting) { | ||||
|     if (!isQuitting && !shouldQuitForUpdate()) { | ||||
|       event.preventDefault(); | ||||
|  | ||||
|       if (process.platform === "darwin") { | ||||
| @@ -124,10 +135,6 @@ function createMainWindow(): Electron.BrowserWindow { | ||||
|   return win; | ||||
| } | ||||
|  | ||||
| // Temporary fix for Electron render colors differently | ||||
| // More info here - https://github.com/electron/electron/issues/10732 | ||||
| app.commandLine.appendSwitch("force-color-profile", "srgb"); | ||||
|  | ||||
| (async () => { | ||||
|   if (!app.requestSingleInstanceLock()) { | ||||
|     app.quit(); | ||||
| @@ -147,6 +154,11 @@ app.commandLine.appendSwitch("force-color-profile", "srgb"); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Used for notifications on Windows | ||||
|   app.setAppUserModelId("org.zulip.zulip-electron"); | ||||
|  | ||||
|   remoteMain.initialize(); | ||||
|  | ||||
|   app.on("second-instance", () => { | ||||
|     if (mainWindow) { | ||||
|       if (mainWindow.isMinimized()) { | ||||
| @@ -159,30 +171,77 @@ app.commandLine.appendSwitch("force-color-profile", "srgb"); | ||||
|  | ||||
|   ipcMain.on( | ||||
|     "permission-callback", | ||||
|     (event: Event, permissionCallbackId: number, grant: boolean) => { | ||||
|       permissionCallbacks.get(permissionCallbackId)(grant); | ||||
|     (event, permissionCallbackId: number, grant: boolean) => { | ||||
|       permissionCallbacks.get(permissionCallbackId)?.(grant); | ||||
|       permissionCallbacks.delete(permissionCallbackId); | ||||
|     }, | ||||
|   ); | ||||
|  | ||||
|   // This event is only available on macOS. Triggers when you click on the dock icon. | ||||
|   app.on("activate", () => { | ||||
|     if (mainWindow) { | ||||
|       // If there is already a window show it | ||||
|       mainWindow.show(); | ||||
|     } else { | ||||
|       mainWindow = createMainWindow(); | ||||
|     } | ||||
|     mainWindow.show(); | ||||
|   }); | ||||
|  | ||||
|   app.on("web-contents-created", (_event, contents: WebContents) => { | ||||
|     contents.setWindowOpenHandler((details) => { | ||||
|       handleExternalLink(contents, details, page); | ||||
|       return {action: "deny"}; | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   const ses = session.fromPartition("persist:webviewsession"); | ||||
|   ses.setUserAgent(`ZulipElectron/${app.getVersion()} ${ses.getUserAgent()}`); | ||||
|  | ||||
|   ipcMain.on("set-spellcheck-langs", () => { | ||||
|     ses.setSpellCheckerLanguages( | ||||
|       ConfigUtil.getConfigItem("spellcheckerLanguages", null) ?? [], | ||||
|     ); | ||||
|   function configureSpellChecker() { | ||||
|     const enable = ConfigUtil.getConfigItem("enableSpellchecker", true); | ||||
|     if (enable && process.platform !== "darwin") { | ||||
|       ses.setSpellCheckerLanguages( | ||||
|         ConfigUtil.getConfigItem("spellcheckerLanguages", null) ?? [], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     ses.setSpellCheckerEnabled(enable); | ||||
|   } | ||||
|  | ||||
|   configureSpellChecker(); | ||||
|   ipcMain.on("configure-spell-checker", configureSpellChecker); | ||||
|  | ||||
|   const clipboardSigKey = crypto.randomBytes(32); | ||||
|  | ||||
|   ipcMain.on("new-clipboard-key", (event) => { | ||||
|     const key = crypto.randomBytes(32); | ||||
|     const hmac = crypto.createHmac("sha256", clipboardSigKey); | ||||
|     hmac.update(key); | ||||
|     event.returnValue = {key, sig: hmac.digest()}; | ||||
|   }); | ||||
|  | ||||
|   ipcMain.handle("poll-clipboard", (event, key, sig) => { | ||||
|     // Check that the key was generated here. | ||||
|     const hmac = crypto.createHmac("sha256", clipboardSigKey); | ||||
|     hmac.update(key); | ||||
|     if (!crypto.timingSafeEqual(sig, hmac.digest())) return; | ||||
|  | ||||
|     try { | ||||
|       // Check that the data on the clipboard was encrypted to the key. | ||||
|       const data = Buffer.from(clipboard.readText(), "hex"); | ||||
|       const iv = data.slice(0, 12); | ||||
|       const ciphertext = data.slice(12, -16); | ||||
|       const authTag = data.slice(-16); | ||||
|       const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv, { | ||||
|         authTagLength: 16, | ||||
|       }); | ||||
|       decipher.setAuthTag(authTag); | ||||
|       return ( | ||||
|         decipher.update(ciphertext, undefined, "utf8") + decipher.final("utf8") | ||||
|       ); | ||||
|     } catch { | ||||
|       // If the parsing or decryption failed in any way, | ||||
|       // the correct token hasn’t been copied yet; try | ||||
|       // again next time. | ||||
|       return undefined; | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   AppMenu.setMenu({ | ||||
|     tabs: [], | ||||
|   }); | ||||
| @@ -195,18 +254,6 @@ app.commandLine.appendSwitch("force-color-profile", "srgb"); | ||||
|     mainWindow.setMenuBarVisibility(!shouldHideMenu); | ||||
|   } | ||||
|  | ||||
|   // Initialize sentry for main process | ||||
|   const errorReporting = ConfigUtil.getConfigItem("errorReporting", true); | ||||
|   if (errorReporting) { | ||||
|     sentryInit(); | ||||
|   } | ||||
|  | ||||
|   const isSystemProxy = ConfigUtil.getConfigItem("useSystemProxy", false); | ||||
|  | ||||
|   if (isSystemProxy) { | ||||
|     (async () => ProxyUtil.resolveSystemProxy(mainWindow))(); | ||||
|   } | ||||
|  | ||||
|   const page = mainWindow.webContents; | ||||
|  | ||||
|   page.on("dom-ready", () => { | ||||
| @@ -246,7 +293,7 @@ app.commandLine.appendSwitch("force-color-profile", "srgb"); | ||||
|     "certificate-error", | ||||
|     ( | ||||
|       event: Event, | ||||
|       webContents: Electron.WebContents, | ||||
|       webContents: WebContents, | ||||
|       urlString: string, | ||||
|       error: string, | ||||
|     ) => { | ||||
| @@ -260,7 +307,7 @@ ${error}`, | ||||
|     }, | ||||
|   ); | ||||
|  | ||||
|   page.session.setPermissionRequestHandler( | ||||
|   ses.setPermissionRequestHandler( | ||||
|     (webContents, permission, callback, details) => { | ||||
|       const {origin} = new URL(details.requestingUrl); | ||||
|       const permissionCallbackId = nextPermissionCallbackId++; | ||||
| @@ -282,7 +329,7 @@ ${error}`, | ||||
|   ); | ||||
|  | ||||
|   // Temporarily remove this event | ||||
|   // electron.powerMonitor.on('resume', () => { | ||||
|   // powerMonitor.on('resume', () => { | ||||
|   // 	mainWindow.reload(); | ||||
|   // 	send(page, 'destroytray'); | ||||
|   // }); | ||||
| @@ -315,35 +362,26 @@ ${error}`, | ||||
|     BadgeSettings.updateBadge(badgeCount, mainWindow); | ||||
|   }); | ||||
|  | ||||
|   ipcMain.on( | ||||
|     "toggle-menubar", | ||||
|     (_event: Electron.IpcMainEvent, showMenubar: boolean) => { | ||||
|       mainWindow.autoHideMenuBar = showMenubar; | ||||
|       mainWindow.setMenuBarVisibility(!showMenubar); | ||||
|       send(page, "toggle-autohide-menubar", showMenubar, true); | ||||
|     }, | ||||
|   ); | ||||
|   ipcMain.on("toggle-menubar", (_event, showMenubar: boolean) => { | ||||
|     mainWindow.autoHideMenuBar = showMenubar; | ||||
|     mainWindow.setMenuBarVisibility(!showMenubar); | ||||
|     send(page, "toggle-autohide-menubar", showMenubar, true); | ||||
|   }); | ||||
|  | ||||
|   ipcMain.on( | ||||
|     "update-badge", | ||||
|     (_event: Electron.IpcMainEvent, messageCount: number) => { | ||||
|       badgeCount = messageCount; | ||||
|       BadgeSettings.updateBadge(badgeCount, mainWindow); | ||||
|       send(page, "tray", messageCount); | ||||
|     }, | ||||
|   ); | ||||
|   ipcMain.on("update-badge", (_event, messageCount: number) => { | ||||
|     badgeCount = messageCount; | ||||
|     BadgeSettings.updateBadge(badgeCount, mainWindow); | ||||
|     send(page, "tray", messageCount); | ||||
|   }); | ||||
|  | ||||
|   ipcMain.on( | ||||
|     "update-taskbar-icon", | ||||
|     (_event: Electron.IpcMainEvent, data: string, text: string) => { | ||||
|       BadgeSettings.updateTaskbarIcon(data, text, mainWindow); | ||||
|     }, | ||||
|   ); | ||||
|   ipcMain.on("update-taskbar-icon", (_event, data: string, text: string) => { | ||||
|     BadgeSettings.updateTaskbarIcon(data, text, mainWindow); | ||||
|   }); | ||||
|  | ||||
|   ipcMain.on( | ||||
|     "forward-message", | ||||
|     <Channel extends keyof RendererMessage>( | ||||
|       _event: Electron.IpcMainEvent, | ||||
|       _event: IpcMainEvent, | ||||
|       listener: Channel, | ||||
|       ...parameters: Parameters<RendererMessage[Channel]> | ||||
|     ) => { | ||||
| @@ -351,138 +389,47 @@ ${error}`, | ||||
|     }, | ||||
|   ); | ||||
|  | ||||
|   ipcMain.on( | ||||
|     "update-menu", | ||||
|     (_event: Electron.IpcMainEvent, props: MenuProps) => { | ||||
|       AppMenu.setMenu(props); | ||||
|       if (props.activeTabIndex !== undefined) { | ||||
|         const activeTab = props.tabs[props.activeTabIndex]; | ||||
|         mainWindow.setTitle(`Zulip - ${activeTab.webviewName}`); | ||||
|       } | ||||
|     }, | ||||
|   ); | ||||
|   ipcMain.on("update-menu", (_event, props: MenuProps) => { | ||||
|     AppMenu.setMenu(props); | ||||
|     if (props.activeTabIndex !== undefined) { | ||||
|       const activeTab = props.tabs[props.activeTabIndex]; | ||||
|       mainWindow.setTitle(`Zulip - ${activeTab.name}`); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   ipcMain.on( | ||||
|     "toggleAutoLauncher", | ||||
|     async (_event: Electron.IpcMainEvent, AutoLaunchValue: boolean) => { | ||||
|       await setAutoLaunch(AutoLaunchValue); | ||||
|     }, | ||||
|   ); | ||||
|  | ||||
|   ipcMain.on( | ||||
|     "downloadFile", | ||||
|     (_event: Electron.IpcMainEvent, url: string, downloadPath: string) => { | ||||
|       page.downloadURL(url); | ||||
|       page.session.once("will-download", async (_event: Event, item) => { | ||||
|         if (ConfigUtil.getConfigItem("promptDownload", false)) { | ||||
|           const showDialogOptions: electron.SaveDialogOptions = { | ||||
|             defaultPath: path.join(downloadPath, item.getFilename()), | ||||
|           }; | ||||
|           item.setSaveDialogOptions(showDialogOptions); | ||||
|         } else { | ||||
|           const getTimeStamp = (): number => { | ||||
|             const date = new Date(); | ||||
|             return date.getTime(); | ||||
|           }; | ||||
|  | ||||
|           const formatFile = (filePath: string): string => { | ||||
|             const fileExtension = path.extname(filePath); | ||||
|             const baseName = path.basename(filePath, fileExtension); | ||||
|             return `${baseName}-${getTimeStamp()}${fileExtension}`; | ||||
|           }; | ||||
|  | ||||
|           const filePath = path.join(downloadPath, item.getFilename()); | ||||
|  | ||||
|           // Update the name and path of the file if it already exists | ||||
|           const updatedFilePath = path.join(downloadPath, formatFile(filePath)); | ||||
|           const setFilePath: string = fs.existsSync(filePath) | ||||
|             ? updatedFilePath | ||||
|             : filePath; | ||||
|           item.setSavePath(setFilePath); | ||||
|         } | ||||
|  | ||||
|         const updatedListener = (_event: Event, state: string): void => { | ||||
|           switch (state) { | ||||
|             case "interrupted": { | ||||
|               // Can interrupted to due to network error, cancel download then | ||||
|               console.log( | ||||
|                 "Download interrupted, cancelling and fallback to dialog download.", | ||||
|               ); | ||||
|               item.cancel(); | ||||
|               break; | ||||
|             } | ||||
|  | ||||
|             case "progressing": { | ||||
|               if (item.isPaused()) { | ||||
|                 item.cancel(); | ||||
|               } | ||||
|  | ||||
|               // This event can also be used to show progress in percentage in future. | ||||
|               break; | ||||
|             } | ||||
|  | ||||
|             default: { | ||||
|               console.info("Unknown updated state of download item"); | ||||
|             } | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         item.on("updated", updatedListener); | ||||
|         item.once("done", (_event: Event, state) => { | ||||
|           if (state === "completed") { | ||||
|             send( | ||||
|               page, | ||||
|               "downloadFileCompleted", | ||||
|               item.getSavePath(), | ||||
|               path.basename(item.getSavePath()), | ||||
|             ); | ||||
|           } else { | ||||
|             console.log("Download failed state:", state); | ||||
|             send(page, "downloadFileFailed", state); | ||||
|           } | ||||
|  | ||||
|           // To stop item for listening to updated events of this file | ||||
|           item.removeListener("updated", updatedListener); | ||||
|         }); | ||||
|       }); | ||||
|     }, | ||||
|   ); | ||||
|   ipcMain.on("toggleAutoLauncher", async (_event, AutoLaunchValue: boolean) => { | ||||
|     await setAutoLaunch(AutoLaunchValue); | ||||
|   }); | ||||
|  | ||||
|   ipcMain.on( | ||||
|     "realm-name-changed", | ||||
|     (_event: Electron.IpcMainEvent, serverURL: string, realmName: string) => { | ||||
|     (_event, serverURL: string, realmName: string) => { | ||||
|       send(page, "update-realm-name", serverURL, realmName); | ||||
|     }, | ||||
|   ); | ||||
|  | ||||
|   ipcMain.on( | ||||
|     "realm-icon-changed", | ||||
|     (_event: Electron.IpcMainEvent, serverURL: string, iconURL: string) => { | ||||
|     (_event, 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, index: number) => { | ||||
|     ConfigUtil.setConfigItem("lastActiveTab", index); | ||||
|   }); | ||||
|  | ||||
|   ipcMain.on( | ||||
|     "save-last-tab", | ||||
|     (_event: Electron.IpcMainEvent, index: number) => { | ||||
|       ConfigUtil.setConfigItem("lastActiveTab", index); | ||||
|     }, | ||||
|   ); | ||||
|   ipcMain.on("focus-this-webview", (event) => { | ||||
|     send(page, "focus-webview-with-id", event.sender.id); | ||||
|     mainWindow.show(); | ||||
|   }); | ||||
|  | ||||
|   // Update user idle status for each realm after every 15s | ||||
|   const idleCheckInterval = 15 * 1000; // 15 seconds | ||||
|   setInterval(() => { | ||||
|     // Set user idle if no activity in 1 second (idleThresholdSeconds) | ||||
|     const idleThresholdSeconds = 1; // 1 second | ||||
|     const idleState = electron.powerMonitor.getSystemIdleState( | ||||
|       idleThresholdSeconds, | ||||
|     ); | ||||
|     const idleState = powerMonitor.getSystemIdleState(idleThresholdSeconds); | ||||
|     if (idleState === "active") { | ||||
|       send(page, "set-active"); | ||||
|     } else { | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| import {app, dialog} from "electron"; | ||||
| import fs from "fs"; | ||||
| import path from "path"; | ||||
| import {app, dialog} from "electron/main"; | ||||
| import fs from "node:fs"; | ||||
| import path from "node:path"; | ||||
|  | ||||
| import {JsonDB} from "node-json-db"; | ||||
| import {DataError} from "node-json-db/dist/lib/Errors"; | ||||
|  | ||||
| import Logger from "../common/logger-util"; | ||||
| import Logger from "../common/logger-util.js"; | ||||
|  | ||||
| const logger = new Logger({ | ||||
|   file: "linux-update-util.log", | ||||
| @@ -12,12 +13,21 @@ const logger = new Logger({ | ||||
|  | ||||
| let db: JsonDB; | ||||
|  | ||||
| reloadDB(); | ||||
| reloadDb(); | ||||
|  | ||||
| export function getUpdateItem(key: string, defaultValue: unknown = null): any { | ||||
|   reloadDB(); | ||||
|   const value = db.getData("/")[key]; | ||||
|   if (value === undefined) { | ||||
| 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; | ||||
|   } | ||||
| @@ -25,17 +35,17 @@ export function getUpdateItem(key: string, defaultValue: unknown = null): any { | ||||
|   return value; | ||||
| } | ||||
|  | ||||
| export function setUpdateItem(key: string, value: unknown): void { | ||||
| export function setUpdateItem(key: string, value: true | null): void { | ||||
|   db.push(`/${key}`, value, true); | ||||
|   reloadDB(); | ||||
|   reloadDb(); | ||||
| } | ||||
|  | ||||
| export function removeUpdateItem(key: string): void { | ||||
|   db.delete(`/${key}`); | ||||
|   reloadDB(); | ||||
|   reloadDb(); | ||||
| } | ||||
|  | ||||
| function reloadDB(): void { | ||||
| function reloadDb(): void { | ||||
|   const linuxUpdateJsonPath = path.join( | ||||
|     app.getPath("userData"), | ||||
|     "/config/updates.json", | ||||
|   | ||||
| @@ -1,38 +1,35 @@ | ||||
| import {Notification, app, net} from "electron"; | ||||
| import type {Session} from "electron/main"; | ||||
| import {Notification, app} from "electron/main"; | ||||
|  | ||||
| import getStream from "get-stream"; | ||||
| import * as semver from "semver"; | ||||
| import {z} from "zod"; | ||||
|  | ||||
| import * as ConfigUtil from "../common/config-util"; | ||||
| import Logger from "../common/logger-util"; | ||||
| import * as ConfigUtil from "../common/config-util.js"; | ||||
| import Logger from "../common/logger-util.js"; | ||||
|  | ||||
| import * as LinuxUpdateUtil from "./linux-update-util"; | ||||
| import {fetchResponse} from "./request"; | ||||
| import * as LinuxUpdateUtil from "./linux-update-util.js"; | ||||
|  | ||||
| const logger = new Logger({ | ||||
|   file: "linux-update-util.log", | ||||
| }); | ||||
|  | ||||
| export async function linuxUpdateNotification( | ||||
|   session: Electron.session, | ||||
| ): Promise<void> { | ||||
| export async function linuxUpdateNotification(session: Session): Promise<void> { | ||||
|   let url = "https://api.github.com/repos/zulip/zulip-desktop/releases"; | ||||
|   url = ConfigUtil.getConfigItem("betaUpdate", false) ? url : url + "/latest"; | ||||
|  | ||||
|   try { | ||||
|     const response = await fetchResponse(net.request({url, session})); | ||||
|     if (response.statusCode !== 200) { | ||||
|       logger.log("Linux update response status: ", response.statusCode); | ||||
|     const response = await session.fetch(url); | ||||
|     if (!response.ok) { | ||||
|       logger.log("Linux update response status: ", response.status); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const data = JSON.parse(await getStream(response)); | ||||
|     const data: unknown = await response.json(); | ||||
|     /* eslint-disable @typescript-eslint/naming-convention */ | ||||
|     const latestVersion = ConfigUtil.getConfigItem("betaUpdate", false) | ||||
|       ? data[0].tag_name | ||||
|       : data.tag_name; | ||||
|     if (typeof latestVersion !== "string") { | ||||
|       throw new TypeError("Expected string for tag_name"); | ||||
|     } | ||||
|       ? z.array(z.object({tag_name: z.string()})).parse(data)[0].tag_name | ||||
|       : z.object({tag_name: z.string()}).parse(data).tag_name; | ||||
|     /* eslint-enable @typescript-eslint/naming-convention */ | ||||
|  | ||||
|     if (semver.gt(latestVersion, app.getVersion())) { | ||||
|       const notified = LinuxUpdateUtil.getUpdateItem(latestVersion); | ||||
|   | ||||
| @@ -1,21 +1,22 @@ | ||||
| import {BrowserWindow, Menu, app, shell} from "electron"; | ||||
| import {shell} from "electron/common"; | ||||
| import type {MenuItemConstructorOptions} from "electron/main"; | ||||
| import {BrowserWindow, Menu, app} from "electron/main"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| import AdmZip from "adm-zip"; | ||||
|  | ||||
| import * as ConfigUtil from "../common/config-util"; | ||||
| import * as DNDUtil from "../common/dnd-util"; | ||||
| import * as t from "../common/translation-util"; | ||||
| import type {RendererMessage} from "../common/typed-ipc"; | ||||
| import type {MenuProps, TabData} from "../common/types"; | ||||
| import * as ConfigUtil from "../common/config-util.js"; | ||||
| import * as DNDUtil from "../common/dnd-util.js"; | ||||
| import * as t from "../common/translation-util.js"; | ||||
| import type {RendererMessage} from "../common/typed-ipc.js"; | ||||
| import type {MenuProps, TabData} from "../common/types.js"; | ||||
|  | ||||
| import {appUpdater} from "./autoupdater"; | ||||
| import {send} from "./typed-ipc-main"; | ||||
| import {appUpdater} from "./autoupdater.js"; | ||||
| import {send} from "./typed-ipc-main.js"; | ||||
|  | ||||
| const appName = app.name; | ||||
|  | ||||
| function getHistorySubmenu( | ||||
|   enableMenu: boolean, | ||||
| ): Electron.MenuItemConstructorOptions[] { | ||||
| function getHistorySubmenu(enableMenu: boolean): MenuItemConstructorOptions[] { | ||||
|   return [ | ||||
|     { | ||||
|       label: t.__("Back"), | ||||
| @@ -41,7 +42,7 @@ function getHistorySubmenu( | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| function getToolsSubmenu(): Electron.MenuItemConstructorOptions[] { | ||||
| function getToolsSubmenu(): MenuItemConstructorOptions[] { | ||||
|   return [ | ||||
|     { | ||||
|       label: t.__("Check for Updates"), | ||||
| @@ -65,7 +66,7 @@ function getToolsSubmenu(): Electron.MenuItemConstructorOptions[] { | ||||
|       click() { | ||||
|         const zip = new AdmZip(); | ||||
|         const date = new Date(); | ||||
|         const dateString = date.toLocaleDateString().replace(/\//g, "-"); | ||||
|         const dateString = date.toLocaleDateString().replaceAll("/", "-"); | ||||
|  | ||||
|         // Create a zip file of all the logs and config data | ||||
|         zip.addLocalFolder(`${app.getPath("appData")}/${appName}/Logs`); | ||||
| @@ -107,7 +108,7 @@ function getToolsSubmenu(): Electron.MenuItemConstructorOptions[] { | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| function getViewSubmenu(): Electron.MenuItemConstructorOptions[] { | ||||
| function getViewSubmenu(): MenuItemConstructorOptions[] { | ||||
|   return [ | ||||
|     { | ||||
|       label: t.__("Reload"), | ||||
| @@ -256,7 +257,7 @@ function getViewSubmenu(): Electron.MenuItemConstructorOptions[] { | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| function getHelpSubmenu(): Electron.MenuItemConstructorOptions[] { | ||||
| function getHelpSubmenu(): MenuItemConstructorOptions[] { | ||||
|   return [ | ||||
|     { | ||||
|       label: `${appName + " Desktop"} v${app.getVersion()}`, | ||||
| @@ -280,12 +281,8 @@ function getHelpSubmenu(): Electron.MenuItemConstructorOptions[] { | ||||
|     }, | ||||
|     { | ||||
|       label: t.__("Report an Issue"), | ||||
|       click() { | ||||
|         // The goal is to notify the main.html BrowserWindow | ||||
|         // which may not be the focused window. | ||||
|         for (const window of BrowserWindow.getAllWindows()) { | ||||
|           send(window.webContents, "open-feedback-modal"); | ||||
|         } | ||||
|       async click() { | ||||
|         await shell.openExternal("https://zulip.com/help/contact-support"); | ||||
|       }, | ||||
|     }, | ||||
|   ]; | ||||
| @@ -294,8 +291,8 @@ function getHelpSubmenu(): Electron.MenuItemConstructorOptions[] { | ||||
| function getWindowSubmenu( | ||||
|   tabs: TabData[], | ||||
|   activeTabIndex?: number, | ||||
| ): Electron.MenuItemConstructorOptions[] { | ||||
|   const initialSubmenu: Electron.MenuItemConstructorOptions[] = [ | ||||
| ): MenuItemConstructorOptions[] { | ||||
|   const initialSubmenu: MenuItemConstructorOptions[] = [ | ||||
|     { | ||||
|       label: t.__("Minimize"), | ||||
|       role: "minimize", | ||||
| @@ -307,11 +304,15 @@ function getWindowSubmenu( | ||||
|   ]; | ||||
|  | ||||
|   if (tabs.length > 0) { | ||||
|     const ShortcutKey = process.platform === "darwin" ? "Cmd" : "Ctrl"; | ||||
|     const shortcutKey = process.platform === "darwin" ? "Cmd" : "Ctrl"; | ||||
|     initialSubmenu.push({ | ||||
|       type: "separator", | ||||
|     }); | ||||
|     for (const tab of tabs) { | ||||
|       // Skip missing elements left by `delete this.tabs[index]` in | ||||
|       // ServerManagerView. | ||||
|       if (tab === undefined) continue; | ||||
|  | ||||
|       // Do not add functional tab settings to list of windows in menu bar | ||||
|       if (tab.role === "function" && tab.name === "Settings") { | ||||
|         continue; | ||||
| @@ -320,7 +321,7 @@ function getWindowSubmenu( | ||||
|       initialSubmenu.push({ | ||||
|         label: tab.name, | ||||
|         accelerator: | ||||
|           tab.role === "function" ? "" : `${ShortcutKey} + ${tab.index + 1}`, | ||||
|           tab.role === "function" ? "" : `${shortcutKey} + ${tab.index + 1}`, | ||||
|         checked: tab.index === activeTabIndex, | ||||
|         click(_item, focusedWindow) { | ||||
|           if (focusedWindow) { | ||||
| @@ -367,7 +368,7 @@ function getWindowSubmenu( | ||||
|   return initialSubmenu; | ||||
| } | ||||
|  | ||||
| function getDarwinTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] { | ||||
| function getDarwinTpl(props: MenuProps): MenuItemConstructorOptions[] { | ||||
|   const {tabs, activeTabIndex, enableMenu = false} = props; | ||||
|  | ||||
|   return [ | ||||
| @@ -425,7 +426,6 @@ function getDarwinTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] { | ||||
|         }, | ||||
|         { | ||||
|           label: t.__("Log Out of Organization"), | ||||
|           accelerator: "Cmd+L", | ||||
|           enabled: enableMenu, | ||||
|           click(_item, focusedWindow) { | ||||
|             if (focusedWindow) { | ||||
| @@ -533,7 +533,7 @@ function getDarwinTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] { | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| function getOtherTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] { | ||||
| function getOtherTpl(props: MenuProps): MenuItemConstructorOptions[] { | ||||
|   const {tabs, activeTabIndex, enableMenu = false} = props; | ||||
|   return [ | ||||
|     { | ||||
| @@ -593,7 +593,6 @@ function getOtherTpl(props: MenuProps): Electron.MenuItemConstructorOptions[] { | ||||
|         }, | ||||
|         { | ||||
|           label: t.__("Log Out of Organization"), | ||||
|           accelerator: "Ctrl+L", | ||||
|           enabled: enableMenu, | ||||
|           click(_item, focusedWindow) { | ||||
|             if (focusedWindow) { | ||||
| @@ -702,7 +701,7 @@ async function checkForUpdate(): Promise<void> { | ||||
| function getNextServer(tabs: TabData[], activeTabIndex: number): number { | ||||
|   do { | ||||
|     activeTabIndex = (activeTabIndex + 1) % tabs.length; | ||||
|   } while (tabs[activeTabIndex].role !== "server"); | ||||
|   } while (tabs[activeTabIndex]?.role !== "server"); | ||||
|  | ||||
|   return activeTabIndex; | ||||
| } | ||||
| @@ -710,7 +709,7 @@ function getNextServer(tabs: TabData[], activeTabIndex: number): number { | ||||
| function getPreviousServer(tabs: TabData[], activeTabIndex: number): number { | ||||
|   do { | ||||
|     activeTabIndex = (activeTabIndex - 1 + tabs.length) % tabs.length; | ||||
|   } while (tabs[activeTabIndex].role !== "server"); | ||||
|   } while (tabs[activeTabIndex]?.role !== "server"); | ||||
|  | ||||
|   return activeTabIndex; | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|   } | ||||
| } | ||||
| @@ -1,35 +1,20 @@ | ||||
| import type {ClientRequest, IncomingMessage} from "electron"; | ||||
| import {app, net} from "electron"; | ||||
| import fs from "fs"; | ||||
| import path from "path"; | ||||
| import stream from "stream"; | ||||
| import util from "util"; | ||||
| import type {Session} from "electron/main"; | ||||
| import {app} from "electron/main"; | ||||
| import fs from "node:fs"; | ||||
| import path from "node:path"; | ||||
| import {Readable} from "node:stream"; | ||||
| import {pipeline} from "node:stream/promises"; | ||||
| import type {ReadableStream} from "node:stream/web"; | ||||
|  | ||||
| import getStream from "get-stream"; | ||||
| import * as Sentry from "@sentry/electron"; | ||||
| import {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); | ||||
| import Logger from "../common/logger-util.js"; | ||||
| import * as Messages from "../common/messages.js"; | ||||
| import type {ServerConf} from "../common/types.js"; | ||||
|  | ||||
| /* Request: domain-util */ | ||||
|  | ||||
| const defaultIconUrl = "../renderer/img/icon.png"; | ||||
|  | ||||
| const logger = new Logger({ | ||||
|   file: "domain-util.log", | ||||
| }); | ||||
| @@ -42,6 +27,7 @@ const generateFilePath = (url: string): string => { | ||||
|   let {length} = url; | ||||
|  | ||||
|   while (length) { | ||||
|     // eslint-disable-next-line no-bitwise, unicorn/prefer-code-point | ||||
|     hash = (hash * 33) ^ url.charCodeAt(--length); | ||||
|   } | ||||
|  | ||||
| @@ -50,33 +36,37 @@ const generateFilePath = (url: string): string => { | ||||
|     fs.mkdirSync(dir); | ||||
|   } | ||||
|  | ||||
|   // eslint-disable-next-line no-bitwise | ||||
|   return `${dir}/${hash >>> 0}${extension}`; | ||||
| }; | ||||
|  | ||||
| export const _getServerSettings = async ( | ||||
|   domain: string, | ||||
|   session: Electron.session, | ||||
|   session: Session, | ||||
| ): Promise<ServerConf> => { | ||||
|   const response = await fetchResponse( | ||||
|     net.request({ | ||||
|       url: domain + "/api/v1/server_settings", | ||||
|       session, | ||||
|     }), | ||||
|   ); | ||||
|   if (response.statusCode !== 200) { | ||||
|   const response = await session.fetch(domain + "/api/v1/server_settings"); | ||||
|   if (!response.ok) { | ||||
|     throw new Error(Messages.invalidZulipServerError(domain)); | ||||
|   } | ||||
|  | ||||
|   const {realm_name, realm_uri, realm_icon} = JSON.parse( | ||||
|     await getStream(response), | ||||
|   ); | ||||
|   if ( | ||||
|     typeof realm_name !== "string" || | ||||
|     typeof realm_uri !== "string" || | ||||
|     typeof realm_icon !== "string" | ||||
|   ) { | ||||
|     throw new TypeError(Messages.noOrgsError(domain)); | ||||
|   } | ||||
|   const data: unknown = await response.json(); | ||||
|   /* eslint-disable @typescript-eslint/naming-convention */ | ||||
|   const { | ||||
|     realm_name, | ||||
|     realm_uri, | ||||
|     realm_icon, | ||||
|     zulip_version, | ||||
|     zulip_feature_level, | ||||
|   } = z | ||||
|     .object({ | ||||
|       realm_name: z.string(), | ||||
|       realm_uri: z.string().url(), | ||||
|       realm_icon: z.string(), | ||||
|       zulip_version: z.string().default("unknown"), | ||||
|       zulip_feature_level: z.number().default(0), | ||||
|     }) | ||||
|     .parse(data); | ||||
|   /* eslint-enable @typescript-eslint/naming-convention */ | ||||
|  | ||||
|   return { | ||||
|     // Some Zulip Servers use absolute URL for server icon whereas others use relative URL | ||||
| @@ -84,28 +74,33 @@ export const _getServerSettings = async ( | ||||
|     icon: realm_icon.startsWith("/") ? realm_uri + realm_icon : realm_icon, | ||||
|     url: realm_uri, | ||||
|     alias: realm_name, | ||||
|     zulipVersion: zulip_version, | ||||
|     zulipFeatureLevel: zulip_feature_level, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export const _saveServerIcon = async ( | ||||
|   url: string, | ||||
|   session: Electron.session, | ||||
| ): Promise<string> => { | ||||
|   session: Session, | ||||
| ): Promise<string | null> => { | ||||
|   try { | ||||
|     const response = await fetchResponse(net.request({url, session})); | ||||
|     if (response.statusCode !== 200) { | ||||
|     const response = await session.fetch(url); | ||||
|     if (!response.ok) { | ||||
|       logger.log("Could not get server icon."); | ||||
|       return defaultIconUrl; | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     const filePath = generateFilePath(url); | ||||
|     await pipeline(response, fs.createWriteStream(filePath)); | ||||
|     await pipeline( | ||||
|       Readable.fromWeb(response.body as ReadableStream<Uint8Array>), | ||||
|       fs.createWriteStream(filePath), | ||||
|     ); | ||||
|     return filePath; | ||||
|   } catch (error: unknown) { | ||||
|     logger.log("Could not get server icon."); | ||||
|     logger.log(error); | ||||
|     logger.reportSentry(error); | ||||
|     return defaultIconUrl; | ||||
|     Sentry.captureException(error); | ||||
|     return null; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @@ -113,19 +108,13 @@ export const _saveServerIcon = async ( | ||||
|  | ||||
| export const _isOnline = async ( | ||||
|   url: string, | ||||
|   session: Electron.session, | ||||
|   session: Session, | ||||
| ): Promise<boolean> => { | ||||
|   try { | ||||
|     const response = await fetchResponse( | ||||
|       net.request({ | ||||
|         method: "HEAD", | ||||
|         url: `${url}/api/v1/server_settings`, | ||||
|         session, | ||||
|       }), | ||||
|     ); | ||||
|     const isValidResponse = | ||||
|       response.statusCode >= 200 && response.statusCode < 400; | ||||
|     return isValidResponse; | ||||
|     const response = await session.fetch(`${url}/api/v1/server_settings`, { | ||||
|       method: "HEAD", | ||||
|     }); | ||||
|     return response.ok; | ||||
|   } catch (error: unknown) { | ||||
|     logger.log(error); | ||||
|     return false; | ||||
|   | ||||
							
								
								
									
										22
									
								
								app/main/sentry.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,22 @@ | ||||
| import {app} from "electron/main"; | ||||
|  | ||||
| import * as Sentry from "@sentry/electron/main"; // eslint-disable-line n/file-extension-in-import | ||||
|  | ||||
| import {getConfigItem} from "../common/config-util.js"; | ||||
|  | ||||
| export const sentryInit = (): void => { | ||||
|   Sentry.init({ | ||||
|     dsn: "https://628dc2f2864243a08ead72e63f94c7b1@o48127.ingest.sentry.io/204668", | ||||
|  | ||||
|     // Don't report errors in development or if disabled by the user. | ||||
|     beforeSend: (event) => | ||||
|       app.isPackaged && getConfigItem("errorReporting", true) ? event : null, | ||||
|  | ||||
|     // We should ignore this error since it's harmless and we know the reason behind this | ||||
|     // This error mainly comes from the console logs. | ||||
|     // This is a temp solution until Sentry supports disabling the console logs | ||||
|     ignoreErrors: ["does not appear to be a valid Zulip server"], | ||||
|  | ||||
|     /// sendTimeout: 30 // wait 30 seconds before considering the sending capture to have failed, default is 1 second | ||||
|   }); | ||||
| }; | ||||
| @@ -1,8 +1,9 @@ | ||||
| import {app} from "electron"; | ||||
| import {app} from "electron/main"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| import AutoLaunch from "auto-launch"; | ||||
|  | ||||
| import * as ConfigUtil from "../common/config-util"; | ||||
| import * as ConfigUtil from "../common/config-util.js"; | ||||
|  | ||||
| export const setAutoLaunch = async ( | ||||
|   AutoLaunchValue: boolean, | ||||
| @@ -19,13 +20,13 @@ export const setAutoLaunch = async ( | ||||
|  | ||||
|   // `setLoginItemSettings` doesn't support linux | ||||
|   if (process.platform === "linux") { | ||||
|     const ZulipAutoLauncher = new AutoLaunch({ | ||||
|     const zulipAutoLauncher = new AutoLaunch({ | ||||
|       name: "Zulip", | ||||
|       isHidden: false, | ||||
|     }); | ||||
|     await (autoLaunchOption | ||||
|       ? ZulipAutoLauncher.enable() | ||||
|       : ZulipAutoLauncher.disable()); | ||||
|       ? zulipAutoLauncher.enable() | ||||
|       : zulipAutoLauncher.disable()); | ||||
|   } else { | ||||
|     app.setLoginItemSettings({ | ||||
|       openAtLogin: autoLaunchOption, | ||||
|   | ||||
| @@ -1,15 +1,22 @@ | ||||
| import type {IpcMainEvent, IpcMainInvokeEvent, WebContents} from "electron"; | ||||
| import type { | ||||
|   IpcMainEvent, | ||||
|   IpcMainInvokeEvent, | ||||
|   WebContents, | ||||
| } from "electron/main"; | ||||
| import { | ||||
|   ipcMain as untypedIpcMain, // eslint-disable-line no-restricted-imports | ||||
| } from "electron"; | ||||
| } from "electron/main"; | ||||
|  | ||||
| import type {MainCall, MainMessage, RendererMessage} from "../common/typed-ipc"; | ||||
| import type { | ||||
|   MainCall, | ||||
|   MainMessage, | ||||
|   RendererMessage, | ||||
| } from "../common/typed-ipc.js"; | ||||
|  | ||||
| type MainListener< | ||||
|   Channel extends keyof MainMessage | ||||
| > = MainMessage[Channel] extends (...args: infer Args) => infer Return | ||||
|   ? (event: IpcMainEvent & {returnValue: Return}, ...args: Args) => void | ||||
|   : never; | ||||
| 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 | ||||
|   | ||||
| @@ -1,37 +1,26 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <link rel="stylesheet" href="css/about.css" /> | ||||
|     <title>Zulip - About</title> | ||||
|   </head> | ||||
| <!doctype html> | ||||
| <meta charset="UTF-8" /> | ||||
| <link rel="stylesheet" href="css/about.css" /> | ||||
|  | ||||
|   <body> | ||||
|     <div class="about"> | ||||
|       <img class="logo" src="../resources/zulip.png" /> | ||||
|       <p class="detail" id="version">v?.?.?</p> | ||||
|       <div class="maintenance-info"> | ||||
|         <p class="detail maintainer"> | ||||
|           Maintained by | ||||
|           <a href="https://zulip.com" target="_blank" rel="noopener noreferrer" | ||||
|             >Zulip</a | ||||
|           > | ||||
|         </p> | ||||
|         <p class="detail license"> | ||||
|           Available under the | ||||
|           <a | ||||
|             href="https://github.com/zulip/zulip-desktop/blob/main/LICENSE" | ||||
|             target="_blank" | ||||
|             rel="noopener noreferrer" | ||||
|             >Apache 2.0 License</a | ||||
|           > | ||||
|         </p> | ||||
|       </div> | ||||
|     </div> | ||||
|     <script> | ||||
|       const {app} = require("electron").remote; | ||||
|       const version_tag = document.querySelector("#version"); | ||||
|       version_tag.textContent = "v" + app.getVersion(); | ||||
|     </script> | ||||
|   </body> | ||||
| </html> | ||||
| <!-- Initially hidden to prevent FOUC --> | ||||
| <div class="about" hidden> | ||||
|   <img class="logo" src="../resources/zulip.png" /> | ||||
|   <p class="detail" id="version"></p> | ||||
|   <div class="maintenance-info"> | ||||
|     <p class="detail maintainer"> | ||||
|       Maintained by | ||||
|       <a href="https://zulip.com" target="_blank" rel="noopener noreferrer" | ||||
|         >Zulip</a | ||||
|       > | ||||
|     </p> | ||||
|     <p class="detail license"> | ||||
|       Available under the | ||||
|       <a | ||||
|         href="https://github.com/zulip/zulip-desktop/blob/main/LICENSE" | ||||
|         target="_blank" | ||||
|         rel="noopener noreferrer" | ||||
|         >Apache 2.0 License</a | ||||
|       > | ||||
|     </p> | ||||
|   </div> | ||||
| </div> | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| body { | ||||
|   background: rgba(250, 250, 250, 1); | ||||
| :host { | ||||
|   contain: strict; | ||||
|   display: flow-root; | ||||
|   background: rgb(250 250 250 / 100%); | ||||
|   font-family: menu, "Helvetica Neue", sans-serif; | ||||
|   -webkit-font-smoothing: subpixel-antialiased; | ||||
| } | ||||
| @@ -10,12 +12,13 @@ body { | ||||
| } | ||||
|  | ||||
| #version { | ||||
|   color: rgba(68, 67, 67, 1); | ||||
|   color: rgb(68 67 67 / 100%); | ||||
|   font-size: 1.3em; | ||||
|   padding-top: 40px; | ||||
| } | ||||
|  | ||||
| .about { | ||||
|   display: block !important; | ||||
|   margin: 25vh auto; | ||||
|   height: 25vh; | ||||
|   text-align: center; | ||||
| @@ -23,7 +26,7 @@ body { | ||||
|  | ||||
| .about p { | ||||
|   font-size: 20px; | ||||
|   color: rgba(0, 0, 0, 0.62); | ||||
|   color: rgb(0 0 0 / 62%); | ||||
| } | ||||
|  | ||||
| .about img { | ||||
| @@ -48,7 +51,7 @@ body { | ||||
|   position: absolute; | ||||
|   width: 100%; | ||||
|   left: 0; | ||||
|   color: rgba(68, 68, 68, 1); | ||||
|   color: rgb(68 68 68 / 100%); | ||||
| } | ||||
|  | ||||
| .maintenance-info p { | ||||
| @@ -58,7 +61,7 @@ body { | ||||
| } | ||||
|  | ||||
| p.detail a { | ||||
|   color: rgba(53, 95, 76, 1); | ||||
|   color: rgb(53 95 76 / 100%); | ||||
| } | ||||
|  | ||||
| p.detail a:hover { | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| :host { | ||||
|   --button-color: rgb(69, 166, 149); | ||||
|   --button-color: rgb(69 166 149); | ||||
| } | ||||
|  | ||||
| button { | ||||
| @@ -14,6 +14,6 @@ button:focus { | ||||
| } | ||||
|  | ||||
| button:active { | ||||
|   background-color: rgb(241, 241, 241); | ||||
|   background-color: rgb(241 241 241); | ||||
|   color: var(--button-color); | ||||
| } | ||||
|   | ||||
							
								
								
									
										14
									
								
								app/renderer/css/fonts.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,14 @@ | ||||
| @font-face { | ||||
|   font-family: "Material Icons"; | ||||
|   font-style: normal; | ||||
|   font-weight: 400; | ||||
|   src: | ||||
|     local("Material Icons"), | ||||
|     local("MaterialIcons-Regular"), | ||||
|     url("../fonts/MaterialIcons-Regular.ttf") format("truetype"); | ||||
| } | ||||
|  | ||||
| @font-face { | ||||
|   font-family: Montserrat; | ||||
|   src: url("../fonts/Montserrat-Regular.ttf") format("truetype"); | ||||
| } | ||||
| @@ -16,9 +16,9 @@ body { | ||||
| } | ||||
|  | ||||
| .toggle-sidebar { | ||||
|   background: rgba(34, 44, 49, 1); | ||||
|   background: rgb(34 44 49 / 100%); | ||||
|   width: 54px; | ||||
|   padding: 27px 0 20px 0; | ||||
|   padding: 27px 0 20px; | ||||
|   justify-content: space-between; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
| @@ -44,6 +44,7 @@ body { | ||||
|  | ||||
| #view-controls-container { | ||||
|   height: calc(100% - 208px); | ||||
|   scrollbar-gutter: stable both-edges; | ||||
|   overflow-y: hidden; | ||||
| } | ||||
|  | ||||
| @@ -52,24 +53,15 @@ body { | ||||
| } | ||||
|  | ||||
| #view-controls-container::-webkit-scrollbar-track { | ||||
|   box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); | ||||
|   background-color: rgb(0 0 0 / 30%); | ||||
| } | ||||
|  | ||||
| #view-controls-container::-webkit-scrollbar-thumb { | ||||
|   background-color: rgba(169, 169, 169, 1); | ||||
|   outline: 1px solid rgba(169, 169, 169, 1); | ||||
|   background-color: rgb(169 169 169 / 100%); | ||||
| } | ||||
|  | ||||
| #view-controls-container:hover { | ||||
|   overflow-y: overlay; | ||||
| } | ||||
|  | ||||
| @font-face { | ||||
|   font-family: "Material Icons"; | ||||
|   font-style: normal; | ||||
|   font-weight: 400; | ||||
|   src: local("Material Icons"), local("MaterialIcons-Regular"), | ||||
|     url(../fonts/MaterialIcons-Regular.ttf) format("truetype"); | ||||
|   overflow-y: scroll; | ||||
| } | ||||
|  | ||||
| /******************* | ||||
| @@ -101,7 +93,7 @@ body { | ||||
|   -webkit-font-smoothing: antialiased; | ||||
|  | ||||
|   /* Support for Safari and Chrome. */ | ||||
|   text-rendering: optimizeLegibility; | ||||
|   text-rendering: optimizelegibility; | ||||
| } | ||||
|  | ||||
| #actions-container { | ||||
| @@ -122,23 +114,23 @@ body { | ||||
| } | ||||
|  | ||||
| .action-button i { | ||||
|   color: rgba(108, 133, 146, 1); | ||||
|   color: rgb(108 133 146 / 100%); | ||||
|   font-size: 28px; | ||||
| } | ||||
|  | ||||
| .action-button:hover i { | ||||
|   color: rgba(152, 169, 179, 1); | ||||
|   color: rgb(152 169 179 / 100%); | ||||
| } | ||||
|  | ||||
| .action-button.active { | ||||
|   /* background-color: rgba(255, 255, 255, 0.25); */ | ||||
|   background-color: rgba(239, 239, 239, 1); | ||||
|   background-color: rgb(239 239 239 / 100%); | ||||
|   opacity: 0.9; | ||||
|   padding-right: 14px; | ||||
| } | ||||
|  | ||||
| .action-button.active i { | ||||
|   color: rgba(28, 38, 43, 1); | ||||
|   color: rgb(28 38 43 / 100%); | ||||
| } | ||||
|  | ||||
| .action-button.disable { | ||||
| @@ -150,7 +142,7 @@ body { | ||||
| } | ||||
|  | ||||
| .action-button.disable:hover i { | ||||
|   color: rgba(108, 133, 146, 1); | ||||
|   color: rgb(108 133 146 / 100%); | ||||
| } | ||||
|  | ||||
| .tab { | ||||
| @@ -180,7 +172,7 @@ body { | ||||
|   margin-top: 5px; | ||||
|   z-index: 11; | ||||
|   line-height: 31px; | ||||
|   color: rgba(238, 238, 238, 1); | ||||
|   color: rgb(238 238 238 / 100%); | ||||
|   text-align: center; | ||||
|   overflow: hidden; | ||||
|   opacity: 0.6; | ||||
| @@ -191,7 +183,7 @@ body { | ||||
|   font-family: Verdana, sans-serif; | ||||
|   font-weight: 600; | ||||
|   font-size: 22px; | ||||
|   border: 2px solid rgba(34, 44, 49, 1); | ||||
|   border: 2px solid rgb(34 44 49 / 100%); | ||||
|   margin-left: 17%; | ||||
|   width: 35px; | ||||
|   border-radius: 4px; | ||||
| @@ -203,7 +195,7 @@ body { | ||||
|  | ||||
| .tab.active .server-tab { | ||||
|   opacity: 1; | ||||
|   background-color: rgba(100, 132, 120, 1); | ||||
|   background-color: rgb(100 132 120 / 100%); | ||||
| } | ||||
|  | ||||
| .tab.functional-tab { | ||||
| @@ -214,7 +206,7 @@ body { | ||||
| .tab.functional-tab.active .server-tab { | ||||
|   padding: 2px 0; | ||||
|   height: 40px; | ||||
|   background-color: rgba(255, 255, 255, 0.25); | ||||
|   background-color: rgb(255 255 255 / 25%); | ||||
| } | ||||
|  | ||||
| .tab.functional-tab .server-tab i { | ||||
| @@ -227,14 +219,14 @@ body { | ||||
|   min-width: 11px; | ||||
|   padding: 0 3px; | ||||
|   height: 17px; | ||||
|   background-color: rgba(244, 67, 54, 1); | ||||
|   background-color: rgb(244 67 54 / 100%); | ||||
|   font-size: 10px; | ||||
|   font-family: sans-serif; | ||||
|   position: absolute; | ||||
|   z-index: 15; | ||||
|   top: 6px; | ||||
|   float: right; | ||||
|   color: rgba(255, 255, 255, 1); | ||||
|   color: rgb(255 255 255 / 100%); | ||||
|   text-align: center; | ||||
|   line-height: 17px; | ||||
|   display: block; | ||||
| @@ -262,7 +254,7 @@ body { | ||||
| } | ||||
|  | ||||
| .tab .server-tab-shortcut { | ||||
|   color: rgba(100, 132, 120, 1); | ||||
|   color: rgb(100 132 120 / 100%); | ||||
|   font-size: 12px; | ||||
|   text-align: center; | ||||
|   font-family: sans-serif; | ||||
| @@ -298,7 +290,9 @@ body { | ||||
|   content: ""; | ||||
|   position: absolute; | ||||
|   z-index: 1; | ||||
|   background: rgba(255, 255, 255, 1) url(../img/ic_loading.gif) no-repeat; | ||||
|  | ||||
|   /* Spinner is released under loading.io free License: https://loading.io/license/#free-license */ | ||||
|   background: rgb(255 255 255 / 100%) url("../img/ic_loading.svg") no-repeat; | ||||
|   background-size: 60px 60px; | ||||
|   background-position: center; | ||||
|   width: 100%; | ||||
| @@ -307,39 +301,62 @@ body { | ||||
|  | ||||
| /* When the active webview is loaded */ | ||||
| #webviews-container.loaded::before { | ||||
|   opacity: 0; | ||||
|   z-index: -1; | ||||
|   visibility: hidden; | ||||
| } | ||||
|  | ||||
| webview { | ||||
|   /* transition: opacity 0.3s ease-in; */ | ||||
| .webview-pane, | ||||
| .functional-view { | ||||
|   position: absolute; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   flex-grow: 1; | ||||
|   visibility: hidden; | ||||
| } | ||||
|  | ||||
| .webview-pane { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
| } | ||||
|  | ||||
| webview.onload { | ||||
|   transition: opacity 1s cubic-bezier(0.95, 0.05, 0.795, 0.035); | ||||
| .webview-pane > webview { | ||||
|   flex: 1; | ||||
| } | ||||
|  | ||||
| webview.active { | ||||
|   opacity: 1; | ||||
| .webview-pane.active, | ||||
| .functional-view.active { | ||||
|   z-index: 1; | ||||
|   visibility: visible; | ||||
| } | ||||
|  | ||||
| webview.disabled { | ||||
|   opacity: 0; | ||||
| } | ||||
|  | ||||
| webview.focus { | ||||
|   outline: 0 solid transparent; | ||||
| } | ||||
|  | ||||
| .webview-unsupported { | ||||
|   background: rgb(254 243 199); | ||||
|   border: 1px solid rgb(253 230 138); | ||||
|   color: rgb(69 26 3); | ||||
|   font-family: system-ui; | ||||
|   font-size: 14px; | ||||
|   display: flex; | ||||
| } | ||||
|  | ||||
| .webview-unsupported[hidden] { | ||||
|   display: none; | ||||
| } | ||||
|  | ||||
| .webview-unsupported-message { | ||||
|   padding: 0.3em; | ||||
|   flex: 1; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .webview-unsupported-dismiss { | ||||
|   padding: 0.3em; | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| /* Tooltip styling */ | ||||
|  | ||||
| #loading-tooltip, | ||||
| @@ -348,13 +365,13 @@ webview.focus { | ||||
| #reload-tooltip, | ||||
| #setting-tooltip { | ||||
|   font-family: sans-serif; | ||||
|   background: rgba(34, 44, 49, 1); | ||||
|   background: rgb(34 44 49 / 100%); | ||||
|   margin-left: 48px; | ||||
|   padding: 6px 8px; | ||||
|   position: absolute; | ||||
|   margin-top: 0; | ||||
|   z-index: 1000; | ||||
|   color: rgba(255, 255, 255, 1); | ||||
|   color: rgb(255 255 255 / 100%); | ||||
|   border-radius: 4px; | ||||
|   text-align: center; | ||||
|   width: 55px; | ||||
| @@ -369,7 +386,7 @@ webview.focus { | ||||
|   content: " "; | ||||
|   border-top: 8px solid transparent; | ||||
|   border-bottom: 8px solid transparent; | ||||
|   border-right: 8px solid rgba(34, 44, 49, 1); | ||||
|   border-right: 8px solid rgb(34 44 49 / 100%); | ||||
|   position: absolute; | ||||
|   top: 7px; | ||||
|   right: 68px; | ||||
| @@ -377,14 +394,14 @@ webview.focus { | ||||
|  | ||||
| #add-server-tooltip, | ||||
| .server-tooltip { | ||||
|   font-family: "arial", sans-serif; | ||||
|   background: rgba(34, 44, 49, 1); | ||||
|   font-family: arial, sans-serif; | ||||
|   background: rgb(34 44 49 / 100%); | ||||
|   left: 56px; | ||||
|   padding: 10px 20px; | ||||
|   position: fixed; | ||||
|   margin-top: 11px; | ||||
|   z-index: 5000 !important; | ||||
|   color: rgba(255, 255, 255, 1); | ||||
|   color: rgb(255 255 255 / 100%); | ||||
|   border-radius: 4px; | ||||
|   text-align: center; | ||||
|   width: max-content; | ||||
| @@ -396,7 +413,7 @@ webview.focus { | ||||
|   content: " "; | ||||
|   border-top: 8px solid transparent; | ||||
|   border-bottom: 8px solid transparent; | ||||
|   border-right: 8px solid rgba(34, 44, 49, 1); | ||||
|   border-right: 8px solid rgb(34 44 49 / 100%); | ||||
|   position: absolute; | ||||
|   top: 10px; | ||||
|   left: -5px; | ||||
| @@ -408,14 +425,14 @@ webview.focus { | ||||
|   position: absolute; | ||||
|   width: 24px; | ||||
|   height: 24px; | ||||
|   background: rgba(34, 44, 49, 1); | ||||
|   background: rgb(34 44 49 / 100%); | ||||
|   border-radius: 20px; | ||||
|   cursor: pointer; | ||||
|   box-shadow: rgba(153, 153, 153, 1) 1px 1px; | ||||
|   box-shadow: rgb(153 153 153 / 100%) 1px 1px; | ||||
| } | ||||
|  | ||||
| #collapse-button i { | ||||
|   color: rgba(239, 239, 239, 1); | ||||
|   color: rgb(239 239 239 / 100%); | ||||
| } | ||||
|  | ||||
| #main-container { | ||||
| @@ -435,8 +452,8 @@ webview.focus { | ||||
|  | ||||
| .popup .popuptext { | ||||
|   visibility: hidden; | ||||
|   background-color: rgba(85, 85, 85, 1); | ||||
|   color: rgba(255, 255, 255, 1); | ||||
|   background-color: rgb(85 85 85 / 100%); | ||||
|   color: rgb(255 255 255 / 100%); | ||||
|   text-align: center; | ||||
|   border-radius: 6px; | ||||
|   padding: 9px 0; | ||||
| @@ -451,11 +468,11 @@ webview.focus { | ||||
|  | ||||
| .popup .show { | ||||
|   visibility: visible; | ||||
|   animation: cssAnimation 0s ease-in 5s forwards; | ||||
|   animation: full-screen-popup 0s ease-in 1s forwards; | ||||
|   animation-fill-mode: forwards; | ||||
| } | ||||
|  | ||||
| @keyframes cssAnimation { | ||||
| @keyframes full-screen-popup { | ||||
|   from { | ||||
|     opacity: 0; | ||||
|   } | ||||
| @@ -467,26 +484,3 @@ webview.focus { | ||||
|     opacity: 1; | ||||
|   } | ||||
| } | ||||
|  | ||||
| send-feedback { | ||||
|   width: 60%; | ||||
|   height: 85%; | ||||
| } | ||||
|  | ||||
| #feedback-modal { | ||||
|   display: none; | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   background-color: rgba(68, 67, 67, 0.81); | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   z-index: 2; | ||||
|   transition: all 1s ease-out; | ||||
| } | ||||
|  | ||||
| #feedback-modal.show { | ||||
|   display: flex; | ||||
| } | ||||
|   | ||||
| @@ -3,8 +3,8 @@ body { | ||||
|   margin: 0; | ||||
|   cursor: default; | ||||
|   font-size: 14px; | ||||
|   color: rgba(51, 51, 51, 1); | ||||
|   background: rgba(255, 255, 255, 1); | ||||
|   color: rgb(51 51 51 / 100%); | ||||
|   background: rgb(255 255 255 / 100%); | ||||
|   user-select: none; | ||||
| } | ||||
|  | ||||
| @@ -45,8 +45,8 @@ body { | ||||
|  | ||||
| .button { | ||||
|   font-size: 16px; | ||||
|   background: rgba(0, 150, 136, 1); | ||||
|   color: rgba(255, 255, 255, 1); | ||||
|   background: rgb(0 150 136 / 100%); | ||||
|   color: rgb(255 255 255 / 100%); | ||||
|   width: 96px; | ||||
|   height: 32px; | ||||
|   border-radius: 5px; | ||||
|   | ||||
| @@ -1,31 +1,37 @@ | ||||
| html, | ||||
| body { | ||||
|   height: 100%; | ||||
|   margin: 0; | ||||
| @import url("@yaireo/tagify/dist/tagify.css"); | ||||
|  | ||||
| :host { | ||||
|   contain: strict; | ||||
|   display: flow-root; | ||||
|   cursor: default; | ||||
|   user-select: none; | ||||
|   font-family: menu, "Helvetica Neue", sans-serif; | ||||
|   -webkit-font-smoothing: antialiased; | ||||
|   font-size: 14px; | ||||
|   background: rgba(239, 239, 239, 1); | ||||
|   background: rgb(239 239 239 / 100%); | ||||
|   letter-spacing: -0.08px; | ||||
|   line-height: 18px; | ||||
|   color: rgba(139, 142, 143, 1); | ||||
|   color: rgb(139 142 143 / 100%); | ||||
|  | ||||
|   /* Copied from https://github.com/yairEO/tagify/blob/v4.17.7/src/tagify.scss#L4-L8 */ | ||||
|   --tagify-dd-color-primary: rgb(53 149 246); | ||||
|   --tagify-dd-bg-color: rgb(255 255 255); | ||||
|   --tagify-dd-item-pad: 0.3em 0.5em; | ||||
| } | ||||
|  | ||||
| kbd { | ||||
|   display: inline-block; | ||||
|   border: 1px solid rgba(204, 204, 204, 1); | ||||
|   border: 1px solid rgb(204 204 204 / 100%); | ||||
|   border-radius: 4px; | ||||
|   font-size: 15px; | ||||
|   font-family: Courier New, Courier, monospace; | ||||
|   font-family: "Courier New", Courier, monospace; | ||||
|   font-weight: bold; | ||||
|   white-space: nowrap; | ||||
|   background-color: rgba(247, 247, 247, 1); | ||||
|   color: rgba(51, 51, 51, 1); | ||||
|   background-color: rgb(247 247 247 / 100%); | ||||
|   color: rgb(51 51 51 / 100%); | ||||
|   margin: 0 0.1em; | ||||
|   padding: 0.3em 0.8em; | ||||
|   text-shadow: 0 1px 0 rgba(255, 255, 255, 1); | ||||
|   text-shadow: 0 1px 0 rgb(255 255 255 / 100%); | ||||
|   line-height: 1.4; | ||||
| } | ||||
|  | ||||
| @@ -33,7 +39,7 @@ table, | ||||
| th, | ||||
| td { | ||||
|   border-collapse: collapse; | ||||
|   color: rgba(56, 52, 48, 1); | ||||
|   color: rgb(56 52 48 / 100%); | ||||
| } | ||||
|  | ||||
| table { | ||||
| @@ -51,19 +57,6 @@ td:nth-child(odd) { | ||||
|   width: 50%; | ||||
| } | ||||
|  | ||||
| @font-face { | ||||
|   font-family: "Material Icons"; | ||||
|   font-style: normal; | ||||
|   font-weight: 400; | ||||
|   src: local("Material Icons"), local("MaterialIcons-Regular"), | ||||
|     url(../fonts/MaterialIcons-Regular.ttf) format("truetype"); | ||||
| } | ||||
|  | ||||
| @font-face { | ||||
|   font-family: "Montserrat"; | ||||
|   src: url(../fonts/Montserrat-Regular.ttf) format("truetype"); | ||||
| } | ||||
|  | ||||
| .material-icons { | ||||
|   font-family: "Material Icons"; | ||||
|   font-weight: normal; | ||||
| @@ -83,13 +76,13 @@ td:nth-child(odd) { | ||||
|   -webkit-font-smoothing: antialiased; | ||||
|  | ||||
|   /* Support for Safari and Chrome. */ | ||||
|   text-rendering: optimizeLegibility; | ||||
|   text-rendering: optimizelegibility; | ||||
| } | ||||
|  | ||||
| #content { | ||||
|   display: flex; | ||||
|   display: flex !important; | ||||
|   height: 100%; | ||||
|   font-family: "Montserrat", sans-serif; | ||||
|   font-family: Montserrat, sans-serif; | ||||
| } | ||||
|  | ||||
| #sidebar { | ||||
| @@ -99,7 +92,7 @@ td:nth-child(odd) { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   font-size: 16px; | ||||
|   background: rgba(242, 242, 242, 1); | ||||
|   background: rgb(242 242 242 / 100%); | ||||
| } | ||||
|  | ||||
| #nav-container { | ||||
| @@ -108,18 +101,18 @@ td:nth-child(odd) { | ||||
|  | ||||
| .nav { | ||||
|   padding: 7px 0; | ||||
|   color: rgba(153, 153, 153, 1); | ||||
|   color: rgb(153 153 153 / 100%); | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| .nav.active { | ||||
|   color: rgba(78, 191, 172, 1); | ||||
|   color: rgb(78 191 172 / 100%); | ||||
|   cursor: default; | ||||
|   position: relative; | ||||
| } | ||||
|  | ||||
| .nav.active::before { | ||||
|   background: rgba(70, 78, 90, 1); | ||||
|   background: rgb(70 78 90 / 100%); | ||||
|   width: 3px; | ||||
|   height: 18px; | ||||
|   position: absolute; | ||||
| @@ -129,13 +122,14 @@ td:nth-child(odd) { | ||||
|  | ||||
| /* We don't want to show this in nav item since we have the + button for adding an Organization */ | ||||
|  | ||||
| /* stylelint-disable-next-line selector-id-pattern */ | ||||
| #nav-AddServer { | ||||
|   display: none; | ||||
| } | ||||
|  | ||||
| #settings-header { | ||||
|   font-size: 22px; | ||||
|   color: rgba(34, 44, 49, 1); | ||||
|   color: rgb(34 44 49 / 100%); | ||||
|   font-weight: bold; | ||||
|   text-transform: uppercase; | ||||
| } | ||||
| @@ -155,19 +149,19 @@ td:nth-child(odd) { | ||||
|  | ||||
| .title { | ||||
|   font-weight: 500; | ||||
|   color: rgba(34, 44, 49, 1); | ||||
|   color: rgb(34 44 49 / 100%); | ||||
| } | ||||
|  | ||||
| .page-title { | ||||
|   color: rgba(34, 44, 49, 1); | ||||
|   color: rgb(34 44 49 / 100%); | ||||
|   font-size: 15px; | ||||
|   font-weight: bold; | ||||
|   padding: 4px 0 6px 0; | ||||
|   padding: 4px 0 6px; | ||||
| } | ||||
|  | ||||
| .add-server-info-row { | ||||
|   display: flex; | ||||
|   margin: 8px 0 0 0; | ||||
|   margin: 8px 0 0; | ||||
| } | ||||
|  | ||||
| .add-server-info-right { | ||||
| @@ -176,9 +170,9 @@ td:nth-child(odd) { | ||||
| } | ||||
|  | ||||
| .sub-title { | ||||
|   padding: 4px 0 6px 0; | ||||
|   padding: 4px 0 6px; | ||||
|   font-weight: bold; | ||||
|   color: rgba(97, 97, 97, 1); | ||||
|   color: rgb(97 97 97 / 100%); | ||||
| } | ||||
|  | ||||
| img.server-info-icon { | ||||
| @@ -205,7 +199,7 @@ img.server-info-icon { | ||||
|  | ||||
| .server-info-row { | ||||
|   display: inline-block; | ||||
|   margin: 5px 0 0 0; | ||||
|   margin: 5px 0 0; | ||||
| } | ||||
|  | ||||
| .server-info-left .server-info-row { | ||||
| @@ -245,18 +239,18 @@ img.server-info-icon { | ||||
|   font-size: 14px; | ||||
|   border-radius: 4px; | ||||
|   padding: 13px; | ||||
|   border: rgba(237, 237, 237, 1) 2px solid; | ||||
|   border: rgb(237 237 237 / 100%) 2px solid; | ||||
|   outline-width: 0; | ||||
|   background: transparent; | ||||
|   max-width: 450px; | ||||
| } | ||||
|  | ||||
| .setting-input-value:focus { | ||||
|   border: rgba(78, 191, 172, 1) 2px solid; | ||||
|   border: rgb(78 191 172 / 100%) 2px solid; | ||||
| } | ||||
|  | ||||
| .invalid-input-value:focus { | ||||
|   border: rgba(239, 83, 80, 1) 2px solid; | ||||
|   border: rgb(239 83 80 / 100%) 2px solid; | ||||
| } | ||||
|  | ||||
| .manual-proxy-block { | ||||
| @@ -266,7 +260,7 @@ img.server-info-icon { | ||||
| .actions-container { | ||||
|   display: flex; | ||||
|   font-size: 14px; | ||||
|   color: rgba(35, 93, 58, 1); | ||||
|   color: rgb(35 93 58 / 100%); | ||||
|   vertical-align: middle; | ||||
|   margin: 10px 0; | ||||
|   flex-wrap: wrap; | ||||
| @@ -295,7 +289,7 @@ img.server-info-icon { | ||||
| } | ||||
|  | ||||
| .action.disabled { | ||||
|   color: rgba(153, 153, 153, 1); | ||||
|   color: rgb(153 153 153 / 100%); | ||||
| } | ||||
|  | ||||
| .action.disabled:hover { | ||||
| @@ -306,14 +300,16 @@ img.server-info-icon { | ||||
|   display: flex; | ||||
|   flex-wrap: wrap; | ||||
|   padding: 12px 30px; | ||||
|   margin: 10px 0 20px 0; | ||||
|   background: rgba(255, 255, 255, 1); | ||||
|   margin: 10px 0 20px; | ||||
|   background: rgb(255 255 255 / 100%); | ||||
|   width: 80%; | ||||
|   transition: all 0.2s; | ||||
| } | ||||
|  | ||||
| .settings-card:hover { | ||||
|   box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 0 0 rgba(0, 0, 0, 0.12); | ||||
|   box-shadow: | ||||
|     0 2px 5px 0 rgb(0 0 0 / 16%), | ||||
|     0 2px 0 0 rgb(0 0 0 / 12%); | ||||
| } | ||||
|  | ||||
| .hidden { | ||||
| @@ -322,11 +318,11 @@ img.server-info-icon { | ||||
| } | ||||
|  | ||||
| .red { | ||||
|   color: rgb(240, 148, 148); | ||||
|   background: rgba(255, 255, 255, 1); | ||||
|   color: rgb(240 148 148); | ||||
|   background: rgb(255 255 255 / 100%); | ||||
|   border-radius: 4px; | ||||
|   display: inline-block; | ||||
|   border: 2px solid rgb(240, 148, 148); | ||||
|   border: 2px solid rgb(240 148 148); | ||||
|   padding: 10px; | ||||
|   width: 100px; | ||||
|   cursor: pointer; | ||||
| @@ -338,13 +334,13 @@ img.server-info-icon { | ||||
| } | ||||
|  | ||||
| .red:hover { | ||||
|   background-color: rgb(240, 148, 148); | ||||
|   color: rgba(255, 255, 255, 1); | ||||
|   background-color: rgb(240 148 148); | ||||
|   color: rgb(255 255 255 / 100%); | ||||
| } | ||||
|  | ||||
| .green { | ||||
|   color: rgba(255, 255, 255, 1); | ||||
|   background: rgba(78, 191, 172, 1); | ||||
|   color: rgb(255 255 255 / 100%); | ||||
|   background: rgb(78 191 172 / 100%); | ||||
|   border-radius: 4px; | ||||
|   display: inline-block; | ||||
|   border: none; | ||||
| @@ -359,8 +355,8 @@ img.server-info-icon { | ||||
| } | ||||
|  | ||||
| .green:hover { | ||||
|   background-color: rgba(60, 159, 141, 1); | ||||
|   color: rgba(255, 255, 255, 1); | ||||
|   background-color: rgb(60 159 141 / 100%); | ||||
|   color: rgb(255 255 255 / 100%); | ||||
| } | ||||
|  | ||||
| .w-150 { | ||||
| @@ -372,9 +368,9 @@ img.server-info-icon { | ||||
| } | ||||
|  | ||||
| .grey { | ||||
|   color: rgba(158, 158, 158, 1); | ||||
|   background: rgba(250, 250, 250, 1); | ||||
|   border: 1px solid rgba(158, 158, 158, 1); | ||||
|   color: rgb(158 158 158 / 100%); | ||||
|   background: rgb(250 250 250 / 100%); | ||||
|   border: 1px solid rgb(158 158 158 / 100%); | ||||
| } | ||||
|  | ||||
| .setting-row { | ||||
| @@ -390,7 +386,7 @@ img.server-info-icon { | ||||
| } | ||||
|  | ||||
| .code { | ||||
|   font-family: Courier New, Courier, monospace; | ||||
|   font-family: "Courier New", Courier, monospace; | ||||
| } | ||||
|  | ||||
| i.open-tab-button { | ||||
| @@ -414,7 +410,7 @@ i.open-tab-button { | ||||
|  | ||||
| .selected-css-path, | ||||
| .download-folder-path { | ||||
|   background: rgba(238, 238, 238, 1); | ||||
|   background: rgb(238 238 238 / 100%); | ||||
|   padding: 5px 10px; | ||||
|   margin-right: 10px; | ||||
|   display: flex; | ||||
| @@ -431,7 +427,7 @@ i.open-tab-button { | ||||
| } | ||||
|  | ||||
| #new-org-button { | ||||
|   margin: 30px 0 30px 0; | ||||
|   margin: 30px 0; | ||||
| } | ||||
|  | ||||
| #create-organization-container { | ||||
| @@ -463,7 +459,7 @@ i.open-tab-button { | ||||
| } | ||||
|  | ||||
| .disallowed:hover { | ||||
|   background-color: rgba(241, 241, 241, 1); | ||||
|   background-color: rgb(241 241 241 / 100%); | ||||
|   cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| @@ -471,7 +467,7 @@ input.toggle-round + label { | ||||
|   padding: 2px; | ||||
|   width: 50px; | ||||
|   height: 25px; | ||||
|   background-color: rgba(221, 221, 221, 1); | ||||
|   background-color: rgb(221 221 221 / 100%); | ||||
|   border-radius: 25px; | ||||
| } | ||||
|  | ||||
| @@ -486,27 +482,21 @@ input.toggle-round + label::after { | ||||
| } | ||||
|  | ||||
| input.toggle-round + label::before { | ||||
|   background-color: rgba(241, 241, 241, 1); | ||||
|   background-color: rgb(241 241 241 / 100%); | ||||
|   border-radius: 25px; | ||||
|   top: 0; | ||||
|   right: 0; | ||||
|   left: 0; | ||||
|   bottom: 0; | ||||
|   inset: 0; | ||||
| } | ||||
|  | ||||
| input.toggle-round + label::after { | ||||
|   width: 25px; | ||||
|   height: 25px; | ||||
|   background-color: rgba(255, 255, 255, 1); | ||||
|   background-color: rgb(255 255 255 / 100%); | ||||
|   border-radius: 100%; | ||||
| } | ||||
|  | ||||
| input.toggle-round:checked + label::before { | ||||
|   background-color: rgba(78, 191, 172, 1); | ||||
|   top: 0; | ||||
|   right: 0; | ||||
|   left: 0; | ||||
|   bottom: 0; | ||||
|   background-color: rgb(78 191 172 / 100%); | ||||
|   inset: 0; | ||||
| } | ||||
|  | ||||
| input.toggle-round:checked + label::after { | ||||
| @@ -527,17 +517,21 @@ input.toggle-round:checked + label::after { | ||||
|   height: 100%; | ||||
|  | ||||
|   /* background: rgba(61, 64, 67, 15); */ | ||||
|   background: linear-gradient(35deg, rgba(0, 59, 82, 1), rgba(69, 181, 155, 1)); | ||||
|   background: linear-gradient( | ||||
|     35deg, | ||||
|     rgb(0 59 82 / 100%), | ||||
|     rgb(69 181 155 / 100%) | ||||
|   ); | ||||
|   overflow: auto; | ||||
| } | ||||
|  | ||||
| /* Modal Content */ | ||||
|  | ||||
| .modal-container { | ||||
|   background-color: rgba(244, 247, 248, 1); | ||||
|   background-color: rgb(244 247 248 / 100%); | ||||
|   margin: auto; | ||||
|   padding: 57px; | ||||
|   border: rgba(218, 225, 227, 1) 1px solid; | ||||
|   border: rgb(218 225 227 / 100%) 1px solid; | ||||
|   width: 550px; | ||||
|   height: 370px; | ||||
|   border-radius: 4px; | ||||
| @@ -551,7 +545,7 @@ input.toggle-round:checked + label::after { | ||||
| .divider { | ||||
|   margin-bottom: 30px; | ||||
|   margin-top: 30px; | ||||
|   color: rgba(125, 135, 138, 1); | ||||
|   color: rgb(125 135 138 / 100%); | ||||
| } | ||||
|  | ||||
| .divider hr { | ||||
| @@ -582,8 +576,8 @@ input.toggle-round:checked + label::after { | ||||
|   margin: auto; | ||||
|   align-items: center; | ||||
|   text-align: center; | ||||
|   color: rgba(255, 255, 255, 1); | ||||
|   background: rgba(78, 191, 172, 1); | ||||
|   color: rgb(255 255 255 / 100%); | ||||
|   background: rgb(78 191 172 / 100%); | ||||
|   border-color: none; | ||||
|   border: none; | ||||
|   width: 98%; | ||||
| @@ -593,11 +587,11 @@ input.toggle-round:checked + label::after { | ||||
| } | ||||
|  | ||||
| .server-center button:hover { | ||||
|   background: rgba(50, 149, 136, 1); | ||||
|   background: rgb(50 149 136 / 100%); | ||||
| } | ||||
|  | ||||
| .server-center button:focus { | ||||
|   background: rgba(50, 149, 136, 1); | ||||
|   background: rgb(50 149 136 / 100%); | ||||
| } | ||||
|  | ||||
| .certificates-card { | ||||
| @@ -646,7 +640,7 @@ input.toggle-round:checked + label::after { | ||||
|   padding-top: 15px; | ||||
|   align-items: center; | ||||
|   text-align: center; | ||||
|   color: rgb(78, 191, 172); | ||||
|   color: rgb(78 191 172); | ||||
|   width: 98%; | ||||
|   height: 46px; | ||||
|   cursor: pointer; | ||||
| @@ -660,7 +654,7 @@ i.open-network-button { | ||||
| } | ||||
|  | ||||
| /* responsive grid */ | ||||
| @media (min-width: 500px) and (max-width: 720px) { | ||||
| @media (width >= 500px) and (width <= 720px) { | ||||
|   #new-server-container { | ||||
|     padding-left: 0; | ||||
|     width: 60vw; | ||||
| @@ -672,7 +666,7 @@ i.open-network-button { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (max-width: 500px) { | ||||
| @media (width <= 500px) { | ||||
|   #new-server-container { | ||||
|     padding-left: 0; | ||||
|     width: 54%; | ||||
| @@ -683,7 +677,7 @@ i.open-network-button { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (max-width: 650px) { | ||||
| @media (width <= 650px) { | ||||
|   .selected-css-path, | ||||
|   .download-folder-path { | ||||
|     margin-right: 15px; | ||||
| @@ -698,7 +692,7 @@ i.open-network-button { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (max-width: 720px) { | ||||
| @media (width <= 720px) { | ||||
|   .modal-container { | ||||
|     width: 60vw; | ||||
|     padding: 40px; | ||||
| @@ -721,7 +715,7 @@ i.open-network-button { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (max-width: 600px) { | ||||
| @media (width <= 600px) { | ||||
|   .divider { | ||||
|     margin-left: 4%; | ||||
|   } | ||||
| @@ -733,7 +727,7 @@ i.open-network-button { | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (max-width: 900px) { | ||||
| @media (width <= 900px) { | ||||
|   .settings-card { | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
| @@ -752,18 +746,26 @@ i.open-network-button { | ||||
| .lang-menu { | ||||
|   font-size: 13px; | ||||
|   font-weight: bold; | ||||
|   background: rgba(78, 191, 172, 1); | ||||
|   background: rgb(78 191 172 / 100%); | ||||
|   width: 100px; | ||||
|   height: 38px; | ||||
|   color: rgba(255, 255, 255, 1); | ||||
|   border-color: rgba(0, 0, 0, 0); | ||||
|   color: rgb(255 255 255 / 100%); | ||||
|   border-color: rgb(0 0 0 / 0%); | ||||
| } | ||||
|  | ||||
| /* stylelint-disable-next-line selector-class-pattern */ | ||||
| .tagify__input { | ||||
|   min-width: 130px !important; | ||||
| } | ||||
|  | ||||
| /* stylelint-disable-next-line selector-class-pattern */ | ||||
| .tagify__input::before { | ||||
|   top: 0; | ||||
|   bottom: 0; | ||||
| } | ||||
|  | ||||
| .settings-tagify-dropdown { | ||||
|   position: relative; | ||||
|   z-index: 9999; | ||||
|   height: 0; | ||||
| } | ||||
|   | ||||
| Before Width: | Height: | Size: 22 KiB | 
							
								
								
									
										8
									
								
								app/renderer/img/ic_loading.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,8 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin: auto; background: rgba(0, 0, 0, 0) none repeat scroll 0% 0%; display: block; shape-rendering: auto; animation-play-state: running; animation-delay: 0s;" width="150px" height="150px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid"> | ||||
| <circle cx="50" cy="50" fill="none" stroke="#759ed4" stroke-width="10" r="42" stroke-dasharray="197.92033717615698 67.97344572538566" style="animation-play-state: running; animation-delay: 0s;"> | ||||
|   <animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;360 50 50" keyTimes="0;1" style="animation-play-state: running; animation-delay: 0s;"></animateTransform> | ||||
| </circle> | ||||
| <!-- Created with loading.io (https://loading.io/spinner/rolling/-bar-circle-curve-round-rotate) --> | ||||
| <!-- "The Rolling spinner is released under loading.io free License." (https://loading.io/license/#free-license) --> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 1018 B | 
| @@ -1,5 +1,4 @@ | ||||
| import crypto from "crypto"; | ||||
| import {clipboard} from "electron"; | ||||
| import {ipcRenderer} from "./typed-ipc-renderer.js"; | ||||
|  | ||||
| // This helper is exposed via electron_bridge for use in the social | ||||
| // login flow. | ||||
| @@ -15,6 +14,12 @@ import {clipboard} from "electron"; | ||||
| // don’t leak anything from the user’s clipboard other than the token | ||||
| // intended for us. | ||||
|  | ||||
| export type ClipboardDecrypter = { | ||||
|   version: number; | ||||
|   key: Uint8Array; | ||||
|   pasted: Promise<string>; | ||||
| }; | ||||
|  | ||||
| export class ClipboardDecrypterImpl implements ClipboardDecrypter { | ||||
|   version: number; | ||||
|   key: Uint8Array; | ||||
| @@ -23,7 +28,8 @@ export class ClipboardDecrypterImpl implements ClipboardDecrypter { | ||||
|   constructor(_: number) { | ||||
|     // At this time, the only version is 1. | ||||
|     this.version = 1; | ||||
|     this.key = crypto.randomBytes(32); | ||||
|     const {key, sig} = ipcRenderer.sendSync("new-clipboard-key"); | ||||
|     this.key = key; | ||||
|     this.pasted = new Promise((resolve) => { | ||||
|       let interval: NodeJS.Timeout | null = null; | ||||
|       const startPolling = () => { | ||||
| @@ -31,7 +37,7 @@ export class ClipboardDecrypterImpl implements ClipboardDecrypter { | ||||
|           interval = setInterval(poll, 1000); | ||||
|         } | ||||
|  | ||||
|         poll(); | ||||
|         void poll(); | ||||
|       }; | ||||
|  | ||||
|       const stopPolling = () => { | ||||
| @@ -41,30 +47,9 @@ export class ClipboardDecrypterImpl implements ClipboardDecrypter { | ||||
|         } | ||||
|       }; | ||||
|  | ||||
|       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 hasn’t been copied yet; try | ||||
|           // again next time. | ||||
|           return; | ||||
|         } | ||||
|       const poll = async () => { | ||||
|         const plaintext = await ipcRenderer.invoke("poll-clipboard", key, sig); | ||||
|         if (plaintext === undefined) return; | ||||
|  | ||||
|         window.removeEventListener("focus", startPolling); | ||||
|         window.removeEventListener("blur", stopPolling); | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import type {HTML} from "../../../common/html"; | ||||
| import type {Html} from "../../../common/html.js"; | ||||
|  | ||||
| export function generateNodeFromHTML(html: HTML): Element { | ||||
| export function generateNodeFromHtml(html: Html): Element { | ||||
|   const wrapper = document.createElement("div"); | ||||
|   wrapper.innerHTML = html.html; | ||||
|  | ||||
|   | ||||
| @@ -1,18 +1,24 @@ | ||||
| import type {ContextMenuParams} from "electron"; | ||||
| import {remote} from "electron"; | ||||
| import type {Event} from "electron/common"; | ||||
| import {clipboard} from "electron/common"; | ||||
| import type {WebContents} from "electron/main"; | ||||
| import type { | ||||
|   ContextMenuParams, | ||||
|   MenuItemConstructorOptions, | ||||
| } from "electron/renderer"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| import * as t from "../../../common/translation-util"; | ||||
| import {Menu} from "@electron/remote"; | ||||
|  | ||||
| const {clipboard, Menu} = remote; | ||||
| import * as t from "../../../common/translation-util.js"; | ||||
|  | ||||
| export const contextMenu = ( | ||||
|   webContents: Electron.WebContents, | ||||
|   webContents: WebContents, | ||||
|   event: Event, | ||||
|   props: ContextMenuParams, | ||||
| ) => { | ||||
|   const isText = props.selectionText !== ""; | ||||
|   const isLink = props.linkURL !== ""; | ||||
|   const linkURL = isLink ? new URL(props.linkURL) : undefined; | ||||
|   const linkUrl = isLink ? new URL(props.linkURL) : undefined; | ||||
|  | ||||
|   const makeSuggestion = (suggestion: string) => ({ | ||||
|     label: suggestion, | ||||
| @@ -22,7 +28,7 @@ export const contextMenu = ( | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
|   let menuTemplate: Electron.MenuItemConstructorOptions[] = [ | ||||
|   let menuTemplate: MenuItemConstructorOptions[] = [ | ||||
|     { | ||||
|       label: t.__("Add to Dictionary"), | ||||
|       visible: props.isEditable && isText && props.misspelledWord.length > 0, | ||||
| @@ -77,7 +83,7 @@ export const contextMenu = ( | ||||
|     }, | ||||
|     { | ||||
|       label: | ||||
|         linkURL?.protocol === "mailto:" | ||||
|         linkUrl?.protocol === "mailto:" | ||||
|           ? t.__("Copy Email Address") | ||||
|           : t.__("Copy Link"), | ||||
|       visible: isLink, | ||||
| @@ -85,7 +91,7 @@ export const contextMenu = ( | ||||
|         clipboard.write({ | ||||
|           bookmark: props.linkText, | ||||
|           text: | ||||
|             linkURL?.protocol === "mailto:" ? linkURL.pathname : props.linkURL, | ||||
|             linkUrl?.protocol === "mailto:" ? linkUrl.pathname : props.linkURL, | ||||
|         }); | ||||
|       }, | ||||
|     }, | ||||
| @@ -119,9 +125,10 @@ export const contextMenu = ( | ||||
|  | ||||
|   if (props.misspelledWord) { | ||||
|     if (props.dictionarySuggestions.length > 0) { | ||||
|       const suggestions: Electron.MenuItemConstructorOptions[] = props.dictionarySuggestions.map( | ||||
|         (suggestion: string) => makeSuggestion(suggestion), | ||||
|       ); | ||||
|       const suggestions: MenuItemConstructorOptions[] = | ||||
|         props.dictionarySuggestions.map((suggestion: string) => | ||||
|           makeSuggestion(suggestion), | ||||
|         ); | ||||
|       menuTemplate = [...suggestions, ...menuTemplate]; | ||||
|     } else { | ||||
|       menuTemplate.unshift({ | ||||
| @@ -131,7 +138,7 @@ export const contextMenu = ( | ||||
|     } | ||||
|   } | ||||
|   // Hide the invisible separators on Linux and Windows | ||||
|   // Electron has a bug which ignores visible: false parameter for separator menuitems. So we remove them here. | ||||
|   // Electron has a bug which ignores visible: false parameter for separator menu items. So we remove them here. | ||||
|   // https://github.com/electron/electron/issues/5869 | ||||
|   // https://github.com/electron/electron/issues/6906 | ||||
|  | ||||
|   | ||||
| @@ -1,18 +1,24 @@ | ||||
| import type {HTML} from "../../../common/html"; | ||||
| import {html} from "../../../common/html"; | ||||
| import type {Html} from "../../../common/html.js"; | ||||
| import {html} from "../../../common/html.js"; | ||||
|  | ||||
| import {generateNodeFromHTML} from "./base"; | ||||
| import type {TabProps} from "./tab"; | ||||
| import Tab from "./tab"; | ||||
| import {generateNodeFromHtml} from "./base.js"; | ||||
| import type {TabProps} from "./tab.js"; | ||||
| import Tab from "./tab.js"; | ||||
|  | ||||
| export type FunctionalTabProps = { | ||||
|   $view: Element; | ||||
| } & TabProps; | ||||
|  | ||||
| export default class FunctionalTab extends Tab { | ||||
|   $view: Element; | ||||
|   $el: Element; | ||||
|   $closeButton?: Element; | ||||
|  | ||||
|   constructor(props: TabProps) { | ||||
|   constructor({$view, ...props}: FunctionalTabProps) { | ||||
|     super(props); | ||||
|  | ||||
|     this.$el = generateNodeFromHTML(this.templateHTML()); | ||||
|     this.$view = $view; | ||||
|     this.$el = generateNodeFromHtml(this.templateHtml()); | ||||
|     if (this.props.name !== "Settings") { | ||||
|       this.props.$root.append(this.$el); | ||||
|       this.$closeButton = this.$el.querySelector(".server-tab-badge")!; | ||||
| @@ -20,7 +26,22 @@ export default class FunctionalTab extends Tab { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   templateHTML(): HTML { | ||||
|   override async activate(): Promise<void> { | ||||
|     await super.activate(); | ||||
|     this.$view.classList.add("active"); | ||||
|   } | ||||
|  | ||||
|   override async deactivate(): Promise<void> { | ||||
|     await super.deactivate(); | ||||
|     this.$view.classList.remove("active"); | ||||
|   } | ||||
|  | ||||
|   override async destroy(): Promise<void> { | ||||
|     await super.destroy(); | ||||
|     this.$view.remove(); | ||||
|   } | ||||
|  | ||||
|   templateHtml(): Html { | ||||
|     return html` | ||||
|       <div class="tab functional-tab" data-tab-id="${this.props.tabIndex}"> | ||||
|         <div class="server-tab-badge close-button"> | ||||
| @@ -33,7 +54,7 @@ export default class FunctionalTab extends Tab { | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   registerListeners(): void { | ||||
|   override registerListeners(): void { | ||||
|     super.registerListeners(); | ||||
|  | ||||
|     this.$el.addEventListener("mouseover", () => { | ||||
| @@ -44,7 +65,7 @@ export default class FunctionalTab extends Tab { | ||||
|       this.$closeButton?.classList.remove("active"); | ||||
|     }); | ||||
|  | ||||
|     this.$closeButton?.addEventListener("click", (event: Event) => { | ||||
|     this.$closeButton?.addEventListener("click", (event) => { | ||||
|       this.props.onDestroy?.(); | ||||
|       event.stopPropagation(); | ||||
|     }); | ||||
|   | ||||
| @@ -1,72 +0,0 @@ | ||||
| import {remote} from "electron"; | ||||
|  | ||||
| import * as ConfigUtil from "../../../common/config-util"; | ||||
| import {ipcRenderer} from "../typed-ipc-renderer"; | ||||
| import * as LinkUtil from "../utils/link-util"; | ||||
|  | ||||
| import type WebView from "./webview"; | ||||
|  | ||||
| const {shell, app} = remote; | ||||
|  | ||||
| const dingSound = new Audio("../resources/sounds/ding.ogg"); | ||||
|  | ||||
| export default function handleExternalLink( | ||||
|   this: WebView, | ||||
|   event: Electron.NewWindowEvent, | ||||
| ): void { | ||||
|   event.preventDefault(); | ||||
|  | ||||
|   const url = new URL(event.url); | ||||
|   const downloadPath = ConfigUtil.getConfigItem( | ||||
|     "downloadsPath", | ||||
|     `${app.getPath("downloads")}`, | ||||
|   ); | ||||
|  | ||||
|   if (LinkUtil.isUploadsUrl(this.props.url, url)) { | ||||
|     ipcRenderer.send("downloadFile", url.href, downloadPath); | ||||
|     ipcRenderer.once( | ||||
|       "downloadFileCompleted", | ||||
|       async (_event: Event, filePath: string, fileName: string) => { | ||||
|         const downloadNotification = new Notification("Download Complete", { | ||||
|           body: `Click to show ${fileName} in folder`, | ||||
|           silent: true, // We'll play our own sound - ding.ogg | ||||
|         }); | ||||
|  | ||||
|         downloadNotification.addEventListener("click", () => { | ||||
|           // Reveal file in download folder | ||||
|           shell.showItemInFolder(filePath); | ||||
|         }); | ||||
|         ipcRenderer.removeAllListeners("downloadFileFailed"); | ||||
|  | ||||
|         // Play sound to indicate download complete | ||||
|         if (!ConfigUtil.getConfigItem("silent", false)) { | ||||
|           await dingSound.play(); | ||||
|         } | ||||
|       }, | ||||
|     ); | ||||
|  | ||||
|     ipcRenderer.once("downloadFileFailed", (_event: Event, state: string) => { | ||||
|       // Automatic download failed, so show save dialog prompt and download | ||||
|       // through webview | ||||
|       // Only do this if it is the automatic download, otherwise show an error (so we aren't showing two save | ||||
|       // prompts right after each other) | ||||
|       // Check that the download is not cancelled by user | ||||
|       if (state !== "cancelled") { | ||||
|         if (ConfigUtil.getConfigItem("promptDownload", false)) { | ||||
|           // We need to create a "new Notification" to display it, but just `Notification(...)` on its own | ||||
|           // doesn't work | ||||
|           // eslint-disable-next-line no-new | ||||
|           new Notification("Download Complete", { | ||||
|             body: "Download failed", | ||||
|           }); | ||||
|         } else { | ||||
|           this.$el!.downloadURL(url.href); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       ipcRenderer.removeAllListeners("downloadFileCompleted"); | ||||
|     }); | ||||
|   } else { | ||||
|     (async () => LinkUtil.openBrowser(url))(); | ||||
|   } | ||||
| } | ||||
| @@ -1,26 +1,53 @@ | ||||
| import type {HTML} from "../../../common/html"; | ||||
| import {html} from "../../../common/html"; | ||||
| import {ipcRenderer} from "../typed-ipc-renderer"; | ||||
| import * as SystemUtil from "../utils/system-util"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| import {generateNodeFromHTML} from "./base"; | ||||
| import type {TabProps} from "./tab"; | ||||
| import Tab from "./tab"; | ||||
| import type {Html} from "../../../common/html.js"; | ||||
| import {html} from "../../../common/html.js"; | ||||
| import {ipcRenderer} from "../typed-ipc-renderer.js"; | ||||
|  | ||||
| import {generateNodeFromHtml} from "./base.js"; | ||||
| import type {TabProps} from "./tab.js"; | ||||
| import Tab from "./tab.js"; | ||||
| import type WebView from "./webview.js"; | ||||
|  | ||||
| export type ServerTabProps = { | ||||
|   webview: Promise<WebView>; | ||||
| } & TabProps; | ||||
|  | ||||
| export default class ServerTab extends Tab { | ||||
|   webview: Promise<WebView>; | ||||
|   $el: Element; | ||||
|   $name: Element; | ||||
|   $icon: HTMLImageElement; | ||||
|   $badge: Element; | ||||
|  | ||||
|   constructor(props: TabProps) { | ||||
|   constructor({webview, ...props}: ServerTabProps) { | ||||
|     super(props); | ||||
|  | ||||
|     this.$el = generateNodeFromHTML(this.templateHTML()); | ||||
|     this.webview = webview; | ||||
|     this.$el = generateNodeFromHtml(this.templateHtml()); | ||||
|     this.props.$root.append(this.$el); | ||||
|     this.registerListeners(); | ||||
|     this.$name = this.$el.querySelector(".server-tooltip")!; | ||||
|     this.$icon = this.$el.querySelector(".server-icons")!; | ||||
|     this.$badge = this.$el.querySelector(".server-tab-badge")!; | ||||
|   } | ||||
|  | ||||
|   templateHTML(): HTML { | ||||
|   override async activate(): Promise<void> { | ||||
|     await super.activate(); | ||||
|     (await this.webview).load(); | ||||
|   } | ||||
|  | ||||
|   override async deactivate(): Promise<void> { | ||||
|     await super.deactivate(); | ||||
|     (await this.webview).hide(); | ||||
|   } | ||||
|  | ||||
|   override async destroy(): Promise<void> { | ||||
|     await super.destroy(); | ||||
|     (await this.webview).destroy(); | ||||
|   } | ||||
|  | ||||
|   templateHtml(): Html { | ||||
|     return html` | ||||
|       <div class="tab" data-tab-id="${this.props.tabIndex}"> | ||||
|         <div class="server-tooltip" style="display:none"> | ||||
| @@ -35,14 +62,19 @@ export default class ServerTab extends Tab { | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   setName(name: string): void { | ||||
|     this.props.name = name; | ||||
|     this.$name.textContent = name; | ||||
|   } | ||||
|  | ||||
|   setIcon(icon: string): void { | ||||
|     this.props.icon = icon; | ||||
|     this.$icon.src = icon; | ||||
|   } | ||||
|  | ||||
|   updateBadge(count: number): void { | ||||
|     if (count > 0) { | ||||
|       const formattedCount = count > 999 ? "1K+" : count.toString(); | ||||
|       this.$badge.textContent = formattedCount; | ||||
|       this.$badge.classList.add("active"); | ||||
|     } else { | ||||
|       this.$badge.classList.remove("active"); | ||||
|     } | ||||
|     this.$badge.textContent = count > 999 ? "1K+" : count.toString(); | ||||
|     this.$badge.classList.toggle("active", count > 0); | ||||
|   } | ||||
|  | ||||
|   generateShortcutText(): string { | ||||
| @@ -53,14 +85,11 @@ export default class ServerTab extends Tab { | ||||
|  | ||||
|     const shownIndex = this.props.index + 1; | ||||
|  | ||||
|     let shortcutText = ""; | ||||
|  | ||||
|     shortcutText = | ||||
|       SystemUtil.getOS() === "Mac" ? `⌘ ${shownIndex}` : `Ctrl+${shownIndex}`; | ||||
|  | ||||
|     // Array index == Shown index - 1 | ||||
|     ipcRenderer.send("switch-server-tab", shownIndex - 1); | ||||
|  | ||||
|     return shortcutText; | ||||
|     return process.platform === "darwin" | ||||
|       ? `⌘${shownIndex}` | ||||
|       : `Ctrl+${shownIndex}`; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import type WebView from "./webview"; | ||||
| import type {TabRole} from "../../../common/types.js"; | ||||
|  | ||||
| export interface TabProps { | ||||
|   role: string; | ||||
| export type TabProps = { | ||||
|   role: TabRole; | ||||
|   icon?: string; | ||||
|   name: string; | ||||
|   $root: Element; | ||||
| @@ -10,20 +10,14 @@ export interface TabProps { | ||||
|   tabIndex: number; | ||||
|   onHover?: () => void; | ||||
|   onHoverOut?: () => void; | ||||
|   webview: WebView; | ||||
|   materialIcon?: string; | ||||
|   onDestroy?: () => void; | ||||
| } | ||||
| }; | ||||
|  | ||||
| export default abstract class Tab { | ||||
|   props: TabProps; | ||||
|   webview: WebView; | ||||
|   abstract $el: Element; | ||||
|  | ||||
|   constructor(props: TabProps) { | ||||
|     this.props = props; | ||||
|     this.webview = this.props.webview; | ||||
|   } | ||||
|   constructor(readonly props: TabProps) {} | ||||
|  | ||||
|   registerListeners(): void { | ||||
|     this.$el.addEventListener("click", this.props.onClick); | ||||
| @@ -37,22 +31,15 @@ export default abstract class Tab { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   showNetworkError(): void { | ||||
|     this.webview.forceLoad(); | ||||
|   } | ||||
|  | ||||
|   activate(): void { | ||||
|   async activate(): Promise<void> { | ||||
|     this.$el.classList.add("active"); | ||||
|     this.webview.load(); | ||||
|   } | ||||
|  | ||||
|   deactivate(): void { | ||||
|   async deactivate(): Promise<void> { | ||||
|     this.$el.classList.remove("active"); | ||||
|     this.webview.hide(); | ||||
|   } | ||||
|  | ||||
|   destroy(): void { | ||||
|   async destroy(): Promise<void> { | ||||
|     this.$el.remove(); | ||||
|     this.webview.$el!.remove(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,129 +1,255 @@ | ||||
| import {remote} from "electron"; | ||||
| import fs from "fs"; | ||||
| import path from "path"; | ||||
| import type {WebContents} from "electron/main"; | ||||
| import fs from "node:fs"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| import * as ConfigUtil from "../../../common/config-util"; | ||||
| import {HTML, html} from "../../../common/html"; | ||||
| import type {RendererMessage} from "../../../common/typed-ipc"; | ||||
| import {ipcRenderer} from "../typed-ipc-renderer"; | ||||
| import * as SystemUtil from "../utils/system-util"; | ||||
| import * as remote from "@electron/remote"; | ||||
| import {app, dialog} from "@electron/remote"; | ||||
|  | ||||
| import {generateNodeFromHTML} from "./base"; | ||||
| import {contextMenu} from "./context-menu"; | ||||
| import handleExternalLink from "./handle-external-link"; | ||||
| import * as ConfigUtil from "../../../common/config-util.js"; | ||||
| import type {Html} from "../../../common/html.js"; | ||||
| import {html} from "../../../common/html.js"; | ||||
| import type {RendererMessage} from "../../../common/typed-ipc.js"; | ||||
| import type {TabRole} from "../../../common/types.js"; | ||||
| import preloadCss from "../../css/preload.css?raw"; // eslint-disable-line n/file-extension-in-import | ||||
| import {ipcRenderer} from "../typed-ipc-renderer.js"; | ||||
| import * as SystemUtil from "../utils/system-util.js"; | ||||
|  | ||||
| const {app, dialog} = remote; | ||||
| import {generateNodeFromHtml} from "./base.js"; | ||||
| import {contextMenu} from "./context-menu.js"; | ||||
|  | ||||
| const shouldSilentWebview = ConfigUtil.getConfigItem("silent", false); | ||||
|  | ||||
| interface WebViewProps { | ||||
| type WebViewProps = { | ||||
|   $root: Element; | ||||
|   rootWebContents: WebContents; | ||||
|   index: number; | ||||
|   tabIndex: number; | ||||
|   url: string; | ||||
|   role: string; | ||||
|   name: string; | ||||
|   role: TabRole; | ||||
|   isActive: () => boolean; | ||||
|   switchLoading: (loading: boolean, url: string) => void; | ||||
|   onNetworkError: (index: number) => void; | ||||
|   nodeIntegration: boolean; | ||||
|   preload: boolean; | ||||
|   preload?: string; | ||||
|   onTitleChange: () => void; | ||||
|   hasPermission?: (origin: string, permission: string) => boolean; | ||||
| } | ||||
|   unsupportedMessage?: string; | ||||
| }; | ||||
|  | ||||
| export default class WebView { | ||||
|   props: WebViewProps; | ||||
|   zoomFactor: number; | ||||
|   badgeCount: number; | ||||
|   loading: boolean; | ||||
|   customCSS: string | false | null; | ||||
|   $webviewsContainer: DOMTokenList; | ||||
|   $el?: Electron.WebviewTag; | ||||
|   domReady?: Promise<void>; | ||||
|  | ||||
|   constructor(props: WebViewProps) { | ||||
|     this.props = props; | ||||
|     this.zoomFactor = 1; | ||||
|     this.loading = true; | ||||
|     this.badgeCount = 0; | ||||
|     this.customCSS = ConfigUtil.getConfigItem("customCSS", null); | ||||
|     this.$webviewsContainer = document.querySelector( | ||||
|       "#webviews-container", | ||||
|     )!.classList; | ||||
|   } | ||||
|  | ||||
|   templateHTML(): HTML { | ||||
|   static templateHtml(props: WebViewProps): Html { | ||||
|     return html` | ||||
|       <webview | ||||
|         class="disabled" | ||||
|         data-tab-id="${this.props.tabIndex}" | ||||
|         src="${this.props.url}" | ||||
|         ${new HTML({html: this.props.nodeIntegration ? "nodeIntegration" : ""})} | ||||
|         ${new HTML({html: this.props.preload ? 'preload="js/preload.js"' : ""})} | ||||
|         partition="persist:webviewsession" | ||||
|         name="${this.props.name}" | ||||
|         webpreferences=" | ||||
|           contextIsolation=${!this.props.nodeIntegration}, | ||||
|           spellcheck=${Boolean( | ||||
|           ConfigUtil.getConfigItem("enableSpellchecker", true), | ||||
|         )}, | ||||
|           worldSafeExecuteJavaScript=true | ||||
|         " | ||||
|       > | ||||
|       </webview> | ||||
|       <div class="webview-pane"> | ||||
|         <div | ||||
|           class="webview-unsupported" | ||||
|           ${props.unsupportedMessage === undefined ? html`hidden` : html``} | ||||
|         > | ||||
|           <span class="webview-unsupported-message" | ||||
|             >${props.unsupportedMessage ?? ""}</span | ||||
|           > | ||||
|           <span class="webview-unsupported-dismiss">×</span> | ||||
|         </div> | ||||
|         <webview | ||||
|           data-tab-id="${props.tabIndex}" | ||||
|           src="${props.url}" | ||||
|           ${props.preload === undefined | ||||
|             ? html`` | ||||
|             : html`preload="${props.preload}"`} | ||||
|           partition="persist:webviewsession" | ||||
|           allowpopups | ||||
|         > | ||||
|         </webview> | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   init(): void { | ||||
|     this.$el = generateNodeFromHTML(this.templateHTML()) as Electron.WebviewTag; | ||||
|     this.domReady = new Promise((resolve) => { | ||||
|       this.$el!.addEventListener( | ||||
|         "dom-ready", | ||||
|   static async create(props: WebViewProps): Promise<WebView> { | ||||
|     const $pane = generateNodeFromHtml( | ||||
|       WebView.templateHtml(props), | ||||
|     ) as HTMLElement; | ||||
|     props.$root.append($pane); | ||||
|  | ||||
|     const $webview: HTMLElement = $pane.querySelector(":scope > webview")!; | ||||
|     await new Promise<void>((resolve) => { | ||||
|       $webview.addEventListener( | ||||
|         "did-attach", | ||||
|         () => { | ||||
|           resolve(); | ||||
|         }, | ||||
|         true, | ||||
|       ); | ||||
|     }); | ||||
|     this.props.$root.append(this.$el); | ||||
|  | ||||
|     // Work around https://github.com/electron/electron/issues/26904 | ||||
|     function getWebContentsIdFunction( | ||||
|       this: undefined, | ||||
|       selector: string, | ||||
|     ): number { | ||||
|       return document | ||||
|         .querySelector<Electron.WebviewTag>(selector)! | ||||
|         .getWebContentsId(); | ||||
|     } | ||||
|  | ||||
|     const selector = `webview[data-tab-id="${CSS.escape( | ||||
|       `${props.tabIndex}`, | ||||
|     )}"]`; | ||||
|     const webContentsId: unknown = | ||||
|       await props.rootWebContents.executeJavaScript( | ||||
|         `(${getWebContentsIdFunction.toString()})(${JSON.stringify(selector)})`, | ||||
|       ); | ||||
|     if (typeof webContentsId !== "number") { | ||||
|       throw new TypeError("Failed to get WebContents ID"); | ||||
|     } | ||||
|  | ||||
|     return new WebView(props, $pane, $webview, webContentsId); | ||||
|   } | ||||
|  | ||||
|   badgeCount = 0; | ||||
|   loading = true; | ||||
|   private zoomFactor = 1; | ||||
|   private customCss: string | false | null; | ||||
|   private readonly $webviewsContainer: DOMTokenList; | ||||
|   private readonly $unsupported: HTMLElement; | ||||
|   private readonly $unsupportedMessage: HTMLElement; | ||||
|   private readonly $unsupportedDismiss: HTMLElement; | ||||
|   private unsupportedDismissed = false; | ||||
|  | ||||
|   private constructor( | ||||
|     readonly props: WebViewProps, | ||||
|     private readonly $pane: HTMLElement, | ||||
|     private readonly $webview: HTMLElement, | ||||
|     readonly webContentsId: number, | ||||
|   ) { | ||||
|     this.customCss = ConfigUtil.getConfigItem("customCSS", null); | ||||
|     this.$webviewsContainer = document.querySelector( | ||||
|       "#webviews-container", | ||||
|     )!.classList; | ||||
|     this.$unsupported = $pane.querySelector(".webview-unsupported")!; | ||||
|     this.$unsupportedMessage = $pane.querySelector( | ||||
|       ".webview-unsupported-message", | ||||
|     )!; | ||||
|     this.$unsupportedDismiss = $pane.querySelector( | ||||
|       ".webview-unsupported-dismiss", | ||||
|     )!; | ||||
|  | ||||
|     this.registerListeners(); | ||||
|   } | ||||
|  | ||||
|   registerListeners(): void { | ||||
|     this.$el!.addEventListener("new-window", (event) => { | ||||
|       handleExternalLink.call(this, event); | ||||
|     }); | ||||
|   destroy(): void { | ||||
|     this.$pane.remove(); | ||||
|   } | ||||
|  | ||||
|   getWebContents(): WebContents { | ||||
|     return remote.webContents.fromId(this.webContentsId)!; | ||||
|   } | ||||
|  | ||||
|   showNotificationSettings(): void { | ||||
|     this.send("show-notification-settings"); | ||||
|   } | ||||
|  | ||||
|   focus(): void { | ||||
|     this.$webview.focus(); | ||||
|     // Work around https://github.com/electron/electron/issues/31918 | ||||
|     this.$webview.shadowRoot?.querySelector("iframe")?.focus(); | ||||
|   } | ||||
|  | ||||
|   hide(): void { | ||||
|     this.$pane.classList.remove("active"); | ||||
|   } | ||||
|  | ||||
|   load(): void { | ||||
|     this.show(); | ||||
|   } | ||||
|  | ||||
|   zoomIn(): void { | ||||
|     this.zoomFactor += 0.1; | ||||
|     this.getWebContents().setZoomFactor(this.zoomFactor); | ||||
|   } | ||||
|  | ||||
|   zoomOut(): void { | ||||
|     this.zoomFactor -= 0.1; | ||||
|     this.getWebContents().setZoomFactor(this.zoomFactor); | ||||
|   } | ||||
|  | ||||
|   zoomActualSize(): void { | ||||
|     this.zoomFactor = 1; | ||||
|     this.getWebContents().setZoomFactor(this.zoomFactor); | ||||
|   } | ||||
|  | ||||
|   logOut(): void { | ||||
|     this.send("logout"); | ||||
|   } | ||||
|  | ||||
|   showKeyboardShortcuts(): void { | ||||
|     this.send("show-keyboard-shortcuts"); | ||||
|   } | ||||
|  | ||||
|   openDevTools(): void { | ||||
|     this.getWebContents().openDevTools(); | ||||
|   } | ||||
|  | ||||
|   back(): void { | ||||
|     if (this.getWebContents().canGoBack()) { | ||||
|       this.getWebContents().goBack(); | ||||
|       this.focus(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   canGoBackButton(): void { | ||||
|     const $backButton = document.querySelector( | ||||
|       "#actions-container #back-action", | ||||
|     )!; | ||||
|     $backButton.classList.toggle("disable", !this.getWebContents().canGoBack()); | ||||
|   } | ||||
|  | ||||
|   forward(): void { | ||||
|     if (this.getWebContents().canGoForward()) { | ||||
|       this.getWebContents().goForward(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   reload(): void { | ||||
|     this.hide(); | ||||
|     // Shows the loading indicator till the webview is reloaded | ||||
|     this.$webviewsContainer.remove("loaded"); | ||||
|     this.loading = true; | ||||
|     this.props.switchLoading(true, this.props.url); | ||||
|     this.getWebContents().reload(); | ||||
|   } | ||||
|  | ||||
|   setUnsupportedMessage(unsupportedMessage: string | undefined) { | ||||
|     this.$unsupported.hidden = | ||||
|       unsupportedMessage === undefined || this.unsupportedDismissed; | ||||
|     this.$unsupportedMessage.textContent = unsupportedMessage ?? ""; | ||||
|   } | ||||
|  | ||||
|   send<Channel extends keyof RendererMessage>( | ||||
|     channel: Channel, | ||||
|     ...args: Parameters<RendererMessage[Channel]> | ||||
|   ): void { | ||||
|     ipcRenderer.sendTo(this.webContentsId, channel, ...args); | ||||
|   } | ||||
|  | ||||
|   private registerListeners(): void { | ||||
|     const webContents = this.getWebContents(); | ||||
|  | ||||
|     if (shouldSilentWebview) { | ||||
|       this.$el!.addEventListener("dom-ready", () => { | ||||
|         this.$el!.setAudioMuted(true); | ||||
|       }); | ||||
|       webContents.setAudioMuted(true); | ||||
|     } | ||||
|  | ||||
|     this.$el!.addEventListener("page-title-updated", (event) => { | ||||
|       const {title} = event; | ||||
|     webContents.on("page-title-updated", (_event, title) => { | ||||
|       this.badgeCount = this.getBadgeCount(title); | ||||
|       this.props.onTitleChange(); | ||||
|     }); | ||||
|  | ||||
|     this.$el!.addEventListener("did-navigate-in-page", (event) => { | ||||
|       const isSettingPage = event.url.includes("renderer/preference.html"); | ||||
|       if (isSettingPage) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|     this.$webview.addEventListener("did-navigate-in-page", () => { | ||||
|       this.canGoBackButton(); | ||||
|     }); | ||||
|  | ||||
|     this.$el!.addEventListener("did-navigate", () => { | ||||
|     this.$webview.addEventListener("did-navigate", () => { | ||||
|       this.canGoBackButton(); | ||||
|     }); | ||||
|  | ||||
|     this.$el!.addEventListener("page-favicon-updated", (event) => { | ||||
|       const {favicons} = event; | ||||
|  | ||||
|     webContents.on("page-favicon-updated", (_event, favicons) => { | ||||
|       // This returns a string of favicons URL. If there is a PM counts in unread messages then the URL would be like | ||||
|       // https://chat.zulip.org/static/images/favicon/favicon-pms.png | ||||
|       if ( | ||||
| @@ -139,33 +265,19 @@ export default class WebView { | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     this.$el!.addEventListener("dom-ready", () => { | ||||
|       const webContents = remote.webContents.fromId( | ||||
|         this.$el!.getWebContentsId(), | ||||
|       ); | ||||
|       webContents.addListener("context-menu", (event, menuParameters) => { | ||||
|         contextMenu(webContents, event, menuParameters); | ||||
|       }); | ||||
|  | ||||
|       if (this.props.role === "server") { | ||||
|         this.$el!.classList.add("onload"); | ||||
|       } | ||||
|     webContents.addListener("context-menu", (event, menuParameters) => { | ||||
|       contextMenu(webContents, event, menuParameters); | ||||
|     }); | ||||
|  | ||||
|     this.$webview.addEventListener("dom-ready", () => { | ||||
|       this.loading = false; | ||||
|       this.props.switchLoading(false, this.props.url); | ||||
|       this.show(); | ||||
|  | ||||
|       // Refocus text boxes after reload | ||||
|       // Remove when upstream issue https://github.com/electron/electron/issues/14474 is fixed | ||||
|       this.$el!.blur(); | ||||
|       this.$el!.focus(); | ||||
|     }); | ||||
|  | ||||
|     this.$el!.addEventListener("did-fail-load", (event) => { | ||||
|       const {errorDescription} = event; | ||||
|       const hasConnectivityError = SystemUtil.connectivityERR.includes( | ||||
|         errorDescription, | ||||
|       ); | ||||
|     webContents.on("did-fail-load", (_event, _errorCode, errorDescription) => { | ||||
|       const hasConnectivityError = | ||||
|         SystemUtil.connectivityError.includes(errorDescription); | ||||
|       if (hasConnectivityError) { | ||||
|         console.error("error", errorDescription); | ||||
|         if (!this.props.url.includes("network.html")) { | ||||
| @@ -174,61 +286,46 @@ export default class WebView { | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     this.$el!.addEventListener("did-start-loading", () => { | ||||
|       const isSettingPage = this.props.url.includes("renderer/preference.html"); | ||||
|       if (!isSettingPage) { | ||||
|         this.props.switchLoading(true, this.props.url); | ||||
|       } | ||||
|     this.$webview.addEventListener("did-start-loading", () => { | ||||
|       this.props.switchLoading(true, this.props.url); | ||||
|     }); | ||||
|  | ||||
|     this.$el!.addEventListener("did-stop-loading", () => { | ||||
|     this.$webview.addEventListener("did-stop-loading", () => { | ||||
|       this.props.switchLoading(false, this.props.url); | ||||
|     }); | ||||
|  | ||||
|     this.$unsupportedDismiss.addEventListener("click", () => { | ||||
|       this.unsupportedDismissed = true; | ||||
|       this.$unsupported.hidden = true; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   getBadgeCount(title: string): number { | ||||
|     const messageCountInTitle = /\((\d+)\)/.exec(title); | ||||
|   private getBadgeCount(title: string): number { | ||||
|     const messageCountInTitle = /^\((\d+)\)/.exec(title); | ||||
|     return messageCountInTitle ? Number(messageCountInTitle[1]) : 0; | ||||
|   } | ||||
|  | ||||
|   async showNotificationSettings(): Promise<void> { | ||||
|     await this.send("show-notification-settings"); | ||||
|   } | ||||
|  | ||||
|   show(): void { | ||||
|   private show(): void { | ||||
|     // Do not show WebView if another tab was selected and this tab should be in background. | ||||
|     if (!this.props.isActive()) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // To show or hide the loading indicator in the the active tab | ||||
|     if (this.loading) { | ||||
|       this.$webviewsContainer.remove("loaded"); | ||||
|     } else { | ||||
|       this.$webviewsContainer.add("loaded"); | ||||
|     } | ||||
|     // To show or hide the loading indicator in the active tab | ||||
|     this.$webviewsContainer.toggle("loaded", !this.loading); | ||||
|  | ||||
|     this.$el!.classList.remove("disabled"); | ||||
|     this.$el!.classList.add("active"); | ||||
|     setTimeout(() => { | ||||
|       if (this.props.role === "server") { | ||||
|         this.$el!.classList.remove("onload"); | ||||
|       } | ||||
|     }, 1000); | ||||
|     this.$pane.classList.add("active"); | ||||
|     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"), | ||||
|       ))(); | ||||
|     (async () => this.getWebContents().insertCSS(preloadCss))(); | ||||
|  | ||||
|     // Get customCSS again from config util to avoid warning user again | ||||
|     const customCSS = ConfigUtil.getConfigItem("customCSS", null); | ||||
|     this.customCSS = customCSS; | ||||
|     if (customCSS) { | ||||
|       if (!fs.existsSync(customCSS)) { | ||||
|         this.customCSS = null; | ||||
|     const customCss = ConfigUtil.getConfigItem("customCSS", null); | ||||
|     this.customCss = customCss; | ||||
|     if (customCss) { | ||||
|       if (!fs.existsSync(customCss)) { | ||||
|         this.customCss = null; | ||||
|         ConfigUtil.setConfigItem("customCSS", null); | ||||
|  | ||||
|         const errorMessage = "The custom css previously set is deleted!"; | ||||
| @@ -237,109 +334,7 @@ export default class WebView { | ||||
|       } | ||||
|  | ||||
|       (async () => | ||||
|         this.$el!.insertCSS( | ||||
|           fs.readFileSync(path.resolve(__dirname, customCSS), "utf8"), | ||||
|         ))(); | ||||
|         this.getWebContents().insertCSS(fs.readFileSync(customCss, "utf8")))(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   focus(): void { | ||||
|     // Focus Webview and it's contents when Window regain focus. | ||||
|     const webContents = remote.webContents.fromId(this.$el!.getWebContentsId()); | ||||
|     // HACK: webContents.isFocused() seems to be true even without the element | ||||
|     // being in focus. So, we check against `document.activeElement`. | ||||
|     if (webContents && this.$el !== document.activeElement) { | ||||
|       // HACK: Looks like blur needs to be called on the previously focused | ||||
|       // element to transfer focus correctly, in Electron v3.0.10 | ||||
|       // See https://github.com/electron/electron/issues/15718 | ||||
|       (document.activeElement as HTMLElement).blur(); | ||||
|       this.$el!.focus(); | ||||
|       webContents.focus(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   hide(): void { | ||||
|     this.$el!.classList.add("disabled"); | ||||
|     this.$el!.classList.remove("active"); | ||||
|   } | ||||
|  | ||||
|   load(): void { | ||||
|     if (this.$el) { | ||||
|       this.show(); | ||||
|     } else { | ||||
|       this.init(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   zoomIn(): void { | ||||
|     this.zoomFactor += 0.1; | ||||
|     this.$el!.setZoomFactor(this.zoomFactor); | ||||
|   } | ||||
|  | ||||
|   zoomOut(): void { | ||||
|     this.zoomFactor -= 0.1; | ||||
|     this.$el!.setZoomFactor(this.zoomFactor); | ||||
|   } | ||||
|  | ||||
|   zoomActualSize(): void { | ||||
|     this.zoomFactor = 1; | ||||
|     this.$el!.setZoomFactor(this.zoomFactor); | ||||
|   } | ||||
|  | ||||
|   async logOut(): Promise<void> { | ||||
|     await this.send("logout"); | ||||
|   } | ||||
|  | ||||
|   async showKeyboardShortcuts(): Promise<void> { | ||||
|     await this.send("show-keyboard-shortcuts"); | ||||
|   } | ||||
|  | ||||
|   openDevTools(): void { | ||||
|     this.$el!.openDevTools(); | ||||
|   } | ||||
|  | ||||
|   back(): void { | ||||
|     if (this.$el!.canGoBack()) { | ||||
|       this.$el!.goBack(); | ||||
|       this.focus(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   canGoBackButton(): void { | ||||
|     const $backButton = document.querySelector( | ||||
|       "#actions-container #back-action", | ||||
|     )!; | ||||
|     if (this.$el!.canGoBack()) { | ||||
|       $backButton.classList.remove("disable"); | ||||
|     } else { | ||||
|       $backButton.classList.add("disable"); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   forward(): void { | ||||
|     if (this.$el!.canGoForward()) { | ||||
|       this.$el!.goForward(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   reload(): void { | ||||
|     this.hide(); | ||||
|     // Shows the loading indicator till the webview is reloaded | ||||
|     this.$webviewsContainer.remove("loaded"); | ||||
|     this.loading = true; | ||||
|     this.props.switchLoading(true, this.props.url); | ||||
|     this.$el!.reload(); | ||||
|   } | ||||
|  | ||||
|   forceLoad(): void { | ||||
|     this.init(); | ||||
|   } | ||||
|  | ||||
|   async send<Channel extends keyof RendererMessage>( | ||||
|     channel: Channel, | ||||
|     ...args: Parameters<RendererMessage[Channel]> | ||||
|   ): Promise<void> { | ||||
|     await this.domReady; | ||||
|     ipcRenderer.sendTo(this.$el!.getWebContentsId(), channel, ...args); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,26 +1,44 @@ | ||||
| import {remote} from "electron"; | ||||
| import {EventEmitter} from "events"; | ||||
| import {EventEmitter} from "events"; // eslint-disable-line unicorn/prefer-node-protocol | ||||
|  | ||||
| import {ClipboardDecrypterImpl} from "./clipboard-decrypter"; | ||||
| import type {NotificationData} from "./notification"; | ||||
| import {newNotification} from "./notification"; | ||||
| import {ipcRenderer} from "./typed-ipc-renderer"; | ||||
| import type {ClipboardDecrypter} from "./clipboard-decrypter.js"; | ||||
| import {ClipboardDecrypterImpl} from "./clipboard-decrypter.js"; | ||||
| import type {NotificationData} from "./notification/index.js"; | ||||
| import {newNotification} from "./notification/index.js"; | ||||
| import {ipcRenderer} from "./typed-ipc-renderer.js"; | ||||
|  | ||||
| type ListenerType = (...args: any[]) => void; | ||||
|  | ||||
| /* eslint-disable @typescript-eslint/naming-convention */ | ||||
| export type ElectronBridge = { | ||||
|   send_event: (eventName: string | symbol, ...args: unknown[]) => boolean; | ||||
|   on_event: (eventName: string, listener: ListenerType) => void; | ||||
|   new_notification: ( | ||||
|     title: string, | ||||
|     options: NotificationOptions, | ||||
|     dispatch: (type: string, eventInit: EventInit) => boolean, | ||||
|   ) => NotificationData; | ||||
|   get_idle_on_system: () => boolean; | ||||
|   get_last_active_on_system: () => number; | ||||
|   get_send_notification_reply_message_supported: () => boolean; | ||||
|   set_send_notification_reply_message_supported: (value: boolean) => void; | ||||
|   decrypt_clipboard: (version: number) => ClipboardDecrypter; | ||||
| }; | ||||
| /* eslint-enable @typescript-eslint/naming-convention */ | ||||
|  | ||||
| let notificationReplySupported = false; | ||||
| // Indicates if the user is idle or not | ||||
| let idle = false; | ||||
| // Indicates the time at which user was last active | ||||
| let lastActive = Date.now(); | ||||
|  | ||||
| export const bridgeEvents = new EventEmitter(); | ||||
| export const bridgeEvents = new EventEmitter(); // eslint-disable-line unicorn/prefer-event-target | ||||
|  | ||||
| /* eslint-disable @typescript-eslint/naming-convention */ | ||||
| const electron_bridge: ElectronBridge = { | ||||
|   send_event: (eventName: string | symbol, ...args: unknown[]): boolean => | ||||
|     bridgeEvents.emit(eventName, ...args), | ||||
|  | ||||
|   on_event: (eventName: string, listener: ListenerType): void => { | ||||
|   on_event(eventName: string, listener: ListenerType): void { | ||||
|     bridgeEvents.on(eventName, listener); | ||||
|   }, | ||||
|  | ||||
| @@ -37,13 +55,14 @@ const electron_bridge: ElectronBridge = { | ||||
|   get_send_notification_reply_message_supported: (): boolean => | ||||
|     notificationReplySupported, | ||||
|  | ||||
|   set_send_notification_reply_message_supported: (value: boolean): void => { | ||||
|   set_send_notification_reply_message_supported(value: boolean): void { | ||||
|     notificationReplySupported = value; | ||||
|   }, | ||||
|  | ||||
|   decrypt_clipboard: (version: number): ClipboardDecrypterImpl => | ||||
|   decrypt_clipboard: (version: number): ClipboardDecrypter => | ||||
|     new ClipboardDecrypterImpl(version), | ||||
| }; | ||||
| /* eslint-enable @typescript-eslint/naming-convention */ | ||||
|  | ||||
| bridgeEvents.on("total_unread_count", (unreadCount: unknown) => { | ||||
|   if (typeof unreadCount !== "number") { | ||||
| @@ -58,45 +77,37 @@ bridgeEvents.on("realm_name", (realmName: unknown) => { | ||||
|     throw new TypeError("Expected string for realmName"); | ||||
|   } | ||||
|  | ||||
|   const serverURL = location.origin; | ||||
|   ipcRenderer.send("realm-name-changed", serverURL, realmName); | ||||
|   const serverUrl = location.origin; | ||||
|   ipcRenderer.send("realm-name-changed", serverUrl, realmName); | ||||
| }); | ||||
|  | ||||
| bridgeEvents.on("realm_icon_url", (iconURL: unknown) => { | ||||
|   if (typeof iconURL !== "string") { | ||||
|     throw new TypeError("Expected string for iconURL"); | ||||
| bridgeEvents.on("realm_icon_url", (iconUrl: unknown) => { | ||||
|   if (typeof iconUrl !== "string") { | ||||
|     throw new TypeError("Expected string for iconUrl"); | ||||
|   } | ||||
|  | ||||
|   const serverURL = location.origin; | ||||
|   const serverUrl = location.origin; | ||||
|   ipcRenderer.send( | ||||
|     "realm-icon-changed", | ||||
|     serverURL, | ||||
|     iconURL.includes("http") ? iconURL : `${serverURL}${iconURL}`, | ||||
|     serverUrl, | ||||
|     iconUrl.includes("http") ? iconUrl : `${serverUrl}${iconUrl}`, | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| // Set user as active and update the time of last activity | ||||
| ipcRenderer.on("set-active", () => { | ||||
|   if (!remote.app.isPackaged) { | ||||
|     console.log("active"); | ||||
|   } | ||||
|  | ||||
|   idle = false; | ||||
|   lastActive = Date.now(); | ||||
| }); | ||||
|  | ||||
| // Set user as idle and time of last activity is left unchanged | ||||
| ipcRenderer.on("set-idle", () => { | ||||
|   if (!remote.app.isPackaged) { | ||||
|     console.log("idle"); | ||||
|   } | ||||
|  | ||||
|   idle = true; | ||||
| }); | ||||
|  | ||||
| // This follows node's idiomatic implementation of event | ||||
| // emitters to make event handling more simpler instead of using | ||||
| // functions zulip side will emit event using ElectronBrigde.send_event | ||||
| // functions zulip side will emit event using ElectronBridge.send_event | ||||
| // which is alias of .emit and on this side we can handle the data by adding | ||||
| // a listener for the event. | ||||
| export default electron_bridge; | ||||
|   | ||||
| @@ -1,55 +0,0 @@ | ||||
| import {remote} from "electron"; | ||||
| import fs from "fs"; | ||||
| import path from "path"; | ||||
|  | ||||
| import SendFeedback from "@electron-elements/send-feedback"; | ||||
|  | ||||
| const {app} = remote; | ||||
|  | ||||
| customElements.define("send-feedback", SendFeedback); | ||||
| export const sendFeedback: SendFeedback = document.querySelector( | ||||
|   "send-feedback", | ||||
| )!; | ||||
| export const feedbackHolder = sendFeedback.parentElement!; | ||||
|  | ||||
| // Make the button color match zulip app's theme | ||||
| sendFeedback.customStylesheet = "css/feedback.css"; | ||||
|  | ||||
| // Customize the fields of custom elements | ||||
| sendFeedback.title = "Report Issue"; | ||||
| sendFeedback.titleLabel = "Issue title:"; | ||||
| sendFeedback.titlePlaceholder = "Enter issue title"; | ||||
| sendFeedback.textareaLabel = "Describe the issue:"; | ||||
| sendFeedback.textareaPlaceholder = | ||||
|   "Succinctly describe your issue and steps to reproduce it..."; | ||||
|  | ||||
| sendFeedback.buttonLabel = "Report Issue"; | ||||
| sendFeedback.loaderSuccessText = ""; | ||||
|  | ||||
| sendFeedback.useReporter("emailReporter", { | ||||
|   email: "support@zulip.com", | ||||
| }); | ||||
|  | ||||
| feedbackHolder.addEventListener("click", (event: Event) => { | ||||
|   // Only remove the class if the grey out faded | ||||
|   // part is clicked and not the feedback element itself | ||||
|   if (event.target === event.currentTarget) { | ||||
|     feedbackHolder.classList.remove("show"); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| sendFeedback.addEventListener("feedback-submitted", () => { | ||||
|   setTimeout(() => { | ||||
|     feedbackHolder.classList.remove("show"); | ||||
|   }, 1000); | ||||
| }); | ||||
|  | ||||
| sendFeedback.addEventListener("feedback-cancelled", () => { | ||||
|   feedbackHolder.classList.remove("show"); | ||||
| }); | ||||
|  | ||||
| const dataDir = app.getPath("userData"); | ||||
| const logsDir = path.join(dataDir, "/Logs"); | ||||
| sendFeedback.logs.push( | ||||
|   ...fs.readdirSync(logsDir).map((file) => path.join(logsDir, file)), | ||||
| ); | ||||
| @@ -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 any; | ||||
| })(); | ||||
| @@ -1,30 +0,0 @@ | ||||
| import * as ConfigUtil from "../../../common/config-util"; | ||||
| import {ipcRenderer} from "../typed-ipc-renderer"; | ||||
|  | ||||
| import {focusCurrentServer} from "./helpers"; | ||||
|  | ||||
| const NativeNotification = window.Notification; | ||||
| export default class BaseNotification extends NativeNotification { | ||||
|   constructor(title: string, options: NotificationOptions) { | ||||
|     options.silent = true; | ||||
|     super(title, options); | ||||
|  | ||||
|     this.addEventListener("click", () => { | ||||
|       // Focus to the server who sent the | ||||
|       // notification if not focused already | ||||
|       focusCurrentServer(); | ||||
|       ipcRenderer.send("focus-app"); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   static async requestPermission(): Promise<NotificationPermission> { | ||||
|     return this.permission; | ||||
|   } | ||||
|  | ||||
|   // Override default Notification permission | ||||
|   static get permission(): NotificationPermission { | ||||
|     return ConfigUtil.getConfigItem("showNotification", true) | ||||
|       ? "granted" | ||||
|       : "denied"; | ||||
|   } | ||||
| } | ||||
| @@ -1,20 +0,0 @@ | ||||
| import {remote} from "electron"; | ||||
|  | ||||
| import {ipcRenderer} from "../typed-ipc-renderer"; | ||||
|  | ||||
| // Do not change this | ||||
| export const appId = "org.zulip.zulip-electron"; | ||||
|  | ||||
| const currentWindow = remote.getCurrentWindow(); | ||||
| const webContents = remote.getCurrentWebContents(); | ||||
| const webContentsId = webContents.id; | ||||
|  | ||||
| // This function will focus the server that sent | ||||
| // the notification. Main function implemented in main.js | ||||
| export function focusCurrentServer(): void { | ||||
|   ipcRenderer.sendTo( | ||||
|     currentWindow.webContents.id, | ||||
|     "focus-webview-with-id", | ||||
|     webContentsId, | ||||
|   ); | ||||
| } | ||||
| @@ -1,41 +1,25 @@ | ||||
| import {remote} from "electron"; | ||||
| import {ipcRenderer} from "../typed-ipc-renderer.js"; | ||||
|  | ||||
| import DefaultNotification from "./default-notification"; | ||||
| import {appId} from "./helpers"; | ||||
|  | ||||
| const {app} = remote; | ||||
|  | ||||
| // From https://github.com/felixrieseberg/electron-windows-notifications#appusermodelid | ||||
| // On windows 8 we have to explicitly set the appUserModelId otherwise notification won't work. | ||||
| app.setAppUserModelId(appId); | ||||
|  | ||||
| export interface NotificationData { | ||||
| export type NotificationData = { | ||||
|   close: () => void; | ||||
|   title: string; | ||||
|   dir: NotificationDirection; | ||||
|   lang: string; | ||||
|   body: string; | ||||
|   tag: string; | ||||
|   image: string; | ||||
|   icon: string; | ||||
|   badge: string; | ||||
|   vibrate: readonly number[]; | ||||
|   timestamp: number; | ||||
|   renotify: boolean; | ||||
|   silent: boolean; | ||||
|   requireInteraction: boolean; | ||||
|   data: unknown; | ||||
|   actions: readonly NotificationAction[]; | ||||
| } | ||||
| }; | ||||
|  | ||||
| export function newNotification( | ||||
|   title: string, | ||||
|   options: NotificationOptions, | ||||
|   dispatch: (type: string, eventInit: EventInit) => boolean, | ||||
| ): NotificationData { | ||||
|   const notification = new DefaultNotification(title, options); | ||||
|   const notification = new Notification(title, {...options, silent: true}); | ||||
|   for (const type of ["click", "close", "error", "show"]) { | ||||
|     notification.addEventListener(type, (ev: Event) => { | ||||
|     notification.addEventListener(type, (ev) => { | ||||
|       if (type === "click") ipcRenderer.send("focus-this-webview"); | ||||
|       if (!dispatch(type, ev)) { | ||||
|         ev.preventDefault(); | ||||
|       } | ||||
| @@ -43,7 +27,7 @@ export function newNotification( | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     close: () => { | ||||
|     close() { | ||||
|       notification.close(); | ||||
|     }, | ||||
|     title: notification.title, | ||||
| @@ -51,15 +35,7 @@ export function newNotification( | ||||
|     lang: notification.lang, | ||||
|     body: notification.body, | ||||
|     tag: notification.tag, | ||||
|     image: notification.image, | ||||
|     icon: notification.icon, | ||||
|     badge: notification.badge, | ||||
|     vibrate: notification.vibrate, | ||||
|     timestamp: notification.timestamp, | ||||
|     renotify: notification.renotify, | ||||
|     silent: notification.silent, | ||||
|     requireInteraction: notification.requireInteraction, | ||||
|     data: notification.data, | ||||
|     actions: notification.actions, | ||||
|   }; | ||||
| } | ||||
|   | ||||
							
								
								
									
										24
									
								
								app/renderer/js/pages/about.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,24 @@ | ||||
| import {app} from "@electron/remote"; | ||||
|  | ||||
| import {bundleUrl} from "../../../common/paths.js"; | ||||
|  | ||||
| export class AboutView { | ||||
|   static async create(): Promise<AboutView> { | ||||
|     return new AboutView( | ||||
|       await (await fetch(new URL("app/renderer/about.html", bundleUrl))).text(), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   readonly $view: HTMLElement; | ||||
|  | ||||
|   private constructor(templateHtml: string) { | ||||
|     this.$view = document.createElement("div"); | ||||
|     const $shadow = this.$view.attachShadow({mode: "open"}); | ||||
|     $shadow.innerHTML = templateHtml; | ||||
|     $shadow.querySelector("#version")!.textContent = `v${app.getVersion()}`; | ||||
|   } | ||||
|  | ||||
|   destroy() { | ||||
|     // Do nothing. | ||||
|   } | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| import {ipcRenderer} from "../typed-ipc-renderer"; | ||||
| import {ipcRenderer} from "../typed-ipc-renderer.js"; | ||||
|  | ||||
| export function init( | ||||
|   $reconnectButton: Element, | ||||
|   | ||||
| @@ -1,22 +1,22 @@ | ||||
| import type {HTML} from "../../../../common/html"; | ||||
| import {html} from "../../../../common/html"; | ||||
| import {generateNodeFromHTML} from "../../components/base"; | ||||
| import {ipcRenderer} from "../../typed-ipc-renderer"; | ||||
| import type {Html} from "../../../../common/html.js"; | ||||
| import {html} from "../../../../common/html.js"; | ||||
| import {generateNodeFromHtml} from "../../components/base.js"; | ||||
| import {ipcRenderer} from "../../typed-ipc-renderer.js"; | ||||
|  | ||||
| interface BaseSectionProps { | ||||
| type BaseSectionProps = { | ||||
|   $element: HTMLElement; | ||||
|   disabled?: boolean; | ||||
|   value: boolean; | ||||
|   clickHandler: () => void; | ||||
| } | ||||
| }; | ||||
|  | ||||
| export function generateSettingOption(props: BaseSectionProps): void { | ||||
|   const {$element, disabled, value, clickHandler} = props; | ||||
|  | ||||
|   $element.textContent = ""; | ||||
|  | ||||
|   const $optionControl = generateNodeFromHTML( | ||||
|     generateOptionHTML(value, disabled), | ||||
|   const $optionControl = generateNodeFromHtml( | ||||
|     generateOptionHtml(value, disabled), | ||||
|   ); | ||||
|   $element.append($optionControl); | ||||
|  | ||||
| @@ -25,12 +25,13 @@ export function generateSettingOption(props: BaseSectionProps): void { | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function generateOptionHTML( | ||||
| export function generateOptionHtml( | ||||
|   settingOption: boolean, | ||||
|   disabled?: boolean, | ||||
| ): HTML { | ||||
|   const labelHTML = disabled | ||||
|     ? html`<label | ||||
| ): Html { | ||||
|   const labelHtml = disabled | ||||
|     ? // eslint-disable-next-line unicorn/template-indent | ||||
|       html`<label | ||||
|         class="disallowed" | ||||
|         title="Setting locked by system administrator." | ||||
|       ></label>` | ||||
| @@ -40,7 +41,7 @@ export function generateOptionHTML( | ||||
|       <div class="action"> | ||||
|         <div class="switch"> | ||||
|           <input class="toggle toggle-round" type="checkbox" checked disabled /> | ||||
|           ${labelHTML} | ||||
|           ${labelHtml} | ||||
|         </div> | ||||
|       </div> | ||||
|     `; | ||||
| @@ -50,7 +51,7 @@ export function generateOptionHTML( | ||||
|     <div class="action"> | ||||
|       <div class="switch"> | ||||
|         <input class="toggle toggle-round" type="checkbox" /> | ||||
|         ${labelHTML} | ||||
|         ${labelHtml} | ||||
|       </div> | ||||
|     </div> | ||||
|   `; | ||||
| @@ -59,12 +60,12 @@ export function generateOptionHTML( | ||||
| /* A method that in future can be used to create dropdown menus using <select> <option> tags. | ||||
|      it needs an object which has ``key: value`` pairs and will return a string that can be appended to HTML | ||||
|   */ | ||||
| export function generateSelectHTML( | ||||
| export function generateSelectHtml( | ||||
|   options: Record<string, string>, | ||||
|   className?: string, | ||||
|   idName?: string, | ||||
| ): HTML { | ||||
|   const optionsHTML = html``.join( | ||||
| ): Html { | ||||
|   const optionsHtml = html``.join( | ||||
|     Object.keys(options).map( | ||||
|       (key) => html` | ||||
|         <option name="${key}" value="${key}">${options[key]}</option> | ||||
| @@ -73,7 +74,7 @@ export function generateSelectHTML( | ||||
|   ); | ||||
|   return html` | ||||
|     <select class="${className}" id="${idName}"> | ||||
|       ${optionsHTML} | ||||
|       ${optionsHtml} | ||||
|     </select> | ||||
|   `; | ||||
| } | ||||
|   | ||||
| @@ -1,25 +1,27 @@ | ||||
| import {html} from "../../../../common/html"; | ||||
| import * as t from "../../../../common/translation-util"; | ||||
| import {ipcRenderer} from "../../typed-ipc-renderer"; | ||||
| import * as DomainUtil from "../../utils/domain-util"; | ||||
| import {html} from "../../../../common/html.js"; | ||||
| import * as t from "../../../../common/translation-util.js"; | ||||
| import {ipcRenderer} from "../../typed-ipc-renderer.js"; | ||||
| import * as DomainUtil from "../../utils/domain-util.js"; | ||||
|  | ||||
| import {reloadApp} from "./base-section"; | ||||
| import {initFindAccounts} from "./find-accounts"; | ||||
| import {initServerInfoForm} from "./server-info-form"; | ||||
| import {reloadApp} from "./base-section.js"; | ||||
| import {initFindAccounts} from "./find-accounts.js"; | ||||
| import {initServerInfoForm} from "./server-info-form.js"; | ||||
|  | ||||
| interface ConnectedOrgSectionProps { | ||||
| type ConnectedOrgSectionProps = { | ||||
|   $root: Element; | ||||
| } | ||||
| }; | ||||
|  | ||||
| export function initConnectedOrgSection(props: ConnectedOrgSectionProps): void { | ||||
|   props.$root.textContent = ""; | ||||
| export function initConnectedOrgSection({ | ||||
|   $root, | ||||
| }: ConnectedOrgSectionProps): void { | ||||
|   $root.textContent = ""; | ||||
|  | ||||
|   const servers = DomainUtil.getDomains(); | ||||
|   props.$root.innerHTML = html` | ||||
|   $root.innerHTML = html` | ||||
|     <div class="settings-pane" id="server-settings-pane"> | ||||
|       <div class="page-title">${t.__("Connected organizations")}</div> | ||||
|       <div class="title" id="existing-servers"> | ||||
|         ${t.__("All the connected orgnizations will appear here.")} | ||||
|         ${t.__("All the connected organizations will appear here.")} | ||||
|       </div> | ||||
|       <div id="server-info-container"></div> | ||||
|       <div id="new-org-button"> | ||||
| @@ -32,18 +34,17 @@ export function initConnectedOrgSection(props: ConnectedOrgSectionProps): void { | ||||
|     </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( | ||||
|   const $serverInfoContainer = $root.querySelector("#server-info-container")!; | ||||
|   const $existingServers = $root.querySelector("#existing-servers")!; | ||||
|   const $newOrgButton: HTMLButtonElement = | ||||
|     $root.querySelector("#new-org-button")!; | ||||
|   const $findAccountsContainer = $root.querySelector( | ||||
|     "#find-accounts-container", | ||||
|   )!; | ||||
|  | ||||
|   const noServerText = t.__("All the connected orgnizations will appear here"); | ||||
|   const noServerText = t.__( | ||||
|     "All the connected organizations will appear here.", | ||||
|   ); | ||||
|   // Show noServerText if no servers are there otherwise hide it | ||||
|   $existingServers.textContent = servers.length === 0 ? noServerText : ""; | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| import {html} from "../../../../common/html"; | ||||
| import * as t from "../../../../common/translation-util"; | ||||
| import {generateNodeFromHTML} from "../../components/base"; | ||||
| import * as LinkUtil from "../../utils/link-util"; | ||||
| import {html} from "../../../../common/html.js"; | ||||
| import * as LinkUtil from "../../../../common/link-util.js"; | ||||
| import * as t from "../../../../common/translation-util.js"; | ||||
| import {generateNodeFromHtml} from "../../components/base.js"; | ||||
|  | ||||
| interface FindAccountsProps { | ||||
| type FindAccountsProps = { | ||||
|   $root: Element; | ||||
| } | ||||
| }; | ||||
|  | ||||
| async function findAccounts(url: string): Promise<void> { | ||||
|   if (!url) { | ||||
| @@ -20,7 +20,7 @@ async function findAccounts(url: string): Promise<void> { | ||||
| } | ||||
|  | ||||
| export function initFindAccounts(props: FindAccountsProps): void { | ||||
|   const $findAccounts = generateNodeFromHTML(html` | ||||
|   const $findAccounts = generateNodeFromHtml(html` | ||||
|     <div class="settings-card certificate-card"> | ||||
|       <div class="certificate-input"> | ||||
|         <div>${t.__("Organization URL")}</div> | ||||
| @@ -58,10 +58,9 @@ export function initFindAccounts(props: FindAccountsProps): void { | ||||
|   }); | ||||
|  | ||||
|   $serverUrlField.addEventListener("input", () => { | ||||
|     if ($serverUrlField.value) { | ||||
|       $serverUrlField.classList.remove("invalid-input-value"); | ||||
|     } else { | ||||
|       $serverUrlField.classList.add("invalid-input-value"); | ||||
|     } | ||||
|     $serverUrlField.classList.toggle( | ||||
|       "invalid-input-value", | ||||
|       $serverUrlField.value === "", | ||||
|     ); | ||||
|   }); | ||||
| } | ||||
|   | ||||
| @@ -1,29 +1,31 @@ | ||||
| import type {OpenDialogOptions} from "electron"; | ||||
| import {remote} from "electron"; | ||||
| import fs from "fs"; | ||||
| import path from "path"; | ||||
| import type {OpenDialogOptions} from "electron/renderer"; | ||||
| import fs from "node:fs"; | ||||
| import path from "node:path"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| import * as remote from "@electron/remote"; | ||||
| import {app, dialog, session} from "@electron/remote"; | ||||
| import Tagify from "@yaireo/tagify"; | ||||
| import ISO6391 from "iso-639-1"; | ||||
| import {z} from "zod"; | ||||
|  | ||||
| import * as ConfigUtil from "../../../../common/config-util"; | ||||
| import * as EnterpriseUtil from "../../../../common/enterprise-util"; | ||||
| import {html} from "../../../../common/html"; | ||||
| import * as t from "../../../../common/translation-util"; | ||||
| import supportedLocales from "../../../../translations/supported-locales.json"; | ||||
| import {ipcRenderer} from "../../typed-ipc-renderer"; | ||||
| import supportedLocales from "../../../../../public/translations/supported-locales.json"; | ||||
| import * as ConfigUtil from "../../../../common/config-util.js"; | ||||
| import * as EnterpriseUtil from "../../../../common/enterprise-util.js"; | ||||
| import {html} from "../../../../common/html.js"; | ||||
| import * as t from "../../../../common/translation-util.js"; | ||||
| import {ipcRenderer} from "../../typed-ipc-renderer.js"; | ||||
|  | ||||
| import {generateSelectHTML, generateSettingOption} from "./base-section"; | ||||
| import {generateSelectHtml, generateSettingOption} from "./base-section.js"; | ||||
|  | ||||
| const {app, dialog, session} = remote; | ||||
| const currentBrowserWindow = remote.getCurrentWindow(); | ||||
|  | ||||
| interface GeneralSectionProps { | ||||
| type GeneralSectionProps = { | ||||
|   $root: Element; | ||||
| } | ||||
| }; | ||||
|  | ||||
| export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|   props.$root.innerHTML = html` | ||||
| export function initGeneralSection({$root}: GeneralSectionProps): void { | ||||
|   $root.innerHTML = html` | ||||
|     <div class="settings-pane"> | ||||
|       <div class="title">${t.__("Appearance")}</div> | ||||
|       <div id="appearance-option-settings" class="settings-card"> | ||||
| @@ -221,9 +223,9 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|   showDesktopNotification(); | ||||
|   enableSpellchecker(); | ||||
|   minimizeOnStart(); | ||||
|   addCustomCSS(); | ||||
|   showCustomCSSPath(); | ||||
|   removeCustomCSS(); | ||||
|   addCustomCss(); | ||||
|   showCustomCssPath(); | ||||
|   removeCustomCss(); | ||||
|   downloadFolder(); | ||||
|   updateQuitOnCloseOption(); | ||||
|   updatePromptDownloadOption(); | ||||
| @@ -250,9 +252,9 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function updateTrayOption(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector("#tray-option .setting-control")!, | ||||
|       $element: $root.querySelector("#tray-option .setting-control")!, | ||||
|       value: ConfigUtil.getConfigItem("trayIcon", true), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("trayIcon", true); | ||||
|         ConfigUtil.setConfigItem("trayIcon", newValue); | ||||
|         ipcRenderer.send("forward-message", "toggletray"); | ||||
| @@ -263,9 +265,9 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function updateMenubarOption(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector("#menubar-option .setting-control")!, | ||||
|       $element: $root.querySelector("#menubar-option .setting-control")!, | ||||
|       value: ConfigUtil.getConfigItem("autoHideMenubar", false), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("autoHideMenubar", false); | ||||
|         ConfigUtil.setConfigItem("autoHideMenubar", newValue); | ||||
|         ipcRenderer.send("toggle-menubar", newValue); | ||||
| @@ -276,9 +278,9 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function updateBadgeOption(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector("#badge-option .setting-control")!, | ||||
|       $element: $root.querySelector("#badge-option .setting-control")!, | ||||
|       value: ConfigUtil.getConfigItem("badgeOption", true), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("badgeOption", true); | ||||
|         ConfigUtil.setConfigItem("badgeOption", newValue); | ||||
|         ipcRenderer.send("toggle-badge-option", newValue); | ||||
| @@ -289,9 +291,9 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function updateDockBouncing(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector("#dock-bounce-option .setting-control")!, | ||||
|       $element: $root.querySelector("#dock-bounce-option .setting-control")!, | ||||
|       value: ConfigUtil.getConfigItem("dockBouncing", true), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("dockBouncing", true); | ||||
|         ConfigUtil.setConfigItem("dockBouncing", newValue); | ||||
|         updateDockBouncing(); | ||||
| @@ -301,11 +303,9 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function updateFlashTaskbar(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector( | ||||
|         "#flash-taskbar-option .setting-control", | ||||
|       )!, | ||||
|       $element: $root.querySelector("#flash-taskbar-option .setting-control")!, | ||||
|       value: ConfigUtil.getConfigItem("flashTaskbarOnMessage", true), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem( | ||||
|           "flashTaskbarOnMessage", | ||||
|           true, | ||||
| @@ -318,10 +318,10 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function autoUpdateOption(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector("#autoupdate-option .setting-control")!, | ||||
|       $element: $root.querySelector("#autoupdate-option .setting-control")!, | ||||
|       disabled: EnterpriseUtil.configItemExists("autoUpdate"), | ||||
|       value: ConfigUtil.getConfigItem("autoUpdate", true), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("autoUpdate", true); | ||||
|         ConfigUtil.setConfigItem("autoUpdate", newValue); | ||||
|         if (!newValue) { | ||||
| @@ -336,9 +336,9 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function betaUpdateOption(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector("#betaupdate-option .setting-control")!, | ||||
|       $element: $root.querySelector("#betaupdate-option .setting-control")!, | ||||
|       value: ConfigUtil.getConfigItem("betaUpdate", false), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("betaUpdate", false); | ||||
|         if (ConfigUtil.getConfigItem("autoUpdate", true)) { | ||||
|           ConfigUtil.setConfigItem("betaUpdate", newValue); | ||||
| @@ -350,9 +350,9 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function updateSilentOption(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector("#silent-option .setting-control")!, | ||||
|       $element: $root.querySelector("#silent-option .setting-control")!, | ||||
|       value: ConfigUtil.getConfigItem("silent", false), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("silent", true); | ||||
|         ConfigUtil.setConfigItem("silent", newValue); | ||||
|         updateSilentOption(); | ||||
| @@ -367,11 +367,11 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function showDesktopNotification(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector( | ||||
|       $element: $root.querySelector( | ||||
|         "#show-notification-option .setting-control", | ||||
|       )!, | ||||
|       value: ConfigUtil.getConfigItem("showNotification", true), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("showNotification", true); | ||||
|         ConfigUtil.setConfigItem("showNotification", newValue); | ||||
|         showDesktopNotification(); | ||||
| @@ -381,9 +381,9 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function updateSidebarOption(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector("#sidebar-option .setting-control")!, | ||||
|       $element: $root.querySelector("#sidebar-option .setting-control")!, | ||||
|       value: ConfigUtil.getConfigItem("showSidebar", true), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("showSidebar", true); | ||||
|         ConfigUtil.setConfigItem("showSidebar", newValue); | ||||
|         ipcRenderer.send("forward-message", "toggle-sidebar", newValue); | ||||
| @@ -394,11 +394,9 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function updateStartAtLoginOption(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector( | ||||
|         "#startAtLogin-option .setting-control", | ||||
|       )!, | ||||
|       $element: $root.querySelector("#startAtLogin-option .setting-control")!, | ||||
|       value: ConfigUtil.getConfigItem("startAtLogin", false), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("startAtLogin", false); | ||||
|         ConfigUtil.setConfigItem("startAtLogin", newValue); | ||||
|         ipcRenderer.send("toggleAutoLauncher", newValue); | ||||
| @@ -409,9 +407,9 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function updateQuitOnCloseOption(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector("#quitOnClose-option .setting-control")!, | ||||
|       $element: $root.querySelector("#quitOnClose-option .setting-control")!, | ||||
|       value: ConfigUtil.getConfigItem("quitOnClose", false), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("quitOnClose", false); | ||||
|         ConfigUtil.setConfigItem("quitOnClose", newValue); | ||||
|         updateQuitOnCloseOption(); | ||||
| @@ -421,18 +419,18 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function enableSpellchecker(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector( | ||||
|       $element: $root.querySelector( | ||||
|         "#enable-spellchecker-option .setting-control", | ||||
|       )!, | ||||
|       value: ConfigUtil.getConfigItem("enableSpellchecker", true), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("enableSpellchecker", true); | ||||
|         ConfigUtil.setConfigItem("enableSpellchecker", newValue); | ||||
|         ipcRenderer.send("configure-spell-checker"); | ||||
|         enableSpellchecker(); | ||||
|         const spellcheckerLanguageInput: HTMLElement = document.querySelector( | ||||
|           "#spellcheck-langs", | ||||
|         )!; | ||||
|         const spellcheckerNote: HTMLElement = document.querySelector("#note")!; | ||||
|         const spellcheckerLanguageInput: HTMLElement = | ||||
|           $root.querySelector("#spellcheck-langs")!; | ||||
|         const spellcheckerNote: HTMLElement = $root.querySelector("#note")!; | ||||
|         spellcheckerLanguageInput.style.display = | ||||
|           spellcheckerLanguageInput.style.display === "none" ? "" : "none"; | ||||
|         spellcheckerNote.style.display = | ||||
| @@ -443,11 +441,11 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function enableErrorReporting(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector( | ||||
|       $element: $root.querySelector( | ||||
|         "#enable-error-reporting .setting-control", | ||||
|       )!, | ||||
|       value: ConfigUtil.getConfigItem("errorReporting", true), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("errorReporting", true); | ||||
|         ConfigUtil.setConfigItem("errorReporting", newValue); | ||||
|         enableErrorReporting(); | ||||
| @@ -462,9 +460,8 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|       filters: [{name: "CSS file", extensions: ["css"]}], | ||||
|     }; | ||||
|  | ||||
|     const {filePaths, canceled} = await dialog.showOpenDialog( | ||||
|       showDialogOptions, | ||||
|     ); | ||||
|     const {filePaths, canceled} = | ||||
|       await dialog.showOpenDialog(showDialogOptions); | ||||
|     if (!canceled) { | ||||
|       ConfigUtil.setConfigItem("customCSS", filePaths[0]); | ||||
|       ipcRenderer.send("forward-message", "hard-reload"); | ||||
| @@ -472,11 +469,11 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|   } | ||||
|  | ||||
|   function setLocale(): void { | ||||
|     const langDiv: HTMLSelectElement = document.querySelector(".lang-div")!; | ||||
|     const langListHTML = generateSelectHTML(supportedLocales, "lang-menu"); | ||||
|     langDiv.innerHTML += langListHTML.html; | ||||
|     const langDiv: HTMLSelectElement = $root.querySelector(".lang-div")!; | ||||
|     const langListHtml = generateSelectHtml(supportedLocales, "lang-menu"); | ||||
|     langDiv.innerHTML += langListHtml.html; | ||||
|     // `langMenu` is the select-option dropdown menu formed after executing the previous command | ||||
|     const langMenu: HTMLSelectElement = document.querySelector(".lang-menu")!; | ||||
|     const langMenu: HTMLSelectElement = $root.querySelector(".lang-menu")!; | ||||
|  | ||||
|     // The next three lines set the selected language visible on the dropdown button | ||||
|     let language = ConfigUtil.getConfigItem("appLanguage", "en"); | ||||
| @@ -491,11 +488,9 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function minimizeOnStart(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector( | ||||
|         "#start-minimize-option .setting-control", | ||||
|       )!, | ||||
|       $element: $root.querySelector("#start-minimize-option .setting-control")!, | ||||
|       value: ConfigUtil.getConfigItem("startMinimized", false), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("startMinimized", false); | ||||
|         ConfigUtil.setConfigItem("startMinimized", newValue); | ||||
|         minimizeOnStart(); | ||||
| @@ -503,27 +498,25 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function addCustomCSS(): void { | ||||
|     const customCSSButton = document.querySelector( | ||||
|   function addCustomCss(): void { | ||||
|     const customCssButton = $root.querySelector( | ||||
|       "#add-custom-css .custom-css-button", | ||||
|     )!; | ||||
|     customCSSButton.addEventListener("click", async () => { | ||||
|     customCssButton.addEventListener("click", async () => { | ||||
|       await customCssDialog(); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function showCustomCSSPath(): void { | ||||
|   function showCustomCssPath(): void { | ||||
|     if (!ConfigUtil.getConfigItem("customCSS", null)) { | ||||
|       const cssPATH: HTMLElement = document.querySelector( | ||||
|         "#remove-custom-css", | ||||
|       )!; | ||||
|       cssPATH.style.display = "none"; | ||||
|       const cssPath: HTMLElement = $root.querySelector("#remove-custom-css")!; | ||||
|       cssPath.style.display = "none"; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function removeCustomCSS(): void { | ||||
|     const removeCSSButton = document.querySelector("#css-delete-action")!; | ||||
|     removeCSSButton.addEventListener("click", () => { | ||||
|   function removeCustomCss(): void { | ||||
|     const removeCssButton = $root.querySelector("#css-delete-action")!; | ||||
|     removeCssButton.addEventListener("click", () => { | ||||
|       ConfigUtil.setConfigItem("customCSS", ""); | ||||
|       ipcRenderer.send("forward-message", "hard-reload"); | ||||
|     }); | ||||
| @@ -535,12 +528,11 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|       properties: ["openDirectory"], | ||||
|     }; | ||||
|  | ||||
|     const {filePaths, canceled} = await dialog.showOpenDialog( | ||||
|       showDialogOptions, | ||||
|     ); | ||||
|     const {filePaths, canceled} = | ||||
|       await dialog.showOpenDialog(showDialogOptions); | ||||
|     if (!canceled) { | ||||
|       ConfigUtil.setConfigItem("downloadsPath", filePaths[0]); | ||||
|       const downloadFolderPath: HTMLElement = document.querySelector( | ||||
|       const downloadFolderPath: HTMLElement = $root.querySelector( | ||||
|         ".download-folder-path", | ||||
|       )!; | ||||
|       downloadFolderPath.textContent = filePaths[0]; | ||||
| @@ -548,7 +540,7 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|   } | ||||
|  | ||||
|   function downloadFolder(): void { | ||||
|     const downloadFolder = document.querySelector( | ||||
|     const downloadFolder = $root.querySelector( | ||||
|       "#download-folder .download-folder-button", | ||||
|     )!; | ||||
|     downloadFolder.addEventListener("click", async () => { | ||||
| @@ -558,9 +550,9 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|  | ||||
|   function updatePromptDownloadOption(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector("#prompt-download .setting-control")!, | ||||
|       $element: $root.querySelector("#prompt-download .setting-control")!, | ||||
|       value: ConfigUtil.getConfigItem("promptDownload", false), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("promptDownload", false); | ||||
|         ConfigUtil.setConfigItem("promptDownload", newValue); | ||||
|         updatePromptDownloadOption(); | ||||
| @@ -589,7 +581,7 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|   } | ||||
|  | ||||
|   function factoryReset(): void { | ||||
|     const factoryResetButton = document.querySelector( | ||||
|     const factoryResetButton = $root.querySelector( | ||||
|       "#factory-reset-option .factory-reset-button", | ||||
|     )!; | ||||
|     factoryResetButton.addEventListener("click", async () => { | ||||
| @@ -598,9 +590,9 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|   } | ||||
|  | ||||
|   function initSpellChecker(): void { | ||||
|     // The elctron API is a no-op on macOS and macOS default spellchecker is used. | ||||
|     // The Electron API is a no-op on macOS and macOS default spellchecker is used. | ||||
|     if (process.platform === "darwin") { | ||||
|       const note: HTMLElement = document.querySelector("#note")!; | ||||
|       const note: HTMLElement = $root.querySelector("#note")!; | ||||
|       note.append(t.__("On macOS, the OS spellchecker is used.")); | ||||
|       note.append(document.createElement("br")); | ||||
|       note.append( | ||||
| @@ -609,21 +601,22 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|         ), | ||||
|       ); | ||||
|     } else { | ||||
|       const note: HTMLElement = document.querySelector("#note")!; | ||||
|       const note: HTMLElement = $root.querySelector("#note")!; | ||||
|       note.append( | ||||
|         t.__("You can select a maximum of 3 languages for spellchecking."), | ||||
|       ); | ||||
|       const spellDiv: HTMLElement = document.querySelector( | ||||
|         "#spellcheck-langs", | ||||
|       )!; | ||||
|       const spellDiv: HTMLElement = $root.querySelector("#spellcheck-langs")!; | ||||
|       spellDiv.innerHTML += html` | ||||
|         <div class="setting-description">${t.__("Spellchecker Languages")}</div> | ||||
|         <input name="spellcheck" placeholder="Enter Languages" /> | ||||
|         <div id="spellcheck-langs-value"> | ||||
|           <input name="spellcheck" placeholder="Enter Languages" /> | ||||
|         </div> | ||||
|       `.html; | ||||
|  | ||||
|       const availableLanguages = session.fromPartition("persist:webviewsession") | ||||
|         .availableSpellCheckerLanguages; | ||||
|       let languagePairs: Map<string, string> = new Map(); | ||||
|       const availableLanguages = session.fromPartition( | ||||
|         "persist:webviewsession", | ||||
|       ).availableSpellCheckerLanguages; | ||||
|       let languagePairs = new Map<string, string>(); | ||||
|       for (const l of availableLanguages) { | ||||
|         if (ISO6391.validate(l)) { | ||||
|           languagePairs.set(ISO6391.getName(l), l); | ||||
| @@ -647,7 +640,7 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|         [...languagePairs].sort((a, b) => (a[0] < b[0] ? -1 : 1)), | ||||
|       ); | ||||
|  | ||||
|       const tagField: HTMLInputElement = document.querySelector( | ||||
|       const tagField: HTMLInputElement = $root.querySelector( | ||||
|         "input[name=spellcheck]", | ||||
|       )!; | ||||
|       const tagify = new Tagify(tagField, { | ||||
| @@ -659,8 +652,20 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|           maxItems: Number.POSITIVE_INFINITY, | ||||
|           closeOnSelect: false, | ||||
|           highlightFirst: true, | ||||
|           position: "manual", | ||||
|           classname: "settings-tagify-dropdown", | ||||
|         }, | ||||
|       }); | ||||
|       tagify.DOM.input.addEventListener("focus", () => { | ||||
|         tagify.dropdown.show(); | ||||
|         $root | ||||
|           .querySelector("#spellcheck-langs-value")! | ||||
|           .append(tagify.DOM.dropdown); | ||||
|       }); | ||||
|       tagify.DOM.input.addEventListener("blur", () => { | ||||
|         tagify.dropdown.hide(true); | ||||
|         tagify.DOM.dropdown.remove(); | ||||
|       }); | ||||
|  | ||||
|       const configuredLanguages: string[] = ( | ||||
|         ConfigUtil.getConfigItem("spellcheckerLanguages", null) ?? [] | ||||
| @@ -673,23 +678,24 @@ export function initGeneralSection(props: GeneralSectionProps): void { | ||||
|       tagField.addEventListener("change", () => { | ||||
|         if (tagField.value.length === 0) { | ||||
|           ConfigUtil.setConfigItem("spellcheckerLanguages", []); | ||||
|           ipcRenderer.send("set-spellcheck-langs"); | ||||
|           ipcRenderer.send("configure-spell-checker"); | ||||
|         } else { | ||||
|           const spellLangs: string[] = [...JSON.parse(tagField.value)].map( | ||||
|             (elt: {value: string}) => languagePairs.get(elt.value)!, | ||||
|           ); | ||||
|           const data: unknown = JSON.parse(tagField.value); | ||||
|           const spellLangs: string[] = z | ||||
|             .array(z.object({value: z.string()})) | ||||
|             .parse(data) | ||||
|             .map((elt) => languagePairs.get(elt.value)!); | ||||
|           ConfigUtil.setConfigItem("spellcheckerLanguages", spellLangs); | ||||
|           ipcRenderer.send("set-spellcheck-langs"); | ||||
|           ipcRenderer.send("configure-spell-checker"); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     // Do not display the spellchecker input and note if it is disabled | ||||
|     if (!ConfigUtil.getConfigItem("enableSpellchecker", true)) { | ||||
|       const spellcheckerLanguageInput: HTMLElement = document.querySelector( | ||||
|         "#spellcheck-langs", | ||||
|       )!; | ||||
|       const spellcheckerNote: HTMLElement = document.querySelector("#note")!; | ||||
|       const spellcheckerLanguageInput: HTMLElement = | ||||
|         $root.querySelector("#spellcheck-langs")!; | ||||
|       const spellcheckerNote: HTMLElement = $root.querySelector("#note")!; | ||||
|       spellcheckerLanguageInput.style.display = "none"; | ||||
|       spellcheckerNote.style.display = "none"; | ||||
|     } | ||||
|   | ||||
| @@ -1,20 +1,18 @@ | ||||
| import type {HTML} from "../../../../common/html"; | ||||
| import {html} from "../../../../common/html"; | ||||
| import * as t from "../../../../common/translation-util"; | ||||
| import type {NavItem} from "../../../../common/types"; | ||||
| import {generateNodeFromHTML} from "../../components/base"; | ||||
| import type {Html} from "../../../../common/html.js"; | ||||
| import {html} from "../../../../common/html.js"; | ||||
| import * as t from "../../../../common/translation-util.js"; | ||||
| import type {NavItem} from "../../../../common/types.js"; | ||||
| import {generateNodeFromHtml} from "../../components/base.js"; | ||||
|  | ||||
| interface PreferenceNavProps { | ||||
| type PreferenceNavProps = { | ||||
|   $root: Element; | ||||
|   onItemSelected: (navItem: NavItem) => void; | ||||
| } | ||||
| }; | ||||
|  | ||||
| export default class PreferenceNav { | ||||
|   props: PreferenceNavProps; | ||||
|   navItems: NavItem[]; | ||||
|   $el: Element; | ||||
|   constructor(props: PreferenceNavProps) { | ||||
|     this.props = props; | ||||
|   constructor(private readonly props: PreferenceNavProps) { | ||||
|     this.navItems = [ | ||||
|       "General", | ||||
|       "Network", | ||||
| @@ -23,13 +21,13 @@ export default class PreferenceNav { | ||||
|       "Shortcuts", | ||||
|     ]; | ||||
|  | ||||
|     this.$el = generateNodeFromHTML(this.templateHTML()); | ||||
|     this.$el = generateNodeFromHtml(this.templateHtml()); | ||||
|     this.props.$root.append(this.$el); | ||||
|     this.registerListeners(); | ||||
|   } | ||||
|  | ||||
|   templateHTML(): HTML { | ||||
|     const navItemsHTML = html``.join( | ||||
|   templateHtml(): Html { | ||||
|     const navItemsHtml = html``.join( | ||||
|       this.navItems.map( | ||||
|         (navItem) => html` | ||||
|           <div class="nav" id="nav-${navItem}">${t.__(navItem)}</div> | ||||
| @@ -40,14 +38,14 @@ export default class PreferenceNav { | ||||
|     return html` | ||||
|       <div> | ||||
|         <div id="settings-header">${t.__("Settings")}</div> | ||||
|         <div id="nav-container">${navItemsHTML}</div> | ||||
|         <div id="nav-container">${navItemsHtml}</div> | ||||
|       </div> | ||||
|     `; | ||||
|   } | ||||
|  | ||||
|   registerListeners(): void { | ||||
|     for (const navItem of this.navItems) { | ||||
|       const $item = document.querySelector(`#nav-${CSS.escape(navItem)}`)!; | ||||
|       const $item = this.$el.querySelector(`#nav-${CSS.escape(navItem)}`)!; | ||||
|       $item.addEventListener("click", () => { | ||||
|         this.props.onItemSelected(navItem); | ||||
|       }); | ||||
| @@ -65,12 +63,12 @@ export default class PreferenceNav { | ||||
|   } | ||||
|  | ||||
|   activate(navItem: NavItem): void { | ||||
|     const $item = document.querySelector(`#nav-${CSS.escape(navItem)}`)!; | ||||
|     const $item = this.$el.querySelector(`#nav-${CSS.escape(navItem)}`)!; | ||||
|     $item.classList.add("active"); | ||||
|   } | ||||
|  | ||||
|   deactivate(navItem: NavItem): void { | ||||
|     const $item = document.querySelector(`#nav-${CSS.escape(navItem)}`)!; | ||||
|     const $item = this.$el.querySelector(`#nav-${CSS.escape(navItem)}`)!; | ||||
|     $item.classList.remove("active"); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,16 +1,16 @@ | ||||
| import * as ConfigUtil from "../../../../common/config-util"; | ||||
| import {html} from "../../../../common/html"; | ||||
| import * as t from "../../../../common/translation-util"; | ||||
| import {ipcRenderer} from "../../typed-ipc-renderer"; | ||||
| import * as ConfigUtil from "../../../../common/config-util.js"; | ||||
| import {html} from "../../../../common/html.js"; | ||||
| import * as t from "../../../../common/translation-util.js"; | ||||
| import {ipcRenderer} from "../../typed-ipc-renderer.js"; | ||||
|  | ||||
| import {generateSettingOption} from "./base-section"; | ||||
| import {generateSettingOption} from "./base-section.js"; | ||||
|  | ||||
| interface NetworkSectionProps { | ||||
| type NetworkSectionProps = { | ||||
|   $root: Element; | ||||
| } | ||||
| }; | ||||
|  | ||||
| export function initNetworkSection(props: NetworkSectionProps): void { | ||||
|   props.$root.innerHTML = html` | ||||
| export function initNetworkSection({$root}: NetworkSectionProps): void { | ||||
|   $root.innerHTML = html` | ||||
|     <div class="settings-pane"> | ||||
|       <div class="title">${t.__("Proxy")}</div> | ||||
|       <div id="appearance-option-settings" class="settings-card"> | ||||
| @@ -55,27 +55,27 @@ export function initNetworkSection(props: NetworkSectionProps): void { | ||||
|     </div> | ||||
|   `.html; | ||||
|  | ||||
|   const $proxyPAC: HTMLInputElement = document.querySelector( | ||||
|   const $proxyPac: HTMLInputElement = $root.querySelector( | ||||
|     "#proxy-pac-option .setting-input-value", | ||||
|   )!; | ||||
|   const $proxyRules: HTMLInputElement = document.querySelector( | ||||
|   const $proxyRules: HTMLInputElement = $root.querySelector( | ||||
|     "#proxy-rules-option .setting-input-value", | ||||
|   )!; | ||||
|   const $proxyBypass: HTMLInputElement = document.querySelector( | ||||
|   const $proxyBypass: HTMLInputElement = $root.querySelector( | ||||
|     "#proxy-bypass-option .setting-input-value", | ||||
|   )!; | ||||
|   const $proxySaveAction = document.querySelector("#proxy-save-action")!; | ||||
|   const $manualProxyBlock = props.$root.querySelector(".manual-proxy-block")!; | ||||
|   const $proxySaveAction = $root.querySelector("#proxy-save-action")!; | ||||
|   const $manualProxyBlock = $root.querySelector(".manual-proxy-block")!; | ||||
|  | ||||
|   toggleManualProxySettings(ConfigUtil.getConfigItem("useManualProxy", false)); | ||||
|   updateProxyOption(); | ||||
|  | ||||
|   $proxyPAC.value = ConfigUtil.getConfigItem("proxyPAC", ""); | ||||
|   $proxyPac.value = ConfigUtil.getConfigItem("proxyPAC", ""); | ||||
|   $proxyRules.value = ConfigUtil.getConfigItem("proxyRules", ""); | ||||
|   $proxyBypass.value = ConfigUtil.getConfigItem("proxyBypass", ""); | ||||
|  | ||||
|   $proxySaveAction.addEventListener("click", () => { | ||||
|     ConfigUtil.setConfigItem("proxyPAC", $proxyPAC.value); | ||||
|     ConfigUtil.setConfigItem("proxyPAC", $proxyPac.value); | ||||
|     ConfigUtil.setConfigItem("proxyRules", $proxyRules.value); | ||||
|     ConfigUtil.setConfigItem("proxyBypass", $proxyBypass.value); | ||||
|  | ||||
| @@ -83,20 +83,14 @@ export function initNetworkSection(props: NetworkSectionProps): void { | ||||
|   }); | ||||
|  | ||||
|   function toggleManualProxySettings(option: boolean): void { | ||||
|     if (option) { | ||||
|       $manualProxyBlock.classList.remove("hidden"); | ||||
|     } else { | ||||
|       $manualProxyBlock.classList.add("hidden"); | ||||
|     } | ||||
|     $manualProxyBlock.classList.toggle("hidden", !option); | ||||
|   } | ||||
|  | ||||
|   function updateProxyOption(): void { | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector( | ||||
|         "#use-system-settings .setting-control", | ||||
|       )!, | ||||
|       $element: $root.querySelector("#use-system-settings .setting-control")!, | ||||
|       value: ConfigUtil.getConfigItem("useSystemProxy", false), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("useSystemProxy", false); | ||||
|         const manualProxyValue = ConfigUtil.getConfigItem( | ||||
|           "useManualProxy", | ||||
| @@ -118,11 +112,9 @@ export function initNetworkSection(props: NetworkSectionProps): void { | ||||
|       }, | ||||
|     }); | ||||
|     generateSettingOption({ | ||||
|       $element: document.querySelector( | ||||
|         "#use-manual-settings .setting-control", | ||||
|       )!, | ||||
|       $element: $root.querySelector("#use-manual-settings .setting-control")!, | ||||
|       value: ConfigUtil.getConfigItem("useManualProxy", false), | ||||
|       clickHandler: () => { | ||||
|       clickHandler() { | ||||
|         const newValue = !ConfigUtil.getConfigItem("useManualProxy", false); | ||||
|         const systemProxyValue = ConfigUtil.getConfigItem( | ||||
|           "useSystemProxy", | ||||
|   | ||||
| @@ -1,21 +1,19 @@ | ||||
| import {remote} from "electron"; | ||||
| import {dialog} from "@electron/remote"; | ||||
|  | ||||
| import {html} from "../../../../common/html"; | ||||
| 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"; | ||||
| import {html} from "../../../../common/html.js"; | ||||
| import * as LinkUtil from "../../../../common/link-util.js"; | ||||
| import * as t from "../../../../common/translation-util.js"; | ||||
| import {generateNodeFromHtml} from "../../components/base.js"; | ||||
| import {ipcRenderer} from "../../typed-ipc-renderer.js"; | ||||
| import * as DomainUtil from "../../utils/domain-util.js"; | ||||
|  | ||||
| const {dialog} = remote; | ||||
|  | ||||
| interface NewServerFormProps { | ||||
| type NewServerFormProps = { | ||||
|   $root: Element; | ||||
|   onChange: () => void; | ||||
| } | ||||
| }; | ||||
|  | ||||
| export function initNewServerForm(props: NewServerFormProps): void { | ||||
|   const $newServerForm = generateNodeFromHTML(html` | ||||
| export function initNewServerForm({$root, onChange}: NewServerFormProps): void { | ||||
|   const $newServerForm = generateNodeFromHtml(html` | ||||
|     <div class="server-input-container"> | ||||
|       <div class="title">${t.__("Organization URL")}</div> | ||||
|       <div class="add-server-info-row"> | ||||
| @@ -50,11 +48,10 @@ export function initNewServerForm(props: NewServerFormProps): void { | ||||
|       </div> | ||||
|     </div> | ||||
|   `); | ||||
|   const $saveServerButton: HTMLButtonElement = $newServerForm.querySelector( | ||||
|     "#connect", | ||||
|   )!; | ||||
|   props.$root.textContent = ""; | ||||
|   props.$root.append($newServerForm); | ||||
|   const $saveServerButton: HTMLButtonElement = | ||||
|     $newServerForm.querySelector("#connect")!; | ||||
|   $root.textContent = ""; | ||||
|   $root.append($newServerForm); | ||||
|   const $newServerUrl: HTMLInputElement = $newServerForm.querySelector( | ||||
|     "input.setting-input-value", | ||||
|   )!; | ||||
| @@ -78,7 +75,7 @@ export function initNewServerForm(props: NewServerFormProps): void { | ||||
|     } | ||||
|  | ||||
|     await DomainUtil.addDomain(serverConf); | ||||
|     props.onChange(); | ||||
|     onChange(); | ||||
|   } | ||||
|  | ||||
|   $saveServerButton.addEventListener("click", async () => { | ||||
| @@ -92,14 +89,14 @@ export function initNewServerForm(props: NewServerFormProps): void { | ||||
|  | ||||
|   // Open create new org link in default browser | ||||
|   const link = "https://zulip.com/new/"; | ||||
|   const externalCreateNewOrgElement = document.querySelector( | ||||
|   const externalCreateNewOrgElement = $root.querySelector( | ||||
|     "#open-create-org-link", | ||||
|   )!; | ||||
|   externalCreateNewOrgElement.addEventListener("click", async () => { | ||||
|     await LinkUtil.openBrowser(new URL(link)); | ||||
|   }); | ||||
|  | ||||
|   const networkSettingsId = document.querySelector(".server-network-option")!; | ||||
|   const networkSettingsId = $root.querySelector(".server-network-option")!; | ||||
|   networkSettingsId.addEventListener("click", () => { | ||||
|     ipcRenderer.send("forward-message", "open-network-settings"); | ||||
|   }); | ||||
|   | ||||
| @@ -1,106 +1,148 @@ | ||||
| import type {DNDSettings} from "../../../../common/dnd-util"; | ||||
| import type {NavItem} from "../../../../common/types"; | ||||
| import {ipcRenderer} from "../../typed-ipc-renderer"; | ||||
| import type {IpcRendererEvent} from "electron/renderer"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| import {initConnectedOrgSection} from "./connected-org-section"; | ||||
| import {initGeneralSection} from "./general-section"; | ||||
| import Nav from "./nav"; | ||||
| import {initNetworkSection} from "./network-section"; | ||||
| import {initServersSection} from "./servers-section"; | ||||
| import {initShortcutsSection} from "./shortcuts-section"; | ||||
| import type {DndSettings} from "../../../../common/dnd-util.js"; | ||||
| import {bundleUrl} from "../../../../common/paths.js"; | ||||
| import type {NavItem} from "../../../../common/types.js"; | ||||
| import {ipcRenderer} from "../../typed-ipc-renderer.js"; | ||||
|  | ||||
| export function initPreferenceView(): void { | ||||
|   const $sidebarContainer = document.querySelector("#sidebar")!; | ||||
|   const $settingsContainer = document.querySelector("#settings-container")!; | ||||
| import {initConnectedOrgSection} from "./connected-org-section.js"; | ||||
| import {initGeneralSection} from "./general-section.js"; | ||||
| import Nav from "./nav.js"; | ||||
| import {initNetworkSection} from "./network-section.js"; | ||||
| import {initServersSection} from "./servers-section.js"; | ||||
| import {initShortcutsSection} from "./shortcuts-section.js"; | ||||
|  | ||||
|   const nav = new Nav({ | ||||
|     $root: $sidebarContainer, | ||||
|     onItemSelected: handleNavigation, | ||||
|   }); | ||||
| export class PreferenceView { | ||||
|   static async create(): Promise<PreferenceView> { | ||||
|     return new PreferenceView( | ||||
|       await ( | ||||
|         await fetch(new URL("app/renderer/preference.html", bundleUrl)) | ||||
|       ).text(), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   const navItem = | ||||
|     nav.navItems.find((navItem) => window.location.hash === `#${navItem}`) ?? | ||||
|     "General"; | ||||
|   readonly $view: HTMLElement; | ||||
|   private readonly $shadow: ShadowRoot; | ||||
|   private readonly $settingsContainer: Element; | ||||
|   private readonly nav: Nav; | ||||
|   private navItem: NavItem = "General"; | ||||
|  | ||||
|   handleNavigation(navItem); | ||||
|   private constructor(templateHtml: string) { | ||||
|     this.$view = document.createElement("div"); | ||||
|     this.$shadow = this.$view.attachShadow({mode: "open"}); | ||||
|     this.$shadow.innerHTML = templateHtml; | ||||
|  | ||||
|   function handleNavigation(navItem: NavItem): void { | ||||
|     nav.select(navItem); | ||||
|     const $sidebarContainer = this.$shadow.querySelector("#sidebar")!; | ||||
|     this.$settingsContainer = this.$shadow.querySelector( | ||||
|       "#settings-container", | ||||
|     )!; | ||||
|  | ||||
|     this.nav = new Nav({ | ||||
|       $root: $sidebarContainer, | ||||
|       onItemSelected: this.handleNavigation, | ||||
|     }); | ||||
|  | ||||
|     ipcRenderer.on("toggle-sidebar", this.handleToggleSidebar); | ||||
|     ipcRenderer.on("toggle-autohide-menubar", this.handleToggleMenubar); | ||||
|     ipcRenderer.on("toggle-dnd", this.handleToggleDnd); | ||||
|  | ||||
|     this.handleNavigation(this.navItem); | ||||
|   } | ||||
|  | ||||
|   handleNavigation = (navItem: NavItem): void => { | ||||
|     this.navItem = navItem; | ||||
|     this.nav.select(navItem); | ||||
|     switch (navItem) { | ||||
|       case "AddServer": | ||||
|       case "AddServer": { | ||||
|         initServersSection({ | ||||
|           $root: $settingsContainer, | ||||
|         }); | ||||
|         break; | ||||
|  | ||||
|       case "General": | ||||
|         initGeneralSection({ | ||||
|           $root: $settingsContainer, | ||||
|         }); | ||||
|         break; | ||||
|  | ||||
|       case "Organizations": | ||||
|         initConnectedOrgSection({ | ||||
|           $root: $settingsContainer, | ||||
|         }); | ||||
|         break; | ||||
|  | ||||
|       case "Network": | ||||
|         initNetworkSection({ | ||||
|           $root: $settingsContainer, | ||||
|         }); | ||||
|         break; | ||||
|  | ||||
|       case "Shortcuts": { | ||||
|         initShortcutsSection({ | ||||
|           $root: $settingsContainer, | ||||
|           $root: this.$settingsContainer, | ||||
|         }); | ||||
|         break; | ||||
|       } | ||||
|  | ||||
|       default: | ||||
|       case "General": { | ||||
|         initGeneralSection({ | ||||
|           $root: this.$settingsContainer, | ||||
|         }); | ||||
|         break; | ||||
|       } | ||||
|  | ||||
|       case "Organizations": { | ||||
|         initConnectedOrgSection({ | ||||
|           $root: this.$settingsContainer, | ||||
|         }); | ||||
|         break; | ||||
|       } | ||||
|  | ||||
|       case "Network": { | ||||
|         initNetworkSection({ | ||||
|           $root: this.$settingsContainer, | ||||
|         }); | ||||
|         break; | ||||
|       } | ||||
|  | ||||
|       case "Shortcuts": { | ||||
|         initShortcutsSection({ | ||||
|           $root: this.$settingsContainer, | ||||
|         }); | ||||
|         break; | ||||
|       } | ||||
|  | ||||
|       default: { | ||||
|         ((n: never) => n)(navItem); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     window.location.hash = `#${navItem}`; | ||||
|   }; | ||||
|  | ||||
|   handleToggleTray(state: boolean) { | ||||
|     this.handleToggle("tray-option", state); | ||||
|   } | ||||
|  | ||||
|   destroy(): void { | ||||
|     ipcRenderer.off("toggle-sidebar", this.handleToggleSidebar); | ||||
|     ipcRenderer.off("toggle-autohide-menubar", this.handleToggleMenubar); | ||||
|     ipcRenderer.off("toggle-dnd", this.handleToggleDnd); | ||||
|   } | ||||
|  | ||||
|   // Handle toggling and reflect changes in preference page | ||||
|   function handleToggle(elementName: string, state = false): void { | ||||
|   private handleToggle(elementName: string, state = false): void { | ||||
|     const inputSelector = `#${elementName} .action .switch input`; | ||||
|     const input: HTMLInputElement = document.querySelector(inputSelector)!; | ||||
|     const input: HTMLInputElement = this.$shadow.querySelector(inputSelector)!; | ||||
|     if (input) { | ||||
|       input.checked = state; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   ipcRenderer.on("switch-settings-nav", (_event: Event, navItem: NavItem) => { | ||||
|     handleNavigation(navItem); | ||||
|   }); | ||||
|   private readonly handleToggleSidebar = ( | ||||
|     _event: IpcRendererEvent, | ||||
|     state: boolean, | ||||
|   ) => { | ||||
|     this.handleToggle("sidebar-option", state); | ||||
|   }; | ||||
|  | ||||
|   ipcRenderer.on("toggle-sidebar-setting", (_event: Event, state: boolean) => { | ||||
|     handleToggle("sidebar-option", state); | ||||
|   }); | ||||
|   private readonly handleToggleMenubar = ( | ||||
|     _event: IpcRendererEvent, | ||||
|     state: boolean, | ||||
|   ) => { | ||||
|     this.handleToggle("menubar-option", state); | ||||
|   }; | ||||
|  | ||||
|   ipcRenderer.on("toggle-menubar-setting", (_event: Event, state: boolean) => { | ||||
|     handleToggle("menubar-option", state); | ||||
|   }); | ||||
|   private readonly handleToggleDnd = ( | ||||
|     _event: IpcRendererEvent, | ||||
|     _state: boolean, | ||||
|     newSettings: Partial<DndSettings>, | ||||
|   ) => { | ||||
|     this.handleToggle("show-notification-option", newSettings.showNotification); | ||||
|     this.handleToggle("silent-option", newSettings.silent); | ||||
|  | ||||
|   ipcRenderer.on("toggle-tray", (_event: Event, state: boolean) => { | ||||
|     handleToggle("tray-option", state); | ||||
|   }); | ||||
|  | ||||
|   ipcRenderer.on( | ||||
|     "toggle-dnd", | ||||
|     (_event: Event, _state: boolean, newSettings: Partial<DNDSettings>) => { | ||||
|       handleToggle("show-notification-option", newSettings.showNotification); | ||||
|       handleToggle("silent-option", newSettings.silent); | ||||
|  | ||||
|       if (process.platform === "win32") { | ||||
|         handleToggle("flash-taskbar-option", newSettings.flashTaskbarOnMessage); | ||||
|       } | ||||
|     }, | ||||
|   ); | ||||
|     if (process.platform === "win32") { | ||||
|       this.handleToggle( | ||||
|         "flash-taskbar-option", | ||||
|         newSettings.flashTaskbarOnMessage, | ||||
|       ); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
|  | ||||
| window.addEventListener("load", initPreferenceView); | ||||
|   | ||||
| @@ -1,27 +1,28 @@ | ||||
| import {remote} from "electron"; | ||||
| import {dialog} from "@electron/remote"; | ||||
|  | ||||
| import {html} from "../../../../common/html"; | ||||
| import * as Messages from "../../../../common/messages"; | ||||
| import * as t from "../../../../common/translation-util"; | ||||
| import type {ServerConf} from "../../../../common/types"; | ||||
| import {generateNodeFromHTML} from "../../components/base"; | ||||
| import {ipcRenderer} from "../../typed-ipc-renderer"; | ||||
| import * as DomainUtil from "../../utils/domain-util"; | ||||
| import {html} from "../../../../common/html.js"; | ||||
| import * as Messages from "../../../../common/messages.js"; | ||||
| import * as t from "../../../../common/translation-util.js"; | ||||
| import type {ServerConf} from "../../../../common/types.js"; | ||||
| import {generateNodeFromHtml} from "../../components/base.js"; | ||||
| import {ipcRenderer} from "../../typed-ipc-renderer.js"; | ||||
| import * as DomainUtil from "../../utils/domain-util.js"; | ||||
|  | ||||
| const {dialog} = remote; | ||||
|  | ||||
| interface ServerInfoFormProps { | ||||
| type ServerInfoFormProps = { | ||||
|   $root: Element; | ||||
|   server: ServerConf; | ||||
|   index: number; | ||||
|   onChange: () => void; | ||||
| } | ||||
| }; | ||||
|  | ||||
| export function initServerInfoForm(props: ServerInfoFormProps): void { | ||||
|   const $serverInfoForm = generateNodeFromHTML(html` | ||||
|   const $serverInfoForm = generateNodeFromHtml(html` | ||||
|     <div class="settings-card"> | ||||
|       <div class="server-info-left"> | ||||
|         <img class="server-info-icon" src="${props.server.icon}" /> | ||||
|         <img | ||||
|           class="server-info-icon" | ||||
|           src="${DomainUtil.iconAsUrl(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> | ||||
|   | ||||
| @@ -1,17 +1,15 @@ | ||||
| import {html} from "../../../../common/html"; | ||||
| import * as t from "../../../../common/translation-util"; | ||||
| import {html} from "../../../../common/html.js"; | ||||
| import * as t from "../../../../common/translation-util.js"; | ||||
|  | ||||
| import {reloadApp} from "./base-section"; | ||||
| import {initNewServerForm} from "./new-server-form"; | ||||
| import {reloadApp} from "./base-section.js"; | ||||
| import {initNewServerForm} from "./new-server-form.js"; | ||||
|  | ||||
| interface ServersSectionProps { | ||||
| type ServersSectionProps = { | ||||
|   $root: Element; | ||||
| } | ||||
| }; | ||||
|  | ||||
| export function initServersSection(props: ServersSectionProps): void { | ||||
|   props.$root.textContent = ""; | ||||
|  | ||||
|   props.$root.innerHTML = html` | ||||
| export function initServersSection({$root}: ServersSectionProps): void { | ||||
|   $root.innerHTML = html` | ||||
|     <div class="add-server-modal"> | ||||
|       <div class="modal-container"> | ||||
|         <div class="settings-pane" id="server-settings-pane"> | ||||
| @@ -21,7 +19,7 @@ export function initServersSection(props: ServersSectionProps): void { | ||||
|       </div> | ||||
|     </div> | ||||
|   `.html; | ||||
|   const $newServerContainer = document.querySelector("#new-server-container")!; | ||||
|   const $newServerContainer = $root.querySelector("#new-server-container")!; | ||||
|  | ||||
|   initNewServerForm({ | ||||
|     $root: $newServerContainer, | ||||
|   | ||||
| @@ -1,16 +1,18 @@ | ||||
| import {html} from "../../../../common/html"; | ||||
| import * as t from "../../../../common/translation-util"; | ||||
| import * as LinkUtil from "../../utils/link-util"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| interface ShortcutsSectionProps { | ||||
| import {html} from "../../../../common/html.js"; | ||||
| import * as LinkUtil from "../../../../common/link-util.js"; | ||||
| import * as t from "../../../../common/translation-util.js"; | ||||
|  | ||||
| type ShortcutsSectionProps = { | ||||
|   $root: Element; | ||||
| } | ||||
| }; | ||||
|  | ||||
| // eslint-disable-next-line complexity | ||||
| export function initShortcutsSection(props: ShortcutsSectionProps): void { | ||||
| export function initShortcutsSection({$root}: ShortcutsSectionProps): void { | ||||
|   const cmdOrCtrl = process.platform === "darwin" ? "⌘" : "Ctrl"; | ||||
|  | ||||
|   props.$root.innerHTML = html` | ||||
|   $root.innerHTML = html` | ||||
|     <div class="settings-pane"> | ||||
|       <div class="settings-card tip"> | ||||
|         <p> | ||||
| @@ -223,9 +225,8 @@ export function initShortcutsSection(props: ShortcutsSectionProps): void { | ||||
|   `.html; | ||||
|  | ||||
|   const link = "https://zulip.com/help/keyboard-shortcuts"; | ||||
|   const externalCreateNewOrgElement = document.querySelector( | ||||
|     "#open-hotkeys-link", | ||||
|   )!; | ||||
|   const externalCreateNewOrgElement = | ||||
|     $root.querySelector("#open-hotkeys-link")!; | ||||
|   externalCreateNewOrgElement.addEventListener("click", async () => { | ||||
|     await LinkUtil.openBrowser(new URL(link)); | ||||
|   }); | ||||
|   | ||||
| @@ -1,72 +1,25 @@ | ||||
| import {contextBridge, webFrame} from "electron"; | ||||
| import fs from "fs"; | ||||
| import {contextBridge} from "electron/renderer"; | ||||
|  | ||||
| import electron_bridge, {bridgeEvents} from "./electron-bridge"; | ||||
| import * as NetworkError from "./pages/network"; | ||||
| import {ipcRenderer} from "./typed-ipc-renderer"; | ||||
| import electron_bridge, {bridgeEvents} from "./electron-bridge.js"; | ||||
| import * as NetworkError from "./pages/network.js"; | ||||
| import {ipcRenderer} from "./typed-ipc-renderer.js"; | ||||
|  | ||||
| contextBridge.exposeInMainWorld("raw_electron_bridge", electron_bridge); | ||||
| contextBridge.exposeInMainWorld("electron_bridge", electron_bridge); | ||||
|  | ||||
| ipcRenderer.on("logout", () => { | ||||
|   if (bridgeEvents.emit("logout")) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   // Create the menu for the below | ||||
|   const dropdown: HTMLElement = document.querySelector(".dropdown-toggle")!; | ||||
|   dropdown.click(); | ||||
|  | ||||
|   const nodes: NodeListOf<HTMLElement> = document.querySelectorAll( | ||||
|     ".dropdown-menu li:last-child a", | ||||
|   ); | ||||
|   nodes[nodes.length - 1].click(); | ||||
|   bridgeEvents.emit("logout"); | ||||
| }); | ||||
|  | ||||
| ipcRenderer.on("show-keyboard-shortcuts", () => { | ||||
|   if (bridgeEvents.emit("show-keyboard-shortcuts")) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   // Create the menu for the below | ||||
|   const node: HTMLElement = document.querySelector( | ||||
|     "a[data-overlay-trigger=keyboard-shortcuts]", | ||||
|   )!; | ||||
|   // Additional check | ||||
|   if (node.textContent!.trim().toLowerCase() === "keyboard shortcuts (?)") { | ||||
|     node.click(); | ||||
|   } else { | ||||
|     // Atleast click the dropdown | ||||
|     const dropdown: HTMLElement = document.querySelector(".dropdown-toggle")!; | ||||
|     dropdown.click(); | ||||
|   } | ||||
|   bridgeEvents.emit("show-keyboard-shortcuts"); | ||||
| }); | ||||
|  | ||||
| ipcRenderer.on("show-notification-settings", () => { | ||||
|   if (bridgeEvents.emit("show-notification-settings")) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   // Create the menu for the below | ||||
|   const dropdown: HTMLElement = document.querySelector(".dropdown-toggle")!; | ||||
|   dropdown.click(); | ||||
|  | ||||
|   const nodes: NodeListOf<HTMLElement> = document.querySelectorAll( | ||||
|     ".dropdown-menu li a", | ||||
|   ); | ||||
|   nodes[2].click(); | ||||
|  | ||||
|   const notificationItem: NodeListOf<HTMLElement> = document.querySelectorAll( | ||||
|     ".normal-settings-list li div", | ||||
|   ); | ||||
|  | ||||
|   // Wait until the notification dom element shows up | ||||
|   setTimeout(() => { | ||||
|     notificationItem[2].click(); | ||||
|   }, 100); | ||||
|   bridgeEvents.emit("show-notification-settings"); | ||||
| }); | ||||
|  | ||||
| window.addEventListener("load", (event: any): void => { | ||||
|   if (!event.target.URL.includes("app/renderer/network.html")) { | ||||
| window.addEventListener("load", () => { | ||||
|   if (!location.href.includes("app/renderer/network.html")) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
| @@ -74,8 +27,3 @@ window.addEventListener("load", (event: any): void => { | ||||
|   const $settingsButton = document.querySelector("#settings")!; | ||||
|   NetworkError.init($reconnectButton, $settingsButton); | ||||
| }); | ||||
|  | ||||
| (async () => | ||||
|   webFrame.executeJavaScript( | ||||
|     fs.readFileSync(require.resolve("./injected"), "utf8"), | ||||
|   ))(); | ||||
|   | ||||
| @@ -1,46 +1,53 @@ | ||||
| import type {NativeImage, WebviewTag} from "electron"; | ||||
| import {remote} from "electron"; | ||||
| import path from "path"; | ||||
| import type {NativeImage} from "electron/common"; | ||||
| import {nativeImage} from "electron/common"; | ||||
| import type {Tray as ElectronTray} from "electron/main"; | ||||
| import path from "node:path"; | ||||
| import process from "node:process"; | ||||
|  | ||||
| import * as ConfigUtil from "../../common/config-util"; | ||||
| import type {RendererMessage} from "../../common/typed-ipc"; | ||||
| import {BrowserWindow, Menu, Tray} from "@electron/remote"; | ||||
|  | ||||
| import {ipcRenderer} from "./typed-ipc-renderer"; | ||||
| import * as ConfigUtil from "../../common/config-util.js"; | ||||
| import {publicPath} from "../../common/paths.js"; | ||||
| import type {RendererMessage} from "../../common/typed-ipc.js"; | ||||
|  | ||||
| const {Tray, Menu, nativeImage, BrowserWindow} = remote; | ||||
| import type {ServerManagerView} from "./main.js"; | ||||
| import {ipcRenderer} from "./typed-ipc-renderer.js"; | ||||
|  | ||||
| let tray: Electron.Tray | null = null; | ||||
| let tray: ElectronTray | null = null; | ||||
|  | ||||
| const ICON_DIR = "../../resources/tray"; | ||||
|  | ||||
| const TRAY_SUFFIX = "tray"; | ||||
|  | ||||
| const APP_ICON = path.join(__dirname, ICON_DIR, TRAY_SUFFIX); | ||||
| const appIcon = path.join(publicPath, "resources/tray/tray"); | ||||
|  | ||||
| const iconPath = (): string => { | ||||
|   if (process.platform === "linux") { | ||||
|     return APP_ICON + "linux.png"; | ||||
|     return appIcon + "linux.png"; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     APP_ICON + (process.platform === "win32" ? "win.ico" : "macOSTemplate.png") | ||||
|     appIcon + (process.platform === "win32" ? "win.ico" : "macOSTemplate.png") | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const winUnreadTrayIconPath = (): string => APP_ICON + "unread.ico"; | ||||
| const winUnreadTrayIconPath = (): string => appIcon + "unread.ico"; | ||||
|  | ||||
| let unread = 0; | ||||
|  | ||||
| const trayIconSize = (): number => { | ||||
|   switch (process.platform) { | ||||
|     case "darwin": | ||||
|     case "darwin": { | ||||
|       return 20; | ||||
|     case "win32": | ||||
|     } | ||||
|  | ||||
|     case "win32": { | ||||
|       return 100; | ||||
|     case "linux": | ||||
|     } | ||||
|  | ||||
|     case "linux": { | ||||
|       return 100; | ||||
|     default: | ||||
|     } | ||||
|  | ||||
|     default: { | ||||
|       return 80; | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @@ -60,42 +67,42 @@ const config = { | ||||
| const renderCanvas = function (arg: number): HTMLCanvasElement { | ||||
|   config.unreadCount = arg; | ||||
|  | ||||
|   const SIZE = config.size * config.pixelRatio; | ||||
|   const PADDING = SIZE * 0.05; | ||||
|   const CENTER = SIZE / 2; | ||||
|   const HAS_COUNT = config.showUnreadCount && config.unreadCount; | ||||
|   const size = config.size * config.pixelRatio; | ||||
|   const padding = size * 0.05; | ||||
|   const center = size / 2; | ||||
|   const hasCount = config.showUnreadCount && config.unreadCount; | ||||
|   const color = config.unreadCount ? config.unreadColor : config.readColor; | ||||
|   const backgroundColor = config.unreadCount | ||||
|     ? config.unreadBackgroundColor | ||||
|     : config.readBackgroundColor; | ||||
|  | ||||
|   const canvas = document.createElement("canvas"); | ||||
|   canvas.width = SIZE; | ||||
|   canvas.height = SIZE; | ||||
|   canvas.width = size; | ||||
|   canvas.height = size; | ||||
|   const ctx = canvas.getContext("2d")!; | ||||
|  | ||||
|   // Circle | ||||
|   // If (!config.thick || config.thick && HAS_COUNT) { | ||||
|   // If (!config.thick || config.thick && hasCount) { | ||||
|   ctx.beginPath(); | ||||
|   ctx.arc(CENTER, CENTER, SIZE / 2 - PADDING, 0, 2 * Math.PI, false); | ||||
|   ctx.arc(center, center, size / 2 - padding, 0, 2 * Math.PI, false); | ||||
|   ctx.fillStyle = backgroundColor; | ||||
|   ctx.fill(); | ||||
|   ctx.lineWidth = SIZE / (config.thick ? 10 : 20); | ||||
|   ctx.lineWidth = size / (config.thick ? 10 : 20); | ||||
|   ctx.strokeStyle = backgroundColor; | ||||
|   ctx.stroke(); | ||||
|   // Count or Icon | ||||
|   if (HAS_COUNT) { | ||||
|   if (hasCount) { | ||||
|     ctx.fillStyle = color; | ||||
|     ctx.textAlign = "center"; | ||||
|     if (config.unreadCount > 99) { | ||||
|       ctx.font = `${config.thick ? "bold " : ""}${SIZE * 0.4}px Helvetica`; | ||||
|       ctx.fillText("99+", CENTER, CENTER + SIZE * 0.15); | ||||
|       ctx.font = `${config.thick ? "bold " : ""}${size * 0.4}px Helvetica`; | ||||
|       ctx.fillText("99+", center, center + size * 0.15); | ||||
|     } else if (config.unreadCount < 10) { | ||||
|       ctx.font = `${config.thick ? "bold " : ""}${SIZE * 0.5}px Helvetica`; | ||||
|       ctx.fillText(String(config.unreadCount), CENTER, CENTER + SIZE * 0.2); | ||||
|       ctx.font = `${config.thick ? "bold " : ""}${size * 0.5}px Helvetica`; | ||||
|       ctx.fillText(String(config.unreadCount), center, center + size * 0.2); | ||||
|     } else { | ||||
|       ctx.font = `${config.thick ? "bold " : ""}${SIZE * 0.5}px Helvetica`; | ||||
|       ctx.fillText(String(config.unreadCount), CENTER, CENTER + SIZE * 0.15); | ||||
|       ctx.font = `${config.thick ? "bold " : ""}${size * 0.5}px Helvetica`; | ||||
|       ctx.fillText(String(config.unreadCount), center, center + size * 0.15); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -168,70 +175,68 @@ const createTray = function (): void { | ||||
|   } | ||||
| }; | ||||
|  | ||||
| ipcRenderer.on("destroytray", (_event: Event) => { | ||||
|   if (!tray) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   tray.destroy(); | ||||
|   if (tray.isDestroyed()) { | ||||
|     tray = null; | ||||
|   } else { | ||||
|     throw new Error("Tray icon not properly destroyed."); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| ipcRenderer.on("tray", (_event: Event, arg: number): void => { | ||||
|   if (!tray) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   // We don't want to create tray from unread messages on macOS since it already has dock badges. | ||||
|   if (process.platform === "linux" || process.platform === "win32") { | ||||
|     if (arg === 0) { | ||||
|       unread = arg; | ||||
|       tray.setImage(iconPath()); | ||||
|       tray.setToolTip("No unread messages"); | ||||
|     } else { | ||||
|       unread = arg; | ||||
|       const image = renderNativeImage(arg); | ||||
|       tray.setImage(image); | ||||
|       tray.setToolTip(`${arg} unread messages`); | ||||
| export function initializeTray(serverManagerView: ServerManagerView) { | ||||
|   ipcRenderer.on("destroytray", () => { | ||||
|     if (!tray) { | ||||
|       return; | ||||
|     } | ||||
|   } | ||||
| }); | ||||
|  | ||||
| function toggleTray(): void { | ||||
|   let state; | ||||
|   if (tray) { | ||||
|     state = false; | ||||
|     tray.destroy(); | ||||
|     if (tray.isDestroyed()) { | ||||
|       tray = null; | ||||
|     } else { | ||||
|       throw new Error("Tray icon not properly destroyed."); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   ipcRenderer.on("tray", (_event, arg: number): void => { | ||||
|     if (!tray) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     ConfigUtil.setConfigItem("trayIcon", false); | ||||
|   } else { | ||||
|     state = true; | ||||
|     createTray(); | ||||
|     // We don't want to create tray from unread messages on macOS since it already has dock badges. | ||||
|     if (process.platform === "linux" || process.platform === "win32") { | ||||
|       const image = renderNativeImage(unread); | ||||
|       tray!.setImage(image); | ||||
|       tray!.setToolTip(`${unread} unread messages`); | ||||
|       if (arg === 0) { | ||||
|         unread = arg; | ||||
|         tray.setImage(iconPath()); | ||||
|         tray.setToolTip("No unread messages"); | ||||
|       } else { | ||||
|         unread = arg; | ||||
|         const image = renderNativeImage(arg); | ||||
|         tray.setImage(image); | ||||
|         tray.setToolTip(`${arg} unread messages`); | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   function toggleTray(): void { | ||||
|     let state; | ||||
|     if (tray) { | ||||
|       state = false; | ||||
|       tray.destroy(); | ||||
|       if (tray.isDestroyed()) { | ||||
|         tray = null; | ||||
|       } | ||||
|  | ||||
|       ConfigUtil.setConfigItem("trayIcon", false); | ||||
|     } else { | ||||
|       state = true; | ||||
|       createTray(); | ||||
|       if (process.platform === "linux" || process.platform === "win32") { | ||||
|         const image = renderNativeImage(unread); | ||||
|         tray!.setImage(image); | ||||
|         tray!.setToolTip(`${unread} unread messages`); | ||||
|       } | ||||
|  | ||||
|       ConfigUtil.setConfigItem("trayIcon", true); | ||||
|     } | ||||
|  | ||||
|     ConfigUtil.setConfigItem("trayIcon", true); | ||||
|     serverManagerView.preferenceView?.handleToggleTray(state); | ||||
|   } | ||||
|  | ||||
|   const selector = "webview:not([class*=disabled])"; | ||||
|   const webview: WebviewTag = document.querySelector(selector)!; | ||||
|   ipcRenderer.sendTo(webview.getWebContentsId(), "toggle-tray", state); | ||||
|   ipcRenderer.on("toggletray", toggleTray); | ||||
|  | ||||
|   if (ConfigUtil.getConfigItem("trayIcon", true)) { | ||||
|     createTray(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| ipcRenderer.on("toggletray", toggleTray); | ||||
|  | ||||
| if (ConfigUtil.getConfigItem("trayIcon", true)) { | ||||
|   createTray(); | ||||
| } | ||||
|  | ||||
| export {}; | ||||
|   | ||||
| @@ -1,19 +1,18 @@ | ||||
| import type {IpcRendererEvent} from "electron"; | ||||
| import type {IpcRendererEvent} from "electron/renderer"; | ||||
| import { | ||||
|   ipcRenderer as untypedIpcRenderer, // eslint-disable-line no-restricted-imports | ||||
| } from "electron"; | ||||
| } from "electron/renderer"; | ||||
|  | ||||
| import type { | ||||
|   MainCall, | ||||
|   MainMessage, | ||||
|   RendererMessage, | ||||
| } from "../../common/typed-ipc"; | ||||
| } from "../../common/typed-ipc.js"; | ||||
|  | ||||
| type RendererListener< | ||||
|   Channel extends keyof RendererMessage | ||||
| > = RendererMessage[Channel] extends (...args: infer Args) => void | ||||
|   ? (event: IpcRendererEvent, ...args: Args) => void | ||||
|   : never; | ||||
| 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>( | ||||
| @@ -24,6 +23,10 @@ export const ipcRenderer: { | ||||
|     channel: Channel, | ||||
|     listener: RendererListener<Channel>, | ||||
|   ): void; | ||||
|   off<Channel extends keyof RendererMessage>( | ||||
|     channel: Channel, | ||||
|     listener: RendererListener<Channel>, | ||||
|   ): void; | ||||
|   removeListener<Channel extends keyof RendererMessage>( | ||||
|     channel: Channel, | ||||
|     listener: RendererListener<Channel>, | ||||
|   | ||||
| @@ -1,53 +1,74 @@ | ||||
| import {remote} from "electron"; | ||||
| import fs from "fs"; | ||||
| import path from "path"; | ||||
| import fs from "node:fs"; | ||||
| import path from "node:path"; | ||||
|  | ||||
| import {app, dialog} from "@electron/remote"; | ||||
| import * as Sentry from "@sentry/electron"; | ||||
| import {JsonDB} from "node-json-db"; | ||||
| import {DataError} from "node-json-db/dist/lib/Errors"; | ||||
| import {z} from "zod"; | ||||
|  | ||||
| import * as EnterpriseUtil from "../../../common/enterprise-util"; | ||||
| import Logger from "../../../common/logger-util"; | ||||
| import * as Messages from "../../../common/messages"; | ||||
| import type {ServerConf} from "../../../common/types"; | ||||
| import {ipcRenderer} from "../typed-ipc-renderer"; | ||||
|  | ||||
| const {app, dialog} = remote; | ||||
| import * as EnterpriseUtil from "../../../common/enterprise-util.js"; | ||||
| import Logger from "../../../common/logger-util.js"; | ||||
| import * as Messages from "../../../common/messages.js"; | ||||
| import * as t from "../../../common/translation-util.js"; | ||||
| import type {ServerConf} from "../../../common/types.js"; | ||||
| import defaultIcon from "../../img/icon.png"; | ||||
| import {ipcRenderer} from "../typed-ipc-renderer.js"; | ||||
|  | ||||
| const logger = new Logger({ | ||||
|   file: "domain-util.log", | ||||
| }); | ||||
|  | ||||
| const defaultIconUrl = "../renderer/img/icon.png"; | ||||
| // For historical reasons, we store this string in domain.json to denote a | ||||
| // missing icon; it does not change with the actual icon location. | ||||
| export const defaultIconSentinel = "../renderer/img/icon.png"; | ||||
|  | ||||
| const serverConfSchema = z.object({ | ||||
|   url: z.string().url(), | ||||
|   alias: z.string(), | ||||
|   icon: z.string(), | ||||
|   zulipVersion: z.string().default("unknown"), | ||||
|   zulipFeatureLevel: z.number().default(0), | ||||
| }); | ||||
|  | ||||
| let db!: JsonDB; | ||||
|  | ||||
| reloadDB(); | ||||
| reloadDb(); | ||||
|  | ||||
| // Migrate from old schema | ||||
| if (db.getData("/").domain) { | ||||
|   (async () => { | ||||
|     await addDomain({ | ||||
|       alias: "Zulip", | ||||
|       url: db.getData("/domain"), | ||||
|     }); | ||||
|     db.delete("/domain"); | ||||
|   })(); | ||||
| try { | ||||
|   const oldDomain = db.getObject<unknown>("/domain"); | ||||
|   if (typeof oldDomain === "string") { | ||||
|     (async () => { | ||||
|       await addDomain({ | ||||
|         alias: "Zulip", | ||||
|         url: oldDomain, | ||||
|       }); | ||||
|       db.delete("/domain"); | ||||
|     })(); | ||||
|   } | ||||
| } catch (error: unknown) { | ||||
|   if (!(error instanceof DataError)) throw error; | ||||
| } | ||||
|  | ||||
| export function getDomains(): ServerConf[] { | ||||
|   reloadDB(); | ||||
|   if (db.getData("/").domains === undefined) { | ||||
|   reloadDb(); | ||||
|   try { | ||||
|     return serverConfSchema.array().parse(db.getObject<unknown>("/domains")); | ||||
|   } catch (error: unknown) { | ||||
|     if (!(error instanceof DataError)) throw error; | ||||
|     return []; | ||||
|   } | ||||
|  | ||||
|   return db.getData("/domains"); | ||||
| } | ||||
|  | ||||
| export function getDomain(index: number): ServerConf { | ||||
|   reloadDB(); | ||||
|   return db.getData(`/domains[${index}]`); | ||||
|   reloadDb(); | ||||
|   return serverConfSchema.parse(db.getObject<unknown>(`/domains[${index}]`)); | ||||
| } | ||||
|  | ||||
| export function updateDomain(index: number, server: ServerConf): void { | ||||
|   reloadDB(); | ||||
|   reloadDb(); | ||||
|   serverConfSchema.parse(server); | ||||
|   db.push(`/domains[${index}]`, server, true); | ||||
| } | ||||
|  | ||||
| @@ -59,18 +80,20 @@ export async function addDomain(server: { | ||||
|   if (server.icon) { | ||||
|     const localIconUrl = await saveServerIcon(server.icon); | ||||
|     server.icon = localIconUrl; | ||||
|     serverConfSchema.parse(server); | ||||
|     db.push("/domains[]", server, true); | ||||
|     reloadDB(); | ||||
|     reloadDb(); | ||||
|   } else { | ||||
|     server.icon = defaultIconUrl; | ||||
|     server.icon = defaultIconSentinel; | ||||
|     serverConfSchema.parse(server); | ||||
|     db.push("/domains[]", server, true); | ||||
|     reloadDB(); | ||||
|     reloadDb(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function removeDomains(): void { | ||||
|   db.delete("/domains"); | ||||
|   reloadDB(); | ||||
|   reloadDb(); | ||||
| } | ||||
|  | ||||
| export function removeDomain(index: number): boolean { | ||||
| @@ -79,7 +102,7 @@ export function removeDomain(index: number): boolean { | ||||
|   } | ||||
|  | ||||
|   db.delete(`/domains[${index}]`); | ||||
|   reloadDB(); | ||||
|   reloadDb(); | ||||
|   return true; | ||||
| } | ||||
|  | ||||
| @@ -112,31 +135,38 @@ async function getServerSettings(domain: string): Promise<ServerConf> { | ||||
| } | ||||
|  | ||||
| export async function saveServerIcon(iconURL: string): Promise<string> { | ||||
|   return ipcRenderer.invoke("save-server-icon", iconURL); | ||||
|   return ( | ||||
|     (await ipcRenderer.invoke("save-server-icon", iconURL)) ?? | ||||
|     defaultIconSentinel | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export async function updateSavedServer( | ||||
|   url: string, | ||||
|   index: number, | ||||
| ): Promise<void> { | ||||
| ): Promise<ServerConf> { | ||||
|   // Does not promise successful update | ||||
|   const oldIcon = getDomain(index).icon; | ||||
|   const serverConf = getDomain(index); | ||||
|   const oldIcon = serverConf.icon; | ||||
|   try { | ||||
|     const newServerConf = await checkDomain(url, true); | ||||
|     const localIconUrl = await saveServerIcon(newServerConf.icon); | ||||
|     if (!oldIcon || localIconUrl !== "../renderer/img/icon.png") { | ||||
|     if (!oldIcon || localIconUrl !== defaultIconSentinel) { | ||||
|       newServerConf.icon = localIconUrl; | ||||
|       updateDomain(index, newServerConf); | ||||
|       reloadDB(); | ||||
|       reloadDb(); | ||||
|     } | ||||
|  | ||||
|     return newServerConf; | ||||
|   } catch (error: unknown) { | ||||
|     logger.log("Could not update server icon."); | ||||
|     logger.log(error); | ||||
|     logger.reportSentry(error); | ||||
|     Sentry.captureException(error); | ||||
|     return serverConf; | ||||
|   } | ||||
| } | ||||
|  | ||||
| function reloadDB(): void { | ||||
| function reloadDb(): void { | ||||
|   const domainJsonPath = path.join( | ||||
|     app.getPath("userData"), | ||||
|     "config/domain.json", | ||||
| @@ -154,7 +184,7 @@ function reloadDB(): void { | ||||
|       ); | ||||
|       logger.error("Error while JSON parsing domain.json: "); | ||||
|       logger.error(error); | ||||
|       logger.reportSentry(error); | ||||
|       Sentry.captureException(error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -172,3 +202,28 @@ export function formatUrl(domain: string): string { | ||||
|  | ||||
|   return `https://${domain}`; | ||||
| } | ||||
|  | ||||
| export function getUnsupportedMessage(server: ServerConf): string | undefined { | ||||
|   if (server.zulipFeatureLevel < 65 /* Zulip Server 4.0 */) { | ||||
|     const realm = new URL(server.url).hostname; | ||||
|     return t.__( | ||||
|       "{{{server}}} runs an outdated Zulip Server version {{{version}}}. It may not fully work in this app.", | ||||
|       {server: realm, version: server.zulipVersion}, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return undefined; | ||||
| } | ||||
|  | ||||
| export function iconAsUrl(iconPath: string): string { | ||||
|   if (iconPath === defaultIconSentinel) return defaultIcon; | ||||
|  | ||||
|   try { | ||||
|     return `data:application/octet-stream;base64,${fs.readFileSync( | ||||
|       iconPath, | ||||
|       "base64", | ||||
|     )}`; | ||||
|   } catch { | ||||
|     return defaultIcon; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,27 +1,25 @@ | ||||
| import * as backoff from "backoff"; | ||||
|  | ||||
| import {html} from "../../../common/html"; | ||||
| import Logger from "../../../common/logger-util"; | ||||
| import type WebView from "../components/webview"; | ||||
| import {ipcRenderer} from "../typed-ipc-renderer"; | ||||
| import {html} from "../../../common/html.js"; | ||||
| import Logger from "../../../common/logger-util.js"; | ||||
| import type WebView from "../components/webview.js"; | ||||
| import {ipcRenderer} from "../typed-ipc-renderer.js"; | ||||
|  | ||||
| const logger = new Logger({ | ||||
|   file: "domain-util.log", | ||||
| }); | ||||
|  | ||||
| export default class ReconnectUtil { | ||||
|   webview: WebView; | ||||
|   url: string; | ||||
|   alreadyReloaded: boolean; | ||||
|   fibonacciBackoff: backoff.Backoff; | ||||
|  | ||||
|   constructor(webview: WebView) { | ||||
|     this.webview = webview; | ||||
|     this.url = webview.props.url; | ||||
|     this.alreadyReloaded = false; | ||||
|     this.fibonacciBackoff = backoff.fibonacci({ | ||||
|       initialDelay: 5000, | ||||
|       maxDelay: 300000, | ||||
|       maxDelay: 300_000, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,6 @@ | ||||
| import os from "os"; | ||||
| import {ipcRenderer} from "../typed-ipc-renderer.js"; | ||||
|  | ||||
| import {ipcRenderer} from "../typed-ipc-renderer"; | ||||
|  | ||||
| export const connectivityERR: string[] = [ | ||||
| export const connectivityError: string[] = [ | ||||
|   "ERR_INTERNET_DISCONNECTED", | ||||
|   "ERR_PROXY_CONNECTION_FAILED", | ||||
|   "ERR_CONNECTION_RESET", | ||||
| @@ -13,27 +11,6 @@ export const connectivityERR: string[] = [ | ||||
|  | ||||
| const userAgent = ipcRenderer.sendSync("fetch-user-agent"); | ||||
|  | ||||
| export function getOS(): string { | ||||
|   const platform = os.platform(); | ||||
|   if (platform === "darwin") { | ||||
|     return "Mac"; | ||||
|   } | ||||
|  | ||||
|   if (platform === "linux") { | ||||
|     return "Linux"; | ||||
|   } | ||||
|  | ||||
|   if (platform === "win32") { | ||||
|     if (Number.parseFloat(os.release()) < 6.2) { | ||||
|       return "Windows 7"; | ||||
|     } | ||||
|  | ||||
|     return "Windows 10"; | ||||
|   } | ||||
|  | ||||
|   return ""; | ||||
| } | ||||
|  | ||||
| export function getUserAgent(): string { | ||||
|   return userAgent; | ||||
| } | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| <!DOCTYPE html> | ||||
| <!doctype html> | ||||
| <html lang="en" class="responsive desktop"> | ||||
|   <head> | ||||
|     <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> | ||||
|     <meta name="viewport" content="width=device-width" /> | ||||
|     <title>Zulip</title> | ||||
|     <link rel="stylesheet" href="css/main.css" type="text/css" media="screen" /> | ||||
|     <link rel="stylesheet" href="css/fonts.css" /> | ||||
|     <link rel="stylesheet" href="css/main.css" /> | ||||
|   </head> | ||||
|  | ||||
|   <body> | ||||
| @@ -29,7 +30,7 @@ | ||||
|             <i class="material-icons md-48">notifications</i> | ||||
|             <span id="dnd-tooltip" style="display: none">Do Not Disturb</span> | ||||
|           </div> | ||||
|           <div class="action-button" id="reload-action"> | ||||
|           <div class="action-button hidden" id="reload-action"> | ||||
|             <i class="material-icons md-48">refresh</i> | ||||
|             <span id="reload-tooltip" style="display: none">Reload</span> | ||||
|           </div> | ||||
| @@ -51,15 +52,5 @@ | ||||
|         <div id="webviews-container"></div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div id="feedback-modal"> | ||||
|       <send-feedback show-cancel-button="show"></send-feedback> | ||||
|     </div> | ||||
|   </body> | ||||
|  | ||||
|   <script> | ||||
|     // we don't use src='./js/main' in the script tag because | ||||
|     // it messes up require module path resolution | ||||
|     require("./js/main"); | ||||
|   </script> | ||||
| </html> | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| <!DOCTYPE html> | ||||
| <!doctype html> | ||||
| <html lang="en" class="responsive desktop"> | ||||
|   <head> | ||||
|     <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> | ||||
|   | ||||
| @@ -1,27 +1,10 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en" class="responsive desktop"> | ||||
|   <head> | ||||
|     <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> | ||||
|     <meta name="viewport" content="width=device-width" /> | ||||
|     <title>Zulip - Settings</title> | ||||
|     <link | ||||
|       rel="stylesheet" | ||||
|       href="css/preference.css" | ||||
|       type="text/css" | ||||
|       media="screen" | ||||
|     /> | ||||
|     <link id="tagify-css" rel="stylesheet" href="data:text/css," /> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="content"> | ||||
|       <div id="sidebar"></div> | ||||
|       <div id="settings-container"></div> | ||||
|     </div> | ||||
|   </body> | ||||
|   <script> | ||||
|     document.querySelector("#tagify-css").href = require.resolve( | ||||
|       "@yaireo/tagify/dist/tagify.css", | ||||
|     ); | ||||
|     require("./js/pages/preference/preference.js"); | ||||
|   </script> | ||||
| </html> | ||||
| <!doctype html> | ||||
| <meta charset="UTF-8" /> | ||||
| <link rel="stylesheet" href="css/fonts.css" /> | ||||
| <link rel="stylesheet" href="css/preference.css" /> | ||||
|  | ||||
| <!-- Initially hidden to prevent FOUC --> | ||||
| <div id="content" hidden> | ||||
|   <div id="sidebar"></div> | ||||
|   <div id="settings-container"></div> | ||||
| </div> | ||||
|   | ||||
| @@ -1,134 +0,0 @@ | ||||
| { | ||||
| 	"About Zulip": "О Зулипу", | ||||
| 	"Actual Size": "Стварна величина", | ||||
| 	"Add Custom Certificates": "Додајте прилагођене цертификате", | ||||
| 	"Add Organization": "Додај организацију", | ||||
| 	"Add a Zulip organization": "Додајте Зулип организацију", | ||||
| 	"Add custom CSS": "Додајте прилагођени ЦСС", | ||||
| 	"Advanced": "Напредно", | ||||
| 	"All the connected organizations will appear here": "Овде ће се појавити све повезане организације", | ||||
| 	"Always start minimized": "Увек започните минимизирано", | ||||
| 	"App Updates": "Апп Упдатес", | ||||
| 	"Appearance": "Изглед", | ||||
| 	"Application Shortcuts": "Пречице за апликације", | ||||
| 	"Are you sure you want to disconnect this organization?": "Јесте ли сигурни да желите прекинути везу с овом организацијом?", | ||||
| 	"Auto hide Menu bar": "Ауто хиде Мену бар", | ||||
| 	"Auto hide menu bar (Press Alt key to display)": "Аутоматско скривање траке менија (притисните тастер Алт да бисте приказали)", | ||||
| 	"Back": "Назад", | ||||
| 	"Bounce dock on new private message": "Одскочите у нову приватну поруку", | ||||
| 	"Certificate file": "Датотека сертификата", | ||||
| 	"Change": "Цханге", | ||||
| 	"Check for Updates": "Провери ажурирања", | ||||
| 	"Close": "Близу", | ||||
| 	"Connect": "Повежи", | ||||
| 	"Connect to another organization": "Повежите се са другом организацијом", | ||||
| 	"Connected organizations": "Повезане организације", | ||||
| 	"Copy": "Копирај", | ||||
| 	"Copy Zulip URL": "Цопи Зулип УРЛ", | ||||
| 	"Create a new organization": "Направите нову организацију", | ||||
| 	"Cut": "Цут", | ||||
| 	"Default download location": "Дефаулт довнлоад лоцатион", | ||||
| 	"Delete": "Обриши", | ||||
| 	"Desktop App Settings": "Подешавања апликације за десктоп рачунаре", | ||||
| 	"Desktop Notifications": "Обавештења о радној површини", | ||||
| 	"Desktop Settings": "Десктоп Сеттингс", | ||||
| 	"Disconnect": "Дисцоннецт", | ||||
| 	"Download App Logs": "Довнлоад Апп Логс", | ||||
| 	"Edit": "Уредити", | ||||
| 	"Edit Shortcuts": "Уреди пречице", | ||||
| 	"Enable auto updates": "Омогући аутоматско ажурирање", | ||||
| 	"Enable error reporting (requires restart)": "Омогући извештавање о грешкама (захтева поновно покретање)", | ||||
| 	"Enable spellchecker (requires restart)": "Омогући провјеру правописа (захтијева поновно покретање)", | ||||
| 	"Factory Reset": "Фацтори Ресет", | ||||
| 	"File": "Филе", | ||||
| 	"Find accounts": "Нађи рачуне", | ||||
| 	"Find accounts by email": "Пронађите рачуне путем е-поште", | ||||
| 	"Flash taskbar on new message": "Фласх трака задатака у новој поруци", | ||||
| 	"Forward": "Напријед", | ||||
| 	"Functionality": "Функционалност", | ||||
| 	"General": "Генерал", | ||||
| 	"Get beta updates": "Набавите бета ажурирања", | ||||
| 	"Hard Reload": "Хард Релоад", | ||||
| 	"Help": "Помоћ", | ||||
| 	"Help Center": "Центар за помоћ", | ||||
| 	"History": "Хистори", | ||||
| 	"History Shortcuts": "Историјске пречице", | ||||
| 	"Keyboard Shortcuts": "Пречице на тастатури", | ||||
| 	"Log Out": "Одјавити се", | ||||
| 	"Log Out of Organization": "Одјавите се из организације", | ||||
| 	"Manual proxy configuration": "Мануал проки цонфигуратион", | ||||
| 	"Minimize": "Минимизе", | ||||
| 	"Mute all sounds from Zulip": "Искључите све звукове из Зулипа", | ||||
| 	"NO": "НЕ", | ||||
| 	"Network": "Мрежа", | ||||
| 	"OR": "ОР", | ||||
| 	"Organization URL": "УРЛ организације", | ||||
| 	"Organizations": "Организације", | ||||
| 	"Paste": "Пасте", | ||||
| 	"Paste and Match Style": "Залепите и подесите стил", | ||||
| 	"Proxy": "Заступник", | ||||
| 	"Proxy bypass rules": "Проки бипасс правила", | ||||
| 	"Proxy rules": "Проки рулес", | ||||
| 	"Quit": "Одустати", | ||||
| 	"Quit Zulip": "Куит Зулип", | ||||
| 	"Redo": "Редо", | ||||
| 	"Release Notes": "Релеасе Нотес", | ||||
| 	"Reload": "Освежи", | ||||
| 	"Report an Issue": "Пријавите проблем", | ||||
| 	"Save": "сачувати", | ||||
| 	"Select All": "Изабери све", | ||||
| 	"Settings": "Подешавања", | ||||
| 	"Shortcuts": "Пречице", | ||||
| 	"Show App Logs": "Прикажи дневнике апликација", | ||||
| 	"Show app icon in system tray": "Покажи икону апликације у системској палети", | ||||
| 	"Show app unread badge": "Покажи непрочитану значку апликације", | ||||
| 	"Show desktop notifications": "Прикажи обавештења радне површине", | ||||
| 	"Show downloaded files in file manager": "Прикажи преузете датотеке у управитељу датотека", | ||||
| 	"Show sidebar": "Схов сидебар", | ||||
| 	"Start app at login": "Покрените апликацију приликом пријављивања", | ||||
| 	"Switch to Next Organization": "Пребаци се на следећу организацију", | ||||
| 	"Switch to Previous Organization": "Пребаци се на претходну организацију", | ||||
| 	"These desktop app shortcuts extend the Zulip webapp's": "Пречице за десктоп апликације проширују Зулип вебаппове", | ||||
| 	"This will delete all application data including all added accounts and preferences": "Ово ће избрисати све податке о апликацији, укључујући све додатне налоге и поставке", | ||||
| 	"Tip": "Савет", | ||||
| 	"Toggle DevTools for Active Tab": "Пребаци ДевТоолс за Ацтиве Таб", | ||||
| 	"Toggle DevTools for Zulip App": "Пребаци ДевТоолс за Зулип Апп", | ||||
| 	"Toggle Do Not Disturb": "Тоггле До Нот Дистурб", | ||||
| 	"Toggle Full Screen": "Тоггле Фулл Сцреен", | ||||
| 	"Toggle Sidebar": "Тоггле Сидебар", | ||||
| 	"Toggle Tray Icon": "Тоггле Траи Ицон", | ||||
| 	"Tools": "Алати", | ||||
| 	"Undo": "Ундо", | ||||
| 	"Upload": "Отпремити", | ||||
| 	"Use system proxy settings (requires restart)": "Користи поставке системског прокија (потребно је поново покренути)", | ||||
| 	"View": "Поглед", | ||||
| 	"View Shortcuts": "Прикажи пречице", | ||||
| 	"Window": "Прозор", | ||||
| 	"Window Shortcuts": "Пречице за прозор", | ||||
| 	"YES": "ДА", | ||||
| 	"Zoom In": "Увеличати", | ||||
| 	"Zoom Out": "Зоом Оут", | ||||
| 	"Zulip Help": "Зулип Хелп", | ||||
| 	"keyboard shortcuts": "пречице на тастатури", | ||||
| 	"script": "скрипта", | ||||
| 	"Quit when the window is closed": "Quit when the window is closed", | ||||
| 	"Ask where to save files before downloading": "Ask where to save files before downloading", | ||||
| 	"Services": "Services", | ||||
| 	"Hide": "Hide", | ||||
| 	"Hide Others": "Hide Others", | ||||
| 	"Unhide": "Unhide", | ||||
| 	"AddServer": "AddServer", | ||||
| 	"App language (requires restart)": "App language (requires restart)", | ||||
| 	"Factory Reset Data": "Factory Reset Data", | ||||
| 	"Reset the application, thus deleting all the connected organizations, accounts, and certificates.": "Reset the application, thus deleting all the connected organizations, accounts, and certificates.", | ||||
| 	"On macOS, the OS spellchecker is used.": "On macOS, the OS spellchecker is used.", | ||||
| 	"Change the language from System Preferences → Keyboard → Text → Spelling.": "Change the language from System Preferences → Keyboard → Text → Spelling.", | ||||
| 	"Copy Link": "Copy Link", | ||||
| 	"Copy Image": "Copy Image", | ||||
| 	"Copy Image URL": "Copy Image URL", | ||||
| 	"No Suggestion Found": "No Suggestion Found", | ||||
| 	"You can select a maximum of 3 languages for spellchecking.": "You can select a maximum of 3 languages for spellchecking.", | ||||
| 	"Spellchecker Languages": "Spellchecker Languages", | ||||
| 	"Add to Dictionary": "Add to Dictionary", | ||||
| 	"Look Up": "Look Up" | ||||
| } | ||||
| @@ -1,134 +0,0 @@ | ||||
| { | ||||
| 	"About Zulip": "Giới thiệu", | ||||
| 	"Actual Size": "Kích thước thực", | ||||
| 	"Add Custom Certificates": "Thêm chứng chỉ tự tùy chỉnh", | ||||
| 	"Add Organization": "Thêm nhóm", | ||||
| 	"Add a Zulip organization": "Thêm nhóm Zulip", | ||||
| 	"Add custom CSS": "Thêm chỉnh sửa CSS", | ||||
| 	"Advanced": "Nâng cao", | ||||
| 	"All the connected organizations will appear here": "Tất cả các nhóm đã kết nối sẽ hiển thị tại đây", | ||||
| 	"Always start minimized": "Luôn thu nhỏ", | ||||
| 	"App Updates": "Cập nhật", | ||||
| 	"Appearance": "Giao diện", | ||||
| 	"Application Shortcuts": "Phím tắt", | ||||
| 	"Are you sure you want to disconnect this organization?": "Bạn có chắc muốn ngừng kết nối với nhóm này?", | ||||
| 	"Auto hide Menu bar": "Tự động ẩn thanh công cụ", | ||||
| 	"Auto hide menu bar (Press Alt key to display)": "Tự động ẩn thanh công cụ (Ấn phím Alt để hiển thị)", | ||||
| 	"Back": "Quay lại", | ||||
| 	"Bounce dock on new private message": "Bounce dock trên tin nhắn mới", | ||||
| 	"Certificate file": "Giấy chứng nhận", | ||||
| 	"Change": "Thay đổi", | ||||
| 	"Check for Updates": "Kiểm tra cập nhật", | ||||
| 	"Close": "Tắt", | ||||
| 	"Connect": "Kết nối", | ||||
| 	"Connect to another organization": "Kết nối với tổ chức khác", | ||||
| 	"Connected organizations": "Tổ chức kết nối", | ||||
| 	"Copy": "Sao chép", | ||||
| 	"Copy Zulip URL": "Sao chép đường dẫn", | ||||
| 	"Create a new organization": "Tạo nhóm mới", | ||||
| 	"Cut": "Cắt", | ||||
| 	"Default download location": "Nơi lưu tập tin mặc định", | ||||
| 	"Delete": "Xóa", | ||||
| 	"Desktop App Settings": "Desktop App Settings", | ||||
| 	"Desktop Notifications": "Thông báo trên giao diện máy tính", | ||||
| 	"Desktop Settings": "Cài đặt ứng dụng", | ||||
| 	"Disconnect": "Ngắt kết nối", | ||||
| 	"Download App Logs": "Download App Logs", | ||||
| 	"Edit": "Chỉnh sửa", | ||||
| 	"Edit Shortcuts": "Edit Shortcuts", | ||||
| 	"Enable auto updates": "Enable auto updates", | ||||
| 	"Enable error reporting (requires restart)": "Enable error reporting (requires restart)", | ||||
| 	"Enable spellchecker (requires restart)": "Enable spellchecker (requires restart)", | ||||
| 	"Factory Reset": "Factory Reset", | ||||
| 	"File": "File", | ||||
| 	"Find accounts": "Find accounts", | ||||
| 	"Find accounts by email": "Find accounts by email", | ||||
| 	"Flash taskbar on new message": "Flash taskbar on new message", | ||||
| 	"Forward": "Forward", | ||||
| 	"Functionality": "Functionality", | ||||
| 	"General": "General", | ||||
| 	"Get beta updates": "Get beta updates", | ||||
| 	"Hard Reload": "Hard Reload", | ||||
| 	"Help": "Help", | ||||
| 	"Help Center": "Help Center", | ||||
| 	"History": "History", | ||||
| 	"History Shortcuts": "History Shortcuts", | ||||
| 	"Keyboard Shortcuts": "Keyboard Shortcuts", | ||||
| 	"Log Out": "Log Out", | ||||
| 	"Log Out of Organization": "Log Out of Organization", | ||||
| 	"Manual proxy configuration": "Manual proxy configuration", | ||||
| 	"Minimize": "Minimize", | ||||
| 	"Mute all sounds from Zulip": "Mute all sounds from Zulip", | ||||
| 	"NO": "NO", | ||||
| 	"Network": "Network", | ||||
| 	"OR": "OR", | ||||
| 	"Organization URL": "Organization URL", | ||||
| 	"Organizations": "Organizations", | ||||
| 	"Paste": "Paste", | ||||
| 	"Paste and Match Style": "Paste and Match Style", | ||||
| 	"Proxy": "Proxy", | ||||
| 	"Proxy bypass rules": "Proxy bypass rules", | ||||
| 	"Proxy rules": "Proxy rules", | ||||
| 	"Quit": "Thoát", | ||||
| 	"Quit Zulip": "Thoát khỏi Zulip", | ||||
| 	"Redo": "Thực hiện lại", | ||||
| 	"Release Notes": "Release Notes", | ||||
| 	"Reload": "Tải lại", | ||||
| 	"Report an Issue": "Report an Issue", | ||||
| 	"Save": "Lưu", | ||||
| 	"Select All": "Select All", | ||||
| 	"Settings": "Cài đặt", | ||||
| 	"Shortcuts": "Shortcuts", | ||||
| 	"Show App Logs": "Show App Logs", | ||||
| 	"Show app icon in system tray": "Show app icon in system tray", | ||||
| 	"Show app unread badge": "Show app unread badge", | ||||
| 	"Show desktop notifications": "Show desktop notifications", | ||||
| 	"Show downloaded files in file manager": "Show downloaded files in file manager", | ||||
| 	"Show sidebar": "Show sidebar", | ||||
| 	"Start app at login": "Start app at login", | ||||
| 	"Switch to Next Organization": "Switch to Next Organization", | ||||
| 	"Switch to Previous Organization": "Switch to Previous Organization", | ||||
| 	"These desktop app shortcuts extend the Zulip webapp's": "These desktop app shortcuts extend the Zulip webapp's", | ||||
| 	"This will delete all application data including all added accounts and preferences": "Mọi dữ liệu trong ứng dụng, bao gồm tất cả tài khoản và tùy chỉnh được thêm vào, sẽ bị xóa", | ||||
| 	"Tip": "Mẹo", | ||||
| 	"Toggle DevTools for Active Tab": "Toggle DevTools for Active Tab", | ||||
| 	"Toggle DevTools for Zulip App": "Toggle DevTools for Zulip App", | ||||
| 	"Toggle Do Not Disturb": "Toggle Do Not Disturb", | ||||
| 	"Toggle Full Screen": "Toggle Full Screen", | ||||
| 	"Toggle Sidebar": "Toggle Sidebar", | ||||
| 	"Toggle Tray Icon": "Toggle Tray Icon", | ||||
| 	"Tools": "Công cụ", | ||||
| 	"Undo": "Hủy thay đổi", | ||||
| 	"Upload": "Tải lên", | ||||
| 	"Use system proxy settings (requires restart)": "Chọn cài đặt system proxy (Yêu cầu khởi động lại)", | ||||
| 	"View": "Xem", | ||||
| 	"View Shortcuts": "Hiển thị phím tắt", | ||||
| 	"Window": "Ứng dụng Window", | ||||
| 	"Window Shortcuts": "Phím tắt trong Windows", | ||||
| 	"YES": "Có", | ||||
| 	"Zoom In": "Phóng to", | ||||
| 	"Zoom Out": "Thu nhỏ", | ||||
| 	"Zulip Help": "Trợ giúp", | ||||
| 	"keyboard shortcuts": "Phím tắt bàn phím", | ||||
| 	"script": "script", | ||||
| 	"Quit when the window is closed": "Thoát khi cửa sổ tắt", | ||||
| 	"Ask where to save files before downloading": "Ask where to save files before downloading", | ||||
| 	"Services": "Services", | ||||
| 	"Hide": "Hide", | ||||
| 	"Hide Others": "Hide Others", | ||||
| 	"Unhide": "Unhide", | ||||
| 	"AddServer": "AddServer", | ||||
| 	"App language (requires restart)": "App language (requires restart)", | ||||
| 	"Factory Reset Data": "Factory Reset Data", | ||||
| 	"Reset the application, thus deleting all the connected organizations, accounts, and certificates.": "Reset the application, thus deleting all the connected organizations, accounts, and certificates.", | ||||
| 	"On macOS, the OS spellchecker is used.": "On macOS, the OS spellchecker is used.", | ||||
| 	"Change the language from System Preferences → Keyboard → Text → Spelling.": "Change the language from System Preferences → Keyboard → Text → Spelling.", | ||||
| 	"Copy Link": "Copy Link", | ||||
| 	"Copy Image": "Copy Image", | ||||
| 	"Copy Image URL": "Copy Image URL", | ||||
| 	"No Suggestion Found": "No Suggestion Found", | ||||
| 	"You can select a maximum of 3 languages for spellchecking.": "You can select a maximum of 3 languages for spellchecking.", | ||||
| 	"Spellchecker Languages": "Spellchecker Languages", | ||||
| 	"Add to Dictionary": "Add to Dictionary", | ||||
| 	"Look Up": "Look Up" | ||||
| } | ||||
| @@ -1,8 +0,0 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||
| <plist version="1.0"> | ||||
|   <dict> | ||||
|     <key>com.apple.security.cs.allow-unsigned-executable-memory</key> | ||||
|     <true/> | ||||
|   </dict> | ||||
| </plist> | ||||
| @@ -1,16 +0,0 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||
| <plist version="1.0"> | ||||
| <dict> | ||||
| 	<key>com.apple.security.app-sandbox</key> | ||||
| 	<true/> | ||||
| 	<key>com.apple.security.network.client</key> | ||||
| 	<true/> | ||||
| 	<key>com.apple.security.files.user-selected.read-only</key> | ||||
| 	<true/> | ||||
| 	<key>com.apple.security.files.user-selected.read-write</key> | ||||
| 	<true/> | ||||
| 	<key>com.apple.security.files.downloads.read-write</key> | ||||
| 	<true/> | ||||
| </dict> | ||||
| </plist> | ||||
							
								
								
									
										130
									
								
								changelog.md
									
									
									
									
									
								
							
							
						
						| @@ -2,6 +2,114 @@ | ||||
|  | ||||
| All notable changes to the Zulip desktop app are documented in this file. | ||||
|  | ||||
| ### v5.10.1 --2023-09-13 | ||||
|  | ||||
| **Dependencies**: | ||||
|  | ||||
| - Upgraded all dependencies, including Electron 26.2.1. | ||||
|  | ||||
| ### v5.10.0 --2023-05-05 | ||||
|  | ||||
| **Removed features**: | ||||
|  | ||||
| - Removed support for Windows 8.1 and earlier, which reached end-of-life earlier this year and are [no longer supported](https://www.electronjs.org/blog/windows-7-to-8-1-deprecation-notice) by Electron. | ||||
| - Removed support for Zulip Server 3.x and earlier, which have been obsolete for more than 18 months, in accordance with our [release lifecycle](https://zulip.readthedocs.io/en/latest/overview/release-lifecycle.html). A notice will now be displayed when connecting to a server with an unsupported version. | ||||
|  | ||||
| **Fixes**: | ||||
|  | ||||
| - Fixed display of the dropdown for the spellchecker languages setting. | ||||
| - Fixed various bugs related to displaying and updating organization icons. | ||||
| - Fixed settings to disable visual display of notifications. | ||||
|  | ||||
| **Dependencies**: | ||||
|  | ||||
| - Upgraded all dependencies, including Electron 24.2.0. | ||||
|  | ||||
| ### v5.9.5 --2023-02-06 | ||||
|  | ||||
| **Fixes**: | ||||
|  | ||||
| - Fixed a hang on startup when an organization cannot be connected at startup. | ||||
|  | ||||
| **Enhancements**: | ||||
|  | ||||
| - Enabled Chromium sandboxing in remote renderer processes for improved security hardening. | ||||
|  | ||||
| **Dependencies**: | ||||
|  | ||||
| - Upgraded all dependencies, including Electron 22.2.0. | ||||
|  | ||||
| ### v5.9.4 --2023-01-04 | ||||
|  | ||||
| **Fixes**: | ||||
|  | ||||
| - The `com.apple.quarantine` extended attribute is now correctly set for downloaded files on macOS. | ||||
| - The external link handler ignores invalid URLs. | ||||
|  | ||||
| **Dependencies**: | ||||
|  | ||||
| - Upgraded all dependencies, including Electron 22.0.0. | ||||
|  | ||||
| ### v5.9.3 --2022-04-28 | ||||
|  | ||||
| **Fixes**: | ||||
|  | ||||
| - Fixed a bug in the automatic updater that would sometimes close the application instead of updating it. | ||||
|   (As with most updater fixes, this fix will take effect when updating _from_ 5.9.3. If you're having trouble updating _to_ 5.9.3, a workaround is to click **Install Later** rather than **Install and Relaunch**, then **Quit** from the menu bar and re-open the application manually.) | ||||
|  | ||||
| **Dependencies**: | ||||
|  | ||||
| - Upgraded all dependencies, including Electron 18.2.0. | ||||
|  | ||||
| ### v5.9.2 --2022-04-20 | ||||
|  | ||||
| **Dependencies**: | ||||
|  | ||||
| - Upgraded all dependencies, including Electron 18.1.0. This fixes an upstream Electron bug that crashed the application when accessibility tools such as screen readers and grammar assistants are in use. | ||||
|  | ||||
| ### v5.9.1 --2022-04-08 | ||||
|  | ||||
| **Dependencies**: | ||||
|  | ||||
| - Upgraded all dependencies, including Electron 18.0.3. | ||||
|  | ||||
| ### v5.9.0 --2022-04-01 | ||||
|  | ||||
| **Fixes**: | ||||
|  | ||||
| - Fixed unread count display when viewing a topic with a parenthesized number. | ||||
| - Fixed parsing of system proxy settings. | ||||
|  | ||||
| **Enhancements**: | ||||
|  | ||||
| - Removed fade-in animation on page load. | ||||
|  | ||||
| **Dependencies**: | ||||
|  | ||||
| - Upgraded all dependencies, including Electron 18.0.1. | ||||
|  | ||||
| ### v5.8.1 --2021-07-29 | ||||
|  | ||||
| **Fixes**: | ||||
|  | ||||
| - Downgraded electron-updater to fix automatic updates on macOS. | ||||
|   (Note that 5.7.0 and 5.8.0 users may still trigger electron-updater bugs trying to automatically update _to_ 5.8.1; once updated, future updates _from_ 5.8.1 should work correctly.) | ||||
|  | ||||
| ### v5.8.0 --2021-07-21 | ||||
|  | ||||
| **Fixes**: | ||||
|  | ||||
| - Fixed the spell checker on macOS. | ||||
| - Fixed `TypeError` after closing the About page. | ||||
|  | ||||
| **Enhancements**: | ||||
|  | ||||
| - Removed `Ctrl`+`L`/`⌘L` keyboard shortcut to prevent accidental logouts. | ||||
|  | ||||
| **Dependencies**: | ||||
|  | ||||
| - Upgraded all dependencies, including Electron 13.1.7. | ||||
|  | ||||
| ### v5.7.0 --2021-04-30 | ||||
|  | ||||
| **Fixes**: | ||||
| @@ -108,10 +216,10 @@ All notable changes to the Zulip desktop app are documented in this file. | ||||
| **New features**: | ||||
|  | ||||
| - Add a cancel button in the report-issue modal. | ||||
| - macOS: Use electron API to get dark tray icon instead of the green icon for the light theme. | ||||
| - macOS: Use Electron API to get dark tray icon instead of the green icon for the light theme. | ||||
| - Remove 'Reset App Data' option. Factory Reset option has been moved to Settings → General. | ||||
| - Support pkg installer on macOS. | ||||
| - Use electron 8 built-in spellchecker. Linux and Windows users can now choose upto three spellchecker languages from Settings → General. On macOS, default spellchecker is used. | ||||
| - Use Electron 8 built-in spellchecker. Linux and Windows users can now choose up to three spellchecker languages from Settings → General. On macOS, default spellchecker is used. | ||||
| - Setup Transifex for better synchronization of translations. The application now supports 41 languages instead of 21. | ||||
|  | ||||
| **Dependencies**: | ||||
| @@ -219,7 +327,7 @@ All notable changes to the Zulip desktop app are documented in this file. | ||||
| - Document enterprise configuration features. | ||||
| - Update the Electron tutorial guide. | ||||
| - Explicitly address where to report bugs in `README.md`. | ||||
| - Fix typo in the link to server/webapp repository in `README.md`. | ||||
| - Fix typo in the link to server/web app repository in `README.md`. | ||||
| - Add documentation for translation. | ||||
|  | ||||
| ### v4.0.0 --2019-08-08 | ||||
| @@ -270,7 +378,7 @@ All notable changes to the Zulip desktop app are documented in this file. | ||||
| **Development**: | ||||
|  | ||||
| - Migrate codebase to TypeScript. | ||||
| - Set the indent_size in `.editconfig` to 4. | ||||
| - Set the indent_size in `.editorconfig` to 4. | ||||
| - Use `.env` file for reading Sentry DSN. | ||||
|  | ||||
| **Documentation**: | ||||
| @@ -339,7 +447,7 @@ All notable changes to the Zulip desktop app are documented in this file. | ||||
| - Fix typo in network error message. | ||||
| - Fix context menu not working on adding new org. | ||||
| - Fix reply from notification. | ||||
| - Fix shorcut section horizontal alignment. | ||||
| - Fix shortcut section horizontal alignment. | ||||
| - Fix broken link in docs. | ||||
| - Fix grammatical errors. | ||||
| - Fix typo error in issue template. | ||||
| @@ -378,7 +486,7 @@ All notable changes to the Zulip desktop app are documented in this file. | ||||
| - Auto hide menubar on Windows/Linux. Add a setting option for the same. | ||||
| - Improve design of setting page. | ||||
| - Toggle app on clicking the tray icon (Linux). | ||||
| - Update sidebar realm name when it's changed in webapp. | ||||
| - Update sidebar realm name when it's changed in web app. | ||||
| - left-sidebar: Add initial character of realm name instead of default icon. | ||||
|  | ||||
| **Fixes**: | ||||
| @@ -415,7 +523,7 @@ All notable changes to the Zulip desktop app are documented in this file. | ||||
|  | ||||
| **Fixes**: | ||||
|  | ||||
| - Fix youtube video not playing in lightbox. | ||||
| - Fix YouTube video not playing in lightbox. | ||||
| - Fix realm name not escaped properly. | ||||
|  | ||||
| <hr> | ||||
| @@ -425,7 +533,7 @@ All notable changes to the Zulip desktop app are documented in this file. | ||||
| **New features**: | ||||
|  | ||||
| - Add a setting option to show downloaded file in file manager. | ||||
| - Added electron bridge to communicate with webapp in real time. | ||||
| - Added Electron bridge to communicate with web app in real time. | ||||
|  | ||||
| **Fixes**: | ||||
|  | ||||
| @@ -538,7 +646,7 @@ electron-updater - `v2.21.8` | ||||
|  | ||||
| - Add an option to download the file attachments instead of opening it in the browser | ||||
|  | ||||
| - Open image link in webapp lightbox | ||||
| - Open image link in web app lightbox | ||||
|  | ||||
| - Add scrollbar for list of organizations on overflow | ||||
|  | ||||
| @@ -575,7 +683,7 @@ electron-updater - `v2.21.8` | ||||
|  | ||||
| - Some users wanted to change the look of the Zulip. Now you have the power. Feel free to add your own CSS using the all-new setting option **Add Custom CSS** | ||||
|  | ||||
| - Added i18n locale helper script. Internalization is coming in the next release | ||||
| - Added i18n locale helper script. Internationalization is coming in the next release | ||||
|  | ||||
| - Added **What's new** in `help` submenu so that you can see all the latest changes in the app | ||||
|  | ||||
| @@ -1017,7 +1125,7 @@ Minor improvements | ||||
|  | ||||
| - Using two package.json structure | ||||
|  | ||||
| - Node integration disabled in main window due to jquery error | ||||
| - Node integration disabled in main window due to jQuery error | ||||
|  | ||||
| - Now using electron-builder for packaging instead of electron-packager | ||||
|  | ||||
|   | ||||
| @@ -49,7 +49,7 @@ If [NPM](https://www.npmjs.com/get-npm) and [node-gyp](https://github.com/nodejs | ||||
|  | ||||
| [node-windows]: https://nodejs.org/en/download/package-manager/#windows | ||||
|  | ||||
| - Also, install install Windows-Build-Tools to compile native node modules by using | ||||
| - Also, install Windows-Build-Tools to compile native node modules by using | ||||
|   ```sh | ||||
|   $ npm install --global windows-build-tools | ||||
|   ``` | ||||
|   | ||||
| @@ -5,13 +5,13 @@ | ||||
| - [Git](http://git-scm.com/book/en/v2/Getting-Started-Installing-Git) | ||||
| - [Node.js](https://nodejs.org) >= v6.9.0 | ||||
| - [python](https://www.python.org/downloads/release/python-2713/) (v2.7.x recommended) | ||||
| - [node-gyp](https://github.com/nodejs/node-gyp#installation) (installed via powershell) | ||||
| - [node-gyp](https://github.com/nodejs/node-gyp#installation) (installed via PowerShell) | ||||
|  | ||||
| ## System specific dependencies | ||||
|  | ||||
| - use only 32bit or 64bit for all of the installers, do not mix architectures | ||||
| - install using default settings | ||||
| - open Windows Powershell as Admin | ||||
| - open Windows PowerShell as Admin | ||||
|  | ||||
| ```powershell | ||||
| C:\Windows\system32> npm install --global --production windows-build-tools | ||||
|   | ||||
| @@ -38,7 +38,7 @@ You'll want Transifex's CLI client, `tx`. | ||||
|  | ||||
| Run `tx push -s`. | ||||
|  | ||||
| This uploads from `app/translations/en.json` to the | ||||
| This uploads from `public/translations/en.json` to the | ||||
| set of strings Transifex shows for contributors to translate. | ||||
| (See `.tx/config` for how that's configured.) | ||||
|  | ||||
| @@ -46,7 +46,7 @@ set of strings Transifex shows for contributors to translate. | ||||
|  | ||||
| Run `tools/tx-pull`. | ||||
|  | ||||
| This writes to files `app/translations/<lang>.json`. | ||||
| This writes to files `public/translations/<lang>.json`. | ||||
| (See `.tx/config` for how that's configured.) | ||||
|  | ||||
| Then look at the following sections to see if further updates are | ||||
| @@ -59,7 +59,7 @@ language. This happens when we've opened up a new language for people | ||||
| to contribute translations into in the Zulip project on Transifex, | ||||
| which we do when someone expresses interest in contributing them. | ||||
|  | ||||
| The locales for supported languages are stored in `app/translations/supported-locales.json` | ||||
| The locales for supported languages are stored in `public/translations/supported-locales.json` | ||||
|  | ||||
| So, when a new language is added, update the `supported-locales` module. | ||||
|  | ||||
|   | ||||
| @@ -7,7 +7,7 @@ | ||||
|  | ||||
| [lr]: https://github.com/zulip/zulip-desktop/releases | ||||
|  | ||||
| ## OS X | ||||
| ## macOS | ||||
|  | ||||
| **DMG or zip**: | ||||
|  | ||||
| @@ -17,7 +17,7 @@ | ||||
|  | ||||
| **Using brew**: | ||||
|  | ||||
| 1. Run `brew cask install zulip` in your terminal | ||||
| 1. Run `brew install --cask zulip` in your terminal | ||||
| 2. The app will be installed in your `Applications` | ||||
| 3. Done! The app will update automatically (you can also use `brew update && brew upgrade zulip`) | ||||
|  | ||||
| @@ -53,20 +53,20 @@ | ||||
|  | ||||
| - First download our signing key to make sure the deb you download is correct: | ||||
|  | ||||
| ``` | ||||
| sudo apt-key adv --keyserver pool.sks-keyservers.net --recv 69AD12704E71A4803DCA3A682424BE5AE9BD10D9 | ||||
| ```bash | ||||
| sudo apt-key adv --keyserver keyserver.ubuntu.com --recv 69AD12704E71A4803DCA3A682424BE5AE9BD10D9 | ||||
| ``` | ||||
|  | ||||
| - Add the repo to your apt source list : | ||||
|  | ||||
| ``` | ||||
| echo "deb https://dl.bintray.com/zulip/debian/ beta main" | | ||||
| ```bash | ||||
| echo "deb https://download.zulip.com/desktop/apt stable main" | | ||||
|   sudo tee -a /etc/apt/sources.list.d/zulip.list | ||||
| ``` | ||||
|  | ||||
| - Now install the client : | ||||
|  | ||||
| ``` | ||||
| ```bash | ||||
| sudo apt-get update | ||||
| sudo apt-get install zulip | ||||
| ``` | ||||
|   | ||||
							
								
								
									
										17332
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
							
								
								
									
										155
									
								
								package.json
									
									
									
									
									
								
							
							
						
						| @@ -1,8 +1,8 @@ | ||||
| { | ||||
|   "name": "zulip", | ||||
|   "productName": "Zulip", | ||||
|   "version": "5.7.0", | ||||
|   "main": "./app/main", | ||||
|   "version": "5.10.1", | ||||
|   "main": "./dist-electron", | ||||
|   "description": "Zulip Desktop App", | ||||
|   "license": "Apache-2.0", | ||||
|   "copyright": "Kandra Labs, Inc.", | ||||
| @@ -18,36 +18,34 @@ | ||||
|     "url": "https://github.com/zulip/zulip-desktop/issues" | ||||
|   }, | ||||
|   "engines": { | ||||
|     "node": ">=12.10.0" | ||||
|     "node": ">=16.13.2" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "start": "tsc && electron .", | ||||
|     "clean-ts-files": "git clean app/*.js -e node_modules -xf", | ||||
|     "start": "vite", | ||||
|     "watch-ts": "tsc -w", | ||||
|     "reinstall": "rimraf node_modules && npm install", | ||||
|     "postinstall": "electron-builder install-app-deps", | ||||
|     "lint-css": "stylelint app/renderer/css/*.css", | ||||
|     "lint-html": "./node_modules/.bin/htmlhint \"app/renderer/*.html\" ", | ||||
|     "lint-css": "stylelint \"app/**/*.css\"", | ||||
|     "lint-html": "htmlhint \"app/**/*.html\"", | ||||
|     "lint-js": "xo", | ||||
|     "prettier-non-js": "prettier --check --ignore-path=.prettierignore.non-js --loglevel=warn .", | ||||
|     "test": "tsc --noEmit && npm run lint-html && npm run lint-css && npm run lint-js && npm run prettier-non-js", | ||||
|     "test-e2e": "tsc && tape 'tests/*.js'", | ||||
|     "pack": "tsc && electron-builder --dir", | ||||
|     "dist": "tsc && electron-builder", | ||||
|     "mas": "tsc && electron-builder --mac mas" | ||||
|     "prettier-non-js": "prettier --check --log-level=warn . \"!**/*.{js,ts}\"", | ||||
|     "test": "tsc && npm run lint-html && npm run lint-css && npm run lint-js && npm run prettier-non-js", | ||||
|     "test-e2e": "vite build && tape \"tests/**/*.js\"", | ||||
|     "pack": "vite build && electron-builder --dir", | ||||
|     "dist": "vite build && electron-builder", | ||||
|     "mas": "vite build && electron-builder --mac mas" | ||||
|   }, | ||||
|   "pre-commit": [ | ||||
|     "test" | ||||
|   ], | ||||
|   "build": { | ||||
|     "afterSign": "./scripts/notarize.js", | ||||
|     "appId": "org.zulip.zulip-electron", | ||||
|     "asar": true, | ||||
|     "asarUnpack": [ | ||||
|       "**/*.node" | ||||
|     ], | ||||
|     "files": [ | ||||
|       "app/**/*" | ||||
|       "dist-electron/**/*" | ||||
|     ], | ||||
|     "copyright": "©2020 Kandra Labs, Inc.", | ||||
|     "mac": { | ||||
| @@ -60,14 +58,19 @@ | ||||
|             "arm64" | ||||
|           ] | ||||
|         }, | ||||
|         "pkg" | ||||
|         { | ||||
|           "target": "pkg", | ||||
|           "arch": [ | ||||
|             "x64", | ||||
|             "arm64" | ||||
|           ] | ||||
|         } | ||||
|       ], | ||||
|       "darkModeSupport": true, | ||||
|       "artifactName": "${productName}-${version}-${arch}.${ext}", | ||||
|       "hardenedRuntime": true, | ||||
|       "entitlements": "build/entitlements.mac.plist", | ||||
|       "entitlementsInherit": "build/entitlements.mac.plist", | ||||
|       "gatekeeperAssess": false | ||||
|       "notarize": { | ||||
|         "teamId": "66KHCWMEYB" | ||||
|       } | ||||
|     }, | ||||
|     "linux": { | ||||
|       "category": "Chat;GNOME;GTK;Network;InstantMessaging", | ||||
| @@ -75,7 +78,7 @@ | ||||
|       "description": "Zulip Desktop Client for Linux", | ||||
|       "target": [ | ||||
|         "deb", | ||||
|         "zip", | ||||
|         "tar.xz", | ||||
|         "AppImage", | ||||
|         "snap" | ||||
|       ], | ||||
| @@ -117,13 +120,18 @@ | ||||
|         } | ||||
|       ], | ||||
|       "icon": "build/icon.ico", | ||||
|       "artifactName": "${productName}-Web-Setup-${version}.${ext}", | ||||
|       "publisherName": "Kandra Labs, Inc." | ||||
|     }, | ||||
|     "msi": { | ||||
|       "artifactName": "${productName}-${version}-${arch}.${ext}" | ||||
|     }, | ||||
|     "nsis": { | ||||
|       "allowToChangeInstallationDirectory": true, | ||||
|       "oneClick": false, | ||||
|       "perMachine": false | ||||
|     }, | ||||
|     "nsisWeb": { | ||||
|       "artifactName": "${productName}-Web-Setup-${version}.${ext}" | ||||
|     } | ||||
|   }, | ||||
|   "keywords": [ | ||||
| @@ -135,45 +143,46 @@ | ||||
|     "InstantMessaging" | ||||
|   ], | ||||
|   "dependencies": { | ||||
|     "@electron-elements/send-feedback": "^2.0.3", | ||||
|     "@sentry/electron": "^2.4.1", | ||||
|     "@yaireo/tagify": "^4.1.1", | ||||
|     "gatemaker": "^1.0.0" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@electron/remote": "^2.0.8", | ||||
|     "@sentry/electron": "^4.1.2", | ||||
|     "@types/adm-zip": "^0.5.0", | ||||
|     "@types/auto-launch": "^5.0.2", | ||||
|     "@types/backoff": "^2.5.2", | ||||
|     "@types/i18n": "^0.13.1", | ||||
|     "@types/node": "^18.16.5", | ||||
|     "@types/requestidlecallback": "^0.3.4", | ||||
|     "@types/yaireo__tagify": "^4.3.2", | ||||
|     "@yaireo/tagify": "^4.5.0", | ||||
|     "adm-zip": "^0.5.5", | ||||
|     "auto-launch": "^5.0.5", | ||||
|     "backoff": "^2.5.0", | ||||
|     "electron": "^26.2.1", | ||||
|     "electron-builder": "^24.6.4", | ||||
|     "electron-log": "^4.3.5", | ||||
|     "electron-updater": "^4.3.8", | ||||
|     "electron-updater": "^6.1.4", | ||||
|     "electron-window-state": "^5.0.3", | ||||
|     "escape-goat": "^3.0.0", | ||||
|     "get-stream": "^6.0.1", | ||||
|     "i18n": "^0.13.2", | ||||
|     "iso-639-1": "^2.1.9", | ||||
|     "escape-goat": "^4.0.0", | ||||
|     "htmlhint": "^1.1.2", | ||||
|     "i18n": "^0.15.1", | ||||
|     "iso-639-1": "^3.1.0", | ||||
|     "medium": "^1.2.0", | ||||
|     "node-json-db": "^1.3.0", | ||||
|     "semver": "^7.3.5" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/adm-zip": "^0.4.34", | ||||
|     "@types/auto-launch": "^5.0.1", | ||||
|     "@types/backoff": "^2.5.1", | ||||
|     "@types/i18n": "^0.13.0", | ||||
|     "@types/node": "^15.0.1", | ||||
|     "@types/requestidlecallback": "^0.3.1", | ||||
|     "@types/yaireo__tagify": "^4.1.0", | ||||
|     "dotenv": "^8.2.0", | ||||
|     "electron": "^12.0.6", | ||||
|     "electron-builder": "^22.10.5", | ||||
|     "electron-notarize": "^1.0.0", | ||||
|     "eslint-import-resolver-typescript": "^2.4.0", | ||||
|     "htmlhint": "^0.14.2", | ||||
|     "playwright-core": "^1.30.0-alpha-jan-3-2023", | ||||
|     "pre-commit": "^1.2.2", | ||||
|     "rimraf": "^3.0.2", | ||||
|     "spectron": "^14.0.0", | ||||
|     "stylelint": "^13.13.0", | ||||
|     "stylelint-config-prettier": "^8.0.2", | ||||
|     "stylelint-config-standard": "^22.0.0", | ||||
|     "prettier": "^3.0.3", | ||||
|     "rimraf": "^5.0.0", | ||||
|     "semver": "^7.3.5", | ||||
|     "stylelint": "^15.6.1", | ||||
|     "stylelint-config-standard": "^34.0.0", | ||||
|     "tape": "^5.2.2", | ||||
|     "typescript": "^4.2.4", | ||||
|     "xo": "^0.39.1" | ||||
|     "typescript": "^5.0.4", | ||||
|     "vite": "^4.1.1", | ||||
|     "vite-plugin-electron": "^0.14.1", | ||||
|     "xo": "^0.56.0", | ||||
|     "zod": "^3.5.1" | ||||
|   }, | ||||
|   "prettier": { | ||||
|     "bracketSpacing": false, | ||||
| @@ -184,11 +193,7 @@ | ||||
|     "prettier": true, | ||||
|     "rules": { | ||||
|       "@typescript-eslint/no-dynamic-delete": "off", | ||||
|       "@typescript-eslint/no-non-null-assertion": "off", | ||||
|       "arrow-body-style": "error", | ||||
|       "import/first": "error", | ||||
|       "import/newline-after-import": "error", | ||||
|       "import/no-cycle": "error", | ||||
|       "import/no-restricted-paths": [ | ||||
|         "error", | ||||
|         { | ||||
| @@ -197,8 +202,7 @@ | ||||
|               "target": "./app/common", | ||||
|               "from": "./app", | ||||
|               "except": [ | ||||
|                 "./common", | ||||
|                 "./translations" | ||||
|                 "./common" | ||||
|               ] | ||||
|             }, | ||||
|             { | ||||
| @@ -206,8 +210,7 @@ | ||||
|               "from": "./app", | ||||
|               "except": [ | ||||
|                 "./common", | ||||
|                 "./main", | ||||
|                 "./translations" | ||||
|                 "./main" | ||||
|               ] | ||||
|             }, | ||||
|             { | ||||
| @@ -216,7 +219,7 @@ | ||||
|               "except": [ | ||||
|                 "./common", | ||||
|                 "./renderer", | ||||
|                 "./translations" | ||||
|                 "./resources" | ||||
|               ] | ||||
|             } | ||||
|           ] | ||||
| @@ -238,11 +241,21 @@ | ||||
|           "paths": [ | ||||
|             { | ||||
|               "name": "electron", | ||||
|               "message": "Use electron/main, electron/renderer, or electron/common." | ||||
|             }, | ||||
|             { | ||||
|               "name": "electron/main", | ||||
|               "importNames": [ | ||||
|                 "ipcMain" | ||||
|               ], | ||||
|               "message": "Use typed-ipc-main." | ||||
|             }, | ||||
|             { | ||||
|               "name": "electron/renderer", | ||||
|               "importNames": [ | ||||
|                 "ipcMain", | ||||
|                 "ipcRenderer" | ||||
|               ], | ||||
|               "message": "Use typed-ipc-main and typed-ipc-renderer." | ||||
|               "message": "Use typed-ipc-renderer." | ||||
|             } | ||||
|           ] | ||||
|         } | ||||
| @@ -254,7 +267,9 @@ | ||||
|           "ignoreDeclarationSort": true | ||||
|         } | ||||
|       ], | ||||
|       "strict": "error" | ||||
|       "strict": "error", | ||||
|       "unicorn/prefer-module": "off", | ||||
|       "unicorn/prefer-top-level-await": "off" | ||||
|     }, | ||||
|     "envs": [ | ||||
|       "node", | ||||
| @@ -266,31 +281,25 @@ | ||||
|           "**/*.ts" | ||||
|         ], | ||||
|         "rules": { | ||||
|           "@typescript-eslint/ban-types": "off", | ||||
|           "@typescript-eslint/consistent-type-imports": [ | ||||
|             "error", | ||||
|             { | ||||
|               "disallowTypeAnnotations": false | ||||
|             } | ||||
|           ], | ||||
|           "@typescript-eslint/no-redeclare": "error", | ||||
|           "@typescript-eslint/no-unused-vars": [ | ||||
|             "error", | ||||
|             { | ||||
|               "vars": "all", | ||||
|               "args": "after-used", | ||||
|               "argsIgnorePattern": "^_", | ||||
|               "caughtErrors": "all" | ||||
|             } | ||||
|           ], | ||||
|           "no-redeclare": "off" | ||||
|         }, | ||||
|         "settings": { | ||||
|           "import/resolver": "typescript" | ||||
|           "unicorn/no-await-expression-member": "off" | ||||
|         } | ||||
|       }, | ||||
|       { | ||||
|         "files": [ | ||||
|           "app/renderer/js/injected.ts", | ||||
|           "scripts/notarize.js", | ||||
|           "tests/**/*.js" | ||||
|         ], | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| # Link to the binary | ||||
| ln -sf '/opt/${productFilename}/${executable}' '/usr/bin/${executable}' | ||||
| ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' | ||||
|  | ||||
| # SUID chrome-sandbox for Electron 5+ | ||||
| chmod 4755 '/opt/${productFilename}/chrome-sandbox' || true | ||||
| chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox' || true | ||||
|  | ||||
| update-mime-database /usr/share/mime || true | ||||
| update-desktop-database /usr/share/applications || true | ||||
|   | ||||
| Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB | 
| Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB | 
| Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB | 
| Before Width: | Height: | Size: 321 B After Width: | Height: | Size: 321 B | 
| Before Width: | Height: | Size: 631 B After Width: | Height: | Size: 631 B | 
| Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 932 B |