refactor(chat): show images in page overlay
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildChatImageOverlayUrl,
|
||||
buildChatWithoutImageOverlayUrl,
|
||||
getChatImageOverlayMessageId,
|
||||
} from "../chat-image-overlay-url";
|
||||
|
||||
describe("chat image overlay url helpers", () => {
|
||||
it("reads a non-empty image message id from search params", () => {
|
||||
expect(
|
||||
getChatImageOverlayMessageId(new URLSearchParams("image=msg_1")),
|
||||
).toBe("msg_1");
|
||||
});
|
||||
|
||||
it("ignores missing or blank image message ids", () => {
|
||||
expect(getChatImageOverlayMessageId(new URLSearchParams(""))).toBeNull();
|
||||
expect(
|
||||
getChatImageOverlayMessageId(new URLSearchParams("image=%20")),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("builds the chat image overlay url", () => {
|
||||
expect(buildChatImageOverlayUrl("msg 1")).toBe("/chat?image=msg+1");
|
||||
});
|
||||
|
||||
it("removes only the image param when closing the overlay", () => {
|
||||
expect(
|
||||
buildChatWithoutImageOverlayUrl(
|
||||
new URLSearchParams("image=msg_1&foo=bar"),
|
||||
),
|
||||
).toBe("/chat?foo=bar");
|
||||
expect(
|
||||
buildChatWithoutImageOverlayUrl(new URLSearchParams("image=msg_1")),
|
||||
).toBe("/chat");
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ChatScrollSessionStorage } from "@/lib/chat/chat_scroll_session_storage";
|
||||
|
||||
import {
|
||||
consumeChatScrollSnapshot,
|
||||
getChatScrollRestoreTop,
|
||||
saveChatScrollSnapshot,
|
||||
} from "../chat-scroll-session";
|
||||
|
||||
afterEach(async () => {
|
||||
await consumeChatScrollSnapshot();
|
||||
await ChatScrollSessionStorage.clearSnapshot();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("chat scroll session", () => {
|
||||
it("saves image viewer scroll sessions by message anchor", async () => {
|
||||
const { anchorNode, scrollNode } = createScrollFixture({
|
||||
anchorTop: 220,
|
||||
scrollTop: 120,
|
||||
});
|
||||
|
||||
saveChatScrollSnapshot(scrollNode, { anchorMessageId: "msg-image-1" });
|
||||
|
||||
const snapshot = await consumeChatScrollSnapshot();
|
||||
expect(snapshot).toEqual({
|
||||
version: 2,
|
||||
reason: "image-viewer",
|
||||
anchorMessageId: "msg-image-1",
|
||||
anchorOffsetTop: 120,
|
||||
fallbackScrollTop: 120,
|
||||
savedAt: expect.any(Number),
|
||||
});
|
||||
expect(anchorNode.dataset.chatMessageId).toBe("msg-image-1");
|
||||
});
|
||||
|
||||
it("restores scroll top by keeping the anchor at the saved visual offset", () => {
|
||||
const { scrollNode } = createScrollFixture({
|
||||
anchorTop: 250,
|
||||
scrollTop: 200,
|
||||
});
|
||||
|
||||
expect(
|
||||
getChatScrollRestoreTop(scrollNode, {
|
||||
version: 2,
|
||||
reason: "image-viewer",
|
||||
anchorMessageId: "msg-image-1",
|
||||
anchorOffsetTop: 120,
|
||||
fallbackScrollTop: 120,
|
||||
savedAt: Date.now(),
|
||||
}),
|
||||
).toBe(230);
|
||||
});
|
||||
|
||||
it("falls back to the saved scroll top when the anchor is missing", () => {
|
||||
const { scrollNode } = createScrollFixture({
|
||||
anchorTop: 250,
|
||||
scrollTop: 200,
|
||||
});
|
||||
|
||||
expect(
|
||||
getChatScrollRestoreTop(scrollNode, {
|
||||
version: 2,
|
||||
reason: "image-viewer",
|
||||
anchorMessageId: "missing-message",
|
||||
anchorOffsetTop: 120,
|
||||
fallbackScrollTop: 180,
|
||||
savedAt: Date.now(),
|
||||
}),
|
||||
).toBe(180);
|
||||
});
|
||||
});
|
||||
|
||||
function createScrollFixture(input: {
|
||||
anchorTop: number;
|
||||
scrollTop: number;
|
||||
}): {
|
||||
anchorNode: HTMLElement;
|
||||
scrollNode: HTMLElement;
|
||||
} {
|
||||
const scrollNode = document.createElement("div");
|
||||
Object.defineProperties(scrollNode, {
|
||||
clientHeight: { configurable: true, value: 400 },
|
||||
scrollHeight: { configurable: true, value: 1000 },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
value: input.scrollTop,
|
||||
writable: true,
|
||||
},
|
||||
});
|
||||
scrollNode.getBoundingClientRect = () => makeRect(100);
|
||||
|
||||
const anchorNode = document.createElement("div");
|
||||
anchorNode.dataset.chatMessageId = "msg-image-1";
|
||||
anchorNode.getBoundingClientRect = () => makeRect(input.anchorTop);
|
||||
|
||||
scrollNode.appendChild(anchorNode);
|
||||
document.body.appendChild(scrollNode);
|
||||
|
||||
return { anchorNode, scrollNode };
|
||||
}
|
||||
|
||||
function makeRect(top: number): DOMRect {
|
||||
return {
|
||||
bottom: top + 40,
|
||||
height: 40,
|
||||
left: 0,
|
||||
right: 100,
|
||||
toJSON: () => ({}),
|
||||
top,
|
||||
width: 100,
|
||||
x: 0,
|
||||
y: top,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
export const CHAT_IMAGE_QUERY_PARAM = "image";
|
||||
|
||||
export function getChatImageOverlayMessageId(input: {
|
||||
get: (key: string) => string | null;
|
||||
}): string | null {
|
||||
const value = input.get(CHAT_IMAGE_QUERY_PARAM)?.trim();
|
||||
return value && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function buildChatImageOverlayUrl(messageId: string): `/chat?${string}` {
|
||||
const params = new URLSearchParams({
|
||||
[CHAT_IMAGE_QUERY_PARAM]: messageId,
|
||||
});
|
||||
return `${ROUTES.chat}?${params.toString()}` as const;
|
||||
}
|
||||
|
||||
export function buildChatWithoutImageOverlayUrl(input: {
|
||||
toString: () => string;
|
||||
}): string {
|
||||
const params = new URLSearchParams(input.toString());
|
||||
params.delete(CHAT_IMAGE_QUERY_PARAM);
|
||||
const query = params.toString();
|
||||
return query ? `${ROUTES.chat}?${query}` : ROUTES.chat;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||
import { useChatState } from "@/stores/chat/chat-context";
|
||||
@@ -17,8 +19,14 @@ import {
|
||||
ChatUnlockDialogs,
|
||||
ExternalBrowserDialog,
|
||||
FirstRechargeOfferBanner,
|
||||
FullscreenImageViewer,
|
||||
PwaInstallOverlay,
|
||||
} from "./components";
|
||||
import {
|
||||
buildChatImageOverlayUrl,
|
||||
buildChatWithoutImageOverlayUrl,
|
||||
getChatImageOverlayMessageId,
|
||||
} from "./chat-image-overlay-url";
|
||||
import {
|
||||
deriveIsGuest,
|
||||
isChatDevelopmentEnvironment,
|
||||
@@ -31,15 +39,44 @@ import { useExternalBrowserPrompt } from "./hooks/use-external-browser-prompt";
|
||||
import styles from "./components/chat-screen.module.css";
|
||||
|
||||
export function ChatScreen() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const state = useChatState();
|
||||
const authState = useAuthState();
|
||||
const authDispatch = useAuthDispatch();
|
||||
const imageMessageId = getChatImageOverlayMessageId(searchParams);
|
||||
const imageReturnUrl = imageMessageId
|
||||
? buildChatImageOverlayUrl(imageMessageId)
|
||||
: ROUTES.chat;
|
||||
const {
|
||||
unlockPaywallRequest,
|
||||
requestMessageUnlock,
|
||||
closeInsufficientCreditsDialog,
|
||||
confirmInsufficientCreditsDialog,
|
||||
} = useChatUnlockNavigationFlow({ returnUrl: ROUTES.chat });
|
||||
} = useChatUnlockNavigationFlow({
|
||||
returnUrl: ROUTES.chat,
|
||||
ignoredKind: "image",
|
||||
});
|
||||
const {
|
||||
unlockPaywallRequest: imageUnlockPaywallRequest,
|
||||
requestMessageUnlock: requestImageUnlock,
|
||||
closeInsufficientCreditsDialog: closeImageInsufficientCreditsDialog,
|
||||
confirmInsufficientCreditsDialog: confirmImageInsufficientCreditsDialog,
|
||||
} = useChatUnlockNavigationFlow({
|
||||
returnUrl: imageReturnUrl,
|
||||
expectedKind: "image",
|
||||
expectedMessageId: imageMessageId ?? undefined,
|
||||
enabled: imageMessageId !== null,
|
||||
});
|
||||
const selectedImageMessage = useMemo(
|
||||
() =>
|
||||
imageMessageId
|
||||
? state.messages.find(
|
||||
(item) => item.id === imageMessageId && item.imageUrl,
|
||||
) ?? null
|
||||
: null,
|
||||
[imageMessageId, state.messages],
|
||||
);
|
||||
|
||||
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
||||
const isGuest = deriveIsGuest(authState.loginStatus);
|
||||
@@ -68,6 +105,21 @@ export function ChatScreen() {
|
||||
loginStatus: authState.loginStatus,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageMessageId || !state.historyLoaded || selectedImageMessage) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildChatWithoutImageOverlayUrl(searchParams), {
|
||||
scroll: false,
|
||||
});
|
||||
}, [
|
||||
imageMessageId,
|
||||
router,
|
||||
searchParams,
|
||||
selectedImageMessage,
|
||||
state.historyLoaded,
|
||||
]);
|
||||
|
||||
function handleUnlockPrivateMessage(messageId: string): void {
|
||||
requestMessageUnlock(messageId, "private");
|
||||
}
|
||||
@@ -76,6 +128,21 @@ export function ChatScreen() {
|
||||
requestMessageUnlock(messageId, "voice");
|
||||
}
|
||||
|
||||
function handleOpenImage(messageId: string): void {
|
||||
router.push(buildChatImageOverlayUrl(messageId), { scroll: false });
|
||||
}
|
||||
|
||||
function handleCloseImageViewer(): void {
|
||||
router.replace(buildChatWithoutImageOverlayUrl(searchParams), {
|
||||
scroll: false,
|
||||
});
|
||||
}
|
||||
|
||||
function handleUnlockImagePaywall(): void {
|
||||
if (!imageMessageId) return;
|
||||
requestImageUnlock(imageMessageId, "image");
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileShell>
|
||||
<div className={styles.shell}>
|
||||
@@ -110,6 +177,7 @@ export function ChatScreen() {
|
||||
unlockingMessageId={state.unlockingMessageId}
|
||||
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={handleUnlockVoiceMessage}
|
||||
onOpenImage={handleOpenImage}
|
||||
/>
|
||||
|
||||
{messageLimitBanner.visible ? (
|
||||
@@ -141,6 +209,27 @@ export function ChatScreen() {
|
||||
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
|
||||
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
|
||||
/>
|
||||
<ChatUnlockDialogs
|
||||
unlockPaywallRequest={imageUnlockPaywallRequest}
|
||||
onCloseInsufficientCreditsDialog={closeImageInsufficientCreditsDialog}
|
||||
onConfirmInsufficientCreditsDialog={
|
||||
confirmImageInsufficientCreditsDialog
|
||||
}
|
||||
/>
|
||||
|
||||
{selectedImageMessage?.imageUrl ? (
|
||||
<FullscreenImageViewer
|
||||
messageId={selectedImageMessage.id}
|
||||
imageUrl={selectedImageMessage.imageUrl}
|
||||
imagePaywalled={selectedImageMessage.imagePaywalled === true}
|
||||
isUnlockingImagePaywall={
|
||||
state.isUnlockingMessage &&
|
||||
state.unlockingMessageId === selectedImageMessage.id
|
||||
}
|
||||
onUnlockImagePaywall={handleUnlockImagePaywall}
|
||||
onClose={handleCloseImageViewer}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChatScrollSessionStorage,
|
||||
type ChatScrollSession,
|
||||
} from "@/lib/chat/chat_scroll_session_storage";
|
||||
import { Result } from "@/utils";
|
||||
|
||||
const CHAT_SCROLL_SAVE_EVENT = "cozsweet:chat:save-scroll";
|
||||
const CHAT_SCROLL_MAX_AGE_MS = 10 * 60 * 1000;
|
||||
|
||||
let memoryScrollSession: ChatScrollSession | null = null;
|
||||
|
||||
interface ChatScrollSnapshotSaveDetail {
|
||||
anchorMessageId: string;
|
||||
}
|
||||
|
||||
export function requestChatScrollSnapshotSave(anchorMessageId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (anchorMessageId.trim().length === 0) return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<ChatScrollSnapshotSaveDetail>(CHAT_SCROLL_SAVE_EVENT, {
|
||||
detail: { anchorMessageId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function listenChatScrollSnapshotSave(
|
||||
listener: (detail: ChatScrollSnapshotSaveDetail) => void,
|
||||
): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const handler = (event: Event) => {
|
||||
if (!(event instanceof CustomEvent)) return;
|
||||
const detail = event.detail as Partial<ChatScrollSnapshotSaveDetail>;
|
||||
if (typeof detail.anchorMessageId !== "string") return;
|
||||
listener({ anchorMessageId: detail.anchorMessageId });
|
||||
};
|
||||
window.addEventListener(CHAT_SCROLL_SAVE_EVENT, handler);
|
||||
return () => window.removeEventListener(CHAT_SCROLL_SAVE_EVENT, handler);
|
||||
}
|
||||
|
||||
export function saveChatScrollSnapshot(
|
||||
scrollNode: HTMLElement,
|
||||
detail: ChatScrollSnapshotSaveDetail,
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const { scrollTop } = scrollNode;
|
||||
if (!Number.isFinite(scrollTop) || scrollTop < 0) return;
|
||||
|
||||
const anchorNode = findChatMessageElement(
|
||||
scrollNode,
|
||||
detail.anchorMessageId,
|
||||
);
|
||||
if (!anchorNode) return;
|
||||
|
||||
const payload: ChatScrollSession = {
|
||||
version: 2,
|
||||
reason: "image-viewer",
|
||||
anchorMessageId: detail.anchorMessageId,
|
||||
anchorOffsetTop: getElementOffsetTop(scrollNode, anchorNode),
|
||||
fallbackScrollTop: scrollTop,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
memoryScrollSession = payload;
|
||||
|
||||
void ChatScrollSessionStorage.setSnapshot(payload);
|
||||
}
|
||||
|
||||
export async function consumeChatScrollSnapshot(): Promise<ChatScrollSession | null> {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const memoryPayload = readValidChatScrollSession(memoryScrollSession);
|
||||
memoryScrollSession = null;
|
||||
if (memoryPayload !== null) {
|
||||
void ChatScrollSessionStorage.clearSnapshot();
|
||||
return memoryPayload;
|
||||
}
|
||||
|
||||
const storageResult = await ChatScrollSessionStorage.consumeSnapshot();
|
||||
if (Result.isErr(storageResult)) return null;
|
||||
return readValidChatScrollSession(storageResult.data);
|
||||
}
|
||||
|
||||
export function getChatScrollRestoreTop(
|
||||
scrollNode: HTMLElement,
|
||||
snapshot: ChatScrollSession,
|
||||
): number {
|
||||
const anchorNode = findChatMessageElement(
|
||||
scrollNode,
|
||||
snapshot.anchorMessageId,
|
||||
);
|
||||
if (anchorNode) {
|
||||
const currentOffsetTop = getElementOffsetTop(scrollNode, anchorNode);
|
||||
return clampScrollTop(
|
||||
scrollNode,
|
||||
scrollNode.scrollTop + currentOffsetTop - snapshot.anchorOffsetTop,
|
||||
);
|
||||
}
|
||||
|
||||
return clampScrollTop(scrollNode, snapshot.fallbackScrollTop);
|
||||
}
|
||||
|
||||
function readValidChatScrollSession(value: unknown): ChatScrollSession | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
|
||||
const payload = value as Partial<ChatScrollSession>;
|
||||
if (
|
||||
payload.version !== 2 ||
|
||||
payload.reason !== "image-viewer" ||
|
||||
typeof payload.anchorMessageId !== "string" ||
|
||||
payload.anchorMessageId.length === 0 ||
|
||||
typeof payload.anchorOffsetTop !== "number" ||
|
||||
!Number.isFinite(payload.anchorOffsetTop) ||
|
||||
typeof payload.fallbackScrollTop !== "number" ||
|
||||
!Number.isFinite(payload.fallbackScrollTop) ||
|
||||
typeof payload.savedAt !== "number" ||
|
||||
!Number.isFinite(payload.savedAt)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() - payload.savedAt > CHAT_SCROLL_MAX_AGE_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
reason: "image-viewer",
|
||||
anchorMessageId: payload.anchorMessageId,
|
||||
anchorOffsetTop: payload.anchorOffsetTop,
|
||||
fallbackScrollTop: Math.max(0, payload.fallbackScrollTop),
|
||||
savedAt: payload.savedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function findChatMessageElement(
|
||||
scrollNode: HTMLElement,
|
||||
messageId: string,
|
||||
): HTMLElement | null {
|
||||
return (
|
||||
Array.from(
|
||||
scrollNode.querySelectorAll<HTMLElement>("[data-chat-message-id]"),
|
||||
).find((element) => element.dataset.chatMessageId === messageId) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function getElementOffsetTop(
|
||||
scrollNode: HTMLElement,
|
||||
element: HTMLElement,
|
||||
): number {
|
||||
return (
|
||||
element.getBoundingClientRect().top -
|
||||
scrollNode.getBoundingClientRect().top
|
||||
);
|
||||
}
|
||||
|
||||
function clampScrollTop(scrollNode: HTMLElement, value: number): number {
|
||||
const maxScrollTop = Math.max(
|
||||
0,
|
||||
scrollNode.scrollHeight - scrollNode.clientHeight,
|
||||
);
|
||||
return Math.min(Math.max(0, value), maxScrollTop);
|
||||
}
|
||||
@@ -16,12 +16,6 @@ import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
|
||||
import {
|
||||
consumeChatScrollSnapshot,
|
||||
getChatScrollRestoreTop,
|
||||
listenChatScrollSnapshotSave,
|
||||
saveChatScrollSnapshot,
|
||||
} from "../chat-scroll-session";
|
||||
import {
|
||||
buildChatRenderItems,
|
||||
createChatMessageKeyResolver,
|
||||
@@ -35,8 +29,6 @@ import styles from "./chat-area.module.css";
|
||||
|
||||
const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96;
|
||||
|
||||
type RestoreState = "idle" | "checking" | "restoring" | "settlingBottom" | "done";
|
||||
|
||||
export interface ChatAreaProps {
|
||||
messages: readonly UiMessage[];
|
||||
isReplyingAI: boolean;
|
||||
@@ -44,6 +36,7 @@ export interface ChatAreaProps {
|
||||
unlockingMessageId?: string | null;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function ChatArea({
|
||||
@@ -53,30 +46,31 @@ export function ChatArea({
|
||||
unlockingMessageId,
|
||||
onUnlockPrivateMessage,
|
||||
onUnlockVoiceMessage,
|
||||
onOpenImage,
|
||||
}: ChatAreaProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const prevLengthRef = useRef(messages.length);
|
||||
const restoredScrollRef = useRef(false);
|
||||
const restoreStateRef = useRef<RestoreState>("idle");
|
||||
const initialScrollSettledRef = useRef(false);
|
||||
const wasNearBottomRef = useRef(true);
|
||||
const getMessageKey = useMemo(() => createChatMessageKeyResolver(), []);
|
||||
|
||||
const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => {
|
||||
useLayoutEffect(() => {
|
||||
if (initialScrollSettledRef.current || messages.length === 0) return;
|
||||
const scrollNode = scrollRef.current;
|
||||
if (!scrollNode) return;
|
||||
saveChatScrollSnapshot(scrollNode, { anchorMessageId });
|
||||
};
|
||||
|
||||
initialScrollSettledRef.current = true;
|
||||
prevLengthRef.current = messages.length;
|
||||
scrollNode.scrollTop = scrollNode.scrollHeight;
|
||||
wasNearBottomRef.current = true;
|
||||
}, [messages.length]);
|
||||
|
||||
// 新消息 → 滚到底部
|
||||
useEffect(() => {
|
||||
if (messages.length !== prevLengthRef.current) {
|
||||
const scrollNode = scrollRef.current;
|
||||
const shouldSkipAutoScroll =
|
||||
restoreStateRef.current === "checking" ||
|
||||
restoreStateRef.current === "restoring" ||
|
||||
restoreStateRef.current === "settlingBottom";
|
||||
|
||||
if (scrollNode && !shouldSkipAutoScroll && wasNearBottomRef.current) {
|
||||
if (scrollNode && wasNearBottomRef.current) {
|
||||
scrollNode.scrollTo({
|
||||
top: scrollNode.scrollHeight,
|
||||
behavior: "smooth",
|
||||
@@ -86,72 +80,6 @@ export function ChatArea({
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (restoredScrollRef.current || messages.length === 0) return;
|
||||
|
||||
const scrollNode = scrollRef.current;
|
||||
if (!scrollNode) return;
|
||||
|
||||
let cancelled = false;
|
||||
let stopStableScrollTask: (() => void) | null = null;
|
||||
|
||||
restoreStateRef.current = "checking";
|
||||
|
||||
const restore = async () => {
|
||||
const snapshot = await consumeChatScrollSnapshot();
|
||||
if (cancelled) return;
|
||||
|
||||
if (snapshot === null) {
|
||||
restoredScrollRef.current = true;
|
||||
restoreStateRef.current = "settlingBottom";
|
||||
prevLengthRef.current = messages.length;
|
||||
stopStableScrollTask = startStableScrollTask({
|
||||
scrollNode,
|
||||
applyScroll: () => {
|
||||
scrollNode.scrollTop = scrollNode.scrollHeight;
|
||||
},
|
||||
onFinish: () => {
|
||||
restoreStateRef.current = "done";
|
||||
wasNearBottomRef.current = true;
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
restoredScrollRef.current = true;
|
||||
restoreStateRef.current = "restoring";
|
||||
prevLengthRef.current = messages.length;
|
||||
|
||||
stopStableScrollTask = startStableScrollTask({
|
||||
scrollNode,
|
||||
applyScroll: () => {
|
||||
scrollNode.scrollTop = getChatScrollRestoreTop(scrollNode, snapshot);
|
||||
},
|
||||
onFinish: () => {
|
||||
restoreStateRef.current = "done";
|
||||
wasNearBottomRef.current = isNearBottom(scrollNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
void restore();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopStableScrollTask?.();
|
||||
};
|
||||
}, [messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const stopListening = listenChatScrollSnapshotSave(({ anchorMessageId }) => {
|
||||
saveCurrentScrollSnapshotNow(anchorMessageId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
stopListening();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main
|
||||
ref={scrollRef}
|
||||
@@ -170,6 +98,7 @@ export function ChatArea({
|
||||
unlockingMessageId,
|
||||
onUnlockPrivateMessage,
|
||||
onUnlockVoiceMessage,
|
||||
onOpenImage,
|
||||
)}
|
||||
|
||||
{isReplyingAI && <LottieMessageBubble />}
|
||||
@@ -183,47 +112,6 @@ function isNearBottom(scrollNode: HTMLElement): boolean {
|
||||
return distanceFromBottom <= CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD;
|
||||
}
|
||||
|
||||
function startStableScrollTask({
|
||||
applyScroll,
|
||||
onFinish,
|
||||
scrollNode,
|
||||
}: {
|
||||
applyScroll: () => void;
|
||||
onFinish: () => void;
|
||||
scrollNode: HTMLElement;
|
||||
}): () => void {
|
||||
let frame: number | null = null;
|
||||
let earlyTimer: number | null = null;
|
||||
let lateTimer: number | null = null;
|
||||
let observerTimer: number | null = null;
|
||||
let finishTimer: number | null = null;
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(applyScroll);
|
||||
|
||||
applyScroll();
|
||||
frame = window.requestAnimationFrame(applyScroll);
|
||||
earlyTimer = window.setTimeout(applyScroll, 120);
|
||||
lateTimer = window.setTimeout(applyScroll, 600);
|
||||
Array.from(scrollNode.children).forEach((child) => {
|
||||
resizeObserver?.observe(child);
|
||||
});
|
||||
observerTimer = window.setTimeout(() => {
|
||||
resizeObserver?.disconnect();
|
||||
}, 800);
|
||||
finishTimer = window.setTimeout(onFinish, 850);
|
||||
|
||||
return () => {
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
if (earlyTimer !== null) window.clearTimeout(earlyTimer);
|
||||
if (lateTimer !== null) window.clearTimeout(lateTimer);
|
||||
if (observerTimer !== null) window.clearTimeout(observerTimer);
|
||||
if (finishTimer !== null) window.clearTimeout(finishTimer);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
/** 渲染消息列表(按日期分组插入分隔条) */
|
||||
function renderMessagesWithDateHeaders(
|
||||
messages: readonly UiMessage[],
|
||||
@@ -232,6 +120,7 @@ function renderMessagesWithDateHeaders(
|
||||
unlockingMessageId?: string | null,
|
||||
onUnlockPrivateMessage?: (messageId: string) => void,
|
||||
onUnlockVoiceMessage?: (messageId: string) => void,
|
||||
onOpenImage?: (messageId: string) => void,
|
||||
) {
|
||||
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
||||
item.type === "date" ? (
|
||||
@@ -255,6 +144,7 @@ function renderMessagesWithDateHeaders(
|
||||
}
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
onOpenImage={onOpenImage}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -72,6 +72,7 @@ export function FullscreenImageViewer({
|
||||
<div
|
||||
className={`${styles.viewer} ${styles.paywallViewer}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Locked fullscreen image"
|
||||
>
|
||||
{shouldUseNativeImage ? (
|
||||
@@ -119,8 +120,17 @@ export function FullscreenImageViewer({
|
||||
className={styles.viewer}
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Fullscreen image"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton}
|
||||
onClick={onClose}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ChevronLeft size={34} strokeWidth={2.5} aria-hidden="true" />
|
||||
</button>
|
||||
{hasImageError ? (
|
||||
<div className="error">🖼</div>
|
||||
) : shouldUseNativeImage ? (
|
||||
@@ -129,6 +139,7 @@ export function FullscreenImageViewer({
|
||||
alt=""
|
||||
className={styles.viewerImage}
|
||||
onError={handleImageError}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
@@ -138,6 +149,7 @@ export function FullscreenImageViewer({
|
||||
height={800}
|
||||
className={styles.viewerImage}
|
||||
onError={handleImageError}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,34 +5,33 @@
|
||||
*
|
||||
* 支持:
|
||||
* - base64 data URI 解码(`data:image/png;base64,...`)
|
||||
* - 点击 → 跳转动态路由打开全屏查看器
|
||||
* - 点击 → 通知聊天页打开页内全屏查看器
|
||||
* - 错误占位符(broken image icon)
|
||||
*/
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import {
|
||||
isBrowserLocalChatImageSource,
|
||||
normalizeChatImageSource,
|
||||
} from "@/lib/chat/chat_media_url";
|
||||
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
||||
|
||||
import { requestChatScrollSnapshotSave } from "../chat-scroll-session";
|
||||
import styles from "./image-bubble.module.css";
|
||||
|
||||
export interface ImageBubbleProps {
|
||||
messageId?: string;
|
||||
imageUrl: string; // base64 data URI 或 URL
|
||||
imagePaywalled?: boolean;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function ImageBubble({
|
||||
messageId,
|
||||
imageUrl,
|
||||
imagePaywalled = false,
|
||||
onOpenImage,
|
||||
}: ImageBubbleProps) {
|
||||
const navigator = useAppNavigator();
|
||||
const [errorSrc, setErrorSrc] = useState<string | null>(null);
|
||||
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
||||
useCachedChatMediaUrl({
|
||||
@@ -42,7 +41,7 @@ export function ImageBubble({
|
||||
});
|
||||
|
||||
const src = normalizeChatImageSource(mediaUrl || imageUrl);
|
||||
const canOpen = Boolean(messageId);
|
||||
const canOpen = Boolean(messageId && onOpenImage);
|
||||
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
|
||||
const error = errorSrc === src;
|
||||
|
||||
@@ -55,9 +54,8 @@ export function ImageBubble({
|
||||
};
|
||||
|
||||
const openImage = () => {
|
||||
if (!messageId) return;
|
||||
requestChatScrollSnapshotSave(messageId);
|
||||
navigator.openChatImage(messageId);
|
||||
if (!messageId || !onOpenImage) return;
|
||||
onOpenImage(messageId);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface MessageBubbleProps {
|
||||
isUnlockingMessage?: boolean;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
@@ -44,6 +45,7 @@ export function MessageBubble({
|
||||
isUnlockingMessage,
|
||||
onUnlockPrivateMessage,
|
||||
onUnlockVoiceMessage,
|
||||
onOpenImage,
|
||||
}: MessageBubbleProps) {
|
||||
const { avatarUrl } = useUserState();
|
||||
|
||||
@@ -70,6 +72,7 @@ export function MessageBubble({
|
||||
isUnlockingMessage={isUnlockingMessage}
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
onOpenImage={onOpenImage}
|
||||
/>
|
||||
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||
</div>
|
||||
@@ -97,6 +100,7 @@ export function MessageBubble({
|
||||
isUnlockingMessage={isUnlockingMessage}
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
onOpenImage={onOpenImage}
|
||||
/>
|
||||
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
||||
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface MessageContentProps {
|
||||
isUnlockingMessage?: boolean;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
const IMAGE_PLACEHOLDER = "[图片]";
|
||||
@@ -37,6 +38,7 @@ export function MessageContent({
|
||||
isUnlockingMessage,
|
||||
onUnlockPrivateMessage,
|
||||
onUnlockVoiceMessage,
|
||||
onOpenImage,
|
||||
}: MessageContentProps) {
|
||||
const hasImage = imageUrl != null && imageUrl.length > 0;
|
||||
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
||||
@@ -74,6 +76,7 @@ export function MessageContent({
|
||||
messageId={messageId}
|
||||
imageUrl={imageUrl}
|
||||
imagePaywalled={imagePaywalled}
|
||||
onOpenImage={onOpenImage}
|
||||
/>
|
||||
)}
|
||||
{hasAudio && audioUrl ? (
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface UseChatUnlockNavigationFlowInput {
|
||||
returnUrl: string;
|
||||
expectedKind?: PendingChatUnlockKind;
|
||||
expectedMessageId?: string;
|
||||
ignoredKind?: PendingChatUnlockKind;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseChatUnlockNavigationFlowOutput {
|
||||
@@ -35,6 +37,8 @@ export function useChatUnlockNavigationFlow({
|
||||
returnUrl,
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
ignoredKind,
|
||||
enabled = true,
|
||||
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
|
||||
const navigator = useAppNavigator();
|
||||
const userState = useUserState();
|
||||
@@ -44,9 +48,12 @@ export function useChatUnlockNavigationFlow({
|
||||
request: chatState.unlockPaywallRequest,
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
ignoredKind,
|
||||
enabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return;
|
||||
|
||||
let cancelled = false;
|
||||
@@ -83,8 +90,10 @@ export function useChatUnlockNavigationFlow({
|
||||
}, [
|
||||
chatDispatch,
|
||||
chatState.historyLoaded,
|
||||
enabled,
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
ignoredKind,
|
||||
navigator.isAuthenticatedUser,
|
||||
returnUrl,
|
||||
]);
|
||||
@@ -135,9 +144,19 @@ function getScopedUnlockPaywallRequest(input: {
|
||||
request: ChatUnlockPaywallRequest | null;
|
||||
expectedKind?: PendingChatUnlockKind;
|
||||
expectedMessageId?: string;
|
||||
ignoredKind?: PendingChatUnlockKind;
|
||||
enabled?: boolean;
|
||||
}): ChatUnlockPaywallRequest | null {
|
||||
const { request, expectedKind, expectedMessageId } = input;
|
||||
const {
|
||||
request,
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
ignoredKind,
|
||||
enabled = true,
|
||||
} = input;
|
||||
if (!enabled) return null;
|
||||
if (!request) return null;
|
||||
if (ignoredKind && request.kind === ignoredKind) return null;
|
||||
if (expectedKind && request.kind !== expectedKind) return null;
|
||||
if (expectedMessageId && request.messageId !== expectedMessageId) return null;
|
||||
return request;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
.emptyScreen {
|
||||
display: flex;
|
||||
min-height: var(--app-viewport-height, 100dvh);
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--page-section-gap, 18px);
|
||||
padding:
|
||||
calc(var(--page-padding-y, 24px) + var(--app-safe-top, 0px))
|
||||
calc(var(--page-padding-x, 24px) + var(--app-safe-right, 0px))
|
||||
calc(var(--page-padding-y, 24px) + var(--app-safe-bottom, 0px))
|
||||
calc(var(--page-padding-x, 24px) + var(--app-safe-left, 0px));
|
||||
background: #0d0b14;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
min-height: var(--responsive-control-height, 44px);
|
||||
padding: 0 var(--responsive-card-padding, 18px);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #191316;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: var(--responsive-body, 15px);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: var(--responsive-body, 16px);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
import { ROUTE_BUILDERS } from "@/router/routes";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import { useChatState } from "@/stores/chat/chat-context";
|
||||
|
||||
import {
|
||||
ChatUnlockDialogs,
|
||||
FullscreenImageViewer,
|
||||
} from "../../components";
|
||||
import { useChatUnlockNavigationFlow } from "../../hooks/use-chat-unlock-navigation-flow";
|
||||
import styles from "./chat-image-viewer-screen.module.css";
|
||||
|
||||
export interface ChatImageViewerScreenProps {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export function ChatImageViewerScreen({
|
||||
messageId,
|
||||
}: ChatImageViewerScreenProps) {
|
||||
const navigator = useAppNavigator();
|
||||
const chatState = useChatState();
|
||||
const returnUrl = ROUTE_BUILDERS.chatImage(messageId);
|
||||
const {
|
||||
unlockPaywallRequest,
|
||||
requestMessageUnlock,
|
||||
closeInsufficientCreditsDialog,
|
||||
confirmInsufficientCreditsDialog,
|
||||
} = useChatUnlockNavigationFlow({
|
||||
returnUrl,
|
||||
expectedKind: "image",
|
||||
expectedMessageId: messageId,
|
||||
});
|
||||
const message = chatState.messages.find(
|
||||
(item) => item.id === messageId && item.imageUrl,
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
navigator.openChat({ scroll: false });
|
||||
};
|
||||
|
||||
const handleUnlockImagePaywall = () => {
|
||||
requestMessageUnlock(messageId, "image");
|
||||
};
|
||||
|
||||
const unlockDialogs = (
|
||||
<ChatUnlockDialogs
|
||||
unlockPaywallRequest={unlockPaywallRequest}
|
||||
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
|
||||
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!message) {
|
||||
return (
|
||||
<>
|
||||
<MobileShell background="#0d0b14">
|
||||
<main className={styles.emptyScreen}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Back to chat
|
||||
</button>
|
||||
<p className={styles.emptyText}>
|
||||
{chatState.historyLoaded ? "Image not found." : "Loading image..."}
|
||||
</p>
|
||||
</main>
|
||||
</MobileShell>
|
||||
{unlockDialogs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileShell background="#000000">
|
||||
<FullscreenImageViewer
|
||||
messageId={messageId}
|
||||
imageUrl={message.imageUrl ?? ""}
|
||||
imagePaywalled={message.imagePaywalled === true}
|
||||
isUnlockingImagePaywall={
|
||||
chatState.isUnlockingMessage &&
|
||||
chatState.unlockingMessageId === messageId
|
||||
}
|
||||
onUnlockImagePaywall={handleUnlockImagePaywall}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</MobileShell>
|
||||
{unlockDialogs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ChatImageViewerScreen } from "./chat-image-viewer-screen";
|
||||
|
||||
export default async function ChatImagePage(
|
||||
props: PageProps<"/chat/image/[messageId]">,
|
||||
) {
|
||||
const { messageId } = await props.params;
|
||||
return <ChatImageViewerScreen messageId={messageId} />;
|
||||
}
|
||||
+25
-1
@@ -1,7 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { MobileShell } from "@/app/_components/core";
|
||||
import { ChatScreen } from "@/app/chat/chat-screen";
|
||||
|
||||
export default function ChatPage() {
|
||||
return <ChatScreen />;
|
||||
return (
|
||||
<Suspense fallback={<ChatFallback />}>
|
||||
<ChatScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatFallback() {
|
||||
return (
|
||||
<MobileShell background="#111111">
|
||||
<div
|
||||
style={{
|
||||
minHeight: "60vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "rgba(255, 255, 255, 0.72)",
|
||||
}}
|
||||
>
|
||||
Loading chat...
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,14 +16,14 @@ describe("NavigationStorage", () => {
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat/image/msg_1",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
stage: "payment",
|
||||
});
|
||||
|
||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat/image/msg_1",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
stage: "payment",
|
||||
});
|
||||
|
||||
@@ -55,14 +55,14 @@ describe("NavigationStorage", () => {
|
||||
it("saves and consumes pending chat image return sessions", async () => {
|
||||
await NavigationStorage.savePendingChatImageReturn({
|
||||
messageId: "msg_1",
|
||||
returnUrl: "/chat/image/msg_1",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatImageReturn(),
|
||||
).resolves.toMatchObject({
|
||||
messageId: "msg_1",
|
||||
returnUrl: "/chat/image/msg_1",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
});
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatImageReturn(),
|
||||
|
||||
@@ -23,7 +23,6 @@ export const StorageKeys = {
|
||||
|
||||
// chat
|
||||
chatHistory: "chat_history",
|
||||
chatScrollSession: "chat_scroll_session",
|
||||
pendingChatImageReturn: "pending_chat_image_return",
|
||||
pendingChatUnlock: "pending_chat_unlock",
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||
import { Result, SessionAsyncUtil, type Result as ResultT } from "@/utils";
|
||||
|
||||
const ChatScrollSessionSchema = z.object({
|
||||
version: z.literal(2),
|
||||
reason: z.literal("image-viewer"),
|
||||
anchorMessageId: z.string().min(1),
|
||||
anchorOffsetTop: z.number(),
|
||||
fallbackScrollTop: z.number(),
|
||||
savedAt: z.number(),
|
||||
});
|
||||
|
||||
export type ChatScrollSession = z.output<typeof ChatScrollSessionSchema>;
|
||||
|
||||
export class ChatScrollSessionStorage {
|
||||
private constructor() {}
|
||||
|
||||
static setSnapshot(snapshot: ChatScrollSession): Promise<ResultT<void>> {
|
||||
return SessionAsyncUtil.setJson(
|
||||
StorageKeys.chatScrollSession,
|
||||
snapshot,
|
||||
ChatScrollSessionSchema,
|
||||
);
|
||||
}
|
||||
|
||||
static getSnapshot(): Promise<ResultT<ChatScrollSession | null>> {
|
||||
return SessionAsyncUtil.getJson(
|
||||
StorageKeys.chatScrollSession,
|
||||
ChatScrollSessionSchema,
|
||||
);
|
||||
}
|
||||
|
||||
static clearSnapshot(): Promise<ResultT<void>> {
|
||||
return SessionAsyncUtil.remove(StorageKeys.chatScrollSession);
|
||||
}
|
||||
|
||||
static async consumeSnapshot(): Promise<ResultT<ChatScrollSession | null>> {
|
||||
const result = await ChatScrollSessionStorage.getSnapshot();
|
||||
const cleared = await ChatScrollSessionStorage.clearSnapshot();
|
||||
if (Result.isErr(result)) return result;
|
||||
if (Result.isErr(cleared)) return cleared;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -39,12 +39,12 @@ describe("subscription exit helpers", () => {
|
||||
consumePendingChatImageReturnMock.mockResolvedValue({
|
||||
reason: "image_paywall",
|
||||
messageId: "msg_1",
|
||||
returnUrl: "/chat/image/msg_1",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
await expect(consumeSubscriptionExplicitExitUrl()).resolves.toBe(
|
||||
"/chat/image/msg_1",
|
||||
"/chat?image=msg_1",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -70,13 +70,13 @@ describe("subscription exit helpers", () => {
|
||||
reason: "single_message_unlock",
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat/image/msg_1",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
stage: "payment",
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
await expect(peekSubscriptionExplicitExitUrl()).resolves.toBe(
|
||||
"/chat/image/msg_1",
|
||||
"/chat?image=msg_1",
|
||||
);
|
||||
expect(clearPendingChatUnlockMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -87,13 +87,13 @@ describe("subscription exit helpers", () => {
|
||||
reason: "single_message_unlock",
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat/image/msg_1",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
stage: "payment",
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
await expect(consumeSubscriptionExplicitExitUrl()).resolves.toBe(
|
||||
"/chat/image/msg_1",
|
||||
"/chat?image=msg_1",
|
||||
);
|
||||
expect(clearPendingChatUnlockMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -16,10 +16,13 @@ describe("route meta", () => {
|
||||
});
|
||||
|
||||
it("classifies known dynamic chat routes", () => {
|
||||
expect(getRouteAccess("/chat/image/msg_1")).toBe("session");
|
||||
expect(getRouteAccess("/chat/deviceid/device_1")).toBe("public");
|
||||
});
|
||||
|
||||
it("does not keep the removed chat image route as a guarded route", () => {
|
||||
expect(getRouteAccess("/chat/image/msg_1")).toBe("public");
|
||||
});
|
||||
|
||||
it("includes private room in static routes", () => {
|
||||
expect(ALL_STATIC_ROUTES).toContain(ROUTES.privateRoom);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ const STATIC_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
|
||||
};
|
||||
|
||||
export function getRouteAccess(pathname: string): RouteAccess {
|
||||
if (pathname.startsWith("/chat/image/")) return "session";
|
||||
if (pathname.startsWith("/chat/deviceid/")) return "public";
|
||||
return STATIC_ROUTE_ACCESS[pathname] ?? "public";
|
||||
}
|
||||
|
||||
@@ -35,8 +35,6 @@ export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
|
||||
export const ROUTE_BUILDERS = {
|
||||
chatDeviceId: (deviceId: string): `/chat/deviceid/${string}` =>
|
||||
`/chat/deviceid/${encodeURIComponent(deviceId)}` as const,
|
||||
chatImage: (messageId: string): `/chat/image/${string}` =>
|
||||
`/chat/image/${encodeURIComponent(messageId)}` as const,
|
||||
subscription: (
|
||||
type: "vip" | "topup",
|
||||
options: { payChannel?: PayChannel; returnTo?: "chat" } = {},
|
||||
@@ -64,5 +62,4 @@ export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
|
||||
/** 联合路由类型,包含静态路由与已知动态路由 */
|
||||
export type Route =
|
||||
| StaticRoute
|
||||
| `/chat/deviceid/${string}`
|
||||
| `/chat/image/${string}`;
|
||||
| `/chat/deviceid/${string}`;
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface AppNavigator {
|
||||
back: () => void;
|
||||
openAuth: (redirectTo?: string) => void;
|
||||
openChat: (options?: { replace?: boolean; scroll?: boolean }) => void;
|
||||
openChatImage: (messageId: string) => void;
|
||||
openSubscription: (input: OpenSubscriptionInput) => void;
|
||||
exitSubscription: (returnTo: SubscriptionReturnTo) => void;
|
||||
exitSubscriptionAfterSuccess: (returnTo: SubscriptionReturnTo) => void;
|
||||
@@ -77,10 +76,6 @@ export function useAppNavigator(): AppNavigator {
|
||||
router.push(ROUTES.chat, navOptions);
|
||||
}, [router]);
|
||||
|
||||
const openChatImage = useCallback((messageId: string): void => {
|
||||
router.push(ROUTE_BUILDERS.chatImage(messageId), { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
const getDefaultPayChannel = useCallback(() => {
|
||||
return getDefaultPayChannelForCountryCode(userState.currentUser?.countryCode);
|
||||
}, [userState.currentUser?.countryCode]);
|
||||
@@ -185,7 +180,6 @@ export function useAppNavigator(): AppNavigator {
|
||||
back,
|
||||
openAuth,
|
||||
openChat,
|
||||
openChatImage,
|
||||
openSubscription,
|
||||
exitSubscription,
|
||||
exitSubscriptionAfterSuccess,
|
||||
@@ -201,7 +195,6 @@ export function useAppNavigator(): AppNavigator {
|
||||
isAuthenticatedUser,
|
||||
openAuth,
|
||||
openChat,
|
||||
openChatImage,
|
||||
openSubscription,
|
||||
openSubscriptionForPendingUnlock,
|
||||
push,
|
||||
|
||||
Reference in New Issue
Block a user