Gallery & Collections
Gallery & Collections
Purpose
Document the redesigned gallery surface (Gallery v2) and the user-owned
collections (albums) that back it. Gallery v2 replaces the home page / behind
the gallery-v2 PostHog flag: flag on renders GalleryV2Shell, flag off keeps
the legacy MainPage — same URL, straight A/B rollout. For the full-stack
engineering view see Gallery v2 — Architecture.
Participants
- Brisket renders the gallery shell, toolbar, grid, detail, search, filters,
albums, and smart groups (
features/gallery-v2/). Reads media viaapi.media.listAllMediaand collections viaapi.collections.*. - Sirloin serves
ListAllMedia(cross-character feed, optionally scoped to a collection or a single character) and the collection CRUD / membership RPCs, and owns themedia.collections+media.collection_mediatables.
Cross-character media (ListAllMedia)
ListAllMedia reuses ListMedia’s filtering, carousel loading, example
controls, and presigned-URL logic:
- Empty
character_id⇒ no character scope (the whole user’s gallery). - Set
character_id⇒ the gallery’s “Characters” filter. - Set
collection_id⇒ results restricted to that collection’s members. - Each row carries optional character context (
media_character_*) so the cross-character grid can label and filter without extra lookups. - Type and character filters pass server-side to
ListAllMediafor both the main feed and album views (viaresolveMediaTypehelper).
Paging uses skip_total with a limit+1 sentinel to detect further pages
without a COUNT(*).
Collections (albums)
- A collection is user-owned (
media.collections, all queries filteruser_id); membership rows live inmedia.collection_mediawith anadded_attimestamp. media_countandcover_pathare computed as subqueries that exclude soft-deleted media;ListCollectionMediaIDslikewise joinsmediaand filtersdeleted_at IS NULL, so the member list stays consistent with the shown count.- An empty (but valid) collection returns no media — callers must not fall back to listing all of the user’s media.
- The cover preview is the most-recently-added non-deleted member.
- Album ordering on the hub is local-only (persisted in
localStorage): the backend has no album ordering. - Filtering inside albums: type and character filters apply server-side for collection-backed and scope-backed albums, client-side for pre-loaded albums (18+). Filters reset when navigating back to the albums hub.
Multi-select & bulk actions
- Selection snapshots full
GalleryItemdata intoselectedItemsMapso items survive filter changes that remove them from the current query. - Context-aware actions: archived view shows Download + Unarchive; elsewhere shows Add to album + Download + Archive.
- Count badge on the select button reflects current selection across filters.
- Downloads fire both the
markMediaDownloadtRPC mutation and the PostHogGeneration Downloadedevent for every item.
Download strategy
Platform-specific download handling via shareOrDownloadImages:
- Embedded browsers (FB/Instagram on Android): toast, no download.
- Single image on mobile (non-Chrome-iOS):
navigator.share(save to photos). - iOS Safari bulk images: redirect to
/downloadpage (tap-and-hold). - iOS Safari videos: blob auto-download.
- Chrome iOS:
application/octet-streamblob trick. - Desktop / Android: staggered
<a download>clicks.
The detail view pre-warms files from cache via useShareableDownload to stay
within iOS’s transient user activation window.
Invariants
- Ownership is enforced server-side on every collection read/mutation and on add-to-collection (media not owned by the user is rejected).
- Draft characters are excluded from the gallery’s search and character filter.
- Deleting a collection soft-deletes the collection and hard-deletes its membership rows in one transaction.
- PostHog
Generation Downloadedfires for every download (single + bulk).
Error paths
- Invalid collection / not owned ⇒
collection not found. - Adding media not owned by the user ⇒ rejected before any membership write.
- Cover presign failures degrade to “no cover” (the collection still lists).
- Embedded browser download ⇒ toast “open in a regular browser”.
Known follow-ups
- Collection media is returned in
created_atorder, while the cover is the most-recently-added member, so the cover and first grid tile can differ. - 18+ gating is session-local and must bind to the real entitlement / age-verification before ship (requires sign-off).
- Per-slide carousel editing needs image-pipeline support; today “Edit” on a carousel edits the cover and yields a standalone photo.
Verification
/on a PR preview: grid, detail, search, filters, albums CRUD, add-to-album, smart groups, favorites/archive.- Multi-select download across carousels + videos + photos on desktop and mobile.
- Create an album, add/remove media, filter inside it, delete media in it, and confirm the count and cover stay consistent.