feat(mobile): improve image viewer and install entry
This commit is contained in:
@@ -57,7 +57,7 @@ test("favorite entry and three-tab Menu navigation render on the real pages", as
|
|||||||
name: "Primary navigation",
|
name: "Primary navigation",
|
||||||
});
|
});
|
||||||
await expect(navigation).toContainText("Chat");
|
await expect(navigation).toContainText("Chat");
|
||||||
await expect(navigation).toContainText("Elio Private Zone");
|
await expect(navigation).toContainText("Private Zone");
|
||||||
await expect(navigation).toContainText("Menu");
|
await expect(navigation).toContainText("Menu");
|
||||||
await savePreview(page, `${mobile ? "mobile" : "desktop"}-splash.png`);
|
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 page.goto(defaultCharacterChatPath);
|
||||||
await expect(page.getByRole("button", { name: "Saved" })).toBeVisible();
|
const iosDownload = page.getByRole("button", { name: "Download" });
|
||||||
await savePreview(page, "mobile-ios-external-chat-saved.png");
|
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([]);
|
expect(browserErrors.filter(isFeatureRuntimeError)).toEqual([]);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
|
import { apiEnvelope } from "@e2e/fixtures/data/common";
|
||||||
|
import { createPaidImageHistoryResponse } from "@e2e/fixtures/data/chat";
|
||||||
import {
|
import {
|
||||||
paidImageDisplayMessageId,
|
paidImageDisplayMessageId,
|
||||||
paidImageMessageId,
|
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);
|
expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath);
|
||||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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(<FavoriteEntryButton characterSlug="elio" />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getButton(label: string): HTMLButtonElement {
|
||||||
|
const button = container.querySelector<HTMLButtonElement>(
|
||||||
|
`button[aria-label="${label}"]`,
|
||||||
|
);
|
||||||
|
if (!button) throw new Error(`Missing button: ${label}`);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -78,19 +78,98 @@
|
|||||||
outline-color: #f84d96;
|
outline-color: #f84d96;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.guideBackdrop {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: calc(100% + 8px);
|
inset: 0;
|
||||||
right: 0;
|
z-index: 240;
|
||||||
z-index: 45;
|
display: flex;
|
||||||
width: max-content;
|
align-items: center;
|
||||||
max-width: min(72vw, 250px);
|
justify-content: center;
|
||||||
padding: 8px 10px;
|
padding:
|
||||||
border-radius: 10px;
|
calc(18px + var(--app-safe-top, 0px))
|
||||||
background: rgba(22, 18, 25, 0.94);
|
calc(18px + var(--app-safe-right, 0px))
|
||||||
color: #ffffff;
|
calc(18px + var(--app-safe-bottom, 0px))
|
||||||
font-size: 11px;
|
calc(18px + var(--app-safe-left, 0px));
|
||||||
font-weight: 700;
|
background: rgba(15, 11, 18, 0.58);
|
||||||
line-height: 1.3;
|
backdrop-filter: blur(4px);
|
||||||
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.24);
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
import { Download, Star } from "lucide-react";
|
import { Download, Share2, Star, X } from "lucide-react";
|
||||||
|
|
||||||
import { openChatInExternalBrowser } from "@/lib/chat/chat_external_browser";
|
import { openChatInExternalBrowser } from "@/lib/chat/chat_external_browser";
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +25,10 @@ export function FavoriteEntryButton({
|
|||||||
tone = "dark",
|
tone = "dark",
|
||||||
}: FavoriteEntryButtonProps) {
|
}: FavoriteEntryButtonProps) {
|
||||||
const [mode, setMode] = useState<FavoriteEntryMode>("favorite");
|
const [mode, setMode] = useState<FavoriteEntryMode>("favorite");
|
||||||
const [installHint, setInstallHint] = useState(false);
|
const [installGuide, setInstallGuide] = useState<
|
||||||
|
"ios" | "browser" | null
|
||||||
|
>(null);
|
||||||
|
const installGuideTitleId = useId();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateMode = () => {
|
const updateMode = () => {
|
||||||
@@ -46,6 +49,15 @@ export function FavoriteEntryButton({
|
|||||||
return unsubscribe;
|
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<void> => {
|
const handleClick = async (): Promise<void> => {
|
||||||
if (mode === "favorite") {
|
if (mode === "favorite") {
|
||||||
await openChatInExternalBrowser({ characterSlug });
|
await openChatInExternalBrowser({ characterSlug });
|
||||||
@@ -53,14 +65,18 @@ export function FavoriteEntryButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "download") {
|
if (mode === "download") {
|
||||||
|
if (PlatformDetector.isIOS()) {
|
||||||
|
setInstallGuide("ios");
|
||||||
|
return;
|
||||||
|
}
|
||||||
pwaUtil.prepareInstallPrompt();
|
pwaUtil.prepareInstallPrompt();
|
||||||
const result = await pwaUtil.install();
|
const result = await pwaUtil.install();
|
||||||
if (result === "accepted" || pwaUtil.isInstalled()) {
|
if (result === "accepted" || pwaUtil.isInstalled()) {
|
||||||
setMode("saved");
|
setMode("saved");
|
||||||
setInstallHint(false);
|
setInstallGuide(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result === "unavailable") setInstallHint(true);
|
if (result === "unavailable") setInstallGuide("browser");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,10 +107,49 @@ export function FavoriteEntryButton({
|
|||||||
)}
|
)}
|
||||||
{mode === "favorite" ? null : <span>{label}</span>}
|
{mode === "favorite" ? null : <span>{label}</span>}
|
||||||
</button>
|
</button>
|
||||||
{installHint ? (
|
{installGuide ? (
|
||||||
<span className={styles.hint} role="status">
|
<div
|
||||||
Chrome menu → Add to Home screen
|
className={styles.guideBackdrop}
|
||||||
</span>
|
onClick={() => setInstallGuide(null)}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
className={styles.guideDialog}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={installGuideTitleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.guideClose}
|
||||||
|
onClick={() => setInstallGuide(null)}
|
||||||
|
aria-label="Close install instructions"
|
||||||
|
>
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span className={styles.guideIcon} aria-hidden="true">
|
||||||
|
{installGuide === "ios" ? (
|
||||||
|
<Share2 size={28} strokeWidth={2.2} />
|
||||||
|
) : (
|
||||||
|
<Download size={28} strokeWidth={2.2} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<h2 id={installGuideTitleId}>Add CozSweet to Home Screen</h2>
|
||||||
|
<p>
|
||||||
|
{installGuide === "ios"
|
||||||
|
? "In Safari, tap the Share button, then choose “Add to Home Screen”."
|
||||||
|
: "Open your browser menu, then choose “Add to Home screen”."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.guideConfirm}
|
||||||
|
onClick={() => setInstallGuide(null)}
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, type CSSProperties } from "react";
|
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
@@ -70,6 +70,8 @@ export function ChatScreen() {
|
|||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
const authDispatch = useAuthDispatch();
|
||||||
|
const imageOpenedFromChatRef = useRef(false);
|
||||||
|
const imageViewerClosingRef = useRef(false);
|
||||||
useSplashLatestMessageSync({
|
useSplashLatestMessageSync({
|
||||||
characterId: state.characterId,
|
characterId: state.characterId,
|
||||||
historyLoaded: state.historyLoaded,
|
historyLoaded: state.historyLoaded,
|
||||||
@@ -127,6 +129,13 @@ export function ChatScreen() {
|
|||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageMessageId) {
|
||||||
|
imageOpenedFromChatRef.current = false;
|
||||||
|
imageViewerClosingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [imageMessageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!imageMessageId ||
|
!imageMessageId ||
|
||||||
@@ -218,6 +227,7 @@ export function ChatScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenImage(displayMessageId: string): void {
|
function handleOpenImage(displayMessageId: string): void {
|
||||||
|
imageOpenedFromChatRef.current = true;
|
||||||
router.push(
|
router.push(
|
||||||
buildChatImageOverlayUrl(displayMessageId, characterRoutes.chat),
|
buildChatImageOverlayUrl(displayMessageId, characterRoutes.chat),
|
||||||
{ scroll: false },
|
{ scroll: false },
|
||||||
@@ -225,6 +235,13 @@ export function ChatScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCloseImageViewer(): void {
|
function handleCloseImageViewer(): void {
|
||||||
|
if (imageViewerClosingRef.current) return;
|
||||||
|
imageViewerClosingRef.current = true;
|
||||||
|
if (imageOpenedFromChatRef.current) {
|
||||||
|
imageOpenedFromChatRef.current = false;
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.replace(
|
router.replace(
|
||||||
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
||||||
{ scroll: false },
|
{ scroll: false },
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<FullscreenImageViewer
|
||||||
|
characterId="elio-silvestri"
|
||||||
|
remoteMessageId="message-1"
|
||||||
|
imageUrl="/chat-image.png"
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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<HTMLButtonElement>(
|
||||||
|
'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<HTMLDivElement>(
|
||||||
|
'[aria-label="Fullscreen image"]',
|
||||||
|
);
|
||||||
|
expect(viewer).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => viewer?.click());
|
||||||
|
act(() =>
|
||||||
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -123,13 +123,14 @@
|
|||||||
|
|
||||||
.compactButton {
|
.compactButton {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
width: min(100%, 240px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 224px;
|
max-width: 240px;
|
||||||
min-height: 44px;
|
min-height: 50px;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 8px;
|
||||||
padding: 5px 8px;
|
padding: 7px 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.42);
|
border: 1px solid rgba(255, 255, 255, 0.42);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -146,8 +147,8 @@
|
|||||||
|
|
||||||
.compactIcon {
|
.compactIcon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 28px;
|
width: 32px;
|
||||||
height: 28px;
|
height: 32px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -160,7 +161,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -180,7 +181,7 @@
|
|||||||
|
|
||||||
.compactCopy strong {
|
.compactCopy strong {
|
||||||
color: #ec006d;
|
color: #ec006d;
|
||||||
font-size: 15px;
|
font-size: 17px;
|
||||||
font-weight: 950;
|
font-weight: 950;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* - 双指缩放(CSS `touch-action: pinch-zoom`)
|
* - 双指缩放(CSS `touch-action: pinch-zoom`)
|
||||||
*/
|
*/
|
||||||
import { ChevronLeft } from "lucide-react";
|
import { ChevronLeft } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, type MouseEvent } from "react";
|
||||||
|
|
||||||
import { BackButton } from "@/app/_components";
|
import { BackButton } from "@/app/_components";
|
||||||
|
|
||||||
@@ -44,6 +44,10 @@ export function FullscreenImageViewer({
|
|||||||
return () => document.removeEventListener("keydown", handleKey);
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleBackdropClick = (event: MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (event.target === event.currentTarget) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
if (imagePaywalled) {
|
if (imagePaywalled) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -90,7 +94,7 @@ export function FullscreenImageViewer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.viewer}
|
className={styles.viewer}
|
||||||
onClick={onClose}
|
onClick={handleBackdropClick}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Fullscreen image"
|
aria-label="Fullscreen image"
|
||||||
@@ -110,7 +114,7 @@ export function FullscreenImageViewer({
|
|||||||
errorClassName="error"
|
errorClassName="error"
|
||||||
width={800}
|
width={800}
|
||||||
height={800}
|
height={800}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("favorite external entry", () => {
|
|||||||
).toBe("download");
|
).toBe("download");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("turns the external iOS action into Saved", () => {
|
it("turns the external iOS action into Download until standalone mode is detected", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveFavoriteEntryMode({
|
resolveFavoriteEntryMode({
|
||||||
hasFavoriteIntent: true,
|
hasFavoriteIntent: true,
|
||||||
@@ -39,7 +39,7 @@ describe("favorite external entry", () => {
|
|||||||
isInAppBrowser: false,
|
isInAppBrowser: false,
|
||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
}),
|
}),
|
||||||
).toBe("saved");
|
).toBe("download");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats an installed PWA as saved", () => {
|
it("treats an installed PWA as saved", () => {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function resolveFavoriteEntryMode({
|
|||||||
if (isInstalled) return "saved";
|
if (isInstalled) return "saved";
|
||||||
if (!hasFavoriteIntent) return "favorite";
|
if (!hasFavoriteIntent) return "favorite";
|
||||||
if (isAndroid) return "download";
|
if (isAndroid) return "download";
|
||||||
if (isIOS) return "saved";
|
if (isIOS) return "download";
|
||||||
return "saved";
|
return "saved";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user