From 08a1e76247b3c7ddeb7ef906a27f149e1a46e3d6 Mon Sep 17 00:00:00 2001 From: Eric Zhu Date: Sat, 16 Nov 2024 18:39:43 +0800 Subject: [PATCH] feat: add confirmation for opening external apps (#1618) * feat: add confirmation for opening external apps * update * update * update * feat: add expose dialog Signed-off-by: Innei * fix: handle all external url Signed-off-by: Innei --------- Signed-off-by: Innei Co-authored-by: Innei --- apps/main/src/window.ts | 30 ++++++++++++ .../src/components/ui/modal/stacked/hooks.tsx | 46 ++++++++++++++++++- .../src/components/ui/modal/stacked/types.tsx | 11 +++++ .../providers/extension-expose-provider.tsx | 9 +++- locales/native/en.json | 3 ++ packages/shared/src/bridge.ts | 14 +++++- 6 files changed, 110 insertions(+), 3 deletions(-) diff --git a/apps/main/src/window.ts b/apps/main/src/window.ts index cd5e0122fc..1091018d25 100644 --- a/apps/main/src/window.ts +++ b/apps/main/src/window.ts @@ -5,10 +5,12 @@ import { is } from "@electron-toolkit/utils" import { callWindowExpose } from "@follow/shared/bridge" import type { BrowserWindowConstructorOptions } from "electron" import { app, BrowserWindow, screen, shell } from "electron" +import type { Event } from "electron/main" import { START_IN_TRAY_ARGS } from "./constants/app" import { isDev, isMacOS, isWindows, isWindows11 } from "./env" import { getIconPath } from "./helper" +import { t } from "./lib/i18n" import { store } from "./lib/store" import { getTrayConfig } from "./lib/tray" import { logger } from "./logger" @@ -109,6 +111,34 @@ export function createWindow( return { action: "deny" } }) + const handleExternalProtocol = async (e: Event, url: string, window: BrowserWindow) => { + const { protocol } = new URL(url) + if (protocol === "http:" || protocol === "https:" || protocol === "follow:") { + return + } + e.preventDefault() + + const caller = callWindowExpose(window) + const confirm = await caller.dialog.ask({ + title: "Open External App?", + message: t("dialog.openExternalApp", { url, interpolation: { escapeValue: false } }), + confirmText: t("dialog.open"), + cancelText: t("dialog.cancel"), + }) + if (!confirm) { + return + } + shell.openExternal(url) + } + + // Handle main window external links + window.webContents.on("will-navigate", (e, url) => handleExternalProtocol(e, url, window)) + + // Handle webview external links + window.webContents.on("did-attach-webview", (_, webContents) => { + webContents.on("will-navigate", (e, url) => handleExternalProtocol(e, url, window)) + }) + // HMR for renderer base on electron-vite cli. // Load the remote URL for development or the local html file for production. if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { diff --git a/apps/renderer/src/components/ui/modal/stacked/hooks.tsx b/apps/renderer/src/components/ui/modal/stacked/hooks.tsx index 94ff4d26d8..90e2d1c5de 100644 --- a/apps/renderer/src/components/ui/modal/stacked/hooks.tsx +++ b/apps/renderer/src/components/ui/modal/stacked/hooks.tsx @@ -1,7 +1,9 @@ +import { Button } from "@follow/components/ui/button/index.js" import type { DragControls } from "framer-motion" import type { ResizeCallback, ResizeStartCallback } from "re-resizable" import { useCallback, useContext, useId, useRef, useState } from "react" import { flushSync } from "react-dom" +import { useTranslation } from "react-i18next" import { useContextSelector } from "use-context-selector" import { useEventCallback } from "usehooks-ts" @@ -11,7 +13,7 @@ import { jotaiStore } from "~/lib/jotai" import { modalStackAtom } from "./atom" import { ModalEventBus } from "./bus" import { CurrentModalContext, CurrentModalStateContext } from "./context" -import type { ModalProps, ModalStackOptions } from "./types" +import type { DialogInstance, ModalProps, ModalStackOptions } from "./types" export const modalIdToPropsMap = {} as Record export const useModalStack = (options?: ModalStackOptions) => { @@ -158,3 +160,45 @@ export const useResizeableModal = ( } export const useIsTopModal = () => useContextSelector(CurrentModalStateContext, (v) => v.isTop) + +export const useDialog = (): DialogInstance => { + const { present } = useModalStack() + const { t } = useTranslation() + return { + ask: useEventCallback((options) => { + return new Promise((resolve) => { + present({ + title: options.title, + content: ({ dismiss }) => ( +
+ {options.message} + +
+ + +
+
+ ), + canClose: true, + clickOutsideToDismiss: false, + }) + }) + }), + } +} diff --git a/apps/renderer/src/components/ui/modal/stacked/types.tsx b/apps/renderer/src/components/ui/modal/stacked/types.tsx index d5b00d3683..9aecce2db0 100644 --- a/apps/renderer/src/components/ui/modal/stacked/types.tsx +++ b/apps/renderer/src/components/ui/modal/stacked/types.tsx @@ -35,3 +35,14 @@ export interface ModalProps { export interface ModalStackOptions { wrapper?: FC } + +export interface DialogInstance { + ask: (options: { + title: string + message: string + onConfirm?: () => void + onCancel?: () => void + confirmText?: string + cancelText?: string + }) => Promise +} diff --git a/apps/renderer/src/providers/extension-expose-provider.tsx b/apps/renderer/src/providers/extension-expose-provider.tsx index 745e8b601d..4373d74b69 100644 --- a/apps/renderer/src/providers/extension-expose-provider.tsx +++ b/apps/renderer/src/providers/extension-expose-provider.tsx @@ -6,7 +6,7 @@ import { toast } from "sonner" import { getGeneralSettings } from "~/atoms/settings/general" import { getUISettings, useToggleZenMode } from "~/atoms/settings/ui" -import { useModalStack } from "~/components/ui/modal/stacked/hooks" +import { useDialog, useModalStack } from "~/components/ui/modal/stacked/hooks" import { useDiscoverRSSHubRouteModal } from "~/hooks/biz/useDiscoverRSSHubRoute" import { useFollow } from "~/hooks/biz/useFollow" import { usePresentUserProfileModal } from "~/modules/profile/hooks" @@ -71,5 +71,12 @@ export const ExtensionExposeProvider = () => { zenMode: toggleZenMode, }) }, [toggleZenMode]) + + const dialog = useDialog() + useEffect(() => { + registerGlobalContext({ + dialog, + }) + }, [dialog]) return null } diff --git a/locales/native/en.json b/locales/native/en.json index b26e489bfe..d5224802ac 100644 --- a/locales/native/en.json +++ b/locales/native/en.json @@ -19,8 +19,11 @@ "contextMenu.searchWithGoogle": "Search with Google", "contextMenu.selectAll": "Select All", "contextMenu.services": "Services", + "dialog.cancel": "Cancel", "dialog.clearAllData": "Are you sure you want to clear all data?", "dialog.no": "No", + "dialog.open": "Open", + "dialog.openExternalApp": "Are you sure you want to open \"{{url}}\" with other apps?", "dialog.yes": "Yes", "menu.about": "About {{name}}", "menu.actualSize": "Actual Size", diff --git a/packages/shared/src/bridge.ts b/packages/shared/src/bridge.ts index 652a5e7f00..b968f32a53 100644 --- a/packages/shared/src/bridge.ts +++ b/packages/shared/src/bridge.ts @@ -6,6 +6,17 @@ import type { GeneralSettings, UISettings } from "./interface/settings" const PREFIX = "__follow" +// eslint-disable-next-line unused-imports/no-unused-vars +declare const dialog: { + ask: (options: { + title: string + message: string + onConfirm?: () => void + onCancel?: () => void + confirmText?: string + cancelText?: string + }) => Promise +} interface RenderGlobalContext { /// Access Settings showSetting: (path?: string) => void @@ -34,6 +45,7 @@ interface RenderGlobalContext { /// Utils toast: typeof toast + dialog: typeof dialog // URL getWebUrl: () => string getApiUrl: () => string @@ -87,7 +99,7 @@ type AddPromise = T extends (...args: infer A) => Promise ? (...args: A) => Promise : T extends (...args: infer A) => infer R ? (...args: A) => Promise> - : any + : unknown type Fn = { [K in keyof T]: AddPromise &