diff --git a/.knowledge/README.md b/.knowledge/README.md
index 33f5e7b..d6d6978 100644
--- a/.knowledge/README.md
+++ b/.knowledge/README.md
@@ -25,6 +25,7 @@ Other web areas are intentionally outside the mobile scope.
- [Design system](design_system/README.md) — mobile visual authority and documentation index
- [Foundations and components](design_system/foundations_and_components.md)
- [Shared packages](integrations/shared_packages.md) — ownership and local consumption of public NPM packages
+- [Embedded content](integrations/embedded_content.md) — trusted legal-document WebView and recovery policy
- [Android versioning](integrations/android_versioning.md) — Firebase minimum version and Google Play update behavior
## Authority
diff --git a/.knowledge/integrations/embedded_content.md b/.knowledge/integrations/embedded_content.md
new file mode 100644
index 0000000..07a452e
--- /dev/null
+++ b/.knowledge/integrations/embedded_content.md
@@ -0,0 +1,30 @@
+# Embedded Content
+
+> Define trusted in-app legal documents, navigation boundaries, and recovery behavior.
+
+Openings embeds only the public privacy policy and terms of service from
+`https://openings.dev`. The drawer sends a closed `privacy` or `terms` identifier
+to the native web-content route. The screen maps that identifier to a compile-time
+URL; route parameters never supply a URL directly.
+
+## Navigation policy
+
+The public `@trebla/managed-webview` package owns WebView lifecycle and navigation
+policy. Privacy allows only the exact `/privacy` path and Terms allows only the
+exact `/terms` path on the `https://openings.dev` origin. HTTP, credentials,
+malformed URLs, other origins, and other paths never load inside the WebView.
+
+The app uses main-document load readiness, a ten-second timeout, and the package's
+built-in challenge detection. Loading content stays behind an opaque native cover.
+A timeout, network or HTTP failure, process termination, detected challenge, or
+external navigation unmounts the document and shows native recovery.
+
+## Recovery and privacy
+
+Recovery offers retry and an explicit browser action when the package provides a
+validated fallback target. The app never opens another application automatically.
+It sends no search text, saved jobs, viewed state, preferences, notification state,
+analytics state, or other local data in legal-document URLs.
+
+Openings owns the native header, six-locale labels, theme values, and recovery
+presentation. The website remains the source of truth for the legal prose.
diff --git a/.knowledge/integrations/shared_packages.md b/.knowledge/integrations/shared_packages.md
index 11f2fc7..b255d3a 100644
--- a/.knowledge/integrations/shared_packages.md
+++ b/.knowledge/integrations/shared_packages.md
@@ -1,6 +1,6 @@
# Shared Packages
-> Define ownership and local consumption of the two public Openings foundation packages.
+> Define ownership and local consumption of public packages used by Openings Mobile.
## Packages
@@ -17,6 +17,12 @@ Both packages are public, MIT-licensed, independently versioned NPM artifacts. T
mobile application is private and must never be published as part of their package
contents.
+`@trebla/managed-webview` is a public React Native infrastructure package. It owns
+HTTPS origin and path policy, readiness, timeout, challenge detection, failure
+unmounting, retry, and explicit browser fallback. Openings owns its document URL
+catalog, navigation route, localization, theme, and recovery presentation. See
+[Embedded content](embedded_content.md) for the consumer contract.
+
## Local development
Mobile resolves exact public npm releases and Metro uses the standard project
@@ -24,7 +30,7 @@ configuration. A fresh clone needs no sibling repository. Changes to either shar
package must be published under semantic versioning before mobile deliberately
updates its exact dependency.
-Jest transforms the package boundary explicitly, and the contract test imports the
+Jest transforms package boundaries explicitly, and contract tests import the
same public exports the application consumes. Do not bypass package exports with a
relative import into another repository's `src` folder.
@@ -35,6 +41,8 @@ relative import into another repository's `src` folder.
system, not a screen-specific layout choice.
- Keep React Native components, Expo APIs, device services, navigation, and native
persistence inside mobile.
+- Keep Openings-specific URLs, copy, and visual presentation out of
+ `@trebla/managed-webview`.
- Keep Next.js, DOM, and browser-only behavior inside web.
- Preserve semantic versioning and add package tests before changing an exported
contract.
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index af2f1b6..2a9c0c1 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1742,6 +1742,28 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
+ - react-native-webview (13.16.1):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-Core-prebuilt
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/bridging
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
- React-NativeModulesApple (0.86.3):
- hermes-engine
- React-callinvoker
@@ -2538,6 +2560,7 @@ DEPENDENCIES:
- React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- React-mutationobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
+ - react-native-webview (from `../node_modules/react-native-webview`)
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-networking (from `../node_modules/react-native/ReactCommon/react/networking`)
- React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`)
@@ -2718,6 +2741,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
+ react-native-webview:
+ :path: "../node_modules/react-native-webview"
React-NativeModulesApple:
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
React-networking:
@@ -2873,6 +2898,7 @@ SPEC CHECKSUMS:
React-microtasksnativemodule: 97a771dbbb4315c71579995b53e4e3e351a99d89
React-mutationobservernativemodule: 530bff1bba9511a5133e3cf8170e3b2c0df49e89
react-native-safe-area-context: 6b4966397ada0f7dd481e4486a2ef936834861a1
+ react-native-webview: 2a4da2e03d3ec2c731c4d1a389cb86aaa4902167
React-NativeModulesApple: 2b658272ca69df0694cf5d54f9510aa7c33977bc
React-networking: 6be0b80e18e710dd30e77b04838ec46666a7bb78
React-oscompat: cea7c66df8076bd97e95885d7f584b86afffde41
diff --git a/package-lock.json b/package-lock.json
index fb815a1..a21f51c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,15 +19,16 @@
"@react-native-firebase/remote-config": "26.3.3",
"@sentry/react-native": "~7.11.0",
"@tanstack/react-query": "^5.102.8",
- "expo": "~57.0.24",
+ "@trebla/managed-webview": "0.2.0",
+ "expo": "~57.0.26",
"expo-application": "~57.0.3",
- "expo-constants": "~57.0.19",
+ "expo-constants": "~57.0.20",
"expo-dev-client": "~57.0.19",
"expo-font": "~57.0.4",
"expo-in-app-updates": "0.12.0",
- "expo-linking": "~57.0.10",
+ "expo-linking": "~57.0.11",
"expo-localization": "~57.0.2",
- "expo-router": "~57.0.22",
+ "expo-router": "~57.0.24",
"expo-splash-screen": "~57.0.9",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.4",
@@ -44,6 +45,7 @@
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
+ "react-native-webview": "13.16.1",
"react-native-worklets": "0.10.1"
},
"devDependencies": {
@@ -1694,9 +1696,9 @@
}
},
"node_modules/@expo/devcert": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz",
- "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.2.tgz",
+ "integrity": "sha512-wz1YedXwdNbYqk/PRr34vixnuw1+l6uSwmPXUj649cot5iYNccb2zIOnK7Nonb1Pfd/dxAY6CvU6KM3uSdxC7A==",
"license": "MIT",
"dependencies": {
"@expo/sudo-prompt": "^9.3.1",
@@ -2171,9 +2173,9 @@
"license": "MIT"
},
"node_modules/@expo/ui": {
- "version": "57.0.19",
- "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.19.tgz",
- "integrity": "sha512-WB95zQPge5nb04zgY3O2NmMtxg6G+qFH3yOAHcBkTUrJXoBReQoyY3cfp/8N5Q/azcfHJcBE3Jy0Rwq9OoRrfA==",
+ "version": "57.0.21",
+ "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.21.tgz",
+ "integrity": "sha512-4s1ypGNxJhJv2r1fj3wZo4Aubao0zGnCajCuiaTp8pTNrT+1vR8KvyoFEwp+c7gpKm2D58nGCiGBXhEnmKHtDQ==",
"license": "MIT",
"dependencies": {
"sf-symbols-typescript": "^2.1.0",
@@ -2200,19 +2202,19 @@
}
},
"node_modules/@expo/ws-tunnel": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz",
- "integrity": "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==",
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.1.tgz",
+ "integrity": "sha512-mljsRffbOHzmSimYQbQu9h9NR3alwCCwEEouRGFL4QRTt8dNvsNtKEPa/5jHmtDLDtsh4xXsWKwzjN9BTpeXHQ==",
"license": "MIT",
"peerDependencies": {
"ws": "^8.0.0"
}
},
"node_modules/@expo/xcpretty": {
- "version": "4.4.5",
- "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.5.tgz",
- "integrity": "sha512-J3eL4n4h5QTwfD0SIz8OIk6/+sOL/hFZAMacgCM07UNlxBQfJipEpIC2AQxvGkbYeStByJ0TVhQAJo+DeNgaSQ==",
- "license": "BSD-3-Clause",
+ "version": "4.4.6",
+ "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.6.tgz",
+ "integrity": "sha512-uRaZZ/IsX1WkFW69xxTZtMeRvI5ziD9yoian+FMqdhITkU6NGvIWd5uRMh2Gbo/6xstpQ4v0GtF1V5YatTwzDA==",
+ "license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.20.0",
"chalk": "^4.1.0",
@@ -3671,9 +3673,9 @@
}
},
"node_modules/@radix-ui/react-direction": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz",
- "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.5.tgz",
+ "integrity": "sha512-/NlO/rOqQabmg+GGr86vCmbJVhqTv85NfM9g6Gknf/JVygXeZ6e6HM/RW1omerW9HsFtecwNqgEAKV6UqDsvpA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
@@ -3719,9 +3721,9 @@
}
},
"node_modules/@radix-ui/react-slot": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
- "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.4.0.tgz",
+ "integrity": "sha512-IuMVHZ+Ah5fkctu3X3qeFrV/ZrBcuWuDTFUUPfS/L5hvoTjzhp/HdWGE14CsQombPdEidsa33HVlYZXi49PZ1Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5"
@@ -4673,6 +4675,20 @@
"node": ">= 10"
}
},
+ "node_modules/@trebla/managed-webview": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@trebla/managed-webview/-/managed-webview-0.2.0.tgz",
+ "integrity": "sha512-4KzAjhdGTeZ8E/SUdvWXlkKj1vVSj/ACkFT3FU6yUZStGp95cv1989xHL0tGe5KlsvJcir5x8ql2U/uRgBZqcg==",
+ "license": "MIT",
+ "workspaces": [
+ "example"
+ ],
+ "peerDependencies": {
+ "react": ">=19 <20",
+ "react-native": ">=0.86 <1",
+ "react-native-webview": ">=13.16 <14"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -6125,9 +6141,9 @@
}
},
"node_modules/babel-plugin-react-native-web": {
- "version": "0.21.2",
- "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz",
- "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==",
+ "version": "0.21.3",
+ "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.3.tgz",
+ "integrity": "sha512-e4fG8NbyRvk5GwU/7G2U9WxzPYtpC4IQN28AyRDzKE0kvywgohq8vT2x0/xBfg8a0RfSkXLlHAtUAGlSy3VhAw==",
"license": "MIT"
},
"node_modules/babel-plugin-syntax-hermes-parser": {
@@ -6191,9 +6207,9 @@
}
},
"node_modules/babel-preset-expo": {
- "version": "57.0.12",
- "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.12.tgz",
- "integrity": "sha512-rrZqJPYbVevy7rqibJZQFd3AsleGslJ63vmB3Qs8b5poOO5AHaehUGbZHjb0HoOZO59d86BEifYbq0/ncoM2UQ==",
+ "version": "57.0.13",
+ "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.13.tgz",
+ "integrity": "sha512-OOl3lqNQ4IagJfsNgtxuna0wpTWt1BswdyktGTOJ5EJCgdQUk/B2vaj/jsl03+kMyuIvpxKioVtbTzxh3mdvvQ==",
"license": "MIT",
"dependencies": {
"@babel/generator": "^7.20.5",
@@ -6242,7 +6258,7 @@
"peerDependencies": {
"@babel/runtime": "^7.20.0",
"expo": "*",
- "expo-widgets": "^57.0.19",
+ "expo-widgets": "^57.0.21",
"react-refresh": ">=0.14.0 <1.0.0"
},
"peerDependenciesMeta": {
@@ -8338,13 +8354,13 @@
"license": "MIT"
},
"node_modules/expo": {
- "version": "57.0.24",
- "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.24.tgz",
- "integrity": "sha512-LxSnKMv7en/w4XDNxW3VOx7SbtQ2Bs4MyGhtoZCToDsEsBPO7pJXlv5GnQUCd7Wi8118QV/VDwsDLBKTB3Z3iQ==",
+ "version": "57.0.26",
+ "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.26.tgz",
+ "integrity": "sha512-U1rsFwcuZNnU61Z/YLnnVC/ZqP0wpxh/rCKUMff3ot/rhLpxFY1zxEqu4vyaexyvBAB1mwpl9lA/ltcHBoQGyg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.0",
- "@expo/cli": "^57.0.26",
+ "@expo/cli": "^57.0.27",
"@expo/config": "~57.0.9",
"@expo/config-plugins": "~57.0.9",
"@expo/devtools": "~57.0.1",
@@ -8355,14 +8371,14 @@
"@expo/metro": "~56.0.2",
"@expo/metro-config": "~57.0.12",
"@ungap/structured-clone": "^1.3.0",
- "babel-preset-expo": "~57.0.12",
+ "babel-preset-expo": "~57.0.13",
"expo-asset": "~57.0.18",
- "expo-constants": "~57.0.19",
+ "expo-constants": "~57.0.20",
"expo-file-system": "~57.0.7",
"expo-font": "~57.0.4",
"expo-keep-awake": "~57.0.2",
"expo-modules-autolinking": "~57.0.13",
- "expo-modules-core": "~57.0.18",
+ "expo-modules-core": "~57.0.20",
"pretty-format": "^29.7.0",
"react-refresh": "^0.14.2",
"whatwg-url-minimum": "^0.1.2"
@@ -8424,9 +8440,9 @@
}
},
"node_modules/expo-constants": {
- "version": "57.0.19",
- "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.19.tgz",
- "integrity": "sha512-3eK1kZRwCdMizQLlM/PPlPwjaPnB5qKvdRDMBMnB1ZxmvWJBf9TB4uEO4DMMfIPaFUxYFs29dsR5fPr1gh3FTQ==",
+ "version": "57.0.20",
+ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.20.tgz",
+ "integrity": "sha512-WycPVvBgbluSa6yAh6DKnX646ZHrae0gV2ZLum5fioNkR+DiWyulq1+UfS++Jc6yfAh/l55NOirv6mt5cnQhSw==",
"license": "MIT",
"dependencies": {
"@expo/env": "~2.4.3"
@@ -8527,9 +8543,9 @@
}
},
"node_modules/expo-glass-effect": {
- "version": "57.0.3",
- "resolved": "https://registry.npmjs.org/expo-glass-effect/-/expo-glass-effect-57.0.3.tgz",
- "integrity": "sha512-aTh2J5eCxf+rSRMBJTnP8Ib5/k8DYFVDuEX1xqesN9F9aqTXocwMv/aQRVBNUs/iogDWhHIJz2pN97IsrrTcoA==",
+ "version": "57.0.4",
+ "resolved": "https://registry.npmjs.org/expo-glass-effect/-/expo-glass-effect-57.0.4.tgz",
+ "integrity": "sha512-/AG2Kljbqfgxu+GchI2YFIbxiF9cPsIpCy4iNDzlRiZk9rvR6BwRfB6gfQVu3V2ExaoGf2h5IsEnqVI5MYNBAg==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
@@ -8563,12 +8579,12 @@
}
},
"node_modules/expo-linking": {
- "version": "57.0.10",
- "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.10.tgz",
- "integrity": "sha512-RzRi4l4pIYzjgoCJKnqkO+lUI+M2YX6qgcMIQkIbLAXdcWrykBoKYwHGlmAkdFHQm89GaYcq329yfxG+c1hv8w==",
+ "version": "57.0.11",
+ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.11.tgz",
+ "integrity": "sha512-bfTyiPb8szacs8XkGowXt5ssH/vbF26vFG8WUWQar8CcRpQcIzXY980ZkP7pK7Pcdy5MattwFdujrWOxwv5s3Q==",
"license": "MIT",
"dependencies": {
- "expo-constants": "~57.0.18",
+ "expo-constants": "~57.0.19",
"invariant": "^2.2.4"
},
"peerDependencies": {
@@ -8617,13 +8633,13 @@
}
},
"node_modules/expo-modules-core": {
- "version": "57.0.18",
- "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.18.tgz",
- "integrity": "sha512-ovB7C+GVQTlKAOIsOIHJVVXN9Mn0ljf2SYBS+afrRaGqq+HZEn1D3o+HReIVeH1NjCn/xDBb7I4HjldHs6luZA==",
+ "version": "57.0.20",
+ "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.20.tgz",
+ "integrity": "sha512-vEL3aeih32yuD7sQHB3Y3LKXRT8mMgoVQWFIcHtX811Bl0StnNZUG2vT5je4fe+OWYVfkjb6gNqfMc04moREgA==",
"license": "MIT",
"dependencies": {
"@expo/expo-modules-macros-plugin": "0.6.1",
- "expo-modules-jsi": "~57.1.0",
+ "expo-modules-jsi": "~57.1.1",
"invariant": "^2.2.4"
},
"peerDependencies": {
@@ -8638,24 +8654,24 @@
}
},
"node_modules/expo-modules-jsi": {
- "version": "57.1.0",
- "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.1.0.tgz",
- "integrity": "sha512-a5ckeHfnbYfonhcHGkM0EU/a0Keh+/OufXy/HyFS+spr5Ib0N4Oh1ZsSYFQhp5xdOUFkVofKRhfH+VmcccIIpA==",
+ "version": "57.1.1",
+ "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.1.1.tgz",
+ "integrity": "sha512-ssnwz3ZuIgCbZj5A3J6NlhQrXZojg+BjkeWrOWqVJhyMHlV/A/cRT7s986chKku97BFt6ucmDu6/BCzrV45Wcw==",
"license": "MIT",
"peerDependencies": {
"react-native": "*"
}
},
"node_modules/expo-router": {
- "version": "57.0.22",
- "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.22.tgz",
- "integrity": "sha512-kOuS3RjMC73YLyBFiV9BzO0Z5OK1nLhPwmptK2II+tePtWXH7oA4zWaPwdWsdzTJRnmGMs/8Sr8YdTPHm1LUCg==",
+ "version": "57.0.24",
+ "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.24.tgz",
+ "integrity": "sha512-XpqE4Oh4yrmc+sVfsDQPLErBlus//85ulLRghVZltYcdvFm9yNzErmaKrXuBVMUL6D3tnpDtkIgxESG9hYBd7w==",
"license": "MIT",
"dependencies": {
"@expo/log-box": "^57.0.4",
"@expo/metro-runtime": "^57.0.16",
"@expo/schema-utils": "^57.0.2",
- "@expo/ui": "^57.0.19",
+ "@expo/ui": "^57.0.21",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.12",
"@react-native-masked-view/masked-view": "^0.3.2",
@@ -8663,7 +8679,7 @@
"color": "^4.2.3",
"debug": "^4.3.4",
"escape-string-regexp": "^4.0.0",
- "expo-glass-effect": "^57.0.3",
+ "expo-glass-effect": "^57.0.4",
"expo-server": "^57.0.3",
"expo-symbols": "^57.0.3",
"fast-deep-equal": "^3.1.3",
@@ -8685,8 +8701,8 @@
"@expo/metro-runtime": "^57.0.16",
"@testing-library/react-native": ">= 13.2.0",
"expo": "*",
- "expo-constants": "^57.0.19",
- "expo-linking": "^57.0.10",
+ "expo-constants": "^57.0.20",
+ "expo-linking": "^57.0.11",
"react": "*",
"react-dom": "*",
"react-native": "*",
@@ -8719,15 +8735,15 @@
}
},
"node_modules/expo-router/node_modules/@radix-ui/react-collection": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz",
- "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==",
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.16.tgz",
+ "integrity": "sha512-yEQrjfI5eBbeYEJXOASpm+gs9eNHY8+UjkZ81GzcuwdLrSMOlEM8jnGoGdeGo92oiBMkuwZUhHTsnsrDiJLcyA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.2.2",
- "@radix-ui/react-primitive": "2.1.10",
- "@radix-ui/react-slot": "1.3.3"
+ "@radix-ui/react-primitive": "2.1.11",
+ "@radix-ui/react-slot": "1.4.0"
},
"peerDependencies": {
"@types/react": "*",
@@ -8745,9 +8761,9 @@
}
},
"node_modules/expo-router/node_modules/@radix-ui/react-presence": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
- "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz",
+ "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
@@ -8768,12 +8784,12 @@
}
},
"node_modules/expo-router/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
- "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
+ "version": "2.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.11.tgz",
+ "integrity": "sha512-RzcaSw5aKl0OPhBJ1wbolKSF6nVvN+0o/QLeIVq+09npiPdnCkPggy2k6gI6yetxl4ZyrlBPYFgCB8BQJZwXcQ==",
"license": "MIT",
"dependencies": {
- "@radix-ui/react-slot": "1.3.3"
+ "@radix-ui/react-slot": "1.4.0"
},
"peerDependencies": {
"@types/react": "*",
@@ -8791,18 +8807,18 @@
}
},
"node_modules/expo-router/node_modules/@radix-ui/react-roving-focus": {
- "version": "1.1.19",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz",
- "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==",
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.20.tgz",
+ "integrity": "sha512-+ZjbkU21fTSJdn4nq/IFKUdKS+JSZHORBZ8ma1f6YpOMNzRt0xf3sVAWVK/+ktHCajPy4HYDsyHXZIZmOMxzIA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.7",
- "@radix-ui/react-collection": "1.1.15",
+ "@radix-ui/react-collection": "1.1.16",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.2.2",
- "@radix-ui/react-direction": "1.1.4",
+ "@radix-ui/react-direction": "1.1.5",
"@radix-ui/react-id": "1.1.4",
- "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-primitive": "2.1.11",
"@radix-ui/react-use-callback-ref": "1.1.4",
"@radix-ui/react-use-controllable-state": "1.2.6",
"@radix-ui/react-use-is-hydrated": "0.1.3",
@@ -8824,18 +8840,18 @@
}
},
"node_modules/expo-router/node_modules/@radix-ui/react-tabs": {
- "version": "1.1.21",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz",
- "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==",
+ "version": "1.1.22",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.22.tgz",
+ "integrity": "sha512-GmVH+P3wx1ZPMuH7WajQzygZIbqgnsb5h8z4Ow3sQMuDqgcqISSBNom6MLVNJvfrkuJTtqfXc3ZolHjX+w0CHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-context": "1.2.2",
- "@radix-ui/react-direction": "1.1.4",
+ "@radix-ui/react-direction": "1.1.5",
"@radix-ui/react-id": "1.1.4",
- "@radix-ui/react-presence": "1.1.10",
- "@radix-ui/react-primitive": "2.1.10",
- "@radix-ui/react-roving-focus": "1.1.19",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.1.11",
+ "@radix-ui/react-roving-focus": "1.1.20",
"@radix-ui/react-use-controllable-state": "1.2.6"
},
"peerDependencies": {
@@ -8933,9 +8949,9 @@
}
},
"node_modules/expo/node_modules/@expo/cli": {
- "version": "57.0.26",
- "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.26.tgz",
- "integrity": "sha512-vm769xuEyHMToi3uY66JLzRA2GGK+3WzdH71HNn67FuVhry82dKTvMUoeHYtHiindyeieiWfEvHT8Kr1VyEVPQ==",
+ "version": "57.0.27",
+ "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.27.tgz",
+ "integrity": "sha512-Jauk4chxmpVG5ElrMLTCwjFP20jbYU7PB4l3LYV8j+4e/3HU5jdl7qTQ4eVy1AaDUE9Ia2NCQ5A9kar2V1gaLg==",
"license": "MIT",
"dependencies": {
"@expo/code-signing-certificates": "^0.0.6",
@@ -8955,7 +8971,7 @@
"@expo/plist": "^0.8.1",
"@expo/prebuild-config": "^57.0.16",
"@expo/require-utils": "^57.0.5",
- "@expo/router-server": "^57.0.10",
+ "@expo/router-server": "^57.0.11",
"@expo/schema-utils": "^57.0.2",
"@expo/spawn-async": "^1.8.0",
"@expo/ws-tunnel": "^2.0.0",
@@ -9016,17 +9032,17 @@
}
},
"node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": {
- "version": "57.0.10",
- "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.10.tgz",
- "integrity": "sha512-8rGh6N5di7jzu7UC1SuIA1TWmVd+vBwOHwC7/1ww+FA6CcxcWyQlcPl39h2+Jpnul78CVzWpIjOcdKsZsp2zXw==",
+ "version": "57.0.11",
+ "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.11.tgz",
+ "integrity": "sha512-0QxbqEkceb1WDeu5VqNdnTPVHlm3U183F7Y1t5LMohmGRAkHVL0PB4tCHBvEqKpLzOzHVObw3reGkJ1tX48h8Q==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4"
},
"peerDependencies": {
- "@expo/metro-runtime": "^57.0.15",
+ "@expo/metro-runtime": "^57.0.16",
"expo": "*",
- "expo-constants": "^57.0.18",
+ "expo-constants": "^57.0.19",
"expo-font": "^57.0.4",
"expo-router": "*",
"expo-server": "^57.0.3",
@@ -15009,9 +15025,9 @@
}
},
"node_modules/react-native-drawer-layout": {
- "version": "4.2.10",
- "resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.10.tgz",
- "integrity": "sha512-O6TQdZ5LSm3dqnuR4rX9KPtE+9dVg7jsezEYz1l01rkQTe4fQvYZIoPu2sZFl5X2N70uhRdjnPULySVR3sBUwA==",
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.11.tgz",
+ "integrity": "sha512-Aua+LAZJ6L3v7ESHsNtxN2sO+hFQKsSz3EDKOLIpWni6PdQEJGFT0Mc2UnEsvFdfV743aHfODTAaHxsJ2D0uFw==",
"license": "MIT",
"dependencies": {
"color": "^4.2.3",
@@ -15149,6 +15165,20 @@
"react-native": "*"
}
},
+ "node_modules/react-native-webview": {
+ "version": "13.16.1",
+ "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz",
+ "integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0",
+ "invariant": "2.2.4"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/react-native-worklets": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz",
@@ -17396,22 +17426,22 @@
}
},
"node_modules/vaul/node_modules/@radix-ui/react-dialog": {
- "version": "1.1.23",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz",
- "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.2.0.tgz",
+ "integrity": "sha512-l5CX8HdNL+9cc4s7HkNyplumSA3FIx68sg9dttBHKr286fRBwq1MBniDVTeI/tkof92/APntMQvC1YG1Khb0Cg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.2.2",
- "@radix-ui/react-dismissable-layer": "1.1.19",
+ "@radix-ui/react-dismissable-layer": "1.1.20",
"@radix-ui/react-focus-guards": "1.1.6",
- "@radix-ui/react-focus-scope": "1.1.16",
+ "@radix-ui/react-focus-scope": "1.2.0",
"@radix-ui/react-id": "1.1.4",
- "@radix-ui/react-portal": "1.1.17",
- "@radix-ui/react-presence": "1.1.10",
- "@radix-ui/react-primitive": "2.1.10",
- "@radix-ui/react-slot": "1.3.3",
+ "@radix-ui/react-portal": "1.1.18",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.1.11",
+ "@radix-ui/react-slot": "1.4.0",
"@radix-ui/react-use-controllable-state": "1.2.6",
"@radix-ui/react-use-layout-effect": "1.1.4",
"aria-hidden": "^1.2.4",
@@ -17433,14 +17463,14 @@
}
},
"node_modules/vaul/node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.19",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz",
- "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==",
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.20.tgz",
+ "integrity": "sha512-35WHRrycfnj2m4LvPiKMKHGLM3Y2WvUA744CkRdxjs1BGDX1c8PVMfmiSGTUwnHRbqdtIoxveYTXvcJSt7VfgQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.5",
- "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-primitive": "2.1.11",
"@radix-ui/react-use-callback-ref": "1.1.4",
"@radix-ui/react-use-effect-event": "0.0.5"
},
@@ -17460,13 +17490,13 @@
}
},
"node_modules/vaul/node_modules/@radix-ui/react-focus-scope": {
- "version": "1.1.16",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz",
- "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.2.0.tgz",
+ "integrity": "sha512-I5S7vsAOozx2lWSBAyuXjWIJ/A8qx4fjbv6115WoaRYQSb1yZDaeNHonUAQzXyE8oD+0whJHbQRl4HIE2gQ0Xw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5",
- "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-primitive": "2.1.11",
"@radix-ui/react-use-callback-ref": "1.1.4"
},
"peerDependencies": {
@@ -17485,12 +17515,12 @@
}
},
"node_modules/vaul/node_modules/@radix-ui/react-portal": {
- "version": "1.1.17",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz",
- "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.18.tgz",
+ "integrity": "sha512-ygFIJ+QXNKf72AAq8x3hd22+ws7pYpmc1S0E4kW0Je+qD4qEb3FvxeKVQRGrVDMOkQaYfsB+Qs+KDh81a1gAOw==",
"license": "MIT",
"dependencies": {
- "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-primitive": "2.1.11",
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
@@ -17509,9 +17539,9 @@
}
},
"node_modules/vaul/node_modules/@radix-ui/react-presence": {
- "version": "1.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
- "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz",
+ "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
@@ -17532,12 +17562,12 @@
}
},
"node_modules/vaul/node_modules/@radix-ui/react-primitive": {
- "version": "2.1.10",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
- "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
+ "version": "2.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.11.tgz",
+ "integrity": "sha512-RzcaSw5aKl0OPhBJ1wbolKSF6nVvN+0o/QLeIVq+09npiPdnCkPggy2k6gI6yetxl4ZyrlBPYFgCB8BQJZwXcQ==",
"license": "MIT",
"dependencies": {
- "@radix-ui/react-slot": "1.3.3"
+ "@radix-ui/react-slot": "1.4.0"
},
"peerDependencies": {
"@types/react": "*",
diff --git a/package.json b/package.json
index cacc91c..c024127 100644
--- a/package.json
+++ b/package.json
@@ -41,15 +41,16 @@
"@react-native-firebase/remote-config": "26.3.3",
"@sentry/react-native": "~7.11.0",
"@tanstack/react-query": "^5.102.8",
- "expo": "~57.0.24",
+ "@trebla/managed-webview": "0.2.0",
+ "expo": "~57.0.26",
"expo-application": "~57.0.3",
- "expo-constants": "~57.0.19",
+ "expo-constants": "~57.0.20",
"expo-dev-client": "~57.0.19",
"expo-font": "~57.0.4",
"expo-in-app-updates": "0.12.0",
- "expo-linking": "~57.0.10",
+ "expo-linking": "~57.0.11",
"expo-localization": "~57.0.2",
- "expo-router": "~57.0.22",
+ "expo-router": "~57.0.24",
"expo-splash-screen": "~57.0.9",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.4",
@@ -66,6 +67,7 @@
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
+ "react-native-webview": "13.16.1",
"react-native-worklets": "0.10.1"
},
"devDependencies": {
diff --git a/src/app/routes/web-content.tsx b/src/app/routes/web-content.tsx
new file mode 100644
index 0000000..65bfe8d
--- /dev/null
+++ b/src/app/routes/web-content.tsx
@@ -0,0 +1 @@
+export { default } from "@/app/web-content";
diff --git a/src/app/web-content/index.tsx b/src/app/web-content/index.tsx
new file mode 100644
index 0000000..f320573
--- /dev/null
+++ b/src/app/web-content/index.tsx
@@ -0,0 +1,143 @@
+import {
+ ManagedWebView,
+ type ManagedWebViewErrorContext,
+ type ManagedWebViewLoadingContext,
+} from "@trebla/managed-webview";
+import { useLocalSearchParams, useRouter } from "expo-router";
+import { ArrowLeft } from "lucide-react-native";
+import { useEffect } from "react";
+import { ActivityIndicator, Pressable, Text, View } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+
+import { useLocale } from "@/contexts/locale";
+import { useAppTheme } from "@/contexts/theme";
+import { openHttpsUrl } from "@/services/external-actions";
+
+import { getLegalContent, parseLegalContentKind } from "./legal-content";
+
+const OPENINGS_ORIGINS = ["https://openings.dev"] as const;
+const WEB_VIEW_STYLE = { flex: 1 } as const;
+
+function OpeningsLoading({
+ appearance,
+ labels,
+}: ManagedWebViewLoadingContext): React.ReactNode {
+ return (
+
+
+
+ {labels.loading}
+
+
+ );
+}
+
+function OpeningsFailure({
+ labels,
+ openExternal,
+ retry,
+}: ManagedWebViewErrorContext): React.ReactNode {
+ return (
+
+
+ {labels.errorTitle}
+
+
+ {labels.errorDescription}
+
+ {retry ? (
+
+
+ {labels.retry}
+
+
+ ) : null}
+ {openExternal ? (
+ void openExternal()}
+ >
+
+ {labels.openExternal}
+
+
+ ) : null}
+
+ );
+}
+
+export default function WebContentScreen(): React.ReactNode {
+ const params = useLocalSearchParams<{ kind?: string | string[] }>();
+ const router = useRouter();
+ const { messages } = useLocale();
+ const { theme } = useAppTheme();
+ const kind = parseLegalContentKind(params.kind);
+
+ useEffect(() => {
+ if (!kind) router.back();
+ }, [kind, router]);
+
+ if (!kind) return null;
+
+ const document = getLegalContent(kind);
+
+ return (
+
+
+ router.back()}
+ >
+
+
+
+ {messages.legal[kind]}
+
+
+
+
+ );
+}
diff --git a/src/app/web-content/legal-content.ts b/src/app/web-content/legal-content.ts
new file mode 100644
index 0000000..80e7e3b
--- /dev/null
+++ b/src/app/web-content/legal-content.ts
@@ -0,0 +1,25 @@
+export type LegalContentKind = "privacy" | "terms";
+
+const LEGAL_CONTENT = {
+ privacy: {
+ kind: "privacy",
+ path: "/privacy",
+ uri: "https://openings.dev/privacy",
+ },
+ terms: {
+ kind: "terms",
+ path: "/terms",
+ uri: "https://openings.dev/terms",
+ },
+} as const satisfies Record<
+ LegalContentKind,
+ { kind: LegalContentKind; path: string; uri: string }
+>;
+
+export function parseLegalContentKind(value: unknown): LegalContentKind | null {
+ return value === "privacy" || value === "terms" ? value : null;
+}
+
+export function getLegalContent(kind: LegalContentKind) {
+ return LEGAL_CONTENT[kind];
+}
diff --git a/src/components/app-drawer/index.tsx b/src/components/app-drawer/index.tsx
index 16e0793..bac0f56 100644
--- a/src/components/app-drawer/index.tsx
+++ b/src/components/app-drawer/index.tsx
@@ -4,9 +4,11 @@ import {
BriefcaseBusiness,
BellOff,
ChevronUp,
+ FileLock2,
Globe2,
Moon,
Sun,
+ ScrollText,
UserRound,
UsersRound,
X,
@@ -143,6 +145,38 @@ export function AppDrawer({ onClose, visible }: AppDrawerProps): React.ReactNode
style={{ paddingBottom: insets.bottom + DRAWER_FOOTER_PADDING }}
testID="app-drawer-footer"
>
+
+ {([
+ { icon: FileLock2, kind: "privacy", label: messages.legal.privacy },
+ { icon: ScrollText, kind: "terms", label: messages.legal.terms },
+ ] as const).map((item) => {
+ const Icon = item.icon;
+
+ return (
+ {
+ router.push({
+ pathname: "/web-content",
+ params: { kind: item.kind },
+ });
+ close();
+ }}
+ >
+
+
+ {item.label}
+
+
+ );
+ })}
+
{notificationConsent === "granted" ? (
{
+ it.each([
+ ["privacy", "https://openings.dev/privacy", "/privacy"],
+ ["terms", "https://openings.dev/terms", "/terms"],
+ ] as const)("maps %s to its canonical first-party document", (kind, uri, path) => {
+ expect(getLegalContent(kind)).toEqual({ kind, path, uri });
+ });
+
+ it.each([undefined, "", "https://evil.example", "support", ["privacy"]])(
+ "rejects the route parameter %p",
+ (value) => expect(parseLegalContentKind(value)).toBeNull(),
+ );
+
+ it.each(["privacy", "terms"] as const)("accepts %s", (value) => {
+ expect(parseLegalContentKind(value)).toBe(value);
+ });
+});
diff --git a/tests/app/web-content-screen.test.tsx b/tests/app/web-content-screen.test.tsx
new file mode 100644
index 0000000..6a5004f
--- /dev/null
+++ b/tests/app/web-content-screen.test.tsx
@@ -0,0 +1,99 @@
+import { act, render } from "@testing-library/react-native";
+
+import WebContentScreen from "@/app/web-content";
+import { messages } from "@/i18n/messages";
+
+const mockBack = jest.fn();
+const mockOpenHttpsUrl = jest.fn();
+const mockMessages = messages;
+let mockKind: unknown = "privacy";
+let managedProps: Record | undefined;
+
+jest.mock("expo-router", () => ({
+ useLocalSearchParams: () => ({ kind: mockKind }),
+ useRouter: () => ({ back: mockBack }),
+}));
+
+jest.mock("@/contexts/locale", () => ({
+ useLocale: () => ({ messages: mockMessages.en }),
+}));
+
+jest.mock("@/contexts/theme", () => ({
+ useAppTheme: () => ({
+ theme: {
+ colors: {
+ canvas: "#ffffff",
+ foreground: "#112233",
+ "muted-foreground": "#445566",
+ primary: "#aabbcc",
+ "primary-foreground": "#001122",
+ },
+ },
+ }),
+}));
+
+jest.mock("@/services/external-actions", () => ({
+ openHttpsUrl: (url: string) => mockOpenHttpsUrl(url),
+}));
+
+jest.mock("@trebla/managed-webview", () => ({
+ ManagedWebView: (props: Record) => {
+ const { View } = jest.requireActual("react-native");
+ managedProps = props as Record;
+ return ;
+ },
+}));
+
+describe("WebContentScreen", () => {
+ beforeEach(() => {
+ mockKind = "privacy";
+ managedProps = undefined;
+ mockBack.mockReset();
+ mockOpenHttpsUrl.mockReset().mockResolvedValue(undefined);
+ });
+
+ it("loads only the selected first-party legal document", async () => {
+ const screen = await render();
+
+ expect(screen.getByTestId("managed-webview")).toBeTruthy();
+ expect(screen.getByTestId("web-content-safe-area").props.edges).toEqual({
+ top: "additive",
+ bottom: "additive",
+ left: "additive",
+ right: "additive",
+ });
+ expect(managedProps).toMatchObject({
+ uri: "https://openings.dev/privacy",
+ allowedOrigins: ["https://openings.dev"],
+ allowedPaths: ["/privacy"],
+ challengeDetection: true,
+ readiness: { type: "load" },
+ timeoutMs: 10_000,
+ webViewStyle: { flex: 1 },
+ labels: {
+ errorDescription: messages.en.legal.errorDescription,
+ errorTitle: messages.en.legal.errorTitle,
+ loading: messages.en.legal.loading,
+ openExternal: messages.en.legal.openBrowser,
+ retry: messages.en.legal.retry,
+ },
+ });
+ });
+
+ it("rejects arbitrary route values without mounting web content", async () => {
+ mockKind = "https://evil.example";
+ const screen = await render();
+
+ expect(screen.queryByTestId("managed-webview")).toBeNull();
+ expect(mockBack).toHaveBeenCalledTimes(1);
+ });
+
+ it("opens package-validated fallback URLs through the HTTPS service", async () => {
+ await render();
+ const onOpenExternal = managedProps?.onOpenExternal as (url: string) => Promise;
+
+ await act(() => onOpenExternal("https://docs.example.org/legal"));
+
+ expect(mockOpenHttpsUrl).toHaveBeenCalledWith("https://docs.example.org/legal");
+ });
+});
diff --git a/tests/components/app-header.test.tsx b/tests/components/app-header.test.tsx
index 98de2c6..2b0372f 100644
--- a/tests/components/app-header.test.tsx
+++ b/tests/components/app-header.test.tsx
@@ -9,10 +9,11 @@ import { ThemeProvider } from "@/contexts/theme";
import { resetNotificationConsentForTests } from "@/services/notifications/consent";
const mockReplace = jest.fn();
+const mockPush = jest.fn();
const mockWithdrawNotifications = jest.fn().mockResolvedValue(true);
jest.mock("expo-router", () => ({
- useRouter: () => ({ replace: mockReplace }),
+ useRouter: () => ({ push: mockPush, replace: mockReplace }),
}));
jest.mock("@/services/notifications/preferences", () => ({
@@ -74,6 +75,8 @@ describe("AppHeader", () => {
expect(screen.getByText("Authors")).toBeTruthy();
expect(screen.getByText("Support openings.dev")).toBeTruthy();
expect(screen.getByText("Star on GitHub")).toBeTruthy();
+ expect(screen.getByText("Privacy policy")).toBeTruthy();
+ expect(screen.getByText("Terms of service")).toBeTruthy();
expect(screen.getByLabelText("Language")).toBeTruthy();
expect(screen.getByLabelText("Appearance")).toBeTruthy();
@@ -90,6 +93,18 @@ describe("AppHeader", () => {
expect(screen.getByText("Escuro")).toBeTruthy();
});
+ it("opens legal documents inside the managed content route", async () => {
+ const screen = await renderAppHeader();
+
+ await fireEvent.press(await screen.findByLabelText("Open navigation menu"));
+ await fireEvent.press(screen.getByText("Privacy policy"));
+
+ expect(mockPush).toHaveBeenCalledWith({
+ pathname: "/web-content",
+ params: { kind: "privacy" },
+ });
+ });
+
it("uses the wordmark as a native destination back to Jobs", async () => {
const screen = await renderAppHeader();
diff --git a/tests/contracts/app-config.test.ts b/tests/contracts/app-config.test.ts
index 54e909b..ac0347c 100644
--- a/tests/contracts/app-config.test.ts
+++ b/tests/contracts/app-config.test.ts
@@ -98,14 +98,14 @@ describe("native application identity", () => {
) as { dependencies: Record };
expect(manifest.dependencies).toMatchObject({
- expo: "~57.0.24",
+ expo: "~57.0.26",
"expo-application": "~57.0.3",
- "expo-constants": "~57.0.19",
+ "expo-constants": "~57.0.20",
"expo-dev-client": "~57.0.19",
"expo-font": "~57.0.4",
- "expo-linking": "~57.0.10",
+ "expo-linking": "~57.0.11",
"expo-localization": "~57.0.2",
- "expo-router": "~57.0.22",
+ "expo-router": "~57.0.24",
"expo-splash-screen": "~57.0.9",
"expo-system-ui": "~57.0.4",
});
diff --git a/tests/contracts/shared-packages.test.ts b/tests/contracts/shared-packages.test.ts
index 3122573..653eba6 100644
--- a/tests/contracts/shared-packages.test.ts
+++ b/tests/contracts/shared-packages.test.ts
@@ -41,4 +41,13 @@ describe("shared package integration", () => {
expect(darkRuntimeTheme.colors.canvas).toBe("#0D1211");
expect(nativewindPreset.theme.extend.spacing.touch).toBe(44);
});
+
+ it("pins the public managed WebView and its native peer", () => {
+ const manifest = JSON.parse(
+ readFileSync(path.join(projectRoot, "package.json"), "utf8"),
+ );
+
+ expect(manifest.dependencies["@trebla/managed-webview"]).toBe("0.2.0");
+ expect(manifest.dependencies["react-native-webview"]).toBe("13.16.1");
+ });
});
diff --git a/tests/i18n/messages.test.ts b/tests/i18n/messages.test.ts
index 07666f3..cc7be5d 100644
--- a/tests/i18n/messages.test.ts
+++ b/tests/i18n/messages.test.ts
@@ -81,4 +81,23 @@ describe("localized messages", () => {
});
}
});
+
+ it("provides complete legal content copy", () => {
+ const messages = moduleContract.messages as Record<
+ string,
+ { legal?: Record }
+ >;
+
+ for (const locale of SUPPORTED_LOCALES) {
+ expect(messages[locale]?.legal).toEqual({
+ errorDescription: expect.any(String),
+ errorTitle: expect.any(String),
+ loading: expect.any(String),
+ openBrowser: expect.any(String),
+ privacy: expect.any(String),
+ retry: expect.any(String),
+ terms: expect.any(String),
+ });
+ }
+ });
});