refactor(chat): centralize unlock coordination
This commit is contained in:
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PendingChatPromotion,
|
||||||
|
PendingChatUnlock,
|
||||||
|
} from "@/lib/navigation/chat_unlock_session";
|
||||||
|
import { createChatPromotionState } from "@/stores/chat/helper/promotion";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveChatUnlockReturnUrl,
|
||||||
|
resolveMessageUnlockRequest,
|
||||||
|
shouldResumePendingChatUnlock,
|
||||||
|
} from "../hooks/use-chat-unlock-coordinator";
|
||||||
|
|
||||||
|
const defaultScope = {
|
||||||
|
defaultReturnUrl: "/chat",
|
||||||
|
imageMessageId: null,
|
||||||
|
imageReturnUrl: "/chat",
|
||||||
|
promotion: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const promotionSession: PendingChatPromotion = {
|
||||||
|
promotionType: "image",
|
||||||
|
lockType: "image_paywall",
|
||||||
|
clientLockId: "promotion-1",
|
||||||
|
createdAt: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("chat unlock coordinator", () => {
|
||||||
|
it("creates a regular message unlock request with its remote id", () => {
|
||||||
|
expect(
|
||||||
|
resolveMessageUnlockRequest(
|
||||||
|
{ messageId: "message-1", kind: "private" },
|
||||||
|
defaultScope,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
displayMessageId: "message-1",
|
||||||
|
messageId: "message-1",
|
||||||
|
kind: "private",
|
||||||
|
returnUrl: "/chat",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the active image overlay for its image unlock", () => {
|
||||||
|
const imageScope = {
|
||||||
|
...defaultScope,
|
||||||
|
imageMessageId: "image-1",
|
||||||
|
imageReturnUrl: "/chat?image=image-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveMessageUnlockRequest(
|
||||||
|
{ messageId: "image-1", kind: "image" },
|
||||||
|
imageScope,
|
||||||
|
).returnUrl,
|
||||||
|
).toBe("/chat?image=image-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the promotion lock identity without sending a temporary remote id", () => {
|
||||||
|
const promotion = createChatPromotionState(promotionSession);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveMessageUnlockRequest(
|
||||||
|
{ messageId: "promotion:promotion-1", kind: "image" },
|
||||||
|
{ ...defaultScope, promotion },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
displayMessageId: "promotion:promotion-1",
|
||||||
|
kind: "image",
|
||||||
|
lockType: "image_paywall",
|
||||||
|
clientLockId: "promotion-1",
|
||||||
|
promotion: promotionSession,
|
||||||
|
returnUrl: "/chat",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the backend id after a promotion receives one", () => {
|
||||||
|
const promotion = createChatPromotionState(
|
||||||
|
promotionSession,
|
||||||
|
"promotion-message-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveMessageUnlockRequest(
|
||||||
|
{ messageId: "promotion-message-1", kind: "image" },
|
||||||
|
{ ...defaultScope, promotion },
|
||||||
|
).messageId,
|
||||||
|
).toBe("promotion-message-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes pending unlocks for the default chat return", () => {
|
||||||
|
expect(
|
||||||
|
shouldResumePendingChatUnlock(
|
||||||
|
createPendingUnlock({
|
||||||
|
displayMessageId: "message-1",
|
||||||
|
messageId: "message-1",
|
||||||
|
kind: "private",
|
||||||
|
returnUrl: "/chat",
|
||||||
|
}),
|
||||||
|
defaultScope,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only resumes an image return when its message matches the overlay", () => {
|
||||||
|
const imageScope = {
|
||||||
|
defaultReturnUrl: "/chat",
|
||||||
|
imageMessageId: "image-1",
|
||||||
|
imageReturnUrl: "/chat?image=image-1",
|
||||||
|
};
|
||||||
|
const pending = createPendingUnlock({
|
||||||
|
displayMessageId: "image-1",
|
||||||
|
messageId: "image-1",
|
||||||
|
kind: "image",
|
||||||
|
returnUrl: "/chat?image=image-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shouldResumePendingChatUnlock(pending, imageScope)).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldResumePendingChatUnlock(
|
||||||
|
{ ...pending, displayMessageId: "image-2", messageId: "image-2" },
|
||||||
|
imageScope,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never routes a promotion unlock through the image overlay", () => {
|
||||||
|
const promotion = createChatPromotionState(promotionSession);
|
||||||
|
const imageMessageId = "promotion:promotion-1";
|
||||||
|
const imageScope = {
|
||||||
|
defaultReturnUrl: "/chat",
|
||||||
|
imageMessageId,
|
||||||
|
imageReturnUrl: `/chat?image=${imageMessageId}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveMessageUnlockRequest(
|
||||||
|
{ messageId: imageMessageId, kind: "image" },
|
||||||
|
{
|
||||||
|
...imageScope,
|
||||||
|
promotion,
|
||||||
|
},
|
||||||
|
).returnUrl,
|
||||||
|
).toBe("/chat");
|
||||||
|
expect(
|
||||||
|
shouldResumePendingChatUnlock(
|
||||||
|
{
|
||||||
|
...createPendingUnlock({
|
||||||
|
displayMessageId: imageMessageId,
|
||||||
|
kind: "image",
|
||||||
|
returnUrl: imageScope.imageReturnUrl,
|
||||||
|
}),
|
||||||
|
lockType: promotionSession.lockType,
|
||||||
|
clientLockId: promotionSession.clientLockId,
|
||||||
|
promotion: promotionSession,
|
||||||
|
},
|
||||||
|
imageScope,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a regular image paywall in the chat flow when no overlay is open", () => {
|
||||||
|
expect(
|
||||||
|
resolveChatUnlockReturnUrl(
|
||||||
|
{
|
||||||
|
displayMessageId: "image-1",
|
||||||
|
messageId: "image-1",
|
||||||
|
kind: "image",
|
||||||
|
},
|
||||||
|
defaultScope,
|
||||||
|
),
|
||||||
|
).toBe("/chat");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createPendingUnlock(
|
||||||
|
input: Pick<
|
||||||
|
PendingChatUnlock,
|
||||||
|
"displayMessageId" | "messageId" | "kind" | "returnUrl"
|
||||||
|
>,
|
||||||
|
): PendingChatUnlock {
|
||||||
|
return {
|
||||||
|
reason: "single_message_unlock",
|
||||||
|
...input,
|
||||||
|
stage: "auth",
|
||||||
|
createdAt: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
shouldStartExternalBrowserPrompt,
|
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 { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator";
|
||||||
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
|
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
|
||||||
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
|
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
|
||||||
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
|
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
|
||||||
@@ -64,40 +64,13 @@ export function ChatScreen() {
|
|||||||
const imageReturnUrl = imageMessageId
|
const imageReturnUrl = imageMessageId
|
||||||
? buildChatImageOverlayUrl(imageMessageId)
|
? buildChatImageOverlayUrl(imageMessageId)
|
||||||
: ROUTES.chat;
|
: ROUTES.chat;
|
||||||
const {
|
const unlockCoordinator = useChatUnlockCoordinator({
|
||||||
unlockPaywallRequest,
|
defaultReturnUrl: ROUTES.chat,
|
||||||
requestMessageUnlock,
|
imageMessageId,
|
||||||
closeInsufficientCreditsDialog,
|
imageReturnUrl,
|
||||||
confirmInsufficientCreditsDialog,
|
promotion: state.promotion,
|
||||||
} = useChatUnlockNavigationFlow({
|
|
||||||
returnUrl: ROUTES.chat,
|
|
||||||
ignoredKind: "image",
|
|
||||||
promotionScope: "exclude",
|
|
||||||
enabled: isPromotionBootstrapReady,
|
enabled: isPromotionBootstrapReady,
|
||||||
});
|
});
|
||||||
const {
|
|
||||||
unlockPaywallRequest: imageUnlockPaywallRequest,
|
|
||||||
requestMessageUnlock: requestImageUnlock,
|
|
||||||
closeInsufficientCreditsDialog: closeImageInsufficientCreditsDialog,
|
|
||||||
confirmInsufficientCreditsDialog: confirmImageInsufficientCreditsDialog,
|
|
||||||
} = useChatUnlockNavigationFlow({
|
|
||||||
returnUrl: imageReturnUrl,
|
|
||||||
expectedKind: "image",
|
|
||||||
expectedMessageId: imageMessageId ?? undefined,
|
|
||||||
promotionScope: "exclude",
|
|
||||||
enabled: isPromotionBootstrapReady && imageMessageId !== null,
|
|
||||||
});
|
|
||||||
const {
|
|
||||||
unlockPaywallRequest: promotionUnlockPaywallRequest,
|
|
||||||
requestMessageUnlock: requestPromotionUnlock,
|
|
||||||
closeInsufficientCreditsDialog: closePromotionInsufficientCreditsDialog,
|
|
||||||
confirmInsufficientCreditsDialog:
|
|
||||||
confirmPromotionInsufficientCreditsDialog,
|
|
||||||
} = useChatUnlockNavigationFlow({
|
|
||||||
returnUrl: ROUTES.chat,
|
|
||||||
promotionScope: "only",
|
|
||||||
enabled: isPromotionBootstrapReady && state.promotion !== null,
|
|
||||||
});
|
|
||||||
const selectedImageMessage = useMemo(
|
const selectedImageMessage = useMemo(
|
||||||
() =>
|
() =>
|
||||||
imageMessageId
|
imageMessageId
|
||||||
@@ -152,36 +125,15 @@ export function ChatScreen() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function handleUnlockPrivateMessage(messageId: string): void {
|
function handleUnlockPrivateMessage(messageId: string): void {
|
||||||
if (requestPromotionMessageUnlock(messageId, "private")) return;
|
unlockCoordinator.requestMessageUnlock(messageId, "private");
|
||||||
requestMessageUnlock(messageId, "private");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUnlockVoiceMessage(messageId: string): void {
|
function handleUnlockVoiceMessage(messageId: string): void {
|
||||||
if (requestPromotionMessageUnlock(messageId, "voice")) return;
|
unlockCoordinator.requestMessageUnlock(messageId, "voice");
|
||||||
requestMessageUnlock(messageId, "voice");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUnlockImageMessage(messageId: string): void {
|
function handleUnlockImageMessage(messageId: string): void {
|
||||||
if (requestPromotionMessageUnlock(messageId, "image")) return;
|
unlockCoordinator.requestMessageUnlock(messageId, "image");
|
||||||
requestMessageUnlock(messageId, "image");
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestPromotionMessageUnlock(
|
|
||||||
messageId: string,
|
|
||||||
kind: "private" | "voice" | "image",
|
|
||||||
): boolean {
|
|
||||||
const promotion = state.promotion;
|
|
||||||
if (!promotion || promotion.message.id !== messageId) return false;
|
|
||||||
|
|
||||||
const temporaryMessageId = `promotion:${promotion.session.clientLockId}`;
|
|
||||||
requestPromotionUnlock(messageId, kind, {
|
|
||||||
remoteMessageId:
|
|
||||||
messageId === temporaryMessageId ? undefined : messageId,
|
|
||||||
lockType: promotion.session.lockType,
|
|
||||||
clientLockId: promotion.session.clientLockId,
|
|
||||||
promotion: promotion.session,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenImage(messageId: string): void {
|
function handleOpenImage(messageId: string): void {
|
||||||
@@ -196,7 +148,7 @@ export function ChatScreen() {
|
|||||||
|
|
||||||
function handleUnlockImagePaywall(): void {
|
function handleUnlockImagePaywall(): void {
|
||||||
if (!imageMessageId) return;
|
if (!imageMessageId) return;
|
||||||
requestImageUnlock(imageMessageId, "image");
|
unlockCoordinator.requestMessageUnlock(imageMessageId, "image");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -264,29 +216,7 @@ export function ChatScreen() {
|
|||||||
{/* PWA 安装提示触发器(聊天消息数量达到阈值后才允许弹出) */}
|
{/* PWA 安装提示触发器(聊天消息数量达到阈值后才允许弹出) */}
|
||||||
<PwaInstallOverlay enabled={shouldShowPwaInstall} />
|
<PwaInstallOverlay enabled={shouldShowPwaInstall} />
|
||||||
|
|
||||||
<ChatUnlockDialogs
|
<ChatUnlockDialogs model={unlockCoordinator.dialogs} />
|
||||||
unlockPaywallRequest={unlockPaywallRequest}
|
|
||||||
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
|
|
||||||
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
|
|
||||||
/>
|
|
||||||
<ChatUnlockDialogs
|
|
||||||
unlockPaywallRequest={promotionUnlockPaywallRequest}
|
|
||||||
includeHistoryUnlock={false}
|
|
||||||
onCloseInsufficientCreditsDialog={
|
|
||||||
closePromotionInsufficientCreditsDialog
|
|
||||||
}
|
|
||||||
onConfirmInsufficientCreditsDialog={
|
|
||||||
confirmPromotionInsufficientCreditsDialog
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ChatUnlockDialogs
|
|
||||||
unlockPaywallRequest={imageUnlockPaywallRequest}
|
|
||||||
includeHistoryUnlock={false}
|
|
||||||
onCloseInsufficientCreditsDialog={closeImageInsufficientCreditsDialog}
|
|
||||||
onConfirmInsufficientCreditsDialog={
|
|
||||||
confirmImageInsufficientCreditsDialog
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedImageMessage?.imageUrl ? (
|
{selectedImageMessage?.imageUrl ? (
|
||||||
<FullscreenImageViewer
|
<FullscreenImageViewer
|
||||||
|
|||||||
@@ -1,59 +1,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { shallowEqual } from "@xstate/react";
|
import type { ChatUnlockDialogModel } from "../hooks/use-chat-unlock-coordinator";
|
||||||
|
|
||||||
import {
|
|
||||||
useChatDispatch,
|
|
||||||
useChatSelector,
|
|
||||||
} from "@/stores/chat/chat-context";
|
|
||||||
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
|
|
||||||
|
|
||||||
import { HistoryUnlockDialog } from "./history-unlock-dialog";
|
import { HistoryUnlockDialog } from "./history-unlock-dialog";
|
||||||
import { InsufficientCreditsDialog } from "./insufficient-credits-dialog";
|
import { InsufficientCreditsDialog } from "./insufficient-credits-dialog";
|
||||||
|
|
||||||
export interface ChatUnlockDialogsProps {
|
export interface ChatUnlockDialogsProps {
|
||||||
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
model: ChatUnlockDialogModel;
|
||||||
onCloseInsufficientCreditsDialog: () => void;
|
|
||||||
onConfirmInsufficientCreditsDialog: () => void;
|
|
||||||
includeHistoryUnlock?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatUnlockDialogs({
|
export function ChatUnlockDialogs({
|
||||||
unlockPaywallRequest,
|
model,
|
||||||
onCloseInsufficientCreditsDialog,
|
|
||||||
onConfirmInsufficientCreditsDialog,
|
|
||||||
includeHistoryUnlock = true,
|
|
||||||
}: ChatUnlockDialogsProps) {
|
}: ChatUnlockDialogsProps) {
|
||||||
const chatState = useChatSelector(
|
|
||||||
(state) => ({
|
|
||||||
isUnlockingHistory: state.context.isUnlockingHistory,
|
|
||||||
lockedHistoryCount: state.context.lockedHistoryCount,
|
|
||||||
unlockHistoryError: state.context.unlockHistoryError,
|
|
||||||
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
|
|
||||||
}),
|
|
||||||
shallowEqual,
|
|
||||||
);
|
|
||||||
const chatDispatch = useChatDispatch();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{includeHistoryUnlock ? (
|
<HistoryUnlockDialog
|
||||||
<HistoryUnlockDialog
|
open={model.historyUnlock.open}
|
||||||
open={chatState.unlockHistoryPromptVisible}
|
lockedCount={model.historyUnlock.lockedCount}
|
||||||
lockedCount={chatState.lockedHistoryCount}
|
isLoading={model.historyUnlock.isLoading}
|
||||||
isLoading={chatState.isUnlockingHistory}
|
errorMessage={model.historyUnlock.errorMessage}
|
||||||
errorMessage={chatState.unlockHistoryError}
|
onClose={model.closeHistoryUnlock}
|
||||||
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
|
onConfirm={model.confirmHistoryUnlock}
|
||||||
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
|
/>
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<InsufficientCreditsDialog
|
<InsufficientCreditsDialog
|
||||||
open={unlockPaywallRequest !== null}
|
open={model.unlockPaywallRequest !== null}
|
||||||
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
|
creditBalance={model.unlockPaywallRequest?.creditBalance ?? 0}
|
||||||
requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0}
|
requiredCredits={model.unlockPaywallRequest?.requiredCredits ?? 0}
|
||||||
shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0}
|
shortfallCredits={model.unlockPaywallRequest?.shortfallCredits ?? 0}
|
||||||
onClose={onCloseInsufficientCreditsDialog}
|
onClose={model.closePaywall}
|
||||||
onConfirm={onConfirmInsufficientCreditsDialog}
|
onConfirm={model.confirmPaywall}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { shallowEqual } from "@xstate/react";
|
||||||
|
|
||||||
|
import type { ChatLockType } from "@/data/schemas/chat";
|
||||||
|
import { behaviorAnalytics } from "@/lib/analytics";
|
||||||
|
import {
|
||||||
|
consumePendingChatUnlock,
|
||||||
|
peekPendingChatUnlock,
|
||||||
|
type PendingChatPromotion,
|
||||||
|
type PendingChatUnlock,
|
||||||
|
type PendingChatUnlockKind,
|
||||||
|
} from "@/lib/navigation/chat_unlock_session";
|
||||||
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
import {
|
||||||
|
useChatDispatch,
|
||||||
|
useChatSelector,
|
||||||
|
} from "@/stores/chat/chat-context";
|
||||||
|
import type { ChatPromotionState } from "@/stores/chat/helper/promotion";
|
||||||
|
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
|
||||||
|
import { useUserSelector } from "@/stores/user/user-context";
|
||||||
|
|
||||||
|
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
|
||||||
|
|
||||||
|
export interface ChatUnlockCoordinatorScope {
|
||||||
|
defaultReturnUrl: string;
|
||||||
|
imageMessageId: string | null;
|
||||||
|
imageReturnUrl: string;
|
||||||
|
promotion: ChatPromotionState | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoordinatedMessageUnlockRequest {
|
||||||
|
displayMessageId: string;
|
||||||
|
messageId?: string;
|
||||||
|
kind: PendingChatUnlockKind;
|
||||||
|
lockType?: ChatLockType;
|
||||||
|
clientLockId?: string;
|
||||||
|
promotion?: PendingChatPromotion;
|
||||||
|
returnUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatUnlockDialogModel {
|
||||||
|
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
||||||
|
historyUnlock: {
|
||||||
|
open: boolean;
|
||||||
|
lockedCount: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
|
};
|
||||||
|
closeHistoryUnlock: () => void;
|
||||||
|
confirmHistoryUnlock: () => void;
|
||||||
|
closePaywall: () => void;
|
||||||
|
confirmPaywall: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseChatUnlockCoordinatorInput
|
||||||
|
extends ChatUnlockCoordinatorScope {
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseChatUnlockCoordinatorOutput {
|
||||||
|
requestMessageUnlock: (
|
||||||
|
messageId: string,
|
||||||
|
kind: PendingChatUnlockKind,
|
||||||
|
) => void;
|
||||||
|
dialogs: ChatUnlockDialogModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatUnlockCoordinator({
|
||||||
|
defaultReturnUrl,
|
||||||
|
imageMessageId,
|
||||||
|
imageReturnUrl,
|
||||||
|
promotion,
|
||||||
|
enabled = true,
|
||||||
|
}: UseChatUnlockCoordinatorInput): UseChatUnlockCoordinatorOutput {
|
||||||
|
const navigator = useAppNavigator();
|
||||||
|
const isVip = useUserSelector((state) => state.context.isVip);
|
||||||
|
const trackedPaywallRef = useRef<string | null>(null);
|
||||||
|
const chatState = useChatSelector(
|
||||||
|
(state) => ({
|
||||||
|
historyLoaded: state.context.historyLoaded,
|
||||||
|
isUnlockingHistory: state.context.isUnlockingHistory,
|
||||||
|
lockedHistoryCount: state.context.lockedHistoryCount,
|
||||||
|
unlockHistoryError: state.context.unlockHistoryError,
|
||||||
|
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
|
||||||
|
unlockPaywallRequest: state.context.unlockPaywallRequest,
|
||||||
|
}),
|
||||||
|
shallowEqual,
|
||||||
|
);
|
||||||
|
const chatDispatch = useChatDispatch();
|
||||||
|
const unlockPaywallRequest = enabled
|
||||||
|
? chatState.unlockPaywallRequest
|
||||||
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!unlockPaywallRequest) {
|
||||||
|
trackedPaywallRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestKey = [
|
||||||
|
unlockPaywallRequest.displayMessageId,
|
||||||
|
unlockPaywallRequest.messageId ?? "",
|
||||||
|
unlockPaywallRequest.kind,
|
||||||
|
unlockPaywallRequest.clientLockId ?? "",
|
||||||
|
].join(":");
|
||||||
|
if (trackedPaywallRef.current === requestKey) return;
|
||||||
|
trackedPaywallRef.current = requestKey;
|
||||||
|
behaviorAnalytics.paywallShown(
|
||||||
|
{
|
||||||
|
entryPoint: "chat_unlock",
|
||||||
|
triggerReason: unlockPaywallRequest.promotion
|
||||||
|
? "ad_landing"
|
||||||
|
: "insufficient_credits",
|
||||||
|
},
|
||||||
|
{ isVip },
|
||||||
|
);
|
||||||
|
}, [isVip, unlockPaywallRequest]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const resumePendingUnlock = async () => {
|
||||||
|
const pending = await peekPendingChatUnlock();
|
||||||
|
if (
|
||||||
|
cancelled ||
|
||||||
|
!pending ||
|
||||||
|
!shouldResumePendingChatUnlock(pending, {
|
||||||
|
defaultReturnUrl,
|
||||||
|
imageMessageId,
|
||||||
|
imageReturnUrl,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumed = await consumePendingChatUnlock();
|
||||||
|
if (cancelled || !consumed) return;
|
||||||
|
|
||||||
|
chatDispatch({
|
||||||
|
type: "ChatUnlockMessageRequested",
|
||||||
|
messageId: consumed.displayMessageId,
|
||||||
|
remoteMessageId: consumed.messageId,
|
||||||
|
kind: consumed.kind,
|
||||||
|
lockType: consumed.lockType,
|
||||||
|
clientLockId: consumed.clientLockId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
void resumePendingUnlock();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
chatDispatch,
|
||||||
|
chatState.historyLoaded,
|
||||||
|
defaultReturnUrl,
|
||||||
|
enabled,
|
||||||
|
imageMessageId,
|
||||||
|
imageReturnUrl,
|
||||||
|
navigator.isAuthenticatedUser,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function requestMessageUnlock(
|
||||||
|
messageId: string,
|
||||||
|
kind: PendingChatUnlockKind,
|
||||||
|
): void {
|
||||||
|
const request = resolveMessageUnlockRequest(
|
||||||
|
{ messageId, kind },
|
||||||
|
{
|
||||||
|
defaultReturnUrl,
|
||||||
|
imageMessageId,
|
||||||
|
imageReturnUrl,
|
||||||
|
promotion,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
navigator.startMessageUnlock({
|
||||||
|
...request,
|
||||||
|
onAuthenticated: () => {
|
||||||
|
chatDispatch({
|
||||||
|
type: "ChatUnlockMessageRequested",
|
||||||
|
messageId: request.displayMessageId,
|
||||||
|
remoteMessageId: request.messageId,
|
||||||
|
kind: request.kind,
|
||||||
|
lockType: request.lockType,
|
||||||
|
clientLockId: request.clientLockId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeHistoryUnlock(): void {
|
||||||
|
chatDispatch({ type: "ChatUnlockHistoryDismissed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmHistoryUnlock(): void {
|
||||||
|
chatDispatch({ type: "ChatUnlockHistoryConfirmed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePaywall(): void {
|
||||||
|
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmPaywall(): void {
|
||||||
|
if (!unlockPaywallRequest) return;
|
||||||
|
|
||||||
|
const returnUrl = resolveChatUnlockReturnUrl(
|
||||||
|
unlockPaywallRequest,
|
||||||
|
{
|
||||||
|
defaultReturnUrl,
|
||||||
|
imageMessageId,
|
||||||
|
imageReturnUrl,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
|
||||||
|
navigator.openSubscriptionForPendingUnlock({
|
||||||
|
displayMessageId: unlockPaywallRequest.displayMessageId,
|
||||||
|
messageId: unlockPaywallRequest.messageId,
|
||||||
|
kind: unlockPaywallRequest.kind,
|
||||||
|
lockType: unlockPaywallRequest.lockType,
|
||||||
|
clientLockId: unlockPaywallRequest.clientLockId,
|
||||||
|
promotion: unlockPaywallRequest.promotion,
|
||||||
|
returnUrl,
|
||||||
|
type: getInsufficientCreditsSubscriptionType(isVip),
|
||||||
|
analytics: {
|
||||||
|
entryPoint: "chat_unlock",
|
||||||
|
triggerReason: unlockPaywallRequest.promotion
|
||||||
|
? "ad_landing"
|
||||||
|
: "insufficient_credits",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestMessageUnlock,
|
||||||
|
dialogs: {
|
||||||
|
unlockPaywallRequest,
|
||||||
|
historyUnlock: {
|
||||||
|
open: chatState.unlockHistoryPromptVisible,
|
||||||
|
lockedCount: chatState.lockedHistoryCount,
|
||||||
|
isLoading: chatState.isUnlockingHistory,
|
||||||
|
errorMessage: chatState.unlockHistoryError,
|
||||||
|
},
|
||||||
|
closeHistoryUnlock,
|
||||||
|
confirmHistoryUnlock,
|
||||||
|
closePaywall,
|
||||||
|
confirmPaywall,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMessageUnlockRequest(
|
||||||
|
input: {
|
||||||
|
messageId: string;
|
||||||
|
kind: PendingChatUnlockKind;
|
||||||
|
},
|
||||||
|
scope: ChatUnlockCoordinatorScope,
|
||||||
|
): CoordinatedMessageUnlockRequest {
|
||||||
|
const matchedPromotion =
|
||||||
|
scope.promotion?.message.id === input.messageId
|
||||||
|
? scope.promotion
|
||||||
|
: null;
|
||||||
|
const temporaryPromotionMessageId = matchedPromotion
|
||||||
|
? `promotion:${matchedPromotion.session.clientLockId}`
|
||||||
|
: null;
|
||||||
|
const target = {
|
||||||
|
displayMessageId: input.messageId,
|
||||||
|
...(input.messageId !== temporaryPromotionMessageId
|
||||||
|
? { messageId: input.messageId }
|
||||||
|
: {}),
|
||||||
|
kind: input.kind,
|
||||||
|
...(matchedPromotion
|
||||||
|
? {
|
||||||
|
lockType: matchedPromotion.session.lockType,
|
||||||
|
clientLockId: matchedPromotion.session.clientLockId,
|
||||||
|
promotion: matchedPromotion.session,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...target,
|
||||||
|
returnUrl: resolveChatUnlockReturnUrl(target, scope),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldResumePendingChatUnlock(
|
||||||
|
pending: PendingChatUnlock,
|
||||||
|
scope: Omit<ChatUnlockCoordinatorScope, "promotion">,
|
||||||
|
): boolean {
|
||||||
|
if (pending.returnUrl === scope.defaultReturnUrl) return true;
|
||||||
|
if (pending.returnUrl !== scope.imageReturnUrl) return false;
|
||||||
|
return isCurrentImageUnlock(pending, scope.imageMessageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChatUnlockReturnUrl(
|
||||||
|
target: Pick<
|
||||||
|
CoordinatedMessageUnlockRequest,
|
||||||
|
"displayMessageId" | "messageId" | "kind" | "promotion"
|
||||||
|
>,
|
||||||
|
scope: Pick<
|
||||||
|
ChatUnlockCoordinatorScope,
|
||||||
|
"defaultReturnUrl" | "imageMessageId" | "imageReturnUrl"
|
||||||
|
>,
|
||||||
|
): string {
|
||||||
|
return isCurrentImageUnlock(target, scope.imageMessageId)
|
||||||
|
? scope.imageReturnUrl
|
||||||
|
: scope.defaultReturnUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentImageUnlock(
|
||||||
|
target: Pick<
|
||||||
|
CoordinatedMessageUnlockRequest,
|
||||||
|
"displayMessageId" | "messageId" | "kind" | "promotion"
|
||||||
|
>,
|
||||||
|
imageMessageId: string | null,
|
||||||
|
): boolean {
|
||||||
|
if (target.promotion || target.kind !== "image" || !imageMessageId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
target.displayMessageId === imageMessageId ||
|
||||||
|
target.messageId === imageMessageId
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { shallowEqual } from "@xstate/react";
|
|
||||||
|
|
||||||
import type { ChatLockType } from "@/data/schemas/chat";
|
|
||||||
import { behaviorAnalytics } from "@/lib/analytics";
|
|
||||||
import {
|
|
||||||
consumePendingChatUnlock,
|
|
||||||
peekPendingChatUnlock,
|
|
||||||
type PendingChatUnlock,
|
|
||||||
type PendingChatUnlockKind,
|
|
||||||
type PendingChatPromotion,
|
|
||||||
} from "@/lib/navigation/chat_unlock_session";
|
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
|
||||||
import {
|
|
||||||
useChatDispatch,
|
|
||||||
useChatSelector,
|
|
||||||
} from "@/stores/chat/chat-context";
|
|
||||||
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
|
|
||||||
import { useUserSelector } from "@/stores/user/user-context";
|
|
||||||
|
|
||||||
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
|
|
||||||
|
|
||||||
type PromotionScope = "any" | "only" | "exclude";
|
|
||||||
|
|
||||||
export interface MessageUnlockOptions {
|
|
||||||
remoteMessageId?: string;
|
|
||||||
lockType?: ChatLockType;
|
|
||||||
clientLockId?: string;
|
|
||||||
promotion?: PendingChatPromotion;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseChatUnlockNavigationFlowInput {
|
|
||||||
returnUrl: string;
|
|
||||||
expectedKind?: PendingChatUnlockKind;
|
|
||||||
expectedMessageId?: string;
|
|
||||||
ignoredKind?: PendingChatUnlockKind;
|
|
||||||
promotionScope?: PromotionScope;
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseChatUnlockNavigationFlowOutput {
|
|
||||||
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
|
||||||
requestMessageUnlock: (
|
|
||||||
messageId: string,
|
|
||||||
kind: PendingChatUnlockKind,
|
|
||||||
options?: MessageUnlockOptions,
|
|
||||||
) => void;
|
|
||||||
closeInsufficientCreditsDialog: () => void;
|
|
||||||
confirmInsufficientCreditsDialog: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChatUnlockNavigationFlow({
|
|
||||||
returnUrl,
|
|
||||||
expectedKind,
|
|
||||||
expectedMessageId,
|
|
||||||
ignoredKind,
|
|
||||||
promotionScope = "any",
|
|
||||||
enabled = true,
|
|
||||||
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
|
|
||||||
const navigator = useAppNavigator();
|
|
||||||
const isVip = useUserSelector((state) => state.context.isVip);
|
|
||||||
const trackedPaywallRef = useRef<string | null>(null);
|
|
||||||
const chatState = useChatSelector(
|
|
||||||
(state) => ({
|
|
||||||
historyLoaded: state.context.historyLoaded,
|
|
||||||
unlockPaywallRequest: state.context.unlockPaywallRequest,
|
|
||||||
}),
|
|
||||||
shallowEqual,
|
|
||||||
);
|
|
||||||
const chatDispatch = useChatDispatch();
|
|
||||||
const unlockPaywallRequest = getScopedUnlockPaywallRequest({
|
|
||||||
request: chatState.unlockPaywallRequest,
|
|
||||||
expectedKind,
|
|
||||||
expectedMessageId,
|
|
||||||
ignoredKind,
|
|
||||||
promotionScope,
|
|
||||||
enabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!unlockPaywallRequest) return;
|
|
||||||
const requestKey = `${unlockPaywallRequest.displayMessageId}:${unlockPaywallRequest.clientLockId ?? ""}`;
|
|
||||||
if (trackedPaywallRef.current === requestKey) return;
|
|
||||||
trackedPaywallRef.current = requestKey;
|
|
||||||
behaviorAnalytics.paywallShown(
|
|
||||||
{
|
|
||||||
entryPoint: "chat_unlock",
|
|
||||||
triggerReason: unlockPaywallRequest.promotion
|
|
||||||
? "ad_landing"
|
|
||||||
: "insufficient_credits",
|
|
||||||
},
|
|
||||||
{ isVip },
|
|
||||||
);
|
|
||||||
}, [isVip, unlockPaywallRequest]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const resumePendingUnlock = async () => {
|
|
||||||
const pending = await peekPendingChatUnlock();
|
|
||||||
if (
|
|
||||||
cancelled ||
|
|
||||||
!pending ||
|
|
||||||
pending.returnUrl !== returnUrl ||
|
|
||||||
!matchesPendingUnlockScope({
|
|
||||||
pending,
|
|
||||||
expectedKind,
|
|
||||||
expectedMessageId,
|
|
||||||
promotionScope,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const consumed = await consumePendingChatUnlock();
|
|
||||||
if (cancelled || !consumed) return;
|
|
||||||
|
|
||||||
chatDispatch({
|
|
||||||
type: "ChatUnlockMessageRequested",
|
|
||||||
messageId: consumed.displayMessageId,
|
|
||||||
remoteMessageId: consumed.messageId,
|
|
||||||
kind: consumed.kind,
|
|
||||||
lockType: consumed.lockType,
|
|
||||||
clientLockId: consumed.clientLockId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
void resumePendingUnlock();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
chatDispatch,
|
|
||||||
chatState.historyLoaded,
|
|
||||||
enabled,
|
|
||||||
expectedKind,
|
|
||||||
expectedMessageId,
|
|
||||||
navigator.isAuthenticatedUser,
|
|
||||||
promotionScope,
|
|
||||||
returnUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
function requestMessageUnlock(
|
|
||||||
messageId: string,
|
|
||||||
kind: PendingChatUnlockKind,
|
|
||||||
options: MessageUnlockOptions = {},
|
|
||||||
): void {
|
|
||||||
const remoteMessageId =
|
|
||||||
options.remoteMessageId ?? (options.lockType ? undefined : messageId);
|
|
||||||
navigator.startMessageUnlock({
|
|
||||||
displayMessageId: messageId,
|
|
||||||
messageId: remoteMessageId,
|
|
||||||
kind,
|
|
||||||
lockType: options.lockType,
|
|
||||||
clientLockId: options.clientLockId,
|
|
||||||
promotion: options.promotion,
|
|
||||||
returnUrl,
|
|
||||||
onAuthenticated: () => {
|
|
||||||
chatDispatch({
|
|
||||||
type: "ChatUnlockMessageRequested",
|
|
||||||
messageId,
|
|
||||||
remoteMessageId,
|
|
||||||
kind,
|
|
||||||
lockType: options.lockType,
|
|
||||||
clientLockId: options.clientLockId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeInsufficientCreditsDialog(): void {
|
|
||||||
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmInsufficientCreditsDialog(): void {
|
|
||||||
if (!unlockPaywallRequest) return;
|
|
||||||
|
|
||||||
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
|
|
||||||
navigator.openSubscriptionForPendingUnlock({
|
|
||||||
displayMessageId: unlockPaywallRequest.displayMessageId,
|
|
||||||
messageId: unlockPaywallRequest.messageId,
|
|
||||||
kind: unlockPaywallRequest.kind,
|
|
||||||
lockType: unlockPaywallRequest.lockType,
|
|
||||||
clientLockId: unlockPaywallRequest.clientLockId,
|
|
||||||
promotion: unlockPaywallRequest.promotion,
|
|
||||||
returnUrl,
|
|
||||||
type: getInsufficientCreditsSubscriptionType(isVip),
|
|
||||||
analytics: {
|
|
||||||
entryPoint: "chat_unlock",
|
|
||||||
triggerReason: unlockPaywallRequest.promotion
|
|
||||||
? "ad_landing"
|
|
||||||
: "insufficient_credits",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
unlockPaywallRequest,
|
|
||||||
requestMessageUnlock,
|
|
||||||
closeInsufficientCreditsDialog,
|
|
||||||
confirmInsufficientCreditsDialog,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getScopedUnlockPaywallRequest(input: {
|
|
||||||
request: ChatUnlockPaywallRequest | null;
|
|
||||||
expectedKind?: PendingChatUnlockKind;
|
|
||||||
expectedMessageId?: string;
|
|
||||||
ignoredKind?: PendingChatUnlockKind;
|
|
||||||
promotionScope?: PromotionScope;
|
|
||||||
enabled?: boolean;
|
|
||||||
}): ChatUnlockPaywallRequest | null {
|
|
||||||
const {
|
|
||||||
request,
|
|
||||||
expectedKind,
|
|
||||||
expectedMessageId,
|
|
||||||
ignoredKind,
|
|
||||||
promotionScope = "any",
|
|
||||||
enabled = true,
|
|
||||||
} = input;
|
|
||||||
if (!enabled || !request) return null;
|
|
||||||
if (promotionScope === "only" && !request.promotion) return null;
|
|
||||||
if (promotionScope === "exclude" && request.promotion) return null;
|
|
||||||
if (ignoredKind && request.kind === ignoredKind) return null;
|
|
||||||
if (expectedKind && request.kind !== expectedKind) return null;
|
|
||||||
if (
|
|
||||||
expectedMessageId &&
|
|
||||||
request.displayMessageId !== expectedMessageId &&
|
|
||||||
request.messageId !== expectedMessageId
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesPendingUnlockScope(input: {
|
|
||||||
pending: PendingChatUnlock;
|
|
||||||
expectedKind?: PendingChatUnlockKind;
|
|
||||||
expectedMessageId?: string;
|
|
||||||
promotionScope?: PromotionScope;
|
|
||||||
}): boolean {
|
|
||||||
const {
|
|
||||||
pending,
|
|
||||||
expectedKind,
|
|
||||||
expectedMessageId,
|
|
||||||
promotionScope = "any",
|
|
||||||
} = input;
|
|
||||||
if (promotionScope === "only" && !pending.promotion) return false;
|
|
||||||
if (promotionScope === "exclude" && pending.promotion) return false;
|
|
||||||
if (expectedKind && pending.kind !== expectedKind) return false;
|
|
||||||
if (
|
|
||||||
expectedMessageId &&
|
|
||||||
pending.displayMessageId !== expectedMessageId &&
|
|
||||||
pending.messageId !== expectedMessageId
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user