From a20a3336656cd3537efcadba2d27a833b1a0ea28 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 11:24:23 +0800 Subject: [PATCH] feat(mobile): improve image viewer and install entry (cherry picked from commit 556bfd2919753d832e1a27de86b9a6ecbf09a836) --- .../mock/favorite-menu-navigation.spec.ts | 14 ++- e2e/specs/mock/paid-image.spec.ts | 37 ++++++ .../__tests__/favorite-entry-button.test.tsx | 80 +++++++++++++ .../core/favorite-entry-button.module.css | 109 +++++++++++++++--- .../core/favorite-entry-button.tsx | 73 ++++++++++-- src/app/chat/chat-screen.tsx | 19 ++- .../fullscreen-image-viewer.test.tsx | 80 +++++++++++++ .../first-recharge-offer-banner.module.css | 17 +-- .../components/fullscreen-image-viewer.tsx | 10 +- .../__tests__/favorite_entry.test.ts | 4 +- src/lib/navigation/favorite_entry.ts | 2 +- 11 files changed, 403 insertions(+), 42 deletions(-) create mode 100644 src/app/_components/core/__tests__/favorite-entry-button.test.tsx create mode 100644 src/app/chat/components/__tests__/fullscreen-image-viewer.test.tsx diff --git a/e2e/specs/mock/favorite-menu-navigation.spec.ts b/e2e/specs/mock/favorite-menu-navigation.spec.ts index aad4963a..3270d18d 100644 --- a/e2e/specs/mock/favorite-menu-navigation.spec.ts +++ b/e2e/specs/mock/favorite-menu-navigation.spec.ts @@ -57,7 +57,7 @@ test("favorite entry and three-tab Menu navigation render on the real pages", as name: "Primary navigation", }); await expect(navigation).toContainText("Chat"); - await expect(navigation).toContainText("Elio Private Zone"); + await expect(navigation).toContainText("Private Zone"); await expect(navigation).toContainText("Menu"); await savePreview(page, `${mobile ? "mobile" : "desktop"}-splash.png`); @@ -94,8 +94,16 @@ test("favorite entry and three-tab Menu navigation render on the real pages", as }); }); await page.goto(defaultCharacterChatPath); - await expect(page.getByRole("button", { name: "Saved" })).toBeVisible(); - await savePreview(page, "mobile-ios-external-chat-saved.png"); + const iosDownload = page.getByRole("button", { name: "Download" }); + await expect(iosDownload).toBeVisible(); + await iosDownload.click(); + await expect( + page.getByRole("heading", { name: "Add CozSweet to Home Screen" }), + ).toBeVisible(); + await expect(page.getByRole("dialog")).toContainText( + "In Safari, tap the Share button, then choose “Add to Home Screen”.", + ); + await savePreview(page, "mobile-ios-external-chat-download.png"); } expect(browserErrors.filter(isFeatureRuntimeError)).toEqual([]); diff --git a/e2e/specs/mock/paid-image.spec.ts b/e2e/specs/mock/paid-image.spec.ts index b9f2202c..2a4bdadc 100644 --- a/e2e/specs/mock/paid-image.spec.ts +++ b/e2e/specs/mock/paid-image.spec.ts @@ -1,6 +1,8 @@ import { expect, test } from "@playwright/test"; import { mockCoreApis } from "@e2e/fixtures/api-mocks"; +import { apiEnvelope } from "@e2e/fixtures/data/common"; +import { createPaidImageHistoryResponse } from "@e2e/fixtures/data/chat"; import { paidImageDisplayMessageId, paidImageMessageId, @@ -97,3 +99,38 @@ test("guest can unlock a paid image after email login and subscription payment", expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath); await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible(); }); + +test("fullscreen image closes from its back button, the image, and browser history Back", async ({ + page, +}) => { + await page.route("**/api/chat/history**", async (route) => { + await route.fulfill({ + json: apiEnvelope(createPaidImageHistoryResponse(true)), + }); + }); + await enterChatFromSplash(page); + await dismissChatInterruptions(page); + + const imageButton = page.getByRole("button", { + name: "Open image in fullscreen", + }); + const imageDialog = page.getByRole("dialog", { name: "Fullscreen image" }); + + await imageButton.click(); + await expect(imageDialog).toBeVisible(); + await imageDialog.getByRole("button", { name: "Back" }).click(); + await expect(imageDialog).toHaveCount(0); + await expect(page).toHaveURL(defaultCharacterChatPath); + + await imageButton.click(); + await expect(imageDialog).toBeVisible(); + await imageDialog.locator("img").click(); + await expect(imageDialog).toHaveCount(0); + await expect(page).toHaveURL(defaultCharacterChatPath); + + await imageButton.click(); + await expect(imageDialog).toBeVisible(); + await page.goBack(); + await expect(imageDialog).toHaveCount(0); + await expect(page).toHaveURL(defaultCharacterChatPath); +}); diff --git a/src/app/_components/core/__tests__/favorite-entry-button.test.tsx b/src/app/_components/core/__tests__/favorite-entry-button.test.tsx new file mode 100644 index 00000000..8340acf3 --- /dev/null +++ b/src/app/_components/core/__tests__/favorite-entry-button.test.tsx @@ -0,0 +1,80 @@ +/* @vitest-environment jsdom */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BrowserDetector } from "@/utils/browser-detect"; +import { PlatformDetector } from "@/utils/platform-detect"; +import { pwaUtil } from "@/utils/pwa"; + +import { FavoriteEntryButton } from "../favorite-entry-button"; + +describe("FavoriteEntryButton install flow", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + window.localStorage.setItem("cozsweet:favorite_entry", "1"); + vi.spyOn(BrowserDetector, "isInAppBrowser").mockReturnValue(false); + vi.spyOn(pwaUtil, "isInstalled").mockReturnValue(false); + vi.spyOn(pwaUtil, "prepareInstallPrompt").mockImplementation(() => undefined); + vi.spyOn(pwaUtil, "subscribe").mockReturnValue(() => undefined); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("shows Download on iOS and opens Safari add-to-home instructions", () => { + vi.spyOn(PlatformDetector, "isIOS").mockReturnValue(true); + vi.spyOn(PlatformDetector, "isAndroid").mockReturnValue(false); + + renderButton(); + const downloadButton = getButton("Download"); + act(() => downloadButton.click()); + + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + expect(container.textContent).toContain("Add CozSweet to Home Screen"); + expect(container.textContent).toContain( + "In Safari, tap the Share button, then choose “Add to Home Screen”.", + ); + }); + + it("opens the native Android install prompt and marks an accepted install as Saved", async () => { + vi.spyOn(PlatformDetector, "isIOS").mockReturnValue(false); + vi.spyOn(PlatformDetector, "isAndroid").mockReturnValue(true); + const install = vi.spyOn(pwaUtil, "install").mockResolvedValue("accepted"); + + renderButton(); + await act(async () => { + getButton("Download").click(); + await Promise.resolve(); + }); + + expect(install).toHaveBeenCalledTimes(1); + expect(getButton("Saved")).not.toBeNull(); + }); + + function renderButton(): void { + act(() => { + root.render(); + }); + } + + function getButton(label: string): HTMLButtonElement { + const button = container.querySelector( + `button[aria-label="${label}"]`, + ); + if (!button) throw new Error(`Missing button: ${label}`); + return button; + } +}); diff --git a/src/app/_components/core/favorite-entry-button.module.css b/src/app/_components/core/favorite-entry-button.module.css index 6aeb012f..da489b2b 100644 --- a/src/app/_components/core/favorite-entry-button.module.css +++ b/src/app/_components/core/favorite-entry-button.module.css @@ -78,19 +78,98 @@ outline-color: #f84d96; } -.hint { - position: absolute; - top: calc(100% + 8px); - right: 0; - z-index: 45; - width: max-content; - max-width: min(72vw, 250px); - padding: 8px 10px; - border-radius: 10px; - background: rgba(22, 18, 25, 0.94); - color: #ffffff; - font-size: 11px; - font-weight: 700; - line-height: 1.3; - box-shadow: 0 10px 26px rgba(0, 0, 0, 0.24); +.guideBackdrop { + position: fixed; + inset: 0; + z-index: 240; + display: flex; + align-items: center; + justify-content: center; + padding: + calc(18px + var(--app-safe-top, 0px)) + calc(18px + var(--app-safe-right, 0px)) + calc(18px + var(--app-safe-bottom, 0px)) + calc(18px + var(--app-safe-left, 0px)); + background: rgba(15, 11, 18, 0.58); + backdrop-filter: blur(4px); +} + +.guideDialog { + position: relative; + display: flex; + width: min(100%, 340px); + flex-direction: column; + align-items: center; + padding: 28px 22px 20px; + border: 1px solid rgba(255, 255, 255, 0.72); + border-radius: 28px; + background: rgba(255, 250, 252, 0.98); + box-shadow: 0 24px 70px rgba(33, 17, 26, 0.3); + color: #2d2026; + text-align: center; +} + +.guideClose { + position: absolute; + top: 12px; + right: 12px; + display: inline-flex; + width: 36px; + height: 36px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 999px; + background: rgba(45, 32, 38, 0.07); + color: #5b4a52; + cursor: pointer; +} + +.guideIcon { + display: inline-flex; + width: 58px; + height: 58px; + align-items: center; + justify-content: center; + margin-bottom: 14px; + border-radius: 19px; + background: linear-gradient(135deg, #ff82b2, #f84d96); + box-shadow: 0 12px 26px rgba(248, 77, 150, 0.26); + color: #ffffff; +} + +.guideDialog h2 { + margin: 0; + color: #281a21; + font-size: 21px; + font-weight: 850; + line-height: 1.2; +} + +.guideDialog p { + margin: 12px 0 20px; + color: #685761; + font-size: 15px; + font-weight: 650; + line-height: 1.5; +} + +.guideConfirm { + width: 100%; + min-height: 46px; + border: 0; + border-radius: 999px; + background: linear-gradient(135deg, #ff76ab, #f84d96); + box-shadow: 0 10px 24px rgba(248, 77, 150, 0.24); + color: #ffffff; + cursor: pointer; + font: inherit; + font-size: 16px; + font-weight: 850; +} + +.guideClose:focus-visible, +.guideConfirm:focus-visible { + outline: 2px solid #f84d96; + outline-offset: 3px; } diff --git a/src/app/_components/core/favorite-entry-button.tsx b/src/app/_components/core/favorite-entry-button.tsx index d8585669..787ed135 100644 --- a/src/app/_components/core/favorite-entry-button.tsx +++ b/src/app/_components/core/favorite-entry-button.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; -import { Download, Star } from "lucide-react"; +import { useEffect, useId, useState } from "react"; +import { Download, Share2, Star, X } from "lucide-react"; import { openChatInExternalBrowser } from "@/lib/chat/chat_external_browser"; import { @@ -25,7 +25,10 @@ export function FavoriteEntryButton({ tone = "dark", }: FavoriteEntryButtonProps) { const [mode, setMode] = useState("favorite"); - const [installHint, setInstallHint] = useState(false); + const [installGuide, setInstallGuide] = useState< + "ios" | "browser" | null + >(null); + const installGuideTitleId = useId(); useEffect(() => { const updateMode = () => { @@ -46,6 +49,15 @@ export function FavoriteEntryButton({ return unsubscribe; }, []); + useEffect(() => { + if (!installGuide) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setInstallGuide(null); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [installGuide]); + const handleClick = async (): Promise => { if (mode === "favorite") { await openChatInExternalBrowser({ characterSlug }); @@ -53,14 +65,18 @@ export function FavoriteEntryButton({ } if (mode === "download") { + if (PlatformDetector.isIOS()) { + setInstallGuide("ios"); + return; + } pwaUtil.prepareInstallPrompt(); const result = await pwaUtil.install(); if (result === "accepted" || pwaUtil.isInstalled()) { setMode("saved"); - setInstallHint(false); + setInstallGuide(null); return; } - if (result === "unavailable") setInstallHint(true); + if (result === "unavailable") setInstallGuide("browser"); } }; @@ -91,10 +107,49 @@ export function FavoriteEntryButton({ )} {mode === "favorite" ? null : {label}} - {installHint ? ( - - Chrome menu → Add to Home screen - + {installGuide ? ( +
setInstallGuide(null)} + role="presentation" + > +
event.stopPropagation()} + > + + +

