refactor(chat): make external browser launch user initiated
This commit is contained in:
@@ -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]);
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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>> {
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
|||||||
Reference in New Issue
Block a user