├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── node.js.yml ├── .gitignore ├── .htmlhintrc ├── .mailmap ├── .prettierignore ├── .stylelintrc ├── .tx └── config ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app ├── common │ ├── config-schemata.ts │ ├── config-util.ts │ ├── default-util.ts │ ├── dnd-util.ts │ ├── enterprise-util.ts │ ├── html.ts │ ├── link-util.ts │ ├── logger-util.ts │ ├── messages.ts │ ├── paths.ts │ ├── translation-util.ts │ ├── typed-ipc.ts │ └── types.ts ├── main │ ├── autoupdater.ts │ ├── badge-settings.ts │ ├── handle-external-link.ts │ ├── index.ts │ ├── linux-update-util.ts │ ├── linuxupdater.ts │ ├── menu.ts │ ├── request.ts │ ├── sentry.ts │ ├── startup.ts │ └── typed-ipc-main.ts ├── renderer │ ├── about.html │ ├── css │ │ ├── about.css │ │ ├── feedback.css │ │ ├── fonts.css │ │ ├── main.css │ │ ├── network.css │ │ ├── preference.css │ │ └── preload.css │ ├── fonts │ │ ├── MaterialIcons-Regular.ttf │ │ └── Montserrat-Regular.ttf │ ├── img │ │ ├── ic_loading.svg │ │ ├── ic_server_tab_default.png │ │ ├── icon.png │ │ └── zulip_network.png │ ├── js │ │ ├── clipboard-decrypter.ts │ │ ├── components │ │ │ ├── base.ts │ │ │ ├── context-menu.ts │ │ │ ├── functional-tab.ts │ │ │ ├── server-tab.ts │ │ │ ├── tab.ts │ │ │ └── webview.ts │ │ ├── electron-bridge.ts │ │ ├── main.ts │ │ ├── notification │ │ │ └── index.ts │ │ ├── pages │ │ │ ├── about.ts │ │ │ ├── network.ts │ │ │ └── preference │ │ │ │ ├── base-section.ts │ │ │ │ ├── connected-org-section.ts │ │ │ │ ├── find-accounts.ts │ │ │ │ ├── general-section.ts │ │ │ │ ├── nav.ts │ │ │ │ ├── network-section.ts │ │ │ │ ├── new-server-form.ts │ │ │ │ ├── preference.ts │ │ │ │ ├── server-info-form.ts │ │ │ │ ├── servers-section.ts │ │ │ │ └── shortcuts-section.ts │ │ ├── preload.ts │ │ ├── tray.ts │ │ ├── typed-ipc-renderer.ts │ │ └── utils │ │ │ ├── domain-util.ts │ │ │ ├── reconnect-util.ts │ │ │ └── system-util.ts │ ├── main.html │ ├── network.html │ └── preference.html └── resources │ └── zulip.png ├── appveyor.yml ├── build ├── dmg-background.svg ├── dmg-background.tiff ├── dmg-icon.icns ├── icon-macos.png ├── icon-macos.svg ├── icon.ico └── zulip.png ├── changelog.md ├── development.md ├── docs ├── Enterprise.md ├── Home.md ├── Windows.md ├── _Footer.md ├── desktop-release.md └── howto │ └── translations.md ├── how-to-install.md ├── i18next-scanner.config.js ├── package-lock.json ├── package.json ├── packaging ├── deb-after-install.sh ├── deb-apt.asc ├── deb-apt.list └── deb-release-upgrades.cfg ├── public ├── resources │ ├── Icon.ico │ ├── Icon.png │ ├── sounds │ │ └── ding.ogg │ └── tray │ │ ├── traylinux.png │ │ ├── traymacOSTemplate.png │ │ ├── traymacOSTemplate@2x.png │ │ ├── traymacOSTemplate@3x.png │ │ ├── traymacOSTemplate@4x.png │ │ ├── trayunread.ico │ │ └── traywin.ico └── translations │ ├── README.md │ ├── ar.json │ ├── be.json │ ├── bg.json │ ├── bn.json │ ├── bqi.json │ ├── ca.json │ ├── cs.json │ ├── cy.json │ ├── da.json │ ├── de.json │ ├── el.json │ ├── en.json │ ├── en_GB.json │ ├── es.json │ ├── eu.json │ ├── fa.json │ ├── fi.json │ ├── fr.json │ ├── gl.json │ ├── gu.json │ ├── hi.json │ ├── hr.json │ ├── hu.json │ ├── id.json │ ├── it.json │ ├── ja.json │ ├── ko.json │ ├── lt.json │ ├── lv.json │ ├── ml.json │ ├── mn.json │ ├── nl.json │ ├── no.json │ ├── pl.json │ ├── pt.json │ ├── pt_PT.json │ ├── ro.json │ ├── ru.json │ ├── si.json │ ├── sk.json │ ├── sr.json │ ├── supported-locales.json │ ├── sv.json │ ├── ta.json │ ├── te.json │ ├── tr.json │ ├── uk.json │ ├── uz.json │ ├── vi.json │ ├── zh-Hans.json │ └── zh_TW.json ├── snap ├── gui │ └── icon.png └── snapcraft.yaml ├── tests ├── index.js ├── package.json ├── setup.js ├── test-add-organization.js └── test-new-organization.js ├── tools ├── fetch-pull-request ├── fetch-pull-request.cmd ├── fetch-rebase-pull-request ├── fetch-rebase-pull-request.cmd ├── push-to-pull-request ├── reset-to-pull-request └── tx-pull ├── tsconfig.json ├── typings.d.ts └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [{*.css,*.html,*.js,*.json,*.ts}] 10 | indent_style = space 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | 3 | *.gif binary 4 | *.jpg binary 5 | *.jpeg binary 6 | *.eot binary 7 | *.woff binary 8 | *.woff2 binary 9 | *.svg binary 10 | *.ttf binary 11 | *.png binary 12 | *.otf binary 13 | *.tif binary 14 | *.ogg binary 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: zulip 2 | patreon: zulip 3 | open_collective: zulip 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | **Describe the bug** 7 | 8 | 9 | 10 | **To Reproduce** 11 | 12 | 13 | 14 | **Expected behavior** 15 | 16 | 17 | 18 | **Screenshots** 19 | 20 | 21 | 22 | **Desktop (please complete the following information):** 23 | 24 | - Operating System: 25 | 26 | - Zulip Desktop Version: 27 | 28 | 29 | **Additional context** 30 | 31 | 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | --- 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | **Problem Description** 7 | 8 | 9 | 10 | **Proposed Solution** 11 | 12 | 13 | 14 | **Describe alternatives you've considered** 15 | 16 | 17 | 18 | **Additional context** 19 | 20 | 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Fixes: 4 | 5 | 9 | 10 | **Screenshots and screen captures:** 11 | 12 | **Platforms this PR was tested on:** 13 | 14 | - [ ] Windows 15 | - [ ] macOS 16 | - [ ] Linux (specify distro) 17 | 18 |
19 | Self-review checklist 20 | 21 | 23 | 24 | 26 | 27 | - [ ] [Self-reviewed](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#how-to-review-code) the changes for clarity and maintainability 28 | (variable names, code reuse, readability, etc.). 29 | 30 | Communicate decisions, questions, and potential concerns. 31 | 32 | - [ ] Explains differences from previous plans (e.g., issue description). 33 | - [ ] Highlights technical choices and bugs encountered. 34 | - [ ] Calls out remaining decisions and concerns. 35 | - [ ] Automated tests verify logic where appropriate. 36 | 37 | Individual commits are ready for review (see [commit discipline](https://zulip.readthedocs.io/en/latest/contributing/commit-discipline.html)). 38 | 39 | - [ ] Each commit is a coherent idea. 40 | - [ ] Commit message(s) explain reasoning and motivation for changes. 41 | 42 | Completed manual review and testing of the following: 43 | 44 | - [ ] Visual appearance of the changes. 45 | - [ ] Responsiveness and internationalization. 46 | - [ ] Strings and tooltips. 47 | - [ ] End-to-end functionality of buttons, interactions and flows. 48 | - [ ] Corner cases, error conditions, and easily imagined bugs. 49 |
50 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - run: npm ci 15 | - run: npm test 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | /node_modules/ 3 | 4 | # npm cache directory 5 | .npm 6 | 7 | # transifexrc - if user prefers it to be in working tree 8 | .transifexrc 9 | 10 | # Compiled binary build directory 11 | /dist/ 12 | /dist-electron/ 13 | 14 | #snap generated files 15 | snap/parts 16 | snap/prime 17 | snap/snap 18 | snap/stage 19 | snap/*.snap 20 | 21 | # Logs 22 | logs 23 | *.log 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | // osx garbage 29 | *.DS_Store 30 | .DS_Store 31 | 32 | # dotenv environment variables file 33 | .env 34 | 35 | # miscellaneous 36 | .idea 37 | config.gypi 38 | 39 | # Test generated files 40 | # tests/package.json 41 | 42 | .python-version 43 | -------------------------------------------------------------------------------- /.htmlhintrc: -------------------------------------------------------------------------------- 1 | { 2 | "attr-value-not-empty": false, 3 | "attr-no-duplication": true, 4 | "doctype-first": true, 5 | "spec-char-escape": true, 6 | "id-unique": true, 7 | "src-not-empty": true, 8 | "title-require": true, 9 | "alt-require": false, 10 | "doctype-html5": true, 11 | "id-class-value": "dash", 12 | "style-disabled": false, 13 | "inline-style-disabled": false, 14 | "inline-script-disabled": false, 15 | "id-class-ad-disabled": false, 16 | "href-abs-or-rel": false, 17 | "attr-unsafe-chars": true, 18 | "head-script-disabled": true 19 | } 20 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Anders Kaseorg 2 | Rishi Gupta 3 | Rishi Gupta 4 | Tim Abbott 5 | Tim Abbott 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /dist-electron 3 | /public/translations/*.json 4 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard"], 3 | "rules": { 4 | "color-named": "never", 5 | "color-no-hex": true, 6 | "font-family-no-missing-generic-family-keyword": [ 7 | true, 8 | {"ignoreFontFamilies": ["Material Icons"]} 9 | ], 10 | "selector-type-no-unknown": [true, {"ignoreTypes": ["webview"]}] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | 4 | [o:zulip:p:zulip:r:desktopjson] 5 | file_filter = public/translations/.json 6 | minimum_perc = 0 7 | source_file = public/translations/en.json 8 | source_lang = en 9 | type = KEYVALUEJSON 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thanks for taking the time to contribute! 4 | 5 | The following is a set of guidelines for contributing to Zulip's desktop Client. These are just guidelines, not rules, so use your best judgement and feel free to propose changes to this document in a pull request. 6 | 7 | ## Getting Started 8 | 9 | Zulip-Desktop app is built on top of [Electron](http://electron.atom.io/). If you are new to Electron, please head over to [this](https://jlord.us/essential-electron) great article. 10 | 11 | ## Community 12 | 13 | - 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). 14 | 15 | - 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). 16 | 17 | ## Issue 18 | 19 | Ensure the bug was not already reported by searching on GitHub under [issues](https://github.com/zulip/zulip-desktop/issues). If you're unable to find an open issue addressing the bug, open a [new issue](https://github.com/zulip/zulip-desktop/issues/new). 20 | 21 | The [zulipbot](https://github.com/zulip/zulipbot) helps to claim an issue by commenting the following in the comment section: "**@zulipbot** claim". **@zulipbot** will assign you to the issue and label the issue as **in progress**. For more details, check out [**@zulipbot**](https://github.com/zulip/zulipbot). 22 | 23 | Please pay attention to the following points while opening an issue. 24 | 25 | ### Does it happen on web browsers? (especially Chrome) 26 | 27 | Zulip's desktop client is based on Electron, which integrates the Chrome engine within a standalone application. 28 | If the problem you encounter can be reproduced on web browsers, it may be an issue with [Zulip web app](https://github.com/zulip/zulip). 29 | 30 | ### Write detailed information 31 | 32 | Detailed information is very helpful to understand an issue. 33 | 34 | For example: 35 | 36 | - How to reproduce the issue, step-by-step. 37 | - The expected behavior (or what is wrong). 38 | - Screenshots for GUI issues. 39 | - The application version. 40 | - The operating system. 41 | - The Zulip-Desktop version. 42 | 43 | ## Pull Requests 44 | 45 | Pull Requests are always welcome. 46 | 47 | 1. When you edit the code, please run `npm run test` to check the formatting of your code before you `git commit`. 48 | 2. Ensure the PR description clearly describes the problem and solution. It should include: 49 | - The operating system on which you tested. 50 | - The Zulip-Desktop version on which you tested. 51 | - The relevant issue number, if applicable. 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zulip Desktop Client 2 | 3 | [![Build Status](https://github.com/zulip/zulip-desktop/actions/workflows/node.js.yml/badge.svg)](https://github.com/zulip/zulip-desktop/actions/workflows/node.js.yml?query=branch%3Amain) 4 | [![Windows Build Status](https://ci.appveyor.com/api/projects/status/github/zulip/zulip-desktop?branch=main&svg=true)](https://ci.appveyor.com/project/zulip/zulip-desktop/branch/main) 5 | [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo) 6 | [![project chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://chat.zulip.org) 7 | 8 | Desktop client for Zulip. Available for Mac, Linux, and Windows. 9 | 10 | ![screenshot](https://i.imgur.com/s1o6TRA.png) 11 | ![screenshot](https://i.imgur.com/vekKnW4.png) 12 | 13 | # Download 14 | 15 | Please see the [installation guide](https://zulip.com/help/desktop-app-install-guide). 16 | 17 | # Features 18 | 19 | - Sign in to multiple organizations 20 | - Desktop notifications with inline reply 21 | - Tray/dock integration 22 | - Multi-language spell checker 23 | - Automatic updates 24 | 25 | # Reporting issues 26 | 27 | This desktop client shares most of its code with the Zulip web app. 28 | Issues in an individual organization's Zulip window should be reported 29 | in the [Zulip server and web app 30 | project](https://github.com/zulip/zulip/issues/new). Other 31 | issues in the desktop app and its settings should be reported [in this 32 | project](https://github.com/zulip/zulip-desktop/issues/new). 33 | 34 | # Contribute 35 | 36 | First, join us on the [Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html)! 37 | Also see our [contribution guidelines](./CONTRIBUTING.md) and our [development guide](./development.md). 38 | 39 | # License 40 | 41 | Released under the [Apache-2.0](./LICENSE) license. 42 | -------------------------------------------------------------------------------- /app/common/config-schemata.ts: -------------------------------------------------------------------------------- 1 | import {z} from "zod"; 2 | 3 | export const dndSettingsSchemata = { 4 | showNotification: z.boolean(), 5 | silent: z.boolean(), 6 | flashTaskbarOnMessage: z.boolean(), 7 | }; 8 | 9 | export const configSchemata = { 10 | ...dndSettingsSchemata, 11 | appLanguage: z.string().nullable(), 12 | autoHideMenubar: z.boolean(), 13 | autoUpdate: z.boolean(), 14 | badgeOption: z.boolean(), 15 | betaUpdate: z.boolean(), 16 | // eslint-disable-next-line @typescript-eslint/naming-convention 17 | customCSS: z.string().or(z.literal(false)).nullable(), 18 | dnd: z.boolean(), 19 | dndPreviousSettings: z.object(dndSettingsSchemata).partial(), 20 | dockBouncing: z.boolean(), 21 | downloadsPath: z.string(), 22 | enableSpellchecker: z.boolean(), 23 | errorReporting: z.boolean(), 24 | lastActiveTab: z.number(), 25 | promptDownload: z.boolean(), 26 | proxyBypass: z.string(), 27 | // eslint-disable-next-line @typescript-eslint/naming-convention 28 | proxyPAC: z.string(), 29 | proxyRules: z.string(), 30 | quitOnClose: z.boolean(), 31 | showSidebar: z.boolean(), 32 | spellcheckerLanguages: z.string().array().nullable(), 33 | startAtLogin: z.boolean(), 34 | startMinimized: z.boolean(), 35 | trayIcon: z.boolean(), 36 | useManualProxy: z.boolean(), 37 | useProxy: z.boolean(), 38 | useSystemProxy: z.boolean(), 39 | }; 40 | 41 | export const enterpriseConfigSchemata = { 42 | ...configSchemata, 43 | presetOrganizations: z.string().array(), 44 | }; 45 | -------------------------------------------------------------------------------- /app/common/config-util.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | 4 | import * as Sentry from "@sentry/core"; 5 | import {JsonDB} from "node-json-db"; 6 | import {DataError} from "node-json-db/dist/lib/Errors"; 7 | import type {z} from "zod"; 8 | import {app, dialog} from "zulip:remote"; 9 | 10 | import {configSchemata} from "./config-schemata.js"; 11 | import * as EnterpriseUtil from "./enterprise-util.js"; 12 | import Logger from "./logger-util.js"; 13 | 14 | export type Config = { 15 | [Key in keyof typeof configSchemata]: z.output<(typeof configSchemata)[Key]>; 16 | }; 17 | 18 | const logger = new Logger({ 19 | file: "config-util.log", 20 | }); 21 | 22 | let database: JsonDB; 23 | 24 | reloadDatabase(); 25 | 26 | export function getConfigItem( 27 | key: Key, 28 | defaultValue: Config[Key], 29 | ): z.output<(typeof configSchemata)[Key]> { 30 | try { 31 | database.reload(); 32 | } catch (error: unknown) { 33 | logger.error("Error while reloading settings.json: "); 34 | logger.error(error); 35 | } 36 | 37 | try { 38 | return configSchemata[key].parse(database.getObject(`/${key}`)); 39 | } catch (error: unknown) { 40 | if (!(error instanceof DataError)) throw error; 41 | setConfigItem(key, defaultValue); 42 | return defaultValue; 43 | } 44 | } 45 | 46 | // This function returns whether a key exists in the configuration file (settings.json) 47 | export function isConfigItemExists(key: string): boolean { 48 | try { 49 | database.reload(); 50 | } catch (error: unknown) { 51 | logger.error("Error while reloading settings.json: "); 52 | logger.error(error); 53 | } 54 | 55 | return database.exists(`/${key}`); 56 | } 57 | 58 | export function setConfigItem( 59 | key: Key, 60 | value: Config[Key], 61 | override?: boolean, 62 | ): void { 63 | if (EnterpriseUtil.configItemExists(key) && !override) { 64 | // If item is in global config and we're not trying to override 65 | return; 66 | } 67 | 68 | configSchemata[key].parse(value); 69 | database.push(`/${key}`, value, true); 70 | database.save(); 71 | } 72 | 73 | export function removeConfigItem(key: string): void { 74 | database.delete(`/${key}`); 75 | database.save(); 76 | } 77 | 78 | function reloadDatabase(): void { 79 | const settingsJsonPath = path.join( 80 | app.getPath("userData"), 81 | "/config/settings.json", 82 | ); 83 | try { 84 | const file = fs.readFileSync(settingsJsonPath, "utf8"); 85 | JSON.parse(file); 86 | } catch (error: unknown) { 87 | if (fs.existsSync(settingsJsonPath)) { 88 | fs.unlinkSync(settingsJsonPath); 89 | dialog.showErrorBox( 90 | "Error saving settings", 91 | "We encountered an error while saving the settings.", 92 | ); 93 | logger.error("Error while JSON parsing settings.json: "); 94 | logger.error(error); 95 | Sentry.captureException(error); 96 | } 97 | } 98 | 99 | database = new JsonDB(settingsJsonPath, true, true); 100 | } 101 | -------------------------------------------------------------------------------- /app/common/default-util.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | 3 | import {app} from "zulip:remote"; 4 | 5 | let setupCompleted = false; 6 | 7 | const zulipDirectory = app.getPath("userData"); 8 | const logDirectory = `${zulipDirectory}/Logs/`; 9 | const configDirectory = `${zulipDirectory}/config/`; 10 | export const initSetUp = (): void => { 11 | // If it is the first time the app is running 12 | // create zulip dir in userData folder to 13 | // avoid errors 14 | if (!setupCompleted) { 15 | if (!fs.existsSync(zulipDirectory)) { 16 | fs.mkdirSync(zulipDirectory); 17 | } 18 | 19 | if (!fs.existsSync(logDirectory)) { 20 | fs.mkdirSync(logDirectory); 21 | } 22 | 23 | // Migrate config files from app data folder to config folder inside app 24 | // data folder. This will be done once when a user updates to the new version. 25 | if (!fs.existsSync(configDirectory)) { 26 | fs.mkdirSync(configDirectory); 27 | const domainJson = `${zulipDirectory}/domain.json`; 28 | const settingsJson = `${zulipDirectory}/settings.json`; 29 | const updatesJson = `${zulipDirectory}/updates.json`; 30 | const windowStateJson = `${zulipDirectory}/window-state.json`; 31 | const configData = [ 32 | { 33 | path: domainJson, 34 | fileName: "domain.json", 35 | }, 36 | { 37 | path: settingsJson, 38 | fileName: "settings.json", 39 | }, 40 | { 41 | path: updatesJson, 42 | fileName: "updates.json", 43 | }, 44 | ]; 45 | for (const data of configData) { 46 | if (fs.existsSync(data.path)) { 47 | fs.copyFileSync(data.path, configDirectory + data.fileName); 48 | fs.unlinkSync(data.path); 49 | } 50 | } 51 | 52 | // `window-state.json` is only deleted not moved, as the electron-window-state 53 | // package will recreate the file in the config folder. 54 | if (fs.existsSync(windowStateJson)) { 55 | fs.unlinkSync(windowStateJson); 56 | } 57 | } 58 | 59 | setupCompleted = true; 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /app/common/dnd-util.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | 3 | import type {z} from "zod"; 4 | 5 | import type {dndSettingsSchemata} from "./config-schemata.js"; 6 | import * as ConfigUtil from "./config-util.js"; 7 | 8 | export type DndSettings = { 9 | [Key in keyof typeof dndSettingsSchemata]: z.output< 10 | (typeof dndSettingsSchemata)[Key] 11 | >; 12 | }; 13 | 14 | type SettingName = keyof DndSettings; 15 | 16 | type Toggle = { 17 | dnd: boolean; 18 | newSettings: Partial; 19 | }; 20 | 21 | export function toggle(): Toggle { 22 | const dnd = !ConfigUtil.getConfigItem("dnd", false); 23 | const dndSettingList: SettingName[] = ["showNotification", "silent"]; 24 | if (process.platform === "win32") { 25 | dndSettingList.push("flashTaskbarOnMessage"); 26 | } 27 | 28 | let newSettings: Partial; 29 | if (dnd) { 30 | const oldSettings: Partial = {}; 31 | newSettings = {}; 32 | 33 | // Iterate through the dndSettingList. 34 | for (const settingName of dndSettingList) { 35 | // Store the current value of setting. 36 | oldSettings[settingName] = ConfigUtil.getConfigItem( 37 | settingName, 38 | settingName !== "silent", 39 | ); 40 | // New value of setting. 41 | newSettings[settingName] = settingName === "silent"; 42 | } 43 | 44 | // Store old value in oldSettings. 45 | ConfigUtil.setConfigItem("dndPreviousSettings", oldSettings); 46 | } else { 47 | newSettings = ConfigUtil.getConfigItem("dndPreviousSettings", { 48 | showNotification: true, 49 | silent: false, 50 | ...(process.platform === "win32" && {flashTaskbarOnMessage: true}), 51 | }); 52 | } 53 | 54 | for (const settingName of dndSettingList) { 55 | ConfigUtil.setConfigItem(settingName, newSettings[settingName]!); 56 | } 57 | 58 | ConfigUtil.setConfigItem("dnd", dnd); 59 | return {dnd, newSettings}; 60 | } 61 | -------------------------------------------------------------------------------- /app/common/enterprise-util.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import process from "node:process"; 4 | 5 | import {z} from "zod"; 6 | 7 | import {enterpriseConfigSchemata} from "./config-schemata.js"; 8 | import Logger from "./logger-util.js"; 9 | 10 | type EnterpriseConfig = { 11 | [Key in keyof typeof enterpriseConfigSchemata]: z.output< 12 | (typeof enterpriseConfigSchemata)[Key] 13 | >; 14 | }; 15 | 16 | const logger = new Logger({ 17 | file: "enterprise-util.log", 18 | }); 19 | 20 | let enterpriseSettings: Partial; 21 | let configFile: boolean; 22 | 23 | reloadDatabase(); 24 | 25 | function reloadDatabase(): void { 26 | let enterpriseFile = "/etc/zulip-desktop-config/global_config.json"; 27 | if (process.platform === "win32") { 28 | enterpriseFile = 29 | "C:\\Program Files\\Zulip-Desktop-Config\\global_config.json"; 30 | } 31 | 32 | enterpriseFile = path.resolve(enterpriseFile); 33 | if (fs.existsSync(enterpriseFile)) { 34 | configFile = true; 35 | try { 36 | const file = fs.readFileSync(enterpriseFile, "utf8"); 37 | const data: unknown = JSON.parse(file); 38 | enterpriseSettings = z 39 | .object(enterpriseConfigSchemata) 40 | .partial() 41 | .parse(data); 42 | } catch (error: unknown) { 43 | logger.log("Error while JSON parsing global_config.json: "); 44 | logger.log(error); 45 | } 46 | } else { 47 | configFile = false; 48 | } 49 | } 50 | 51 | export function hasConfigFile(): boolean { 52 | return configFile; 53 | } 54 | 55 | export function getConfigItem( 56 | key: Key, 57 | defaultValue: EnterpriseConfig[Key], 58 | ): EnterpriseConfig[Key] { 59 | reloadDatabase(); 60 | if (!configFile) { 61 | return defaultValue; 62 | } 63 | 64 | const value = enterpriseSettings[key]; 65 | return value === undefined ? defaultValue : (value as EnterpriseConfig[Key]); 66 | } 67 | 68 | export function configItemExists(key: keyof EnterpriseConfig): boolean { 69 | reloadDatabase(); 70 | if (!configFile) { 71 | return false; 72 | } 73 | 74 | return enterpriseSettings[key] !== undefined; 75 | } 76 | 77 | export function isPresetOrg(url: string): boolean { 78 | if (!configFile || !configItemExists("presetOrganizations")) { 79 | return false; 80 | } 81 | 82 | const presetOrgs = enterpriseSettings.presetOrganizations; 83 | if (!Array.isArray(presetOrgs)) { 84 | throw new TypeError("Expected array for presetOrgs"); 85 | } 86 | 87 | for (const org of presetOrgs) { 88 | if (url.includes(org)) { 89 | return true; 90 | } 91 | } 92 | 93 | return false; 94 | } 95 | -------------------------------------------------------------------------------- /app/common/html.ts: -------------------------------------------------------------------------------- 1 | import {htmlEscape} from "escape-goat"; 2 | 3 | export class Html { 4 | html: string; 5 | 6 | constructor({html}: {html: string}) { 7 | this.html = html; 8 | } 9 | 10 | join(htmls: readonly Html[]): Html { 11 | return new Html({html: htmls.map((html) => html.html).join(this.html)}); 12 | } 13 | } 14 | 15 | export function html( 16 | template: TemplateStringsArray, 17 | ...values: unknown[] 18 | ): Html { 19 | let html = template[0]; 20 | for (const [index, value] of values.entries()) { 21 | html += value instanceof Html ? value.html : htmlEscape(String(value)); 22 | html += template[index + 1]; 23 | } 24 | 25 | return new Html({html}); 26 | } 27 | -------------------------------------------------------------------------------- /app/common/link-util.ts: -------------------------------------------------------------------------------- 1 | import {shell} from "electron/common"; 2 | import fs from "node:fs"; 3 | import os from "node:os"; 4 | import path from "node:path"; 5 | 6 | import {html} from "./html.js"; 7 | 8 | export async function openBrowser(url: URL): Promise { 9 | if (["http:", "https:", "mailto:"].includes(url.protocol)) { 10 | await shell.openExternal(url.href); 11 | } else { 12 | // For security, indirect links to non-whitelisted protocols 13 | // through a real web browser via a local HTML file. 14 | const directory = fs.mkdtempSync(path.join(os.tmpdir(), "zulip-redirect-")); 15 | const file = path.join(directory, "redirect.html"); 16 | fs.writeFileSync( 17 | file, 18 | html` 19 | 20 | 21 | 22 | 23 | 24 | Redirecting 25 | 30 | 31 | 32 |

Opening ${url.href}

33 | 34 | 35 | `.html, 36 | ); 37 | await shell.openPath(file); 38 | setTimeout(() => { 39 | fs.unlinkSync(file); 40 | fs.rmdirSync(directory); 41 | }, 15_000); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/common/logger-util.ts: -------------------------------------------------------------------------------- 1 | import {Console} from "node:console"; // eslint-disable-line n/prefer-global/console 2 | import fs from "node:fs"; 3 | import os from "node:os"; 4 | import process from "node:process"; 5 | 6 | import {app} from "zulip:remote"; 7 | 8 | import {initSetUp} from "./default-util.js"; 9 | 10 | type LoggerOptions = { 11 | file?: string; 12 | }; 13 | 14 | initSetUp(); 15 | 16 | const logDirectory = `${app.getPath("userData")}/Logs`; 17 | 18 | type Level = "log" | "debug" | "info" | "warn" | "error"; 19 | 20 | export default class Logger { 21 | nodeConsole: Console; 22 | 23 | constructor(options: LoggerOptions = {}) { 24 | let {file = "console.log"} = options; 25 | 26 | file = `${logDirectory}/${file}`; 27 | 28 | // Trim log according to type of process 29 | if (process.type === "renderer") { 30 | requestIdleCallback(async () => this.trimLog(file)); 31 | } else { 32 | process.nextTick(async () => this.trimLog(file)); 33 | } 34 | 35 | const fileStream = fs.createWriteStream(file, {flags: "a"}); 36 | const nodeConsole = new Console(fileStream); 37 | 38 | this.nodeConsole = nodeConsole; 39 | } 40 | 41 | _log(type: Level, ...arguments_: unknown[]): void { 42 | arguments_.unshift(this.getTimestamp() + " |\t"); 43 | arguments_.unshift(type.toUpperCase() + " |"); 44 | this.nodeConsole[type](...arguments_); 45 | console[type](...arguments_); 46 | } 47 | 48 | log(...arguments_: unknown[]): void { 49 | this._log("log", ...arguments_); 50 | } 51 | 52 | debug(...arguments_: unknown[]): void { 53 | this._log("debug", ...arguments_); 54 | } 55 | 56 | info(...arguments_: unknown[]): void { 57 | this._log("info", ...arguments_); 58 | } 59 | 60 | warn(...arguments_: unknown[]): void { 61 | this._log("warn", ...arguments_); 62 | } 63 | 64 | error(...arguments_: unknown[]): void { 65 | this._log("error", ...arguments_); 66 | } 67 | 68 | getTimestamp(): string { 69 | const date = new Date(); 70 | const timestamp = 71 | `${date.getMonth()}/${date.getDate()} ` + 72 | `${date.getMinutes()}:${date.getSeconds()}`; 73 | return timestamp; 74 | } 75 | 76 | async trimLog(file: string): Promise { 77 | const data = await fs.promises.readFile(file, "utf8"); 78 | 79 | const maxLogFileLines = 500; 80 | const logs = data.split(os.EOL); 81 | const logLength = logs.length - 1; 82 | 83 | // Keep bottom maxLogFileLines of each log instance 84 | if (logLength > maxLogFileLines) { 85 | const trimmedLogs = logs.slice(logLength - maxLogFileLines); 86 | const toWrite = trimmedLogs.join(os.EOL); 87 | await fs.promises.writeFile(file, toWrite); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/common/messages.ts: -------------------------------------------------------------------------------- 1 | type DialogBoxError = { 2 | title: string; 3 | content: string; 4 | }; 5 | 6 | export function invalidZulipServerError(domain: string): string { 7 | return `${domain} does not appear to be a valid Zulip server. Make sure that 8 | • You can connect to that URL in a web browser. 9 | • If you need a proxy to connect to the Internet, that you've configured your proxy in the Network settings. 10 | • It's a Zulip server. (The oldest supported version is 1.6). 11 | • The server has a valid certificate. 12 | • The SSL is correctly configured for the certificate. Check out the SSL troubleshooting guide - 13 | https://zulip.readthedocs.io/en/stable/production/ssl-certificates.html`; 14 | } 15 | 16 | export function enterpriseOrgError( 17 | length: number, 18 | domains: string[], 19 | ): DialogBoxError { 20 | let domainList = ""; 21 | for (const domain of domains) { 22 | domainList += `• ${domain}\n`; 23 | } 24 | 25 | return { 26 | title: `Could not add the following ${ 27 | length === 1 ? "organization" : "organizations" 28 | }`, 29 | content: `${domainList}\nPlease contact your system administrator.`, 30 | }; 31 | } 32 | 33 | export function orgRemovalError(url: string): DialogBoxError { 34 | return { 35 | title: `Removing ${url} is a restricted operation.`, 36 | content: "Please contact your system administrator.", 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /app/common/paths.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import process from "node:process"; 3 | import url from "node:url"; 4 | 5 | export const bundlePath = __dirname; 6 | 7 | export const publicPath = import.meta.env.DEV 8 | ? path.join(bundlePath, "../public") 9 | : bundlePath; 10 | 11 | export const bundleUrl = import.meta.env.DEV 12 | ? process.env.VITE_DEV_SERVER_URL 13 | : url.pathToFileURL(__dirname).href + "/"; 14 | 15 | export const publicUrl = bundleUrl; 16 | -------------------------------------------------------------------------------- /app/common/translation-util.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | import i18n from "i18n"; 4 | 5 | import * as ConfigUtil from "./config-util.js"; 6 | import {publicPath} from "./paths.js"; 7 | 8 | i18n.configure({ 9 | directory: path.join(publicPath, "translations/"), 10 | updateFiles: false, 11 | }); 12 | 13 | /* Fetches the current appLocale from settings.json */ 14 | i18n.setLocale(ConfigUtil.getConfigItem("appLanguage", "en") ?? "en"); 15 | 16 | export {__} from "i18n"; 17 | -------------------------------------------------------------------------------- /app/common/typed-ipc.ts: -------------------------------------------------------------------------------- 1 | import type {DndSettings} from "./dnd-util.js"; 2 | import type {MenuProperties, ServerConfig} from "./types.js"; 3 | 4 | export type MainMessage = { 5 | "clear-app-settings": () => void; 6 | "configure-spell-checker": () => void; 7 | "fetch-user-agent": () => string; 8 | "focus-app": () => void; 9 | "focus-this-webview": () => void; 10 | "new-clipboard-key": () => {key: Uint8Array; sig: Uint8Array}; 11 | "permission-callback": (permissionCallbackId: number, grant: boolean) => void; 12 | "quit-app": () => void; 13 | "realm-icon-changed": (serverURL: string, iconURL: string) => void; 14 | "realm-name-changed": (serverURL: string, realmName: string) => void; 15 | "reload-full-app": () => void; 16 | "save-last-tab": (index: number) => void; 17 | "switch-server-tab": (index: number) => void; 18 | "toggle-app": () => void; 19 | "toggle-badge-option": (newValue: boolean) => void; 20 | "toggle-menubar": (showMenubar: boolean) => void; 21 | toggleAutoLauncher: (AutoLaunchValue: boolean) => void; 22 | "unread-count": (unreadCount: number) => void; 23 | "update-badge": (messageCount: number) => void; 24 | "update-menu": (properties: MenuProperties) => void; 25 | "update-taskbar-icon": (data: string, text: string) => void; 26 | }; 27 | 28 | export type MainCall = { 29 | "get-server-settings": (domain: string) => ServerConfig; 30 | "is-online": (url: string) => boolean; 31 | "poll-clipboard": (key: Uint8Array, sig: Uint8Array) => string | undefined; 32 | "save-server-icon": (iconURL: string) => string | null; 33 | }; 34 | 35 | export type RendererMessage = { 36 | back: () => void; 37 | "copy-zulip-url": () => void; 38 | destroytray: () => void; 39 | "enter-fullscreen": () => void; 40 | focus: () => void; 41 | "focus-webview-with-id": (webviewId: number) => void; 42 | forward: () => void; 43 | "hard-reload": () => void; 44 | "leave-fullscreen": () => void; 45 | "log-out": () => void; 46 | logout: () => void; 47 | "new-server": () => void; 48 | "open-about": () => void; 49 | "open-help": () => void; 50 | "open-network-settings": () => void; 51 | "open-org-tab": () => void; 52 | "open-settings": () => void; 53 | "permission-request": ( 54 | options: {webContentsId: number | null; origin: string; permission: string}, 55 | rendererCallbackId: number, 56 | ) => void; 57 | "play-ding-sound": () => void; 58 | "reload-current-viewer": () => void; 59 | "reload-proxy": (showAlert: boolean) => void; 60 | "reload-viewer": () => void; 61 | "render-taskbar-icon": (messageCount: number) => void; 62 | "set-active": () => void; 63 | "set-idle": () => void; 64 | "show-keyboard-shortcuts": () => void; 65 | "show-notification-settings": () => void; 66 | "switch-server-tab": (index: number) => void; 67 | "tab-devtools": () => void; 68 | "toggle-autohide-menubar": ( 69 | autoHideMenubar: boolean, 70 | updateMenu: boolean, 71 | ) => void; 72 | "toggle-dnd": (state: boolean, newSettings: Partial) => void; 73 | "toggle-sidebar": (show: boolean) => void; 74 | "toggle-silent": (state: boolean) => void; 75 | "toggle-tray": (state: boolean) => void; 76 | toggletray: () => void; 77 | tray: (argument: number) => void; 78 | "update-realm-icon": (serverURL: string, iconURL: string) => void; 79 | "update-realm-name": (serverURL: string, realmName: string) => void; 80 | "webview-reload": () => void; 81 | zoomActualSize: () => void; 82 | zoomIn: () => void; 83 | zoomOut: () => void; 84 | }; 85 | -------------------------------------------------------------------------------- /app/common/types.ts: -------------------------------------------------------------------------------- 1 | export type MenuProperties = { 2 | tabs: TabData[]; 3 | activeTabIndex?: number; 4 | enableMenu?: boolean; 5 | }; 6 | 7 | export type NavigationItem = 8 | | "General" 9 | | "Network" 10 | | "AddServer" 11 | | "Organizations" 12 | | "Shortcuts"; 13 | 14 | export type ServerConfig = { 15 | url: string; 16 | alias: string; 17 | icon: string; 18 | zulipVersion: string; 19 | zulipFeatureLevel: number; 20 | }; 21 | 22 | export type TabRole = "server" | "function"; 23 | export type TabPage = "Settings" | "About"; 24 | 25 | export type TabData = { 26 | role: TabRole; 27 | page?: TabPage; 28 | label: string; 29 | index: number; 30 | }; 31 | -------------------------------------------------------------------------------- /app/main/autoupdater.ts: -------------------------------------------------------------------------------- 1 | import {shell} from "electron/common"; 2 | import {app, dialog, session} from "electron/main"; 3 | import process from "node:process"; 4 | 5 | import log from "electron-log/main"; 6 | import { 7 | type UpdateDownloadedEvent, 8 | type UpdateInfo, 9 | autoUpdater, 10 | } from "electron-updater"; 11 | 12 | import * as ConfigUtil from "../common/config-util.js"; 13 | import * as t from "../common/translation-util.js"; 14 | 15 | import {linuxUpdateNotification} from "./linuxupdater.js"; // Required only in case of linux 16 | 17 | let quitting = false; 18 | 19 | export function shouldQuitForUpdate(): boolean { 20 | return quitting; 21 | } 22 | 23 | export async function appUpdater(updateFromMenu = false): Promise { 24 | // Don't initiate auto-updates in development 25 | if (!app.isPackaged) { 26 | return; 27 | } 28 | 29 | if (process.platform === "linux" && !process.env.APPIMAGE) { 30 | const ses = session.fromPartition("persist:webviewsession"); 31 | await linuxUpdateNotification(ses); 32 | return; 33 | } 34 | 35 | let updateAvailable = false; 36 | 37 | // Log what's happening 38 | const updateLogger = log.create({logId: "updates"}); 39 | updateLogger.transports.file.fileName = "updates.log"; 40 | updateLogger.transports.file.level = "info"; 41 | autoUpdater.logger = updateLogger; 42 | 43 | // Handle auto updates for beta/pre releases 44 | const isBetaUpdate = ConfigUtil.getConfigItem("betaUpdate", false); 45 | 46 | autoUpdater.allowPrerelease = isBetaUpdate; 47 | 48 | const eventsListenerRemove = [ 49 | "update-available", 50 | "update-not-available", 51 | ] as const; 52 | autoUpdater.on("update-available", async (info: UpdateInfo) => { 53 | if (updateFromMenu) { 54 | updateAvailable = true; 55 | 56 | // This is to prevent removal of 'update-downloaded' and 'error' event listener. 57 | for (const event of eventsListenerRemove) { 58 | autoUpdater.removeAllListeners(event); 59 | } 60 | 61 | await dialog.showMessageBox({ 62 | message: t.__( 63 | "A new version {{{version}}} of Zulip Desktop is available.", 64 | {version: info.version}, 65 | ), 66 | detail: t.__( 67 | "The update will be downloaded in the background. You will be notified when it is ready to be installed.", 68 | ), 69 | }); 70 | } 71 | }); 72 | 73 | autoUpdater.on("update-not-available", async () => { 74 | if (updateFromMenu) { 75 | // Remove all autoUpdator listeners so that next time autoUpdator is manually called these 76 | // listeners don't trigger multiple times. 77 | autoUpdater.removeAllListeners(); 78 | 79 | await dialog.showMessageBox({ 80 | message: t.__("No updates available."), 81 | detail: t.__( 82 | "You are running the latest version of Zulip Desktop.\nVersion: {{{version}}}", 83 | {version: app.getVersion()}, 84 | ), 85 | }); 86 | } 87 | }); 88 | 89 | autoUpdater.on("error", async (error: Error) => { 90 | if (updateFromMenu) { 91 | // Remove all autoUpdator listeners so that next time autoUpdator is manually called these 92 | // listeners don't trigger multiple times. 93 | autoUpdater.removeAllListeners(); 94 | 95 | const messageText = updateAvailable 96 | ? t.__("Unable to download the update.") 97 | : t.__("Unable to check for updates."); 98 | const link = "https://zulip.com/apps/"; 99 | const {response} = await dialog.showMessageBox({ 100 | type: "error", 101 | buttons: [t.__("Manual Download"), t.__("Cancel")], 102 | message: messageText, 103 | detail: t.__( 104 | "Error: {{{error}}}\n\nThe latest version of Zulip Desktop is available at:\n{{{link}}}\nCurrent version: {{{version}}}", 105 | {error: error.message, link, version: app.getVersion()}, 106 | ), 107 | }); 108 | if (response === 0) { 109 | await shell.openExternal(link); 110 | } 111 | } 112 | }); 113 | 114 | // Ask the user if update is available 115 | autoUpdater.on("update-downloaded", async (event: UpdateDownloadedEvent) => { 116 | // Ask user to update the app 117 | const {response} = await dialog.showMessageBox({ 118 | type: "question", 119 | buttons: [t.__("Install and Relaunch"), t.__("Install Later")], 120 | defaultId: 0, 121 | message: t.__("A new update {{{version}}} has been downloaded.", { 122 | version: event.version, 123 | }), 124 | detail: t.__( 125 | "It will be installed the next time you restart the application.", 126 | ), 127 | }); 128 | if (response === 0) { 129 | quitting = true; 130 | autoUpdater.quitAndInstall(); 131 | } 132 | }); 133 | // Init for updates 134 | await autoUpdater.checkForUpdates(); 135 | } 136 | -------------------------------------------------------------------------------- /app/main/badge-settings.ts: -------------------------------------------------------------------------------- 1 | import {nativeImage} from "electron/common"; 2 | import {type BrowserWindow, app} from "electron/main"; 3 | import process from "node:process"; 4 | 5 | import * as ConfigUtil from "../common/config-util.js"; 6 | 7 | import {send} from "./typed-ipc-main.js"; 8 | 9 | function showBadgeCount(messageCount: number, mainWindow: BrowserWindow): void { 10 | if (process.platform === "win32") { 11 | updateOverlayIcon(messageCount, mainWindow); 12 | } else { 13 | app.badgeCount = messageCount; 14 | } 15 | } 16 | 17 | function hideBadgeCount(mainWindow: BrowserWindow): void { 18 | if (process.platform === "win32") { 19 | mainWindow.setOverlayIcon(null, ""); 20 | } else { 21 | app.badgeCount = 0; 22 | } 23 | } 24 | 25 | export function updateBadge( 26 | badgeCount: number, 27 | mainWindow: BrowserWindow, 28 | ): void { 29 | if (ConfigUtil.getConfigItem("badgeOption", true)) { 30 | showBadgeCount(badgeCount, mainWindow); 31 | } else { 32 | hideBadgeCount(mainWindow); 33 | } 34 | } 35 | 36 | function updateOverlayIcon( 37 | messageCount: number, 38 | mainWindow: BrowserWindow, 39 | ): void { 40 | if (!mainWindow.isFocused()) { 41 | mainWindow.flashFrame( 42 | ConfigUtil.getConfigItem("flashTaskbarOnMessage", true), 43 | ); 44 | } 45 | 46 | if (messageCount === 0) { 47 | mainWindow.setOverlayIcon(null, ""); 48 | } else { 49 | send(mainWindow.webContents, "render-taskbar-icon", messageCount); 50 | } 51 | } 52 | 53 | export function updateTaskbarIcon( 54 | data: string, 55 | text: string, 56 | mainWindow: BrowserWindow, 57 | ): void { 58 | const img = nativeImage.createFromDataURL(data); 59 | mainWindow.setOverlayIcon(img, text); 60 | } 61 | -------------------------------------------------------------------------------- /app/main/handle-external-link.ts: -------------------------------------------------------------------------------- 1 | import {type Event, shell} from "electron/common"; 2 | import { 3 | type HandlerDetails, 4 | Notification, 5 | type SaveDialogOptions, 6 | type WebContents, 7 | app, 8 | } from "electron/main"; 9 | import fs from "node:fs"; 10 | import path from "node:path"; 11 | 12 | import * as ConfigUtil from "../common/config-util.js"; 13 | import * as LinkUtil from "../common/link-util.js"; 14 | 15 | import {send} from "./typed-ipc-main.js"; 16 | 17 | function isUploadsUrl(server: string, url: URL): boolean { 18 | return url.origin === server && url.pathname.startsWith("/user_uploads/"); 19 | } 20 | 21 | function downloadFile({ 22 | contents, 23 | url, 24 | downloadPath, 25 | completed, 26 | failed, 27 | }: { 28 | contents: WebContents; 29 | url: string; 30 | downloadPath: string; 31 | completed(filePath: string, fileName: string): Promise; 32 | failed(state: string): void; 33 | }) { 34 | contents.downloadURL(url); 35 | contents.session.once("will-download", async (_event, item) => { 36 | if (ConfigUtil.getConfigItem("promptDownload", false)) { 37 | const showDialogOptions: SaveDialogOptions = { 38 | defaultPath: path.join(downloadPath, item.getFilename()), 39 | }; 40 | item.setSaveDialogOptions(showDialogOptions); 41 | } else { 42 | const getTimeStamp = (): number => { 43 | const date = new Date(); 44 | return date.getTime(); 45 | }; 46 | 47 | const formatFile = (filePath: string): string => { 48 | const fileExtension = path.extname(filePath); 49 | const baseName = path.basename(filePath, fileExtension); 50 | return `${baseName}-${getTimeStamp()}${fileExtension}`; 51 | }; 52 | 53 | const filePath = path.join(downloadPath, item.getFilename()); 54 | 55 | // Update the name and path of the file if it already exists 56 | const updatedFilePath = path.join(downloadPath, formatFile(filePath)); 57 | const setFilePath: string = fs.existsSync(filePath) 58 | ? updatedFilePath 59 | : filePath; 60 | item.setSavePath(setFilePath); 61 | } 62 | 63 | const updatedListener = (_event: Event, state: string): void => { 64 | switch (state) { 65 | case "interrupted": { 66 | // Can interrupted to due to network error, cancel download then 67 | console.log( 68 | "Download interrupted, cancelling and fallback to dialog download.", 69 | ); 70 | item.cancel(); 71 | break; 72 | } 73 | 74 | case "progressing": { 75 | if (item.isPaused()) { 76 | item.cancel(); 77 | } 78 | 79 | // This event can also be used to show progress in percentage in future. 80 | break; 81 | } 82 | 83 | default: { 84 | console.info("Unknown updated state of download item"); 85 | } 86 | } 87 | }; 88 | 89 | item.on("updated", updatedListener); 90 | item.once("done", async (_event, state) => { 91 | if (state === "completed") { 92 | await completed(item.getSavePath(), path.basename(item.getSavePath())); 93 | } else { 94 | console.log("Download failed state:", state); 95 | failed(state); 96 | } 97 | 98 | // To stop item for listening to updated events of this file 99 | item.removeListener("updated", updatedListener); 100 | }); 101 | }); 102 | } 103 | 104 | export default function handleExternalLink( 105 | contents: WebContents, 106 | details: HandlerDetails, 107 | mainContents: WebContents, 108 | ): void { 109 | let url: URL; 110 | try { 111 | url = new URL(details.url); 112 | } catch { 113 | return; 114 | } 115 | 116 | const downloadPath = ConfigUtil.getConfigItem( 117 | "downloadsPath", 118 | `${app.getPath("downloads")}`, 119 | ); 120 | 121 | if (isUploadsUrl(new URL(contents.getURL()).origin, url)) { 122 | downloadFile({ 123 | contents, 124 | url: url.href, 125 | downloadPath, 126 | async completed(filePath: string, fileName: string) { 127 | const downloadNotification = new Notification({ 128 | title: "Download Complete", 129 | body: `Click to show ${fileName} in folder`, 130 | silent: true, // We'll play our own sound - ding.ogg 131 | }); 132 | downloadNotification.on("click", () => { 133 | // Reveal file in download folder 134 | shell.showItemInFolder(filePath); 135 | }); 136 | downloadNotification.show(); 137 | 138 | // Play sound to indicate download complete 139 | if (!ConfigUtil.getConfigItem("silent", false)) { 140 | send(mainContents, "play-ding-sound"); 141 | } 142 | }, 143 | failed(state: string) { 144 | // Automatic download failed, so show save dialog prompt and download 145 | // through webview 146 | // Only do this if it is the automatic download, otherwise show an error (so we aren't showing two save 147 | // prompts right after each other) 148 | // Check that the download is not cancelled by user 149 | if (state !== "cancelled") { 150 | if (ConfigUtil.getConfigItem("promptDownload", false)) { 151 | new Notification({ 152 | title: "Download Complete", 153 | body: "Download failed", 154 | }).show(); 155 | } else { 156 | contents.downloadURL(url.href); 157 | } 158 | } 159 | }, 160 | }); 161 | } else { 162 | (async () => LinkUtil.openBrowser(url))(); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /app/main/linux-update-util.ts: -------------------------------------------------------------------------------- 1 | import {app, dialog} from "electron/main"; 2 | import fs from "node:fs"; 3 | import path from "node:path"; 4 | 5 | import {JsonDB} from "node-json-db"; 6 | import {DataError} from "node-json-db/dist/lib/Errors"; 7 | 8 | import Logger from "../common/logger-util.js"; 9 | import * as t from "../common/translation-util.js"; 10 | 11 | const logger = new Logger({ 12 | file: "linux-update-util.log", 13 | }); 14 | 15 | let database: JsonDB; 16 | 17 | reloadDatabase(); 18 | 19 | export function getUpdateItem( 20 | key: string, 21 | defaultValue: true | null = null, 22 | ): true | null { 23 | reloadDatabase(); 24 | let value: unknown; 25 | try { 26 | value = database.getObject(`/${key}`); 27 | } catch (error: unknown) { 28 | if (!(error instanceof DataError)) throw error; 29 | } 30 | 31 | if (value !== true && value !== null) { 32 | setUpdateItem(key, defaultValue); 33 | return defaultValue; 34 | } 35 | 36 | return value; 37 | } 38 | 39 | export function setUpdateItem(key: string, value: true | null): void { 40 | database.push(`/${key}`, value, true); 41 | reloadDatabase(); 42 | } 43 | 44 | export function removeUpdateItem(key: string): void { 45 | database.delete(`/${key}`); 46 | reloadDatabase(); 47 | } 48 | 49 | function reloadDatabase(): void { 50 | const linuxUpdateJsonPath = path.join( 51 | app.getPath("userData"), 52 | "/config/updates.json", 53 | ); 54 | try { 55 | const file = fs.readFileSync(linuxUpdateJsonPath, "utf8"); 56 | JSON.parse(file); 57 | } catch (error: unknown) { 58 | if (fs.existsSync(linuxUpdateJsonPath)) { 59 | fs.unlinkSync(linuxUpdateJsonPath); 60 | dialog.showErrorBox( 61 | t.__("Error saving update notifications"), 62 | t.__("We encountered an error while saving the update notifications."), 63 | ); 64 | logger.error("Error while JSON parsing updates.json: "); 65 | logger.error(error); 66 | } 67 | } 68 | 69 | database = new JsonDB(linuxUpdateJsonPath, true, true); 70 | } 71 | -------------------------------------------------------------------------------- /app/main/linuxupdater.ts: -------------------------------------------------------------------------------- 1 | import {Notification, type Session, app} from "electron/main"; 2 | 3 | import * as semver from "semver"; 4 | import {z} from "zod"; 5 | 6 | import * as ConfigUtil from "../common/config-util.js"; 7 | import Logger from "../common/logger-util.js"; 8 | 9 | import * as LinuxUpdateUtil from "./linux-update-util.js"; 10 | 11 | const logger = new Logger({ 12 | file: "linux-update-util.log", 13 | }); 14 | 15 | export async function linuxUpdateNotification(session: Session): Promise { 16 | let url = "https://api.github.com/repos/zulip/zulip-desktop/releases"; 17 | url = ConfigUtil.getConfigItem("betaUpdate", false) ? url : url + "/latest"; 18 | 19 | try { 20 | const response = await session.fetch(url); 21 | if (!response.ok) { 22 | logger.log("Linux update response status: ", response.status); 23 | return; 24 | } 25 | 26 | const data: unknown = await response.json(); 27 | /* eslint-disable @typescript-eslint/naming-convention */ 28 | const latestVersion = ConfigUtil.getConfigItem("betaUpdate", false) 29 | ? z.array(z.object({tag_name: z.string()})).parse(data)[0].tag_name 30 | : z.object({tag_name: z.string()}).parse(data).tag_name; 31 | /* eslint-enable @typescript-eslint/naming-convention */ 32 | 33 | if (semver.gt(latestVersion, app.getVersion())) { 34 | const notified = LinuxUpdateUtil.getUpdateItem(latestVersion); 35 | if (notified === null) { 36 | new Notification({ 37 | title: "Zulip Update", 38 | body: `A new version ${latestVersion} is available. Please update using your package manager.`, 39 | }).show(); 40 | LinuxUpdateUtil.setUpdateItem(latestVersion, true); 41 | } 42 | } 43 | } catch (error: unknown) { 44 | logger.error("Linux update error."); 45 | logger.error(error); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/main/request.ts: -------------------------------------------------------------------------------- 1 | import {type Session, app} from "electron/main"; 2 | import fs from "node:fs"; 3 | import path from "node:path"; 4 | import {Readable} from "node:stream"; 5 | import {pipeline} from "node:stream/promises"; 6 | import type {ReadableStream} from "node:stream/web"; 7 | 8 | import * as Sentry from "@sentry/electron/main"; 9 | import {z} from "zod"; 10 | 11 | import Logger from "../common/logger-util.js"; 12 | import * as Messages from "../common/messages.js"; 13 | import type {ServerConfig} from "../common/types.js"; 14 | 15 | /* Request: domain-util */ 16 | 17 | const logger = new Logger({ 18 | file: "domain-util.log", 19 | }); 20 | 21 | const generateFilePath = (url: string): string => { 22 | const directory = `${app.getPath("userData")}/server-icons`; 23 | const extension = path.extname(url).split("?")[0]; 24 | 25 | let hash = 5381; 26 | let {length} = url; 27 | 28 | while (length) { 29 | // eslint-disable-next-line no-bitwise, unicorn/prefer-code-point 30 | hash = (hash * 33) ^ url.charCodeAt(--length); 31 | } 32 | 33 | // Create 'server-icons' directory if not existed 34 | if (!fs.existsSync(directory)) { 35 | fs.mkdirSync(directory); 36 | } 37 | 38 | // eslint-disable-next-line no-bitwise 39 | return `${directory}/${hash >>> 0}${extension}`; 40 | }; 41 | 42 | export const _getServerSettings = async ( 43 | domain: string, 44 | session: Session, 45 | ): Promise => { 46 | const response = await session.fetch(domain + "/api/v1/server_settings"); 47 | if (!response.ok) { 48 | throw new Error(Messages.invalidZulipServerError(domain)); 49 | } 50 | 51 | const data: unknown = await response.json(); 52 | /* eslint-disable @typescript-eslint/naming-convention */ 53 | const { 54 | realm_name, 55 | realm_uri, 56 | realm_icon, 57 | zulip_version, 58 | zulip_feature_level, 59 | } = z 60 | .object({ 61 | realm_name: z.string(), 62 | realm_uri: z.string().url(), 63 | realm_icon: z.string(), 64 | zulip_version: z.string().default("unknown"), 65 | zulip_feature_level: z.number().default(0), 66 | }) 67 | .parse(data); 68 | /* eslint-enable @typescript-eslint/naming-convention */ 69 | 70 | return { 71 | // Some Zulip Servers use absolute URL for server icon whereas others use relative URL 72 | // Following check handles both the cases 73 | icon: realm_icon.startsWith("/") ? realm_uri + realm_icon : realm_icon, 74 | url: realm_uri, 75 | alias: realm_name, 76 | zulipVersion: zulip_version, 77 | zulipFeatureLevel: zulip_feature_level, 78 | }; 79 | }; 80 | 81 | export const _saveServerIcon = async ( 82 | url: string, 83 | session: Session, 84 | ): Promise => { 85 | try { 86 | const response = await session.fetch(url); 87 | if (!response.ok) { 88 | logger.log("Could not get server icon."); 89 | return null; 90 | } 91 | 92 | const filePath = generateFilePath(url); 93 | await pipeline( 94 | Readable.fromWeb(response.body as ReadableStream), 95 | fs.createWriteStream(filePath), 96 | ); 97 | return filePath; 98 | } catch (error: unknown) { 99 | logger.log("Could not get server icon."); 100 | logger.log(error); 101 | Sentry.captureException(error); 102 | return null; 103 | } 104 | }; 105 | 106 | /* Request: reconnect-util */ 107 | 108 | export const _isOnline = async ( 109 | url: string, 110 | session: Session, 111 | ): Promise => { 112 | try { 113 | const response = await session.fetch(`${url}/api/v1/server_settings`, { 114 | method: "HEAD", 115 | }); 116 | return response.ok; 117 | } catch (error: unknown) { 118 | logger.log(error); 119 | return false; 120 | } 121 | }; 122 | -------------------------------------------------------------------------------- /app/main/sentry.ts: -------------------------------------------------------------------------------- 1 | import {app} from "electron/main"; 2 | 3 | import * as Sentry from "@sentry/electron/main"; 4 | 5 | import {getConfigItem} from "../common/config-util.js"; 6 | 7 | export const sentryInit = (): void => { 8 | Sentry.init({ 9 | dsn: "https://628dc2f2864243a08ead72e63f94c7b1@o48127.ingest.sentry.io/204668", 10 | 11 | // Don't report errors in development or if disabled by the user. 12 | beforeSend: (event) => 13 | app.isPackaged && getConfigItem("errorReporting", true) ? event : null, 14 | 15 | // We should ignore this error since it's harmless and we know the reason behind this 16 | // This error mainly comes from the console logs. 17 | // This is a temp solution until Sentry supports disabling the console logs 18 | ignoreErrors: ["does not appear to be a valid Zulip server"], 19 | 20 | /// sendTimeout: 30 // wait 30 seconds before considering the sending capture to have failed, default is 1 second 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /app/main/startup.ts: -------------------------------------------------------------------------------- 1 | import {app} from "electron/main"; 2 | import process from "node:process"; 3 | 4 | import AutoLaunch from "auto-launch"; 5 | 6 | import * as ConfigUtil from "../common/config-util.js"; 7 | 8 | export const setAutoLaunch = async ( 9 | AutoLaunchValue: boolean, 10 | ): Promise => { 11 | // Don't run this in development 12 | if (!app.isPackaged) { 13 | return; 14 | } 15 | 16 | const autoLaunchOption = ConfigUtil.getConfigItem( 17 | "startAtLogin", 18 | AutoLaunchValue, 19 | ); 20 | 21 | // `setLoginItemSettings` doesn't support linux 22 | if (process.platform === "linux") { 23 | const zulipAutoLauncher = new AutoLaunch({ 24 | name: "Zulip", 25 | isHidden: false, 26 | }); 27 | await (autoLaunchOption 28 | ? zulipAutoLauncher.enable() 29 | : zulipAutoLauncher.disable()); 30 | } else { 31 | app.setLoginItemSettings({ 32 | openAtLogin: autoLaunchOption, 33 | openAsHidden: false, 34 | }); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /app/main/typed-ipc-main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type IpcMainEvent, 3 | type IpcMainInvokeEvent, 4 | type WebContents, 5 | ipcMain as untypedIpcMain, // eslint-disable-line no-restricted-imports 6 | } from "electron/main"; 7 | 8 | import type { 9 | MainCall, 10 | MainMessage, 11 | RendererMessage, 12 | } from "../common/typed-ipc.js"; 13 | 14 | type MainListener = 15 | MainMessage[Channel] extends (...arguments_: infer Arguments) => infer Return 16 | ? ( 17 | event: IpcMainEvent & {returnValue: Return}, 18 | ...arguments_: Arguments 19 | ) => void 20 | : never; 21 | 22 | type MainHandler = MainCall[Channel] extends ( 23 | ...arguments_: infer Arguments 24 | ) => infer Return 25 | ? ( 26 | event: IpcMainInvokeEvent, 27 | ...arguments_: Arguments 28 | ) => Return | Promise 29 | : never; 30 | 31 | export const ipcMain: { 32 | on( 33 | channel: "forward-message", 34 | listener: ( 35 | event: IpcMainEvent, 36 | channel: Channel, 37 | ...arguments_: Parameters 38 | ) => void, 39 | ): void; 40 | on( 41 | channel: "forward-to", 42 | listener: ( 43 | event: IpcMainEvent, 44 | webContentsId: number, 45 | channel: Channel, 46 | ...arguments_: Parameters 47 | ) => void, 48 | ): void; 49 | on( 50 | channel: Channel, 51 | listener: MainListener, 52 | ): void; 53 | once( 54 | channel: Channel, 55 | listener: MainListener, 56 | ): void; 57 | removeListener( 58 | channel: Channel, 59 | listener: MainListener, 60 | ): void; 61 | removeAllListeners(channel?: keyof MainMessage): void; 62 | handle( 63 | channel: Channel, 64 | handler: MainHandler, 65 | ): void; 66 | handleOnce( 67 | channel: Channel, 68 | handler: MainHandler, 69 | ): void; 70 | removeHandler(channel: keyof MainCall): void; 71 | } = untypedIpcMain; 72 | 73 | export function send( 74 | contents: WebContents, 75 | channel: Channel, 76 | ...arguments_: Parameters 77 | ): void { 78 | contents.send(channel, ...arguments_); 79 | } 80 | 81 | export function sendToFrame( 82 | contents: WebContents, 83 | frameId: number | [number, number], 84 | channel: Channel, 85 | ...arguments_: Parameters 86 | ): void { 87 | contents.sendToFrame(frameId, channel, ...arguments_); 88 | } 89 | -------------------------------------------------------------------------------- /app/renderer/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | -------------------------------------------------------------------------------- /app/renderer/css/about.css: -------------------------------------------------------------------------------- 1 | :host { 2 | contain: strict; 3 | display: flow-root; 4 | background: rgb(250 250 250 / 100%); 5 | font-family: menu, "Helvetica Neue", sans-serif; 6 | -webkit-font-smoothing: subpixel-antialiased; 7 | } 8 | 9 | .logo { 10 | display: block; 11 | margin: -40px auto; 12 | } 13 | 14 | #version { 15 | color: rgb(68 67 67 / 100%); 16 | font-size: 1.3em; 17 | padding-top: 40px; 18 | } 19 | 20 | .about { 21 | display: block !important; 22 | margin: 25vh auto; 23 | height: 25vh; 24 | text-align: center; 25 | } 26 | 27 | .about p { 28 | font-size: 20px; 29 | color: rgb(0 0 0 / 62%); 30 | } 31 | 32 | .about img { 33 | width: 150px; 34 | } 35 | 36 | .detail { 37 | text-align: center; 38 | } 39 | 40 | .detail.maintainer { 41 | font-size: 1.2em; 42 | font-weight: 500; 43 | } 44 | 45 | .detail.license { 46 | font-size: 0.8em; 47 | } 48 | 49 | .maintenance-info { 50 | position: absolute; 51 | width: 100%; 52 | left: 0; 53 | color: rgb(68 68 68 / 100%); 54 | } 55 | 56 | .maintenance-info p { 57 | margin: 0; 58 | font-size: 1em; 59 | width: 100%; 60 | } 61 | 62 | p.detail a { 63 | color: rgb(53 95 76 / 100%); 64 | } 65 | 66 | p.detail a:hover { 67 | text-decoration: underline; 68 | } 69 | -------------------------------------------------------------------------------- /app/renderer/css/feedback.css: -------------------------------------------------------------------------------- 1 | :host { 2 | --button-color: rgb(69 166 149); 3 | } 4 | 5 | button { 6 | background-color: var(--button-color); 7 | border-color: var(--button-color); 8 | } 9 | 10 | button:hover, 11 | button:focus { 12 | border-color: var(--button-color); 13 | color: var(--button-color); 14 | } 15 | 16 | button:active { 17 | background-color: rgb(241 241 241); 18 | color: var(--button-color); 19 | } 20 | -------------------------------------------------------------------------------- /app/renderer/css/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Material Icons"; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: 6 | local("Material Icons"), 7 | local("MaterialIcons-Regular"), 8 | url("../fonts/MaterialIcons-Regular.ttf") format("truetype"); 9 | } 10 | 11 | @font-face { 12 | font-family: Montserrat; 13 | src: url("../fonts/Montserrat-Regular.ttf") format("truetype"); 14 | } 15 | -------------------------------------------------------------------------------- /app/renderer/css/network.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | cursor: default; 5 | font-size: 14px; 6 | color: rgb(51 51 51 / 100%); 7 | background: rgb(255 255 255 / 100%); 8 | user-select: none; 9 | } 10 | 11 | #content { 12 | display: flex; 13 | flex-direction: column; 14 | font-family: "Trebuchet MS", Helvetica, sans-serif; 15 | margin: 100px 200px; 16 | text-align: center; 17 | } 18 | 19 | #title { 20 | text-align: left; 21 | font-size: 24px; 22 | font-weight: bold; 23 | margin: 20px 0; 24 | } 25 | 26 | #subtitle { 27 | font-size: 20px; 28 | text-align: left; 29 | margin: 12px 0; 30 | } 31 | 32 | #description { 33 | text-align: left; 34 | font-size: 16px; 35 | list-style-position: inside; 36 | } 37 | 38 | #reconnect { 39 | float: left; 40 | } 41 | 42 | #settings { 43 | margin-left: 116px; 44 | } 45 | 46 | .button { 47 | font-size: 16px; 48 | background: rgb(0 150 136 / 100%); 49 | color: rgb(255 255 255 / 100%); 50 | width: 96px; 51 | height: 32px; 52 | border-radius: 5px; 53 | line-height: 32px; 54 | cursor: pointer; 55 | } 56 | 57 | .button:hover { 58 | opacity: 0.8; 59 | } 60 | -------------------------------------------------------------------------------- /app/renderer/css/preload.css: -------------------------------------------------------------------------------- 1 | /* Override css rules */ 2 | 3 | .portico-wrap > .header { 4 | display: none; 5 | } 6 | 7 | .portico-container > .footer { 8 | display: none; 9 | } 10 | -------------------------------------------------------------------------------- /app/renderer/fonts/MaterialIcons-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/app/renderer/fonts/MaterialIcons-Regular.ttf -------------------------------------------------------------------------------- /app/renderer/fonts/Montserrat-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/app/renderer/fonts/Montserrat-Regular.ttf -------------------------------------------------------------------------------- /app/renderer/img/ic_loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/renderer/img/ic_server_tab_default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/app/renderer/img/ic_server_tab_default.png -------------------------------------------------------------------------------- /app/renderer/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/app/renderer/img/icon.png -------------------------------------------------------------------------------- /app/renderer/img/zulip_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/app/renderer/img/zulip_network.png -------------------------------------------------------------------------------- /app/renderer/js/clipboard-decrypter.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from "./typed-ipc-renderer.js"; 2 | 3 | // This helper is exposed via electron_bridge for use in the social 4 | // login flow. 5 | // 6 | // It consists of a key and a promised token. The in-app page sends 7 | // the key to the server, and opens the user’s browser to a page where 8 | // they can log in and get a token encrypted to that key. When the 9 | // user copies the encrypted token from their browser to the 10 | // clipboard, we decrypt it and resolve the promise. The in-app page 11 | // then uses the decrypted token to log the user in within the app. 12 | // 13 | // The encryption is authenticated (AES-GCM) to guarantee that we 14 | // don’t leak anything from the user’s clipboard other than the token 15 | // intended for us. 16 | 17 | export type ClipboardDecrypter = { 18 | version: number; 19 | key: Uint8Array; 20 | pasted: Promise; 21 | }; 22 | 23 | export class ClipboardDecrypterImplementation implements ClipboardDecrypter { 24 | version: number; 25 | key: Uint8Array; 26 | pasted: Promise; 27 | 28 | constructor(_: number) { 29 | // At this time, the only version is 1. 30 | this.version = 1; 31 | const {key, sig} = ipcRenderer.sendSync("new-clipboard-key"); 32 | this.key = key; 33 | this.pasted = new Promise((resolve) => { 34 | let interval: NodeJS.Timeout | null = null; 35 | const startPolling = () => { 36 | if (interval === null) { 37 | interval = setInterval(poll, 1000); 38 | } 39 | 40 | void poll(); 41 | }; 42 | 43 | const stopPolling = () => { 44 | if (interval !== null) { 45 | clearInterval(interval); 46 | interval = null; 47 | } 48 | }; 49 | 50 | const poll = async () => { 51 | const plaintext = await ipcRenderer.invoke("poll-clipboard", key, sig); 52 | if (plaintext === undefined) return; 53 | 54 | window.removeEventListener("focus", startPolling); 55 | window.removeEventListener("blur", stopPolling); 56 | stopPolling(); 57 | resolve(plaintext); 58 | }; 59 | 60 | window.addEventListener("focus", startPolling); 61 | window.addEventListener("blur", stopPolling); 62 | if (document.hasFocus()) { 63 | startPolling(); 64 | } 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/renderer/js/components/base.ts: -------------------------------------------------------------------------------- 1 | import type {Html} from "../../../common/html.js"; 2 | 3 | export function generateNodeFromHtml(html: Html): Element { 4 | const wrapper = document.createElement("div"); 5 | wrapper.innerHTML = html.html; 6 | 7 | if (wrapper.firstElementChild === null) { 8 | throw new Error("No element found in HTML"); 9 | } 10 | 11 | return wrapper.firstElementChild; 12 | } 13 | -------------------------------------------------------------------------------- /app/renderer/js/components/context-menu.ts: -------------------------------------------------------------------------------- 1 | import {type Event, clipboard} from "electron/common"; 2 | import type {WebContents} from "electron/main"; 3 | import type { 4 | ContextMenuParams, 5 | MenuItemConstructorOptions, 6 | } from "electron/renderer"; 7 | import process from "node:process"; 8 | 9 | import {Menu} from "@electron/remote"; 10 | 11 | import * as t from "../../../common/translation-util.js"; 12 | 13 | export const contextMenu = ( 14 | webContents: WebContents, 15 | event: Event, 16 | properties: ContextMenuParams, 17 | ) => { 18 | const isText = properties.selectionText !== ""; 19 | const isLink = properties.linkURL !== ""; 20 | const linkUrl = isLink ? new URL(properties.linkURL) : undefined; 21 | 22 | const makeSuggestion = (suggestion: string) => ({ 23 | label: suggestion, 24 | visible: true, 25 | async click() { 26 | await webContents.insertText(suggestion); 27 | }, 28 | }); 29 | 30 | let menuTemplate: MenuItemConstructorOptions[] = [ 31 | { 32 | label: t.__("Add to Dictionary"), 33 | visible: 34 | properties.isEditable && isText && properties.misspelledWord.length > 0, 35 | click(_item) { 36 | webContents.session.addWordToSpellCheckerDictionary( 37 | properties.misspelledWord, 38 | ); 39 | }, 40 | }, 41 | { 42 | type: "separator", 43 | visible: 44 | properties.isEditable && isText && properties.misspelledWord.length > 0, 45 | }, 46 | { 47 | label: `${t.__("Look Up")} "${properties.selectionText}"`, 48 | visible: process.platform === "darwin" && isText, 49 | click(_item) { 50 | webContents.showDefinitionForSelection(); 51 | }, 52 | }, 53 | { 54 | type: "separator", 55 | visible: process.platform === "darwin" && isText, 56 | }, 57 | { 58 | label: t.__("Cut"), 59 | visible: isText, 60 | enabled: properties.isEditable, 61 | accelerator: "CommandOrControl+X", 62 | click(_item) { 63 | webContents.cut(); 64 | }, 65 | }, 66 | { 67 | label: t.__("Copy"), 68 | accelerator: "CommandOrControl+C", 69 | enabled: properties.editFlags.canCopy, 70 | click(_item) { 71 | webContents.copy(); 72 | }, 73 | }, 74 | { 75 | label: t.__("Paste"), // Bug: Paste replaces text 76 | accelerator: "CommandOrControl+V", 77 | enabled: properties.isEditable, 78 | click() { 79 | webContents.paste(); 80 | }, 81 | }, 82 | { 83 | type: "separator", 84 | }, 85 | { 86 | label: 87 | linkUrl?.protocol === "mailto:" 88 | ? t.__("Copy Email Address") 89 | : t.__("Copy Link"), 90 | visible: isLink, 91 | click(_item) { 92 | clipboard.write({ 93 | bookmark: properties.linkText, 94 | text: 95 | linkUrl?.protocol === "mailto:" 96 | ? linkUrl.pathname 97 | : properties.linkURL, 98 | }); 99 | }, 100 | }, 101 | { 102 | label: t.__("Copy Image"), 103 | visible: properties.mediaType === "image", 104 | click(_item) { 105 | webContents.copyImageAt(properties.x, properties.y); 106 | }, 107 | }, 108 | { 109 | label: t.__("Copy Image URL"), 110 | visible: properties.mediaType === "image", 111 | click(_item) { 112 | clipboard.write({ 113 | bookmark: properties.srcURL, 114 | text: properties.srcURL, 115 | }); 116 | }, 117 | }, 118 | { 119 | type: "separator", 120 | visible: isLink || properties.mediaType === "image", 121 | }, 122 | { 123 | label: t.__("Services"), 124 | visible: process.platform === "darwin", 125 | role: "services", 126 | }, 127 | ]; 128 | 129 | if (properties.misspelledWord) { 130 | if (properties.dictionarySuggestions.length > 0) { 131 | const suggestions: MenuItemConstructorOptions[] = 132 | properties.dictionarySuggestions.map((suggestion: string) => 133 | makeSuggestion(suggestion), 134 | ); 135 | menuTemplate = [...suggestions, ...menuTemplate]; 136 | } else { 137 | menuTemplate.unshift({ 138 | label: t.__("No Suggestion Found"), 139 | enabled: false, 140 | }); 141 | } 142 | } 143 | // Hide the invisible separators on Linux and Windows 144 | // Electron has a bug which ignores visible: false parameter for separator menu items. So we remove them here. 145 | // https://github.com/electron/electron/issues/5869 146 | // https://github.com/electron/electron/issues/6906 147 | 148 | const filteredMenuTemplate = menuTemplate.filter( 149 | (menuItem) => menuItem.visible ?? true, 150 | ); 151 | const menu = Menu.buildFromTemplate(filteredMenuTemplate); 152 | menu.popup(); 153 | }; 154 | -------------------------------------------------------------------------------- /app/renderer/js/components/functional-tab.ts: -------------------------------------------------------------------------------- 1 | import {type Html, html} from "../../../common/html.js"; 2 | import type {TabPage} from "../../../common/types.js"; 3 | 4 | import {generateNodeFromHtml} from "./base.js"; 5 | import Tab, {type TabProperties} from "./tab.js"; 6 | 7 | export type FunctionalTabProperties = { 8 | $view: Element; 9 | page: TabPage; 10 | } & TabProperties; 11 | 12 | export default class FunctionalTab extends Tab { 13 | $view: Element; 14 | $el: Element; 15 | $closeButton?: Element; 16 | 17 | constructor({$view, ...properties}: FunctionalTabProperties) { 18 | super(properties); 19 | 20 | this.$view = $view; 21 | this.$el = generateNodeFromHtml(this.templateHtml()); 22 | if (properties.page !== "Settings") { 23 | this.properties.$root.append(this.$el); 24 | this.$closeButton = this.$el.querySelector(".server-tab-badge")!; 25 | this.registerListeners(); 26 | } 27 | } 28 | 29 | override async activate(): Promise { 30 | await super.activate(); 31 | this.$view.classList.add("active"); 32 | } 33 | 34 | override async deactivate(): Promise { 35 | await super.deactivate(); 36 | this.$view.classList.remove("active"); 37 | } 38 | 39 | override async destroy(): Promise { 40 | await super.destroy(); 41 | this.$view.remove(); 42 | } 43 | 44 | templateHtml(): Html { 45 | return html` 46 |
47 |
48 | close 49 |
50 |
51 | ${this.properties.materialIcon} 52 |
53 |
54 | `; 55 | } 56 | 57 | override registerListeners(): void { 58 | super.registerListeners(); 59 | 60 | this.$el.addEventListener("mouseover", () => { 61 | this.$closeButton?.classList.add("active"); 62 | }); 63 | 64 | this.$el.addEventListener("mouseout", () => { 65 | this.$closeButton?.classList.remove("active"); 66 | }); 67 | 68 | this.$closeButton?.addEventListener("click", (event) => { 69 | this.properties.onDestroy?.(); 70 | event.stopPropagation(); 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/renderer/js/components/server-tab.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | 3 | import {type Html, html} from "../../../common/html.js"; 4 | import {ipcRenderer} from "../typed-ipc-renderer.js"; 5 | 6 | import {generateNodeFromHtml} from "./base.js"; 7 | import Tab, {type TabProperties} from "./tab.js"; 8 | import type WebView from "./webview.js"; 9 | 10 | export type ServerTabProperties = { 11 | webview: Promise; 12 | } & TabProperties; 13 | 14 | export default class ServerTab extends Tab { 15 | webview: Promise; 16 | $el: Element; 17 | $name: Element; 18 | $icon: HTMLImageElement; 19 | $badge: Element; 20 | 21 | constructor({webview, ...properties}: ServerTabProperties) { 22 | super(properties); 23 | 24 | this.webview = webview; 25 | this.$el = generateNodeFromHtml(this.templateHtml()); 26 | this.properties.$root.append(this.$el); 27 | this.registerListeners(); 28 | this.$name = this.$el.querySelector(".server-tooltip")!; 29 | this.$icon = this.$el.querySelector(".server-icons")!; 30 | this.$badge = this.$el.querySelector(".server-tab-badge")!; 31 | } 32 | 33 | override async activate(): Promise { 34 | await super.activate(); 35 | (await this.webview).load(); 36 | } 37 | 38 | override async deactivate(): Promise { 39 | await super.deactivate(); 40 | (await this.webview).hide(); 41 | } 42 | 43 | override async destroy(): Promise { 44 | await super.destroy(); 45 | (await this.webview).destroy(); 46 | } 47 | 48 | templateHtml(): Html { 49 | return html` 50 |
51 | 54 |
55 |
56 | 57 |
58 |
${this.generateShortcutText()}
59 |
60 | `; 61 | } 62 | 63 | setLabel(label: string): void { 64 | this.properties.label = label; 65 | this.$name.textContent = label; 66 | } 67 | 68 | setIcon(icon: string): void { 69 | this.properties.icon = icon; 70 | this.$icon.src = icon; 71 | } 72 | 73 | updateBadge(count: number): void { 74 | this.$badge.textContent = count > 999 ? "1K+" : count.toString(); 75 | this.$badge.classList.toggle("active", count > 0); 76 | } 77 | 78 | generateShortcutText(): string { 79 | // Only provide shortcuts for server [0..9] 80 | if (this.properties.index >= 9) { 81 | return ""; 82 | } 83 | 84 | const shownIndex = this.properties.index + 1; 85 | 86 | // Array index == Shown index - 1 87 | ipcRenderer.send("switch-server-tab", shownIndex - 1); 88 | 89 | return process.platform === "darwin" 90 | ? `⌘${shownIndex}` 91 | : `Ctrl+${shownIndex}`; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /app/renderer/js/components/tab.ts: -------------------------------------------------------------------------------- 1 | import type {TabPage, TabRole} from "../../../common/types.js"; 2 | 3 | export type TabProperties = { 4 | role: TabRole; 5 | page?: TabPage; 6 | icon?: string; 7 | label: string; 8 | $root: Element; 9 | onClick: () => void; 10 | index: number; 11 | tabIndex: number; 12 | onHover?: () => void; 13 | onHoverOut?: () => void; 14 | materialIcon?: string; 15 | onDestroy?: () => void; 16 | }; 17 | 18 | export default abstract class Tab { 19 | abstract $el: Element; 20 | 21 | constructor(readonly properties: TabProperties) {} 22 | 23 | registerListeners(): void { 24 | this.$el.addEventListener("click", this.properties.onClick); 25 | 26 | if (this.properties.onHover !== undefined) { 27 | this.$el.addEventListener("mouseover", this.properties.onHover); 28 | } 29 | 30 | if (this.properties.onHoverOut !== undefined) { 31 | this.$el.addEventListener("mouseout", this.properties.onHoverOut); 32 | } 33 | } 34 | 35 | async activate(): Promise { 36 | this.$el.classList.add("active"); 37 | } 38 | 39 | async deactivate(): Promise { 40 | this.$el.classList.remove("active"); 41 | } 42 | 43 | async destroy(): Promise { 44 | this.$el.remove(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/renderer/js/electron-bridge.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from "node:events"; 2 | 3 | import { 4 | type ClipboardDecrypter, 5 | ClipboardDecrypterImplementation, 6 | } from "./clipboard-decrypter.js"; 7 | import {type NotificationData, newNotification} from "./notification/index.js"; 8 | import {ipcRenderer} from "./typed-ipc-renderer.js"; 9 | 10 | type ListenerType = (...arguments_: any[]) => void; 11 | 12 | /* eslint-disable @typescript-eslint/naming-convention */ 13 | export type ElectronBridge = { 14 | send_event: (eventName: string | symbol, ...arguments_: unknown[]) => boolean; 15 | on_event: (eventName: string, listener: ListenerType) => void; 16 | new_notification: ( 17 | title: string, 18 | options: NotificationOptions, 19 | dispatch: (type: string, eventInit: EventInit) => boolean, 20 | ) => NotificationData; 21 | get_idle_on_system: () => boolean; 22 | get_last_active_on_system: () => number; 23 | get_send_notification_reply_message_supported: () => boolean; 24 | set_send_notification_reply_message_supported: (value: boolean) => void; 25 | decrypt_clipboard: (version: number) => ClipboardDecrypter; 26 | }; 27 | /* eslint-enable @typescript-eslint/naming-convention */ 28 | 29 | let notificationReplySupported = false; 30 | // Indicates if the user is idle or not 31 | let idle = false; 32 | // Indicates the time at which user was last active 33 | let lastActive = Date.now(); 34 | 35 | export const bridgeEvents = new EventEmitter(); // eslint-disable-line unicorn/prefer-event-target 36 | 37 | /* eslint-disable @typescript-eslint/naming-convention */ 38 | const electron_bridge: ElectronBridge = { 39 | send_event: (eventName: string | symbol, ...arguments_: unknown[]): boolean => 40 | bridgeEvents.emit(eventName, ...arguments_), 41 | 42 | on_event(eventName: string, listener: ListenerType): void { 43 | bridgeEvents.on(eventName, listener); 44 | }, 45 | 46 | new_notification: ( 47 | title: string, 48 | options: NotificationOptions, 49 | dispatch: (type: string, eventInit: EventInit) => boolean, 50 | ): NotificationData => newNotification(title, options, dispatch), 51 | 52 | get_idle_on_system: (): boolean => idle, 53 | 54 | get_last_active_on_system: (): number => lastActive, 55 | 56 | get_send_notification_reply_message_supported: (): boolean => 57 | notificationReplySupported, 58 | 59 | set_send_notification_reply_message_supported(value: boolean): void { 60 | notificationReplySupported = value; 61 | }, 62 | 63 | decrypt_clipboard: (version: number): ClipboardDecrypter => 64 | new ClipboardDecrypterImplementation(version), 65 | }; 66 | /* eslint-enable @typescript-eslint/naming-convention */ 67 | 68 | bridgeEvents.on("total_unread_count", (unreadCount: unknown) => { 69 | if (typeof unreadCount !== "number") { 70 | throw new TypeError("Expected string for unreadCount"); 71 | } 72 | 73 | ipcRenderer.send("unread-count", unreadCount); 74 | }); 75 | 76 | bridgeEvents.on("realm_name", (realmName: unknown) => { 77 | if (typeof realmName !== "string") { 78 | throw new TypeError("Expected string for realmName"); 79 | } 80 | 81 | const serverUrl = location.origin; 82 | ipcRenderer.send("realm-name-changed", serverUrl, realmName); 83 | }); 84 | 85 | bridgeEvents.on("realm_icon_url", (iconUrl: unknown) => { 86 | if (typeof iconUrl !== "string") { 87 | throw new TypeError("Expected string for iconUrl"); 88 | } 89 | 90 | const serverUrl = location.origin; 91 | ipcRenderer.send( 92 | "realm-icon-changed", 93 | serverUrl, 94 | iconUrl.includes("http") ? iconUrl : `${serverUrl}${iconUrl}`, 95 | ); 96 | }); 97 | 98 | // Set user as active and update the time of last activity 99 | ipcRenderer.on("set-active", () => { 100 | idle = false; 101 | lastActive = Date.now(); 102 | }); 103 | 104 | // Set user as idle and time of last activity is left unchanged 105 | ipcRenderer.on("set-idle", () => { 106 | idle = true; 107 | }); 108 | 109 | // This follows node's idiomatic implementation of event 110 | // emitters to make event handling more simpler instead of using 111 | // functions zulip side will emit event using ElectronBridge.send_event 112 | // which is alias of .emit and on this side we can handle the data by adding 113 | // a listener for the event. 114 | export default electron_bridge; 115 | -------------------------------------------------------------------------------- /app/renderer/js/notification/index.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from "../typed-ipc-renderer.js"; 2 | 3 | export type NotificationData = { 4 | close: () => void; 5 | title: string; 6 | dir: NotificationDirection; 7 | lang: string; 8 | body: string; 9 | tag: string; 10 | icon: string; 11 | data: unknown; 12 | }; 13 | 14 | export function newNotification( 15 | title: string, 16 | options: NotificationOptions, 17 | dispatch: (type: string, eventInit: EventInit) => boolean, 18 | ): NotificationData { 19 | const notification = new Notification(title, {...options, silent: true}); 20 | for (const type of ["click", "close", "error", "show"]) { 21 | notification.addEventListener(type, (event) => { 22 | if (type === "click") ipcRenderer.send("focus-this-webview"); 23 | if (!dispatch(type, event)) { 24 | event.preventDefault(); 25 | } 26 | }); 27 | } 28 | 29 | return { 30 | close() { 31 | notification.close(); 32 | }, 33 | title: notification.title, 34 | dir: notification.dir, 35 | lang: notification.lang, 36 | body: notification.body, 37 | tag: notification.tag, 38 | icon: notification.icon, 39 | data: notification.data, 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /app/renderer/js/pages/about.ts: -------------------------------------------------------------------------------- 1 | import {app} from "@electron/remote"; 2 | 3 | import {Html, html} from "../../../common/html.js"; 4 | import {bundleUrl} from "../../../common/paths.js"; 5 | import * as t from "../../../common/translation-util.js"; 6 | import {generateNodeFromHtml} from "../components/base.js"; 7 | 8 | export class AboutView { 9 | static async create(): Promise { 10 | return new AboutView( 11 | await (await fetch(new URL("app/renderer/about.html", bundleUrl))).text(), 12 | ); 13 | } 14 | 15 | readonly $view: HTMLElement; 16 | 17 | private constructor(templateHtml: string) { 18 | this.$view = document.createElement("div"); 19 | const $shadow = this.$view.attachShadow({mode: "open"}); 20 | $shadow.innerHTML = templateHtml; 21 | $shadow.querySelector("#version")!.textContent = `v${app.getVersion()}`; 22 | const maintenanceInfoHtml = html` 23 |
24 |

25 | ${new Html({ 26 | html: t.__("Maintained by {{{link}}}Zulip{{{endLink}}}", { 27 | link: '', 28 | endLink: "", 29 | }), 30 | })} 31 |

32 |

33 | ${new Html({ 34 | html: t.__( 35 | "Available under the {{{link}}}Apache 2.0 License{{{endLink}}}", 36 | { 37 | link: '', 38 | endLink: "", 39 | }, 40 | ), 41 | })} 42 |

43 |
44 | `; 45 | $shadow 46 | .querySelector(".about")! 47 | .append(generateNodeFromHtml(maintenanceInfoHtml)); 48 | } 49 | 50 | destroy() { 51 | // Do nothing. 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/renderer/js/pages/network.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from "../typed-ipc-renderer.js"; 2 | 3 | export function init( 4 | $reconnectButton: Element, 5 | $settingsButton: Element, 6 | ): void { 7 | $reconnectButton.addEventListener("click", () => { 8 | ipcRenderer.send("forward-message", "reload-viewer"); 9 | }); 10 | $settingsButton.addEventListener("click", () => { 11 | ipcRenderer.send("forward-message", "open-settings"); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /app/renderer/js/pages/preference/base-section.ts: -------------------------------------------------------------------------------- 1 | import {type Html, html} from "../../../../common/html.js"; 2 | import {generateNodeFromHtml} from "../../components/base.js"; 3 | import {ipcRenderer} from "../../typed-ipc-renderer.js"; 4 | 5 | type BaseSectionProperties = { 6 | $element: HTMLElement; 7 | disabled?: boolean; 8 | value: boolean; 9 | clickHandler: () => void; 10 | }; 11 | 12 | export function generateSettingOption(properties: BaseSectionProperties): void { 13 | const {$element, disabled, value, clickHandler} = properties; 14 | 15 | $element.textContent = ""; 16 | 17 | const $optionControl = generateNodeFromHtml( 18 | generateOptionHtml(value, disabled), 19 | ); 20 | $element.append($optionControl); 21 | 22 | if (!disabled) { 23 | $optionControl.addEventListener("click", clickHandler); 24 | } 25 | } 26 | 27 | export function generateOptionHtml( 28 | settingOption: boolean, 29 | disabled?: boolean, 30 | ): Html { 31 | const labelHtml = disabled 32 | ? html`` 36 | : html``; 37 | if (settingOption) { 38 | return html` 39 |
40 |
41 | 42 | ${labelHtml} 43 |
44 |
45 | `; 46 | } 47 | 48 | return html` 49 |
50 |
51 | 52 | ${labelHtml} 53 |
54 |
55 | `; 56 | } 57 | 58 | /* A method that in future can be used to create dropdown menus using 75 | ${optionsHtml} 76 | 77 | `; 78 | } 79 | 80 | export function reloadApp(): void { 81 | ipcRenderer.send("forward-message", "reload-viewer"); 82 | } 83 | -------------------------------------------------------------------------------- /app/renderer/js/pages/preference/connected-org-section.ts: -------------------------------------------------------------------------------- 1 | import {html} from "../../../../common/html.js"; 2 | import * as t from "../../../../common/translation-util.js"; 3 | import {ipcRenderer} from "../../typed-ipc-renderer.js"; 4 | import * as DomainUtil from "../../utils/domain-util.js"; 5 | 6 | import {reloadApp} from "./base-section.js"; 7 | import {initFindAccounts} from "./find-accounts.js"; 8 | import {initServerInfoForm} from "./server-info-form.js"; 9 | 10 | type ConnectedOrgSectionProperties = { 11 | $root: Element; 12 | }; 13 | 14 | export function initConnectedOrgSection({ 15 | $root, 16 | }: ConnectedOrgSectionProperties): void { 17 | $root.textContent = ""; 18 | 19 | const servers = DomainUtil.getDomains(); 20 | $root.innerHTML = html` 21 |
22 |
${t.__("Connected organizations")}
23 |
24 | ${t.__("All the connected organizations will appear here.")} 25 |
26 |
27 |
28 | 31 |
32 |
${t.__("Find accounts by email")}
33 |
34 |
35 | `.html; 36 | 37 | const $serverInfoContainer = $root.querySelector("#server-info-container")!; 38 | const $existingServers = $root.querySelector("#existing-servers")!; 39 | const $newOrgButton: HTMLButtonElement = 40 | $root.querySelector("#new-org-button")!; 41 | const $findAccountsContainer = $root.querySelector( 42 | "#find-accounts-container", 43 | )!; 44 | 45 | const noServerText = t.__( 46 | "All the connected organizations will appear here.", 47 | ); 48 | // Show noServerText if no servers are there otherwise hide it 49 | $existingServers.textContent = servers.length === 0 ? noServerText : ""; 50 | 51 | for (const [i, server] of servers.entries()) { 52 | initServerInfoForm({ 53 | $root: $serverInfoContainer, 54 | server, 55 | index: i, 56 | onChange: reloadApp, 57 | }); 58 | } 59 | 60 | $newOrgButton.addEventListener("click", () => { 61 | ipcRenderer.send("forward-message", "open-org-tab"); 62 | }); 63 | 64 | initFindAccounts({ 65 | $root: $findAccountsContainer, 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /app/renderer/js/pages/preference/find-accounts.ts: -------------------------------------------------------------------------------- 1 | import {html} from "../../../../common/html.js"; 2 | import * as LinkUtil from "../../../../common/link-util.js"; 3 | import * as t from "../../../../common/translation-util.js"; 4 | import {generateNodeFromHtml} from "../../components/base.js"; 5 | 6 | type FindAccountsProperties = { 7 | $root: Element; 8 | }; 9 | 10 | async function findAccounts(url: string): Promise { 11 | if (!url) { 12 | return; 13 | } 14 | 15 | if (!url.startsWith("http")) { 16 | url = "https://" + url; 17 | } 18 | 19 | await LinkUtil.openBrowser(new URL("/accounts/find", url)); 20 | } 21 | 22 | export function initFindAccounts(properties: FindAccountsProperties): void { 23 | const $findAccounts = generateNodeFromHtml(html` 24 |
25 |
26 |
${t.__("Organization URL")}
27 | 28 |
29 |
30 | 33 |
34 |
35 | `); 36 | properties.$root.append($findAccounts); 37 | const $findAccountsButton = $findAccounts.querySelector( 38 | "#find-accounts-button", 39 | )!; 40 | const $serverUrlField: HTMLInputElement = $findAccounts.querySelector( 41 | "input.setting-input-value", 42 | )!; 43 | 44 | $findAccountsButton.addEventListener("click", async () => { 45 | await findAccounts($serverUrlField.value); 46 | }); 47 | 48 | $serverUrlField.addEventListener("click", () => { 49 | if ($serverUrlField.value === "zulipchat.com") { 50 | $serverUrlField.setSelectionRange(0, 0); 51 | } 52 | }); 53 | 54 | $serverUrlField.addEventListener("keypress", async (event) => { 55 | if (event.key === "Enter") { 56 | await findAccounts($serverUrlField.value); 57 | } 58 | }); 59 | 60 | $serverUrlField.addEventListener("input", () => { 61 | $serverUrlField.classList.toggle( 62 | "invalid-input-value", 63 | $serverUrlField.value === "", 64 | ); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /app/renderer/js/pages/preference/nav.ts: -------------------------------------------------------------------------------- 1 | import {type Html, html} from "../../../../common/html.js"; 2 | import * as t from "../../../../common/translation-util.js"; 3 | import type {NavigationItem} from "../../../../common/types.js"; 4 | import {generateNodeFromHtml} from "../../components/base.js"; 5 | 6 | type PreferenceNavigationProperties = { 7 | $root: Element; 8 | onItemSelected: (navigationItem: NavigationItem) => void; 9 | }; 10 | 11 | export default class PreferenceNavigation { 12 | navigationItems: Array<{navigationItem: NavigationItem; label: string}>; 13 | $el: Element; 14 | constructor(private readonly properties: PreferenceNavigationProperties) { 15 | this.navigationItems = [ 16 | {navigationItem: "General", label: t.__("General")}, 17 | {navigationItem: "Network", label: t.__("Network")}, 18 | {navigationItem: "AddServer", label: t.__("Add Organization")}, 19 | {navigationItem: "Organizations", label: t.__("Organizations")}, 20 | {navigationItem: "Shortcuts", label: t.__("Shortcuts")}, 21 | ]; 22 | 23 | this.$el = generateNodeFromHtml(this.templateHtml()); 24 | this.properties.$root.append(this.$el); 25 | this.registerListeners(); 26 | } 27 | 28 | templateHtml(): Html { 29 | const navigationItemsHtml = html``.join( 30 | this.navigationItems.map( 31 | ({navigationItem, label}) => 32 | html``, 33 | ), 34 | ); 35 | 36 | return html` 37 |
38 |
${t.__("Settings")}
39 | 40 |
41 | `; 42 | } 43 | 44 | registerListeners(): void { 45 | for (const {navigationItem} of this.navigationItems) { 46 | const $item = this.$el.querySelector( 47 | `#nav-${CSS.escape(navigationItem)}`, 48 | )!; 49 | $item.addEventListener("click", () => { 50 | this.properties.onItemSelected(navigationItem); 51 | }); 52 | } 53 | } 54 | 55 | select(navigationItemToSelect: NavigationItem): void { 56 | for (const {navigationItem} of this.navigationItems) { 57 | if (navigationItem === navigationItemToSelect) { 58 | this.activate(navigationItem); 59 | } else { 60 | this.deactivate(navigationItem); 61 | } 62 | } 63 | } 64 | 65 | activate(navigationItem: NavigationItem): void { 66 | const $item = this.$el.querySelector(`#nav-${CSS.escape(navigationItem)}`)!; 67 | $item.classList.add("active"); 68 | } 69 | 70 | deactivate(navigationItem: NavigationItem): void { 71 | const $item = this.$el.querySelector(`#nav-${CSS.escape(navigationItem)}`)!; 72 | $item.classList.remove("active"); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/renderer/js/pages/preference/network-section.ts: -------------------------------------------------------------------------------- 1 | import * as ConfigUtil from "../../../../common/config-util.js"; 2 | import {html} from "../../../../common/html.js"; 3 | import * as t from "../../../../common/translation-util.js"; 4 | import {ipcRenderer} from "../../typed-ipc-renderer.js"; 5 | 6 | import {generateSettingOption} from "./base-section.js"; 7 | 8 | type NetworkSectionProperties = { 9 | $root: Element; 10 | }; 11 | 12 | export function initNetworkSection({$root}: NetworkSectionProperties): void { 13 | $root.innerHTML = html` 14 |
15 |
${t.__("Proxy")}
16 |
17 |
18 |
19 | ${t.__("Use system proxy settings (requires restart)")} 20 |
21 |
22 |
23 |
24 |
25 | ${t.__("Manual proxy configuration")} 26 |
27 |
28 |
29 |
30 |
31 | PAC ${t.__("script")} 32 | 36 |
37 |
38 | ${t.__("Proxy rules")} 39 | 43 |
44 |
45 | ${t.__("Proxy bypass rules")} 46 | 47 |
48 |
49 |
50 | ${t.__("Save")} 51 |
52 |
53 |
54 |
55 |
56 | `.html; 57 | 58 | const $proxyPac: HTMLInputElement = $root.querySelector( 59 | "#proxy-pac-option .setting-input-value", 60 | )!; 61 | const $proxyRules: HTMLInputElement = $root.querySelector( 62 | "#proxy-rules-option .setting-input-value", 63 | )!; 64 | const $proxyBypass: HTMLInputElement = $root.querySelector( 65 | "#proxy-bypass-option .setting-input-value", 66 | )!; 67 | const $proxySaveAction = $root.querySelector("#proxy-save-action")!; 68 | const $manualProxyBlock = $root.querySelector(".manual-proxy-block")!; 69 | 70 | toggleManualProxySettings(ConfigUtil.getConfigItem("useManualProxy", false)); 71 | updateProxyOption(); 72 | 73 | $proxyPac.value = ConfigUtil.getConfigItem("proxyPAC", ""); 74 | $proxyRules.value = ConfigUtil.getConfigItem("proxyRules", ""); 75 | $proxyBypass.value = ConfigUtil.getConfigItem("proxyBypass", ""); 76 | 77 | $proxySaveAction.addEventListener("click", () => { 78 | ConfigUtil.setConfigItem("proxyPAC", $proxyPac.value); 79 | ConfigUtil.setConfigItem("proxyRules", $proxyRules.value); 80 | ConfigUtil.setConfigItem("proxyBypass", $proxyBypass.value); 81 | 82 | ipcRenderer.send("forward-message", "reload-proxy", true); 83 | }); 84 | 85 | function toggleManualProxySettings(option: boolean): void { 86 | $manualProxyBlock.classList.toggle("hidden", !option); 87 | } 88 | 89 | function updateProxyOption(): void { 90 | generateSettingOption({ 91 | $element: $root.querySelector("#use-system-settings .setting-control")!, 92 | value: ConfigUtil.getConfigItem("useSystemProxy", false), 93 | clickHandler() { 94 | const newValue = !ConfigUtil.getConfigItem("useSystemProxy", false); 95 | const manualProxyValue = ConfigUtil.getConfigItem( 96 | "useManualProxy", 97 | false, 98 | ); 99 | if (manualProxyValue && newValue) { 100 | ConfigUtil.setConfigItem("useManualProxy", !manualProxyValue); 101 | toggleManualProxySettings(!manualProxyValue); 102 | } 103 | 104 | if (!newValue) { 105 | // Remove proxy system proxy settings 106 | ConfigUtil.setConfigItem("proxyRules", ""); 107 | ipcRenderer.send("forward-message", "reload-proxy", false); 108 | } 109 | 110 | ConfigUtil.setConfigItem("useSystemProxy", newValue); 111 | updateProxyOption(); 112 | }, 113 | }); 114 | generateSettingOption({ 115 | $element: $root.querySelector("#use-manual-settings .setting-control")!, 116 | value: ConfigUtil.getConfigItem("useManualProxy", false), 117 | clickHandler() { 118 | const newValue = !ConfigUtil.getConfigItem("useManualProxy", false); 119 | const systemProxyValue = ConfigUtil.getConfigItem( 120 | "useSystemProxy", 121 | false, 122 | ); 123 | toggleManualProxySettings(newValue); 124 | if (systemProxyValue && newValue) { 125 | ConfigUtil.setConfigItem("useSystemProxy", !systemProxyValue); 126 | } 127 | 128 | ConfigUtil.setConfigItem("proxyRules", ""); 129 | ConfigUtil.setConfigItem("useManualProxy", newValue); 130 | // Reload app only when turning manual proxy off, hence !newValue 131 | ipcRenderer.send("forward-message", "reload-proxy", !newValue); 132 | updateProxyOption(); 133 | }, 134 | }); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /app/renderer/js/pages/preference/new-server-form.ts: -------------------------------------------------------------------------------- 1 | import {dialog} from "@electron/remote"; 2 | 3 | import {html} from "../../../../common/html.js"; 4 | import * as LinkUtil from "../../../../common/link-util.js"; 5 | import * as t from "../../../../common/translation-util.js"; 6 | import {generateNodeFromHtml} from "../../components/base.js"; 7 | import {ipcRenderer} from "../../typed-ipc-renderer.js"; 8 | import * as DomainUtil from "../../utils/domain-util.js"; 9 | 10 | type NewServerFormProperties = { 11 | $root: Element; 12 | onChange: () => void; 13 | }; 14 | 15 | export function initNewServerForm({ 16 | $root, 17 | onChange, 18 | }: NewServerFormProperties): void { 19 | const $newServerForm = generateNodeFromHtml(html` 20 |
21 |
${t.__("Organization URL")}
22 |
23 | 28 |
29 |
30 | 31 |
32 |
33 |
34 |
35 | ${t.__("OR")} 36 |
37 |
38 |
39 |
40 | 43 |
44 |
45 |
46 | ${t.__("Network and Proxy Settings")} 49 | open_in_new 50 |
51 |
52 |
53 | `); 54 | const $saveServerButton: HTMLButtonElement = 55 | $newServerForm.querySelector("#connect")!; 56 | $root.textContent = ""; 57 | $root.append($newServerForm); 58 | const $newServerUrl: HTMLInputElement = $newServerForm.querySelector( 59 | "input.setting-input-value", 60 | )!; 61 | 62 | async function submitFormHandler(): Promise { 63 | $saveServerButton.textContent = "Connecting..."; 64 | let serverConfig; 65 | try { 66 | serverConfig = await DomainUtil.checkDomain($newServerUrl.value.trim()); 67 | } catch (error: unknown) { 68 | $saveServerButton.textContent = "Connect"; 69 | await dialog.showMessageBox({ 70 | type: "error", 71 | message: 72 | error instanceof Error 73 | ? `${error.name}: ${error.message}` 74 | : t.__("Unknown error"), 75 | buttons: [t.__("OK")], 76 | }); 77 | return; 78 | } 79 | 80 | await DomainUtil.addDomain(serverConfig); 81 | onChange(); 82 | } 83 | 84 | $saveServerButton.addEventListener("click", async () => { 85 | await submitFormHandler(); 86 | }); 87 | $newServerUrl.addEventListener("keypress", async (event) => { 88 | if (event.key === "Enter") { 89 | await submitFormHandler(); 90 | } 91 | }); 92 | 93 | // Open create new org link in default browser 94 | const link = "https://zulip.com/new/"; 95 | const externalCreateNewOrgElement = $root.querySelector( 96 | "#open-create-org-link", 97 | )!; 98 | externalCreateNewOrgElement.addEventListener("click", async () => { 99 | await LinkUtil.openBrowser(new URL(link)); 100 | }); 101 | 102 | const networkSettingsId = $root.querySelector(".server-network-option")!; 103 | networkSettingsId.addEventListener("click", () => { 104 | ipcRenderer.send("forward-message", "open-network-settings"); 105 | }); 106 | } 107 | -------------------------------------------------------------------------------- /app/renderer/js/pages/preference/preference.ts: -------------------------------------------------------------------------------- 1 | import type {IpcRendererEvent} from "electron/renderer"; 2 | import process from "node:process"; 3 | 4 | import type {DndSettings} from "../../../../common/dnd-util.js"; 5 | import {bundleUrl} from "../../../../common/paths.js"; 6 | import type {NavigationItem} from "../../../../common/types.js"; 7 | import {ipcRenderer} from "../../typed-ipc-renderer.js"; 8 | 9 | import {initConnectedOrgSection} from "./connected-org-section.js"; 10 | import {initGeneralSection} from "./general-section.js"; 11 | import Nav from "./nav.js"; 12 | import {initNetworkSection} from "./network-section.js"; 13 | import {initServersSection} from "./servers-section.js"; 14 | import {initShortcutsSection} from "./shortcuts-section.js"; 15 | 16 | export class PreferenceView { 17 | static async create(): Promise { 18 | return new PreferenceView( 19 | await ( 20 | await fetch(new URL("app/renderer/preference.html", bundleUrl)) 21 | ).text(), 22 | ); 23 | } 24 | 25 | readonly $view: HTMLElement; 26 | private readonly $shadow: ShadowRoot; 27 | private readonly $settingsContainer: Element; 28 | private readonly nav: Nav; 29 | private navigationItem: NavigationItem = "General"; 30 | 31 | private constructor(templateHtml: string) { 32 | this.$view = document.createElement("div"); 33 | this.$shadow = this.$view.attachShadow({mode: "open"}); 34 | this.$shadow.innerHTML = templateHtml; 35 | 36 | const $sidebarContainer = this.$shadow.querySelector("#sidebar")!; 37 | this.$settingsContainer = this.$shadow.querySelector( 38 | "#settings-container", 39 | )!; 40 | 41 | this.nav = new Nav({ 42 | $root: $sidebarContainer, 43 | onItemSelected: this.handleNavigation, 44 | }); 45 | 46 | ipcRenderer.on("toggle-sidebar", this.handleToggleSidebar); 47 | ipcRenderer.on("toggle-autohide-menubar", this.handleToggleMenubar); 48 | ipcRenderer.on("toggle-dnd", this.handleToggleDnd); 49 | 50 | this.handleNavigation(this.navigationItem); 51 | } 52 | 53 | handleNavigation = (navigationItem: NavigationItem): void => { 54 | this.navigationItem = navigationItem; 55 | this.nav.select(navigationItem); 56 | switch (navigationItem) { 57 | case "AddServer": { 58 | initServersSection({ 59 | $root: this.$settingsContainer, 60 | }); 61 | break; 62 | } 63 | 64 | case "General": { 65 | initGeneralSection({ 66 | $root: this.$settingsContainer, 67 | }); 68 | break; 69 | } 70 | 71 | case "Organizations": { 72 | initConnectedOrgSection({ 73 | $root: this.$settingsContainer, 74 | }); 75 | break; 76 | } 77 | 78 | case "Network": { 79 | initNetworkSection({ 80 | $root: this.$settingsContainer, 81 | }); 82 | break; 83 | } 84 | 85 | case "Shortcuts": { 86 | initShortcutsSection({ 87 | $root: this.$settingsContainer, 88 | }); 89 | break; 90 | } 91 | } 92 | 93 | location.hash = `#${navigationItem}`; 94 | }; 95 | 96 | handleToggleTray(state: boolean) { 97 | this.handleToggle("tray-option", state); 98 | } 99 | 100 | destroy(): void { 101 | ipcRenderer.off("toggle-sidebar", this.handleToggleSidebar); 102 | ipcRenderer.off("toggle-autohide-menubar", this.handleToggleMenubar); 103 | ipcRenderer.off("toggle-dnd", this.handleToggleDnd); 104 | } 105 | 106 | // Handle toggling and reflect changes in preference page 107 | private handleToggle(elementName: string, state = false): void { 108 | const inputSelector = `#${elementName} .action .switch input`; 109 | const input: HTMLInputElement = this.$shadow.querySelector(inputSelector)!; 110 | if (input) { 111 | input.checked = state; 112 | } 113 | } 114 | 115 | private readonly handleToggleSidebar = ( 116 | _event: IpcRendererEvent, 117 | state: boolean, 118 | ) => { 119 | this.handleToggle("sidebar-option", state); 120 | }; 121 | 122 | private readonly handleToggleMenubar = ( 123 | _event: IpcRendererEvent, 124 | state: boolean, 125 | ) => { 126 | this.handleToggle("menubar-option", state); 127 | }; 128 | 129 | private readonly handleToggleDnd = ( 130 | _event: IpcRendererEvent, 131 | _state: boolean, 132 | newSettings: Partial, 133 | ) => { 134 | this.handleToggle("show-notification-option", newSettings.showNotification); 135 | this.handleToggle("silent-option", newSettings.silent); 136 | 137 | if (process.platform === "win32") { 138 | this.handleToggle( 139 | "flash-taskbar-option", 140 | newSettings.flashTaskbarOnMessage, 141 | ); 142 | } 143 | }; 144 | } 145 | -------------------------------------------------------------------------------- /app/renderer/js/pages/preference/server-info-form.ts: -------------------------------------------------------------------------------- 1 | import {dialog} from "@electron/remote"; 2 | 3 | import {html} from "../../../../common/html.js"; 4 | import * as Messages from "../../../../common/messages.js"; 5 | import * as t from "../../../../common/translation-util.js"; 6 | import type {ServerConfig} from "../../../../common/types.js"; 7 | import {generateNodeFromHtml} from "../../components/base.js"; 8 | import {ipcRenderer} from "../../typed-ipc-renderer.js"; 9 | import * as DomainUtil from "../../utils/domain-util.js"; 10 | 11 | type ServerInfoFormProperties = { 12 | $root: Element; 13 | server: ServerConfig; 14 | index: number; 15 | onChange: () => void; 16 | }; 17 | 18 | export function initServerInfoForm(properties: ServerInfoFormProperties): void { 19 | const $serverInfoForm = generateNodeFromHtml(html` 20 |
21 |
22 | 26 |
27 | ${properties.server.alias} 28 | open_in_new 29 |
30 |
31 |
32 |
33 | ${properties.server.url} 36 |
37 |
38 |
39 | ${t.__("Disconnect")} 40 |
41 |
42 |
43 |
44 | `); 45 | const $serverInfoAlias = $serverInfoForm.querySelector(".server-info-alias")!; 46 | const $serverIcon = $serverInfoForm.querySelector(".server-info-icon")!; 47 | const $deleteServerButton = $serverInfoForm.querySelector( 48 | ".server-delete-action", 49 | )!; 50 | const $openServerButton = $serverInfoForm.querySelector(".open-tab-button")!; 51 | properties.$root.append($serverInfoForm); 52 | 53 | $deleteServerButton.addEventListener("click", async () => { 54 | const {response} = await dialog.showMessageBox({ 55 | type: "warning", 56 | buttons: [t.__("Yes"), t.__("No")], 57 | defaultId: 0, 58 | message: t.__("Are you sure you want to disconnect this organization?"), 59 | }); 60 | if (response === 0) { 61 | if (DomainUtil.removeDomain(properties.index)) { 62 | ipcRenderer.send("reload-full-app"); 63 | } else { 64 | const {title, content} = Messages.orgRemovalError( 65 | DomainUtil.getDomain(properties.index).url, 66 | ); 67 | dialog.showErrorBox(title, content); 68 | } 69 | } 70 | }); 71 | 72 | $openServerButton.addEventListener("click", () => { 73 | ipcRenderer.send("forward-message", "switch-server-tab", properties.index); 74 | }); 75 | 76 | $serverInfoAlias.addEventListener("click", () => { 77 | ipcRenderer.send("forward-message", "switch-server-tab", properties.index); 78 | }); 79 | 80 | $serverIcon.addEventListener("click", () => { 81 | ipcRenderer.send("forward-message", "switch-server-tab", properties.index); 82 | }); 83 | } 84 | -------------------------------------------------------------------------------- /app/renderer/js/pages/preference/servers-section.ts: -------------------------------------------------------------------------------- 1 | import {html} from "../../../../common/html.js"; 2 | import * as t from "../../../../common/translation-util.js"; 3 | 4 | import {reloadApp} from "./base-section.js"; 5 | import {initNewServerForm} from "./new-server-form.js"; 6 | 7 | type ServersSectionProperties = { 8 | $root: Element; 9 | }; 10 | 11 | export function initServersSection({$root}: ServersSectionProperties): void { 12 | $root.innerHTML = html` 13 |
14 | 20 |
21 | `.html; 22 | const $newServerContainer = $root.querySelector("#new-server-container")!; 23 | 24 | initNewServerForm({ 25 | $root: $newServerContainer, 26 | onChange: reloadApp, 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /app/renderer/js/preload.ts: -------------------------------------------------------------------------------- 1 | import {contextBridge} from "electron/renderer"; 2 | 3 | import electron_bridge, {bridgeEvents} from "./electron-bridge.js"; 4 | import * as NetworkError from "./pages/network.js"; 5 | import {ipcRenderer} from "./typed-ipc-renderer.js"; 6 | 7 | contextBridge.exposeInMainWorld("electron_bridge", electron_bridge); 8 | 9 | ipcRenderer.on("logout", () => { 10 | bridgeEvents.emit("logout"); 11 | }); 12 | 13 | ipcRenderer.on("show-keyboard-shortcuts", () => { 14 | bridgeEvents.emit("show-keyboard-shortcuts"); 15 | }); 16 | 17 | ipcRenderer.on("show-notification-settings", () => { 18 | bridgeEvents.emit("show-notification-settings"); 19 | }); 20 | 21 | window.addEventListener("load", () => { 22 | if (!location.href.includes("app/renderer/network.html")) { 23 | return; 24 | } 25 | 26 | const $reconnectButton = document.querySelector("#reconnect")!; 27 | const $settingsButton = document.querySelector("#settings")!; 28 | NetworkError.init($reconnectButton, $settingsButton); 29 | }); 30 | -------------------------------------------------------------------------------- /app/renderer/js/tray.ts: -------------------------------------------------------------------------------- 1 | import {type NativeImage, nativeImage} from "electron/common"; 2 | import type {Tray as ElectronTray} from "electron/main"; 3 | import path from "node:path"; 4 | import process from "node:process"; 5 | 6 | import {BrowserWindow, Menu, Tray} from "@electron/remote"; 7 | 8 | import * as ConfigUtil from "../../common/config-util.js"; 9 | import {publicPath} from "../../common/paths.js"; 10 | import type {RendererMessage} from "../../common/typed-ipc.js"; 11 | 12 | import type {ServerManagerView} from "./main.js"; 13 | import {ipcRenderer} from "./typed-ipc-renderer.js"; 14 | 15 | let tray: ElectronTray | null = null; 16 | 17 | const appIcon = path.join(publicPath, "resources/tray/tray"); 18 | 19 | const iconPath = (): string => { 20 | if (process.platform === "linux") { 21 | return appIcon + "linux.png"; 22 | } 23 | 24 | return ( 25 | appIcon + (process.platform === "win32" ? "win.ico" : "macOSTemplate.png") 26 | ); 27 | }; 28 | 29 | const winUnreadTrayIconPath = (): string => appIcon + "unread.ico"; 30 | 31 | let unread = 0; 32 | 33 | const trayIconSize = (): number => { 34 | switch (process.platform) { 35 | case "darwin": { 36 | return 20; 37 | } 38 | 39 | case "win32": { 40 | return 100; 41 | } 42 | 43 | case "linux": { 44 | return 100; 45 | } 46 | 47 | default: { 48 | return 80; 49 | } 50 | } 51 | }; 52 | 53 | // Default config for Icon we might make it OS specific if needed like the size 54 | const config = { 55 | pixelRatio: window.devicePixelRatio, 56 | unreadCount: 0, 57 | showUnreadCount: true, 58 | unreadColor: "#000000", 59 | readColor: "#000000", 60 | unreadBackgroundColor: "#B9FEEA", 61 | readBackgroundColor: "#B9FEEA", 62 | size: trayIconSize(), 63 | thick: process.platform === "win32", 64 | }; 65 | 66 | const renderCanvas = function (argument: number): HTMLCanvasElement { 67 | config.unreadCount = argument; 68 | 69 | const size = config.size * config.pixelRatio; 70 | const padding = size * 0.05; 71 | const center = size / 2; 72 | const hasCount = config.showUnreadCount && config.unreadCount; 73 | const color = config.unreadCount ? config.unreadColor : config.readColor; 74 | const backgroundColor = config.unreadCount 75 | ? config.unreadBackgroundColor 76 | : config.readBackgroundColor; 77 | 78 | const canvas = document.createElement("canvas"); 79 | canvas.width = size; 80 | canvas.height = size; 81 | const context = canvas.getContext("2d")!; 82 | 83 | // Circle 84 | // If (!config.thick || config.thick && hasCount) { 85 | context.beginPath(); 86 | context.arc(center, center, size / 2 - padding, 0, 2 * Math.PI, false); 87 | context.fillStyle = backgroundColor; 88 | context.fill(); 89 | context.lineWidth = size / (config.thick ? 10 : 20); 90 | context.strokeStyle = backgroundColor; 91 | context.stroke(); 92 | // Count or Icon 93 | if (hasCount) { 94 | context.fillStyle = color; 95 | context.textAlign = "center"; 96 | if (config.unreadCount > 99) { 97 | context.font = `${config.thick ? "bold " : ""}${size * 0.4}px Helvetica`; 98 | context.fillText("99+", center, center + size * 0.15); 99 | } else if (config.unreadCount < 10) { 100 | context.font = `${config.thick ? "bold " : ""}${size * 0.5}px Helvetica`; 101 | context.fillText(String(config.unreadCount), center, center + size * 0.2); 102 | } else { 103 | context.font = `${config.thick ? "bold " : ""}${size * 0.5}px Helvetica`; 104 | context.fillText( 105 | String(config.unreadCount), 106 | center, 107 | center + size * 0.15, 108 | ); 109 | } 110 | } 111 | 112 | return canvas; 113 | }; 114 | 115 | /** 116 | * Renders the tray icon as a native image 117 | * @param arg: Unread count 118 | * @return the native image 119 | */ 120 | const renderNativeImage = function (argument: number): NativeImage { 121 | if (process.platform === "win32") { 122 | return nativeImage.createFromPath(winUnreadTrayIconPath()); 123 | } 124 | 125 | const canvas = renderCanvas(argument); 126 | const pngData = nativeImage 127 | .createFromDataURL(canvas.toDataURL("image/png")) 128 | .toPNG(); 129 | return nativeImage.createFromBuffer(pngData, { 130 | scaleFactor: config.pixelRatio, 131 | }); 132 | }; 133 | 134 | function sendAction( 135 | channel: Channel, 136 | ...arguments_: Parameters 137 | ): void { 138 | const win = BrowserWindow.getAllWindows()[0]; 139 | 140 | if (process.platform === "darwin") { 141 | win.restore(); 142 | } 143 | 144 | ipcRenderer.send("forward-to", win.webContents.id, channel, ...arguments_); 145 | } 146 | 147 | const createTray = function (): void { 148 | const contextMenu = Menu.buildFromTemplate([ 149 | { 150 | label: "Zulip", 151 | click() { 152 | ipcRenderer.send("focus-app"); 153 | }, 154 | }, 155 | { 156 | label: "Settings", 157 | click() { 158 | ipcRenderer.send("focus-app"); 159 | sendAction("open-settings"); 160 | }, 161 | }, 162 | { 163 | type: "separator", 164 | }, 165 | { 166 | label: "Quit", 167 | click() { 168 | ipcRenderer.send("quit-app"); 169 | }, 170 | }, 171 | ]); 172 | tray = new Tray(iconPath()); 173 | tray.setContextMenu(contextMenu); 174 | if (process.platform === "linux" || process.platform === "win32") { 175 | tray.on("click", () => { 176 | ipcRenderer.send("toggle-app"); 177 | }); 178 | } 179 | }; 180 | 181 | export function initializeTray(serverManagerView: ServerManagerView) { 182 | ipcRenderer.on("destroytray", () => { 183 | if (!tray) { 184 | return; 185 | } 186 | 187 | tray.destroy(); 188 | if (tray.isDestroyed()) { 189 | tray = null; 190 | } else { 191 | throw new Error("Tray icon not properly destroyed."); 192 | } 193 | }); 194 | 195 | ipcRenderer.on("tray", (_event, argument: number): void => { 196 | if (!tray) { 197 | return; 198 | } 199 | 200 | // We don't want to create tray from unread messages on macOS since it already has dock badges. 201 | if (process.platform === "linux" || process.platform === "win32") { 202 | if (argument === 0) { 203 | unread = argument; 204 | tray.setImage(iconPath()); 205 | tray.setToolTip("No unread messages"); 206 | } else { 207 | unread = argument; 208 | const image = renderNativeImage(argument); 209 | tray.setImage(image); 210 | tray.setToolTip(`${argument} unread messages`); 211 | } 212 | } 213 | }); 214 | 215 | function toggleTray(): void { 216 | let state; 217 | if (tray) { 218 | state = false; 219 | tray.destroy(); 220 | if (tray.isDestroyed()) { 221 | tray = null; 222 | } 223 | 224 | ConfigUtil.setConfigItem("trayIcon", false); 225 | } else { 226 | state = true; 227 | createTray(); 228 | if (process.platform === "linux" || process.platform === "win32") { 229 | const image = renderNativeImage(unread); 230 | tray!.setImage(image); 231 | tray!.setToolTip(`${unread} unread messages`); 232 | } 233 | 234 | ConfigUtil.setConfigItem("trayIcon", true); 235 | } 236 | 237 | serverManagerView.preferenceView?.handleToggleTray(state); 238 | } 239 | 240 | ipcRenderer.on("toggletray", toggleTray); 241 | 242 | if (ConfigUtil.getConfigItem("trayIcon", true)) { 243 | createTray(); 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /app/renderer/js/typed-ipc-renderer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type IpcRendererEvent, 3 | ipcRenderer as untypedIpcRenderer, // eslint-disable-line no-restricted-imports 4 | } from "electron/renderer"; 5 | 6 | import type { 7 | MainCall, 8 | MainMessage, 9 | RendererMessage, 10 | } from "../../common/typed-ipc.js"; 11 | 12 | type RendererListener = 13 | RendererMessage[Channel] extends (...arguments_: infer Arguments) => void 14 | ? (event: IpcRendererEvent, ...arguments_: Arguments) => void 15 | : never; 16 | 17 | export const ipcRenderer: { 18 | on( 19 | channel: Channel, 20 | listener: RendererListener, 21 | ): void; 22 | once( 23 | channel: Channel, 24 | listener: RendererListener, 25 | ): void; 26 | off( 27 | channel: Channel, 28 | listener: RendererListener, 29 | ): void; 30 | removeListener( 31 | channel: Channel, 32 | listener: RendererListener, 33 | ): void; 34 | removeAllListeners(channel: keyof RendererMessage): void; 35 | send( 36 | channel: "forward-message", 37 | rendererChannel: Channel, 38 | ...arguments_: Parameters 39 | ): void; 40 | send( 41 | channel: "forward-to", 42 | webContentsId: number, 43 | rendererChannel: Channel, 44 | ...arguments_: Parameters 45 | ): void; 46 | send( 47 | channel: Channel, 48 | ...arguments_: Parameters 49 | ): void; 50 | invoke( 51 | channel: Channel, 52 | ...arguments_: Parameters 53 | ): Promise>; 54 | sendSync( 55 | channel: Channel, 56 | ...arguments_: Parameters 57 | ): ReturnType; 58 | postMessage( 59 | channel: Channel, 60 | message: Parameters extends [infer Message] 61 | ? Message 62 | : never, 63 | transfer?: MessagePort[], 64 | ): void; 65 | sendToHost( 66 | channel: Channel, 67 | ...arguments_: Parameters 68 | ): void; 69 | } = untypedIpcRenderer; 70 | -------------------------------------------------------------------------------- /app/renderer/js/utils/domain-util.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | 4 | import {app, dialog} from "@electron/remote"; 5 | import * as Sentry from "@sentry/electron/renderer"; 6 | import {JsonDB} from "node-json-db"; 7 | import {DataError} from "node-json-db/dist/lib/Errors"; 8 | import {z} from "zod"; 9 | 10 | import * as EnterpriseUtil from "../../../common/enterprise-util.js"; 11 | import Logger from "../../../common/logger-util.js"; 12 | import * as Messages from "../../../common/messages.js"; 13 | import * as t from "../../../common/translation-util.js"; 14 | import type {ServerConfig} from "../../../common/types.js"; 15 | import defaultIcon from "../../img/icon.png"; 16 | import {ipcRenderer} from "../typed-ipc-renderer.js"; 17 | 18 | const logger = new Logger({ 19 | file: "domain-util.log", 20 | }); 21 | 22 | // For historical reasons, we store this string in domain.json to denote a 23 | // missing icon; it does not change with the actual icon location. 24 | export const defaultIconSentinel = "../renderer/img/icon.png"; 25 | 26 | const serverConfigSchema = z.object({ 27 | url: z.string().url(), 28 | alias: z.string(), 29 | icon: z.string(), 30 | zulipVersion: z.string().default("unknown"), 31 | zulipFeatureLevel: z.number().default(0), 32 | }); 33 | 34 | let database!: JsonDB; 35 | 36 | reloadDatabase(); 37 | 38 | // Migrate from old schema 39 | try { 40 | const oldDomain = database.getObject("/domain"); 41 | if (typeof oldDomain === "string") { 42 | (async () => { 43 | await addDomain({ 44 | alias: "Zulip", 45 | url: oldDomain, 46 | }); 47 | database.delete("/domain"); 48 | })(); 49 | } 50 | } catch (error: unknown) { 51 | if (!(error instanceof DataError)) throw error; 52 | } 53 | 54 | export function getDomains(): ServerConfig[] { 55 | reloadDatabase(); 56 | try { 57 | return serverConfigSchema 58 | .array() 59 | .parse(database.getObject("/domains")); 60 | } catch (error: unknown) { 61 | if (!(error instanceof DataError)) throw error; 62 | return []; 63 | } 64 | } 65 | 66 | export function getDomain(index: number): ServerConfig { 67 | reloadDatabase(); 68 | return serverConfigSchema.parse( 69 | database.getObject(`/domains[${index}]`), 70 | ); 71 | } 72 | 73 | export function updateDomain(index: number, server: ServerConfig): void { 74 | reloadDatabase(); 75 | serverConfigSchema.parse(server); 76 | database.push(`/domains[${index}]`, server, true); 77 | } 78 | 79 | export async function addDomain(server: { 80 | url: string; 81 | alias: string; 82 | icon?: string; 83 | }): Promise { 84 | if (server.icon) { 85 | const localIconUrl = await saveServerIcon(server.icon); 86 | server.icon = localIconUrl; 87 | serverConfigSchema.parse(server); 88 | database.push("/domains[]", server, true); 89 | reloadDatabase(); 90 | } else { 91 | server.icon = defaultIconSentinel; 92 | serverConfigSchema.parse(server); 93 | database.push("/domains[]", server, true); 94 | reloadDatabase(); 95 | } 96 | } 97 | 98 | export function removeDomains(): void { 99 | database.delete("/domains"); 100 | reloadDatabase(); 101 | } 102 | 103 | export function removeDomain(index: number): boolean { 104 | if (EnterpriseUtil.isPresetOrg(getDomain(index).url)) { 105 | return false; 106 | } 107 | 108 | database.delete(`/domains[${index}]`); 109 | reloadDatabase(); 110 | return true; 111 | } 112 | 113 | // Check if domain is already added 114 | export function duplicateDomain(domain: string): boolean { 115 | domain = formatUrl(domain); 116 | return getDomains().some((server) => server.url === domain); 117 | } 118 | 119 | export async function checkDomain( 120 | domain: string, 121 | silent = false, 122 | ): Promise { 123 | if (!silent && duplicateDomain(domain)) { 124 | // Do not check duplicate in silent mode 125 | throw new Error("This server has been added."); 126 | } 127 | 128 | domain = formatUrl(domain); 129 | 130 | try { 131 | return await getServerSettings(domain); 132 | } catch { 133 | throw new Error(Messages.invalidZulipServerError(domain)); 134 | } 135 | } 136 | 137 | async function getServerSettings(domain: string): Promise { 138 | return ipcRenderer.invoke("get-server-settings", domain); 139 | } 140 | 141 | export async function saveServerIcon(iconURL: string): Promise { 142 | return ( 143 | (await ipcRenderer.invoke("save-server-icon", iconURL)) ?? 144 | defaultIconSentinel 145 | ); 146 | } 147 | 148 | export async function updateSavedServer( 149 | url: string, 150 | index: number, 151 | ): Promise { 152 | // Does not promise successful update 153 | const serverConfig = getDomain(index); 154 | const oldIcon = serverConfig.icon; 155 | try { 156 | const newServerConfig = await checkDomain(url, true); 157 | const localIconUrl = await saveServerIcon(newServerConfig.icon); 158 | if (!oldIcon || localIconUrl !== defaultIconSentinel) { 159 | newServerConfig.icon = localIconUrl; 160 | updateDomain(index, newServerConfig); 161 | reloadDatabase(); 162 | } 163 | 164 | return newServerConfig; 165 | } catch (error: unknown) { 166 | logger.log("Could not update server icon."); 167 | logger.log(error); 168 | Sentry.captureException(error); 169 | return serverConfig; 170 | } 171 | } 172 | 173 | function reloadDatabase(): void { 174 | const domainJsonPath = path.join( 175 | app.getPath("userData"), 176 | "config/domain.json", 177 | ); 178 | try { 179 | const file = fs.readFileSync(domainJsonPath, "utf8"); 180 | JSON.parse(file); 181 | } catch (error: unknown) { 182 | if (fs.existsSync(domainJsonPath)) { 183 | fs.unlinkSync(domainJsonPath); 184 | dialog.showErrorBox( 185 | t.__("Error saving new organization"), 186 | t.__( 187 | "There was an error while saving the new organization. You may have to add your previous organizations again.", 188 | ), 189 | ); 190 | logger.error("Error while JSON parsing domain.json: "); 191 | logger.error(error); 192 | Sentry.captureException(error); 193 | } 194 | } 195 | 196 | database = new JsonDB(domainJsonPath, true, true); 197 | } 198 | 199 | export function formatUrl(domain: string): string { 200 | if (domain.startsWith("http://") || domain.startsWith("https://")) { 201 | return domain; 202 | } 203 | 204 | if (domain.startsWith("localhost:")) { 205 | return `http://${domain}`; 206 | } 207 | 208 | return `https://${domain}`; 209 | } 210 | 211 | export function getUnsupportedMessage( 212 | server: ServerConfig, 213 | ): string | undefined { 214 | if (server.zulipFeatureLevel < 65 /* Zulip Server 4.0 */) { 215 | const realm = new URL(server.url).hostname; 216 | return t.__( 217 | "{{{server}}} runs an outdated Zulip Server version {{{version}}}. It may not fully work in this app.", 218 | {server: realm, version: server.zulipVersion}, 219 | ); 220 | } 221 | 222 | return undefined; 223 | } 224 | 225 | export function iconAsUrl(iconPath: string): string { 226 | if (iconPath === defaultIconSentinel) return defaultIcon; 227 | 228 | try { 229 | return `data:application/octet-stream;base64,${fs.readFileSync( 230 | iconPath, 231 | "base64", 232 | )}`; 233 | } catch { 234 | return defaultIcon; 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /app/renderer/js/utils/reconnect-util.ts: -------------------------------------------------------------------------------- 1 | import * as backoff from "backoff"; 2 | 3 | import {html} from "../../../common/html.js"; 4 | import Logger from "../../../common/logger-util.js"; 5 | import type WebView from "../components/webview.js"; 6 | import {ipcRenderer} from "../typed-ipc-renderer.js"; 7 | 8 | const logger = new Logger({ 9 | file: "domain-util.log", 10 | }); 11 | 12 | export default class ReconnectUtil { 13 | url: string; 14 | alreadyReloaded: boolean; 15 | fibonacciBackoff: backoff.Backoff; 16 | 17 | constructor(webview: WebView) { 18 | this.url = webview.properties.url; 19 | this.alreadyReloaded = false; 20 | this.fibonacciBackoff = backoff.fibonacci({ 21 | initialDelay: 5000, 22 | maxDelay: 300_000, 23 | }); 24 | } 25 | 26 | async isOnline(): Promise { 27 | return ipcRenderer.invoke("is-online", this.url); 28 | } 29 | 30 | pollInternetAndReload(): void { 31 | this.fibonacciBackoff.backoff(); 32 | this.fibonacciBackoff.on("ready", async () => { 33 | if (await this._checkAndReload()) { 34 | this.fibonacciBackoff.reset(); 35 | } else { 36 | this.fibonacciBackoff.backoff(); 37 | } 38 | }); 39 | } 40 | 41 | async _checkAndReload(): Promise { 42 | if (this.alreadyReloaded) { 43 | return true; 44 | } 45 | 46 | if (await this.isOnline()) { 47 | ipcRenderer.send("forward-message", "reload-viewer"); 48 | logger.log("You're back online."); 49 | return true; 50 | } 51 | 52 | logger.log( 53 | "There is no internet connection, try checking network cables, modem and router.", 54 | ); 55 | const errorMessageHolder = document.querySelector("#description"); 56 | if (errorMessageHolder) { 57 | errorMessageHolder.innerHTML = html` 58 |
Your internet connection doesn't seem to work properly!
59 |
Verify that it works and then click try again.
60 | `.html; 61 | } 62 | 63 | return false; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/renderer/js/utils/system-util.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from "../typed-ipc-renderer.js"; 2 | 3 | export const connectivityError: string[] = [ 4 | "ERR_INTERNET_DISCONNECTED", 5 | "ERR_PROXY_CONNECTION_FAILED", 6 | "ERR_CONNECTION_RESET", 7 | "ERR_NOT_CONNECTED", 8 | "ERR_NAME_NOT_RESOLVED", 9 | "ERR_NETWORK_CHANGED", 10 | ]; 11 | 12 | const userAgent = ipcRenderer.sendSync("fetch-user-agent"); 13 | 14 | export function getUserAgent(): string { 15 | return userAgent; 16 | } 17 | -------------------------------------------------------------------------------- /app/renderer/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | Zulip 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/renderer/network.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | Zulip - Network Troubleshooting 11 | 17 | 18 | 19 |
20 |
21 |
We can't connect to this organization
22 |
This could be because
23 |
    24 |
  • You're not online or your proxy is misconfigured.
  • 25 |
  • There is no Zulip organization hosted at this URL.
  • 26 |
  • This Zulip organization is temporarily unavailable.
  • 27 |
  • This Zulip organization has been moved or deleted.
  • 28 |
29 |
30 |
Reconnect
31 |
Settings
32 |
33 |
34 | 35 | 36 | -------------------------------------------------------------------------------- /app/renderer/preference.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | -------------------------------------------------------------------------------- /app/resources/zulip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/app/resources/zulip.png -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | version: build{build} 2 | 3 | platform: 4 | - x64 5 | 6 | os: Visual Studio 2015 7 | 8 | cache: 9 | - node_modules -> appveyor.yml 10 | 11 | install: 12 | - ps: Install-Product node LTS $env:platform 13 | - node --version 14 | - npm --version 15 | - python --version 16 | - npm ci 17 | 18 | build: off 19 | 20 | test_script: 21 | - npm run test 22 | # - npm run test-e2e 23 | -------------------------------------------------------------------------------- /build/dmg-background.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 12 | 14 | 16 | 17 | -------------------------------------------------------------------------------- /build/dmg-background.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/build/dmg-background.tiff -------------------------------------------------------------------------------- /build/dmg-icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/build/dmg-icon.icns -------------------------------------------------------------------------------- /build/icon-macos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/build/icon-macos.png -------------------------------------------------------------------------------- /build/icon-macos.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/build/icon.ico -------------------------------------------------------------------------------- /build/zulip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/build/zulip.png -------------------------------------------------------------------------------- /development.md: -------------------------------------------------------------------------------- 1 | # Development Setup 2 | 3 | This is a guide to running the Zulip desktop app from source, 4 | in order to contribute to developing it. 5 | 6 | ## Prerequisites 7 | 8 | To build and run the app from source, you'll need the following: 9 | 10 | - [Git](http://git-scm.com/book/en/v2/Getting-Started-Installing-Git) 11 | - Use our [Git Guide](https://zulip.readthedocs.io/en/latest/git/setup.html) to get started with Git and GitHub. 12 | - [Node.js](https://nodejs.org) >= v10.16.3 13 | - [NPM](https://www.npmjs.com/get-npm) and 14 | [node-gyp](https://github.com/nodejs/node-gyp#installation), 15 | if they don't come bundled with your Node.js installation 16 | - [Python](https://www.python.org/downloads/release/python-2713/) 17 | (v2.7.x recommended) 18 | - A C++ compiler compatible with C++11 19 | - Development headers for the libXext, libXtst, and libxkbfile libraries, which can be installed using apt on Ubuntu using 20 | ```sh 21 | $ sudo apt install libxext-dev libxtst-dev libxkbfile-dev libgconf-2-4 22 | ``` 23 | 24 | ### Ubuntu/Linux and other Debian-based distributions 25 | 26 | On a system running Debian, Ubuntu, or another Debian-based Linux 27 | distribution, you can install all dependencies through the package 28 | manager (see [here][node-debian] for more on the first command): 29 | 30 | ```sh 31 | $ curl -sL https://deb.nodesource.com/setup_6.x | sudo -E bash - 32 | $ sudo apt install git nodejs python build-essential snapcraft libxext-dev libxtst-dev libxkbfile-dev libgconf-2-4 33 | ``` 34 | 35 | [node-debian]: https://nodejs.org/en/download/package-manager/#debian-and-ubuntu-based-linux-distributions 36 | 37 | ### MacOS 38 | 39 | On a system running MacOS, you can refer to [official nodejs docs][node-mac] to 40 | install nodejs. To ensure Node.js has been installed, run `node -v` in terminal to know your node version. 41 | 42 | [node-mac]: https://nodejs.org/en/download/package-manager/#macos 43 | 44 | If [NPM](https://www.npmjs.com/get-npm) and [node-gyp](https://github.com/nodejs/node-gyp#installation) don't come bundled with your Node.js installation, you will need to install them manually. 45 | 46 | ### Windows 47 | 48 | - Download Node.js for Windows and install it. You can refer to the official docs [here][node-windows] to do so. To ensure Node.js has been installed, run `node -v` in Git Bash to know your node version. 49 | 50 | [node-windows]: https://nodejs.org/en/download/package-manager/#windows 51 | 52 | - Also, install Windows-Build-Tools to compile native node modules by using 53 | ```sh 54 | $ npm install --global windows-build-tools 55 | ``` 56 | 57 | ## Download, build, and run 58 | 59 | Clone the source locally: 60 | 61 | ```sh 62 | $ git clone https://github.com/zulip/zulip-desktop 63 | $ cd zulip-desktop 64 | ``` 65 | 66 | Install project dependencies: 67 | 68 | ```sh 69 | $ npm install 70 | ``` 71 | 72 | Start the app: 73 | 74 | ```sh 75 | $ npm start 76 | ``` 77 | 78 | Run tests: 79 | 80 | ```sh 81 | $ npm test 82 | ``` 83 | 84 | ## How to contribute? 85 | 86 | Feel free to fork this repository, test it locally and then report any bugs 87 | you find in the [issue tracker](https://github.com/zulip/zulip-desktop/issues). 88 | 89 | You can read more about making contributions in our [Contributing Guide](./CONTRIBUTING.md). 90 | 91 | ## Making a release 92 | 93 | To package the app into an installer: 94 | 95 | ``` 96 | npm run dist 97 | ``` 98 | 99 | This command will produce distributable packages or installers for the 100 | operating system you're running on: 101 | 102 | - on Windows, a .7z nsis file and a .exe WebSetup file 103 | - on macOS, a `.dmg` file 104 | - on Linux, a plain `.zip` file as well as a `.deb` file, `.snap` file and an 105 | `AppImage` file. 106 | 107 | To generate all three types of files, you will need all three operating 108 | systems. 109 | 110 | The output distributable packages appear in the `dist/` directory. 111 | -------------------------------------------------------------------------------- /docs/Enterprise.md: -------------------------------------------------------------------------------- 1 | # Configuring Zulip Desktop for multiple users 2 | 3 | If you're a system admin and want to add certain organizations to the Zulip app for 4 | all users of your system, you can do so by creating an enterprise config file. 5 | The file should be placed at `/etc/zulip-desktop-config` for Linux and macOS computers 6 | and inside `C:\Program Files\Zulip-Desktop-Config` on Windows. 7 | It must be named `global_config.json` in both cases. 8 | 9 | To specify the preset organization you want to add for other users, you will need to 10 | add the `json` shown below to the `global_config.json`. Replace `https://chat.zulip.org` with the 11 | organization you want to add. You can also specify multiple organizations. 12 | 13 | ```json 14 | { 15 | "presetOrganizations": ["https://chat.zulip.org"], 16 | "autoUpdate": false 17 | } 18 | ``` 19 | 20 | The above example adds [Zulip Community](https://chat.zulip.org) to Zulip every time the app is loaded. 21 | Users can add new organizations at all times, but cannot remove any organizations listed under `presetOrganizations`. 22 | 23 | If you'd like to remove organizations and have admin access, you'll need to change the config file and remove the concerned URL from the `value` field. 24 | 25 | It also turns off automatic updates for every Zulip user on the same machine. 26 | 27 | Currently, we only support `presetOrganizations` and `autoUpdate` settings. We are working on other settings as well, and will update this page when we add support for more. 28 | -------------------------------------------------------------------------------- /docs/Home.md: -------------------------------------------------------------------------------- 1 | # Installation instructions 2 | 3 | - [[Windows]] 4 | -------------------------------------------------------------------------------- /docs/Windows.md: -------------------------------------------------------------------------------- 1 | ** Windows Set up instructions ** 2 | 3 | ## Prerequisites 4 | 5 | - [Git](http://git-scm.com/book/en/v2/Getting-Started-Installing-Git) 6 | - [Node.js](https://nodejs.org) >= v6.9.0 7 | - [python](https://www.python.org/downloads/release/python-2713/) (v2.7.x recommended) 8 | - [node-gyp](https://github.com/nodejs/node-gyp#installation) (installed via PowerShell) 9 | 10 | ## System specific dependencies 11 | 12 | - use only 32bit or 64bit for all of the installers, do not mix architectures 13 | - install using default settings 14 | - open Windows PowerShell as Admin 15 | 16 | ```powershell 17 | C:\Windows\system32> npm install --global --production windows-build-tools 18 | ``` 19 | 20 | ## Installation 21 | 22 | Clone the source locally: 23 | 24 | ```sh 25 | $ git clone https://github.com/zulip/zulip-desktop 26 | $ cd zulip-desktop 27 | ``` 28 | 29 | Install project dependencies: 30 | 31 | ```sh 32 | $ npm install 33 | ``` 34 | 35 | Start the app: 36 | 37 | ```sh 38 | $ npm start 39 | ``` 40 | 41 | ### Making a release 42 | 43 | To package app into an installer use command: 44 | 45 | ``` 46 | npm run pack 47 | npm run dist 48 | ``` 49 | 50 | It will start the packaging process. The ready for distribution file (e.g. dmg, windows installer, deb package) will be outputted to the `dist` directory. 51 | -------------------------------------------------------------------------------- /docs/_Footer.md: -------------------------------------------------------------------------------- 1 | ### Want to contribute to this Wiki? 2 | 3 | [Edit `/docs` files and send a pull request.](https://github.com/zulip/zulip-desktop/tree/main/docs) 4 | -------------------------------------------------------------------------------- /docs/desktop-release.md: -------------------------------------------------------------------------------- 1 | # New release checklist - 2 | 3 | ## We need to cross check following things before pushing a new release + after updating electron version. This is just to make sure that nothing gets broken. 4 | 5 | ## - Desktop notifications 6 | 7 | ## - Spellchecker 8 | 9 | ## - Auto updates 10 | 11 | **Check for the logs in -** 12 | 13 | - **on Linux:** `~/.config/Zulip/log.log` 14 | - **on OS X:** `~/Library/Logs/Zulip/log.log` 15 | - **on Windows:** `%USERPROFILE%\AppData\Roaming\Zulip\log.log` 16 | 17 | ## - All the installer i.e. 18 | 19 | - Linux (.deb, AppImage) 20 | - Mac - (.dmg) 21 | - Windows - (web installer for 32/64bit) 22 | 23 | ## - Check for errors in console (if any) 24 | 25 | ## - Code signing verification on Mac and Windows 26 | 27 | ## - Tray and menu options 28 | 29 | # We need to cross check all these things on - 30 | 31 | - Windows 7 32 | - Windows 8 33 | - Windows 10 34 | - Ubuntu 14.04/16.04 35 | - macOSX 36 | -------------------------------------------------------------------------------- /docs/howto/translations.md: -------------------------------------------------------------------------------- 1 | # Managing translations 2 | 3 | A person using the Zulip app can choose from a large number of 4 | languages for the app to present its UI in. 5 | 6 | Within the running app, we use the library `i18n` to get the 7 | appropriate translation for a given string ("message") used in the UI. 8 | 9 | To manage the set of UI messages and translations for them, and 10 | provide a nice workflow for people to contribute translations, we use 11 | (along with the rest of the Zulip project) a service called Transifex. 12 | 13 | ## Maintainers: syncing to/from Transifex 14 | 15 | ### Setup 16 | 17 | You'll want Transifex's CLI client, `tx`. 18 | 19 | - Install in your homedir with `easy_install transifex-client` or `pip3 install --user transifex-client`. 20 | Or you can use your Zulip dev server virtualenv, which has it. 21 | 22 | - Configure a `.transifexrc` with your API token. See [upstream 23 | instructions](https://docs.transifex.com/client/client-configuration#transifexrc). 24 | 25 | This can go either in your homedir, or in your working tree to make 26 | the configuration apply only locally; it's already ignored in our 27 | `.gitignore`. 28 | 29 | - You'll need to be added [as a "maintainer"][tx-zulip-maintainers] to 30 | the Zulip project on Transifex. (Upstream [recommends 31 | this][tx-docs-maintainers] as the set of permissions on a Transifex 32 | project needed for interacting with it as a developer.) 33 | 34 | [tx-zulip-maintainers]: https://www.transifex.com/zulip/zulip/settings/maintainers/ 35 | [tx-docs-maintainers]: https://docs.transifex.com/teams/understanding-user-roles#project-maintainers 36 | 37 | ### Uploading strings to translate 38 | 39 | Run `tx push -s`. 40 | 41 | This uploads from `public/translations/en.json` to the 42 | set of strings Transifex shows for contributors to translate. 43 | (See `.tx/config` for how that's configured.) 44 | 45 | ### Downloading translated strings 46 | 47 | Run `tools/tx-pull`. 48 | 49 | This writes to files `public/translations/.json`. 50 | (See `.tx/config` for how that's configured.) 51 | 52 | Then look at the following sections to see if further updates are 53 | needed to take full advantage of the new or updated translations. 54 | 55 | ### Updating the languages supported in the code 56 | 57 | Sometimes when downloading translated strings we get a file for a new 58 | language. This happens when we've opened up a new language for people 59 | to contribute translations into in the Zulip project on Transifex, 60 | which we do when someone expresses interest in contributing them. 61 | 62 | The locales for supported languages are stored in `public/translations/supported-locales.json` 63 | 64 | So, when a new language is added, update the `supported-locales` module. 65 | 66 | ### Updating the languages offered in the UI 67 | 68 | The `supported-locales.json` module is also responsible for the language dropsdown ont he settings page. 69 | Maintainers/contributors only need to add the locale to the new language which would result in addition of it to the dropdown automatically. 70 | -------------------------------------------------------------------------------- /how-to-install.md: -------------------------------------------------------------------------------- 1 | # How to install 2 | 3 | **Note:** If you download from the [releases page](https://github.com/zulip/zulip-desktop/releases), be careful what version you pick. Releases that end with `-beta` are beta releases and the rest are stable. 4 | 5 | - **beta:** these releases are the right balance between getting new features early while staying away from nasty bugs. 6 | - **stable:** these releases are more thoroughly tested; they receive new features later, but there's a lower chance that things will go wrong. 7 | 8 | [lr]: https://github.com/zulip/zulip-desktop/releases 9 | 10 | ## macOS 11 | 12 | **DMG or zip**: 13 | 14 | 1. Download [Zulip-x.x.x.dmg][lr] or [Zulip-x.x.x-mac.zip][lr] 15 | 2. Open or unzip the file and drag the app into the `Applications` folder 16 | 3. Done! The app will update automatically 17 | 18 | **Using brew**: 19 | 20 | 1. Run `brew install --cask zulip` in your terminal 21 | 2. The app will be installed in your `Applications` 22 | 3. Done! The app will update automatically (you can also use `brew update && brew upgrade zulip`) 23 | 24 | ## Windows 25 | 26 | **Installer (recommended)**: 27 | 28 | 1. Download [Zulip-Web-Setup-x.x.x.exe][lr] 29 | 2. Run the installer, wait until it finishes 30 | 3. Done! The app will update automatically 31 | 32 | **Portable**: 33 | 34 | 1. Download [zulip-x.x.x-arch.nsis.7z][lr] [*here arch = ia32 (32-bit), x64 (64-bit)*] 35 | 2. Extract the zip wherever you want (e.g. a flash drive) and run the app from there 36 | 37 | ## Linux 38 | 39 | **Ubuntu, Debian 8+ (deb package)**: 40 | 41 | 1. Download [Zulip-x.x.x-amd64.deb][lr] 42 | 2. Double click and install, or run `dpkg -i Zulip-x.x.x-amd64.deb` in the terminal 43 | 3. Start the app with your app launcher or by running `zulip` in a terminal 44 | 4. Done! The app will NOT update automatically, but you can still check for updates 45 | 46 | **Other distros (Fedora, CentOS, Arch Linux etc)** : 47 | 48 | 1. Download Zulip-x.x.x-x86_64.AppImage[LR] 49 | 2. Make it executable using chmod a+x Zulip-x.x.x-x86_64.AppImage 50 | 3. Start the app with your app launcher 51 | 52 | **You can also use `apt-get` (recommended)**: 53 | 54 | - First download our signing key to make sure the deb you download is correct: 55 | 56 | ```bash 57 | sudo apt-key adv --keyserver keyserver.ubuntu.com --recv 69AD12704E71A4803DCA3A682424BE5AE9BD10D9 58 | ``` 59 | 60 | - Add the repo to your apt source list : 61 | 62 | ```bash 63 | echo "deb https://download.zulip.com/desktop/apt stable main" | 64 | sudo tee -a /etc/apt/sources.list.d/zulip.list 65 | ``` 66 | 67 | - Now install the client : 68 | 69 | ```bash 70 | sudo apt-get update 71 | sudo apt-get install zulip 72 | ``` 73 | -------------------------------------------------------------------------------- /i18next-scanner.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | input: ["app/**/*.ts"], 5 | options: { 6 | debug: true, 7 | removeUnusedKeys: true, 8 | sort: true, 9 | func: {list: ["t.__"], extensions: [".ts"]}, 10 | defaultLng: "en", 11 | defaultValue: (lng, ns, key) => (lng === "en" ? key : ""), 12 | resource: { 13 | loadPath: "public/translations/{{lng}}.json", 14 | savePath: "public/translations/{{lng}}.json", 15 | jsonIndent: "\t", 16 | }, 17 | keySeparator: false, 18 | nsSeparator: false, 19 | context: false, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /packaging/deb-after-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Link to the binary 4 | ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' 5 | 6 | # SUID chrome-sandbox for Electron 5+ 7 | chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox' || true 8 | 9 | update-mime-database /usr/share/mime || true 10 | update-desktop-database /usr/share/applications || true 11 | 12 | # Clean up configuration for old Bintray repository 13 | rm -f /etc/apt/zulip.list 14 | -------------------------------------------------------------------------------- /packaging/deb-apt.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mQENBFmdzvQBCADJ4BFlK+4ymIWa3jrNL0WfGPV3dVkZ1Ghy5MsgRIs81CpVS83m 4 | kyBLULY551GNwuHZaeXbkaA+cTDyhEPBFr0MTF0gO514escnjwcL7U1UCLA4I0WP 5 | 0yETXLHp7HFh4g+MZpObkgmLP55aV3jqgNK/p05umrhECBl1HJo+8T+0VNi2x1Pm 6 | LoJVvA7uJHcsNaQVWQF4RP0MaI4TLyjHZAJlpthQfbmq0AbZMEjDu8Th5G9KTsqE 7 | WRyFoAj/SWwKQK2U4xpnA6jEraMcvsYYQMrCXlG+MOV7zVknLrH5tfk7JlmWB4DV 8 | cs+QP5Z/UrVu+YpTpaoJoZV6LlEU1kNGjtq9ABEBAAG0TVp1bGlwIEFQVCBSZXBv 9 | c2l0b3J5IFNpZ25pbmcgS2V5IEJpbnRyYXkgKFByb2R1Y3Rpb24pIDxzdXBwb3J0 10 | QHp1bGlwY2hhdC5jb20+iQE4BBMBAgAiBQJZnc70AhsDBgsJCAcDAgYVCAIJCgsE 11 | FgIDAQIeAQIXgAAKCRAkJL5a6b0Q2Vg1CADJzrH0mbwKi5GiHo5+iX5/WuUkSA8S 12 | lI7FWzkbnPD0sfxJBwBNhZnAALQUvCybHxoU8VZ5ZbU1vbU+EG7pUMzENZLgEhoC 13 | MDl1j8uCSahjjO+bk8qHhgM1FUKpoGec2wKfPKpcz1P+/bLTRKe7aqilkPSYOjeV 14 | u8JI713zRL0nHd9vYZDoN2HR30J5sqgjRHtK5okNhiFG+pF3HFATG7nbNOa/tv+q 15 | ZvhbI/5S8P5VKPSK/1lmMh0UFyNIbPg6MvWiqnfy7DAvOZGJpawkiN2B0XhNZKZR 16 | KKXvFk3qvFpNTCUrH77MlPgjn+oRbE9SYm0phj0o2jQi/s1s2r75tk/ZuQENBFmd 17 | zvQBCACv7VNQ6x3hfaRl8YF8bbrWXN2ZWxEa353p4QryHODsa7wHtsoNR3P30TIL 18 | yafjjcV8P6dzyDw6TpfRqqQDKLY6FtznT2HdceQSffGTXB4CRV7KURBqh81PX/Jo 19 | dz0NwkNrd0NWqkk6BnLX6U5tGuYiqC3vLpjOHmVQezJ41xpf85ElJ2nBW0rEcmfk 20 | fwQthJU7BbqWKd6nbt2G+xWkCVoN6q+CWLXtK0laHMKBGQnoiQpldotsKM8UnDeQ 21 | XPqrEi28ksjVW8tBStCkLwV2hCxk49zdTvRjrhBTQ1Ff/kenuEwqbSERiKfA7I8o 22 | mlqulSiJ6rYdDnGjNcoRgnHb50hTABEBAAGJAR8EGAECAAkFAlmdzvQCGwwACgkQ 23 | JCS+Wum9ENnsOQgApQ2+4azOXprYQXj1ImamD30pmvvKD06Z7oDzappFpEXzRSJK 24 | tMfNaowG7YrXujydrpqaOgv4kFzaAJizWGbmOKXTwQJnavGC1JC4Lijx0s3CLtms 25 | OY3EC2GMNTp2rACuxZQ+26lBuPG8Nd+rNnP8DSzdQROQD2EITplqR1Rc0FLHGspu 26 | rL0JsVTuWS3qSpR3nlmwuLjVgIs5KEaOVEa4pkH9QwyAFDsprF0uZP8xAAs8WrVr 27 | Isg3zs7YUcAtu/i6C2jPuMsHjGfKStkYW/4+wONIynhoFjqeYrR0CiZ9lvVa3tJk 28 | BCeqaQFskx1HhgWBT9Qqc73+i45udWUsa3issg== 29 | =YJGK 30 | -----END PGP PUBLIC KEY BLOCK----- 31 | -------------------------------------------------------------------------------- /packaging/deb-apt.list: -------------------------------------------------------------------------------- 1 | deb https://download.zulip.com/desktop/apt stable main 2 | -------------------------------------------------------------------------------- /packaging/deb-release-upgrades.cfg: -------------------------------------------------------------------------------- 1 | [ThirdPartyMirrors] 2 | zulip-desktop=https://download.zulip.com/desktop/apt 3 | -------------------------------------------------------------------------------- /public/resources/Icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/public/resources/Icon.ico -------------------------------------------------------------------------------- /public/resources/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/public/resources/Icon.png -------------------------------------------------------------------------------- /public/resources/sounds/ding.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/public/resources/sounds/ding.ogg -------------------------------------------------------------------------------- /public/resources/tray/traylinux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/public/resources/tray/traylinux.png -------------------------------------------------------------------------------- /public/resources/tray/traymacOSTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/public/resources/tray/traymacOSTemplate.png -------------------------------------------------------------------------------- /public/resources/tray/traymacOSTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/public/resources/tray/traymacOSTemplate@2x.png -------------------------------------------------------------------------------- /public/resources/tray/traymacOSTemplate@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/public/resources/tray/traymacOSTemplate@3x.png -------------------------------------------------------------------------------- /public/resources/tray/traymacOSTemplate@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/public/resources/tray/traymacOSTemplate@4x.png -------------------------------------------------------------------------------- /public/resources/tray/trayunread.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/public/resources/tray/trayunread.ico -------------------------------------------------------------------------------- /public/resources/tray/traywin.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/public/resources/tray/traywin.ico -------------------------------------------------------------------------------- /public/translations/README.md: -------------------------------------------------------------------------------- 1 | # How to help translate Zulip Desktop 2 | 3 | These are _generated_ files (\*) that contain translations of the strings in 4 | the app. 5 | 6 | You can help translate Zulip Desktop into your language! We do our 7 | translations in Transifex, which is a nice web app for collaborating on 8 | translations; a maintainer then syncs those translations into this repo. 9 | To help out, [join the Zulip project on 10 | Transifex](https://www.transifex.com/zulip/zulip/) and enter translations 11 | there. More details in the [Zulip contributor docs](https://zulip.readthedocs.io/en/latest/translating/translating.html#translators-workflow). 12 | 13 | Within that Transifex project, if you'd like to focus on Zulip Desktop, look 14 | at `desktop.json`. The other resources there are for the Zulip web/mobile 15 | app, where translations are also very welcome. 16 | 17 | (\*) One file is an exception: `en.json` is manually maintained as a 18 | list of (English) messages in the source code, and is used when we upload to 19 | Transifex a list of strings to be translated. It doesn't contain any 20 | translations. 21 | -------------------------------------------------------------------------------- /public/translations/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "A new update {{{version}}} has been downloaded.": "A new update {{{version}}} has been downloaded.", 3 | "A new version {{{version}}} of Zulip Desktop is available.": "A new version {{{version}}} of Zulip Desktop is available.", 4 | "About": "Zulipについて", 5 | "About Zulip": "Zulip について", 6 | "Actual Size": "サイズを元に戻す", 7 | "Add Organization": "組織を追加", 8 | "Add a Zulip organization": "新しいZulip組織を追加", 9 | "Add custom CSS": "カスタムCSSを追加", 10 | "Add to Dictionary": "Add to Dictionary", 11 | "Advanced": "その他", 12 | "All the connected organizations will appear here.": "All the connected organizations will appear here.", 13 | "Always start minimized": "常に最小化して起動", 14 | "App Updates": "アプリのアップデート", 15 | "App language (requires restart)": "アプリの言語設定 (再起動が必要です)", 16 | "Appearance": "外観", 17 | "Application Shortcuts": "アプリケーションのショートカット", 18 | "Are you sure you want to disconnect this organization?": "本当にこの組織から脱退しますか?", 19 | "Are you sure?": "Are you sure?", 20 | "Ask where to save files before downloading": "ダウンロード時にファイルの保存先を指定する", 21 | "Auto hide Menu bar": "メニューバーを自動的に隠す", 22 | "Auto hide menu bar (Press Alt key to display)": "メニューバーを自動的に隠す (Altキーを押すと表示)", 23 | "Available under the {{{link}}}Apache 2.0 License{{{endLink}}}": "Available under the {{{link}}}Apache 2.0 License{{{endLink}}}", 24 | "Back": "戻る", 25 | "Bounce dock on new private message": "新しいプライベートメッセージがあると Dock アイコンが跳ねる", 26 | "CSS file": "CSS file", 27 | "Cancel": "キャンセル", 28 | "Certificate error": "Certificate error", 29 | "Change": "変更", 30 | "Change the language from System Preferences → Keyboard → Text → Spelling.": "Change the language from System Preferences → Keyboard → Text → Spelling.", 31 | "Check for Updates": "アップデートを確認", 32 | "Close": "閉じる", 33 | "Connect": "接続", 34 | "Connect to another organization": "別の組織に接続", 35 | "Connected organizations": "接続済みの組織", 36 | "Copy": "コピー", 37 | "Copy Email Address": "Copy Email Address", 38 | "Copy Image": "画像をコピー", 39 | "Copy Image URL": "画像の URL をコピー", 40 | "Copy Link": "リンクをコピー", 41 | "Copy Zulip URL": "Zulip URL をコピー", 42 | "Create a new organization": "新しい組織を作成", 43 | "Custom CSS file deleted": "Custom CSS file deleted", 44 | "Cut": "切り取り", 45 | "Default download location": "デフォルトのダウンロードフォルダー", 46 | "Delete": "削除", 47 | "Desktop Notifications": "デスクトップ通知", 48 | "Desktop Settings": "デスクトップ設定", 49 | "Disconnect": "切断", 50 | "Disconnect organization": "Disconnect organization", 51 | "Do Not Disturb": "サイレントモード", 52 | "Download App Logs": "アプリログをダウンロード", 53 | "Edit": "編集", 54 | "Edit Shortcuts": "ショートカットを編集", 55 | "Emoji & Symbols": "絵文字と記号", 56 | "Enable auto updates": "自動更新を有効にする", 57 | "Enable error reporting (requires restart)": "エラー報告を有効にする (再起動が必要です)", 58 | "Enable spellchecker (requires restart)": "スペルチェックを有効にする (再起動が必要です)", 59 | "Enter Full Screen": "Enter Full Screen", 60 | "Error saving new organization": "Error saving new organization", 61 | "Error saving update notifications": "Error saving update notifications", 62 | "Error: {{{error}}}\n\nThe latest version of Zulip Desktop is available at:\n{{{link}}}\nCurrent version: {{{version}}}": "Error: {{{error}}}\n\nThe latest version of Zulip Desktop is available at:\n{{{link}}}\nCurrent version: {{{version}}}", 63 | "Factory Reset": "ファクトリーリセット", 64 | "Factory Reset Data": "Factory Reset Data", 65 | "File": "ファイル", 66 | "Find accounts": "アカウントを探す", 67 | "Find accounts by email": "メールでアカウントを探す", 68 | "Flash taskbar on new message": "新しいメッセージがあるとタスクバーを点滅させる", 69 | "Forward": "進む", 70 | "Functionality": "機能", 71 | "General": "全般", 72 | "Get beta updates": "ベータ版のアップデートを入手", 73 | "Go Back": "戻る", 74 | "Hard Reload": "ハードリロード", 75 | "Help": "ヘルプ", 76 | "Help Center": "ヘルプセンター", 77 | "Hide": "非表示", 78 | "Hide Others": "Hide Others", 79 | "Hide Zulip": "Hide Zulip", 80 | "History": "履歴", 81 | "History Shortcuts": "履歴ショートカット", 82 | "Install Later": "Install Later", 83 | "Install and Relaunch": "Install and Relaunch", 84 | "It will be installed the next time you restart the application.": "It will be installed the next time you restart the application.", 85 | "Keyboard Shortcuts": "キーボードショートカット", 86 | "Later": "Later", 87 | "Loading": "ロード中", 88 | "Log Out": "ログアウト", 89 | "Log Out of Organization": "組織からログアウトする", 90 | "Look Up": "Look Up", 91 | "Maintained by {{{link}}}Zulip{{{endLink}}}": "Maintained by {{{link}}}Zulip{{{endLink}}}", 92 | "Manual Download": "Manual Download", 93 | "Manual proxy configuration": "手動プロキシ設定", 94 | "Minimize": "最小化", 95 | "Mute all sounds from Zulip": "Zulip からのすべてのサウンドをミュート", 96 | "Network": "ネットワーク", 97 | "Network and Proxy Settings": "ネットワークとプロキシ", 98 | "New servers added. Reload app now?": "New servers added. Reload app now?", 99 | "No": "いいえ", 100 | "No Suggestion Found": "No Suggestion Found", 101 | "No updates available.": "No updates available.", 102 | "Notification settings": "通知設定", 103 | "OK": "OK", 104 | "OR": "または", 105 | "On macOS, the OS spellchecker is used.": "macOSでは、OSのスペルチェックが使用されます。", 106 | "Organization URL": "組織のURL", 107 | "Organizations": "組織", 108 | "Paste": "貼り付け", 109 | "Paste and Match Style": "スタイルを合わせて貼り付け", 110 | "Proxy": "プロキシ", 111 | "Proxy bypass rules": "プロキシバイパスルール", 112 | "Proxy rules": "プロキシルール", 113 | "Proxy settings saved.": "プロキシ設定を保存しました。", 114 | "Quit": "終了", 115 | "Quit Zulip": "Zulip を終了", 116 | "Quit when the window is closed": "ウインドウを閉じるときに自動的に退出", 117 | "Redo": "やり直す", 118 | "Release Notes": "リリースノート", 119 | "Reload": "リロード", 120 | "Report an Issue": "問題を報告する", 121 | "Reset App Settings": "アプリの設定をリセット", 122 | "Reset the application, thus deleting all the connected organizations and accounts.": "アプリケーションをリセットし、接続されたすべての組織とアカウントを削除します。", 123 | "Save": "保存", 124 | "Select All": "すべて選択", 125 | "Select Download Location": "Select Download Location", 126 | "Select file": "Select file", 127 | "Services": "サービス", 128 | "Settings": "設定", 129 | "Shortcuts": "ショートカット", 130 | "Show app icon in system tray": "システムトレイにアプリアイコンを表示する", 131 | "Show desktop notifications": "デスクトップ通知を表示する", 132 | "Show sidebar": "サイドバーを表示", 133 | "Show unread count badge on app icon": "Show unread count badge on app icon", 134 | "Spellchecker Languages": "スペルチェックの言語", 135 | "Start app at login": "ログイン時にアプリを起動する", 136 | "Switch to Next Organization": "次の組織に切り替える", 137 | "Switch to Previous Organization": "前の組織に切り替える", 138 | "The custom CSS previously set is deleted.": "The custom CSS previously set is deleted.", 139 | "The server presented an invalid certificate for {{{origin}}}:\n\n{{{error}}}": "The server presented an invalid certificate for {{{origin}}}:\n\n{{{error}}}", 140 | "The update will be downloaded in the background. You will be notified when it is ready to be installed.": "The update will be downloaded in the background. You will be notified when it is ready to be installed.", 141 | "There was an error while saving the new organization. You may have to add your previous organizations again.": "There was an error while saving the new organization. You may have to add your previous organizations again.", 142 | "These desktop app shortcuts extend the Zulip webapp's": "これらのデスクトップアプリのショートカットは Zulip Web アプリケーションのショートカットを拡張します。", 143 | "Tip": "ヒント", 144 | "Toggle DevTools for Active Tab": "アクティブなタブの DevTools を切り替え", 145 | "Toggle DevTools for Zulip App": "Zulip App の DevTools を切り替え", 146 | "Toggle Do Not Disturb": "サイレントモードの切り替え", 147 | "Toggle Full Screen": "フルスクリーンの切り替え", 148 | "Toggle Sidebar": "サイドバーの切り替え", 149 | "Toggle Tray Icon": "トレイアイコンの切り替え", 150 | "Tools": "ツール", 151 | "Unable to check for updates.": "Unable to check for updates.", 152 | "Unable to download the update.": "Unable to download the update.", 153 | "Undo": "元に戻す", 154 | "Unhide": "表示", 155 | "Unknown error": "Unknown error", 156 | "Upload": "アップロード", 157 | "Use system proxy settings (requires restart)": "システムのプロキシ設定を使用する (再起動が必要です)", 158 | "View": "表示", 159 | "View Shortcuts": "ショートカットを表示", 160 | "We encountered an error while saving the update notifications.": "We encountered an error while saving the update notifications.", 161 | "Window": "ウインドウ", 162 | "Window Shortcuts": "ウィンドウショートカット", 163 | "Yes": "はい", 164 | "You are running the latest version of Zulip Desktop.\nVersion: {{{version}}}": "You are running the latest version of Zulip Desktop.\nVersion: {{{version}}}", 165 | "You can select a maximum of 3 languages for spellchecking.": "最大で3つの言語のスペルチェックを選択できます。", 166 | "Zoom In": "拡大", 167 | "Zoom Out": "縮小", 168 | "keyboard shortcuts": "キーボードショートカット", 169 | "script": "スクリプト", 170 | "{{{server}}} runs an outdated Zulip Server version {{{version}}}. It may not fully work in this app.": "{{{server}}} runs an outdated Zulip Server version {{{version}}}. It may not fully work in this app." 171 | } 172 | -------------------------------------------------------------------------------- /public/translations/ko.json: -------------------------------------------------------------------------------- 1 | { 2 | "A new update {{{version}}} has been downloaded.": "A new update {{{version}}} has been downloaded.", 3 | "A new version {{{version}}} of Zulip Desktop is available.": "A new version {{{version}}} of Zulip Desktop is available.", 4 | "About": "관하여", 5 | "About Zulip": "Zulip에 대해", 6 | "Actual Size": "실제 크기", 7 | "Add Organization": "조직 추가", 8 | "Add a Zulip organization": "새로운 Zulip 조직 추가", 9 | "Add custom CSS": "맞춤 CSS 추가", 10 | "Add to Dictionary": "사전에 추가하기", 11 | "Advanced": "많은", 12 | "All the connected organizations will appear here.": "All the connected organizations will appear here.", 13 | "Always start minimized": "항상 최소화 된 상태로 시작하십시오.", 14 | "App Updates": "앱 업데이트", 15 | "App language (requires restart)": "앱 언어 (재시작 필요함)", 16 | "Appearance": "외관", 17 | "Application Shortcuts": "애플리케이션 단축키", 18 | "Are you sure you want to disconnect this organization?": "이 조직의 연결을 해제 하시겠습니까?", 19 | "Are you sure?": "Are you sure?", 20 | "Ask where to save files before downloading": "다운로드 전에 어디에 파일을 저장할지 묻기", 21 | "Auto hide Menu bar": "메뉴 바 자동 숨기기", 22 | "Auto hide menu bar (Press Alt key to display)": "메뉴 바 자동 숨기기 (표시하려면 Alt 키를 누릅니다)", 23 | "Available under the {{{link}}}Apache 2.0 License{{{endLink}}}": "Available under the {{{link}}}Apache 2.0 License{{{endLink}}}", 24 | "Back": "뒤로가기", 25 | "Bounce dock on new private message": "새로운 비공개 메시지에 바운스 독", 26 | "CSS file": "CSS file", 27 | "Cancel": "취소", 28 | "Certificate error": "Certificate error", 29 | "Change": "변경", 30 | "Change the language from System Preferences → Keyboard → Text → Spelling.": "시스템 환경설정 → 키보드 → 텍스트 → 맞춤법에서 언어를 바꾸세요.", 31 | "Check for Updates": "업데이트 확인", 32 | "Close": "닫기", 33 | "Connect": "연결", 34 | "Connect to another organization": "다른 조직에 연결", 35 | "Connected organizations": "연결된 조직", 36 | "Copy": "복사", 37 | "Copy Email Address": "Copy Email Address", 38 | "Copy Image": "이미지 복사", 39 | "Copy Image URL": "이미지 URL 복사", 40 | "Copy Link": "링크 복사", 41 | "Copy Zulip URL": "Zulip URL 복사", 42 | "Create a new organization": "새 조직 만들기", 43 | "Custom CSS file deleted": "Custom CSS file deleted", 44 | "Cut": "잘라내기", 45 | "Default download location": "기본 다운로드 위치", 46 | "Delete": "삭제", 47 | "Desktop Notifications": "데스크톱 알림", 48 | "Desktop Settings": "데스크톱 설정", 49 | "Disconnect": "연결 끊기", 50 | "Disconnect organization": "Disconnect organization", 51 | "Do Not Disturb": "Do Not Disturb", 52 | "Download App Logs": "앱 로그 다운로드", 53 | "Edit": "편집하다", 54 | "Edit Shortcuts": "바로 가기 편집", 55 | "Emoji & Symbols": "Emoji & Symbols", 56 | "Enable auto updates": "자동 업데이트 사용", 57 | "Enable error reporting (requires restart)": "오류보고 사용 (재시작 필요)", 58 | "Enable spellchecker (requires restart)": "맞춤법 검사기 사용 (재시작 필요)", 59 | "Enter Full Screen": "Enter Full Screen", 60 | "Error saving new organization": "Error saving new organization", 61 | "Error saving update notifications": "Error saving update notifications", 62 | "Error: {{{error}}}\n\nThe latest version of Zulip Desktop is available at:\n{{{link}}}\nCurrent version: {{{version}}}": "Error: {{{error}}}\n\nThe latest version of Zulip Desktop is available at:\n{{{link}}}\nCurrent version: {{{version}}}", 63 | "Factory Reset": "공장 초기화", 64 | "Factory Reset Data": "공장 초기화 정보", 65 | "File": "파일", 66 | "Find accounts": "계정 찾기", 67 | "Find accounts by email": "이메일을 통한 계정 찾기", 68 | "Flash taskbar on new message": "새 메시지의 Flash 작업 표시 줄", 69 | "Forward": "앞으로", 70 | "Functionality": "기능", 71 | "General": "일반", 72 | "Get beta updates": "베타 업데이트 받기", 73 | "Go Back": "Go Back", 74 | "Hard Reload": "하드 다시로드", 75 | "Help": "도움", 76 | "Help Center": "지원 센터", 77 | "Hide": "숨기기", 78 | "Hide Others": "나머지 숨기기", 79 | "Hide Zulip": "Hide Zulip", 80 | "History": "히스토리", 81 | "History Shortcuts": "히스토리 단축키", 82 | "Install Later": "Install Later", 83 | "Install and Relaunch": "Install and Relaunch", 84 | "It will be installed the next time you restart the application.": "It will be installed the next time you restart the application.", 85 | "Keyboard Shortcuts": "키보드 단축키", 86 | "Later": "Later", 87 | "Loading": "Loading", 88 | "Log Out": "로그 아웃", 89 | "Log Out of Organization": "조직에서 로그 아웃", 90 | "Look Up": "찾아보기", 91 | "Maintained by {{{link}}}Zulip{{{endLink}}}": "Maintained by {{{link}}}Zulip{{{endLink}}}", 92 | "Manual Download": "Manual Download", 93 | "Manual proxy configuration": "수동 프록시 구성", 94 | "Minimize": "최소화", 95 | "Mute all sounds from Zulip": "Zulip에서 모든 소리를 음소거합니다.", 96 | "Network": "네트워크", 97 | "Network and Proxy Settings": "Network and Proxy Settings", 98 | "New servers added. Reload app now?": "New servers added. Reload app now?", 99 | "No": "아니오", 100 | "No Suggestion Found": "추천을 찾지 못했습니다", 101 | "No updates available.": "No updates available.", 102 | "Notification settings": "알림 설정", 103 | "OK": "OK", 104 | "OR": "또는", 105 | "On macOS, the OS spellchecker is used.": "macOS에서는 운영체제의 맞춤법 검사기가 사용됩니다.", 106 | "Organization URL": "조직 URL", 107 | "Organizations": "조직", 108 | "Paste": "붙여넣기", 109 | "Paste and Match Style": "스타일을 일치시켜 붙여넣기", 110 | "Proxy": "프록시", 111 | "Proxy bypass rules": "프록시 우회 규칙", 112 | "Proxy rules": "프록시 규칙", 113 | "Proxy settings saved.": "Proxy settings saved.", 114 | "Quit": "종료", 115 | "Quit Zulip": "Zulip 을 종료합니다.", 116 | "Quit when the window is closed": "윈도우가 닫히면 종료", 117 | "Redo": "다시실행", 118 | "Release Notes": "릴리즈 노트", 119 | "Reload": "새로고침", 120 | "Report an Issue": "문제 신고", 121 | "Reset App Settings": "Reset App Settings", 122 | "Reset the application, thus deleting all the connected organizations and accounts.": "Reset the application, thus deleting all the connected organizations and accounts.", 123 | "Save": "저장", 124 | "Select All": "모두 선택", 125 | "Select Download Location": "Select Download Location", 126 | "Select file": "Select file", 127 | "Services": "서비스들", 128 | "Settings": "설정", 129 | "Shortcuts": "바로 가기", 130 | "Show app icon in system tray": "시스템 트레이에 앱 아이콘 표시", 131 | "Show desktop notifications": "바탕 화면 알림 표시", 132 | "Show sidebar": "사이드 바 표시", 133 | "Show unread count badge on app icon": "Show unread count badge on app icon", 134 | "Spellchecker Languages": "맞춤법 검사기 언어", 135 | "Start app at login": "로그인시 앱 시작", 136 | "Switch to Next Organization": "다음 조직으로 전환", 137 | "Switch to Previous Organization": "이전 조직으로 전환", 138 | "The custom CSS previously set is deleted.": "The custom CSS previously set is deleted.", 139 | "The server presented an invalid certificate for {{{origin}}}:\n\n{{{error}}}": "The server presented an invalid certificate for {{{origin}}}:\n\n{{{error}}}", 140 | "The update will be downloaded in the background. You will be notified when it is ready to be installed.": "The update will be downloaded in the background. You will be notified when it is ready to be installed.", 141 | "There was an error while saving the new organization. You may have to add your previous organizations again.": "There was an error while saving the new organization. You may have to add your previous organizations again.", 142 | "These desktop app shortcuts extend the Zulip webapp's": "데스크톱 앱 바로 가기들은 Zulip 웹 앱을 확장합니다.", 143 | "Tip": "팁", 144 | "Toggle DevTools for Active Tab": "DevTools for Active Tab 토글", 145 | "Toggle DevTools for Zulip App": "Zulip App 용 DevTools 토글", 146 | "Toggle Do Not Disturb": "방해 금지 전환", 147 | "Toggle Full Screen": "전체 화면 토글", 148 | "Toggle Sidebar": "사이드 바 전환", 149 | "Toggle Tray Icon": "트레이 아이콘 토글", 150 | "Tools": "도구들", 151 | "Unable to check for updates.": "Unable to check for updates.", 152 | "Unable to download the update.": "Unable to download the update.", 153 | "Undo": "되돌리기", 154 | "Unhide": "나타내기", 155 | "Unknown error": "Unknown error", 156 | "Upload": "올리기", 157 | "Use system proxy settings (requires restart)": "시스템 프록시 설정 사용 (다시 시작해야 함)", 158 | "View": "보기", 159 | "View Shortcuts": "바로가기 보기", 160 | "We encountered an error while saving the update notifications.": "We encountered an error while saving the update notifications.", 161 | "Window": "창", 162 | "Window Shortcuts": "창 바로 가기", 163 | "Yes": "네", 164 | "You are running the latest version of Zulip Desktop.\nVersion: {{{version}}}": "You are running the latest version of Zulip Desktop.\nVersion: {{{version}}}", 165 | "You can select a maximum of 3 languages for spellchecking.": "최대 3개 언어에 대한 맞춤법 검사기를 선택할수 있습니다.", 166 | "Zoom In": "확대", 167 | "Zoom Out": "축소", 168 | "keyboard shortcuts": "키보드 단축키", 169 | "script": "스크립트", 170 | "{{{server}}} runs an outdated Zulip Server version {{{version}}}. It may not fully work in this app.": "{{{server}}} runs an outdated Zulip Server version {{{version}}}. It may not fully work in this app." 171 | } 172 | -------------------------------------------------------------------------------- /public/translations/supported-locales.json: -------------------------------------------------------------------------------- 1 | { 2 | "bqi": "Bakhtiari", 3 | "ca": "Català", 4 | "cs": "Čeština", 5 | "cy": "Cymraeg", 6 | "da": "Dansk", 7 | "de": "Deutsch", 8 | "en_GB": "English (United Kingdom)", 9 | "en": "English (United States)", 10 | "es": "Español", 11 | "eu": "Euskara", 12 | "fr": "Français", 13 | "gl": "Galego", 14 | "hr": "Hrvatski", 15 | "id": "Indonesia", 16 | "it": "Italiano", 17 | "lv": "Latviešu", 18 | "lt": "Lietuvių", 19 | "hu": "Magyar", 20 | "nl": "Nederlands", 21 | "no": "Norsk", 22 | "uz": "O‘zbek", 23 | "pl": "Polski", 24 | "pt": "Português", 25 | "pt_PT": "Português (Portugal)", 26 | "ro": "Română", 27 | "sk": "Slovenčina", 28 | "fi": "Suomi", 29 | "sv": "Svenska", 30 | "vi": "Tiếng Việt", 31 | "tr": "Türkçe", 32 | "el": "Ελληνικά", 33 | "be": "Беларуская", 34 | "bg": "Български", 35 | "mn": "Монгол", 36 | "ru": "Русский", 37 | "sr": "Српски", 38 | "uk": "Українська", 39 | "ar": "العربية", 40 | "fa": "فارسی", 41 | "hi": "हिन्दी", 42 | "bn": "বাংলা", 43 | "gu": "ગુજરાતી", 44 | "ta": "தமிழ்", 45 | "te": "తెలుగు", 46 | "ml": "മലയാളം", 47 | "si": "සිංහල", 48 | "ko": "한국어", 49 | "zh_TW": "中文 (台灣)", 50 | "zh-Hans": "中文 (简体)", 51 | "ja": "日本語" 52 | } 53 | -------------------------------------------------------------------------------- /public/translations/zh-Hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "A new update {{{version}}} has been downloaded.": "已下载 {{{version}}} 新版本", 3 | "A new version {{{version}}} of Zulip Desktop is available.": "Zulip Desktop {{{version}}} 新版本现已推出", 4 | "About": "关于Zulip", 5 | "About Zulip": "关于 Zulip", 6 | "Actual Size": "真实大小", 7 | "Add Organization": "添加组织", 8 | "Add a Zulip organization": "添加 Zulip 组织", 9 | "Add custom CSS": "添加自定义 CSS", 10 | "Add to Dictionary": "添加到词典", 11 | "Advanced": "高级", 12 | "All the connected organizations will appear here.": "所有已连接的组织都会在此显示。", 13 | "Always start minimized": "总是最小化启动", 14 | "App Updates": "应用更新", 15 | "App language (requires restart)": "应用语言 (需要重启)", 16 | "Appearance": "外观", 17 | "Application Shortcuts": "快捷键", 18 | "Are you sure you want to disconnect this organization?": "您确定要与此组织断开连接?", 19 | "Are you sure?": "你确定吗?", 20 | "Ask where to save files before downloading": "下载前询问保存文件位置", 21 | "Auto hide Menu bar": "自动隐藏菜单栏", 22 | "Auto hide menu bar (Press Alt key to display)": "自动隐藏菜单栏 (按Alt键显示)", 23 | "Available under the {{{link}}}Apache 2.0 License{{{endLink}}}": "受 {{{link}}}Apache 2.0 License{{{endLink}}} 约束", 24 | "Back": "后退", 25 | "Bounce dock on new private message": "收到新私信时弹出Dock", 26 | "CSS file": "CSS 文件", 27 | "Cancel": "取消", 28 | "Certificate error": "证书错误", 29 | "Change": "更改", 30 | "Change the language from System Preferences → Keyboard → Text → Spelling.": "从 \"系统偏好设置 → 键盘 → 文本 → 拼写\" 更改语言。", 31 | "Check for Updates": "检查更新...", 32 | "Close": "关闭", 33 | "Connect": "连接", 34 | "Connect to another organization": "连接到另一个组织", 35 | "Connected organizations": "连接的组织", 36 | "Copy": "复制", 37 | "Copy Email Address": "复制电子邮箱地址", 38 | "Copy Image": "复制图片", 39 | "Copy Image URL": "复制图片链接", 40 | "Copy Link": "复制链接", 41 | "Copy Zulip URL": "复制Zulip地址", 42 | "Create a new organization": "创建新的组织", 43 | "Custom CSS file deleted": "自定义CSS文件已删除", 44 | "Cut": "剪切", 45 | "Default download location": "缺省下载位置", 46 | "Delete": "删除", 47 | "Desktop Notifications": "桌面通知", 48 | "Desktop Settings": "桌面设置", 49 | "Disconnect": "断开", 50 | "Disconnect organization": "断开组织", 51 | "Do Not Disturb": "请勿打扰", 52 | "Download App Logs": "下载应用日志", 53 | "Edit": "编辑", 54 | "Edit Shortcuts": "编辑快捷键", 55 | "Emoji & Symbols": "Emoji和符号", 56 | "Enable auto updates": "启用自动更新", 57 | "Enable error reporting (requires restart)": "启用错误报告(需要重启)", 58 | "Enable spellchecker (requires restart)": "启用拼写检查(需要重启)", 59 | "Enter Full Screen": "进入全屏", 60 | "Error saving new organization": "保存新组织时出错", 61 | "Error saving update notifications": "保存更新通知时出错", 62 | "Error: {{{error}}}\n\nThe latest version of Zulip Desktop is available at:\n{{{link}}}\nCurrent version: {{{version}}}": "错误:{{{error}}}\n\n最新版 Zulip Desktop 已可用\n{{{link}}}\n当前版本:{{{version}}}", 63 | "Factory Reset": "恢复出厂设置", 64 | "Factory Reset Data": "恢复出厂设置与重置数据", 65 | "File": "文件", 66 | "Find accounts": "查找账号", 67 | "Find accounts by email": "通过电子邮件查找账号", 68 | "Flash taskbar on new message": "收到新消息时闪烁任务栏", 69 | "Forward": "前进", 70 | "Functionality": "功能性", 71 | "General": "通用", 72 | "Get beta updates": "获取测试版更新", 73 | "Go Back": "返回", 74 | "Hard Reload": "强制重新加载", 75 | "Help": "帮助", 76 | "Help Center": "帮助中心", 77 | "Hide": "隐藏", 78 | "Hide Others": "隐藏其他", 79 | "Hide Zulip": "隐藏Zulip", 80 | "History": "历史消息", 81 | "History Shortcuts": "历史快捷键", 82 | "Install Later": "稍后安装", 83 | "Install and Relaunch": "安装并重新打开应用", 84 | "It will be installed the next time you restart the application.": "将在您下次重新启动应用时安装", 85 | "Keyboard Shortcuts": "快捷键", 86 | "Later": "稍后", 87 | "Loading": "加载中", 88 | "Log Out": "退出", 89 | "Log Out of Organization": "登出此组织", 90 | "Look Up": "查询", 91 | "Maintained by {{{link}}}Zulip{{{endLink}}}": "由 {{{link}}}Zulip{{{endLink}}} 维护", 92 | "Manual Download": "手动下载", 93 | "Manual proxy configuration": "手动代理配置", 94 | "Minimize": "最小化", 95 | "Mute all sounds from Zulip": "将 Zulip 中的所有声音静音", 96 | "Network": "网络", 97 | "Network and Proxy Settings": "网络和代理设置", 98 | "New servers added. Reload app now?": "已添加新服务器。现在重新加载应用程序吗?", 99 | "No": "否", 100 | "No Suggestion Found": "未找到建议", 101 | "No updates available.": "目前没有可用的更新", 102 | "Notification settings": "通知设置", 103 | "OK": "确定", 104 | "OR": "或", 105 | "On macOS, the OS spellchecker is used.": "在 macOS 上,使用操作系统的拼写检查器。", 106 | "Organization URL": "组织 URL", 107 | "Organizations": "组织", 108 | "Paste": "粘贴", 109 | "Paste and Match Style": "粘贴并匹配格式", 110 | "Proxy": "代理", 111 | "Proxy bypass rules": "代理绕过规则", 112 | "Proxy rules": "代理规则", 113 | "Proxy settings saved.": "代理设置已保存。", 114 | "Quit": "退出", 115 | "Quit Zulip": "退出Zulip", 116 | "Quit when the window is closed": "窗口关闭时退出", 117 | "Redo": "撤销", 118 | "Release Notes": "发行说明", 119 | "Reload": "重新加载", 120 | "Report an Issue": "反馈问题", 121 | "Reset App Settings": "重置应用设置", 122 | "Reset the application, thus deleting all the connected organizations and accounts.": "重置应用程序,将删除所有已连接的组织、账号和证书。", 123 | "Save": "保存", 124 | "Select All": "全选", 125 | "Select Download Location": "选择下载位置", 126 | "Select file": "选择文件", 127 | "Services": "服务", 128 | "Settings": "设置", 129 | "Shortcuts": "快捷键", 130 | "Show app icon in system tray": "在系统托盘中显示应用图标", 131 | "Show desktop notifications": "显示桌面通知", 132 | "Show sidebar": "显示侧边栏", 133 | "Show unread count badge on app icon": "在应用程序图标上显示未读计数徽章", 134 | "Spellchecker Languages": "拼写检查语言", 135 | "Start app at login": "开机时自动启动", 136 | "Switch to Next Organization": "切换到下个组织", 137 | "Switch to Previous Organization": "切换到上个组织", 138 | "The custom CSS previously set is deleted.": "先前设置的自定义CSS已删除。", 139 | "The server presented an invalid certificate for {{{origin}}}:\n\n{{{error}}}": "服务器为 {{{origin}}} 提供了无效的证书:\n\n{{{error}}}", 140 | "The update will be downloaded in the background. You will be notified when it is ready to be installed.": "更新将在后台下载。当更新准备好安装时,您将收到通知。", 141 | "There was an error while saving the new organization. You may have to add your previous organizations again.": "保存新组织时出错。您可能需要重新添加以前的组织。", 142 | "These desktop app shortcuts extend the Zulip webapp's": "这些桌面版快捷键扩展自 Zulip 网页版", 143 | "Tip": "提示", 144 | "Toggle DevTools for Active Tab": "切换活动选项卡的开发者工具", 145 | "Toggle DevTools for Zulip App": "切换Zulip应用的开发者工具", 146 | "Toggle Do Not Disturb": "切换勿扰", 147 | "Toggle Full Screen": "切换全屏", 148 | "Toggle Sidebar": "切换侧边栏", 149 | "Toggle Tray Icon": "切换托盘图标", 150 | "Tools": "工具", 151 | "Unable to check for updates.": "无法检查更新", 152 | "Unable to download the update.": "无法下载更新", 153 | "Undo": "撤销", 154 | "Unhide": "取消隐藏", 155 | "Unknown error": "未知错误", 156 | "Upload": "上传", 157 | "Use system proxy settings (requires restart)": "使用系统代理设置(需要重启生效)", 158 | "View": "视图", 159 | "View Shortcuts": "查看快捷键", 160 | "We encountered an error while saving the update notifications.": "在保存更新通知的时候遇到一个问题", 161 | "Window": "窗口", 162 | "Window Shortcuts": "窗口快捷键", 163 | "Yes": "是", 164 | "You are running the latest version of Zulip Desktop.\nVersion: {{{version}}}": "您使用的是最新版的Zulip Desktop应用程序\n版本号:{{{version}}}", 165 | "You can select a maximum of 3 languages for spellchecking.": "您最多可选择 3 种语言进行拼写检查。", 166 | "Zoom In": "放大", 167 | "Zoom Out": "缩小", 168 | "keyboard shortcuts": "快捷键", 169 | "script": "脚本", 170 | "{{{server}}} runs an outdated Zulip Server version {{{version}}}. It may not fully work in this app.": "{{{server}}}在过时的{{{version}}}版Zulip服务器上运行。在该应用中可能无法正常运作。" 171 | } 172 | -------------------------------------------------------------------------------- /public/translations/zh_TW.json: -------------------------------------------------------------------------------- 1 | { 2 | "A new update {{{version}}} has been downloaded.": "A new update {{{version}}} has been downloaded.", 3 | "A new version {{{version}}} of Zulip Desktop is available.": "A new version {{{version}}} of Zulip Desktop is available.", 4 | "About": "About", 5 | "About Zulip": "關於 Zulip", 6 | "Actual Size": "實際大小", 7 | "Add Organization": "新增組織", 8 | "Add a Zulip organization": "新增 Zulip 組織", 9 | "Add custom CSS": "新增自定義 CSS", 10 | "Add to Dictionary": "新增至資料夾", 11 | "Advanced": "進階", 12 | "All the connected organizations will appear here.": "所有已連線的織職都會在此顯示", 13 | "Always start minimized": "始終最小化開啟", 14 | "App Updates": "應用程式更新", 15 | "App language (requires restart)": "應用程式語言(需要重新啟動)", 16 | "Appearance": "外觀", 17 | "Application Shortcuts": "應用程式快捷鍵", 18 | "Are you sure you want to disconnect this organization?": "您確定要斷開與此組織之連結?", 19 | "Are you sure?": "Are you sure?", 20 | "Ask where to save files before downloading": "下載前詢問檔案儲存位置", 21 | "Auto hide Menu bar": "自動隱藏功能選單", 22 | "Auto hide menu bar (Press Alt key to display)": "自動隱藏功能選單(按 Alt 鍵顯示)", 23 | "Available under the {{{link}}}Apache 2.0 License{{{endLink}}}": "Available under the {{{link}}}Apache 2.0 License{{{endLink}}}", 24 | "Back": "返回", 25 | "Bounce dock on new private message": "Bounce dock on 新的私人訊息", 26 | "CSS file": "CSS file", 27 | "Cancel": "取消", 28 | "Certificate error": "Certificate error", 29 | "Change": "修改", 30 | "Change the language from System Preferences → Keyboard → Text → Spelling.": "變更語言:System Preferences → Keyboard → Text → Spelling.", 31 | "Check for Updates": "檢查更新", 32 | "Close": "關閉", 33 | "Connect": "連結", 34 | "Connect to another organization": "連結至其他組織", 35 | "Connected organizations": "已連結的組織", 36 | "Copy": "複製", 37 | "Copy Email Address": "Copy Email Address", 38 | "Copy Image": "複製圖片", 39 | "Copy Image URL": "複製圖片網址", 40 | "Copy Link": "複製網址", 41 | "Copy Zulip URL": "複製 Zulip 網址", 42 | "Create a new organization": "新增組織", 43 | "Custom CSS file deleted": "Custom CSS file deleted", 44 | "Cut": "剪下", 45 | "Default download location": "預設下載位置", 46 | "Delete": "刪除", 47 | "Desktop Notifications": "桌面版通知", 48 | "Desktop Settings": "桌面版設定", 49 | "Disconnect": "斷開連結", 50 | "Disconnect organization": "Disconnect organization", 51 | "Do Not Disturb": "Do Not Disturb", 52 | "Download App Logs": "下載應用程式紀錄", 53 | "Edit": "編輯", 54 | "Edit Shortcuts": "編輯快捷鍵", 55 | "Emoji & Symbols": "表情符號", 56 | "Enable auto updates": "啟用自動更新", 57 | "Enable error reporting (requires restart)": "啟用錯誤回報(需要重新啟動)", 58 | "Enable spellchecker (requires restart)": "啟用拼字檢查(需要重新啟動)", 59 | "Enter Full Screen": "全螢幕", 60 | "Error saving new organization": "Error saving new organization", 61 | "Error saving update notifications": "Error saving update notifications", 62 | "Error: {{{error}}}\n\nThe latest version of Zulip Desktop is available at:\n{{{link}}}\nCurrent version: {{{version}}}": "Error: {{{error}}}\n\nThe latest version of Zulip Desktop is available at:\n{{{link}}}\nCurrent version: {{{version}}}", 63 | "Factory Reset": "初始化", 64 | "Factory Reset Data": "設定初始化", 65 | "File": "檔案", 66 | "Find accounts": "查詢帳號", 67 | "Find accounts by email": "用 email 查詢帳號", 68 | "Flash taskbar on new message": "有新訊息時閃爍任務欄", 69 | "Forward": "轉發", 70 | "Functionality": "功能性", 71 | "General": "一般", 72 | "Get beta updates": "取得 beta 更新", 73 | "Go Back": "Go Back", 74 | "Hard Reload": "強制重新載入", 75 | "Help": "幫助", 76 | "Help Center": "幫助中心", 77 | "Hide": "隱藏", 78 | "Hide Others": "隱藏其他", 79 | "Hide Zulip": "隱藏 Zulip", 80 | "History": "歷史", 81 | "History Shortcuts": "歷史快捷鍵", 82 | "Install Later": "Install Later", 83 | "Install and Relaunch": "Install and Relaunch", 84 | "It will be installed the next time you restart the application.": "It will be installed the next time you restart the application.", 85 | "Keyboard Shortcuts": "鍵盤快捷鍵", 86 | "Later": "Later", 87 | "Loading": "Loading", 88 | "Log Out": "登出", 89 | "Log Out of Organization": "登出此組織", 90 | "Look Up": "查詢", 91 | "Maintained by {{{link}}}Zulip{{{endLink}}}": "Maintained by {{{link}}}Zulip{{{endLink}}}", 92 | "Manual Download": "Manual Download", 93 | "Manual proxy configuration": "手動 proxy 設定", 94 | "Minimize": "最小化", 95 | "Mute all sounds from Zulip": "將所有 Zulip 音效靜音", 96 | "Network": "網路", 97 | "Network and Proxy Settings": "網路與代理設定", 98 | "New servers added. Reload app now?": "New servers added. Reload app now?", 99 | "No": "No", 100 | "No Suggestion Found": "找不到建議事項", 101 | "No updates available.": "No updates available.", 102 | "Notification settings": "通知設定", 103 | "OK": "OK", 104 | "OR": "或", 105 | "On macOS, the OS spellchecker is used.": "在 macOS 下使用作業系統的拼字檢查", 106 | "Organization URL": "組織網址", 107 | "Organizations": "組織", 108 | "Paste": "貼上", 109 | "Paste and Match Style": "貼上並套用樣式", 110 | "Proxy": "Proxy", 111 | "Proxy bypass rules": "Proxy 白名單規則", 112 | "Proxy rules": "Proxy 規則", 113 | "Proxy settings saved.": "Proxy settings saved.", 114 | "Quit": "退出", 115 | "Quit Zulip": "退出 Zulip", 116 | "Quit when the window is closed": "當關閉視窗時退出", 117 | "Redo": "重做", 118 | "Release Notes": "版本更新公告", 119 | "Reload": "重新載入", 120 | "Report an Issue": "回報問題", 121 | "Reset App Settings": "重置應用設定", 122 | "Reset the application, thus deleting all the connected organizations and accounts.": "重置應用並刪除所有資料", 123 | "Save": "儲存", 124 | "Select All": "選擇全部", 125 | "Select Download Location": "Select Download Location", 126 | "Select file": "Select file", 127 | "Services": "服務", 128 | "Settings": "設定", 129 | "Shortcuts": "快捷鍵", 130 | "Show app icon in system tray": "顯示應用程式圖示在系統夾", 131 | "Show desktop notifications": "顯示桌面版通知", 132 | "Show sidebar": "顯示側邊欄", 133 | "Show unread count badge on app icon": "Show unread count badge on app icon", 134 | "Spellchecker Languages": "需要拼字檢查的語言", 135 | "Start app at login": "登入時開啟應用程式", 136 | "Switch to Next Organization": "切換至後一個組織", 137 | "Switch to Previous Organization": "切換至前一個組織", 138 | "The custom CSS previously set is deleted.": "The custom CSS previously set is deleted.", 139 | "The server presented an invalid certificate for {{{origin}}}:\n\n{{{error}}}": "The server presented an invalid certificate for {{{origin}}}:\n\n{{{error}}}", 140 | "The update will be downloaded in the background. You will be notified when it is ready to be installed.": "The update will be downloaded in the background. You will be notified when it is ready to be installed.", 141 | "There was an error while saving the new organization. You may have to add your previous organizations again.": "There was an error while saving the new organization. You may have to add your previous organizations again.", 142 | "These desktop app shortcuts extend the Zulip webapp's": "這些桌面版快捷鍵是從 Zulip web 版應用程式擴展而來", 143 | "Tip": "提示", 144 | "Toggle DevTools for Active Tab": "切換 DevTools for Active Tab", 145 | "Toggle DevTools for Zulip App": "切換 DevTools for Zulip App", 146 | "Toggle Do Not Disturb": "切換勿擾模式", 147 | "Toggle Full Screen": "切換全螢幕", 148 | "Toggle Sidebar": "切換側邊欄", 149 | "Toggle Tray Icon": "切換夾圖示", 150 | "Tools": "工具", 151 | "Unable to check for updates.": "Unable to check for updates.", 152 | "Unable to download the update.": "Unable to download the update.", 153 | "Undo": "復原", 154 | "Unhide": "取消隱藏", 155 | "Unknown error": "Unknown error", 156 | "Upload": "上傳", 157 | "Use system proxy settings (requires restart)": "使用系統 proxy 設定(需要重新啟動)", 158 | "View": "檢視", 159 | "View Shortcuts": "檢視快捷鍵", 160 | "We encountered an error while saving the update notifications.": "We encountered an error while saving the update notifications.", 161 | "Window": "視窗", 162 | "Window Shortcuts": "視窗快捷鍵", 163 | "Yes": "Yes", 164 | "You are running the latest version of Zulip Desktop.\nVersion: {{{version}}}": "You are running the latest version of Zulip Desktop.\nVersion: {{{version}}}", 165 | "You can select a maximum of 3 languages for spellchecking.": "您最多可以選擇 3 個語言拼字檢查", 166 | "Zoom In": "放大", 167 | "Zoom Out": "縮小", 168 | "keyboard shortcuts": "鍵盤快捷鍵", 169 | "script": "script", 170 | "{{{server}}} runs an outdated Zulip Server version {{{version}}}. It may not fully work in this app.": "{{{server}}} Zulip 伺服器版本過時 {{{version}}}. 服務可能無法正常運作" 171 | } 172 | -------------------------------------------------------------------------------- /snap/gui/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulip/zulip-desktop/916fab7963cbce41bf94fb7c1bb9585f815e41ac/snap/gui/icon.png -------------------------------------------------------------------------------- /snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: zulip 2 | version: 2.3.82 3 | summary: Zulip Desktop Client for Linux 4 | description: Zulip combines the immediacy of Slack with an email threading model. With Zulip, you can catch up on important conversations while ignoring irrelevant ones. 5 | confinement: strict 6 | grade: stable 7 | icon: snap/gui/icon.png 8 | apps: 9 | zulip: 10 | command: env TMPDIR=$XDG_RUNTIME_DIR desktop-launch $SNAP/zulip 11 | plugs: 12 | - desktop 13 | - desktop-legacy 14 | - home 15 | - x11 16 | - unity7 17 | - browser-support 18 | - network 19 | - gsettings 20 | - pulseaudio 21 | - opengl 22 | parts: 23 | app: 24 | plugin: dump 25 | stage-packages: 26 | - libasound2 27 | - libgconf2-4 28 | - libnotify4 29 | - libnspr4 30 | - libnss3 31 | - libpcre3 32 | - libpulse0 33 | - libxss1 34 | - libxtst6 35 | source: dist/linux-unpacked 36 | after: 37 | - desktop-gtk3 38 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const {chan, put, take} = require("medium"); 3 | const test = require("tape"); 4 | 5 | const setup = require("./setup.js"); 6 | 7 | test("app runs", async (t) => { 8 | t.timeoutAfter(10e3); 9 | setup.resetTestDataDir(); 10 | const app = await setup.createApp(); 11 | try { 12 | const windows = chan(); 13 | for (const win of app.windows()) put(windows, win); 14 | app.on("window", (win) => put(windows, win)); 15 | 16 | const mainWindow = await take(windows); 17 | t.equal(await mainWindow.title(), "Zulip"); 18 | 19 | await mainWindow.waitForSelector("#connect"); 20 | } finally { 21 | await setup.endTest(app); 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5.9.3", 3 | "productName": "ZulipTest", 4 | "main": "../dist-electron" 5 | } 6 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const fs = require("node:fs"); 3 | const path = require("node:path"); 4 | const process = require("node:process"); 5 | 6 | const {_electron} = require("playwright-core"); 7 | 8 | const testsPkg = require("./package.json"); 9 | 10 | module.exports = { 11 | createApp, 12 | endTest, 13 | resetTestDataDir: resetTestDataDirectory, 14 | }; 15 | 16 | // Runs Zulip Desktop. 17 | // Returns a promise that resolves to an Electron Application once the app has loaded. 18 | function createApp() { 19 | return _electron.launch({ 20 | args: [path.join(__dirname)], // Ensure this dir has a package.json file with a 'main' entry point 21 | }); 22 | } 23 | 24 | // Quit the app, end the test 25 | async function endTest(app) { 26 | await app.close(); 27 | } 28 | 29 | function getAppDataDirectory() { 30 | let base; 31 | 32 | switch (process.platform) { 33 | case "darwin": { 34 | base = path.join(process.env.HOME, "Library", "Application Support"); 35 | break; 36 | } 37 | 38 | case "linux": { 39 | base = 40 | process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME, ".config"); 41 | break; 42 | } 43 | 44 | case "win32": { 45 | base = process.env.APPDATA; 46 | break; 47 | } 48 | 49 | default: { 50 | throw new Error("Could not detect app data dir base."); 51 | } 52 | } 53 | 54 | console.log("Detected App Data Dir base:", base); 55 | return path.join(base, testsPkg.productName); 56 | } 57 | 58 | // Resets the test directory, containing domain.json, window-state.json, etc 59 | function resetTestDataDirectory() { 60 | const appDataDirectory = getAppDataDirectory(); 61 | fs.rmSync(appDataDirectory, {force: true, recursive: true}); 62 | } 63 | -------------------------------------------------------------------------------- /tests/test-add-organization.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const {chan, put, take} = require("medium"); 3 | const test = require("tape"); 4 | 5 | const setup = require("./setup.js"); 6 | 7 | test("add-organization", async (t) => { 8 | t.timeoutAfter(50e3); 9 | setup.resetTestDataDir(); 10 | const app = await setup.createApp(); 11 | try { 12 | const windows = chan(); 13 | for (const win of app.windows()) put(windows, win); 14 | app.on("window", (win) => put(windows, win)); 15 | 16 | const mainWindow = await take(windows); 17 | t.equal(await mainWindow.title(), "Zulip"); 18 | 19 | await mainWindow.fill( 20 | ".setting-input-value", 21 | "zulip-desktop-test.zulipchat.com", 22 | ); 23 | await mainWindow.click("#connect"); 24 | 25 | const orgWebview = await take(windows); 26 | await orgWebview.waitForSelector("#id_username"); 27 | } finally { 28 | await setup.endTest(app); 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /tests/test-new-organization.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const {chan, put, take} = require("medium"); 3 | const test = require("tape"); 4 | 5 | const setup = require("./setup.js"); 6 | 7 | // Create new org link should open in the default browser [WIP] 8 | 9 | test("new-org-link", async (t) => { 10 | t.timeoutAfter(50e3); 11 | setup.resetTestDataDir(); 12 | const app = await setup.createApp(); 13 | try { 14 | const windows = chan(); 15 | for (const win of app.windows()) put(windows, win); 16 | app.on("window", (win) => put(windows, win)); 17 | 18 | const mainWindow = await take(windows); 19 | t.equal(await mainWindow.title(), "Zulip"); 20 | 21 | await mainWindow.click("#open-create-org-link"); 22 | } finally { 23 | await setup.endTest(app); 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /tools/fetch-pull-request: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -x 4 | 5 | if ! git diff-index --quiet HEAD; then 6 | set +x 7 | echo "There are uncommitted changes:" 8 | git status --short 9 | echo "Doing nothing to avoid losing your work." 10 | exit 1 11 | fi 12 | request_id="$1" 13 | remote=${2:-"upstream"} 14 | git fetch "$remote" "pull/$request_id/head" 15 | git checkout -B "review-original-${request_id}" 16 | git reset --hard FETCH_HEAD 17 | -------------------------------------------------------------------------------- /tools/fetch-pull-request.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | git diff-index --quiet HEAD 3 | if %ERRORLEVEL% NEQ 0 ( 4 | echo "There are uncommitted changes:" 5 | git status --short 6 | echo "Doing nothing to avoid losing your work." 7 | exit /B 1 8 | ) 9 | 10 | if "%~1"=="" ( 11 | echo "Error you must specify the PR number" 12 | ) 13 | 14 | if "%~2"=="" ( 15 | set remote="upstream" 16 | ) else ( 17 | set remote=%2 18 | ) 19 | 20 | set request_id="%1" 21 | git fetch "%remote%" "pull/%request_id%/head" 22 | git checkout -B "review-%request_id%" 23 | git reset --hard FETCH_HEAD 24 | -------------------------------------------------------------------------------- /tools/fetch-rebase-pull-request: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -x 4 | 5 | if ! git diff-index --quiet HEAD; then 6 | set +x 7 | echo "There are uncommitted changes:" 8 | git status --short 9 | echo "Doing nothing to avoid losing your work." 10 | exit 1 11 | fi 12 | request_id="$1" 13 | remote=${2:-"upstream"} 14 | git fetch "$remote" "pull/$request_id/head" 15 | git checkout -B "review-${request_id}" $remote/main 16 | git reset --hard FETCH_HEAD 17 | git pull --rebase 18 | -------------------------------------------------------------------------------- /tools/fetch-rebase-pull-request.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | git diff-index --quiet HEAD 3 | if %errorlevel% neq 0 ( 4 | echo "There are uncommitted changes:" 5 | git status --short 6 | echo "Doing nothing to avoid losing your work." 7 | exit \B 1 8 | ) 9 | 10 | if "%~1"=="" ( 11 | echo "Error you must specify the PR number" 12 | ) 13 | 14 | if "%~2"=="" ( 15 | set remote="upstream" 16 | ) else ( 17 | set remote=%2 18 | ) 19 | 20 | set request_id="%1" 21 | git fetch "%remote%" "pull/%request_id%/head" 22 | git checkout -B "review-%request_id%" %remote%/main 23 | git reset --hard FETCH_HEAD 24 | git pull --rebase 25 | -------------------------------------------------------------------------------- /tools/push-to-pull-request: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | usage () { 5 | cat >&2 <&2 45 | exit 1 46 | fi 47 | 48 | # See https://developer.github.com/v3/pulls/#get-a-single-pull-request . 49 | # This is the old REST API; the new GraphQL API does look neat, but it 50 | # seems to require authentication even for simple lookups of public data, 51 | # and that'd be a pain for a simple script like this. 52 | pr_url=https://api.github.com/repos/"${repo_fq}"/pulls/"${pr_id}" 53 | pr_details="$(curl -s "$pr_url")" 54 | 55 | pr_jq () { 56 | echo "$pr_details" | jq "$@" 57 | } 58 | 59 | if [ "$(pr_jq -r .message)" = "Not Found" ]; then 60 | echo "Invalid PR URL: $pr_url" 61 | exit 1 62 | fi 63 | 64 | if [ "$(pr_jq .maintainer_can_modify)" != "true" ]; then 65 | # This happens when the PR has already been merged or closed, or 66 | # if the contributor has turned off the (default) setting to allow 67 | # maintainers of the target repo to push to their PR branch. 68 | # 69 | # The latter seems to be rare (in Greg's experience doing the 70 | # manual equivalent of this script for many different 71 | # contributors, none have ever chosen this setting), but give a 72 | # decent error message if it does happen. 73 | echo "error: PR already closed, or contributor has disallowed pushing to branch" >&2 74 | exit 1 75 | fi 76 | 77 | pr_head_repo_fq="$(pr_jq -r .head.repo.full_name)" 78 | pr_head_refname="$(pr_jq -r .head.ref)" 79 | 80 | set -x 81 | exec git push git@github.com:"$pr_head_repo_fq" +@:"$pr_head_refname" 82 | -------------------------------------------------------------------------------- /tools/reset-to-pull-request: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | if ! git diff-index --quiet HEAD; then 5 | set +x 6 | echo "There are uncommitted changes:" 7 | git status --short 8 | echo "Doing nothing to avoid losing your work." 9 | exit 1 10 | fi 11 | 12 | remote_default="$(git config zulip.zulipRemote || echo upstream)" 13 | 14 | request_id="$1" 15 | remote=${2:-"$remote_default"} 16 | 17 | set -x 18 | git fetch "$remote" "pull/$request_id/head" 19 | git reset --hard FETCH_HEAD 20 | -------------------------------------------------------------------------------- /tools/tx-pull: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eux 3 | exec tx pull -a -f --minimum-perc=5 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "target": "esnext", 5 | "module": "esnext", 6 | "moduleResolution": "bundler", 7 | "esModuleInterop": true, 8 | "paths": { 9 | // https://github.com/getsentry/sentry-electron/issues/957 10 | "@sentry/node/build/types/integrations/anr/common": [ 11 | "./node_modules/@sentry/node/build/types/integrations/anr/common" 12 | ] 13 | }, 14 | "resolveJsonModule": true, 15 | "strict": true, 16 | "noImplicitOverride": true, 17 | "types": ["vite/client"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module "zulip:remote" { 2 | export const { 3 | app, 4 | dialog, 5 | }: typeof import("electron/main") | typeof import("@electron/remote"); 6 | } 7 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | 3 | import * as path from "node:path"; 4 | 5 | import {defineConfig} from "vite"; 6 | import electron from "vite-plugin-electron"; 7 | 8 | export default defineConfig({ 9 | plugins: [ 10 | electron([ 11 | { 12 | entry: { 13 | index: "app/main", 14 | }, 15 | vite: { 16 | build: { 17 | sourcemap: true, 18 | rollupOptions: { 19 | external: ["electron", /^electron\//, /^gatemaker\//], 20 | }, 21 | ssr: true, 22 | }, 23 | resolve: { 24 | alias: { 25 | "zulip:remote": "electron/main", 26 | }, 27 | }, 28 | ssr: { 29 | noExternal: true, 30 | }, 31 | }, 32 | }, 33 | { 34 | entry: { 35 | preload: "app/renderer/js/preload.ts", 36 | }, 37 | vite: { 38 | build: { 39 | sourcemap: "inline", 40 | rollupOptions: { 41 | external: ["electron", /^electron\//], 42 | }, 43 | }, 44 | }, 45 | }, 46 | { 47 | entry: { 48 | renderer: "app/renderer/js/main.ts", 49 | }, 50 | vite: { 51 | build: { 52 | sourcemap: true, 53 | rollupOptions: { 54 | external: ["electron", /^electron\//], 55 | }, 56 | }, 57 | resolve: { 58 | alias: { 59 | "zulip:remote": "@electron/remote", 60 | }, 61 | }, 62 | }, 63 | }, 64 | ]), 65 | ], 66 | build: { 67 | outDir: "dist-electron", 68 | sourcemap: true, 69 | rollupOptions: { 70 | input: { 71 | renderer: path.join(__dirname, "app/renderer/main.html"), 72 | network: path.join(__dirname, "app/renderer/network.html"), 73 | about: path.join(__dirname, "app/renderer/about.html"), 74 | preference: path.join(__dirname, "app/renderer/preference.html"), 75 | }, 76 | }, 77 | }, 78 | }); 79 | --------------------------------------------------------------------------------