Gallery v2 — Architecture (Brisket + Sirloin)
Gallery v2 — Architecture (Brisket + Sirloin)
The engineering view of the redesigned gallery. For the flow-level “what and why” (albums, carousels, edit bundles, 18+ gating as product behaviour) see Gallery & Collections. This doc is the full-stack implementation across the two tiers that own it: brisket (the UI) and sirloin (the API + storage).
The thesis
The gallery is a cross-character feed built from one backend read
(ListAllMedia) and one collections API, both served by sirloin. The feed
itself is derived client-side: it fetches flat media pages and builds date
groups, edit bundles, and in-flight generation overlays in a pure transform
layer with no extra round-trips. The same ListAllMedia call powers the main
grid, Favorites, Archived, search, and every album view — only its filter
arguments change.
Smart album folders are the exception, and deliberately so. “By character”,
“By month”, and “By type” are not derived from the loaded feed pages — that
would only ever see the newest ~48 items, so old months and less-recent
characters silently vanished (the original bug this design fixes). Instead each
folder’s list, count, and cover come from their own bounded ListAllMedia
queries (limit: 4, real total), one per folder, so the numbers are
server-truthful on accounts with years of media without ever fetching the whole
library. See §5.
flowchart LR A["route /<br/>flag-gated"] --> B["GalleryV2Shell"] B --> C["useGalleryMedia<br/>api.media.listAllMedia<br/>(infinite query)"] B --> D["useCollections<br/>api.collections.*"] B --> K["useSmartFolders<br/>bounded per-folder<br/>count queries"] C --> E["transforms<br/>constants + helper-functions"] E --> F["GalleryToolbar · GalleryContent · DetailPortal · GalleryOverlays"] K --> F C -->|tRPC → ConnectRPC| G["sirloin ListAllMedia<br/>reuses ListMedia<br/>(+ date range)"] K -->|tRPC → ConnectRPC| G D -->|tRPC → ConnectRPC| H["sirloin collections RPCs"] G --> I["media.media (+ carousel/character joins)"] H --> J["media.collections<br/>media.collection_media"]1. Where it lives and how it’s gated
Gallery v2 replaces the home page / behind a PostHog flag. Page reads the
gallery-v2 flag and renders GalleryV2Shell when on, or the legacy MainPage
when off — no flash of the wrong gallery.
The feature is rooted at GalleryV2Shell (features/gallery-v2/index.tsx), a
~300-line composition shell that wires together extracted hooks and sub-components:
| Module | Role |
|---|---|
GalleryToolbar | Header: view toggle, search, filters, select + density controls |
GalleryContent | Tab content switch (media grid, albums, smart views, saved views) |
DetailPortal | Fullscreen detail overlay with all action callbacks |
GalleryOverlays | Add-to-album, 18+ gate, and selection bar sheets |
2. Brisket — state model
State lives in three tiers:
- Server state (React Query via tRPC). All media and collections, fetched
through
useGalleryMediaanduseCollections. - View state (React hooks). Filters and selection in
useGalleryFilters: active tab, type/character filters, debounced search (1s), selection mode, and theselectedItemsMap(snapshots full items so selections survive filter changes). - Local persistence. Grid density via
atomWithStorage(store.ts), recent searches inuseRecentSearches(capped, deduped).
Extracted hooks keep the shell thin:
| Hook | Responsibility |
|---|---|
useGalleryFeed | Pending media split + feed group memos |
useMediaActions | Download, favorite, archive, create actions + PostHog tracking |
useDetailNavigation | Detail state, resolution, related items, prev/next nav |
useSelection | Selected IDs/items memos + bulk actions wiring |
useBulkActions | Archived vs standard action sets |
useCharacters | Character query + draft filtering |
3. Brisket — data fetching and pagination
One procedure backs the entire surface: api.media.listAllMedia. The grid,
Favorites, Archived, search, and album views all call it with different arguments:
characterId— empty = whole gallery; set = “Characters” filter.collectionId— scopes to one album’s members.filter.search/filter.mediaType/filter.displayType— text search, Photos/Videos/Carousels filter, DEFAULT / FAVORITES_ONLY / ARCHIVE_ONLY scope.filter.dateFrom/filter.dateTo— half-open created-at range[dateFrom, dateTo)(RFC3339). Powers the “By month” album and its folder counts. Empty strings are a no-op, so every other caller is unaffected.skipTotal: true— opts out of the expensiveCOUNT(*); the folder-count queries deliberately set it false to read the realtotal.
Paging is an infinite query. The shared Sentinel component
(features/main-page/gallery-grid/sentinel) drives prefetch with configurable
rootMargin. Type filter resolution uses the shared resolveMediaType helper.
Loading state gates on isPending (not isLoading): after a filter change
re-keys the query, the fetch hasn’t started for one render tick, and isLoading
would flash the empty state before the loading grid.
Search is debounced 1s on the client (≥2 chars); the search panel shows skeleton loading during debounce and query in-flight.
Favorite/archive/download are optimistic mutations that patch the infinite-query
cache and reconcile on settle. The settle-time invalidate is debounced
~300ms so a bulk action (one mutation per selected item) collapses into a
single refetch wave instead of one per item — important now that the album hub
keeps ~N folder-count queries live.
4. Brisket — the gallery-data transform layer
Raw media pages are turned into everything the UI shows by pure functions in
constants.ts (types) and helper-functions.ts (transforms). This is where the
feed’s “intelligence” lives, and none of it touches the network.
- Adapt.
mediaToItemmaps a sirloinMediarow to the clientGalleryItem;groupByDatebuckets items under human date labels preserving server order. - Lineage (edit bundles).
buildLineagecollapses an edit chain into a single latest tile for the grid while keeping the full family for the detail’s Related strip. It works only over already-loaded pages. - Pending merge.
splitPendingMediasorts in-flight generations into three buckets (edits, carousel-cover edits, fresh);buildFeedGroupssplices them into the feed as overlays or shimmer tiles in “Today”. - Month ranges.
monthRangesenumerates calendar-month buckets (local-time boundaries, sent as absolute instants) from the current month back to the account’s oldest item — the input to the “By month” album’s date-bounded count queries. (The old client-sidesmartGroupsgrouper is gone; folders are server-backed now — see §5.) - Carousels in the detail. The detail renders a carousel as its individual
slides (each a
photoitem carryingcarouselOf= the parent).wholeCarouselOfrecovers the whole carousel from either shape (for “download all”), andcanRecreateItemdecides whether Recreate is offered. - Download.
shareOrDownloadImageshandles platform-specific download strategies: embedded browser guard (FB/Instagram), native share sheet on mobile, iOS Safari/downloadpage fallback for bulk images, and staggered auto-download on desktop. PostHogGeneration Downloadedevents fire for every download.
5. Brisket — albums, detail, selection
- Albums hub.
hooks/use-hub-cards.tsbuilds the card list: real collections, Favorites/Archived system views, the 18+ card, and the three smart shortcuts. Real-album counts/covers come from the collections API; smart-card group counts come from the folder hooks below. - Smart folders (server-backed).
hooks/use-smart-folders.tsreplaces the old client grouper:useCharacterFoldersenumerates folders fromgetAllCharacters(every non-draft character, not gated on the per-type image/video aggregates — a carousel-only character must still appear) and gives each a{characterId, limit: 4, skipTotal: false}query for its exact count + 4 covers; zero-count folders are hidden.useTypeFoldersruns one count query per media type.useMonthFoldersruns alimit: 1OLDEST probe to find the account’s first month, builds ranges withmonthRanges, then counts each month with a date-bounded query — lazily, a year at a time as the list scrolls, so a six-year account never fires every month’s count up front.- The count queries are enabled only while the hub is on screen, and share
query keys with the folder contents, so counts and the opened folder can’t
disagree.
useCharacterCountsis reused to label the “Characters” filter/dropdown from the same queries, so those numbers always match the By-character folders. - Opening a folder renders an
AlbumViewscoped by the same filter (characterId/mediaType/ date range) with normalSentinelpaging — no client-side slicing of pre-loaded items. - Counts that can’t be fully enumerated cheaply get an honest “N+” suffix (the 18+ card while more feed pages exist; the month card before every month is counted). “Save as album” is hidden for smart groups — it would persist only the loaded ids.
- Album name validation.
collectionNameError(inuse-collections.ts) blocks duplicate names (case-insensitive, trimmed) and the reserved system names (“Favorites”/“Archived”/“18+”, which the hub filters out) on both create and rename. Client-side only — the backend still accepts duplicates. - Detail / fullscreen. Split into
mobile/(draggable bottom sheet withuse-detail-layoutgeometry hook) anddesktop/(split layout). Types inconstants.ts, actions inactions/, carousel nav, related strip, prompt/info blocks each in their own modules. Keyboard navigation suppressed when a dialog is layered on top. Two carousel-specific rules mirror prod:- Recreate is hidden for a carousel with no
mediaExampleId(canRecreateItem): a carousel born from Recreate carries no example, so recreating it would fall through to a text-to-carousel workflow with an empty prompt and fail. Recreate on any slide re-creates the whole carousel, never the single slide. - Download on a carousel opens a choice sheet (
use-detail-download.ts+download-choice-sheet): the whole carousel (all slides, rebuilt viawholeCarouselOf) or just the shown photo. The sheet layers atz-[400], above the detail’sz-[300]. Multi-select already downloads whole carousels by default; edit bundles download only their latest version. Both download variants are pre-warmed so the pick fires the iOS share sheet under its own tap activation.
- Recreate is hidden for a carousel with no
- Selection & bulk actions. Selection snapshots the full
GalleryItemviaselectedItemsMapwhen toggled, so items survive filter changes. The floating selection bar shows context-aware actions (download + unarchive in archived view; add + download + archive elsewhere). Count badge on the select button.
6. Sirloin — ListAllMedia
A thin cross-character wrapper over ListMedia, reusing its filtering, carousel
loading, and presigned-URL logic.
- Optional character scope (empty = all characters).
- Optional collection scope (resolves member ids, then
IN (...)filter). - Optional date range:
filter.date_from/filter.date_toaddcreated_at >= from/created_at < to(half-open, RFC3339). Empty strings stay a strict no-op, so pre-existing callers are byte-identical. Applies on both theskip_totalandCOUNT(*)paths. (internal/pkg/storage/media.go, in theListMediaquery builder —ListAllMediadelegates to it.) - Empty-collection invariant: returns empty, never falls back to all media.
- Character context fields (
media_character_*) joined only for cross-character reads. skip_totalpaging:limit+1sentinel, noCOUNT(*).
7. Sirloin — collections service and storage
| RPC | Notes |
|---|---|
| CreateCollection | name required |
| UpdateCollection | partial update (only provided fields) |
| DeleteCollection | soft-deletes collection, hard-deletes membership rows |
| ListCollections | computes count + cover per row |
| AddMediaToCollection | ownership-checked, idempotent (ON CONFLICT DO NOTHING) |
| RemoveMediaFromCollection | hard-deletes membership only |
Ownership enforced on every read/mutation. resolveCollectionAndMedia verifies
both the collection and all media ids belong to the caller before any write.
Count, cover, and member list all exclude soft-deleted media via consistent
deleted_at IS NULL filters.
8. Schema and proto
Migration 127_collections.sql adds two tables under the media schema:
media.collections— soft-deletable, owned byuser_id.media.collection_media— join table with composite PK,added_atfor ordering,ON DELETE CASCADEon both FKs.
Proto surface: rpc ListAllMedia, six collection RPCs, ListAllMediaRequest
with optional collection_id/character_id, character-context fields on Media,
and the Collection message with computed count + cover URL. ListMediaFilter
already carried date_from/date_to fields — they were dormant (the storage
builder ignored them) until the “By month” work activated them; no proto change
was needed.
Invariants
- Cross-character reads carry character context; single-character reads skip the join.
- An empty-but-valid collection returns no media — never a fallback to all media.
- Ownership enforced server-side on every collection read/mutation.
- Member list,
media_count, andcover_pathall exclude soft-deleted media. - Smart album folder lists, counts, and covers are server-truthful (bounded per-folder queries), never derived from the loaded feed pages.
- A folder’s count and its opened contents come from the same filtered
ListAllMediapath, so they can’t drift apart. - The date filter is a strict no-op on empty strings — activating it changed no existing caller’s query.
- Album ordering and recent searches are client-local.
- PostHog
Generation Downloadedfires for every download (single + bulk).
Known gaps
- Edit bundles are page-local (no server-side history walk).
- Cover vs first tile can differ (added-at vs created-at ordering).
- 18+ is still a client-side filter of the loaded pages (count + folder), so it’s
a lower bound (“N+”). Its real fix is a server
is_nsfwfilter (FOXY-483); until then the 18+ folder has no pagination of its own. - The character card’s generation chips count raw media rows (carousel slides, co-star media), so they can legitimately differ from the gallery’s per-character counts, which count gallery tiles for the current user.
- Duplicate-album-name blocking is client-only; the backend still accepts dupes, so albums created before this guard stay duplicated until renamed.
- Month bucketing uses local-time boundaries; an item created late on the last day of a month can bucket differently than a UTC reading would.
- Per-slide carousel edit yields a standalone photo (needs image-pipeline support).
Verification
- Toggle the
gallery-v2PostHog flag off/on → same URL, different gallery. - Grid, detail, search, filters, album CRUD, add-to-album, smart folders, favorites/archive on a PR preview.
- On a >1-year, many-character account: “By month” lists every non-empty month (not just the newest page) and “By character” lists every character with media — opening an old month or a less-recent character paginates correctly.
- Character-filter dropdown counts match the By-character folder counts.
- Carousel detail: Download prompts whole-vs-single; Recreate is present for an original carousel and hidden for one created via Recreate.
- Multi-select download across carousels + videos + photos.
- Create an album; a duplicate or reserved name is rejected inline.
- Create an album, add/remove media, delete album; confirm count and cover consistency.