ultisuite-client/lib/ai/use-ai-iframe-navigation.ts
R3D347HR4Y 3477361db0
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(ai-chat): implement iframe navigation and external link handling
- Added `useAiIframeNavigation` hook to manage iframe navigation and enforce base paths.
- Introduced `useAiIframeExternalLinks` hook to handle external link opening in the parent tab.
- Updated `AiChatIframe` component to utilize the new hooks for improved user experience.
2026-06-13 22:55:33 +02:00

92 lines
2.8 KiB
TypeScript

import { useEffect } from "react"
import type { RefObject } from "react"
import { resolveAiEmbedOrigin } from "@/lib/ai/embed-url"
function normalizePublicPath(publicPath: string): string {
const path = (publicPath || "/ai").replace(/\/$/, "") || "/ai"
return path.startsWith("/") ? path : `/${path}`
}
/** Ultimail suite routes — never prefix with /ai (would 404 in OpenWebUI). */
const SUITE_ROOT_PATHS = new Set([
"/mail",
"/drive",
"/chat",
"/contacts",
"/cloud",
"/agenda",
"/office",
])
function isSuiteRoute(pathname: string): boolean {
for (const root of SUITE_ROOT_PATHS) {
if (pathname === root || pathname.startsWith(`${root}/`)) return true
}
return false
}
/** Keep UltiAI iframe on the OpenWebUI subpath (e.g. /ai) — block suite landing at /. */
export function useAiIframeNavigation(
iframeRef: RefObject<HTMLIFrameElement | null>,
publicPath: string
) {
useEffect(() => {
const iframe = iframeRef.current
if (!iframe) return
const base = normalizePublicPath(publicPath)
const embedOrigin = resolveAiEmbedOrigin(publicPath)
const enforceBasePath = () => {
try {
const win = iframe.contentWindow
if (!win) return
const { pathname, search, hash, origin } = win.location
if (embedOrigin && origin !== embedOrigin) return
const inBase =
pathname === base || pathname.startsWith(`${base}/`)
if (inBase) return
// Suite landing or explicit suite module — stay on OpenWebUI home.
if (pathname === "/" || pathname === "" || isSuiteRoute(pathname)) {
win.location.replace(`${base}/${search}${hash}`)
return
}
// OpenWebUI route without /ai prefix (e.g. /notes, /workspace) — preserve path.
win.location.replace(`${base}${pathname}${search}${hash}`)
} catch {
// Cross-origin — parent cannot read location.
}
}
iframe.addEventListener("load", enforceBasePath)
const timer = window.setInterval(enforceBasePath, 400)
return () => {
iframe.removeEventListener("load", enforceBasePath)
window.clearInterval(timer)
}
}, [iframeRef, publicPath])
}
/** Open external links from the embed in the parent tab, not inside the iframe. */
export function useAiIframeExternalLinks(embedOrigin: string) {
useEffect(() => {
if (!embedOrigin) return
const onMessage = (event: MessageEvent) => {
if (event.origin !== embedOrigin) return
const data = event.data as { type?: string; href?: string } | null
if (data?.type !== "ULTI_OPEN_LINK" || typeof data.href !== "string") return
window.open(data.href, "_blank", "noopener,noreferrer")
}
window.addEventListener("message", onMessage)
return () => window.removeEventListener("message", onMessage)
}, [embedOrigin])
}