diff --git a/docs/backend/FRONTEND_PRIVATE_ROOM_API.md b/docs/backend/FRONTEND_PRIVATE_ROOM_API.md index 374a6e2a..2c709ad2 100644 --- a/docs/backend/FRONTEND_PRIVATE_ROOM_API.md +++ b/docs/backend/FRONTEND_PRIVATE_ROOM_API.md @@ -208,13 +208,19 @@ Paywall 导航发起后立即消费当前请求,避免 React 重渲染重复 任一条件不满足时,页面通过 replace 删除 `album` 和 `image`,并保留其他查询参数。 -页面内点击打开 Gallery 时,关闭操作优先使用浏览器 back;直接刷新或外部分享 Gallery URL 时,关闭操作使用 replace 返回当前角色 Private Room。键盘 Escape 关闭,左右方向键和横向滑动切换图片。 +页面内点击九宫格缩略图时,Gallery 从该图片的后端数组原始索引打开。关闭操作优先使用浏览器 back;直接刷新或外部分享 Gallery URL 时,关闭操作使用 replace 返回当前角色 Private Room。 + +Gallery 只浏览 `locked=false` 且 URL 非空的图片,但 URL 中的 `image` 仍使用原始数组索引。横向拖动时图片跟随指针,达到视口宽度 18%(最低 56px),或达到 0.45px/ms 且至少移动 24px 时切换;首尾越界拖动使用 0.28 阻尼且不循环。松手后使用 240ms 横向吸附动画,键盘方向键和左右按钮复用相同切换逻辑。Escape 关闭;`prefers-reduced-motion` 下取消吸附过渡。 ## 7. UI 与数据边界 - React key 使用稳定 `albumId`; - 卡片图片数量优先使用 `imageCount`,为 0 时回退到 `images.length`; -- Gallery 只渲染当前索引存在 URL 的图片; +- 锁定相册继续只显示模糊封面和解锁入口; +- 解锁相册过滤锁定或空 URL 图片,并保留剩余图片的原始数组索引; +- 1 张图片显示 4:5 大图,2/4 张使用两列,其余使用三列正方形九宫格; +- 超过 9 张时卡片显示前九张,末格显示 `+N`,Gallery 仍可浏览全部有效图片; +- Gallery 只挂载当前图片和相邻图片,避免多图相册同时解码全部原图; - `creditBalance` 是当前列表/解锁响应快照,不替代 User Store 权益; - Private Room 不使用 Chat 的 `conversationKey`、消息缓存或媒体缓存; - Private Room 不持有 Payment Actor,Top-up 通过路由级导航进入独立 Payment Provider。 diff --git a/src/app/private-room/__tests__/private-album-gallery-motion.test.ts b/src/app/private-room/__tests__/private-album-gallery-motion.test.ts new file mode 100644 index 00000000..9b5efb51 --- /dev/null +++ b/src/app/private-room/__tests__/private-album-gallery-motion.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { + getResistedGalleryDragDistance, + resolveGallerySwipeDirection, +} from "../private-album-gallery-motion"; + +describe("private album Gallery motion", () => { + it("switches after crossing the viewport distance threshold", () => { + expect( + resolveGallerySwipeDirection({ + distance: -72, + durationMs: 500, + viewportWidth: 390, + hasPrevious: true, + hasNext: true, + }), + ).toBe(1); + expect( + resolveGallerySwipeDirection({ + distance: 72, + durationMs: 500, + viewportWidth: 390, + hasPrevious: true, + hasNext: true, + }), + ).toBe(-1); + }); + + it("switches on a short fast swipe but not a slow short drag", () => { + const baseInput = { + distance: -30, + viewportWidth: 390, + hasPrevious: true, + hasNext: true, + }; + + expect( + resolveGallerySwipeDirection({ ...baseInput, durationMs: 50 }), + ).toBe(1); + expect( + resolveGallerySwipeDirection({ ...baseInput, durationMs: 500 }), + ).toBe(0); + }); + + it("does not move beyond the first or last image", () => { + expect( + resolveGallerySwipeDirection({ + distance: 100, + durationMs: 100, + viewportWidth: 390, + hasPrevious: false, + hasNext: true, + }), + ).toBe(0); + expect( + resolveGallerySwipeDirection({ + distance: -100, + durationMs: 100, + viewportWidth: 390, + hasPrevious: true, + hasNext: false, + }), + ).toBe(0); + }); + + it("applies resistance only while pulling past an edge", () => { + expect(getResistedGalleryDragDistance(100, false, true)).toBeCloseTo(28); + expect(getResistedGalleryDragDistance(-100, true, false)).toBeCloseTo(-28); + expect(getResistedGalleryDragDistance(-100, false, true)).toBe(-100); + }); +}); diff --git a/src/app/private-room/__tests__/private-album-images.test.ts b/src/app/private-room/__tests__/private-album-images.test.ts new file mode 100644 index 00000000..b090f808 --- /dev/null +++ b/src/app/private-room/__tests__/private-album-images.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { PrivateAlbumSchema } from "@/data/schemas/private-room"; + +import { + findPrivateAlbumDisplayImage, + getPrivateAlbumDisplayImages, + getPrivateAlbumGridLayout, +} from "../private-album-images"; + +describe("private album display images", () => { + const album = PrivateAlbumSchema.parse({ + albumId: "album-1", + images: [ + { url: " /images/photo-1.png ", locked: false, index: 0 }, + { url: "/images/photo-2.png", locked: true, index: 1 }, + { url: " ", locked: false, index: 2 }, + { url: "/images/photo-4.png", locked: false, index: 3 }, + ], + unlocked: true, + lockDetail: { locked: false }, + }); + + it("filters unavailable images while preserving source indexes", () => { + expect(getPrivateAlbumDisplayImages(album)).toEqual([ + { sourceIndex: 0, url: "/images/photo-1.png" }, + { sourceIndex: 3, url: "/images/photo-4.png" }, + ]); + }); + + it("resolves Gallery images by their original source index", () => { + expect(findPrivateAlbumDisplayImage(album, 3)).toEqual({ + sourceIndex: 3, + url: "/images/photo-4.png", + }); + expect(findPrivateAlbumDisplayImage(album, 1)).toBeNull(); + expect(findPrivateAlbumDisplayImage(album, 2)).toBeNull(); + }); + + it.each([ + [0, "empty"], + [1, "single"], + [2, "double"], + [3, "triple"], + [4, "double"], + [5, "triple"], + [9, "triple"], + ] as const)("uses the expected grid for %i images", (count, layout) => { + expect(getPrivateAlbumGridLayout(count)).toBe(layout); + }); +}); diff --git a/src/app/private-room/components/__tests__/private-album-card.interaction.test.tsx b/src/app/private-room/components/__tests__/private-album-card.interaction.test.tsx new file mode 100644 index 00000000..e64df07c --- /dev/null +++ b/src/app/private-room/components/__tests__/private-album-card.interaction.test.tsx @@ -0,0 +1,67 @@ +/* @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 { PrivateAlbumSchema } from "@/data/schemas/private-room"; + +import { PrivateAlbumCard } from "../private-album-card"; + +describe("PrivateAlbumCard interactions", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("opens the selected thumbnail with its original source index", () => { + const onOpenGallery = vi.fn(); + const album = PrivateAlbumSchema.parse({ + albumId: "album-1", + title: "Private afternoon", + imageCount: 4, + images: [ + { url: "", locked: false, index: 0 }, + { url: "/images/private-room/locked.png", locked: true, index: 1 }, + { url: "/images/private-room/photo-2.png", locked: false, index: 2 }, + { url: "/images/private-room/photo-3.png", locked: false, index: 3 }, + ], + locked: false, + unlocked: true, + lockDetail: { locked: false }, + }); + + act(() => { + root.render( + undefined} + />, + ); + }); + + const thumbnail = container.querySelector( + 'button[data-image-index="3"]', + ); + act(() => thumbnail?.click()); + + expect(onOpenGallery).toHaveBeenCalledOnce(); + expect(onOpenGallery).toHaveBeenCalledWith(3); + }); +}); diff --git a/src/app/private-room/components/__tests__/private-album-card.test.tsx b/src/app/private-room/components/__tests__/private-album-card.test.tsx index 27ab2fb7..395e2f3b 100644 --- a/src/app/private-room/components/__tests__/private-album-card.test.tsx +++ b/src/app/private-room/components/__tests__/private-album-card.test.tsx @@ -43,10 +43,10 @@ function renderCard(album: PrivateAlbum): string { } describe("PrivateAlbumCard", () => { - it("renders only the first unlocked image and its photo count", () => { + it("renders every available unlocked image in the adaptive grid", () => { const html = renderCard( makeAlbum({ - imageCount: 8, + imageCount: 2, images: [ { url: COVER_URL, locked: false, index: 0 }, { @@ -59,13 +59,48 @@ describe("PrivateAlbumCard", () => { ); expect(html).toContain("elio.png"); - expect(html).not.toContain("%2Fimages%2Fcover%2Felio.png"); - expect(html).toContain("8 photos"); + expect(html).toContain("%2Fimages%2Fcover%2Felio.png"); + expect(html).toContain('data-grid-layout="double"'); + expect(html).toContain('data-image-index="0"'); + expect(html).toContain('data-image-index="1"'); expect(html).toContain("A quiet morning by the water."); - expect(html).toContain('aria-label="Open 8 private album photos"'); + expect(html).toContain( + 'aria-label="Open photo 2 of 2 from Elio Silvestri"', + ); expect(html).toContain('data-analytics-key="private_album.open_gallery"'); }); + it.each([ + { count: 1, layout: "single" }, + { count: 2, layout: "double" }, + { count: 3, layout: "triple" }, + { count: 4, layout: "double" }, + { count: 5, layout: "triple" }, + { count: 9, layout: "triple" }, + ])("uses the $layout layout for $count images", ({ count, layout }) => { + const html = renderCard( + makeAlbum({ + imageCount: count, + images: makeImages(count), + }), + ); + + expect(html).toContain(`data-grid-layout="${layout}"`); + }); + + it("limits the preview to nine images and shows the remaining count", () => { + const html = renderCard( + makeAlbum({ + imageCount: 12, + images: makeImages(12), + }), + ); + + expect(html.match(/data-image-index=/g)).toHaveLength(9); + expect(html).toContain("+3"); + expect(html).not.toContain('data-image-index="9"'); + }); + it("uses the backend first image as the locked cover", () => { const html = renderCard( makeAlbum({ @@ -93,3 +128,11 @@ describe("PrivateAlbumCard", () => { expect(html).toContain("No photo available"); }); }); + +function makeImages(count: number) { + return Array.from({ length: count }, (_, index) => ({ + url: `/images/private-room/photo-${index}.png`, + locked: false, + index, + })); +} diff --git a/src/app/private-room/components/__tests__/private-album-gallery.test.tsx b/src/app/private-room/components/__tests__/private-album-gallery.test.tsx new file mode 100644 index 00000000..37610b91 --- /dev/null +++ b/src/app/private-room/components/__tests__/private-album-gallery.test.tsx @@ -0,0 +1,154 @@ +/* @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 { PrivateAlbumSchema } from "@/data/schemas/private-room"; + +import { PrivateAlbumGallery } from "../private-album-gallery"; + +const album = PrivateAlbumSchema.parse({ + albumId: "album-1", + title: "Private afternoon", + imageCount: 5, + images: [ + { url: "/images/private-room/photo-0.png", locked: false, index: 0 }, + { url: "", locked: false, index: 1 }, + { url: "/images/private-room/locked.png", locked: true, index: 2 }, + { url: "/images/private-room/photo-3.png", locked: false, index: 3 }, + { url: "/images/private-room/photo-4.png", locked: false, index: 4 }, + ], + locked: false, + unlocked: true, + lockDetail: { locked: false }, +}); + +describe("PrivateAlbumGallery", () => { + let container: HTMLDivElement; + let root: Root; + let onClose: () => void; + let onImageIndexChange: (index: number) => void; + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + Object.defineProperties(HTMLElement.prototype, { + setPointerCapture: { + configurable: true, + value: vi.fn(), + }, + hasPointerCapture: { + configurable: true, + value: vi.fn(() => true), + }, + releasePointerCapture: { + configurable: true, + value: vi.fn(), + }, + }); + onClose = vi.fn(); + onImageIndexChange = vi.fn(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("follows a drag and switches to the next available source index", () => { + renderGallery(0); + const viewport = getViewport(); + Object.defineProperty(viewport, "clientWidth", { + configurable: true, + value: 390, + }); + + dispatchPointer(viewport, "pointerdown", 300); + dispatchPointer(viewport, "pointermove", 200); + expect(viewport.querySelector("[aria-hidden=false]")?.style.getPropertyValue("--gallery-drag-offset")).toBe("-100px"); + dispatchPointer(viewport, "pointerup", 200); + + expect(onImageIndexChange).toHaveBeenCalledWith(3); + expect(container.textContent).toContain("2 / 3"); + }); + + it("snaps back on pointer cancellation without changing images", () => { + renderGallery(3); + const viewport = getViewport(); + Object.defineProperty(viewport, "clientWidth", { + configurable: true, + value: 390, + }); + + dispatchPointer(viewport, "pointerdown", 300); + dispatchPointer(viewport, "pointermove", 200); + dispatchPointer(viewport, "pointercancel", 200); + + expect(onImageIndexChange).not.toHaveBeenCalled(); + expect(container.textContent).toContain("2 / 3"); + }); + + it("uses the same available indexes for controls and keyboard navigation", () => { + renderGallery(3); + + act(() => { + container + .querySelector('button[aria-label="Next photo"]') + ?.click(); + }); + expect(onImageIndexChange).toHaveBeenLastCalledWith(4); + + act(() => { + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }), + ); + }); + expect(onImageIndexChange).toHaveBeenLastCalledWith(3); + + act(() => { + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + function renderGallery(imageIndex: number): void { + act(() => { + root.render( + , + ); + }); + } + + function getViewport(): HTMLDivElement { + const viewport = container.querySelector( + '[data-testid="private-album-gallery-viewport"]', + ); + if (!viewport) throw new Error("Missing Gallery viewport"); + return viewport; + } +}); + +function dispatchPointer( + target: HTMLElement, + type: "pointerdown" | "pointermove" | "pointerup" | "pointercancel", + clientX: number, +): void { + const event = new MouseEvent(type, { + bubbles: true, + button: 0, + clientX, + }); + Object.defineProperty(event, "pointerId", { value: 1 }); + act(() => target.dispatchEvent(event)); +} diff --git a/src/app/private-room/components/private-album-card.tsx b/src/app/private-room/components/private-album-card.tsx index 23929865..5541170b 100644 --- a/src/app/private-room/components/private-album-card.tsx +++ b/src/app/private-room/components/private-album-card.tsx @@ -6,6 +6,12 @@ import { CharacterAvatar } from "@/app/_components"; import type { PrivateAlbum } from "@/data/schemas/private-room"; import { isPrivateAlbumLocked } from "@/lib/private-room/private_album"; +import { + getPrivateAlbumDisplayImages, + getPrivateAlbumGridLayout, + PRIVATE_ALBUM_GRID_LIMIT, +} from "../private-album-images"; + import styles from "../private-room-screen.module.css"; export interface PrivateAlbumCardProps { @@ -14,7 +20,7 @@ export interface PrivateAlbumCardProps { avatarUrl: string; index: number; isUnlocking: boolean; - onOpenGallery: () => void; + onOpenGallery: (imageIndex: number) => void; onUnlock: () => void; } @@ -31,6 +37,19 @@ export function PrivateAlbumCard({ const firstImage = album.images[0]; const firstImageUrl = firstImage?.url || null; const photoCount = album.imageCount || album.images.length; + const displayImages = getPrivateAlbumDisplayImages(album); + const previewImages = displayImages.slice(0, PRIVATE_ALBUM_GRID_LIMIT); + const gridLayout = getPrivateAlbumGridLayout(previewImages.length); + const overflowCount = Math.max( + 0, + displayImages.length - PRIVATE_ALBUM_GRID_LIMIT, + ); + const gridLayoutClassName = + gridLayout === "single" + ? styles.mediaGridSingle + : gridLayout === "double" + ? styles.mediaGridDouble + : styles.mediaGridTriple; return (
- ) : firstImageUrl ? ( - + {previewImages.map((image, imagePosition) => ( + + ))} + ) : (
void; } +interface ActivePointerGesture { + pointerId: number; + startX: number; + startTime: number; + startPosition: number; + viewportWidth: number; +} + +type GallerySlideStyle = CSSProperties & { + "--gallery-slide-offset": string; + "--gallery-drag-offset": string; +}; + export function PrivateAlbumGallery({ album, imageIndex, onClose, onImageIndexChange, }: PrivateAlbumGalleryProps) { - const touchStartXRef = useRef(null); - const currentImage = album.images[imageIndex]; - const hasPrevious = imageIndex > 0; - const hasNext = imageIndex < album.images.length - 1; + const displayImages = useMemo( + () => getPrivateAlbumDisplayImages(album), + [album], + ); + const [activeSourceIndex, setActiveSourceIndex] = useState(imageIndex); + const [dragOffset, setDragOffset] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const gestureRef = useRef(null); + const activePosition = displayImages.findIndex( + (image) => image.sourceIndex === activeSourceIndex, + ); + const currentImage = displayImages[activePosition]; + const hasPrevious = activePosition > 0; + const hasNext = + activePosition >= 0 && activePosition < displayImages.length - 1; useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); if (event.key === "ArrowLeft" && hasPrevious) { - onImageIndexChange(imageIndex - 1); + const previousImage = displayImages[activePosition - 1]; + if (previousImage) { + setActiveSourceIndex(previousImage.sourceIndex); + onImageIndexChange(previousImage.sourceIndex); + } } if (event.key === "ArrowRight" && hasNext) { - onImageIndexChange(imageIndex + 1); + const nextImage = displayImages[activePosition + 1]; + if (nextImage) { + setActiveSourceIndex(nextImage.sourceIndex); + onImageIndexChange(nextImage.sourceIndex); + } } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [hasNext, hasPrevious, imageIndex, onClose, onImageIndexChange]); + }, [ + activePosition, + displayImages, + hasNext, + hasPrevious, + onClose, + onImageIndexChange, + ]); - if (!currentImage?.url) return null; + if (!currentImage) return null; - const handleTouchEnd = (event: React.TouchEvent) => { - const touchStartX = touchStartXRef.current; - touchStartXRef.current = null; - if (touchStartX === null) return; - const delta = event.changedTouches[0]?.clientX ?? touchStartX; - const distance = delta - touchStartX; - if (distance > 48 && hasPrevious) onImageIndexChange(imageIndex - 1); - if (distance < -48 && hasNext) onImageIndexChange(imageIndex + 1); + const changeToPosition = (position: number) => { + const nextImage = displayImages[position]; + if (!nextImage || position === activePosition) return; + setActiveSourceIndex(nextImage.sourceIndex); + setDragOffset(0); + setIsDragging(false); + onImageIndexChange(nextImage.sourceIndex); + }; + + const handlePointerDown = (event: ReactPointerEvent) => { + if (event.button !== 0 || displayImages.length <= 1) return; + const viewportWidth = event.currentTarget.clientWidth || window.innerWidth; + gestureRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startTime: event.timeStamp, + startPosition: activePosition, + viewportWidth, + }; + event.currentTarget.setPointerCapture(event.pointerId); + setDragOffset(0); + setIsDragging(true); + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + event.preventDefault(); + const distance = event.clientX - gesture.startX; + setDragOffset( + getResistedGalleryDragDistance( + distance, + gesture.startPosition > 0, + gesture.startPosition < displayImages.length - 1, + ), + ); + }; + + const finishPointerGesture = ( + event: ReactPointerEvent, + cancelled: boolean, + ) => { + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + gestureRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + const distance = event.clientX - gesture.startX; + const direction = cancelled + ? 0 + : resolveGallerySwipeDirection({ + distance, + durationMs: event.timeStamp - gesture.startTime, + viewportWidth: gesture.viewportWidth, + hasPrevious: gesture.startPosition > 0, + hasNext: gesture.startPosition < displayImages.length - 1, + }); + + setIsDragging(false); + setDragOffset(0); + if (direction !== 0) { + changeToPosition(gesture.startPosition + direction); + } }; return ( @@ -58,10 +166,6 @@ export function PrivateAlbumGallery({ role="dialog" aria-modal="true" aria-label={album.title || "Private album gallery"} - onTouchStart={(event) => { - touchStartXRef.current = event.touches[0]?.clientX ?? null; - }} - onTouchEnd={handleTouchEnd} > -
- {`${album.title +
finishPointerGesture(event, false)} + onPointerCancel={(event) => finishPointerGesture(event, true)} + onLostPointerCapture={(event) => finishPointerGesture(event, true)} + > + {displayImages.map((image, position) => { + if (Math.abs(position - activePosition) > 1) return null; + const slideStyle = { + "--gallery-slide-offset": `${(position - activePosition) * 100}%`, + "--gallery-drag-offset": `${dragOffset}px`, + } as GallerySlideStyle; + + return ( +
+ {`${album.title +
+ ); + })}
- - {imageIndex + 1} / {album.images.length} + + {activePosition + 1} / {displayImages.length} {hasPrevious ? (