feat(chat): prompt facebook users to open external browser

This commit is contained in:
2026-06-18 10:59:13 +08:00
parent d2bced8889
commit 172ef28060
9 changed files with 242 additions and 30 deletions
+64 -2
View File
@@ -17,16 +17,19 @@
* - `ChatLogout` → 登出(机器自动 cleanup WS actor * - `ChatLogout` → 登出(机器自动 cleanup WS actor
* - chat 机器内部用 fromCallback actor 完整管理 WS 生命周期 * - chat 机器内部用 fromCallback actor 完整管理 WS 生命周期
*/ */
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import type { LoginStatus } from "@/data/dto/auth"; import type { LoginStatus } from "@/data/dto/auth";
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 { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { AppEnvUtil } from "@/utils/app-env"; import { AppEnvUtil } from "@/utils/app-env";
import { ROUTES } from "@/router/routes"; import { BrowserDetector } from "@/utils/browser-detect";
import { UrlLauncherUtil } from "@/utils/url-launcher-util";
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
@@ -36,6 +39,7 @@ import {
ChatHeader, ChatHeader,
ChatInputBar, ChatInputBar,
ChatQuotaExhaustedBanner, ChatQuotaExhaustedBanner,
ExternalBrowserDialog,
PwaInstallOverlay, PwaInstallOverlay,
} from "./components"; } from "./components";
import styles from "./components/chat-screen.module.css"; import styles from "./components/chat-screen.module.css";
@@ -81,6 +85,8 @@ export function ChatScreen() {
const state = useChatState(); const state = useChatState();
const authState = useAuthState(); const authState = useAuthState();
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false);
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段) // isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus); const isGuest = deriveIsGuest(authState.loginStatus);
@@ -103,6 +109,7 @@ export function ChatScreen() {
// - 登录态变化(guest→email 等)→ 派 ChatGuestLogin 或 ChatNonGuestLogin // - 登录态变化(guest→email 等)→ 派 ChatGuestLogin 或 ChatNonGuestLogin
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
const prevLoginStatusRef = useRef<LoginStatus | null>(null); const prevLoginStatusRef = useRef<LoginStatus | null>(null);
const externalBrowserPromptShownRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return; if (!authState.hasInitialized || authState.isLoading) return;
@@ -125,6 +132,55 @@ export function ChatScreen() {
router, router,
]); ]);
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
if (authState.loginStatus !== "facebook") return;
if (externalBrowserPromptShownRef.current) return;
if (!BrowserDetector.isInAppBrowser()) return;
externalBrowserPromptShownRef.current = true;
const timer = window.setTimeout(() => {
setShowExternalBrowserDialog(true);
}, 3000);
return () => window.clearTimeout(timer);
}, [
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
]);
async function handleOpenExternalBrowser(): Promise<void> {
setShowExternalBrowserDialog(false);
const authStorage = AuthStorage.getInstance();
const userStorage = UserStorage.getInstance();
const [deviceIdR, facebookIdR, avatarUrlR] = await Promise.all([
authStorage.getDeviceId(),
authStorage.getFacebookId(),
userStorage.getAvatarUrl(),
]);
const deviceId = deviceIdR.success ? deviceIdR.data : null;
const facebookId = facebookIdR.success ? facebookIdR.data : null;
const avatarUrl = avatarUrlR.success ? avatarUrlR.data : null;
if (deviceId && facebookId) {
UrlLauncherUtil.openUrlWithExternalBrowser(
ROUTE_BUILDERS.chatDeviceId(deviceId),
{
queryParams: {
fbid: facebookId,
...(avatarUrl ? { avatarUrl } : {}),
},
},
);
return;
}
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.chat);
}
return ( return (
<MobileShell> <MobileShell>
<div className={styles.shell}> <div className={styles.shell}>
@@ -162,6 +218,12 @@ export function ChatScreen() {
{/* PWA 安装提示触发器(无可见 UI3.5s 后弹 dialog */} {/* PWA 安装提示触发器(无可见 UI3.5s 后弹 dialog */}
<PwaInstallOverlay /> <PwaInstallOverlay />
<ExternalBrowserDialog
open={showExternalBrowserDialog}
onClose={() => setShowExternalBrowserDialog(false)}
onConfirm={() => void handleOpenExternalBrowser()}
/>
</div> </div>
</MobileShell> </MobileShell>
); );
@@ -0,0 +1,64 @@
.overlay {
position: fixed;
inset: 0;
z-index: 70;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md, 16px);
background: rgba(0, 0, 0, 0.5);
}
.dialog {
width: 100%;
max-width: 360px;
padding: 24px 18px 18px;
text-align: center;
background: var(--color-page-background, #ffffff);
border-radius: 32px;
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.22);
}
.title {
margin: 0 0 10px;
font-size: var(--font-size-22, 22px);
font-weight: 700;
line-height: 1.2;
color: var(--color-text-foreground, #171717);
}
.content {
margin: 0 12px 22px;
font-size: var(--font-size-md, 15px);
line-height: 1.5;
color: var(--color-text-secondary, #555555);
white-space: pre-line;
}
.actions {
display: flex;
gap: var(--spacing-md, 12px);
}
.button {
flex: 1 1 auto;
height: 44px;
border: 0;
border-radius: 999px;
font-size: var(--font-size-md, 15px);
font-weight: 700;
cursor: pointer;
}
.secondary {
color: var(--color-text-secondary, #555555);
background: #efefef;
}
.primary {
color: #ffffff;
background: linear-gradient(
var(--color-button-gradient-start, #ff67e0),
var(--color-button-gradient-end, #ff52a2)
);
}
@@ -0,0 +1,52 @@
"use client";
import styles from "./external-browser-dialog.module.css";
export interface ExternalBrowserDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
}
export function ExternalBrowserDialog({
open,
onClose,
onConfirm,
}: ExternalBrowserDialogProps) {
if (!open) return null;
return (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-labelledby="external-browser-title"
>
<div className={styles.dialog}>
<h2 id="external-browser-title" className={styles.title}>
Open in browser
</h2>
<p className={styles.content}>
Sync your Facebook profile to an external browser{"\n"}
for the best chat experience.
</p>
<div className={styles.actions}>
<button
type="button"
className={`${styles.button} ${styles.secondary}`}
onClick={onClose}
>
Later
</button>
<button
type="button"
className={`${styles.button} ${styles.primary}`}
onClick={onConfirm}
>
Continue
</button>
</div>
</div>
</div>
);
}
+1
View File
@@ -11,6 +11,7 @@ export * from "./chat-input-text-field";
export * from "./chat-quota-exhausted-banner"; export * from "./chat-quota-exhausted-banner";
export * from "./chat-send-button"; export * from "./chat-send-button";
export * from "./date-header"; export * from "./date-header";
export * from "./external-browser-dialog";
export * from "./fullscreen-image-viewer"; export * from "./fullscreen-image-viewer";
export * from "./image-bubble"; export * from "./image-bubble";
export * from "./language-dialog"; export * from "./language-dialog";
@@ -1,18 +1,12 @@
"use client"; "use client";
/** /**
* 深链落地:把 deviceId + fbid 写进 AuthStorage,然后跳 `/chat`。 * 深链落地:把 deviceId + fbid/avatarUrl 写进本地存储,然后跳 `/chat`。
*
* 对齐 Flutter `lib/router/app_router.dart` 中 `/chat/deviceid/:deviceId` 的
* `redirect` 回调(`app_router.dart:60-78`)。
* *
* 设计: * 设计:
* - 不走 Server `redirect()`:避免把 fbid 暴露在 URL 里。 * - 不走 Server `redirect()`:避免把 fbid 暴露在 URL 里。
* - deviceId 走 `AuthStorage.getInstance().setDeviceId`(与 `token_interceptor.ts` 同源 * - deviceId / fbid 都走 `AuthStorage.getInstance().setXxx`(与 `token_interceptor.ts` 同源
* 写入键 `cozsweet.deviceId` * - avatarUrl 写入 UserStorage 的头像 slot,外部浏览器首次恢复时可带给后端
* - fbid 走 `window.localStorage.setItem(StorageKeys.facebookId, ...)`
* 与 async `AuthStorage.setFacebookId` 同键名。Sync 层暂未统一抽象,
* 仅留 TODO。
* - 写入完成后 `router.replace('/chat')`URL 不留深链痕迹。 * - 写入完成后 `router.replace('/chat')`URL 不留深链痕迹。
*/ */
@@ -20,15 +14,20 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { StorageKeys } from "@/data/storage/storage_keys"; import { UserStorage } from "@/data/storage/user/user_storage";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
interface DeepLinkPersistProps { interface DeepLinkPersistProps {
deviceId: string; deviceId: string;
fbid: string | null; fbid: string | null;
avatarUrl: string | null;
} }
export default function DeepLinkPersist({ deviceId, fbid }: DeepLinkPersistProps) { export default function DeepLinkPersist({
deviceId,
fbid,
avatarUrl,
}: DeepLinkPersistProps) {
const router = useRouter(); const router = useRouter();
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
@@ -37,10 +36,10 @@ export default function DeepLinkPersist({ deviceId, fbid }: DeepLinkPersistProps
try { try {
await AuthStorage.getInstance().setDeviceId(deviceId); await AuthStorage.getInstance().setDeviceId(deviceId);
if (fbid && fbid.length > 0) { if (fbid && fbid.length > 0) {
// TODO: 统一 sync/async AuthStorage 后改为 `AuthStorage.getInstance().setFacebookId(fbid)`。 await AuthStorage.getInstance().setFacebookId(fbid);
// 当前 sync 层未暴露该方法;使用 `StorageKeys.facebookId` 保持与 async 层 }
// `src/data/storage/auth/auth_storage.ts` 键名一致。 if (avatarUrl && avatarUrl.length > 0) {
window.localStorage.setItem(StorageKeys.facebookId, fbid); await UserStorage.getInstance().setAvatarUrl(avatarUrl);
} }
} catch (err) { } catch (err) {
// 写不进 localStorage 时(例如隐私模式)也不阻塞跳转;记录到 console 即可。 // 写不进 localStorage 时(例如隐私模式)也不阻塞跳转;记录到 console 即可。
@@ -50,7 +49,7 @@ export default function DeepLinkPersist({ deviceId, fbid }: DeepLinkPersistProps
router.replace(ROUTES.chat); router.replace(ROUTES.chat);
} }
})(); })();
}, [deviceId, fbid, router]); }, [avatarUrl, deviceId, fbid, router]);
return ( return (
<div <div
+14 -5
View File
@@ -2,14 +2,14 @@
* 深链入口(Server Component * 深链入口(Server Component
* *
* 对齐 Flutter `lib/router/app_router.dart:60-78` 的 `GoRoute(path: '/chat/deviceid/:deviceId')` * 对齐 Flutter `lib/router/app_router.dart:60-78` 的 `GoRoute(path: '/chat/deviceid/:deviceId')`
* 把 `deviceId` 路径参数与 `fbid` 查询参数透传给 Client 组件 `<DeepLinkPersist>` * 把 `deviceId` 路径参数与 `fbid` / `avatarUrl` 查询参数透传给 Client 组件
* 由它把数据写入 localStorage 后 `router.replace('/chat')`。 * `<DeepLinkPersist>`由它把数据写入 localStorage 后 `router.replace('/chat')`。
* *
* Next.js 15+ 约定:`params` / `searchParams` 是 Promise,必须 await。 * Next.js 15+ 约定:`params` / `searchParams` 是 Promise,必须 await。
* Next.js 16 全局 helper `PageProps<'/literal'>` 由 `next dev` / `next build` / * Next.js 16 全局 helper `PageProps<'/literal'>` 由 `next dev` / `next build` /
* `next typegen` 生成。 * `next typegen` 生成。
* *
* 这里不调用 `redirect()`:把 fbid 暴露在 URL 上对用户不友好; * 这里不调用 `redirect()`:把 fbid / avatarUrl 暴露在 URL 上对用户不友好;
* 跳转推迟到 Client 完成持久化之后。 * 跳转推迟到 Client 完成持久化之后。
*/ */
@@ -19,8 +19,17 @@ export default async function ChatDeviceIdPage(
props: PageProps<"/chat/deviceid/[deviceId]">, props: PageProps<"/chat/deviceid/[deviceId]">,
) { ) {
const { deviceId } = await props.params; const { deviceId } = await props.params;
const { fbid } = await props.searchParams; const { avatarUrl, fbid } = await props.searchParams;
const fbidStr = Array.isArray(fbid) ? fbid[0] : fbid ?? null; const fbidStr = Array.isArray(fbid) ? fbid[0] : fbid ?? null;
const avatarUrlStr = Array.isArray(avatarUrl)
? avatarUrl[0]
: avatarUrl ?? null;
return <DeepLinkPersist deviceId={deviceId} fbid={fbidStr} />; return (
<DeepLinkPersist
deviceId={deviceId}
fbid={fbidStr}
avatarUrl={avatarUrlStr}
/>
);
} }
+6 -3
View File
@@ -3,8 +3,6 @@
/** /**
* AuthStorage 完整实现(基于 unstorage 抽象) * AuthStorage 完整实现(基于 unstorage 抽象)
* *
* 原始 Dart: lib/data/services/storage/auth_storage.dart
*
* SECURITY: 当前与 Dart 端行为一致,refresh_token 以明文存于 localStorage。 * SECURITY: 当前与 Dart 端行为一致,refresh_token 以明文存于 localStorage。
* 后续轮次将升级到 HttpOnly Cookie 方案,参见 `clearAuthData` 处的 TODO 标记。 * 后续轮次将升级到 HttpOnly Cookie 方案,参见 `clearAuthData` 处的 TODO 标记。
* *
@@ -99,6 +97,9 @@ export class AuthStorage implements IAuthStorage {
setFacebookId(id: string): Promise<ResultT<void>> { setFacebookId(id: string): Promise<ResultT<void>> {
return SpAsyncUtil.setString(StorageKeys.facebookId, id); return SpAsyncUtil.setString(StorageKeys.facebookId, id);
} }
clearFacebookId(): Promise<ResultT<void>> {
return SpAsyncUtil.remove(StorageKeys.facebookId);
}
// ---- bulk clear ---- // ---- bulk clear ----
@@ -107,6 +108,8 @@ export class AuthStorage implements IAuthStorage {
if (!r1.success) return r1; if (!r1.success) return r1;
const r2 = await this.clearGuestToken(); const r2 = await this.clearGuestToken();
if (!r2.success) return r2; if (!r2.success) return r2;
return this.clearRefreshToken(); const r3 = await this.clearRefreshToken();
if (!r3.success) return r3;
return this.clearFacebookId();
} }
} }
+1
View File
@@ -31,6 +31,7 @@ export interface IAuthStorage {
getFacebookId(): Promise<Result<string | null>>; getFacebookId(): Promise<Result<string | null>>;
setFacebookId(id: string): Promise<Result<void>>; setFacebookId(id: string): Promise<Result<void>>;
clearFacebookId(): Promise<Result<void>>;
clearAuthData(): Promise<Result<void>>; clearAuthData(): Promise<Result<void>>;
} }
+25 -4
View File
@@ -243,8 +243,9 @@ export const guestLoginActor = fromPromise<LoginStatus, void>(async () => {
// 1. deviceIdentifier.getDeviceId() —— 无则通过 fingerprintjs 生成 + 落盘 // 1. deviceIdentifier.getDeviceId() —— 无则通过 fingerprintjs 生成 + 落盘
// (给后续 OAuth/email 登录准备 deviceId,不在 splash 首次访问就 auto-create 游客) // (给后续 OAuth/email 登录准备 deviceId,不在 splash 首次访问就 auto-create 游客)
// 2. AuthStorage.getLoginToken() —— 有 → "email" // 2. AuthStorage.getLoginToken() —— 有 → "email"
// 3. AuthStorage.getGuestToken() —— 有 → "guest" // 3. AuthStorage.getFacebookId() —— 有 → facebookIdLogin → "facebook"
// 4. 都没有 → "notLoggedIn"(让用户显式选 OAuth / Email / Guest 入口) // 4. AuthStorage.getGuestToken() —— 有 → "guest"
// 5. 都没有 → "notLoggedIn"(让用户显式选 OAuth / Email / Guest 入口)
export const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => { export const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => {
// 1. 拿 deviceId(不用于 auto-guest-login,仅供后续登录用) // 1. 拿 deviceId(不用于 auto-guest-login,仅供后续登录用)
@@ -257,12 +258,32 @@ export const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => {
return "email" as LoginStatus; return "email" as LoginStatus;
} }
// 3. 查 guestToken // 3. 外部浏览器深链同步:仅 fbid 落地时,用它换业务 token
const facebookIdR = await storage.getFacebookId();
if (facebookIdR.success && facebookIdR.data) {
const avatarUrlR = await UserStorage.getInstance().getAvatarUrl();
const result = await authRepo.facebookIdLogin({
fbId: facebookIdR.data,
avatarUrl: avatarUrlR.success ? avatarUrlR.data ?? undefined : undefined,
});
if (Result.isErr(result)) {
log.warn(
{ err: result.error.message },
"[auth-machine] checkAuthStatusActor: facebookIdLogin failed",
);
return "notLoggedIn" as LoginStatus;
}
return "facebook" as LoginStatus;
}
// 4. 查 guestToken
const guestTokenR = await storage.getGuestToken(); const guestTokenR = await storage.getGuestToken();
if (guestTokenR.success && guestTokenR.data) { if (guestTokenR.success && guestTokenR.data) {
return "guest" as LoginStatus; return "guest" as LoginStatus;
} }
// 4. 都没有 → "notLoggedIn"(保持 splash 页面 + 登录入口) // 5. 都没有 → "notLoggedIn"(保持 splash 页面 + 登录入口)
return "notLoggedIn" as LoginStatus; return "notLoggedIn" as LoginStatus;
}); });