feat(navigation): add external entry routing
This commit is contained in:
@@ -35,6 +35,9 @@ function makeAuthStorageMock(input: {
|
||||
getFacebookId: vi.fn(async () => Result.ok(null)),
|
||||
setFacebookId: vi.fn(async () => Result.ok(undefined)),
|
||||
clearFacebookId: vi.fn(async () => Result.ok(undefined)),
|
||||
getFacebookAppId: vi.fn(async () => Result.ok(null)),
|
||||
setFacebookAppId: vi.fn(async () => Result.ok(undefined)),
|
||||
clearFacebookAppId: vi.fn(async () => Result.ok(undefined)),
|
||||
clearBusinessAuthData: vi.fn(async () => Result.ok(undefined)),
|
||||
clearAuthData: vi.fn(async () => Result.ok(undefined)),
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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 { ROUTES } from "@/router/routes";
|
||||
import { todayString, UrlLauncherUtil } from "@/utils";
|
||||
|
||||
export async function resolveExternalBrowserPromptEligibility(): Promise<{
|
||||
@@ -27,42 +27,31 @@ export function recordExternalBrowserPromptShown(
|
||||
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 [deviceIdR, facebookIdR, facebookAppIdR, avatarUrlR] =
|
||||
await Promise.all([
|
||||
authStorage.getDeviceId(),
|
||||
authStorage.getFacebookId(),
|
||||
authStorage.getFacebookAppId(),
|
||||
userStorage.getAvatarUrl(),
|
||||
]);
|
||||
|
||||
const deviceId = deviceIdR.success ? deviceIdR.data : null;
|
||||
const facebookId = facebookIdR.success ? facebookIdR.data : null;
|
||||
const facebookAppId = facebookAppIdR.success ? facebookAppIdR.data : null;
|
||||
const avatarUrl = avatarUrlR.success ? avatarUrlR.data : null;
|
||||
|
||||
if (deviceId && facebookId) {
|
||||
UrlLauncherUtil.openUrlWithExternalBrowser(
|
||||
ROUTE_BUILDERS.chatDeviceId(deviceId),
|
||||
{
|
||||
queryParams: {
|
||||
fbid: facebookId,
|
||||
...(avatarUrl ? { avatarUrl } : {}),
|
||||
},
|
||||
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.externalEntry, {
|
||||
queryParams: {
|
||||
target: "chat",
|
||||
deviceId,
|
||||
fbid: facebookId,
|
||||
...(facebookAppId ? { fb_app_id: facebookAppId } : {}),
|
||||
...(avatarUrl ? { avatarUrl } : {}),
|
||||
},
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash);
|
||||
}
|
||||
|
||||
export async function persistExternalBrowserChatDeepLink(input: {
|
||||
deviceId: string;
|
||||
facebookId?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
}): Promise<void> {
|
||||
await AuthStorage.getInstance().setDeviceId(input.deviceId);
|
||||
if (input.facebookId && input.facebookId.length > 0) {
|
||||
await AuthStorage.getInstance().setFacebookId(input.facebookId);
|
||||
}
|
||||
if (input.avatarUrl && input.avatarUrl.length > 0) {
|
||||
await UserStorage.getInstance().setAvatarUrl(input.avatarUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
import { resolveExternalEntryTarget } from "../external_entry";
|
||||
|
||||
describe("external entry navigation", () => {
|
||||
it("defaults to chat", () => {
|
||||
expect(resolveExternalEntryTarget({})).toBe(ROUTES.chat);
|
||||
});
|
||||
|
||||
it("resolves supported target aliases", () => {
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
export type ExternalEntryTarget =
|
||||
| typeof ROUTES.chat
|
||||
| typeof ROUTES.tip
|
||||
| typeof ROUTES.privateRoom;
|
||||
|
||||
export interface ExternalEntryPayload {
|
||||
deviceId?: string | null;
|
||||
facebookId?: string | null;
|
||||
facebookAppId?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalEntryTargetInput {
|
||||
target?: string | null;
|
||||
redirect?: string | null;
|
||||
next?: string | null;
|
||||
}
|
||||
|
||||
export function resolveExternalEntryTarget({
|
||||
target,
|
||||
redirect,
|
||||
next,
|
||||
}: ExternalEntryTargetInput): ExternalEntryTarget {
|
||||
return (
|
||||
resolveExplicitRoute(redirect) ??
|
||||
resolveExplicitRoute(next) ??
|
||||
resolveTargetAlias(target) ??
|
||||
ROUTES.chat
|
||||
);
|
||||
}
|
||||
|
||||
export async function persistExternalEntryPayload({
|
||||
deviceId,
|
||||
facebookId,
|
||||
facebookAppId,
|
||||
avatarUrl,
|
||||
}: ExternalEntryPayload): Promise<void> {
|
||||
const authStorage = AuthStorage.getInstance();
|
||||
const userStorage = UserStorage.getInstance();
|
||||
const tasks: Array<Promise<unknown>> = [];
|
||||
|
||||
if (hasValue(deviceId)) {
|
||||
tasks.push(authStorage.setDeviceId(deviceId));
|
||||
}
|
||||
if (hasValue(facebookId)) {
|
||||
tasks.push(authStorage.setFacebookId(facebookId));
|
||||
}
|
||||
if (hasValue(facebookAppId)) {
|
||||
tasks.push(authStorage.setFacebookAppId(facebookAppId));
|
||||
}
|
||||
if (hasValue(avatarUrl)) {
|
||||
tasks.push(userStorage.setAvatarUrl(avatarUrl));
|
||||
}
|
||||
|
||||
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(
|
||||
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" || target === "coffee") {
|
||||
return ROUTES.tip;
|
||||
}
|
||||
if (
|
||||
target === "private-room" ||
|
||||
target === "private_room" ||
|
||||
target === "privateroom" ||
|
||||
target === "private" ||
|
||||
target === "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;
|
||||
}
|
||||
Reference in New Issue
Block a user