Skip to content

Add native table of contents navigation for multi-page content products - #70

Merged
binary-koan merged 5 commits into
antiwork:mainfrom
yashranaway:feat/native-toc-navigation
Feb 28, 2026
Merged

binary-koan merged 5 commits into
antiwork:mainfrom
yashranaway:feat/native-toc-navigation

Conversation

@yashranaway

Copy link
Copy Markdown
Contributor

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):

  • New ContentPageNav component: sticky footer with Previous/Next, page counter, and TOC sheet
  • Updated purchase page to handle tocData messages from web and send mobileAppPageChange messages back

Web changes (gumroad):

  • Hide web TOC/Previous/Next nav when in mobile app WebView
  • Send tocData message to React Native with pages and active index
  • Listen for mobileAppPageChange messages from React Native

Companion web changes: This feature requires changes to gumroad web repo (WithContent.tsx). The implementation is ready on branch feat/native-toc-navigation but I'm unable to open a PR on antiwork/gumroad since 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

image

after

Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-02-21.at.12.07.02.mov

Test Results

image

Checklist


AI Disclosure

Claude Opus 4.6 was used to assist with implementation and test writing.

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 yashranaway left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@binary-koan _a Could you please take a look?

Comment thread components/content-page-nav.tsx Outdated
Comment on lines +8 to +11
type TocPage = {
page_id: string;
title: string | null;
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread components/content-page-nav.tsx Outdated
<Pressable
onPress={() => onPageChange(activePageIndex - 1)}
disabled={!hasPrevious}
className={cn("flex-row items-center gap-1", !hasPrevious && "opacity-50")}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following the codebase pattern from Button, Checkbox, RadioGroup etc. where disabled Pressables get opacity-50 rather than changing individual child colors.

Comment thread components/content-page-nav.tsx Outdated
Comment on lines +38 to +43
<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>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +55 to +90
<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>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using page_id from the web data as the key, which is guaranteed unique per product.

Comment thread app/purchase/[id].tsx
Comment on lines +36 to +43
type TocDataMessage = {
type: "tocData";
payload: {
pages: { page_id: string; title: string | null }[];
activePageIndex: number;
};
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/purchase/[id].tsx
export default function DownloadScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const [isDownloading, setIsDownloading] = useState(false);
const [tocPages, setTocPages] = useState<TocDataMessage["payload"]["pages"]>([]);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/purchase/[id].tsx Outdated
Comment on lines +74 to +80
const handleNativePageChange = useCallback(
(pageIndex: number) => {
webViewRef.current?.postMessage(JSON.stringify({ type: "mobileAppPageChange", payload: { pageIndex } }));
},
[],
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/purchase/[id].tsx
const data = event.nativeEvent.data;
try {
const message = JSON.parse(data) as ClickMessage;
const message = JSON.parse(data) as ClickMessage | TocDataMessage;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/purchase/[id].tsx
Comment on lines +87 to +92
if (message.type === "tocData") {
setTocPages(message.payload.pages);
setActivePageIndex(message.payload.activePageIndex);
return;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just stores the page list and active index. The web side is the single source of truth b.

@yashranaway
yashranaway marked this pull request as ready for review February 21, 2026 07:10
@binary-koan

Copy link
Copy Markdown
Contributor

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 props.is_mobile_app_web_view will hide it on the current native apps which still need it

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!)

@yashranaway

Copy link
Copy Markdown
Contributor Author

@binary-koan _a Thanks I have made these changes,

  • For the shared hook I extracted a useReactNativeMessage hook that handles the window vs document listener, JSON parsing, and type checking. Both WithContent.tsx and FileList.tsx use it now.

  • For Expo detection, didn't think about the existing native apps still needing the web TOC. Changed the Expo app to send display=expo_app instead of mobile_app. On the Rails side, is_mobile_app_web_view stays true for both (so Layout hiding etc. still works for all native apps), and a new is_expo_app flag gates just the TOC stuff (hiding the web nav + sending tocData messages).

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!

@binary-koan

Copy link
Copy Markdown
Contributor

Works great locally, had a go at updating the design. The Previous/Next buttons being different widths and making the middle button slightly off center was bugging me so I tried changing the order, and also generally updated the design to be closer to what we have on web.

Simulator Screenshot - iPhone 16 Pro - 2026-02-27 at 23 46 56 Simulator Screenshot - iPhone 16 Pro - 2026-02-28 at 00 38 06

Will do a final check and merge tomorrow

@yashranaway

Copy link
Copy Markdown
Contributor Author

Looks great! Thanks.

binary-koan added a commit to antiwork/gumroad that referenced this pull request Feb 28, 2026
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>
@binary-koan
binary-koan merged commit 0719345 into antiwork:main Feb 28, 2026
2 checks passed
@yashranaway
yashranaway deleted the feat/native-toc-navigation branch February 28, 2026 16:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants