Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/rn-expo-version-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@youversion/platform-react-native-expo-core': minor
'@youversion/platform-react-native-expo-ui': minor
---

Add optional version filter lists to `YouVersionProvider`: `permittedVersionIds`, `excludedVersionIds`, and `permittedLanguageTags`. The UI provider forwards them through native wrappers into each DOM web `YouVersionProvider`. Filtering runs in the web SDK once a 4657-shaped `@youversion/platform-react-ui` is published and pinned.
8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ _Avoid_: Picker modal, chapter popover
A **Native Wrapper** that hosts Bible version picker content inside one **Native Sheet**. The native side passes the current `versionId` in and receives a new `versionId` via `onSelect`. In-sheet navigation (version list ↔ language list) is owned by the **Version Picker Shell Layout** — not native.
_Avoid_: Version modal, stacked picker sheets, native language-panel flags

**Version Filter**:
Optional allowlists on core `YouVersionProvider` — `permittedVersionIds`, `excludedVersionIds`, `permittedLanguageTags` — that restrict which Bible versions and languages the web SDK may use. Unset permit list = no restriction; `[]` = permit nothing; exclusion wins; language tags are BCP 47. Native stores and forwards the lists into each Expo DOM web `YouVersionProvider`; it does not re-implement the web usability predicate.
_Avoid_: Per-component filter props; a native `isUsableVersion` helper; renaming to platform-configuration types

**Version Refuse**:
When a persisted or host `versionId` is not permitted, native chrome still passes that id into the WebView and lets the web SDK refuse. Native does not auto-pick another version, silently fall back to the default version id, or rewrite **Reader Location** / Bible Card version MMKV on refuse. First-open defaults when there is no stored or host id are unchanged.
_Avoid_: Silent 3034 swap; rewriting recents or persisted location on refuse; picker-only refuse while text still renders

**Version Picker Shell Layout**:
The Expo DOM wrapper (`bible-version-picker-content.tsx`) for version picker sheet content. It owns the version ↔ language cross-fade, shell height, and keyboard overlap via `visualViewport` (same role as **Chapter Picker Shell Layout** for chapter picker). Web uses Radix popover + `isLanguagesOpen`; mobile duplicates layout outside that **Presentation Shell**. On the language trigger, call `event.preventDefault()` so the Web SDK does not also run `setIsLanguagesOpen`.
_Avoid_: Assuming `BibleVersionPicker.Content` popover layout applies inside **Native Sheet**
Expand Down
10 changes: 5 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ commit — Android **and** iOS. New commits do not rebuild automatically. To
upload the current PR head again, an approved collaborator comments one of
the following on the open PR:

| Comment | Platforms rebuilt |
|---|---|
| `/app-live <sha>` | Android **and** iOS |
| `/app-live-ios <sha>` | iOS only |
| `/app-live-android <sha>` | Android only |
| Comment | Platforms rebuilt |
| ------------------------- | ------------------- |
| `/app-live <sha>` | Android **and** iOS |
| `/app-live-ios <sha>` | iOS only |
| `/app-live-android <sha>` | Android only |

