fix(e2e): stabilize authenticated unlock flows
This commit is contained in:
+1
-1
@@ -40,6 +40,6 @@ pnpm test:e2e:real
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
PLAYWRIGHT_BASE_URL=https://cozsweet.com \
|
PLAYWRIGHT_BASE_URL=https://cozsweet.com \
|
||||||
E2E_API_BASE_URL=https://proapi.banlv-ai.com \
|
E2E_API_BASE_URL=https://api.banlv-ai.com \
|
||||||
pnpm test:e2e:prod
|
pnpm test:e2e:prod
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export function createWeeklyLimitChatSendResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const paidImageMessageId = "msg_photo_paywall_001";
|
export const paidImageMessageId = "msg_photo_paywall_001";
|
||||||
|
export const paidImageDisplayMessageId = `server:${paidImageMessageId}:assistant`;
|
||||||
export const paidImageUrl =
|
export const paidImageUrl =
|
||||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||||
export const paidImageChatSendResponse = {
|
export const paidImageChatSendResponse = {
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ export async function dismissChatInterruptions(page: Page) {
|
|||||||
|
|
||||||
export async function signInWithEmailAndOpenChat(page: Page) {
|
export async function signInWithEmailAndOpenChat(page: Page) {
|
||||||
await seedEmailSession(page);
|
await seedEmailSession(page);
|
||||||
const historyResponsePromise = page.waitForResponse("**/api/chat/history**");
|
|
||||||
await page.goto(defaultCharacterChatPath);
|
await page.goto(defaultCharacterChatPath);
|
||||||
await historyResponsePromise;
|
|
||||||
await dismissChatInterruptions(page);
|
await dismissChatInterruptions(page);
|
||||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({ timeout: 20_000 });
|
await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({ timeout: 20_000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
defaultCharacterChatUrl,
|
defaultCharacterChatUrl,
|
||||||
expectInsufficientCreditsDialog,
|
expectInsufficientCreditsDialog,
|
||||||
expectVipChatSubscriptionUrl,
|
expectVipChatSubscriptionUrl,
|
||||||
setEmailSessionStorage,
|
|
||||||
submitEmailLogin,
|
submitEmailLogin,
|
||||||
switchToEmailSignIn,
|
switchToEmailSignIn,
|
||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
@@ -36,13 +35,10 @@ test("guest unlocks a promoted image through email login and top-up", async ({
|
|||||||
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
||||||
|
|
||||||
await switchToEmailSignIn(page);
|
await switchToEmailSignIn(page);
|
||||||
await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
|
|
||||||
|
|
||||||
await setEmailSessionStorage(page);
|
|
||||||
const unlockRequestPromise = page.waitForRequest(
|
const unlockRequestPromise = page.waitForRequest(
|
||||||
"**/api/chat/unlock-private",
|
"**/api/chat/unlock-private",
|
||||||
);
|
);
|
||||||
await page.reload();
|
await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
|
||||||
|
|
||||||
const unlockRequest = await unlockRequestPromise;
|
const unlockRequest = await unlockRequestPromise;
|
||||||
expect(unlockRequest.method()).toBe("POST");
|
expect(unlockRequest.method()).toBe("POST");
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
import { paidImageMessageId } from "@e2e/fixtures/test-data";
|
import {
|
||||||
|
paidImageDisplayMessageId,
|
||||||
|
paidImageMessageId,
|
||||||
|
} from "@e2e/fixtures/test-data";
|
||||||
import {
|
import {
|
||||||
clearBrowserState,
|
clearBrowserState,
|
||||||
completeVipPayment,
|
completeVipPayment,
|
||||||
@@ -10,13 +13,12 @@ import {
|
|||||||
enterChatFromSplash,
|
enterChatFromSplash,
|
||||||
expectInsufficientCreditsDialog,
|
expectInsufficientCreditsDialog,
|
||||||
expectVipChatSubscriptionUrl,
|
expectVipChatSubscriptionUrl,
|
||||||
setEmailSessionStorage,
|
|
||||||
submitEmailLogin,
|
submitEmailLogin,
|
||||||
switchToEmailSignIn,
|
switchToEmailSignIn,
|
||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
const paidImageOverlayUrl = new RegExp(
|
const paidImageOverlayUrl = new RegExp(
|
||||||
`${defaultCharacterChatPath}\\?image=${paidImageMessageId}$`,
|
`${defaultCharacterChatPath}\\?image=${encodeURIComponent(paidImageDisplayMessageId)}$`,
|
||||||
);
|
);
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
@@ -63,19 +65,16 @@ test("guest can unlock a paid image after email login and subscription payment",
|
|||||||
|
|
||||||
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
||||||
expect(new URL(page.url()).searchParams.get("redirect")).toBe(
|
expect(new URL(page.url()).searchParams.get("redirect")).toBe(
|
||||||
`${defaultCharacterChatPath}?image=${paidImageMessageId}`,
|
`${defaultCharacterChatPath}?image=${encodeURIComponent(paidImageDisplayMessageId)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await switchToEmailSignIn(page);
|
await switchToEmailSignIn(page);
|
||||||
await submitEmailLogin(page, {
|
|
||||||
expectedUrl: paidImageOverlayUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
await setEmailSessionStorage(page);
|
|
||||||
const unlockRequestPromise = page.waitForRequest(
|
const unlockRequestPromise = page.waitForRequest(
|
||||||
"**/api/chat/unlock-private",
|
"**/api/chat/unlock-private",
|
||||||
);
|
);
|
||||||
await page.goto(`${defaultCharacterChatPath}?image=${paidImageMessageId}`);
|
await submitEmailLogin(page, {
|
||||||
|
expectedUrl: paidImageOverlayUrl,
|
||||||
|
});
|
||||||
|
|
||||||
const unlockRequest = await unlockRequestPromise;
|
const unlockRequest = await unlockRequestPromise;
|
||||||
expect(unlockRequest.postDataJSON()).toMatchObject({
|
expect(unlockRequest.postDataJSON()).toMatchObject({
|
||||||
@@ -92,7 +91,7 @@ test("guest can unlock a paid image after email login and subscription payment",
|
|||||||
|
|
||||||
await expect(page).toHaveURL(
|
await expect(page).toHaveURL(
|
||||||
new RegExp(
|
new RegExp(
|
||||||
`${defaultCharacterChatPath}(?:\\?image=msg_photo_paywall_001)?$`,
|
`${defaultCharacterChatPath}(?:\\?image=${encodeURIComponent(paidImageDisplayMessageId)})?$`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath);
|
expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
import { paidImageMessageId } from "@e2e/fixtures/test-data";
|
import {
|
||||||
|
paidImageDisplayMessageId,
|
||||||
|
paidImageMessageId,
|
||||||
|
} from "@e2e/fixtures/test-data";
|
||||||
import {
|
import {
|
||||||
clearBrowserState,
|
clearBrowserState,
|
||||||
defaultCharacterChatPath,
|
defaultCharacterChatPath,
|
||||||
@@ -12,7 +15,7 @@ import {
|
|||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
const paidImageOverlayUrl = new RegExp(
|
const paidImageOverlayUrl = new RegExp(
|
||||||
`${defaultCharacterChatPath}\\?image=${paidImageMessageId}$`,
|
`${defaultCharacterChatPath}\\?image=${encodeURIComponent(paidImageDisplayMessageId)}$`,
|
||||||
);
|
);
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export default defineConfig({
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: Boolean(process.env.CI),
|
forbidOnly: Boolean(process.env.CI),
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
// Next.js dev + Turbopack can return incomplete module payloads when the
|
||||||
|
// local mock suite compiles many routes concurrently. Keep this tier
|
||||||
|
// deterministic locally as well as in CI.
|
||||||
|
workers: 1,
|
||||||
reporter: process.env.CI
|
reporter: process.env.CI
|
||||||
? [["list"], ["html", { open: "never" }]]
|
? [["list"], ["html", { open: "never" }]]
|
||||||
: [["list"], ["html", { open: "never" }]],
|
: [["list"], ["html", { open: "never" }]],
|
||||||
|
|||||||
@@ -132,7 +132,11 @@ export function ChatScreen() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!imageMessageId || !state.historyLoaded || selectedImageMessage) {
|
if (
|
||||||
|
!imageMessageId ||
|
||||||
|
!state.networkHistoryLoaded ||
|
||||||
|
selectedImageMessage
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(
|
router.replace(
|
||||||
@@ -145,7 +149,7 @@ export function ChatScreen() {
|
|||||||
router,
|
router,
|
||||||
searchParams,
|
searchParams,
|
||||||
selectedImageMessage,
|
selectedImageMessage,
|
||||||
state.historyLoaded,
|
state.networkHistoryLoaded,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
|||||||
requiredCredits: 0,
|
requiredCredits: 0,
|
||||||
shortfallCredits: 0,
|
shortfallCredits: 0,
|
||||||
historyLoaded: true,
|
historyLoaded: true,
|
||||||
|
networkHistoryLoaded: true,
|
||||||
historyTotal: 0,
|
historyTotal: 0,
|
||||||
historyLimit: 50,
|
historyLimit: 50,
|
||||||
nextHistoryOffset: 0,
|
nextHistoryOffset: 0,
|
||||||
@@ -267,6 +268,8 @@ describe("chat history pagination helpers", () => {
|
|||||||
limit: 0,
|
limit: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(nextState.networkHistoryLoaded).toBe(true);
|
||||||
|
|
||||||
expect(nextState.historyLimit).toBe(50);
|
expect(nextState.historyLimit).toBe(50);
|
||||||
expect(nextState.nextHistoryOffset).toBe(50);
|
expect(nextState.nextHistoryOffset).toBe(50);
|
||||||
expect(nextState.historyTotal).toBe(120);
|
expect(nextState.historyTotal).toBe(120);
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface ChatState {
|
|||||||
upgradeReason: MachineContext["upgradeReason"];
|
upgradeReason: MachineContext["upgradeReason"];
|
||||||
/** True as soon as a local snapshot or the first network result is renderable. */
|
/** True as soon as a local snapshot or the first network result is renderable. */
|
||||||
historyLoaded: boolean;
|
historyLoaded: boolean;
|
||||||
|
/** True after the network history request has completed or failed. */
|
||||||
|
networkHistoryLoaded: boolean;
|
||||||
hasMoreHistory: boolean;
|
hasMoreHistory: boolean;
|
||||||
isLoadingMoreHistory: boolean;
|
isLoadingMoreHistory: boolean;
|
||||||
unlockHistoryPromptVisible: boolean;
|
unlockHistoryPromptVisible: boolean;
|
||||||
@@ -93,6 +95,7 @@ function selectChatState(state: ChatSnapshot): SelectedChatState {
|
|||||||
upgradePromptVisible: state.context.upgradePromptVisible,
|
upgradePromptVisible: state.context.upgradePromptVisible,
|
||||||
upgradeReason: state.context.upgradeReason,
|
upgradeReason: state.context.upgradeReason,
|
||||||
historyLoaded: state.context.historyLoaded,
|
historyLoaded: state.context.historyLoaded,
|
||||||
|
networkHistoryLoaded: state.context.networkHistoryLoaded,
|
||||||
hasMoreHistory:
|
hasMoreHistory:
|
||||||
state.context.historyLoaded &&
|
state.context.historyLoaded &&
|
||||||
state.context.nextHistoryOffset < state.context.historyTotal,
|
state.context.nextHistoryOffset < state.context.historyTotal,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export interface ChatState {
|
|||||||
shortfallCredits: number;
|
shortfallCredits: number;
|
||||||
/** history 首屏可展示标志(本地快照加载完成即可进入 ready)。 */
|
/** history 首屏可展示标志(本地快照加载完成即可进入 ready)。 */
|
||||||
historyLoaded: boolean;
|
historyLoaded: boolean;
|
||||||
|
/** 网络历史已返回或已明确失败;用于避免过早判定深链消息不存在。 */
|
||||||
|
networkHistoryLoaded: boolean;
|
||||||
historyTotal: number;
|
historyTotal: number;
|
||||||
historyLimit: number;
|
historyLimit: number;
|
||||||
nextHistoryOffset: number;
|
nextHistoryOffset: number;
|
||||||
@@ -80,6 +82,7 @@ export function createInitialChatState(
|
|||||||
requiredCredits: 0,
|
requiredCredits: 0,
|
||||||
shortfallCredits: 0,
|
shortfallCredits: 0,
|
||||||
historyLoaded: false,
|
historyLoaded: false,
|
||||||
|
networkHistoryLoaded: false,
|
||||||
historyTotal: 0,
|
historyTotal: 0,
|
||||||
historyLimit: 50,
|
historyLimit: 50,
|
||||||
nextHistoryOffset: 0,
|
nextHistoryOffset: 0,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function applyNetworkHistoryLoadedOutput(
|
|||||||
ChatState,
|
ChatState,
|
||||||
| "messages"
|
| "messages"
|
||||||
| "historyLoaded"
|
| "historyLoaded"
|
||||||
|
| "networkHistoryLoaded"
|
||||||
| "historyTotal"
|
| "historyTotal"
|
||||||
| "historyLimit"
|
| "historyLimit"
|
||||||
| "nextHistoryOffset"
|
| "nextHistoryOffset"
|
||||||
@@ -47,6 +48,7 @@ export function applyNetworkHistoryLoadedOutput(
|
|||||||
return {
|
return {
|
||||||
messages: [...output.messages, ...optimisticTail],
|
messages: [...output.messages, ...optimisticTail],
|
||||||
historyLoaded: true,
|
historyLoaded: true,
|
||||||
|
networkHistoryLoaded: true,
|
||||||
historyTotal: Math.max(0, output.total),
|
historyTotal: Math.max(0, output.total),
|
||||||
historyLimit,
|
historyLimit,
|
||||||
nextHistoryOffset: historyLimit,
|
nextHistoryOffset: historyLimit,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const markHistoryLoadFailedAction = baseChatMachineSetup.assign(
|
|||||||
const characterErrorCode = getCharacterErrorCode(event.error);
|
const characterErrorCode = getCharacterErrorCode(event.error);
|
||||||
return {
|
return {
|
||||||
historyLoaded: true,
|
historyLoaded: true,
|
||||||
|
networkHistoryLoaded: true,
|
||||||
characterErrorCode,
|
characterErrorCode,
|
||||||
canSendMessage:
|
canSendMessage:
|
||||||
characterErrorCode === "CHARACTER_DISABLED" ||
|
characterErrorCode === "CHARACTER_DISABLED" ||
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
chatDispatch: vi.fn(),
|
||||||
|
getLoginToken: vi.fn(async () => ({
|
||||||
|
success: true as const,
|
||||||
|
data: "email-token",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/data/storage/auth", () => ({
|
||||||
|
AuthStorage: {
|
||||||
|
getInstance: () => ({ getLoginToken: mocks.getLoginToken }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/auth/auth-context", () => ({
|
||||||
|
selectAuthIsLoading: () => false,
|
||||||
|
useAuthSelector: (selector: (state: unknown) => unknown) =>
|
||||||
|
selector({
|
||||||
|
context: {
|
||||||
|
hasInitialized: true,
|
||||||
|
loginStatus: "email",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/chat/chat-context", () => ({
|
||||||
|
useChatDispatch: () => mocks.chatDispatch,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ChatAuthSync } from "../chat-auth-sync";
|
||||||
|
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
let root: ReturnType<typeof createRoot> | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) act(() => root?.unmount());
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
root = null;
|
||||||
|
mocks.chatDispatch.mockReset();
|
||||||
|
mocks.getLoginToken.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChatAuthSync", () => {
|
||||||
|
it("dispatches the authenticated session after StrictMode restarts the effect", async () => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<StrictMode>
|
||||||
|
<ChatAuthSync />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.chatDispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.chatDispatch).toHaveBeenCalledWith({
|
||||||
|
type: "ChatUserLogin",
|
||||||
|
token: "email-token",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,14 +34,15 @@ export function ChatAuthSync() {
|
|||||||
|
|
||||||
const sessionKey = authState.loginStatus;
|
const sessionKey = authState.loginStatus;
|
||||||
if (prevSessionKeyRef.current === sessionKey) return;
|
if (prevSessionKeyRef.current === sessionKey) return;
|
||||||
prevSessionKeyRef.current = sessionKey;
|
|
||||||
|
|
||||||
if (authState.loginStatus === "notLoggedIn") {
|
if (authState.loginStatus === "notLoggedIn") {
|
||||||
|
prevSessionKeyRef.current = sessionKey;
|
||||||
chatDispatch({ type: "ChatLogout" });
|
chatDispatch({ type: "ChatLogout" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authState.loginStatus === "guest") {
|
if (authState.loginStatus === "guest") {
|
||||||
|
prevSessionKeyRef.current = sessionKey;
|
||||||
chatDispatch({ type: "ChatGuestLogin" });
|
chatDispatch({ type: "ChatGuestLogin" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,7 @@ export function ChatAuthSync() {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const tokenR = await AuthStorage.getInstance().getLoginToken();
|
const tokenR = await AuthStorage.getInstance().getLoginToken();
|
||||||
if (!cancelled && tokenR.success && tokenR.data) {
|
if (!cancelled && tokenR.success && tokenR.data) {
|
||||||
|
prevSessionKeyRef.current = sessionKey;
|
||||||
chatDispatch({
|
chatDispatch({
|
||||||
type: "ChatUserLogin",
|
type: "ChatUserLogin",
|
||||||
token: tokenR.data,
|
token: tokenR.data,
|
||||||
|
|||||||
@@ -29,12 +29,13 @@ export function ChatPaymentSuccessSync() {
|
|||||||
|
|
||||||
const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`;
|
const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`;
|
||||||
if (lastPaidKeyRef.current === paidKey) return;
|
if (lastPaidKeyRef.current === paidKey) return;
|
||||||
lastPaidKeyRef.current = paidKey;
|
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (await hasPendingChatUnlock(characterId)) return;
|
const hasPendingUnlock = await hasPendingChatUnlock(characterId);
|
||||||
if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" });
|
if (cancelled) return;
|
||||||
|
lastPaidKeyRef.current = paidKey;
|
||||||
|
if (!hasPendingUnlock) chatDispatch({ type: "ChatPaymentSucceeded" });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user