diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index f592f12a..7637028f 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -17,16 +17,19 @@ * - `ChatLogout` → 登出(机器自动 cleanup WS actor) * - chat 机器内部用 fromCallback actor 完整管理 WS 生命周期 */ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useAuthState } from "@/stores/auth/auth-context"; import type { LoginStatus } from "@/data/dto/auth"; 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 { 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"; @@ -36,6 +39,7 @@ import { ChatHeader, ChatInputBar, ChatQuotaExhaustedBanner, + ExternalBrowserDialog, PwaInstallOverlay, } from "./components"; import styles from "./components/chat-screen.module.css"; @@ -81,6 +85,8 @@ export function ChatScreen() { const state = useChatState(); const authState = useAuthState(); const chatDispatch = useChatDispatch(); + const [showExternalBrowserDialog, setShowExternalBrowserDialog] = + useState(false); // isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段) const isGuest = deriveIsGuest(authState.loginStatus); @@ -103,6 +109,7 @@ export function ChatScreen() { // - 登录态变化(guest→email 等)→ 派 ChatGuestLogin 或 ChatNonGuestLogin // ───────────────────────────────────────────────────────────── const prevLoginStatusRef = useRef(null); + const externalBrowserPromptShownRef = useRef(false); useEffect(() => { if (!authState.hasInitialized || authState.isLoading) return; @@ -125,6 +132,55 @@ export function ChatScreen() { 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 { + 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 (
@@ -162,6 +218,12 @@ export function ChatScreen() { {/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */} + + setShowExternalBrowserDialog(false)} + onConfirm={() => void handleOpenExternalBrowser()} + />
); diff --git a/src/app/chat/components/external-browser-dialog.module.css b/src/app/chat/components/external-browser-dialog.module.css new file mode 100644 index 00000000..d33f2f7c --- /dev/null +++ b/src/app/chat/components/external-browser-dialog.module.css @@ -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) + ); +} diff --git a/src/app/chat/components/external-browser-dialog.tsx b/src/app/chat/components/external-browser-dialog.tsx new file mode 100644 index 00000000..25ff8b17 --- /dev/null +++ b/src/app/chat/components/external-browser-dialog.tsx @@ -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 ( +
+
+

+ Open in browser +

+

+ Sync your Facebook profile to an external browser{"\n"} + for the best chat experience. +

+
+ + +
+
+
+ ); +} diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index e5aaf4e6..7cf4da87 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -11,6 +11,7 @@ export * from "./chat-input-text-field"; export * from "./chat-quota-exhausted-banner"; export * from "./chat-send-button"; export * from "./date-header"; +export * from "./external-browser-dialog"; export * from "./fullscreen-image-viewer"; export * from "./image-bubble"; export * from "./language-dialog"; diff --git a/src/app/chat/deviceid/[deviceId]/DeepLinkPersist.tsx b/src/app/chat/deviceid/[deviceId]/DeepLinkPersist.tsx index a8157fd1..04ca1d7b 100644 --- a/src/app/chat/deviceid/[deviceId]/DeepLinkPersist.tsx +++ b/src/app/chat/deviceid/[deviceId]/DeepLinkPersist.tsx @@ -1,18 +1,12 @@ "use client"; /** - * 深链落地:把 deviceId + fbid 写进 AuthStorage,然后跳 `/chat`。 - * - * 对齐 Flutter `lib/router/app_router.dart` 中 `/chat/deviceid/:deviceId` 的 - * `redirect` 回调(`app_router.dart:60-78`)。 + * 深链落地:把 deviceId + fbid/avatarUrl 写进本地存储,然后跳 `/chat`。 * * 设计: * - 不走 Server `redirect()`:避免把 fbid 暴露在 URL 里。 - * - deviceId 走 `AuthStorage.getInstance().setDeviceId`(与 `token_interceptor.ts` 同源; - * 写入键 `cozsweet.deviceId`)。 - * - fbid 走 `window.localStorage.setItem(StorageKeys.facebookId, ...)`, - * 与 async `AuthStorage.setFacebookId` 同键名。Sync 层暂未统一抽象, - * 仅留 TODO。 + * - deviceId / fbid 都走 `AuthStorage.getInstance().setXxx`(与 `token_interceptor.ts` 同源) + * - avatarUrl 写入 UserStorage 的头像 slot,外部浏览器首次恢复时可带给后端。 * - 写入完成后 `router.replace('/chat')`,URL 不留深链痕迹。 */ @@ -20,15 +14,20 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; 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"; interface DeepLinkPersistProps { deviceId: string; 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 [done, setDone] = useState(false); @@ -37,10 +36,10 @@ export default function DeepLinkPersist({ deviceId, fbid }: DeepLinkPersistProps try { await AuthStorage.getInstance().setDeviceId(deviceId); if (fbid && fbid.length > 0) { - // TODO: 统一 sync/async AuthStorage 后改为 `AuthStorage.getInstance().setFacebookId(fbid)`。 - // 当前 sync 层未暴露该方法;使用 `StorageKeys.facebookId` 保持与 async 层 - // `src/data/storage/auth/auth_storage.ts` 键名一致。 - window.localStorage.setItem(StorageKeys.facebookId, fbid); + await AuthStorage.getInstance().setFacebookId(fbid); + } + if (avatarUrl && avatarUrl.length > 0) { + await UserStorage.getInstance().setAvatarUrl(avatarUrl); } } catch (err) { // 写不进 localStorage 时(例如隐私模式)也不阻塞跳转;记录到 console 即可。 @@ -50,7 +49,7 @@ export default function DeepLinkPersist({ deviceId, fbid }: DeepLinkPersistProps router.replace(ROUTES.chat); } })(); - }, [deviceId, fbid, router]); + }, [avatarUrl, deviceId, fbid, router]); return (
`, - * 由它把数据写入 localStorage 后 `router.replace('/chat')`。 + * 把 `deviceId` 路径参数与 `fbid` / `avatarUrl` 查询参数透传给 Client 组件 + * ``,由它把数据写入 localStorage 后 `router.replace('/chat')`。 * * Next.js 15+ 约定:`params` / `searchParams` 是 Promise,必须 await。 * Next.js 16 全局 helper `PageProps<'/literal'>` 由 `next dev` / `next build` / * `next typegen` 生成。 * - * 这里不调用 `redirect()`:把 fbid 暴露在 URL 上对用户不友好; + * 这里不调用 `redirect()`:把 fbid / avatarUrl 暴露在 URL 上对用户不友好; * 跳转推迟到 Client 完成持久化之后。 */ @@ -19,8 +19,17 @@ export default async function ChatDeviceIdPage( props: PageProps<"/chat/deviceid/[deviceId]">, ) { 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 avatarUrlStr = Array.isArray(avatarUrl) + ? avatarUrl[0] + : avatarUrl ?? null; - return ; + return ( + + ); } diff --git a/src/data/storage/auth/auth_storage.ts b/src/data/storage/auth/auth_storage.ts index acd125ff..872c2b63 100644 --- a/src/data/storage/auth/auth_storage.ts +++ b/src/data/storage/auth/auth_storage.ts @@ -3,8 +3,6 @@ /** * AuthStorage 完整实现(基于 unstorage 抽象) * - * 原始 Dart: lib/data/services/storage/auth_storage.dart - * * SECURITY: 当前与 Dart 端行为一致,refresh_token 以明文存于 localStorage。 * 后续轮次将升级到 HttpOnly Cookie 方案,参见 `clearAuthData` 处的 TODO 标记。 * @@ -99,6 +97,9 @@ export class AuthStorage implements IAuthStorage { setFacebookId(id: string): Promise> { return SpAsyncUtil.setString(StorageKeys.facebookId, id); } + clearFacebookId(): Promise> { + return SpAsyncUtil.remove(StorageKeys.facebookId); + } // ---- bulk clear ---- @@ -107,6 +108,8 @@ export class AuthStorage implements IAuthStorage { if (!r1.success) return r1; const r2 = await this.clearGuestToken(); if (!r2.success) return r2; - return this.clearRefreshToken(); + const r3 = await this.clearRefreshToken(); + if (!r3.success) return r3; + return this.clearFacebookId(); } } diff --git a/src/data/storage/auth/iauth_storage.ts b/src/data/storage/auth/iauth_storage.ts index 7c9705bb..871e3630 100644 --- a/src/data/storage/auth/iauth_storage.ts +++ b/src/data/storage/auth/iauth_storage.ts @@ -31,6 +31,7 @@ export interface IAuthStorage { getFacebookId(): Promise>; setFacebookId(id: string): Promise>; + clearFacebookId(): Promise>; clearAuthData(): Promise>; } diff --git a/src/stores/auth/auth-actors.ts b/src/stores/auth/auth-actors.ts index 67ff2e4a..f144abc6 100644 --- a/src/stores/auth/auth-actors.ts +++ b/src/stores/auth/auth-actors.ts @@ -243,8 +243,9 @@ export const guestLoginActor = fromPromise(async () => { // 1. deviceIdentifier.getDeviceId() —— 无则通过 fingerprintjs 生成 + 落盘 // (给后续 OAuth/email 登录准备 deviceId,不在 splash 首次访问就 auto-create 游客) // 2. AuthStorage.getLoginToken() —— 有 → "email" -// 3. AuthStorage.getGuestToken() —— 有 → "guest" -// 4. 都没有 → "notLoggedIn"(让用户显式选 OAuth / Email / Guest 入口) +// 3. AuthStorage.getFacebookId() —— 有 → facebookIdLogin → "facebook" +// 4. AuthStorage.getGuestToken() —— 有 → "guest" +// 5. 都没有 → "notLoggedIn"(让用户显式选 OAuth / Email / Guest 入口) export const checkAuthStatusActor = fromPromise(async () => { // 1. 拿 deviceId(不用于 auto-guest-login,仅供后续登录用) @@ -257,12 +258,32 @@ export const checkAuthStatusActor = fromPromise(async () => { 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(); if (guestTokenR.success && guestTokenR.data) { return "guest" as LoginStatus; } - // 4. 都没有 → "notLoggedIn"(保持 splash 页面 + 登录入口) + // 5. 都没有 → "notLoggedIn"(保持 splash 页面 + 登录入口) return "notLoggedIn" as LoginStatus; });