mirror of
				https://github.com/zulip/zulip-desktop.git
				synced 2025-11-03 21:43:18 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			341 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			341 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import type {WebContents} from "electron/main";
 | 
						||
import fs from "node:fs";
 | 
						||
import process from "node:process";
 | 
						||
 | 
						||
import * as remote from "@electron/remote";
 | 
						||
import {app, dialog} from "@electron/remote";
 | 
						||
 | 
						||
import * as ConfigUtil from "../../../common/config-util.js";
 | 
						||
import type {Html} from "../../../common/html.js";
 | 
						||
import {html} from "../../../common/html.js";
 | 
						||
import type {RendererMessage} from "../../../common/typed-ipc.js";
 | 
						||
import type {TabRole} from "../../../common/types.js";
 | 
						||
import preloadCss from "../../css/preload.css?raw"; // eslint-disable-line n/file-extension-in-import
 | 
						||
import {ipcRenderer} from "../typed-ipc-renderer.js";
 | 
						||
import * as SystemUtil from "../utils/system-util.js";
 | 
						||
 | 
						||
import {generateNodeFromHtml} from "./base.js";
 | 
						||
import {contextMenu} from "./context-menu.js";
 | 
						||
 | 
						||
const shouldSilentWebview = ConfigUtil.getConfigItem("silent", false);
 | 
						||
 | 
						||
type WebViewProps = {
 | 
						||
  $root: Element;
 | 
						||
  rootWebContents: WebContents;
 | 
						||
  index: number;
 | 
						||
  tabIndex: number;
 | 
						||
  url: string;
 | 
						||
  role: TabRole;
 | 
						||
  isActive: () => boolean;
 | 
						||
  switchLoading: (loading: boolean, url: string) => void;
 | 
						||
  onNetworkError: (index: number) => void;
 | 
						||
  preload?: string;
 | 
						||
  onTitleChange: () => void;
 | 
						||
  hasPermission?: (origin: string, permission: string) => boolean;
 | 
						||
  unsupportedMessage?: string;
 | 
						||
};
 | 
						||
 | 
						||
