From ac644dcddd67fd3e3e976d6cc8ce46723f6fab0b Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Thu, 26 Mar 2026 16:13:08 -0700 Subject: [PATCH] done Signed-off-by: Yujong Lee --- apps/web/src/components/deeplink-prompt.tsx | 64 ++++++++++++ apps/web/src/hooks/use-auto-deeplink.ts | 49 +++++++++ apps/web/src/routes/_view/callback/auth.tsx | 72 ++------------ .../src/routes/_view/callback/integration.tsx | 99 +++---------------- 4 files changed, 132 insertions(+), 152 deletions(-) create mode 100644 apps/web/src/components/deeplink-prompt.tsx create mode 100644 apps/web/src/hooks/use-auto-deeplink.ts diff --git a/apps/web/src/components/deeplink-prompt.tsx b/apps/web/src/components/deeplink-prompt.tsx new file mode 100644 index 0000000000..944e53fb31 --- /dev/null +++ b/apps/web/src/components/deeplink-prompt.tsx @@ -0,0 +1,64 @@ +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "@hypr/utils"; + +import { openDeeplink } from "@/hooks/use-auto-deeplink"; + +export function DeeplinkPrompt({ url }: { url: string }) { + const [copied, setCopied] = useState(false); + + const handleDeeplink = () => { + openDeeplink(url); + }; + + const handleCopy = async () => { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ + + +
+ ); +} diff --git a/apps/web/src/hooks/use-auto-deeplink.ts b/apps/web/src/hooks/use-auto-deeplink.ts new file mode 100644 index 0000000000..4df7739425 --- /dev/null +++ b/apps/web/src/hooks/use-auto-deeplink.ts @@ -0,0 +1,49 @@ +import { useEffect, useRef } from "react"; + +function isMacOS(userAgent: string) { + const normalizedUserAgent = userAgent.toLowerCase(); + const isMobileAppleDevice = /iphone|ipad|ipod/.test(normalizedUserAgent); + return ( + (normalizedUserAgent.includes("macintosh") || + normalizedUserAgent.includes("mac os x")) && + !isMobileAppleDevice + ); +} + +export function openDeeplink(url: string) { + if (isMacOS(window.navigator.userAgent)) { + const iframe = document.createElement("iframe"); + iframe.src = url; + iframe.setAttribute("aria-hidden", "true"); + iframe.tabIndex = -1; + iframe.style.position = "absolute"; + iframe.style.width = "0"; + iframe.style.height = "0"; + iframe.style.border = "0"; + iframe.style.opacity = "0"; + iframe.style.pointerEvents = "none"; + document.body.append(iframe); + + return () => { + iframe.remove(); + }; + } + + window.location.href = url; + + return undefined; +} + +export function useAutoDeeplink(url: string | null) { + const lastTriggeredUrl = useRef(null); + + useEffect(() => { + if (!url || lastTriggeredUrl.current === url) { + return; + } + + lastTriggeredUrl.current = url; + + return openDeeplink(url); + }, [url]); +} diff --git a/apps/web/src/routes/_view/callback/auth.tsx b/apps/web/src/routes/_view/callback/auth.tsx index a44552e842..2f2bee6ca5 100644 --- a/apps/web/src/routes/_view/callback/auth.tsx +++ b/apps/web/src/routes/_view/callback/auth.tsx @@ -1,13 +1,14 @@ import { useOutlit } from "@outlit/browser/react"; import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; -import { CheckIcon, CopyIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { z } from "zod"; import { cn } from "@hypr/utils"; +import { DeeplinkPrompt } from "@/components/deeplink-prompt"; import { exchangeOAuthCode, exchangeOtpToken } from "@/functions/auth"; import { desktopSchemeSchema } from "@/functions/desktop-flow"; +import { useAutoDeeplink } from "@/hooks/use-auto-deeplink"; import { useAnalytics } from "@/hooks/use-posthog"; const validateSearch = z.object({ @@ -134,7 +135,6 @@ function Component() { const navigate = useNavigate(); const { identify: identifyOutlit, isInitialized } = useOutlit(); const { identify: identifyPosthog } = useAnalytics(); - const [copied, setCopied] = useState(false); useEffect(() => { if (!search.access_token || !isInitialized) return; @@ -169,26 +169,8 @@ function Component() { return null; }; - // Browsers require a user gesture (click) to open custom URL schemes. - // Auto-triggering via setTimeout fails for email magic links because - // the page is opened from an external context (email client) without - // "transient user activation". OAuth redirects work because they maintain - // activation through the redirect chain. - const handleDeeplink = () => { - const deeplink = getDeeplink(); - if (search.flow === "desktop" && deeplink) { - window.location.href = deeplink; - } - }; - - const handleCopy = async () => { - const deeplink = getDeeplink(); - if (deeplink) { - await navigator.clipboard.writeText(deeplink); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; + const deeplink = search.flow === "desktop" ? getDeeplink() : null; + useAutoDeeplink(deeplink); useEffect(() => { if (search.flow === "web" && !search.error) { @@ -246,49 +228,7 @@ function Component() {

- {hasTokens && ( -
- - - -
- )} + {hasTokens && deeplink && } ); diff --git a/apps/web/src/routes/_view/callback/integration.tsx b/apps/web/src/routes/_view/callback/integration.tsx index 5283b0d0c2..f542adfef7 100644 --- a/apps/web/src/routes/_view/callback/integration.tsx +++ b/apps/web/src/routes/_view/callback/integration.tsx @@ -1,12 +1,11 @@ import { useQueryClient } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { CheckIcon, CopyIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { z } from "zod"; -import { cn } from "@hypr/utils"; - +import { DeeplinkPrompt } from "@/components/deeplink-prompt"; import { flowSearchSchema } from "@/functions/desktop-flow"; +import { useAutoDeeplink } from "@/hooks/use-auto-deeplink"; const commonSearch = { integration_id: z.string(), @@ -51,31 +50,17 @@ function Component() { const scheme = search.scheme ?? "hyprnote"; const navigate = useNavigate(); const queryClient = useQueryClient(); - const [copied, setCopied] = useState(false); - - const getDeeplink = () => { - return buildDeeplinkUrl(scheme, { - integration_id: search.integration_id, - status: search.status, - return_to: search.return_to, - }); - }; - const handleDeeplink = () => { - const deeplink = getDeeplink(); - if (search.flow === "desktop" && deeplink) { - window.location.href = deeplink; - } - }; + const deeplink = + search.flow === "desktop" + ? buildDeeplinkUrl(scheme, { + integration_id: search.integration_id, + status: search.status, + return_to: search.return_to, + }) + : null; - const handleCopy = async () => { - const deeplink = getDeeplink(); - if (deeplink) { - await navigator.clipboard.writeText(deeplink); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; + useAutoDeeplink(search.status === "success" ? deeplink : null); useEffect(() => { if (search.flow === "web") { @@ -86,22 +71,6 @@ function Component() { } }, [search.flow, navigate, queryClient]); - useEffect(() => { - if (search.flow === "desktop" && search.status === "success") { - const deeplink = getDeeplink(); - const timer = setTimeout(() => { - window.location.href = deeplink; - }, 250); - return () => clearTimeout(timer); - } - }, [ - search.flow, - search.status, - scheme, - search.integration_id, - search.return_to, - ]); - const isSuccess = search.status === "success"; if (search.flow === "desktop") { @@ -119,49 +88,7 @@ function Component() {

- {isSuccess && ( -
- - - -
- )} + {isSuccess && deeplink && } );