feat(chat): prompt facebook users to open external browser
This commit is contained in:
@@ -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<LoginStatus | null>(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<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 (
|
||||
<MobileShell>
|
||||
<div className={styles.shell}>
|
||||
@@ -162,6 +218,12 @@ export function ChatScreen() {
|
||||
|
||||
{/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
||||
<PwaInstallOverlay />
|
||||
|
||||
<ExternalBrowserDialog
|
||||
open={showExternalBrowserDialog}
|
||||
onClose={() => setShowExternalBrowserDialog(false)}
|
||||
onConfirm={() => void handleOpenExternalBrowser()}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
* 深链入口(Server Component)
|
||||
*
|
||||
* 对齐 Flutter `lib/router/app_router.dart:60-78` 的 `GoRoute(path: '/chat/deviceid/:deviceId')`:
|
||||
* 把 `deviceId` 路径参数与 `fbid` 查询参数透传给 Client 组件 `<DeepLinkPersist>`,
|
||||
* 由它把数据写入 localStorage 后 `router.replace('/chat')`。
|
||||
* 把 `deviceId` 路径参数与 `fbid` / `avatarUrl` 查询参数透传给 Client 组件
|
||||
* `<DeepLinkPersist>`,由它把数据写入 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 <DeepLinkPersist deviceId={deviceId} fbid={fbidStr} />;
|
||||
return (
|
||||
<DeepLinkPersist
|
||||
deviceId={deviceId}
|
||||
fbid={fbidStr}
|
||||
avatarUrl={avatarUrlStr}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ResultT<void>> {
|
||||
return SpAsyncUtil.setString(StorageKeys.facebookId, id);
|
||||
}
|
||||
clearFacebookId(): Promise<ResultT<void>> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface IAuthStorage {
|
||||
|
||||
getFacebookId(): Promise<Result<string | null>>;
|
||||
setFacebookId(id: string): Promise<Result<void>>;
|
||||
clearFacebookId(): Promise<Result<void>>;
|
||||
|
||||
clearAuthData(): Promise<Result<void>>;
|
||||
}
|
||||
|
||||
@@ -243,8 +243,9 @@ export const guestLoginActor = fromPromise<LoginStatus, void>(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<LoginStatus, void>(async () => {
|
||||
// 1. 拿 deviceId(不用于 auto-guest-login,仅供后续登录用)
|
||||
@@ -257,12 +258,32 @@ export const checkAuthStatusActor = fromPromise<LoginStatus, void>(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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user