refactor(chat): split screen orchestration
Docker Image / Build and Push Docker Image (push) Successful in 14m9s
Docker Image / Build and Push Docker Image (push) Successful in 14m9s
This commit is contained in:
@@ -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 { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { getInsufficientCreditsMessageLimitView } from "../chat-screen.helpers";
|
import {
|
||||||
|
getInsufficientCreditsMessageLimitView,
|
||||||
|
shouldShowMessageLimitBanner,
|
||||||
|
} from "../chat-screen.helpers";
|
||||||
|
|
||||||
describe("getInsufficientCreditsMessageLimitView", () => {
|
describe("getInsufficientCreditsMessageLimitView", () => {
|
||||||
it("asks guests to log in for more free chats", () => {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { LoginStatus } from "@/data/dto/auth";
|
import type { LoginStatus } from "@/data/dto/auth";
|
||||||
|
import type { ChatUpgradeReason } from "@/stores/chat/chat-state";
|
||||||
import { AppEnvUtil, BrowserDetector } from "@/utils";
|
import { AppEnvUtil, BrowserDetector } from "@/utils";
|
||||||
|
|
||||||
export interface ExternalBrowserPromptState {
|
export interface ExternalBrowserPromptState {
|
||||||
@@ -18,10 +19,22 @@ export interface InsufficientCreditsMessageLimitView {
|
|||||||
action: InsufficientCreditsAction;
|
action: InsufficientCreditsAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessageLimitBannerState {
|
||||||
|
upgradePromptVisible: boolean;
|
||||||
|
upgradeReason: ChatUpgradeReason | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function deriveIsGuest(loginStatus: LoginStatus): boolean {
|
export function deriveIsGuest(loginStatus: LoginStatus): boolean {
|
||||||
return loginStatus === "guest";
|
return loginStatus === "guest";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldShowMessageLimitBanner({
|
||||||
|
upgradePromptVisible,
|
||||||
|
upgradeReason,
|
||||||
|
}: MessageLimitBannerState): boolean {
|
||||||
|
return upgradePromptVisible && upgradeReason === "insufficient_credits";
|
||||||
|
}
|
||||||
|
|
||||||
export function getInsufficientCreditsMessageLimitView(
|
export function getInsufficientCreditsMessageLimitView(
|
||||||
loginStatus: LoginStatus,
|
loginStatus: LoginStatus,
|
||||||
): InsufficientCreditsMessageLimitView {
|
): InsufficientCreditsMessageLimitView {
|
||||||
|
|||||||
+30
-124
@@ -1,18 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
import { useChatState } from "@/stores/chat/chat-context";
|
||||||
import { useUserState } from "@/stores/user/user-context";
|
|
||||||
import { ROUTES } from "@/router/routes";
|
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";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
|
|
||||||
@@ -22,32 +14,26 @@ import {
|
|||||||
ChatHeader,
|
ChatHeader,
|
||||||
ChatInputBar,
|
ChatInputBar,
|
||||||
ChatInsufficientCreditsBanner,
|
ChatInsufficientCreditsBanner,
|
||||||
|
ChatUnlockDialogs,
|
||||||
ExternalBrowserDialog,
|
ExternalBrowserDialog,
|
||||||
FirstRechargeOfferBanner,
|
FirstRechargeOfferBanner,
|
||||||
HistoryUnlockDialog,
|
|
||||||
InsufficientCreditsDialog,
|
|
||||||
PwaInstallOverlay,
|
PwaInstallOverlay,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import {
|
import {
|
||||||
deriveIsGuest,
|
deriveIsGuest,
|
||||||
getInsufficientCreditsMessageLimitView,
|
|
||||||
getInsufficientCreditsSubscriptionType,
|
|
||||||
isChatDevelopmentEnvironment,
|
isChatDevelopmentEnvironment,
|
||||||
shouldStartExternalBrowserPrompt,
|
|
||||||
} from "./chat-screen.helpers";
|
} from "./chat-screen.helpers";
|
||||||
import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner";
|
import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner";
|
||||||
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 { 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() {
|
||||||
const state = useChatState();
|
const state = useChatState();
|
||||||
const chatDispatch = useChatDispatch();
|
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
const authDispatch = useAuthDispatch();
|
||||||
const userState = useUserState();
|
|
||||||
const navigator = useAppNavigator();
|
|
||||||
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
|
|
||||||
useState(false);
|
|
||||||
const {
|
const {
|
||||||
unlockPaywallRequest,
|
unlockPaywallRequest,
|
||||||
requestMessageUnlock,
|
requestMessageUnlock,
|
||||||
@@ -59,100 +45,33 @@ export function ChatScreen() {
|
|||||||
const isGuest = deriveIsGuest(authState.loginStatus);
|
const isGuest = deriveIsGuest(authState.loginStatus);
|
||||||
|
|
||||||
// 发送能力由后端统一返回,当前只处理积分不足引导。
|
// 发送能力由后端统一返回,当前只处理积分不足引导。
|
||||||
const showMessageLimitBanner =
|
const messageLimitBanner = useChatMessageLimitBanner({
|
||||||
state.upgradePromptVisible &&
|
upgradePromptVisible: state.upgradePromptVisible,
|
||||||
state.upgradeReason === "insufficient_credits";
|
upgradeReason: state.upgradeReason,
|
||||||
const messageLimitView = getInsufficientCreditsMessageLimitView(
|
loginStatus: authState.loginStatus,
|
||||||
authState.loginStatus,
|
});
|
||||||
);
|
|
||||||
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
|
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
|
||||||
historyLoaded: state.historyLoaded,
|
historyLoaded: state.historyLoaded,
|
||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
});
|
});
|
||||||
const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10;
|
const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10;
|
||||||
|
const externalBrowserPrompt = useExternalBrowserPrompt({
|
||||||
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({
|
|
||||||
hasInitialized: authState.hasInitialized,
|
hasInitialized: authState.hasInitialized,
|
||||||
isLoading: authState.isLoading,
|
isLoading: authState.isLoading,
|
||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
})
|
});
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mounted = true;
|
useChatGuestLogin({
|
||||||
let timer: number | undefined;
|
dispatch: authDispatch,
|
||||||
|
hasInitialized: authState.hasInitialized,
|
||||||
const init = async () => {
|
isLoading: authState.isLoading,
|
||||||
if (externalBrowserPromptShownRef.current) return;
|
loginStatus: authState.loginStatus,
|
||||||
|
});
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUnlockPrivateMessage(messageId: string): void {
|
function handleUnlockPrivateMessage(messageId: string): void {
|
||||||
requestMessageUnlock(messageId, "private");
|
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 {
|
function handleUnlockVoiceMessage(messageId: string): void {
|
||||||
requestMessageUnlock(messageId, "voice");
|
requestMessageUnlock(messageId, "voice");
|
||||||
}
|
}
|
||||||
@@ -187,19 +106,18 @@ export function ChatScreen() {
|
|||||||
<ChatArea
|
<ChatArea
|
||||||
messages={state.messages}
|
messages={state.messages}
|
||||||
isReplyingAI={state.isReplyingAI}
|
isReplyingAI={state.isReplyingAI}
|
||||||
isGuest={isGuest}
|
|
||||||
isUnlockingMessage={state.isUnlockingMessage}
|
isUnlockingMessage={state.isUnlockingMessage}
|
||||||
unlockingMessageId={state.unlockingMessageId}
|
unlockingMessageId={state.unlockingMessageId}
|
||||||
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
||||||
onUnlockVoiceMessage={handleUnlockVoiceMessage}
|
onUnlockVoiceMessage={handleUnlockVoiceMessage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showMessageLimitBanner ? (
|
{messageLimitBanner.visible ? (
|
||||||
<ChatInsufficientCreditsBanner
|
<ChatInsufficientCreditsBanner
|
||||||
title={messageLimitView.title}
|
title={messageLimitBanner.title}
|
||||||
description={messageLimitView.description}
|
description={messageLimitBanner.description}
|
||||||
ctaLabel={messageLimitView.ctaLabel}
|
ctaLabel={messageLimitBanner.ctaLabel}
|
||||||
onUnlock={handleMessageLimitUnlock}
|
onUnlock={messageLimitBanner.unlock}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ChatInputBar disabled={!state.historyLoaded} />
|
<ChatInputBar disabled={!state.historyLoaded} />
|
||||||
@@ -213,27 +131,15 @@ export function ChatScreen() {
|
|||||||
<PwaInstallOverlay enabled={shouldShowPwaInstall} />
|
<PwaInstallOverlay enabled={shouldShowPwaInstall} />
|
||||||
|
|
||||||
<ExternalBrowserDialog
|
<ExternalBrowserDialog
|
||||||
open={showExternalBrowserDialog}
|
open={externalBrowserPrompt.open}
|
||||||
onClose={() => setShowExternalBrowserDialog(false)}
|
onClose={externalBrowserPrompt.close}
|
||||||
onConfirm={() => void handleOpenExternalBrowser()}
|
onConfirm={() => void externalBrowserPrompt.confirm()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HistoryUnlockDialog
|
<ChatUnlockDialogs
|
||||||
open={state.unlockHistoryPromptVisible}
|
unlockPaywallRequest={unlockPaywallRequest}
|
||||||
lockedCount={state.lockedHistoryCount}
|
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
|
||||||
isLoading={state.isUnlockingHistory}
|
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
|
|||||||
@@ -59,6 +59,32 @@
|
|||||||
gap: var(--spacing-1, 4px);
|
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 {
|
.bubbleAi {
|
||||||
max-width: var(--chat-bubble-max-width, 75%);
|
max-width: var(--chat-bubble-max-width, 75%);
|
||||||
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
|
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* - 新消息到达时自动滚到底部
|
* - 新消息到达时自动滚到底部
|
||||||
* - 用户向上滚动时不强制拉回(保持阅读上下文)
|
* - 用户向上滚动时不强制拉回(保持阅读上下文)
|
||||||
*/
|
*/
|
||||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||||
|
|
||||||
import type { UiMessage } from "@/data/dto/chat";
|
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;
|
const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96;
|
||||||
|
|
||||||
type RestoreState = "idle" | "checking" | "restoring" | "settlingBottom" | "done";
|
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 {
|
export interface ChatAreaProps {
|
||||||
messages: readonly UiMessage[];
|
messages: readonly UiMessage[];
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
isGuest: boolean;
|
|
||||||
isUnlockingMessage?: boolean;
|
isUnlockingMessage?: boolean;
|
||||||
unlockingMessageId?: string | null;
|
unlockingMessageId?: string | null;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||||
@@ -55,6 +58,7 @@ export function ChatArea({
|
|||||||
const restoredScrollRef = useRef(false);
|
const restoredScrollRef = useRef(false);
|
||||||
const restoreStateRef = useRef<RestoreState>("idle");
|
const restoreStateRef = useRef<RestoreState>("idle");
|
||||||
const wasNearBottomRef = useRef(true);
|
const wasNearBottomRef = useRef(true);
|
||||||
|
const getMessageKey = useMemo(() => createChatMessageKeyResolver(), []);
|
||||||
|
|
||||||
const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => {
|
const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => {
|
||||||
const scrollNode = scrollRef.current;
|
const scrollNode = scrollRef.current;
|
||||||
@@ -160,6 +164,7 @@ export function ChatArea({
|
|||||||
|
|
||||||
{renderMessagesWithDateHeaders(
|
{renderMessagesWithDateHeaders(
|
||||||
messages,
|
messages,
|
||||||
|
getMessageKey,
|
||||||
isUnlockingMessage,
|
isUnlockingMessage,
|
||||||
unlockingMessageId,
|
unlockingMessageId,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
@@ -221,21 +226,13 @@ function startStableScrollTask({
|
|||||||
/** 渲染消息列表(按日期分组插入分隔条) */
|
/** 渲染消息列表(按日期分组插入分隔条) */
|
||||||
function renderMessagesWithDateHeaders(
|
function renderMessagesWithDateHeaders(
|
||||||
messages: readonly UiMessage[],
|
messages: readonly UiMessage[],
|
||||||
|
getMessageKey: (message: UiMessage) => string,
|
||||||
isUnlockingMessage?: boolean,
|
isUnlockingMessage?: boolean,
|
||||||
unlockingMessageId?: string | null,
|
unlockingMessageId?: string | null,
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void,
|
onUnlockPrivateMessage?: (messageId: string) => void,
|
||||||
onUnlockVoiceMessage?: (messageId: string) => void,
|
onUnlockVoiceMessage?: (messageId: string) => void,
|
||||||
) {
|
) {
|
||||||
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
|
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
||||||
|
|
||||||
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) =>
|
|
||||||
item.type === "date" ? (
|
item.type === "date" ? (
|
||||||
<DateHeader key={item.key} date={item.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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export * from "./chat-insufficient-credits-banner";
|
|||||||
export * from "./chat-input-bar";
|
export * from "./chat-input-bar";
|
||||||
export * from "./chat-input-text-field";
|
export * from "./chat-input-text-field";
|
||||||
export * from "./chat-send-button";
|
export * from "./chat-send-button";
|
||||||
|
export * from "./chat-unlock-dialogs";
|
||||||
export * from "./date-header";
|
export * from "./date-header";
|
||||||
export * from "./external-browser-dialog";
|
export * from "./external-browser-dialog";
|
||||||
export * from "./first-recharge-offer-banner";
|
export * from "./first-recharge-offer-banner";
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function MessageBubble({
|
|||||||
aria-label="AI message"
|
aria-label="AI message"
|
||||||
>
|
>
|
||||||
<MessageAvatar isFromAI={true} />
|
<MessageAvatar isFromAI={true} />
|
||||||
<div style={{ width: 8 }} />
|
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
||||||
<MessageContent
|
<MessageContent
|
||||||
content={content}
|
content={content}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
@@ -71,7 +71,7 @@ export function MessageBubble({
|
|||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
/>
|
/>
|
||||||
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
|
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ export function MessageBubble({
|
|||||||
data-chat-message-id={messageId}
|
data-chat-message-id={messageId}
|
||||||
aria-label="User message"
|
aria-label="User message"
|
||||||
>
|
>
|
||||||
<div style={{ width: 43 }} /> {/* 占位 */}
|
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||||
<MessageContent
|
<MessageContent
|
||||||
content={content}
|
content={content}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
@@ -98,7 +98,7 @@ export function MessageBubble({
|
|||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
/>
|
/>
|
||||||
<div style={{ width: 8 }} />
|
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
||||||
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
|
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ImageBubble } from "./image-bubble";
|
|||||||
import { PrivateMessageCard } from "./private-message-card";
|
import { PrivateMessageCard } from "./private-message-card";
|
||||||
import { TextBubble } from "./text-bubble";
|
import { TextBubble } from "./text-bubble";
|
||||||
import { VoiceBubble } from "./voice-bubble";
|
import { VoiceBubble } from "./voice-bubble";
|
||||||
|
import styles from "./chat-area.module.css";
|
||||||
|
|
||||||
export interface MessageContentProps {
|
export interface MessageContentProps {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -55,16 +56,10 @@ export function MessageContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
className={[
|
||||||
display: "flex",
|
styles.messageContent,
|
||||||
flexDirection: "column",
|
isFromAI ? styles.messageContentAi : styles.messageContentUser,
|
||||||
// 关键:在 row flex 父中撑开(不依赖 content 循环)—— 让 TextBubble 能拿到真实宽度
|
].join(" ")}
|
||||||
flex: "1 1 0",
|
|
||||||
// 防超长 content(图片 / 超长 url)撑爆父
|
|
||||||
minWidth: 0,
|
|
||||||
alignItems: isFromAI ? "flex-start" : "flex-end",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isLockedPrivateMessage ? (
|
{isLockedPrivateMessage ? (
|
||||||
<PrivateMessageCard
|
<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 { MobileShell } from "@/app/_components/core";
|
||||||
import { ROUTE_BUILDERS } from "@/router/routes";
|
import { ROUTE_BUILDERS } from "@/router/routes";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
import { useChatState } from "@/stores/chat/chat-context";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ChatUnlockDialogs,
|
||||||
FullscreenImageViewer,
|
FullscreenImageViewer,
|
||||||
HistoryUnlockDialog,
|
|
||||||
InsufficientCreditsDialog,
|
|
||||||
} from "../../components";
|
} from "../../components";
|
||||||
import { useChatUnlockNavigationFlow } from "../../hooks/use-chat-unlock-navigation-flow";
|
import { useChatUnlockNavigationFlow } from "../../hooks/use-chat-unlock-navigation-flow";
|
||||||
import styles from "./chat-image-viewer-screen.module.css";
|
import styles from "./chat-image-viewer-screen.module.css";
|
||||||
@@ -22,7 +21,6 @@ export function ChatImageViewerScreen({
|
|||||||
}: ChatImageViewerScreenProps) {
|
}: ChatImageViewerScreenProps) {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
const chatState = useChatState();
|
const chatState = useChatState();
|
||||||
const chatDispatch = useChatDispatch();
|
|
||||||
const returnUrl = ROUTE_BUILDERS.chatImage(messageId);
|
const returnUrl = ROUTE_BUILDERS.chatImage(messageId);
|
||||||
const {
|
const {
|
||||||
unlockPaywallRequest,
|
unlockPaywallRequest,
|
||||||
@@ -46,25 +44,12 @@ export function ChatImageViewerScreen({
|
|||||||
requestMessageUnlock(messageId, "image");
|
requestMessageUnlock(messageId, "image");
|
||||||
};
|
};
|
||||||
|
|
||||||
const historyUnlockDialog = (
|
const unlockDialogs = (
|
||||||
<>
|
<ChatUnlockDialogs
|
||||||
<HistoryUnlockDialog
|
unlockPaywallRequest={unlockPaywallRequest}
|
||||||
open={chatState.unlockHistoryPromptVisible}
|
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
|
||||||
lockedCount={chatState.lockedHistoryCount}
|
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
|
||||||
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={closeInsufficientCreditsDialog}
|
|
||||||
onConfirm={confirmInsufficientCreditsDialog}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
@@ -84,7 +69,7 @@ export function ChatImageViewerScreen({
|
|||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
{historyUnlockDialog}
|
{unlockDialogs}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,7 +89,7 @@ export function ChatImageViewerScreen({
|
|||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
/>
|
/>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
{historyUnlockDialog}
|
{unlockDialogs}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user