Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
8b59aba
fix(core): align HighlightsClient with the live highlights API contract
Dustin-Kelley Jul 7, 2026
906379b
Merge branch 'main' into dk/fix-highlights-api-contract
cameronapak Jul 8, 2026
fa496e2
fix(core): accept uppercase hex colors from highlights API; clarify r…
cameronapak Jul 8, 2026
cb8e8fc
fix(core): handle empty highlights, normalize color, add recent-colors
cameronapak Jul 8, 2026
36fe0e8
docs(core): update HighlightsClient documentation for OAuth permissions
Dustin-Kelley Jul 8, 2026
af72465
feat(ui): integrate highlights functionality into BibleReader and Navbar
Dustin-Kelley Jul 8, 2026
2ec9c36
feat(ui): integrate Highlights API into BibleReader for signed-in users
Dustin-Kelley Jul 8, 2026
0833710
Merge branch 'main' into dk/ype-1034-wire-api-logic-to-highlights
Dustin-Kelley Jul 8, 2026
fae7962
refactor(ui): rename state variables in useReaderHighlights for clarity
Dustin-Kelley Jul 9, 2026
298484c
fix(ui): clear reader highlights on sign-out and user switch
Dustin-Kelley Jul 9, 2026
99cf3c0
fix(ui): normalize highlight colors to lowercase for consistency
Dustin-Kelley Jul 9, 2026
586b78b
test(ui): use userId in useReaderHighlights auth mock to match produc…
Dustin-Kelley Jul 9, 2026
7860b44
fix(ui): ensure optimistic store sync on highlight mutation failures
Dustin-Kelley Jul 9, 2026
6787098
test(ui): add tests for useReaderHighlights behavior when signed out
Dustin-Kelley Jul 9, 2026
131f1cf
fix(ui): user1 highlights from rendering on user2 sign in
Dustin-Kelley Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/wire-highlights-api-to-reader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@youversion/platform-core': minor
'@youversion/platform-react-hooks': minor
'@youversion/platform-react-ui': minor
---

Wire the Highlights API into the Bible reader, replacing the localStorage-only highlight store.

- `BibleReader` now loads and saves verse highlights via the Highlights API for signed-in readers (chapter-scoped `GET`, optimistic `create`/`delete`), with the server as the source of truth. The previous `localStorage` persistence (`youversion-platform:highlights:<versionId>`) is removed.
- The reader's sign-in (and the example navbar button) now request the `highlights` data-exchange permission so the token is granted highlights access.
- `@youversion/platform-react-hooks` now exports `YouVersionAuthContext`, enabling null-safe reads of signed-in state without `useYVAuth()` throwing when no auth provider is mounted.

When not signed in, highlighting is currently ephemeral session state (not persisted); the dedicated not-signed-in / permission flow is a follow-up.
34 changes: 34 additions & 0 deletions docs/adr/YPE-642-verse-action-popover.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,37 @@ Selection is always enabled in BibleReader (no opt-out prop for now; YAGNI).
(SSR) says prefer `useEffect`. Pre-existing; clean up while here.
- #131 leftovers to handle: replace the old verse-selection Storybook story +
remove demo highlight CSS; add a changeset.

## As-built addendum — YPE-1034 (Highlights API wiring)

**ADR-001 (localStorage only) is superseded.** The follow-up ticket it named
landed: highlights now sync via the Highlights API for signed-in readers, and
the localStorage store is removed entirely.

