Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions apps/web/src/components/deeplink-prompt.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-4">
<button
onClick={handleDeeplink}
className={cn([
"flex h-12 w-full cursor-pointer items-center justify-center text-base font-medium transition-all",
"rounded-full bg-linear-to-t from-stone-600 to-stone-500 text-white shadow-md hover:scale-[102%] hover:shadow-lg active:scale-[98%]",
])}
>
Open Char
</button>

<button
onClick={handleCopy}
className={cn([
"flex w-full cursor-pointer flex-col items-center gap-3 p-4 text-left transition-all",
"rounded-lg border border-stone-100 bg-stone-50 hover:bg-stone-100 active:scale-[99%]",
])}
>
<p className="text-sm text-stone-500">
Button not working? Copy the link instead
</p>
<span
className={cn([
"flex h-10 w-full items-center justify-center gap-2 text-sm font-medium",
"rounded-full bg-linear-to-t from-neutral-200 to-neutral-100 text-neutral-900 shadow-xs",
])}
>
{copied ? (
<>
<CheckIcon className="size-4" />
Copied!
</>
) : (
<>
<CopyIcon className="size-4" />
Copy URL
</>
)}
</span>
</button>
</div>
);
}
49 changes: 49 additions & 0 deletions apps/web/src/hooks/use-auto-deeplink.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);

useEffect(() => {
if (!url || lastTriggeredUrl.current === url) {
return;
}

lastTriggeredUrl.current = url;

return openDeeplink(url);
}, [url]);
}
72 changes: 6 additions & 66 deletions apps/web/src/routes/_view/callback/auth.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -246,49 +228,7 @@ function Component() {
</p>
</div>

{hasTokens && (
<div className="flex flex-col gap-4">
<button
onClick={handleDeeplink}
className={cn([
"flex h-12 w-full cursor-pointer items-center justify-center text-base font-medium transition-all",
"rounded-full bg-linear-to-t from-stone-600 to-stone-500 text-white shadow-md hover:scale-[102%] hover:shadow-lg active:scale-[98%]",
])}
>
Open Char
</button>

<button
onClick={handleCopy}
className={cn([
"flex w-full cursor-pointer flex-col items-center gap-3 p-4 text-left transition-all",
"rounded-lg border border-stone-100 bg-stone-50 hover:bg-stone-100 active:scale-[99%]",
])}
>
<p className="text-sm text-stone-500">
Button not working? Copy the link instead
</p>
<span
className={cn([
"flex h-10 w-full items-center justify-center gap-2 text-sm font-medium",
"rounded-full bg-linear-to-t from-neutral-200 to-neutral-100 text-neutral-900 shadow-xs",
])}
>
{copied ? (
<>
<CheckIcon className="size-4" />
Copied!
</>
) : (
<>
<CopyIcon className="size-4" />
Copy URL
</>
)}
</span>
</button>
</div>
)}
{hasTokens && deeplink && <DeeplinkPrompt url={deeplink} />}
</div>
</div>
);
Expand Down
99 changes: 13 additions & 86 deletions apps/web/src/routes/_view/callback/integration.tsx
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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") {
Expand All @@ -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") {
Expand All @@ -119,49 +88,7 @@ function Component() {
</p>
</div>

{isSuccess && (
<div className="flex flex-col gap-4">
<button
onClick={handleDeeplink}
className={cn([
"flex h-12 w-full cursor-pointer items-center justify-center text-base font-medium transition-all",
"rounded-full bg-linear-to-t from-stone-600 to-stone-500 text-white shadow-md hover:scale-[102%] hover:shadow-lg active:scale-[98%]",
])}
>
Open Char
</button>

<button
onClick={handleCopy}
className={cn([
"flex w-full cursor-pointer flex-col items-center gap-3 p-4 text-left transition-all",
"rounded-lg border border-stone-100 bg-stone-50 hover:bg-stone-100 active:scale-[99%]",
])}
>
<p className="text-sm text-stone-500">
Button not working? Copy the link instead
</p>
<span
className={cn([
"flex h-10 w-full items-center justify-center gap-2 text-sm font-medium",
"rounded-full bg-linear-to-t from-neutral-200 to-neutral-100 text-neutral-900 shadow-xs",
])}
>
{copied ? (
<>
<CheckIcon className="size-4" />
Copied!
</>
) : (
<>
<CopyIcon className="size-4" />
Copy URL
</>
)}
</span>
</button>
</div>
)}
{isSuccess && deeplink && <DeeplinkPrompt url={deeplink} />}
</div>
</div>
);
Expand Down
Loading