Files
zulip-desktop/app/renderer/js/main.ts
Anders Kaseorg b35d45955b WebView: Move initialization from dom-ready event to did-attach event.
This fixes the bug where the context menu would disappear immediately
if the page had been loaded an even number of times.

Fixes #662, fixes #991, fixes #1010.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2021-10-08 18:10:43 -07:00

1209 lines
37 KiB
TypeScript

import {clipboard, remote} from "electron";
import path from "path";
import type {Config} from "../../common/config-util";
import * as ConfigUtil from "../../common/config-util";
import * as DNDUtil from "../../common/dnd-util";
import type {DNDSettings} from "../../common/dnd-util";
import * as EnterpriseUtil from "../../common/enterprise-util";
import Logger from "../../common/logger-util";
import * as Messages from "../../common/messages";
import type {RendererMessage} from "../../common/typed-ipc";
import type {NavItem, ServerConf, TabData} from "../../common/types";
import FunctionalTab from "./components/functional-tab";
import ServerTab from "./components/server-tab";
import WebView from "./components/webview";
import {ipcRenderer} from "./typed-ipc-renderer";
import * as DomainUtil from "./utils/domain-util";
import * as LinkUtil from "./utils/link-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;
}
type WebviewListener =
| "webview-reload"
| "back"
| "focus"
| "forward"
| "zoomIn"
| "zoomOut"
| "zoomActualSize"
| "log-out"
| "show-keyboard-shortcuts"
| "tab-devtools";
const logger = new Logger({
file: "errors.log",
});
const rendererDirectory = path.resolve(__dirname, "..");
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.textContent = `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", false);
if (proxyEnableOldState) {
ConfigUtil.setConfigItem("useManualProxy", true);
}
ConfigUtil.removeConfigItem("useProxy");
}
const proxyEnabled =
ConfigUtil.getConfigItem("useManualProxy", false) ||
ConfigUtil.getConfigItem("useSystemProxy", false);
await session.fromPartition("persist:webviewsession").setProxy(
proxyEnabled
? {
pacScript: ConfigUtil.getConfigItem("proxyPAC", ""),
proxyRules: ConfigUtil.getConfigItem("proxyRules", ""),
proxyBypassRules: ConfigUtil.getConfigItem("proxyBypass", ""),
}
: {
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: Partial<Config> = {
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) as Array<
{[Key in keyof Config]: [Key, Config[Key]]}[keyof Config]
>) {
// Give preference to defaults defined in global_config.json
if (EnterpriseUtil.configItemExists(setting)) {
ConfigUtil.setConfigItem(
setting,
EnterpriseUtil.getConfigItem(setting, value),
true,
);
} else if (!ConfigUtil.isConfigItemExists(setting)) {
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: unknown) {
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", 0);
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: ServerConf, index: number): void {
const tabIndex = this.getTabIndex();
this.tabs.push(
new ServerTab({
role: "server",
icon: server.icon,
name: 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: 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");
for (const [index, $serverImg] of $serverImgs.entries()) {
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="${CSS.escape(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, 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].textContent` 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: NavItem = "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(): TabData[] {
return this.tabs.map((tab) => ({
role: tab.props.role,
name: tab.props.name,
index: tab.props.index,
webviewName: tab.webview.props.name,
}));
}
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.textContent = "";
this.$webviewsContainer.textContent = "";
}
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<Channel extends keyof RendererMessage>(
channel: Channel,
...args: Parameters<RendererMessage[Channel]>
): void {
if (this.getActiveWebview()) {
const webContentsId = this.getActiveWebview().getWebContentsId();
ipcRenderer.sendTo(webContentsId, channel, ...args);
}
}
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: HTMLElement, 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: async () => {
// Switch to tab whose icon was right-clicked
this.activateTab(index);
await 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<
[WebviewListener, (webview: WebView) => void | Promise<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",
async (webview) => {
await webview.logOut();
},
],
[
"show-keyboard-shortcuts",
async (webview) => {
await webview.showKeyboardShortcuts();
},
],
[
"tab-devtools",
(webview) => {
webview.openDevTools();
},
],
];
for (const [channel, listener] of webviewListeners) {
ipcRenderer.on(channel, async () => {
const activeWebview = this.tabs[this.activeTabIndex].webview;
if (activeWebview) {
await listener(activeWebview);
}
});
}
ipcRenderer.on(
"permission-request",
(
event: Event,
{
webContentsId,
origin,
permission,
}: {
webContentsId: number | null;
origin: string;
permission: string;
},
permissionCallbackId: 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("permission-callback", permissionCallbackId, grant);
},
);
ipcRenderer.on("show-network-error", (event: Event, index: number) => {
this.openNetworkTroubleshooting(index);
});
ipcRenderer.on("open-settings", async () => {
await this.openSettings();
});
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");
for (const webview of webviews) {
try {
webview.setAudioMuted(state);
} catch {
// Webview is not ready yet
webview.addEventListener("did-attach", () => {
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: Partial<DNDSettings>) => {
this.toggleDNDButton(state);
ipcRenderer.send(
"forward-message",
"toggle-silent",
newSettings.silent ?? false,
);
const webContentsId = this.getActiveWebview().getWebContentsId();
ipcRenderer.sendTo(webContentsId, "toggle-dnd", state, newSettings);
},
);
ipcRenderer.on(
"update-realm-name",
(event: Event, serverURL: string, realmName: string) => {
for (const [index, domain] of DomainUtil.getDomains().entries()) {
if (domain.url.includes(serverURL)) {
const serverTooltipSelector = ".tab .server-tooltip";
const serverTooltips = document.querySelectorAll(
serverTooltipSelector,
);
serverTooltips[index].textContent = realmName;
this.tabs[index].props.name = realmName;
this.tabs[index].webview.props.name = realmName;
domain.alias = 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",
async (event: Event, serverURL: string, iconURL: string) => {
await Promise.all(
DomainUtil.getDomains().map(async (domain, index) => {
if (domain.url.includes(serverURL)) {
const localIconUrl: string = await DomainUtil.saveServerIcon(
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");
for (const webview of webviews) {
const currentId = webview.getWebContentsId();
const tabId = webview.getAttribute("data-tab-id")!;
const concurrentTab: HTMLButtonElement = document.querySelector(
`div[data-tab-id="${CSS.escape(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("copy-zulip-url", () => {
clipboard.writeText(this.getCurrentActiveServer());
});
ipcRenderer.on("new-server", async () => {
await this.openSettings("AddServer");
});
ipcRenderer.on("set-active", () => {
const webviews: NodeListOf<Electron.WebviewTag> =
document.querySelectorAll("webview");
for (const webview of webviews) {
ipcRenderer.sendTo(webview.getWebContentsId(), "set-active");
}
});
ipcRenderer.on("set-idle", () => {
const webviews: NodeListOf<Electron.WebviewTag> =
document.querySelectorAll("webview");
for (const webview of webviews) {
ipcRenderer.sendTo(webview.getWebContentsId(), "set-idle");
}
});
ipcRenderer.on("open-network-settings", async () => {
await this.openSettings("Network");
});
}
}
window.addEventListener("load", async () => {
const serverManagerView = new ServerManagerView();
await serverManagerView.init();
});
export {};