`<sha>` is the full 40-character sha of the head commit being approved, and
the comment must contain nothing else. The single-platform commands are
Expand Down
4 changes: 3 additions & 1 deletion apps/example/app/(tabs)/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ export default function ProfileScreen() {
) : null}
<Text style={[styles.muted, { color: theme.muted }]}>You are signed in as</Text>
<Text style={[styles.name, { color: theme.fg }]}>{userInfo?.name ?? '(no name)'}</Text>
<Text style={[styles.email, { color: theme.email }]}>{userInfo?.email ?? '(no email)'}</Text>
<Text style={[styles.email, { color: theme.email }]}>
{userInfo?.email ?? '(no email)'}
</Text>
<View style={styles.button}>
<YouVersionAuthButton mode="signOut" background={colorScheme} outline />
</View>
Expand Down
5 changes: 4 additions & 1 deletion apps/example/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ const REDIRECT_URI = 'youversionauth://callback'
export default function RootLayout() {
const appKey = process.env.EXPO_PUBLIC_YOUVERSION_APP_KEY


return (
<GestureHandlerRootView style={{ flex: 1 }}>
{appKey ? (
Expand All @@ -26,6 +25,10 @@ export default function RootLayout() {
scopes: ['profile', 'email'],
permissions: ['highlights'],
}}
// Optional version filter (forwarded to the web SDK once published):
// permittedVersionIds={[111, 206]}
// excludedVersionIds={[3034]}
// permittedLanguageTags={['en', 'zh-Hans']}
>
<Stack screenOptions={{ headerShown: false }} />
</YouVersionProvider>
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0013-native-highlights-optimistic-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The paint math lives in `packages/core/src/highlights/optimistic.ts`. It is a po

Web never retires a remove overlay. That stops a stale GET from painting the color that was just deleted ("vapor"). The cost is unbounded: a new color from another device stays invisible until the user leaves the chapter.

We keep the vapor fix and drop most of that cost. If the server reports a *different* color, the remove entry retires. That color cannot be an echo of the deletion. It is newer data. The remaining failure needs the server two steps behind (green → yellow → remove, then a GET that still reports green).
We keep the vapor fix and drop most of that cost. If the server reports a _different_ color, the remove entry retires. That color cannot be an echo of the deletion. It is newer data. The remaining failure needs the server two steps behind (green → yellow → remove, then a GET that still reports green).

`shouldRetire` is one named function for that reason. Revert to web behavior with `return false` in the remove branch. Tests pin both directions, because each side reads like a bug.

Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0014-cached-grant-is-a-hint.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ The cache is a **hint**. Use it to choose UI and to skip a redundant prompt. The

Clearing stays best-effort. No extra write is layered on a store that just refused a write.

| Failure | Behavior |
| --- | --- |
| Normal clear | Entry removed |
| Failure | Behavior |
| ------------- | -------------------------------------------- |
| Normal clear | Entry removed |
| Removal fails | Stale grant accepted — bounded by the server |

The second row needs an MMKV removal to fail. The worst outcome is a skipped prompt, then a request the server denies. Reviewers will keep rediscovering this. It is a decision, not a missing `try`.
Expand Down
10 changes: 5 additions & 5 deletions docs/adr/0015-data-exchange-return-scheme.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ The first version of this ADR set a hardcoded `youversionauth://callback` and to

Measured on a Pixel 6 Pro API 34 with a real app key (2026-08-04):

| Registered callback | Return reached the SDK |
| --- | --- |
| App `redirectUri` only | Yes, when the SDK watched that same URI |
| SDK-owned scheme only | Yes for data exchange. Sign-in then failed with `invalid_request: redirect_uri does not match registered callback URL` |
| Both registered | The page still returned to the app key URL. A watcher on the other scheme saw `cancel` |
| Registered callback | Return reached the SDK |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| App `redirectUri` only | Yes, when the SDK watched that same URI |
| SDK-owned scheme only | Yes for data exchange. Sign-in then failed with `invalid_request: redirect_uri does not match registered callback URL` |
| Both registered | The page still returned to the app key URL. A watcher on the other scheme saw `cancel` |

Swift and Kotlin already use one URL for both flows (`youversionauth://callback`). The example app matches that. Consumers can pass their own `redirectUri`. The SDK does not care which string it is. It must match the registered callback.

Expand Down
21 changes: 21 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,27 @@ export default function App() {
}
```

### Version filter

Optional lists on `YouVersionProvider` restrict which Bible versions and languages the SDK may use. They are stored on the provider context and forwarded into each DOM component's web `YouVersionProvider` for the web SDK to enforce.

- `permittedVersionIds?: number[]` — unset means no restriction; `[]` means permit nothing
- `excludedVersionIds?: number[]` — exclusion wins over permits
- `permittedLanguageTags?: string[]` — BCP 47 tags (e.g. `en`, `zh-Hans`)

Native chrome does not auto-pick another version or rewrite persisted reader location when a stored or host `versionId` is refused. The id is still passed into the WebView; the web SDK handles version refuse. First-open defaults when there is no stored or host id are unchanged.

```tsx
<YouVersionProvider
appKey="YOUR_APP_KEY"
permittedVersionIds={[111, 206]}
excludedVersionIds={[3034]}
permittedLanguageTags={['en']}
>
{/* ... */}
</YouVersionProvider>
```

### Permissions

`auth.permissions` asks for YouVersion Platform permissions (e.g. `'highlights'`) at sign-in; the user can decline. Read the grant back with `useYVAuth()`: `hasPermission(permission)`, or `grantedPermissions` for the list (`null` = nothing requested or nothing known yet, `[]` = declined).
Expand Down
53 changes: 53 additions & 0 deletions packages/core/src/__tests__/youversion-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,57 @@ describe('YouVersionProvider', () => {
expect(screen.getByTestId('content')).toBeTruthy()
expect(MockDrainHost).toHaveBeenCalled()
})

it('stores version filter lists on context when provided', () => {
render(
<YouVersionProvider
appKey="appkey"
permittedVersionIds={[111, 206]}
excludedVersionIds={[3034]}
permittedLanguageTags={['en', 'zh-Hans']}
>
<ContextPeek />
</YouVersionProvider>,
)

expect(JSON.parse(screen.getByTestId('ctx').props.children)).toEqual(
expect.objectContaining({
installationId: 'inst-1',
appKey: 'appkey',
apiHost: 'api.youversion.com',
permittedVersionIds: [111, 206],
excludedVersionIds: [3034],
permittedLanguageTags: ['en', 'zh-Hans'],
}),
)
})

it('keeps unset version filter lists distinct from empty arrays on context', () => {
const { rerender } = render(
<YouVersionProvider appKey="appkey">
<ContextPeek />
</YouVersionProvider>,
)

const unset = JSON.parse(screen.getByTestId('ctx').props.children) as Record<string, unknown>
expect(unset.permittedVersionIds).toBeUndefined()
expect(unset.excludedVersionIds).toBeUndefined()
expect(unset.permittedLanguageTags).toBeUndefined()

rerender(
<YouVersionProvider
appKey="appkey"
permittedVersionIds={[]}
excludedVersionIds={[]}
permittedLanguageTags={[]}
>
<ContextPeek />
</YouVersionProvider>,
)

const empty = JSON.parse(screen.getByTestId('ctx').props.children) as Record<string, unknown>
expect(empty.permittedVersionIds).toEqual([])
expect(empty.excludedVersionIds).toEqual([])
expect(empty.permittedLanguageTags).toEqual([])
})
})
6 changes: 5 additions & 1 deletion packages/core/src/highlights/__tests__/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,11 @@ describe('deriveServerColors', () => {
it('drops invalid hex from paint projection', () => {
expect(
deriveServerColors(
[highlight('JHN.3.16', 'fffe00'), highlight('JHN.3.17', 'gg0000'), highlight('JHN.3.18', '123456')],
[
highlight('JHN.3.16', 'fffe00'),
highlight('JHN.3.17', 'gg0000'),
highlight('JHN.3.18', '123456'),
],
scope,
),
).toEqual({ 16: 'fffe00', 18: '123456' })
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/youversion-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export type YouVersionContextValue = {
apiHost: string
installationId: string
authRedirectUrl?: string
/** Version filter: unset = no restriction; `[]` = permit nothing. See web SDK version filter. */
permittedVersionIds?: number[]
/** Version filter: excluded version ids win over permits. */
excludedVersionIds?: number[]
/** Version filter: BCP 47 language tags (e.g. `en`, `zh-Hans`). */
permittedLanguageTags?: string[]
}

export const YouVersionContext = createContext<YouVersionContextValue | null>(null)
29 changes: 27 additions & 2 deletions packages/core/src/youversion-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ export type YouVersionProviderProps = {
appKey: string
apiHost?: string
auth?: AuthConfig
/** Version filter: unset = no restriction; `[]` = permit nothing. Forwarded to web SDK. */
permittedVersionIds?: number[]
/** Version filter: excluded version ids win over permits. Forwarded to web SDK. */
excludedVersionIds?: number[]
/** Version filter: BCP 47 language tags (e.g. `en`, `zh-Hans`). Forwarded to web SDK. */
permittedLanguageTags?: string[]
/**
* Kept for API compatibility. Installation ID resolution is synchronous, so
* children render immediately and this prop is unused.
Expand All @@ -22,14 +28,33 @@ export default function YouVersionProvider({
appKey,
apiHost = DEFAULT_API_HOST,
auth,
permittedVersionIds,
excludedVersionIds,
permittedLanguageTags,
fallback: _fallback = null,
children,
}: YouVersionProviderProps) {
const [installationId] = useState(getOrSetInstallationId)

const config = useMemo(
() => ({ installationId, appKey, apiHost, authRedirectUrl: auth?.redirectUri }),
[installationId, appKey, apiHost, auth?.redirectUri],
() => ({
installationId,
appKey,
apiHost,
authRedirectUrl: auth?.redirectUri,
permittedVersionIds,
excludedVersionIds,
permittedLanguageTags,
}),
[
installationId,
appKey,
apiHost,
auth?.redirectUri,
permittedVersionIds,
excludedVersionIds,
permittedLanguageTags,
],
)

return (
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export default function App() {
}
```

The UI `YouVersionProvider` forwards the same optional version filter lists as core (`permittedVersionIds`, `excludedVersionIds`, `permittedLanguageTags`). See the core package README for unset vs `[]`, exclusion wins, and native refuse behavior.

## Documentation and API Reference

- [React Native (Expo) SDK Quick Start](https://developers.youversion.com/sdks/react-native)
Expand Down
14 changes: 12 additions & 2 deletions packages/ui/jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,24 @@ jest.mock('@youversion/platform-react-native-expo-core', () => {

const TestContext = React.createContext(null)

function YouVersionProvider({ appKey, apiHost, children }) {
function YouVersionProvider({
appKey,
apiHost,
permittedVersionIds,
excludedVersionIds,
permittedLanguageTags,
children,
}) {
const value = React.useMemo(
() => ({
appKey,
apiHost: apiHost ?? 'https://api.youversion.com',
installationId: 'test-installation-id',
permittedVersionIds,
excludedVersionIds,
permittedLanguageTags,
}),
[appKey, apiHost],
[appKey, apiHost, permittedVersionIds, excludedVersionIds, permittedLanguageTags],
)
return React.createElement(TestContext.Provider, { value }, children)
}
Expand Down
20 changes: 17 additions & 3 deletions packages/ui/src/dom/bible-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ComponentType } from 'react'

import { applySDKConfig } from '../lib/dom-apply'
import { ContentSizedBody } from '../lib/content-sized-body'
import type { InternalVersionFilterProps } from '../lib/version-filter-props'
import { YouVersionProvider } from '../lib/web-yv-provider'

type WebBibleCardProps = import('@youversion/platform-react-ui').BibleCardProps
Expand All @@ -15,7 +16,7 @@ type NativeActionBibleCardProps = WebBibleCardProps & {
onFootnotePress?: (data: FootnoteData) => Promise<void>
}

export type BibleCardProps = Omit<
type BibleCardBridgeProps = Omit<
WebBibleCardProps,
'onVersionChange' | 'onVersionPickerPress' | 'onFootnotePress'
> & {
Expand All @@ -29,6 +30,10 @@ export type BibleCardProps = Omit<
dom?: import('expo/dom').DOMProps
}

export type BibleCardProps = BibleCardBridgeProps

type BibleCardDOMProps = BibleCardBridgeProps & InternalVersionFilterProps

export default function BibleCardDOM({
appKey,
apiHost,
Expand All @@ -37,13 +42,22 @@ export default function BibleCardDOM({
onVersionChange,
onVersionPickerPress,
onFootnotePress,
permittedVersionIds,
excludedVersionIds,
permittedLanguageTags,
...props
}: BibleCardProps) {
}: BibleCardDOMProps) {
applySDKConfig({ appKey, apiHost, installationId })
const NativeActionBibleCard = BibleCard as ComponentType<NativeActionBibleCardProps>

return (
<YouVersionProvider appKey={appKey} theme={theme}>
<YouVersionProvider
appKey={appKey}
theme={theme}
permittedVersionIds={permittedVersionIds}
excludedVersionIds={excludedVersionIds}
permittedLanguageTags={permittedLanguageTags}
>
<ContentSizedBody />
<NativeActionBibleCard
{...props}
Expand Down
Loading