Files
zulip-desktop/app/renderer/js/main.ts
Anders Kaseorg 8d66f05924 xo: Sort imports with import/order.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2020-07-23 23:06:41 -07:00

1060 lines
34 KiB
TypeScript

import {ipcRenderer, remote, clipboard} from 'electron';
import path from 'path';
import isDev from 'electron-is-dev';
import escape from 'escape-html';
import * as Messages from '../../resources/messages';
import FunctionalTab from './components/functional-tab';
import ServerTab from './components/server-tab';
import WebView from './components/webview';
import {feedbackHolder} from './feedback';
import * as CommonUtil from './utils/common-util';
import * as ConfigUtil from './utils/config-util';
import * as DNDUtil from './utils/dnd-util';
import type {DNDSettings} from './utils/dnd-util';
import * as DomainUtil from './utils/domain-util';
import * as EnterpriseUtil from './utils/enterprise-util';
import * as LinkUtil from './utils/link-util';
import Logger from './utils/logger-util';
import ReconnectUtil from './utils/reconnect-util';
// eslint-disable-next-line import/no-unassigned-import
import './tray';
const {session, app, Menu, dialog} = remote;
interface FunctionalTabProps {
name: string;
materialIcon: string;
url: string;
}
interface SettingsOptions extends DNDSettings {
autoHideMenubar: boolean;
trayIcon: boolean;
useManualProxy: boolean;
useSystemProxy: boolean;
showSidebar: boolean;
badgeOption: boolean;
startAtLogin: boolean;
startMinimized: boolean;
enableSpellchecker: boolean;
autoUpdate: boolean;
betaUpdate: boolean;
errorReporting: boolean;
customCSS: boolean;
lastActiveTab: number;
dnd: boolean;
dndPreviousSettings: DNDSettings;
downloadsPath: string;
quitOnClose: boolean;
promptDownload: boolean;
dockBouncing?: boolean;
spellcheckerLanguages?: string[];
}
const logger = new Logger({
file: 'errors.log',
timestamp: true
});
const rendererDirectory = path.resolve(__dirname, '..');
export type ServerOrFunctionalTab = ServerTab | FunctionalTab;
class ServerManagerView {
$addServerButton: HTMLButtonElement;
$tabsContainer: Element;
$reloadButton: HTMLButtonElement;
$loadingIndicator: HTMLButtonElement;
$settingsButton: HTMLButtonElement;
$webviewsContainer: Element;
$backButton: HTMLButtonElement;
$dndButton: HTMLButtonElement;
$addServerTooltip: HTMLElement;
$reloadTooltip: HTMLElement;
$loadingTooltip: HTMLElement;
$settingsTooltip: HTMLElement;
$serverIconTooltip: HTMLCollectionOf<HTMLElement>;
$backTooltip: HTMLElement;
$dndTooltip: HTMLElement;
$sidebar: Element;
$fullscreenPopup: Element;
$fullscreenEscapeKey: string;
loading: Set<string>;
activeTabIndex: number;
tabs: ServerOrFunctionalTab[];
functionalTabs: Map<string, number>;
tabIndex: number;
presetOrgs: string[];
constructor() {
this.$addServerButton = document.querySelector('#add-tab');
this.$tabsContainer = document.querySelector('#tabs-container');
const $actionsContainer = document.querySelector('#actions-container');
this.$reloadButton = $actionsContainer.querySelector('#reload-action');
this.$loadingIndicator = $actionsContainer.querySelector('#loading-action');
this.$settingsButton = $actionsContainer.querySelector('#settings-action');
this.$webviewsContainer = document.querySelector('#webviews-container');
this.$backButton = $actionsContainer.querySelector('#back-action');
this.$dndButton = $actionsContainer.querySelector('#dnd-action');
this.$addServerTooltip = document.querySelector('#add-server-tooltip');
this.$reloadTooltip = $actionsContainer.querySelector('#reload-tooltip');
this.$loadingTooltip = $actionsContainer.querySelector('#loading-tooltip');
this.$settingsTooltip = $actionsContainer.querySelector('#setting-tooltip');
// TODO: This should have been querySelector but the problem is that
// querySelector doesn't return elements not present in dom whereas somehow
// getElementsByClassName does. To fix this we need to call this after this.initTabs
// is called in this.init.
// eslint-disable-next-line unicorn/prefer-query-selector
this.$serverIconTooltip = document.getElementsByClassName('server-tooltip') as HTMLCollectionOf<HTMLElement>;
this.$backTooltip = $actionsContainer.querySelector('#back-tooltip');
this.$dndTooltip = $actionsContainer.querySelector('#dnd-tooltip');
this.$sidebar = document.querySelector('#sidebar');
this.$fullscreenPopup = document.querySelector('#fullscreen-popup');
this.$fullscreenEscapeKey = process.platform === 'darwin' ? '^⌘F' : 'F11';
this.$fullscreenPopup.innerHTML = `Press ${this.$fullscreenEscapeKey} to exit full screen`;
this.loading = new Set();
this.activeTabIndex = -1;
this.tabs = [];
this.presetOrgs = [];
this.functionalTabs = new Map();
this.tabIndex = 0;
}
async init(): Promise<void> {
await this.loadProxy();
this.initDefaultSettings();
this.initSidebar();
this.removeUAfromDisk();
if (EnterpriseUtil.hasConfigFile()) {
await this.initPresetOrgs();
}
await this.initTabs();
this.initActions();
this.registerIpcs();
ipcRenderer.send('set-spellcheck-langs');
}
async loadProxy(): Promise<void> {
// To change proxyEnable to useManualProxy in older versions
const proxyEnabledOld = ConfigUtil.isConfigItemExists('useProxy');
if (proxyEnabledOld) {
const proxyEnableOldState = ConfigUtil.getConfigItem('useProxy');
if (proxyEnableOldState) {
ConfigUtil.setConfigItem('useManualProxy', true);
}
ConfigUtil.removeConfigItem('useProxy');
}
const proxyEnabled = ConfigUtil.getConfigItem('useManualProxy') || ConfigUtil.getConfigItem('useSystemProxy');
if (proxyEnabled) {
await session.fromPartition('persist:webviewsession').setProxy({
pacScript: ConfigUtil.getConfigItem('proxyPAC', ''),
proxyRules: ConfigUtil.getConfigItem('proxyRules', ''),
proxyBypassRules: ConfigUtil.getConfigItem('proxyBypass', '')
});
} else {
await session.fromPartition('persist:webviewsession').setProxy({
pacScript: '',
proxyRules: '',
proxyBypassRules: ''
});
}
}
// Settings are initialized only when user clicks on General/Server/Network section settings
// In case, user doesn't visit these section, those values set to be null automatically
// This will make sure the default settings are correctly set to either true or false
initDefaultSettings(): void {
// Default settings which should be respected
const settingOptions: SettingsOptions = {
autoHideMenubar: false,
trayIcon: true,
useManualProxy: false,
useSystemProxy: false,
showSidebar: true,
badgeOption: true,
startAtLogin: false,
startMinimized: false,
enableSpellchecker: true,
showNotification: true,
autoUpdate: true,
betaUpdate: false,
errorReporting: true,
customCSS: false,
silent: false,
lastActiveTab: 0,
dnd: false,
dndPreviousSettings: {
showNotification: true,
silent: false
},
downloadsPath: `${app.getPath('downloads')}`,
quitOnClose: false,
promptDownload: false
};
// Platform specific settings
if (process.platform === 'win32') {
// Only available on Windows
settingOptions.flashTaskbarOnMessage = true;
settingOptions.dndPreviousSettings.flashTaskbarOnMessage = true;
}
if (process.platform === 'darwin') {
// Only available on macOS
settingOptions.dockBouncing = true;
}
if (process.platform !== 'darwin') {
settingOptions.autoHideMenubar = false;
settingOptions.spellcheckerLanguages = ['en-US'];
}
for (const [setting, value] of Object.entries(settingOptions)) {
// Give preference to defaults defined in global_config.json
if (EnterpriseUtil.configItemExists(setting)) {
ConfigUtil.setConfigItem(setting, EnterpriseUtil.getConfigItem(setting), true);
} else if (ConfigUtil.getConfigItem(setting) === null) {
ConfigUtil.setConfigItem(setting, value);
}
}
}
initSidebar(): void {
const showSidebar = ConfigUtil.getConfigItem('showSidebar', true);
this.toggleSidebar(showSidebar);
}
// Remove the stale UA string from the disk if the app is not freshly
// installed. This should be removed in a further release.
removeUAfromDisk(): void {
ConfigUtil.removeConfigItem('userAgent');
}
async queueDomain(domain: string): Promise<boolean> {
// Allows us to start adding multiple domains to the app simultaneously
// promise of addition resolves in both cases, but we consider it rejected
// if the resolved value is false
try {
const serverConf = await DomainUtil.checkDomain(domain);
await DomainUtil.addDomain(serverConf);
return true;
} catch (error) {
logger.error(error);
logger.error(`Could not add ${domain}. Please contact your system administrator.`);
return false;
}
}
async initPresetOrgs(): Promise<void> {
// Read preset organizations from global_config.json and queues them
// for addition to the app's domains
const preAddedDomains = DomainUtil.getDomains();
this.presetOrgs = EnterpriseUtil.getConfigItem('presetOrganizations', []);
// Set to true if at least one new domain is added
const domainPromises = [];
for (const url of this.presetOrgs) {
if (DomainUtil.duplicateDomain(url)) {
continue;
}
domainPromises.push(this.queueDomain(url));
}
const domainsAdded = await Promise.all(domainPromises);
if (domainsAdded.includes(true)) {
// At least one domain was resolved
if (preAddedDomains.length > 0) {
// User already has servers added
// ask them before reloading the app
const {response} = await dialog.showMessageBox({
type: 'question',
buttons: ['Yes', 'Later'],
defaultId: 0,
message: 'New server' + (domainsAdded.length > 1 ? 's' : '') + ' added. Reload app now?'
});
if (response === 0) {
ipcRenderer.send('reload-full-app');
}
} else {
ipcRenderer.send('reload-full-app');
}
} else if (domainsAdded.length > 0) {
// Find all orgs that failed
const failedDomains: string[] = [];
for (const org of this.presetOrgs) {
if (DomainUtil.duplicateDomain(org)) {
continue;
}
failedDomains.push(org);
}
const {title, content} = Messages.enterpriseOrgError(domainsAdded.length, failedDomains);
dialog.showErrorBox(title, content);
if (DomainUtil.getDomains().length === 0) {
// No orgs present, stop showing loading gif
await this.openSettings('AddServer');
}
}
}
async initTabs(): Promise<void> {
const servers = DomainUtil.getDomains();
if (servers.length > 0) {
for (const [i, server] of servers.entries()) {
this.initServer(server, i);
}
// Open last active tab
let lastActiveTab = ConfigUtil.getConfigItem('lastActiveTab');
if (lastActiveTab >= servers.length) {
lastActiveTab = 0;
}
// `checkDomain()` and `webview.load()` for lastActiveTab before the others
await DomainUtil.updateSavedServer(servers[lastActiveTab].url, lastActiveTab);
this.activateTab(lastActiveTab);
await Promise.all(servers.map(async (server, i) => {
// After the lastActiveTab is activated, we load the others in the background
// without activating them, to prevent flashing of server icons
if (i === lastActiveTab) {
return;
}
await DomainUtil.updateSavedServer(server.url, i);
this.tabs[i].webview.load();
}));
// Remove focus from the settings icon at sidebar bottom
this.$settingsButton.classList.remove('active');
} else if (this.presetOrgs.length === 0) {
// Not attempting to add organisations in the background
await this.openSettings('AddServer');
} else {
this.showLoading(true);
}
}
initServer(server: DomainUtil.ServerConf, index: number): void {
const tabIndex = this.getTabIndex();
this.tabs.push(new ServerTab({
role: 'server',
icon: server.icon,
name: CommonUtil.decodeString(server.alias),
$root: this.$tabsContainer,
onClick: this.activateLastTab.bind(this, index),
index,
tabIndex,
onHover: this.onHover.bind(this, index),
onHoverOut: this.onHoverOut.bind(this, index),
webview: new WebView({
$root: this.$webviewsContainer,
index,
tabIndex,
url: server.url,
role: 'server',
name: CommonUtil.decodeString(server.alias),
hasPermission: (origin: string, permission: string) =>
origin === server.url && permission === 'notifications',
isActive: () => index === this.activeTabIndex,
switchLoading: (loading: boolean, url: string) => {
if (loading) {
this.loading.add(url);
} else {
this.loading.delete(url);
}
this.showLoading(this.loading.has(this.tabs[this.activeTabIndex].webview.props.url));
},
onNetworkError: (index: number) => this.openNetworkTroubleshooting(index),
onTitleChange: this.updateBadge.bind(this),
nodeIntegration: false,
preload: true
})
}));
this.loading.add(server.url);
}
initActions(): void {
this.initDNDButton();
this.initServerActions();
this.initLeftSidebarEvents();
}
initServerActions(): void {
const $serverImgs: NodeListOf<HTMLImageElement> = document.querySelectorAll('.server-icons');
$serverImgs.forEach(($serverImg, index) => {
this.addContextMenu($serverImg, index);
if ($serverImg.src.includes('img/icon.png')) {
this.displayInitialCharLogo($serverImg, index);
}
$serverImg.addEventListener('error', () => {
this.displayInitialCharLogo($serverImg, index);
});
});
}
initLeftSidebarEvents(): void {
this.$dndButton.addEventListener('click', () => {
const dndUtil = DNDUtil.toggle();
ipcRenderer.send('forward-message', 'toggle-dnd', dndUtil.dnd, dndUtil.newSettings);
});
this.$reloadButton.addEventListener('click', () => {
this.tabs[this.activeTabIndex].webview.reload();
});
this.$addServerButton.addEventListener('click', async () => {
await this.openSettings('AddServer');
});
this.$settingsButton.addEventListener('click', async () => {
await this.openSettings('General');
});
this.$backButton.addEventListener('click', () => {
this.tabs[this.activeTabIndex].webview.back();
});
this.sidebarHoverEvent(this.$addServerButton, this.$addServerTooltip, true);
this.sidebarHoverEvent(this.$loadingIndicator, this.$loadingTooltip);
this.sidebarHoverEvent(this.$settingsButton, this.$settingsTooltip);
this.sidebarHoverEvent(this.$reloadButton, this.$reloadTooltip);
this.sidebarHoverEvent(this.$backButton, this.$backTooltip);
this.sidebarHoverEvent(this.$dndButton, this.$dndTooltip);
}
initDNDButton(): void {
const dnd = ConfigUtil.getConfigItem('dnd', false);
this.toggleDNDButton(dnd);
}
getTabIndex(): number {
const currentIndex = this.tabIndex;
this.tabIndex++;
return currentIndex;
}
getCurrentActiveServer(): string {
return this.tabs[this.activeTabIndex].webview.props.url;
}
displayInitialCharLogo($img: HTMLImageElement, index: number): void {
// The index parameter is needed because webview[data-tab-id] can
// increment beyond the size of the sidebar org array and throw an
// error
const $altIcon = document.createElement('div');
const $parent = $img.parentElement;
const $container = $parent.parentElement;
const webviewId = $container.dataset.tabId;
const $webview = document.querySelector(`webview[data-tab-id="${webviewId}"]`);
const realmName = $webview.getAttribute('name');
if (realmName === null) {
$img.src = '/img/icon.png';
return;
}
$altIcon.textContent = realmName.charAt(0) || 'Z';
$altIcon.classList.add('server-icon');
$altIcon.classList.add('alt-icon');
$img.remove();
$parent.append($altIcon);
this.addContextMenu($altIcon as HTMLImageElement, index);
}
sidebarHoverEvent(SidebarButton: HTMLButtonElement, SidebarTooltip: HTMLElement, addServer = false): void {
SidebarButton.addEventListener('mouseover', () => {
SidebarTooltip.removeAttribute('style');
// To handle position of add server tooltip due to scrolling of list of organizations
// This could not be handled using CSS, hence the top of the tooltip is made same
// as that of its parent element.
// This needs to handled only for the add server tooltip and not others.
if (addServer) {
const {top} = SidebarButton.getBoundingClientRect();
SidebarTooltip.style.top = `${top}px`;
}
});
SidebarButton.addEventListener('mouseout', () => {
SidebarTooltip.style.display = 'none';
});
}
onHover(index: number): void {
// `this.$serverIconTooltip[index].innerHTML` already has realm name, so we are just
// removing the style.
this.$serverIconTooltip[index].removeAttribute('style');
// To handle position of servers' tooltip due to scrolling of list of organizations
// This could not be handled using CSS, hence the top of the tooltip is made same
// as that of its parent element.
const {top} = this.$serverIconTooltip[index].parentElement.getBoundingClientRect();
this.$serverIconTooltip[index].style.top = `${top}px`;
}
onHoverOut(index: number): void {
this.$serverIconTooltip[index].style.display = 'none';
}
openFunctionalTab(tabProps: FunctionalTabProps): void {
if (this.functionalTabs.has(tabProps.name)) {
this.activateTab(this.functionalTabs.get(tabProps.name));
return;
}
this.functionalTabs.set(tabProps.name, this.tabs.length);
const tabIndex = this.getTabIndex();
this.tabs.push(new FunctionalTab({
role: 'function',
materialIcon: tabProps.materialIcon,
name: tabProps.name,
$root: this.$tabsContainer,
index: this.functionalTabs.get(tabProps.name),
tabIndex,
onClick: this.activateTab.bind(this, this.functionalTabs.get(tabProps.name)),
onDestroy: this.destroyTab.bind(this, tabProps.name, this.functionalTabs.get(tabProps.name)),
webview: new WebView({
$root: this.$webviewsContainer,
index: this.functionalTabs.get(tabProps.name),
tabIndex,
url: tabProps.url,
role: 'function',
name: tabProps.name,
isActive: () => this.functionalTabs.get(tabProps.name) === this.activeTabIndex,
switchLoading: (loading: boolean, url: string) => {
if (loading) {
this.loading.add(url);
} else {
this.loading.delete(url);
}
this.showLoading(this.loading.has(this.tabs[this.activeTabIndex].webview.props.url));
},
onNetworkError: (index: number) => this.openNetworkTroubleshooting(index),
onTitleChange: this.updateBadge.bind(this),
nodeIntegration: true,
preload: false
})
}));
// To show loading indicator the first time a functional tab is opened, indicator is
// closed when the functional tab DOM is ready, handled in webview.js
this.$webviewsContainer.classList.remove('loaded');
this.activateTab(this.functionalTabs.get(tabProps.name));
}
async openSettings(nav = 'General'): Promise<void> {
this.openFunctionalTab({
name: 'Settings',
materialIcon: 'settings',
url: `file://${rendererDirectory}/preference.html#${nav}`
});
this.$settingsButton.classList.add('active');
await this.tabs[this.functionalTabs.get('Settings')].webview.send('switch-settings-nav', nav);
}
openAbout(): void {
this.openFunctionalTab({
name: 'About',
materialIcon: 'sentiment_very_satisfied',
url: `file://${rendererDirectory}/about.html`
});
}
openNetworkTroubleshooting(index: number): void {
const reconnectUtil = new ReconnectUtil(this.tabs[index].webview);
reconnectUtil.pollInternetAndReload();
this.tabs[index].webview.props.url = `file://${rendererDirectory}/network.html`;
this.tabs[index].showNetworkError();
}
activateLastTab(index: number): void {
// Open all the tabs in background, also activate the tab based on the index
this.activateTab(index);
// Save last active tab via main process to avoid JSON DB errors
ipcRenderer.send('save-last-tab', index);
}
// Returns this.tabs in an way that does
// not crash app when this.tabs is passed into
// ipcRenderer. Something about webview, and props.webview
// properties in ServerTab causes the app to crash.
get tabsForIpc(): ServerOrFunctionalTab[] {
const tabs: ServerOrFunctionalTab[] = [];
this.tabs.forEach((tab: ServerOrFunctionalTab) => {
const proto = Object.create(Object.getPrototypeOf(tab));
const tabClone = Object.assign(proto, tab);
tabClone.webview = {props: {}};
tabClone.webview.props.name = tab.webview.props.name;
delete tabClone.props.webview;
tabs.push(tabClone);
});
return tabs;
}
activateTab(index: number, hideOldTab = true): void {
if (!this.tabs[index]) {
return;
}
if (this.activeTabIndex !== -1) {
if (this.activeTabIndex === index) {
return;
}
if (hideOldTab) {
// If old tab is functional tab Settings, remove focus from the settings icon at sidebar bottom
if (this.tabs[this.activeTabIndex].props.role === 'function' && this.tabs[this.activeTabIndex].props.name === 'Settings') {
this.$settingsButton.classList.remove('active');
}
this.tabs[this.activeTabIndex].deactivate();
}
}
try {
this.tabs[index].webview.canGoBackButton();
} catch {
}
this.activeTabIndex = index;
this.tabs[index].activate();
this.showLoading(this.loading.has(this.tabs[this.activeTabIndex].webview.props.url));
ipcRenderer.send('update-menu', {
// JSON stringify this.tabs to avoid a crash
// util.inspect is being used to handle circular references
tabs: this.tabsForIpc,
activeTabIndex: this.activeTabIndex,
// Following flag controls whether a menu item should be enabled or not
enableMenu: this.tabs[index].props.role === 'server'
});
}
showLoading(loading: boolean): void {
if (!loading) {
this.$reloadButton.removeAttribute('style');
this.$loadingIndicator.style.display = 'none';
} else if (loading) {
this.$reloadButton.style.display = 'none';
this.$loadingIndicator.removeAttribute('style');
}
}
destroyTab(name: string, index: number): void {
if (this.tabs[index].webview.loading) {
return;
}
this.tabs[index].destroy();
delete this.tabs[index];
this.functionalTabs.delete(name);
// Issue #188: If the functional tab was not focused, do not activate another tab.
if (this.activeTabIndex === index) {
this.activateTab(0, false);
}
}
destroyView(): void {
// Show loading indicator
this.$webviewsContainer.classList.remove('loaded');
// Clear global variables
this.activeTabIndex = -1;
this.tabs = [];
this.functionalTabs.clear();
// Clear DOM elements
this.$tabsContainer.innerHTML = '';
this.$webviewsContainer.innerHTML = '';
}
async reloadView(): Promise<void> {
// Save and remember the index of last active tab so that we can use it later
const lastActiveTab = this.tabs[this.activeTabIndex].props.index;
ConfigUtil.setConfigItem('lastActiveTab', lastActiveTab);
// Destroy the current view and re-initiate it
this.destroyView();
await this.initTabs();
this.initServerActions();
}
// This will trigger when pressed CTRL/CMD + R [WIP]
// It won't reload the current view properly when you add/delete a server.
reloadCurrentView(): void {
this.$reloadButton.click();
}
updateBadge(): void {
let messageCountAll = 0;
for (const tab of this.tabs) {
if (tab && tab instanceof ServerTab && tab.updateBadge) {
const count = tab.webview.badgeCount;
messageCountAll += count;
tab.updateBadge(count);
}
}
ipcRenderer.send('update-badge', messageCountAll);
}
updateGeneralSettings(setting: string, value: unknown): void {
if (this.getActiveWebview()) {
const webContents = remote.webContents.fromId(this.getActiveWebview().getWebContentsId());
webContents.send(setting, value);
}
}
toggleSidebar(show: boolean): void {
if (show) {
this.$sidebar.classList.remove('sidebar-hide');
} else {
this.$sidebar.classList.add('sidebar-hide');
}
}
// Toggles the dnd button icon.
toggleDNDButton(alert: boolean): void {
this.$dndTooltip.textContent = (alert ? 'Disable' : 'Enable') + ' Do Not Disturb';
this.$dndButton.querySelector('i').textContent = alert ? 'notifications_off' : 'notifications';
}
isLoggedIn(tabIndex: number): boolean {
const url = this.tabs[tabIndex].webview.$el.src;
return !(url.endsWith('/login/') || this.tabs[tabIndex].webview.loading);
}
getActiveWebview(): Electron.WebviewTag {
const selector = 'webview:not(.disabled)';
const webview: Electron.WebviewTag = document.querySelector(selector);
return webview;
}
addContextMenu($serverImg: HTMLImageElement, index: number): void {
$serverImg.addEventListener('contextmenu', event => {
event.preventDefault();
const template = [
{
label: 'Disconnect organization',
click: async () => {
const {response} = await dialog.showMessageBox({
type: 'warning',
buttons: ['YES', 'NO'],
defaultId: 0,
message: 'Are you sure you want to disconnect this organization?'
});
if (response === 0) {
if (DomainUtil.removeDomain(index)) {
ipcRenderer.send('reload-full-app');
} else {
const {title, content} = Messages.orgRemovalError(DomainUtil.getDomain(index).url);
dialog.showErrorBox(title, content);
}
}
}
},
{
label: 'Notification settings',
enabled: this.isLoggedIn(index),
click: () => {
// Switch to tab whose icon was right-clicked
this.activateTab(index);
this.tabs[index].webview.showNotificationSettings();
}
},
{
label: 'Copy Zulip URL',
click: () => {
clipboard.writeText(DomainUtil.getDomain(index).url);
}
}
];
const contextMenu = Menu.buildFromTemplate(template);
contextMenu.popup({window: remote.getCurrentWindow()});
});
}
registerIpcs(): void {
const webviewListeners: Array<[string, (webview: WebView) => void]> = [
['webview-reload', webview => webview.reload()],
['back', webview => webview.back()],
['focus', webview => webview.focus()],
['forward', webview => webview.forward()],
['zoomIn', webview => webview.zoomIn()],
['zoomOut', webview => webview.zoomOut()],
['zoomActualSize', webview => webview.zoomActualSize()],
['log-out', webview => webview.logOut()],
['shortcut', webview => webview.showShortcut()],
['tab-devtools', webview => webview.openDevTools()]
];
for (const [channel, listener] of webviewListeners) {
ipcRenderer.on(channel, () => {
const activeWebview = this.tabs[this.activeTabIndex].webview;
if (activeWebview) {
listener(activeWebview);
}
});
}
ipcRenderer.on('permission-request', (
event: Event,
{webContentsId, origin, permission}: {
webContentsId: number | null;
origin: string;
permission: string;
},
rendererCallbackId: number
) => {
const grant = webContentsId === null ?
origin === 'null' && permission === 'notifications' :
this.tabs.some(
({webview}) =>
!webview.loading &&
webview.$el.getWebContentsId() === webContentsId &&
webview.props.hasPermission?.(origin, permission)
);
console.log(
grant ? 'Granted' : 'Denied', 'permissions request for',
permission, 'from', origin
);
ipcRenderer.send('renderer-callback', rendererCallbackId, grant);
});
ipcRenderer.on('show-network-error', (event: Event, index: number) => {
this.openNetworkTroubleshooting(index);
});
ipcRenderer.on('open-settings', async (event: Event, settingNav: string) => {
await this.openSettings(settingNav);
});
ipcRenderer.on('open-about', this.openAbout.bind(this));
ipcRenderer.on('open-help', async () => {
// Open help page of current active server
await LinkUtil.openBrowser(new URL('https://zulip.com/help/'));
});
ipcRenderer.on('reload-viewer', this.reloadView.bind(this, this.tabs[this.activeTabIndex].props.index));
ipcRenderer.on('reload-current-viewer', this.reloadCurrentView.bind(this));
ipcRenderer.on('hard-reload', () => {
ipcRenderer.send('reload-full-app');
});
ipcRenderer.on('switch-server-tab', (event: Event, index: number) => {
this.activateLastTab(index);
});
ipcRenderer.on('open-org-tab', async () => {
await this.openSettings('AddServer');
});
ipcRenderer.on('reload-proxy', async (event: Event, showAlert: boolean) => {
await this.loadProxy();
if (showAlert) {
await dialog.showMessageBox({
message: 'Proxy settings saved!',
buttons: ['OK']
});
ipcRenderer.send('reload-full-app');
}
});
ipcRenderer.on('toggle-sidebar', (event: Event, show: boolean) => {
// Toggle the left sidebar
this.toggleSidebar(show);
// Toggle sidebar switch in the general settings
this.updateGeneralSettings('toggle-sidebar-setting', show);
});
ipcRenderer.on('toggle-silent', (event: Event, state: boolean) => {
const webviews: NodeListOf<Electron.WebviewTag> = document.querySelectorAll('webview');
webviews.forEach(webview => {
try {
webview.setAudioMuted(state);
} catch {
// Webview is not ready yet
webview.addEventListener('dom-ready', () => {
webview.setAudioMuted(state);
});
}
});
});
ipcRenderer.on('toggle-autohide-menubar', (event: Event, autoHideMenubar: boolean, updateMenu: boolean) => {
if (updateMenu) {
ipcRenderer.send('update-menu', {
tabs: this.tabsForIpc,
activeTabIndex: this.activeTabIndex
});
return;
}
this.updateGeneralSettings('toggle-menubar-setting', autoHideMenubar);
});
ipcRenderer.on('toggle-dnd', (event: Event, state: boolean, newSettings: DNDSettings) => {
this.toggleDNDButton(state);
ipcRenderer.send('forward-message', 'toggle-silent', newSettings.silent);
const webContents = remote.webContents.fromId(this.getActiveWebview().getWebContentsId());
webContents.send('toggle-dnd', state, newSettings);
});
ipcRenderer.on('update-realm-name', (event: Event, serverURL: string, realmName: string) => {
DomainUtil.getDomains().forEach((domain: DomainUtil.ServerConf, index: number) => {
if (domain.url.includes(serverURL)) {
const serverTooltipSelector = '.tab .server-tooltip';
const serverTooltips = document.querySelectorAll(serverTooltipSelector);
serverTooltips[index].innerHTML = escape(realmName);
this.tabs[index].props.name = escape(realmName);
this.tabs[index].webview.props.name = realmName;
domain.alias = escape(realmName);
DomainUtil.updateDomain(index, domain);
// Update the realm name also on the Window menu
ipcRenderer.send('update-menu', {
tabs: this.tabsForIpc,
activeTabIndex: this.activeTabIndex
});
}
});
});
ipcRenderer.on('update-realm-icon', (event: Event, serverURL: string, iconURL: string) => {
DomainUtil.getDomains().forEach(async (domain, index) => {
if (domain.url.includes(serverURL)) {
const localIconUrl: string = await DomainUtil.saveServerIcon({
url: serverURL,
icon: iconURL
});
const serverImgsSelector = '.tab .server-icons';
const serverImgs: NodeListOf<HTMLImageElement> = document.querySelectorAll(serverImgsSelector);
serverImgs[index].src = localIconUrl;
domain.icon = localIconUrl;
DomainUtil.updateDomain(index, domain);
}
});
});
ipcRenderer.on('enter-fullscreen', () => {
this.$fullscreenPopup.classList.add('show');
this.$fullscreenPopup.classList.remove('hidden');
});
ipcRenderer.on('leave-fullscreen', () => {
this.$fullscreenPopup.classList.remove('show');
});
ipcRenderer.on('focus-webview-with-id', (event: Event, webviewId: number) => {
const webviews: NodeListOf<Electron.WebviewTag> = document.querySelectorAll('webview');
webviews.forEach(webview => {
const currentId = webview.getWebContentsId();
const tabId = webview.getAttribute('data-tab-id');
const concurrentTab: HTMLButtonElement = document.querySelector(`div[data-tab-id="${tabId}"]`);
if (currentId === webviewId) {
concurrentTab.click();
}
});
});
ipcRenderer.on('render-taskbar-icon', (event: Event, messageCount: number) => {
// Create a canvas from unread messagecounts
function createOverlayIcon(messageCount: number): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.height = 128;
canvas.width = 128;
canvas.style.letterSpacing = '-5px';
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#f42020';
ctx.beginPath();
ctx.ellipse(64, 64, 64, 64, 0, 0, 2 * Math.PI);
ctx.fill();
ctx.textAlign = 'center';
ctx.fillStyle = 'white';
if (messageCount > 99) {
ctx.font = '65px Helvetica';
ctx.fillText('99+', 64, 85);
} else if (messageCount < 10) {
ctx.font = '90px Helvetica';
ctx.fillText(String(Math.min(99, messageCount)), 64, 96);
} else {
ctx.font = '85px Helvetica';
ctx.fillText(String(Math.min(99, messageCount)), 64, 90);
}
return canvas;
}
ipcRenderer.send('update-taskbar-icon', createOverlayIcon(messageCount).toDataURL(), String(messageCount));
});
ipcRenderer.on('open-feedback-modal', () => {
feedbackHolder.classList.add('show');
});
ipcRenderer.on('copy-zulip-url', () => {
clipboard.writeText(this.getCurrentActiveServer());
});
ipcRenderer.on('new-server', async () => {
await this.openSettings('AddServer');
});
// Redo and undo functionality since the default API doesn't work on macOS
ipcRenderer.on('undo', () => this.getActiveWebview().undo());
ipcRenderer.on('redo', () => this.getActiveWebview().redo());
ipcRenderer.on('set-active', async () => {
const webviews: NodeListOf<Electron.WebviewTag> = document.querySelectorAll('webview');
await Promise.all([...webviews].map(async webview => webview.send('set-active')));
});
ipcRenderer.on('set-idle', async () => {
const webviews: NodeListOf<Electron.WebviewTag> = document.querySelectorAll('webview');
await Promise.all([...webviews].map(async webview => webview.send('set-idle')));
});
ipcRenderer.on('open-network-settings', async () => {
await this.openSettings('Network');
});
}
}
window.addEventListener('load', async () => {
// Only start electron-connect (auto reload on change) when its ran
// from `npm run dev` or `gulp dev` and not from `npm start`
if (isDev && remote.getGlobal('process').argv.includes('--electron-connect')) {
// eslint-disable-next-line node/no-unsupported-features/es-syntax
(await import('electron-connect')).client.create();
}
const serverManagerView = new ServerManagerView();
await serverManagerView.init();
});
export { };