Skip to content

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:

ModuleRole
GalleryToolbarHeader: view toggle, search, filters, select + density controls
GalleryContentTab content switch (media grid, albums, smart views, saved views)
DetailPortalFullscreen detail overlay with all action callbacks
GalleryOverlaysAdd-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 useGalleryMedia and useCollections.
  • View state (React hooks). Filters and selection in useGalleryFilters: active tab, type/character filters, debounced search (1s), selection mode, and the selectedItemsMap (snapshots full items so selections survive filter changes).
  • Local persistence. Grid density via atomWithStorage (store.ts), recent searches in useRecentSearches (capped, deduped).

Extracted hooks keep the shell thin:

HookResponsibility
useGalleryFeedPending media split + feed group memos
useMediaActionsDownload, favorite, archive, create actions + PostHog tracking
useDetailNavigationDetail state, resolution, related items, prev/next nav
useSelectionSelected IDs/items memos + bulk actions wiring
useBulkActionsArchived vs standard action sets
useCharactersCharacter 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 expensive COUNT(*); the folder-count queries deliberately set it false to read the real total.

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.

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. mediaToItem maps a sirloin Media row to the client GalleryItem; groupByDate buckets items under human date labels preserving server order.
  • Lineage (edit bundles). buildLineage collapses 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. splitPendingMedia sorts in-flight generations into three buckets (edits, carousel-cover edits, fresh); buildFeedGroups splices them into the feed as overlays or shimmer tiles in “Today”.
  • Month ranges. monthRanges enumerates 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-side smartGroups grouper is gone; folders are server-backed now — see §5.)
  • Carousels in the detail. The detail renders a carousel as its individual slides (each a photo item carrying carouselOf = the parent). wholeCarouselOf recovers the whole carousel from either shape (for “download all”), and canRecreateItem decides whether Recreate is offered.
  • Download. shareOrDownloadImages handles platform-specific download strategies: embedded browser guard (FB/Instagram), native share sheet on mobile, iOS Safari /download page fallback for bulk images, and staggered auto-download on desktop. PostHog Generation Downloaded events fire for every download.

5. Brisket — albums, detail, selection

  • Albums hub. hooks/use-hub-cards.ts builds 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.ts replaces the old client grouper:
    • useCharacterFolders enumerates folders from getAllCharacters (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.
    • useTypeFolders runs one count query per media type.
    • useMonthFolders runs a limit: 1 OLDEST probe to find the account’s first month, builds ranges with monthRanges, 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. useCharacterCounts is 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 AlbumView scoped by the same filter (characterId / mediaType / date range) with normal Sentinel paging — 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 (in use-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 with use-detail-layout geometry hook) and desktop/ (split layout). Types in constants.ts, actions in actions/, 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 via wholeCarouselOf) or just the shown photo. The sheet layers at z-[400], above the detail’s z-[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.
  • Selection & bulk actions. Selection snapshots the full GalleryItem via selectedItemsMap when 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_to add created_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 the skip_total and COUNT(*) paths. (internal/pkg/storage/media.go, in the ListMedia query builder — ListAllMedia delegates 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_total paging: limit+1 sentinel, no COUNT(*).

7. Sirloin — collections service and storage

RPCNotes
CreateCollectionname required
UpdateCollectionpartial update (only provided fields)
DeleteCollectionsoft-deletes collection, hard-deletes membership rows
ListCollectionscomputes count + cover per row
AddMediaToCollectionownership-checked, idempotent (ON CONFLICT DO NOTHING)
RemoveMediaFromCollectionhard-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 by user_id.
  • media.collection_media — join table with composite PK, added_at for ordering, ON DELETE CASCADE on 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, and cover_path all 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 ListAllMedia path, 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 Downloaded fires 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_nsfw filter (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-v2 PostHog 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.