Repository navigation
Add native table of contents navigation for multi-page content products - #70
Conversation
Replace the web-based TOC/Previous/Next buttons (which required scrolling down) with a native sticky footer bar that shows page navigation and a TOC sheet.
yashranaway
left a comment
There was a problem hiding this comment.
@binary-koan _a Could you please take a look?
| type TocPage = { | ||
| page_id: string; | ||
| title: string | null; | ||
| }; |
There was a problem hiding this comment.
Kept this as a local type rather than importing from the purchase page since this component doesn't need to know about the message protocol — it just needs page IDs and titles.
| <Pressable | ||
| onPress={() => onPageChange(activePageIndex - 1)} | ||
| disabled={!hasPrevious} | ||
| className={cn("flex-row items-center gap-1", !hasPrevious && "opacity-50")} |
There was a problem hiding this comment.
Following the codebase pattern from Button, Checkbox, RadioGroup etc. where disabled Pressables get opacity-50 rather than changing individual child colors.
| <Pressable onPress={() => setSheetOpen(true)} className="flex-1 flex-row items-center justify-center gap-1.5"> | ||
| <LineIcon name="book-content" size={18} className="text-foreground" /> | ||
| <Text className="text-sm font-bold text-foreground"> | ||
| {activePageIndex + 1} of {pages.length} | ||
| </Text> | ||
| </Pressable> |
There was a problem hiding this comment.
The center area is flex-1 so it fills the space between Previous and Next, keeping the layout balanced. Tapping opens the sheet — I used book-content icon since it felt fitting for a table of contents.
| <Sheet open={sheetOpen} onOpenChange={setSheetOpen}> | ||
| <SheetHeader onClose={() => setSheetOpen(false)}> | ||
| <SheetTitle>Table of contents</SheetTitle> | ||
| </SheetHeader> | ||
| <SheetContent> | ||
| <FlatList | ||
| data={pages} | ||
| keyExtractor={(item) => item.page_id} | ||
| renderItem={({ item, index }) => { | ||
| const isActive = index === activePageIndex; | ||
| return ( | ||
| <Pressable | ||
| onPress={() => { | ||
| onPageChange(index); | ||
| setSheetOpen(false); | ||
| }} | ||
| className={cn("flex-row items-center gap-3 px-4 py-3", isActive && "bg-accent")} | ||
| > | ||
| <Text className={cn("text-sm", isActive ? "font-bold text-accent-foreground" : "text-foreground")}> | ||
| {index + 1}. | ||
| </Text> | ||
| <Text | ||
| className={cn( | ||
| "flex-1 text-sm", | ||
| isActive ? "font-bold text-accent-foreground" : "text-foreground", | ||
| )} | ||
| numberOfLines={2} | ||
| > | ||
| {item.title ?? "Untitled"} | ||
| </Text> | ||
| </Pressable> | ||
| ); | ||
| }} | ||
| /> | ||
| </SheetContent> | ||
| </Sheet> |
There was a problem hiding this comment.
Using FlatList inside SheetContent so the list scrolls independently if there are many pages. Active page gets bg-accent highlight to match the brand pink. Sheet closes after selecting a page so the user sees the content change immediately.
| <SheetContent> | ||
| <FlatList | ||
| data={pages} | ||
| keyExtractor={(item) => item.page_id} |
There was a problem hiding this comment.
Using page_id from the web data as the key, which is guaranteed unique per product.
| type TocDataMessage = { | ||
| type: "tocData"; | ||
| payload: { | ||
| pages: { page_id: string; title: string | null }[]; | ||
| activePageIndex: number; | ||
| }; | ||
| }; | ||
|
|
There was a problem hiding this comment.
Mirrors the exact shape sent by the web side's useEffect in WithContent.tsx. The pages array is a slimmed-down version of RichContentPage — we only need page_id and title for the native nav.
| export default function DownloadScreen() { | ||
| const { id } = useLocalSearchParams<{ id: string }>(); | ||
| const [isDownloading, setIsDownloading] = useState(false); | ||
| const [tocPages, setTocPages] = useState<TocDataMessage["payload"]["pages"]>([]); |
There was a problem hiding this comment.
Reusing the type from the message definition to keep them in sync. Starts empty so the footer doesn't render until the web sends data.
| const handleNativePageChange = useCallback( | ||
| (pageIndex: number) => { | ||
| webViewRef.current?.postMessage(JSON.stringify({ type: "mobileAppPageChange", payload: { pageIndex } })); | ||
| }, | ||
| [], | ||
| ); | ||
|
|
There was a problem hiding this comment.
Wrapped in useCallback since it's passed as a prop to ContentPageNav. This sends a message to the WebView which then does the actual page change (calling handlePageChange on the web side, which updates state AND calls saveLastContentPage). The web then round-trips back with updated tocData, so the native side stays in sync without duplicating page-change logic.
| const data = event.nativeEvent.data; | ||
| try { | ||
| const message = JSON.parse(data) as ClickMessage; | ||
| const message = JSON.parse(data) as ClickMessage | TocDataMessage; |
There was a problem hiding this comment.
Changed from as ClickMessage to as ClickMessage | TocDataMessage so we can handle both message types. The if (message.type === "tocData") check runs first, before the existing click handling.
| if (message.type === "tocData") { | ||
| setTocPages(message.payload.pages); | ||
| setActivePageIndex(message.payload.activePageIndex); | ||
| return; | ||
| } | ||
|
|
There was a problem hiding this comment.
Just stores the page list and active index. The web side is the single source of truth b.
|
Looking at the diff on web, I think we should factor out a common hook for dealing with React Native messages - all the window vs document stuff, JSON parsing, etc shouldn't be duplicated We also unfortunately need to detect the Expo app specifically rather than any mobile app, since I believe just checking I'll think about the design a bit as well, the ToC button doesn't obviously look clickable to me so I wonder if we can make it clearer (I'll think about what apps we can compare to, lmk if you have any good examples!) |
|
@binary-koan _a Thanks I have made these changes,
Now for the design totally fair, the center button is pretty subtle. Looked at a few apps for reference: Apple Books has a similar bottom bar with arrows and a centered TOC button, Kindle does a thin bar that opens a page scrubber + TOC, Substack Reader uses a floating pill with the chapter title. Ours is closest to Apple Books. Could make it more obviously tappable by adding a border/pill around the center, adding a chevron-down next to "1 of 5", or swapping the icon for something more universally "list-like". Happy to try whatever direction you land on! |
|
Looks great! Thanks. |
Paired with antiwork/gumroad-mobile#70 * Adds support for sending the table of contents to the Expo app and navigating when requested * Hides the web navigation when running in React Native --------- Co-authored-by: Aditya Garud <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>


Issue: #27
Description
Problem
Products with multiple content pages show a web-based TOC button and Previous/Next buttons at the bottom of the HTML content in the WebView. This requires scrolling down on long pages to navigate between pages, which is a poor mobile UX.
Solution
Replace the web-based navigation with a native sticky footer bar that is always visible. The footer has Previous/Next buttons, a page counter ("2 of 5"), and a TOC button that opens a sheet with the full page list.
Mobile app changes (
gumroad-mobile):ContentPageNavcomponent: sticky footer with Previous/Next, page counter, and TOC sheettocDatamessages from web and sendmobileAppPageChangemessages backWeb changes (
gumroad):tocDatamessage to React Native with pages and active indexmobileAppPageChangemessages from React NativeCompanion web changes: This feature requires changes to
gumroadweb repo (WithContent.tsx). The implementation is ready on branchfeat/native-toc-navigationbut I'm unable to open a PR onantiwork/gumroadsince it's limited to collaborators. The web-side diff is small (1 file, ~40 lines) happy to share it however is preferred.Before/After
before
after
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-02-21.at.12.07.02.mov
Test Results
Checklist
AI Disclosure
Claude Opus 4.6 was used to assist with implementation and test writing.