feat(mobile): improve image viewer and install entry
Docker Image / Build and Push Docker Image (push) Successful in 2m5s

(cherry picked from commit 556bfd2919)
This commit is contained in:
Codex
2026-07-24 11:24:23 +08:00
parent 92768047e9
commit a20a333665
11 changed files with 403 additions and 42 deletions
@@ -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;
}
.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;
}
@@ -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<FavoriteEntryMode>("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<void> => {
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 : <span>{label}</span>}
</button>
{installHint ? (
<span className={styles.hint} role="status">
Chrome menu Add to Home screen
</span>
{installGuide ? (
<div
className={styles.guideBackdrop}
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}
</div>
);