Add CozSweet to Home Screen

+

+ {installGuide === "ios" + ? "In Safari, tap the Share button, then choose “Add to Home Screen”." + : "Open your browser menu, then choose “Add to Home screen”."} +

+ +
+
) : null} ); diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 8654e3c3..975038fd 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, type CSSProperties } from "react"; +import { useEffect, useMemo, useRef, type CSSProperties } from "react"; import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; @@ -70,6 +70,8 @@ export function ChatScreen() { const chatDispatch = useChatDispatch(); const authState = useAuthState(); const authDispatch = useAuthDispatch(); + const imageOpenedFromChatRef = useRef(false); + const imageViewerClosingRef = useRef(false); useSplashLatestMessageSync({ characterId: state.characterId, historyLoaded: state.historyLoaded, @@ -127,6 +129,13 @@ export function ChatScreen() { loginStatus: authState.loginStatus, }); + useEffect(() => { + if (!imageMessageId) { + imageOpenedFromChatRef.current = false; + imageViewerClosingRef.current = false; + } + }, [imageMessageId]); + useEffect(() => { if ( !imageMessageId || @@ -218,6 +227,7 @@ export function ChatScreen() { } function handleOpenImage(displayMessageId: string): void { + imageOpenedFromChatRef.current = true; router.push( buildChatImageOverlayUrl(displayMessageId, characterRoutes.chat), { scroll: false }, @@ -225,6 +235,13 @@ export function ChatScreen() { } function handleCloseImageViewer(): void { + if (imageViewerClosingRef.current) return; + imageViewerClosingRef.current = true; + if (imageOpenedFromChatRef.current) { + imageOpenedFromChatRef.current = false; + router.back(); + return; + } router.replace( buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat), { scroll: false }, diff --git a/src/app/chat/components/__tests__/fullscreen-image-viewer.test.tsx b/src/app/chat/components/__tests__/fullscreen-image-viewer.test.tsx new file mode 100644 index 00000000..52db81c0 --- /dev/null +++ b/src/app/chat/components/__tests__/fullscreen-image-viewer.test.tsx @@ -0,0 +1,80 @@ +/* @vitest-environment jsdom */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from "vitest"; + +import { FullscreenImageViewer } from "../fullscreen-image-viewer"; + +describe("FullscreenImageViewer", () => { + let container: HTMLDivElement; + let root: Root; + let onClose: Mock<() => void>; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + onClose = vi.fn<() => void>(); + + act(() => { + root.render( + , + ); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("closes exactly once when the fullscreen image is tapped", () => { + const image = container.querySelector("img"); + expect(image).not.toBeNull(); + + act(() => image?.click()); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("closes exactly once from the visible back button", () => { + const backButton = container.querySelector( + 'button[aria-label="Back"]', + ); + expect(backButton).not.toBeNull(); + + act(() => backButton?.click()); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("closes from the backdrop and Escape key", () => { + const viewer = container.querySelector( + '[aria-label="Fullscreen image"]', + ); + expect(viewer).not.toBeNull(); + + act(() => viewer?.click()); + act(() => + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })), + ); + + expect(onClose).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/app/chat/components/first-recharge-offer-banner.module.css b/src/app/chat/components/first-recharge-offer-banner.module.css index 20fc2f46..16c71b6c 100644 --- a/src/app/chat/components/first-recharge-offer-banner.module.css +++ b/src/app/chat/components/first-recharge-offer-banner.module.css @@ -123,13 +123,14 @@ .compactButton { display: grid; + width: min(100%, 240px); min-width: 0; - max-width: 224px; - min-height: 44px; + max-width: 240px; + min-height: 50px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; - gap: 7px; - padding: 5px 8px; + gap: 8px; + padding: 7px 10px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.42); border-radius: 16px; @@ -146,8 +147,8 @@ .compactIcon { display: inline-flex; - width: 28px; - height: 28px; + width: 32px; + height: 32px; align-items: center; justify-content: center; border-radius: 10px; @@ -160,7 +161,7 @@ min-width: 0; flex-direction: column; gap: 2px; - font-size: 10px; + font-size: 11px; font-weight: 800; line-height: 1.05; text-transform: uppercase; @@ -180,7 +181,7 @@ .compactCopy strong { color: #ec006d; - font-size: 15px; + font-size: 17px; font-weight: 950; letter-spacing: -0.02em; } diff --git a/src/app/chat/components/fullscreen-image-viewer.tsx b/src/app/chat/components/fullscreen-image-viewer.tsx index bc1f236f..43143e27 100644 --- a/src/app/chat/components/fullscreen-image-viewer.tsx +++ b/src/app/chat/components/fullscreen-image-viewer.tsx @@ -10,7 +10,7 @@ * - 双指缩放(CSS `touch-action: pinch-zoom`) */ import { ChevronLeft } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, type MouseEvent } from "react"; import { BackButton } from "@/app/_components"; @@ -44,6 +44,10 @@ export function FullscreenImageViewer({ return () => document.removeEventListener("keydown", handleKey); }, [onClose]); + const handleBackdropClick = (event: MouseEvent) => { + if (event.target === event.currentTarget) onClose(); + }; + if (imagePaywalled) { return (
event.stopPropagation()} + onClick={onClose} />
); diff --git a/src/lib/navigation/__tests__/favorite_entry.test.ts b/src/lib/navigation/__tests__/favorite_entry.test.ts index 726c39a8..d33c7d52 100644 --- a/src/lib/navigation/__tests__/favorite_entry.test.ts +++ b/src/lib/navigation/__tests__/favorite_entry.test.ts @@ -30,7 +30,7 @@ describe("favorite external entry", () => { ).toBe("download"); }); - it("turns the external iOS action into Saved", () => { + it("turns the external iOS action into Download until standalone mode is detected", () => { expect( resolveFavoriteEntryMode({ hasFavoriteIntent: true, @@ -39,7 +39,7 @@ describe("favorite external entry", () => { isInAppBrowser: false, isInstalled: false, }), - ).toBe("saved"); + ).toBe("download"); }); it("treats an installed PWA as saved", () => { diff --git a/src/lib/navigation/favorite_entry.ts b/src/lib/navigation/favorite_entry.ts index 49b298f2..b3956289 100644 --- a/src/lib/navigation/favorite_entry.ts +++ b/src/lib/navigation/favorite_entry.ts @@ -26,7 +26,7 @@ export function resolveFavoriteEntryMode({ if (isInstalled) return "saved"; if (!hasFavoriteIntent) return "favorite"; if (isAndroid) return "download"; - if (isIOS) return "saved"; + if (isIOS) return "download"; return "saved"; }