- **Persistence:** `bible-reader.tsx` `Content` no longer owns a
localStorage-backed `highlightStore` / `persistHighlights`. The
`youversion-platform:highlights:<versionId>` key is gone and is never written.
Highlight state now lives in the new `lib/use-reader-highlights.ts` hook.
- **Signed in:** `useReaderHighlights` fetches via `useHighlights` (chapter-scoped
`GET /v1/highlights`), and apply/remove call `createHighlight` /
`deleteHighlight` optimistically per verse. The server is the source of truth —
the store is rebuilt from the fetched collection, filtered to the current
`version_id` + chapter prefix so a stale mid-refetch collection can't leak
another scope's colors (this replaces ADR-001's version-keyed reset guard).
- **Signed out (interim):** highlights are ephemeral session state — applied to
the in-memory store, never persisted, no API calls. This is a placeholder: the
not-signed-in / permission state machine (a later ticket) will replace it with
a pending-highlight → sign-in flow that defers the highlight until the
`highlights` permission is granted. ADR-002's API-shaped model made this swap
mechanical, as intended.
- **Auth read:** `useReaderHighlights` reads sign-in state via the raw
`YouVersionAuthContext` (now exported from `@youversion/platform-react-hooks`)
rather than `useYVAuth()`, which throws when no auth provider is mounted — the
reader must keep working for consumers who don't enable auth.
- **Permission at sign-in:** the reader's `handleSignIn` (and the example navbar
button) request `permissions: ['highlights']` so the token is actually granted
highlights access. This is a data-exchange **permission**
(`requested_permissions[]`), not an OIDC scope — see the corrected
`HighlightsClient` docs.
- **Out of scope (unchanged):** failure/revert UX (optimistic + `console.error`
only) and `getRecentColors` (popover keeps its hardcoded colors).
7 changes: 6 additions & 1 deletion examples/vite-react/src/components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { Menu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { ModeToggle } from '@/components/mode-toggle';
import { useYVAuth, YouVersionAuthButton } from '@youversion/platform-react-ui';
import {
SignInWithYouVersionPermission,
useYVAuth,
YouVersionAuthButton,
} from '@youversion/platform-react-ui';
import type { Page } from '@/App';

const navItems: { label: string; page: Page }[] = [
Expand Down Expand Up @@ -88,6 +92,7 @@ export function Navbar({ currentPage, onNavigate }: NavbarProps) {
size="short"
onAuthError={(err) => console.error('Auth error:', err)}
scopes={['profile', 'email']}
permissions={[SignInWithYouVersionPermission.highlights]}
/>
)}

Expand Down
17 changes: 10 additions & 7 deletions packages/core/src/highlights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ export type DeleteHighlightOptions = {

/**
* Client for interacting with Highlights API endpoints.
* Note: All endpoints require OAuth authentication with appropriate scopes
* (`read_highlights` / `write_highlights`), passed as an
* `Authorization: Bearer <token>` header.
* Note: All endpoints require an OAuth access token passed as an
* `Authorization: Bearer <token>` header, and the user must have granted the
* `highlights` data-exchange permission at sign-in. That permission is
* requested via the `requested_permissions[]` authorize param (see
* `SignInWithYouVersionPermission`) — it is NOT an OIDC scope and never appears
* in the token's `scope`. A token without it yields 403/404 from these routes.
*/
export class HighlightsClient {
private client: ApiClient;
Expand Down Expand Up @@ -129,7 +132,7 @@ export class HighlightsClient {
/**
* Fetches a collection of highlights for a user.
* The response will return a color per verse without ranges.
* Requires OAuth with read_highlights scope.
* Requires the `highlights` permission (see class note).
* @param options Query parameters scoping the fetch. `version_id` and `passage_id`
* (verse or chapter USFM, e.g. "JHN.3") are both required by the API.
* @param lat Optional long access token. If not provided, retrieves from YouVersionPlatformConfiguration.
Expand Down Expand Up @@ -166,7 +169,7 @@ export class HighlightsClient {
/**
* Fetches the user's recently used highlight colors, followed by the default
* colors, as hex strings (no `#`). Useful for building a color picker.
* Requires OAuth with read_highlights scope.
* Requires the `highlights` permission (see class note).
* @param lat Optional long access token. If not provided, retrieves from YouVersionPlatformConfiguration.
* @returns An ordered list of hex color strings.
*/
Expand All @@ -193,7 +196,7 @@ export class HighlightsClient {
/**
* Creates or updates a highlight on a passage.
* Verse ranges may be used in the passage_id attribute.
* Requires OAuth with write_highlights scope.
* Requires the `highlights` permission (see class note).
* @param data The highlight data to create or update.
* @param lat Optional long access token. If not provided, retrieves from YouVersionPlatformConfiguration.
* @returns The created or updated Highlight object.
Expand Down Expand Up @@ -229,7 +232,7 @@ export class HighlightsClient {

/**
* Clears highlights for a passage.
* Requires OAuth with write_highlights scope.
* Requires the `highlights` permission (see class note).
* @param passageId The passage identifier (USFM format, e.g., "MAT.1.1" or "MAT.1.1-5").
* @param options Query parameters; `version_id` is required by the API (sent as `bible_id`).
* @param lat Optional long access token. If not provided, retrieves from YouVersionPlatformConfiguration.
Expand Down
1 change: 1 addition & 0 deletions packages/hooks/src/context/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './YouVersionContext';
export * from './YouVersionProvider';
export * from './YouVersionAuthContext';
77 changes: 24 additions & 53 deletions packages/ui/src/components/bible-reader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@

import i18n from '@/i18n';
import { useDelayedLoading } from '@/lib/use-delayed-loading';
import { useReaderHighlights } from '@/lib/use-reader-highlights';
import { cn } from '@/lib/utils';
import { INTER_FONT, SOURCE_SERIF_FONT, type FontFamily } from '@/lib/verse-html-utils';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import type { BibleBook } from '@youversion/platform-core';
import { DEFAULT_LICENSE_FREE_BIBLE_VERSION, getAdjacentChapter } from '@youversion/platform-core';
import {
DEFAULT_LICENSE_FREE_BIBLE_VERSION,
getAdjacentChapter,
SignInWithYouVersionPermission,
} from '@youversion/platform-core';
import {
useBooks,
usePassage,
Expand Down Expand Up @@ -503,40 +508,22 @@ function Content() {
}, [book, chapter]);

// ---- Verse selection + highlights ------------------------------------------
// Selection is ephemeral; highlights persist to localStorage only for now
// (ADR-001) in the future `highlight` API shape: keyed by full passage_id USFM
// and scoped by versionId/bible_id (ADR-002). The reader DOM ref lets us anchor
// the popover and pull clean verse text for Copy / Share.
// Selection is ephemeral. Highlights sync via the Highlights API for signed-in
// readers (chapter-scoped, keyed by full passage_id USFM); when not signed in
// they are ephemeral session state (never persisted) — see useReaderHighlights.
// The reader DOM ref lets us anchor the popover and pull clean verse text for
// Copy / Share.
const readerRef = useRef<HTMLDivElement>(null);
const [selectedVerses, setSelectedVerses] = useState<number[]>([]);
const [popoverOpen, setPopoverOpen] = useState(false);
const [anchorElement, setAnchorElement] = useState<HTMLElement | null>(null);
const lastSelectionRef = useRef<number[]>([]);
const [highlightStore, setHighlightStore] = useState<Record<string, string>>({});

const highlightsStorageKey = `youversion-platform:highlights:${versionId}`;

// Clear the store synchronously (during render) the moment the version key
// changes. The load effect below runs *after* paint, so without this the
// previous version's highlights would paint over the new text for one frame —
// their `${book}.${chapter}.N` keys collide with the new version's verses.
const [loadedHighlightsKey, setLoadedHighlightsKey] = useState(highlightsStorageKey);
if (loadedHighlightsKey !== highlightsStorageKey) {
setLoadedHighlightsKey(highlightsStorageKey);
setHighlightStore({});
}

// Load this version's highlights when the version changes (client-only).
useEffect(() => {
let data: Record<string, string> = {};
try {
const raw = localStorage.getItem(highlightsStorageKey);
if (raw) data = JSON.parse(raw) as Record<string, string>;
} catch {
// Ignore (unavailable or malformed storage).
}
setHighlightStore(data);
}, [highlightsStorageKey]);
const { highlightsByPassageId, applyHighlight, removeHighlight } = useReaderHighlights({
versionId,
book,
chapter,
});

// Navigating away (book/chapter/version) drops the selection — those verses no
// longer exist on screen (ADR-007).
Expand All @@ -551,13 +538,13 @@ function Content() {
const chapterPrefix = `${book}.${chapter}.`;
const highlightedVerses = useMemo(() => {
const map: Record<number, string> = {};
for (const [passageId, color] of Object.entries(highlightStore)) {
for (const [passageId, color] of Object.entries(highlightsByPassageId)) {
if (!passageId.startsWith(chapterPrefix)) continue;
const verseNum = parseInt(passageId.slice(chapterPrefix.length), 10);
if (verseNum) map[verseNum] = color;
}
return map;
}, [highlightStore, chapterPrefix]);
}, [highlightsByPassageId, chapterPrefix]);

// Distinct colors present in the current selection → drives the X (remove) circles.
const activeHighlights = useMemo(
Expand All @@ -570,14 +557,6 @@ function Content() {
[selectedVerses, highlightedVerses],
);

function persistHighlights(next: Record<string, string>) {
try {
localStorage.setItem(highlightsStorageKey, JSON.stringify(next));
} catch {
// Ignore (private mode / quota exceeded).
}
}

function closeAndClearSelection() {
setPopoverOpen(false);
setSelectedVerses([]);
Expand Down Expand Up @@ -607,23 +586,12 @@ function Content() {
}

function handleHighlight(color: string) {
const next = { ...highlightStore };
for (const verse of selectedVerses) {
next[`${book}.${chapter}.${verse}`] = color;
}
setHighlightStore(next);
persistHighlights(next);
applyHighlight(selectedVerses, color);
closeAndClearSelection();
}

function handleClearHighlight(color: string) {
const next = { ...highlightStore };
for (const verse of selectedVerses) {
const passageId = `${book}.${chapter}.${verse}`;
if (next[passageId] === color) delete next[passageId];
}
setHighlightStore(next);
persistHighlights(next);
removeHighlight(selectedVerses, color);

// Multiple colors active → keep open so the user can remove others (AC 8a);
// last color removed → dismiss (AC 8).
Expand Down Expand Up @@ -844,7 +812,10 @@ function UserMenu() {
if (onSignInPress) {
onSignInPress();
} else {
void signIn({ scopes: ['profile'] });
void signIn({
scopes: ['profile'],
permissions: [SignInWithYouVersionPermission.highlights],
});
}
};
const handleSignOut = () => {
Expand Down
Loading
Loading