Compare commits

..

2 Commits

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

2
.gitattributes vendored
View File

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

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

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

View File

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

View File

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

View File

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

7
.gitignore vendored
View File

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

View File

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

View File

@@ -1,3 +1,6 @@
sudo: required
dist: trusty
os: os:
- osx - osx
- linux - linux
@@ -12,12 +15,23 @@ addons:
language: node_js language: node_js
node_js: node_js:
- '10' - '8'
- '12'
before_install:
- ./scripts/travis-xvfb.sh
- npm install -g gulp
- npm install
cache: cache:
directories: directories:
- node_modules - node_modules
- app/node_modules
script: script:
- npm run test - npm run travis
notifications:
webhooks:
urls:
- https://zulip.org/zulipbot/travis
on_success: always
on_failure: always

View File

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

View File

@@ -1,16 +1,15 @@
# Zulip Desktop Client # Zulip Desktop Client
[![Build Status](https://travis-ci.com/zulip/zulip-desktop.svg?branch=master)](https://travis-ci.com/github/zulip/zulip-desktop) [![Build Status](https://travis-ci.org/zulip/zulip-desktop.svg?branch=master)](https://travis-ci.org/zulip/zulip-desktop)
[![Windows Build Status](https://ci.appveyor.com/api/projects/status/github/zulip/zulip-desktop?branch=master&svg=true)](https://ci.appveyor.com/project/zulip/zulip-desktop/branch/master) [![Windows Build Status](https://ci.appveyor.com/api/projects/status/github/zulip/zulip-desktop?branch=master&svg=true)](https://ci.appveyor.com/project/zulip/zulip-desktop/branch/master)
[![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo) [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo)
[![project chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://chat.zulip.org) [![project chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://chat.zulip.org)
Desktop client for Zulip. Available for Mac, Linux, and Windows. Desktop client for Zulip. Available for Mac, Linux, and Windows.
![screenshot](https://i.imgur.com/s1o6TRA.png) <img src="http://i.imgur.com/ChzTq4F.png"/>
![screenshot](https://i.imgur.com/vekKnW4.png)
# Download # Download
Please see the [installation guide](https://zulip.com/help/desktop-app-install-guide). Please see the [installation guide](https://zulipchat.com/help/desktop-app-install-guide).
# Features # Features
* Sign in to multiple organizations * Sign in to multiple organizations

View File

@@ -1,26 +1,20 @@
import {app, dialog, session} from 'electron'; 'use strict';
import util from 'util'; import { app, dialog, shell } from 'electron';
import { autoUpdater } from 'electron-updater';
import { linuxUpdateNotification } from './linuxupdater'; // Required only in case of linux
import isDev from 'electron-is-dev'; import log = require('electron-log');
import log from 'electron-log'; import isDev = require('electron-is-dev');
import {UpdateDownloadedEvent, UpdateInfo, autoUpdater} from 'electron-updater'; import ConfigUtil = require('../renderer/js/utils/config-util');
import * as ConfigUtil from '../renderer/js/utils/config-util'; export function appUpdater(updateFromMenu = false): void {
import * as LinkUtil from '../renderer/js/utils/link-util';
import {linuxUpdateNotification} from './linuxupdater'; // Required only in case of linux
const sleep = util.promisify(setTimeout);
export async function appUpdater(updateFromMenu = false): Promise<void> {
// Don't initiate auto-updates in development // Don't initiate auto-updates in development
if (isDev) { if (isDev) {
return; return;
} }
if (process.platform === 'linux' && !process.env.APPIMAGE) { if (process.platform === 'linux' && !process.env.APPIMAGE) {
const ses = session.fromPartition('persist:webviewsession'); linuxUpdateNotification();
await linuxUpdateNotification(ses);
return; return;
} }
@@ -40,72 +34,73 @@ export async function appUpdater(updateFromMenu = false): Promise<void> {
autoUpdater.allowPrerelease = isBetaUpdate || false; autoUpdater.allowPrerelease = isBetaUpdate || false;
const eventsListenerRemove = ['update-available', 'update-not-available']; const eventsListenerRemove = ['update-available', 'update-not-available'];
autoUpdater.on('update-available', async (info: UpdateInfo) => { autoUpdater.on('update-available', info => {
if (updateFromMenu) { if (updateFromMenu) {
dialog.showMessageBox({
message: `A new version ${info.version}, of Zulip Desktop is available`,
detail: 'The update will be downloaded in the background. You will be notified when it is ready to be installed.'
});
updateAvailable = true; updateAvailable = true;
// This is to prevent removal of 'update-downloaded' and 'error' event listener. // This is to prevent removal of 'update-downloaded' and 'error' event listener.
eventsListenerRemove.forEach(event => { eventsListenerRemove.forEach(event => {
autoUpdater.removeAllListeners(event); autoUpdater.removeAllListeners(event);
}); });
await dialog.showMessageBox({
message: `A new version ${info.version}, of Zulip Desktop is available`,
detail: 'The update will be downloaded in the background. You will be notified when it is ready to be installed.'
});
} }
}); });
autoUpdater.on('update-not-available', async () => { autoUpdater.on('update-not-available', () => {
if (updateFromMenu) { if (updateFromMenu) {
// Remove all autoUpdator listeners so that next time autoUpdator is manually called these dialog.showMessageBox({
// listeners don't trigger multiple times.
autoUpdater.removeAllListeners();
await dialog.showMessageBox({
message: 'No updates available', message: 'No updates available',
detail: `You are running the latest version of Zulip Desktop.\nVersion: ${app.getVersion()}` detail: `You are running the latest version of Zulip Desktop.\nVersion: ${app.getVersion()}`
}); });
}
});
autoUpdater.on('error', async (error: Error) => {
if (updateFromMenu) {
// Remove all autoUpdator listeners so that next time autoUpdator is manually called these // Remove all autoUpdator listeners so that next time autoUpdator is manually called these
// listeners don't trigger multiple times. // listeners don't trigger multiple times.
autoUpdater.removeAllListeners(); autoUpdater.removeAllListeners();
}
});
autoUpdater.on('error', error => {
if (updateFromMenu) {
const messageText = (updateAvailable) ? ('Unable to download the updates') : ('Unable to check for updates'); const messageText = (updateAvailable) ? ('Unable to download the updates') : ('Unable to check for updates');
const {response} = await dialog.showMessageBox({ dialog.showMessageBox({
type: 'error', type: 'error',
buttons: ['Manual Download', 'Cancel'], buttons: ['Manual Download', 'Cancel'],
message: messageText, message: messageText,
detail: `Error: ${error.message}\n\nThe latest version of Zulip Desktop is available at -\nhttps://zulip.com/apps/.\n detail: (error).toString() + `\n\nThe latest version of Zulip Desktop is available at -\nhttps://zulipchat.com/apps/.\n
Current Version: ${app.getVersion()}` Current Version: ${app.getVersion()}`
}, response => {
if (response === 0) {
shell.openExternal('https://zulipchat.com/apps/');
}
}); });
if (response === 0) { // Remove all autoUpdator listeners so that next time autoUpdator is manually called these
await LinkUtil.openBrowser(new URL('https://zulip.com/apps/')); // listeners don't trigger multiple times.
} autoUpdater.removeAllListeners();
} }
}); });
// Ask the user if update is available // Ask the user if update is available
autoUpdater.on('update-downloaded', async (event: UpdateDownloadedEvent) => { autoUpdater.on('update-downloaded', event => {
// Ask user to update the app // Ask user to update the app
const {response} = await dialog.showMessageBox({ dialog.showMessageBox({
type: 'question', type: 'question',
buttons: ['Install and Relaunch', 'Install Later'], buttons: ['Install and Relaunch', 'Install Later'],
defaultId: 0, defaultId: 0,
message: `A new update ${event.version} has been downloaded`, message: `A new update ${event.version} has been downloaded`,
detail: 'It will be installed the next time you restart the application' detail: 'It will be installed the next time you restart the application'
}, response => {
if (response === 0) {
setTimeout(() => {
autoUpdater.quitAndInstall();
// force app to quit. This is just a workaround, ideally autoUpdater.quitAndInstall() should relaunch the app.
app.quit();
}, 1000);
}
}); });
if (response === 0) {
await sleep(1000);
autoUpdater.quitAndInstall();
// Force app to quit. This is just a workaround, ideally autoUpdater.quitAndInstall() should relaunch the app.
app.quit();
}
}); });
// Init for updates // Init for updates
await autoUpdater.checkForUpdates(); autoUpdater.checkForUpdates();
} }

View File

@@ -1,21 +1,31 @@
'use strict';
import { sentryInit } from '../renderer/js/utils/sentry-util';
import { appUpdater } from './autoupdater';
import { setAutoLaunch } from './startup';
import electron, {app, dialog, ipcMain, session} from 'electron'; import windowStateKeeper = require('electron-window-state');
import fs from 'fs'; import path = require('path');
import path from 'path'; import fs = require('fs');
import isDev = require('electron-is-dev');
import electron = require('electron');
const { app, ipcMain, session } = electron;
import windowStateKeeper from 'electron-window-state'; import AppMenu = require('./menu');
import BadgeSettings = require('../renderer/js/pages/preference/badge-settings');
import ConfigUtil = require('../renderer/js/utils/config-util');
import ProxyUtil = require('../renderer/js/utils/proxy-util');
import * as BadgeSettings from '../renderer/js/pages/preference/badge-settings'; interface PatchedGlobal extends NodeJS.Global {
import * as ConfigUtil from '../renderer/js/utils/config-util'; mainWindowState: windowStateKeeper.State;
import * as ProxyUtil from '../renderer/js/utils/proxy-util'; }
import {sentryInit} from '../renderer/js/utils/sentry-util';
import {appUpdater} from './autoupdater'; const globalPatched = global as PatchedGlobal;
import * as AppMenu from './menu';
import {_getServerSettings, _saveServerIcon, _isOnline} from './request';
import {setAutoLaunch} from './startup';
let mainWindowState: windowStateKeeper.State; // Adds debug features like hotkeys for triggering dev tools and reload
// in development mode
if (isDev) {
require('electron-debug')();
}
// Prevent window being garbage collected // Prevent window being garbage collected
let mainWindow: Electron.BrowserWindow; let mainWindow: Electron.BrowserWindow;
@@ -41,40 +51,23 @@ if (singleInstanceLock) {
app.quit(); app.quit();
} }
const rendererCallbacks = new Map();
let nextRendererCallbackId = 0;
ipcMain.on('renderer-callback', (event: Event, rendererCallbackId: number, ...args: any[]) => {
rendererCallbacks.get(rendererCallbackId)(...args);
rendererCallbacks.delete(rendererCallbackId);
});
function makeRendererCallback(callback: (...args: any[]) => void): number {
rendererCallbacks.set(nextRendererCallbackId, callback);
return nextRendererCallbackId++;
}
const APP_ICON = path.join(__dirname, '../resources', 'Icon'); const APP_ICON = path.join(__dirname, '../resources', 'Icon');
const iconPath = (): string => APP_ICON + (process.platform === 'win32' ? '.ico' : '.png'); const iconPath = (): string => {
return APP_ICON + (process.platform === 'win32' ? '.ico' : '.png');
// Toggle the app window
const toggleApp = (): void => {
if (!mainWindow.isVisible() || mainWindow.isMinimized()) {
mainWindow.show();
} else {
mainWindow.hide();
}
}; };
function createMainWindow(): Electron.BrowserWindow { function createMainWindow(): Electron.BrowserWindow {
// Load the previous state with fallback to defaults // Load the previous state with fallback to defaults
mainWindowState = windowStateKeeper({ const mainWindowState: windowStateKeeper.State = windowStateKeeper({
defaultWidth: 1100, defaultWidth: 1100,
defaultHeight: 720, defaultHeight: 720,
path: `${app.getPath('userData')}/config` path: `${app.getPath('userData')}/config`
}); });
// Let's keep the window position global so that we can access it in other process
globalPatched.mainWindowState = mainWindowState;
const win = new electron.BrowserWindow({ const win = new electron.BrowserWindow({
// This settings needs to be saved in config // This settings needs to be saved in config
title: 'Zulip', title: 'Zulip',
@@ -83,14 +76,12 @@ function createMainWindow(): Electron.BrowserWindow {
y: mainWindowState.y, y: mainWindowState.y,
width: mainWindowState.width, width: mainWindowState.width,
height: mainWindowState.height, height: mainWindowState.height,
minWidth: 500, minWidth: 300,
minHeight: 400, minHeight: 400,
webPreferences: { webPreferences: {
contextIsolation: false, plugins: true,
enableRemoteModule: true,
nodeIntegration: true, nodeIntegration: true,
partition: 'persist:webviewsession', partition: 'persist:webviewsession'
webviewTag: true
}, },
show: false show: false
}); });
@@ -99,16 +90,15 @@ function createMainWindow(): Electron.BrowserWindow {
win.webContents.send('focus'); win.webContents.send('focus');
}); });
(async () => win.loadURL(mainURL))(); win.loadURL(mainURL);
// Keep the app running in background on close event // Keep the app running in background on close event
win.on('close', event => { win.on('close', e => {
if (ConfigUtil.getConfigItem('quitOnClose')) { if (ConfigUtil.getConfigItem("quitOnClose")) {
app.quit(); app.quit();
} }
if (!isQuitting) { if (!isQuitting) {
event.preventDefault(); e.preventDefault();
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
app.hide(); app.hide();
@@ -129,8 +119,8 @@ function createMainWindow(): Electron.BrowserWindow {
}); });
// To destroy tray icon when navigate to a new URL // To destroy tray icon when navigate to a new URL
win.webContents.on('will-navigate', event => { win.webContents.on('will-navigate', e => {
if (event) { if (e) {
win.webContents.send('destroytray'); win.webContents.send('destroytray');
} }
}); });
@@ -150,23 +140,19 @@ app.disableHardwareAcceleration();
// More info here - https://github.com/electron/electron/issues/10732 // More info here - https://github.com/electron/electron/issues/10732
app.commandLine.appendSwitch('force-color-profile', 'srgb'); app.commandLine.appendSwitch('force-color-profile', 'srgb');
// This event is only available on macOS. Triggers when you click on the dock icon. // eslint-disable-next-line max-params
app.on('certificate-error', (event: Event, _webContents: Electron.WebContents, _url: string, _error: string, _certificate: any, callback) => {
event.preventDefault();
callback(true);
});
app.on('activate', () => { app.on('activate', () => {
if (mainWindow) { if (!mainWindow) {
// If there is already a window show it
mainWindow.show();
} else {
mainWindow = createMainWindow(); mainWindow = createMainWindow();
} }
}); });
app.on('ready', () => { app.on('ready', () => {
const ses = session.fromPartition('persist:webviewsession');
ses.setUserAgent(`ZulipElectron/${app.getVersion()} ${ses.getUserAgent()}`);
ipcMain.on('set-spellcheck-langs', () => {
ses.setSpellCheckerLanguages(ConfigUtil.getConfigItem('spellcheckerLanguages'));
});
AppMenu.setMenu({ AppMenu.setMenu({
tabs: [] tabs: []
}); });
@@ -175,7 +161,7 @@ app.on('ready', () => {
// Auto-hide menu bar on Windows + Linux // Auto-hide menu bar on Windows + Linux
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
const shouldHideMenu = ConfigUtil.getConfigItem('autoHideMenubar') || false; const shouldHideMenu = ConfigUtil.getConfigItem('autoHideMenubar') || false;
mainWindow.autoHideMenuBar = shouldHideMenu; mainWindow.setAutoHideMenuBar(shouldHideMenu);
mainWindow.setMenuBarVisibility(!shouldHideMenu); mainWindow.setMenuBarVisibility(!shouldHideMenu);
} }
@@ -188,7 +174,7 @@ app.on('ready', () => {
const isSystemProxy = ConfigUtil.getConfigItem('useSystemProxy'); const isSystemProxy = ConfigUtil.getConfigItem('useSystemProxy');
if (isSystemProxy) { if (isSystemProxy) {
(async () => ProxyUtil.resolveSystemProxy(mainWindow))(); ProxyUtil.resolveSystemProxy(mainWindow);
} }
const page = mainWindow.webContents; const page = mainWindow.webContents;
@@ -199,49 +185,17 @@ app.on('ready', () => {
} else { } else {
mainWindow.show(); mainWindow.show();
} }
}); if (!ConfigUtil.isConfigItemExists('userAgent')) {
const userAgent = session.fromPartition('webview:persistsession').getUserAgent();
ipcMain.on('fetch-user-agent', event => { ConfigUtil.setConfigItem('userAgent', userAgent);
event.returnValue = session.fromPartition('persist:webviewsession').getUserAgent();
});
ipcMain.handle('get-server-settings', async (event, domain: string) => _getServerSettings(domain, ses));
ipcMain.handle('save-server-icon', async (event, url: string) => _saveServerIcon(url, ses));
ipcMain.handle('is-online', async (event, url: string) => _isOnline(url, ses));
page.once('did-frame-finish-load', async () => {
// Initiate auto-updates on MacOS and Windows
if (ConfigUtil.getConfigItem('autoUpdate')) {
await appUpdater();
} }
}); });
app.on('certificate-error', ( page.once('did-frame-finish-load', () => {
event: Event, // Initiate auto-updates on MacOS and Windows
webContents: Electron.WebContents, if (ConfigUtil.getConfigItem('autoUpdate')) {
urlString: string, appUpdater();
error: string }
) => {
const url = new URL(urlString);
dialog.showErrorBox(
'Certificate error',
`The server presented an invalid certificate for ${url.origin}:
${error}`
);
});
page.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
const {origin} = new URL(details.requestingUrl);
page.send('permission-request', {
webContentsId: webContents.id === mainWindow.webContents.id ?
null :
webContents.id,
origin,
permission
}, makeRendererCallback(callback));
}); });
// Temporarily remove this event // Temporarily remove this event
@@ -258,6 +212,32 @@ ${error}`
app.quit(); app.quit();
}); });
// Code to show pdf in a new BrowserWindow (currently commented out due to bug-upstream)
// ipcMain.on('pdf-view', (event, url) => {
// // Paddings for pdfWindow so that it fits into the main browserWindow
// const paddingWidth = 55;
// const paddingHeight = 22;
// // Get the config of main browserWindow
// const mainWindowState = global.mainWindowState;
// // Window to view the pdf file
// const pdfWindow = new electron.BrowserWindow({
// x: mainWindowState.x + paddingWidth,
// y: mainWindowState.y + paddingHeight,
// width: mainWindowState.width - paddingWidth,
// height: mainWindowState.height - paddingHeight,
// webPreferences: {
// plugins: true,
// partition: 'persist:webviewsession'
// }
// });
// pdfWindow.loadURL(url);
// // We don't want to have the menu bar in pdf window
// pdfWindow.setMenu(null);
// });
// Reload full app not just webview, useful in debugging // Reload full app not just webview, useful in debugging
ipcMain.on('reload-full-app', () => { ipcMain.on('reload-full-app', () => {
mainWindow.reload(); mainWindow.reload();
@@ -265,80 +245,80 @@ ${error}`
}); });
ipcMain.on('clear-app-settings', () => { ipcMain.on('clear-app-settings', () => {
mainWindowState.unmanage(); globalPatched.mainWindowState.unmanage();
app.relaunch(); app.relaunch();
app.exit(); app.exit();
}); });
ipcMain.on('toggle-app', () => { ipcMain.on('toggle-app', () => {
toggleApp(); if (!mainWindow.isVisible() || mainWindow.isMinimized()) {
mainWindow.show();
} else {
mainWindow.hide();
}
}); });
ipcMain.on('toggle-badge-option', () => { ipcMain.on('toggle-badge-option', () => {
BadgeSettings.updateBadge(badgeCount, mainWindow); BadgeSettings.updateBadge(badgeCount, mainWindow);
}); });
ipcMain.on('toggle-menubar', (_event: Electron.IpcMainEvent, showMenubar: boolean) => { ipcMain.on('toggle-menubar', (_event: Electron.IpcMessageEvent, showMenubar: boolean) => {
mainWindow.autoHideMenuBar = showMenubar; mainWindow.setAutoHideMenuBar(showMenubar);
mainWindow.setMenuBarVisibility(!showMenubar); mainWindow.setMenuBarVisibility(!showMenubar);
page.send('toggle-autohide-menubar', showMenubar, true); page.send('toggle-autohide-menubar', showMenubar, true);
}); });
ipcMain.on('update-badge', (_event: Electron.IpcMainEvent, messageCount: number) => { ipcMain.on('update-badge', (_event: Electron.IpcMessageEvent, messageCount: number) => {
badgeCount = messageCount; badgeCount = messageCount;
BadgeSettings.updateBadge(badgeCount, mainWindow); BadgeSettings.updateBadge(badgeCount, mainWindow);
page.send('tray', messageCount); page.send('tray', messageCount);
}); });
ipcMain.on('update-taskbar-icon', (_event: Electron.IpcMainEvent, data: string, text: string) => { ipcMain.on('update-taskbar-icon', (_event: Electron.IpcMessageEvent, data: any, text: string) => {
BadgeSettings.updateTaskbarIcon(data, text, mainWindow); BadgeSettings.updateTaskbarIcon(data, text, mainWindow);
}); });
ipcMain.on('forward-message', (_event: Electron.IpcMainEvent, listener: string, ...parameters: unknown[]) => { ipcMain.on('forward-message', (_event: Electron.IpcMessageEvent, listener: any, ...params: any[]) => {
page.send(listener, ...parameters); page.send(listener, ...params);
}); });
ipcMain.on('update-menu', (_event: Electron.IpcMainEvent, props: AppMenu.MenuProps) => { ipcMain.on('update-menu', (_event: Electron.IpcMessageEvent, props: any) => {
AppMenu.setMenu(props); AppMenu.setMenu(props);
const activeTab = props.tabs[props.activeTabIndex]; const activeTab = props.tabs[props.activeTabIndex];
if (activeTab) { if (activeTab) {
mainWindow.setTitle(`Zulip - ${activeTab.webviewName}`); mainWindow.setTitle(`Zulip - ${activeTab.webview.props.name}`);
} }
}); });
ipcMain.on('toggleAutoLauncher', async (_event: Electron.IpcMainEvent, AutoLaunchValue: boolean) => { ipcMain.on('toggleAutoLauncher', (_event: Electron.IpcMessageEvent, AutoLaunchValue: boolean) => {
await setAutoLaunch(AutoLaunchValue); setAutoLaunch(AutoLaunchValue);
}); });
ipcMain.on('downloadFile', (_event: Electron.IpcMainEvent, url: string, downloadPath: string) => { ipcMain.on('downloadFile', (_event: Electron.IpcMessageEvent, url: string, downloadPath: string) => {
page.downloadURL(url); page.downloadURL(url);
page.session.once('will-download', async (_event: Event, item) => { page.session.once('will-download', (_event: Event, item) => {
if (ConfigUtil.getConfigItem('promptDownload', false)) { const filePath = path.join(downloadPath, item.getFilename());
const showDialogOptions: electron.SaveDialogOptions = {
defaultPath: path.join(downloadPath, item.getFilename())
};
item.setSaveDialogOptions(showDialogOptions);
} else {
const getTimeStamp = (): number => {
const date = new Date();
return date.getTime();
};
const formatFile = (filePath: string): string => { const getTimeStamp = (): any => {
const fileExtension = path.extname(filePath); const date = new Date();
const baseName = path.basename(filePath, fileExtension); return date.getTime();
return `${baseName}-${getTimeStamp()}${fileExtension}`; };
};
const filePath = path.join(downloadPath, item.getFilename()); const formatFile = (filePath: string): string => {
const fileExtension = path.extname(filePath);
const baseName = path.basename(filePath, fileExtension);
return `${baseName}-${getTimeStamp()}${fileExtension}`;
};
// Update the name and path of the file if it already exists // Update the name and path of the file if it already exists
const updatedFilePath = path.join(downloadPath, formatFile(filePath));
const setFilePath: string = fs.existsSync(filePath) ? updatedFilePath : filePath;
item.setSavePath(setFilePath);
}
const updatedListener = (_event: Event, state: string): void => { const updatedFilePath = path.join(downloadPath, formatFile(filePath));
const setFilePath = fs.existsSync(filePath) ? updatedFilePath : filePath;
item.setSavePath(setFilePath);
item.on('updated', (_event: Event, state) => {
switch (state) { switch (state) {
case 'interrupted': { case 'interrupted': {
// Can interrupted to due to network error, cancel download then // Can interrupted to due to network error, cancel download then
@@ -346,52 +326,47 @@ ${error}`
item.cancel(); item.cancel();
break; break;
} }
case 'progressing': { case 'progressing': {
if (item.isPaused()) { if (item.isPaused()) {
item.cancel(); item.cancel();
} }
// This event can also be used to show progress in percentage in future. // This event can also be used to show progress in percentage in future.
break; break;
} }
default: { default: {
console.info('Unknown updated state of download item'); console.info('Unknown updated state of download item');
} }
} }
}; });
item.on('updated', updatedListener);
item.once('done', (_event: Event, state) => { item.once('done', (_event: Event, state) => {
const getFileName = fs.existsSync(filePath) ? formatFile(filePath) : item.getFilename();
if (state === 'completed') { if (state === 'completed') {
page.send('downloadFileCompleted', item.getSavePath(), path.basename(item.getSavePath())); page.send('downloadFileCompleted', item.getSavePath(), getFileName);
} else { } else {
console.log('Download failed state:', state); console.log('Download failed state: ', state);
page.send('downloadFileFailed', state); page.send('downloadFileFailed');
} }
// To stop item for listening to updated events of this file // To stop item for listening to updated events of this file
item.removeListener('updated', updatedListener); item.removeAllListeners('updated');
}); });
}); });
}); });
ipcMain.on('realm-name-changed', (_event: Electron.IpcMainEvent, serverURL: string, realmName: string) => { ipcMain.on('realm-name-changed', (_event: Electron.IpcMessageEvent, serverURL: string, realmName: string) => {
page.send('update-realm-name', serverURL, realmName); page.send('update-realm-name', serverURL, realmName);
}); });
ipcMain.on('realm-icon-changed', (_event: Electron.IpcMainEvent, serverURL: string, iconURL: string) => { ipcMain.on('realm-icon-changed', (_event: Electron.IpcMessageEvent, serverURL: string, iconURL: string) => {
page.send('update-realm-icon', serverURL, iconURL); page.send('update-realm-icon', serverURL, iconURL);
}); });
// Using event.sender.send instead of page.send here to // Using event.sender.send instead of page.send here to
// make sure the value of errorReporting is sent only once on load. // make sure the value of errorReporting is sent only once on load.
ipcMain.on('error-reporting', (event: Electron.IpcMainEvent) => { ipcMain.on('error-reporting', (event: Electron.IpcMessageEvent) => {
event.sender.send('error-reporting-val', errorReporting); event.sender.send('error-reporting-val', errorReporting);
}); });
ipcMain.on('save-last-tab', (_event: Electron.IpcMainEvent, index: number) => { ipcMain.on('save-last-tab', (_event: Electron.IpcMessageEvent, index: number) => {
ConfigUtil.setConfigItem('lastActiveTab', index); ConfigUtil.setConfigItem('lastActiveTab', index);
}); });
@@ -400,12 +375,19 @@ ${error}`
setInterval(() => { setInterval(() => {
// Set user idle if no activity in 1 second (idleThresholdSeconds) // Set user idle if no activity in 1 second (idleThresholdSeconds)
const idleThresholdSeconds = 1; // 1 second const idleThresholdSeconds = 1; // 1 second
const idleState = electron.powerMonitor.getSystemIdleState(idleThresholdSeconds);
if (idleState === 'active') { // TODO: Remove typecast to any when types get added
page.send('set-active'); // TODO: use powerMonitor.getSystemIdleState when upgrading electron
} else { // powerMonitor.querySystemIdleState is deprecated in current electron
page.send('set-idle'); // version at the time of writing.
} const powerMonitor = electron.powerMonitor as any;
powerMonitor.querySystemIdleState(idleThresholdSeconds, (idleState: string) => {
if (idleState === 'active') {
page.send('set-active');
} else {
page.send('set-idle');
}
});
}, idleCheckInterval); }, idleCheckInterval);
}); });

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,112 +0,0 @@
import {ClientRequest, IncomingMessage, app, net} from 'electron';
import fs from 'fs';
import path from 'path';
import stream from 'stream';
import util from 'util';
import getStream from 'get-stream';
import {ServerConf} from '../renderer/js/utils/domain-util';
import Logger from '../renderer/js/utils/logger-util';
import * as Messages from '../resources/messages';
export async function fetchResponse(request: ClientRequest): Promise<IncomingMessage> {
return new Promise((resolve, reject) => {
request.on('response', resolve);
request.on('abort', () => reject(new Error('Request aborted')));
request.on('error', reject);
request.end();
});
}
const pipeline = util.promisify(stream.pipeline);
/* Request: domain-util */
const defaultIconUrl = '../renderer/img/icon.png';
const logger = new Logger({
file: 'domain-util.log',
timestamp: true
});
const generateFilePath = (url: string): string => {
const dir = `${app.getPath('userData')}/server-icons`;
const extension = path.extname(url).split('?')[0];
let hash = 5381;
let {length} = url;
while (length) {
hash = (hash * 33) ^ url.charCodeAt(--length);
}
// Create 'server-icons' directory if not existed
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
return `${dir}/${hash >>> 0}${extension}`;
};
export const _getServerSettings = async (domain: string, session: Electron.session): Promise<ServerConf> => {
const response = await fetchResponse(net.request({
url: domain + '/api/v1/server_settings',
session
}));
if (response.statusCode !== 200) {
throw new Error(Messages.invalidZulipServerError(domain));
}
const {realm_name, realm_uri, realm_icon} = JSON.parse(await getStream(response));
if (
typeof realm_name !== 'string' ||
typeof realm_uri !== 'string' ||
typeof realm_icon !== 'string'
) {
throw new TypeError(Messages.noOrgsError(domain));
}
return {
// Some Zulip Servers use absolute URL for server icon whereas others use relative URL
// Following check handles both the cases
icon: realm_icon.startsWith('/') ? realm_uri + realm_icon : realm_icon,
url: realm_uri,
alias: realm_name
};
};
export const _saveServerIcon = async (url: string, session: Electron.session): Promise<string> => {
try {
const response = await fetchResponse(net.request({url, session}));
if (response.statusCode !== 200) {
logger.log('Could not get server icon.');
return defaultIconUrl;
}
const filePath = generateFilePath(url);
await pipeline(response, fs.createWriteStream(filePath));
return filePath;
} catch (error: unknown) {
logger.log('Could not get server icon.');
logger.log(error);
logger.reportSentry(error);
return defaultIconUrl;
}
};
/* Request: reconnect-util */
export const _isOnline = async (url: string, session: Electron.session): Promise<boolean> => {
try {
const response = await fetchResponse(net.request({
url: `${url}/static/favicon.ico`,
session
}));
const isValidResponse = response.statusCode >= 200 && response.statusCode < 400;
return isValidResponse;
} catch (error: unknown) {
logger.log(error);
return false;
}
};

View File

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

1940
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
app/package.json Normal file
View File

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

View File

@@ -14,19 +14,34 @@
<div class="maintenance-info"> <div class="maintenance-info">
<p class="detail maintainer"> <p class="detail maintainer">
Maintained by Maintained by
<a href="https://zulip.com" target="_blank" rel="noopener noreferrer">Zulip</a> <a onclick="linkInBrowser('website')">Zulip</a>
</p> </p>
<p class="detail license"> <p class="detail license">
Available under the Available under the
<a href="https://github.com/zulip/zulip-desktop/blob/master/LICENSE" target="_blank" rel="noopener noreferrer">Apache 2.0 License</a> <a onclick="linkInBrowser('license')">Apache 2.0 License</a>
</p> </p>
</div> </div>
</div> </div>
<script> <script>
const { app } = require('electron').remote; const { app } = require('electron').remote;
const { shell } = require('electron');
const version_tag = document.querySelector('#version'); const version_tag = document.querySelector('#version');
version_tag.textContent = 'v' + app.getVersion(); version_tag.innerHTML = 'v' + app.getVersion();
function linkInBrowser(type) {
let url;
switch (type) {
case 'website':
url = "https://zulipchat.com";
break;
case 'license':
url = "https://github.com/zulip/zulip-desktop/blob/master/LICENSE";
break;
}
shell.openExternal(url);
}
</script> </script>
<script>require('./js/shared/preventdrag.js')</script>
</body> </body>
</html> </html>

View File

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

View File

@@ -389,10 +389,6 @@ img.server-info-icon {
margin: 6px; margin: 6px;
} }
#note {
font-size: 10px;
}
.code { .code {
font-family: Courier New, Courier, monospace; font-family: Courier New, Courier, monospace;
} }
@@ -622,6 +618,10 @@ input.toggle-round:checked + label::after {
max-width: 100%; max-width: 100%;
} }
#add-certificate-button {
margin: 10px 10px 10px 37px;
}
.tip { .tip {
background: none; background: none;
padding: 0; padding: 0;
@@ -663,26 +663,6 @@ i.open-network-button {
} }
/* responsive grid */ /* responsive grid */
@media (min-width: 500px) and (max-width: 720px) {
#new-server-container {
padding-left: 0px;
width: 60vw;
padding-right: 4vh;
}
.page-title {
width: 60vw;
}
}
@media (max-width: 500px) {
#new-server-container {
padding-left: 0px;
width: 54%;
}
.page-title {
width: 54%;
}
}
@media (max-width: 650px) { @media (max-width: 650px) {
.selected-css-path, .selected-css-path,
@@ -715,6 +695,9 @@ i.open-network-button {
margin-right: 6px; margin-right: 6px;
width: 43%; width: 43%;
} }
#new-server-container {
padding-left: 0px;
}
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@@ -741,13 +724,3 @@ i.open-network-button {
margin-top: 10px; margin-top: 10px;
} }
} }
.lang-menu {
font-size: 13px;
font-weight: bold;
background: rgba(78, 191, 172, 1.000);
width: 100px;
height: 38px;
color: rgba(255, 255, 255, 1.000);
border-color: rgba(0, 0, 0, 0);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

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

View File

@@ -1,7 +1,11 @@
export default class BaseComponent { 'use strict';
generateNodeFromHTML(html: string): Element | null {
class BaseComponent {
generateNodeFromTemplate(template: string): Element | null {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.innerHTML = html; wrapper.innerHTML = template;
return wrapper.firstElementChild; return wrapper.firstElementChild;
} }
} }
export = BaseComponent;

View File

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

View File

@@ -1,30 +1,28 @@
import {htmlEscape} from 'escape-goat'; 'use strict';
import Tab, {TabProps} from './tab'; import Tab = require('./tab');
export default class FunctionalTab extends Tab { class FunctionalTab extends Tab {
$closeButton: Element; $closeButton: Element;
template(): string {
return `<div class="tab functional-tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tab-badge close-button">
<i class="material-icons">close</i>
</div>
<div class="server-tab">
<i class="material-icons">${this.props.materialIcon}</i>
</div>
</div>`;
}
constructor(props: TabProps) { // TODO: Typescript - This type for props should be TabProps
constructor(props: any) {
super(props); super(props);
this.init(); this.init();
} }
templateHTML(): string {
return htmlEscape`
<div class="tab functional-tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tab-badge close-button">
<i class="material-icons">close</i>
</div>
<div class="server-tab">
<i class="material-icons">${this.props.materialIcon}</i>
</div>
</div>
`;
}
init(): void { init(): void {
this.$el = this.generateNodeFromHTML(this.templateHTML()); this.$el = this.generateNodeFromTemplate(this.template());
if (this.props.name !== 'Settings') { if (this.props.name !== 'Settings') {
this.props.$root.append(this.$el); this.props.$root.append(this.$el);
this.$closeButton = this.$el.querySelectorAll('.server-tab-badge')[0]; this.$closeButton = this.$el.querySelectorAll('.server-tab-badge')[0];
@@ -43,9 +41,11 @@ export default class FunctionalTab extends Tab {
this.$closeButton.classList.remove('active'); this.$closeButton.classList.remove('active');
}); });
this.$closeButton.addEventListener('click', (event: Event) => { this.$closeButton.addEventListener('click', (e: Event) => {
this.props.onDestroy(); this.props.onDestroy();
event.stopPropagation(); e.stopPropagation();
}); });
} }
} }
export = FunctionalTab;

View File

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

View File

@@ -1,34 +1,32 @@
import {ipcRenderer} from 'electron'; 'use strict';
import {htmlEscape} from 'escape-goat'; import { ipcRenderer } from 'electron';
import * as SystemUtil from '../utils/system-util'; import Tab = require('./tab');
import SystemUtil = require('../utils/system-util');
import Tab, {TabProps} from './tab'; class ServerTab extends Tab {
export default class ServerTab extends Tab {
$badge: Element; $badge: Element;
constructor(props: TabProps) { template(): string {
return `<div class="tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tooltip" style="display:none">${this.props.name}</div>
<div class="server-tab-badge"></div>
<div class="server-tab">
<img class="server-icons" src='${this.props.icon}'/>
</div>
<div class="server-tab-shortcut">${this.generateShortcutText()}</div>
</div>`;
}
// TODO: Typescript - This type for props should be TabProps
constructor(props: any) {
super(props); super(props);
this.init(); this.init();
} }
templateHTML(): string {
return htmlEscape`
<div class="tab" data-tab-id="${this.props.tabIndex}">
<div class="server-tooltip" style="display:none">${this.props.name}</div>
<div class="server-tab-badge"></div>
<div class="server-tab">
<img class="server-icons" src="${this.props.icon}"/>
</div>
<div class="server-tab-shortcut">${this.generateShortcutText()}</div>
</div>
`;
}
init(): void { init(): void {
this.$el = this.generateNodeFromHTML(this.templateHTML()); this.$el = this.generateNodeFromTemplate(this.template());
this.props.$root.append(this.$el); this.props.$root.append(this.$el);
this.registerListeners(); this.registerListeners();
this.$badge = this.$el.querySelectorAll('.server-tab-badge')[0]; this.$badge = this.$el.querySelectorAll('.server-tab-badge')[0];
@@ -37,7 +35,7 @@ export default class ServerTab extends Tab {
updateBadge(count: number): void { updateBadge(count: number): void {
if (count > 0) { if (count > 0) {
const formattedCount = count > 999 ? '1K+' : count.toString(); const formattedCount = count > 999 ? '1K+' : count.toString();
this.$badge.textContent = formattedCount; this.$badge.innerHTML = formattedCount;
this.$badge.classList.add('active'); this.$badge.classList.add('active');
} else { } else {
this.$badge.classList.remove('active'); this.$badge.classList.remove('active');
@@ -54,7 +52,11 @@ export default class ServerTab extends Tab {
let shortcutText = ''; let shortcutText = '';
shortcutText = SystemUtil.getOS() === 'Mac' ? `${shownIndex}` : `Ctrl+${shownIndex}`; if (SystemUtil.getOS() === 'Mac') {
shortcutText = `${shownIndex}`;
} else {
shortcutText = `Ctrl+${shownIndex}`;
}
// Array index == Shown index - 1 // Array index == Shown index - 1
ipcRenderer.send('switch-server-tab', shownIndex - 1); ipcRenderer.send('switch-server-tab', shownIndex - 1);
@@ -62,3 +64,5 @@ export default class ServerTab extends Tab {
return shortcutText; return shortcutText;
} }
} }
export = ServerTab;

View File

@@ -1,22 +1,14 @@
import BaseComponent from './base'; 'use strict';
import WebView from './webview';
export interface TabProps { import WebView = require('./webview');
role: string; import BaseComponent = require('./base');
icon?: string;
name: string; // TODO: TypeScript - Type annotate props
$root: Element; interface TabProps {
onClick: () => void; [key: string]: any;
index: number;
tabIndex: number;
onHover?: () => void;
onHoverOut?: () => void;
webview: WebView;
materialIcon?: string;
onDestroy?: () => void;
} }
export default class Tab extends BaseComponent { class Tab extends BaseComponent {
props: TabProps; props: TabProps;
webview: WebView; webview: WebView;
$el: Element; $el: Element;
@@ -48,7 +40,9 @@ export default class Tab extends BaseComponent {
} }
destroy(): void { destroy(): void {
this.$el.remove(); this.$el.parentNode.removeChild(this.$el);
this.webview.$el.remove(); this.webview.$el.parentNode.removeChild(this.webview.$el);
} }
} }
export = Tab;

View File

@@ -1,80 +1,61 @@
import {ipcRenderer, remote} from 'electron'; 'use strict';
import fs from 'fs'; import { remote } from 'electron';
import path from 'path';
import {htmlEscape} from 'escape-goat'; import path = require('path');
import fs = require('fs');
import ConfigUtil = require('../utils/config-util');
import SystemUtil = require('../utils/system-util');
import BaseComponent = require('../components/base');
import handleExternalLink = require('../components/handle-external-link');
import * as ConfigUtil from '../utils/config-util'; const { app, dialog } = remote;
import * as SystemUtil from '../utils/system-util';
import BaseComponent from './base';
import {contextMenu} from './context-menu';
import handleExternalLink from './handle-external-link';
const {app, dialog} = remote;
const shouldSilentWebview = ConfigUtil.getConfigItem('silent'); const shouldSilentWebview = ConfigUtil.getConfigItem('silent');
// TODO: TypeScript - Type annotate WebViewProps.
interface WebViewProps { interface WebViewProps {
$root: Element; [key: string]: any;
index: number;
tabIndex: number;
url: string;
role: string;
name: string;
isActive: () => boolean;
switchLoading: (loading: boolean, url: string) => void;
onNetworkError: (index: number) => void;
nodeIntegration: boolean;
preload: boolean;
onTitleChange: () => void;
hasPermission?: (origin: string, permission: string) => boolean;
} }
export default class WebView extends BaseComponent { class WebView extends BaseComponent {
props: WebViewProps; props: any;
zoomFactor: number; zoomFactor: number;
badgeCount: number; badgeCount: number;
loading: boolean; loading: boolean;
customCSS: string; customCSS: string;
$webviewsContainer: DOMTokenList; $webviewsContainer: DOMTokenList;
$el: Electron.WebviewTag; $el: Electron.WebviewTag;
domReady?: Promise<void>;
// This is required because in main.js we access WebView.method as
// webview[method].
[key: string]: any;
constructor(props: WebViewProps) { constructor(props: WebViewProps) {
super(); super();
this.props = props; this.props = props;
this.zoomFactor = 1; this.zoomFactor = 1.0;
this.loading = true; this.loading = true;
this.badgeCount = 0; this.badgeCount = 0;
this.customCSS = ConfigUtil.getConfigItem('customCSS'); this.customCSS = ConfigUtil.getConfigItem('customCSS');
this.$webviewsContainer = document.querySelector('#webviews-container').classList; this.$webviewsContainer = document.querySelector('#webviews-container').classList;
} }
templateHTML(): string { template(): string {
return htmlEscape` return `<webview
<webview class="disabled"
class="disabled" data-tab-id="${this.props.tabIndex}"
data-tab-id="${this.props.tabIndex}" src="${this.props.url}"
src="${this.props.url}" ${this.props.nodeIntegration ? 'nodeIntegration' : ''}
` + (this.props.nodeIntegration ? 'nodeIntegration' : '') + htmlEscape` ${this.props.preload ? 'preload="js/preload.js"' : ''}
` + (this.props.preload ? 'preload="js/preload.js"' : '') + htmlEscape` partition="persist:webviewsession"
partition="persist:webviewsession" name="${this.props.name}"
name="${this.props.name}" webpreferences="allowRunningInsecureContent, javascript=yes">
webpreferences=" </webview>`;
contextIsolation=${!this.props.nodeIntegration},
spellcheck=${Boolean(ConfigUtil.getConfigItem('enableSpellchecker'))}
">
</webview>
`;
} }
init(): void { init(): void {
this.$el = this.generateNodeFromHTML(this.templateHTML()) as Electron.WebviewTag; this.$el = this.generateNodeFromTemplate(this.template()) as Electron.WebviewTag;
this.domReady = new Promise(resolve => {
this.$el.addEventListener('dom-ready', () => resolve(), true);
});
this.props.$root.append(this.$el); this.props.$root.append(this.$el);
this.registerListeners(); this.registerListeners();
@@ -92,7 +73,7 @@ export default class WebView extends BaseComponent {
} }
this.$el.addEventListener('page-title-updated', event => { this.$el.addEventListener('page-title-updated', event => {
const {title} = event; const { title } = event;
this.badgeCount = this.getBadgeCount(title); this.badgeCount = this.getBadgeCount(title);
this.props.onTitleChange(); this.props.onTitleChange();
}); });
@@ -102,7 +83,6 @@ export default class WebView extends BaseComponent {
if (isSettingPage) { if (isSettingPage) {
return; return;
} }
this.canGoBackButton(); this.canGoBackButton();
}); });
@@ -111,14 +91,14 @@ export default class WebView extends BaseComponent {
}); });
this.$el.addEventListener('page-favicon-updated', event => { this.$el.addEventListener('page-favicon-updated', event => {
const {favicons} = event; const { favicons } = event;
// This returns a string of favicons URL. If there is a PM counts in unread messages then the URL would be like // This returns a string of favicons URL. If there is a PM counts in unread messages then the URL would be like
// https://chat.zulip.org/static/images/favicon/favicon-pms.png // https://chat.zulip.org/static/images/favicon/favicon-pms.png
if (favicons[0].indexOf('favicon-pms') > 0 && process.platform === 'darwin') { if (favicons[0].indexOf('favicon-pms') > 0 && process.platform === 'darwin') {
// This api is only supported on macOS // This api is only supported on macOS
app.dock.setBadge('●'); app.dock.setBadge('●');
// Bounce the dock // bounce the dock
if (ConfigUtil.getConfigItem('dockBouncing')) { if (ConfigUtil.getConfigItem('dockBouncing')) {
app.dock.bounce(); app.dock.bounce();
} }
@@ -126,15 +106,9 @@ export default class WebView extends BaseComponent {
}); });
this.$el.addEventListener('dom-ready', () => { this.$el.addEventListener('dom-ready', () => {
const webContents = remote.webContents.fromId(this.$el.getWebContentsId());
webContents.addListener('context-menu', (event, menuParameters) => {
contextMenu(webContents, event, menuParameters);
});
if (this.props.role === 'server') { if (this.props.role === 'server') {
this.$el.classList.add('onload'); this.$el.classList.add('onload');
} }
this.loading = false; this.loading = false;
this.props.switchLoading(false, this.props.url); this.props.switchLoading(false, this.props.url);
this.show(); this.show();
@@ -146,7 +120,7 @@ export default class WebView extends BaseComponent {
}); });
this.$el.addEventListener('did-fail-load', event => { this.$el.addEventListener('did-fail-load', event => {
const {errorDescription} = event; const { errorDescription } = event;
const hasConnectivityErr = SystemUtil.connectivityERR.includes(errorDescription); const hasConnectivityErr = SystemUtil.connectivityERR.includes(errorDescription);
if (hasConnectivityErr) { if (hasConnectivityErr) {
console.error('error', errorDescription); console.error('error', errorDescription);
@@ -161,6 +135,12 @@ export default class WebView extends BaseComponent {
if (!isSettingPage) { if (!isSettingPage) {
this.props.switchLoading(true, this.props.url); this.props.switchLoading(true, this.props.url);
} }
let userAgent = SystemUtil.getUserAgent();
if (!userAgent) {
SystemUtil.setUserAgent(this.$el.getUserAgent());
userAgent = SystemUtil.getUserAgent();
}
this.$el.setUserAgent(userAgent);
}); });
this.$el.addEventListener('did-stop-loading', () => { this.$el.addEventListener('did-stop-loading', () => {
@@ -174,7 +154,7 @@ export default class WebView extends BaseComponent {
} }
showNotificationSettings(): void { showNotificationSettings(): void {
ipcRenderer.sendTo(this.$el.getWebContentsId(), 'show-notification-settings'); this.$el.executeJavaScript('showNotificationSettings()');
} }
show(): void { show(): void {
@@ -200,27 +180,27 @@ export default class WebView extends BaseComponent {
this.focus(); this.focus();
this.props.onTitleChange(); this.props.onTitleChange();
// Injecting preload css in webview to override some css rules // Injecting preload css in webview to override some css rules
(async () => this.$el.insertCSS(fs.readFileSync(path.join(__dirname, '/../../css/preload.css'), 'utf8')))(); this.$el.insertCSS(fs.readFileSync(path.join(__dirname, '/../../css/preload.css'), 'utf8'));
// Get customCSS again from config util to avoid warning user again // get customCSS again from config util to avoid warning user again
this.customCSS = ConfigUtil.getConfigItem('customCSS'); this.customCSS = ConfigUtil.getConfigItem('customCSS');
if (this.customCSS) { if (this.customCSS) {
if (!fs.existsSync(this.customCSS)) { if (!fs.existsSync(this.customCSS)) {
this.customCSS = null; this.customCSS = null;
ConfigUtil.setConfigItem('customCSS', null); ConfigUtil.setConfigItem('customCSS', null);
const errorMessage = 'The custom css previously set is deleted!'; const errMsg = 'The custom css previously set is deleted!';
dialog.showErrorBox('custom css file deleted!', errorMessage); dialog.showErrorBox('custom css file deleted!', errMsg);
return; return;
} }
(async () => this.$el.insertCSS(fs.readFileSync(path.resolve(__dirname, this.customCSS), 'utf8')))(); this.$el.insertCSS(fs.readFileSync(path.resolve(__dirname, this.customCSS), 'utf8'));
} }
} }
focus(): void { focus(): void {
// Focus Webview and it's contents when Window regain focus. // focus Webview and it's contents when Window regain focus.
const webContents = remote.webContents.fromId(this.$el.getWebContentsId()); const webContents = this.$el.getWebContents();
// HACK: webContents.isFocused() seems to be true even without the element // HACK: webContents.isFocused() seems to be true even without the element
// being in focus. So, we check against `document.activeElement`. // being in focus. So, we check against `document.activeElement`.
if (webContents && this.$el !== document.activeElement) { if (webContents && this.$el !== document.activeElement) {
@@ -257,16 +237,16 @@ export default class WebView extends BaseComponent {
} }
zoomActualSize(): void { zoomActualSize(): void {
this.zoomFactor = 1; this.zoomFactor = 1.0;
this.$el.setZoomFactor(this.zoomFactor); this.$el.setZoomFactor(this.zoomFactor);
} }
logOut(): void { logOut(): void {
ipcRenderer.sendTo(this.$el.getWebContentsId(), 'logout'); this.$el.executeJavaScript('logout()');
} }
showKeyboardShortcuts(): void { showShortcut(): void {
ipcRenderer.sendTo(this.$el.getWebContentsId(), 'show-keyboard-shortcuts'); this.$el.executeJavaScript('shortcut()');
} }
openDevTools(): void { openDevTools(): void {
@@ -308,8 +288,9 @@ export default class WebView extends BaseComponent {
this.init(); this.init();
} }
async send(channel: string, ...parameters: unknown[]): Promise<void> { send(channel: string, ...param: any[]): void {
await this.domReady; this.$el.send(channel, ...param);
await this.$el.send(channel, ...parameters);
} }
} }
export = WebView;

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,17 @@
import {ipcRenderer} from 'electron'; 'use strict';
import {htmlEscape} from 'escape-goat'; import { ipcRenderer } from 'electron';
import BaseComponent from '../../components/base'; import BaseComponent = require('../../components/base');
interface BaseSectionProps { class BaseSection extends BaseComponent {
$element: HTMLElement; // TODO: TypeScript - Here props should be object type
disabled?: boolean; generateSettingOption(props: any): void {
value: boolean;
clickHandler: () => void;
}
export default class BaseSection extends BaseComponent {
generateSettingOption(props: BaseSectionProps): void {
const {$element, disabled, value, clickHandler} = props; const {$element, disabled, value, clickHandler} = props;
$element.textContent = ''; $element.innerHTML = '';
const $optionControl = this.generateNodeFromHTML(this.generateOptionHTML(value, disabled)); const $optionControl = this.generateNodeFromTemplate(this.generateOptionTemplate(value, disabled));
$element.append($optionControl); $element.append($optionControl);
if (!disabled) { if (!disabled) {
@@ -25,42 +19,32 @@ export default class BaseSection extends BaseComponent {
} }
} }
generateOptionHTML(settingOption: boolean, disabled?: boolean): string { generateOptionTemplate(settingOption: boolean, disabled: boolean): string {
const labelHTML = disabled ? '<label class="disallowed" title="Setting locked by system administrator."></label>' : '<label></label>'; const label = disabled ? `<label class="disallowed" title="Setting locked by system administrator."/>` : `<label/>`;
if (settingOption) { if (settingOption) {
return htmlEscape` return `
<div class="action"> <div class="action">
<div class="switch"> <div class="switch">
<input class="toggle toggle-round" type="checkbox" checked disabled> <input class="toggle toggle-round" type="checkbox" checked disabled>
` + labelHTML + htmlEscape` ${label}
</div>
</div>
`;
} else {
return `
<div class="action">
<div class="switch">
<input class="toggle toggle-round" type="checkbox">
${label}
</div> </div>
</div> </div>
`; `;
} }
return htmlEscape`
<div class="action">
<div class="switch">
<input class="toggle toggle-round" type="checkbox">
` + labelHTML + htmlEscape`
</div>
</div>
`;
}
/* A method that in future can be used to create dropdown menus using <select> <option> tags.
it needs an object which has ``key: value`` pairs and will return a string that can be appended to HTML
*/
generateSelectHTML(options: Record<string, string>, className?: string, idName?: string): string {
let html = htmlEscape`<select class="${className}" id="${idName}">\n`;
Object.keys(options).forEach(key => {
html += htmlEscape`<option name="${key}" value="${key}">${options[key]}</option>\n`;
});
html += '</select>';
return html;
} }
reloadApp(): void { reloadApp(): void {
ipcRenderer.send('forward-message', 'reload-viewer'); ipcRenderer.send('forward-message', 'reload-viewer');
} }
} }
export = BaseSection;

View File

@@ -1,36 +1,36 @@
import {ipcRenderer} from 'electron'; 'use strict';
import {htmlEscape} from 'escape-goat'; import { ipcRenderer } from 'electron';
import * as DomainUtil from '../../utils/domain-util'; import BaseSection = require('./base-section');
import * as t from '../../utils/translation-util'; import DomainUtil = require('../../utils/domain-util');
import ServerInfoForm = require('./server-info-form');
import AddCertificate = require('./add-certificate');
import FindAccounts = require('./find-accounts');
import t = require('../../utils/translation-util');
import BaseSection from './base-section'; class ConnectedOrgSection extends BaseSection {
import FindAccounts from './find-accounts'; // TODO: TypeScript - Here props should be object type
import ServerInfoForm from './server-info-form'; props: any;
interface ConnectedOrgSectionProps {
$root: Element;
}
export default class ConnectedOrgSection extends BaseSection {
props: ConnectedOrgSectionProps;
$serverInfoContainer: Element | null; $serverInfoContainer: Element | null;
$existingServers: Element | null; $existingServers: Element | null;
$newOrgButton: HTMLButtonElement | null; $newOrgButton: HTMLButtonElement | null;
$addCertificateContainer: Element | null;
$findAccountsContainer: Element | null; $findAccountsContainer: Element | null;
constructor(props: ConnectedOrgSectionProps) { constructor(props: any) {
super(); super();
this.props = props; this.props = props;
} }
templateHTML(): string { template(): string {
return htmlEscape` return `
<div class="settings-pane" id="server-settings-pane"> <div class="settings-pane" id="server-settings-pane">
<div class="page-title">${t.__('Connected organizations')}</div> <div class="page-title">${t.__('Connected organizations')}</div>
<div class="title" id="existing-servers">${t.__('All the connected orgnizations will appear here.')}</div> <div class="title" id="existing-servers">${t.__('All the connected orgnizations will appear here.')}</div>
<div id="server-info-container"></div> <div id="server-info-container"></div>
<div id="new-org-button"><button class="green sea w-250">${t.__('Connect to another organization')}</button></div> <div id="new-org-button"><button class="green sea w-250">${t.__('Connect to another organization')}</button></div>
<div class="page-title">${t.__('Add Custom Certificates')}</div>
<div id="add-certificate-container"></div>
<div class="page-title">${t.__('Find accounts by email')}</div> <div class="page-title">${t.__('Find accounts by email')}</div>
<div id="find-accounts-container"></div> <div id="find-accounts-container"></div>
</div> </div>
@@ -42,24 +42,25 @@ export default class ConnectedOrgSection extends BaseSection {
} }
initServers(): void { initServers(): void {
this.props.$root.textContent = ''; this.props.$root.innerHTML = '';
const servers = DomainUtil.getDomains(); const servers = DomainUtil.getDomains();
this.props.$root.innerHTML = this.templateHTML(); this.props.$root.innerHTML = this.template();
this.$serverInfoContainer = document.querySelector('#server-info-container'); this.$serverInfoContainer = document.querySelector('#server-info-container');
this.$existingServers = document.querySelector('#existing-servers'); this.$existingServers = document.querySelector('#existing-servers');
this.$newOrgButton = document.querySelector('#new-org-button'); this.$newOrgButton = document.querySelector('#new-org-button');
this.$addCertificateContainer = document.querySelector('#add-certificate-container');
this.$findAccountsContainer = document.querySelector('#find-accounts-container'); this.$findAccountsContainer = document.querySelector('#find-accounts-container');
const noServerText = t.__('All the connected orgnizations will appear here'); const noServerText = t.__('All the connected orgnizations will appear here');
// Show noServerText if no servers are there otherwise hide it // Show noServerText if no servers are there otherwise hide it
this.$existingServers.textContent = servers.length === 0 ? noServerText : ''; this.$existingServers.innerHTML = servers.length === 0 ? noServerText : '';
for (const [i, server] of servers.entries()) { for (let i = 0; i < servers.length; i++) {
new ServerInfoForm({ new ServerInfoForm({
$root: this.$serverInfoContainer, $root: this.$serverInfoContainer,
server, server: servers[i],
index: i, index: i,
onChange: this.reloadApp onChange: this.reloadApp
}).init(); }).init();
@@ -69,12 +70,21 @@ export default class ConnectedOrgSection extends BaseSection {
ipcRenderer.send('forward-message', 'open-org-tab'); ipcRenderer.send('forward-message', 'open-org-tab');
}); });
this.initAddCertificate();
this.initFindAccounts(); this.initFindAccounts();
} }
initAddCertificate(): void {
new AddCertificate({
$root: this.$addCertificateContainer
}).init();
}
initFindAccounts(): void { initFindAccounts(): void {
new FindAccounts({ new FindAccounts({
$root: this.$findAccountsContainer $root: this.$findAccountsContainer
}).init(); }).init();
} }
} }
export = ConnectedOrgSection;

View File

@@ -1,25 +1,23 @@
import {htmlEscape} from 'escape-goat'; 'use-strict';
import BaseComponent from '../../components/base'; import { shell } from 'electron';
import * as LinkUtil from '../../utils/link-util';
import * as t from '../../utils/translation-util';
interface FindAccountsProps { import BaseComponent = require('../../components/base');
$root: Element; import t = require('../../utils/translation-util');
}
export default class FindAccounts extends BaseComponent { class FindAccounts extends BaseComponent {
props: FindAccountsProps; // TODO: TypeScript - Here props should be object type
props: any;
$findAccounts: Element | null; $findAccounts: Element | null;
$findAccountsButton: Element | null; $findAccountsButton: Element | null;
$serverUrlField: HTMLInputElement | null; $serverUrlField: HTMLInputElement | null;
constructor(props: FindAccountsProps) { constructor(props: any) {
super(); super();
this.props = props; this.props = props;
} }
templateHTML(): string { template(): string {
return htmlEscape` return `
<div class="settings-card certificate-card"> <div class="settings-card certificate-card">
<div class="certificate-input"> <div class="certificate-input">
<div>${t.__('Organization URL')}</div> <div>${t.__('Organization URL')}</div>
@@ -33,28 +31,26 @@ export default class FindAccounts extends BaseComponent {
} }
init(): void { init(): void {
this.$findAccounts = this.generateNodeFromHTML(this.templateHTML()); this.$findAccounts = this.generateNodeFromTemplate(this.template());
this.props.$root.append(this.$findAccounts); this.props.$root.append(this.$findAccounts);
this.$findAccountsButton = this.$findAccounts.querySelector('#find-accounts-button'); this.$findAccountsButton = this.$findAccounts.querySelector('#find-accounts-button');
this.$serverUrlField = this.$findAccounts.querySelectorAll('input.setting-input-value')[0] as HTMLInputElement; this.$serverUrlField = this.$findAccounts.querySelectorAll('input.setting-input-value')[0] as HTMLInputElement;
this.initListeners(); this.initListeners();
} }
async findAccounts(url: string): Promise<void> { findAccounts(url: string): void {
if (!url) { if (!url) {
return; return;
} }
if (!url.startsWith('http')) { if (!url.startsWith('http')) {
url = 'https://' + url; url = 'https://' + url;
} }
shell.openExternal(url + '/accounts/find');
await LinkUtil.openBrowser(new URL('/accounts/find', url));
} }
initListeners(): void { initListeners(): void {
this.$findAccountsButton.addEventListener('click', async () => { this.$findAccountsButton.addEventListener('click', () => {
await this.findAccounts(this.$serverUrlField.value); this.findAccounts(this.$serverUrlField.value);
}); });
this.$serverUrlField.addEventListener('click', () => { this.$serverUrlField.addEventListener('click', () => {
@@ -63,9 +59,9 @@ export default class FindAccounts extends BaseComponent {
} }
}); });
this.$serverUrlField.addEventListener('keypress', async event => { this.$serverUrlField.addEventListener('keypress', event => {
if (event.key === 'Enter') { if (event.keyCode === 13) {
await this.findAccounts(this.$serverUrlField.value); this.findAccounts(this.$serverUrlField.value);
} }
}); });
@@ -78,3 +74,5 @@ export default class FindAccounts extends BaseComponent {
}); });
} }
} }
export = FindAccounts;

View File

@@ -1,34 +1,27 @@
import {ipcRenderer, remote, OpenDialogOptions} from 'electron'; 'use strict';
import path from 'path'; import { ipcRenderer, remote, OpenDialogOptions } from 'electron';
import Tagify from '@yaireo/tagify'; import path = require('path');
import {htmlEscape} from 'escape-goat'; import fs = require('fs-extra');
import fs from 'fs-extra';
import ISO6391 from 'iso-639-1';
import supportedLocales from '../../../../translations/supported-locales.json'; const { app, dialog } = remote;
import * as ConfigUtil from '../../utils/config-util';
import * as EnterpriseUtil from '../../utils/enterprise-util';
import * as t from '../../utils/translation-util';
import BaseSection from './base-section';
const {app, dialog, session} = remote;
const currentBrowserWindow = remote.getCurrentWindow(); const currentBrowserWindow = remote.getCurrentWindow();
interface GeneralSectionProps { import BaseSection = require('./base-section');
$root: Element; import ConfigUtil = require('../../utils/config-util');
} import EnterpriseUtil = require('./../../utils/enterprise-util');
import t = require('../../utils/translation-util');
export default class GeneralSection extends BaseSection { class GeneralSection extends BaseSection {
props: GeneralSectionProps; // TODO: TypeScript - Here props should be object type
constructor(props: GeneralSectionProps) { props: any;
constructor(props: any) {
super(); super();
this.props = props; this.props = props;
} }
templateHTML(): string { template(): string {
return htmlEscape` return `
<div class="settings-pane"> <div class="settings-pane">
<div class="title">${t.__('Appearance')}</div> <div class="title">${t.__('Appearance')}</div>
<div id="appearance-option-settings" class="settings-card"> <div id="appearance-option-settings" class="settings-card">
@@ -97,38 +90,32 @@ export default class GeneralSection extends BaseSection {
<div class="setting-description">${t.__('Enable spellchecker (requires restart)')}</div> <div class="setting-description">${t.__('Enable spellchecker (requires restart)')}</div>
<div class="setting-control"></div> <div class="setting-control"></div>
</div> </div>
<div class="setting-row" id="spellcheck-langs" style= "display:${process.platform === 'darwin' ? 'none' : ''}"></div>
<div class="setting-row" id="note"></div>
</div> </div>
<div class="title">${t.__('Advanced')}</div> <div class="title">${t.__('Advanced')}</div>
<div class="settings-card"> <div class="settings-card">
<div class="setting-row" id="enable-error-reporting">
<div class="setting-row" id="enable-error-reporting"> <div class="setting-description">${t.__('Enable error reporting (requires restart)')}</div>
<div class="setting-description">${t.__('Enable error reporting (requires restart)')}</div> <div class="setting-control"></div>
<div class="setting-control"></div> </div>
</div> <div class="setting-row" id="show-download-folder">
<div class="setting-description">${t.__('Show downloaded files in file manager')}</div>
<div class="setting-row" id="app-language"> <div class="setting-control"></div>
<div class="setting-description">${t.__('App language (requires restart)')}</div> </div>
<div id="lang-div" class="lang-div"></div> <div class="setting-row" id="add-custom-css">
</div> <div class="setting-description">
${t.__('Add custom CSS')}
<div class="setting-row" id="add-custom-css"> </div>
<div class="setting-description"> <button class="custom-css-button green">${t.__('Upload')}</button>
${t.__('Add custom CSS')} </div>
</div> <div class="setting-row" id="remove-custom-css">
<button class="custom-css-button green">${t.__('Upload')}</button> <div class="setting-description">
</div> <div class="selected-css-path" id="custom-css-path">${ConfigUtil.getConfigItem('customCSS')}</div>
<div class="setting-row" id="remove-custom-css"> </div>
<div class="setting-description"> <div class="action red" id="css-delete-action">
<div class="selected-css-path" id="custom-css-path">${ConfigUtil.getConfigString('customCSS', '')}</div> <i class="material-icons">indeterminate_check_box</i>
</div> <span>${t.__('Delete')}</span>
<div class="action red" id="css-delete-action"> </div>
<i class="material-icons">indeterminate_check_box</i> </div>
<span>${t.__('Delete')}</span>
</div>
</div>
<div class="setting-row" id="download-folder"> <div class="setting-row" id="download-folder">
<div class="setting-description"> <div class="setting-description">
${t.__('Default download location')} ${t.__('Default download location')}
@@ -137,28 +124,25 @@ export default class GeneralSection extends BaseSection {
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-description"> <div class="setting-description">
<div class="download-folder-path">${ConfigUtil.getConfigString('downloadsPath', app.getPath('downloads'))}</div> <div class="download-folder-path">${ConfigUtil.getConfigItem('downloadsPath', `${app.getPath('downloads')}`)}</div>
</div> </div>
</div> </div>
<div class="setting-row" id="prompt-download">
<div class="setting-description">${t.__('Ask where to save files before downloading')}</div>
<div class="setting-control"></div>
</div>
</div> </div>
<div class="title">${t.__('Factory Reset Data')}</div> <div class="title">${t.__('Reset Application Data')}</div>
<div class="settings-card"> <div class="settings-card">
<div class="setting-row" id="factory-reset-option"> <div class="setting-row" id="resetdata-option">
<div class="setting-description">${t.__('Reset the application, thus deleting all the connected organizations and accounts.')} <div class="setting-description">${t.__('This will delete all application data including all added accounts and preferences')}
</div> </div>
<button class="factory-reset-button red w-150">${t.__('Factory Reset')}</button> <button class="reset-data-button red w-150">${t.__('Reset App Data')}</button>
</div> </div>
</div> </div>
</div> </div>
`; `;
} }
init(): void { init(): void {
this.props.$root.innerHTML = this.templateHTML(); this.props.$root.innerHTML = this.template();
this.updateTrayOption(); this.updateTrayOption();
this.updateBadgeOption(); this.updateBadgeOption();
this.updateSilentOption(); this.updateSilentOption();
@@ -166,7 +150,7 @@ export default class GeneralSection extends BaseSection {
this.betaUpdateOption(); this.betaUpdateOption();
this.updateSidebarOption(); this.updateSidebarOption();
this.updateStartAtLoginOption(); this.updateStartAtLoginOption();
this.factoryReset(); this.updateResetDataOption();
this.showDesktopNotification(); this.showDesktopNotification();
this.enableSpellchecker(); this.enableSpellchecker();
this.minimizeOnStart(); this.minimizeOnStart();
@@ -174,11 +158,9 @@ export default class GeneralSection extends BaseSection {
this.showCustomCSSPath(); this.showCustomCSSPath();
this.removeCustomCSS(); this.removeCustomCSS();
this.downloadFolder(); this.downloadFolder();
this.showDownloadFolder();
this.updateQuitOnCloseOption(); this.updateQuitOnCloseOption();
this.updatePromptDownloadOption();
this.enableErrorReporting(); this.enableErrorReporting();
this.setLocale();
this.initSpellChecker();
// Platform specific settings // Platform specific settings
@@ -186,7 +168,6 @@ export default class GeneralSection extends BaseSection {
if (process.platform === 'win32') { if (process.platform === 'win32') {
this.updateFlashTaskbar(); this.updateFlashTaskbar();
} }
// Dock bounce on macOS // Dock bounce on macOS
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
this.updateDockBouncing(); this.updateDockBouncing();
@@ -273,7 +254,6 @@ export default class GeneralSection extends BaseSection {
ConfigUtil.setConfigItem('betaUpdate', false); ConfigUtil.setConfigItem('betaUpdate', false);
this.betaUpdateOption(); this.betaUpdateOption();
} }
this.autoUpdateOption(); this.autoUpdateOption();
} }
}); });
@@ -301,7 +281,9 @@ export default class GeneralSection extends BaseSection {
const newValue = !ConfigUtil.getConfigItem('silent', true); const newValue = !ConfigUtil.getConfigItem('silent', true);
ConfigUtil.setConfigItem('silent', newValue); ConfigUtil.setConfigItem('silent', newValue);
this.updateSilentOption(); this.updateSilentOption();
currentBrowserWindow.webContents.send('toggle-silent', newValue); // TODO: TypeScript: currentWindow of type BrowserWindow doesn't
// have a .send() property per typescript.
(currentBrowserWindow as any).send('toggle-silent', newValue);
} }
}); });
} }
@@ -364,10 +346,6 @@ export default class GeneralSection extends BaseSection {
const newValue = !ConfigUtil.getConfigItem('enableSpellchecker'); const newValue = !ConfigUtil.getConfigItem('enableSpellchecker');
ConfigUtil.setConfigItem('enableSpellchecker', newValue); ConfigUtil.setConfigItem('enableSpellchecker', newValue);
this.enableSpellchecker(); this.enableSpellchecker();
const spellcheckerLanguageInput: HTMLElement = document.querySelector('#spellcheck-langs');
const spellcheckerNote: HTMLElement = document.querySelector('#note');
spellcheckerLanguageInput.style.display = spellcheckerLanguageInput.style.display === 'none' ? '' : 'none';
spellcheckerNote.style.display = spellcheckerNote.style.display === 'none' ? '' : 'none';
} }
}); });
} }
@@ -384,34 +362,43 @@ export default class GeneralSection extends BaseSection {
}); });
} }
async customCssDialog(): Promise<void> { clearAppDataDialog(): void {
const clearAppDataMessage = 'By clicking proceed you will be removing all added accounts and preferences from Zulip. When the application restarts, it will be as if you are starting Zulip for the first time.';
const getAppPath = path.join(app.getPath('appData'), app.getName());
dialog.showMessageBox({
type: 'warning',
buttons: ['YES', 'NO'],
defaultId: 0,
message: 'Are you sure',
detail: clearAppDataMessage
}, response => {
if (response === 0) {
fs.remove(getAppPath);
setTimeout(() => ipcRenderer.send('forward-message', 'hard-reload'), 1000);
}
});
}
customCssDialog(): void {
const showDialogOptions: OpenDialogOptions = { const showDialogOptions: OpenDialogOptions = {
title: 'Select file', title: 'Select file',
properties: ['openFile'], properties: ['openFile'],
filters: [{name: 'CSS file', extensions: ['css']}] filters: [{ name: 'CSS file', extensions: ['css'] }]
}; };
const {filePaths, canceled} = await dialog.showOpenDialog(showDialogOptions); dialog.showOpenDialog(showDialogOptions, selectedFile => {
if (!canceled) { if (selectedFile) {
ConfigUtil.setConfigItem('customCSS', filePaths[0]); ConfigUtil.setConfigItem('customCSS', selectedFile[0]);
ipcRenderer.send('forward-message', 'hard-reload'); ipcRenderer.send('forward-message', 'hard-reload');
} }
});
} }
setLocale(): void { updateResetDataOption(): void {
const langDiv: HTMLSelectElement = document.querySelector('.lang-div'); const resetDataButton = document.querySelector('#resetdata-option .reset-data-button');
const langListHTML = this.generateSelectHTML(supportedLocales, 'lang-menu'); resetDataButton.addEventListener('click', () => {
langDiv.innerHTML += langListHTML; this.clearAppDataDialog();
// `langMenu` is the select-option dropdown menu formed after executing the previous command
const langMenu: HTMLSelectElement = document.querySelector('.lang-menu');
// The next three lines set the selected language visible on the dropdown button
let language = ConfigUtil.getConfigItem('appLanguage');
language = language && langMenu.options.namedItem(language) ? language : 'en';
langMenu.options.namedItem(language).selected = true;
langMenu.addEventListener('change', (event: Event) => {
ConfigUtil.setConfigItem('appLanguage', (event.target as HTMLSelectElement).value);
}); });
} }
@@ -429,8 +416,8 @@ export default class GeneralSection extends BaseSection {
addCustomCSS(): void { addCustomCSS(): void {
const customCSSButton = document.querySelector('#add-custom-css .custom-css-button'); const customCSSButton = document.querySelector('#add-custom-css .custom-css-button');
customCSSButton.addEventListener('click', async () => { customCSSButton.addEventListener('click', () => {
await this.customCssDialog(); this.customCssDialog();
}); });
} }
@@ -444,140 +431,43 @@ export default class GeneralSection extends BaseSection {
removeCustomCSS(): void { removeCustomCSS(): void {
const removeCSSButton = document.querySelector('#css-delete-action'); const removeCSSButton = document.querySelector('#css-delete-action');
removeCSSButton.addEventListener('click', () => { removeCSSButton.addEventListener('click', () => {
ConfigUtil.setConfigItem('customCSS', ''); ConfigUtil.setConfigItem('customCSS', "");
ipcRenderer.send('forward-message', 'hard-reload'); ipcRenderer.send('forward-message', 'hard-reload');
}); });
} }
async downloadFolderDialog(): Promise<void> { downloadFolderDialog(): void {
const showDialogOptions: OpenDialogOptions = { const showDialogOptions: OpenDialogOptions = {
title: 'Select Download Location', title: 'Select Download Location',
properties: ['openDirectory'] properties: ['openDirectory']
}; };
const {filePaths, canceled} = await dialog.showOpenDialog(showDialogOptions); dialog.showOpenDialog(showDialogOptions, selectedFolder => {
if (!canceled) { if (selectedFolder) {
ConfigUtil.setConfigItem('downloadsPath', filePaths[0]); ConfigUtil.setConfigItem('downloadsPath', selectedFolder[0]);
const downloadFolderPath: HTMLElement = document.querySelector('.download-folder-path'); const downloadFolderPath: HTMLElement = document.querySelector('.download-folder-path');
downloadFolderPath.textContent = filePaths[0]; downloadFolderPath.innerText = selectedFolder[0];
}
}
downloadFolder(): void {
const downloadFolder = document.querySelector('#download-folder .download-folder-button');
downloadFolder.addEventListener('click', async () => {
await this.downloadFolderDialog();
});
}
updatePromptDownloadOption(): void {
this.generateSettingOption({
$element: document.querySelector('#prompt-download .setting-control'),
value: ConfigUtil.getConfigItem('promptDownload', false),
clickHandler: () => {
const newValue = !ConfigUtil.getConfigItem('promptDownload');
ConfigUtil.setConfigItem('promptDownload', newValue);
this.updatePromptDownloadOption();
} }
}); });
} }
downloadFolder(): void {
async factoryResetSettings(): Promise<void> { const downloadFolder = document.querySelector('#download-folder .download-folder-button');
const clearAppDataMessage = 'When the application restarts, it will be as if you have just downloaded Zulip app.'; downloadFolder.addEventListener('click', () => {
const getAppPath = path.join(app.getPath('appData'), app.name); this.downloadFolderDialog();
const {response} = await dialog.showMessageBox({
type: 'warning',
buttons: ['YES', 'NO'],
defaultId: 0,
message: 'Are you sure?',
detail: clearAppDataMessage
});
if (response === 0) {
await fs.remove(getAppPath);
setTimeout(() => ipcRenderer.send('clear-app-settings'), 1000);
}
}
factoryReset(): void {
const factoryResetButton = document.querySelector('#factory-reset-option .factory-reset-button');
factoryResetButton.addEventListener('click', async () => {
await this.factoryResetSettings();
}); });
} }
initSpellChecker(): void { showDownloadFolder(): void {
// The elctron API is a no-op on macOS and macOS default spellchecker is used. this.generateSettingOption({
if (process.platform === 'darwin') { $element: document.querySelector('#show-download-folder .setting-control'),
const note: HTMLElement = document.querySelector('#note'); value: ConfigUtil.getConfigItem('showDownloadFolder', false),
note.append(t.__('On macOS, the OS spellchecker is used.')); clickHandler: () => {
note.append(document.createElement('br')); const newValue = !ConfigUtil.getConfigItem('showDownloadFolder');
note.append(t.__('Change the language from System Preferences → Keyboard → Text → Spelling.')); ConfigUtil.setConfigItem('showDownloadFolder', newValue);
} else { this.showDownloadFolder();
const note: HTMLElement = document.querySelector('#note'); }
note.append(t.__('You can select a maximum of 3 languages for spellchecking.')); });
const spellDiv: HTMLElement = document.querySelector('#spellcheck-langs');
spellDiv.innerHTML += htmlEscape`
<div class="setting-description">${t.__('Spellchecker Languages')}</div>
<input name='spellcheck' placeholder='Enter Languages'>`;
const availableLanguages = session.fromPartition('persist:webviewsession').availableSpellCheckerLanguages;
let languagePairs: Map<string, string> = new Map();
availableLanguages.forEach((l: string) => {
if (ISO6391.validate(l)) {
languagePairs.set(ISO6391.getName(l), l);
}
});
// Manually set names for languages not available in ISO6391
languagePairs.set('English (AU)', 'en-AU');
languagePairs.set('English (CA)', 'en-CA');
languagePairs.set('English (GB)', 'en-GB');
languagePairs.set('English (US)', 'en-US');
languagePairs.set('Spanish (Latin America)', 'es-419');
languagePairs.set('Spanish (Argentina)', 'es-AR');
languagePairs.set('Spanish (Mexico)', 'es-MX');
languagePairs.set('Spanish (US)', 'es-US');
languagePairs.set('Portuguese (Brazil)', 'pt-BR');
languagePairs.set('Portuguese (Portugal)', 'pt-PT');
languagePairs.set('Serbo-Croatian', 'sh');
languagePairs = new Map([...languagePairs].sort((a, b) => ((a[0] < b[0]) ? -1 : 1)));
const tagField: HTMLElement = document.querySelector('input[name=spellcheck]');
const tagify = new Tagify(tagField, {
whitelist: [...languagePairs.keys()],
enforceWhitelist: true,
maxTags: 3,
dropdown: {
enabled: 0,
maxItems: Infinity,
closeOnSelect: false,
highlightFirst: true
}
});
const configuredLanguages: string[] = ConfigUtil.getConfigItem('spellcheckerLanguages').map((code: string) => [...languagePairs].find(pair => (pair[1] === code))[0]);
tagify.addTags(configuredLanguages);
tagField.addEventListener('change', event => {
if ((event.target as HTMLInputElement).value.length === 0) {
ConfigUtil.setConfigItem('spellcheckerLanguages', []);
ipcRenderer.send('set-spellcheck-langs');
} else {
const spellLangs: string[] = [...JSON.parse((event.target as HTMLInputElement).value).values()].map(elt => languagePairs.get(elt.value));
ConfigUtil.setConfigItem('spellcheckerLanguages', spellLangs);
ipcRenderer.send('set-spellcheck-langs');
}
});
}
// Do not display the spellchecker input and note if it is disabled
if (!ConfigUtil.getConfigItem('enableSpellchecker')) {
const spellcheckerLanguageInput: HTMLElement = document.querySelector('#spellcheck-langs');
const spellcheckerNote: HTMLElement = document.querySelector('#note');
spellcheckerLanguageInput.style.display = 'none';
spellcheckerNote.style.display = 'none';
}
} }
} }
export = GeneralSection;

View File

@@ -1,47 +1,43 @@
import {htmlEscape} from 'escape-goat'; 'use strict';
import BaseComponent from '../../components/base'; import BaseComponent = require('../../components/base');
import * as t from '../../utils/translation-util'; import t = require('../../utils/translation-util');
interface PreferenceNavProps { class PreferenceNav extends BaseComponent {
$root: Element; // TODO: TypeScript - Here props should be object type
onItemSelected: (navItem: string) => void; props: any;
}
export default class PreferenceNav extends BaseComponent {
props: PreferenceNavProps;
navItems: string[]; navItems: string[];
$el: Element; $el: Element;
constructor(props: PreferenceNavProps) { constructor(props: any) {
super(); super();
this.props = props; this.props = props;
this.navItems = ['General', 'Network', 'AddServer', 'Organizations', 'Shortcuts']; this.navItems = ['General', 'Network', 'AddServer', 'Organizations', 'Shortcuts'];
this.init(); this.init();
} }
templateHTML(): string { template(): string {
let navItemsHTML = ''; let navItemsTemplate = '';
for (const navItem of this.navItems) { for (const navItem of this.navItems) {
navItemsHTML += htmlEscape`<div class="nav" id="nav-${navItem}">${t.__(navItem)}</div>`; navItemsTemplate += `<div class="nav" id="nav-${navItem}">${t.__(navItem)}</div>`;
} }
return htmlEscape` return `
<div> <div>
<div id="settings-header">${t.__('Settings')}</div> <div id="settings-header">${t.__('Settings')}</div>
<div id="nav-container">` + navItemsHTML + htmlEscape`</div> <div id="nav-container">${navItemsTemplate}</div>
</div> </div>
`; `;
} }
init(): void { init(): void {
this.$el = this.generateNodeFromHTML(this.templateHTML()); this.$el = this.generateNodeFromTemplate(this.template());
this.props.$root.append(this.$el); this.props.$root.append(this.$el);
this.registerListeners(); this.registerListeners();
} }
registerListeners(): void { registerListeners(): void {
for (const navItem of this.navItems) { for (const navItem of this.navItems) {
const $item = document.querySelector(`#nav-${CSS.escape(navItem)}`); const $item = document.querySelector(`#nav-${navItem}`);
$item.addEventListener('click', () => { $item.addEventListener('click', () => {
this.props.onItemSelected(navItem); this.props.onItemSelected(navItem);
}); });
@@ -59,12 +55,14 @@ export default class PreferenceNav extends BaseComponent {
} }
activate(navItem: string): void { activate(navItem: string): void {
const $item = document.querySelector(`#nav-${CSS.escape(navItem)}`); const $item = document.querySelector(`#nav-${navItem}`);
$item.classList.add('active'); $item.classList.add('active');
} }
deactivate(navItem: string): void { deactivate(navItem: string): void {
const $item = document.querySelector(`#nav-${CSS.escape(navItem)}`); const $item = document.querySelector(`#nav-${navItem}`);
$item.classList.remove('active'); $item.classList.remove('active');
} }
} }
export = PreferenceNav;

View File

@@ -1,30 +1,26 @@
import {ipcRenderer} from 'electron'; 'use strict';
import {htmlEscape} from 'escape-goat'; import { ipcRenderer } from 'electron';
import * as ConfigUtil from '../../utils/config-util'; import BaseSection = require('./base-section');
import * as t from '../../utils/translation-util'; import ConfigUtil = require('../../utils/config-util');
import t = require('../../utils/translation-util');
import BaseSection from './base-section'; class NetworkSection extends BaseSection {
// TODO: TypeScript - Here props should be object type
interface NetworkSectionProps { props: any;
$root: Element;
}
export default class NetworkSection extends BaseSection {
props: NetworkSectionProps;
$proxyPAC: HTMLInputElement; $proxyPAC: HTMLInputElement;
$proxyRules: HTMLInputElement; $proxyRules: HTMLInputElement;
$proxyBypass: HTMLInputElement; $proxyBypass: HTMLInputElement;
$proxySaveAction: Element; $proxySaveAction: Element;
$manualProxyBlock: Element; $manualProxyBlock: Element;
constructor(props: NetworkSectionProps) { constructor(props: any) {
super(); super();
this.props = props; this.props = props;
} }
templateHTML(): string { template(): string {
return htmlEscape` return `
<div class="settings-pane"> <div class="settings-pane">
<div class="title">${t.__('Proxy')}</div> <div class="title">${t.__('Proxy')}</div>
<div id="appearance-option-settings" class="settings-card"> <div id="appearance-option-settings" class="settings-card">
@@ -61,7 +57,7 @@ export default class NetworkSection extends BaseSection {
} }
init(): void { init(): void {
this.props.$root.innerHTML = this.templateHTML(); this.props.$root.innerHTML = this.template();
this.$proxyPAC = document.querySelector('#proxy-pac-option .setting-input-value'); this.$proxyPAC = document.querySelector('#proxy-pac-option .setting-input-value');
this.$proxyRules = document.querySelector('#proxy-rules-option .setting-input-value'); this.$proxyRules = document.querySelector('#proxy-rules-option .setting-input-value');
this.$proxyBypass = document.querySelector('#proxy-bypass-option .setting-input-value'); this.$proxyBypass = document.querySelector('#proxy-bypass-option .setting-input-value');
@@ -108,13 +104,11 @@ export default class NetworkSection extends BaseSection {
ConfigUtil.setConfigItem('useManualProxy', !manualProxyValue); ConfigUtil.setConfigItem('useManualProxy', !manualProxyValue);
this.toggleManualProxySettings(!manualProxyValue); this.toggleManualProxySettings(!manualProxyValue);
} }
if (newValue === false) {
if (!newValue) {
// Remove proxy system proxy settings // Remove proxy system proxy settings
ConfigUtil.setConfigItem('proxyRules', ''); ConfigUtil.setConfigItem('proxyRules', '');
ipcRenderer.send('forward-message', 'reload-proxy', false); ipcRenderer.send('forward-message', 'reload-proxy', false);
} }
ConfigUtil.setConfigItem('useSystemProxy', newValue); ConfigUtil.setConfigItem('useSystemProxy', newValue);
this.updateProxyOption(); this.updateProxyOption();
} }
@@ -129,7 +123,6 @@ export default class NetworkSection extends BaseSection {
if (systemProxyValue && newValue) { if (systemProxyValue && newValue) {
ConfigUtil.setConfigItem('useSystemProxy', !systemProxyValue); ConfigUtil.setConfigItem('useSystemProxy', !systemProxyValue);
} }
ConfigUtil.setConfigItem('proxyRules', ''); ConfigUtil.setConfigItem('proxyRules', '');
ConfigUtil.setConfigItem('useManualProxy', newValue); ConfigUtil.setConfigItem('useManualProxy', newValue);
// Reload app only when turning manual proxy off, hence !newValue // Reload app only when turning manual proxy off, hence !newValue
@@ -139,3 +132,5 @@ export default class NetworkSection extends BaseSection {
}); });
} }
} }
export = NetworkSection;

View File

@@ -1,38 +1,33 @@
import {ipcRenderer, remote} from 'electron'; 'use strict';
import {htmlEscape} from 'escape-goat'; import { shell, ipcRenderer } from 'electron';
import BaseComponent from '../../components/base'; import BaseComponent = require('../../components/base');
import * as DomainUtil from '../../utils/domain-util'; import DomainUtil = require('../../utils/domain-util');
import * as LinkUtil from '../../utils/link-util'; import t = require('../../utils/translation-util');
import * as t from '../../utils/translation-util';
const {dialog} = remote; class NewServerForm extends BaseComponent {
// TODO: TypeScript - Here props should be object type
interface NewServerFormProps { props: any;
$root: Element;
onChange: () => void;
}
export default class NewServerForm extends BaseComponent {
props: NewServerFormProps;
$newServerForm: Element; $newServerForm: Element;
$saveServerButton: HTMLButtonElement; $saveServerButton: HTMLButtonElement;
$newServerUrl: HTMLInputElement; $newServerUrl: HTMLInputElement;
constructor(props: NewServerFormProps) { constructor(props: any) {
super(); super();
this.props = props; this.props = props;
} }
templateHTML(): string { template(): string {
return htmlEscape` return `
<div class="server-input-container"> <div class="server-input-container">
<div class="title">${t.__('Organization URL')}</div> <div class="title">${t.__('Organization URL')}</div>
<div class="add-server-info-row"> <div class="add-server-info-row">
<input class="setting-input-value" autofocus placeholder="your-organization.zulipchat.com or zulip.your-organization.com"/> <input class="setting-input-value" autofocus placeholder="your-organization.zulipchat.com or zulip.your-organization.com"/>
</div> </div>
<div class="server-center"> <div class="server-center">
<button id="connect">${t.__('Connect')}</button> <div class="server-save-action">
<button id="connect">${t.__('Connect')}</button>
</div>
</div> </div>
<div class="server-center"> <div class="server-center">
<div class="divider"> <div class="divider">
@@ -40,7 +35,9 @@ export default class NewServerForm extends BaseComponent {
</div> </div>
</div> </div>
<div class="server-center"> <div class="server-center">
<button id="open-create-org-link">${t.__('Create a new organization')}</button> <div class="server-save-action">
<button id="open-create-org-link">${t.__('Create a new organization')}</button>
</div>
</div> </div>
<div class="server-center"> <div class="server-center">
<div class="server-network-option"> <div class="server-network-option">
@@ -58,38 +55,30 @@ export default class NewServerForm extends BaseComponent {
} }
initForm(): void { initForm(): void {
this.$newServerForm = this.generateNodeFromHTML(this.templateHTML()); this.$newServerForm = this.generateNodeFromTemplate(this.template());
this.$saveServerButton = this.$newServerForm.querySelector('#connect'); this.$saveServerButton = this.$newServerForm.querySelectorAll('.server-save-action')[0] as HTMLButtonElement;
this.props.$root.textContent = ''; this.props.$root.innerHTML = '';
this.props.$root.append(this.$newServerForm); this.props.$root.append(this.$newServerForm);
this.$newServerUrl = this.$newServerForm.querySelectorAll('input.setting-input-value')[0] as HTMLInputElement; this.$newServerUrl = this.$newServerForm.querySelectorAll('input.setting-input-value')[0] as HTMLInputElement;
} }
async submitFormHandler(): Promise<void> { submitFormHandler(): void {
this.$saveServerButton.textContent = 'Connecting...'; this.$saveServerButton.children[0].innerHTML = 'Connecting...';
let serverConf; DomainUtil.checkDomain(this.$newServerUrl.value).then(serverConf => {
try { DomainUtil.addDomain(serverConf).then(() => {
serverConf = await DomainUtil.checkDomain(this.$newServerUrl.value.trim()); this.props.onChange(this.props.index);
} catch (error: unknown) {
this.$saveServerButton.textContent = 'Connect';
await dialog.showMessageBox({
type: 'error',
message: error instanceof Error ?
`${error.name}: ${error.message}` : 'Unknown error',
buttons: ['OK']
}); });
return; }, errorMessage => {
} this.$saveServerButton.children[0].innerHTML = 'Connect';
alert(errorMessage);
await DomainUtil.addDomain(serverConf); });
this.props.onChange();
} }
openCreateNewOrgExternalLink(): void { openCreateNewOrgExternalLink(): void {
const link = 'https://zulip.com/new/'; const link = 'https://zulipchat.com/new/';
const externalCreateNewOrgElement = document.querySelector('#open-create-org-link'); const externalCreateNewOrgEl = document.querySelector('#open-create-org-link');
externalCreateNewOrgElement.addEventListener('click', async () => { externalCreateNewOrgEl.addEventListener('click', () => {
await LinkUtil.openBrowser(new URL(link)); shell.openExternal(link);
}); });
} }
@@ -99,16 +88,20 @@ export default class NewServerForm extends BaseComponent {
} }
initActions(): void { initActions(): void {
this.$saveServerButton.addEventListener('click', async () => { this.$saveServerButton.addEventListener('click', () => {
await this.submitFormHandler(); this.submitFormHandler();
}); });
this.$newServerUrl.addEventListener('keypress', async event => { this.$newServerUrl.addEventListener('keypress', event => {
if (event.key === 'Enter') { const EnterkeyCode = event.keyCode;
await this.submitFormHandler(); // Submit form when Enter key is pressed
if (EnterkeyCode === 13) {
this.submitFormHandler();
} }
}); });
// Open create new org link in default browser // open create new org link in default browser
this.openCreateNewOrgExternalLink(); this.openCreateNewOrgExternalLink();
this.networkSettingsLink(); this.networkSettingsLink();
} }
} }
export = NewServerForm;

View File

@@ -1,18 +1,18 @@
import {ipcRenderer} from 'electron'; 'use strict';
import BaseComponent from '../../components/base'; import { ipcRenderer } from 'electron';
import type {DNDSettings} from '../../utils/dnd-util';
import ConnectedOrgSection from './connected-org-section'; import BaseComponent = require('../../components/base');
import GeneralSection from './general-section'; import Nav = require('./nav');
import Nav from './nav'; import ServersSection = require('./servers-section');
import NetworkSection from './network-section'; import GeneralSection = require('./general-section');
import ServersSection from './servers-section'; import NetworkSection = require('./network-section');
import ShortcutsSection from './shortcuts-section'; import ConnectedOrgSection = require('./connected-org-section');
import ShortcutsSection = require('./shortcuts-section');
type Section = ServersSection | GeneralSection | NetworkSection | ConnectedOrgSection | ShortcutsSection; type Section = ServersSection | GeneralSection | NetworkSection | ConnectedOrgSection | ShortcutsSection;
export default class PreferenceView extends BaseComponent { class PreferenceView extends BaseComponent {
$sidebarContainer: Element; $sidebarContainer: Element;
$settingsContainer: Element; $settingsContainer: Element;
nav: Nav; nav: Nav;
@@ -37,9 +37,8 @@ export default class PreferenceView extends BaseComponent {
let nav = 'General'; let nav = 'General';
const hasTag = window.location.hash; const hasTag = window.location.hash;
if (hasTag) { if (hasTag) {
nav = hasTag.slice(1); nav = hasTag.substring(1);
} }
this.handleNavigation(nav); this.handleNavigation(nav);
} }
@@ -52,38 +51,32 @@ export default class PreferenceView extends BaseComponent {
}); });
break; break;
} }
case 'General': { case 'General': {
this.section = new GeneralSection({ this.section = new GeneralSection({
$root: this.$settingsContainer $root: this.$settingsContainer
}); });
break; break;
} }
case 'Organizations': { case 'Organizations': {
this.section = new ConnectedOrgSection({ this.section = new ConnectedOrgSection({
$root: this.$settingsContainer $root: this.$settingsContainer
}); });
break; break;
} }
case 'Network': { case 'Network': {
this.section = new NetworkSection({ this.section = new NetworkSection({
$root: this.$settingsContainer $root: this.$settingsContainer
}); });
break; break;
} }
case 'Shortcuts': { case 'Shortcuts': {
this.section = new ShortcutsSection({ this.section = new ShortcutsSection({
$root: this.$settingsContainer $root: this.$settingsContainer
}); });
break; break;
} }
default: break; default: break;
} }
this.section.init(); this.section.init();
window.location.hash = `#${navItem}`; window.location.hash = `#${navItem}`;
} }
@@ -114,7 +107,7 @@ export default class PreferenceView extends BaseComponent {
this.handleToggle('tray-option', state); this.handleToggle('tray-option', state);
}); });
ipcRenderer.on('toggle-dnd', (_event: Event, _state: boolean, newSettings: DNDSettings) => { ipcRenderer.on('toggle-dnd', (_event: Event, _state: boolean, newSettings: any) => {
this.handleToggle('show-notification-option', newSettings.showNotification); this.handleToggle('show-notification-option', newSettings.showNotification);
this.handleToggle('silent-option', newSettings.silent); this.handleToggle('silent-option', newSettings.silent);
@@ -129,3 +122,5 @@ window.addEventListener('load', () => {
const preferenceView = new PreferenceView(); const preferenceView = new PreferenceView();
preferenceView.init(); preferenceView.init();
}); });
export = PreferenceView;

View File

@@ -1,35 +1,29 @@
import {remote, ipcRenderer} from 'electron'; 'use strict';
import {htmlEscape} from 'escape-goat'; import { remote, ipcRenderer } from 'electron';
import * as Messages from '../../../../resources/messages'; import BaseComponent = require('../../components/base');
import BaseComponent from '../../components/base'; import DomainUtil = require('../../utils/domain-util');
import * as DomainUtil from '../../utils/domain-util'; import Messages = require('./../../../../resources/messages');
import * as t from '../../utils/translation-util'; import t = require('../../utils/translation-util');
const {dialog} = remote; const { dialog } = remote;
interface ServerInfoFormProps { class ServerInfoForm extends BaseComponent {
$root: Element; // TODO: TypeScript - Here props should be object type
server: DomainUtil.ServerConf; props: any;
index: number;
onChange: () => void;
}
export default class ServerInfoForm extends BaseComponent {
props: ServerInfoFormProps;
$serverInfoForm: Element; $serverInfoForm: Element;
$serverInfoAlias: Element; $serverInfoAlias: Element;
$serverIcon: Element; $serverIcon: Element;
$deleteServerButton: Element; $deleteServerButton: Element;
$openServerButton: Element; $openServerButton: Element;
constructor(props: ServerInfoFormProps) { constructor(props: any) {
super(); super();
this.props = props; this.props = props;
} }
templateHTML(): string { template(): string {
return htmlEscape` return `
<div class="settings-card"> <div class="settings-card">
<div class="server-info-left"> <div class="server-info-left">
<img class="server-info-icon" src="${this.props.server.icon}"/> <img class="server-info-icon" src="${this.props.server.icon}"/>
@@ -58,7 +52,7 @@ export default class ServerInfoForm extends BaseComponent {
} }
initForm(): void { initForm(): void {
this.$serverInfoForm = this.generateNodeFromHTML(this.templateHTML()); this.$serverInfoForm = this.generateNodeFromTemplate(this.template());
this.$serverInfoAlias = this.$serverInfoForm.querySelectorAll('.server-info-alias')[0]; this.$serverInfoAlias = this.$serverInfoForm.querySelectorAll('.server-info-alias')[0];
this.$serverIcon = this.$serverInfoForm.querySelectorAll('.server-info-icon')[0]; this.$serverIcon = this.$serverInfoForm.querySelectorAll('.server-info-icon')[0];
this.$deleteServerButton = this.$serverInfoForm.querySelectorAll('.server-delete-action')[0]; this.$deleteServerButton = this.$serverInfoForm.querySelectorAll('.server-delete-action')[0];
@@ -67,21 +61,22 @@ export default class ServerInfoForm extends BaseComponent {
} }
initActions(): void { initActions(): void {
this.$deleteServerButton.addEventListener('click', async () => { this.$deleteServerButton.addEventListener('click', () => {
const {response} = await dialog.showMessageBox({ dialog.showMessageBox({
type: 'warning', type: 'warning',
buttons: [t.__('YES'), t.__('NO')], buttons: [t.__('YES'), t.__('NO')],
defaultId: 0, defaultId: 0,
message: t.__('Are you sure you want to disconnect this organization?') message: t.__('Are you sure you want to disconnect this organization?')
}); }, response => {
if (response === 0) { if (response === 0) {
if (DomainUtil.removeDomain(this.props.index)) { if (DomainUtil.removeDomain(this.props.index)) {
ipcRenderer.send('reload-full-app'); ipcRenderer.send('reload-full-app');
} else { } else {
const {title, content} = Messages.orgRemovalError(DomainUtil.getDomain(this.props.index).url); const { title, content } = Messages.orgRemovalError(DomainUtil.getDomain(this.props.index).url);
dialog.showErrorBox(title, content); dialog.showErrorBox(title, content);
}
} }
} });
}); });
this.$openServerButton.addEventListener('click', () => { this.$openServerButton.addEventListener('click', () => {
@@ -97,3 +92,5 @@ export default class ServerInfoForm extends BaseComponent {
}); });
} }
} }
export = ServerInfoForm;

View File

@@ -1,32 +1,28 @@
import {htmlEscape} from 'escape-goat'; 'use strict';
import * as t from '../../utils/translation-util'; import BaseSection = require('./base-section');
import NewServerForm = require('./new-server-form');
import t = require('../../utils/translation-util');
import BaseSection from './base-section'; class ServersSection extends BaseSection {
import NewServerForm from './new-server-form'; // TODO: TypeScript - Here props should be object type
props: any;
interface ServersSectionProps {
$root: Element;
}
export default class ServersSection extends BaseSection {
props: ServersSectionProps;
$newServerContainer: Element; $newServerContainer: Element;
constructor(props: ServersSectionProps) { constructor(props: any) {
super(); super();
this.props = props; this.props = props;
} }
templateHTML(): string { template(): string {
return htmlEscape` return `
<div class="add-server-modal"> <div class="add-server-modal">
<div class="modal-container"> <div class="modal-container">
<div class="settings-pane" id="server-settings-pane"> <div class="settings-pane" id="server-settings-pane">
<div class="page-title">${t.__('Add a Zulip organization')}</div> <div class="page-title">${t.__('Add a Zulip organization')}</div>
<div id="new-server-container"></div> <div id="new-server-container"></div>
</div>
</div> </div>
</div> </div>
</div>
`; `;
} }
@@ -35,9 +31,9 @@ export default class ServersSection extends BaseSection {
} }
initServers(): void { initServers(): void {
this.props.$root.textContent = ''; this.props.$root.innerHTML = '';
this.props.$root.innerHTML = this.templateHTML(); this.props.$root.innerHTML = this.template();
this.$newServerContainer = document.querySelector('#new-server-container'); this.$newServerContainer = document.querySelector('#new-server-container');
this.initNewServerForm(); this.initNewServerForm();
@@ -50,3 +46,5 @@ export default class ServersSection extends BaseSection {
}).init(); }).init();
} }
} }
export = ServersSection;

View File

@@ -1,29 +1,25 @@
import {htmlEscape} from 'escape-goat'; 'use strict';
import * as LinkUtil from '../../utils/link-util'; import { shell } from 'electron';
import * as t from '../../utils/translation-util';
import BaseSection from './base-section'; import BaseSection = require('./base-section');
import t = require('../../utils/translation-util');
interface ShortcutsSectionProps { class ShortcutsSection extends BaseSection {
$root: Element; // TODO: TypeScript - Here props should be object type
} props: any;
constructor(props: any) {
export default class ShortcutsSection extends BaseSection {
props: ShortcutsSectionProps;
constructor(props: ShortcutsSectionProps) {
super(); super();
this.props = props; this.props = props;
} }
// TODO - Deduplicate templateMac and templateWinLin functions. In theory
// TODO - Deduplicate templateMacHTML and templateWinLinHTML functions. In theory
// they both should be the same the only thing different should be the userOSKey // they both should be the same the only thing different should be the userOSKey
// variable but there seems to be inconsistences between both function, one has more // variable but there seems to be inconsistences between both function, one has more
// lines though one may just be using more new lines and other thing is the use of +. // lines though one may just be using more new lines and other thing is the use of +.
templateMacHTML(): string { templateMac(): string {
const userOSKey = '⌘'; const userOSKey = '⌘';
return htmlEscape` return `
<div class="settings-pane"> <div class="settings-pane">
<div class="settings-card tip"><p><b><i class="material-icons md-14">settings</i>${t.__('Tip')}: </b>${t.__('These desktop app shortcuts extend the Zulip webapp\'s')} <span id="open-hotkeys-link"> ${t.__('keyboard shortcuts')}</span>.</p></div> <div class="settings-card tip"><p><b><i class="material-icons md-14">settings</i>${t.__('Tip')}: </b>${t.__('These desktop app shortcuts extend the Zulip webapp\'s')} <span id="open-hotkeys-link"> ${t.__('keyboard shortcuts')}</span>.</p></div>
<div class="title">${t.__('Application Shortcuts')}</div> <div class="title">${t.__('Application Shortcuts')}</div>
@@ -184,10 +180,10 @@ export default class ShortcutsSection extends BaseSection {
`; `;
} }
templateWinLinHTML(): string { templateWinLin(): string {
const userOSKey = 'Ctrl'; const userOSKey = 'Ctrl';
return htmlEscape` return `
<div class="settings-pane"> <div class="settings-pane">
<div class="settings-card tip"><p><b><i class="material-icons md-14">settings</i>${t.__('Tip')}: </b>${t.__('These desktop app shortcuts extend the Zulip webapp\'s')} <span id="open-hotkeys-link"> ${t.__('keyboard shortcuts')}</span>.</p></div> <div class="settings-card tip"><p><b><i class="material-icons md-14">settings</i>${t.__('Tip')}: </b>${t.__('These desktop app shortcuts extend the Zulip webapp\'s')} <span id="open-hotkeys-link"> ${t.__('keyboard shortcuts')}</span>.</p></div>
<div class="title">${t.__('Application Shortcuts')}</div> <div class="title">${t.__('Application Shortcuts')}</div>
@@ -333,16 +329,17 @@ export default class ShortcutsSection extends BaseSection {
} }
openHotkeysExternalLink(): void { openHotkeysExternalLink(): void {
const link = 'https://zulip.com/help/keyboard-shortcuts'; const link = 'https://zulipchat.com/help/keyboard-shortcuts';
const externalCreateNewOrgElement = document.querySelector('#open-hotkeys-link'); const externalCreateNewOrgEl = document.querySelector('#open-hotkeys-link');
externalCreateNewOrgElement.addEventListener('click', async () => { externalCreateNewOrgEl.addEventListener('click', () => {
await LinkUtil.openBrowser(new URL(link)); shell.openExternal(link);
}); });
} }
init(): void { init(): void {
this.props.$root.innerHTML = (process.platform === 'darwin') ? this.props.$root.innerHTML = (process.platform === 'darwin') ?
this.templateMacHTML() : this.templateWinLinHTML(); this.templateMac() : this.templateWinLin();
this.openHotkeysExternalLink(); this.openHotkeysExternalLink();
} }
} }
export = ShortcutsSection;

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,21 @@
import {ipcRenderer, remote, WebviewTag, NativeImage} from 'electron'; 'use strict';
import path from 'path'; import { ipcRenderer, remote, WebviewTag, NativeImage } from 'electron';
import * as ConfigUtil from './utils/config-util'; import path = require('path');
import ConfigUtil = require('./utils/config-util.js');
const { Tray, Menu, nativeImage, BrowserWindow } = remote;
const {Tray, Menu, nativeImage, BrowserWindow} = remote; const APP_ICON = path.join(__dirname, '../../resources/tray', 'tray');
let tray: Electron.Tray; declare let window: ZulipWebWindow;
const ICON_DIR = '../../resources/tray';
const TRAY_SUFFIX = 'tray';
const APP_ICON = path.join(__dirname, ICON_DIR, TRAY_SUFFIX);
const iconPath = (): string => { const iconPath = (): string => {
if (process.platform === 'linux') { if (process.platform === 'linux') {
return APP_ICON + 'linux.png'; return APP_ICON + 'linux.png';
} }
return APP_ICON + (process.platform === 'win32' ? 'win.ico' : 'osx.png');
return APP_ICON + (process.platform === 'win32' ? 'win.ico' : 'macOSTemplate.png');
}; };
const winUnreadTrayIconPath = (): string => APP_ICON + 'unread.ico';
let unread = 0; let unread = 0;
const trayIconSize = (): number => { const trayIconSize = (): number => {
@@ -50,64 +43,69 @@ const config = {
thick: process.platform === 'win32' thick: process.platform === 'win32'
}; };
const renderCanvas = function (arg: number): HTMLCanvasElement { const renderCanvas = function (arg: number): Promise<HTMLCanvasElement> {
config.unreadCount = arg; config.unreadCount = arg;
const SIZE = config.size * config.pixelRatio; return new Promise(resolve => {
const PADDING = SIZE * 0.05; const SIZE = config.size * config.pixelRatio;
const CENTER = SIZE / 2; const PADDING = SIZE * 0.05;
const HAS_COUNT = config.showUnreadCount && config.unreadCount; const CENTER = SIZE / 2;
const color = config.unreadCount ? config.unreadColor : config.readColor; const HAS_COUNT = config.showUnreadCount && config.unreadCount;
const backgroundColor = config.unreadCount ? config.unreadBackgroundColor : config.readBackgroundColor; const color = config.unreadCount ? config.unreadColor : config.readColor;
const backgroundColor = config.unreadCount ? config.unreadBackgroundColor : config.readBackgroundColor;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = SIZE; canvas.width = SIZE;
canvas.height = SIZE; canvas.height = SIZE;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
// Circle // Circle
// If (!config.thick || config.thick && HAS_COUNT) { // If (!config.thick || config.thick && HAS_COUNT) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(CENTER, CENTER, (SIZE / 2) - PADDING, 0, 2 * Math.PI, false); ctx.arc(CENTER, CENTER, (SIZE / 2) - PADDING, 0, 2 * Math.PI, false);
ctx.fillStyle = backgroundColor; ctx.fillStyle = backgroundColor;
ctx.fill(); ctx.fill();
ctx.lineWidth = SIZE / (config.thick ? 10 : 20); ctx.lineWidth = SIZE / (config.thick ? 10 : 20);
ctx.strokeStyle = backgroundColor; ctx.strokeStyle = backgroundColor;
ctx.stroke(); ctx.stroke();
// Count or Icon // Count or Icon
if (HAS_COUNT) { if (HAS_COUNT) {
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
if (config.unreadCount > 99) { if (config.unreadCount > 99) {
ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.4}px Helvetica`; ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.4}px Helvetica`;
ctx.fillText('99+', CENTER, CENTER + (SIZE * 0.15)); ctx.fillText('99+', CENTER, CENTER + (SIZE * 0.15));
} else if (config.unreadCount < 10) { } else if (config.unreadCount < 10) {
ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`; ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.2)); ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.20));
} else { } else {
ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`; ctx.font = `${config.thick ? 'bold ' : ''}${SIZE * 0.5}px Helvetica`;
ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.15)); ctx.fillText(String(config.unreadCount), CENTER, CENTER + (SIZE * 0.15));
}
resolve(canvas);
} }
} });
return canvas;
}; };
/** /**
* Renders the tray icon as a native image * Renders the tray icon as a native image
* @param arg: Unread count * @param arg: Unread count
* @return the native image * @return the native image
*/ */
const renderNativeImage = function (arg: number): NativeImage { const renderNativeImage = function (arg: number): Promise<NativeImage> {
if (process.platform === 'win32') { return Promise.resolve()
return nativeImage.createFromPath(winUnreadTrayIconPath()); .then(() => renderCanvas(arg))
} .then(canvas => {
const pngData = nativeImage.createFromDataURL(canvas.toDataURL('image/png')).toPNG();
const canvas = renderCanvas(arg); // TODO: Fix the function to correctly use Promise correctly.
const pngData = nativeImage.createFromDataURL(canvas.toDataURL('image/png')).toPNG(); // the Promise.resolve().then(...) above is useless we should
return nativeImage.createFromBuffer(pngData, { // start with renderCanvas(arg).then
scaleFactor: config.pixelRatio // eslint-disable-next-line promise/no-return-wrap
}); return Promise.resolve(nativeImage.createFromBuffer(pngData, {
scaleFactor: config.pixelRatio
}));
});
}; };
function sendAction(action: string): void { function sendAction(action: string): void {
@@ -121,6 +119,7 @@ function sendAction(action: string): void {
} }
const createTray = function (): void { const createTray = function (): void {
window.tray = new Tray(iconPath());
const contextMenu = Menu.buildFromTemplate([ const contextMenu = Menu.buildFromTemplate([
{ {
label: 'Zulip', label: 'Zulip',
@@ -145,23 +144,22 @@ const createTray = function (): void {
} }
} }
]); ]);
tray = new Tray(iconPath()); window.tray.setContextMenu(contextMenu);
tray.setContextMenu(contextMenu);
if (process.platform === 'linux' || process.platform === 'win32') { if (process.platform === 'linux' || process.platform === 'win32') {
tray.on('click', () => { window.tray.on('click', () => {
ipcRenderer.send('toggle-app'); ipcRenderer.send('toggle-app');
}); });
} }
}; };
ipcRenderer.on('destroytray', (event: Event): Event => { ipcRenderer.on('destroytray', (event: Event): Event => {
if (!tray) { if (!window.tray) {
return undefined; return undefined;
} }
tray.destroy(); window.tray.destroy();
if (tray.isDestroyed()) { if (window.tray.isDestroyed()) {
tray = null; window.tray = null;
} else { } else {
throw new Error('Tray icon not properly destroyed.'); throw new Error('Tray icon not properly destroyed.');
} }
@@ -170,50 +168,48 @@ ipcRenderer.on('destroytray', (event: Event): Event => {
}); });
ipcRenderer.on('tray', (_event: Event, arg: number): void => { ipcRenderer.on('tray', (_event: Event, arg: number): void => {
if (!tray) { if (!window.tray) {
return; return;
} }
// We don't want to create tray from unread messages on macOS since it already has dock badges. // We don't want to create tray from unread messages on macOS since it already has dock badges.
if (process.platform === 'linux' || process.platform === 'win32') { if (process.platform === 'linux' || process.platform === 'win32') {
if (arg === 0) { if (arg === 0) {
unread = arg; unread = arg;
tray.setImage(iconPath()); window.tray.setImage(iconPath());
tray.setToolTip('No unread messages'); window.tray.setToolTip('No unread messages');
} else { } else {
unread = arg; unread = arg;
const image = renderNativeImage(arg); renderNativeImage(arg).then(image => {
tray.setImage(image); window.tray.setImage(image);
tray.setToolTip(`${arg} unread messages`); window.tray.setToolTip(arg + ' unread messages');
});
} }
} }
}); });
function toggleTray(): void { function toggleTray(): void {
let state; let state;
if (tray) { if (window.tray) {
state = false; state = false;
tray.destroy(); window.tray.destroy();
if (tray.isDestroyed()) { if (window.tray.isDestroyed()) {
tray = null; window.tray = null;
} }
ConfigUtil.setConfigItem('trayIcon', false); ConfigUtil.setConfigItem('trayIcon', false);
} else { } else {
state = true; state = true;
createTray(); createTray();
if (process.platform === 'linux' || process.platform === 'win32') { if (process.platform === 'linux' || process.platform === 'win32') {
const image = renderNativeImage(unread); renderNativeImage(unread).then(image => {
tray.setImage(image); window.tray.setImage(image);
tray.setToolTip(`${unread} unread messages`); window.tray.setToolTip(unread + ' unread messages');
});
} }
ConfigUtil.setConfigItem('trayIcon', true); ConfigUtil.setConfigItem('trayIcon', true);
} }
const selector = 'webview:not([class*=disabled])'; const selector = 'webview:not([class*=disabled])';
const webview: WebviewTag = document.querySelector(selector); const webview: WebviewTag = document.querySelector(selector);
const webContents = remote.webContents.fromId(webview.getWebContentsId()); const webContents = webview.getWebContents();
webContents.send('toggletray', state); webContents.send('toggletray', state);
} }
@@ -222,5 +218,3 @@ ipcRenderer.on('toggletray', toggleTray);
if (ConfigUtil.getConfigItem('trayIcon', true)) { if (ConfigUtil.getConfigItem('trayIcon', true)) {
createTray(); createTray();
} }
export {};

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,19 @@
import electron from 'electron'; import fs = require('fs');
import fs from 'fs';
const app = process.type === 'renderer' ? electron.remote.app : electron.app; let app: Electron.App = null;
let setupCompleted = false; let setupCompleted = false;
if (process.type === 'renderer') {
app = require('electron').remote.app;
} else {
app = require('electron').app;
}
const zulipDir = app.getPath('userData'); const zulipDir = app.getPath('userData');
const logDir = `${zulipDir}/Logs/`; const logDir = `${zulipDir}/Logs/`;
const certificatesDir = `${zulipDir}/certificates/`;
const configDir = `${zulipDir}/config/`; const configDir = `${zulipDir}/config/`;
export const initSetUp = (): void => { export const initSetUp = (): void => {
// If it is the first time the app is running // if it is the first time the app is running
// create zulip dir in userData folder to // create zulip dir in userData folder to
// avoid errors // avoid errors
if (!setupCompleted) { if (!setupCompleted) {
@@ -20,26 +25,35 @@ export const initSetUp = (): void => {
fs.mkdirSync(logDir); fs.mkdirSync(logDir);
} }
if (!fs.existsSync(certificatesDir)) {
fs.mkdirSync(certificatesDir);
}
// Migrate config files from app data folder to config folder inside app // Migrate config files from app data folder to config folder inside app
// data folder. This will be done once when a user updates to the new version. // data folder. This will be done once when a user updates to the new version.
if (!fs.existsSync(configDir)) { if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir); fs.mkdirSync(configDir);
const domainJson = `${zulipDir}/domain.json`; const domainJson = `${zulipDir}/domain.json`;
const certificatesJson = `${zulipDir}/certificates.json`;
const settingsJson = `${zulipDir}/settings.json`; const settingsJson = `${zulipDir}/settings.json`;
const updatesJson = `${zulipDir}/updates.json`; const updatesJson = `${zulipDir}/updates.json`;
const windowStateJson = `${zulipDir}/window-state.json`; const windowStateJson = `${zulipDir}/window-state.json`;
const configData = [ const configData = [
{ {
path: domainJson, path: domainJson,
fileName: 'domain.json' fileName: `domain.json`
},
{
path: certificatesJson,
fileName: `certificates.json`
}, },
{ {
path: settingsJson, path: settingsJson,
fileName: 'settings.json' fileName: `settings.json`
}, },
{ {
path: updatesJson, path: updatesJson,
fileName: 'updates.json' fileName: `updates.json`
} }
]; ];
configData.forEach(data => { configData.forEach(data => {
@@ -48,7 +62,7 @@ export const initSetUp = (): void => {
fs.unlinkSync(data.path); fs.unlinkSync(data.path);
} }
}); });
// `window-state.json` is only deleted not moved, as the electron-window-state // window-state.json is only deleted not moved, as the electron-window-state
// package will recreate the file in the config folder. // package will recreate the file in the config folder.
if (fs.existsSync(windowStateJson)) { if (fs.existsSync(windowStateJson)) {
fs.unlinkSync(windowStateJson); fs.unlinkSync(windowStateJson);

View File

@@ -1,28 +1,30 @@
import * as ConfigUtil from './config-util'; 'use strict';
type SettingName = 'showNotification' | 'silent' | 'flashTaskbarOnMessage'; import ConfigUtil = require('./config-util');
export interface DNDSettings { // TODO: TypeScript - add to Setting interface
showNotification?: boolean; // the list of settings since we have fixed amount of them
silent?: boolean; // We want to do this by creating a new module that exports
flashTaskbarOnMessage?: boolean; // this interface
interface Setting {
[key: string]: any;
} }
interface Toggle { interface Toggle {
dnd: boolean; dnd: boolean;
newSettings: DNDSettings; newSettings: Setting;
} }
export function toggle(): Toggle { export function toggle(): Toggle {
const dnd = !ConfigUtil.getConfigItem('dnd', false); const dnd = !ConfigUtil.getConfigItem('dnd', false);
const dndSettingList: SettingName[] = ['showNotification', 'silent']; const dndSettingList = ['showNotification', 'silent'];
if (process.platform === 'win32') { if (process.platform === 'win32') {
dndSettingList.push('flashTaskbarOnMessage'); dndSettingList.push('flashTaskbarOnMessage');
} }
let newSettings: DNDSettings; let newSettings: Setting;
if (dnd) { if (dnd) {
const oldSettings: DNDSettings = {}; const oldSettings: Setting = {};
newSettings = {}; newSettings = {};
// Iterate through the dndSettingList. // Iterate through the dndSettingList.

View File

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

View File

@@ -1,81 +1,80 @@
import fs from 'fs'; import fs = require('fs');
import path from 'path'; import path = require('path');
import Logger from './logger-util'; import Logger = require('./logger-util');
const logger = new Logger({ const logger = new Logger({
file: 'enterprise-util.log', file: 'enterprise-util.log',
timestamp: true timestamp: true
}); });
// TODO: replace enterpriseSettings type with an interface once settings are final let instance: null | EnterpriseUtil = null;
let enterpriseSettings: Record<string, unknown>;
let configFile: boolean;
reloadDB(); class EnterpriseUtil {
// todo: replace enterpriseSettings type with an interface once settings are final
function reloadDB(): void { enterpriseSettings: any;
let enterpriseFile = '/etc/zulip-desktop-config/global_config.json'; configFile: boolean;
if (process.platform === 'win32') { constructor() {
enterpriseFile = 'C:\\Program Files\\Zulip-Desktop-Config\\global_config.json'; if (instance) {
} return instance;
enterpriseFile = path.resolve(enterpriseFile);
if (fs.existsSync(enterpriseFile)) {
configFile = true;
try {
const file = fs.readFileSync(enterpriseFile, 'utf8');
enterpriseSettings = JSON.parse(file);
} catch (error: unknown) {
logger.log('Error while JSON parsing global_config.json: ');
logger.log(error);
} }
} else { instance = this;
configFile = false;
}
}
export function hasConfigFile(): boolean { this.reloadDB();
return configFile;
}
export function getConfigItem(key: string, defaultValue?: unknown): any {
reloadDB();
if (!configFile) {
return defaultValue;
} }
if (defaultValue === undefined) { reloadDB(): void {
defaultValue = null; let enterpriseFile = '/etc/zulip-desktop-config/global_config.json';
} if (process.platform === 'win32') {
enterpriseFile = 'C:\\Program Files\\Zulip-Desktop-Config\\global_config.json';
}
return configItemExists(key) ? enterpriseSettings[key] : defaultValue; enterpriseFile = path.resolve(enterpriseFile);
} if (fs.existsSync(enterpriseFile)) {
this.configFile = true;
export function configItemExists(key: string): boolean { try {
reloadDB(); const file = fs.readFileSync(enterpriseFile, 'utf8');
if (!configFile) { this.enterpriseSettings = JSON.parse(file);
return false; } catch (err) {
} logger.log('Error while JSON parsing global_config.json: ');
logger.log(err);
return (enterpriseSettings[key] !== undefined); }
} } else {
this.configFile = false;
export function isPresetOrg(url: string): boolean {
if (!configFile || !configItemExists('presetOrganizations')) {
return false;
}
const presetOrgs = enterpriseSettings.presetOrganizations;
if (!Array.isArray(presetOrgs)) {
throw new TypeError('Expected array for presetOrgs');
}
for (const org of presetOrgs) {
if (url.includes(org)) {
return true;
} }
} }
return false; getConfigItem(key: string, defaultValue?: any): any {
this.reloadDB();
if (!this.configFile) {
return defaultValue;
}
if (defaultValue === undefined) {
defaultValue = null;
}
return this.configItemExists(key) ? this.enterpriseSettings[key] : defaultValue;
}
configItemExists(key: string): boolean {
this.reloadDB();
if (!this.configFile) {
return false;
}
return (this.enterpriseSettings[key] !== undefined);
}
isPresetOrg(url: string): boolean {
if (!this.configFile || !this.configItemExists('presetOrganizations')) {
return false;
}
const presetOrgs = this.enterpriseSettings.presetOrganizations;
for (const org of presetOrgs) {
if (url.includes(org)) {
return true;
}
}
return false;
}
} }
export = new EnterpriseUtil();

View File

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

View File

@@ -1,10 +1,10 @@
import electron from 'electron'; 'use strict';
import fs from 'fs'; import JsonDB from 'node-json-db';
import path from 'path';
import {JsonDB} from 'node-json-db'; import fs = require('fs');
import path = require('path');
import Logger from './logger-util'; import electron = require('electron');
import Logger = require('./logger-util');
const remote = const remote =
process.type === 'renderer' ? electron.remote : electron; process.type === 'renderer' ? electron.remote : electron;
@@ -15,49 +15,62 @@ const logger = new Logger({
}); });
/* To make the util runnable in both main and renderer process */ /* To make the util runnable in both main and renderer process */
const {dialog, app} = remote; const { dialog, app } = remote;
let instance: null | LinuxUpdateUtil = null;
let db: JsonDB; class LinuxUpdateUtil {
db: JsonDB;
reloadDB(); constructor() {
if (instance) {
return instance;
} else {
instance = this;
}
export function getUpdateItem(key: string, defaultValue: unknown = null): any { this.reloadDB();
reloadDB(); return instance;
const value = db.getData('/')[key];
if (value === undefined) {
setUpdateItem(key, defaultValue);
return defaultValue;
} }
return value; getUpdateItem(key: string, defaultValue: any = null): any {
} this.reloadDB();
const value = this.db.getData('/')[key];
export function setUpdateItem(key: string, value: unknown): void { if (value === undefined) {
db.push(`/${key}`, value, true); this.setUpdateItem(key, defaultValue);
reloadDB(); return defaultValue;
} } else {
return value;
export function removeUpdateItem(key: string): void {
db.delete(`/${key}`);
reloadDB();
}
function reloadDB(): void {
const linuxUpdateJsonPath = path.join(app.getPath('userData'), '/config/updates.json');
try {
const file = fs.readFileSync(linuxUpdateJsonPath, 'utf8');
JSON.parse(file);
} catch (error: unknown) {
if (fs.existsSync(linuxUpdateJsonPath)) {
fs.unlinkSync(linuxUpdateJsonPath);
dialog.showErrorBox(
'Error saving update notifications.',
'We encountered an error while saving the update notifications.'
);
logger.error('Error while JSON parsing updates.json: ');
logger.error(error);
} }
} }
db = new JsonDB(linuxUpdateJsonPath, true, true); setUpdateItem(key: string, value: any): void {
this.db.push(`/${key}`, value, true);
this.reloadDB();
}
removeUpdateItem(key: string): void {
this.db.delete(`/${key}`);
this.reloadDB();
}
reloadDB(): void {
const linuxUpdateJsonPath = path.join(app.getPath('userData'), '/config/updates.json');
try {
const file = fs.readFileSync(linuxUpdateJsonPath, 'utf8');
JSON.parse(file);
} catch (err) {
if (fs.existsSync(linuxUpdateJsonPath)) {
fs.unlinkSync(linuxUpdateJsonPath);
dialog.showErrorBox(
'Error saving update notifications.',
'We encountered an error while saving the update notifications.'
);
logger.error('Error while JSON parsing updates.json: ');
logger.error(err);
}
}
this.db = new JsonDB(linuxUpdateJsonPath, true, true);
}
} }
export = new LinuxUpdateUtil();

View File

@@ -1,15 +1,19 @@
import {Console} from 'console'; // eslint-disable-line node/prefer-global/console import { Console as NodeConsole } from 'console'; // eslint-disable-line node/prefer-global/console
import electron from 'electron'; import { initSetUp } from './default-util';
import fs from 'fs'; import { sentryInit, captureException } from './sentry-util';
import os from 'os';
import isDev from 'electron-is-dev'; import fs = require('fs');
import os = require('os');
import {initSetUp} from './default-util'; import isDev = require('electron-is-dev');
import {sentryInit, captureException} from './sentry-util'; import electron = require('electron');
// this interface adds [key: string]: any so
// we can do console[type] later on in the code
interface PatchedConsole extends Console {
[key: string]: any;
}
interface LoggerOptions { interface LoggerOptions {
timestamp?: true | (() => string); timestamp?: any;
file?: string; file?: string;
level?: boolean; level?: boolean;
logInDevMode?: boolean; logInDevMode?: boolean;
@@ -25,9 +29,9 @@ if (process.type === 'renderer') {
// Report Errors to Sentry only if it is enabled in settings // Report Errors to Sentry only if it is enabled in settings
// Gets the value of reportErrors from config-util for renderer process // Gets the value of reportErrors from config-util for renderer process
// For main process, sentryInit() is handled in index.js // For main process, sentryInit() is handled in index.js
const {ipcRenderer} = electron; const { ipcRenderer } = electron;
ipcRenderer.send('error-reporting'); ipcRenderer.send('error-reporting');
ipcRenderer.on('error-reporting-val', (_event: Event, errorReporting: boolean) => { ipcRenderer.on('error-reporting-val', (_event: any, errorReporting: boolean) => {
reportErrors = errorReporting; reportErrors = errorReporting;
if (reportErrors) { if (reportErrors) {
sentryInit(); sentryInit();
@@ -37,31 +41,23 @@ if (process.type === 'renderer') {
app = electron.app; app = electron.app;
} }
const browserConsole: PatchedConsole = console;
const logDir = `${app.getPath('userData')}/Logs`; const logDir = `${app.getPath('userData')}/Logs`;
type Level = 'log' | 'debug' | 'info' | 'warn' | 'error'; class Logger {
const levels: Level[] = ['log', 'debug', 'info', 'warn', 'error']; nodeConsole: PatchedConsole;
type LogMethod = (...args: unknown[]) => void; timestamp: any; // TODO: TypeScript - Figure out how to make this work with string | Function.
export default class Logger {
log: LogMethod;
debug: LogMethod;
info: LogMethod;
warn: LogMethod;
error: LogMethod;
nodeConsole: Console;
timestamp?: () => string;
level: boolean; level: boolean;
logInDevMode: boolean; logInDevMode: boolean;
[key: string]: any;
constructor(options: LoggerOptions = {}) { constructor(opts: LoggerOptions = {}) {
let { let {
timestamp = true, timestamp = true,
file = 'console.log', file = 'console.log',
level = true, level = true,
logInDevMode = false logInDevMode = false
} = options; } = opts;
file = `${logDir}/${file}`; file = `${logDir}/${file}`;
if (timestamp === true) { if (timestamp === true) {
@@ -75,8 +71,8 @@ export default class Logger {
process.nextTick(() => this.trimLog(file)); process.nextTick(() => this.trimLog(file));
} }
const fileStream = fs.createWriteStream(file, {flags: 'a'}); const fileStream = fs.createWriteStream(file, { flags: 'a' });
const nodeConsole = new Console(fileStream); const nodeConsole = new NodeConsole(fileStream);
this.nodeConsole = nodeConsole; this.nodeConsole = nodeConsole;
this.timestamp = timestamp; this.timestamp = timestamp;
@@ -85,38 +81,42 @@ export default class Logger {
this.setUpConsole(); this.setUpConsole();
} }
_log(type: Level, ...args: unknown[]): void { _log(type: string, ...args: any[]): void {
const { const {
nodeConsole, timestamp, level, logInDevMode nodeConsole, timestamp, level, logInDevMode
} = this; } = this;
let nodeConsoleLog;
/* eslint-disable no-fallthrough */
switch (true) { switch (true) {
case typeof timestamp === 'function': case typeof timestamp === 'function':
args.unshift(timestamp() + ' |\t'); args.unshift(timestamp() + ' |\t');
// Fall through
case (level): case (level !== false):
args.unshift(type.toUpperCase() + ' |'); args.unshift(type.toUpperCase() + ' |');
// Fall through
case isDev || logInDevMode: case isDev || logInDevMode:
nodeConsole[type](...args); nodeConsoleLog = nodeConsole[type] || nodeConsole.log;
break; nodeConsoleLog.apply(null, args); // eslint-disable-line prefer-spread
default: default: break;
} }
/* eslint-enable no-fallthrough */
console[type](...args); browserConsole[type].apply(null, args);
} }
setUpConsole(): void { setUpConsole(): void {
for (const type of levels) { for (const type in browserConsole) {
this.setupConsoleMethod(type); this.setupConsoleMethod(type);
} }
} }
setupConsoleMethod(type: Level): void { setupConsoleMethod(type: string): void {
this[type] = (...args: unknown[]) => this._log(type, ...args); this[type] = (...args: any[]) => {
const log = this._log.bind(this, type, ...args);
log();
};
} }
getTimestamp(): string { getTimestamp(): string {
@@ -127,18 +127,17 @@ export default class Logger {
return timestamp; return timestamp;
} }
reportSentry(err: unknown): void { reportSentry(err: string): void {
if (reportErrors) { if (reportErrors) {
captureException(err); captureException(err);
} }
} }
trimLog(file: string): void { trimLog(file: string): void{
fs.readFile(file, 'utf8', (err, data) => { fs.readFile(file, 'utf8', (err, data) => {
if (err) { if (err) {
throw err; throw err;
} }
const MAX_LOG_FILE_LINES = 500; const MAX_LOG_FILE_LINES = 500;
const logs = data.split(os.EOL); const logs = data.split(os.EOL);
const logLength = logs.length - 1; const logLength = logs.length - 1;
@@ -152,3 +151,5 @@ export default class Logger {
}); });
} }
} }
export = Logger;

View File

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

View File

@@ -1,83 +1,142 @@
import * as ConfigUtil from './config-util'; 'use strict';
export interface ProxyRule { import url = require('url');
import ConfigUtil = require('./config-util');
let instance: null | ProxyUtil = null;
interface ProxyRule {
hostname?: string; hostname?: string;
port?: number; port?: number;
} }
// TODO: Refactor to async function class ProxyUtil {
export async function resolveSystemProxy(mainWindow: Electron.BrowserWindow): Promise<void> { constructor() {
const page = mainWindow.webContents; if (instance) {
const ses = page.session; return instance;
const resolveProxyUrl = 'www.example.com'; } else {
instance = this;
// Check HTTP Proxy
const httpProxy = (async () => {
const proxy = await ses.resolveProxy('http://' + resolveProxyUrl);
let httpString = '';
if (proxy !== 'DIRECT') {
// In case of proxy HTTPS url:port, windows gives first word as HTTPS while linux gives PROXY
// for all other HTTP or direct url:port both uses PROXY
if (proxy.includes('PROXY') || proxy.includes('HTTPS')) {
httpString = 'http=' + proxy.split('PROXY')[1] + ';';
}
} }
return httpString; return instance;
})(); }
// Check HTTPS Proxy
const httpsProxy = (async () => { // Return proxy to be used for a particular uri, to be used for request
const proxy = await ses.resolveProxy('https://' + resolveProxyUrl); getProxy(_uri: string): ProxyRule | void {
let httpsString = ''; const parsedUri = url.parse(_uri);
if (proxy !== 'DIRECT' || proxy.includes('HTTPS')) { if (parsedUri === null) {
// In case of proxy HTTPS url:port, windows gives first word as HTTPS while linux gives PROXY return;
// for all other HTTP or direct url:port both uses PROXY
if (proxy.includes('PROXY') || proxy.includes('HTTPS')) {
httpsString += 'https=' + proxy.split('PROXY')[1] + ';';
}
} }
return httpsString; const uri = parsedUri;
})(); const proxyRules = ConfigUtil.getConfigItem('proxyRules', '').split(';');
// If SPS is on and system uses no proxy then request should not try to use proxy from
// Check FTP Proxy // environment. NO_PROXY = '*' makes request ignore all environment proxy variables.
const ftpProxy = (async () => { if (proxyRules[0] === '') {
const proxy = await ses.resolveProxy('ftp://' + resolveProxyUrl); process.env.NO_PROXY = '*';
let ftpString = ''; return;
if (proxy !== 'DIRECT') {
if (proxy.includes('PROXY')) {
ftpString += 'ftp=' + proxy.split('PROXY')[1] + ';';
}
} }
return ftpString; const proxyRule: any = {};
})(); if (uri.protocol === 'http:') {
proxyRules.forEach((proxy: string) => {
// Check SOCKS Proxy if (proxy.includes('http=')) {
const socksProxy = (async () => { proxyRule.hostname = proxy.split('http=')[1].trim().split(':')[0];
const proxy = await ses.resolveProxy('socks4://' + resolveProxyUrl); proxyRule.port = proxy.split('http=')[1].trim().split(':')[1];
let socksString = ''; }
if (proxy !== 'DIRECT') { });
if (proxy.includes('SOCKS5')) { return proxyRule;
socksString += 'socks=' + proxy.split('SOCKS5')[1] + ';';
} else if (proxy.includes('SOCKS4')) {
socksString += 'socks=' + proxy.split('SOCKS4')[1] + ';';
} else if (proxy.includes('PROXY')) {
socksString += 'socks=' + proxy.split('PROXY')[1] + ';';
}
} }
return socksString; if (uri.protocol === 'https:') {
})(); proxyRules.forEach((proxy: string) => {
if (proxy.includes('https=')) {
proxyRule.hostname = proxy.split('https=')[1].trim().split(':')[0];
proxyRule.port = proxy.split('https=')[1].trim().split(':')[1];
}
});
return proxyRule;
}
}
const values = await Promise.all([httpProxy, httpsProxy, ftpProxy, socksProxy]); // TODO: Refactor to async function
let proxyString = ''; resolveSystemProxy(mainWindow: Electron.BrowserWindow): void {
values.forEach(proxy => { const page = mainWindow.webContents;
proxyString += proxy; const ses = page.session;
}); const resolveProxyUrl = 'www.example.com';
ConfigUtil.setConfigItem('systemProxyRules', proxyString);
const useSystemProxy = ConfigUtil.getConfigItem('useSystemProxy'); // Check HTTP Proxy
if (useSystemProxy) { const httpProxy = new Promise(resolve => {
ConfigUtil.setConfigItem('proxyRules', proxyString); ses.resolveProxy('http://' + resolveProxyUrl, (proxy: string) => {
let httpString = '';
if (proxy !== 'DIRECT') {
// in case of proxy HTTPS url:port, windows gives first word as HTTPS while linux gives PROXY
// for all other HTTP or direct url:port both uses PROXY
if (proxy.includes('PROXY') || proxy.includes('HTTPS')) {
httpString = 'http=' + proxy.split('PROXY')[1] + ';';
}
}
resolve(httpString);
});
});
// Check HTTPS Proxy
const httpsProxy = new Promise(resolve => {
ses.resolveProxy('https://' + resolveProxyUrl, (proxy: string) => {
let httpsString = '';
if (proxy !== 'DIRECT' || proxy.includes('HTTPS')) {
// in case of proxy HTTPS url:port, windows gives first word as HTTPS while linux gives PROXY
// for all other HTTP or direct url:port both uses PROXY
if (proxy.includes('PROXY') || proxy.includes('HTTPS')) {
httpsString += 'https=' + proxy.split('PROXY')[1] + ';';
}
}
resolve(httpsString);
});
});
// Check FTP Proxy
const ftpProxy = new Promise(resolve => {
ses.resolveProxy('ftp://' + resolveProxyUrl, (proxy: string) => {
let ftpString = '';
if (proxy !== 'DIRECT') {
if (proxy.includes('PROXY')) {
ftpString += 'ftp=' + proxy.split('PROXY')[1] + ';';
}
}
resolve(ftpString);
});
});
// Check SOCKS Proxy
const socksProxy = new Promise(resolve => {
ses.resolveProxy('socks4://' + resolveProxyUrl, (proxy: string) => {
let socksString = '';
if (proxy !== 'DIRECT') {
if (proxy.includes('SOCKS5')) {
socksString += 'socks=' + proxy.split('SOCKS5')[1] + ';';
} else if (proxy.includes('SOCKS4')) {
socksString += 'socks=' + proxy.split('SOCKS4')[1] + ';';
} else if (proxy.includes('PROXY')) {
socksString += 'socks=' + proxy.split('PROXY')[1] + ';';
}
}
resolve(socksString);
});
});
Promise.all([httpProxy, httpsProxy, ftpProxy, socksProxy]).then(values => {
let proxyString = '';
values.forEach(proxy => {
proxyString += proxy;
});
ConfigUtil.setConfigItem('systemProxyRules', proxyString);
const useSystemProxy = ConfigUtil.getConfigItem('useSystemProxy');
if (useSystemProxy) {
ConfigUtil.setConfigItem('proxyRules', proxyString);
}
});
} }
} }
export = new ProxyUtil();

View File

@@ -1,24 +1,26 @@
import {ipcRenderer} from 'electron'; import { ipcRenderer } from 'electron';
import backoff from 'backoff'; import backoff = require('backoff');
import {htmlEscape} from 'escape-goat'; import request = require('request');
import Logger = require('./logger-util');
import type WebView from '../components/webview'; import RequestUtil = require('./request-util');
import DomainUtil = require('./domain-util');
import Logger from './logger-util';
const logger = new Logger({ const logger = new Logger({
file: 'domain-util.log', file: `domain-util.log`,
timestamp: true timestamp: true
}); });
export default class ReconnectUtil { class ReconnectUtil {
webview: WebView; // TODO: TypeScript - Figure out how to annotate webview
// it should be WebView; maybe make it a generic so we can
// pass the class from main.ts
webview: any;
url: string; url: string;
alreadyReloaded: boolean; alreadyReloaded: boolean;
fibonacciBackoff: backoff.Backoff; fibonacciBackoff: any;
constructor(webview: WebView) { constructor(webview: any) {
this.webview = webview; this.webview = webview;
this.url = webview.props.url; this.url = webview.props.url;
this.alreadyReloaded = false; this.alreadyReloaded = false;
@@ -32,40 +34,69 @@ export default class ReconnectUtil {
}); });
} }
async isOnline(): Promise<boolean> { isOnline(): Promise<boolean> {
return ipcRenderer.invoke('is-online', this.url); return new Promise(resolve => {
} try {
const ignoreCerts = DomainUtil.shouldIgnoreCerts(this.url);
pollInternetAndReload(): void { if (ignoreCerts === null) {
this.fibonacciBackoff.backoff(); return;
this.fibonacciBackoff.on('ready', async () => { }
if (await this._checkAndReload()) { request(
this.fibonacciBackoff.reset(); {
} else { url: `${this.url}/static/favicon.ico`,
this.fibonacciBackoff.backoff(); ...RequestUtil.requestOptions(this.url, ignoreCerts)
},
(error: Error, response: any) => {
const isValidResponse =
!error && response.statusCode >= 200 && response.statusCode < 400;
resolve(isValidResponse);
}
);
} catch (err) {
logger.log(err);
} }
}); });
} }
async _checkAndReload(): Promise<boolean> { pollInternetAndReload(): void {
if (this.alreadyReloaded) { this.fibonacciBackoff.backoff();
return true; this.fibonacciBackoff.on('ready', () => {
} this._checkAndReload().then(status => {
if (status) {
this.fibonacciBackoff.reset();
} else {
this.fibonacciBackoff.backoff();
}
});
});
}
if (await this.isOnline()) { // TODO: Make this a async function
ipcRenderer.send('forward-message', 'reload-viewer'); _checkAndReload(): Promise<boolean> {
logger.log('You\'re back online.'); return new Promise(resolve => {
return true; if (!this.alreadyReloaded) { // eslint-disable-line no-negated-condition
} this.isOnline()
.then((online: boolean) => {
if (online) {
ipcRenderer.send('forward-message', 'reload-viewer');
logger.log('You\'re back online.');
return resolve(true);
}
logger.log('There is no internet connection, try checking network cables, modem and router.'); logger.log('There is no internet connection, try checking network cables, modem and router.');
const errorMessageHolder = document.querySelector('#description'); const errMsgHolder = document.querySelector('#description');
if (errorMessageHolder) { if (errMsgHolder) {
errorMessageHolder.innerHTML = htmlEscape` errMsgHolder.innerHTML = `
<div>Your internet connection doesn't seem to work properly!</div> <div>Your internet connection doesn't seem to work properly!</div>
<div>Verify that it works and then click try again.</div>`; <div>Verify that it works and then click try again.</div>`;
} }
return resolve(false);
return false; });
} else {
return resolve(true);
}
});
} }
} }
export = ReconnectUtil;

View File

@@ -0,0 +1,87 @@
import { remote } from 'electron';
import fs = require('fs');
import path = require('path');
import ConfigUtil = require('./config-util');
import Logger = require('./logger-util');
import ProxyUtil = require('./proxy-util');
import CertificateUtil = require('./certificate-util');
import SystemUtil = require('./system-util');
const { app } = remote;
const logger = new Logger({
file: `request-util.log`,
timestamp: true
});
let instance: null | RequestUtil = null;
// TODO: TypeScript - Use ProxyRule for the proxy property
// we can do this now since we use export = ProxyUtil syntax
interface RequestUtilResponse {
ca: string;
proxy: string | void | object;
ecdhCurve: 'auto';
headers: { 'User-Agent': string };
rejectUnauthorized: boolean;
}
class RequestUtil {
constructor() {
if (!instance) {
instance = this;
}
return instance;
}
// ignoreCerts parameter helps in fetching server icon and
// other server details when user chooses to ignore certificate warnings
requestOptions(domain: string, ignoreCerts: boolean): RequestUtilResponse {
domain = this.formatUrl(domain);
const certificate = CertificateUtil.getCertificate(
encodeURIComponent(domain)
);
let certificateFile = null;
if (certificate && certificate.includes('/')) {
// certificate saved using old app version
certificateFile = certificate;
} else if (certificate) {
certificateFile = path.join(`${app.getPath('userData')}/certificates`, certificate);
}
let certificateLocation = '';
if (certificate) {
// To handle case where certificate has been moved from the location in certificates.json
try {
certificateLocation = fs.readFileSync(certificateFile, 'utf8');
} catch (err) {
logger.warn(`Error while trying to get certificate: ${err}`);
}
}
const proxyEnabled = ConfigUtil.getConfigItem('useManualProxy') || ConfigUtil.getConfigItem('useSystemProxy');
// If certificate for the domain exists add it as a ca key in the request's parameter else consider only domain as the parameter for request
// Add proxy as a parameter if it is being used.
return {
ca: certificateLocation ? certificateLocation : '',
proxy: proxyEnabled ? ProxyUtil.getProxy(domain) : '',
ecdhCurve: 'auto',
headers: { 'User-Agent': SystemUtil.getUserAgent() },
rejectUnauthorized: !ignoreCerts
};
}
formatUrl(domain: string): string {
const hasPrefix = domain.startsWith('http', 0);
if (hasPrefix) {
return domain;
} else {
return domain.includes('localhost:') ? `http://${domain}` : `https://${domain}`;
}
}
}
const requestUtil = new RequestUtil();
export = requestUtil;

View File

@@ -1,17 +1,21 @@
import {init} from '@sentry/electron'; import { init } from '@sentry/electron';
import isDev from 'electron-is-dev';
import isDev = require('electron-is-dev');
import path = require('path');
import dotenv = require('dotenv');
dotenv.config({ path: path.resolve(__dirname, '/../../../../.env') });
export const sentryInit = (): void => { export const sentryInit = (): void => {
if (!isDev) { if (!isDev) {
init({ init({
dsn: 'https://628dc2f2864243a08ead72e63f94c7b1@sentry.io/204668', dsn: process.env.SENTRY_DSN,
// We should ignore this error since it's harmless and we know the reason behind this // We should ignore this error since it's harmless and we know the reason behind this
// This error mainly comes from the console logs. // This error mainly comes from the console logs.
// This is a temp solution until Sentry supports disabling the console logs // This is a temp solution until Sentry supports disabling the console logs
ignoreErrors: ['does not appear to be a valid Zulip server'] ignoreErrors: ['does not appear to be a valid Zulip server']
/// sendTimeout: 30 // wait 30 seconds before considering the sending capture to have failed, default is 1 second // sendTimeout: 30 // wait 30 seconds before considering the sending capture to have failed, default is 1 second
}); });
} }
}; };
export {captureException} from '@sentry/electron'; export { captureException } from '@sentry/electron';

View File

@@ -1,38 +1,64 @@
import {ipcRenderer} from 'electron'; 'use strict';
import os from 'os'; import { remote } from 'electron';
export const connectivityERR: string[] = [ import os = require('os');
'ERR_INTERNET_DISCONNECTED', import ConfigUtil = require('./config-util');
'ERR_PROXY_CONNECTION_FAILED',
'ERR_CONNECTION_RESET',
'ERR_NOT_CONNECTED',
'ERR_NAME_NOT_RESOLVED',
'ERR_NETWORK_CHANGED'
];
const userAgent = ipcRenderer.sendSync('fetch-user-agent'); const { app } = remote;
let instance: null | SystemUtil = null;
export function getOS(): string { class SystemUtil {
const platform = os.platform(); connectivityERR: string[];
if (platform === 'darwin') {
return 'Mac';
}
if (platform === 'linux') { userAgent: string | null;
return 'Linux';
}
if (platform === 'win32') { constructor() {
if (Number.parseFloat(os.release()) < 6.2) { if (instance) {
return 'Windows 7'; return instance;
} else {
instance = this;
} }
return 'Windows 10'; this.connectivityERR = [
'ERR_INTERNET_DISCONNECTED',
'ERR_PROXY_CONNECTION_FAILED',
'ERR_CONNECTION_RESET',
'ERR_NOT_CONNECTED',
'ERR_NAME_NOT_RESOLVED',
'ERR_NETWORK_CHANGED'
];
this.userAgent = null;
return instance;
} }
return ''; getOS(): string {
const platform = os.platform();
if (platform === 'darwin') {
return 'Mac';
} else if (platform === 'linux') {
return 'Linux';
} else if (platform === 'win32') {
if (parseFloat(os.release()) < 6.2) {
return 'Windows 7';
} else {
return 'Windows 10';
}
} else {
return '';
}
}
setUserAgent(webViewUserAgent: string): void {
this.userAgent = `ZulipElectron/${app.getVersion()} ${webViewUserAgent}`;
}
getUserAgent(): string | null {
if (!this.userAgent) {
this.setUserAgent(ConfigUtil.getConfigItem('userAgent', null));
}
return this.userAgent;
}
} }
export function getUserAgent(): string { export = new SystemUtil();
return userAgent;
}

View File

@@ -1,18 +1,35 @@
import path from 'path'; 'use strict';
import i18n from 'i18n'; import path = require('path');
import electron = require('electron');
import i18n = require('i18n');
import * as ConfigUtil from './config-util'; let instance: TranslationUtil = null;
let app: Electron.App = null;
i18n.configure({ /* To make the util runnable in both main and renderer process */
directory: path.join(__dirname, '../../../translations/'), if (process.type === 'renderer') {
updateFiles: false app = electron.remote.app;
}); } else {
app = electron.app;
/* Fetches the current appLocale from settings.json */
const appLocale = ConfigUtil.getConfigItem('appLanguage');
/* If no locale present in the json, en is set default */
export function __(phrase: string): string {
return i18n.__({phrase, locale: appLocale ? appLocale : 'en'});
} }
class TranslationUtil {
constructor() {
if (instance) {
return this;
}
instance = this;
i18n.configure({
directory: path.join(__dirname, '../../../translations/'),
register: this
});
}
__(phrase: string): string {
return i18n.__({ phrase, locale: app.getLocale() ? app.getLocale() : 'en' });
}
}
export = new TranslationUtil();

View File

@@ -52,7 +52,7 @@
</div> </div>
<div id="feedback-modal"> <div id="feedback-modal">
<send-feedback show-cancel-button="show"></send-feedback> <send-feedback></send-feedback>
</div> </div>
</body> </body>
@@ -61,4 +61,5 @@
// it messes up require module path resolution // it messes up require module path resolution
require('./js/main'); require('./js/main');
</script> </script>
<script>require('./js/shared/preventdrag.js')</script>
</html> </html>

View File

@@ -23,4 +23,7 @@
</div> </div>
</div> </div>
</body> </body>
<script>var exports = {};</script>
<script src="js/preload.js"></script>
<script>require('./js/shared/preventdrag.js')</script>
</html> </html>

View File

@@ -5,7 +5,6 @@
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<title>Zulip - Settings</title> <title>Zulip - Settings</title>
<link rel="stylesheet" href="css/preference.css" type="text/css" media="screen"> <link rel="stylesheet" href="css/preference.css" type="text/css" media="screen">
<link id="tagify-css" rel="stylesheet" href="data:text/css,">
</head> </head>
<body> <body>
<div id="content"> <div id="content">
@@ -14,7 +13,7 @@
</div> </div>
</body> </body>
<script> <script>
document.querySelector('#tagify-css').href = require.resolve('@yaireo/tagify/dist/tagify.css');
require('./js/pages/preference/preference.js'); require('./js/pages/preference/preference.js');
require('./js/shared/preventdrag.js')
</script> </script>
</html> </html>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -3,36 +3,50 @@ interface DialogBoxError {
content: string; content: string;
} }
export function invalidZulipServerError(domain: string): string { class Messages {
return `${domain} does not appear to be a valid Zulip server. Make sure that invalidZulipServerError(domain: string): string {
\n • You can connect to that URL in a web browser.\ return `${domain} does not appear to be a valid Zulip server. Make sure that
\n • If you need a proxy to connect to the Internet, that you've configured your proxy in the Network settings.\ \n • You can connect to that URL in a web browser.\
\n • It's a Zulip server. (The oldest supported version is 1.6).\ \n • If you need a proxy to connect to the Internet, that you've configured your proxy in the Network settings.\
\n • The server has a valid certificate. \ \n • It's a Zulip server. (The oldest supported version is 1.6).\
\n • The SSL is correctly configured for the certificate. Check out the SSL troubleshooting guide - \n • The server has a valid certificate. (You can add custom certificates in Settings > Organizations). \
\n https://zulip.readthedocs.io/en/stable/production/ssl-certificates.html`; \n • The SSL is correctly configured for the certificate. Check out the SSL troubleshooting guide -
} \n https://zulip.readthedocs.io/en/stable/production/ssl-certificates.html`;
export function noOrgsError(domain: string): string {
return `${domain} does not have any organizations added.\
\nPlease contact your server administrator.`;
}
export function enterpriseOrgError(length: number, domains: string[]): DialogBoxError {
let domainList = '';
for (const domain of domains) {
domainList += `${domain}\n`;
} }
return { noOrgsError(domain: string): string {
title: `Could not add the following ${length === 1 ? 'organization' : 'organizations'}`, return `${domain} does not have any organizations added.\
content: `${domainList}\nPlease contact your system administrator.` \nPlease contact your server administrator.`;
}; }
certErrorMessage(domain: string, error: string): string {
return `Do you trust certificate from ${domain}? \n ${error}`;
}
certErrorDetail(): string {
return `The organization you're connecting to is either someone impersonating the Zulip server you entered, or the server you're trying to connect to is configured in an insecure way.
\nIf you have a valid certificate please add it from Settings>Organizations and try to add the organization again.
\nUnless you have a good reason to believe otherwise, you should not proceed.
\nYou can click here if you'd like to proceed with the connection.`;
}
enterpriseOrgError(length: number, domains: string[]): DialogBoxError {
let domainList = '';
for (const domain of domains) {
domainList += `${domain}\n`;
}
return {
title: `Could not add the following ${length === 1 ? `organization` : `organizations`}`,
content: `${domainList}\nPlease contact your system administrator.`
};
}
orgRemovalError(url: string): DialogBoxError {
return {
title: `Removing ${url} is a restricted operation.`,
content: `Please contact your system administrator.`
};
}
} }
export function orgRemovalError(url: string): DialogBoxError { export = new Messages();
return {
title: `Removing ${url} is a restricted operation.`,
content: 'Please contact your system administrator.'
};
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 631 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 932 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,134 +0,0 @@
{
"About Zulip": "حول \"زوليب\"",
"Actual Size": "الحجم الفعلي",
"Add Custom Certificates": "إضافة رخصة معدلة",
"Add Organization": "إضافة منظمة",
"Add a Zulip organization": "إضافة منظمة \"زوليب\"",
"Add custom CSS": "إصافة CSS معدلة",
"Advanced": "متقدم",
"All the connected organizations will appear here": "جميع المنظمات المتصلة تعرض هنا",
"Always start minimized": "دائماً إبدأ بالقليل",
"App Updates": "تحديث التطبيق",
"Appearance": "المظهر",
"Application Shortcuts": "إختصارات التطبيق",
"Are you sure you want to disconnect this organization?": "هل أنت متأكد من فصل هذة المنظمة؟",
"Auto hide Menu bar": "أخف القائمة تلقائياً",
"Auto hide menu bar (Press Alt key to display)": "أخف القائمة تلقائياً (إضغط Alt لعرض القائمة)",
"Back": "رجوع",
"Bounce dock on new private message": "أخرج المنصة في حال رسالة خاصة جديدة",
"Certificate file": "ملف الشهادة",
"Change": "تغيير",
"Check for Updates": "التحقق من التحديثات",
"Close": "اغلاق",
"Connect": "اتصال",
"Connect to another organization": "التوصيل مع منظمة أخرى",
"Connected organizations": "المنظمات المتصلة",
"Copy": "نسخ",
"Copy Zulip URL": "نسخ رابط زوليب",
"Create a new organization": "Create a new organization",
"Cut": "قص",
"Default download location": "موقع التحميل الافتراضي",
"Delete": "حذف",
"Desktop App Settings": "إعدادت تطبيق سطح المكتب",
"Desktop Notifications": "إشعارات سطح المكتب",
"Desktop Settings": "إعدادات سطح المكتب",
"Disconnect": "قطع الاتصال",
"Download App Logs": "تنزيل سجلات التطبيق",
"Edit": "تعديل",
"Edit Shortcuts": "تعديل الاختصارات",
"Enable auto updates": "تفعيل التحديثات التلقائية",
"Enable error reporting (requires restart)": "تفعيل تقارير الأخطاء (يتطلب إعادة التشغيل)",
"Enable spellchecker (requires restart)": "Enable spellchecker (requires restart)",
"Factory Reset": "إعادة ضبط المصنع",
"File": "ملف",
"Find accounts": "Find accounts",
"Find accounts by email": "Find accounts by email",
"Flash taskbar on new message": "Flash taskbar on new message",
"Forward": "Forward",
"Functionality": "Functionality",
"General": "General",
"Get beta updates": "Get beta updates",
"Hard Reload": "Hard Reload",
"Help": "Help",
"Help Center": "Help Center",
"History": "History",
"History Shortcuts": "History Shortcuts",
"Keyboard Shortcuts": "Keyboard Shortcuts",
"Log Out": "Log Out",
"Log Out of Organization": "Log Out of Organization",
"Manual proxy configuration": "Manual proxy configuration",
"Minimize": "Minimize",
"Mute all sounds from Zulip": "Mute all sounds from Zulip",
"NO": "NO",
"Network": "Network",
"OR": "OR",
"Organization URL": "Organization URL",
"Organizations": "Organizations",
"Paste": "Paste",
"Paste and Match Style": "Paste and Match Style",
"Proxy": "Proxy",
"Proxy bypass rules": "Proxy bypass rules",
"Proxy rules": "Proxy rules",
"Quit": "Quit",
"Quit Zulip": "Quit Zulip",
"Redo": "Redo",
"Release Notes": "Release Notes",
"Reload": "Reload",
"Report an Issue": "Report an Issue",
"Save": "Save",
"Select All": "Select All",
"Settings": "Settings",
"Shortcuts": "Shortcuts",
"Show App Logs": "Show App Logs",
"Show app icon in system tray": "Show app icon in system tray",
"Show app unread badge": "Show app unread badge",
"Show desktop notifications": "Show desktop notifications",
"Show downloaded files in file manager": "Show downloaded files in file manager",
"Show sidebar": "Show sidebar",
"Start app at login": "Start app at login",
"Switch to Next Organization": "Switch to Next Organization",
"Switch to Previous Organization": "Switch to Previous Organization",
"These desktop app shortcuts extend the Zulip webapp's": "These desktop app shortcuts extend the Zulip webapp's",
"This will delete all application data including all added accounts and preferences": "This will delete all application data including all added accounts and preferences",
"Tip": "Tip",
"Toggle DevTools for Active Tab": "Toggle DevTools for Active Tab",
"Toggle DevTools for Zulip App": "Toggle DevTools for Zulip App",
"Toggle Do Not Disturb": "Toggle Do Not Disturb",
"Toggle Full Screen": "Toggle Full Screen",
"Toggle Sidebar": "Toggle Sidebar",
"Toggle Tray Icon": "Toggle Tray Icon",
"Tools": "Tools",
"Undo": "Undo",
"Upload": "Upload",
"Use system proxy settings (requires restart)": "Use system proxy settings (requires restart)",
"View": "View",
"View Shortcuts": "View Shortcuts",
"Window": "Window",
"Window Shortcuts": "Window Shortcuts",
"YES": "YES",
"Zoom In": "Zoom In",
"Zoom Out": "Zoom Out",
"Zulip Help": "Zulip Help",
"keyboard shortcuts": "keyboard shortcuts",
"script": "script",
"Quit when the window is closed": "Quit when the window is closed",
"Ask where to save files before downloading": "Ask where to save files before downloading",
"Services": "Services",
"Hide": "Hide",
"Hide Others": "Hide Others",
"Unhide": "Unhide",
"AddServer": "AddServer",
"App language (requires restart)": "App language (requires restart)",
"Factory Reset Data": "Factory Reset Data",
"Reset the application, thus deleting all the connected organizations, accounts, and certificates.": "Reset the application, thus deleting all the connected organizations, accounts, and certificates.",
"On macOS, the OS spellchecker is used.": "On macOS, the OS spellchecker is used.",
"Change the language from System Preferences → Keyboard → Text → Spelling.": "Change the language from System Preferences → Keyboard → Text → Spelling.",
"Copy Link": "Copy Link",
"Copy Image": "Copy Image",
"Copy Image URL": "Copy Image URL",
"No Suggestion Found": "No Suggestion Found",
"You can select a maximum of 3 languages for spellchecking.": "You can select a maximum of 3 languages for spellchecking.",
"Spellchecker Languages": "Spellchecker Languages",
"Add to Dictionary": "Add to Dictionary",
"Look Up": "Look Up"
}

View File

@@ -1,5 +1,5 @@
{ {
"About Zulip": "Относно Zulip", "About Zulip": "За Zulip",
"Actual Size": "Действителен размер", "Actual Size": "Действителен размер",
"Add Custom Certificates": "Добавяне на персонализирани сертификати", "Add Custom Certificates": "Добавяне на персонализирани сертификати",
"Add Organization": "Добавяне на организация", "Add Organization": "Добавяне на организация",
@@ -75,6 +75,9 @@
"Release Notes": "Бележки към изданието", "Release Notes": "Бележки към изданието",
"Reload": "Презареди", "Reload": "Презареди",
"Report an Issue": "Подаване на сигнал за проблем", "Report an Issue": "Подаване на сигнал за проблем",
"Reset App Data": "Нулиране на данни за приложения",
"Reset App Settings": "Нулиране на настройките на приложението",
"Reset Application Data": "Нулиране на данните на приложението",
"Save": "Запази", "Save": "Запази",
"Select All": "Избери всички", "Select All": "Избери всички",
"Settings": "Настройки", "Settings": "Настройки",
@@ -110,25 +113,5 @@
"Zoom Out": "Отдалечавам", "Zoom Out": "Отдалечавам",
"Zulip Help": "Помощ за Zulip", "Zulip Help": "Помощ за Zulip",
"keyboard shortcuts": "комбинация от клавиши", "keyboard shortcuts": "комбинация от клавиши",
"script": "писменост", "script": "писменост"
"Quit when the window is closed": "Quit when the window is closed",
"Ask where to save files before downloading": "Ask where to save files before downloading",
"Services": "Services",
"Hide": "Hide",
"Hide Others": "Hide Others",
"Unhide": "Unhide",
"AddServer": "AddServer",
"App language (requires restart)": "App language (requires restart)",
"Factory Reset Data": "Factory Reset Data",
"Reset the application, thus deleting all the connected organizations, accounts, and certificates.": "Reset the application, thus deleting all the connected organizations, accounts, and certificates.",
"On macOS, the OS spellchecker is used.": "On macOS, the OS spellchecker is used.",
"Change the language from System Preferences → Keyboard → Text → Spelling.": "Change the language from System Preferences → Keyboard → Text → Spelling.",
"Copy Link": "Copy Link",
"Copy Image": "Copy Image",
"Copy Image URL": "Copy Image URL",
"No Suggestion Found": "No Suggestion Found",
"You can select a maximum of 3 languages for spellchecking.": "You can select a maximum of 3 languages for spellchecking.",
"Spellchecker Languages": "Spellchecker Languages",
"Add to Dictionary": "Add to Dictionary",
"Look Up": "Look Up"
} }

View File

@@ -1,134 +0,0 @@
{
"About Zulip": "Quant a Zulip",
"Actual Size": "Actual Size",
"Add Custom Certificates": "Add Custom Certificates",
"Add Organization": "Add Organization",
"Add a Zulip organization": "Add a Zulip organization",
"Add custom CSS": "Add custom CSS",
"Advanced": "Advanced",
"All the connected organizations will appear here": "All the connected organizations will appear here",
"Always start minimized": "Always start minimized",
"App Updates": "App Updates",
"Appearance": "Appearance",
"Application Shortcuts": "Application Shortcuts",
"Are you sure you want to disconnect this organization?": "Are you sure you want to disconnect this organization?",
"Auto hide Menu bar": "Auto hide Menu bar",
"Auto hide menu bar (Press Alt key to display)": "Auto hide menu bar (Press Alt key to display)",
"Back": "Back",
"Bounce dock on new private message": "Bounce dock on new private message",
"Certificate file": "Certificate file",
"Change": "Change",
"Check for Updates": "Check for Updates",
"Close": "Tancar",
"Connect": "Connect",
"Connect to another organization": "Connect to another organization",
"Connected organizations": "Connected organizations",
"Copy": "Copia",
"Copy Zulip URL": "Copy Zulip URL",
"Create a new organization": "Crea una nova organització",
"Cut": "Cut",
"Default download location": "Default download location",
"Delete": "Elimina",
"Desktop App Settings": "Configuració de l'aplicació d'escriptori",
"Desktop Notifications": "Desktop Notifications",
"Desktop Settings": "Configuració d'escriptori",
"Disconnect": "Disconnect",
"Download App Logs": "Download App Logs",
"Edit": "Edita",
"Edit Shortcuts": "Edit Shortcuts",
"Enable auto updates": "Enable auto updates",
"Enable error reporting (requires restart)": "Enable error reporting (requires restart)",
"Enable spellchecker (requires restart)": "Enable spellchecker (requires restart)",
"Factory Reset": "Factory Reset",
"File": "Fitxer",
"Find accounts": "Find accounts",
"Find accounts by email": "Find accounts by email",
"Flash taskbar on new message": "Flash taskbar on new message",
"Forward": "Forward",
"Functionality": "Functionality",
"General": "General",
"Get beta updates": "Get beta updates",
"Hard Reload": "Hard Reload",
"Help": "Help",
"Help Center": "Centre d'ajuda",
"History": "Historial",
"History Shortcuts": "Dreceres d'historial",
"Keyboard Shortcuts": "Keyboard Shortcuts",
"Log Out": "Log Out",
"Log Out of Organization": "Log Out of Organization",
"Manual proxy configuration": "Manual proxy configuration",
"Minimize": "Minimize",
"Mute all sounds from Zulip": "Silencia tots els sons de Zulip",
"NO": "NO",
"Network": "Network",
"OR": "OR",
"Organization URL": "URL d'organització",
"Organizations": "Organizations",
"Paste": "Paste",
"Paste and Match Style": "Paste and Match Style",
"Proxy": "Proxy",
"Proxy bypass rules": "Proxy bypass rules",
"Proxy rules": "Proxy rules",
"Quit": "Quit",
"Quit Zulip": "Quit Zulip",
"Redo": "Redo",
"Release Notes": "Release Notes",
"Reload": "Reload",
"Report an Issue": "Report an Issue",
"Save": "Guardar",
"Select All": "Select All",
"Settings": "Configuració",
"Shortcuts": "Shortcuts",
"Show App Logs": "Show App Logs",
"Show app icon in system tray": "Show app icon in system tray",
"Show app unread badge": "Mostrar una marca en la icona si hi ha missatges no llegits",
"Show desktop notifications": "Show desktop notifications",
"Show downloaded files in file manager": "Show downloaded files in file manager",
"Show sidebar": "Show sidebar",
"Start app at login": "Start app at login",
"Switch to Next Organization": "Switch to Next Organization",
"Switch to Previous Organization": "Switch to Previous Organization",
"These desktop app shortcuts extend the Zulip webapp's": "These desktop app shortcuts extend the Zulip webapp's",
"This will delete all application data including all added accounts and preferences": "This will delete all application data including all added accounts and preferences",
"Tip": "Tip",
"Toggle DevTools for Active Tab": "Toggle DevTools for Active Tab",
"Toggle DevTools for Zulip App": "Toggle DevTools for Zulip App",
"Toggle Do Not Disturb": "Toggle Do Not Disturb",
"Toggle Full Screen": "Toggle Full Screen",
"Toggle Sidebar": "Toggle Sidebar",
"Toggle Tray Icon": "Toggle Tray Icon",
"Tools": "Tools",
"Undo": "Undo",
"Upload": "Pujada",
"Use system proxy settings (requires restart)": "Use system proxy settings (requires restart)",
"View": "View",
"View Shortcuts": "View Shortcuts",
"Window": "Window",
"Window Shortcuts": "Window Shortcuts",
"YES": "YES",
"Zoom In": "Zoom In",
"Zoom Out": "Zoom Out",
"Zulip Help": "Zulip Help",
"keyboard shortcuts": "keyboard shortcuts",
"script": "script",
"Quit when the window is closed": "Quit when the window is closed",
"Ask where to save files before downloading": "Ask where to save files before downloading",
"Services": "Services",
"Hide": "Hide",
"Hide Others": "Hide Others",
"Unhide": "Unhide",
"AddServer": "AddServer",
"App language (requires restart)": "App language (requires restart)",
"Factory Reset Data": "Factory Reset Data",
"Reset the application, thus deleting all the connected organizations, accounts, and certificates.": "Reset the application, thus deleting all the connected organizations, accounts, and certificates.",
"On macOS, the OS spellchecker is used.": "On macOS, the OS spellchecker is used.",
"Change the language from System Preferences → Keyboard → Text → Spelling.": "Change the language from System Preferences → Keyboard → Text → Spelling.",
"Copy Link": "Copy Link",
"Copy Image": "Copy Image",
"Copy Image URL": "Copy Image URL",
"No Suggestion Found": "No Suggestion Found",
"You can select a maximum of 3 languages for spellchecking.": "You can select a maximum of 3 languages for spellchecking.",
"Spellchecker Languages": "Spellchecker Languages",
"Add to Dictionary": "Add to Dictionary",
"Look Up": "Look Up"
}

View File

@@ -1,134 +1,117 @@
{ {
"About Zulip": "O Zulipu", "About Zulip": "O uživateli Zulip",
"Actual Size": "Skutečná velikost", "Actual Size": "Aktuální velikost",
"Add Custom Certificates": "Přidat vlastní certifikáty", "Add Custom Certificates": "Přidat vlastní certifikáty",
"Add Organization": "Přidat organizaci", "Add Organization": "Přidat organizaci",
"Add a Zulip organization": "Přidat organizaci Zulip", "Add a Zulip organization": "Přidejte organizaci Zulip",
"Add custom CSS": "Přidat vlastní CSS", "Add custom CSS": "Přidat vlastní CSS",
"Advanced": "Rozšířené", "Advanced": "Pokročilý",
"All the connected organizations will appear here": "Všechny připojené organizace se objeví zde", "All the connected organizations will appear here": "Zde se zobrazí všechny připojené organizace",
"Always start minimized": "Vždy spouštět minimalizova", "Always start minimized": "Vždy začněte minimalizovat",
"App Updates": "Aktualizace aplikace", "App Updates": "Aktualizace aplikací",
"Appearance": "Vzhled", "Appearance": "Vzhled",
"Application Shortcuts": "Zkratky programu", "Application Shortcuts": "Zkratky aplikací",
"Are you sure you want to disconnect this organization?": "Opravdu chcete odpojit tuto organizaci?", "Are you sure you want to disconnect this organization?": "Opravdu chcete tuto organizaci odpojit?",
"Auto hide Menu bar": "Automaticky skrývat menu", "Auto hide Menu bar": "Auto hide Menu bar",
"Auto hide menu bar (Press Alt key to display)": "Automaticky skrývat menu (pro zobrazení stiskněte klávesu Alt)", "Auto hide menu bar (Press Alt key to display)": "Automaticky skrýt lištu menu (stiskněte klávesu Alt pro zobrazení)",
"Back": "Zpět", "Back": "Zadní",
"Bounce dock on new private message": "Poskakování ikony v docku po obdržení nové soukromé zprávy", "Bounce dock on new private message": "Bounce dock na nové soukromé zprávy",
"Certificate file": "Soubor s certifikátem", "Certificate file": "Soubor certifikátu",
"Change": "Změnit", "Change": "Změna",
"Check for Updates": "Zkontrolovat aktualizace", "Check for Updates": "Kontrola aktualizací",
"Close": "Zavřít", "Close": "Zavřít",
"Connect": "Připojit se", "Connect": "Připojit",
"Connect to another organization": "Připojit se k jiné organizaci", "Connect to another organization": "Připojte se k jiné organizaci",
"Connected organizations": "Připojené organizace", "Connected organizations": "Připojené organizace",
"Copy": "Kopírovat", "Copy": "kopírovat",
"Copy Zulip URL": "Kopírovat adresu (URL) Zulipu", "Copy Zulip URL": "Kopírovat adresu URL aplikace Zulip",
"Create a new organization": "Vytvořit novou organizaci", "Create a new organization": "Vytvořit novou organizaci",
"Cut": "Vyjmout", "Cut": "Střih",
"Default download location": "Výchozí umístění stahování", "Default download location": "Výchozí umístění stahování",
"Delete": "Smazat", "Delete": "Smazat",
"Desktop App Settings": "Nastavení desktopové aplikace", "Desktop App Settings": "Nastavení aplikace Desktop",
"Desktop Notifications": "Oznámení na ploše", "Desktop Notifications": "Oznámení o pracovní ploše",
"Desktop Settings": "Nastavení plochy", "Desktop Settings": "Nastavení plochy",
"Disconnect": "Odpojit", "Disconnect": "Odpojit",
"Download App Logs": "Stáhnout záznamy programu", "Download App Logs": "Stáhnout App Logs",
"Edit": "Upravit", "Edit": "Upravit",
"Edit Shortcuts": "Upravit zkratky", "Edit Shortcuts": "Upravit zkratky",
"Enable auto updates": "Povolit automatické aktualizace", "Enable auto updates": "Povolit automatické aktualizace",
"Enable error reporting (requires restart)": "Povolit hlášení chyb (vyžaduje opětovné spuštění programu)", "Enable error reporting (requires restart)": "Povolit hlášení chyb (vyžaduje restart)",
"Enable spellchecker (requires restart)": "Povolit kontrolu pravopisu (vyžaduje opětovné spuštění programu)", "Enable spellchecker (requires restart)": "Povolit kontrolu pravopisu (vyžaduje restartování)",
"Factory Reset": "Obnovení do továrního nastavení", "Factory Reset": "Tovární nastavení",
"File": "Soubor", "File": "Soubor",
"Find accounts": "Najít účty", "Find accounts": "Najít účty",
"Find accounts by email": "Najít účty podle adresy elektronické pošty", "Find accounts by email": "Najít účty e-mailem",
"Flash taskbar on new message": "Blikání ikony v hlavním panelu při obdržení nové zprávy", "Flash taskbar on new message": "Hlavní panel Flash na nové zprávě",
"Forward": "Vpřed", "Forward": "Vpřed",
"Functionality": "Funkce", "Functionality": "Funkčnost",
"General": "Obecné", "General": "Všeobecné",
"Get beta updates": "Dostávat beta aktualizace", "Get beta updates": "Získejte aktualizace beta",
"Hard Reload": "Tvrdé znovunahrání", "Hard Reload": "Hard Reload",
"Help": "Nápověda", "Help": "Pomoc",
"Help Center": "Centrum nápovědy", "Help Center": "Centrum nápovědy",
"History": "Historie", "History": "Dějiny",
"History Shortcuts": "Zkratky pro historii", "History Shortcuts": "Zkratky historie",
"Keyboard Shortcuts": "Klávesové zkratky", "Keyboard Shortcuts": "Klávesové zkratky",
"Log Out": "Odhlásit se", "Log Out": "Odhlásit se",
"Log Out of Organization": "Odhlásit se z organizace", "Log Out of Organization": "Odhlásit se z organizace",
"Manual proxy configuration": "Ruční nastavení proxy", "Manual proxy configuration": "Ruční konfigurace proxy",
"Minimize": "Minimalizovat", "Minimize": "Minimalizovat",
"Mute all sounds from Zulip": "Ztlumit všechny zvuky ze Zulipu", "Mute all sounds from Zulip": "Vypněte všechny zvuky ze Zulipu",
"NO": "NE", "NO": "NE",
"Network": "Síť", "Network": "Síť",
"OR": "NEBO", "OR": "NEBO",
"Organization URL": "Adresa organizace", "Organization URL": "URL organizace",
"Organizations": "Organizace", "Organizations": "Organizace",
"Paste": "Vložit", "Paste": "Vložit",
"Paste and Match Style": "Vložit a ponechat styl", "Paste and Match Style": "Vložit a shodit styl",
"Proxy": "Proxy", "Proxy": "Proxy",
"Proxy bypass rules": "Pravidla pro obejití Proxy", "Proxy bypass rules": "Proxy bypass pravidla",
"Proxy rules": "Pravidla Proxy", "Proxy rules": "Proxy pravidla",
"Quit": "Ukončit", "Quit": "Přestat",
"Quit Zulip": "Ukončit Zulip", "Quit Zulip": "Ukončete Zulip",
"Redo": "Znovu", "Redo": "Předělat",
"Release Notes": "Poznámky k této verzi", "Release Notes": "Poznámky k vydání",
"Reload": "Nahrát znovu", "Reload": "Znovu načíst",
"Report an Issue": "Nahlásit problém", "Report an Issue": "Nahlásit problém",
"Reset App Data": "Resetovat data aplikace",
"Reset App Settings": "Obnovit nastavení aplikace",
"Reset Application Data": "Resetovat data aplikace",
"Save": "Uložit", "Save": "Uložit",
"Select All": "Vybrat vše", "Select All": "Vybrat vše",
"Settings": "Nastavení", "Settings": "Nastavení",
"Shortcuts": "Zkratky", "Shortcuts": "Zkratky",
"Show App Logs": "Zobrazit záznamy programu", "Show App Logs": "Zobrazit protokoly aplikací",
"Show app icon in system tray": "Zobrazovat ikonu programu v oznamovací oblasti panelu", "Show app icon in system tray": "Zobrazit ikonu aplikace v systémové liště",
"Show app unread badge": "Zobrazovat u ikony aplikace symbol nepřečteno", "Show app unread badge": "Zobrazit nepřečtený odznak aplikace",
"Show desktop notifications": "Zobrazovat oznámení na ploše", "Show desktop notifications": "Zobrazit oznámení na ploše",
"Show downloaded files in file manager": "Zobrazit stažené soubory ve správci souborů", "Show downloaded files in file manager": "Zobrazit stažené soubory ve správci souborů",
"Show sidebar": "Zobrazovat postranní panel", "Show sidebar": "Zobrazit postranní panel",
"Start app at login": "Spustit program při přihlášení", "Start app at login": "Spuštění aplikace při přihlášení",
"Switch to Next Organization": "Přepnout na další organizaci", "Switch to Next Organization": "Přepnout na další organizaci",
"Switch to Previous Organization": "Přepnout na předchozí organizaci", "Switch to Previous Organization": "Přepněte na předchozí organizaci",
"These desktop app shortcuts extend the Zulip webapp's": "Tyto zkratky rozšiřují webovou aplikaci Zulipu", "These desktop app shortcuts extend the Zulip webapp's": "Tyto klávesové zkratky pro aplikaci na ploše rozšiřují aplikaci Zulip webapp",
"This will delete all application data including all added accounts and preferences": "Toto smaže všechna data programu včetně všech přidaných účtů a nastavení", "This will delete all application data including all added accounts and preferences": "Tím odstraníte všechna data aplikace včetně všech přidaných účtů a předvoleb",
"Tip": "Tip", "Tip": "Tip",
"Toggle DevTools for Active Tab": "Přepnout vývojářské nástroje pro aktivní kartu", "Toggle DevTools for Active Tab": "Přepnout DevTools pro Active Tab",
"Toggle DevTools for Zulip App": "Přepnout vývojářské nástroje pro program Zulip", "Toggle DevTools for Zulip App": "Přepnout DevTools pro Zulip App",
"Toggle Do Not Disturb": "Přepnout mód Nerušit", "Toggle Do Not Disturb": "Přepnout Do Nerušit",
"Toggle Full Screen": "Přepnout na celou obrazovku", "Toggle Full Screen": "Přepnout na celou obrazovku",
"Toggle Sidebar": "Přepnout zobrazení postranního panelu", "Toggle Sidebar": "Přepnout postranní panel",
"Toggle Tray Icon": "Přepnout ikonu v oznamovací oblasti panelu", "Toggle Tray Icon": "Přepnout ikonu zásobníku",
"Tools": "Nástroje", "Tools": "Nástroje",
"Undo": "Zpět", "Undo": "vrátit",
"Upload": "Nahrát", "Upload": "nahrát",
"Use system proxy settings (requires restart)": "Použít systémová nastavení proxy (vyžaduje opětovné spuštění programu)", "Use system proxy settings (requires restart)": "Použít nastavení proxy serveru (vyžaduje restart)",
"View": "Zobrazení", "View": "Pohled",
"View Shortcuts": "Zobrazit zkratky", "View Shortcuts": "Zobrazit zástupce",
"Window": "Okno", "Window": "Okno",
"Window Shortcuts": "Zkratky pro okno", "Window Shortcuts": "Klávesové zkratky",
"YES": "ANO", "YES": "ANO",
"Zoom In": "Přiblížit", "Zoom In": "Přiblížit",
"Zoom Out": "Oddálit", "Zoom Out": "Oddálit",
"Zulip Help": "Nápověda Zulipu", "Zulip Help": "Nápověda Zulip",
"keyboard shortcuts": "klávesové zkratky", "keyboard shortcuts": "klávesové zkratky",
"script": "skript", "script": "skript"
"Quit when the window is closed": "Ukončit, když je okno zavřeno",
"Ask where to save files before downloading": "Před stažením se zeptat kam uložit soubory",
"Services": "Služby",
"Hide": "Skrýt",
"Hide Others": "Skrýt jiné",
"Unhide": "Zobrazit",
"AddServer": "Přidat server",
"App language (requires restart)": "Jazyk programu (vyžaduje opětovné spuštění programu)",
"Factory Reset Data": "Obnovení dat do továrního nastavení",
"Reset the application, thus deleting all the connected organizations, accounts, and certificates.": "Obnovit program do výchozího nastavení. čili smazat všechny připojené organizace, účty a certifikáty.",
"On macOS, the OS spellchecker is used.": "Na macOS se používá kontrola pravopisu OS.",
"Change the language from System Preferences → Keyboard → Text → Spelling.": "Změnit jazyk v Nastavení systému → Klávesnice → Text → Kontrola pravopisu.",
"Copy Link": "Kopírovat odkaz",
"Copy Image": "Kopírovat obrázek",
"Copy Image URL": "Kopírovat adresu (URL) obrázku",
"No Suggestion Found": "Nenalezen žádný návrh",
"You can select a maximum of 3 languages for spellchecking.": "Pro kontrolu pravopisu můžete vybrat nejvíce 3 jazyky.",
"Spellchecker Languages": "Kontrola pravopisu jazyků",
"Add to Dictionary": "Přidat do slovníku",
"Look Up": "Vyhledat"
} }

View File

@@ -1,134 +0,0 @@
{
"About Zulip": "Om Zulip",
"Actual Size": "Faktisk størrelse",
"Add Custom Certificates": "Tilføj egne certifikater",
"Add Organization": "Opret organisation",
"Add a Zulip organization": "Opret en Zulip organisation",
"Add custom CSS": "Tilføj egen CSS",
"Advanced": "Avanceret",
"All the connected organizations will appear here": "Alle de tilsluttede organisationer vil blive vist her",
"Always start minimized": "Start altid minimeret",
"App Updates": "App-opdateringer",
"Appearance": "Udseende",
"Application Shortcuts": "Genveje",
"Are you sure you want to disconnect this organization?": "Er du sikker på du vil frakoble denne organisation? ",
"Auto hide Menu bar": "Skjul menu automatisk",
"Auto hide menu bar (Press Alt key to display)": "Skjul menu automatisk (tryk på Alt-tasten for at vise)",
"Back": "Tilbage",
"Bounce dock on new private message": "Bounce dock on new private message",
"Certificate file": "Certifikat-fil",
"Change": "Skift",
"Check for Updates": "Tjek for opdateringer",
"Close": "Luk",
"Connect": "Tilslut",
"Connect to another organization": "Forbind til en anden organisation",
"Connected organizations": "Tilsluttede organisationer",
"Copy": "Kopiér",
"Copy Zulip URL": "Kopiér Zulip URL",
"Create a new organization": "Opret ny organisation",
"Cut": "Klip",
"Default download location": "Default download placering",
"Delete": "Slet",
"Desktop App Settings": "Desktop app-indstillinger",
"Desktop Notifications": "Desktop-notifikationer",
"Desktop Settings": "Desktop-indstillinger",
"Disconnect": "Frakobl",
"Download App Logs": "Download app-logfiler",
"Edit": "Redigér",
"Edit Shortcuts": "Redigér genveje",
"Enable auto updates": "Aktivér auto-opdateringer",
"Enable error reporting (requires restart)": "Aktivér fejlrapportering (kræver genstart)",
"Enable spellchecker (requires restart)": "Aktivér stavekontrol (kræver genstart)",
"Factory Reset": "Nulstil til fabriksindstillinger",
"File": "Fil",
"Find accounts": "Find konti",
"Find accounts by email": "Find accounts by email",
"Flash taskbar on new message": "Flash taskbar on new message",
"Forward": "Forward",
"Functionality": "Funktionalitet",
"General": "Generelt",
"Get beta updates": "Få beta opdateringer",
"Hard Reload": "Hård reload",
"Help": "Hjælp",
"Help Center": "Hjælpecenter",
"History": "Historik",
"History Shortcuts": "Historik genveje",
"Keyboard Shortcuts": "Tastatur genveje",
"Log Out": "Log ud",
"Log Out of Organization": "Log ud af organisation",
"Manual proxy configuration": "Manuel proxy opsætning",
"Minimize": "Minimér",
"Mute all sounds from Zulip": "Mute all sounds from Zulip",
"NO": "NEJ",
"Network": "Netværk",
"OR": "ELLER",
"Organization URL": "Organisation URL",
"Organizations": "Organisationer",
"Paste": "Indsæt",
"Paste and Match Style": "Indsæt med samme formattering",
"Proxy": "Proxy",
"Proxy bypass rules": "Proxy bypass regler",
"Proxy rules": "Proxy regler",
"Quit": "Luk",
"Quit Zulip": "Luk Zulip",
"Redo": "Redo",
"Release Notes": "Release Notes",
"Reload": "Reload",
"Report an Issue": "Report an Issue",
"Save": "Save",
"Select All": "Select All",
"Settings": "Settings",
"Shortcuts": "Shortcuts",
"Show App Logs": "Show App Logs",
"Show app icon in system tray": "Show app icon in system tray",
"Show app unread badge": "Show app unread badge",
"Show desktop notifications": "Show desktop notifications",
"Show downloaded files in file manager": "Show downloaded files in file manager",
"Show sidebar": "Show sidebar",
"Start app at login": "Start app at login",
"Switch to Next Organization": "Switch to Next Organization",
"Switch to Previous Organization": "Switch to Previous Organization",
"These desktop app shortcuts extend the Zulip webapp's": "These desktop app shortcuts extend the Zulip webapp's",
"This will delete all application data including all added accounts and preferences": "This will delete all application data including all added accounts and preferences",
"Tip": "Tip",
"Toggle DevTools for Active Tab": "Toggle DevTools for Active Tab",
"Toggle DevTools for Zulip App": "Toggle DevTools for Zulip App",
"Toggle Do Not Disturb": "Toggle Do Not Disturb",
"Toggle Full Screen": "Toggle Full Screen",
"Toggle Sidebar": "Toggle Sidebar",
"Toggle Tray Icon": "Toggle Tray Icon",
"Tools": "Tools",
"Undo": "Undo",
"Upload": "Upload",
"Use system proxy settings (requires restart)": "Use system proxy settings (requires restart)",
"View": "View",
"View Shortcuts": "View Shortcuts",
"Window": "Window",
"Window Shortcuts": "Window Shortcuts",
"YES": "YES",
"Zoom In": "Zoom In",
"Zoom Out": "Zoom Out",
"Zulip Help": "Zulip Help",
"keyboard shortcuts": "keyboard shortcuts",
"script": "script",
"Quit when the window is closed": "Quit when the window is closed",
"Ask where to save files before downloading": "Ask where to save files before downloading",
"Services": "Services",
"Hide": "Hide",
"Hide Others": "Hide Others",
"Unhide": "Unhide",
"AddServer": "AddServer",
"App language (requires restart)": "App language (requires restart)",
"Factory Reset Data": "Factory Reset Data",
"Reset the application, thus deleting all the connected organizations, accounts, and certificates.": "Reset the application, thus deleting all the connected organizations, accounts, and certificates.",
"On macOS, the OS spellchecker is used.": "On macOS, the OS spellchecker is used.",
"Change the language from System Preferences → Keyboard → Text → Spelling.": "Change the language from System Preferences → Keyboard → Text → Spelling.",
"Copy Link": "Copy Link",
"Copy Image": "Copy Image",
"Copy Image URL": "Copy Image URL",
"No Suggestion Found": "No Suggestion Found",
"You can select a maximum of 3 languages for spellchecking.": "You can select a maximum of 3 languages for spellchecking.",
"Spellchecker Languages": "Spellchecker Languages",
"Add to Dictionary": "Add to Dictionary",
"Look Up": "Look Up"
}

View File

@@ -1,134 +1,117 @@
{ {
"About Zulip": "Über Zulip", "About Zulip": "Über Zulip",
"Actual Size": "Tatsächliche Größe", "Actual Size": "Tatsächliche Größe",
"Add Custom Certificates": "Eigene Zertifikate hinzufügen", "Add Custom Certificates": "Benutzerdefinierte Zertifikate hinzufügen",
"Add Organization": "Organisation hinzufügen", "Add Organization": "Organisation hinzufügen",
"Add a Zulip organization": "Zulip-Organisation hinzufügen", "Add a Zulip organization": "Fügen Sie eine Zulip-Organisation hinzu",
"Add custom CSS": "Eigenes CSS hinzufügen", "Add custom CSS": "Fügen Sie benutzerdefiniertes CSS hinzu",
"Advanced": "Erweitert", "Advanced": "Fortgeschritten",
"All the connected organizations will appear here": "Alle verbundenen Organisationen erscheinen hier", "All the connected organizations will appear here": "Alle verbundenen Organisationen werden hier angezeigt",
"Always start minimized": "Immer minimiert öffnen", "Always start minimized": "Immer minimiert anfangen",
"App Updates": "App Updates", "App Updates": "App-Updates",
"Appearance": "Erscheinungsbild", "Appearance": "Aussehen",
"Application Shortcuts": "App-Verknüpfungen", "Application Shortcuts": "Anwendungsverknüpfungen",
"Are you sure you want to disconnect this organization?": "Bist du dir sicher, dass du die Verbindung zur Organisation trennen möchtest?", "Are you sure you want to disconnect this organization?": "Möchten Sie diese Organisation wirklich trennen?",
"Auto hide Menu bar": "Menü automatisch verstecken", "Auto hide Menu bar": "Menüleiste automatisch ausblenden",
"Auto hide menu bar (Press Alt key to display)": "Menü automatisch verstecken (zum Anzeigen die Alt-Taste drücken)", "Auto hide menu bar (Press Alt key to display)": "Menüleiste automatisch ausblenden (Drücken Sie die Alt-Taste, um anzuzeigen)",
"Back": "Zurück", "Back": "Zurück",
"Bounce dock on new private message": "Im Dock hüpfen, wenn neue private Nachrichten eingehen", "Bounce dock on new private message": "Bounce-Dock für neue private Nachricht",
"Certificate file": "Zertifikatsdatei", "Certificate file": "Zertifikatsdatei",
"Change": "Ändern", "Change": "Veränderung",
"Check for Updates": "Auf Updates prüfen", "Check for Updates": "Auf Updates prüfen",
"Close": "Schließen", "Close": "Schließen",
"Connect": "Verbinden", "Connect": "Verbinden",
"Connect to another organization": "Mit einer anderen Organisation verbinden", "Connect to another organization": "Stellen Sie eine Verbindung zu einer anderen Organisation her",
"Connected organizations": "Verbundene Organisationen", "Connected organizations": "Verbundene Organisationen",
"Copy": "Kopieren", "Copy": "Kopieren",
"Copy Zulip URL": "Zulip-URL kopieren", "Copy Zulip URL": "Zulip-URL kopieren",
"Create a new organization": "Eine neue Organisation erstellen", "Create a new organization": "Erstellen Sie eine neue Organisation",
"Cut": "Ausschneiden", "Cut": "Schnitt",
"Default download location": "Voreingestelltes Ziel für Downloads", "Default download location": "Standard-Download-Speicherort",
"Delete": "Löschen", "Delete": "Löschen",
"Desktop App Settings": "Einstellungen für Desktop-App", "Desktop App Settings": "Desktop App-Einstellungen",
"Desktop Notifications": "Desktopbenachrichtigungen", "Desktop Notifications": "Desktop-Benachrichtigungen",
"Desktop Settings": "Desktop-Einstellungen", "Desktop Settings": "Desktop-Einstellungen",
"Disconnect": "Verbindung trennen", "Disconnect": "Trennen",
"Download App Logs": "Logdateien der App herunterladen", "Download App Logs": "App-Protokolle herunterladen",
"Edit": "Bearbeiten", "Edit": "Bearbeiten",
"Edit Shortcuts": "Tastenkürzel bearbeiten", "Edit Shortcuts": "Verknüpfungen bearbeiten",
"Enable auto updates": "Automatisch aktualisieren", "Enable auto updates": "Automatische Updates aktivieren",
"Enable error reporting (requires restart)": "Fehlerberichte aktivieren (erfordert Neustart)", "Enable error reporting (requires restart)": "Fehlerberichterstattung aktivieren (Neustart erforderlich)",
"Enable spellchecker (requires restart)": "Rechtschreibprüfung aktivieren (erfordert Neustart)", "Enable spellchecker (requires restart)": "Rechtschreibprüfung aktivieren (Neustart erforderlich)",
"Factory Reset": "Alle Einstellungen auf Standardwerte zurücksetzen", "Factory Reset": "Werkseinstellungen zurückgesetzt",
"File": "Datei", "File": "Datei",
"Find accounts": "Accounts finden", "Find accounts": "Konten suchen",
"Find accounts by email": "Accounts anhand E-Mail-Adresse finden", "Find accounts by email": "Finden Sie Konten per E-Mail",
"Flash taskbar on new message": "Farbliche Hervorhebung in Taskbar bei neuen Nachrichten", "Flash taskbar on new message": "Flash-Taskleiste bei neuer Nachricht",
"Forward": "Weiter", "Forward": "Nach vorne",
"Functionality": "Funktionalität", "Functionality": "Funktionalität",
"General": "Allgemein", "General": "Allgemeines",
"Get beta updates": "Auf Betaversionen aktualisieren", "Get beta updates": "Erhalten Sie Beta-Updates",
"Hard Reload": "Komplett neu laden", "Hard Reload": "Festes Nachladen",
"Help": "Hilfe", "Help": "Hilfe",
"Help Center": "Hilfe-Zentrum", "Help Center": "Hilfezentrum",
"History": "Verlauf", "History": "Geschichte",
"History Shortcuts": "Kurzbefehle für Verlauf", "History Shortcuts": "Verlaufsverknüpfungen",
"Keyboard Shortcuts": "Tastenkürzel", "Keyboard Shortcuts": "Tastatürkürzel",
"Log Out": "Abmelden", "Log Out": "Ausloggen",
"Log Out of Organization": "Von Organisation abmelden", "Log Out of Organization": "Aus Organisation ausloggen",
"Manual proxy configuration": "Manuelle Proxy-Konfiguration", "Manual proxy configuration": "Manuelle Proxy-Konfiguration",
"Minimize": "Minimieren", "Minimize": "Minimieren",
"Mute all sounds from Zulip": "Alle Zulip-Klänge stummschalten", "Mute all sounds from Zulip": "Schaltet alle Sounds von Zulip stumm",
"NO": "NEIN", "NO": "NEIN",
"Network": "Netzwerk", "Network": "Netzwerk",
"OR": "ODER", "OR": "ODER",
"Organization URL": "URL der Organisation", "Organization URL": "Organisations-URL",
"Organizations": "Organisationen", "Organizations": "Organisationen",
"Paste": "Einfügen", "Paste": "Einfügen",
"Paste and Match Style": "Ohne Formatierung einfügen", "Paste and Match Style": "Einfügen und Format anpassen",
"Proxy": "Proxy", "Proxy": "Proxy",
"Proxy bypass rules": "Proxy-Ausnahmen", "Proxy bypass rules": "Proxy-Umgehungsregeln",
"Proxy rules": "Proxy-Regeln", "Proxy rules": "Proxy-Regeln",
"Quit": "Beenden", "Quit": "Verlassen",
"Quit Zulip": "Zulip beenden", "Quit Zulip": "Beenden Sie Zulip",
"Redo": "Wiederholen", "Redo": "Redo",
"Release Notes": "Hinweise zur Versionsfreigabe", "Release Notes": "Versionshinweise",
"Reload": "Neu laden", "Reload": "Neu laden",
"Report an Issue": "Ein Problem melden", "Report an Issue": "Ein Problem melden",
"Save": "Speichern", "Reset App Data": "App-Daten zurücksetzen",
"Select All": "Alles auswählen", "Reset App Settings": "App-Einstellungen zurücksetzen",
"Settings": "Einstellungen", "Reset Application Data": "Anwendungsdaten zurücksetzen",
"Shortcuts": "Kurzbefehle", "Save": "sparen",
"Show App Logs": "Logdateien der App anzeigen", "Select All": "Wählen Sie Alle",
"Show app icon in system tray": "App-Icon in Systemleiste anzeigen", "Settings": "die Einstellungen",
"Show app unread badge": "Anzahl ungelesener Nachrichten in App-Icon einblenden", "Shortcuts": "Verknüpfungen",
"Show desktop notifications": "Desktopbenachrichtigungen anzeigen", "Show App Logs": "App-Protokolle anzeigen",
"Show downloaded files in file manager": "Heruntergeladene Dateien in Dateimanager anzeigen", "Show app icon in system tray": "App-Symbol in der Taskleiste anzeigen",
"Show app unread badge": "Zeige App ungelesenen Badge",
"Show desktop notifications": "Desktop-Benachrichtigungen anzeigen",
"Show downloaded files in file manager": "Heruntergeladene Dateien im Dateimanager anzeigen",
"Show sidebar": "Seitenleiste anzeigen", "Show sidebar": "Seitenleiste anzeigen",
"Start app at login": "App beim Login automatisch starten", "Start app at login": "App beim Login starten",
"Switch to Next Organization": "Zur nächsten Organisation wechseln", "Switch to Next Organization": "Wechseln Sie zur nächsten Organisation",
"Switch to Previous Organization": "Zur vorhergehenden Organisation wechseln", "Switch to Previous Organization": "Zur vorherigen Organisation wechseln",
"These desktop app shortcuts extend the Zulip webapp's": "Dies sind zusätzliche Kurzbefehle in der Desktop-App gegenüber der Web-App", "These desktop app shortcuts extend the Zulip webapp's": "Diese Desktop-App-Verknüpfungen erweitern die Zulip-Webanwendungen",
"This will delete all application data including all added accounts and preferences": "Hiermit werden alle Anwendungsdaten einschließlich aller Accounts und Einstellungen gelöscht", "This will delete all application data including all added accounts and preferences": "Dadurch werden alle Anwendungsdaten einschließlich aller hinzugefügten Konten und Einstellungen gelöscht",
"Tip": "Tipp", "Tip": "Spitze",
"Toggle DevTools for Active Tab": "Entwickler-Tools für aktiven Tab umschalten", "Toggle DevTools for Active Tab": "DevTools für aktive Registerkarte umschalten",
"Toggle DevTools for Zulip App": "Entwickler-Tools für Zulip-App umschalten", "Toggle DevTools for Zulip App": "DevTools für Zulip App umschalten",
"Toggle Do Not Disturb": "Nicht-Stören-Modus umschalten", "Toggle Do Not Disturb": "Nicht stören umschalten",
"Toggle Full Screen": "Vollbildschirm umschalten", "Toggle Full Screen": "Vollbild umschalten",
"Toggle Sidebar": "Seitenleiste umschalten", "Toggle Sidebar": "Seitenleiste umschalten",
"Toggle Tray Icon": "Tray-Icon umschalten", "Toggle Tray Icon": "Taskleistensymbol umschalten",
"Tools": "Extras", "Tools": "Werkzeuge",
"Undo": "Rückgängig", "Undo": "Rückgängig machen",
"Upload": "Hochladen", "Upload": "Hochladen",
"Use system proxy settings (requires restart)": "Systemweite Proxy-Einstellungen verwenden (erfordert Neustart)", "Use system proxy settings (requires restart)": "System-Proxy-Einstellungen verwenden (Neustart erforderlich)",
"View": "Ansicht", "View": "Aussicht",
"View Shortcuts": "Tastenkürzel anzeigen", "View Shortcuts": "Verknüpfungen anzeigen",
"Window": "Fenster", "Window": "Fenster",
"Window Shortcuts": "Kurzbefehle für Fenster", "Window Shortcuts": "Fensterverknüpfungen",
"YES": "JA", "YES": "JA",
"Zoom In": "Vergrößern", "Zoom In": "Hineinzoomen",
"Zoom Out": "Verkleinern", "Zoom Out": "Rauszoomen",
"Zulip Help": "Hilfe zu Zulip", "Zulip Help": "Zulip-Hilfe",
"keyboard shortcuts": "Tastenkürzel", "keyboard shortcuts": "Tastatürkürzel",
"script": "Skript", "script": "Skript"
"Quit when the window is closed": "Beim Schließen des Fensters beenden",
"Ask where to save files before downloading": "Fragen, wo heruntergeladene Dateien gespeichert werden sollen",
"Services": "Dienste",
"Hide": "Verbergen",
"Hide Others": "Andere verbergen",
"Unhide": "Nicht mehr verbergen",
"AddServer": "ServerHinzufügen",
"App language (requires restart)": "Sprache der App (benötigt Neustart)",
"Factory Reset Data": "Auf Werkseinstellung zurücksetzen",
"Reset the application, thus deleting all the connected organizations, accounts, and certificates.": "Die App wird zurückgesetzt, somit werden alle verbundenen Organisationen, Konten und Zertifikate gelöscht.",
"On macOS, the OS spellchecker is used.": "In macOS wird die OS Rechtschreibprüfung verwendet.",
"Change the language from System Preferences → Keyboard → Text → Spelling.": "Ändere die Spracheinstellung über Systemeinstellungen → Tastatur → Text → Rechtschreibung.",
"Copy Link": "Link kopieren",
"Copy Image": "Bild kopieren",
"Copy Image URL": "Bild-URL kopieren",
"No Suggestion Found": "Keine Vorschläge gefunden",
"You can select a maximum of 3 languages for spellchecking.": "Du kannst höchstens 3 Sprachen für die Rechtschreibprüfung auswählen.",
"Spellchecker Languages": "Sprachen für die Rechtschreibprüfung",
"Add to Dictionary": "Zum Wörterbuch hinzufügen",
"Look Up": "Nachschlagen"
} }

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