feat(chat): add history unlock api
This commit is contained in:
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { LoginStatus } from "@/data/dto/auth";
|
||||||
|
import { AppStorage } from "@/data/storage/app/app_storage";
|
||||||
|
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||||
|
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||||
|
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
|
||||||
|
import { AppEnvUtil, BrowserDetector, todayString, UrlLauncherUtil } from "@/utils";
|
||||||
|
|
||||||
|
export interface ExternalBrowserPromptState {
|
||||||
|
hasInitialized: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
loginStatus: LoginStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveIsGuest(loginStatus: LoginStatus): boolean {
|
||||||
|
return loginStatus === "guest";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isChatDevelopmentEnvironment(): boolean {
|
||||||
|
return AppEnvUtil.isDevelopment();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldStartExternalBrowserPrompt({
|
||||||
|
hasInitialized,
|
||||||
|
isLoading,
|
||||||
|
loginStatus,
|
||||||
|
}: ExternalBrowserPromptState): boolean {
|
||||||
|
if (!hasInitialized || isLoading) return false;
|
||||||
|
return (
|
||||||
|
isChatDevelopmentEnvironment() ||
|
||||||
|
(loginStatus === "facebook" && BrowserDetector.isInAppBrowser())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveExternalBrowserPromptEligibility(): Promise<{
|
||||||
|
canShow: boolean;
|
||||||
|
today: string;
|
||||||
|
}> {
|
||||||
|
const today = todayString();
|
||||||
|
const result = await AppStorage.canShowExternalBrowserDialog(today);
|
||||||
|
return {
|
||||||
|
canShow: result.success && result.data,
|
||||||
|
today,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordExternalBrowserPromptShown(today: string): Promise<unknown> {
|
||||||
|
return AppStorage.recordExternalBrowserDialogShown(today);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openChatInExternalBrowser(): Promise<void> {
|
||||||
|
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.splash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatPaywallSubscriptionUrl(): string {
|
||||||
|
return ROUTE_BUILDERS.subscription("vip", {
|
||||||
|
returnTo: "chat",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatPaywallNavigationUrl(loginStatus: LoginStatus): string {
|
||||||
|
const subscriptionUrl = getChatPaywallSubscriptionUrl();
|
||||||
|
if (deriveIsGuest(loginStatus)) {
|
||||||
|
return ROUTE_BUILDERS.authWithRedirect(subscriptionUrl);
|
||||||
|
}
|
||||||
|
return subscriptionUrl;
|
||||||
|
}
|
||||||
@@ -5,13 +5,7 @@ import Image from "next/image";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
import type { LoginStatus } from "@/data/dto/auth";
|
|
||||||
import { AppStorage } from "@/data/storage/app/app_storage";
|
|
||||||
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
|
||||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
|
||||||
import { useChatState } from "@/stores/chat/chat-context";
|
import { useChatState } from "@/stores/chat/chat-context";
|
||||||
import { AppEnvUtil, BrowserDetector, todayString, UrlLauncherUtil } from "@/utils";
|
|
||||||
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
|
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
|
|
||||||
@@ -24,16 +18,17 @@ import {
|
|||||||
ExternalBrowserDialog,
|
ExternalBrowserDialog,
|
||||||
PwaInstallOverlay,
|
PwaInstallOverlay,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
|
import {
|
||||||
|
deriveIsGuest,
|
||||||
|
getChatPaywallNavigationUrl,
|
||||||
|
isChatDevelopmentEnvironment,
|
||||||
|
openChatInExternalBrowser,
|
||||||
|
recordExternalBrowserPromptShown,
|
||||||
|
resolveExternalBrowserPromptEligibility,
|
||||||
|
shouldStartExternalBrowserPrompt,
|
||||||
|
} from "./chat-screen.helpers";
|
||||||
import styles from "./components/chat-screen.module.css";
|
import styles from "./components/chat-screen.module.css";
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 派生 isGuest —— 单一来源:`auth.loginStatus`
|
|
||||||
*/
|
|
||||||
function deriveIsGuest(loginStatus: LoginStatus): boolean {
|
|
||||||
return loginStatus === "guest";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatScreen() {
|
export function ChatScreen() {
|
||||||
const state = useChatState();
|
const state = useChatState();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
@@ -50,17 +45,17 @@ export function ChatScreen() {
|
|||||||
const messageLimitTitle = "The limit for free chat times\nhas been reached";
|
const messageLimitTitle = "The limit for free chat times\nhas been reached";
|
||||||
|
|
||||||
const externalBrowserPromptShownRef = useRef(false);
|
const externalBrowserPromptShownRef = useRef(false);
|
||||||
const chatPaywallSubscriptionUrl = ROUTE_BUILDERS.subscription("vip", {
|
|
||||||
returnTo: "chat",
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authState.hasInitialized || authState.isLoading) return;
|
if (
|
||||||
const isDev = AppEnvUtil.isDevelopment();
|
!shouldStartExternalBrowserPrompt({
|
||||||
const canTriggerPrompt =
|
hasInitialized: authState.hasInitialized,
|
||||||
isDev ||
|
isLoading: authState.isLoading,
|
||||||
(authState.loginStatus === "facebook" && BrowserDetector.isInAppBrowser());
|
loginStatus: authState.loginStatus,
|
||||||
if (!canTriggerPrompt) return;
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
@@ -68,15 +63,14 @@ export function ChatScreen() {
|
|||||||
const init = async () => {
|
const init = async () => {
|
||||||
if (externalBrowserPromptShownRef.current) return;
|
if (externalBrowserPromptShownRef.current) return;
|
||||||
|
|
||||||
const today = todayString();
|
const eligibility = await resolveExternalBrowserPromptEligibility();
|
||||||
const canShowResult = await AppStorage.canShowExternalBrowserDialog(today);
|
if (!mounted || !eligibility.canShow) return;
|
||||||
if (!mounted || !canShowResult.success || !canShowResult.data) return;
|
|
||||||
|
|
||||||
externalBrowserPromptShownRef.current = true;
|
externalBrowserPromptShownRef.current = true;
|
||||||
timer = window.setTimeout(() => {
|
timer = window.setTimeout(() => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setShowExternalBrowserDialog(true);
|
setShowExternalBrowserDialog(true);
|
||||||
void AppStorage.recordExternalBrowserDialogShown(today);
|
void recordExternalBrowserPromptShown(eligibility.today);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,41 +88,11 @@ export function ChatScreen() {
|
|||||||
|
|
||||||
async function handleOpenExternalBrowser(): Promise<void> {
|
async function handleOpenExternalBrowser(): Promise<void> {
|
||||||
setShowExternalBrowserDialog(false);
|
setShowExternalBrowserDialog(false);
|
||||||
|
await openChatInExternalBrowser();
|
||||||
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.splash);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openChatPaywallSubscription(): void {
|
function openChatPaywallSubscription(): void {
|
||||||
if (isGuest) {
|
router.push(getChatPaywallNavigationUrl(authState.loginStatus));
|
||||||
router.push(ROUTE_BUILDERS.authWithRedirect(chatPaywallSubscriptionUrl));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(chatPaywallSubscriptionUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUnlockPrivateMessage(): void {
|
function handleUnlockPrivateMessage(): void {
|
||||||
@@ -182,11 +146,10 @@ export function ChatScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<ChatInputBar disabled={!state.historyLoaded} />
|
<ChatInputBar disabled={!state.historyLoaded} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 浏览器提示(应用内浏览器检测) */}
|
{/* 浏览器提示(应用内浏览器检测) */}
|
||||||
<BrowserHintOverlay forceShow={AppEnvUtil.isDevelopment()} />
|
<BrowserHintOverlay forceShow={isChatDevelopmentEnvironment()} />
|
||||||
|
|
||||||
{/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
{/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
||||||
<PwaInstallOverlay />
|
<PwaInstallOverlay />
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ export * from "./send_message_request";
|
|||||||
export * from "./stt_data";
|
export * from "./stt_data";
|
||||||
export * from "./sync_message";
|
export * from "./sync_message";
|
||||||
export * from "./ui_message";
|
export * from "./ui_message";
|
||||||
|
export * from "./unlock_history_response";
|
||||||
export * from "./unlock_private_request";
|
export * from "./unlock_private_request";
|
||||||
export * from "./unlock_private_response";
|
export * from "./unlock_private_response";
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 一键解锁历史锁定消息响应 DTO
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
UnlockHistoryResponseSchema,
|
||||||
|
type UnlockHistoryReason,
|
||||||
|
type UnlockHistoryResponseData,
|
||||||
|
type UnlockHistoryResponseInput,
|
||||||
|
} from "@/data/schemas/chat/unlock_history_response";
|
||||||
|
|
||||||
|
export class UnlockHistoryResponse {
|
||||||
|
declare readonly unlocked: boolean;
|
||||||
|
declare readonly reason: UnlockHistoryReason;
|
||||||
|
declare readonly totalLocked: number;
|
||||||
|
declare readonly unlockedCount: number;
|
||||||
|
declare readonly privateCount: number;
|
||||||
|
declare readonly imageCount: number;
|
||||||
|
declare readonly voiceCount: number;
|
||||||
|
declare readonly requiredCredits: number;
|
||||||
|
declare readonly currentCredits: number;
|
||||||
|
declare readonly remainingCredits: number;
|
||||||
|
declare readonly shortfallCredits: number;
|
||||||
|
declare readonly costsByMessage: Record<string, number>;
|
||||||
|
declare readonly messageIds: string[];
|
||||||
|
|
||||||
|
private constructor(input: UnlockHistoryResponseInput) {
|
||||||
|
const data = UnlockHistoryResponseSchema.parse(input);
|
||||||
|
Object.assign(this, data);
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(input: UnlockHistoryResponseInput): UnlockHistoryResponse {
|
||||||
|
return new UnlockHistoryResponse(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(json: unknown): UnlockHistoryResponse {
|
||||||
|
return UnlockHistoryResponse.from(json as UnlockHistoryResponseInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): UnlockHistoryResponseData {
|
||||||
|
return UnlockHistoryResponseSchema.parse(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatSendResponse,
|
ChatSendResponse,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
|
UnlockHistoryResponse,
|
||||||
UnlockPrivateRequest,
|
UnlockPrivateRequest,
|
||||||
UnlockPrivateResponse,
|
UnlockPrivateResponse,
|
||||||
} from "@/data/dto/chat";
|
} from "@/data/dto/chat";
|
||||||
@@ -68,6 +69,11 @@ export class ChatRepository implements IChatRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 一键解锁历史锁定消息。 */
|
||||||
|
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
|
||||||
|
return Result.wrap(() => this.api.unlockHistory());
|
||||||
|
}
|
||||||
|
|
||||||
/** 把本地缓存中的私密消息标记为已解锁。 */
|
/** 把本地缓存中的私密消息标记为已解锁。 */
|
||||||
async markPrivateMessageUnlockedInLocal(
|
async markPrivateMessageUnlockedInLocal(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
ChatHistoryResponse,
|
ChatHistoryResponse,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatSendResponse,
|
ChatSendResponse,
|
||||||
|
UnlockHistoryResponse,
|
||||||
UnlockPrivateResponse,
|
UnlockPrivateResponse,
|
||||||
} from "@/data/dto/chat";
|
} from "@/data/dto/chat";
|
||||||
|
|
||||||
@@ -32,6 +33,9 @@ export interface IChatRepository {
|
|||||||
/** 解锁私密消息。 */
|
/** 解锁私密消息。 */
|
||||||
unlockPrivateMessage(messageId: string): Promise<Result<UnlockPrivateResponse>>;
|
unlockPrivateMessage(messageId: string): Promise<Result<UnlockPrivateResponse>>;
|
||||||
|
|
||||||
|
/** 一键解锁历史锁定消息。 */
|
||||||
|
unlockHistory(): Promise<Result<UnlockHistoryResponse>>;
|
||||||
|
|
||||||
/** 把本地缓存中的私密消息标记为已解锁。 */
|
/** 把本地缓存中的私密消息标记为已解锁。 */
|
||||||
markPrivateMessageUnlockedInLocal(
|
markPrivateMessageUnlockedInLocal(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ export * from "./image_upload_response";
|
|||||||
export * from "./send_message_request";
|
export * from "./send_message_request";
|
||||||
export * from "./stt_data";
|
export * from "./stt_data";
|
||||||
export * from "./sync_message";
|
export * from "./sync_message";
|
||||||
|
export * from "./unlock_history_response";
|
||||||
export * from "./unlock_private_request";
|
export * from "./unlock_private_request";
|
||||||
export * from "./unlock_private_response";
|
export * from "./unlock_private_response";
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 一键解锁历史锁定消息响应
|
||||||
|
*/
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const UnlockHistoryCostsByMessageSchema = z.record(z.string(), z.number());
|
||||||
|
|
||||||
|
export const UnlockHistoryReasonSchema = z.enum([
|
||||||
|
"ok",
|
||||||
|
"insufficient_balance",
|
||||||
|
"no_locked_messages",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const UnlockHistoryResponseSchema = z.object({
|
||||||
|
unlocked: z.boolean(),
|
||||||
|
reason: UnlockHistoryReasonSchema,
|
||||||
|
totalLocked: z.number().default(0),
|
||||||
|
unlockedCount: z.number().default(0),
|
||||||
|
privateCount: z.number().default(0),
|
||||||
|
imageCount: z.number().default(0),
|
||||||
|
voiceCount: z.number().default(0),
|
||||||
|
requiredCredits: z.number().default(0),
|
||||||
|
currentCredits: z.number().default(0),
|
||||||
|
remainingCredits: z.number().default(0),
|
||||||
|
shortfallCredits: z.number().default(0),
|
||||||
|
costsByMessage: UnlockHistoryCostsByMessageSchema.default({}),
|
||||||
|
messageIds: z.array(z.string()).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UnlockHistoryReason = z.output<typeof UnlockHistoryReasonSchema>;
|
||||||
|
export type UnlockHistoryResponseInput = z.input<
|
||||||
|
typeof UnlockHistoryResponseSchema
|
||||||
|
>;
|
||||||
|
export type UnlockHistoryResponseData = z.output<
|
||||||
|
typeof UnlockHistoryResponseSchema
|
||||||
|
>;
|
||||||
@@ -112,6 +112,9 @@ export class ApiPath {
|
|||||||
/** 解锁私密消息 */
|
/** 解锁私密消息 */
|
||||||
static readonly chatUnlockPrivate = `${ApiPath._chat}/unlock-private`;
|
static readonly chatUnlockPrivate = `${ApiPath._chat}/unlock-private`;
|
||||||
|
|
||||||
|
/** 一键解锁历史锁定消息 */
|
||||||
|
static readonly chatUnlockHistory = `${ApiPath._chat}/unlock-history`;
|
||||||
|
|
||||||
// ============ 数据看板相关 ============
|
// ============ 数据看板相关 ============
|
||||||
private static readonly _metrics = `${ApiPath._baseUrl}/metrics`;
|
private static readonly _metrics = `${ApiPath._baseUrl}/metrics`;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SttData,
|
SttData,
|
||||||
SyncMessage,
|
SyncMessage,
|
||||||
|
UnlockHistoryResponse,
|
||||||
UnlockPrivateRequest,
|
UnlockPrivateRequest,
|
||||||
UnlockPrivateResponse,
|
UnlockPrivateResponse,
|
||||||
} from "@/data/dto/chat";
|
} from "@/data/dto/chat";
|
||||||
@@ -101,6 +102,21 @@ export class ChatApi {
|
|||||||
unwrap(env) as Record<string, unknown>,
|
unwrap(env) as Record<string, unknown>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一键解锁历史锁定消息
|
||||||
|
*/
|
||||||
|
async unlockHistory(): Promise<UnlockHistoryResponse> {
|
||||||
|
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||||
|
ApiPath.chatUnlockHistory,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return UnlockHistoryResponse.fromJson(
|
||||||
|
unwrap(env) as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user