feat(chat): add promotional external entry flow

This commit is contained in:
2026-07-13 12:43:18 +08:00
parent e5ee9940ca
commit 3752b3b729
44 changed files with 1623 additions and 190 deletions
+2 -2
View File
@@ -25,10 +25,10 @@ export async function openChatInExternalBrowser(): Promise<void> {
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.externalEntry, {
queryParams: {
target: "chat",
deviceId,
device_id: deviceId,
asid,
...(psid ? { psid } : {}),
...(avatarUrl ? { avatarUrl } : {}),
...(avatarUrl ? { avatar_url: avatarUrl } : {}),
},
});
return;
@@ -2,46 +2,60 @@ import { describe, expect, it } from "vitest";
import { ROUTES } from "@/router/routes";
import { resolveExternalEntryTarget } from "../external_entry";
import {
resolveExternalEntryPromotionType,
resolveExternalEntryTarget,
} from "../external_entry";
describe("external entry navigation", () => {
it("defaults to chat", () => {
expect(resolveExternalEntryTarget({})).toBe(ROUTES.chat);
});
it("resolves supported target aliases", () => {
it("resolves only canonical targets", () => {
expect(resolveExternalEntryTarget({ target: "chat" })).toBe(ROUTES.chat);
expect(resolveExternalEntryTarget({ target: "tip" })).toBe(ROUTES.tip);
expect(resolveExternalEntryTarget({ target: "coffee" })).toBe(ROUTES.tip);
expect(resolveExternalEntryTarget({ target: "private-room" })).toBe(
ROUTES.privateRoom,
);
});
it("rejects removed aliases and unsupported targets", () => {
expect(resolveExternalEntryTarget({ target: "tips" })).toBe(ROUTES.chat);
expect(resolveExternalEntryTarget({ target: "coffee" })).toBe(ROUTES.chat);
expect(resolveExternalEntryTarget({ target: "private_room" })).toBe(
ROUTES.privateRoom,
);
});
it("prefers safe explicit redirect routes over target aliases", () => {
expect(
resolveExternalEntryTarget({
target: "chat",
redirect: ROUTES.tip,
}),
).toBe(ROUTES.tip);
});
it("rejects unsupported and external redirect routes", () => {
expect(
resolveExternalEntryTarget({
redirect: "https://example.com/chat",
target: "unknown",
}),
).toBe(ROUTES.chat);
expect(resolveExternalEntryTarget({ redirect: "//example.com" })).toBe(
ROUTES.chat,
);
expect(resolveExternalEntryTarget({ redirect: "/sidebar" })).toBe(
ROUTES.chat,
);
expect(resolveExternalEntryTarget({ target: "/tip" })).toBe(ROUTES.chat);
expect(resolveExternalEntryTarget({ target: "sidebar" })).toBe(ROUTES.chat);
});
});
describe("external entry promotion", () => {
it("accepts only explicit promotion mode and supported message types", () => {
expect(
resolveExternalEntryPromotionType({
mode: "promotion",
promotionType: "voice",
}),
).toBe("voice");
expect(
resolveExternalEntryPromotionType({
mode: "promotion",
promotionType: "image",
}),
).toBe("image");
expect(
resolveExternalEntryPromotionType({
mode: "normal",
promotionType: "private",
}),
).toBeNull();
expect(
resolveExternalEntryPromotionType({
mode: "promotion",
promotionType: "video",
}),
).toBeNull();
});
});
@@ -68,6 +68,7 @@ describe("subscription exit helpers", () => {
it("peeks chat unlock return urls without clearing them", async () => {
peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock",
displayMessageId: "msg_1",
messageId: "msg_1",
kind: "image",
returnUrl: "/chat?image=msg_1",
@@ -85,6 +86,7 @@ describe("subscription exit helpers", () => {
consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockResolvedValue({
reason: "single_message_unlock",
displayMessageId: "msg_1",
messageId: "msg_1",
kind: "image",
returnUrl: "/chat?image=msg_1",
+23 -1
View File
@@ -4,18 +4,26 @@ import {
NavigationStorage,
type PendingChatUnlock,
type PendingChatUnlockKind,
type PendingChatPromotion,
type PendingChatPromotionType,
type PendingChatUnlockStage,
} from "@/data/storage/navigation";
export type {
PendingChatUnlock,
PendingChatUnlockKind,
PendingChatPromotion,
PendingChatPromotionType,
PendingChatUnlockStage,
};
export async function savePendingChatUnlock(input: {
messageId: string;
displayMessageId?: string;
messageId?: string;
kind: PendingChatUnlockKind;
lockType?: PendingChatUnlock["lockType"];
clientLockId?: string;
promotion?: PendingChatPromotion;
returnUrl: string;
stage: PendingChatUnlockStage;
}): Promise<void> {
@@ -37,3 +45,17 @@ export async function hasPendingChatUnlock(): Promise<boolean> {
export async function clearPendingChatUnlock(): Promise<void> {
await NavigationStorage.clearPendingChatUnlock();
}
export async function savePendingChatPromotion(
promotionType: PendingChatPromotionType,
): Promise<PendingChatPromotion> {
return NavigationStorage.savePendingChatPromotion(promotionType);
}
export async function consumePendingChatPromotion(): Promise<PendingChatPromotion | null> {
return NavigationStorage.consumePendingChatPromotion();
}
export async function clearPendingChatPromotion(): Promise<void> {
await NavigationStorage.clearPendingChatPromotion();
}
+27 -38
View File
@@ -2,6 +2,7 @@
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { UserStorage } from "@/data/storage/user/user_storage";
import type { PendingChatPromotionType } from "@/data/storage/navigation";
import { ROUTES } from "@/router/routes";
export type ExternalEntryTarget =
@@ -18,21 +19,34 @@ export interface ExternalEntryPayload {
export interface ExternalEntryTargetInput {
target?: string | null;
redirect?: string | null;
next?: string | null;
}
export interface ExternalEntryPromotionInput {
mode?: string | null;
promotionType?: string | null;
}
export function resolveExternalEntryPromotionType({
mode,
promotionType,
}: ExternalEntryPromotionInput): PendingChatPromotionType | null {
if (mode?.trim().toLowerCase() !== "promotion") return null;
const normalizedType = promotionType?.trim().toLowerCase();
if (
normalizedType === "voice" ||
normalizedType === "image" ||
normalizedType === "private"
) {
return normalizedType;
}
return null;
}
export function resolveExternalEntryTarget({
target,
redirect,
next,
}: ExternalEntryTargetInput): ExternalEntryTarget {
return (
resolveExplicitRoute(redirect) ??
resolveExplicitRoute(next) ??
resolveTargetAlias(target) ??
ROUTES.chat
);
return resolveTarget(target) ?? ROUTES.chat;
}
export async function persistExternalEntryPayload({
@@ -61,44 +75,19 @@ export async function persistExternalEntryPayload({
await Promise.all(tasks);
}
function resolveExplicitRoute(
value: string | null | undefined,
): ExternalEntryTarget | null {
if (!value) return null;
const route = normalizeRoute(value);
if (
route === ROUTES.chat ||
route === ROUTES.tip ||
route === ROUTES.privateRoom
) {
return route;
}
return null;
}
function resolveTargetAlias(
function resolveTarget(
value: string | null | undefined,
): ExternalEntryTarget | null {
if (!value) return null;
const target = value.trim().toLowerCase();
if (target === "chat") return ROUTES.chat;
if (target === "tip" || target === "tips") {
return ROUTES.tip;
}
if (target === "private-room") {
return ROUTES.privateRoom;
}
if (target === "tip") return ROUTES.tip;
if (target === "private-room") return ROUTES.privateRoom;
return null;
}
function normalizeRoute(value: string): string {
const trimmed = value.trim();
if (!trimmed.startsWith("/") || trimmed.startsWith("//")) return "";
return trimmed.split(/[?#]/, 1)[0] ?? "";
}
function hasValue(value: string | null | undefined): value is string {
return typeof value === "string" && value.trim().length > 0;
}