Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions .changeset/fix-highlights-jam-issues.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@youversion/platform-core': patch
'@youversion/platform-react-hooks': minor
'@youversion/platform-react-ui': patch
---

Fix four highlights-stack bugs surfaced by a staging session, plus a fill fade-in.

- **Invisible primary button (ui):** the `default` button variant paired `bg-background` with `text-primary-foreground`, resolving to white-on-white in the light theme (the highlight permission dialog's Continue button was invisible). It now uses the standard `bg-primary` / `text-primary-foreground` pairing. `YouVersionAuthButton` pins `bg-background` explicitly so its neutral brand surface is unchanged.
- **Optimistic overlay dropped before the write is visible (ui):** after a successful highlight write the seam hook dropped the optimistic overlay as soon as ANY refetch landed, trusting it as server truth. Under read-after-write lag (staging slowness, prod read replicas) that GET often did not yet reflect the write, so a highlight flickered out and back on apply, or a removed highlight reappeared. The overlay is now retired only once a fetch actually reflects the write (apply → the verse shows the written color; remove → the verse no longer shows the removed color); until then the overlay wins. Reset paths (chapter/version change, sign-out) release any write the server never converges on.
- **Refetch coalescing (hooks behavior change):** `useHighlights.createHighlight` / `deleteHighlight` no longer refetch on each call. Direct consumers of `useHighlights` must now call `refetch()` themselves after their mutations settle. The sole in-repo consumer (`useBibleReaderHighlights`) now issues a single refetch after each batch settles — success or failure — so highlighting verses `[2,3,5]` fires two POSTs but only one GET (previously two). Batches with partial failures settle per sub-write: succeeded writes reconcile to server truth, only the failed writes' verses revert.
- **DELETE by range is unsupported (core + ui):** range passage-ids (e.g. `JHN.1.2-3`) returned a non-2xx from staging and threw. The remove path now sends one DELETE per verse (`JHN.1.2`, `JHN.1.3`); POST/apply still collapses to ranges, which works. The `HighlightsClient.deleteHighlight` docstring no longer claims range delete is supported (marked unverified pending API-team confirmation). Large removals issue more DELETEs but still coalesce to a single refetch.
- **Highlight fade-in (core styles):** verse highlight fills now transition their background color (~250ms, disabled under `prefers-reduced-motion`) instead of popping in, matching the Bible app. The selection underline and layout are untouched.

Known/accepted trade-off (pending product sign-off): the optimistic overlay holds the locally-written value until a fetch reflects that write. A concurrent change from another device to the same verse therefore renders stale until navigation or the next write on this client. This is the deliberate cost of eliminating the flicker/ghost bugs above; documented in code comments in `useBibleReaderHighlights`.
11 changes: 11 additions & 0 deletions .changeset/highlight-auth-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@youversion/platform-core': patch
'@youversion/platform-react-hooks': patch
'@youversion/platform-react-ui': patch
---

Add the highlight auth flow: a color tap in BibleReader without a session or the `highlights` permission now stashes the intent and runs the two-path grant flow (YPE-1034, still behind the internal `HIGHLIGHTS_LIVE` flag).

- **Core**: new `DataExchangeClient.updateToken` (`POST /data-exchange/token`, 201 → `{ token }`, Zod-validated) plus `buildDataExchangeUrl` / `parseDataExchangeCallback` / `handleDataExchangeCallback` for the hosted just-in-time grant. Sign-in and data-exchange callbacks now parse `granted_permissions` and seed an optimistic permission cache on `YouVersionPlatformConfiguration` (`grantedPermissions`, `hasPermission`, `saveGrantedPermissions`, `removeGrantedPermission`); the cache is cleared on sign-out and a 401/403 invalidates it (server truth wins).
- **Hooks**: new `useHighlightAuthActions` exposing the one-fell-swoop sign-in (requesting `highlights`), the just-in-time data-exchange redirect, the permission-cache reads/invalidation, and the data-exchange return handler.
- **UI**: `useBibleReaderHighlights` now runs the state machine — pending highlights persist to `sessionStorage` (~10-minute expiry) to survive the redirect round-trip and apply automatically on a granted return; a just-in-time permission confirm dialog (`HighlightPermissionDialog`, copy matched to the native SDK) gates the data-exchange grant. Write failures route by status: 401/403 invalidates the cache, keeps the pending highlight, and re-prompts; 5xx/network reverts the optimistic overlay and discards. Apply/remove writes are serialized through a FIFO queue with per-verse ownership so overlapping operations settle to the last-issued state. Copy/share-only behavior when no auth provider is configured is unchanged.
12 changes: 12 additions & 0 deletions .changeset/xstate-highlights-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@youversion/platform-react-ui': patch
---

Rewrite the BibleReader highlights flow as an explicit xstate v5 statechart (YPE-1034 / PR-288, still behind the internal `HIGHLIGHTS_LIVE` flag). `useBibleReaderHighlights` is now a thin adapter over `bibleReaderHighlightsMachine`; the machine (authored with `setup()` and named guards/actions/actors, so it is Stately-visualizable) owns the whole flow — optimistic overlay, serialized writes with per-verse ownership, reconcile, the auth flow, and the resume-on-return path. A mermaid statechart lives in `docs/highlight-flow-statechart.md`. All previously-shipped invariants are preserved.

Behavior changes:

- **Sign-in dialog first (signed out):** a signed-out color tap now opens a `SignInDialog` (introducing the app, with an optional integrator prompt) instead of redirecting to OAuth immediately. Confirm launches the sign-in redirect requesting `highlights`; decline/dismiss discards the pending highlight and keeps the verse selection. The dialog's `appName` comes from `YouVersionPlatformConfiguration.appName` (falling back to "This app") and its prompt from `signInPromptMessage`. The hook return gains `signInDialogOpen`, `confirmSignInDialog`, and `cancelSignInDialog`.
- **Flag-off hides the highlights UI:** when `HIGHLIGHTS_LIVE` is off, `VerseActionPopover` hides the color row and the remove (checkmark) circles entirely via a new `highlightsEnabled` prop — only Copy / Share remain. Previously the row still rendered while taps were inert.
- **"Vapor" fix:** a deleted highlight no longer briefly reappears when a stale read-replica fetch lands after the delete settled. The reconcile step no longer retires remove-overlay entries; a removed verse is held until a reset path (scope change, sign-out, or a newer write). Apply-side convergence is unchanged. This matches the already-accepted trade-off (a concurrent same-verse edit from another device renders stale until navigation or the next write).
- **Remove-failure no longer re-prompts:** a 401/403 on a remove invalidates the permission cache but no longer opens the permission dialog (there is no pending highlight to resume), resolving a documented deferred wart.
25 changes: 25 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,28 @@ reader is inert: no fetches, no writes, nothing rendered from the API.

The floating action bar (YPE-642) that appears over a verse selection,
offering highlight colors, copy, and share.

## Highlights permission

The data-exchange permission (`highlights`) an app must hold before it can
read or write a user's highlights. Permissions are open-ended strings, not
enums — more arrive later — and the server reports the ones a user granted
back to the app via `granted_permissions` on the sign-in and data-exchange
callbacks (YPE-1034).

## Highlight auth flow

The flow (YPE-1034) that turns a color tap into an applied highlight when the
reader is not yet authorized. A tap forks: authorized writes optimistically;
signed out opens the sign-in dialog; signed in without the **highlights
permission** opens the just-in-time permission dialog. Consent routes through
a full-page data-exchange redirect and resumes on return.

## Pending highlight

A user's stashed highlight intent (verses, color, scope) held in
`sessionStorage` while the **highlight auth flow** is in flight (YPE-1034). It
survives the full-page redirect round-trip and expires (~10 min) so an
abandoned round-trip can never silently apply a highlight during a much later
sign-in. It is intent, not highlight data (highlights stay server-only,
ADR-001); discarded on decline, cancel, failure, or successful apply.
101 changes: 101 additions & 0 deletions docs/highlight-flow-statechart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# BibleReader highlights flow — statechart (PR-288)

The BibleReader highlights flow is an [xstate v5](https://stately.ai/docs) state
machine: [`packages/ui/src/components/bible-reader-highlights-machine.ts`](../packages/ui/src/components/bible-reader-highlights-machine.ts).
`useBibleReaderHighlights` is a thin adapter that feeds React-owned inputs (auth,
the `HIGHLIGHTS_LIVE` flag, the fetched highlights, the scope) into the machine
as events and reads back the dialog states + the rendered verse map.

The machine is authored with `setup()` and named guards/actions/actors so it is
statically analyzable — paste the source into the [Stately visualizer](https://stately.ai/viz)
to explore it interactively.

## States and events

- **`booting`** → routes to `disabled` or `enabled` from the initial input.
- **`disabled`** — the flag is off **or** no auth provider is mounted. Fully
inert: no fetch, no writes, no dialogs. A color tap resolves to `noop`.
- **`enabled`** — a parallel state with two independent regions:
- **`flow`** — the auth / dialog flow.
- `resuming` consumes the data-exchange return exactly once, then routes on
the pending highlight + auth + permission.
- `awaitingAuth` waits for the authenticated flip after a redirect return.
- `idle` is interactive.
- `signInDialog` / `permissionDialog` are the two consent dialogs.
- **`writer`** — serialized optimistic writes. `idle → writing → checkQueue`,
processing one queued operation at a time so a DELETE can never overtake an
in-flight POST for the same verse.

`TAP_COLOR` forks in `flow`: authorized (`applied`) → optimistic write; signed
out → `signInDialog`; signed in without the permission → `permissionDialog`.
Both dialog paths stash a pending highlight (10-min `sessionStorage` TTL) so the
intent survives the full-page redirect and resumes on a granted return.

The stash is a **list**, not a single slot: a fresh tap replaces it, but a queued
optimistic write that loses permission (401/403) _appends_ its intent with
verse-level last-wins. Two writes queued in different colors can each 401, and
both intents must survive the re-grant — a single slot would let the second
overwrite the first. On resume, `applyPendingHighlight` re-applies every live
entry (each to its own scope, first-to-last); entries never overlap on verses.

```mermaid
stateDiagram-v2
[*] --> booting
booting --> disabled: flag off / no provider
booting --> enabled: flag on & provider

disabled --> enabled: AUTH_CHANGED (enabled)
enabled --> disabled: AUTH_CHANGED (disabled)

state disabled {
note right of disabled
TAP_COLOR → outcome "noop"
no fetch / writes / dialogs
end note
}

state enabled {
--
state flow {
[*] --> resuming
resuming --> idle: no pending
resuming --> awaitingAuth: pending & not authed
resuming --> idle: pending & authed & permission / applyPending
resuming --> permissionDialog: pending & authed & no permission

awaitingAuth --> idle: authed & permission / applyPending
awaitingAuth --> permissionDialog: authed & no permission

idle --> idle: TAP_COLOR authorized / optimistic write
idle --> signInDialog: TAP_COLOR signed out / stash pending
idle --> permissionDialog: TAP_COLOR no permission / stash pending

signInDialog --> idle: CONFIRM_SIGN_IN / start sign-in redirect
signInDialog --> idle: DECLINE_SIGN_IN / clear pending

permissionDialog --> idle: CONFIRM_PERMISSION / start data-exchange
permissionDialog --> idle: CANCEL_PERMISSION / clear pending

idle --> permissionDialog: PERMISSION_LOST (401/403 on write)
}
--
state writer {
[*] --> w_idle
w_idle --> writing: queue has work
writing --> checkQueue: processWrite done / settle + shift
checkQueue --> writing: queue has work
checkQueue --> w_idle: queue empty
}
}
```

_(`ENQUEUE`, `AUTH_CHANGED`, `HIGHLIGHTS_UPDATED`, and `SCOPE_CHANGED` are handled
without leaving the current state: they update context and let the `always`
guards re-route. `HIGHLIGHTS_UPDATED` also runs the overlay reconcile.)_

## The "vapor" fix
Comment thread
cameronapak marked this conversation as resolved.

See the "vapor" fix block comment at the top of
`packages/ui/src/components/bible-reader-highlights-machine.ts` for the root
cause, fix, and trade-off — that comment sits next to `reconcileOverlay` and is
the single source of truth for this rationale.
17 changes: 16 additions & 1 deletion packages/core/src/Users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { YouVersionUserInfo } from './YouVersionUserInfo';
import { YouVersionPlatformConfiguration } from './YouVersionPlatformConfiguration';
import { SignInWithYouVersionPKCEAuthorizationRequestBuilder } from './SignInWithYouVersionPKCE';
import { SignInWithYouVersionResult } from './SignInWithYouVersionResult';
import { parseGrantedPermissions } from './permissions';

export class YouVersionAPIUsers {
/**
Expand Down Expand Up @@ -121,6 +122,12 @@ export class YouVersionAPIUsers {
token_type: string;
};

// Parse the data-exchange permissions the server granted. The server
// echoes them as `granted_permissions` on the callback URL (comma- or
// space-separated, param may repeat). Used below to seed the optimistic
// permission cache — the single source of truth for granted permissions.
const grantedPermissions = parseGrantedPermissions(urlParams);

// Extract user info from ID token
const result = this.extractSignInResult(tokens);

Expand All @@ -133,14 +140,22 @@ export class YouVersionAPIUsers {
);

// Persist the decoded user profile so it survives reloads without
// retaining the ID token itself.
// retaining the ID token itself. This must happen before the permission
// cache is seeded below, since grants are scoped to the current user.
YouVersionPlatformConfiguration.saveUserInfo({
id: result.yvpUserId,
name: result.name,
email: result.email,
avatar_url: result.profilePicture,
});

// Persist the granted permissions into the optimistic permission cache so
// a one-fell-swoop sign-in that requested `highlights` can apply a pending
// highlight on return without a probe round-trip.
if (grantedPermissions.length > 0) {
YouVersionPlatformConfiguration.saveGrantedPermissions(grantedPermissions);
}

// Clean up localStorage
localStorage.removeItem('youversion-auth-code-verifier');
localStorage.removeItem('youversion-auth-redirect-uri');
Expand Down
Loading
Loading