src/features/content/.
Files
Endpoints
getContentList normalises each item so older responses are safe: isFeatured becomes a strict boolean, tags defaults to [] and unlockedAt defaults to null.
Data model
type describes what the item is. mediaKind describes what its media is. The two are independent: a knowledge item can carry a video, and how an item opens depends mostly on mediaKind.
Categories and chips
Categories are studio-defined rows from the taxonomy endpoint. An item withcategoryId: null belongs to a synthetic General bucket with key GENERAL_KEY = 'general'.
buildCategoryChips(categories, items) builds the chip row in this order:
- A featured chip with key
FEATURED_KEY = 'featured', only when at least one item hasisFeatured. - One chip per taxonomy category that has at least one item.
- The General chip, only when at least one item has no category.
- A shopping recommendations chip with key
SHOPPING_KEY = 'shopping'. Always present.
items is undefined), the occupancy filter is skipped, so the featured chip and every category chip show.
ContentScreen starts with category set to FEATURED_KEY. If the selected key is not in the chip list, it falls back to defaultCategoryKey(chips), the first chip. The route param section=shopping forces the shopping chip. Selecting another chip clears that param and resets the tag filter, both search boxes and the shopping filter.
ContentCategoryBar keeps the active chip centred. It records each chip’s x and width from onLayout and scrolls so the active chip sits in the middle. The first centring is not animated, later ones are.
Featured items
There is no separate endpoint for featured content.featuredItems(items) filters the loaded list by isFeatured, and the featured chip shows that subset. Because the featured chip is first, it is the default view whenever any featured item exists.
Shopping chip
The shopping chip swaps the grid forShoppingRecommendationsList from src/features/shopping/, driven by useShoppingSection. It reuses the same search field (debounced with useDebouncedValue) and the same filter sheet, with shopping categories as options. A button opens /shopping-list. The shopping feature itself is outside this page.
Tags and the filter sheet
Tags are studio-wide and come from the taxonomy endpoint. Each item carries its owntags array.
buildTagOptions(scopedItems, tags) builds the filter options for the open category:
- Only tags that appear on at least one item in that category are offered, with a count.
- An “all” option with key
ALL_SUB_KEY = 'all'and the category’s item count is put first. - If no tag is in use, the list is empty and the filter button is hidden.
ContentFilterSheet is a bottom sheet in a Modal, with its own search box and one radio row per option. Selecting a row calls onSelect(key), or onSelect(null) for the “all” row, then closes. The search box resets each time the sheet opens.
When a tag is selected, a selected chip with the tag name appears under the chip row. Tapping it clears the filter.
The funnel button shows when filter options exist and, outside shopping, no search is active.
Search
Search matchestitle only, case-insensitive, using useDeferredValue. It cuts across every category: with a non-empty query, the result replaces the scoped list instead of filtering inside it. The tag filter is ignored during search.
Grid
The list is a two-columnFlatList. Column width is derived from the window width capped at sizes.maxContentWidth. Pull to refresh refetches the list.
ContentGridCard has a fixed aspect (CARD_RATIO = 1.16) and shows:
- A poster from
posterFor(item):thumbnailUrl, ormediaUrlwhenmediaKindisimage, otherwise a brand gradient. - A play glyph for
videoandyoutubemedia, and an external-link glyph forlinkitems. A card with no poster and neither glyph shows the type icon fromcategoryIcon(item.type). - A category badge from
itemMetaLabel(item): the category name or the General label. - A “new” badge when
isNewItem(item)is true. The reference date isunlockedAt ?? createdAt, and an item is new forNEW_ITEM_DAYS = 14days. - The title, and a type label from
itemTypeLabel(item)unless it equals the badge. Fortype: 'other'with anotherLabel, that label replaces the generic one.
Opening an item
openItem(item) in ContentScreen decides what a tap does:
ContentItemScreen repeats the first two rules for deep links. A link item opens the browser and the screen closes itself. A pdf item renders only the PDF viewer. closeContentItem() goes back when possible, otherwise replaces the route with /content.
Item screen
For every other item the screen shows a hero and a body. Hero.ContentMedia draws the poster with a play overlay when the item has playable media. Over it sit a type badge, one badge per tag, and the title.
Body.
recipe: the description (if any), the ingredients text,StepsFlowforsteps, thenRecipeMacros.- Any other type: an about card with the trimmed
description, when there is one.
StepsFlow numbers only entries of type step. A tip entry renders as a highlighted note and does not consume a number.
Video player
ContentVideoPlayer is a full-screen Modal. It is also used by the home banner (see Home screen).
parseVideoEmbed(url) classifies the URL:
Embeds are loaded with a
Referer header and a widget_referrer query set to EMBED_REFERRER. By default the embed URL asks for autoplay, and the direct player calls play() on creation.
Stopping playback on close
Unmounting does not stop audio on either backend, so each player registers a synchronous stop function in a shared ref and every exit path calls it before unmounting:- Direct player:
player.pause(). - Embed player: injects a script that pauses every
videoandaudioelement in the page.
background (not inactive). Picture in picture is disabled on the WebView and not enabled on the native view, because it would keep playing after close. The direct player sets audioMixingMode = 'doNotMix' so playback is audible with the iOS silent switch on.
PDF viewer
ContentPdfViewer is a full-screen Modal with a close button and a WebView. The source comes from pdfViewerSource(url, Platform.OS) in src/features/plans/lib/planKind.ts:
- iOS loads the file URL directly.
- Android loads
https://docs.google.com/gview?embedded=true&url=..., because the Android WebView cannot render a PDF. The file URL must be publicly fetchable for this to work.
onLoadEnd. A load error replaces the WebView with an error message. The component takes an optional source prop to override the computed one, which src/features/plans/components/FilePlanView.tsx uses to show a file plan full screen.
Recipe macros
RecipeMacros reads ten flat numeric fields on the item: servingGrams, servingKcal, servingProtein, servingCarbs, servingFat and the matching total* fields.
- A set counts as present when any of its kcal, protein, carbs or fat values is not null. With neither set present the card renders nothing.
- When both sets exist, a two-tab switch chooses between one serving and the whole recipe. It starts on one serving. With one set, there is no switch.
- The header shows the scope label and the gram weight when known, and the kcal value rounded to a whole number.
- Three columns show protein, carbs and fat, rounded to one decimal, each with a bar.
KCAL_PER_GRAM (protein 4, carbs 4, fat 9).
When the studio uses the portion system (useMbp().enabled), the three values are converted with mbpFromMacros(macros, anchors) and shown in portion units with the studio’s portion label. The shares are then computed from the portion numbers directly. See Nutrition overview for the portion system.