refactor(chat): split screen orchestration
Docker Image / Build and Push Docker Image (push) Successful in 14m9s

This commit is contained in:
2026-07-08 15:33:24 +08:00
parent 18aa69bdd9
commit 21b6d346bd
15 changed files with 522 additions and 179 deletions
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import type { UiMessage } from "@/data/dto/chat";
import {
buildChatRenderItems,
createChatMessageKeyResolver,
} from "../components/chat-area";
describe("chat area render items", () => {
it("keeps local message keys stable when history is prepended", () => {
const localMessage: UiMessage = {
content: "optimistic message",
isFromAI: false,
date: "2026-07-08",
};
const historyMessage: UiMessage = {
id: "history-msg-1",
content: "history message",
isFromAI: true,
date: "2026-07-07",
};
const getMessageKey = createChatMessageKeyResolver();
const firstItems = buildChatRenderItems([localMessage], getMessageKey);
const secondItems = buildChatRenderItems(
[historyMessage, localMessage],
getMessageKey,
);
expect(findMessageKey(firstItems, localMessage)).toBe("local-msg-1");
expect(findMessageKey(secondItems, localMessage)).toBe("local-msg-1");
expect(findMessageKey(secondItems, historyMessage)).toBe(
"msg-history-msg-1",
);
});
});
function findMessageKey(
items: ReturnType<typeof buildChatRenderItems>,
message: UiMessage,
): string | undefined {
return items.find((item) => item.type === "msg" && item.message === message)
?.key;
}
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import { getInsufficientCreditsMessageLimitView } from "../chat-screen.helpers";
import {
getInsufficientCreditsMessageLimitView,
shouldShowMessageLimitBanner,
} from "../chat-screen.helpers";
describe("getInsufficientCreditsMessageLimitView", () => {
it("asks guests to log in for more free chats", () => {
@@ -33,3 +36,32 @@ describe("getInsufficientCreditsMessageLimitView", () => {
},
);
});
describe("shouldShowMessageLimitBanner", () => {
it("shows only for visible insufficient credit prompts", () => {
expect(
shouldShowMessageLimitBanner({
upgradePromptVisible: true,
upgradeReason: "insufficient_credits",
}),
).toBe(true);
});
it("hides when the prompt is not visible", () => {
expect(
shouldShowMessageLimitBanner({
upgradePromptVisible: false,
upgradeReason: "insufficient_credits",
}),
).toBe(false);
});
it("hides when there is no upgrade reason", () => {
expect(
shouldShowMessageLimitBanner({
upgradePromptVisible: true,
upgradeReason: null,
}),
).toBe(false);
});
});
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { shouldSubmitGuestLogin } from "../hooks/use-chat-guest-login";
describe("shouldSubmitGuestLogin", () => {
it("submits once after auth initialization reaches notLoggedIn", () => {
expect(
shouldSubmitGuestLogin({
alreadyRequested: false,
hasInitialized: true,
isLoading: false,
loginStatus: "notLoggedIn",
}),
).toBe(true);
});
it("waits until auth initialization has completed", () => {
expect(
shouldSubmitGuestLogin({
alreadyRequested: false,
hasInitialized: false,
isLoading: false,
loginStatus: "notLoggedIn",
}),
).toBe(false);
});
it("does not submit while auth is already loading", () => {
expect(
shouldSubmitGuestLogin({
alreadyRequested: false,
hasInitialized: true,
isLoading: true,
loginStatus: "notLoggedIn",
}),
).toBe(false);
});
it("does not submit again for the same notLoggedIn window", () => {
expect(
shouldSubmitGuestLogin({
alreadyRequested: true,
hasInitialized: true,
isLoading: false,
loginStatus: "notLoggedIn",
}),
).toBe(false);
});
it("does not submit when the user already has a login status", () => {
expect(
shouldSubmitGuestLogin({
alreadyRequested: false,
hasInitialized: true,
isLoading: false,
loginStatus: "guest",
}),
).toBe(false);
});
});
+13
View File
@@ -1,6 +1,7 @@
"use client";
import type { LoginStatus } from "@/data/dto/auth";
import type { ChatUpgradeReason } from "@/stores/chat/chat-state";
import { AppEnvUtil, BrowserDetector } from "@/utils";
export interface ExternalBrowserPromptState {
@@ -18,10 +19,22 @@ export interface InsufficientCreditsMessageLimitView {
action: InsufficientCreditsAction;
}
export interface MessageLimitBannerState {
upgradePromptVisible: boolean;
upgradeReason: ChatUpgradeReason | null;
}
export function deriveIsGuest(loginStatus: LoginStatus): boolean {
return loginStatus === "guest";
}
export function shouldShowMessageLimitBanner({
upgradePromptVisible,
upgradeReason,
}: MessageLimitBannerState): boolean {
return upgradePromptVisible && upgradeReason === "insufficient_credits";
}
export function getInsufficientCreditsMessageLimitView(
loginStatus: LoginStatus,
): InsufficientCreditsMessageLimitView {
+30 -124
View File
@@ -1,18 +1,10 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { useUserState } from "@/stores/user/user-context";
import { useChatState } from "@/stores/chat/chat-context";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
openChatInExternalBrowser,
recordExternalBrowserPromptShown,
resolveExternalBrowserPromptEligibility,
} from "@/lib/chat/chat_external_browser";
import { MobileShell } from "@/app/_components/core";
@@ -22,32 +14,26 @@ import {
ChatHeader,
ChatInputBar,
ChatInsufficientCreditsBanner,
ChatUnlockDialogs,
ExternalBrowserDialog,
FirstRechargeOfferBanner,
HistoryUnlockDialog,
InsufficientCreditsDialog,
PwaInstallOverlay,
} from "./components";
import {
deriveIsGuest,
getInsufficientCreditsMessageLimitView,
getInsufficientCreditsSubscriptionType,
isChatDevelopmentEnvironment,
shouldStartExternalBrowserPrompt,
} from "./chat-screen.helpers";
import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner";
import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow";
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
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";
export function ChatScreen() {
const state = useChatState();
const chatDispatch = useChatDispatch();
const authState = useAuthState();
const authDispatch = useAuthDispatch();
const userState = useUserState();
const navigator = useAppNavigator();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false);
const {
unlockPaywallRequest,
requestMessageUnlock,
@@ -59,100 +45,33 @@ export function ChatScreen() {
const isGuest = deriveIsGuest(authState.loginStatus);
// 发送能力由后端统一返回,当前只处理积分不足引导。
const showMessageLimitBanner =
state.upgradePromptVisible &&
state.upgradeReason === "insufficient_credits";
const messageLimitView = getInsufficientCreditsMessageLimitView(
authState.loginStatus,
);
const messageLimitBanner = useChatMessageLimitBanner({
upgradePromptVisible: state.upgradePromptVisible,
upgradeReason: state.upgradeReason,
loginStatus: authState.loginStatus,
});
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
historyLoaded: state.historyLoaded,
loginStatus: authState.loginStatus,
});
const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10;
const externalBrowserPromptShownRef = useRef(false);
const guestLoginRequestedRef = useRef(false);
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
if (authState.loginStatus !== "notLoggedIn") {
guestLoginRequestedRef.current = false;
return;
}
if (guestLoginRequestedRef.current) return;
guestLoginRequestedRef.current = true;
authDispatch({ type: "AuthGuestLoginSubmitted" });
}, [
authDispatch,
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
]);
useEffect(() => {
if (
!shouldStartExternalBrowserPrompt({
const externalBrowserPrompt = useExternalBrowserPrompt({
hasInitialized: authState.hasInitialized,
isLoading: authState.isLoading,
loginStatus: authState.loginStatus,
})
) {
return;
}
});
let mounted = true;
let timer: number | undefined;
const init = async () => {
if (externalBrowserPromptShownRef.current) return;
const eligibility = await resolveExternalBrowserPromptEligibility();
if (!mounted || !eligibility.canShow) return;
externalBrowserPromptShownRef.current = true;
timer = window.setTimeout(() => {
if (!mounted) return;
setShowExternalBrowserDialog(true);
void recordExternalBrowserPromptShown(eligibility.today);
}, 3000);
};
void init();
return () => {
mounted = false;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
]);
async function handleOpenExternalBrowser(): Promise<void> {
setShowExternalBrowserDialog(false);
await openChatInExternalBrowser();
}
useChatGuestLogin({
dispatch: authDispatch,
hasInitialized: authState.hasInitialized,
isLoading: authState.isLoading,
loginStatus: authState.loginStatus,
});
function handleUnlockPrivateMessage(messageId: string): void {
requestMessageUnlock(messageId, "private");
}
function handleMessageLimitUnlock(): void {
if (messageLimitView.action === "auth") {
navigator.openAuth(ROUTES.chat);
return;
}
navigator.openSubscription({
type: getInsufficientCreditsSubscriptionType(userState.isVip),
returnTo: "chat",
});
}
function handleUnlockVoiceMessage(messageId: string): void {
requestMessageUnlock(messageId, "voice");
}
@@ -187,19 +106,18 @@ export function ChatScreen() {
<ChatArea
messages={state.messages}
isReplyingAI={state.isReplyingAI}
isGuest={isGuest}
isUnlockingMessage={state.isUnlockingMessage}
unlockingMessageId={state.unlockingMessageId}
onUnlockPrivateMessage={handleUnlockPrivateMessage}
onUnlockVoiceMessage={handleUnlockVoiceMessage}
/>
{showMessageLimitBanner ? (
{messageLimitBanner.visible ? (
<ChatInsufficientCreditsBanner
title={messageLimitView.title}
description={messageLimitView.description}
ctaLabel={messageLimitView.ctaLabel}
onUnlock={handleMessageLimitUnlock}
title={messageLimitBanner.title}
description={messageLimitBanner.description}
ctaLabel={messageLimitBanner.ctaLabel}
onUnlock={messageLimitBanner.unlock}
/>
) : (
<ChatInputBar disabled={!state.historyLoaded} />
@@ -213,27 +131,15 @@ export function ChatScreen() {
<PwaInstallOverlay enabled={shouldShowPwaInstall} />
<ExternalBrowserDialog
open={showExternalBrowserDialog}
onClose={() => setShowExternalBrowserDialog(false)}
onConfirm={() => void handleOpenExternalBrowser()}
open={externalBrowserPrompt.open}
onClose={externalBrowserPrompt.close}
onConfirm={() => void externalBrowserPrompt.confirm()}
/>
<HistoryUnlockDialog
open={state.unlockHistoryPromptVisible}
lockedCount={state.lockedHistoryCount}
isLoading={state.isUnlockingHistory}
errorMessage={state.unlockHistoryError}
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
/>
<InsufficientCreditsDialog
open={unlockPaywallRequest !== null}
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0}
shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0}
onClose={closeInsufficientCreditsDialog}
onConfirm={confirmInsufficientCreditsDialog}
<ChatUnlockDialogs
unlockPaywallRequest={unlockPaywallRequest}
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
/>
</div>
</MobileShell>
@@ -59,6 +59,32 @@
gap: var(--spacing-1, 4px);
}
.bubbleInlineSpacer {
flex: 0 0 var(--spacing-sm, 8px);
width: var(--spacing-sm, 8px);
}
.bubbleAvatarSpacer {
flex: 0 0 var(--chat-avatar-size, 43px);
width: var(--chat-avatar-size, 43px);
}
.messageContent {
display: flex;
flex: 1 1 0;
min-width: 0;
flex-direction: column;
gap: var(--spacing-sm, 8px);
}
.messageContentAi {
align-items: flex-start;
}
.messageContentUser {
align-items: flex-end;
}
.bubbleAi {
max-width: var(--chat-bubble-max-width, 75%);
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
+47 -12
View File
@@ -12,7 +12,7 @@
* - 新消息到达时自动滚到底部
* - 用户向上滚动时不强制拉回(保持阅读上下文)
*/
import { useEffect, useLayoutEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
import type { UiMessage } from "@/data/dto/chat";
@@ -31,11 +31,14 @@ import styles from "./chat-area.module.css";
const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96;
type RestoreState = "idle" | "checking" | "restoring" | "settlingBottom" | "done";
type ChatMessageKeyResolver = (message: UiMessage) => string;
type ChatRenderItem =
| { type: "date"; date: string; key: string }
| { type: "msg"; message: UiMessage; key: string };
export interface ChatAreaProps {
messages: readonly UiMessage[];
isReplyingAI: boolean;
isGuest: boolean;
isUnlockingMessage?: boolean;
unlockingMessageId?: string | null;
onUnlockPrivateMessage?: (messageId: string) => void;
@@ -55,6 +58,7 @@ export function ChatArea({
const restoredScrollRef = useRef(false);
const restoreStateRef = useRef<RestoreState>("idle");
const wasNearBottomRef = useRef(true);
const getMessageKey = useMemo(() => createChatMessageKeyResolver(), []);
const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => {
const scrollNode = scrollRef.current;
@@ -160,6 +164,7 @@ export function ChatArea({
{renderMessagesWithDateHeaders(
messages,
getMessageKey,
isUnlockingMessage,
unlockingMessageId,
onUnlockPrivateMessage,
@@ -221,21 +226,13 @@ function startStableScrollTask({
/** 渲染消息列表(按日期分组插入分隔条) */
function renderMessagesWithDateHeaders(
messages: readonly UiMessage[],
getMessageKey: (message: UiMessage) => string,
isUnlockingMessage?: boolean,
unlockingMessageId?: string | null,
onUnlockPrivateMessage?: (messageId: string) => void,
onUnlockVoiceMessage?: (messageId: string) => void,
) {
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
messages.forEach((m, i) => {
if (i === 0 || m.date !== messages[i - 1].date) {
items.push({ type: "date", date: m.date, key: `d-${i}-${m.date}` });
}
items.push({ type: "msg", message: m, key: `m-${i}` });
});
return items.map((item) =>
return buildChatRenderItems(messages, getMessageKey).map((item) =>
item.type === "date" ? (
<DateHeader key={item.key} date={item.date} />
) : (
@@ -261,3 +258,41 @@ function renderMessagesWithDateHeaders(
),
);
}
export function createChatMessageKeyResolver(): ChatMessageKeyResolver {
const localMessageKeys = new WeakMap<UiMessage, string>();
let nextLocalMessageKey = 0;
return (message) => {
if (message.id && message.id.length > 0) return `msg-${message.id}`;
const existing = localMessageKeys.get(message);
if (existing) return existing;
nextLocalMessageKey += 1;
const next = `local-msg-${nextLocalMessageKey}`;
localMessageKeys.set(message, next);
return next;
};
}
export function buildChatRenderItems(
messages: readonly UiMessage[],
getMessageKey: ChatMessageKeyResolver,
): ChatRenderItem[] {
const items: ChatRenderItem[] = [];
messages.forEach((message, index) => {
const messageKey = getMessageKey(message);
if (index === 0 || message.date !== messages[index - 1].date) {
items.push({
type: "date",
date: message.date,
key: `date-${message.date}-${messageKey}`,
});
}
items.push({ type: "msg", message, key: messageKey });
});
return items;
}
@@ -0,0 +1,43 @@
"use client";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
import { HistoryUnlockDialog } from "./history-unlock-dialog";
import { InsufficientCreditsDialog } from "./insufficient-credits-dialog";
export interface ChatUnlockDialogsProps {
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
onCloseInsufficientCreditsDialog: () => void;
onConfirmInsufficientCreditsDialog: () => void;
}
export function ChatUnlockDialogs({
unlockPaywallRequest,
onCloseInsufficientCreditsDialog,
onConfirmInsufficientCreditsDialog,
}: ChatUnlockDialogsProps) {
const chatState = useChatState();
const chatDispatch = useChatDispatch();
return (
<>
<HistoryUnlockDialog
open={chatState.unlockHistoryPromptVisible}
lockedCount={chatState.lockedHistoryCount}
isLoading={chatState.isUnlockingHistory}
errorMessage={chatState.unlockHistoryError}
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
/>
<InsufficientCreditsDialog
open={unlockPaywallRequest !== null}
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0}
shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0}
onClose={onCloseInsufficientCreditsDialog}
onConfirm={onConfirmInsufficientCreditsDialog}
/>
</>
);
}
+1
View File
@@ -10,6 +10,7 @@ export * from "./chat-insufficient-credits-banner";
export * from "./chat-input-bar";
export * from "./chat-input-text-field";
export * from "./chat-send-button";
export * from "./chat-unlock-dialogs";
export * from "./date-header";
export * from "./external-browser-dialog";
export * from "./first-recharge-offer-banner";
+4 -4
View File
@@ -55,7 +55,7 @@ export function MessageBubble({
aria-label="AI message"
>
<MessageAvatar isFromAI={true} />
<div style={{ width: 8 }} />
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageContent
content={content}
imageUrl={imageUrl}
@@ -71,7 +71,7 @@ export function MessageBubble({
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage}
/>
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
</div>
);
}
@@ -82,7 +82,7 @@ export function MessageBubble({
data-chat-message-id={messageId}
aria-label="User message"
>
<div style={{ width: 43 }} /> {/* 占位 */}
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
<MessageContent
content={content}
imageUrl={imageUrl}
@@ -98,7 +98,7 @@ export function MessageBubble({
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage}
/>
<div style={{ width: 8 }} />
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
</div>
);
+5 -10
View File
@@ -3,6 +3,7 @@ import { ImageBubble } from "./image-bubble";
import { PrivateMessageCard } from "./private-message-card";
import { TextBubble } from "./text-bubble";
import { VoiceBubble } from "./voice-bubble";
import styles from "./chat-area.module.css";
export interface MessageContentProps {
content: string;
@@ -55,16 +56,10 @@ export function MessageContent({
return (
<div
style={{
display: "flex",
flexDirection: "column",
// 关键:在 row flex 父中撑开(不依赖 content 循环)—— 让 TextBubble 能拿到真实宽度
flex: "1 1 0",
// 防超长 content(图片 / 超长 url)撑爆父
minWidth: 0,
alignItems: isFromAI ? "flex-start" : "flex-end",
gap: 8,
}}
className={[
styles.messageContent,
isFromAI ? styles.messageContentAi : styles.messageContentUser,
].join(" ")}
>
{isLockedPrivateMessage ? (
<PrivateMessageCard
@@ -0,0 +1,63 @@
"use client";
import { type Dispatch, useEffect, useRef } from "react";
import type { LoginStatus } from "@/data/dto/auth";
import type { AuthEvent } from "@/stores/auth/auth-events";
export interface GuestLoginState {
hasInitialized: boolean;
isLoading: boolean;
loginStatus: LoginStatus;
}
export interface ShouldSubmitGuestLoginInput extends GuestLoginState {
alreadyRequested: boolean;
}
export interface UseChatGuestLoginInput extends GuestLoginState {
dispatch: Dispatch<AuthEvent>;
}
export function shouldSubmitGuestLogin({
alreadyRequested,
hasInitialized,
isLoading,
loginStatus,
}: ShouldSubmitGuestLoginInput): boolean {
if (!hasInitialized || isLoading) return false;
if (loginStatus !== "notLoggedIn") return false;
return !alreadyRequested;
}
export function useChatGuestLogin({
dispatch,
hasInitialized,
isLoading,
loginStatus,
}: UseChatGuestLoginInput): void {
const guestLoginRequestedRef = useRef(false);
useEffect(() => {
if (!hasInitialized || isLoading) return;
if (loginStatus !== "notLoggedIn") {
guestLoginRequestedRef.current = false;
return;
}
if (
!shouldSubmitGuestLogin({
alreadyRequested: guestLoginRequestedRef.current,
hasInitialized,
isLoading,
loginStatus,
})
) {
return;
}
guestLoginRequestedRef.current = true;
dispatch({ type: "AuthGuestLoginSubmitted" });
}, [dispatch, hasInitialized, isLoading, loginStatus]);
}
@@ -0,0 +1,57 @@
"use client";
import type { LoginStatus } from "@/data/dto/auth";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import type { ChatUpgradeReason } from "@/stores/chat/chat-state";
import { useUserState } from "@/stores/user/user-context";
import {
getInsufficientCreditsMessageLimitView,
getInsufficientCreditsSubscriptionType,
shouldShowMessageLimitBanner,
type InsufficientCreditsMessageLimitView,
} from "../chat-screen.helpers";
export interface UseChatMessageLimitBannerInput {
upgradePromptVisible: boolean;
upgradeReason: ChatUpgradeReason | null;
loginStatus: LoginStatus;
}
export interface ChatMessageLimitBannerView
extends InsufficientCreditsMessageLimitView {
visible: boolean;
unlock: () => void;
}
export function useChatMessageLimitBanner({
upgradePromptVisible,
upgradeReason,
loginStatus,
}: UseChatMessageLimitBannerInput): ChatMessageLimitBannerView {
const navigator = useAppNavigator();
const userState = useUserState();
const view = getInsufficientCreditsMessageLimitView(loginStatus);
function unlock(): void {
if (view.action === "auth") {
navigator.openAuth(ROUTES.chat);
return;
}
navigator.openSubscription({
type: getInsufficientCreditsSubscriptionType(userState.isVip),
returnTo: "chat",
});
}
return {
...view,
visible: shouldShowMessageLimitBanner({
upgradePromptVisible,
upgradeReason,
}),
unlock,
};
}
@@ -0,0 +1,82 @@
"use client";
import { useCallback, useEffect, useRef, useState } 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 interface ExternalBrowserPromptController {
open: boolean;
close: () => void;
confirm: () => Promise<void>;
}
export function useExternalBrowserPrompt({
hasInitialized,
isLoading,
loginStatus,
}: ExternalBrowserPromptState): ExternalBrowserPromptController {
const [open, setOpen] = useState(false);
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;
setOpen(true);
void recordExternalBrowserPromptShown(eligibility.today);
}, EXTERNAL_BROWSER_PROMPT_DELAY_MS);
};
void init();
return () => {
mounted = false;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [hasInitialized, isLoading, loginStatus]);
const close = useCallback(() => {
setOpen(false);
}, []);
const confirm = useCallback(async () => {
setOpen(false);
await openChatInExternalBrowser();
}, []);
return {
open,
close,
confirm,
};
}
@@ -3,12 +3,11 @@
import { MobileShell } from "@/app/_components/core";
import { ROUTE_BUILDERS } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { useChatState } from "@/stores/chat/chat-context";
import {
ChatUnlockDialogs,
FullscreenImageViewer,
HistoryUnlockDialog,
InsufficientCreditsDialog,
} from "../../components";
import { useChatUnlockNavigationFlow } from "../../hooks/use-chat-unlock-navigation-flow";
import styles from "./chat-image-viewer-screen.module.css";
@@ -22,7 +21,6 @@ export function ChatImageViewerScreen({
}: ChatImageViewerScreenProps) {
const navigator = useAppNavigator();
const chatState = useChatState();
const chatDispatch = useChatDispatch();
const returnUrl = ROUTE_BUILDERS.chatImage(messageId);
const {
unlockPaywallRequest,
@@ -46,25 +44,12 @@ export function ChatImageViewerScreen({
requestMessageUnlock(messageId, "image");
};
const historyUnlockDialog = (
<>
<HistoryUnlockDialog
open={chatState.unlockHistoryPromptVisible}
lockedCount={chatState.lockedHistoryCount}
isLoading={chatState.isUnlockingHistory}
errorMessage={chatState.unlockHistoryError}
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
const unlockDialogs = (
<ChatUnlockDialogs
unlockPaywallRequest={unlockPaywallRequest}
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
/>
<InsufficientCreditsDialog
open={unlockPaywallRequest !== null}
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0}
shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0}
onClose={closeInsufficientCreditsDialog}
onConfirm={confirmInsufficientCreditsDialog}
/>
</>
);
if (!message) {
@@ -84,7 +69,7 @@ export function ChatImageViewerScreen({
</p>
</main>
</MobileShell>
{historyUnlockDialog}
{unlockDialogs}
</>
);
}
@@ -104,7 +89,7 @@ export function ChatImageViewerScreen({
onClose={handleClose}
/>
</MobileShell>
{historyUnlockDialog}
{unlockDialogs}
</>
);
}