export default class WebView {
 | 
						||
  static templateHtml(props: WebViewProps): Html {
 | 
						||
    return html`
 | 
						||
      <div class="webview-pane">
 | 
						||
        <div
 | 
						||
          class="webview-unsupported"
 | 
						||
          ${props.unsupportedMessage === undefined ? html`hidden` : html``}
 | 
						||
        >
 | 
						||
          <span class="webview-unsupported-message"
 | 
						||
            >${props.unsupportedMessage ?? ""}</span
 | 
						||
          >
 | 
						||
          <span class="webview-unsupported-dismiss">×</span>
 | 
						||
        </div>
 | 
						||
        <webview
 | 
						||
          data-tab-id="${props.tabIndex}"
 | 
						||
          src="${props.url}"
 | 
						||
          ${props.preload === undefined
 | 
						||
            ? html``
 | 
						||
            : html`preload="${props.preload}"`}
 | 
						||
          partition="persist:webviewsession"
 | 
						||
          allowpopups
 | 
						||
        >
 | 
						||
        </webview>
 | 
						||
      </div>
 | 
						||
    `;
 | 
						||
  }
 | 
						||
 | 
						||
  static async create(props: WebViewProps): Promise<WebView> {
 | 
						||
    const $pane = generateNodeFromHtml(
 | 
						||
      WebView.templateHtml(props),
 | 
						||
    ) as HTMLElement;
 | 
						||
    props.$root.append($pane);
 | 
						||
 | 
						||
    const $webview: HTMLElement = $pane.querySelector(":scope > webview")!;
 | 
						||
    await new Promise<void>((resolve) => {
 | 
						||
      $webview.addEventListener(
 | 
						||
        "did-attach",
 | 
						||
        () => {
 | 
						||
          resolve();
 | 
						||
        },
 | 
						||
        true,
 | 
						||
      );
 | 
						||
    });
 | 
						||
 | 
						||
    // Work around https://github.com/electron/electron/issues/26904
 | 
						||
    function getWebContentsIdFunction(
 | 
						||
      this: undefined,
 | 
						||
      selector: string,
 | 
						||
    ): number {
 | 
						||
      return document
 | 
						||
        .querySelector<Electron.WebviewTag>(selector)!
 | 
						||
        .getWebContentsId();
 | 
						||
    }
 | 
						||
 | 
						||
    const selector = `webview[data-tab-id="${CSS.escape(
 | 
						||
      `${props.tabIndex}`,
 | 
						||
    )}"]`;
 | 
						||
    const webContentsId: unknown =
 | 
						||
      await props.rootWebContents.executeJavaScript(
 | 
						||
        `(${getWebContentsIdFunction.toString()})(${JSON.stringify(selector)})`,
 | 
						||
      );
 | 
						||
    if (typeof webContentsId !== "number") {
 | 
						||
      throw new TypeError("Failed to get WebContents ID");
 | 
						||
    }
 | 
						||
 | 
						||
    return new WebView(props, $pane, $webview, webContentsId);
 | 
						||
  }
 | 
						||
 | 
						||
  badgeCount = 0;
 | 
						||
  loading = true;
 | 
						||
  private zoomFactor = 1;
 | 
						||
  private customCss: string | false | null;
 | 
						||
  private readonly $webviewsContainer: DOMTokenList;
 | 
						||
  private readonly $unsupported: HTMLElement;
 | 
						||
  private readonly $unsupportedMessage: HTMLElement;
 | 
						||
  private readonly $unsupportedDismiss: HTMLElement;
 | 
						||
  private unsupportedDismissed = false;
 | 
						||
 | 
						||
  private constructor(
 | 
						||
    readonly props: WebViewProps,
 | 
						||
    private readonly $pane: HTMLElement,
 | 
						||
    private readonly $webview: HTMLElement,
 | 
						||
    readonly webContentsId: number,
 | 
						||
  ) {
 | 
						||
    this.customCss = ConfigUtil.getConfigItem("customCSS", null);
 | 
						||
    this.$webviewsContainer = document.querySelector(
 | 
						||
      "#webviews-container",
 | 
						||
    )!.classList;
 | 
						||
    this.$unsupported = $pane.querySelector(".webview-unsupported")!;
 | 
						||
    this.$unsupportedMessage = $pane.querySelector(
 | 
						||
      ".webview-unsupported-message",
 | 
						||
    )!;
 | 
						||
    this.$unsupportedDismiss = $pane.querySelector(
 | 
						||
      ".webview-unsupported-dismiss",
 | 
						||
    )!;
 | 
						||
 | 
						||
    this.registerListeners();
 | 
						||
  }
 | 
						||
 | 
						||
  destroy(): void {
 | 
						||
    this.$pane.remove();
 | 
						||
  }
 | 
						||
 | 
						||
  getWebContents(): WebContents {
 | 
						||
    return remote.webContents.fromId(this.webContentsId)!;
 | 
						||
  }
 | 
						||
 | 
						||
  showNotificationSettings(): void {
 | 
						||
    this.send("show-notification-settings");
 | 
						||
  }
 | 
						||
 | 
						||
  focus(): void {
 | 
						||
    this.$webview.focus();
 | 
						||
    // Work around https://github.com/electron/electron/issues/31918
 | 
						||
    this.$webview.shadowRoot?.querySelector("iframe")?.focus();
 | 
						||
  }
 | 
						||
 | 
						||
  hide(): void {
 | 
						||
    this.$pane.classList.remove("active");
 | 
						||
  }
 | 
						||
 | 
						||
  load(): void {
 | 
						||
    this.show();
 | 
						||
  }
 | 
						||
 | 
						||
  zoomIn(): void {
 | 
						||
    this.zoomFactor += 0.1;
 | 
						||
    this.getWebContents().setZoomFactor(this.zoomFactor);
 | 
						||
  }
 | 
						||
 | 
						||
  zoomOut(): void {
 | 
						||
    this.zoomFactor -= 0.1;
 | 
						||
    this.getWebContents().setZoomFactor(this.zoomFactor);
 | 
						||
  }
 | 
						||
 | 
						||
  zoomActualSize(): void {
 | 
						||
    this.zoomFactor = 1;
 | 
						||
    this.getWebContents().setZoomFactor(this.zoomFactor);
 | 
						||
  }
 | 
						||
 | 
						||
  logOut(): void {
 | 
						||
    this.send("logout");
 | 
						||
  }
 | 
						||
 | 
						||
  showKeyboardShortcuts(): void {
 | 
						||
    this.send("show-keyboard-shortcuts");
 | 
						||
  }
 | 
						||
 | 
						||
  openDevTools(): void {
 | 
						||
    this.getWebContents().openDevTools();
 | 
						||
  }
 | 
						||
 | 
						||
  back(): void {
 | 
						||
    if (this.getWebContents().canGoBack()) {
 | 
						||
      this.getWebContents().goBack();
 | 
						||
      this.focus();
 | 
						||
    }
 | 
						||
  }
 | 
						||
 | 
						||
  canGoBackButton(): void {
 | 
						||
    const $backButton = document.querySelector(
 | 
						||
      "#actions-container #back-action",
 | 
						||
    )!;
 | 
						||
    $backButton.classList.toggle("disable", !this.getWebContents().canGoBack());
 | 
						||
  }
 | 
						||
 | 
						||
  forward(): void {
 | 
						||
    if (this.getWebContents().canGoForward()) {
 | 
						||
      this.getWebContents().goForward();
 | 
						||
    }
 | 
						||
  }
 | 
						||
 | 
						||
  reload(): void {
 | 
						||
    this.hide();
 | 
						||
    // Shows the loading indicator till the webview is reloaded
 | 
						||
    this.$webviewsContainer.remove("loaded");
 | 
						||
    this.loading = true;
 | 
						||
    this.props.switchLoading(true, this.props.url);
 | 
						||
    this.getWebContents().reload();
 | 
						||
  }
 | 
						||
 | 
						||
  setUnsupportedMessage(unsupportedMessage: string | undefined) {
 | 
						||
    this.$unsupported.hidden =
 | 
						||
      unsupportedMessage === undefined || this.unsupportedDismissed;
 | 
						||
    this.$unsupportedMessage.textContent = unsupportedMessage ?? "";
 | 
						||
  }
 | 
						||
 | 
						||
  send<Channel extends keyof RendererMessage>(
 | 
						||
    channel: Channel,
 | 
						||
    ...args: Parameters<RendererMessage[Channel]>
 | 
						||
  ): void {
 | 
						||
    ipcRenderer.sendTo(this.webContentsId, channel, ...args);
 | 
						||
  }
 | 
						||
 | 
						||
  private registerListeners(): void {
 | 
						||
    const webContents = this.getWebContents();
 | 
						||
 | 
						||
    if (shouldSilentWebview) {
 | 
						||
      webContents.setAudioMuted(true);
 | 
						||
    }
 | 
						||
 | 
						||
    webContents.on("page-title-updated", (_event, title) => {
 | 
						||
      this.badgeCount = this.getBadgeCount(title);
 | 
						||
      this.props.onTitleChange();
 | 
						||
    });
 | 
						||
 | 
						||
    this.$webview.addEventListener("did-navigate-in-page", () => {
 | 
						||
      this.canGoBackButton();
 | 
						||
    });
 | 
						||
 | 
						||
    this.$webview.addEventListener("did-navigate", () => {
 | 
						||
      this.canGoBackButton();
 | 
						||
    });
 | 
						||
 | 
						||
    webContents.on("page-favicon-updated", (_event, favicons) => {
 | 
						||
      // This returns a string of favicons URL. If there is a PM counts in unread messages then the URL would be like
 | 
						||
      // https://chat.zulip.org/static/images/favicon/favicon-pms.png
 | 
						||
      if (
 | 
						||
        favicons[0].indexOf("favicon-pms") > 0 &&
 | 
						||
        process.platform === "darwin"
 | 
						||
      ) {
 | 
						||
        // This api is only supported on macOS
 | 
						||
        app.dock.setBadge("●");
 | 
						||
        // Bounce the dock
 | 
						||
        if (ConfigUtil.getConfigItem("dockBouncing", true)) {
 | 
						||
          app.dock.bounce();
 | 
						||
        }
 | 
						||
      }
 | 
						||
    });
 | 
						||
 | 
						||
    webContents.addListener("context-menu", (event, menuParameters) => {
 | 
						||
      contextMenu(webContents, event, menuParameters);
 | 
						||
    });
 | 
						||
 | 
						||
    this.$webview.addEventListener("dom-ready", () => {
 | 
						||
      this.loading = false;
 | 
						||
      this.props.switchLoading(false, this.props.url);
 | 
						||
      this.show();
 | 
						||
    });
 | 
						||
 | 
						||
    webContents.on("did-fail-load", (_event, _errorCode, errorDescription) => {
 | 
						||
      const hasConnectivityError =
 | 
						||
        SystemUtil.connectivityError.includes(errorDescription);
 | 
						||
      if (hasConnectivityError) {
 | 
						||
        console.error("error", errorDescription);
 | 
						||
        if (!this.props.url.includes("network.html")) {
 | 
						||
          this.props.onNetworkError(this.props.index);
 | 
						||
        }
 | 
						||
      }
 | 
						||
    });
 | 
						||
 | 
						||
    this.$webview.addEventListener("did-start-loading", () => {
 | 
						||
      this.props.switchLoading(true, this.props.url);
 | 
						||
    });
 | 
						||
 | 
						||
    this.$webview.addEventListener("did-stop-loading", () => {
 | 
						||
      this.props.switchLoading(false, this.props.url);
 | 
						||
    });
 | 
						||
 | 
						||
    this.$unsupportedDismiss.addEventListener("click", () => {
 | 
						||
      this.unsupportedDismissed = true;
 | 
						||
      this.$unsupported.hidden = true;
 | 
						||
    });
 | 
						||
  }
 | 
						||
 | 
						||
  private getBadgeCount(title: string): number {
 | 
						||
    const messageCountInTitle = /^\((\d+)\)/.exec(title);
 | 
						||
    return messageCountInTitle ? Number(messageCountInTitle[1]) : 0;
 | 
						||
  }
 | 
						||
 | 
						||
  private show(): void {
 | 
						||
    // Do not show WebView if another tab was selected and this tab should be in background.
 | 
						||
    if (!this.props.isActive()) {
 | 
						||
      return;
 | 
						||
    }
 | 
						||
 | 
						||
    // To show or hide the loading indicator in the active tab
 | 
						||
    this.$webviewsContainer.toggle("loaded", !this.loading);
 | 
						||
 | 
						||
    this.$pane.classList.add("active");
 | 
						||
    this.focus();
 | 
						||
    this.props.onTitleChange();
 | 
						||
    // Injecting preload css in webview to override some css rules
 | 
						||
    (async () => this.getWebContents().insertCSS(preloadCss))();
 | 
						||
 | 
						||
    // Get customCSS again from config util to avoid warning user again
 | 
						||
    const customCss = ConfigUtil.getConfigItem("customCSS", null);
 | 
						||
    this.customCss = customCss;
 | 
						||
    if (customCss) {
 | 
						||
      if (!fs.existsSync(customCss)) {
 | 
						||
        this.customCss = null;
 | 
						||
        ConfigUtil.setConfigItem("customCSS", null);
 | 
						||
 | 
						||
        const errorMessage = "The custom css previously set is deleted!";
 | 
						||
        dialog.showErrorBox("custom css file deleted!", errorMessage);
 | 
						||
        return;
 | 
						||
      }
 | 
						||
 | 
						||
      (async () =>
 | 
						||
        this.getWebContents().insertCSS(fs.readFileSync(customCss, "utf8")))();
 | 
						||
    }
 | 
						||
  }
 | 
						||
}
 |