refactor(chat): make external browser launch user initiated

This commit is contained in:
2026-07-09 15:18:52 +08:00
parent 360db0f813
commit dfa5b0c068
7 changed files with 56 additions and 136 deletions
-6
View File
@@ -34,7 +34,6 @@ import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-ba
import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow"; import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow";
import { useChatGuestLogin } from "./hooks/use-chat-guest-login"; import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner"; import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
import { useExternalBrowserPrompt } from "./hooks/use-external-browser-prompt";
import styles from "./components/chat-screen.module.css"; import styles from "./components/chat-screen.module.css";
export function ChatScreen() { export function ChatScreen() {
@@ -91,11 +90,6 @@ export function ChatScreen() {
loginStatus: authState.loginStatus, loginStatus: authState.loginStatus,
}); });
const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10; const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10;
useExternalBrowserPrompt({
hasInitialized: authState.hasInitialized,
isLoading: authState.isLoading,
loginStatus: authState.loginStatus,
});
useChatGuestLogin({ useChatGuestLogin({
dispatch: authDispatch, dispatch: authDispatch,
@@ -9,22 +9,54 @@
align-items: flex-end; align-items: flex-end;
gap: var(--spacing-1, 4px); gap: var(--spacing-1, 4px);
z-index: 20; z-index: 20;
pointer-events: none; max-width: min(52vw, 210px);
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
pointer-events: auto;
transition: opacity 0.16s ease, transform 0.16s ease;
-webkit-tap-highlight-color: transparent;
} }
.emoji { .emoji {
font-size: var(--responsive-icon-size-md, 24px); font-size: var(--responsive-icon-size-md, 24px);
line-height: 1; line-height: 1;
filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.32));
} }
.hintBubble { .hintBubble {
padding: var(--spacing-2, 8px) var(--spacing-3, 12px); padding: var(--spacing-2, 8px) var(--spacing-3, 12px);
background: var(--color-blur-background, rgba(13, 11, 20, 0.85)); border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: var(--radius-md, 8px); border-radius: var(--radius-lg, 14px);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.11), rgba(255, 255, 255, 0.04)),
var(--color-blur-background, rgba(13, 11, 20, 0.84));
box-shadow:
0 10px 24px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.14);
font-size: var(--font-size-sm, 12px); font-size: var(--font-size-sm, 12px);
font-weight: 500; font-weight: 700;
line-height: 1.25;
color: #fff; color: #fff;
white-space: pre-line;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
max-width: min(52vw, 200px); text-align: left;
white-space: pre-line;
overflow-wrap: break-word;
}
.overlay:hover {
opacity: 0.94;
transform: translateY(-1px);
}
.overlay:active {
opacity: 0.88;
transform: translateY(1px) scale(0.99);
}
.overlay:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.88);
outline-offset: 4px;
} }
@@ -10,10 +10,11 @@
* 行为: * 行为:
* - 开发环境始终显示(测试方便) * - 开发环境始终显示(测试方便)
* - 生产环境仅在应用内浏览器中显示 * - 生产环境仅在应用内浏览器中显示
* - 视觉:右上角 👆 + 模糊背景气泡 * - 点击后主动打开外部浏览器
*/ */
import { useState } from "react"; import { useState } from "react";
import { openChatInExternalBrowser } from "@/lib/chat/chat_external_browser";
import { BrowserDetector } from "@/utils"; import { BrowserDetector } from "@/utils";
import styles from "./browser-hint-overlay.module.css"; import styles from "./browser-hint-overlay.module.css";
@@ -25,7 +26,7 @@ export interface BrowserHintOverlayProps {
forceShow?: boolean; forceShow?: boolean;
} }
const DEFAULT_TEXT = "Tap ⋮ , open in browser\nfor better chat"; const DEFAULT_TEXT = "Open in external browser\nto find me easily";
export function BrowserHintOverlay({ export function BrowserHintOverlay({
text = DEFAULT_TEXT, text = DEFAULT_TEXT,
@@ -38,12 +39,20 @@ export function BrowserHintOverlay({
const shouldShow = forceShow || isInAppBrowser; const shouldShow = forceShow || isInAppBrowser;
if (!shouldShow) return null; if (!shouldShow) return null;
const handleClick = (): void => {
void openChatInExternalBrowser().catch((error) => {
console.warn("[chat] Failed to open external browser", error);
});
};
return ( return (
<div className={styles.overlay} role="status" aria-live="polite"> <button
<span className={styles.emoji} aria-hidden="true"> type="button"
👆 className={styles.overlay}
</span> aria-label={text}
<div className={styles.hintBubble}>{text}</div> onClick={handleClick}
</div> >
<span className={styles.hintBubble}>{text}</span>
</button>
); );
} }
@@ -1,75 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import {
openChatInExternalBrowser,
recordExternalBrowserPromptShown,
resolveExternalBrowserPromptEligibility,
} from "@/lib/chat/chat_external_browser";
import {
type ExternalBrowserPromptState,
shouldStartExternalBrowserPrompt,
} from "../chat-screen.helpers";
const EXTERNAL_BROWSER_PROMPT_DELAY_MS = 3000;
export function useExternalBrowserPrompt({
hasInitialized,
isLoading,
loginStatus,
}: ExternalBrowserPromptState): void {
const promptShownRef = useRef(false);
useEffect(() => {
if (
!shouldStartExternalBrowserPrompt({
hasInitialized,
isLoading,
loginStatus,
})
) {
return;
}
let mounted = true;
let timer: number | undefined;
const init = async () => {
if (promptShownRef.current) return;
const eligibility = await resolveExternalBrowserPromptEligibility();
if (!mounted || !eligibility.canShow) return;
promptShownRef.current = true;
timer = window.setTimeout(() => {
if (!mounted) return;
void (async () => {
try {
await recordExternalBrowserPromptShown(eligibility.today);
} catch (error) {
console.warn(
"[chat] Failed to record external browser auto launch",
error,
);
}
if (!mounted) return;
try {
await openChatInExternalBrowser();
} catch (error) {
console.warn("[chat] Failed to open external browser", error);
}
})();
}, EXTERNAL_BROWSER_PROMPT_DELAY_MS);
};
void init();
return () => {
mounted = false;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [hasInitialized, isLoading, loginStatus]);
}
-20
View File
@@ -41,26 +41,6 @@ export class AppStorage {
return SpAsyncUtil.setBool(StorageKeys.pwaDialogShown, true); return SpAsyncUtil.setBool(StorageKeys.pwaDialogShown, true);
} }
// ---- external browser dialog ----
static async canShowExternalBrowserDialog(
todayString: string,
): Promise<ResultT<boolean>> {
return AppStorage.canReportKey(
StorageKeys.lastExternalBrowserDialogShown,
todayString,
);
}
static recordExternalBrowserDialogShown(
todayString: string,
): Promise<ResultT<void>> {
return SpAsyncUtil.setString(
StorageKeys.lastExternalBrowserDialogShown,
todayString,
);
}
// ---- PWA event reporting ---- // ---- PWA event reporting ----
static canReportPwaEvent(todayString: string): Promise<ResultT<boolean>> { static canReportPwaEvent(todayString: string): Promise<ResultT<boolean>> {
-1
View File
@@ -29,7 +29,6 @@ export const StorageKeys = {
// pwa / app info // pwa / app info
pwaDialogShown: "pwa_dialog_shown", pwaDialogShown: "pwa_dialog_shown",
lastExternalBrowserDialogShown: "last_external_browser_dialog_shown",
lastPwaEventReported: "last_pwa_event_reported", lastPwaEventReported: "last_pwa_event_reported",
lastAppInfoReported: "last_app_info_reported", lastAppInfoReported: "last_app_info_reported",
firstRechargeOfferBannerDismissed: "first_recharge_offer_banner_dismissed", firstRechargeOfferBannerDismissed: "first_recharge_offer_banner_dismissed",
+1 -20
View File
@@ -1,28 +1,9 @@
"use client"; "use client";
import { AppStorage } from "@/data/storage/app/app_storage";
import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { UserStorage } from "@/data/storage/user/user_storage"; import { UserStorage } from "@/data/storage/user/user_storage";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { todayString, UrlLauncherUtil } from "@/utils"; import { UrlLauncherUtil } from "@/utils";
export async function resolveExternalBrowserPromptEligibility(): Promise<{
canShow: boolean;
today: string;
}> {
const today = todayString();
const result = await AppStorage.canShowExternalBrowserDialog(today);
return {
canShow: result.success && result.data,
today,
};
}
export function recordExternalBrowserPromptShown(
today: string,
): Promise<unknown> {
return AppStorage.recordExternalBrowserDialogShown(today);
}
export async function openChatInExternalBrowser(): Promise<void> { export async function openChatInExternalBrowser(): Promise<void> {
const authStorage = AuthStorage.getInstance(); const authStorage = AuthStorage.getInstance();