274 lines
6.8 KiB
TypeScript
274 lines
6.8 KiB
TypeScript
import { getRegisteredAccessToken } from '../tokenAccess';
|
|
import { buildPreviewRuntimeUrl, resolvePreviewAppOrigin } from '../previewRuntime';
|
|
|
|
const CHAT_EXTERNAL_LINK_OPENED_AT_KEY = 'ai-code-app.chat.external-link-opened-at';
|
|
const CHAT_EXTERNAL_LINK_TTL_MS = 15_000;
|
|
const EXTERNAL_WINDOW_TARGET_PREFIX = 'ai-code-app.external-window';
|
|
const UNSUPPORTED_STANDALONE_FALLBACK_DELAY_MS = 600;
|
|
|
|
type LinkNavigationEvent = {
|
|
preventDefault?: () => void;
|
|
stopPropagation?: () => void;
|
|
};
|
|
|
|
type OpenExternalLinkOptions = {
|
|
event?: LinkNavigationEvent;
|
|
onUnsupportedStandalone?: (url: string) => void;
|
|
allowSameTabFallback?: boolean;
|
|
};
|
|
|
|
function canUseSessionStorage() {
|
|
return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined';
|
|
}
|
|
|
|
function persistExternalLinkOpenTimestamp(openedAt: number) {
|
|
if (!canUseSessionStorage()) {
|
|
return;
|
|
}
|
|
|
|
window.sessionStorage.setItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY, String(openedAt));
|
|
}
|
|
|
|
function clearExternalLinkOpenTimestamp() {
|
|
if (!canUseSessionStorage()) {
|
|
return;
|
|
}
|
|
|
|
window.sessionStorage.removeItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY);
|
|
}
|
|
|
|
function isStandaloneDisplayMode() {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
window.matchMedia?.('(display-mode: standalone)').matches === true ||
|
|
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
|
);
|
|
}
|
|
|
|
function isAppleMobileStandaloneMode() {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
const navigatorValue = window.navigator as Navigator & { standalone?: boolean };
|
|
const userAgent = navigatorValue.userAgent ?? '';
|
|
const platform = navigatorValue.platform ?? '';
|
|
const maxTouchPoints = typeof navigatorValue.maxTouchPoints === 'number' ? navigatorValue.maxTouchPoints : 0;
|
|
const isAppleMobileUserAgent = /iPhone|iPad|iPod/iu.test(userAgent);
|
|
const isTouchMac = platform === 'MacIntel' && maxTouchPoints > 1;
|
|
|
|
return isStandaloneDisplayMode() && (isAppleMobileUserAgent || isTouchMac);
|
|
}
|
|
|
|
function buildExternalWindowTarget() {
|
|
return `${EXTERNAL_WINDOW_TARGET_PREFIX}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function openExternalWindow(url: string, target: string) {
|
|
if (typeof window === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
const openedWindow = window.open('', target);
|
|
|
|
if (!openedWindow) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
openedWindow.opener = null;
|
|
} catch {
|
|
// Ignore opener access failures in restricted runtimes.
|
|
}
|
|
|
|
try {
|
|
openedWindow.location.replace(url);
|
|
} catch {
|
|
try {
|
|
openedWindow.location.href = url;
|
|
} catch {
|
|
// Leave the popup verification fallback to handle blocked navigations.
|
|
}
|
|
}
|
|
|
|
return openedWindow;
|
|
}
|
|
|
|
function clickExternalAnchor(url: string, target: string) {
|
|
if (typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const anchor = document.createElement('a');
|
|
anchor.href = url;
|
|
anchor.target = target;
|
|
anchor.rel = 'noopener noreferrer';
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
document.body.removeChild(anchor);
|
|
}
|
|
|
|
function buildPreviewRuntimeFallbackUrl(url: string) {
|
|
if (typeof window === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const targetUrl = new URL(url, window.location.origin);
|
|
const previewOrigin = resolvePreviewAppOrigin();
|
|
|
|
if (targetUrl.origin !== window.location.origin && targetUrl.origin !== previewOrigin) {
|
|
return null;
|
|
}
|
|
|
|
const previewRuntimeUrl = buildPreviewRuntimeUrl(
|
|
targetUrl.pathname,
|
|
targetUrl.search,
|
|
getRegisteredAccessToken(),
|
|
null,
|
|
'mobile',
|
|
);
|
|
|
|
if (!targetUrl.hash) {
|
|
return previewRuntimeUrl;
|
|
}
|
|
|
|
const previewUrl = new URL(previewRuntimeUrl);
|
|
previewUrl.hash = targetUrl.hash;
|
|
return previewUrl.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function canFallbackToSameTab(url: string) {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const targetUrl = new URL(url, window.location.origin);
|
|
const previewOrigin = resolvePreviewAppOrigin();
|
|
return targetUrl.origin === window.location.origin || targetUrl.origin === previewOrigin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function openSameTabFallback(url: string) {
|
|
if (typeof window === 'undefined' || !canFallbackToSameTab(url)) {
|
|
return false;
|
|
}
|
|
|
|
window.location.assign(url);
|
|
return true;
|
|
}
|
|
|
|
function openPreviewRuntimeFallback(url: string) {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
const previewRuntimeUrl = buildPreviewRuntimeFallbackUrl(url);
|
|
|
|
if (!previewRuntimeUrl) {
|
|
return false;
|
|
}
|
|
|
|
window.location.assign(previewRuntimeUrl);
|
|
return true;
|
|
}
|
|
|
|
function hasOpenedWindowNavigated(openedWindow: Window | null) {
|
|
if (!openedWindow || openedWindow.closed) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return openedWindow.location.href !== 'about:blank';
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function scheduleExternalWindowFallback(
|
|
url: string,
|
|
openedWindow: Window | null,
|
|
allowSameTabFallback: boolean,
|
|
callback?: (url: string) => void,
|
|
) {
|
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
window.setTimeout(() => {
|
|
const pageStillVisible = document.visibilityState !== 'hidden';
|
|
const pageStillFocused = typeof document.hasFocus !== 'function' || document.hasFocus();
|
|
|
|
if (!pageStillVisible || !pageStillFocused) {
|
|
return;
|
|
}
|
|
|
|
if (hasOpenedWindowNavigated(openedWindow)) {
|
|
return;
|
|
}
|
|
|
|
if (allowSameTabFallback && isAppleMobileStandaloneMode()) {
|
|
if (openPreviewRuntimeFallback(url)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (allowSameTabFallback && openSameTabFallback(url)) {
|
|
return;
|
|
}
|
|
|
|
callback?.(url);
|
|
}, UNSUPPORTED_STANDALONE_FALLBACK_DELAY_MS);
|
|
}
|
|
|
|
export function shouldSkipForegroundResyncAfterExternalLink() {
|
|
if (!canUseSessionStorage()) {
|
|
return false;
|
|
}
|
|
|
|
const rawOpenedAt = window.sessionStorage.getItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY);
|
|
clearExternalLinkOpenTimestamp();
|
|
|
|
if (!rawOpenedAt) {
|
|
return false;
|
|
}
|
|
|
|
const openedAt = Number(rawOpenedAt);
|
|
return Number.isFinite(openedAt) && Date.now() - openedAt <= CHAT_EXTERNAL_LINK_TTL_MS;
|
|
}
|
|
|
|
export function openExternalLinkInNewWindow(url: string, options: OpenExternalLinkOptions = {}) {
|
|
options.event?.preventDefault?.();
|
|
options.event?.stopPropagation?.();
|
|
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
persistExternalLinkOpenTimestamp(Date.now());
|
|
const target = buildExternalWindowTarget();
|
|
const allowSameTabFallback = options.allowSameTabFallback ?? true;
|
|
const openedWindow = openExternalWindow(url, target);
|
|
|
|
if (!openedWindow && allowSameTabFallback) {
|
|
clickExternalAnchor(url, target);
|
|
}
|
|
|
|
scheduleExternalWindowFallback(url, openedWindow, allowSameTabFallback, options.onUnsupportedStandalone);
|
|
}
|
|
|
|
export function openChatExternalLink(url: string, event?: LinkNavigationEvent) {
|
|
openExternalLinkInNewWindow(url, {
|
|
event,
|
|
});
|
|
}
|