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";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { useChatState } from "@/stores/chat/chat-context";
|
import { useChatState } from "@/stores/chat/chat-context";
|
||||||
@@ -17,8 +19,14 @@ import {
|
|||||||
ChatUnlockDialogs,
|
ChatUnlockDialogs,
|
||||||
ExternalBrowserDialog,
|
ExternalBrowserDialog,
|
||||||
FirstRechargeOfferBanner,
|
FirstRechargeOfferBanner,
|
||||||
|
FullscreenImageViewer,
|
||||||
PwaInstallOverlay,
|
PwaInstallOverlay,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
|
import {
|
||||||
|
buildChatImageOverlayUrl,
|
||||||
|
buildChatWithoutImageOverlayUrl,
|
||||||
|
getChatImageOverlayMessageId,
|
||||||
|
} from "./chat-image-overlay-url";
|
||||||
import {
|
import {
|
||||||
deriveIsGuest,
|
deriveIsGuest,
|
||||||
isChatDevelopmentEnvironment,
|
isChatDevelopmentEnvironment,
|
||||||
@@ -31,15 +39,44 @@ import { useExternalBrowserPrompt } from "./hooks/use-external-browser-prompt";
|
|||||||
import styles from "./components/chat-screen.module.css";
|
import styles from "./components/chat-screen.module.css";
|
||||||
|
|
||||||
export function ChatScreen() {
|
export function ChatScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const state = useChatState();
|
const state = useChatState();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
const authDispatch = useAuthDispatch();
|
||||||
|
const imageMessageId = getChatImageOverlayMessageId(searchParams);
|
||||||
|
const imageReturnUrl = imageMessageId
|
||||||
|
? buildChatImageOverlayUrl(imageMessageId)
|
||||||
|
: ROUTES.chat;
|
||||||
const {
|
const {
|
||||||
unlockPaywallRequest,
|
unlockPaywallRequest,
|
||||||
requestMessageUnlock,
|
requestMessageUnlock,
|
||||||
closeInsufficientCreditsDialog,
|
closeInsufficientCreditsDialog,
|
||||||
confirmInsufficientCreditsDialog,
|
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 字段)
|
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
||||||
const isGuest = deriveIsGuest(authState.loginStatus);
|
const isGuest = deriveIsGuest(authState.loginStatus);
|
||||||
@@ -68,6 +105,21 @@ export function ChatScreen() {
|
|||||||
loginStatus: authState.loginStatus,
|
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 {
|
function handleUnlockPrivateMessage(messageId: string): void {
|
||||||
requestMessageUnlock(messageId, "private");
|
requestMessageUnlock(messageId, "private");
|
||||||
}
|
}
|
||||||
@@ -76,6 +128,21 @@ export function ChatScreen() {
|
|||||||
requestMessageUnlock(messageId, "voice");
|
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 (
|
return (
|
||||||
<MobileShell>
|
<MobileShell>
|
||||||
<div className={styles.shell}>
|
<div className={styles.shell}>
|
||||||
@@ -110,6 +177,7 @@ export function ChatScreen() {
|
|||||||
unlockingMessageId={state.unlockingMessageId}
|
unlockingMessageId={state.unlockingMessageId}
|
||||||
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
||||||
onUnlockVoiceMessage={handleUnlockVoiceMessage}
|
onUnlockVoiceMessage={handleUnlockVoiceMessage}
|
||||||
|
onOpenImage={handleOpenImage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{messageLimitBanner.visible ? (
|
{messageLimitBanner.visible ? (
|
||||||
@@ -141,6 +209,27 @@ export function ChatScreen() {
|
|||||||
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
|
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
|
||||||
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
|
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>
|
</div>
|
||||||
</MobileShell>
|
</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 type { UiMessage } from "@/data/dto/chat";
|
||||||
|
|
||||||
import {
|
|
||||||
consumeChatScrollSnapshot,
|
|
||||||
getChatScrollRestoreTop,
|
|
||||||
listenChatScrollSnapshotSave,
|
|
||||||
saveChatScrollSnapshot,
|
|
||||||
} from "../chat-scroll-session";
|
|
||||||
import {
|
import {
|
||||||
buildChatRenderItems,
|
buildChatRenderItems,
|
||||||
createChatMessageKeyResolver,
|
createChatMessageKeyResolver,
|
||||||
@@ -35,8 +29,6 @@ import styles from "./chat-area.module.css";
|
|||||||
|
|
||||||
const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96;
|
const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96;
|
||||||
|
|
||||||
type RestoreState = "idle" | "checking" | "restoring" | "settlingBottom" | "done";
|
|
||||||
|
|
||||||
export interface ChatAreaProps {
|
export interface ChatAreaProps {
|
||||||
messages: readonly UiMessage[];
|
messages: readonly UiMessage[];
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
@@ -44,6 +36,7 @@ export interface ChatAreaProps {
|
|||||||
unlockingMessageId?: string | null;
|
unlockingMessageId?: string | null;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||||
|
onOpenImage?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatArea({
|
export function ChatArea({
|
||||||
@@ -53,30 +46,31 @@ export function ChatArea({
|
|||||||
unlockingMessageId,
|
unlockingMessageId,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
|
onOpenImage,
|
||||||
}: ChatAreaProps) {
|
}: ChatAreaProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const prevLengthRef = useRef(messages.length);
|
const prevLengthRef = useRef(messages.length);
|
||||||
const restoredScrollRef = useRef(false);
|
const initialScrollSettledRef = useRef(false);
|
||||||
const restoreStateRef = useRef<RestoreState>("idle");
|
|
||||||
const wasNearBottomRef = useRef(true);
|
const wasNearBottomRef = useRef(true);
|
||||||
const getMessageKey = useMemo(() => createChatMessageKeyResolver(), []);
|
const getMessageKey = useMemo(() => createChatMessageKeyResolver(), []);
|
||||||
|
|
||||||
const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => {
|
useLayoutEffect(() => {
|
||||||
|
if (initialScrollSettledRef.current || messages.length === 0) return;
|
||||||
const scrollNode = scrollRef.current;
|
const scrollNode = scrollRef.current;
|
||||||
if (!scrollNode) return;
|
if (!scrollNode) return;
|
||||||
saveChatScrollSnapshot(scrollNode, { anchorMessageId });
|
|
||||||
};
|
initialScrollSettledRef.current = true;
|
||||||
|
prevLengthRef.current = messages.length;
|
||||||
|
scrollNode.scrollTop = scrollNode.scrollHeight;
|
||||||
|
wasNearBottomRef.current = true;
|
||||||
|
}, [messages.length]);
|
||||||
|
|
||||||
// 新消息 → 滚到底部
|
// 新消息 → 滚到底部
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length !== prevLengthRef.current) {
|
if (messages.length !== prevLengthRef.current) {
|
||||||
const scrollNode = scrollRef.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({
|
scrollNode.scrollTo({
|
||||||
top: scrollNode.scrollHeight,
|
top: scrollNode.scrollHeight,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
@@ -86,72 +80,6 @@ export function ChatArea({
|
|||||||
}
|
}
|
||||||
}, [messages.length]);
|
}, [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 (
|
return (
|
||||||
<main
|
<main
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
@@ -170,6 +98,7 @@ export function ChatArea({
|
|||||||
unlockingMessageId,
|
unlockingMessageId,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
|
onOpenImage,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isReplyingAI && <LottieMessageBubble />}
|
{isReplyingAI && <LottieMessageBubble />}
|
||||||
@@ -183,47 +112,6 @@ function isNearBottom(scrollNode: HTMLElement): boolean {
|
|||||||
return distanceFromBottom <= CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD;
|
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(
|
function renderMessagesWithDateHeaders(
|
||||||
messages: readonly UiMessage[],
|
messages: readonly UiMessage[],
|
||||||
@@ -232,6 +120,7 @@ function renderMessagesWithDateHeaders(
|
|||||||
unlockingMessageId?: string | null,
|
unlockingMessageId?: string | null,
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void,
|
onUnlockPrivateMessage?: (messageId: string) => void,
|
||||||
onUnlockVoiceMessage?: (messageId: string) => void,
|
onUnlockVoiceMessage?: (messageId: string) => void,
|
||||||
|
onOpenImage?: (messageId: string) => void,
|
||||||
) {
|
) {
|
||||||
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
||||||
item.type === "date" ? (
|
item.type === "date" ? (
|
||||||
@@ -255,6 +144,7 @@ function renderMessagesWithDateHeaders(
|
|||||||
}
|
}
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
|
onOpenImage={onOpenImage}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export function FullscreenImageViewer({
|
|||||||
<div
|
<div
|
||||||
className={`${styles.viewer} ${styles.paywallViewer}`}
|
className={`${styles.viewer} ${styles.paywallViewer}`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
aria-label="Locked fullscreen image"
|
aria-label="Locked fullscreen image"
|
||||||
>
|
>
|
||||||
{shouldUseNativeImage ? (
|
{shouldUseNativeImage ? (
|
||||||
@@ -119,8 +120,17 @@ export function FullscreenImageViewer({
|
|||||||
className={styles.viewer}
|
className={styles.viewer}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
aria-label="Fullscreen image"
|
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 ? (
|
{hasImageError ? (
|
||||||
<div className="error">🖼</div>
|
<div className="error">🖼</div>
|
||||||
) : shouldUseNativeImage ? (
|
) : shouldUseNativeImage ? (
|
||||||
@@ -129,6 +139,7 @@ export function FullscreenImageViewer({
|
|||||||
alt=""
|
alt=""
|
||||||
className={styles.viewerImage}
|
className={styles.viewerImage}
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
@@ -138,6 +149,7 @@ export function FullscreenImageViewer({
|
|||||||
height={800}
|
height={800}
|
||||||
className={styles.viewerImage}
|
className={styles.viewerImage}
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,34 +5,33 @@
|
|||||||
*
|
*
|
||||||
* 支持:
|
* 支持:
|
||||||
* - base64 data URI 解码(`data:image/png;base64,...`)
|
* - base64 data URI 解码(`data:image/png;base64,...`)
|
||||||
* - 点击 → 跳转动态路由打开全屏查看器
|
* - 点击 → 通知聊天页打开页内全屏查看器
|
||||||
* - 错误占位符(broken image icon)
|
* - 错误占位符(broken image icon)
|
||||||
*/
|
*/
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
|
||||||
import {
|
import {
|
||||||
isBrowserLocalChatImageSource,
|
isBrowserLocalChatImageSource,
|
||||||
normalizeChatImageSource,
|
normalizeChatImageSource,
|
||||||
} from "@/lib/chat/chat_media_url";
|
} from "@/lib/chat/chat_media_url";
|
||||||
import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_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";
|
import styles from "./image-bubble.module.css";
|
||||||
|
|
||||||
export interface ImageBubbleProps {
|
export interface ImageBubbleProps {
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
imageUrl: string; // base64 data URI 或 URL
|
imageUrl: string; // base64 data URI 或 URL
|
||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
|
onOpenImage?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageBubble({
|
export function ImageBubble({
|
||||||
messageId,
|
messageId,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled = false,
|
imagePaywalled = false,
|
||||||
|
onOpenImage,
|
||||||
}: ImageBubbleProps) {
|
}: ImageBubbleProps) {
|
||||||
const navigator = useAppNavigator();
|
|
||||||
const [errorSrc, setErrorSrc] = useState<string | null>(null);
|
const [errorSrc, setErrorSrc] = useState<string | null>(null);
|
||||||
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
||||||
useCachedChatMediaUrl({
|
useCachedChatMediaUrl({
|
||||||
@@ -42,7 +41,7 @@ export function ImageBubble({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const src = normalizeChatImageSource(mediaUrl || imageUrl);
|
const src = normalizeChatImageSource(mediaUrl || imageUrl);
|
||||||
const canOpen = Boolean(messageId);
|
const canOpen = Boolean(messageId && onOpenImage);
|
||||||
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
|
const shouldUseNativeImage = isBrowserLocalChatImageSource(src);
|
||||||
const error = errorSrc === src;
|
const error = errorSrc === src;
|
||||||
|
|
||||||
@@ -55,9 +54,8 @@ export function ImageBubble({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openImage = () => {
|
const openImage = () => {
|
||||||
if (!messageId) return;
|
if (!messageId || !onOpenImage) return;
|
||||||
requestChatScrollSnapshotSave(messageId);
|
onOpenImage(messageId);
|
||||||
navigator.openChatImage(messageId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface MessageBubbleProps {
|
|||||||
isUnlockingMessage?: boolean;
|
isUnlockingMessage?: boolean;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||||
|
onOpenImage?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
@@ -44,6 +45,7 @@ export function MessageBubble({
|
|||||||
isUnlockingMessage,
|
isUnlockingMessage,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
|
onOpenImage,
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const { avatarUrl } = useUserState();
|
const { avatarUrl } = useUserState();
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ export function MessageBubble({
|
|||||||
isUnlockingMessage={isUnlockingMessage}
|
isUnlockingMessage={isUnlockingMessage}
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
|
onOpenImage={onOpenImage}
|
||||||
/>
|
/>
|
||||||
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
@@ -97,6 +100,7 @@ export function MessageBubble({
|
|||||||
isUnlockingMessage={isUnlockingMessage}
|
isUnlockingMessage={isUnlockingMessage}
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
|
onOpenImage={onOpenImage}
|
||||||
/>
|
/>
|
||||||
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
||||||
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
|
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface MessageContentProps {
|
|||||||
isUnlockingMessage?: boolean;
|
isUnlockingMessage?: boolean;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||||
|
onOpenImage?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_PLACEHOLDER = "[图片]";
|
const IMAGE_PLACEHOLDER = "[图片]";
|
||||||
@@ -37,6 +38,7 @@ export function MessageContent({
|
|||||||
isUnlockingMessage,
|
isUnlockingMessage,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
|
onOpenImage,
|
||||||
}: MessageContentProps) {
|
}: MessageContentProps) {
|
||||||
const hasImage = imageUrl != null && imageUrl.length > 0;
|
const hasImage = imageUrl != null && imageUrl.length > 0;
|
||||||
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
||||||
@@ -74,6 +76,7 @@ export function MessageContent({
|
|||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
imagePaywalled={imagePaywalled}
|
imagePaywalled={imagePaywalled}
|
||||||
|
onOpenImage={onOpenImage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasAudio && audioUrl ? (
|
{hasAudio && audioUrl ? (
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface UseChatUnlockNavigationFlowInput {
|
|||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
expectedKind?: PendingChatUnlockKind;
|
expectedKind?: PendingChatUnlockKind;
|
||||||
expectedMessageId?: string;
|
expectedMessageId?: string;
|
||||||
|
ignoredKind?: PendingChatUnlockKind;
|
||||||
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseChatUnlockNavigationFlowOutput {
|
export interface UseChatUnlockNavigationFlowOutput {
|
||||||
@@ -35,6 +37,8 @@ export function useChatUnlockNavigationFlow({
|
|||||||
returnUrl,
|
returnUrl,
|
||||||
expectedKind,
|
expectedKind,
|
||||||
expectedMessageId,
|
expectedMessageId,
|
||||||
|
ignoredKind,
|
||||||
|
enabled = true,
|
||||||
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
|
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
const userState = useUserState();
|
const userState = useUserState();
|
||||||
@@ -44,9 +48,12 @@ export function useChatUnlockNavigationFlow({
|
|||||||
request: chatState.unlockPaywallRequest,
|
request: chatState.unlockPaywallRequest,
|
||||||
expectedKind,
|
expectedKind,
|
||||||
expectedMessageId,
|
expectedMessageId,
|
||||||
|
ignoredKind,
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return;
|
if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -83,8 +90,10 @@ export function useChatUnlockNavigationFlow({
|
|||||||
}, [
|
}, [
|
||||||
chatDispatch,
|
chatDispatch,
|
||||||
chatState.historyLoaded,
|
chatState.historyLoaded,
|
||||||
|
enabled,
|
||||||
expectedKind,
|
expectedKind,
|
||||||
expectedMessageId,
|
expectedMessageId,
|
||||||
|
ignoredKind,
|
||||||
navigator.isAuthenticatedUser,
|
navigator.isAuthenticatedUser,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
]);
|
]);
|
||||||
@@ -135,9 +144,19 @@ function getScopedUnlockPaywallRequest(input: {
|
|||||||
request: ChatUnlockPaywallRequest | null;
|
request: ChatUnlockPaywallRequest | null;
|
||||||
expectedKind?: PendingChatUnlockKind;
|
expectedKind?: PendingChatUnlockKind;
|
||||||
expectedMessageId?: string;
|
expectedMessageId?: string;
|
||||||
|
ignoredKind?: PendingChatUnlockKind;
|
||||||
|
enabled?: boolean;
|
||||||
}): ChatUnlockPaywallRequest | null {
|
}): ChatUnlockPaywallRequest | null {
|
||||||
const { request, expectedKind, expectedMessageId } = input;
|
const {
|
||||||
|
request,
|
||||||
|
expectedKind,
|
||||||
|
expectedMessageId,
|
||||||
|
ignoredKind,
|
||||||
|
enabled = true,
|
||||||
|
} = input;
|
||||||
|
if (!enabled) return null;
|
||||||
if (!request) return null;
|
if (!request) return null;
|
||||||
|
if (ignoredKind && request.kind === ignoredKind) return null;
|
||||||
if (expectedKind && request.kind !== expectedKind) return null;
|
if (expectedKind && request.kind !== expectedKind) return null;
|
||||||
if (expectedMessageId && request.messageId !== expectedMessageId) return null;
|
if (expectedMessageId && request.messageId !== expectedMessageId) return null;
|
||||||
return request;
|
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";
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { MobileShell } from "@/app/_components/core";
|
||||||
import { ChatScreen } from "@/app/chat/chat-screen";
|
import { ChatScreen } from "@/app/chat/chat-screen";
|
||||||
|
|
||||||
export default function ChatPage() {
|
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({
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
returnUrl: "/chat/image/msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
stage: "payment",
|
stage: "payment",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
|
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
returnUrl: "/chat/image/msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
stage: "payment",
|
stage: "payment",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,14 +55,14 @@ describe("NavigationStorage", () => {
|
|||||||
it("saves and consumes pending chat image return sessions", async () => {
|
it("saves and consumes pending chat image return sessions", async () => {
|
||||||
await NavigationStorage.savePendingChatImageReturn({
|
await NavigationStorage.savePendingChatImageReturn({
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
returnUrl: "/chat/image/msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
NavigationStorage.consumePendingChatImageReturn(),
|
NavigationStorage.consumePendingChatImageReturn(),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
returnUrl: "/chat/image/msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
NavigationStorage.consumePendingChatImageReturn(),
|
NavigationStorage.consumePendingChatImageReturn(),
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export const StorageKeys = {
|
|||||||
|
|
||||||
// chat
|
// chat
|
||||||
chatHistory: "chat_history",
|
chatHistory: "chat_history",
|
||||||
chatScrollSession: "chat_scroll_session",
|
|
||||||
pendingChatImageReturn: "pending_chat_image_return",
|
pendingChatImageReturn: "pending_chat_image_return",
|
||||||
pendingChatUnlock: "pending_chat_unlock",
|
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({
|
consumePendingChatImageReturnMock.mockResolvedValue({
|
||||||
reason: "image_paywall",
|
reason: "image_paywall",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
returnUrl: "/chat/image/msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(consumeSubscriptionExplicitExitUrl()).resolves.toBe(
|
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",
|
reason: "single_message_unlock",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
returnUrl: "/chat/image/msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
stage: "payment",
|
stage: "payment",
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(peekSubscriptionExplicitExitUrl()).resolves.toBe(
|
await expect(peekSubscriptionExplicitExitUrl()).resolves.toBe(
|
||||||
"/chat/image/msg_1",
|
"/chat?image=msg_1",
|
||||||
);
|
);
|
||||||
expect(clearPendingChatUnlockMock).not.toHaveBeenCalled();
|
expect(clearPendingChatUnlockMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -87,13 +87,13 @@ describe("subscription exit helpers", () => {
|
|||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
returnUrl: "/chat/image/msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
stage: "payment",
|
stage: "payment",
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(consumeSubscriptionExplicitExitUrl()).resolves.toBe(
|
await expect(consumeSubscriptionExplicitExitUrl()).resolves.toBe(
|
||||||
"/chat/image/msg_1",
|
"/chat?image=msg_1",
|
||||||
);
|
);
|
||||||
expect(clearPendingChatUnlockMock).toHaveBeenCalledTimes(1);
|
expect(clearPendingChatUnlockMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ describe("route meta", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("classifies known dynamic chat routes", () => {
|
it("classifies known dynamic chat routes", () => {
|
||||||
expect(getRouteAccess("/chat/image/msg_1")).toBe("session");
|
|
||||||
expect(getRouteAccess("/chat/deviceid/device_1")).toBe("public");
|
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", () => {
|
it("includes private room in static routes", () => {
|
||||||
expect(ALL_STATIC_ROUTES).toContain(ROUTES.privateRoom);
|
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 {
|
export function getRouteAccess(pathname: string): RouteAccess {
|
||||||
if (pathname.startsWith("/chat/image/")) return "session";
|
|
||||||
if (pathname.startsWith("/chat/deviceid/")) return "public";
|
if (pathname.startsWith("/chat/deviceid/")) return "public";
|
||||||
return STATIC_ROUTE_ACCESS[pathname] ?? "public";
|
return STATIC_ROUTE_ACCESS[pathname] ?? "public";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
|
|||||||
export const ROUTE_BUILDERS = {
|
export const ROUTE_BUILDERS = {
|
||||||
chatDeviceId: (deviceId: string): `/chat/deviceid/${string}` =>
|
chatDeviceId: (deviceId: string): `/chat/deviceid/${string}` =>
|
||||||
`/chat/deviceid/${encodeURIComponent(deviceId)}` as const,
|
`/chat/deviceid/${encodeURIComponent(deviceId)}` as const,
|
||||||
chatImage: (messageId: string): `/chat/image/${string}` =>
|
|
||||||
`/chat/image/${encodeURIComponent(messageId)}` as const,
|
|
||||||
subscription: (
|
subscription: (
|
||||||
type: "vip" | "topup",
|
type: "vip" | "topup",
|
||||||
options: { payChannel?: PayChannel; returnTo?: "chat" } = {},
|
options: { payChannel?: PayChannel; returnTo?: "chat" } = {},
|
||||||
@@ -64,5 +62,4 @@ export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
|
|||||||
/** 联合路由类型,包含静态路由与已知动态路由 */
|
/** 联合路由类型,包含静态路由与已知动态路由 */
|
||||||
export type Route =
|
export type Route =
|
||||||
| StaticRoute
|
| StaticRoute
|
||||||
| `/chat/deviceid/${string}`
|
| `/chat/deviceid/${string}`;
|
||||||
| `/chat/image/${string}`;
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export interface AppNavigator {
|
|||||||
back: () => void;
|
back: () => void;
|
||||||
openAuth: (redirectTo?: string) => void;
|
openAuth: (redirectTo?: string) => void;
|
||||||
openChat: (options?: { replace?: boolean; scroll?: boolean }) => void;
|
openChat: (options?: { replace?: boolean; scroll?: boolean }) => void;
|
||||||
openChatImage: (messageId: string) => void;
|
|
||||||
openSubscription: (input: OpenSubscriptionInput) => void;
|
openSubscription: (input: OpenSubscriptionInput) => void;
|
||||||
exitSubscription: (returnTo: SubscriptionReturnTo) => void;
|
exitSubscription: (returnTo: SubscriptionReturnTo) => void;
|
||||||
exitSubscriptionAfterSuccess: (returnTo: SubscriptionReturnTo) => void;
|
exitSubscriptionAfterSuccess: (returnTo: SubscriptionReturnTo) => void;
|
||||||
@@ -77,10 +76,6 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
router.push(ROUTES.chat, navOptions);
|
router.push(ROUTES.chat, navOptions);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const openChatImage = useCallback((messageId: string): void => {
|
|
||||||
router.push(ROUTE_BUILDERS.chatImage(messageId), { scroll: false });
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const getDefaultPayChannel = useCallback(() => {
|
const getDefaultPayChannel = useCallback(() => {
|
||||||
return getDefaultPayChannelForCountryCode(userState.currentUser?.countryCode);
|
return getDefaultPayChannelForCountryCode(userState.currentUser?.countryCode);
|
||||||
}, [userState.currentUser?.countryCode]);
|
}, [userState.currentUser?.countryCode]);
|
||||||
@@ -185,7 +180,6 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
back,
|
back,
|
||||||
openAuth,
|
openAuth,
|
||||||
openChat,
|
openChat,
|
||||||
openChatImage,
|
|
||||||
openSubscription,
|
openSubscription,
|
||||||
exitSubscription,
|
exitSubscription,
|
||||||
exitSubscriptionAfterSuccess,
|
exitSubscriptionAfterSuccess,
|
||||||
@@ -201,7 +195,6 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
isAuthenticatedUser,
|
isAuthenticatedUser,
|
||||||
openAuth,
|
openAuth,
|
||||||
openChat,
|
openChat,
|
||||||
openChatImage,
|
|
||||||
openSubscription,
|
openSubscription,
|
||||||
openSubscriptionForPendingUnlock,
|
openSubscriptionForPendingUnlock,
|
||||||
push,
|
push,
|
||||||
|
|||||||
Reference in New Issue
Block a user